@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.
- package/CHANGELOG.md +1989 -0
- package/LICENSE +21 -0
- package/README.md +370 -0
- package/README.zh-CN.md +606 -0
- package/docs/cli.md +252 -0
- package/docs/configuration.md +110 -0
- package/docs/design.md +155 -0
- package/docs/goal.md +59 -0
- package/docs/hooks.md +110 -0
- package/docs/loops.md +315 -0
- package/docs/mcp.md +90 -0
- package/docs/screenshot.png +0 -0
- package/docs/session-archive.md +64 -0
- package/docs/triggers.md +140 -0
- package/docs/troubleshooting.md +111 -0
- package/docs/web-ui-parity.md +305 -0
- package/examples/README.md +18 -0
- package/examples/mcp-notify-server.mjs +35 -0
- package/examples/mcp.toml +8 -0
- package/package.json +71 -0
- package/skills/pi-loops/SKILL.md +50 -0
- package/src/archive.ts +476 -0
- package/src/args.ts +217 -0
- package/src/cli-entry.mjs +23 -0
- package/src/cli.ts +610 -0
- package/src/config.ts +103 -0
- package/src/danger.ts +128 -0
- package/src/goal.ts +178 -0
- package/src/hooks.ts +436 -0
- package/src/host-control-channel.ts +243 -0
- package/src/host-control.ts +203 -0
- package/src/host-entry.mjs +14 -0
- package/src/host-runtime.ts +222 -0
- package/src/host.ts +329 -0
- package/src/inbox.ts +232 -0
- package/src/job-edit.ts +113 -0
- package/src/job-health.ts +29 -0
- package/src/lock.ts +193 -0
- package/src/log.ts +108 -0
- package/src/mcp-pool.ts +141 -0
- package/src/mcp.ts +885 -0
- package/src/paths.ts +33 -0
- package/src/pi-loops.ts +2292 -0
- package/src/pi-resolver.mjs +49 -0
- package/src/presence.ts +143 -0
- package/src/protocol.ts +198 -0
- package/src/redact.ts +61 -0
- package/src/register-pi.mjs +3 -0
- package/src/runner.ts +149 -0
- package/src/schedule.ts +431 -0
- package/src/scheduler.ts +981 -0
- package/src/sdk-runner.ts +536 -0
- package/src/share.ts +121 -0
- package/src/slots.ts +76 -0
- package/src/snapshot.ts +40 -0
- package/src/store.ts +599 -0
- package/src/subagent-guard.ts +47 -0
- package/src/thinking.ts +30 -0
- package/src/toml.ts +244 -0
- package/src/tools.ts +455 -0
- package/src/transcript.ts +84 -0
- package/src/trigger-runtime.ts +711 -0
- package/src/triggers.ts +558 -0
- package/src/trust.ts +47 -0
- package/src/ts-entry.mjs +67 -0
- package/src/ui-prefs.ts +84 -0
- package/src/version.ts +14 -0
- 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 <instructions> 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:  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 = { "<": "<", ">": ">", "&": "&", '"': """, "'": "'" };
|
|
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>`;
|