@alphacoder-v0/pi-loops 0.17.3

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +1989 -0
  2. package/LICENSE +21 -0
  3. package/README.md +370 -0
  4. package/README.zh-CN.md +606 -0
  5. package/docs/cli.md +252 -0
  6. package/docs/configuration.md +110 -0
  7. package/docs/design.md +155 -0
  8. package/docs/goal.md +59 -0
  9. package/docs/hooks.md +110 -0
  10. package/docs/loops.md +315 -0
  11. package/docs/mcp.md +90 -0
  12. package/docs/screenshot.png +0 -0
  13. package/docs/session-archive.md +64 -0
  14. package/docs/triggers.md +140 -0
  15. package/docs/troubleshooting.md +111 -0
  16. package/docs/web-ui-parity.md +305 -0
  17. package/examples/README.md +18 -0
  18. package/examples/mcp-notify-server.mjs +35 -0
  19. package/examples/mcp.toml +8 -0
  20. package/package.json +71 -0
  21. package/skills/pi-loops/SKILL.md +50 -0
  22. package/src/archive.ts +476 -0
  23. package/src/args.ts +217 -0
  24. package/src/cli-entry.mjs +23 -0
  25. package/src/cli.ts +610 -0
  26. package/src/config.ts +103 -0
  27. package/src/danger.ts +128 -0
  28. package/src/goal.ts +178 -0
  29. package/src/hooks.ts +436 -0
  30. package/src/host-control-channel.ts +243 -0
  31. package/src/host-control.ts +203 -0
  32. package/src/host-entry.mjs +14 -0
  33. package/src/host-runtime.ts +222 -0
  34. package/src/host.ts +329 -0
  35. package/src/inbox.ts +232 -0
  36. package/src/job-edit.ts +113 -0
  37. package/src/job-health.ts +29 -0
  38. package/src/lock.ts +193 -0
  39. package/src/log.ts +108 -0
  40. package/src/mcp-pool.ts +141 -0
  41. package/src/mcp.ts +885 -0
  42. package/src/paths.ts +33 -0
  43. package/src/pi-loops.ts +2292 -0
  44. package/src/pi-resolver.mjs +49 -0
  45. package/src/presence.ts +143 -0
  46. package/src/protocol.ts +198 -0
  47. package/src/redact.ts +61 -0
  48. package/src/register-pi.mjs +3 -0
  49. package/src/runner.ts +149 -0
  50. package/src/schedule.ts +431 -0
  51. package/src/scheduler.ts +981 -0
  52. package/src/sdk-runner.ts +536 -0
  53. package/src/share.ts +121 -0
  54. package/src/slots.ts +76 -0
  55. package/src/snapshot.ts +40 -0
  56. package/src/store.ts +599 -0
  57. package/src/subagent-guard.ts +47 -0
  58. package/src/thinking.ts +30 -0
  59. package/src/toml.ts +244 -0
  60. package/src/tools.ts +455 -0
  61. package/src/transcript.ts +84 -0
  62. package/src/trigger-runtime.ts +711 -0
  63. package/src/triggers.ts +558 -0
  64. package/src/trust.ts +47 -0
  65. package/src/ts-entry.mjs +67 -0
  66. package/src/ui-prefs.ts +84 -0
  67. package/src/version.ts +14 -0
  68. package/src/web.mjs +4484 -0
