@join-pantheon/cli 0.32.1 → 0.33.0-rc.2

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/dist/board.js ADDED
@@ -0,0 +1,1417 @@
1
+ import { createServer } from "node:http";
2
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { spawn } from "node:child_process";
4
+ import { chmodSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
+ import { homedir, platform } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { CONFIG_DIR } from "./config.js";
9
+ import { LIMITS } from "./protocol.js";
10
+ import { childNodeFlags } from "./managed.js";
11
+ import { which } from "./which.js";
12
+ import { VERSION } from "./version.js";
13
+ const HERE = dirname(fileURLToPath(import.meta.url));
14
+ export const CLI_ENTRY = resolve(HERE, "cli.js");
15
+ export const BOARD_IDLE_MS = 30 * 60_000;
16
+ export const BOARD_REFRESH_MS = 15_000;
17
+ export const LANES = [
18
+ { id: "now", title: "Needs you now", empty: "Nothing needs you right now." },
19
+ { id: "handling", title: "Handling", empty: "Your agents are not working on anything for the team." },
20
+ { id: "waiting", title: "Waiting", empty: "You are not waiting on anyone." },
21
+ { id: "older", title: "Older", empty: "Nothing has been stuck for over a week." },
22
+ ];
23
+ export const boardStatePath = (home = CONFIG_DIR) => join(home, "board.json");
24
+ export function readBoardState(home = CONFIG_DIR) {
25
+ try {
26
+ const s = JSON.parse(readFileSync(boardStatePath(home), "utf8"));
27
+ if (!Number.isInteger(s.pid) || !Number.isInteger(s.port) || typeof s.token !== "string" || !TOKEN_RE.test(s.token))
28
+ return null;
29
+ return { pid: s.pid, port: s.port, token: s.token, started: Number(s.started) || 0, keep: !!s.keep, version: String(s.version ?? "") };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function writeBoardState(s, home = CONFIG_DIR) {
36
+ mkdirSync(home, { recursive: true, mode: 0o700 });
37
+ const file = boardStatePath(home);
38
+ const tmp = `${file}.${process.pid}.tmp`;
39
+ writeFileSync(tmp, JSON.stringify(s) + "\n", { mode: 0o600 });
40
+ try {
41
+ chmodSync(tmp, 0o600);
42
+ }
43
+ catch { }
44
+ renameSync(tmp, file);
45
+ }
46
+ export function clearBoardState(pid, home = CONFIG_DIR) {
47
+ const s = readBoardState(home);
48
+ if (s && s.pid === pid)
49
+ rmSync(boardStatePath(home), { force: true });
50
+ }
51
+ export const LINK_TTL_MS = 30_000;
52
+ export const boardLink = (port, nonce) => `http://127.0.0.1:${port}/#open=${nonce}`;
53
+ const TOKEN_RE = /^[0-9a-f]{64}$/;
54
+ const ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
55
+ const MAX_BODY_BYTES = 64 * 1024;
56
+ const SENT_KEEP_MS = 10 * 60_000;
57
+ export function defaultBoardDeps(home = CONFIG_DIR) {
58
+ return {
59
+ status: async () => (await import("./tray.js")).trayStatus(),
60
+ cli: runCli,
61
+ actionFile: join(home, "bin", ".hud-action.json"),
62
+ openTerminal: (actionId) => openExplainTerminal(home, actionId),
63
+ now: Date.now,
64
+ peerUid: (c, b) => loopbackPeerUid(c, b),
65
+ };
66
+ }
67
+ export function loopbackPeerUid(clientPort, boardPort, read = (p) => readFileSync(p, "utf8"), os = platform()) {
68
+ if (os !== "linux")
69
+ return null;
70
+ const hex = (n) => n.toString(16).toUpperCase().padStart(4, "0");
71
+ const at = (p) => [`0100007F:${hex(p)}`, `0000000000000000FFFF00000100007F:${hex(p)}`];
72
+ const locals = at(clientPort), remotes = at(boardPort);
73
+ let readable = false;
74
+ for (const f of ["/proc/net/tcp", "/proc/net/tcp6"]) {
75
+ let text;
76
+ try {
77
+ text = read(f);
78
+ readable = true;
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ for (const line of text.split("\n").slice(1)) {
84
+ const w = line.trim().split(/\s+/);
85
+ if (w.length < 8 || !locals.includes(w[1].toUpperCase()) || !remotes.includes(w[2].toUpperCase()))
86
+ continue;
87
+ const uid = Number(w[7]);
88
+ if (Number.isInteger(uid))
89
+ return uid;
90
+ }
91
+ }
92
+ return readable ? -1 : null;
93
+ }
94
+ export function runCli(args, timeoutMs, env = process.env) {
95
+ return new Promise((done) => {
96
+ let out = "", err = "";
97
+ const child = spawn(process.execPath, [...childNodeFlags(), CLI_ENTRY, ...args], {
98
+ stdio: ["ignore", "pipe", "pipe"], windowsHide: true, env: { ...env, NO_COLOR: "1" },
99
+ });
100
+ const t = setTimeout(() => { try {
101
+ child.kill();
102
+ }
103
+ catch { } }, timeoutMs);
104
+ child.stdout.on("data", (d) => { if (out.length < 4 * 1024 * 1024)
105
+ out += d.toString(); });
106
+ child.stderr.on("data", (d) => { if (err.length < 1024 * 1024)
107
+ err += d.toString(); });
108
+ child.on("error", (e) => { clearTimeout(t); done({ code: null, out, err: err + e.message }); });
109
+ child.on("close", (code) => { clearTimeout(t); done({ code, out, err }); });
110
+ });
111
+ }
112
+ const sha = (s) => createHash("sha256").update(s).digest();
113
+ const same = (a, b) => timingSafeEqual(sha(a), sha(b));
114
+ const keyOf = (s) => createHash("sha256").update(s).digest("hex");
115
+ export const pingProof = (token, port, challenge) => createHmac("sha256", token).update(`pantheon-board-ping:${port}:${challenge}`).digest("hex");
116
+ const MAX_LINKS = 32;
117
+ const MAX_SESSIONS = 64;
118
+ const BASE_HEADERS = {
119
+ "Cache-Control": "no-store",
120
+ "X-Content-Type-Options": "nosniff",
121
+ "Referrer-Policy": "no-referrer",
122
+ "X-Frame-Options": "DENY",
123
+ "Cross-Origin-Resource-Policy": "same-origin",
124
+ "Cross-Origin-Opener-Policy": "same-origin",
125
+ };
126
+ function sendJson(res, status, body, extra = {}) {
127
+ const data = JSON.stringify(body);
128
+ res.writeHead(status, { ...BASE_HEADERS, "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(data), ...extra });
129
+ res.end(data);
130
+ }
131
+ function readBody(req) {
132
+ return new Promise((done) => {
133
+ let size = 0;
134
+ const parts = [];
135
+ let over = false;
136
+ req.on("data", (d) => { size += d.length; if (size > MAX_BODY_BYTES) {
137
+ over = true;
138
+ return;
139
+ } parts.push(d); });
140
+ req.on("end", () => done(over ? null : Buffer.concat(parts).toString("utf8")));
141
+ req.on("error", () => done(null));
142
+ });
143
+ }
144
+ function strictObject(raw, allowed) {
145
+ if (raw === null)
146
+ return null;
147
+ let v;
148
+ try {
149
+ v = JSON.parse(raw);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ if (!v || typeof v !== "object" || Array.isArray(v))
155
+ return null;
156
+ for (const k of Object.keys(v))
157
+ if (!allowed.includes(k))
158
+ return null;
159
+ return v;
160
+ }
161
+ const str = (v, re) => (typeof v === "string" && re.test(v) ? v : null);
162
+ const SPACE_RE = /^[^\u0000-\u001f\u007f]{1,100}$/;
163
+ export function explainOutcome(r, nonce) {
164
+ const out = r.out.trim();
165
+ const notSent = /^not sent: (.*)$/m.exec(out);
166
+ if (notSent)
167
+ return { ok: false, message: notSent[1].trim() };
168
+ if (nonce !== undefined && !new RegExp(`^action ${nonce}$`, "m").test(out)) {
169
+ return { ok: false, message: "another window took that action before it ran — nothing was sent from here; try again" };
170
+ }
171
+ const sent = /^sent (ANSWER|NOTE|ASK|RETRACT) id=(\S+)/m.exec(out);
172
+ if (r.code === 0 && sent)
173
+ return { ok: true, message: `sent ${sent[1]}`, sent: { kind: sent[1], id: sent[2] } };
174
+ const err = r.err.replace(/\x1b\[[0-9;]*m/g, "");
175
+ if (r.code === 0 && /^done — /m.test(err))
176
+ return { ok: true, message: "Done — it is no longer waiting on you." };
177
+ if (r.code === 0 && /no longer waiting on you/.test(err))
178
+ return { ok: true, message: "It was already settled — it is no longer waiting on you." };
179
+ if (r.code === 0)
180
+ return { ok: true, message: "Done." };
181
+ const last = err.split("\n").map((l) => l.trim()).filter((l) => l && !/^pantheon \d|update available|upgrade/i.test(l)).at(-1);
182
+ return { ok: false, message: last ? last.slice(0, 300) : "the relay could not be reached" };
183
+ }
184
+ export function hudActionFor(op, item) {
185
+ const ref = item.ref ? { act: item.ref } : {};
186
+ const space = item.space ? { space: item.space } : {};
187
+ const goal = item.goal ? { goal: item.goal } : {};
188
+ switch (op.op) {
189
+ case "done":
190
+ if (item.kind !== "decision")
191
+ return { error: "only your own decision can be marked done" };
192
+ return { action: "done", decision: item.id, ...space, ...ref };
193
+ case "choose": {
194
+ const c = item.choices?.[op.index];
195
+ if (!c)
196
+ return { error: "that choice is not on this item" };
197
+ return { action: "reply", kind: "ANSWER", body: c.answer, ...goal, ...space, decision: item.id, ...ref };
198
+ }
199
+ case "reply":
200
+ if (op.kind === "ANSWER" && item.kind !== "decision")
201
+ return { error: "only the person it waits on can answer it — add a note or ask back" };
202
+ return { action: "reply", kind: op.kind, body: op.body, ...goal, ...space, ...(item.kind === "decision" ? { decision: item.id } : {}), ...ref };
203
+ case "open":
204
+ return { action: "open", prompt: item.prompt, ...space, ...goal, ...(item.kind === "decision" ? { decision: item.id } : {}) };
205
+ }
206
+ }
207
+ export function parseBoardAction(raw) {
208
+ const v = strictObject(raw, ["op", "id", "space", "index", "kind", "body", "act"]);
209
+ if (!v)
210
+ return null;
211
+ const space = v.space === undefined ? undefined : str(v.space, SPACE_RE);
212
+ if (space === null)
213
+ return null;
214
+ const sp = space === undefined ? {} : { space };
215
+ if (v.op === "retract") {
216
+ const act = str(v.act, ID_RE);
217
+ return act && Object.keys(v).every((k) => ["op", "act"].includes(k)) ? { op: "retract", act } : null;
218
+ }
219
+ const id = str(v.id, ID_RE);
220
+ if (!id)
221
+ return null;
222
+ const only = (keys) => Object.keys(v).every((k) => ["op", "id", "space", ...keys].includes(k));
223
+ switch (v.op) {
224
+ case "done": return only([]) ? { op: "done", id, ...sp } : null;
225
+ case "open": return only([]) ? { op: "open", id, ...sp } : null;
226
+ case "choose":
227
+ return only(["index"]) && Number.isInteger(v.index) && v.index >= 0 && v.index < 10
228
+ ? { op: "choose", id, ...sp, index: v.index } : null;
229
+ case "reply": {
230
+ if (!only(["kind", "body"]))
231
+ return null;
232
+ if (v.kind !== "ANSWER" && v.kind !== "NOTE" && v.kind !== "ASK")
233
+ return null;
234
+ if (typeof v.body !== "string")
235
+ return null;
236
+ const body = v.body.trim();
237
+ if (!body || body.length > LIMITS.bodyChars)
238
+ return null;
239
+ return { op: "reply", id, ...sp, kind: v.kind, body };
240
+ }
241
+ default: return null;
242
+ }
243
+ }
244
+ export async function startBoardServer(o = {}) {
245
+ const home = o.home ?? CONFIG_DIR;
246
+ const deps = { ...defaultBoardDeps(home), ...(o.deps ?? {}) };
247
+ const token = o.token ?? randomBytes(32).toString("hex");
248
+ if (!TOKEN_RE.test(token))
249
+ throw new Error("a board token is 64 hex characters");
250
+ const idleMs = o.idleMs ?? BOARD_IDLE_MS;
251
+ let keep = !!o.keep;
252
+ let lastSeen = deps.now();
253
+ let port = 0;
254
+ let lastStatus = null;
255
+ const sent = new Map();
256
+ let chain = Promise.resolve();
257
+ const serial = (fn) => { const p = chain.then(fn, fn); chain = p.catch(() => { }); return p; };
258
+ const origins = () => [`http://127.0.0.1:${port}`, `http://localhost:${port}`];
259
+ const links = new Map();
260
+ const sessions = new Set();
261
+ const mintLink = () => {
262
+ const now = deps.now();
263
+ for (const [k, until] of links)
264
+ if (until <= now)
265
+ links.delete(k);
266
+ while (links.size >= MAX_LINKS)
267
+ links.delete(links.keys().next().value);
268
+ const nonce = randomBytes(32).toString("hex");
269
+ links.set(keyOf(nonce), now + LINK_TTL_MS);
270
+ return boardLink(port, nonce);
271
+ };
272
+ const useLink = (nonce) => {
273
+ const k = keyOf(nonce);
274
+ const until = links.get(k);
275
+ links.delete(k);
276
+ return until !== undefined && until > deps.now();
277
+ };
278
+ const bearerOf = (req) => /^Bearer ([0-9a-f]{64})$/.exec(req.headers.authorization ?? "")?.[1] ?? null;
279
+ const authed = (req) => {
280
+ const b = bearerOf(req);
281
+ if (!b)
282
+ return null;
283
+ if (same(b, token))
284
+ return "token";
285
+ return sessions.has(keyOf(b)) ? "session" : null;
286
+ };
287
+ const peerChecked = new WeakMap();
288
+ const samePerson = (req) => {
289
+ const sock = req.socket;
290
+ const known = peerChecked.get(sock);
291
+ if (known !== undefined)
292
+ return known;
293
+ const me = typeof process.getuid === "function" ? process.getuid() : null;
294
+ const addr = sock.remoteAddress ?? "";
295
+ let ok = addr === "127.0.0.1" || addr === "::ffff:127.0.0.1";
296
+ if (ok && me !== null && sock.remotePort) {
297
+ const uid = deps.peerUid(sock.remotePort, port);
298
+ ok = uid === null || uid === me;
299
+ }
300
+ peerChecked.set(sock, ok);
301
+ return ok;
302
+ };
303
+ const status = async () => {
304
+ const s = await deps.status();
305
+ lastStatus = { at: deps.now(), status: s };
306
+ return s;
307
+ };
308
+ const findItem = async (id, space) => {
309
+ const match = (s) => (s.items ?? []).find((i) => i.id === id && (space === undefined || (i.space ?? "") === space)) ?? null;
310
+ if (lastStatus && deps.now() - lastStatus.at < 2 * 60_000) {
311
+ const hit = match(lastStatus.status);
312
+ if (hit)
313
+ return hit;
314
+ }
315
+ return match(await status());
316
+ };
317
+ const knownSpace = async (space) => {
318
+ const s = lastStatus?.status ?? await status();
319
+ return (s.spaces ?? (s.space ? [s.space] : [])).includes(space);
320
+ };
321
+ const writeAction = (a) => {
322
+ mkdirSync(dirname(deps.actionFile), { recursive: true });
323
+ const tmp = `${deps.actionFile}.${process.pid}.board.tmp`;
324
+ const nonce = randomBytes(16).toString("hex");
325
+ const text = JSON.stringify({ ...a, nonce });
326
+ writeFileSync(tmp, text, { mode: 0o600 });
327
+ renameSync(tmp, deps.actionFile);
328
+ return { text, nonce };
329
+ };
330
+ const ours = (text) => { try {
331
+ return readFileSync(deps.actionFile, "utf8") === text;
332
+ }
333
+ catch {
334
+ return false;
335
+ } };
336
+ const dropOurs = (text) => { if (ours(text)) {
337
+ try {
338
+ rmSync(deps.actionFile, { force: true });
339
+ }
340
+ catch { }
341
+ } };
342
+ const runQueued = (a) => serial(async () => {
343
+ const { text, nonce } = writeAction(a);
344
+ try {
345
+ return { r: await deps.cli(["explain", "--queued", "--only-action", "--action-id", nonce], 30_000), nonce };
346
+ }
347
+ finally {
348
+ dropOurs(text);
349
+ }
350
+ });
351
+ const openQueued = (a) => serial(async () => {
352
+ const { text, nonce } = writeAction(a);
353
+ const r = await deps.openTerminal(nonce).catch((e) => ({ ok: false, why: e.message }));
354
+ if (!r.ok) {
355
+ dropOurs(text);
356
+ return r;
357
+ }
358
+ const until = deps.now() + 25_000;
359
+ while (ours(text) && deps.now() < until)
360
+ await new Promise((r2) => setTimeout(r2, 200));
361
+ if (ours(text)) {
362
+ dropOurs(text);
363
+ return { ok: false, why: "the terminal did not start in time — try again, or copy the question into your agent" };
364
+ }
365
+ return { ok: true };
366
+ });
367
+ const handle = async (req, res) => {
368
+ const host = req.headers.host ?? "";
369
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
370
+ sendJson(res, 403, { ok: false, error: "wrong host" });
371
+ return;
372
+ }
373
+ if (!samePerson(req)) {
374
+ sendJson(res, 403, { ok: false, error: "this board belongs to another user on this computer" });
375
+ return;
376
+ }
377
+ const site = req.headers["sec-fetch-site"];
378
+ if (typeof site === "string" && site !== "same-origin" && site !== "none") {
379
+ sendJson(res, 403, { ok: false, error: "cross-site request" });
380
+ return;
381
+ }
382
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
383
+ const method = req.method ?? "GET";
384
+ if (method !== "GET" && method !== "POST") {
385
+ sendJson(res, 405, { ok: false, error: "method not allowed" }, { Allow: "GET, POST" });
386
+ return;
387
+ }
388
+ if (method === "POST") {
389
+ if (!origins().includes(String(req.headers.origin ?? ""))) {
390
+ sendJson(res, 403, { ok: false, error: "foreign origin" });
391
+ return;
392
+ }
393
+ if (!/^application\/json(;|$)/i.test(String(req.headers["content-type"] ?? ""))) {
394
+ sendJson(res, 415, { ok: false, error: "JSON only" });
395
+ return;
396
+ }
397
+ }
398
+ if (method === "GET" && url.pathname === "/") {
399
+ const nonce = randomBytes(16).toString("base64");
400
+ const html = boardHtml(nonce);
401
+ res.writeHead(200, {
402
+ ...BASE_HEADERS,
403
+ "Content-Type": "text/html; charset=utf-8",
404
+ "Content-Length": Buffer.byteLength(html),
405
+ "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
406
+ });
407
+ res.end(html);
408
+ return;
409
+ }
410
+ if (method === "POST" && url.pathname === "/api/session") {
411
+ const nonce = bearerOf(req);
412
+ const body = strictObject(await readBody(req), []);
413
+ if (!nonce || !useLink(nonce)) {
414
+ sendJson(res, 401, { ok: false, error: "this board link has expired or was already used — run pantheon board for a fresh one" });
415
+ return;
416
+ }
417
+ if (body === null) {
418
+ sendJson(res, 400, { ok: false, error: "bad request" });
419
+ return;
420
+ }
421
+ const session = randomBytes(32).toString("hex");
422
+ while (sessions.size >= MAX_SESSIONS)
423
+ sessions.delete(sessions.values().next().value);
424
+ sessions.add(keyOf(session));
425
+ lastSeen = deps.now();
426
+ sendJson(res, 200, { ok: true, session });
427
+ return;
428
+ }
429
+ if (method === "GET" && url.pathname === "/api/ping") {
430
+ const challenge = str(url.searchParams.get("challenge"), /^[0-9a-f]{32,128}$/);
431
+ if (authed(req))
432
+ lastSeen = deps.now();
433
+ sendJson(res, 200, { ok: true, board: true, pid: process.pid, version: VERSION, keep, ...(challenge ? { proof: pingProof(token, port, challenge) } : {}) });
434
+ return;
435
+ }
436
+ const who = authed(req);
437
+ if (!who) {
438
+ sendJson(res, 401, { ok: false, error: "this board needs its link — run pantheon board to open it" });
439
+ return;
440
+ }
441
+ lastSeen = deps.now();
442
+ if (method === "POST" && url.pathname === "/api/link") {
443
+ if (who !== "token") {
444
+ sendJson(res, 403, { ok: false, error: "links are made by pantheon board" });
445
+ return;
446
+ }
447
+ if (strictObject(await readBody(req), []) === null) {
448
+ sendJson(res, 400, { ok: false, error: "bad request" });
449
+ return;
450
+ }
451
+ sendJson(res, 200, { ok: true, link: mintLink(), ttl_ms: LINK_TTL_MS });
452
+ return;
453
+ }
454
+ if (method === "POST" && url.pathname === "/api/keep") {
455
+ if (strictObject(await readBody(req), []) === null) {
456
+ sendJson(res, 400, { ok: false, error: "bad request" });
457
+ return;
458
+ }
459
+ keep = true;
460
+ sendJson(res, 200, { ok: true, keep });
461
+ return;
462
+ }
463
+ if (method === "POST" && url.pathname === "/api/stop") {
464
+ if (strictObject(await readBody(req), []) === null) {
465
+ sendJson(res, 400, { ok: false, error: "bad request" });
466
+ return;
467
+ }
468
+ sendJson(res, 200, { ok: true });
469
+ setTimeout(() => { void close().then(() => o.onIdle?.()); }, 20);
470
+ return;
471
+ }
472
+ if (method === "GET" && url.pathname === "/api/board") {
473
+ try {
474
+ sendJson(res, 200, { ok: true, at: deps.now(), refresh_ms: BOARD_REFRESH_MS, status: await status() });
475
+ }
476
+ catch (e) {
477
+ sendJson(res, 200, { ok: false, error: `could not read the board: ${e.message}` });
478
+ }
479
+ return;
480
+ }
481
+ if (method === "GET" && url.pathname === "/api/detail") {
482
+ const id = str(url.searchParams.get("id"), ID_RE);
483
+ const space = url.searchParams.get("space");
484
+ if (!id || (space !== null && (!str(space, SPACE_RE) || !(await knownSpace(space))))) {
485
+ sendJson(res, 400, { ok: false, error: "bad request" });
486
+ return;
487
+ }
488
+ const r = await deps.cli(["tray-detail", id, ...(space ? ["--space", space] : [])], 20_000);
489
+ try {
490
+ sendJson(res, 200, JSON.parse(r.out.trim().split("\n").at(-1) ?? ""));
491
+ }
492
+ catch {
493
+ sendJson(res, 200, { ok: false });
494
+ }
495
+ return;
496
+ }
497
+ if (method === "GET" && url.pathname === "/api/thread") {
498
+ const goal = str(url.searchParams.get("goal"), ID_RE);
499
+ const space = url.searchParams.get("space");
500
+ if (!goal || !space || !str(space, SPACE_RE) || !(await knownSpace(space))) {
501
+ sendJson(res, 400, { ok: false, error: "bad request" });
502
+ return;
503
+ }
504
+ const teams = lastStatus?.status.spaces ?? [];
505
+ const r = await deps.cli(["history", "--goal", goal, "--json", "--last", "40", ...(teams.length > 1 ? ["--space", space] : [])], 20_000);
506
+ try {
507
+ const acts = JSON.parse(r.out);
508
+ sendJson(res, 200, { ok: true, acts: acts.map((a) => ({ id: String(a.id ?? ""), act: String(a.act ?? ""), from: String(a.from ?? ""), ts: Number(a.ts) || 0, body: String(a.body ?? "").slice(0, 6000), ...(a.summary ? { summary: String(a.summary) } : {}) })) });
509
+ }
510
+ catch {
511
+ sendJson(res, 200, { ok: false, error: "the thread could not be read" });
512
+ }
513
+ return;
514
+ }
515
+ if (method === "POST" && url.pathname === "/api/action") {
516
+ const op = parseBoardAction(await readBody(req));
517
+ if (!op) {
518
+ sendJson(res, 400, { ok: false, error: "bad request" });
519
+ return;
520
+ }
521
+ if (op.op === "retract") {
522
+ const s = sent.get(op.act);
523
+ if (!s || deps.now() - s.at > SENT_KEEP_MS) {
524
+ sendJson(res, 400, { ok: false, error: "only something this board just sent can be retracted" });
525
+ return;
526
+ }
527
+ const q = await runQueued({ action: "retract", act: op.act, ...(s.space ? { space: s.space } : {}) });
528
+ const r = explainOutcome(q.r, q.nonce);
529
+ if (r.ok)
530
+ sent.delete(op.act);
531
+ sendJson(res, 200, { ok: r.ok, message: r.ok ? "Retracted." : r.message });
532
+ return;
533
+ }
534
+ const item = await findItem(op.id, op.space);
535
+ if (!item) {
536
+ sendJson(res, 200, { ok: false, message: "that is no longer on your board" });
537
+ return;
538
+ }
539
+ const hud = hudActionFor(op, item);
540
+ if ("error" in hud) {
541
+ sendJson(res, 400, { ok: false, error: hud.error });
542
+ return;
543
+ }
544
+ if (op.op === "open") {
545
+ const r = await openQueued(hud);
546
+ sendJson(res, 200, { ok: r.ok, message: r.ok ? "Opening your agent in a terminal…" : (r.why ?? "no terminal could be opened here — copy the question into your agent") });
547
+ return;
548
+ }
549
+ const q = await runQueued(hud);
550
+ const r = explainOutcome(q.r, q.nonce);
551
+ if (r.sent) {
552
+ sent.set(r.sent.id, { ...(item.space ? { space: item.space } : {}), at: deps.now() });
553
+ for (const [k, v] of sent)
554
+ if (deps.now() - v.at > SENT_KEEP_MS)
555
+ sent.delete(k);
556
+ }
557
+ sendJson(res, 200, { ok: r.ok, message: r.message, ...(r.sent ? { sent: r.sent.id } : {}) });
558
+ return;
559
+ }
560
+ sendJson(res, 404, { ok: false, error: "not found" });
561
+ };
562
+ const server = createServer((req, res) => {
563
+ handle(req, res).catch((e) => { try {
564
+ sendJson(res, 500, { ok: false, error: e.message });
565
+ }
566
+ catch { } });
567
+ });
568
+ server.headersTimeout = 15_000;
569
+ server.requestTimeout = 60_000;
570
+ await new Promise((ok, fail) => { server.once("error", fail); server.listen(0, "127.0.0.1", () => { server.off("error", fail); ok(); }); });
571
+ port = server.address().port;
572
+ let closed = false;
573
+ const timer = setInterval(() => {
574
+ if (keep || closed)
575
+ return;
576
+ if (deps.now() - lastSeen > idleMs) {
577
+ void close().then(() => o.onIdle?.());
578
+ }
579
+ }, o.idleCheckMs ?? 30_000);
580
+ timer.unref?.();
581
+ async function close() {
582
+ if (closed)
583
+ return;
584
+ closed = true;
585
+ clearInterval(timer);
586
+ await new Promise((r) => { server.close(() => r()); server.closeAllConnections?.(); });
587
+ }
588
+ return { port, token, get url() { return mintLink(); }, link: mintLink, server, get keep() { return keep; }, close, lastSeen: () => lastSeen };
589
+ }
590
+ const LINK_RE = /^http:\/\/127\.0\.0\.1:\d{1,5}\/#open=[0-9a-f]{64}$/;
591
+ export function inServiceCgroup(read = (p) => readFileSync(p, "utf8"), os = platform()) {
592
+ if (os !== "linux")
593
+ return false;
594
+ let text;
595
+ try {
596
+ text = read("/proc/self/cgroup");
597
+ }
598
+ catch {
599
+ return false;
600
+ }
601
+ const line = text.split("\n").find((l) => l.startsWith("0::")) ?? text.split("\n").find((l) => l.includes(":name=systemd:"));
602
+ const path = line?.slice(line.indexOf(":", line.indexOf(":") + 1) + 1).trim() ?? "";
603
+ return /\.service$/.test(path.replace(/\/+$/, ""));
604
+ }
605
+ export function scopeArgv(argv, find = (n) => which(n)) {
606
+ const sr = find("systemd-run");
607
+ return sr ? [sr, "--user", "--scope", "--collect", "--quiet", "--", ...argv] : null;
608
+ }
609
+ export function browserCommand(url, os = platform(), find = (n) => which(n), o = {}) {
610
+ if (!LINK_RE.test(url))
611
+ throw new Error("not a board link");
612
+ const opts = { detached: true, stdio: "ignore", windowsHide: true };
613
+ if (os === "darwin")
614
+ return { cmd: "open", args: [url], opts };
615
+ if (os === "win32")
616
+ return { cmd: "cmd.exe", args: ["/d", "/c", "start", '""', url], opts: { ...opts, windowsVerbatimArguments: true } };
617
+ const x = find("xdg-open");
618
+ if (!x)
619
+ return null;
620
+ const scoped = o.scope ? scopeArgv([x, url], find) : null;
621
+ return scoped ? { cmd: scoped[0], args: scoped.slice(1), opts } : { cmd: x, args: [url], opts };
622
+ }
623
+ export function redirectPage(url, dir, now = Date.now()) {
624
+ if (!LINK_RE.test(url))
625
+ throw new Error("not a board link");
626
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
627
+ for (const f of readdirSync(dir)) {
628
+ if (!/^board-open-[0-9a-f]{16}\.html$/.test(f))
629
+ continue;
630
+ try {
631
+ if (now - statSync(join(dir, f)).mtimeMs > 60_000)
632
+ rmSync(join(dir, f), { force: true });
633
+ }
634
+ catch { }
635
+ }
636
+ const file = join(dir, `board-open-${randomBytes(8).toString("hex")}.html`);
637
+ writeFileSync(file, `<!doctype html><meta charset="utf-8"><meta name="referrer" content="no-referrer"><meta http-equiv="refresh" content="0;url=${url}"><title>Pantheon</title><script>location.replace(${JSON.stringify(url)})</script>\n`, { mode: 0o600 });
638
+ return file;
639
+ }
640
+ export function fileOpenCommand(file, os = platform()) {
641
+ const opts = { detached: true, stdio: "ignore", windowsHide: true };
642
+ if (os === "darwin")
643
+ return { cmd: "open", args: [file], opts };
644
+ if (os === "win32")
645
+ return { cmd: "explorer.exe", args: [file], opts };
646
+ return null;
647
+ }
648
+ export async function openBrowser(url, d = {}) {
649
+ if (!LINK_RE.test(url))
650
+ throw new Error("not a board link");
651
+ const os = d.os ?? platform();
652
+ const find = d.find ?? ((n) => which(n));
653
+ if (os === "linux") {
654
+ const portal = d.portal ?? (async (u) => (await import("./notice-linux.js")).portalOpenUri(u));
655
+ if (await portal(url).catch(() => false))
656
+ return "portal";
657
+ }
658
+ if (os === "darwin" || os === "win32") {
659
+ const page = redirectPage(url, d.pageDir ?? join(CONFIG_DIR, "bin"));
660
+ const how = fileOpenCommand(page, os);
661
+ return (await (d.launch ?? launch)(how)) ? "direct" : false;
662
+ }
663
+ const scope = os === "linux" && (d.inService ?? (() => inServiceCgroup()))();
664
+ const how = browserCommand(url, os, find, { scope });
665
+ if (!how)
666
+ return false;
667
+ if (!(await (d.launch ?? launch)(how)))
668
+ return false;
669
+ return how.cmd.endsWith("systemd-run") ? "scope" : "direct";
670
+ }
671
+ export function launch(l) {
672
+ return new Promise((done) => {
673
+ try {
674
+ const child = spawn(l.cmd, l.args, l.opts);
675
+ child.once("error", () => done(false));
676
+ child.once("spawn", () => { child.unref(); done(true); });
677
+ }
678
+ catch {
679
+ done(false);
680
+ }
681
+ });
682
+ }
683
+ export function terminalCommand(home, actionId, os = platform(), env = process.env, find = (n) => which(n), node = process.execPath, entry = CLI_ENTRY) {
684
+ if (!/^[0-9a-f]{16,64}$/.test(actionId))
685
+ return { error: "not an action id" };
686
+ const argv = [node, ...childNodeFlags(), entry, "explain", "--queued", "--only-action", "--action-id", actionId];
687
+ const opts = { detached: true, stdio: "ignore", env };
688
+ if (os === "darwin") {
689
+ const file = join(home, "bin", "pantheon-board-explain.command");
690
+ const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
691
+ mkdirSync(dirname(file), { recursive: true });
692
+ writeFileSync(file, `#!/bin/zsh\n${argv.map(q).join(" ")}\n`, { mode: 0o700 });
693
+ try {
694
+ chmodSync(file, 0o700);
695
+ }
696
+ catch { }
697
+ return { cmd: "open", args: [file], opts };
698
+ }
699
+ if (os === "win32") {
700
+ if (argv.some((a) => /["%\r\n]/.test(a)))
701
+ return { error: "the path to Pantheon holds a character the Windows console cannot pass safely" };
702
+ const line = `start "Pantheon" ${argv.map((a) => `"${a}"`).join(" ")}`;
703
+ return { cmd: "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], opts: { ...opts, windowsVerbatimArguments: true, windowsHide: true } };
704
+ }
705
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
706
+ return { error: "there is no desktop here to open a terminal on" };
707
+ const terms = [
708
+ ["x-terminal-emulator", ["-e"]], ["gnome-terminal", ["--"]], ["konsole", ["-e"]], ["xfce4-terminal", ["-x"]],
709
+ ["kitty", []], ["alacritty", ["-e"]], ["wezterm", ["start", "--"]], ["xterm", ["-e"]],
710
+ ];
711
+ for (const [name, pre] of terms) {
712
+ const bin = find(name);
713
+ if (bin)
714
+ return { cmd: bin, args: [...pre, ...argv], opts };
715
+ }
716
+ return { error: "no terminal program was found (x-terminal-emulator, gnome-terminal, konsole, xterm …)" };
717
+ }
718
+ async function openExplainTerminal(home, actionId) {
719
+ const t = terminalCommand(home, actionId);
720
+ if ("error" in t)
721
+ return { ok: false, why: `${t.error} — copy the question into your agent instead` };
722
+ return (await launch(t)) ? { ok: true } : { ok: false, why: "the terminal could not be started — copy the question into your agent instead" };
723
+ }
724
+ export async function ping(s, timeoutMs = 1500) {
725
+ const challenge = randomBytes(16).toString("hex");
726
+ try {
727
+ const res = await fetch(`http://127.0.0.1:${s.port}/api/ping?challenge=${challenge}`, { signal: AbortSignal.timeout(timeoutMs), redirect: "error" });
728
+ if (!res.ok)
729
+ return false;
730
+ const j = await res.json();
731
+ if (j.board !== true || j.pid !== s.pid || typeof j.proof !== "string" || !/^[0-9a-f]{64}$/.test(j.proof))
732
+ return false;
733
+ return timingSafeEqual(Buffer.from(j.proof, "hex"), Buffer.from(pingProof(s.token, s.port, challenge), "hex"));
734
+ }
735
+ catch {
736
+ return false;
737
+ }
738
+ }
739
+ export async function boardPost(s, path, body = {}) {
740
+ return fetch(`http://127.0.0.1:${s.port}${path}`, {
741
+ method: "POST",
742
+ headers: { Authorization: `Bearer ${s.token}`, Origin: `http://127.0.0.1:${s.port}`, "Content-Type": "application/json" },
743
+ body: JSON.stringify(body), signal: AbortSignal.timeout(3000),
744
+ });
745
+ }
746
+ const pidAlive = (pid) => { try {
747
+ process.kill(pid, 0);
748
+ return true;
749
+ }
750
+ catch {
751
+ return false;
752
+ } };
753
+ export function pidOwner(pid, os = platform()) {
754
+ if (os !== "linux")
755
+ return null;
756
+ try {
757
+ return statSync(`/proc/${pid}`).uid;
758
+ }
759
+ catch {
760
+ return null;
761
+ }
762
+ }
763
+ export async function runningBoard(home = CONFIG_DIR) {
764
+ const s = readBoardState(home);
765
+ if (!s || !pidAlive(s.pid))
766
+ return null;
767
+ const me = typeof process.getuid === "function" ? process.getuid() : null;
768
+ const owner = pidOwner(s.pid);
769
+ if (me !== null && owner !== null && owner !== me)
770
+ return null;
771
+ return (await ping(s)) ? s : null;
772
+ }
773
+ export async function boardLinkFor(s) {
774
+ const r = await boardPost(s, "/api/link");
775
+ const j = await r.json().catch(() => null);
776
+ const link = j?.link;
777
+ if (!r.ok || !j?.ok || typeof link !== "string" || !LINK_RE.test(link) || !link.startsWith(`http://127.0.0.1:${s.port}/`)) {
778
+ throw new Error("the board did not hand out a link — run pantheon board --stop, then pantheon board");
779
+ }
780
+ return link;
781
+ }
782
+ export async function ensureBoard(o = {}) {
783
+ const home = o.home ?? CONFIG_DIR;
784
+ const up = await runningBoard(home);
785
+ if (up) {
786
+ if (o.keep && !up.keep)
787
+ await boardPost(up, "/api/keep").catch(() => null);
788
+ return { state: up, url: await boardLinkFor(up), reused: true };
789
+ }
790
+ const before = readBoardState(home);
791
+ (o.start ?? (() => {
792
+ const argv = [process.execPath, ...childNodeFlags(), CLI_ENTRY, "board", "--serve", ...(o.keep ? ["--keep"] : [])];
793
+ const start = (run) => {
794
+ const child = spawn(run[0], run.slice(1), { detached: true, stdio: "ignore", windowsHide: true, cwd: homedir(), env: process.env });
795
+ child.unref();
796
+ return child;
797
+ };
798
+ const scoped = inServiceCgroup() ? scopeArgv(argv) : null;
799
+ if (!scoped) {
800
+ start(argv).on("error", () => { });
801
+ return;
802
+ }
803
+ let fell = false;
804
+ const fallBack = () => { if (!fell) {
805
+ fell = true;
806
+ start(argv).on("error", () => { });
807
+ } };
808
+ const c = start(scoped);
809
+ c.on("error", fallBack);
810
+ c.on("exit", (code) => { if (code !== 0 && code !== null)
811
+ fallBack(); });
812
+ }))();
813
+ const until = Date.now() + (o.waitMs ?? 15_000);
814
+ while (Date.now() < until) {
815
+ await new Promise((r) => setTimeout(r, 150));
816
+ const s = readBoardState(home);
817
+ if (s && (!before || s.pid !== before.pid || s.started !== before.started) && await ping(s))
818
+ return { state: s, url: await boardLinkFor(s), reused: false };
819
+ }
820
+ throw new Error("the board did not start — run pantheon board --serve to see why");
821
+ }
822
+ export async function serveBoard(o = {}) {
823
+ const home = o.home ?? CONFIG_DIR;
824
+ if (await runningBoard(home))
825
+ return;
826
+ let resolveDone;
827
+ const done = new Promise((r) => { resolveDone = r; });
828
+ const b = await startBoardServer({ keep: !!o.keep, home, onIdle: () => resolveDone() });
829
+ writeBoardState({ pid: process.pid, port: b.port, token: b.token, started: Date.now(), keep: !!o.keep, version: VERSION }, home);
830
+ const stop = () => { void b.close().then(() => resolveDone()); };
831
+ process.on("SIGINT", stop);
832
+ process.on("SIGTERM", stop);
833
+ await done;
834
+ clearBoardState(process.pid, home);
835
+ }
836
+ export const termSafe = (s) => s.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "").replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]+/g, " ").replace(/\s+/g, " ").trim();
837
+ export function ago(ms, now) {
838
+ if (!ms)
839
+ return "";
840
+ const s = Math.max(0, Math.round((now - ms) / 1000));
841
+ if (s < 60)
842
+ return "just now";
843
+ const m = Math.round(s / 60);
844
+ if (m < 60)
845
+ return `${m}m ago`;
846
+ const h = Math.round(m / 60);
847
+ if (h < 48)
848
+ return `${h}h ago`;
849
+ return `${Math.round(h / 24)}d ago`;
850
+ }
851
+ const arg = (s) => (/^[A-Za-z0-9._@:/-]+$/.test(s) ? s : `"${s.replace(/["\\$`%]/g, "")}"`);
852
+ export function watchCommands(i, multi) {
853
+ const sp = i.space ? ` --space ${arg(i.space)}` : "";
854
+ if (i.kind === "decision") {
855
+ const team = multi && i.space ? ` --space ${arg(i.space)}` : "";
856
+ const out = i.choices?.length
857
+ ? [`pantheon approvals answer ${i.id} ${i.choices.map((c) => `"${c.answer}"`).join(" | ")}${team}`]
858
+ : [`pantheon approvals answer ${i.id} "<your answer>"${team}`];
859
+ out.push(`pantheon explain ${i.id} (open it in your agent)`);
860
+ return out;
861
+ }
862
+ if (!i.ref)
863
+ return [];
864
+ if (i.kind === "owed")
865
+ return [`pantheon say ANSWER "<your reply>" --ref ${i.ref}${sp}`, `pantheon show ${i.ref}${sp}`];
866
+ return [`pantheon say NOTE "<your note>" --ref ${i.ref}${sp}`, `pantheon show ${i.ref}${sp}`];
867
+ }
868
+ export function renderWatch(s, o = { now: Date.now() }) {
869
+ const width = Math.max(40, o.width ?? 100);
870
+ const c = (code, t) => (o.color ? `\x1b[${code}m${t}\x1b[0m` : t);
871
+ const B = (t) => c("1", t), D = (t) => c("2", t), Y = (t) => c("33", t);
872
+ const fit = (t, n = width) => (t.length > n ? `${t.slice(0, n - 1)}…` : t);
873
+ const lines = [];
874
+ const stamp = new Date(o.now).toLocaleTimeString();
875
+ if (!s.configured) {
876
+ lines.push(B("Pantheon board"), "", "This machine is not signed in to a team.", "", D(" pantheon start start a team"), D(" pantheon join <invite> join one"));
877
+ return lines.join("\n") + "\n";
878
+ }
879
+ const spaces = s.spaces ?? (s.space ? [s.space] : []);
880
+ const multi = spaces.length > 1;
881
+ lines.push(fit(`${B("Pantheon board")} ${termSafe(s.member ?? "")} · ${termSafe(multi ? `${spaces.length} teams` : (s.space ?? ""))} · ${s.relay_ok ? `${s.online ?? 0} of ${s.members ?? 0} online` : "relay unreachable"} · worker ${s.worker_running ? "running" : "off"}`, width + (o.color ? 8 : 0)));
882
+ if (s.relay_ok === false || s.decisions_ok === false)
883
+ lines.push(Y(fit(`! Could not reach: ${termSafe((s.unavailable_spaces ?? []).join(", ") || "the relay")} — what is shown may be incomplete`)));
884
+ const items = s.items ?? [];
885
+ for (const lane of LANES) {
886
+ const mine = items.filter((i) => i.lane === lane.id);
887
+ lines.push("", B(`${lane.title} (${mine.length})`));
888
+ if (!mine.length) {
889
+ lines.push(D(` ${lane.empty}`));
890
+ continue;
891
+ }
892
+ for (const i of mine) {
893
+ const meta = [termSafe(i.who), ago(i.when, o.now), multi && i.space ? `team ${termSafe(i.space)}` : ""].filter(Boolean).join(" · ");
894
+ lines.push(fit(` • ${termSafe(i.headline || i.title)}`));
895
+ lines.push(D(fit(` ${meta} ${i.id}`)));
896
+ if (i.context)
897
+ lines.push(D(fit(` for: ${termSafe(i.context)}`)));
898
+ if (lane.id === "now")
899
+ for (const cmd of watchCommands({ ...i, ...(i.space ? { space: termSafe(i.space) } : {}) }, multi))
900
+ lines.push(` ${cmd}`);
901
+ }
902
+ }
903
+ lines.push("", D(fit(`updated ${stamp} · refreshes every ${BOARD_REFRESH_MS / 1000}s · r refresh · q quit · the board in a browser: pantheon board`)));
904
+ return lines.join("\n") + "\n";
905
+ }
906
+ export async function watchBoard(o = {}) {
907
+ const status = o.status ?? (async () => (await import("./tray.js")).trayStatus());
908
+ const out = o.out ?? process.stdout;
909
+ const input = o.input ?? process.stdin;
910
+ const color = !!out.isTTY && !process.env.NO_COLOR;
911
+ const frame = async () => {
912
+ let text;
913
+ try {
914
+ text = renderWatch(await status(), { now: Date.now(), width: (out.columns ?? 100) - 1, color });
915
+ }
916
+ catch (e) {
917
+ text = `Pantheon board\n\ncould not read the board: ${termSafe(e.message)}\n`;
918
+ }
919
+ return text;
920
+ };
921
+ if (!out.isTTY) {
922
+ out.write(await frame());
923
+ return;
924
+ }
925
+ let stopped = false;
926
+ let timer = null;
927
+ const draw = async () => {
928
+ const text = await frame();
929
+ if (stopped)
930
+ return;
931
+ out.write(`\x1b[H${text.replace(/\n/g, "\x1b[K\n")}\x1b[J`);
932
+ };
933
+ out.write("\x1b[?25l\x1b[2J");
934
+ await new Promise((resolveDone) => {
935
+ const finish = () => {
936
+ if (stopped)
937
+ return;
938
+ stopped = true;
939
+ if (timer)
940
+ clearInterval(timer);
941
+ if (input.isTTY) {
942
+ try {
943
+ input.setRawMode(false);
944
+ }
945
+ catch { }
946
+ input.pause();
947
+ }
948
+ out.write("\x1b[?25h\n");
949
+ resolveDone();
950
+ };
951
+ if (input.isTTY) {
952
+ input.setRawMode(true);
953
+ input.resume();
954
+ input.on("data", (d) => {
955
+ const k = d.toString();
956
+ if (k === "q" || k === "Q" || k === "\u0003" || k === "\u001b")
957
+ finish();
958
+ else if (k === "r" || k === "R")
959
+ void draw();
960
+ });
961
+ }
962
+ process.once("SIGINT", finish);
963
+ process.once("SIGTERM", finish);
964
+ void draw();
965
+ timer = setInterval(() => { void draw(); }, BOARD_REFRESH_MS);
966
+ });
967
+ }
968
+ export function boardHtml(nonce) {
969
+ return `<!doctype html>
970
+ <html lang="en">
971
+ <head>
972
+ <meta charset="utf-8">
973
+ <meta name="viewport" content="width=device-width, initial-scale=1">
974
+ <meta name="referrer" content="no-referrer">
975
+ <meta name="color-scheme" content="light dark">
976
+ <title>Pantheon board</title>
977
+ <link rel="icon" href="data:,">
978
+ <style nonce="${nonce}">${BOARD_CSS}</style>
979
+ </head>
980
+ <body>
981
+ <a class="skip" href="#board">Skip to the board</a>
982
+ <header class="top">
983
+ <div class="title"><h1>Pantheon</h1><p id="who" class="muted"></p></div>
984
+ <div class="bar"><span id="health" class="muted"></span><button type="button" id="refresh">Refresh</button></div>
985
+ </header>
986
+ <div id="banner" class="banner" role="alert" hidden></div>
987
+ <main id="board" tabindex="-1" aria-busy="true">
988
+ <p id="loading" class="muted">Loading your board…</p>
989
+ ${LANES.map((l) => ` <section class="lane lane-${l.id}" aria-labelledby="h-${l.id}">
990
+ <h2 id="h-${l.id}">${l.title} <span class="count" id="c-${l.id}"></span></h2>
991
+ <ul id="l-${l.id}" class="${l.id === "now" ? "cards" : "rows"}"></ul>
992
+ <p id="e-${l.id}" class="empty muted" hidden>${l.empty}</p>
993
+ </section>`).join("\n")}
994
+ </main>
995
+ <dialog id="detail" aria-labelledby="d-title">
996
+ <div class="dlg">
997
+ <div class="dlg-head"><h2 id="d-title"></h2><button type="button" id="d-close" aria-label="Close">Close</button></div>
998
+ <p id="d-meta" class="muted"></p>
999
+ <p id="d-owner" class="owner"></p>
1000
+ <p id="d-context" class="muted" hidden></p>
1001
+ <div id="d-body" class="body"></div>
1002
+ <div id="d-options" class="choices" role="group" aria-label="Answer with one of its options" hidden></div>
1003
+ <div id="d-choices" class="choices" role="group" aria-label="One-click answers" hidden></div>
1004
+ <div class="reply">
1005
+ <label for="d-kind">Reply as</label>
1006
+ <select id="d-kind"></select>
1007
+ <label for="d-text" class="sr">Your words</label>
1008
+ <textarea id="d-text" rows="4" placeholder="Your own words. Nothing is sent until you press Send."></textarea>
1009
+ <div class="row"><button type="button" id="d-send" class="primary">Send</button><span class="muted hint">Ctrl+Enter sends</span></div>
1010
+ </div>
1011
+ <p id="d-status" role="status" aria-live="polite"></p>
1012
+ <div class="row"><button type="button" id="d-retract" hidden>Retract what I sent</button></div>
1013
+ <div class="row actions" id="d-actions"></div>
1014
+ <section id="d-thread" class="thread" hidden aria-label="The thread"></section>
1015
+ </div>
1016
+ </dialog>
1017
+ <p id="toast" class="toast" role="status" aria-live="polite"></p>
1018
+ <script nonce="${nonce}">${BOARD_JS}</script>
1019
+ </body>
1020
+ </html>
1021
+ `;
1022
+ }
1023
+ const BOARD_CSS = `
1024
+ :root{--bg:#f7f7f5;--card:#fff;--text:#1b1b1b;--muted:#545454;--line:#d6d6d2;--accent:#0b57d0;--accent-text:#fff;--warn-bg:#fff4d6;--warn-text:#4d3800;--err-bg:#fde7e7;--err-text:#6b0f0f;--focus:#0b57d0;--chip:#ececea}
1025
+ @media (prefers-color-scheme:dark){:root{--bg:#121212;--card:#1d1d1d;--text:#ececec;--muted:#b0b0b0;--line:#3a3a3a;--accent:#8ab4f8;--accent-text:#0b1a33;--warn-bg:#3a2f0b;--warn-text:#ffe7a3;--err-bg:#3d1414;--err-text:#ffd0d0;--focus:#8ab4f8;--chip:#2a2a2a}}
1026
+ *{box-sizing:border-box}
1027
+ body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,Ubuntu,Cantarell,"Noto Sans",sans-serif}
1028
+ .skip{position:absolute;left:-999px;top:0;background:var(--card);color:var(--text);padding:8px}
1029
+ .skip:focus{left:8px}
1030
+ .top{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--line)}
1031
+ .top h1{font-size:20px;margin:0}
1032
+ .title p{margin:2px 0 0}
1033
+ .bar{display:flex;gap:12px;align-items:center}
1034
+ .muted{color:var(--muted)}
1035
+ main{max-width:980px;margin:0 auto;padding:12px 16px 48px}
1036
+ main:focus{outline:none}
1037
+ .lane h2{font-size:16px;margin:24px 0 8px}
1038
+ .count{color:var(--muted);font-weight:400}
1039
+ ul{list-style:none;margin:0;padding:0}
1040
+ .cards>li{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px 16px;margin:0 0 10px}
1041
+ .rows>li{border-bottom:1px solid var(--line);padding:8px 2px}
1042
+ h3{font-size:15px;margin:0 0 4px;font-weight:600}
1043
+ .linkish{all:unset;cursor:pointer;color:var(--text);overflow-wrap:anywhere}
1044
+ .linkish:hover{text-decoration:underline}
1045
+ .linkish:focus-visible,button:focus-visible,select:focus-visible,textarea:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}
1046
+ .meta{margin:0;font-size:13px;color:var(--muted)}
1047
+ .chip{display:inline-block;background:var(--chip);border-radius:6px;padding:0 6px;margin-left:4px;font-size:12px;color:var(--text)}
1048
+ .ctx{margin:4px 0 0;font-size:13px;color:var(--muted)}
1049
+ .actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:10px}
1050
+ .choices{display:flex;flex-wrap:wrap;gap:8px;margin:10px 0}
1051
+ button{font:inherit;font-size:14px;padding:6px 12px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--text);cursor:pointer}
1052
+ button:hover{border-color:var(--muted)}
1053
+ button[disabled]{opacity:.55;cursor:default}
1054
+ .primary{background:var(--accent);border-color:var(--accent);color:var(--accent-text)}
1055
+ .banner{max-width:980px;margin:12px auto 0;padding:10px 14px;border-radius:8px;background:var(--warn-bg);color:var(--warn-text)}
1056
+ .banner.err{background:var(--err-bg);color:var(--err-text)}
1057
+ .empty{margin:4px 0}
1058
+ dialog{border:1px solid var(--line);border-radius:12px;padding:0;background:var(--card);color:var(--text);width:min(760px,96vw);max-height:92vh}
1059
+ dialog::backdrop{background:rgba(0,0,0,.45)}
1060
+ .dlg{padding:18px 20px}
1061
+ .dlg-head{display:flex;gap:12px;align-items:flex-start;justify-content:space-between}
1062
+ .dlg-head h2{font-size:18px;margin:0;overflow-wrap:anywhere}
1063
+ .owner{font-weight:600;margin:8px 0}
1064
+ .body p{margin:8px 0;white-space:pre-wrap;overflow-wrap:anywhere}
1065
+ .body ul{list-style:disc;padding-left:22px}
1066
+ pre{background:var(--chip);padding:8px 10px;border-radius:6px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere}
1067
+ .reply{display:grid;gap:6px;margin:12px 0;border-top:1px solid var(--line);padding-top:12px}
1068
+ .reply select{justify-self:start}
1069
+ select,textarea{font:inherit;color:var(--text);background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:6px 8px}
1070
+ textarea{width:100%;resize:vertical}
1071
+ .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
1072
+ .hint{font-size:12px}
1073
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
1074
+ .thread{border-top:1px solid var(--line);margin-top:12px;padding-top:8px}
1075
+ .thread article{border-bottom:1px solid var(--line);padding:8px 0}
1076
+ .thread .body-text{white-space:pre-wrap;overflow-wrap:anywhere;margin:4px 0 0}
1077
+ .toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--text);color:var(--bg);padding:8px 14px;border-radius:8px;margin:0;max-width:90vw}
1078
+ .toast:empty{display:none}
1079
+ .copybox{width:100%;margin-top:8px}
1080
+ `;
1081
+ const BOARD_JS = `
1082
+ (() => {
1083
+ "use strict";
1084
+ const $ = (id) => document.getElementById(id);
1085
+ const LANES = ${JSON.stringify(LANES.map((l) => l.id))};
1086
+ // The link's single-use nonce, traded once for a session secret. The secret
1087
+ // lives in sessionStorage (this exact origin, port included) and goes out only
1088
+ // as a header this page sets — never as a cookie, which the browser would also
1089
+ // send to every other port on 127.0.0.1.
1090
+ const SESSION_KEY = "pantheon-board-session";
1091
+ let link = null;
1092
+ let session = null;
1093
+ {
1094
+ const m = /^#open=([0-9a-f]{64})$/.exec(location.hash);
1095
+ if (m) link = m[1];
1096
+ // The nonce leaves the address bar and this history entry before anything else runs.
1097
+ history.replaceState(null, "", "/");
1098
+ }
1099
+ try { const s = sessionStorage.getItem(SESSION_KEY); if (s && /^[0-9a-f]{64}$/.test(s)) session = s; } catch { /* storage off: this tab only */ }
1100
+ function keepSession(s) { session = s; try { if (s) sessionStorage.setItem(SESSION_KEY, s); else sessionStorage.removeItem(SESSION_KEY); } catch { /* storage off */ } }
1101
+ let state = null;
1102
+ let current = null;
1103
+ let lastSent = null;
1104
+ let retractTimer = null;
1105
+ let timer = null;
1106
+ let refreshMs = ${BOARD_REFRESH_MS};
1107
+
1108
+ function el(tag, cls, text) {
1109
+ const e = document.createElement(tag);
1110
+ if (cls) e.className = cls;
1111
+ if (text !== undefined && text !== null) e.textContent = String(text);
1112
+ return e;
1113
+ }
1114
+ function button(label, onClick, cls) {
1115
+ const b = el("button", cls || "", label);
1116
+ b.type = "button";
1117
+ b.addEventListener("click", onClick);
1118
+ return b;
1119
+ }
1120
+ function ago(ms) {
1121
+ if (!ms) return "";
1122
+ const s = Math.max(0, Math.round((Date.now() - ms) / 1000));
1123
+ if (s < 60) return "just now";
1124
+ const m = Math.round(s / 60); if (m < 60) return m + "m ago";
1125
+ const h = Math.round(m / 60); if (h < 48) return h + "h ago";
1126
+ return Math.round(h / 24) + "d ago";
1127
+ }
1128
+ let toastTimer = null;
1129
+ function toast(msg) {
1130
+ $("toast").textContent = msg;
1131
+ if (toastTimer) clearTimeout(toastTimer);
1132
+ toastTimer = setTimeout(() => { $("toast").textContent = ""; }, 6000);
1133
+ }
1134
+ function banner(msg, isError) {
1135
+ const b = $("banner");
1136
+ if (!msg) { b.hidden = true; b.textContent = ""; return; }
1137
+ // The same words again are not news: an alert re-announced every refresh is noise.
1138
+ if (!b.hidden && b.textContent === msg) return;
1139
+ b.textContent = msg;
1140
+ b.className = isError ? "banner err" : "banner";
1141
+ b.hidden = false;
1142
+ }
1143
+
1144
+ async function api(path, body, bearer) {
1145
+ const headers = { "Accept": "application/json" };
1146
+ const cred = bearer || session;
1147
+ if (cred) headers["Authorization"] = "Bearer " + cred;
1148
+ const init = { method: body === undefined ? "GET" : "POST", headers, credentials: "omit", cache: "no-store", redirect: "error" };
1149
+ if (body !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); }
1150
+ let res;
1151
+ try { res = await fetch(path, init); }
1152
+ catch { const e = new Error("gone"); e.gone = true; throw e; }
1153
+ let j = null;
1154
+ try { j = await res.json(); } catch { j = null; }
1155
+ if (res.status === 401) { const e = new Error("auth"); e.auth = true; throw e; }
1156
+ if (!res.ok) throw new Error((j && j.error) || ("the board answered " + res.status));
1157
+ return j;
1158
+ }
1159
+
1160
+ function multi() { return !!(state && state.spaces && state.spaces.length > 1); }
1161
+ function surface() { return (state && state.surface_name) || "your agent"; }
1162
+ function key(item) { return (item.space || "") + "|" + item.id; }
1163
+
1164
+ function render() {
1165
+ const s = state;
1166
+ $("loading").hidden = true;
1167
+ $("board").setAttribute("aria-busy", "false");
1168
+ if (!s.configured) {
1169
+ $("who").textContent = "";
1170
+ $("health").textContent = "";
1171
+ banner("This machine is not signed in to a team. Run pantheon start to start one, or pantheon join with your invite.", true);
1172
+ for (const l of LANES) { $("l-" + l).replaceChildren(); $("c-" + l).textContent = ""; $("e-" + l).hidden = false; }
1173
+ return;
1174
+ }
1175
+ const spaces = s.spaces || (s.space ? [s.space] : []);
1176
+ $("who").textContent = (s.member || "") + " · " + (spaces.length > 1 ? spaces.length + " teams: " + spaces.join(", ") : "team " + (s.space || ""));
1177
+ $("health").textContent = (s.relay_ok ? (s.online || 0) + " of " + (s.members || 0) + " online" : "relay unreachable") + " · worker " + (s.worker_running ? "running" : "off") + (s.worker_state ? " (" + s.worker_state + ")" : "");
1178
+ const down = (s.unavailable_spaces || []);
1179
+ const items = s.items || [];
1180
+ if (!s.relay_ok && !items.length) banner("The relay could not be reached" + (down.length ? " for " + down.join(", ") : "") + ". The board will try again in " + Math.round(refreshMs / 1000) + " seconds.", true);
1181
+ else if (down.length || s.decisions_ok === false) banner("Could not reach: " + (down.join(", ") || "the relay") + ". What is shown may be incomplete.", false);
1182
+ else banner("");
1183
+ // Keep the keyboard where it was across a redraw.
1184
+ const f = document.activeElement;
1185
+ const keep = f && f.dataset ? { k: f.dataset.k, a: f.dataset.a } : null;
1186
+ for (const lane of LANES) {
1187
+ const list = $("l-" + lane);
1188
+ const mine = items.filter((i) => i.lane === lane);
1189
+ $("c-" + lane).textContent = "(" + mine.length + ")";
1190
+ $("e-" + lane).hidden = mine.length > 0;
1191
+ list.replaceChildren(...mine.map((i) => row(i, lane === "now")));
1192
+ }
1193
+ if (keep && keep.k) {
1194
+ for (const b of document.querySelectorAll("[data-k]")) if (b.dataset.k === keep.k && b.dataset.a === keep.a) { b.focus(); break; }
1195
+ }
1196
+ }
1197
+
1198
+ function tag(b, item, a) { b.dataset.k = key(item); b.dataset.a = a; return b; }
1199
+
1200
+ function row(item, card) {
1201
+ const li = el("li");
1202
+ const h = el("h3");
1203
+ h.append(tag(button(item.headline || item.title, () => openDetail(item), "linkish"), item, "open"));
1204
+ li.append(h);
1205
+ const meta = el("p", "meta");
1206
+ meta.append(el("span", "", [item.who, ago(item.when)].filter(Boolean).join(" · ")));
1207
+ if (multi() && item.space) meta.append(el("span", "chip", item.space));
1208
+ if (item.kind === "decision") meta.append(el("span", "chip", "your decision"));
1209
+ else if (item.kind === "owed") meta.append(el("span", "chip", item.promise ? "your agent promised" : "your side owes"));
1210
+ else meta.append(el("span", "chip", item.decider ? item.decider + " decides" : "waiting on " + item.who));
1211
+ li.append(meta);
1212
+ if (item.context) li.append(el("p", "ctx", "For: " + item.context));
1213
+ const acts = el("div", "actions");
1214
+ if (card) {
1215
+ (item.choices || []).forEach((c, idx) => acts.append(tag(button(c.label, (ev) => act({ op: "choose", id: item.id, space: item.space, index: idx }, ev.currentTarget, c.label + " — sent."), idx === 0 ? "primary" : ""), item, "c" + idx)));
1216
+ if (item.kind === "decision") acts.append(tag(button("Mark done", (ev) => act({ op: "done", id: item.id, space: item.space }, ev.currentTarget), ""), item, "done"));
1217
+ acts.append(tag(button("Reply…", () => openDetail(item, true)), item, "reply"));
1218
+ acts.append(tag(button("Explain in " + surface(), (ev) => act({ op: "open", id: item.id, space: item.space }, ev.currentTarget)), item, "explain"));
1219
+ } else {
1220
+ acts.append(tag(button("Details", () => openDetail(item)), item, "details"));
1221
+ }
1222
+ acts.append(tag(button("Copy the question", () => copy(item.prompt)), item, "copy"));
1223
+ li.append(acts);
1224
+ return li;
1225
+ }
1226
+
1227
+ async function copy(text) {
1228
+ try { await navigator.clipboard.writeText(text); toast("Copied — paste it into your agent."); return; }
1229
+ catch { /* fall back to a box the person copies from */ }
1230
+ const box = el("textarea", "copybox");
1231
+ box.readOnly = true;
1232
+ box.value = text;
1233
+ box.setAttribute("aria-label", "The question, to copy");
1234
+ const host = $("detail").open ? $("d-status") : $("banner");
1235
+ host.replaceChildren(box);
1236
+ host.hidden = false;
1237
+ box.focus(); box.select();
1238
+ toast("Press Ctrl+C (or Cmd+C) to copy.");
1239
+ }
1240
+
1241
+ async function act(body, btn, okText) {
1242
+ if (btn) btn.disabled = true;
1243
+ toast("Working…");
1244
+ try {
1245
+ const r = await api("/api/action", body);
1246
+ toast(r.ok ? (okText || r.message) : "Not sent — " + r.message);
1247
+ if (r.ok) await refresh();
1248
+ return r;
1249
+ } catch (e) {
1250
+ toast(e.auth ? "This board link has expired. Run pantheon board to open a fresh one." : e.gone ? "The board has stopped. Run pantheon board to start it again." : "Not sent — " + e.message);
1251
+ return null;
1252
+ } finally { if (btn) btn.disabled = false; }
1253
+ }
1254
+
1255
+ function ownerLine(item) {
1256
+ if (item.kind === "decision") return "Yours to decide.";
1257
+ if (item.decider) return item.decider + "'s to decide. You can add a note or ask back.";
1258
+ if (item.kind === "owed") return "Your agent owes this. You can add a note or ask back.";
1259
+ return "Waiting on " + item.who + ". You can add a note or ask back.";
1260
+ }
1261
+
1262
+ function blocks(host, list) {
1263
+ host.replaceChildren();
1264
+ for (const b of list) {
1265
+ if (b.kind === "para") host.append(el("p", "", b.text));
1266
+ else if (b.kind === "bullets") { const ul = el("ul"); for (const t of b.items || []) ul.append(el("li", "", t)); host.append(ul); }
1267
+ else if (b.kind === "code") { const pre = el("pre"); pre.append(el("code", "", (b.lines || []).join("\\n"))); host.append(pre); }
1268
+ }
1269
+ }
1270
+
1271
+ function setStatus(msg) { $("d-status").replaceChildren(); $("d-status").textContent = msg || ""; }
1272
+
1273
+ async function openDetail(item, focusReply) {
1274
+ current = item;
1275
+ const d = $("detail");
1276
+ $("d-title").textContent = item.headline || item.title;
1277
+ $("d-meta").textContent = [item.who, ago(item.when), item.space ? "team " + item.space : ""].filter(Boolean).join(" · ");
1278
+ $("d-owner").textContent = ownerLine(item);
1279
+ $("d-context").hidden = !item.context;
1280
+ $("d-context").textContent = item.context ? "For: " + item.context : "";
1281
+ blocks($("d-body"), [{ kind: "para", text: item.detail || "" }]);
1282
+ $("d-options").hidden = true; $("d-options").replaceChildren();
1283
+ const ch = $("d-choices"); ch.replaceChildren();
1284
+ (item.choices || []).forEach((c, idx) => ch.append(button(c.label, (ev) => act({ op: "choose", id: item.id, space: item.space, index: idx }, ev.currentTarget, c.label + " — sent.").then((r) => { if (r && r.ok) d.close(); }), idx === 0 ? "primary" : "")));
1285
+ ch.hidden = !(item.choices && item.choices.length);
1286
+ const kind = $("d-kind"); kind.replaceChildren();
1287
+ for (const [v, label] of (item.kind === "decision" ? [["ANSWER", "Answer"], ["NOTE", "Note"], ["ASK", "Ask back"]] : [["NOTE", "Note"], ["ASK", "Ask back"]])) { const o = el("option", "", label); o.value = v; kind.append(o); }
1288
+ $("d-text").value = "";
1289
+ setStatus("");
1290
+ $("d-retract").hidden = true;
1291
+ const actions = $("d-actions"); actions.replaceChildren();
1292
+ if (item.kind === "decision") actions.append(button("Mark done", (ev) => act({ op: "done", id: item.id, space: item.space }, ev.currentTarget).then((r) => { if (r && r.ok) d.close(); })));
1293
+ actions.append(button("Explain in " + surface(), (ev) => act({ op: "open", id: item.id, space: item.space }, ev.currentTarget)));
1294
+ if (item.goal && item.space) actions.append(button("Show the thread", () => thread(item)));
1295
+ actions.append(button("Copy the question", () => copy(item.prompt)));
1296
+ $("d-thread").hidden = true; $("d-thread").replaceChildren();
1297
+ if (!d.open) d.showModal();
1298
+ if (focusReply) $("d-text").focus(); else $("d-close").focus();
1299
+ if (!item.ref) return;
1300
+ try {
1301
+ const q = "/api/detail?id=" + encodeURIComponent(item.ref) + (item.space ? "&space=" + encodeURIComponent(item.space) : "");
1302
+ const j = await api(q);
1303
+ if (current !== item || !j || !j.ok) return;
1304
+ if (Array.isArray(j.blocks) && j.blocks.length) blocks($("d-body"), j.blocks);
1305
+ if (item.kind === "decision" && Array.isArray(j.options) && j.options.length) {
1306
+ const host = $("d-options");
1307
+ for (const o of j.options) host.append(button("(" + o.key + ") " + o.text, (ev) => act({ op: "reply", id: item.id, space: item.space, kind: "ANSWER", body: "(" + o.key + ") " + o.text }, ev.currentTarget, "Answered (" + o.key + ") — it is no longer waiting on you.").then((r) => { if (r && r.ok) d.close(); })));
1308
+ host.hidden = false;
1309
+ }
1310
+ } catch { /* the short text stays */ }
1311
+ }
1312
+
1313
+ async function thread(item) {
1314
+ const host = $("d-thread");
1315
+ host.hidden = false;
1316
+ host.replaceChildren(el("p", "muted", "Loading the thread…"));
1317
+ try {
1318
+ const j = await api("/api/thread?goal=" + encodeURIComponent(item.goal) + "&space=" + encodeURIComponent(item.space));
1319
+ host.replaceChildren(el("h3", "", "The thread"));
1320
+ if (!j.ok || !j.acts.length) { host.append(el("p", "muted", j.ok ? "Nothing in this thread yet." : j.error)); return; }
1321
+ for (const a of j.acts) {
1322
+ const art = el("article");
1323
+ art.append(el("p", "meta", [a.from, a.act, ago(a.ts)].filter(Boolean).join(" · ")));
1324
+ art.append(el("p", "body-text", a.summary && a.body.length > 600 ? a.summary : a.body));
1325
+ host.append(art);
1326
+ }
1327
+ } catch (e) { host.replaceChildren(el("p", "muted", "The thread could not be read: " + e.message)); }
1328
+ }
1329
+
1330
+ async function send() {
1331
+ const item = current;
1332
+ if (!item) return;
1333
+ const text = $("d-text").value.trim();
1334
+ if (!text) { setStatus("Type your reply first."); return; }
1335
+ const kind = $("d-kind").value;
1336
+ const btn = $("d-send");
1337
+ if (btn.disabled) return;
1338
+ btn.disabled = true;
1339
+ setStatus("Sending…");
1340
+ try {
1341
+ const r = await api("/api/action", { op: "reply", id: item.id, space: item.space, kind, body: text });
1342
+ if (!r.ok) { setStatus("Not sent — " + r.message + " Your words are still in the box."); return; }
1343
+ $("d-text").value = "";
1344
+ setStatus(kind === "ANSWER" ? "Answered — it is no longer waiting on you." : kind === "ASK" ? "Asked — the agent that raised this owes you a reply." : "Noted — it stays waiting.");
1345
+ if (r.sent) {
1346
+ lastSent = r.sent;
1347
+ $("d-retract").hidden = false;
1348
+ if (retractTimer) clearTimeout(retractTimer);
1349
+ retractTimer = setTimeout(() => { $("d-retract").hidden = true; lastSent = null; }, 60000);
1350
+ }
1351
+ refresh();
1352
+ } catch (e) {
1353
+ setStatus(e.auth ? "This board link has expired. Run pantheon board to open a fresh one." : "Not sent — " + (e.gone ? "the board has stopped." : e.message) + " Your words are still in the box.");
1354
+ } finally { btn.disabled = false; }
1355
+ }
1356
+
1357
+ async function retract() {
1358
+ if (!lastSent) return;
1359
+ const btn = $("d-retract");
1360
+ btn.disabled = true;
1361
+ setStatus("Retracting…");
1362
+ try {
1363
+ const r = await api("/api/action", { op: "retract", act: lastSent });
1364
+ if (r.ok) { lastSent = null; btn.hidden = true; setStatus("Retracted."); refresh(); }
1365
+ else setStatus("Could not retract — " + (r.message || r.error));
1366
+ } catch (e) { setStatus("Could not retract — " + e.message); }
1367
+ finally { btn.disabled = false; }
1368
+ }
1369
+
1370
+ async function refresh() {
1371
+ try {
1372
+ const j = await api("/api/board");
1373
+ if (!j.ok) { banner(j.error || "The board could not be read.", true); return; }
1374
+ if (j.refresh_ms) refreshMs = j.refresh_ms;
1375
+ state = j.status;
1376
+ render();
1377
+ $("refresh").title = "Updated " + new Date(j.at).toLocaleTimeString();
1378
+ } catch (e) {
1379
+ $("loading").hidden = true;
1380
+ if (e.auth) { keepSession(null); banner("This board link has expired. Run pantheon board to open a fresh one.", true); stop(); }
1381
+ else if (e.gone) { banner("The board has stopped — it stops after 30 minutes with no open tab. Run pantheon board to start it again.", true); stop(); }
1382
+ else banner("The board could not be read: " + e.message, true);
1383
+ }
1384
+ }
1385
+ function stop() { if (timer) { clearInterval(timer); timer = null; } }
1386
+ function schedule() { stop(); if (!document.hidden) timer = setInterval(refresh, refreshMs); }
1387
+
1388
+ $("refresh").addEventListener("click", () => refresh());
1389
+ $("d-close").addEventListener("click", () => $("detail").close());
1390
+ $("detail").addEventListener("close", () => { current = null; });
1391
+ $("d-send").addEventListener("click", () => send());
1392
+ $("d-retract").addEventListener("click", () => retract());
1393
+ $("d-text").addEventListener("keydown", (ev) => { if (ev.key === "Enter" && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); send(); } });
1394
+ // A hidden tab stops asking: the relay is not polled for nobody, and the board
1395
+ // can stop itself once no one is looking.
1396
+ document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); schedule(); });
1397
+
1398
+ (async () => {
1399
+ if (link) {
1400
+ const once = link;
1401
+ link = null;
1402
+ try {
1403
+ const j = await api("/api/session", {}, once);
1404
+ if (!j || typeof j.session !== "string" || !/^[0-9a-f]{64}$/.test(j.session)) throw new Error("no session");
1405
+ keepSession(j.session);
1406
+ } catch (e) {
1407
+ // A used or expired link, but this tab already has a session: carry on with it.
1408
+ if (!(e.auth && session)) { $("loading").hidden = true; banner(e.auth ? "This board link has expired or was already used. Run pantheon board to open a fresh one." : "The board could not be reached. Run pantheon board again.", true); return; }
1409
+ }
1410
+ }
1411
+ if (!session) { $("loading").hidden = true; banner("This board needs its link. Run pantheon board to open it.", true); return; }
1412
+ await refresh();
1413
+ schedule();
1414
+ })();
1415
+ })();
1416
+ `;
1417
+ //# sourceMappingURL=board.js.map