package/src/web.mjs ADDED
@@ -0,0 +1,4484 @@
1
+ #!/usr/bin/env node
2
+ // The browser front end for pi, in one file with no dependencies. `pi-loops` starts it; nobody runs
3
+ // it by path (`src/cli.ts` is what passes it a port, a loops directory and the rest of pi's args).
4
+ //
5
+ // It runs `pi --mode rpc`, which is pi with no terminal UI: commands in on stdin as JSON lines,
6
+ // events out on stdout the same way. The browser speaks that protocol through this process, so the
7
+ // session is a real pi session — same models, tools, extensions, session file, `--resume` — with a
8
+ // different front end, running across a process boundary instead of inside the binary.
9
+ //
10
+ // The automation panel reads pi-loops' own files (jobs.json, triggers.json, inbox.jsonl) rather
11
+ // than scraping command output: they are on this machine, they are JSON, and text meant for a
12
+ // human is a bad wire format.
13
+ //
14
+ // Loopback and a token by default. `--host` binds elsewhere for a phone on the same network, and
15
+ // refuses `--no-auth` when it does.
16
+
17
+ import { spawn } from "node:child_process";
18
+ import { randomBytes, randomInt, randomUUID, timingSafeEqual } from "node:crypto";
19
+ import * as fs from "node:fs";
20
+ import * as http from "node:http";
21
+ import * as os from "node:os";
22
+ import * as path from "node:path";
23
+
24
+ /* ------------------------------------------------------------------ arguments */
25
+
26
+ /**
27
+ * The version of this file, baked into the page it serves.
28
+ *
29
+ * A tab that has been open across an upgrade looks exactly like a current one — the panel even
30
+ * shows a version, but that is the server's, read live. The page had no way to say how old *it*
31
+ * was, which is how three rounds of "no reply appears" were spent on a page that could not have
32
+ * received one. Now it says.
33
+ */
34
+ const VERSION = (() => {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")).version ?? "?";
37
+ } catch {
38
+ return "?"; // running from somewhere without the manifest; the comparison simply goes quiet
39
+ }
40
+ })();
41
+
42
+ const argv = process.argv.slice(2);
43
+ const dashdash = argv.indexOf("--");
44
+ const ourArgs = dashdash === -1 ? argv : argv.slice(0, dashdash);
45
+ const piArgs = dashdash === -1 ? [] : argv.slice(dashdash + 1);
46
+ const flag = (name) => ourArgs.includes(`--${name}`);
47
+ const optionValue = (name, fallback) => {
48
+ // Both spellings, because `--host=0.0.0.0` used to parse as "no --host at all" — which bound
49
+ // loopback and skipped the refusal that goes with binding anywhere else.
50
+ const eq = ourArgs.find((a) => a.startsWith(`--${name}=`));
51
+ if (eq) return eq.slice(name.length + 3);
52
+ const i = ourArgs.indexOf(`--${name}`);
53
+ return i !== -1 && ourArgs[i + 1] ? ourArgs[i + 1] : fallback;
54
+ };
55
+ /**
56
+ * A value-taking flag passed with nothing after it. `optionValue` falls back to the default, which
57
+ * for `--port` meant `pi-loops --web --port` served on 4173 without a word — the same silence as a
58
+ * port that is not a number, and it deserves the same refusal.
59
+ */
60
+ const missingValue = (name) => {
61
+ if (ourArgs.some((a) => a.startsWith(`--${name}=`))) return false;
62
+ const i = ourArgs.indexOf(`--${name}`);
63
+ const next = i === -1 ? undefined : ourArgs[i + 1];
64
+ return i !== -1 && (next === undefined || next === "" || next.startsWith("--"));
65
+ };
66
+
67
+ if (flag("help")) {
68
+ console.log(`pi-loops web — the browser front end for pi
69
+
70
+ pi-loops --web [options] [-- <pi args>]
71
+
72
+ --port <n> port to serve on (default 4173; 0 takes any free one)
73
+ --host <addr> bind somewhere other than loopback, for a phone on the same network
74
+ --allow-host <n,…> accept these Host values, for a reverse proxy in front
75
+ --loops-dir <p> pi-loops directory for the automation panel (default $PI_LOOPS_DIR)
76
+ --no-auth no token and no cookie; loopback only, and refused with --host
77
+ --no-open do not open a browser
78
+ --help
79
+
80
+ Anything after -- goes to pi, e.g. pi-loops --web -- --model anthropic/claude-opus-5`);
81
+ process.exit(0);
82
+ }
83
+
84
+ // `--port 0` means "any free port"; the URL printed below is the
85
+ // one that was actually bound.
86
+ const portArg = missingValue("port") ? "" : String(optionValue("port", "4173"));
87
+ // Said out loud rather than fixed up. `--port 41773x` used to serve on 4173 instead, so the address
88
+ // in the terminal was not the address that was asked for and the reason was nowhere.
89
+ if (!/^\d+$/.test(portArg) || Number(portArg) > 65535) {
90
+ console.error(`pi-loops web: --port takes a number from 0 to 65535; got ${portArg ? `"${portArg}"` : "nothing"}`);
91
+ process.exit(1);
92
+ }
93
+ const PORT = Number(portArg);
94
+ const LOOPS_DIR = optionValue("loops-dir", process.env.PI_LOOPS_DIR || path.join(os.homedir(), ".pi", "agent", "loops"));
95
+ /**
96
+ * The token is kept in a file rather than made fresh each launch, so the address stays the same
97
+ * one every time: bookmark http://127.0.0.1:4173/ and it works tomorrow. It is a boring secret —
98
+ * 0600 in the loops directory, next to the jobs it lets you run — and it exists because anything
99
+ * that reaches /rpc gets the whole session, including any *website* you happen to have open (a
100
+ * page cannot read this server's answers, but without a check it could still tell your agent what
101
+ * to do). You will not have to type it: the browser gets a cookie on the first visit.
102
+ */
103
+ function storedToken() {
104
+ const file = path.join(LOOPS_DIR, "web-token");
105
+ try {
106
+ // lstat, not stat: a symlink here is not a token file, it is someone else choosing where
107
+ // this process reads and writes. LOOPS_DIR can be pointed anywhere with --loops-dir.
108
+ const st = fs.lstatSync(file);
109
+ if (!st.isFile()) throw new Error(`${file} is not a regular file`);
110
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) throw new Error(`${file} belongs to another user`);
111
+ // The mode is only applied when the file is created, so a backup restored at 0644 would
112
+ // otherwise stay that way for ever, readable by every account on the machine.
113
+ if (st.mode & 0o077) fs.chmodSync(file, 0o600);
114
+ const found = fs.readFileSync(file, "utf8").trim();
115
+ if (/^[A-Za-z0-9_-]{8,}$/.test(found)) return found;
116
+ } catch (err) {
117
+ if (err?.code !== "ENOENT") {
118
+ // Anything other than "not there yet" is a thing to say out loud rather than paper over
119
+ // by writing a new token: it means the path is not what this expects it to be.
120
+ console.error(`pi-loops web: ${err?.message ?? err}`);
121
+ process.exit(1);
122
+ }
123
+ }
124
+ const made = randomBytes(16).toString("hex");
125
+ fs.mkdirSync(LOOPS_DIR, { recursive: true });
126
+ fs.writeFileSync(file, `${made}\n`, { mode: 0o600, flag: "w" });
127
+ fs.chmodSync(file, 0o600);
128
+ return made;
129
+ }
130
+
131
+ const TOKEN = process.env.PI_WEB_TOKEN || storedToken();
132
+ const COOKIE = "pi_web_token";
133
+ /**
134
+ * `--no-auth`: no token, no cookie, nothing to carry — anything on this machine that can reach
135
+ * 127.0.0.1 gets the session. What is left of the fence is `hostAllowed()` and the `Sec-Fetch-Site`
136
+ * check below, which together still keep a *browser* on another site out; what you are giving up
137
+ * is the guarantee against everything else, other accounts and other programs included.
138
+ */
139
+ const NO_AUTH = flag("no-auth");
140
+ /**
141
+ * What to bind. Loopback by default, because the safe thing should not need to be asked for. A
142
+ * phone cannot reach a loopback address at all, so `--host 0.0.0.0` (or a specific interface) is
143
+ * how the front end gets onto your own network — and at that point the token stops being a
144
+ * formality, which is why `--no-auth` is refused here rather than quietly obeyed.
145
+ */
146
+ const HOST_BIND = optionValue("host", "127.0.0.1");
147
+ const LOOPBACK = HOST_BIND === "127.0.0.1" || HOST_BIND === "::1" || HOST_BIND === "localhost";
148
+ if (!LOOPBACK && NO_AUTH) {
149
+ console.error("pi-loops web: --no-auth is refused with --host: that would put an unauthenticated shell on the network");
150
+ process.exit(1);
151
+ }
152
+ // It is substituted into a JS string literal in the page and into a URL on the console, so what it
153
+ // may contain is not a matter of taste: a quote ends the literal early and the rest of the token
154
+ // becomes code. Say so at startup rather than serving a broken page.
155
+ if (!/^[A-Za-z0-9_-]{8,}$/.test(TOKEN)) {
156
+ console.error("PI_WEB_TOKEN must be at least 8 characters of letters, digits, - or _");
157
+ process.exit(1);
158
+ }
159
+ const HOST = os.hostname();
160
+
161
+ /* ------------------------------------------------------------------ pi, in rpc mode */
162
+
163
+ /**
164
+ * pi, in a process group of its own.
165
+ *
166
+ * pi in rpc mode installs handlers for SIGTERM and SIGHUP, and on either of those it shuts the
167
+ * session down properly — which is where pi-loops hands the clock to a headless host, so loops and
168
+ * triggers keep running after the window closes. It installs none for SIGINT. A terminal's Ctrl-C
169
+ * goes to the whole foreground process group, so a pi sharing this one died of that SIGINT before
170
+ * `leave` below could ask it to quit: no hand-off, no host, the core promise of the product broken
171
+ * by the most ordinary way there is to stop a program. `detached` puts pi outside the group the
172
+ * terminal signals, so the only signal it gets is the SIGTERM we send it.
173
+ *
174
+ * POSIX only: on Windows `detached` means a new console window rather than a new process group,
175
+ * which is a visible regression for no gain — Ctrl-C there arrives as a console control event,
176
+ * not as a group signal — so that platform keeps the arrangement it had.
177
+ *
178
+ * Nothing else changes: the stdio pipes are what this process talks to pi through, and they are
179
+ * unaffected by which group it is in. If this process dies without running `leave` — SIGKILL, a
180
+ * crash — pi's stdin closes with it, and pi treats that end-of-input as a quit, hand-off included.
181
+ */
182
+ const pi = spawn(process.env.PI_BIN || "pi", ["--mode", "rpc", ...piArgs], { stdio: ["pipe", "pipe", "pipe"], detached: process.platform !== "win32" });
183
+ let piAlive = true;
184
+
185
+ pi.on("error", (err) => {
186
+ console.error(`could not start pi: ${err.message}`);
187
+ process.exit(1);
188
+ });
189
+ pi.on("exit", (code, signal) => {
190
+ piAlive = false;
191
+ // The page has to be told *why*. pi's own reasons for refusing to start — a duplicate extension,
192
+ // a provider that will not authenticate — are on its stderr, and a browser tab that just says
193
+ // "pi exited" sends you to the terminal to find out what a terminal already knew.
194
+ broadcast({ type: "pi_exit", code, signal, stderr: recentStderr() });
195
+ console.error(`pi exited (${signal || code})`);
196
+ // Long enough for that event to reach an attached browser before this process goes with it.
197
+ setTimeout(() => process.exit(code ?? 0), 500);
198
+ });
199
+
200
+ /**
201
+ * pi's stderr, forwarded to ours and kept — bounded — so the page can show the last of it when pi
202
+ * dies. Bounded because a chatty provider error can be very long and nobody reads past the end.
203
+ */
204
+ const STDERR_KEEP = 8000;
205
+ /** What actually leaves this process: the tail, which is the part that says why pi stopped. */
206
+ const STDERR_SEND = 4000;
207
+ let stderrTail = "";
208
+ /**
209
+ * The shapes a credential takes in a line of output.
210
+ *
211
+ * `src/redact.ts` is this project's list and this process cannot import it: the front end is
212
+ * launched as plain `node src/web.mjs`, with no TypeScript loader in it. This is the subset that
213
+ * matters for a stderr tail — a provider that refused to authenticate prints the key it was given,
214
+ * and an MCP server started by pi prints the Authorization header it was handed. Anything not
215
+ * matched here is bounded rather than masked, which is why the tail is short.
216
+ */
217
+ const SECRET_SHAPES = [
218
+ /sk-[A-Za-z0-9_-]{20,}/g,
219
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
220
+ /\bgh[ousp]_[A-Za-z0-9]{30,}\b/g,
221
+ /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g,
222
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/g,
223
+ /\bAIza[0-9A-Za-z_-]{35}\b/g,
224
+ /Bearer\s+[A-Za-z0-9._-]{16,}/g,
225
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
226
+ ];
227
+
228
+ /**
229
+ * The last of pi's stderr, capped and redacted, because this is broadcast to every attached browser.
230
+ *
231
+ * It used to go out as it arrived. A provider that refuses to authenticate is one of the commonest
232
+ * reasons pi exits at all, and what it prints is the key it was refused with — so the one event
233
+ * whose whole purpose is to explain a failure was the one event that could carry a credential.
234
+ */
235
+ function recentStderr() {
236
+ // Redact the whole kept tail, then cut. The other order cut first, so a key that straddled the
237
+ // 4000-character boundary lost the `sk-` its pattern starts at — nothing matched, and the second
238
+ // half of it went out to every attached browser.
239
+ let out = stderrTail.trim();
240
+ for (const re of SECRET_SHAPES) {
241
+ re.lastIndex = 0;
242
+ out = out.replace(re, "[REDACTED]");
243
+ }
244
+ return out.slice(-STDERR_SEND);
245
+ }
246
+ pi.stderr.on("data", (d) => {
247
+ process.stderr.write(d);
248
+ stderrTail = (stderrTail + d.toString("utf8")).slice(-STDERR_KEEP);
249
+ });
250
+
251
+ let nextId = 1;
252
+ const pending = new Map();
253
+
254
+ /**
255
+ * Write one line to pi. Its stdin is a pipe to another process: it can be gone before the `exit`
256
+ * event that sets `piAlive`, and an EPIPE arrives as an unhandled `error` event on the socket,
257
+ * which ends this process too. A browser front end whose pi died should say so, not die with it.
258
+ */
259
+ function writeToPi(line) {
260
+ if (!piAlive) return false;
261
+ try {
262
+ pi.stdin.write(line);
263
+ return true;
264
+ } catch (err) {
265
+ console.error(`pi-loops web: could not write to pi: ${err?.message ?? err}`);
266
+ return false;
267
+ }
268
+ }
269
+ pi.stdin.on("error", (err) => {
270
+ piAlive = false;
271
+ console.error(`pi-loops web: pi's input closed: ${err?.message ?? err}`);
272
+ });
273
+
274
+ /** Send one RPC command and wait for the response that carries its id. */
275
+ function rpc(command, timeoutMs = 60_000) {
276
+ if (!piAlive) return Promise.resolve({ success: false, error: "pi is not running" });
277
+ const id = `web-${nextId++}`;
278
+ if (!writeToPi(`${JSON.stringify({ ...command, id })}\n`)) return Promise.resolve({ success: false, error: "pi is not running" });
279
+ return new Promise((resolve) => {
280
+ const timer = setTimeout(() => {
281
+ // A command that never answers must not hold a browser request open for ever.
282
+ if (pending.delete(id)) resolve({ success: false, error: `timed out after ${timeoutMs}ms` });
283
+ }, timeoutMs);
284
+ pending.set(id, (msg) => {
285
+ clearTimeout(timer);
286
+ resolve(msg);
287
+ });
288
+ });
289
+ }
290
+
291
+ // Strict JSONL: split on \n only. Node's readline also splits on U+2028/U+2029, which are legal
292
+ // inside JSON strings — the rpc protocol calls this out, and one tool result containing either
293
+ // would desync the stream for the rest of the session.
294
+ let buf = "";
295
+ pi.stdout.on("data", (chunk) => {
296
+ buf += chunk.toString("utf8");
297
+ let nl;
298
+ while ((nl = buf.indexOf("\n")) !== -1) {
299
+ const line = buf.slice(0, nl).replace(/\r$/, "");
300
+ buf = buf.slice(nl + 1);
301
+ if (!line.trim()) continue;
302
+ let msg;
303
+ try {
304
+ msg = JSON.parse(line);
305
+ } catch {
306
+ continue; // never desync the whole session over one unparseable line
307
+ }
308
+ if (msg.type === "response" && msg.id && pending.has(msg.id)) {
309
+ pending.get(msg.id)(msg);
310
+ pending.delete(msg.id);
311
+ continue; // a command's own answer belongs to its caller, not to the feed
312
+ }
313
+ relayNote(msg);
314
+ observe(msg);
315
+ broadcast(msg);
316
+ }
317
+ });
318
+
319
+ /* ------------------------------------------------------------------ what the server keeps */
320
+
321
+ let entriesCache = { at: 0, value: undefined };
322
+
323
+ const live = {
324
+ queue: { steering: [], followUp: [] },
325
+ goal: undefined,
326
+ lastPoll: undefined,
327
+ runtime: undefined,
328
+ lastError: undefined,
329
+ pendingAsks: new Map(), // dialogs asked while no browser was attached
330
+ };
331
+
332
+ /**
333
+ * How many unanswered dialogs are kept, for the same reason the backlog is bounded.
334
+ *
335
+ * A dialog nobody answers is offered again to every browser that attaches, every eight seconds, for
336
+ * as long as this process lives — so an abandoned one is not merely memory, it is a question that
337
+ * will not stop being asked. Past this the oldest goes, which is the one least likely to still have
338
+ * an extension waiting behind it.
339
+ */
340
+ const MAX_PENDING_ASKS = 50;
341
+
342
+ /** Fold the events that carry state a late-joining browser still needs. */
343
+ function observe(ev) {
344
+ if (ev.type === "queue_update") live.queue = { steering: ev.steering ?? [], followUp: ev.followUp ?? [] };
345
+ if (ev.type === "extension_ui_request") {
346
+ if (ev.method === "confirm" || ev.method === "select" || ev.method === "input" || ev.method === "editor") {
347
+ live.pendingAsks.set(ev.id, ev);
348
+ while (live.pendingAsks.size > MAX_PENDING_ASKS) live.pendingAsks.delete(live.pendingAsks.keys().next().value);
349
+ }
350
+ }
351
+ if (ev.type === "entry_appended") {
352
+ entriesCache = { at: 0, value: undefined }; // the session moved; search and undo must re-read
353
+ const entry = ev.entry;
354
+ if (entry?.type !== "custom") return;
355
+ // pi-loops writes its own state into the session as custom entries, so the panel can read it
356
+ // structurally instead of parsing the text it prints for a person.
357
+ if (entry.customType === "goal_state") live.goal = entry.data;
358
+ if (entry.customType === "trigger_result") live.lastPoll = { ...entry.data, at: entry.timestamp };
359
+ // Which MCP servers actually connected, what they exposed, who owns the clock: state that
360
+ // lives in the pi process and is in no file this side could open.
361
+ if (entry.customType === "pi_loops_snapshot") live.runtime = entry.data;
362
+ }
363
+ }
364
+
365
+ /* ------------------------------------------------------------------ pi-loops, read from disk */
366
+
367
+ function readJson(file, fallback) {
368
+ try {
369
+ return JSON.parse(fs.readFileSync(file, "utf8"));
370
+ } catch {
371
+ return fallback;
372
+ }
373
+ }
374
+
375
+ /**
376
+ * Is `child` inside `ancestor`? A `startsWith` on the resolved path is not this test: with ancestor
377
+ * `/home/u/proj`, `/home/u/proj-secrets/.env` passes it. Symlinks are resolved first — `realOf`
378
+ * resolves as much of each path as exists, so a file that is not there yet still answers — which
379
+ * means a link planted in the project cannot point out of it either.
380
+ */
381
+ function isUnder(child, ancestor) {
382
+ const realRoot = realOf(ancestor);
383
+ const realPath = realOf(child);
384
+ const rel = path.relative(realRoot, realPath);
385
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
386
+ }
387
+
388
+ /** A root so broad that containment would mean "everything": never treated as one project. */
389
+ function tooBroad(dir) {
390
+ return dir === "/" || dir === os.homedir() || path.dirname(dir) === dir;
391
+ }
392
+
393
+ /** Containment, but with the project rule on top of it: `$HOME` contains everything and is nobody's. */
394
+ function isUnderProject(child, project) {
395
+ if (!child || !project) return false;
396
+ const realRoot = realOf(project);
397
+ const realPath = realOf(child);
398
+ if (realRoot === realPath) return true;
399
+ if (tooBroad(realRoot)) return false;
400
+ const rel = path.relative(realRoot, realPath);
401
+ return !!rel && !rel.startsWith("..") && !path.isAbsolute(rel);
402
+ }
403
+
404
+ /**
405
+ * pi-loops' own rule, answered the same way the extension answers it: a job belongs to this project
406
+ * if its cwd is the session's, or under it, or the session's is under the job's.
407
+ *
408
+ * It was a string prefix here and `withinProject` there (src/presence.ts), and the two disagreed in
409
+ * both directions: a project reached through a symlink — a worktree, a home on another volume, a
410
+ * `~/code` that points at a mounted disk — was the same project to `/cron` and a different one to
411
+ * this panel, so the job was listed in the terminal and missing from the page. And a job whose cwd
412
+ * is `$HOME` was shown here under every project, while the terminal counts it as its own.
413
+ *
414
+ * A panel that disagrees with the command is worse than either: what it costs to be wrong is
415
+ * somebody concluding their job is gone.
416
+ */
417
+ function sameProject(jobCwd, sessionCwd) {
418
+ if (!jobCwd || !sessionCwd) return false;
419
+ return isUnderProject(jobCwd, sessionCwd) || isUnderProject(sessionCwd, jobCwd);
420
+ }
421
+
422
+ function formatSchedule(s) {
423
+ if (!s || typeof s !== "object") return "?";
424
+ if (s.kind === "cron") return s.expr;
425
+ if (s.kind === "once") return `once at ${new Date(s.at).toLocaleString()}`;
426
+ if (s.kind === "every") {
427
+ const m = Math.round(s.ms / 60_000);
428
+ if (m < 60) return `every ${m}m`;
429
+ return m % 60 === 0 ? `every ${m / 60}h` : `every ${Math.floor(m / 60)}h${m % 60}m`;
430
+ }
431
+ return "?";
432
+ }
433
+
434
+ /**
435
+ * When each job runs next, as the scheduler worked it out.
436
+ *
437
+ * This used to be computed here, and understood only `every <interval>` — so a job on a cron
438
+ * expression, which is the first example in the README, showed no next run at all. Writing a second
439
+ * cron parser into this file was the wrong fix: the answer is a pure function of the job and the
440
+ * clock, and the process that owns the clock already has the evaluator. It writes the answers to
441
+ * `next-runs.json` beside the store when one of them changes, and this reads them like every other
442
+ * file pi-loops keeps.
443
+ *
444
+ * Missing file, older pi-loops, no leader yet: no next run shown, which is what was shown before.
445
+ */
446
+ function nextRuns() {
447
+ // This machine's file. Leadership is per host, and so is the clock a cron expression is matched
448
+ // against, so on a shared `$HOME` each machine keeps its own answers rather than overwriting the
449
+ // other's with times computed in a different timezone.
450
+ const safe = HOST.replace(/[^A-Za-z0-9._-]/g, "_");
451
+ const doc = readJson(path.join(LOOPS_DIR, `next-runs.${safe}.json`), { next: {} });
452
+ return doc?.next && typeof doc.next === "object" ? doc.next : {};
453
+ }
454
+
455
+ /** An ISO time, but only if it is still ahead of us. */
456
+ function futureOnly(iso) {
457
+ const at = Date.parse(iso ?? "");
458
+ return Number.isFinite(at) && at > Date.now() ? iso : undefined;
459
+ }
460
+
461
+ function automation(cwd) {
462
+ // The directory is the evidence, not any one file in it: a project with rules and no cron jobs
463
+ // has no jobs.json at all, and reporting "pi-loops not found" there would be a lie.
464
+ if (!fs.existsSync(LOOPS_DIR)) return { installed: false, dir: LOOPS_DIR };
465
+ const jobsFile = readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] });
466
+ /**
467
+ * This project's, whatever machine they were made on.
468
+ *
469
+ * The host used to be part of this test, so a job stamped with another hostname vanished from
470
+ * the panel — and a hostname changes on its own: a rebuilt container, a machine renamed by
471
+ * DHCP, a restored backup. What you got was a job sitting enabled in `jobs.json`, never running,
472
+ * invisible in the one place you would look. The terminal always listed it and said whose it
473
+ * was; so does this, and the count of everything in other projects goes with it, because a list
474
+ * that silently drops things is worse than a longer list.
475
+ */
476
+ const inThisProject = (j) => sameProject(j.cwd, cwd);
477
+ const allJobs = jobsFile.jobs ?? [];
478
+ const nexts = nextRuns();
479
+ const jobs = allJobs.filter(inThisProject).map((j) => ({
480
+ id: j.id,
481
+ name: j.name,
482
+ schedule: formatSchedule(j.schedule),
483
+ stateful: !!j.stateful,
484
+ enabled: !!j.enabled,
485
+ prompt: j.prompt,
486
+ runCount: j.runCount ?? 0,
487
+ lastError: j.lastError,
488
+ running: !!j.running,
489
+ // Set only when it is not this machine's: the panel says so, and nothing else has to guess.
490
+ otherHost: j.host && j.host !== HOST ? j.host : undefined,
491
+ // What to type to fix it. The terminal's version of this line says `/cron set <n>`, where n
492
+ // is the position in a numbered list — which this panel does not have, so telling someone to
493
+ // use one was telling them to go and find a terminal. A name or a full id resolves anywhere.
494
+ ref: j.name || j.id,
495
+ // Only for a job that is actually going to run: disabled, or belonging to another machine,
496
+ // and a next run would be a promise nothing intends to keep — which is the rule `/cron`
497
+ // follows. A time that has already passed is a stale answer rather than a next run (the job
498
+ // fired and no tick has rewritten the file yet), and saying nothing beats saying something
499
+ // visibly wrong.
500
+ next: j.enabled && !(j.host && j.host !== HOST) ? futureOnly(nexts[j.id]) : undefined,
501
+ }));
502
+ const allRules = readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? [];
503
+ const rules = allRules
504
+ .filter(inThisProject)
505
+ .map((r) => ({ id: r.id, condition: r.condition, action: r.action, enabled: !!r.enabled, fireOnce: !!r.fireOnce, firedAt: r.firedAt }));
506
+ // Two counts, not one. They used to be added together, and `/cron` counts jobs only — so the
507
+ // panel's number disagreed with the command's whenever a rule lived somewhere else, and the line
508
+ // that exists to say "your job is not gone, it is elsewhere" pointed at `/cron all`, where a
509
+ // reader would then count one job too few and conclude the panel was wrong about both.
510
+ const elsewhere = { jobs: allJobs.length - jobs.length, rules: allRules.length - rules.length };
511
+
512
+ let inboxNew = 0;
513
+ try {
514
+ for (const line of fs.readFileSync(path.join(LOOPS_DIR, "inbox.jsonl"), "utf8").split("\n")) {
515
+ if (!line.trim()) continue;
516
+ try {
517
+ if ((JSON.parse(line).status ?? "new") === "new") inboxNew++;
518
+ } catch {
519
+ /* skip a torn line */
520
+ }
521
+ }
522
+ } catch {
523
+ /* no inbox yet */
524
+ }
525
+
526
+ // MCP servers and hooks are deliberately not counted from the config files here: what matters
527
+ // is which ones actually connected and what they exposed, and that is in the runtime snapshot.
528
+ return { installed: true, dir: LOOPS_DIR, jobs, rules, inboxNew, elsewhere };
529
+ }
530
+
531
+ /* ------------------------------------------------------------------ what a prompt needs first */
532
+
533
+ /**
534
+ * The commands the page runs itself, rather than sending to the model as text.
535
+ *
536
+ * These are the ones you reach for between turns rather than during them — the context is full, or
537
+ * the job is finished and the next one should not inherit it. Typing the command is the way that
538
+ * habit is expressed, so they are completed like any other command and answered by the page; the
539
+ * buttons in the header are the same three actions for anyone who did not grow the habit.
540
+ */
541
+ const PAGE_COMMANDS = [
542
+ { name: "clear", description: "start a new session — this one stays on disk, /resume brings it back" },
543
+ { name: "new", description: "start a new session — this one stays on disk, /resume brings it back" },
544
+ { name: "resume", description: "go back to an earlier session in this project" },
545
+ { name: "compact", description: "compact the context; anything after it steers the summary" },
546
+ ];
547
+
548
+ /** Commands this front end answers some other way, so typing one is not sent to the model as text. */
549
+ const UI_COMMANDS = {
550
+ model: "use the model picker in the header",
551
+ thinking: "use the thinking picker in the header",
552
+ abort: "use the stop button",
553
+ cost: "shown in the header, next to the buttons",
554
+ find: "use the find button",
555
+ history: "press ↑ in the composer",
556
+ undo: "use the undo button",
557
+ save: "use the save button",
558
+ export: "use the save button",
559
+ "session-share": "use the share button",
560
+ quit: "close the tab; pi keeps running until you stop this process",
561
+ login: "log in once from a terminal (`pi`), then restart this front end — oauth has no rpc command",
562
+ };
563
+
564
+ /**
565
+ * pi's *built-in* slash commands do not exist in rpc mode — `get_commands` returns only extension
566
+ * commands and skills, and anything else is delivered to the model as literal text. Sending
567
+ * `/help` and getting a paragraph of prose back is worse than being told it is not here.
568
+ */
569
+ function commandRefusal(text) {
570
+ const trimmed = text.trim();
571
+ if (!trimmed.startsWith("/")) return undefined;
572
+ // Lowercased, as the page lowercases it before deciding which commands it runs itself. Without
573
+ // this a stale tab sending `/CLEAR` was told it is not a command at all, rather than told to
574
+ // reload — the one thing that would have fixed it.
575
+ const name = trimmed.slice(1).split(/\s/)[0].toLowerCase();
576
+ if (!name) return undefined;
577
+ if (commandList.some((c) => c.name.toLowerCase() === name)) return undefined;
578
+ // A page old enough not to know these sends them here. Say which half is out of date, rather
579
+ // than "not a command" about something this server does implement.
580
+ if (PAGE_COMMANDS.some((c) => c.name === name)) {
581
+ return { success: false, error: `/${name} is run by the page — reload this tab to get the version that does it` };
582
+ }
583
+ const hint = UI_COMMANDS[name];
584
+ return {
585
+ success: false,
586
+ error: hint ? `/${name} — ${hint}` : `/${name} is not a command in this session (pi's terminal commands are not available over rpc)`,
587
+ };
588
+ }
589
+
590
+ /**
591
+ * Expand `@path` the way the terminal does before submitting. In rpc mode nothing expands it —
592
+ * verified: the mention arrives at the model as the literal five characters — so the front end
593
+ * that offers the completion has to be the one that honours it.
594
+ */
595
+ function expandMentions(text, cwd) {
596
+ const seen = new Set();
597
+ const blocks = [];
598
+ for (const m of text.matchAll(/(^|\s)@([^\s]+)/g)) {
599
+ const rel = m[2].replace(/[.,;:)\]]+$/, "");
600
+ if (seen.has(rel)) continue;
601
+ seen.add(rel);
602
+ const file = path.resolve(cwd, rel);
603
+ // A mention must not read outside the project, and a huge file is a mistake, not a mention.
604
+ if (!isUnder(file, cwd)) continue;
605
+ let stat;
606
+ try {
607
+ stat = fs.statSync(file);
608
+ } catch {
609
+ continue;
610
+ }
611
+ if (!stat.isFile() || stat.size > 256 * 1024) continue;
612
+ let content;
613
+ try {
614
+ content = fs.readFileSync(file, "utf8");
615
+ } catch {
616
+ continue;
617
+ }
618
+ if (content.includes("\u0000")) continue; // binary: the path alone is more use than bytes
619
+ blocks.push(`\n\n<file path="${rel}">\n${content}\n</file>`);
620
+ }
621
+ return text + blocks.join("");
622
+ }
623
+
624
+ /* ------------------------------------------------------------------ the snapshot */
625
+
626
+ let modelCatalog = [];
627
+ let thinkingLevels = [];
628
+ let commandList = [];
629
+
630
+ async function refreshCatalog() {
631
+ const [models, commands, levels] = await Promise.all([
632
+ rpc({ type: "get_available_models" }, 10_000),
633
+ rpc({ type: "get_commands" }, 10_000),
634
+ // Which thinking levels mean anything for the model in use: the map pi carries has nulls in
635
+ // it, and offering a level a model does not have is offering nothing.
636
+ rpc({ type: "get_available_thinking_levels" }, 10_000),
637
+ ]);
638
+ if (levels?.success) thinkingLevels = levels.data?.levels ?? [];
639
+ // pi says a great deal more about a model than its name, and the page can use most of it: which
640
+ // of them take images, how big a context is, what a token costs. Keeping only the id meant the
641
+ // picker was 33 identical-looking lines and the attach button was a guess.
642
+ if (models?.success) {
643
+ modelCatalog = (models.data?.models ?? []).map((m) => ({
644
+ id: m.id,
645
+ provider: m.provider,
646
+ name: m.name,
647
+ images: Array.isArray(m.input) ? m.input.includes("image") : undefined,
648
+ reasoning: !!m.reasoning,
649
+ contextWindow: m.contextWindow,
650
+ cost: m.cost ? { input: m.cost.input, output: m.cost.output } : undefined,
651
+ }));
652
+ }
653
+ if (commands?.success) commandList = (commands.data?.commands ?? []).map((c) => ({ name: c.name, description: c.description, source: c.source }));
654
+ }
655
+
656
+ /**
657
+ * A session that has been running has its snapshot somewhere back in the transcript, and pi-loops
658
+ * only writes a new one when something changes — so read the last one, then ask for a fresh one.
659
+ */
660
+ async function primeRuntime() {
661
+ const entries = await rpc({ type: "get_entries" }, 20_000);
662
+ if (entries?.success) {
663
+ for (const e of entries.data?.entries ?? []) {
664
+ if (e?.type === "custom" && e.customType === "pi_loops_snapshot") live.runtime = e.data;
665
+ if (e?.type === "custom" && e.customType === "goal_state") live.goal = e.data;
666
+ }
667
+ }
668
+ if (commandList.some((c) => c.name === "cron")) await rpc({ type: "prompt", message: "/cron snapshot" }, 20_000);
669
+ }
670
+
671
+ /**
672
+ * The session's directory, as pi reports it. `@mention` expansion and path completion are anchored
673
+ * here and nowhere else: the browser is told this value and echoes it back on every request, and a
674
+ * root that the caller supplies is not a boundary at all — `isUnder(f, "/")` is true of every file
675
+ * on the disk. Reaching those routes already needs the token, which /rpc turns into the whole
676
+ * session, so this is an invariant kept rather than a hole closed.
677
+ */
678
+ let sessionCwd = process.cwd();
679
+
680
+ /**
681
+ * Which file the session is being written to, as of the last `get_state`.
682
+ *
683
+ * Everything about starting or resuming a session is anchored to this one value rather than to
684
+ * anything the browser supplies: its directory is where a new session goes and the only place
685
+ * `/sessions` will list, and switching to it is the one destination that is refused (pi treats
686
+ * a switch to the file it is already writing as a brand-new session pointed at that file, which
687
+ * is two sessions with one file between them).
688
+ */
689
+ let sessionFile;
690
+
691
+ async function snapshot() {
692
+ const state = await rpc({ type: "get_state" }, 15_000);
693
+ const s = state?.success ? state.data : {};
694
+ const cwd = s.cwd || process.cwd();
695
+ sessionCwd = cwd;
696
+ if (typeof s.sessionFile === "string" && s.sessionFile) sessionFile = s.sessionFile;
697
+ return {
698
+ ok: !!state?.success,
699
+ sessionId: s.sessionId,
700
+ sessionName: s.sessionName,
701
+ sessionFile: s.sessionFile,
702
+ cwd,
703
+ model: s.model ? { id: s.model.id, provider: s.model.provider, label: `${s.model.provider}/${s.model.id}` } : undefined,
704
+ modelCatalog,
705
+ thinkingLevels,
706
+ // The page polls this. Comparing these two against what it has received is how it notices
707
+ // that it is no longer being told anything — a dropped stream, a suspended tab, or a server
708
+ // that has been restarted under it.
709
+ seq: eventSeq,
710
+ epoch: EPOCH,
711
+ // What is serving this, so a page can tell whether it is the page this server would send.
712
+ version: VERSION,
713
+ thinkingLevel: s.thinkingLevel,
714
+ busy: !!s.isStreaming,
715
+ compacting: !!s.isCompacting,
716
+ messageCount: s.messageCount ?? 0,
717
+ queue: live.queue,
718
+ goal: live.goal,
719
+ lastPoll: live.lastPoll,
720
+ runtime: live.runtime,
721
+ pendingAsks: [...live.pendingAsks.values()],
722
+ automation: automation(cwd),
723
+ piAlive,
724
+ };
725
+ }
726
+
727
+ /* ------------------------------------------------------------------ starting over */
728
+
729
+ /**
730
+ * Starting a session and going back to one, without leaving the page.
731
+ *
732
+ * Both are `switch_session`, which pi answers by replacing the session inside the process it
733
+ * already has: same model, same MCP connections, same extensions — pi rebuilds them against the
734
+ * new session and pi-loops has handled that path since it was written (`session_shutdown` with a
735
+ * reason that is not "quit", then `session_start`, which is how `/new` and `/resume` behave in the
736
+ * terminal). The clock is not handed to the headless host and no scheduled run is lost: one that
737
+ * the swap aborts gives its slot back and re-fires on the next tick.
738
+ *
739
+ * A path that does not exist yet is how a *new* session is asked for. Nothing is written until the
740
+ * first message, so the new-session button costs an empty file only if you use it.
741
+ */
742
+
743
+ /** The directory pi keeps this project's sessions in: where the current one is, and nowhere else. */
744
+ function sessionsDir() {
745
+ return sessionFile ? path.dirname(sessionFile) : undefined;
746
+ }
747
+
748
+ /**
749
+ * What is in that directory, newest first.
750
+ *
751
+ * Only the head of each file is read. A session is a transcript — some are megabytes — and this
752
+ * list is drawn every time the dialog opens; what it needs is the header, plus enough of the start
753
+ * to say which conversation this was. pi's own picker prefers a name over the first message when
754
+ * one is set, and so does this, with the caveat that a name set late in a long session is past the
755
+ * part that gets read and shows as the first message instead.
756
+ */
757
+ const SESSION_HEAD = 64 * 1024;
758
+ function listSessionsHere(limit = 40) {
759
+ const dir = sessionsDir();
760
+ if (!dir) return [];
761
+ let names;
762
+ try {
763
+ names = fs.readdirSync(dir);
764
+ } catch {
765
+ return []; // nothing saved for this project yet
766
+ }
767
+ const out = [];
768
+ // Sorting by age means looking at all of them, and this runs on the request thread: a directory
769
+ // that has grown past anything anyone would page through is cut off rather than stalling it.
770
+ for (const name of names.slice(0, 2000)) {
771
+ if (!name.endsWith(".jsonl")) continue;
772
+ const file = path.join(dir, name);
773
+ let stat;
774
+ try {
775
+ stat = fs.statSync(file);
776
+ } catch {
777
+ continue;
778
+ }
779
+ if (!stat.isFile()) continue;
780
+ out.push({ file, mtimeMs: stat.mtimeMs, size: stat.size });
781
+ }
782
+ out.sort((a, b) => b.mtimeMs - a.mtimeMs);
783
+ const sessions = [];
784
+ // What the filesystem resolves to, not what the name says: a link in this directory pointing at
785
+ // the open session is otherwise a way to be offered the one destination that is refused.
786
+ const open = sessionFile ? realOf(sessionFile) : undefined;
787
+ for (const entry of out.slice(0, limit)) {
788
+ const head = readHead(entry.file);
789
+ if (!head) continue; // not a session file, or not a readable one
790
+ sessions.push({ ...entry, ...head, current: realOf(entry.file) === open });
791
+ }
792
+ return sessions;
793
+ }
794
+
795
+ /** The header, a name if one was set early, and the first thing a person said. */
796
+ function readHead(file) {
797
+ let text;
798
+ try {
799
+ const fd = fs.openSync(file, "r");
800
+ try {
801
+ const buf = Buffer.alloc(SESSION_HEAD);
802
+ text = buf.toString("utf8", 0, fs.readSync(fd, buf, 0, SESSION_HEAD, 0));
803
+ } finally {
804
+ fs.closeSync(fd);
805
+ }
806
+ } catch {
807
+ return undefined;
808
+ }
809
+ const lines = text.split("\n");
810
+ let header;
811
+ try {
812
+ header = JSON.parse(lines[0] ?? "");
813
+ } catch {
814
+ return undefined;
815
+ }
816
+ if (header?.type !== "session" || typeof header.id !== "string") return undefined;
817
+ const cut = text.length >= SESSION_HEAD;
818
+ let name;
819
+ let first;
820
+ let messages = 0;
821
+ // The last line of a partial read is half a line. A file that fitted has no such line, and
822
+ // dropping one there loses a message from a count that could have been exact.
823
+ for (const line of lines.slice(1, cut ? -1 : undefined)) {
824
+ if (!line.trim()) continue;
825
+ let entry;
826
+ try {
827
+ entry = JSON.parse(line);
828
+ } catch {
829
+ continue;
830
+ }
831
+ // Written by whatever set it — a person, a model, an extension — so it is bounded here the
832
+ // way the first message is, rather than carrying 64KB into the picker.
833
+ if (entry?.type === "session_info" && typeof entry.name === "string") name = entry.name.slice(0, 200);
834
+ if (entry?.type !== "message") continue;
835
+ messages++;
836
+ if (first || entry.message?.role !== "user") continue;
837
+ const content = entry.message.content;
838
+ const said = typeof content === "string" ? content : (content ?? []).map((c) => (typeof c?.text === "string" ? c.text : "")).join(" ");
839
+ // A mention is expanded before it is sent, so the first user message can be a whole file.
840
+ if (said.trim()) first = said.trim().slice(0, 200);
841
+ }
842
+ return { id: header.id, startedAt: header.timestamp, name, first, messages, truncated: cut };
843
+ }
844
+
845
+ /**
846
+ * Where a new session goes: pi's own naming, in pi's own directory.
847
+ *
848
+ * The uuid in the name is this side's, and the id pi puts in the header is pi's — they will not be
849
+ * the same string, because pi mints its id when it takes the path and nothing can know it sooner.
850
+ * pi does not mind: `--session <id>`, `-c` and its picker all match on the header, checked against
851
+ * a session file deliberately named after a different uuid.
852
+ *
853
+ * pi-loops did mind, in one place, and it cost every inject-and-run job made in a session started
854
+ * here: `sessionExists` decided by the file name, so ten minutes later the scheduler parked those
855
+ * jobs as belonging to a session that did not exist, and `/cron gc` deletes what it parks. That
856
+ * function reads the header now. Anything else that identifies a session by its file name is
857
+ * wrong about a session this function named.
858
+ */
859
+ function newSessionPath() {
860
+ const dir = sessionsDir();
861
+ if (!dir) return undefined;
862
+ return path.join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}_${randomUUID()}.jsonl`);
863
+ }
864
+
865
+ /**
866
+ * The model a session was last using, as pi would read it back.
867
+ *
868
+ * pi's own rule, walked from the leaf upwards: the newest `model_change` on this branch, or failing
869
+ * that the model the newest assistant message on it was answered by. Upwards rather than through the
870
+ * flat list because `get_entries` hands back the whole tree, abandoned branches included, and the
871
+ * model in an abandoned branch is not the model of this conversation.
872
+ */
873
+ function lastModelOf(entries, leafId) {
874
+ const byId = new Map((entries ?? []).filter((e) => e?.id).map((e) => [e.id, e]));
875
+ for (let e = byId.get(leafId); e; e = e.parentId ? byId.get(e.parentId) : undefined) {
876
+ if (e.type === "model_change" && e.provider && e.modelId) return { provider: e.provider, modelId: e.modelId };
877
+ const m = e.type === "message" ? e.message : undefined;
878
+ if (m?.role === "assistant" && m.provider && m.model) return { provider: m.provider, modelId: m.model };
879
+ }
880
+ return undefined;
881
+ }
882
+
883
+ /**
884
+ * Put a resumed session back on the model it was having its conversation with.
885
+ *
886
+ * `--model` on the launch command line — which the launcher also supplies from `ui.json`'s remembered
887
+ * choice — is not a one-off: pi re-resolves it every time the session inside the process is replaced,
888
+ * so resuming an earlier session in this window landed it on the model *this process* started with.
889
+ * A fresh `pi --resume` restores the session's own model instead, and a window should not mean
890
+ * something different from a terminal.
891
+ *
892
+ * Best effort by nature: a model this session used and this machine has no credentials for any more
893
+ * is refused by pi, and the session stays on the one it has — which is what the panel then says.
894
+ */
895
+ async function restoreModelOf(file) {
896
+ const r = await rpc({ type: "get_entries" }, 20_000);
897
+ if (!r?.success) return;
898
+ const want = lastModelOf(r.data?.entries ?? [], r.data?.leafId);
899
+ if (!want) return; // a session nobody ever answered in: nothing to go back to
900
+ const state = await rpc({ type: "get_state" }, 15_000);
901
+ const now = state?.success ? state.data?.model : undefined;
902
+ if (now && now.provider === want.provider && now.id === want.modelId) return; // already there
903
+ const answer = await rpc({ type: "set_model", provider: want.provider, modelId: want.modelId }, 20_000);
904
+ // Whoever asked for the swap refreshes the catalog right after this, which is where the new
905
+ // model's thinking levels come from; there is nothing to do here on success.
906
+ if (answer?.success) return;
907
+ // Not a failure of the resume: the conversation is back, on a different model. The terminal is
908
+ // where that belongs — the page reads the model it is actually on from /state.
909
+ console.error(`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"}`);
910
+ }
911
+
912
+ /**
913
+ * Swap the session under the page.
914
+ *
915
+ * Refused while a turn is running: the swap aborts it, and losing a reply you are waiting for is
916
+ * not something to discover afterwards. The page asks first, which is where the question belongs;
917
+ * this is the check that means it cannot be skipped by a stale tab.
918
+ */
919
+ async function swapSession(file, { restoreModel = false } = {}) {
920
+ if (!piAlive) return { success: false, error: "pi is not running" };
921
+ if (!file) return { success: false, error: "this session has no file on disk (pi was started with --no-session)" };
922
+ const before = await rpc({ type: "get_state" }, 15_000);
923
+ if (!before?.success) return { success: false, error: before?.error ?? "pi did not answer" };
924
+ if (before.data?.isStreaming || before.data?.isCompacting) return { success: false, error: "a turn is running — stop it first" };
925
+ const current = before.data?.sessionFile;
926
+ if (current && realOf(file) === realOf(current)) return { success: false, error: "this is the session you are already in" };
927
+ const answer = await rpc({ type: "switch_session", sessionPath: file }, 60_000);
928
+ if (!answer?.success) return answer ?? { success: false, error: "pi did not answer" };
929
+ if (answer.data?.cancelled) return { success: true, data: { cancelled: true } };
930
+
931
+ // A different session is a different sequence of events: the numbers the page was counting mean
932
+ // nothing against the ones that start now, and the epoch is how it is told that.
933
+ EPOCH = randomBytes(6).toString("hex");
934
+ backlog.length = 0;
935
+ entriesCache = { at: 0, value: undefined };
936
+ live.queue = { steering: [], followUp: [] };
937
+ live.goal = undefined;
938
+ live.lastPoll = undefined;
939
+ live.runtime = undefined;
940
+ live.lastError = undefined;
941
+ // Dialogs belong to the session that asked them. The extension that was waiting for an answer
942
+ // went down with the swap, and answering now would be answering nobody.
943
+ live.pendingAsks.clear();
944
+ sessionFile = file;
945
+ // Only a resume: a new session is meant to start on whatever this process starts sessions on.
946
+ if (restoreModel) await restoreModelOf(file);
947
+ // The rebuilt extensions register their commands again, and pi-loops writes a snapshot for the
948
+ // panel only when something changes — so ask for both rather than showing the old session's.
949
+ await refreshCatalog();
950
+ await primeRuntime();
951
+ return answer;
952
+ }
953
+
954
+ /* ------------------------------------------------------------------ http */
955
+
956
+ const clients = new Set();
957
+ const backlog = [];
958
+
959
+ /**
960
+ * Every event carries a number, and the history hand-off says which number it was current at. The
961
+ * browser replays the transcript, then ignores anything from the backlog it already has — exactly,
962
+ * instead of the "drop message_end for the first 300ms" guess this replaces. That guess dropped
963
+ * live events whenever the backlog held a turn that was still running: the tool results and the
964
+ * end-of-message that turns a streamed reply into rendered Markdown both went with it, so you got
965
+ * a page of raw asterisks and tools stuck on "running".
966
+ */
967
+ let eventSeq = 0;
968
+ /**
969
+ * Which run of this process the numbers belong to. Without it, a browser holding number 40 from the
970
+ * process that just exited quietly ignores the first forty events of the one that replaced it —
971
+ * every number is "already seen". A restart is not a gap, it is a different sequence entirely.
972
+ */
973
+ let EPOCH = randomBytes(6).toString("hex");
974
+
975
+ function broadcast(event) {
976
+ if (event && typeof event === "object") {
977
+ event.seq = ++eventSeq;
978
+ event.epoch = EPOCH;
979
+ }
980
+ backlog.push(event);
981
+ if (backlog.length > 800) backlog.shift();
982
+ const data = `data: ${JSON.stringify(event)}\n\n`;
983
+ for (const res of clients) {
984
+ try {
985
+ res.write(data);
986
+ } catch {
987
+ clients.delete(res);
988
+ }
989
+ }
990
+ }
991
+
992
+ /** Constant-time compare, so a token cannot be found a byte at a time. */
993
+ function sameToken(given) {
994
+ if (typeof given !== "string" || given.length !== TOKEN.length) return false;
995
+ return timingSafeEqual(Buffer.from(given), Buffer.from(TOKEN));
996
+ }
997
+
998
+ /** A Host that is an address rather than a name: nothing to re-resolve, so nothing to rebind. */
999
+ const IP_LITERAL = /^(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9A-Fa-f:.]+\])$/;
1000
+ /**
1001
+ * Where the connection actually came from, for the tailnet case. `tailscale serve` proxies to
1002
+ * loopback, so a genuine one arrives on a local socket; a direct connection to the tailnet address
1003
+ * arrives from 100.64/10, which is the range Tailscale hands out. A `.ts.net` Host from anywhere
1004
+ * else is a name pointed at this machine by someone, which is the thing the check is for.
1005
+ */
1006
+ function fromTailnet(req) {
1007
+ const from = String(req.socket?.remoteAddress ?? "").replace(/^::ffff:/, "");
1008
+ if (from === "127.0.0.1" || from === "::1") return true;
1009
+ const octets = from.split(".").map(Number);
1010
+ return octets.length === 4 && octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127;
1011
+ }
1012
+
1013
+ /** Names you put in front of this yourself: a reverse proxy, a hostname on your own network. */
1014
+ const ALLOWED_HOSTS = new Set(
1015
+ String(optionValue("allow-host", ""))
1016
+ .split(",")
1017
+ .map((h) => h.trim().toLowerCase())
1018
+ .filter(Boolean),
1019
+ );
1020
+
1021
+ /**
1022
+ * Whether this request may be answered at all, judged by the name it arrived under.
1023
+ *
1024
+ * Without a Host check, a page on any site could point a hostname of its own at 127.0.0.1 and talk
1025
+ * to this server from the browser; the token is what actually stops that, and this is the second
1026
+ * lock. It is *not* "only this machine": loopback always passes, and so do a bare address on the
1027
+ * network this was deliberately bound to, a `.ts.net` name arriving from a tailnet, and every name
1028
+ * given to `--allow-host` — which is a promise the person who started this made about what sits in
1029
+ * front of it, not a resolution anything here could check.
1030
+ */
1031
+ function hostAllowed(req) {
1032
+ const host = String(req.headers.host ?? "").replace(/:\d+$/, "");
1033
+ if (host === "127.0.0.1" || host === "localhost" || host === "[::1]") return true;
1034
+ // With no token, the only thing standing between a request and the session is where it came
1035
+ // from, so nothing but loopback counts. Refusing --no-auth at bind time was not enough: a
1036
+ // loopback-bound server reached through `tailscale serve` — or `tailscale funnel`, which is the
1037
+ // open internet — arrives here as a local socket carrying a tailnet name.
1038
+ if (NO_AUTH) return false;
1039
+ // Bound to the network on purpose: the phone reaches this by IP, so a bare IP address has to be
1040
+ // allowed. An address cannot be rebound — there is no name to re-resolve — which is the whole
1041
+ // reason the rule is shaped this way.
1042
+ if (!LOOPBACK && IP_LITERAL.test(host)) return true;
1043
+ // Tailscale's MagicDNS names. `tailscale serve` is the good way to reach this from a phone: it
1044
+ // terminates TLS on the tailnet and proxies to loopback here, so the request arrives looking
1045
+ // local but carrying the tailnet name as its Host. That zone belongs to Tailscale and its names
1046
+ // resolve to tailnet devices, so it is not a name an attacker can point at this machine.
1047
+ if (host.endsWith(".ts.net") && fromTailnet(req)) return true;
1048
+ return ALLOWED_HOSTS.has(host.toLowerCase());
1049
+ }
1050
+
1051
+ /**
1052
+ * The cookie the page was given on its first visit. `SameSite=Strict` is the point of it: a browser
1053
+ * does not attach it to anything another site initiated, not even a top-level link, so it opens
1054
+ * this server to the tab you opened yourself and to nothing else.
1055
+ */
1056
+ function cookieToken(req) {
1057
+ for (const part of String(req.headers.cookie ?? "").split(";")) {
1058
+ const cut = part.indexOf("=");
1059
+ if (cut > 0 && part.slice(0, cut).trim() === COOKIE) return part.slice(cut + 1).trim();
1060
+ }
1061
+ return undefined;
1062
+ }
1063
+
1064
+ /**
1065
+ * The browser says who started the request, and it is not something a page can lie about. It costs
1066
+ * nothing and it is the only lock left standing under `--no-auth`, where a form post from any site
1067
+ * would otherwise reach /prompt — a page cannot read the answer, but it does not need to. Absent
1068
+ * from curl and from older browsers, so its absence cannot be treated as a failure.
1069
+ */
1070
+ function crossSite(req) {
1071
+ const site = req.headers["sec-fetch-site"];
1072
+ // "same-site" is not the same as "same origin": a cookie's SameSite is scoped to the site, and a
1073
+ // site ignores the port — http://localhost:5173, which is any Vite dev server on this machine,
1074
+ // is the same site as this one and its pages are handed the cookie. Those are the pages most
1075
+ // likely to exist and to be attacked, so same-site is a no, not a yes.
1076
+ if (site === "cross-site" || site === "same-site") return true;
1077
+ // A browser that does not send that header still sends Origin on anything that could do harm.
1078
+ const origin = req.headers.origin;
1079
+ // "null" is what an opaque origin sends — a sandboxed document, which is exactly what the file
1080
+ // previews are served as. Treating it as same-site would let one of them back in on a browser
1081
+ // that sends no Sec-Fetch-Site.
1082
+ if (origin === "null") return true;
1083
+ if (!origin) return false;
1084
+ try {
1085
+ return new URL(origin).host !== req.headers.host;
1086
+ } catch {
1087
+ return true; // an Origin that is not a URL is not one this server put there
1088
+ }
1089
+ }
1090
+
1091
+ function authed(req, url) {
1092
+ if (!hostAllowed(req) || crossSite(req)) return false;
1093
+ if (NO_AUTH) return true;
1094
+ return sameToken(url.searchParams.get("token")) || sameToken(req.headers["x-pi-web-token"]) || sameToken(cookieToken(req));
1095
+ }
1096
+
1097
+ /**
1098
+ * A one-shot key for the browser launcher. The real token must not appear in argv: on Linux
1099
+ * `/proc/<pid>/cmdline` is world-readable, so `xdg-open http://…?token=…` hands the token to every
1100
+ * other account on the machine — and with it `/rpc`, which is the whole session. This key is valid
1101
+ * for one page load, for a minute, and the page it serves carries the real token.
1102
+ */
1103
+ let openKey = randomBytes(16).toString("hex");
1104
+ let openKeyExpires = 0;
1105
+
1106
+ /**
1107
+ * A six-digit code for a phone. The token is 32 hex characters, which is fine to click and
1108
+ * miserable to type on a screen keyboard, so the terminal prints a number instead: enter it once
1109
+ * and that device has the cookie for good. Single use, and the scheme gives up after twenty wrong
1110
+ * guesses — six digits is a million, so twenty tries is not a search, but the counter is what
1111
+ * makes that a fact rather than an assumption.
1112
+ */
1113
+ let pairCode = "";
1114
+ let pairTries = 0;
1115
+ let pairExpires = 0;
1116
+ const PAIR_MAX_TRIES = 20;
1117
+ /** Long enough to find your phone and point it at the screen; not long enough to forget about. */
1118
+ const PAIR_TTL_MS = 10 * 60 * 1000;
1119
+
1120
+ function newPairCode() {
1121
+ pairCode = String(randomInt(0, 1_000_000)).padStart(6, "0");
1122
+ pairExpires = Date.now() + PAIR_TTL_MS;
1123
+ pairTries = 0;
1124
+ return pairCode;
1125
+ }
1126
+
1127
+ /** Used, cancelled, or expired: all three mean the same thing to everything downstream. */
1128
+ function clearPairCode() {
1129
+ pairCode = "";
1130
+ pairExpires = 0;
1131
+ }
1132
+
1133
+ /**
1134
+ * Check a pairing guess, and spend one of the twenty tries if it is wrong.
1135
+ *
1136
+ * Named for the side effect, because the side effect is the whole reason the order of the checks in
1137
+ * `GET /` matters: a browser that is already signed in must be recognised as signed in *before*
1138
+ * anything asks this, or reloading a bookmarked address that still carries an old `?pair=` burns a
1139
+ * try the phone waiting on the other side of the room needs.
1140
+ */
1141
+ function spendPairCode(given) {
1142
+ if (pairCode && Date.now() > pairExpires) clearPairCode();
1143
+ if (!pairCode || given == null) return false;
1144
+ // Shape first. A six-character guess can still be twelve bytes, and timingSafeEqual throws on a
1145
+ // length mismatch — which used to be a 500 that told a stranger a code was armed, and did it
1146
+ // without spending one of the twenty tries.
1147
+ if (typeof given !== "string" || !/^\d{6}$/.test(given)) {
1148
+ if (given) spendTry();
1149
+ return false;
1150
+ }
1151
+ if (timingSafeEqual(Buffer.from(given), Buffer.from(pairCode))) {
1152
+ clearPairCode();
1153
+ return true;
1154
+ }
1155
+ spendTry();
1156
+ return false;
1157
+ }
1158
+
1159
+ /** A wrong code is either a typo or a search, and there is no way to tell them apart from here. */
1160
+ function spendTry() {
1161
+ if (++pairTries >= PAIR_MAX_TRIES) {
1162
+ clearPairCode();
1163
+ console.error("pi-loops web: too many wrong pairing codes; ask for another one");
1164
+ }
1165
+ }
1166
+
1167
+ async function body(req) {
1168
+ const chunks = [];
1169
+ let size = 0;
1170
+ for await (const c of req) {
1171
+ size += c.length;
1172
+ // Images arrive here as base64, so the cap is generous — but it is a cap.
1173
+ if (size > 32 * 1024 * 1024) throw new Error("request too large");
1174
+ chunks.push(c);
1175
+ }
1176
+ return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
1177
+ }
1178
+
1179
+ const json = (res, data, code = 200) => {
1180
+ // Some of these carry a session, a transcript, or a live pairing code; none are worth caching.
1181
+ res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
1182
+ res.end(JSON.stringify(data));
1183
+ };
1184
+
1185
+ /** Slash commands at the start of the line, and `@`-paths anywhere. */
1186
+ function complete(text, cwd) {
1187
+ const trimmed = text ?? "";
1188
+ if (trimmed.startsWith("/") && !trimmed.includes(" ")) {
1189
+ const q = trimmed.slice(1).toLowerCase();
1190
+ // The page's own first, and only once: an extension is free to register a command of one of
1191
+ // these names, but the page intercepts the line before it is sent, so that is the one that
1192
+ // would run and offering both would be offering a choice that does not exist.
1193
+ return [...PAGE_COMMANDS, ...commandList.filter((c) => !PAGE_COMMANDS.some((p) => p.name === c.name))]
1194
+ .filter((c) => c.name.toLowerCase().startsWith(q))
1195
+ .slice(0, 20)
1196
+ .map((c) => ({ value: `/${c.name}`, hint: c.description ?? "" }));
1197
+ }
1198
+ const at = trimmed.lastIndexOf("@");
1199
+ if (at === -1) return [];
1200
+ const partial = trimmed.slice(at + 1);
1201
+ if (/\s/.test(partial)) return [];
1202
+ const dir = path.resolve(cwd, partial.endsWith("/") ? partial : path.dirname(partial));
1203
+ // Completion must not become a file browser for the whole disk.
1204
+ if (!isUnder(dir, cwd)) return [];
1205
+ const leaf = partial.endsWith("/") ? "" : path.basename(partial);
1206
+ let entries;
1207
+ try {
1208
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1209
+ } catch {
1210
+ return [];
1211
+ }
1212
+ return entries
1213
+ .filter((e) => !e.name.startsWith(".") && e.name.toLowerCase().startsWith(leaf.toLowerCase()))
1214
+ .slice(0, 20)
1215
+ .map((e) => {
1216
+ const rel = path.relative(cwd, path.join(dir, e.name));
1217
+ return { value: `@${rel}${e.isDirectory() ? "/" : ""}`, hint: e.isDirectory() ? "dir" : "file", replaceFrom: at };
1218
+ });
1219
+ }
1220
+
1221
+ /** The whole session, for search and undo. Typing in the search box must not re-read it per key. */
1222
+ async function cachedEntries() {
1223
+ if (Date.now() - entriesCache.at < 3_000 && entriesCache.value) return entriesCache.value;
1224
+ const r = await rpc({ type: "get_entries" }, 30_000);
1225
+ entriesCache = { at: Date.now(), value: r };
1226
+ return r;
1227
+ }
1228
+
1229
+ /**
1230
+ * Commands with a route of their own, which `/rpc` therefore refuses.
1231
+ *
1232
+ * `/rpc` passes a body straight to pi, which is what makes it useful — anything in the protocol this
1233
+ * front end has not grown a button for is reachable. It also made every guard on every route
1234
+ * optional: `{"type":"switch_session"}` posted here skipped the mid-turn refusal, the same-session
1235
+ * check, the "one of this project's sessions" allowlist, and the epoch, backlog and pending-dialog
1236
+ * reset a swap owes the attached browsers. A route that exists is the route that runs.
1237
+ */
1238
+ const OWN_ROUTES = new Set(["switch_session", "extension_ui_response", "prompt", "steer", "follow_up", "compact", "fork", "abort", "cycle_model", "cycle_thinking_level"]);
1239
+
1240
+ /**
1241
+ * And the two commands no route here should offer at all. Both replace the session the attached
1242
+ * browsers are watching: no new `EPOCH` is minted, the backlog and pending dialogs of the old
1243
+ * session are never cleared, the model catalog is never refreshed, and every page goes on rendering
1244
+ * a session that is no longer the one pi is in — with no way to notice. Opening a fresh session is
1245
+ * `/switch_session`-shaped work, and that route already exists.
1246
+ */
1247
+ const NO_ROUTE_AT_ALL = new Set(["new_session", "clone"]);
1248
+
1249
+ const server = http.createServer(async (req, res) => {
1250
+ const url = new URL(req.url, "http://127.0.0.1");
1251
+ try {
1252
+ if (url.pathname === "/" && req.method === "GET") {
1253
+ const key = url.searchParams.get("open");
1254
+ // The second lock still applies: a page on another site cannot be allowed to fetch this
1255
+ // one just because it guessed the key, and only a browser on this machine ever has it.
1256
+ // Both of these hand out the cookie without a token, so both owe the same locks the token
1257
+ // routes have. Without the cross-site one, a page on any site could point an iframe at
1258
+ // this URL twenty times and burn the pairing code the phone was waiting for.
1259
+ const trusted = hostAllowed(req) && !crossSite(req);
1260
+ // Asked first, because the two ways in below both have a cost and a browser that is already
1261
+ // signed in should pay neither. `spendPairCode` spends one of twenty tries, so a signed-in
1262
+ // tab reloading a bookmark that still carries an old `?pair=` used to burn a try — and the
1263
+ // one-shot launcher key was spent the same way, on a browser that did not need it.
1264
+ const signedIn = authed(req, url);
1265
+ const openOk =
1266
+ !signedIn && trusted && ((key && openKey && key === openKey && Date.now() < openKeyExpires) || spendPairCode(url.searchParams.get("pair")));
1267
+ if (openOk) openKey = ""; // one load, then it is spent
1268
+ if (!openOk && !signedIn) return void res.writeHead(403, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-pi-loops-web": "1" }).end(DOOR);
1269
+ res.writeHead(200, {
1270
+ "content-type": "text/html; charset=utf-8",
1271
+ // The URL carries the token, so no other site should ever be told it — and the page
1272
+ // itself carries it, so it must not sit in the browser's disk cache either.
1273
+ "referrer-policy": "no-referrer",
1274
+ "cache-control": "no-store",
1275
+ "x-content-type-options": "nosniff",
1276
+ /*
1277
+ * The page carries the token in a variable, and the Markdown renderer turns model
1278
+ * output into DOM. Those two facts are fine today and the review found nothing to get
1279
+ * through the renderer — but a mistake there tomorrow would be a mistake that can
1280
+ * exfiltrate a credential which never expires. This says: no origin but this one, for
1281
+ * anything. Inline script and style are what this page is made of; the value is in
1282
+ * connect-src and frame-ancestors, which is where a stolen token would have to go and
1283
+ * how a hostile page would have to reach in.
1284
+ */
1285
+ "content-security-policy":
1286
+ "default-src 'none'; connect-src 'self'; img-src 'self' data: blob:; script-src 'unsafe-inline'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
1287
+ // How a second launch on the same port knows the thing already there is one of these.
1288
+ "x-pi-loops-web": "1",
1289
+ // Why this browser never has to see the token again. A year, because the token in the
1290
+ // file outlives the process and a front end you have to re-authorise is a chore.
1291
+ "set-cookie": `${COOKIE}=${TOKEN}; Path=/; Max-Age=31536000; HttpOnly; SameSite=Strict`,
1292
+ });
1293
+ return void res.end(PAGE.replace("__TOKEN__", () => TOKEN).replace("__VERSION__", () => VERSION));
1294
+ }
1295
+ /**
1296
+ * Installability, before the token check on purpose. A browser fetches the manifest and the
1297
+ * icon without credentials — they would 403 behind it, and the page would simply not be
1298
+ * installable — and neither one says anything a stranger does not already know from the
1299
+ * door page. There is no service worker: nothing here is worth caching, and a stale copy of
1300
+ * a front end whose only job is to be live is worse than no copy at all. What this buys is
1301
+ * the part that matters on a phone: an icon on the home screen and a window with no browser
1302
+ * chrome eating a fifth of the screen.
1303
+ */
1304
+ // Behind the same "did another site start this" lock as everything else. A browser fetching a
1305
+ // manifest or an icon for this page says same-origin; an <img> on someone else's page probing
1306
+ // a port range says cross-site, and would otherwise learn from the load that this is here.
1307
+ if ((url.pathname === "/manifest.webmanifest" || url.pathname === "/icon.svg") && (!hostAllowed(req) || crossSite(req))) {
1308
+ return void res.writeHead(403).end();
1309
+ }
1310
+ if (url.pathname === "/manifest.webmanifest") {
1311
+ res.writeHead(200, { "content-type": "application/manifest+json; charset=utf-8" });
1312
+ return void res.end(
1313
+ JSON.stringify({
1314
+ name: "pi-loops",
1315
+ short_name: "pi-loops",
1316
+ start_url: "/",
1317
+ scope: "/",
1318
+ display: "standalone",
1319
+ background_color: "#111111",
1320
+ theme_color: "#111111",
1321
+ icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }],
1322
+ }),
1323
+ );
1324
+ }
1325
+ if (url.pathname === "/icon.svg") {
1326
+ res.writeHead(200, { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "max-age=86400" });
1327
+ return void res.end(ICON);
1328
+ }
1329
+ if (!authed(req, url)) return void json(res, { error: "bad or missing token" }, 403);
1330
+
1331
+ if (url.pathname === "/events" && req.method === "GET") {
1332
+ // One person, a few tabs. An unbounded set is a way to run this process out of memory.
1333
+ if (clients.size >= 16) return void json(res, { error: "too many event streams open" }, 429);
1334
+ res.on("error", () => clients.delete(res));
1335
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1336
+ res.write(": connected\n\n");
1337
+ // A browser opened mid-session still needs what already happened.
1338
+ for (const e of backlog) res.write(`data: ${JSON.stringify(e)}\n\n`);
1339
+ clients.add(res);
1340
+ const keepAlive = setInterval(() => res.write(": ping\n\n"), 25_000);
1341
+ req.on("close", () => {
1342
+ clearInterval(keepAlive);
1343
+ clients.delete(res);
1344
+ });
1345
+ return;
1346
+ }
1347
+ if (url.pathname === "/state" && req.method === "GET") return void json(res, await snapshot());
1348
+ if (url.pathname === "/history" && req.method === "GET") {
1349
+ const r = await rpc({ type: "get_messages" }, 20_000);
1350
+ // The number of the last event that happened before this transcript was taken.
1351
+ return void json(res, { messages: r?.success ? (r.data?.messages ?? []) : [], seq: eventSeq, epoch: EPOCH });
1352
+ }
1353
+ if (url.pathname === "/prompt" && req.method === "POST") {
1354
+ const { text, images, mode } = await body(req);
1355
+ if (!text && !(images ?? []).length) return void json(res, { success: false, error: "empty prompt" }, 400);
1356
+ const guard = commandRefusal(text ?? "");
1357
+ if (guard) return void json(res, guard, 400);
1358
+ // Submitting while a turn runs queues instead of racing it, as the TUI does.
1359
+ const type = mode === "steer" ? "steer" : mode === "follow_up" ? "follow_up" : "prompt";
1360
+ const message = expandMentions(text ?? "", sessionCwd);
1361
+ return void json(res, await rpc({ type, message, ...(images?.length ? { images } : {}) }));
1362
+ }
1363
+ if (url.pathname === "/model" && req.method === "POST") {
1364
+ // Recorded below, once pi has accepted it.
1365
+ // The catalog is rendered as `provider/id`; the command takes the two halves.
1366
+ const { model } = await body(req);
1367
+ const cut = String(model ?? "").indexOf("/");
1368
+ if (cut < 1) return void json(res, { success: false, error: "model must be provider/id" }, 400);
1369
+ const answer = await rpc({ type: "set_model", provider: model.slice(0, cut), modelId: model.slice(cut + 1) }, 20_000);
1370
+ // A different model has a different set of thinking levels; the picker must follow it.
1371
+ if (answer?.success) {
1372
+ await refreshCatalog();
1373
+ rememberPref("model", String(model));
1374
+ }
1375
+ return void json(res, answer);
1376
+ }
1377
+ if (url.pathname === "/thinking" && req.method === "POST") {
1378
+ const { level } = await body(req);
1379
+ const answer = await rpc({ type: "set_thinking_level", level });
1380
+ if (answer?.success) rememberPref("thinking", String(level));
1381
+ return void json(res, answer);
1382
+ }
1383
+ if (url.pathname === "/complete" && req.method === "POST") {
1384
+ const { text } = await body(req);
1385
+ return void json(res, { items: complete(text, sessionCwd) });
1386
+ }
1387
+ /**
1388
+ * A file the session made, so you can look at it instead of at its source.
1389
+ *
1390
+ * Everything about this route is about not turning "show me that chart" into "run whatever
1391
+ * the model wrote". It is anchored inside the session's own directory, the extensions it
1392
+ * serves are a list rather than anything on disk, and every response is sandboxed — an
1393
+ * opaque origin, which is what stops an HTML file the model wrote from turning round and
1394
+ * driving the session with the cookie that fetched it.
1395
+ */
1396
+ if (url.pathname === "/file" && req.method === "GET") {
1397
+ const wanted = url.searchParams.get("path") ?? "";
1398
+ const file = path.resolve(sessionCwd, wanted.startsWith("~/") ? path.join(os.homedir(), wanted.slice(2)) : wanted);
1399
+ const root = previewRoot(file);
1400
+ if (!root) return void res.writeHead(403).end("outside the session directory and your home directory");
1401
+ // A dot segment is where secrets live — .ssh, .env, .claude/.credentials.json — and
1402
+ // nothing anybody wants to *look at* begins with one. Checked on the path the filesystem
1403
+ // resolves to, not the one that was asked for: membership is decided after symlinks are
1404
+ // followed, and a rule applied before them is a rule with a door next to it. A link at
1405
+ // ~/Documents/cfg pointing into ~/.config is how that door gets used.
1406
+ const real = realOf(file);
1407
+ const rel = path.relative(realOf(root), real);
1408
+ if (rel.split(path.sep).some((seg) => seg.startsWith(".")) || isUnder(file, LOOPS_DIR)) {
1409
+ return void res.writeHead(403).end("not a file this previews");
1410
+ }
1411
+ const type = PREVIEW_TYPES[path.extname(real).toLowerCase()];
1412
+ if (!type) return void res.writeHead(415).end("not a kind of file this previews");
1413
+ // Outside the session's own directory, only things one *looks* at. A home directory holds
1414
+ // service-account keys named like ordinary JSON and password exports named like ordinary
1415
+ // CSV, and neither is a thing anybody previews — while a picture or a page is exactly
1416
+ // what an agent leaves in Downloads for a person to open.
1417
+ const inSessionDir = isUnder(file, sessionCwd);
1418
+ if (!inSessionDir && !VISUAL_TYPES.has(path.extname(real).toLowerCase())) {
1419
+ return void res.writeHead(415).end("outside the session directory, only pictures, PDFs and pages are shown");
1420
+ }
1421
+ // Opened once, and everything after this is about that one open file — not about the
1422
+ // name, which the session is free to point somewhere else in between.
1423
+ let fd;
1424
+ try {
1425
+ fd = fs.openSync(file, "r");
1426
+ } catch {
1427
+ return void res.writeHead(404).end("no such file");
1428
+ }
1429
+ const stat = fs.fstatSync(fd);
1430
+ if (!stat.isFile()) {
1431
+ fs.closeSync(fd);
1432
+ return void res.writeHead(404).end("not a file");
1433
+ }
1434
+ if (stat.size > PREVIEW_MAX_BYTES) {
1435
+ fs.closeSync(fd);
1436
+ return void res.writeHead(413).end("too big to preview");
1437
+ }
1438
+ res.writeHead(200, {
1439
+ "content-type": type,
1440
+ "content-length": stat.size,
1441
+ "x-content-type-options": "nosniff",
1442
+ "cache-control": "no-store",
1443
+ /*
1444
+ * An opaque origin: no cookie of ours is attached to anything it asks for, and it has
1445
+ * no same-origin access to this server. On top of that it may not fetch anything,
1446
+ * which is what stops a page from carrying its contents somewhere else — and scripts
1447
+ * run only for the session's own files. `~/Downloads` is where a browser puts what
1448
+ * the web gave you, and running that under an address you trust is not previewing.
1449
+ */
1450
+ "content-security-policy": [
1451
+ inSessionDir ? "sandbox allow-scripts" : "sandbox",
1452
+ "default-src 'none'",
1453
+ "img-src data: blob:",
1454
+ "style-src 'unsafe-inline'",
1455
+ inSessionDir ? "script-src 'unsafe-inline'" : "script-src 'none'",
1456
+ "font-src data:",
1457
+ ].join("; "),
1458
+ // It chooses its own referrer policy otherwise, and this address is our address.
1459
+ "referrer-policy": "no-referrer",
1460
+ "content-disposition": "inline",
1461
+ });
1462
+ return void fs.createReadStream(file, { fd }).pipe(res);
1463
+ }
1464
+ if (url.pathname === "/preview" && req.method === "POST") {
1465
+ // HTML the model wrote into the conversation rather than into a file. Held in memory,
1466
+ // briefly, and served under the same sandbox as anything else here.
1467
+ const { html } = await body(req);
1468
+ const text = String(html ?? "");
1469
+ if (text.length > PREVIEW_MAX_BYTES) return void json(res, { success: false, error: "too big to preview" }, 413);
1470
+ const id = randomBytes(9).toString("hex");
1471
+ previews.set(id, { html: text, at: Date.now() });
1472
+ // Two rules, said separately, because one of them used to be written as a condition inside a
1473
+ // loop over the Map and depended on it shrinking as it was walked.
1474
+ for (const [key, v] of previews) if (Date.now() - v.at > PREVIEW_TTL_MS) previews.delete(key);
1475
+ while (previews.size > PREVIEW_KEEP) previews.delete(previews.keys().next().value);
1476
+ return void json(res, { success: true, id });
1477
+ }
1478
+ if (url.pathname.startsWith("/preview/") && req.method === "GET") {
1479
+ const found = previews.get(url.pathname.slice("/preview/".length));
1480
+ if (!found) return void res.writeHead(404).end("that preview has expired");
1481
+ res.writeHead(200, {
1482
+ "content-type": "text/html; charset=utf-8",
1483
+ "x-content-type-options": "nosniff",
1484
+ "cache-control": "no-store",
1485
+ "content-security-policy": "sandbox allow-scripts",
1486
+ "referrer-policy": "no-referrer",
1487
+ });
1488
+ return void res.end(found.html);
1489
+ }
1490
+ if (url.pathname === "/pair" && req.method === "POST") {
1491
+ // Behind the token, so this is a browser that is already in asking for a code for the next
1492
+ // device. It replaces any outstanding one and resets the guess budget with it.
1493
+ if ((await body(req)).cancel) {
1494
+ // The dialog was closed. A code left armed and forgotten is the thing to avoid.
1495
+ clearPairCode();
1496
+ return void json(res, { success: true });
1497
+ }
1498
+ // Which addresses that device could actually use. The page knows the one it reached this
1499
+ // server on — which is the right answer under `tailscale serve` — and this adds the ones
1500
+ // only the machine can see. A loopback-only server has none, and the page says so.
1501
+ const port = server.address()?.port ?? PORT;
1502
+ const addresses = LOOPBACK ? [] : lanAddresses(port);
1503
+ return void json(res, { success: true, code: newPairCode(), expiresIn: Math.round(PAIR_TTL_MS / 1000), addresses });
1504
+ }
1505
+ if (url.pathname === "/abort" && req.method === "POST") return void json(res, await rpc({ type: "abort" }));
1506
+ if (url.pathname === "/compact" && req.method === "POST") {
1507
+ // What survives compaction is worth steering — "keep the API shapes, drop the search" is
1508
+ // the difference between a summary you can work from and one you have to undo. pi takes
1509
+ // the instructions the way `/compact <text>` does in the terminal.
1510
+ const { instructions } = await body(req);
1511
+ const custom = String(instructions ?? "").trim();
1512
+ return void json(res, await rpc({ type: "compact", ...(custom ? { customInstructions: custom.slice(0, 2000) } : {}) }, 300_000));
1513
+ }
1514
+ if (url.pathname === "/sessions" && req.method === "GET") {
1515
+ await snapshot(); // whichever file pi is writing now is what the list is anchored to
1516
+ return void json(res, { sessions: listSessionsHere(), dir: sessionsDir() });
1517
+ }
1518
+ if (url.pathname === "/session/new" && req.method === "POST") {
1519
+ await snapshot();
1520
+ return void json(res, await swapSession(newSessionPath()));
1521
+ }
1522
+ if (url.pathname === "/session/switch" && req.method === "POST") {
1523
+ const { file } = await body(req);
1524
+ await snapshot();
1525
+ // The list this came from is the only place it may point: a path from the browser is not
1526
+ // a reason to open a file anywhere on the disk, and `switch_session` on something that is
1527
+ // not a session is how you get an empty session written over it.
1528
+ const wanted = typeof file === "string" ? path.resolve(file) : "";
1529
+ const match = listSessionsHere().find((s) => path.resolve(s.file) === wanted);
1530
+ if (!match) return void json(res, { success: false, error: "no session of this project has that path" }, 400);
1531
+ // The resolved path, which is the one that was checked: pi's cwd is not this process's,
1532
+ // so a relative path would be checked here and opened somewhere else.
1533
+ return void json(res, await swapSession(path.resolve(match.file), { restoreModel: true }));
1534
+ }
1535
+ if (url.pathname === "/queue/clear" && req.method === "POST") return void json(res, await rpc({ type: "clear_queue" }));
1536
+ if (url.pathname === "/trigger/immediate" && req.method === "POST") {
1537
+ // "Run now" on a job. pi-loops exposes it as a command, and a command is a prompt here.
1538
+ const { id } = await body(req);
1539
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(id ?? "")) return void json(res, { success: false, error: "bad id" }, 400);
1540
+ const command = id.startsWith("dyn-") ? `/triggers run ${id}` : `/cron run ${id}`;
1541
+ return void json(res, await rpc({ type: "prompt", message: command }));
1542
+ }
1543
+ if (url.pathname === "/stats" && req.method === "GET") {
1544
+ // The cost, and the context gauge the terminal footer shows.
1545
+ const r = await rpc({ type: "get_session_stats" }, 20_000);
1546
+ return void json(res, r?.success ? r.data : { error: r?.error });
1547
+ }
1548
+ if (url.pathname === "/find" && req.method === "POST") {
1549
+ // Find. `get_entries` is the whole tree, so this searches abandoned branches and
1550
+ // pre-compaction history too, which is exactly what you want when looking for something
1551
+ // you remember saying.
1552
+ const { q } = await body(req);
1553
+ const needle = String(q ?? "").toLowerCase();
1554
+ if (needle.length < 2) return void json(res, { hits: [] });
1555
+ const r = await cachedEntries();
1556
+ const hits = [];
1557
+ for (const e of r?.success ? (r.data?.entries ?? []) : []) {
1558
+ const m = e?.message;
1559
+ if (!m) continue;
1560
+ const text = typeof m.content === "string" ? m.content : (m.content ?? []).map((c) => c.text ?? c.thinking ?? "").join(" ");
1561
+ const at = text.toLowerCase().indexOf(needle);
1562
+ if (at === -1) continue;
1563
+ hits.push({ role: m.role, when: e.timestamp, excerpt: text.slice(Math.max(0, at - 60), at + 140) });
1564
+ if (hits.length >= 50) break;
1565
+ }
1566
+ return void json(res, { hits });
1567
+ }
1568
+ if (url.pathname === "/undo" && req.method === "POST") {
1569
+ // Undo: fork from the last user message on this branch. pi hands back its text, so
1570
+ // the prompt returns to the composer rather than being lost.
1571
+ const r = await cachedEntries();
1572
+ if (!r?.success) return void json(res, { success: false, error: r?.error ?? "cannot read the session" }, 500);
1573
+ const entries = r.data?.entries ?? [];
1574
+ const last = [...entries].reverse().find((e) => e?.type === "message" && e.message?.role === "user");
1575
+ if (!last) return void json(res, { success: false, error: "nothing to undo" }, 400);
1576
+ const forked = await rpc({ type: "fork", entryId: last.id }, 30_000);
1577
+ entriesCache = { at: 0, value: undefined }; // the branch moved under us
1578
+ return void json(res, forked);
1579
+ }
1580
+ if (url.pathname === "/share" && req.method === "POST") {
1581
+ // pi-loops' command does the rendering, the redaction and the confirmation; its dialog
1582
+ // reaches the browser as an extension_ui_request like every other approval. It blocks on
1583
+ // that dialog, so this must not sit on a short timeout waiting for an answer.
1584
+ const { public: isPublic } = await body(req);
1585
+ if (!commandList.some((c) => c.name === "session-share")) return void json(res, { success: false, error: "pi-loops is not loaded in this session" }, 400);
1586
+ return void json(res, await rpc({ type: "prompt", message: isPublic ? "/session-share --public" : "/session-share" }, 600_000));
1587
+ }
1588
+ if (url.pathname === "/export" && req.method === "POST") {
1589
+ // Save. pi writes the HTML itself; this only reports where it landed.
1590
+ return void json(res, await rpc({ type: "export_html" }, 60_000));
1591
+ }
1592
+ if (url.pathname === "/ui-response" && req.method === "POST") {
1593
+ // Named fields only: spreading the body would let the caller set `type` and write any
1594
+ // command it liked to pi's stdin through a route that is meant to answer a dialog.
1595
+ const { id, value, confirmed, cancelled } = await body(req);
1596
+ if (typeof id !== "string" || !id) return void json(res, { error: "id required" }, 400);
1597
+ live.pendingAsks.delete(id);
1598
+ const answer = { type: "extension_ui_response", id };
1599
+ if (cancelled === true) answer.cancelled = true;
1600
+ else if (typeof confirmed === "boolean") answer.confirmed = confirmed;
1601
+ else if (value !== undefined) answer.value = String(value);
1602
+ writeToPi(`${JSON.stringify(answer)}\n`);
1603
+ return void json(res, { ok: true });
1604
+ }
1605
+ if (url.pathname === "/rpc" && req.method === "POST") {
1606
+ // Escape hatch: anything in pi's protocol this UI has not grown a button for yet.
1607
+ const command = await body(req);
1608
+ const type = String(command?.type ?? "");
1609
+ if (OWN_ROUTES.has(type)) return void json(res, { success: false, error: `${type} has a route of its own` }, 400);
1610
+ if (NO_ROUTE_AT_ALL.has(type)) return void json(res, { success: false, error: `${type} would replace the session the attached browsers are watching; switch sessions with /switch_session` }, 400);
1611
+ return void json(res, await rpc(command));
1612
+ }
1613
+ json(res, { error: "not found" }, 404);
1614
+ } catch (err) {
1615
+ json(res, { error: String(err?.message ?? err) }, 500);
1616
+ }
1617
+ });
1618
+
1619
+ /**
1620
+ * The port is fixed on purpose — one address, bookmarkable — and the price of a fixed port is that
1621
+ * you can collide with yourself. Running `pi-loops` twice is not a mistake worth an error message:
1622
+ * the second one hands you the window the first one is already serving, and gets out of the way
1623
+ * rather than leaving a second pi running behind a server that never bound.
1624
+ */
1625
+ server.on("error", (err) => {
1626
+ if (err?.code !== "EADDRINUSE") {
1627
+ console.error(`pi-loops web: cannot listen on port ${PORT}: ${err?.message ?? err}`);
1628
+ return void leave(1);
1629
+ }
1630
+ const there = `http://127.0.0.1:${PORT}/`;
1631
+ // No credentials on this request. Whatever is on that port might not be one of ours, and a
1632
+ // secret sent to find that out has already been sent. An instance of this program answers the
1633
+ // header on its 403 too, so the question can be asked without proving anything.
1634
+ fetch(there)
1635
+ .then((r) => {
1636
+ if (r.headers.get("x-pi-loops-web") !== "1") throw new Error("not ours");
1637
+ console.log(`pi-loops web is already running on ${there} — opening that`);
1638
+ // A browser asked for a URL it already has open answers by bringing that tab forward,
1639
+ // without reloading it — so starting the session again handed you the same page you were
1640
+ // already looking at, however old it was. A different address every time means the tab
1641
+ // is replaced rather than merely focused, which is the difference between "it opened"
1642
+ // and "it opened the version I just installed".
1643
+ if (!flag("no-open") && process.stdout.isTTY) openBrowser(`${there}?opened=${Date.now().toString(36)}`);
1644
+ leave(0);
1645
+ })
1646
+ .catch(() => {
1647
+ console.error(`pi-loops web: port ${PORT} is taken by something else; --port <n> picks another`);
1648
+ leave(1);
1649
+ });
1650
+ });
1651
+
1652
+ /**
1653
+ * How long to wait for pi to finish quitting.
1654
+ *
1655
+ * SIGTERM to pi is not a kill, it is `/quit`: pi tears the session down, pi-loops decides whether to
1656
+ * hand the clock to a headless host, and that hand-off waits for the host to record itself before it
1657
+ * says so. Fifteen seconds is that path with room to spare — and the line it prints is the one thing
1658
+ * a person wants to see after pressing Ctrl-C, so exiting before it is written would be exiting
1659
+ * before the news.
1660
+ */
1661
+ const PI_QUIT_WAIT_MS = 15_000;
1662
+ let leaving = false;
1663
+ /** Set when pi's own word on the shutdown reached the terminal, so nothing repeats it in other words. */
1664
+ let relayedNote = false;
1665
+
1666
+ /**
1667
+ * pi's `ui.notify` while it is quitting, printed rather than only broadcast.
1668
+ *
1669
+ * pi-loops says what it did with the clock through `ctx.ui.notify`, and `ctx.hasUI` is true in rpc
1670
+ * mode: pi binds a real UI context there, whose `notify` is an `extension_ui_request` event on
1671
+ * stdout. So the hand-off line went to the page — and the page's server is this process, on its way
1672
+ * out, with no browser left to draw it. The person who pressed Ctrl-C got `pi exited (143)` and no
1673
+ * word that their loops were still running somewhere.
1674
+ *
1675
+ * Only while leaving, and only for `notify`: a notification during a live session belongs to the
1676
+ * window, and a terminal echoing every one of them would be noise.
1677
+ */
1678
+ function relayNote(msg) {
1679
+ if (!leaving || msg?.type !== "extension_ui_request" || msg.method !== "notify") return;
1680
+ const text = String(msg.message ?? "").trim();
1681
+ if (!text) return;
1682
+ relayedNote = true;
1683
+ console.error(text);
1684
+ }
1685
+
1686
+ /**
1687
+ * Is `pid` a process that exists? `src/lock.ts` owns this test and this file cannot import it — the
1688
+ * front end runs as plain `node src/web.mjs`, with no TypeScript loader — so it is repeated here.
1689
+ * EPERM means alive and owned by somebody else.
1690
+ */
1691
+ function pidRunning(pid) {
1692
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1693
+ try {
1694
+ process.kill(pid, 0);
1695
+ return true;
1696
+ } catch (err) {
1697
+ return err?.code === "EPERM";
1698
+ }
1699
+ }
1700
+
1701
+ /**
1702
+ * The background host, if one is running — `host.json` and a live pid, which is how
1703
+ * `pi-loops host status` answers the same question (`liveHost` in `src/host-control.ts`).
1704
+ *
1705
+ * That one also checks the record against the last boot and the process's command line, to catch a
1706
+ * recycled pid after a power loss. Not repeated: this is a line of news printed once on the way out,
1707
+ * and the record was written seconds ago by the pi that just quit.
1708
+ */
1709
+ function liveHostPid() {
1710
+ const rec = readJson(path.join(LOOPS_DIR, "host.json"), undefined);
1711
+ if (!rec || rec.host !== HOST || !pidRunning(rec.pid)) return undefined;
1712
+ return rec.pid;
1713
+ }
1714
+
1715
+ /**
1716
+ * Is there anything a headless host would exist for? The rule `shouldHandOff` applies: an enabled
1717
+ * *stateful* loop or an enabled rule belonging to this machine. A plain loop injects its prompt into
1718
+ * an open chat, so it is not work a host can carry.
1719
+ *
1720
+ * MCP push sources are the third kind and are not counted: they live in `mcp.toml`, which this file
1721
+ * has no parser for. Missing one costs a line nobody sees; guessing would cost telling somebody
1722
+ * their automation is dead when it is not.
1723
+ */
1724
+ function automationToKeepRunning() {
1725
+ const ours = (x) => x.enabled && (!x.host || x.host === HOST);
1726
+ const jobs = (readJson(path.join(LOOPS_DIR, "jobs.json"), { jobs: [] }).jobs ?? []).filter((j) => ours(j) && j.stateful);
1727
+ const rules = (readJson(path.join(LOOPS_DIR, "triggers.json"), { rules: [] }).rules ?? []).filter(ours);
1728
+ return jobs.length + rules.length;
1729
+ }
1730
+
1731
+ /**
1732
+ * Another interactive pi still open on this machine — which is a reason for there to be no host, and
1733
+ * not a failure: that window keeps the clock, and the host starts when the last one goes.
1734
+ *
1735
+ * `src/presence.ts` writes one small file per live process and prunes on the same two tests used
1736
+ * here, a fresh heartbeat and a live pid. Our own pi is excluded by pid: on the timeout path it is
1737
+ * still alive, and its own entry would read as somebody else's window.
1738
+ */
1739
+ function otherPiOpen() {
1740
+ let names;
1741
+ try {
1742
+ names = fs.readdirSync(path.join(LOOPS_DIR, "presence"));
1743
+ } catch {
1744
+ return false; // no presence directory means nothing has run here
1745
+ }
1746
+ for (const name of names) {
1747
+ if (!name.endsWith(".json")) continue;
1748
+ const e = readJson(path.join(LOOPS_DIR, "presence", name), undefined);
1749
+ if (!e || e.host !== HOST || e.kind === "host" || e.pid === pi.pid) continue;
1750
+ if (Date.now() - Date.parse(e.heartbeatAt ?? "") > 90_000) continue; // PRESENCE_STALE_MS
1751
+ if (pidRunning(e.pid)) return true;
1752
+ }
1753
+ return false;
1754
+ }
1755
+
1756
+ /**
1757
+ * `[host] auto = false` in `config.toml`, read by hand.
1758
+ *
1759
+ * `src/config.ts` owns this setting and is out of reach here, so this is that one key and nothing
1760
+ * else: the table it sits under, and a literal `false`. Anything this does not recognise reads as
1761
+ * the default, which is on — the same direction the real parser errs in, and the safe one, because
1762
+ * it leads to the line that names a log file rather than to a claim about a setting.
1763
+ */
1764
+ function hostAutoOff() {
1765
+ let text;
1766
+ try {
1767
+ text = fs.readFileSync(path.join(LOOPS_DIR, "config.toml"), "utf8");
1768
+ } catch {
1769
+ return false; // no file, so the default: the host starts
1770
+ }
1771
+ let table = "";
1772
+ for (const raw of text.split("\n")) {
1773
+ const line = raw.replace(/#.*$/, "").trim();
1774
+ const header = /^\[([^\]]+)\]$/.exec(line);
1775
+ if (header) table = header[1].trim();
1776
+ else if (table === "host" && /^auto\s*=\s*false\b/.test(line)) return true;
1777
+ }
1778
+ return false;
1779
+ }
1780
+
1781
+ /**
1782
+ * What happened to the automation, in one line, for the person who pressed Ctrl-C.
1783
+ *
1784
+ * The hand-off is the product's core promise and it happens in a process that is going away: after
1785
+ * `pi exited` there is nothing on the terminal to say whether the loops are still running, or where
1786
+ * to look for them. pi's own wording is preferred when it arrives (`relayNote`); nothing guarantees
1787
+ * it does — rpc mode does not flush stdout on SIGTERM — so the loops directory is read instead.
1788
+ *
1789
+ * Silence is a real outcome: with nothing enabled, or another window still holding the clock, there
1790
+ * is no news, and a front end that announces a non-event teaches people to stop reading it.
1791
+ */
1792
+ function reportAutomation() {
1793
+ if (relayedNote) return;
1794
+ try {
1795
+ const pid = liveHostPid();
1796
+ if (pid) return void console.error(`automation handed to a background host (pid ${pid}); pi-loops host status | stop`);
1797
+ if (!automationToKeepRunning() || otherPiOpen()) return;
1798
+ if (hostAutoOff()) return void console.error("[host] auto = false: automation stops with this pi");
1799
+ console.error(`no background host started; automation is not running — see ${path.join(LOOPS_DIR, "host.log")}`);
1800
+ } catch (err) {
1801
+ // Reading the directory must not hold the exit: say what went wrong and go.
1802
+ console.error(`pi-loops web: could not tell whether automation is still running: ${err?.message ?? err}`);
1803
+ }
1804
+ }
1805
+
1806
+ /**
1807
+ * Take the pi we started with us: it has no server in front of it and nobody to talk to.
1808
+ *
1809
+ * SIGTERM and then wait: pi's shutdown is where the clock is handed to a headless host, and a front
1810
+ * end that did not wait would exit before that happened and before anything could be said about it.
1811
+ * Bounded, because a pi wedged in shutdown must not leave a window that will not close.
1812
+ */
1813
+ function leave(code) {
1814
+ if (leaving) return;
1815
+ leaving = true;
1816
+ if (!piAlive) process.exit(code);
1817
+ const timer = setTimeout(() => {
1818
+ console.error(`pi did not finish quitting in ${PI_QUIT_WAIT_MS / 1000}s; leaving anyway`);
1819
+ reportAutomation();
1820
+ process.exit(code);
1821
+ }, PI_QUIT_WAIT_MS);
1822
+ pi.once("exit", () => {
1823
+ clearTimeout(timer);
1824
+ // A moment for the last of pi's stdout before deciding what to say. The note is an event on
1825
+ // that pipe and `exit` fires while it may still be unread, so reporting straight away would
1826
+ // always fall back to reading the directory and never use pi's own wording. Bounded and
1827
+ // short: `close` — the process gone *and* its pipes drained — normally arrives in a tick.
1828
+ const done = () => {
1829
+ reportAutomation();
1830
+ process.exit(code);
1831
+ };
1832
+ const drain = setTimeout(done, 300);
1833
+ pi.once("close", () => {
1834
+ clearTimeout(drain);
1835
+ done();
1836
+ });
1837
+ });
1838
+ try {
1839
+ pi.kill("SIGTERM");
1840
+ } catch {
1841
+ // already gone, which is the outcome we wanted — its `exit` has fired or is about to
1842
+ }
1843
+ }
1844
+ server.listen(PORT, HOST_BIND, async () => {
1845
+ // The port actually bound, which is not the one asked for when that was 0.
1846
+ const port = server.address()?.port ?? PORT;
1847
+ console.log(`pi-loops web on http://${LOOPBACK ? "127.0.0.1" : HOST_BIND === "0.0.0.0" ? "127.0.0.1" : HOST_BIND}:${port}/`);
1848
+ if (!LOOPBACK) {
1849
+ for (const a of lanAddresses(port)) console.log(` on this network: ${a.url} (${a.via})`);
1850
+ // Worth saying once, out loud: this is plain http, and the cookie it hands out is a token
1851
+ // that outlives the process. `tailscale serve` gets the same phone in over TLS and leaves
1852
+ // this server on loopback, which is why the docs lead with it.
1853
+ console.log(" this is unencrypted; on a network you do not own, prefer: tailscale serve --bg " + port);
1854
+ }
1855
+ // The code is what another device actually uses: the door page asks for it, so nobody types 32
1856
+ // hex characters on a screen keyboard. Printed on loopback too, because `tailscale serve` puts a
1857
+ // phone in front of a loopback-bound server and that phone still has to get in once. On a
1858
+ // terminal only — it stays live until someone pairs, so a log file holding it is a log file
1859
+ // holding the way in. A browser that is already signed in can mint another through /pair.
1860
+ if (process.stdout.isTTY) console.log(` pairing code for another device: ${newPairCode()}`);
1861
+ if (NO_AUTH) console.log(" --no-auth: anything that can reach this port can drive this session");
1862
+ // Only needed by a browser that has not been here before; after one visit the cookie is enough.
1863
+ // On a terminal only: this token outlives the process now, and stdout redirected to a file is a
1864
+ // credential written to a file, where a per-launch random one used to expire on its own.
1865
+ else if (process.stdout.isTTY) console.log(` first visit from another browser: http://127.0.0.1:${port}/?token=${TOKEN}`);
1866
+ await refreshCatalog();
1867
+ await primeRuntime();
1868
+ if (!flag("no-open") && process.stdout.isTTY) {
1869
+ openKeyExpires = Date.now() + 60_000;
1870
+ openBrowser(`http://127.0.0.1:${port}/?open=${openKey}`);
1871
+ }
1872
+ });
1873
+
1874
+ /**
1875
+ * The addresses another device on your networks could actually use.
1876
+ *
1877
+ * `internal` in Node means loopback and nothing else, so the raw list also contains every virtual
1878
+ * bridge this machine happens to run — docker0, libvirt, VirtualBox. Those are addresses that
1879
+ * belong to something else on the phone's network, and sending a live pairing code to one hands a
1880
+ * secret to a stranger. Named ones are dropped; the rest are ordered by how likely they are to be
1881
+ * the one that works, tailnet first.
1882
+ *
1883
+ * Each comes back as the whole address to open and the interface it belongs to — `{ url, via }`,
1884
+ * the shape the page and the pairing dialog both already used, under the names they used. Handing
1885
+ * back a bare address and an interface name left two call sites assembling the same URL and calling
1886
+ * the same field two different things.
1887
+ */
1888
+ const VIRTUAL = /^(docker|virbr|br-|veth|vmnet|vboxnet|lxcbr|podman|cni|flannel|kube)/i;
1889
+
1890
+ function lanAddresses(port) {
1891
+ const out = [];
1892
+ for (const [name, list] of Object.entries(os.networkInterfaces())) {
1893
+ if (VIRTUAL.test(name)) continue;
1894
+ for (const ni of list ?? []) {
1895
+ if (ni.family !== "IPv4" || ni.internal) continue;
1896
+ const [a, b] = ni.address.split(".").map(Number);
1897
+ // 100.64/10 is the range Tailscale hands out: if there is one, it is the answer.
1898
+ const rank = a === 100 && b >= 64 && b <= 127 ? 0 : a === 192 && b === 168 ? 1 : a === 10 ? 2 : 3;
1899
+ out.push({ url: `http://${ni.address}:${port}/`, via: name, rank });
1900
+ }
1901
+ }
1902
+ return out.sort((x, y) => x.rank - y.rank).map(({ url, via }) => ({ url, via }));
1903
+ }
1904
+
1905
+ function openBrowser(url) {
1906
+ const cmd = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
1907
+ try {
1908
+ spawn(cmd[0], cmd[1], { stdio: "ignore", detached: true }).unref();
1909
+ } catch {
1910
+ /* a URL on stdout is enough */
1911
+ }
1912
+ }
1913
+
1914
+ for (const sig of ["SIGINT", "SIGTERM"]) {
1915
+ // `leave` is the one place that decides what going away means; a second copy of it here is a
1916
+ // second place to forget something. Ctrl-C in the terminal that started the session arrives here
1917
+ // as SIGINT and goes down that one path — pi is detached, so this handler is the only thing that
1918
+ // ends it, and it ends it the way `/quit` does.
1919
+ process.on(sig, () => leave(0));
1920
+ }
1921
+
1922
+ /**
1923
+ * What an unauthorised browser gets. Deliberately not the token: this is the request that has not
1924
+ * proved anything. Telling you where it lives costs an attacker nothing they did not already have
1925
+ * — reading the file needs your account — and saves you a search.
1926
+ */
1927
+ const DOOR = `<!doctype html><meta charset="utf-8"><title>pi-loops</title>
1928
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1929
+ <meta name="color-scheme" content="light dark">
1930
+ <body style="font:15px/1.6 system-ui,sans-serif;max-width:34rem;margin:12vh auto;padding:0 1.5rem;background:Canvas;color:CanvasText">
1931
+ <h2 style="font-weight:600">This browser has not been here before.</h2>
1932
+ <p>Start the session from a terminal on this machine and it will open a window that works from
1933
+ then on:</p>
1934
+ <pre style="background:#8881;padding:.7rem 1rem;border-radius:6px;overflow:auto">pi-loops</pre>
1935
+ <p style="opacity:.7">On a browser that is already signed in, press <b>add device</b> — it shows a
1936
+ QR to point this camera at, and the same six digits to type if you would rather. Either way, this
1937
+ device stays signed in afterwards.</p>
1938
+ <form method="get" style="display:flex;gap:.5rem">
1939
+ <input name="pair" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" maxlength="6"
1940
+ placeholder="000000" style="font:inherit;letter-spacing:.3em;padding:.55rem .7rem;flex:1;min-width:0;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit">
1941
+ <button style="font:inherit;padding:.55rem 1rem;border:1px solid #8886;border-radius:6px;background:transparent;color:inherit">enter</button>
1942
+ </form>`;
1943
+
1944
+ /**
1945
+ * What you chose last time, so the next session starts there.
1946
+ *
1947
+ * A session opens on pi's default model, which is why choosing the same one every morning was the
1948
+ * first thing this front end asked of anybody. It lives next to the loops rather than in the
1949
+ * package, so it survives an upgrade, and the launcher is what applies it — the terminal window
1950
+ * gets the same memory as this one.
1951
+ */
1952
+ function rememberPref(key, setting) {
1953
+ const file = path.join(LOOPS_DIR, "ui.json");
1954
+ let doc = {};
1955
+ try {
1956
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) ?? {};
1957
+ } catch {
1958
+ // no file yet, or one that is not readable as JSON: this write replaces it
1959
+ }
1960
+ if (doc[key] === setting) return;
1961
+ doc[key] = setting;
1962
+ try {
1963
+ fs.mkdirSync(LOOPS_DIR, { recursive: true });
1964
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
1965
+ } catch (err) {
1966
+ console.error(`pi-loops web: could not remember ${key}: ${err?.message ?? err}`);
1967
+ }
1968
+ }
1969
+
1970
+ /**
1971
+ * Where a preview may read from.
1972
+ *
1973
+ * The session's own directory first — that is where the work is. But an agent asked to make
1974
+ * something for a person puts it where a person keeps things, which is usually the home directory,
1975
+ * and refusing to show you your own `~/Downloads/report.html` because the session started in
1976
+ * `~/code` is a rule serving nobody. The home directory is therefore also a root, minus every dot
1977
+ * segment and minus the directory holding the token. Nothing here is a capability the session did
1978
+ * not already have: it can read those files, and print them.
1979
+ */
1980
+ function previewRoot(file) {
1981
+ if (isUnder(file, sessionCwd)) return sessionCwd;
1982
+ const home = os.homedir();
1983
+ // A container with HOME=/ would make the whole disk a preview root, which is not a home
1984
+ // directory in any sense this rule means.
1985
+ if (home && home !== "/" && isUnder(file, home)) return home;
1986
+ return undefined;
1987
+ }
1988
+
1989
+ /** The path the filesystem actually means: symlinks followed as far as anything exists. */
1990
+ function realOf(file) {
1991
+ try {
1992
+ return fs.realpathSync(file);
1993
+ } catch {
1994
+ try {
1995
+ return path.join(fs.realpathSync(path.dirname(file)), path.basename(file));
1996
+ } catch {
1997
+ return path.resolve(file);
1998
+ }
1999
+ }
2000
+ }
2001
+
2002
+ /** What is worth *looking* at, as opposed to reading: the list that applies outside the project. */
2003
+ const VISUAL_TYPES = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".pdf", ".html", ".htm"]);
2004
+
2005
+ /** What a preview will serve, and nothing else: an extension not on this list is not previewed. */
2006
+ const PREVIEW_TYPES = {
2007
+ ".png": "image/png",
2008
+ ".jpg": "image/jpeg",
2009
+ ".jpeg": "image/jpeg",
2010
+ ".gif": "image/gif",
2011
+ ".webp": "image/webp",
2012
+ ".avif": "image/avif",
2013
+ ".svg": "image/svg+xml",
2014
+ ".pdf": "application/pdf",
2015
+ ".html": "text/html; charset=utf-8",
2016
+ ".htm": "text/html; charset=utf-8",
2017
+ ".txt": "text/plain; charset=utf-8",
2018
+ ".md": "text/plain; charset=utf-8",
2019
+ ".csv": "text/plain; charset=utf-8",
2020
+ ".json": "application/json; charset=utf-8",
2021
+ };
2022
+ const PREVIEW_MAX_BYTES = 25 * 1024 * 1024;
2023
+ /** HTML written into the conversation instead of into a file, kept just long enough to look at. */
2024
+ const previews = new Map();
2025
+ /** Eight, because a preview is a tab you have open right now, and nobody has more of those than that. */
2026
+ const PREVIEW_KEEP = 8;
2027
+ /** Half an hour: long enough that a tab left open over lunch still reloads, short enough to forget. */
2028
+ const PREVIEW_TTL_MS = 30 * 60_000;
2029
+
2030
+ /** Maskable, so Android can crop it to whatever shape the launcher uses without eating the mark. */
2031
+ const ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2032
+ <rect width="512" height="512" rx="96" fill="#111"/>
2033
+ <g fill="none" stroke="#4ade80" stroke-width="34" stroke-linecap="round">
2034
+ <path d="M150 190h212"/><path d="M212 190v148"/><path d="M300 190v112a36 36 0 0 0 62 24"/>
2035
+ </g></svg>`;
2036
+
2037
+ /* ------------------------------------------------------------------ the page */
2038
+
2039
+ const PAGE = String.raw`<!doctype html>
2040
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
2041
+ <meta name="theme-color" content="#111">
2042
+ <meta name="mobile-web-app-capable" content="yes">
2043
+ <meta name="apple-mobile-web-app-capable" content="yes">
2044
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
2045
+ <link rel="manifest" href="/manifest.webmanifest">
2046
+ <script>
2047
+ // Before the first paint, which is the whole reason it is up here in a tag of its own rather
2048
+ // than with the rest of the script: applied any later, the page renders light and then blinks.
2049
+ try { var t = localStorage.getItem("theme"); if (t === "dark" || t === "light") document.documentElement.dataset.theme = t; } catch (e) {}
2050
+ </script>
2051
+ <title>pi web</title>
2052
+ <style>
2053
+ /* Named colours rather than translucent greys over whatever the browser paints. Every surface is
2054
+ stated, so the page looks the same on a machine whose default background is not white — and so
2055
+ dark is a design rather than an inversion of light. */
2056
+ :root{
2057
+ color-scheme:light;
2058
+ --bg:#f7f7f7;--panel:#fff;--side:#fafafa;--field:#fff;--soft:#eee;
2059
+ --ink:#111;--muted:#666;--faint:#999;--line:#dcdcdc;--line-strong:#bbb;
2060
+ --accent:#2f7d5d;--warn:#a4620f;--bad:#b3261e;--shadow:rgba(0,0,0,.14);
2061
+ /* Two fonts, because there are two kinds of text here. Prose — what the model wrote, what you
2062
+ wrote — reads better proportional, Chinese especially. Anything that came from a terminal or
2063
+ has columns in it stays monospace, and that stack names CJK faces whose characters are exactly
2064
+ twice the ASCII advance: without one, a box a terminal drew comes apart on the first Chinese
2065
+ character. */
2066
+ --font-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
2067
+ --font-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Sarasa Mono SC","Noto Sans Mono CJK SC","Source Han Mono SC",monospace;
2068
+ /* One column of text, centred, however wide the window is: a 2000px line is not readable. The
2069
+ composer uses the same figure so the two line up. */
2070
+ --pad:max(16px,calc((100% - 58rem) / 2));
2071
+ }
2072
+ @media (prefers-color-scheme:dark){html:not([data-theme=light]){
2073
+ color-scheme:dark;
2074
+ --bg:#0a0a0a;--panel:#0f0f0f;--side:#0c0c0c;--field:#161616;--soft:#1c1c1c;
2075
+ --ink:#f2f2f2;--muted:#a0a0a0;--faint:#6a6a6a;--line:#282828;--line-strong:#454545;
2076
+ --accent:#5fbf93;--warn:#d99a4e;--bad:#e26a62;--shadow:rgba(0,0,0,.5);
2077
+ }}
2078
+ html[data-theme=dark]{
2079
+ color-scheme:dark;
2080
+ --bg:#0a0a0a;--panel:#0f0f0f;--side:#0c0c0c;--field:#161616;--soft:#1c1c1c;
2081
+ --ink:#f2f2f2;--muted:#a0a0a0;--faint:#6a6a6a;--line:#282828;--line-strong:#454545;
2082
+ --accent:#5fbf93;--warn:#d99a4e;--bad:#e26a62;--shadow:rgba(0,0,0,.5);
2083
+ }
2084
+ *{box-sizing:border-box}
2085
+ /* dvh, not vh: a phone's address bar slides away and 100vh keeps counting the space it used to
2086
+ occupy, so the composer sits below the fold exactly when you are trying to type into it. */
2087
+ body{margin:0;height:100vh;height:100dvh;display:flex;background:var(--bg);color:var(--ink);font:15px/1.62 var(--font-ui);-webkit-font-smoothing:antialiased}
2088
+ main{flex:1;display:flex;flex-direction:column;min-width:0;background:var(--panel)}
2089
+
2090
+ header{display:flex;gap:10px;align-items:center;padding:9px 14px;border-bottom:1px solid var(--line);flex-wrap:wrap;min-height:52px}
2091
+ header b{font-weight:650}
2092
+ header .cwd{color:var(--muted);font-size:12px;border:1px solid var(--line);border-radius:999px;padding:1px 9px;background:var(--field);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:min(34vw,26rem)}
2093
+ header .grow{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:flex-end}
2094
+ select,button,textarea,input{font:inherit;color:inherit;background:transparent;border:1px solid var(--line);border-radius:6px;padding:5px 9px}
2095
+ /* A select's dropdown is drawn by the platform, not by the page. With a transparent background the
2096
+ list is painted on system white while the options keep the page's text colour — light-on-white in
2097
+ a dark theme. Canvas and CanvasText follow color-scheme, so both halves agree in both themes. */
2098
+ select,option{background:Canvas;color:CanvasText}
2099
+ select:focus,textarea:focus,input:focus{outline:none;border-color:var(--line-strong)}
2100
+ button{cursor:pointer;color:var(--muted)}
2101
+ button:hover{color:var(--ink);background:var(--soft)}
2102
+ button.primary{border-color:var(--accent);color:var(--ink)}
2103
+ .badge{border:1px solid var(--line);border-radius:999px;padding:1px 9px;font-size:12px;color:var(--muted);white-space:nowrap}
2104
+
2105
+ /* Not a toast that fades: this is the difference between the window working and not, and it stays
2106
+ until it is dealt with. Clicking it does the one thing it asks for. */
2107
+ #stale{display:block;width:100%;border:0;border-bottom:1px solid var(--line);border-radius:0;
2108
+ background:var(--warn);color:#fff;padding:7px var(--pad);text-align:left;font-size:13px}
2109
+ #stale:hover{background:var(--warn);color:#fff;filter:brightness(1.08)}
2110
+ #stale[hidden]{display:none}
2111
+ #findbar{display:flex;gap:8px;align-items:center;padding:7px var(--pad);border-bottom:1px solid var(--line);background:var(--side)}
2112
+ /* The UA rule for [hidden] loses to any author rule that sets display, so this has to say it. */
2113
+ #findbar[hidden]{display:none}
2114
+ #findbar input{flex:1}
2115
+ .hit{border-left:2px solid var(--line-strong);padding-left:10px;font-size:13px;color:var(--muted);font-family:var(--font-mono)}
2116
+
2117
+ #feed{flex:1;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain;padding:20px var(--pad) 24px;display:flex;flex-direction:column;gap:14px;overflow-wrap:anywhere}
2118
+ /* The feed is a column flexbox with a definite height, so its children shrink by default: a tall
2119
+ block — an expanded tool, a long reply — was squeezed to fit instead of making the feed scroll,
2120
+ and its last line was cut in half. */
2121
+ #feed>*{flex:0 0 auto}
2122
+ .row{position:relative;min-width:0;max-width:100%}
2123
+ .role{display:flex;gap:8px;align-items:center;color:var(--faint);font-size:11px;letter-spacing:.03em;text-transform:uppercase;margin-bottom:3px}
2124
+ /* The copy button is not part of the conversation, so it waits until you are on the block. A finger
2125
+ has no hover, so on a touch screen it is simply always there. */
2126
+ .role button{border:0;padding:0 5px;font-size:11px;background:none;min-height:0;color:var(--faint);opacity:0;text-transform:none}
2127
+ .row:hover .role button,.row:focus-within .role button{opacity:1}
2128
+ .role button:hover{color:var(--ink);background:none}
2129
+ @media (pointer:coarse){.role button{opacity:.7}}
2130
+
2131
+ /* You, on the right, in a bubble; the model, full width, as prose. The shape says who is speaking
2132
+ before a word of it is read, which is most of what makes a long session scannable. */
2133
+ .row.user{align-self:flex-end;max-width:min(84%,40rem)}
2134
+ .row.user>span{display:block;white-space:pre-wrap;background:var(--soft);border-radius:16px 16px 5px 16px;padding:9px 14px}
2135
+ .row.user .role{justify-content:flex-end}
2136
+ /* Status lines are context, not conversation: quiet, small, monospace. */
2137
+ .notice{color:var(--muted);font-family:var(--font-mono);font-size:12.5px;white-space:pre-wrap}
2138
+ .err{color:var(--ink);border-left:2px solid var(--bad);padding-left:10px;font-family:var(--font-mono);font-size:12.5px;white-space:pre-wrap}
2139
+ .row>span{white-space:pre-wrap;word-break:break-word}
2140
+ /* The markdown body is block-level HTML; pre-wrap there turns the whitespace between tags into
2141
+ blank lines. It is set on the span itself, so the selector has to be more specific than .row>span. */
2142
+ .row>span.md,.md{white-space:normal}
2143
+
2144
+ /* A tool call and what it returned are one thing, and it is closed. A tool that prints two hundred
2145
+ lines should not push the conversation off the screen to do it. */
2146
+ /* The run of them is the block; each one inside is a line. */
2147
+ /* Closed, it is a line of text — a box drawn around one line is itself the noise this is about.
2148
+ The box appears when you open it, because then there is something in it. */
2149
+ details.work{border:1px solid transparent;border-radius:10px;overflow:hidden}
2150
+ details.work[open]{border-color:var(--line);background:var(--side)}
2151
+ details.work:not([open])>summary{padding-left:0;padding-right:0}
2152
+ details.work>summary{cursor:pointer;display:flex;align-items:center;gap:8px;padding:7px 11px;font-family:var(--font-mono);font-size:12px;color:var(--muted);list-style:none}
2153
+ details.work>summary::-webkit-details-marker{display:none}
2154
+ details.work>summary::before{content:"▸";color:var(--faint);flex:0 0 auto}
2155
+ details.work[open]>summary::before{content:"▾"}
2156
+ details.work>summary>span.what{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2157
+ details.work>summary:hover{color:var(--ink)}
2158
+ details.work>details.tool,details.work>details.think,details.work>.note{border:0;border-top:1px solid var(--line);border-radius:0;background:transparent;padding:6px 11px}
2159
+ details.work>.note{color:var(--muted);font-family:var(--font-mono);font-size:12px;white-space:pre-wrap}
2160
+ details.tool{border:1px solid var(--line);border-radius:10px;background:var(--side);overflow:hidden}
2161
+ details.tool>summary{cursor:pointer;display:flex;align-items:center;gap:8px;padding:7px 11px;font-family:var(--font-mono);font-size:12px;color:var(--muted);list-style:none}
2162
+ details.tool>summary::-webkit-details-marker{display:none}
2163
+ details.tool>summary::before{content:"▸";color:var(--faint);flex:0 0 auto}
2164
+ details.tool[open]>summary::before{content:"▾"}
2165
+ details.tool>summary>span.what{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2166
+ details.tool>summary>span.state{color:var(--faint);font-size:11px}
2167
+ details.tool>summary>button{border:0;background:none;color:var(--faint);padding:0 5px;font-size:11px;min-height:0;opacity:0}
2168
+ details.tool:hover>summary>button,details.tool:focus-within>summary>button{opacity:1}
2169
+ @media (pointer:coarse){details.tool>summary>button{opacity:.7}}
2170
+ details.tool>summary:hover{color:var(--ink)}
2171
+ details.tool.err{border-color:var(--bad);padding-left:0}
2172
+ details.tool pre{margin:0;padding:9px 11px;border-top:1px solid var(--line);max-height:22em;overflow:auto;color:var(--muted)}
2173
+ details.think{color:var(--muted)}
2174
+ details.think:not([open])>summary{padding-left:0}
2175
+ details.think>summary{cursor:pointer;font-size:12px;color:var(--faint);list-style:none}
2176
+ details.think>summary::-webkit-details-marker{display:none}
2177
+ details.think>summary::before{content:"▸ ";color:var(--faint)}
2178
+ details.think>summary .peek{color:var(--faint);font-style:italic}
2179
+ details.think[open]>summary .peek{display:none}
2180
+ details.think[open]>summary::before{content:"▾ "}
2181
+ details.think pre{border-left:2px solid var(--line);padding-left:10px;font-style:italic;font-size:13px}
2182
+ pre{margin:4px 0 0;white-space:pre-wrap;word-break:break-word;max-height:22em;overflow:auto;font-family:var(--font-mono);font-size:12.5px;line-height:1.55}
2183
+
2184
+ .empty{margin:auto;max-width:34rem;color:var(--muted);text-align:center}
2185
+ .empty h2{font-size:15px;font-weight:650;color:var(--ink);margin:0 0 6px}
2186
+ .empty p{margin:.4em 0}
2187
+ .empty code{font-family:var(--font-mono);font-size:12.5px;background:var(--soft);border-radius:5px;padding:1px 5px}
2188
+
2189
+ .md>*:first-child{margin-top:0}.md>*:last-child{margin-bottom:0}
2190
+ .md p{margin:0 0 10px}
2191
+ .md h3,.md h4,.md h5,.md h6{margin:14px 0 7px;line-height:1.25;font-weight:650}
2192
+ .md h3{font-size:16px}.md h4{font-size:14.5px}.md h5,.md h6{font-size:13.5px;color:var(--muted)}
2193
+ .md ul,.md ol{margin:0 0 10px 22px;padding:0}.md li{margin:3px 0}
2194
+ .md blockquote{margin:0 0 10px;padding-left:12px;border-left:2px solid var(--line-strong);color:var(--muted)}
2195
+ .md code{border:1px solid var(--line);border-radius:5px;background:var(--side);padding:1px 5px;font-family:var(--font-mono);font-size:.88em;overflow-wrap:anywhere}
2196
+ .md pre.code{border:1px solid var(--line);border-radius:10px;background:var(--side);padding:11px 12px;margin:0 0 10px;position:relative;white-space:pre;overflow:auto;max-height:none}
2197
+ .md pre.code code{border:0;background:none;padding:0;font-size:13px}
2198
+ .md pre.code[data-lang]::before{content:attr(data-lang);position:absolute;top:4px;right:9px;font-size:10px;color:var(--faint)}
2199
+ .md pre.code button.preview{position:absolute;top:2px;right:44px;font-size:11px;padding:1px 8px;min-height:0;background:var(--panel)}
2200
+ .md hr{border:0;border-top:1px solid var(--line);margin:14px 0}
2201
+ .md a{color:var(--ink);text-decoration:underline;text-underline-offset:2px}
2202
+ /* A link to something on disk says so, so it is not mistaken for a link to the web. */
2203
+ .md a.file::before{content:"⇱ ";color:var(--faint);text-decoration:none}
2204
+ /* A path that was written in backticks keeps looking like one, and is a link as well. */
2205
+ .md a.file>code{border-color:var(--line-strong)}
2206
+ .md img{max-width:100%;height:auto;border:1px solid var(--line);border-radius:8px;margin:4px 0;display:block}
2207
+ .md table{border-collapse:collapse;margin:0 0 10px;display:block;overflow-x:auto;max-width:100%;font-size:13.5px}
2208
+ .md th,.md td{border:1px solid var(--line);padding:5px 10px;text-align:left;vertical-align:top}
2209
+ .md th{font-weight:650;background:var(--soft);white-space:nowrap}
2210
+
2211
+ /* Sitting just above the composer, out of the way of the text, and only while there is something
2212
+ below the fold to go to. */
2213
+ #newer{position:absolute;left:50%;transform:translateX(-50%);bottom:calc(100% + 6px);z-index:6;
2214
+ border-radius:999px;padding:4px 12px;font-size:12.5px;background:var(--field);
2215
+ border-color:var(--line-strong);box-shadow:0 4px 14px var(--shadow)}
2216
+ form#composer{display:flex;flex-direction:column;gap:7px;padding:10px var(--pad) calc(12px + env(safe-area-inset-bottom));border-top:1px solid var(--line);background:var(--panel);position:relative}
2217
+ .composer-row{display:flex;gap:8px;align-items:flex-end}
2218
+ textarea{flex:1;resize:none;min-height:46px;max-height:40vh;background:var(--field);border-color:var(--line-strong);border-radius:12px;padding:10px 13px}
2219
+ .hint{font-size:12px;color:var(--faint);display:flex;gap:10px}
2220
+ #thumbs{display:flex;gap:6px;flex-wrap:wrap}
2221
+ #thumbs .thumb{position:relative;line-height:0}
2222
+ #thumbs img{height:46px;border:1px solid var(--line);border-radius:6px}
2223
+ img.shot{max-width:100%;max-height:26em;height:auto;border:1px solid var(--line);border-radius:8px;margin:6px 0 0;display:block}
2224
+ #thumbs .x{position:absolute;top:-6px;right:-6px;width:20px;height:20px;min-height:0;padding:0;border-radius:999px;background:var(--panel);border:1px solid var(--line-strong);color:var(--muted);font-size:12px;line-height:1}
2225
+ #pop{position:absolute;bottom:100%;left:var(--pad);right:var(--pad);max-height:min(15em,32vh);overflow:auto;border:1px solid var(--line-strong);border-radius:8px;background:var(--field);box-shadow:0 10px 30px var(--shadow);display:none;z-index:5}
2226
+ #pop div{padding:8px 11px;cursor:pointer;display:flex;gap:10px;border-bottom:1px solid var(--line)}
2227
+ #pop div:last-child{border-bottom:0}
2228
+ #pop div.sel,#pop div:hover{background:var(--soft)}
2229
+ #pop .h{color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
2230
+
2231
+ aside{width:21rem;border-left:1px solid var(--line);overflow:auto;background:var(--side);padding:0}
2232
+ aside.hidden{display:none}
2233
+ aside>div{padding:14px 15px;border-bottom:1px solid var(--line)}
2234
+ aside h2{font-size:12px;font-weight:700;color:var(--ink);margin:0 0 9px;letter-spacing:0;text-transform:none}
2235
+ .card{border:1px solid var(--line);border-radius:8px;background:var(--field);padding:8px 10px;margin-bottom:7px;font-size:13px}
2236
+ /* The name is the thing you act on — a /cron set <name> --host here takes it — so it is the one
2237
+ thing in this card that must not be abbreviated. It used to be clipped to an ellipsis while the
2238
+ schedule beside it wrapped onto two lines: unreadable and impossible to copy, which is the whole
2239
+ point of showing it. It wraps now and the row wraps with it; the schedule stays on one piece. */
2240
+ .card .t{display:flex;gap:7px;align-items:center;flex-wrap:wrap}
2241
+ .card .t b{font-weight:650;min-width:0;overflow-wrap:anywhere}
2242
+ .card .t .m{white-space:nowrap}
2243
+ .card .m{color:var(--muted);font-size:12px}
2244
+ .card button{padding:2px 9px;font-size:12px;margin-left:auto;min-height:0}
2245
+ .off{opacity:.5}
2246
+ .dot{display:inline-block;width:7px;height:7px;border-radius:99px;margin-right:6px;vertical-align:middle}
2247
+ .up{background:var(--accent)}.down{background:var(--bad)}.idle{background:var(--faint)}
2248
+ /* Counts as a row of figures rather than a sentence: the number is the thing being read, and each
2249
+ one is a target big enough for a finger. */
2250
+ .metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));border:1px solid var(--line);border-radius:8px;overflow:hidden;background:var(--field);margin-top:8px}
2251
+ .metrics>*{min-width:0;padding:8px 9px;border-right:1px solid var(--line);text-align:left;min-height:46px;border-radius:0;border-top:0;border-bottom:0}
2252
+ .metrics>*:last-child{border-right:0}
2253
+ .metrics b{display:block;font-size:16px;line-height:1.2;color:var(--ink)}
2254
+ .metrics span{display:block;color:var(--muted);font-size:11px}
2255
+ button.metric{background:none;border-left:0;cursor:pointer}
2256
+ button.metric:hover{background:var(--soft)}
2257
+ button.count{border:0;padding:0;background:none;font:inherit;min-height:0;text-decoration:underline dotted;text-underline-offset:2px;color:inherit}
2258
+ button.count:hover{background:none;color:var(--ink)}
2259
+
2260
+ dialog{border:1px solid var(--line);border-radius:12px;padding:16px;max-width:42rem;width:min(92vw,42rem);background:var(--panel);color:var(--ink);box-shadow:0 16px 48px var(--shadow)}
2261
+ dialog::backdrop{background:rgba(0,0,0,.4)}
2262
+ dialog h3{margin:0 0 10px;font-size:14px;font-weight:650}
2263
+ dialog pre{background:var(--side);border:1px solid var(--line);padding:9px 10px;border-radius:8px}
2264
+ dialog menu{display:flex;gap:8px;justify-content:flex-end;padding:0;margin:14px 0 0}
2265
+ #pairPick{width:100%;margin-bottom:8px}
2266
+ .qr{display:flex;justify-content:center;padding:6px 0}
2267
+ .qr svg{width:min(62vw,240px);height:auto;background:#fff;padding:8px;border-radius:8px}
2268
+ /* By id, not by a class called "code": markdown fenced blocks are <pre class="code"> and were
2269
+ inheriting this one's letter-spacing, which spread every line of code out across the page. */
2270
+ #pairCode{text-align:center;font-size:32px;letter-spacing:.28em;padding:8px 0 2px;font-variant-numeric:tabular-nums;font-family:var(--font-mono)}
2271
+ #pairWhere{text-align:center;padding-bottom:4px}
2272
+ .detail-row{padding:4px 0;border-bottom:1px solid var(--line);font-size:13px;font-family:var(--font-mono)}
2273
+ .detail-row:last-child{border-bottom:0}
2274
+ /* A session is picked by recognising the conversation, so the first thing said is the line that
2275
+ matters and everything else is a caption under it. */
2276
+ #sessBody{max-height:60vh;overflow:auto}
2277
+ .sess{display:block;width:100%;text-align:left;padding:9px 10px;margin-bottom:6px;border:1px solid var(--line);
2278
+ border-radius:9px;background:var(--side);color:var(--ink);cursor:pointer}
2279
+ .sess:hover{border-color:var(--accent)}
2280
+ /* Two lines of the opening prompt, so a long one does not turn the list into a page. */
2281
+ .sess b{font-weight:600;font-size:13px;line-height:1.35;
2282
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
2283
+ .sess span{display:block;margin-top:3px;font-size:11px;color:var(--muted);font-family:var(--font-mono)}
2284
+ .sess[disabled]{cursor:default;opacity:.75;border-style:dashed}
2285
+ .sess[disabled]:hover{border-color:var(--line)}
2286
+ #detailBody{max-height:60vh;overflow:auto}
2287
+
2288
+ /* ---------------------------------------------------------------- narrow screens */
2289
+ /* The panel is not dropped on a phone, it is put behind a button: what a loop is doing is the
2290
+ reason to open this on a phone at all. It slides over the conversation rather than taking a
2291
+ third of it, the way a drawer does everywhere else. */
2292
+ #drawer,#more{display:none}
2293
+ /* The drawer covers the conversation, so tapping "somewhere else" has to mean the whole of
2294
+ somewhere else — not the 47px strip the drawer happens to leave. It dims what it covers, which
2295
+ is also how you know the panel is on top of the conversation rather than beside it. */
2296
+ #scrim{display:none}
2297
+ #actions{display:contents}
2298
+ /* In the sheet they are a list, not a row, and each one is a full-width target. */
2299
+ #menuBody #actions{display:flex;flex-direction:column;gap:8px}
2300
+ #menuBody #actions button{width:100%;text-align:left;min-height:44px}
2301
+ @media (max-width:900px){
2302
+ :root{--pad:12px}
2303
+ #drawer,#more{display:inline-block}
2304
+ /* Thirteen controls do not fit across a phone. The ones you reach for mid-conversation stay;
2305
+ the rest are one tap away behind ⋯, which is where they belong on a screen this size. */
2306
+ header>#actions,.grow>#actions{display:none}
2307
+ #menuBody #actions{display:flex}
2308
+ aside{position:fixed;top:0;right:0;bottom:0;width:min(88vw,23rem);z-index:40;
2309
+ box-shadow:-14px 0 40px var(--shadow);transform:translateX(101%);transition:transform .18s ease;
2310
+ padding-bottom:calc(10px + env(safe-area-inset-bottom))}
2311
+ aside.hidden{display:block}
2312
+ aside.open{transform:none}
2313
+ #scrim:not([hidden]){display:block;position:fixed;inset:0;z-index:39;background:rgba(0,0,0,.4)}
2314
+ header{padding:8px 12px;gap:7px;padding-top:calc(8px + env(safe-area-inset-top))}
2315
+ #feed{gap:12px}
2316
+ .row.user{max-width:92%}
2317
+ /* Anything under 16px makes iOS Safari zoom the whole page the moment you focus it, and it does
2318
+ not zoom back out. Touch targets get a finger's worth of height at the same time. */
2319
+ textarea,input,select{font-size:16px}
2320
+ textarea{min-height:44px}
2321
+ /* Three buttons beside the box left it about 150 characters wide, with its own scrollbar. The
2322
+ box gets the width; the buttons get a row of their own and split it. */
2323
+ .composer-row{flex-wrap:wrap}
2324
+ .composer-row textarea{flex:1 1 100%}
2325
+ .composer-row button{flex:1 1 0}
2326
+ /* The path is in the Session panel. Here it was costing a whole header row, on a screen where
2327
+ the header was already taking an eighth of the height. */
2328
+ header .cwd{display:none}
2329
+ /* And the name of the program, which is on the tab, the home screen icon and the address bar
2330
+ already. What is left fits on one row: which model, how hard it is thinking, what it cost,
2331
+ whether it is busy. */
2332
+ header>b{display:none}
2333
+ header select{max-width:8.5rem}
2334
+ header{min-height:0}
2335
+ button{padding:8px 12px;min-height:40px}
2336
+ .card button,.role button{min-height:32px}
2337
+ .hint{display:none}
2338
+ }
2339
+ @media (pointer:coarse){button,select{min-height:40px}}
2340
+ </style>
2341
+ <main>
2342
+ <header>
2343
+ <b>pi web</b>
2344
+ <span class="cwd" id="cwd"></span>
2345
+ <select id="model" title="Model"></select>
2346
+ <select id="thinking" title="Thinking level">
2347
+ <option>off</option><option>minimal</option><option>low</option><option>medium</option><option>high</option><option>xhigh</option>
2348
+ </select>
2349
+ <span class="grow">
2350
+ <span class="badge" id="queue" hidden></span>
2351
+ <span class="badge" id="cost" title="Session tokens and cost"></span>
2352
+ <button id="expand" title="Expand every step">▸ steps</button>
2353
+ <span id="actions">
2354
+ <button id="reload" title="Load this page again from the server">reload</button>
2355
+ <button id="find" title="Search the whole session, including abandoned branches" aria-label="Search this session">find</button>
2356
+ <button id="undo" title="Fork from your last message and put it back in the composer">undo</button>
2357
+ <button id="save" title="Export this session as HTML">save</button>
2358
+ <button id="share" title="Upload a redacted transcript as a GitHub gist (asks first)">share</button>
2359
+ <button id="compact" title="Summarise the context so far and carry on (/compact &lt;instructions&gt; steers it)">compact</button>
2360
+ <button id="clear" title="Start a new session — this one stays on disk">clear</button>
2361
+ <button id="resume" title="Go back to an earlier session in this project">resume</button>
2362
+ <button id="adddev" title="Show a code and a QR for signing in another device" aria-label="Add a device">add device</button>
2363
+ <button id="theme" title="Theme: system, light, dark" aria-label="Change theme">◐</button>
2364
+ </span>
2365
+ <span class="badge" id="status">connecting</span>
2366
+ <button id="more" title="Session actions" aria-label="Session actions">⋯</button>
2367
+ <button id="drawer" title="Automation, runtime and session panel" aria-label="Toggle the side panel" aria-expanded="true">panel</button>
2368
+ </span>
2369
+ </header>
2370
+ <div id="findbar" hidden><input id="findq" placeholder="search this session…" ><span id="findn" class="notice"></span></div>
2371
+ <button id="stale" hidden></button>
2372
+ <div id="feed" role="log" aria-live="polite" aria-label="Conversation"></div>
2373
+ <form id="composer">
2374
+ <button type="button" id="newer" hidden>↓ newer</button>
2375
+ <div id="pop"></div>
2376
+ <div id="thumbs"></div>
2377
+ <div class="composer-row">
2378
+ <textarea id="input" placeholder="Message pi — / for commands, @ for files, Enter sends"></textarea>
2379
+ <button type="button" id="attach">attach</button>
2380
+ <button type="submit" class="primary">send</button>
2381
+ <button type="button" id="stop">stop</button>
2382
+ </div>
2383
+ <div class="hint"><span>Enter send · Shift+Enter newline · paste images</span><span id="sid"></span></div>
2384
+ <input type="file" id="file" accept="image/png,image/jpeg,image/webp,image/gif,image/avif" multiple hidden>
2385
+ </form>
2386
+ </main>
2387
+ <aside>
2388
+ <div><h2>Automation</h2><div id="auto"></div></div>
2389
+ <div><h2>Runtime</h2><div id="runtime" class="notice">—</div></div>
2390
+ <div><h2>Goal</h2><div id="goal" class="notice">none</div></div>
2391
+ <div><h2>Session</h2><div id="meta" class="notice"></div></div>
2392
+ </aside>
2393
+ <div id="scrim" hidden></div>
2394
+ <dialog id="menu" aria-label="Session actions" role="dialog">
2395
+ <h3>Session</h3><div id="menuBody"></div>
2396
+ <menu><button id="menuClose" value="close">close</button></menu>
2397
+ </dialog>
2398
+ <dialog id="pairdlg" aria-labelledby="pairTitle" role="dialog">
2399
+ <h3 id="pairTitle">Add a device</h3>
2400
+ <select id="pairPick" aria-label="Which address to point that device at" hidden></select>
2401
+ <div id="pairQr" class="qr"></div>
2402
+ <div id="pairCode"></div>
2403
+ <div id="pairWhere" class="notice"></div>
2404
+ <menu><button id="pairClose" value="close">close</button></menu>
2405
+ </dialog>
2406
+ <dialog id="detail" aria-labelledby="detailTitle" role="dialog">
2407
+ <h3 id="detailTitle"></h3><div id="detailBody"></div>
2408
+ <menu><button value="close">close</button></menu>
2409
+ </dialog>
2410
+ <dialog id="sessdlg" aria-labelledby="sessTitle" role="dialog">
2411
+ <h3 id="sessTitle">Earlier sessions</h3>
2412
+ <div id="sessBody"></div>
2413
+ <menu><button id="sessClose" value="close">close</button></menu>
2414
+ </dialog>
2415
+ <dialog id="ask" aria-labelledby="askTitle" role="dialog"><form method="dialog">
2416
+ <h3 id="askTitle"></h3><pre id="askBody"></pre><div id="askWhy" class="notice" hidden></div><div id="askField"></div>
2417
+ <menu><button value="cancel">cancel</button><button value="ok" class="primary" id="askOk">approve</button></menu>
2418
+ </form></dialog>
2419
+ <script>
2420
+ const TOKEN = "__TOKEN__";
2421
+ /** The version this page was served by. The server says its own in /state; a difference is age. */
2422
+ const PAGE_VERSION = "__VERSION__";
2423
+ /**
2424
+ * One request, and always a value.
2425
+ *
2426
+ * Most callers here are fire-and-forget — a queue cleared, a level set, a job run now — and a
2427
+ * rejected fetch in one of those is a promise nobody is waiting on. The terminal catch is what
2428
+ * makes "always a value" true: every caller can read success and error whether the request
2429
+ * failed at the server or never left the browser.
2430
+ */
2431
+ const api = (p, b) =>
2432
+ fetch(p + (p.includes("?") ? "&" : "?") + "token=" + TOKEN, b === undefined ? {} : { method: "POST", body: JSON.stringify(b) })
2433
+ .then((r) => r.json())
2434
+ .catch((e) => ({ success: false, error: String(e) }));
2435
+ const $ = (id) => document.getElementById(id);
2436
+ const feed = $("feed"), statusEl = $("status");
2437
+ let state = {}, cwd = "", busy = false, atBottom = true, takesImages = true;
2438
+ /** Installed once the stream is up; until then a poll has nothing to compare against. */
2439
+ let checkStream;
2440
+
2441
+ feed.addEventListener("scroll", () => {
2442
+ atBottom = feed.scrollHeight - feed.scrollTop - feed.clientHeight < 40;
2443
+ if (atBottom) $("newer").hidden = true;
2444
+ });
2445
+ /**
2446
+ * Following the bottom, but only while you are at it: an update must not yank the page while you
2447
+ * are reading back through it. The cost of that is silence — a reply arrives, a tool finishes, and
2448
+ * nothing on screen says so — which is what the pill is for.
2449
+ */
2450
+ const scroll = () => {
2451
+ if (atBottom) {
2452
+ feed.scrollTop = feed.scrollHeight;
2453
+ $("newer").hidden = true;
2454
+ } else {
2455
+ $("newer").hidden = false;
2456
+ }
2457
+ };
2458
+
2459
+ /**
2460
+ * Text on its way to the screen. pi's session carries whatever was written to it, and plenty of
2461
+ * that was written for a terminal: an extension's startup banner, a coloured diff, a spinner. A
2462
+ * browser has no terminal to interpret those, so without this you read the escape codes themselves
2463
+ * — ESC[38;5;240m before every box character. Stripped rather than rendered: colour is not what
2464
+ * those bytes are here for, and a page that executes terminal control sequences is a worse idea.
2465
+ */
2466
+ function plain(s) {
2467
+ return String(s ?? "")
2468
+ .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g, "")
2469
+ .replace(/\u001b[[(][0-9;?]*[ -\/]*[@-~]/g, "")
2470
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "")
2471
+ // Bidi embeddings, overrides and isolates: invisible characters that reorder the line drawn
2472
+ // around them. A session named "delete\u202e evil red" draws as "deleteder live" — a different
2473
+ // conversation from the one that would open, in the control that decides which one opens. The
2474
+ // marks ordinary mixed-direction text uses (U+200E, U+200F) are left alone; these are the ones
2475
+ // whose only use on a line of someone else's text is to make it say something else.
2476
+ .replace(/[\u202a-\u202e\u2066-\u2069]/g, "");
2477
+ }
2478
+
2479
+ /**
2480
+ * A QR code, so a phone is pointed at the screen instead of typing a token.
2481
+ *
2482
+ * Byte mode, error correction level M, versions 1 to 6 — 106 characters, which is more than any
2483
+ * address this prints. Verified against a real decoder (OpenCV) at every length up to that, not
2484
+ * against another encoder: what matters is whether a scanner reads it.
2485
+ *
2486
+ */
2487
+ const CAP = [null, 14, 26, 42, 62, 84, 106];
2488
+ const TOTAL = [null, 26, 44, 70, 100, 134, 172];
2489
+ const BLOCKS = [null, 1, 1, 1, 2, 2, 4];
2490
+ const ECPB = [null, 10, 16, 26, 18, 24, 16];
2491
+ const ALIGN = [null, [], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34]];
2492
+
2493
+ const EXP = new Uint8Array(512);
2494
+ const LOG = new Uint8Array(256);
2495
+ {
2496
+ let x = 1;
2497
+ for (let i = 0; i < 255; i++) {
2498
+ EXP[i] = x;
2499
+ LOG[x] = i;
2500
+ x = (x << 1) ^ (x & 0x80 ? 0x11d : 0);
2501
+ }
2502
+ for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
2503
+ }
2504
+ const mul = (a, b) => (a && b ? EXP[LOG[a] + LOG[b]] : 0);
2505
+
2506
+ function generator(n) {
2507
+ let poly = [1];
2508
+ for (let i = 0; i < n; i++) {
2509
+ const next = new Array(poly.length + 1).fill(0);
2510
+ // The coefficients run highest power first, so multiplying by x keeps each index and
2511
+ // multiplying by a constant moves one to the right. Written the other way round this builds
2512
+ // the reverse polynomial, which produces plausible-looking parity that no scanner accepts.
2513
+ for (let j = 0; j < poly.length; j++) {
2514
+ next[j] ^= poly[j];
2515
+ next[j + 1] ^= mul(poly[j], EXP[i]);
2516
+ }
2517
+ poly = next;
2518
+ }
2519
+ return poly;
2520
+ }
2521
+
2522
+ function ecc(data, n) {
2523
+ const gen = generator(n);
2524
+ const rest = new Uint8Array(data.length + n);
2525
+ rest.set(data);
2526
+ for (let i = 0; i < data.length; i++) {
2527
+ const factor = rest[i];
2528
+ if (!factor) continue;
2529
+ for (let j = 0; j < gen.length; j++) rest[i + j] ^= mul(gen[j], factor);
2530
+ }
2531
+ return rest.slice(data.length);
2532
+ }
2533
+
2534
+ function bitsOf(text) {
2535
+ const bytes = new TextEncoder().encode(text);
2536
+ const bits = [];
2537
+ const push = (value, n) => { for (let i = n - 1; i >= 0; i--) bits.push((value >> i) & 1); };
2538
+ push(4, 4); // byte mode
2539
+ push(bytes.length, 8); // count, 8 bits for versions 1-9
2540
+ for (const b of bytes) push(b, 8);
2541
+ return { bits, length: bytes.length };
2542
+ }
2543
+
2544
+ function encode(text) {
2545
+ const { bits, length } = bitsOf(text);
2546
+ const version = CAP.findIndex((c, i) => i > 0 && c >= length);
2547
+ if (version < 1) return undefined; // longer than this encoder covers
2548
+ const dataWords = TOTAL[version] - BLOCKS[version] * ECPB[version];
2549
+ for (let i = 0; i < 4 && bits.length < dataWords * 8; i++) bits.push(0);
2550
+ while (bits.length % 8) bits.push(0);
2551
+ const words = [];
2552
+ for (let i = 0; i < bits.length; i += 8) words.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
2553
+ for (let pad = 0; words.length < dataWords; pad++) words.push(pad % 2 ? 0x11 : 0xec);
2554
+
2555
+ // Split into blocks, each with its own error correction, then interleave both halves.
2556
+ const n = BLOCKS[version];
2557
+ const short = Math.floor(dataWords / n);
2558
+ const long = dataWords % n; // that many blocks at the end carry one extra data codeword
2559
+ const blocks = [];
2560
+ let at = 0;
2561
+ for (let i = 0; i < n; i++) {
2562
+ const size = short + (i >= n - long ? 1 : 0);
2563
+ const data = Uint8Array.from(words.slice(at, at + size));
2564
+ at += size;
2565
+ blocks.push({ data, ec: ecc(data, ECPB[version]) });
2566
+ }
2567
+ const out = [];
2568
+ for (let i = 0; i < short + 1; i++) for (const b of blocks) if (i < b.data.length) out.push(b.data[i]);
2569
+ for (let i = 0; i < ECPB[version]; i++) for (const b of blocks) out.push(b.ec[i]);
2570
+ return { version, size: 17 + version * 4, words: out };
2571
+ }
2572
+
2573
+ /* ------------------------------------------------------------------ the matrix */
2574
+
2575
+ const FORMAT_MASK = 0x5412;
2576
+
2577
+ function formatBits(mask) {
2578
+ // EC level M is 00; the five data bits are that plus the mask, extended by BCH(15,5).
2579
+ let value = (0b00 << 3) | mask;
2580
+ let rest = value << 10;
2581
+ for (let i = 4; i >= 0; i--) if ((rest >> (i + 10)) & 1) rest ^= 0x537 << i;
2582
+ return ((value << 10) | rest) ^ FORMAT_MASK;
2583
+ }
2584
+
2585
+ function blank(size) {
2586
+ return { m: Array.from({ length: size }, () => new Int8Array(size).fill(-1)), size };
2587
+ }
2588
+
2589
+ function place(grid, r, c, v) {
2590
+ grid.m[r][c] = v;
2591
+ }
2592
+
2593
+ function finder(grid, r, c) {
2594
+ for (let dr = -1; dr <= 7; dr++) {
2595
+ for (let dc = -1; dc <= 7; dc++) {
2596
+ const rr = r + dr;
2597
+ const cc = c + dc;
2598
+ if (rr < 0 || cc < 0 || rr >= grid.size || cc >= grid.size) continue;
2599
+ const edge = dr === -1 || dr === 7 || dc === -1 || dc === 7;
2600
+ const ring = dr === 0 || dr === 6 || dc === 0 || dc === 6;
2601
+ const core = dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4;
2602
+ place(grid, rr, cc, edge ? 0 : ring || core ? 1 : 0);
2603
+ }
2604
+ }
2605
+ }
2606
+
2607
+ function reserveFormat(grid) {
2608
+ const s = grid.size;
2609
+ for (let i = 0; i < 9; i++) {
2610
+ if (grid.m[8][i] === -1) place(grid, 8, i, 0);
2611
+ if (grid.m[i][8] === -1) place(grid, i, 8, 0);
2612
+ }
2613
+ for (let i = 0; i < 8; i++) {
2614
+ place(grid, 8, s - 1 - i, 0);
2615
+ place(grid, s - 1 - i, 8, 0);
2616
+ }
2617
+ place(grid, s - 8, 8, 1); // the dark module, always set
2618
+ }
2619
+
2620
+ function skeleton(version) {
2621
+ const grid = blank(17 + version * 4);
2622
+ const s = grid.size;
2623
+ finder(grid, 0, 0);
2624
+ finder(grid, 0, s - 7);
2625
+ finder(grid, s - 7, 0);
2626
+ for (let i = 8; i < s - 8; i++) {
2627
+ place(grid, 6, i, i % 2 === 0 ? 1 : 0);
2628
+ place(grid, i, 6, i % 2 === 0 ? 1 : 0);
2629
+ }
2630
+ for (const r of ALIGN[version]) {
2631
+ for (const c of ALIGN[version]) {
2632
+ if (grid.m[r][c] !== -1) continue; // never on top of a finder
2633
+ for (let dr = -2; dr <= 2; dr++) {
2634
+ for (let dc = -2; dc <= 2; dc++) {
2635
+ const ring = Math.max(Math.abs(dr), Math.abs(dc));
2636
+ place(grid, r + dr, c + dc, ring === 1 ? 0 : 1);
2637
+ }
2638
+ }
2639
+ }
2640
+ }
2641
+ reserveFormat(grid);
2642
+ return grid;
2643
+ }
2644
+
2645
+ /** The zigzag: two columns at a time, right to left, skipping the timing column. */
2646
+ function fill(grid, words) {
2647
+ const bits = [];
2648
+ for (const w of words) for (let i = 7; i >= 0; i--) bits.push((w >> i) & 1);
2649
+ const s = grid.size;
2650
+ let at = 0;
2651
+ let up = true;
2652
+ for (let right = s - 1; right > 0; right -= 2) {
2653
+ if (right === 6) right = 5; // the vertical timing pattern is not a data column
2654
+ for (let step = 0; step < s; step++) {
2655
+ const row = up ? s - 1 - step : step;
2656
+ for (const col of [right, right - 1]) {
2657
+ if (grid.m[row][col] !== -1) continue;
2658
+ grid.m[row][col] = at < bits.length ? bits[at++] : 0;
2659
+ }
2660
+ }
2661
+ up = !up;
2662
+ }
2663
+ }
2664
+
2665
+ const MASKS = [
2666
+ (r, c) => (r + c) % 2 === 0,
2667
+ (r) => r % 2 === 0,
2668
+ (_, c) => c % 3 === 0,
2669
+ (r, c) => (r + c) % 3 === 0,
2670
+ (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
2671
+ (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
2672
+ (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
2673
+ (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
2674
+ ];
2675
+
2676
+ function penalty(m, s) {
2677
+ let score = 0;
2678
+ // Rule 1: runs of five or more of the same colour, in both directions.
2679
+ for (let i = 0; i < s; i++) {
2680
+ for (const line of [m[i], m.map((row) => row[i])]) {
2681
+ let run = 1;
2682
+ for (let j = 1; j < s; j++) {
2683
+ if (line[j] === line[j - 1]) run++;
2684
+ else { if (run >= 5) score += run - 2; run = 1; }
2685
+ }
2686
+ if (run >= 5) score += run - 2;
2687
+ }
2688
+ }
2689
+ // Rule 2: every 2x2 block of one colour.
2690
+ for (let r = 0; r < s - 1; r++) for (let c = 0; c < s - 1; c++) {
2691
+ const v = m[r][c];
2692
+ if (v === m[r][c + 1] && v === m[r + 1][c] && v === m[r + 1][c + 1]) score += 3;
2693
+ }
2694
+ // Rule 3: the finder-like pattern, which must not appear in the data.
2695
+ const A = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
2696
+ const B = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
2697
+ const matches = (line, at, pat) => pat.every((v, k) => line[at + k] === v);
2698
+ for (let i = 0; i < s; i++) {
2699
+ const row = m[i];
2700
+ const col = m.map((r) => r[i]);
2701
+ for (let j = 0; j + 11 <= s; j++) {
2702
+ for (const line of [row, col]) if (matches(line, j, A) || matches(line, j, B)) score += 40;
2703
+ }
2704
+ }
2705
+ // Rule 4: how far the proportion of dark modules is from half.
2706
+ let dark = 0;
2707
+ for (const row of m) for (const v of row) dark += v;
2708
+ score += Math.floor(Math.abs((dark * 100) / (s * s) - 50) / 5) * 10;
2709
+ return score;
2710
+ }
2711
+
2712
+ function matrixWithMask(text, only) {
2713
+ const data = encode(text);
2714
+ if (!data) return undefined;
2715
+ const base = skeleton(data.version);
2716
+ const reserved = base.m.map((row) => Int8Array.from(row, (v) => (v === -1 ? 0 : 1)));
2717
+ fill(base, data.words);
2718
+ const s = base.size;
2719
+
2720
+ let best;
2721
+ for (let mask = only ?? 0; mask < (only === undefined ? 8 : only + 1); mask++) {
2722
+ const m = base.m.map((row, r) => Int8Array.from(row, (v, c) => (reserved[r][c] ? v : v ^ (MASKS[mask](r, c) ? 1 : 0))));
2723
+ const bits = formatBits(mask);
2724
+ const at = (i) => (bits >> i) & 1;
2725
+ // Two copies, so a damaged corner still leaves the mask readable. Bit 0 is the least
2726
+ // significant, and the two copies walk the corner in opposite directions.
2727
+ for (let i = 0; i < 6; i++) m[i][8] = at(i);
2728
+ m[7][8] = at(6);
2729
+ m[8][8] = at(7);
2730
+ m[8][7] = at(8);
2731
+ for (let i = 9; i < 15; i++) m[8][14 - i] = at(i);
2732
+ for (let i = 0; i < 7; i++) m[8][s - 1 - i] = at(i);
2733
+ for (let i = 7; i < 15; i++) m[s - 15 + i][8] = at(i);
2734
+ m[s - 8][8] = 1; // the dark module is not part of the format bits and is always set
2735
+ const score = penalty(m, s);
2736
+ if (!best || score < best.score) best = { score, m, mask };
2737
+ }
2738
+ return { size: s, version: data.version, mask: best.mask, m: best.m };
2739
+ }
2740
+
2741
+ const qrMatrix = (text) => matrixWithMask(text, undefined);
2742
+
2743
+ /* ---------------- markdown ---------------- */
2744
+ /*
2745
+ * A small Markdown renderer. A model writes Markdown whether or not the front end reads it, so a
2746
+ * page that shows the source is showing you asterisks and pipes where a list and a table were
2747
+ * meant. This covers what actually turns up in a reply: fenced code, headings, lists, quotes,
2748
+ * rules, and inline code, emphasis and links.
2749
+ *
2750
+ * Everything is escaped before anything is added — by esc(), the same one the panel uses, because
2751
+ * there was a second copy of it here and two escape tables is one too many — and the only attribute
2752
+ * this ever writes is an href that had to survive a scheme check first. A reply is not trusted
2753
+ * input: it is whatever the model was persuaded to write, and a tool result inside it is whatever a
2754
+ * web page said.
2755
+ */
2756
+
2757
+ /** http and https and nothing else: javascript: and data: are both a way to run code from a link. */
2758
+ function safeHref(url) {
2759
+ const trimmed = String(url ?? "").trim();
2760
+ // The placeholder marker means a code span was taken out of this URL and will be put back after
2761
+ // the attribute is written — so the address would not be the one the link says. Leave it as text.
2762
+ if (trimmed.includes("\u0000")) return "";
2763
+ return /^https?:\/\//i.test(trimmed) ? esc(trimmed) : "";
2764
+ }
2765
+
2766
+ /**
2767
+ * A path the session wrote, turned into something you can look at.
2768
+ *
2769
+ * A reply that says "I put the chart in ./out/chart.png" is a reply you cannot see the chart in.
2770
+ * Anything that is not an absolute URL is treated as a path relative to the session's directory
2771
+ * and handed to /file, which decides whether it exists, whether it is inside, and whether it is a
2772
+ * kind of thing worth showing — none of which the page is in a position to know.
2773
+ */
2774
+ function fileHref(url) {
2775
+ const trimmed = String(url ?? "").trim();
2776
+ if (!trimmed || trimmed.includes("\u0000")) return "";
2777
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return ""; // a scheme of its own
2778
+ // No token in it. These are same-origin requests from this page, so the cookie authenticates
2779
+ // them — and a page the model wrote, opened from one of these links, can read its own address.
2780
+ // A credential that outlives the process has no business being in a URL that document can see.
2781
+ return "/file?path=" + encodeURIComponent(trimmed.replace(/^\.\//, ""));
2782
+ }
2783
+
2784
+ /** The whole of a code span, and nothing but a path. */
2785
+ const ONLY_A_PATH = /^(?:~\/|\.{0,2}\/)[^\s<>"']+\.(?:png|jpe?g|gif|webp|avif|svg|pdf|html?|txt|md|csv|json)$/i;
2786
+
2787
+ /**
2788
+ * A path written in prose, rather than as a link.
2789
+ *
2790
+ * "I put it in /home/you/Downloads/report.html" is how a model actually says where something is,
2791
+ * and until now that was a sentence with a dead end in it. Only paths that name a file this can
2792
+ * show — the extension list, the same one the server enforces — so ordinary words with slashes in
2793
+ * them are left alone.
2794
+ */
2795
+ const PREVIEWABLE = /(^|[^\w\/~.-])((?:~\/|\.{0,2}\/)[^\s)<>"',。;:]+\.(?:png|jpe?g|gif|webp|avif|svg|pdf|html?|txt|md|csv|json))(?=$|[^\w-])/gi;
2796
+
2797
+ function linkPathsInProse(html) {
2798
+ // Only the text between tags: run over the whole string and it would find the paths inside the
2799
+ // href attributes it has just written. And not inside a link, where it would nest one in another.
2800
+ let depth = 0;
2801
+ return html
2802
+ .split(/(<[^>]*>)/)
2803
+ .map((part, i) => {
2804
+ if (i % 2) {
2805
+ if (/^<a\b/i.test(part)) depth++;
2806
+ else if (/^<\/a>/i.test(part)) depth = Math.max(0, depth - 1);
2807
+ return part;
2808
+ }
2809
+ if (depth) return part;
2810
+ return part.replace(PREVIEWABLE, (whole, before, p) => {
2811
+ const href = fileHref(p);
2812
+ if (!href) return whole;
2813
+ const image = /\.(png|jpe?g|gif|webp|avif|svg)$/i.test(p);
2814
+ return (
2815
+ before +
2816
+ (image
2817
+ ? '<img src="' + href + '" alt="' + esc(p) + '" loading="lazy">'
2818
+ : '<a href="' + href + '" target="_blank" rel="noreferrer noopener" class="file">' + esc(p) + "</a>")
2819
+ );
2820
+ });
2821
+ })
2822
+ .join("");
2823
+ }
2824
+
2825
+ // This page is a template literal inside a Node file, so a backtick cannot be written here at all
2826
+ // — and Markdown is made of them. Building the character keeps those two facts from colliding.
2827
+ const BT = String.fromCharCode(96);
2828
+ const RE_CODESPAN = new RegExp(BT + "([^" + BT + "]+)" + BT, "g");
2829
+ const FENCE = "^\\s*" + BT + BT + BT;
2830
+
2831
+ function inlineMd(text) {
2832
+ // Code spans first and put back last, so nothing inside them is read as markup.
2833
+ const spans = [];
2834
+ let out = String(text).replace(RE_CODESPAN, (_, code) => {
2835
+ spans.push(code);
2836
+ return "\u0000" + (spans.length - 1) + "\u0000";
2837
+ });
2838
+ out = esc(out)
2839
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
2840
+ .replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>")
2841
+ // Images first, or the link rule would eat them: ![alt](src) is a link with a bang in front.
2842
+ .replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (whole, alt, src) => {
2843
+ const where = safeHref(src) || fileHref(src);
2844
+ return where ? '<img src="' + where + '" alt="' + esc(alt) + '" loading="lazy">' : whole;
2845
+ })
2846
+ .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (whole, label, href) => {
2847
+ const safe = safeHref(href);
2848
+ if (safe) return '<a href="' + safe + '" target="_blank" rel="noreferrer noopener">' + label + "</a>";
2849
+ const local = fileHref(href);
2850
+ return local ? '<a href="' + local + '" target="_blank" rel="noreferrer noopener" class="file">' + label + "</a>" : whole;
2851
+ });
2852
+ out = linkPathsInProse(out);
2853
+ /**
2854
+ * And a code span that is *only* a path is a path.
2855
+ *
2856
+ * Backticks around a filename is how a model writes one — I put it in, then the path in them —
2857
+ * and treating that as a literal string to be left alone was a rule that fired on exactly the
2858
+ * case it was meant to serve. A span with anything else in it is still a literal string, and a
2859
+ * path inside a fenced block is still code.
2860
+ */
2861
+ return out.replace(/\u0000(\d+)\u0000/g, (_, i) => {
2862
+ const raw = String(spans[Number(i)] ?? "").trim();
2863
+ const href = ONLY_A_PATH.test(raw) ? fileHref(raw) : "";
2864
+ if (!href) return "<code>" + esc(spans[Number(i)]) + "</code>";
2865
+ if (/\.(png|jpe?g|gif|webp|avif|svg)$/i.test(raw)) return '<img src="' + href + '" alt="' + esc(raw) + '" loading="lazy">';
2866
+ return '<a href="' + href + '" target="_blank" rel="noreferrer noopener" class="file"><code>' + esc(raw) + "</code></a>";
2867
+ });
2868
+ }
2869
+
2870
+ /** One table row into its cells: the outer pipes are optional, the inner ones are the separator. */
2871
+ function cells(line) {
2872
+ return line.trim().replace(/^\||\|$/g, "").split("|").map((c) => c.trim());
2873
+ }
2874
+
2875
+ function markdownToHtml(src) {
2876
+ const lines = String(src ?? "").split("\n");
2877
+ const html = [];
2878
+ let list = "";
2879
+ let para = [];
2880
+ const flushPara = () => { if (para.length) { html.push("<p>" + inlineMd(para.join("\n")) + "</p>"); para = []; } };
2881
+ const flushList = () => { if (list) { html.push("</" + list + ">"); list = ""; } };
2882
+ for (let i = 0; i < lines.length; i++) {
2883
+ const line = lines[i];
2884
+ const fence = new RegExp(FENCE + "(\\w*)").exec(line);
2885
+ if (fence) {
2886
+ flushPara(); flushList();
2887
+ const body = [];
2888
+ for (i++; i < lines.length && !new RegExp(FENCE).test(lines[i]); i++) body.push(lines[i]);
2889
+ html.push('<pre class="code"' + (fence[1] ? ' data-lang="' + esc(fence[1]) + '"' : "") + "><code>" + esc(body.join("\n")) + "</code></pre>");
2890
+ continue;
2891
+ }
2892
+ const head = /^(#{1,4})\s+(.*)$/.exec(line);
2893
+ if (head) { flushPara(); flushList(); html.push("<h" + (head[1].length + 2) + ">" + inlineMd(head[2]) + "</h" + (head[1].length + 2) + ">"); continue; }
2894
+ if (/^\s*([-*_])\s*\1\s*\1[-*_\s]*$/.test(line)) { flushPara(); flushList(); html.push("<hr>"); continue; }
2895
+ const quote = /^>\s?(.*)$/.exec(line);
2896
+ if (quote) { flushPara(); flushList(); html.push("<blockquote>" + inlineMd(quote[1]) + "</blockquote>"); continue; }
2897
+ // A table is the one block that needs the line after it to be recognised at all: the divider is
2898
+ // what separates a header row from a paragraph that happens to contain pipes.
2899
+ if (line.includes("|") && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes("-")) {
2900
+ flushPara(); flushList();
2901
+ const aligns = cells(lines[i + 1]).map((c) => (/^:.*:$/.test(c) ? "center" : c.startsWith(":") ? "left" : c.endsWith(":") ? "right" : ""));
2902
+ const head = cells(line);
2903
+ const body = [];
2904
+ for (i += 2; i < lines.length && lines[i].includes("|"); i++) body.push(cells(lines[i]));
2905
+ const cell = (tag, text, n) => "<" + tag + (aligns[n] ? ' style="text-align:' + aligns[n] + '"' : "") + ">" + inlineMd(text) + "</" + tag + ">";
2906
+ html.push(
2907
+ "<table><thead><tr>" + head.map((c, n) => cell("th", c, n)).join("") + "</tr></thead><tbody>" +
2908
+ body.map((r) => "<tr>" + r.map((c, n) => cell("td", c, n)).join("") + "</tr>").join("") +
2909
+ "</tbody></table>",
2910
+ );
2911
+ i--;
2912
+ continue;
2913
+ }
2914
+ const item = /^\s*(?:([-*+])|(\d+)[.)])\s+(.*)$/.exec(line);
2915
+ if (item) {
2916
+ flushPara();
2917
+ const want = item[1] ? "ul" : "ol";
2918
+ if (list !== want) { flushList(); html.push("<" + want + ">"); list = want; }
2919
+ html.push("<li>" + inlineMd(item[3]) + "</li>");
2920
+ continue;
2921
+ }
2922
+ if (!line.trim()) { flushPara(); flushList(); continue; }
2923
+ para.push(line);
2924
+ }
2925
+ flushPara(); flushList();
2926
+ return html.join("");
2927
+ }
2928
+
2929
+ /* ---------------- feed ---------------- */
2930
+
2931
+ /**
2932
+ * Copy, which on a phone is the difference between having a command and retyping it. The clipboard
2933
+ * API needs a secure context, and http://192.168.x.x is not one — so there is a fallback, and it is
2934
+ * the only reason this is more than one line.
2935
+ */
2936
+ function copyBtn(getText) {
2937
+ const b = document.createElement("button");
2938
+ b.type = "button";
2939
+ b.textContent = "copy";
2940
+ b.title = "Copy this message";
2941
+ b.onclick = async (e) => {
2942
+ e.stopPropagation();
2943
+ const text = getText();
2944
+ try {
2945
+ if (navigator.clipboard && window.isSecureContext) await navigator.clipboard.writeText(text);
2946
+ else {
2947
+ const ta = document.createElement("textarea");
2948
+ ta.value = text;
2949
+ ta.style.position = "fixed";
2950
+ ta.style.opacity = "0";
2951
+ document.body.append(ta);
2952
+ ta.focus();
2953
+ ta.select();
2954
+ const ok = document.execCommand("copy");
2955
+ ta.remove();
2956
+ if (!ok) throw new Error("copy refused");
2957
+ }
2958
+ b.textContent = "copied";
2959
+ } catch (_) {
2960
+ b.textContent = "copy failed";
2961
+ }
2962
+ setTimeout(() => { b.textContent = "copy"; }, 1400);
2963
+ };
2964
+ return b;
2965
+ }
2966
+
2967
+ /**
2968
+ * Long text, cut, saying how much there was.
2969
+ *
2970
+ * Three places cap what they show — a tool result, an extension's message, pi's dying words — and
2971
+ * they reported the drop three different ways, one of them not at all: a result that stopped at 8000
2972
+ * characters looked like a result that ended there. A cap that hides its own existence is a lie
2973
+ * about the output.
2974
+ */
2975
+ function capped(text, n) {
2976
+ const s = String(text ?? "");
2977
+ return s.length > n ? s.slice(0, n) + "\n… (" + s.length + " chars)" : s;
2978
+ }
2979
+
2980
+ /** Replace a row's plain text with its rendered Markdown, keeping the source for the copy button. */
2981
+ function mdInto(el, text) {
2982
+ el.raw = text;
2983
+ el.className = "md";
2984
+ el.innerHTML = markdownToHtml(plain(text));
2985
+ // A page written into the conversation is a page you cannot look at. This opens it — sandboxed,
2986
+ // served back by this process, never rendered inside this document.
2987
+ for (const pre of el.querySelectorAll?.('pre.code[data-lang="html"]') ?? []) {
2988
+ // Read before the button goes in, or the word "preview" ends up inside the page it opens.
2989
+ const source = pre.textContent;
2990
+ const b = document.createElement("button");
2991
+ b.type = "button";
2992
+ b.className = "preview";
2993
+ b.textContent = "preview";
2994
+ b.onclick = async () => {
2995
+ const r = await api("/preview", { html: source });
2996
+ if (!r.success) return row("err", "error", r.error || "could not open a preview");
2997
+ window.open("/preview/" + r.id, "_blank", "noopener");
2998
+ };
2999
+ pre.append(b);
3000
+ }
3001
+ }
3002
+
3003
+ /** The clock, on hover: a long session has no other answer to "when". */
3004
+ function stamp(el) {
3005
+ el.title = new Date().toLocaleTimeString();
3006
+ return el;
3007
+ }
3008
+
3009
+ function row(cls, role, text) {
3010
+ const el = document.createElement("div");
3011
+ el.className = "row " + cls;
3012
+ const b = document.createElement("span");
3013
+ if (role) {
3014
+ const r = document.createElement("div");
3015
+ r.className = "role";
3016
+ const name = document.createElement("span");
3017
+ name.textContent = role;
3018
+ r.append(name);
3019
+ // Only where there is something worth copying: a status line is not.
3020
+ if (cls === "" || cls === "tool" || cls === "user") r.append(copyBtn(() => b.raw ?? b.textContent ?? ""));
3021
+ el.append(r);
3022
+ }
3023
+ if (text) b.textContent = plain(text);
3024
+ el.append(b);
3025
+ clearEmpty();
3026
+ endToolGroup();
3027
+ feed.append(stamp(el));
3028
+ scroll();
3029
+ return b;
3030
+ }
3031
+ /** Calls with no result yet, so a result can be given to the call that made it. */
3032
+ const openCalls = [];
3033
+
3034
+ function argSummary(args) {
3035
+ if (typeof args === "string") return args;
3036
+ const o = args ?? {};
3037
+ // The first string that looks like the subject: a command, a path, a pattern.
3038
+ for (const k of ["command", "cmd", "path", "file_path", "filePath", "pattern", "query", "url", "id"]) {
3039
+ if (typeof o[k] === "string" && o[k]) return o[k];
3040
+ }
3041
+ const first = Object.values(o).find((v) => typeof v === "string" && v);
3042
+ return first ?? Object.keys(o).join(", ");
3043
+ }
3044
+
3045
+ /** The stretch of work being collected right now, or nothing between them. */
3046
+ let toolGroup;
3047
+
3048
+ /**
3049
+ * The stretch of work being collected, made if there is not one.
3050
+ *
3051
+ * Thinking and tool calls arrive interleaved — think, read, think, run, think — and each one used to
3052
+ * take a row of the conversation. A turn that reads four files and runs two commands spent six rows
3053
+ * saying so, and a long agentic turn was thirty rows of plumbing around three sentences of answer;
3054
+ * the conversation is the thing being read. Consecutive steps collect into one block instead: while
3055
+ * it is happening its summary is the step that is happening, so you can still see what it is doing,
3056
+ * and when the answer arrives it closes into "6 steps · thinking, read, bash". Everything is still
3057
+ * there, one click in. Anything that is not part of the work — a reply, a message, a notice — ends
3058
+ * the stretch.
3059
+ */
3060
+ function toolGroupFor() {
3061
+ if (toolGroup && toolGroup.parentElement === feed) return toolGroup;
3062
+ const d = document.createElement("details");
3063
+ d.className = "row work";
3064
+ d.open = allOpen;
3065
+ const s = document.createElement("summary");
3066
+ const what = document.createElement("span");
3067
+ what.className = "what";
3068
+ s.append(what);
3069
+ d.append(s);
3070
+ d.what = what;
3071
+ d.names = [];
3072
+ clearEmpty();
3073
+ feed.append(stamp(d));
3074
+ toolGroup = d;
3075
+ return d;
3076
+ }
3077
+
3078
+ /** While a turn runs, the line says what is happening; afterwards, what happened. */
3079
+ function describeGroup(group, nowLine) {
3080
+ if (!group) return;
3081
+ const n = group.names.length;
3082
+ if (nowLine) {
3083
+ group.what.textContent = nowLine;
3084
+ return;
3085
+ }
3086
+ const unique = [...new Set(group.names)];
3087
+ group.what.textContent = n + (n === 1 ? " step · " : " steps · ") + unique.slice(0, 4).join(", ") + (unique.length > 4 ? "…" : "");
3088
+ }
3089
+
3090
+ /** Anything that is not part of the work ends the stretch: an answer, a message, a notice. */
3091
+ function endToolGroup() {
3092
+ if (toolGroup) describeGroup(toolGroup, "");
3093
+ toolGroup = undefined;
3094
+ }
3095
+
3096
+ /**
3097
+ * Every stretch of work at once, because that is how a person reads them: either the conversation
3098
+ * on its own, or the whole of what went into it. The choice is kept in this browser.
3099
+ */
3100
+ let allOpen = remembered("work", "closed") === "open";
3101
+
3102
+ function setAllWork(open) {
3103
+ allOpen = open;
3104
+ remember("work", open ? "open" : "closed");
3105
+ for (const d of feed.querySelectorAll?.("details.work") ?? []) d.open = open;
3106
+ const b = $("expand");
3107
+ b.textContent = open ? "▾ steps" : "▸ steps";
3108
+ b.title = open ? "Collapse every step" : "Expand every step";
3109
+ }
3110
+
3111
+ /**
3112
+ * The image types an <img> will actually render, and so the only ones a data: URL here may claim.
3113
+ *
3114
+ * Both ends of the page need it: a type out of a tool result is whatever some web page said, and a
3115
+ * type off the clipboard is whatever the source application wrote. One list for all three ways in —
3116
+ * the file picker's accept list, a paste, and a tool result — so nothing is sent under a type it is not.
3117
+ */
3118
+ const IMAGE_TYPE = /^image\/(png|jpeg|gif|webp|avif)$/;
3119
+
3120
+ /** One image content block, as an image. The data is base64 in the message; nothing is fetched. */
3121
+ function imageOf(c) {
3122
+ const img = document.createElement("img");
3123
+ img.className = "shot";
3124
+ img.loading = "lazy";
3125
+ img.alt = "image";
3126
+ const type = IMAGE_TYPE.test(c.mimeType || "") ? c.mimeType : "image/png";
3127
+ img.src = "data:" + type + ";base64," + String(c.data).replace(/[^A-Za-z0-9+/=]/g, "");
3128
+ return img;
3129
+ }
3130
+
3131
+ /**
3132
+ * A tool call and what it returned are one block, and it is closed.
3133
+ *
3134
+ * They used to be two rows, both open: the arguments as a block of JSON, then however many thousand
3135
+ * characters came back. One shell command could push the conversation off the screen, and on a phone
3136
+ * it did. The summary is the line that matters — which tool, on what — and the rest is one tap away.
3137
+ */
3138
+ function toolRow(name, args, id, orphan) {
3139
+ const el = document.createElement("details");
3140
+ el.className = "row tool";
3141
+ const sum = document.createElement("summary");
3142
+ const what = document.createElement("span");
3143
+ what.className = "what";
3144
+ const state = document.createElement("span");
3145
+ state.className = "state";
3146
+ el.state = state;
3147
+ // Copying a tool call means copying what it ran and what came back, which is the pair of things
3148
+ // you paste into a bug report.
3149
+ sum.append(what, state, copyBtn(() => [...el.querySelectorAll("pre")].map((x) => x.textContent).join("\n\n")));
3150
+ el.append(sum);
3151
+ const pre = document.createElement("pre");
3152
+ el.append(pre);
3153
+ el.what = what;
3154
+ el.setArgs = (a) => {
3155
+ what.textContent = plain(name + " " + argSummary(a)).slice(0, 200);
3156
+ pre.textContent = plain(typeof a === "string" ? a : JSON.stringify(a ?? {}, null, 1));
3157
+ };
3158
+ el.setArgs(args);
3159
+ state.textContent = "running";
3160
+ const group = toolGroupFor();
3161
+ group.names.push(name);
3162
+ group.append(el);
3163
+ describeGroup(group, plain(name + " " + argSummary(args)).slice(0, 120));
3164
+ // A block made to hold an orphan result is not waiting for one. Registering it here is what used
3165
+ // to break the pairing for everything after it.
3166
+ if (!orphan) {
3167
+ openCalls.push({ id, name, el });
3168
+ if (openCalls.length > 100) openCalls.shift();
3169
+ }
3170
+ scroll();
3171
+ return el;
3172
+ }
3173
+
3174
+ /**
3175
+ * Which call a result belongs to.
3176
+ *
3177
+ * By id when there is one, because two calls to the same tool can finish in either order and a
3178
+ * queue keyed on the name would hand each one the other's output. By name only as a fallback, and
3179
+ * a result that matches nothing gets a block of its own rather than corrupting the list — the
3180
+ * earlier version pushed that orphan onto the queue it had just failed to find, and every later
3181
+ * result for that tool was off by one for the life of the tab.
3182
+ */
3183
+ function claimCall(id, name) {
3184
+ let at = id ? openCalls.findIndex((c) => c.id === id) : -1;
3185
+ if (at === -1) at = openCalls.findIndex((c) => c.name === name);
3186
+ if (at === -1) return undefined;
3187
+ return openCalls.splice(at, 1)[0].el;
3188
+ }
3189
+
3190
+ /** Nothing is coming for these now. They stop claiming to be running, and stop being remembered. */
3191
+ function endOpenCalls() {
3192
+ for (const c of openCalls) if (c.el.state.textContent === "running") c.el.state.textContent = "stopped";
3193
+ openCalls.length = 0;
3194
+ endToolGroup();
3195
+ }
3196
+
3197
+ function toolResult(name, text, isError, id) {
3198
+ const el = claimCall(id, name) ?? toolRow(name || "tool", "", undefined, true);
3199
+ el.state.textContent = isError ? "error" : "";
3200
+ if (isError) el.classList.add("err");
3201
+ const pre = document.createElement("pre");
3202
+ pre.textContent = plain(capped(text, 8000));
3203
+ el.append(pre);
3204
+ scroll();
3205
+ return el;
3206
+ }
3207
+ /**
3208
+ * Thinking is closed, and clicking opens it — which it always was. What it was missing is a reason
3209
+ * to click: a line saying only "thinking" hides an unknown amount of unknown text. The summary
3210
+ * carries the first of it and how much there is, so the choice is an informed one.
3211
+ */
3212
+ function thinkRow() {
3213
+ const d = document.createElement("details");
3214
+ d.className = "row think";
3215
+ const s = document.createElement("summary");
3216
+ const label = document.createElement("span");
3217
+ label.textContent = "thinking";
3218
+ const peek = document.createElement("span");
3219
+ peek.className = "peek";
3220
+ s.append(label, peek);
3221
+ d.append(s);
3222
+ const p = document.createElement("pre");
3223
+ d.append(p);
3224
+ // Kept up to date as the deltas arrive, and it is the closed state that shows it.
3225
+ p.onGrow = () => {
3226
+ const text = p.textContent.replace(/\s+/g, " ").trim();
3227
+ peek.textContent = text ? " · " + (text.length > 90 ? text.slice(0, 90) + "…" : text) : "";
3228
+ describeGroup(d.parentElement?.classList?.contains("work") ? d.parentElement : undefined, "thinking · " + text.slice(0, 90));
3229
+ };
3230
+ clearEmpty();
3231
+ // Thinking is part of the work, not a separate row of the conversation.
3232
+ const group = toolGroupFor();
3233
+ group.names.push("thinking");
3234
+ group.append(d);
3235
+ scroll();
3236
+ return p;
3237
+ }
3238
+
3239
+ /* ---------------- streaming assembly: deltas are keyed by contentIndex ---------------- */
3240
+ let blocks = new Map();
3241
+ const blockAt = (key, make) => { if (!blocks.has(key)) blocks.set(key, make()); return blocks.get(key); };
3242
+ /** Append a delta, keeping the raw text: an escape sequence can be split across two of them. */
3243
+ function grow(el, delta) {
3244
+ el.raw = (el.raw || "") + delta;
3245
+ el.textContent = plain(el.raw);
3246
+ el.onGrow?.();
3247
+ }
3248
+
3249
+ function handle(ev) {
3250
+ switch (ev.type) {
3251
+ case "agent_start": busy = true; setStatus(); break;
3252
+ case "agent_end": busy = false; endOpenCalls(); setStatus(); refresh(); break;
3253
+ case "message_start": blocks = new Map(); break;
3254
+ case "message_update": {
3255
+ const d = ev.assistantMessageEvent || {};
3256
+ if (d.type === "text_delta") grow(blockAt("t" + d.contentIndex, () => row("", "assistant", "")), d.delta);
3257
+ else if (d.type === "thinking_delta") grow(blockAt("k" + d.contentIndex, thinkRow), d.delta);
3258
+ else if (d.type === "toolcall_start") blockAt("c" + d.contentIndex, () => toolRow(d.toolName, "", d.toolCallId ?? d.id));
3259
+ else if (d.type === "toolcall_end" && d.toolCall) blocks.get("c" + d.contentIndex)?.setArgs(d.toolCall.arguments);
3260
+ break;
3261
+ }
3262
+ case "message_end":
3263
+ // Markdown is rendered once the message is whole. Half a fenced block is not a fenced block,
3264
+ // and re-parsing on every delta would rewrite the DOM under a finger that is scrolling it.
3265
+ for (const [key, el] of blocks) if (key[0] === "t" && el.raw) mdInto(el, el.raw);
3266
+ renderMessage(ev.message, true);
3267
+ break;
3268
+ case "queue_update": state.queue = { steering: ev.steering || [], followUp: ev.followUp || [] }; setStatus(); break;
3269
+ case "extension_ui_request": onAsk(ev); break;
3270
+ case "entry_appended": if (ev.entry?.type === "custom") refresh(); break;
3271
+ case "compaction_end":
3272
+ // A compaction you asked for is reported by the call that asked for it. This line is for the
3273
+ // one pi does on its own when the context fills up — and it used to say "context compacted"
3274
+ // for that one whether it had worked, been aborted, or failed, and say it a second time over
3275
+ // the top of a manual one that had just reported the opposite.
3276
+ if (ev.reason !== "manual") row("notice", "", compactionLine(ev.result, ev.aborted, ev.errorMessage, true));
3277
+ refresh();
3278
+ break;
3279
+ case "pi_exit": {
3280
+ busy = false;
3281
+ statusEl.textContent = "pi exited";
3282
+ // The reason belongs on the page. Anything else means reading a terminal you may have opened
3283
+ // this window precisely to avoid.
3284
+ const why = (ev.stderr || "").trim();
3285
+ const el = row("err", "pi exited" + (ev.signal ? " (" + ev.signal + ")" : ev.code == null ? "" : " (code " + ev.code + ")"), "");
3286
+ if (why) {
3287
+ const pre = document.createElement("pre");
3288
+ pre.textContent = plain(capped(why, 4000));
3289
+ el.parentElement.append(pre);
3290
+ } else {
3291
+ el.textContent = "it stopped without saying why; the terminal that started this has its output";
3292
+ }
3293
+ scroll();
3294
+ break;
3295
+ }
3296
+ }
3297
+ }
3298
+
3299
+ /**
3300
+ * What you typed, drawn here the moment you sent it — and sent back by pi when it lands, because a
3301
+ * front end that joined later has to see it too. Both are right; showing both is not. Each message
3302
+ * drawn locally is remembered until its echo arrives and cancels it.
3303
+ */
3304
+ const drewLocally = [];
3305
+ function alreadyDrawn(text) {
3306
+ const at = drewLocally.indexOf(text);
3307
+ if (at === -1) return false;
3308
+ drewLocally.splice(at, 1);
3309
+ return true;
3310
+ }
3311
+
3312
+ /**
3313
+ * One message onto the screen. fromStream says it arrived live rather than out of the transcript:
3314
+ * a live one may be the echo of something this page already drew, and a live assistant message has
3315
+ * already been rendered by the deltas that built it.
3316
+ */
3317
+ function renderMessage(message, fromStream) {
3318
+ if (!message) return;
3319
+ if (message.role === "user") {
3320
+ const text = typeof message.content === "string" ? message.content : (message.content || []).filter((c) => c.type === "text").map((c) => c.text).join("");
3321
+ const imgs = typeof message.content === "string" ? 0 : (message.content || []).filter((c) => c.type === "image").length;
3322
+ const shown = text + (imgs ? "\n[" + imgs + " image(s)]" : "");
3323
+ if (fromStream && alreadyDrawn(shown)) return;
3324
+ if (text || imgs) {
3325
+ const body = row("user", "you", text);
3326
+ // What you sent, shown back. A line saying "[2 image(s)]" is a receipt, not a message.
3327
+ for (const c of Array.isArray(message.content) ? message.content : []) {
3328
+ if (c?.type === "image" && c.data) body.parentElement.append(imageOf(c));
3329
+ }
3330
+ }
3331
+ } else if (message.role === "toolResult") {
3332
+ const parts = Array.isArray(message.content) ? message.content : [message.content];
3333
+ const text = parts.map((c) => (typeof c === "string" ? c : (c?.text ?? ""))).join("");
3334
+ const el = toolResult(message.toolName || "tool", text, message.isError, message.toolCallId ?? message.id);
3335
+ // A tool that answers with a picture was answering with nothing at all until now: the image
3336
+ // blocks were filtered out and only the text of the result was kept.
3337
+ for (const c of parts) if (c && c.type === "image" && c.data) el.append(imageOf(c));
3338
+ } else if (message.role === "custom") {
3339
+ // A promotion pi-loops pushed into the chat ("[Trigger ...] ..."), or another extension message.
3340
+ // display:false means the model sees it and the person is not meant to.
3341
+ if (message.display === false) return;
3342
+ const text = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "");
3343
+ const shown = capped(text, 4000);
3344
+ /**
3345
+ * Where it goes depends on when it arrived. An extension that logs what a tool just did — "wrote
3346
+ * 166 lines to x.html" — speaks in the middle of a stretch of work, and belongs inside it: as a
3347
+ * row of its own it both takes a line and cuts the stretch in two, so six steps and thirty-five
3348
+ * become three rows instead of one. A message that arrives between turns is a message.
3349
+ */
3350
+ if (toolGroup) {
3351
+ const el = document.createElement("div");
3352
+ el.className = "note";
3353
+ const name = document.createElement("div");
3354
+ name.className = "role";
3355
+ name.textContent = message.customType || "note";
3356
+ const body = document.createElement("span");
3357
+ body.textContent = plain(shown);
3358
+ el.append(name, body);
3359
+ toolGroup.append(el);
3360
+ toolGroup.names.push(message.customType || "note");
3361
+ describeGroup(toolGroup, plain(shown).slice(0, 120));
3362
+ scroll();
3363
+ return;
3364
+ }
3365
+ row("tool", message.customType || "custom", shown);
3366
+ } else if (message.role === "assistant" && !fromStream) {
3367
+ for (const c of message.content || []) {
3368
+ if (c.type === "text" && c.text) mdInto(row("", "assistant", ""), c.text);
3369
+ else if (c.type === "thinking" && c.thinking) {
3370
+ const p = thinkRow();
3371
+ p.textContent = plain(c.thinking);
3372
+ p.onGrow();
3373
+ }
3374
+ else if (c.type === "toolCall") toolRow(c.name, c.arguments, c.id).state.textContent = "";
3375
+ }
3376
+ }
3377
+ }
3378
+
3379
+ function setStatus() {
3380
+ const q = (state.queue?.steering?.length || 0) + (state.queue?.followUp?.length || 0);
3381
+ const qe = $("queue");
3382
+ qe.hidden = !q;
3383
+ qe.textContent = "queued " + q + " ✕";
3384
+ qe.onclick = () => api("/queue/clear", {}).then(refresh);
3385
+ statusEl.textContent = busy ? "working…" : "ready";
3386
+ }
3387
+
3388
+ /* ---------------- the automation panel ---------------- */
3389
+ // Quotes included: the sidebar interpolates a job id into an attribute, and jobs.json is a file on
3390
+ // disk, not something this page gets to assume is well formed.
3391
+ const ESCAPES = { "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&#39;" };
3392
+ function esc(s) { return String(s ?? "").replace(/[<>&"']/g, (c) => ESCAPES[c]); }
3393
+ // A count out of a JSON file is a number only if the file says so: a runCount holding an img tag
3394
+ // with an onerror handler is a perfectly valid file, and interpolating it raw reaches the DOM.
3395
+ function num(n) { return Number.isFinite(Number(n)) ? Number(n) : 0; }
3396
+ // And .slice() is a string method: a job whose lastError is a number, or which has no id at all,
3397
+ // is a file this page has to draw, not a reason for the whole panel to stop redrawing.
3398
+ function str(v) { return v === undefined || v === null ? "" : String(v); }
3399
+
3400
+ /**
3401
+ * Escape, and strip what escaping does not stop.
3402
+ *
3403
+ * esc() keeps a name out of the markup; it does nothing about a bidi override, which is invisible and
3404
+ * reorders the line drawn around it, or a C0 control, which can blank what follows. Every string in
3405
+ * this panel comes off the disk (jobs.json, triggers.json) or out of pi (a snapshot, a dialog), so
3406
+ * every one of them goes through both. A job named "backup\u202e evil" must draw as what it is.
3407
+ */
3408
+ function safeText(v) { return esc(plain(str(v))); }
3409
+
3410
+ function renderSidebar(s) {
3411
+ const a = s.automation || {};
3412
+ const box = $("auto");
3413
+ if (!a.installed) { box.innerHTML = '<div class="notice">pi-loops not found in ' + safeText(a.dir || "") + "</div>"; }
3414
+ else {
3415
+ let html = "";
3416
+ html += '<div class="notice">inbox <b>' + num(a.inboxNew) + "</b> new · " + num(a.jobs.length) + " job(s) · " + num(a.rules.length) + " rule(s)</div>";
3417
+ for (const j of a.jobs) {
3418
+ html += '<div class="card' + (j.enabled ? "" : " off") + '"><div class="t"><b>' + safeText(j.name || str(j.id).slice(0, 14)) + "</b>" +
3419
+ '<span class="m">' + safeText(j.schedule) + (j.stateful ? " · loop" : "") + "</span>" +
3420
+ '<button data-run="' + safeText(j.id) + '">run</button></div>' +
3421
+ '<div class="m">' + safeText(str(j.prompt).slice(0, 90)) + "</div>" +
3422
+ '<div class="m">' + (j.running ? "running · " : "") + "runs " + num(j.runCount) + (j.next ? " · next " + safeText(whenNext(j.next)) : "") + "</div>" +
3423
+ // A job belonging to a hostname this machine no longer has: it is listed, because it exists,
3424
+ // and it says why nothing is happening rather than leaving you to find out from the silence.
3425
+ (j.otherHost ? '<div class="m" style="color:#c93">other host: ' + safeText(j.otherHost) + " — run <b>/cron set " + safeText(j.ref) + " --host here</b></div>" : "") +
3426
+ (j.lastError ? '<div class="m" style="color:#c66">' + safeText(str(j.lastError).slice(0, 120)) + "</div>" : "") + "</div>";
3427
+ }
3428
+ for (const r of a.rules) {
3429
+ html += '<div class="card' + (r.enabled ? "" : " off") + '"><div class="t"><b>rule</b><span class="m">' + (r.fireOnce ? "once" : "repeat") + "</span></div>" +
3430
+ '<div class="m">when ' + safeText(str(r.condition).slice(0, 80)) + "</div><div class=\"m\">→ " + safeText(str(r.action).slice(0, 80)) + "</div></div>";
3431
+ }
3432
+ if (!a.jobs.length && !a.rules.length) html += '<div class="notice">no jobs or rules in this project</div>';
3433
+ // The store is machine-wide and this list is not: without these lines, a job made in another
3434
+ // directory is indistinguishable from a job that is gone. Counted and labelled apart, because
3435
+ // /cron counts jobs and /triggers counts rules: one number covering both agrees with neither
3436
+ // command, and the line exists to send someone to the command.
3437
+ const jobsAway = num(a.elsewhere?.jobs);
3438
+ const rulesAway = num(a.elsewhere?.rules);
3439
+ if (jobsAway) html += '<div class="notice">+ ' + jobsAway + " job" + (jobsAway === 1 ? "" : "s") + " in other projects — /cron all</div>";
3440
+ if (rulesAway) html += '<div class="notice">+ ' + rulesAway + " rule" + (rulesAway === 1 ? "" : "s") + " elsewhere — /triggers rules --all</div>";
3441
+ if (s.lastPoll) html += '<div class="notice">last check: ' + safeText(s.lastPoll.state || "") + " · " + esc(new Date(s.lastPoll.at).toLocaleTimeString()) + "</div>";
3442
+ box.innerHTML = html;
3443
+ box.querySelectorAll("[data-run]").forEach((b) => (b.onclick = () => api("/trigger/immediate", { id: b.dataset.run })));
3444
+ }
3445
+ renderRuntime(s.runtime);
3446
+ $("goal").textContent = s.goal ? plain((s.goal.condition || "") + " — " + (s.goal.status || "") + " (" + (s.goal.iterations ?? 0) + ")") : "none";
3447
+ $("meta").textContent = plain([s.sessionName, s.messageCount + " messages", s.model?.label].filter(Boolean).join(" · "));
3448
+ }
3449
+
3450
+ // What only the pi process knows: which servers connected, what they exposed, who owns the clock.
3451
+ // pi-loops writes it as a pi_loops_snapshot entry; nothing here is guessed from config files.
3452
+ /**
3453
+ * A count on its own answers "how many" and nothing else — and the question a person actually has
3454
+ * is which ones. Any number rendered through this can be clicked for the list behind it.
3455
+ */
3456
+ const detailLists = new Map();
3457
+ let detailSeq = 0;
3458
+
3459
+ /**
3460
+ * Put a list behind a number and give back the key that opens it, or nothing when there is no list.
3461
+ *
3462
+ * Both the inline counts and the tiles need this, and both used to mint their own key: two copies of
3463
+ * one convention, which is how the two of them could have disagreed about it.
3464
+ */
3465
+ function detailKey(label, names) {
3466
+ const items = (names || []).filter(Boolean).map(String);
3467
+ if (!items.length) return undefined;
3468
+ const key = "d" + detailSeq++;
3469
+ detailLists.set(key, { title: label, items });
3470
+ return key;
3471
+ }
3472
+
3473
+ function countOf(n, label, names) {
3474
+ const key = detailKey(label, names);
3475
+ if (!key) return num(n) + " " + esc(label);
3476
+ return '<button class="count" data-detail="' + key + '">' + num(n) + " " + esc(label) + "</button>";
3477
+ }
3478
+
3479
+ /**
3480
+ * A next run, written so it cannot be read as the wrong day.
3481
+ *
3482
+ * The time alone was fine while the only jobs with a next run were the every-N-minutes ones, which
3483
+ * are always soon. A cron expression can be next April, and "15:13:25" for next April is not a
3484
+ * shorter way of saying it — it is a different thing. Today keeps the time; anything else says
3485
+ * which day it is.
3486
+ */
3487
+ function whenNext(iso) {
3488
+ const at = new Date(iso);
3489
+ return at.toDateString() === new Date().toDateString() ? at.toLocaleTimeString() : at.toLocaleString();
3490
+ }
3491
+
3492
+ /** A row of figures rather than a sentence: the number is what is being read. */
3493
+ function metrics(items) {
3494
+ const tileHtml = items.map(([label, n, names]) => {
3495
+ const inner = "<b>" + num(n) + "</b><span>" + esc(label) + "</span>";
3496
+ const key = detailKey(label, names);
3497
+ if (!key) return "<div>" + inner + "</div>";
3498
+ return '<button class="metric" data-detail="' + key + '">' + inner + "</button>";
3499
+ });
3500
+ return '<div class="metrics">' + tileHtml.join("") + "</div>";
3501
+ }
3502
+
3503
+ function showDetail(key) {
3504
+ const d = detailLists.get(key);
3505
+ if (!d) return;
3506
+ $("detailTitle").textContent = plain(d.title) + " (" + d.items.length + ")";
3507
+ const body = $("detailBody");
3508
+ body.innerHTML = "";
3509
+ for (const item of d.items) {
3510
+ const line = document.createElement("div");
3511
+ line.className = "detail-row";
3512
+ // An MCP server's name and a tool's name are whatever that server said they were.
3513
+ line.textContent = plain(item);
3514
+ body.append(line);
3515
+ }
3516
+ $("detail").showModal();
3517
+ }
3518
+
3519
+ /**
3520
+ * Thirty-three lines of provider-slash-id is a list, not a picker. Grouped by provider, named the
3521
+ * a person would name them, and annotated with the two things that decide the choice: how much
3522
+ * context there is and whether it can look at a picture.
3523
+ */
3524
+ function drawModels(catalog, current) {
3525
+ const picker = $("model");
3526
+ const key = catalog.length + "|" + (current ?? "");
3527
+ if (picker.dataset.n === key) return; // unchanged; do not disturb a menu somebody has open
3528
+ picker.dataset.n = key;
3529
+ picker.innerHTML = "";
3530
+ const byProvider = new Map();
3531
+ for (const m of catalog) {
3532
+ if (!byProvider.has(m.provider)) byProvider.set(m.provider, []);
3533
+ byProvider.get(m.provider).push(m);
3534
+ }
3535
+ const label = (m) => {
3536
+ const bits = [m.name || m.id];
3537
+ if (m.contextWindow) bits.push(Math.round(m.contextWindow / 1000) + "k");
3538
+ if (m.images) bits.push("images");
3539
+ return bits.join(" · ");
3540
+ };
3541
+
3542
+ const spec = (m) => m.provider + "/" + m.id;
3543
+ const known = new Map(catalog.map((m) => [spec(m), m]));
3544
+
3545
+ // The model in use, when the catalog does not have it.
3546
+ //
3547
+ // pi lists only providers you have credentials for, and a session can be running on one you do
3548
+ // not: --model deepseek resolving to a gateway that will not authenticate leaves the session on
3549
+ // that model with nothing in the catalog to match it. The picker then had no option to select and
3550
+ // showed nothing at all — a blank control, while the Session panel named the model. Say which one
3551
+ // it is and why it is not in the list below.
3552
+ if (current && !known.has(String(current))) {
3553
+ const g = document.createElement("optgroup");
3554
+ g.label = String(current).split("/")[0];
3555
+ g.append(new Option(String(current) + " (not in the catalog)", String(current)));
3556
+ picker.append(g);
3557
+ }
3558
+
3559
+ // What you have chosen before, most recent first — this browser only, and only ones still
3560
+ // offered. pi already filters the catalog to providers you have configured, so everything here
3561
+ // is usable; this is about the difference between usable and used.
3562
+ const recent = remembered("models", "").split(",").filter(Boolean);
3563
+ const mine = recent.map((k) => known.get(k)).filter(Boolean).slice(0, 5);
3564
+ if (mine.length) {
3565
+ const g = document.createElement("optgroup");
3566
+ g.label = "recent";
3567
+ for (const m of mine) g.append(new Option(label(m), spec(m)));
3568
+ picker.append(g);
3569
+ }
3570
+
3571
+ // Then the provider whose model is in use, then the rest as pi listed them.
3572
+ const currentProvider = current ? String(current).split("/")[0] : undefined;
3573
+ const order = [...byProvider.keys()].sort((a, b) => (a === currentProvider ? -1 : b === currentProvider ? 1 : 0));
3574
+ for (const provider of order) {
3575
+ const g = document.createElement("optgroup");
3576
+ g.label = provider;
3577
+ for (const m of byProvider.get(provider)) g.append(new Option(label(m), spec(m)));
3578
+ picker.append(g);
3579
+ }
3580
+ }
3581
+
3582
+ /** Only the levels this model has. A level pi maps to null is a level that does nothing. */
3583
+ function drawThinking(levels, current) {
3584
+ const picker = $("thinking");
3585
+ const list = (levels && levels.length ? levels : ["off", "minimal", "low", "medium", "high", "xhigh"]).map(String);
3586
+ if (picker.dataset.levels !== list.join(",")) {
3587
+ picker.dataset.levels = list.join(",");
3588
+ picker.innerHTML = "";
3589
+ for (const l of list) picker.append(new Option(l, l));
3590
+ }
3591
+ if (current) picker.value = current;
3592
+ }
3593
+
3594
+ function renderRuntime(rt) {
3595
+ const box = $("runtime");
3596
+ // The panel is redrawn every few seconds; without this the lists behind it accumulate for as long
3597
+ // as the tab is open.
3598
+ detailLists.clear();
3599
+ if (!rt) { box.textContent = "no snapshot yet — /cron snapshot writes one"; return; }
3600
+ const dot = (state) => '<span class="dot ' + (state === "connected" ? "up" : state === "disabled" || state === "idle" ? "idle" : "down") + '"></span>';
3601
+ let html = "";
3602
+ const sc = rt.scheduler || {};
3603
+ html += "<div>" + dot(sc.running ? (sc.leader ? "connected" : "idle") : "down") +
3604
+ (sc.running ? (sc.leader ? "owns the clock" : "standby (another pi owns the clock)") : "scheduler not running") +
3605
+ (sc.runs || sc.checks ? " · " + num(sc.runs) + " run(s), " + num(sc.checks) + " check(s)" : "") + "</div>";
3606
+ for (const m of rt.mcp || []) {
3607
+ // An MCP server's name, its state and its last error are all that server's own words.
3608
+ html += "<div>" + dot(m.state) + safeText(m.name) + " <span class=\"m\">" + safeText(m.state) + " · " + countOf((m.tools || []).length, "tools", m.tools) +
3609
+ (m.injects ? " · injects" : "") + (m.queued ? " · " + num(m.queued) + " queued" : "") + "</span></div>" +
3610
+ (m.lastError ? '<div class="m" style="color:#c66"> ' + safeText(str(m.lastError).slice(0, 120)) + "</div>" : "");
3611
+ }
3612
+ if (rt.mcpConfigError) html += '<div class="m" style="color:#c66">mcp.toml: ' + safeText(rt.mcpConfigError) + "</div>";
3613
+ const h = rt.hooks || {};
3614
+ html += metrics([
3615
+ ["hooks", h.count, h.events],
3616
+ ["tools", (rt.tools || []).length, rt.tools],
3617
+ ["mcp", (rt.mcp || []).length, (rt.mcp || []).map((m) => m.name)],
3618
+ ]);
3619
+ if (rt.poll) html += '<div class="m">last check ' + esc(new Date(rt.poll.at).toLocaleTimeString()) + " · " + safeText(rt.poll.outcome || "") + "</div>";
3620
+ html += '<div class="m">snapshot ' + esc(new Date(rt.at).toLocaleTimeString()) + " · v" + safeText(rt.version || "?") + "</div>";
3621
+ box.innerHTML = html;
3622
+ // The lists are rebuilt with the panel, so the handler is attached to the panel, not the buttons.
3623
+ box.onclick = (e) => {
3624
+ // The tiles have a <b> and a <span> filling them, so the click target is usually one of those
3625
+ // and not the button carrying the key. Ask upwards.
3626
+ const key = e.target?.closest?.("[data-detail]")?.dataset?.detail ?? e.target?.dataset?.detail;
3627
+ if (key) showDetail(key);
3628
+ };
3629
+ }
3630
+
3631
+ async function refresh() {
3632
+ state = await api("/state");
3633
+ checkStream?.(state);
3634
+ checkAge(state);
3635
+ cwd = state.cwd || "";
3636
+ $("cwd").textContent = cwd;
3637
+ $("cwd").title = cwd;
3638
+ $("sid").textContent = (state.sessionId || "").slice(0, 8);
3639
+ drawModels(state.modelCatalog || [], state.model?.label);
3640
+ if (state.model) $("model").value = state.model.label;
3641
+ drawThinking(state.thinkingLevels, state.thinkingLevel);
3642
+ // Whether this model can look at an image is something pi knows and the button was guessing at.
3643
+ const currentModel = (state.modelCatalog || []).find((m) => state.model && m.provider + "/" + m.id === state.model.label);
3644
+ takesImages = currentModel?.images !== false;
3645
+ $("attach").disabled = !takesImages;
3646
+ $("attach").title = takesImages ? "Attach images" : (state.model?.id || "this model") + " does not take images";
3647
+ busy = state.busy;
3648
+ setStatus();
3649
+ renderSidebar(state);
3650
+ api("/stats").then((st) => {
3651
+ if (!st || st.error) return;
3652
+ const pct = st.contextUsage?.percent;
3653
+ $("cost").textContent = "$" + (st.cost ?? 0).toFixed(3) + " · " + Math.round((st.tokens?.total ?? 0) / 1000) + "k" + (pct == null ? "" : " · ctx " + Math.round(pct) + "%");
3654
+ });
3655
+ for (const ask of state.pendingAsks || []) onAsk(ask);
3656
+ }
3657
+
3658
+ /* ---------------- extension dialogs (pi-loops' approvals land here) ---------------- */
3659
+ /**
3660
+ * Which dialogs this page has already put on the screen, so the eight-second poll does not offer the
3661
+ * same one again. Cleared by a resync: a reload takes the transcript afresh, and the server's own
3662
+ * list of unanswered dialogs is what it hands back.
3663
+ */
3664
+ const asked = new Set();
3665
+ /**
3666
+ * The dialog on the screen right now, if any. There is one dialog element for all of them, and
3667
+ * showModal() on one that is already open throws — so the id is held here as well as in the set
3668
+ * above, which a resync empties on purpose.
3669
+ */
3670
+ let showingAsk;
3671
+ function onAsk(req) {
3672
+ if (req.method === "notify") { row("notice", "", req.message); return; }
3673
+ if (!["confirm", "select", "input", "editor"].includes(req.method)) return;
3674
+ if (asked.has(req.id) || showingAsk) return;
3675
+ asked.add(req.id);
3676
+ showingAsk = req.id;
3677
+ const dlg = $("ask");
3678
+ // An extension writes the title, and an extension is code somebody else installed: the same rule
3679
+ // the message body has always had applies to the line above it.
3680
+ $("askTitle").textContent = plain(req.title || req.method);
3681
+ /**
3682
+ * A confirmation is only worth anything if you can see what you are confirming. The danger gate
3683
+ * sends a message with the command in it and a line saying why it was stopped; run together in
3684
+ * one paragraph they read as prose and get waved through. Split on the first blank line: what is
3685
+ * about to run goes in a box of its own, the reasoning goes under it.
3686
+ */
3687
+ const text = String(req.message || "");
3688
+ const cut = text.indexOf("\n\n");
3689
+ const subject = cut === -1 ? text : text.slice(0, cut);
3690
+ const why = cut === -1 ? "" : text.slice(cut + 2).trim();
3691
+ $("askBody").textContent = plain(subject);
3692
+ const reason = $("askWhy");
3693
+ reason.textContent = plain(why);
3694
+ reason.hidden = !why;
3695
+ const field = $("askField");
3696
+ field.innerHTML = "";
3697
+ let input = null;
3698
+ if (req.method === "select") { input = document.createElement("select"); for (const o of req.options || []) input.append(new Option(o, o)); field.append(input); }
3699
+ if (req.method === "input" || req.method === "editor") {
3700
+ input = document.createElement("textarea");
3701
+ input.style.width = "100%"; input.rows = req.method === "editor" ? 8 : 2; input.value = req.prefill || "";
3702
+ field.append(input);
3703
+ }
3704
+ $("askOk").textContent = req.method === "confirm" ? "approve" : "ok";
3705
+ // Esc closes a dialog with no returnValue, which is already "declined". What must not happen is a
3706
+ // stray Enter approving something: the cancel button holds the focus, so it is what Enter hits.
3707
+ setTimeout(() => dlg.querySelector("menu button")?.focus(), 0);
3708
+ dlg.returnValue = "";
3709
+ dlg.showModal();
3710
+ dlg.addEventListener("close", () => {
3711
+ showingAsk = undefined;
3712
+ const ok = dlg.returnValue === "ok";
3713
+ const answer = { id: req.id };
3714
+ if (!ok) answer.cancelled = true;
3715
+ else if (req.method === "confirm") answer.confirmed = true;
3716
+ else answer.value = input ? input.value : undefined;
3717
+ api("/ui-response", answer);
3718
+ row("notice", "", (req.title || req.method) + " → " + (ok ? "approved" : "declined"));
3719
+ }, { once: true });
3720
+ }
3721
+
3722
+ /* ---------------- composer ---------------- */
3723
+ let images = [];
3724
+ function drawThumbs() {
3725
+ $("thumbs").innerHTML = "";
3726
+ images.forEach((im, i) => {
3727
+ // A visible ✕ rather than a tooltip: a finger cannot hover, so "click to remove" was a secret.
3728
+ const wrap = document.createElement("div");
3729
+ wrap.className = "thumb";
3730
+ const img = document.createElement("img");
3731
+ img.src = "data:" + im.mimeType + ";base64," + im.data;
3732
+ const x = document.createElement("button");
3733
+ x.type = "button";
3734
+ x.className = "x";
3735
+ x.textContent = "✕";
3736
+ x.title = "Remove this image";
3737
+ x.setAttribute("aria-label", "Remove this image");
3738
+ x.onclick = () => { images.splice(i, 1); drawThumbs(); };
3739
+ wrap.append(img, x);
3740
+ $("thumbs").append(wrap);
3741
+ });
3742
+ }
3743
+ /** One message is one message. Ten images is already an unusual one, and a hundred is a mistake. */
3744
+ const MAX_IMAGES = 10;
3745
+
3746
+ function addFile(file) {
3747
+ if (!file) return;
3748
+ if (images.length >= MAX_IMAGES) return row("notice", "", "up to " + MAX_IMAGES + " images per message; the rest were left out");
3749
+ // Refused, not relabelled. An SVG or a TIFF used to be sent as "image/png" — bytes claiming to be
3750
+ // something they are not, which neither an <img> nor a model can decode, and the only sign of it
3751
+ // was a broken thumbnail. The same notice the ten-image cap uses, because it is the same answer:
3752
+ // this is not going with the message, and here is why.
3753
+ if (!IMAGE_TYPE.test(file.type || "")) return row("notice", "", (file.type || "that file") + " is not an image this can send; attach PNG, JPEG, GIF, WebP or AVIF");
3754
+ const r = new FileReader();
3755
+ r.onload = () => { images.push({ type: "image", data: String(r.result).split(",")[1], mimeType: file.type }); drawThumbs(); };
3756
+ r.readAsDataURL(file);
3757
+ }
3758
+ /**
3759
+ * On a phone, tapping a button beside the box first blurs the box — which dismisses the soft
3760
+ * keyboard, which relayouts the page — and the tap then lands wherever that button used to be.
3761
+ * "send" appears dead. Taking the focus steal away makes the click land where you aimed it. This
3762
+ * is not visible in a desktop browser, which has no soft keyboard to dismiss.
3763
+ */
3764
+ for (const b of document.querySelectorAll("form#composer button")) {
3765
+ b.addEventListener("pointerdown", (e) => e.preventDefault());
3766
+ }
3767
+
3768
+ $("attach").onclick = () => $("file").click();
3769
+ $("file").onchange = (e) => { for (const f of e.target.files) addFile(f); e.target.value = ""; };
3770
+ $("input").addEventListener("paste", (e) => {
3771
+ // Every image type the clipboard offers, so addFile is the one place that decides which of them
3772
+ // can actually be sent — the picker and a paste then accept exactly the same set.
3773
+ const pics = [...(e.clipboardData?.items || [])].filter((i) => i.type.startsWith("image/"));
3774
+ if (!pics.length) return;
3775
+ if (!takesImages) return void row("notice", "", (state.model?.id || "this model") + " does not take images; the paste was dropped");
3776
+ for (const item of pics) addFile(item.getAsFile());
3777
+ });
3778
+
3779
+ $("composer").onsubmit = async (e) => {
3780
+ e.preventDefault();
3781
+ const input = $("input");
3782
+ const text = input.value.trim();
3783
+ if (!text && !images.length) return;
3784
+ input.value = ""; hidePop();
3785
+ if (text) { sentPrompts.push(text); if (sentPrompts.length > 200) sentPrompts.shift(); }
3786
+ promptIdx = -1;
3787
+ // Typed rather than clicked, which is how these get used once they are a habit. They run here
3788
+ // instead of being sent: anything attached stays in the tray, and a session about to be replaced
3789
+ // is not given a message it would carry nowhere.
3790
+ if (pageCommand(text)) return;
3791
+ const payload = { text, images, mode: busy ? "follow_up" : undefined };
3792
+ const shown = text + (images.length ? "\n[" + images.length + " image(s)]" : "");
3793
+ if (!busy) {
3794
+ row("user", "you", shown);
3795
+ drewLocally.push(shown);
3796
+ // A message pi never echoes back must not sit here waiting to swallow a later one.
3797
+ if (drewLocally.length > 20) drewLocally.shift();
3798
+ }
3799
+ images = []; drawThumbs();
3800
+ const r = await api("/prompt", payload);
3801
+ if (!r.success) row("err", "error", r.error || JSON.stringify(r));
3802
+ };
3803
+ $("stop").onclick = () => api("/abort", {});
3804
+
3805
+ /* ---------------- find, undo, save ---------------- */
3806
+ $("find").onclick = () => {
3807
+ const bar = $("findbar");
3808
+ bar.hidden = !bar.hidden;
3809
+ if (!bar.hidden) $("findq").focus();
3810
+ };
3811
+ let findTimer;
3812
+ $("findq").oninput = () => {
3813
+ clearTimeout(findTimer);
3814
+ findTimer = setTimeout(async () => {
3815
+ const q = $("findq").value.trim();
3816
+ if (q.length < 2) { $("findn").textContent = ""; return; }
3817
+ const r = await api("/find", { q });
3818
+ const hits = r.hits || [];
3819
+ $("findn").textContent = hits.length + " hit(s)";
3820
+ // Results go into the feed rather than a separate pane: it is where you were already reading.
3821
+ row("notice", "", "find: " + q);
3822
+ for (const h of hits.slice(0, 20)) row("hit", h.role + " · " + new Date(h.when).toLocaleString(), "…" + h.excerpt + "…");
3823
+ }, 250);
3824
+ };
3825
+ $("undo").onclick = async () => {
3826
+ const r = await api("/undo", {});
3827
+ if (!r.success) return row("err", "error", r.error || "cannot undo");
3828
+ if (r.data?.cancelled) return row("notice", "", "undo was cancelled by an extension");
3829
+ // pi hands back the forked message; put it where it came from.
3830
+ $("input").value = r.data?.text ?? "";
3831
+ // The branch moved, so the conversation is taken again — through resync, which is the one path
3832
+ // that also resets what the page is counting against and drains what arrived meanwhile. This used
3833
+ // to be a second copy of it, and the copy was missing all three of those.
3834
+ // Only claim the message is back if it is. pi hands one back when there was one to hand back.
3835
+ await resync($("input").value ? "forked from your last message — it is back in the composer" : "forked from your last message");
3836
+ };
3837
+ $("save").onclick = async () => {
3838
+ const r = await api("/export", {});
3839
+ row("notice", "", r.success ? "saved " + r.data.path : "export failed: " + (r.error || ""));
3840
+ };
3841
+ // pi-loops' /share renders and redacts, then asks — the dialog arrives here like any other.
3842
+ $("share").onclick = () => api("/share", {});
3843
+
3844
+ /* ---------------- keeping the context in hand ---------------- */
3845
+
3846
+ /**
3847
+ * Compact, and say what it did.
3848
+ *
3849
+ * "context compacted" on its own is the reason nobody trusted this button: compaction costs a model
3850
+ * call and throws most of the conversation away, and a line that reports neither is asking to be
3851
+ * taken on faith. pi hands back what it was and what it became, so that is what goes on the screen.
3852
+ */
3853
+ function compactionLine(result, aborted, errorMessage, automatic) {
3854
+ if (aborted) return "compaction aborted";
3855
+ if (!result) return "compaction failed: " + (errorMessage || "no reason given");
3856
+ // Thousands where there are thousands. A summary that came out at 312 tokens was reported as
3857
+ // "0k", which reads as "gone" rather than "small" — and the numbers either side of that arrow are
3858
+ // the whole reason the line exists.
3859
+ const k = (n) => (n >= 1000 ? Math.round(n / 1000) + "k" : String(n));
3860
+ const before = result.tokensBefore, after = result.estimatedTokensAfter, cost = result.usage?.cost?.total;
3861
+ return (automatic ? "context compacted on its own" : "context compacted") +
3862
+ (before && after ? " · " + k(before) + " → " + k(after) : "") +
3863
+ (cost ? " · $" + cost.toFixed(3) : "");
3864
+ }
3865
+
3866
+ async function compactNow(instructions) {
3867
+ const said = row("notice", "", instructions ? "compacting — " + instructions : "compacting…");
3868
+ const r = await api("/compact", instructions ? { instructions } : {});
3869
+ said.textContent = plain(compactionLine(r.success ? r.data : undefined, false, r.error, false));
3870
+ refresh();
3871
+ }
3872
+
3873
+ /** Why the next reload is happening, when it is happening because somebody asked for it. */
3874
+ let swapWhy = "";
3875
+
3876
+ /**
3877
+ * Start a new session, or go back to one.
3878
+ *
3879
+ * Nothing is deleted by either: the session being left is a file on disk that resume lists, which
3880
+ * is the difference between this and clearing a terminal.
3881
+ */
3882
+ async function swap(call, body, why) {
3883
+ // Said before the request, not after it. The new session starts announcing itself the moment pi
3884
+ // swaps — and those events carry the new epoch, so the reload is often already under way by the
3885
+ // time this call returns. Setting the reason afterwards lost the race every time in a browser and
3886
+ // never once against a stand-in: what you got was "the session restarted", over an empty feed,
3887
+ // for a session you had just asked to start.
3888
+ swapWhy = why;
3889
+ const note = row("notice", "", "switching…");
3890
+ const r = await api(call, body);
3891
+ // Mid-turn the server refuses rather than asking, and its refusal says what to do about it. A
3892
+ // dialog here would be a second way to answer the same question, on the one screen where the
3893
+ // turn it is about is already visible.
3894
+ if (!r.success) { swapWhy = ""; note.textContent = plain(r.error || "could not switch session"); return; }
3895
+ if (r.data?.cancelled) { swapWhy = ""; note.textContent = "an extension stopped the switch"; return; }
3896
+ // The feed is not cleared here. The epoch changed under the page, and the reload that follows is
3897
+ // the same one a restarted server gets — one path, already tested, rather than two that drift.
3898
+ refresh();
3899
+ }
3900
+
3901
+ /**
3902
+ * The commands the page runs itself. Returns true when the line was one of them.
3903
+ *
3904
+ * The server keeps the same list and refuses these on the prompt route, so a tab left open across
3905
+ * an upgrade is told to reload rather than having /clear delivered to the model as two words.
3906
+ */
3907
+ function pageCommand(text) {
3908
+ const m = /^\/(\w[\w-]*)\s*([\s\S]*)$/.exec(text.trim());
3909
+ if (!m) return false;
3910
+ const name = m[1].toLowerCase();
3911
+ const rest = m[2].trim();
3912
+ if (name !== "clear" && name !== "new" && name !== "resume" && name !== "compact") return false;
3913
+ row("user", "you", text.trim());
3914
+ if (name === "compact") compactNow(rest);
3915
+ else if (name === "resume") openSessions();
3916
+ else swap("/session/new", {}, "new session — the one you left is under resume");
3917
+ return true;
3918
+ }
3919
+
3920
+ $("compact").onclick = () => compactNow("");
3921
+ $("clear").onclick = () => swap("/session/new", {}, "new session — the one you left is under resume");
3922
+
3923
+ /** The sessions of this project, newest first — the conversation, not the filename. */
3924
+ async function openSessions() {
3925
+ const body = $("sessBody");
3926
+ body.innerHTML = "";
3927
+ $("sessdlg").showModal();
3928
+ const r = await api("/sessions");
3929
+ const list = r.sessions || [];
3930
+ body.innerHTML = "";
3931
+ if (!list.length) {
3932
+ const empty = document.createElement("div");
3933
+ empty.className = "notice";
3934
+ empty.textContent = "no other sessions saved for this project yet";
3935
+ body.append(empty);
3936
+ return;
3937
+ }
3938
+ for (const s of list) {
3939
+ const b = document.createElement("button");
3940
+ b.className = "sess";
3941
+ if (s.current) b.disabled = true;
3942
+ const title = document.createElement("b");
3943
+ // Everything drawn from a file or from pi goes through plain(), panel and feed alike. It matters
3944
+ // most here: this is the control that decides which conversation comes back, and a bidi override
3945
+ // in a name makes one of them read as another.
3946
+ title.textContent = plain(s.name || s.first || "") || "(nothing said yet)";
3947
+ const meta = document.createElement("span");
3948
+ const when = s.mtimeMs ? new Date(s.mtimeMs).toLocaleString() : "";
3949
+ // The count is what was read, and only part of a long session is read: say "20+" rather than
3950
+ // a number that is quietly wrong.
3951
+ const msgs = s.messages + (s.truncated ? "+" : "") + " message(s)";
3952
+ meta.textContent = [when, msgs, s.current ? "this session" : ""].filter(Boolean).join(" · ");
3953
+ b.append(title, meta);
3954
+ if (!s.current) {
3955
+ b.onclick = () => {
3956
+ $("sessdlg").close();
3957
+ swap("/session/switch", { file: s.file }, "back in: " + (s.name || s.first || "an earlier session"));
3958
+ };
3959
+ }
3960
+ body.append(b);
3961
+ }
3962
+ }
3963
+ $("resume").onclick = openSessions;
3964
+ $("sessClose").onclick = () => $("sessdlg").close();
3965
+ $("model").onchange = async (e) => {
3966
+ // Remembered here and nowhere else, so the next time this browser opens the picker the ones you
3967
+ // reach for are at the top of it.
3968
+ const was = remembered("models", "").split(",").filter(Boolean);
3969
+ remember("models", [e.target.value, ...was.filter((m) => m !== e.target.value)].slice(0, 5).join(","));
3970
+ const r = await api("/model", { model: e.target.value });
3971
+ if (!r.success) row("err", "error", r.error || "model not available — check credentials");
3972
+ refresh();
3973
+ };
3974
+ $("thinking").onchange = (e) => api("/thinking", { level: e.target.value }).then(refresh);
3975
+
3976
+ /* ---------------- adding a device ---------------- */
3977
+
3978
+ /** The matrix as an SVG. One rect per dark module; a version 6 code is 41 across, which is fine. */
3979
+ function qrSvg(q) {
3980
+ const quiet = 4;
3981
+ const span = q.size + quiet * 2;
3982
+ let rects = "";
3983
+ for (let r = 0; r < q.size; r++) {
3984
+ // One rect per run of dark modules rather than per module: fewer nodes, same picture.
3985
+ let run = 0;
3986
+ for (let c = 0; c <= q.size; c++) {
3987
+ if (c < q.size && q.m[r][c]) { run++; continue; }
3988
+ if (run) rects += '<rect x="' + (c - run + quiet) + '" y="' + (r + quiet) + '" width="' + run + '" height="1"/>';
3989
+ run = 0;
3990
+ }
3991
+ }
3992
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + span + " " + span + '" shape-rendering="crispEdges" role="img" aria-label="Pairing code as a QR code">' +
3993
+ '<rect width="' + span + '" height="' + span + '" fill="#fff"/><g fill="#000">' + rects + "</g></svg>";
3994
+ }
3995
+
3996
+ $("adddev").onclick = async () => {
3997
+ const r = await api("/pair", {});
3998
+ if (!r.success) { row("err", "error", r.error || "could not make a pairing code"); return; }
3999
+ $("pairCode").textContent = r.code;
4000
+
4001
+ // The address this browser reached, first: under tailscale serve that is the tailnet name, and
4002
+ // it is the only one anybody had to configure. Then whatever else the machine can see — which is
4003
+ // a guess, so when there is more than one the choice is offered rather than made.
4004
+ const thisWindow = location.origin + "/";
4005
+ const loopback = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/)/.test(thisWindow);
4006
+ const candidates = (loopback ? [] : [{ url: thisWindow, via: "this window" }]).concat(r.addresses || []);
4007
+
4008
+ const pick = $("pairPick");
4009
+ pick.innerHTML = "";
4010
+ for (const c of candidates) pick.append(new Option(c.url + " (" + c.via + ")", c.url));
4011
+ pick.hidden = candidates.length < 2;
4012
+
4013
+ const draw = () => {
4014
+ const target = pick.value || candidates[0]?.url;
4015
+ const qr = target ? qrMatrix(target + "?pair=" + r.code) : undefined;
4016
+ $("pairQr").innerHTML = qr ? qrSvg(qr) : "";
4017
+ $("pairWhere").textContent = target
4018
+ ? "Scan it, or open " + target + " on that device and type the code. Good for " +
4019
+ Math.round((r.expiresIn || 600) / 60) + " minutes, once."
4020
+ : "This window is on " + thisWindow + ", which only this machine can reach. Run tailscale serve --bg " +
4021
+ (location.port || 80) + " and reload, or start with --host 0.0.0.0.";
4022
+ };
4023
+ pick.onchange = draw;
4024
+ draw();
4025
+ $("pairdlg").showModal();
4026
+ };
4027
+ /**
4028
+ * Closing the dialog retires the code. It is a live grant that a camera resolves in one frame, so
4029
+ * leaving it armed — and on screen behind whatever you do next — because nobody pressed anything
4030
+ * is the wrong default.
4031
+ */
4032
+ function closePairing() {
4033
+ $("pairQr").innerHTML = "";
4034
+ $("pairCode").textContent = "";
4035
+ $("pairWhere").textContent = "";
4036
+ $("pairdlg").close();
4037
+ api("/pair", { cancel: true });
4038
+ }
4039
+ $("pairClose").onclick = closePairing;
4040
+ $("pairdlg").addEventListener("close", () => { if ($("pairCode").textContent) closePairing(); });
4041
+
4042
+ /**
4043
+ * Clicking away from a dialog closes it. A native <dialog> does not do this on its own: the
4044
+ * backdrop is drawn by the browser and takes the click, so the click target is the dialog element
4045
+ * itself while the point is outside its box. That is the test.
4046
+ */
4047
+ for (const dlg of document.querySelectorAll("dialog")) {
4048
+ dlg.addEventListener("click", (e) => {
4049
+ if (e.target !== dlg) return; // a click on something inside it
4050
+ const r = dlg.getBoundingClientRect();
4051
+ const outside = e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom;
4052
+ if (outside) dlg.close();
4053
+ });
4054
+ }
4055
+
4056
+ /* ---------------- the actions sheet ---------------- */
4057
+ /**
4058
+ * The same buttons, moved rather than duplicated: a second copy means two of every id, two
4059
+ * handlers, and one of them going stale the next time somebody edits the other. On a wide screen
4060
+ * they live in the header; on a narrow one they are behind ⋯, and this carries them there and back.
4061
+ */
4062
+ $("more").onclick = () => {
4063
+ $("menuBody").append($("actions"));
4064
+ $("menu").showModal();
4065
+ };
4066
+ function closeMenu() {
4067
+ document.querySelector("header .grow").insertBefore($("actions"), $("status"));
4068
+ $("menu").close();
4069
+ }
4070
+ $("menuClose").onclick = closeMenu;
4071
+ $("menu").addEventListener("close", () => { if ($("menuBody").children.length) closeMenu(); });
4072
+ /**
4073
+ * The sheet gets out of the way *before* the button does its job, not after. A modal dialog makes
4074
+ * the rest of the document inert, so an action that ends by focusing something — find, for one —
4075
+ * had its focus call ignored and then no keyboard came up. Capture phase, so this runs first.
4076
+ */
4077
+ $("menuBody").addEventListener("click", (e) => { if (e.target.tagName === "BUTTON") closeMenu(); }, true);
4078
+
4079
+ /**
4080
+ * This page against the one the server would send now. They differ when something was installed
4081
+ * while this window was open — at which point everything still looks fine and none of the fixes in
4082
+ * the new version are running here, because they are not in this document.
4083
+ */
4084
+ function checkAge(s) {
4085
+ if (!s?.version || s.version === PAGE_VERSION || PAGE_VERSION === "__" + "VERSION__") return;
4086
+ const bar = $("stale");
4087
+ bar.hidden = false;
4088
+ bar.textContent = "This window is running v" + PAGE_VERSION + "; v" + s.version + " is installed. Reload to use it.";
4089
+ }
4090
+ $("stale").onclick = () => location.reload();
4091
+
4092
+ /* ---------------- theme, and the side panel ---------------- */
4093
+ /**
4094
+ * Both of these are a preference about this browser and nothing else — no server, no session, no
4095
+ * other device. localStorage is where that belongs, and it is allowed to fail: a private window,
4096
+ * a browser set to block site data, a thumbnailer. The page has to come up either way.
4097
+ */
4098
+ function remembered(key, fallback) {
4099
+ try {
4100
+ return localStorage.getItem(key) ?? fallback;
4101
+ } catch (_) {
4102
+ return fallback;
4103
+ }
4104
+ }
4105
+ function remember(key, value) {
4106
+ try {
4107
+ localStorage.setItem(key, value);
4108
+ } catch (_) {
4109
+ // Nothing to do and nothing worth saying: the preference simply does not outlive the tab.
4110
+ }
4111
+ }
4112
+
4113
+ const THEMES = ["system", "light", "dark"];
4114
+ function applyTheme(theme) {
4115
+ const root = document.documentElement;
4116
+ if (theme === "system") root.removeAttribute("data-theme");
4117
+ else root.setAttribute("data-theme", theme);
4118
+ $("theme").textContent = theme === "system" ? "◐" : theme === "light" ? "☀" : "☾";
4119
+ $("theme").title = "Theme: " + theme + " (click to change)";
4120
+ }
4121
+ let theme = remembered("theme", "system");
4122
+ applyTheme(THEMES.includes(theme) ? theme : "system");
4123
+ $("theme").onclick = () => {
4124
+ theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
4125
+ applyTheme(theme);
4126
+ remember("theme", theme);
4127
+ };
4128
+
4129
+ const side = document.querySelector("aside");
4130
+ /**
4131
+ * One button, two jobs, because the panel means two different things depending on the width. On a
4132
+ * narrow screen it is a drawer that slides over the conversation; on a wide one it is a column that
4133
+ * can be given back to the conversation, and that choice is worth remembering.
4134
+ */
4135
+ function applyPanel(hidden) {
4136
+ side.classList.toggle("hidden", hidden);
4137
+ $("drawer").setAttribute("aria-expanded", String(!hidden));
4138
+ }
4139
+ let panelHidden = remembered("panel", "shown") === "hidden";
4140
+ applyPanel(panelHidden);
4141
+ /** Open or shut, in one place, so the scrim can never disagree with the drawer. */
4142
+ function setDrawer(open) {
4143
+ side.classList.toggle("open", open);
4144
+ $("scrim").hidden = !open;
4145
+ $("drawer").setAttribute("aria-expanded", String(open));
4146
+ }
4147
+ $("scrim").onclick = () => setDrawer(false);
4148
+
4149
+ // The one button that is always the right answer when something looks wrong.
4150
+ $("reload").onclick = () => location.reload();
4151
+ $("expand").onclick = () => setAllWork(!allOpen);
4152
+
4153
+ $("newer").onclick = () => {
4154
+ atBottom = true;
4155
+ feed.scrollTop = feed.scrollHeight;
4156
+ $("newer").hidden = true;
4157
+ };
4158
+
4159
+ $("drawer").onclick = (e) => {
4160
+ e.stopPropagation();
4161
+ if (window.matchMedia && window.matchMedia("(max-width:900px)").matches) {
4162
+ setDrawer(!side.classList.contains("open"));
4163
+ return;
4164
+ }
4165
+ panelHidden = !panelHidden;
4166
+ applyPanel(panelHidden);
4167
+ remember("panel", panelHidden ? "hidden" : "shown");
4168
+ };
4169
+ // Tapping the conversation puts the drawer away too, for the sliver of it the drawer leaves.
4170
+ feed.addEventListener("click", () => setDrawer(false));
4171
+
4172
+ /* ---------------- completion ---------------- */
4173
+ const pop = $("pop");
4174
+ let completions = [], completionIdx = 0;
4175
+ /**
4176
+ * Every keystroke asks for completions, and the answers do not necessarily come back in the order
4177
+ * they were asked for. Without this, typing "@src/we" quickly showed the whole of src/ — the reply
4178
+ * to "@src/" arriving after the reply to "@src/we" and overwriting it. Only the newest request is
4179
+ * allowed to draw.
4180
+ */
4181
+ let popSeq = 0;
4182
+ /**
4183
+ * Closing counts as newer, or an answer still in flight reopens what was just closed. Typing
4184
+ * "/compact keep the summary short" asks for completions on "/compact", closes the list on the
4185
+ * space, and then had the answer arrive and put it back — so Enter accepted "/compact" instead of
4186
+ * sending the line. Anything with an argument was reachable by mouse and not by typing.
4187
+ */
4188
+ const hidePop = () => { popSeq++; pop.style.display = "none"; completions = []; };
4189
+
4190
+ async function updatePop() {
4191
+ const el = $("input");
4192
+ const upto = el.value.slice(0, el.selectionStart);
4193
+ const line = upto.split("\n").pop();
4194
+ const mine = ++popSeq;
4195
+ const r = await api("/complete", { text: line });
4196
+ if (mine !== popSeq) return; // something newer is already on its way
4197
+ completions = r.items || [];
4198
+ if (!completions.length) return hidePop();
4199
+ completionIdx = 0;
4200
+ renderCompletions();
4201
+ pop.style.display = "block";
4202
+ }
4203
+ function renderCompletions() {
4204
+ pop.innerHTML = "";
4205
+ completions.forEach((it, i) => {
4206
+ const d = document.createElement("div");
4207
+ if (i === completionIdx) d.className = "sel";
4208
+ d.innerHTML = "<span>" + esc(it.value) + '</span><span class="h">' + esc(it.hint) + "</span>";
4209
+ d.onclick = () => accept(i);
4210
+ pop.append(d);
4211
+ });
4212
+ }
4213
+ /** What a candidate would replace: the token the caret is sitting at the end of. */
4214
+ function completionToken(it) {
4215
+ const el = $("input");
4216
+ const upto = el.value.slice(0, el.selectionStart);
4217
+ const nl = upto.lastIndexOf("\n") + 1;
4218
+ const from = it.replaceFrom !== undefined ? nl + it.replaceFrom : nl;
4219
+ return el.value.slice(from, el.selectionStart);
4220
+ }
4221
+ function accept(i) {
4222
+ const it = completions[i];
4223
+ const el = $("input");
4224
+ const upto = el.value.slice(0, el.selectionStart);
4225
+ const nl = upto.lastIndexOf("\n") + 1;
4226
+ const from = it.replaceFrom !== undefined ? nl + it.replaceFrom : nl;
4227
+ el.value = el.value.slice(0, from) + it.value + (it.value.endsWith("/") ? "" : " ") + el.value.slice(el.selectionStart);
4228
+ el.focus();
4229
+ hidePop();
4230
+ }
4231
+ /** What has been sent from this composer, for the up-arrow. Not window.history, which it shadowed. */
4232
+ const sentPrompts = [];
4233
+ let promptIdx = -1;
4234
+ let promptDraft = "";
4235
+
4236
+ /**
4237
+ * Whether an input method is mid-word. Typing Chinese, Japanese or Korean means Enter picks a
4238
+ * candidate from the IME's own list — it is not a request to send anything, and treating it as one
4239
+ * sends half a sentence and clears what you were writing. The isComposing flag is the answer where
4240
+ * it is set; keyCode 229 says the same thing on browsers that only report it that way, and the last
4241
+ * milliseconds after composition ends cover the browsers that fire compositionend first and hand
4242
+ * the very same Enter to keydown afterwards.
4243
+ */
4244
+ let composing = false;
4245
+ let composedAt = 0;
4246
+ $("input").addEventListener("compositionstart", () => { composing = true; });
4247
+ $("input").addEventListener("compositionend", () => { composing = false; composedAt = Date.now(); });
4248
+ const midWord = (e) => composing || e.isComposing || e.keyCode === 229 || Date.now() - composedAt < 50;
4249
+
4250
+ $("input").onkeydown = (e) => {
4251
+ if (midWord(e)) return;
4252
+ // Prompt history, but only while the caret is on the first/last line, so arrows still navigate
4253
+ // a multi-line draft.
4254
+ if (!completions.length && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
4255
+ const el = $("input");
4256
+ const before = el.value.slice(0, el.selectionStart);
4257
+ const after = el.value.slice(el.selectionStart);
4258
+ const atTop = !before.includes("\n");
4259
+ const atEnd = !after.includes("\n");
4260
+ if (e.key === "ArrowUp" && atTop && sentPrompts.length) {
4261
+ if (promptIdx === -1) promptDraft = el.value;
4262
+ promptIdx = Math.min(promptIdx + 1, sentPrompts.length - 1);
4263
+ el.value = sentPrompts[sentPrompts.length - 1 - promptIdx];
4264
+ e.preventDefault();
4265
+ return;
4266
+ }
4267
+ if (e.key === "ArrowDown" && atEnd && promptIdx >= 0) {
4268
+ promptIdx -= 1;
4269
+ el.value = promptIdx === -1 ? promptDraft : sentPrompts[sentPrompts.length - 1 - promptIdx];
4270
+ e.preventDefault();
4271
+ return;
4272
+ }
4273
+ }
4274
+ if (completions.length) {
4275
+ if (e.key === "ArrowDown") { e.preventDefault(); completionIdx = (completionIdx + 1) % completions.length; renderCompletions(); return; }
4276
+ if (e.key === "ArrowUp") { e.preventDefault(); completionIdx = (completionIdx - 1 + completions.length) % completions.length; renderCompletions(); return; }
4277
+ if (e.key === "Tab") { e.preventDefault(); accept(completionIdx); return; }
4278
+ if (e.key === "Enter" && !e.shiftKey) {
4279
+ e.preventDefault();
4280
+ // A command you have typed out in full needs no completing, and Enter on it means run it —
4281
+ // which is what it means in a terminal. "/inbox" then Enter used to accept the completion it
4282
+ // was already equal to and add a space, so every argumentless command took two Enters. Only
4283
+ // when there is nothing left to add: a shorter prefix still gets the candidate, as before.
4284
+ const it = completions[completionIdx];
4285
+ if (it && completionToken(it) === it.value) { hidePop(); $("composer").requestSubmit(); return; }
4286
+ accept(completionIdx);
4287
+ return;
4288
+ }
4289
+ if (e.key === "Escape") { hidePop(); return; }
4290
+ }
4291
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("composer").requestSubmit(); }
4292
+ };
4293
+ $("input").oninput = () => {
4294
+ const el = $("input");
4295
+ const line = el.value.slice(0, el.selectionStart).split("\n").pop();
4296
+ if (/(^\/[\w-]*$)|(@[^\s]*$)/.test(line)) updatePop(); else hidePop();
4297
+ };
4298
+
4299
+ /**
4300
+ * What a new session looks like before anything has happened. A blank rectangle is the one thing a
4301
+ * front end can show that says nothing at all — and this is also the first screen on a phone that
4302
+ * has just been paired, where "what is this and what do I type" is a real question.
4303
+ */
4304
+ let emptyEl;
4305
+
4306
+ /**
4307
+ * Whether there is a conversation in the feed.
4308
+ *
4309
+ * A line the page wrote about itself — why it reloaded, what a button just did — is not a
4310
+ * conversation, so it does not count. Without that, undoing the only message in a session left a
4311
+ * single grey sentence on an otherwise blank screen: the one case the empty state exists for.
4312
+ */
4313
+ function conversationIsEmpty() {
4314
+ for (const el of feed.children) if (!/(^|\s)notice(\s|$)/.test(String(el.className ?? ""))) return false;
4315
+ return true;
4316
+ }
4317
+
4318
+ function showEmpty() {
4319
+ if (!conversationIsEmpty()) return;
4320
+ const el = document.createElement("div");
4321
+ el.className = "empty";
4322
+ const h = document.createElement("h2");
4323
+ h.textContent = "A pi session, in a browser.";
4324
+ const p1 = document.createElement("p");
4325
+ p1.textContent = "Type below and press Enter. Slash for commands, @ for files, and images can be pasted straight in.";
4326
+ const p2 = document.createElement("p");
4327
+ p2.textContent = "Everything scheduled — loops, cron jobs, triggers — is in the panel, and keeps running whether or not this window is open.";
4328
+ el.append(h, p1, p2);
4329
+ emptyEl = el;
4330
+ feed.append(el);
4331
+ }
4332
+
4333
+ /**
4334
+ * Anything arriving in the feed retires the empty state. Called on every row, so it holds the one
4335
+ * node rather than walking the feed: replaying a long history was a scan per message.
4336
+ */
4337
+ function clearEmpty() {
4338
+ if (!emptyEl) return;
4339
+ emptyEl.remove();
4340
+ emptyEl = undefined;
4341
+ }
4342
+
4343
+ /* ---------------- staying in step with the server ---------------- */
4344
+ /**
4345
+ * Where the event stream had got to when this page last agreed with the server, and which run of the
4346
+ * server those numbers belong to.
4347
+ *
4348
+ * The feed is built by appending, which is what keeps a selection alive and a tool panel open — but
4349
+ * it also means an event that never arrives is simply missing, and the page goes quietly out of date
4350
+ * until someone reloads it. The numbers make that detectable: a jump means the stream dropped
4351
+ * something (a reconnect, a slow tab the browser suspended), and the answer is to take the transcript
4352
+ * again rather than carry on with a hole in it.
4353
+ */
4354
+ let seen = 0;
4355
+ let epoch;
4356
+ /**
4357
+ * Taking the transcript again takes a moment, and events keep arriving while it happens. They used to
4358
+ * be drawn onto a feed that the resync was about to empty — so a message sent at exactly the wrong
4359
+ * moment went in and then vanished. They wait in the holding list instead, and are applied against
4360
+ * the numbering the new transcript establishes.
4361
+ */
4362
+ let resyncing = false;
4363
+ let behind = 0;
4364
+ const waiting = [];
4365
+
4366
+ /** What to call this reload: what was asked for, if anything was, and once. */
4367
+ function reloadReason() {
4368
+ const why = swapWhy || "the session restarted — the conversation above was reloaded";
4369
+ swapWhy = "";
4370
+ return why;
4371
+ }
4372
+
4373
+ /**
4374
+ * Start the conversation again from the server's copy of it, and say why.
4375
+ *
4376
+ * Everything that throws the feed away goes through here — a gap in the numbering, a server that was
4377
+ * replaced, a session swapped, an undo that moved the branch. Undo used to do its own version of it
4378
+ * and the two drifted: that one never showed the empty state, left the sequence number and the epoch
4379
+ * pointing at a transcript it had just replaced, and dropped whatever was waiting to be applied.
4380
+ */
4381
+ async function resync(why) {
4382
+ resyncing = true;
4383
+ feed.innerHTML = "";
4384
+ emptyEl = undefined;
4385
+ toolGroup = undefined;
4386
+ openCalls.length = 0;
4387
+ blocks = new Map();
4388
+ // A dialog is offered again if the server still has it unanswered, and it does say so in /state.
4389
+ asked.clear();
4390
+ const again = await api("/history");
4391
+ for (const m of again.messages || []) renderMessage(m, false);
4392
+ seen = again.seq ?? seen;
4393
+ epoch = again.epoch ?? epoch;
4394
+ // Said after the clearing, or it would be the first thing the clearing removes.
4395
+ row("notice", "", why);
4396
+ showEmpty();
4397
+ feed.scrollTop = feed.scrollHeight;
4398
+ resyncing = false;
4399
+ behind = 0;
4400
+ // Whatever happened while we were reading: anything the transcript already covers is dropped by
4401
+ // the same rule as always.
4402
+ const held = waiting.splice(0, waiting.length);
4403
+ for (const ev of held) applyEvent(ev);
4404
+ refresh();
4405
+ }
4406
+
4407
+ /**
4408
+ * One event, against the numbering this page is counting on: already covered by the transcript, the
4409
+ * next one in sequence, or evidence that something in between never arrived.
4410
+ */
4411
+ function applyEvent(ev) {
4412
+ if (!ev.seq) return handle(ev); // not numbered: nothing to reason about
4413
+ if (epoch && ev.epoch && ev.epoch !== epoch) {
4414
+ // A different run of the server: its numbers mean nothing next to the ones we were counting.
4415
+ epoch = ev.epoch;
4416
+ seen = ev.seq;
4417
+ void resync(reloadReason()).catch(() => {});
4418
+ return;
4419
+ }
4420
+ if (ev.seq <= seen) return; // already in the transcript we replayed
4421
+ if (ev.seq > seen + 1 && seen > 0) {
4422
+ // Something in between never arrived. Do not draw this one on top of the gap.
4423
+ seen = ev.seq;
4424
+ void resync("reconnected — the conversation above was reloaded").catch(() => {});
4425
+ return;
4426
+ }
4427
+ seen = ev.seq;
4428
+ handle(ev);
4429
+ }
4430
+
4431
+ /* ---------------- start ---------------- */
4432
+ (async () => {
4433
+ setAllWork(allOpen);
4434
+ await refresh();
4435
+ const hist = await api("/history");
4436
+ for (const m of hist.messages || []) renderMessage(m, false);
4437
+ showEmpty();
4438
+ feed.scrollTop = feed.scrollHeight;
4439
+ // Only what happened after the transcript we just replayed. The server numbers every event and
4440
+ // told us which number that was, so this is exact: a turn that is running right now keeps
4441
+ // streaming into the page instead of being dropped for being too early.
4442
+ seen = hist.seq ?? 0;
4443
+ epoch = hist.epoch;
4444
+
4445
+ /**
4446
+ * The poll is the heartbeat.
4447
+ *
4448
+ * Everything else here reacts to events, which is no use at all when the events are what stopped
4449
+ * arriving — and they do: a server restarted under an open page, a stream the browser dropped
4450
+ * while the tab was in the background, a proxy that closed it quietly. The page went on looking
4451
+ * alive, drawing what you typed and never showing an answer, until somebody thought to reload it.
4452
+ * /state carries the same two numbers the stream does, so a page that is behind can see that it
4453
+ * is behind. Twice in a row, because one poll can simply overtake an event in flight.
4454
+ */
4455
+ checkStream = (s) => {
4456
+ // A reload ends by refreshing, and a refresh polls: without this the two call each other.
4457
+ if (resyncing || !s || !s.epoch) return;
4458
+ if (s.epoch !== epoch) {
4459
+ epoch = s.epoch;
4460
+ seen = s.seq ?? seen;
4461
+ behind = 0;
4462
+ void resync(reloadReason()).catch(() => {});
4463
+ return;
4464
+ }
4465
+ if ((s.seq ?? 0) > seen) behind++;
4466
+ else behind = 0;
4467
+ if (behind >= 2) {
4468
+ behind = 0;
4469
+ void resync("the connection dropped — the conversation above was reloaded").catch(() => {});
4470
+ }
4471
+ };
4472
+
4473
+ const es = new EventSource("/events?token=" + TOKEN);
4474
+ es.onmessage = (e) => {
4475
+ const ev = JSON.parse(e.data);
4476
+ if (resyncing) return void waiting.push(ev); // applied once the transcript is back
4477
+ applyEvent(ev);
4478
+ };
4479
+
4480
+ es.onerror = () => { statusEl.textContent = "reconnecting…"; };
4481
+ es.onopen = () => refresh();
4482
+ setInterval(refresh, 8000);
4483
+ })();
4484
+ </script>`;