@oharness/server 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-pane.d.ts +48 -0
- package/dist/browser-pane.js +185 -0
- package/dist/browser-pane.js.map +1 -0
- package/dist/i18n.js +144 -0
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +20 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.d.ts +8 -0
- package/dist/page/client/account.js +1895 -0
- package/dist/page/client/account.js.map +1 -0
- package/dist/page/client/artifacts.d.ts +7 -0
- package/dist/page/client/artifacts.js +767 -0
- package/dist/page/client/artifacts.js.map +1 -0
- package/dist/page/client/base.d.ts +8 -0
- package/dist/page/client/base.js +400 -0
- package/dist/page/client/base.js.map +1 -0
- package/dist/page/client/browser.d.ts +21 -0
- package/dist/page/client/browser.js +904 -0
- package/dist/page/client/browser.js.map +1 -0
- package/dist/page/client/composer.d.ts +8 -0
- package/dist/page/client/composer.js +1694 -0
- package/dist/page/client/composer.js.map +1 -0
- package/dist/page/client/dialogs.d.ts +7 -0
- package/dist/page/client/dialogs.js +724 -0
- package/dist/page/client/dialogs.js.map +1 -0
- package/dist/page/client/events.d.ts +7 -0
- package/dist/page/client/events.js +288 -0
- package/dist/page/client/events.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +46 -0
- package/dist/page/client/index.js.map +1 -0
- package/dist/page/client/panels.d.ts +7 -0
- package/dist/page/client/panels.js +659 -0
- package/dist/page/client/panels.js.map +1 -0
- package/dist/page/client/pickers.d.ts +7 -0
- package/dist/page/client/pickers.js +624 -0
- package/dist/page/client/pickers.js.map +1 -0
- package/dist/page/client/routines.d.ts +6 -0
- package/dist/page/client/routines.js +910 -0
- package/dist/page/client/routines.js.map +1 -0
- package/dist/page/client/shell.d.ts +6 -0
- package/dist/page/client/shell.js +690 -0
- package/dist/page/client/shell.js.map +1 -0
- package/dist/page/client/state.d.ts +7 -0
- package/dist/page/client/state.js +1275 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +699 -0
- package/dist/page/client/transcript.js.map +1 -0
- package/dist/page/client/usage.d.ts +7 -0
- package/dist/page/client/usage.js +954 -0
- package/dist/page/client/usage.js.map +1 -0
- package/dist/page/style.d.ts +1 -0
- package/dist/page/style.js +2793 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +70 -13583
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +511 -25
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -17,6 +17,9 @@ import { visibleText } from "@oharness/engine/core/types.ts";
|
|
|
17
17
|
import { errorMessage } from "@oharness/engine/core/errors.ts";
|
|
18
18
|
import { officialBaseUrl, officialTeam } from "@oharness/engine/auth/providers/official.ts";
|
|
19
19
|
import { loadUserConfig, updateUserConfig } from "@oharness/engine/config.ts";
|
|
20
|
+
import { EXTENSION_ID, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/host-manifest.ts";
|
|
21
|
+
import { announcePresence } from "@oharness/engine/browser/presence.ts";
|
|
22
|
+
import { extensionHostReachable } from "@oharness/engine/browser/socket.ts";
|
|
20
23
|
import { RoutineRunner } from "@oharness/engine/schedule/runner.ts";
|
|
21
24
|
import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
|
|
22
25
|
import { ChangeLedger } from "./changes.js";
|
|
@@ -28,6 +31,7 @@ import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pi
|
|
|
28
31
|
import { browseFiles, searchFiles, within } from "./files.js";
|
|
29
32
|
import { analyticsState } from "./analytics.js";
|
|
30
33
|
import { PAGE } from "./page.js";
|
|
34
|
+
import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
|
|
31
35
|
import { artifactDocument, sendArtifact } from "./artifacts.js";
|
|
32
36
|
import { sendProjectExport } from "./project-export.js";
|
|
33
37
|
import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
|
|
@@ -204,12 +208,23 @@ export async function startWebServer(opts) {
|
|
|
204
208
|
const makeRunner = (session, harness) => {
|
|
205
209
|
const ledger = new ChangeLedger(session.cwd);
|
|
206
210
|
ledger.attach(harness.hooks);
|
|
207
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
session,
|
|
213
|
+
agent: harness.agent(session),
|
|
214
|
+
harness,
|
|
215
|
+
browser: harness.browserFor(session.id),
|
|
216
|
+
ledger,
|
|
217
|
+
queue: [],
|
|
218
|
+
};
|
|
208
219
|
};
|
|
209
220
|
/** The runner a request names, or the most recently focused one. */
|
|
210
221
|
let focused = opts.session.id;
|
|
211
222
|
const runnerFor = (id) => runners.get(typeof id === "string" && id ? id : focused);
|
|
212
|
-
|
|
223
|
+
const initial = makeRunner(opts.session, opts.harness);
|
|
224
|
+
// Preserve caller-supplied agent overrides for the initial session. Its
|
|
225
|
+
// browser is still the same one bound by Harness.agent(session).
|
|
226
|
+
initial.agent = opts.agent;
|
|
227
|
+
runners.set(opts.session.id, initial);
|
|
213
228
|
let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
|
|
214
229
|
/**
|
|
215
230
|
* Sign-ins waiting for their emailed code.
|
|
@@ -430,7 +445,12 @@ export async function startWebServer(opts) {
|
|
|
430
445
|
if (url.pathname === "/" && req.method === "GET") {
|
|
431
446
|
if (!authorized(url, req))
|
|
432
447
|
return send(res, 401, { error: "missing or bad token" });
|
|
433
|
-
res.writeHead(200, {
|
|
448
|
+
res.writeHead(200, {
|
|
449
|
+
"content-type": "text/html; charset=utf-8",
|
|
450
|
+
// Framed by itself and by the OHarness extension's side panel, and by
|
|
451
|
+
// nothing else: a page that drives a shell is not one to embed.
|
|
452
|
+
"content-security-policy": `frame-ancestors 'self' chrome-extension://${EXTENSION_ID}`,
|
|
453
|
+
});
|
|
434
454
|
res.end(PAGE);
|
|
435
455
|
return;
|
|
436
456
|
}
|
|
@@ -458,6 +478,201 @@ export async function startWebServer(opts) {
|
|
|
458
478
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
459
479
|
return send(res, 200, { pages: runner?.harness.extensions.listPages() ?? [] });
|
|
460
480
|
}
|
|
481
|
+
/*
|
|
482
|
+
* The browser pane: what state it is in, its picture, and the input of
|
|
483
|
+
* somebody who has taken it over.
|
|
484
|
+
*
|
|
485
|
+
* State is polled by every open page, so it is answered from what the
|
|
486
|
+
* session already knows and never starts a browser to find out — a person
|
|
487
|
+
* who opened the pane has not asked for Chrome.
|
|
488
|
+
*/
|
|
489
|
+
case "GET /browser/state": {
|
|
490
|
+
res.setHeader("cache-control", "no-store");
|
|
491
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
492
|
+
return send(res, 200, await browserState(runner?.browser));
|
|
493
|
+
}
|
|
494
|
+
case "GET /browser/stream": {
|
|
495
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
496
|
+
const quality = Number(url.searchParams.get("quality") ?? "85");
|
|
497
|
+
return streamFrames(runner?.browser, res, Number.isFinite(quality) ? Math.min(90, Math.max(20, quality)) : 85);
|
|
498
|
+
}
|
|
499
|
+
case "POST /browser/input": {
|
|
500
|
+
res.setHeader("cache-control", "no-store");
|
|
501
|
+
const body = await readJson(req);
|
|
502
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
503
|
+
const session = runner?.browser;
|
|
504
|
+
if (!session || !session.state().running)
|
|
505
|
+
return send(res, 409, { error: "no browser is running" });
|
|
506
|
+
const event = parseInputEvent(body.event);
|
|
507
|
+
if (!event)
|
|
508
|
+
return send(res, 400, { error: "unrecognised input event" });
|
|
509
|
+
await session.forward(event);
|
|
510
|
+
return send(res, 200, { ok: true });
|
|
511
|
+
}
|
|
512
|
+
case "POST /browser/navigate": {
|
|
513
|
+
res.setHeader("cache-control", "no-store");
|
|
514
|
+
const body = await readJson(req);
|
|
515
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
516
|
+
const session = runner?.browser;
|
|
517
|
+
if (!session)
|
|
518
|
+
return send(res, 409, { error: "browser is disabled" });
|
|
519
|
+
if (body.action === "reload") {
|
|
520
|
+
if (!session.running)
|
|
521
|
+
return send(res, 409, { error: "no browser is running" });
|
|
522
|
+
try {
|
|
523
|
+
return send(res, 200, await session.reload());
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
const raw = typeof body.url === "string" ? body.url.trim() : "";
|
|
530
|
+
if (!raw)
|
|
531
|
+
return send(res, 400, { error: "url is required" });
|
|
532
|
+
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::|\/|$)/i.test(raw);
|
|
533
|
+
const value = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `${local ? "http" : "https"}://${raw}`;
|
|
534
|
+
let parsed;
|
|
535
|
+
try {
|
|
536
|
+
parsed = new URL(value);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return send(res, 400, { error: "invalid url" });
|
|
540
|
+
}
|
|
541
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
542
|
+
return send(res, 400, { error: "only http and https URLs are supported" });
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
return send(res, 200, await session.navigate(parsed.href));
|
|
546
|
+
}
|
|
547
|
+
catch (error) {
|
|
548
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
case "POST /browser/tab": {
|
|
552
|
+
res.setHeader("cache-control", "no-store");
|
|
553
|
+
const body = await readJson(req);
|
|
554
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
555
|
+
const session = runner?.browser;
|
|
556
|
+
if (!session || !session.running)
|
|
557
|
+
return send(res, 409, { error: "no browser is running" });
|
|
558
|
+
if (typeof body.id !== "string" || !body.id)
|
|
559
|
+
return send(res, 400, { error: "tab id is required" });
|
|
560
|
+
if (body.action === "close") {
|
|
561
|
+
if (runner?.running)
|
|
562
|
+
return send(res, 409, { error: "stop the running turn before closing its browser tab" });
|
|
563
|
+
if (!session.closePaneTab)
|
|
564
|
+
return send(res, 501, { error: "this browser provider cannot close tabs" });
|
|
565
|
+
try {
|
|
566
|
+
return send(res, 200, await session.closePaneTab(body.id));
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
if (!session.selectPaneTab)
|
|
573
|
+
return send(res, 501, { error: "this browser provider cannot switch tabs" });
|
|
574
|
+
try {
|
|
575
|
+
return send(res, 200, await session.selectPaneTab(body.id));
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
case "POST /browser/close": {
|
|
582
|
+
res.setHeader("cache-control", "no-store");
|
|
583
|
+
const body = await readJson(req);
|
|
584
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
585
|
+
const session = runner?.browser;
|
|
586
|
+
if (!runner || !session)
|
|
587
|
+
return send(res, 404, { error: "no such browser session" });
|
|
588
|
+
if (runner.running)
|
|
589
|
+
return send(res, 409, { error: "stop the running turn before stopping its browser" });
|
|
590
|
+
if (!session.running)
|
|
591
|
+
return send(res, 200, { closed: false, running: false });
|
|
592
|
+
await session.close();
|
|
593
|
+
return send(res, 200, { closed: true, running: false });
|
|
594
|
+
}
|
|
595
|
+
/*
|
|
596
|
+
* Importing a site's sign-in from the person's own browser.
|
|
597
|
+
*
|
|
598
|
+
* GET lists what could be imported and names the browser; it lists tabs
|
|
599
|
+
* and attaches to none. POST is the dialog's confirmation — the only way
|
|
600
|
+
* anything is read out of that browser — or its "don't ask again".
|
|
601
|
+
*/
|
|
602
|
+
/**
|
|
603
|
+
* Hand the person's current tab to a conversation. Asked for by the
|
|
604
|
+
* extension's side panel, from the page the person is looking at — a
|
|
605
|
+
* click of theirs, never something the agent can call.
|
|
606
|
+
*/
|
|
607
|
+
case "POST /browser/adopt": {
|
|
608
|
+
res.setHeader("cache-control", "no-store");
|
|
609
|
+
const body = await readJson(req);
|
|
610
|
+
const session = runnerFor(typeof body.session === "string" ? body.session : null)?.browser;
|
|
611
|
+
if (!session)
|
|
612
|
+
return send(res, 404, { error: "browser is disabled" });
|
|
613
|
+
if (!session.adoptYourTab)
|
|
614
|
+
return send(res, 501, { error: "this browser cannot take one of your tabs" });
|
|
615
|
+
const tabId = typeof body.tabId === "number" || typeof body.tabId === "string" ? String(body.tabId) : "";
|
|
616
|
+
if (!tabId)
|
|
617
|
+
return send(res, 400, { error: "tabId is required" });
|
|
618
|
+
try {
|
|
619
|
+
return send(res, 200, await session.adoptYourTab(tabId));
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
return send(res, 409, { error: errorMessage(error) });
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** Whether the extension is connected, and how to get it; see extensionInfo. */
|
|
626
|
+
case "GET /browser/extension":
|
|
627
|
+
res.setHeader("cache-control", "no-store");
|
|
628
|
+
return send(res, 200, await extensionInfo());
|
|
629
|
+
/** Open the extension's folder, for loading it into Chrome by hand. */
|
|
630
|
+
case "POST /browser/extension/reveal": {
|
|
631
|
+
if (!opts.extensionDir || !(await isDirectory(opts.extensionDir)))
|
|
632
|
+
return send(res, 404, { error: "no extension folder here" });
|
|
633
|
+
const result = await revealDirectory(opts.extensionDir);
|
|
634
|
+
return send(res, "error" in result ? 500 : 200, result);
|
|
635
|
+
}
|
|
636
|
+
case "GET /browser/import": {
|
|
637
|
+
res.setHeader("cache-control", "no-store");
|
|
638
|
+
const session = runnerFor(url.searchParams.get("session"))?.browser;
|
|
639
|
+
if (!session)
|
|
640
|
+
return send(res, 404, { error: "browser is disabled" });
|
|
641
|
+
if (!session.loginImportSources)
|
|
642
|
+
return send(res, 501, { error: "this browser provider cannot import sign-ins" });
|
|
643
|
+
try {
|
|
644
|
+
return send(res, 200, await session.loginImportSources());
|
|
645
|
+
}
|
|
646
|
+
catch (error) {
|
|
647
|
+
return send(res, 409, { error: errorMessage(error) });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
case "POST /browser/import": {
|
|
651
|
+
res.setHeader("cache-control", "no-store");
|
|
652
|
+
const body = await readJson(req);
|
|
653
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
654
|
+
const session = runner?.browser;
|
|
655
|
+
if (!runner || !session)
|
|
656
|
+
return send(res, 404, { error: "no such browser session" });
|
|
657
|
+
if (body.action === "dismiss") {
|
|
658
|
+
if (typeof body.site !== "string")
|
|
659
|
+
return send(res, 400, { error: "site is required" });
|
|
660
|
+
await session.dismissLoginImport?.(body.site);
|
|
661
|
+
return send(res, 200, { ok: true });
|
|
662
|
+
}
|
|
663
|
+
// The import navigates the page the agent is using.
|
|
664
|
+
if (runner.running)
|
|
665
|
+
return send(res, 409, { error: "wait for the running turn to finish before importing a sign-in" });
|
|
666
|
+
if (!session.importLogin)
|
|
667
|
+
return send(res, 501, { error: "this browser provider cannot import sign-ins" });
|
|
668
|
+
const sites = Array.isArray(body.sites) ? body.sites.filter((site) => typeof site === "string").slice(0, 20) : [];
|
|
669
|
+
try {
|
|
670
|
+
return send(res, 200, await session.importLogin(sites));
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
461
676
|
case "GET /plugin-page": {
|
|
462
677
|
res.setHeader("cache-control", "no-store");
|
|
463
678
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
@@ -1370,6 +1585,10 @@ export async function startWebServer(opts) {
|
|
|
1370
1585
|
// The whole status, not just the path: the page needs to know
|
|
1371
1586
|
// whether the switch it draws is the user's to flip.
|
|
1372
1587
|
audit: await current.harness.auditStatus(),
|
|
1588
|
+
browserMode: current.harness.browserMode(),
|
|
1589
|
+
// For the browser row: whether the person's browser can be reached
|
|
1590
|
+
// now, and where to get the extension when it cannot.
|
|
1591
|
+
browserExtension: await extensionInfo(),
|
|
1373
1592
|
settingsPaths: {
|
|
1374
1593
|
user: current.harness.settings.pathFor("user"),
|
|
1375
1594
|
project: current.harness.settings.pathFor("project"),
|
|
@@ -1392,6 +1611,23 @@ export async function startWebServer(opts) {
|
|
|
1392
1611
|
const saved = await current.harness.setWritableRoots(body.roots);
|
|
1393
1612
|
return send(res, 200, { roots: saved });
|
|
1394
1613
|
}
|
|
1614
|
+
/**
|
|
1615
|
+
* The built-in browser or the person's own, through the extension.
|
|
1616
|
+
*
|
|
1617
|
+
* A machine preference, so every project's harness moves with it — a
|
|
1618
|
+
* switch that changed only the project on screen would look ignored the
|
|
1619
|
+
* moment someone opened another one.
|
|
1620
|
+
*/
|
|
1621
|
+
case "POST /settings/browser-mode": {
|
|
1622
|
+
const body = await readJson(req);
|
|
1623
|
+
if (body.mode !== "auto" && body.mode !== "builtin" && body.mode !== "extension") {
|
|
1624
|
+
return send(res, 400, { error: "mode must be auto, builtin or extension" });
|
|
1625
|
+
}
|
|
1626
|
+
for (const harness of new Set([current.harness, ...harnesses.values()])) {
|
|
1627
|
+
await harness.setBrowserMode(body.mode);
|
|
1628
|
+
}
|
|
1629
|
+
return send(res, 200, { mode: body.mode });
|
|
1630
|
+
}
|
|
1395
1631
|
/**
|
|
1396
1632
|
* Who is signed in, if anyone.
|
|
1397
1633
|
*
|
|
@@ -2253,6 +2489,7 @@ export async function startWebServer(opts) {
|
|
|
2253
2489
|
const runner = runners.get(id);
|
|
2254
2490
|
runner?.running?.abort();
|
|
2255
2491
|
runners.delete(id);
|
|
2492
|
+
await runner?.harness.closeBrowserFor(id);
|
|
2256
2493
|
// Its artifacts move from memory to the log half of the catalogue,
|
|
2257
2494
|
// and a fold taken before this session existed does not have them.
|
|
2258
2495
|
cachedLogs = undefined;
|
|
@@ -2282,7 +2519,9 @@ export async function startWebServer(opts) {
|
|
|
2282
2519
|
// knowing about it at all, not the absence of a transcript.
|
|
2283
2520
|
if (!deleted && !runners.has(id))
|
|
2284
2521
|
return send(res, 404, { error: "no such session" });
|
|
2522
|
+
const removed = runners.get(id);
|
|
2285
2523
|
runners.delete(id);
|
|
2524
|
+
await removed?.harness.closeBrowserFor(id);
|
|
2286
2525
|
// Its artifact log went with it, and a fold taken a moment ago
|
|
2287
2526
|
// still names it.
|
|
2288
2527
|
cachedLogs = undefined;
|
|
@@ -2344,6 +2583,7 @@ export async function startWebServer(opts) {
|
|
|
2344
2583
|
const runner = runnerFor(id);
|
|
2345
2584
|
if (runner)
|
|
2346
2585
|
runner.session.title = title;
|
|
2586
|
+
broadcast({ type: "title", session: id, title });
|
|
2347
2587
|
return send(res, 200, { ok: true, id, title });
|
|
2348
2588
|
}
|
|
2349
2589
|
case "POST /upload": {
|
|
@@ -2403,9 +2643,6 @@ export async function startWebServer(opts) {
|
|
|
2403
2643
|
if (!prompt && !ids.length && !references.length) {
|
|
2404
2644
|
return send(res, 400, { error: "prompt is required" });
|
|
2405
2645
|
}
|
|
2406
|
-
// Per runner, not global: a turn in one tab must not block another.
|
|
2407
|
-
if (runner.running)
|
|
2408
|
-
return send(res, 409, { error: "a turn is already running" });
|
|
2409
2646
|
const staged = ids
|
|
2410
2647
|
.map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
|
|
2411
2648
|
.filter((entry) => Boolean(entry));
|
|
@@ -2416,6 +2653,19 @@ export async function startWebServer(opts) {
|
|
|
2416
2653
|
// a composer is known to have moved on, so it is a good moment to ask
|
|
2417
2654
|
// what else has been abandoned.
|
|
2418
2655
|
sweepAttachments();
|
|
2656
|
+
// Per runner, not global: a turn in one tab must not block another.
|
|
2657
|
+
// A second prompt into a busy runner waits, and is shown waiting. It
|
|
2658
|
+
// is not handed to the running turn on its own: putting words in front
|
|
2659
|
+
// of a model mid-thought is a thing to choose, not a thing that
|
|
2660
|
+
// happens because you typed fast. /sendnow is that choice.
|
|
2661
|
+
//
|
|
2662
|
+
// This used to 409, and the page discarded the reply, so an impatient
|
|
2663
|
+
// Enter cleared the composer and lost what had been typed.
|
|
2664
|
+
if (runner.running) {
|
|
2665
|
+
runner.queue.push({ id: randomUUID(), prompt, staged, references });
|
|
2666
|
+
broadcastQueue(runner);
|
|
2667
|
+
return send(res, 202, { ok: true, queued: true });
|
|
2668
|
+
}
|
|
2419
2669
|
void runTurn(runner, prompt, staged, references);
|
|
2420
2670
|
return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
|
|
2421
2671
|
}
|
|
@@ -2451,6 +2701,84 @@ export async function startWebServer(opts) {
|
|
|
2451
2701
|
runnerFor(body.session)?.running?.abort();
|
|
2452
2702
|
}
|
|
2453
2703
|
return send(res, 200, { ok: true });
|
|
2704
|
+
case "POST /unqueue": {
|
|
2705
|
+
const body = await readJson(req);
|
|
2706
|
+
const runner = runnerFor(body.session);
|
|
2707
|
+
if (!runner)
|
|
2708
|
+
return send(res, 404, { error: "no such session" });
|
|
2709
|
+
// Either kind: one handed to the running turn but not yet read, or
|
|
2710
|
+
// one still waiting for the turn to end.
|
|
2711
|
+
if (typeof body.id === "string" && runner.agent.retract(body.id)) {
|
|
2712
|
+
broadcastQueue(runner);
|
|
2713
|
+
return send(res, 200, { ok: true });
|
|
2714
|
+
}
|
|
2715
|
+
const at = runner.queue.findIndex((entry) => entry.id === body.id);
|
|
2716
|
+
if (at >= 0) {
|
|
2717
|
+
// The bytes came out of the staging map when this was queued, so
|
|
2718
|
+
// they are held by the entry alone and go when it does.
|
|
2719
|
+
runner.queue.splice(at, 1);
|
|
2720
|
+
broadcastQueue(runner);
|
|
2721
|
+
}
|
|
2722
|
+
return send(res, 200, { ok: true });
|
|
2723
|
+
}
|
|
2724
|
+
/**
|
|
2725
|
+
* One waiting message, now, without waiting for its place in the queue.
|
|
2726
|
+
*
|
|
2727
|
+
* While a turn runs that means handing it to the turn — the same path a
|
|
2728
|
+
* prompt typed mid-turn takes — so it is read at the next gap rather
|
|
2729
|
+
* than after the whole thing. Nothing is interrupted: "now" here is as
|
|
2730
|
+
* soon as the conversation has a place to put it.
|
|
2731
|
+
*
|
|
2732
|
+
* A message carrying files cannot ride the inbox, so it moves to the
|
|
2733
|
+
* head of the queue instead and the reply says so.
|
|
2734
|
+
*/
|
|
2735
|
+
case "POST /sendnow": {
|
|
2736
|
+
const body = await readJson(req);
|
|
2737
|
+
const runner = runnerFor(body.session);
|
|
2738
|
+
if (!runner)
|
|
2739
|
+
return send(res, 404, { error: "no such session" });
|
|
2740
|
+
const at = runner.queue.findIndex((entry) => entry.id === body.id);
|
|
2741
|
+
if (at < 0)
|
|
2742
|
+
return send(res, 404, { error: "no such queued message" });
|
|
2743
|
+
const entry = runner.queue[at];
|
|
2744
|
+
if (!runner.running) {
|
|
2745
|
+
runner.queue.splice(at, 1);
|
|
2746
|
+
broadcastQueue(runner);
|
|
2747
|
+
void runTurn(runner, entry.prompt, entry.staged, entry.references);
|
|
2748
|
+
return send(res, 202, { ok: true, sent: true });
|
|
2749
|
+
}
|
|
2750
|
+
const plain = entry.prompt && !entry.staged.length && !entry.references.length;
|
|
2751
|
+
if (plain && runner.agent.interject(entry.prompt)) {
|
|
2752
|
+
runner.queue.splice(at, 1);
|
|
2753
|
+
broadcastQueue(runner);
|
|
2754
|
+
return send(res, 202, { ok: true, interjected: true });
|
|
2755
|
+
}
|
|
2756
|
+
// Cannot be handed over, so the most that can be done is to put it
|
|
2757
|
+
// first in line for when the turn ends.
|
|
2758
|
+
runner.queue.splice(at, 1);
|
|
2759
|
+
runner.queue.unshift(entry);
|
|
2760
|
+
broadcastQueue(runner);
|
|
2761
|
+
return send(res, 200, { ok: true, moved: true });
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Send what is waiting, now.
|
|
2765
|
+
*
|
|
2766
|
+
* The queue drains itself after a turn that ends on its own, but not
|
|
2767
|
+
* after an interrupt or an error — stopping means stop, and a message
|
|
2768
|
+
* typed before the failure should not fire into whatever caused it.
|
|
2769
|
+
* This is how the page restarts a queue left standing that way.
|
|
2770
|
+
*/
|
|
2771
|
+
case "POST /flush": {
|
|
2772
|
+
const body = await readJson(req);
|
|
2773
|
+
const runner = runnerFor(body.session);
|
|
2774
|
+
if (!runner)
|
|
2775
|
+
return send(res, 404, { error: "no such session" });
|
|
2776
|
+
if (runner.running)
|
|
2777
|
+
return send(res, 409, { error: "a turn is already running" });
|
|
2778
|
+
if (!drainQueue(runner))
|
|
2779
|
+
return send(res, 200, { ok: true, sent: false });
|
|
2780
|
+
return send(res, 202, { ok: true, sent: true });
|
|
2781
|
+
}
|
|
2454
2782
|
default:
|
|
2455
2783
|
return send(res, 404, { error: "not found" });
|
|
2456
2784
|
}
|
|
@@ -2469,14 +2797,14 @@ export async function startWebServer(opts) {
|
|
|
2469
2797
|
// attempt could be replayed by anything that saw it in the address bar.
|
|
2470
2798
|
pendingBrowserSignIns.delete(state);
|
|
2471
2799
|
if (!pending) {
|
|
2472
|
-
return closeTab(res, 400, "That sign-in has expired. Start
|
|
2800
|
+
return closeTab(res, 400, "That sign-in has expired.", "These links are only good for a few minutes. Start the sign-in again from OHarness.");
|
|
2473
2801
|
}
|
|
2474
2802
|
const failure = url.searchParams.get("error");
|
|
2475
2803
|
if (failure)
|
|
2476
|
-
return closeTab(res, 400, failure);
|
|
2804
|
+
return closeTab(res, 400, "That sign-in did not go through.", failure);
|
|
2477
2805
|
const code = url.searchParams.get("code") ?? "";
|
|
2478
2806
|
if (!code)
|
|
2479
|
-
return closeTab(res, 400, "The site sent no sign-in back.");
|
|
2807
|
+
return closeTab(res, 400, "That sign-in did not go through.", "The site sent no sign-in back. Start it again from OHarness.");
|
|
2480
2808
|
const result = await exchangeHandoff(code, state);
|
|
2481
2809
|
if (result.type !== "success") {
|
|
2482
2810
|
const message = result.error ?? "that sign-in was not accepted";
|
|
@@ -2484,19 +2812,19 @@ export async function startWebServer(opts) {
|
|
|
2484
2812
|
type: "notice",
|
|
2485
2813
|
message: `${pending.intent === "key" ? "getting a key" : "sign-in"} failed: ${message}`,
|
|
2486
2814
|
});
|
|
2487
|
-
return closeTab(res, 400, message);
|
|
2815
|
+
return closeTab(res, 400, "That sign-in was not accepted.", message);
|
|
2488
2816
|
}
|
|
2489
2817
|
try {
|
|
2490
2818
|
await current.harness.auth.complete(pending.provider, result);
|
|
2491
2819
|
}
|
|
2492
2820
|
catch (err) {
|
|
2493
|
-
return closeTab(res, 500, errorMessage(err));
|
|
2821
|
+
return closeTab(res, 500, "That sign-in could not be saved.", errorMessage(err));
|
|
2494
2822
|
}
|
|
2495
2823
|
// What happened, in the words of the errand that was run. "Signed in" on
|
|
2496
2824
|
// a tab where somebody just created an API key is a small lie that makes
|
|
2497
2825
|
// them wonder which of the two actually took.
|
|
2498
2826
|
closeTab(res, 200, pending.intent === "key"
|
|
2499
|
-
? `That key is now in use for ${pending.provider}
|
|
2827
|
+
? `That key is now in use for ${pending.provider}.`
|
|
2500
2828
|
: `Signed in to ${pending.provider}.`);
|
|
2501
2829
|
// Same as a saved key: the credential is stored, now go ask the gateway
|
|
2502
2830
|
// what the account can actually call.
|
|
@@ -2669,6 +2997,9 @@ export async function startWebServer(opts) {
|
|
|
2669
2997
|
contextWindow: model.contextWindow,
|
|
2670
2998
|
contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
|
|
2671
2999
|
busy: Boolean(runner.running),
|
|
3000
|
+
// So a reload, or a second tab, sees what is still waiting. The queue
|
|
3001
|
+
// lives on the runner rather than in the page for exactly this reason.
|
|
3002
|
+
queued: queueView(runner),
|
|
2672
3003
|
};
|
|
2673
3004
|
}
|
|
2674
3005
|
/**
|
|
@@ -2760,6 +3091,8 @@ export async function startWebServer(opts) {
|
|
|
2760
3091
|
cwd: project.path,
|
|
2761
3092
|
permissionPrompt: (request, signal) => permissionPrompt(request, signal),
|
|
2762
3093
|
questionPrompt: (request, signal) => questionPrompt(request, signal),
|
|
3094
|
+
...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
|
|
3095
|
+
...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
|
|
2763
3096
|
}));
|
|
2764
3097
|
harnesses.set(project.path, harness);
|
|
2765
3098
|
watchConnections(harness);
|
|
@@ -2780,6 +3113,8 @@ export async function startWebServer(opts) {
|
|
|
2780
3113
|
cwd,
|
|
2781
3114
|
permissionPrompt: async () => false,
|
|
2782
3115
|
questionPrompt: async () => ({ outcome: "unavailable" }),
|
|
3116
|
+
...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
|
|
3117
|
+
...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
|
|
2783
3118
|
}),
|
|
2784
3119
|
onStart: (routine, meta) => {
|
|
2785
3120
|
if (meta.catchUp)
|
|
@@ -2885,6 +3220,46 @@ export async function startWebServer(opts) {
|
|
|
2885
3220
|
});
|
|
2886
3221
|
return snapshot;
|
|
2887
3222
|
}
|
|
3223
|
+
/**
|
|
3224
|
+
* What the page needs to draw the waiting chips, without the bytes.
|
|
3225
|
+
*
|
|
3226
|
+
* Interjections first, and marked as such: they reach the model during the
|
|
3227
|
+
* running turn, while the queue waits for it to finish. Drawn as one list
|
|
3228
|
+
* because that is the order the conversation will read them in.
|
|
3229
|
+
*/
|
|
3230
|
+
const queueView = (runner) => [
|
|
3231
|
+
...runner.agent.pendingInterjections.map((entry) => ({
|
|
3232
|
+
id: entry.id,
|
|
3233
|
+
text: entry.text,
|
|
3234
|
+
attachments: [],
|
|
3235
|
+
references: [],
|
|
3236
|
+
soon: true,
|
|
3237
|
+
})),
|
|
3238
|
+
...runner.queue.map((entry) => ({
|
|
3239
|
+
id: entry.id,
|
|
3240
|
+
text: entry.prompt,
|
|
3241
|
+
attachments: entry.staged.map((file) => file.name),
|
|
3242
|
+
references: entry.references,
|
|
3243
|
+
soon: false,
|
|
3244
|
+
})),
|
|
3245
|
+
];
|
|
3246
|
+
const broadcastQueue = (runner) => {
|
|
3247
|
+
broadcast({ type: "queue", session: runner.session.id, queued: queueView(runner) });
|
|
3248
|
+
};
|
|
3249
|
+
/**
|
|
3250
|
+
* Start the next waiting prompt, if there is one.
|
|
3251
|
+
*
|
|
3252
|
+
* Returns whether a turn was started, so a caller can tell an empty queue
|
|
3253
|
+
* from one that is now running.
|
|
3254
|
+
*/
|
|
3255
|
+
function drainQueue(runner) {
|
|
3256
|
+
const next = runner.queue.shift();
|
|
3257
|
+
if (!next)
|
|
3258
|
+
return false;
|
|
3259
|
+
broadcastQueue(runner);
|
|
3260
|
+
void runTurn(runner, next.prompt, next.staged, next.references);
|
|
3261
|
+
return true;
|
|
3262
|
+
}
|
|
2888
3263
|
async function runTurn(runner, prompt, staged = [], references = []) {
|
|
2889
3264
|
const controller = new AbortController();
|
|
2890
3265
|
runner.running = controller;
|
|
@@ -2907,18 +3282,42 @@ export async function startWebServer(opts) {
|
|
|
2907
3282
|
const note = referenceNote(references);
|
|
2908
3283
|
const text = note ? `${note}\n\n${prompt}` : prompt;
|
|
2909
3284
|
const input = staged.length ? buildMessage(text, staged) : text;
|
|
3285
|
+
let failed = false;
|
|
3286
|
+
// The session names itself from its first prompt once the agent takes it
|
|
3287
|
+
// in. Said the moment that happens: waiting for the idle state left a new
|
|
3288
|
+
// tab reading "Untitled" for the whole of its first turn.
|
|
3289
|
+
let untitled = !runner.session.title;
|
|
2910
3290
|
try {
|
|
2911
3291
|
for await (const event of runner.agent.run(input, { signal: controller.signal })) {
|
|
3292
|
+
if (untitled && runner.session.title) {
|
|
3293
|
+
untitled = false;
|
|
3294
|
+
broadcast({ type: "title", session: id, title: runner.session.title });
|
|
3295
|
+
}
|
|
2912
3296
|
// The agent's own question_request is not forwarded: the page draws
|
|
2913
3297
|
// the card from questionPrompt's announce, which carries the id the
|
|
2914
3298
|
// answer is posted back to. Forwarding both drew the card twice, and
|
|
2915
3299
|
// the copy without that id could only ever 404.
|
|
2916
3300
|
if (event.type === "question_request")
|
|
2917
3301
|
continue;
|
|
3302
|
+
// The agent reports a provider failure as an event and breaks its own
|
|
3303
|
+
// loop rather than throwing, so the catch below never sees it — and a
|
|
3304
|
+
// queue that drained into a dead provider would replay the failure
|
|
3305
|
+
// once per waiting message.
|
|
3306
|
+
if (event.type === "error" && event.fatal)
|
|
3307
|
+
failed = true;
|
|
3308
|
+
// Read into the conversation: the chip goes, and the words appear in
|
|
3309
|
+
// the transcript where the model saw them rather than where they were
|
|
3310
|
+
// typed. `prompt` is what the page draws a user message from.
|
|
3311
|
+
if (event.type === "interjected") {
|
|
3312
|
+
broadcastQueue(runner);
|
|
3313
|
+
broadcast({ type: "prompt", session: id, text: event.text, interjected: true });
|
|
3314
|
+
continue;
|
|
3315
|
+
}
|
|
2918
3316
|
broadcast({ ...event, session: id });
|
|
2919
3317
|
}
|
|
2920
3318
|
}
|
|
2921
3319
|
catch (err) {
|
|
3320
|
+
failed = true;
|
|
2922
3321
|
broadcast({
|
|
2923
3322
|
type: "error",
|
|
2924
3323
|
session: id,
|
|
@@ -2937,17 +3336,35 @@ export async function startWebServer(opts) {
|
|
|
2937
3336
|
...(typeof err?.provider === "string"
|
|
2938
3337
|
? { provider: err.provider }
|
|
2939
3338
|
: {}),
|
|
3339
|
+
// And the code, for the failure that carries no status: nothing was
|
|
3340
|
+
// sent, so there is no HTTP answer to read — but "not signed in" is
|
|
3341
|
+
// just as much a dialog to open as a refused key is.
|
|
3342
|
+
...(typeof err?.code === "string"
|
|
3343
|
+
? { code: err.code }
|
|
3344
|
+
: {}),
|
|
2940
3345
|
fatal: true,
|
|
2941
3346
|
});
|
|
2942
3347
|
}
|
|
2943
3348
|
finally {
|
|
2944
3349
|
runner.running = undefined;
|
|
3350
|
+
// The window between the loop's last read of the inbox and the run
|
|
3351
|
+
// admitting it is over. Whatever landed in it is put at the head of the
|
|
3352
|
+
// queue rather than left unread inside a finished agent.
|
|
3353
|
+
const unread = runner.agent.takeUnread();
|
|
3354
|
+
for (let i = unread.length - 1; i >= 0; i--) {
|
|
3355
|
+
runner.queue.unshift({ id: unread[i].id, prompt: unread[i].text, staged: [], references: [] });
|
|
3356
|
+
}
|
|
2945
3357
|
broadcast({
|
|
2946
3358
|
type: "idle",
|
|
2947
3359
|
session: id,
|
|
2948
3360
|
state: state(runner),
|
|
2949
3361
|
changes: runner.ledger.list(),
|
|
2950
3362
|
});
|
|
3363
|
+
// Only after a turn that ended on its own. An interrupt means stop, and
|
|
3364
|
+
// an error would usually meet the next prompt with the same error, so
|
|
3365
|
+
// both leave the queue standing for the person to send or drop.
|
|
3366
|
+
if (!controller.signal.aborted && !failed)
|
|
3367
|
+
drainQueue(runner);
|
|
2951
3368
|
}
|
|
2952
3369
|
}
|
|
2953
3370
|
function stream(res) {
|
|
@@ -3116,6 +3533,18 @@ export async function startWebServer(opts) {
|
|
|
3116
3533
|
return send(res, 404, { error: "no such artifact" });
|
|
3117
3534
|
sendArtifact(res, found.artifact, found.revision);
|
|
3118
3535
|
}
|
|
3536
|
+
/**
|
|
3537
|
+
* What the page needs to help someone get the browser extension: whether it
|
|
3538
|
+
* is connected now, where to get it, and — where this app ships its files —
|
|
3539
|
+
* that there is a folder to load it from.
|
|
3540
|
+
*/
|
|
3541
|
+
async function extensionInfo() {
|
|
3542
|
+
return {
|
|
3543
|
+
connected: await extensionHostReachable(),
|
|
3544
|
+
installUrl: EXTENSION_INSTALL_URL,
|
|
3545
|
+
folder: Boolean(opts.extensionDir) && (await isDirectory(opts.extensionDir)),
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3119
3548
|
function constantTimeEqual(supplied, expected) {
|
|
3120
3549
|
if (!supplied)
|
|
3121
3550
|
return false;
|
|
@@ -3158,6 +3587,9 @@ export async function startWebServer(opts) {
|
|
|
3158
3587
|
}
|
|
3159
3588
|
const address = server.address();
|
|
3160
3589
|
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
3590
|
+
const presence = opts.presence === false
|
|
3591
|
+
? undefined
|
|
3592
|
+
: announcePresence({ url: `http://${host}:${port}`, token, kind: opts.presence?.kind ?? "web" });
|
|
3161
3593
|
return {
|
|
3162
3594
|
url: `http://${host}:${port}/?token=${token}`,
|
|
3163
3595
|
token,
|
|
@@ -3165,6 +3597,7 @@ export async function startWebServer(opts) {
|
|
|
3165
3597
|
questionPrompt,
|
|
3166
3598
|
async close() {
|
|
3167
3599
|
clearInterval(staleTimer);
|
|
3600
|
+
presence?.close();
|
|
3168
3601
|
await routines.close();
|
|
3169
3602
|
for (const unsubscribe of connectionSubscriptions)
|
|
3170
3603
|
unsubscribe();
|
|
@@ -3376,35 +3809,88 @@ function isLoopbackHost(host) {
|
|
|
3376
3809
|
}
|
|
3377
3810
|
return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "localhost";
|
|
3378
3811
|
}
|
|
3379
|
-
|
|
3380
|
-
|
|
3812
|
+
/**
|
|
3813
|
+
* The last thing a sign-in shows anybody.
|
|
3814
|
+
*
|
|
3815
|
+
* A tab the app opened, on a loopback port, that exists to say one sentence
|
|
3816
|
+
* and be closed. It is still the product's own page, though — for a lot of
|
|
3817
|
+
* people it is the first screen after "Continue with Google" — so it gets the
|
|
3818
|
+
* mark, a state that is legible at a glance, and a line saying what to do
|
|
3819
|
+
* next. `message` is the sentence; `hint` is what follows it, and defaults to
|
|
3820
|
+
* the only useful instruction a successful sign-in has.
|
|
3821
|
+
*
|
|
3822
|
+
* Failure is carried by the badge and by one tinted rule, not by a heading set
|
|
3823
|
+
* in red: a red sentence reads as the page shouting, and the sentence is
|
|
3824
|
+
* usually the calmest thing on it ("that sign-in has expired").
|
|
3825
|
+
*/
|
|
3826
|
+
function closeTab(res, status, message, hint) {
|
|
3381
3827
|
const ok = status < 400;
|
|
3828
|
+
const escape = (text) => text.replace(/[&<>]/g, (char) => (char === "&" ? "&" : char === "<" ? "<" : ">"));
|
|
3829
|
+
const heading = escape(message);
|
|
3830
|
+
const body = escape(hint ?? (ok ? "You can close this tab and go back to OHarness." : "Start it again from OHarness."));
|
|
3382
3831
|
const page = `<!doctype html><meta charset="utf-8"><title>OHarness</title>` +
|
|
3383
3832
|
`<meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
3384
3833
|
`<style>` +
|
|
3385
3834
|
`:root{color-scheme:light dark;` +
|
|
3386
|
-
`--bg:#
|
|
3835
|
+
`--bg:#fafafa;--card:#fff;--fg:#141618;--dim:#6b7280;--line:#0000001a;--mark:#111827;` +
|
|
3836
|
+
`--good:#047857;--good-bg:#0478571f;--bad:#b91c1c;--bad-bg:#b91c1c1a;--glow:#f9731640}` +
|
|
3387
3837
|
`@media(prefers-color-scheme:dark){:root{` +
|
|
3388
|
-
`--bg:#
|
|
3838
|
+
`--bg:#0a0b0d;--card:#141619;--fg:#e8e8e8;--dim:#8b93a1;--line:#ffffff14;--mark:#e5e7eb;` +
|
|
3839
|
+
`--good:#6ee7b7;--good-bg:#6ee7b71f;--bad:#f87171;--bad-bg:#f871711f;--glow:#f9731636}}` +
|
|
3389
3840
|
`*{box-sizing:border-box}` +
|
|
3390
|
-
|
|
3841
|
+
// The two washes are the site's own lighting, reduced to what a page this
|
|
3842
|
+
// small can carry: a warm pool above the card and a cooler one below it.
|
|
3843
|
+
// Fixed, so a short page does not scroll them into view as a seam.
|
|
3844
|
+
`body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;` +
|
|
3391
3845
|
`background:var(--bg);color:var(--fg);` +
|
|
3846
|
+
`background-image:radial-gradient(60% 46% at 50% 4%,var(--glow) 0%,transparent 68%),` +
|
|
3847
|
+
`radial-gradient(48% 38% at 50% 100%,#6366f11f 0%,transparent 70%);` +
|
|
3848
|
+
`background-attachment:fixed;` +
|
|
3392
3849
|
`font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,sans-serif;` +
|
|
3393
3850
|
`-webkit-font-smoothing:antialiased}` +
|
|
3394
|
-
`main{width:100%;max-width:
|
|
3395
|
-
`border:1px solid var(--line);box-shadow:0 1px 2px #0000000f,0
|
|
3396
|
-
`
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3851
|
+
`main{width:100%;max-width:25rem;padding:32px 28px 26px;border-radius:18px;background:var(--card);` +
|
|
3852
|
+
`border:1px solid var(--line);box-shadow:0 1px 2px #0000000f,0 24px 60px #00000026;text-align:center;` +
|
|
3853
|
+
`animation:rise .34s cubic-bezier(.2,.8,.3,1) both}` +
|
|
3854
|
+
`@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}` +
|
|
3855
|
+
`@media(prefers-reduced-motion:reduce){main{animation:none}}` +
|
|
3856
|
+
// The mark alone, in the state's colour. A disc behind it made a third
|
|
3857
|
+
// bordered shape on a card that only has one thing to say, and a filled
|
|
3858
|
+
// circle reads as a notification — nothing here needs acknowledging.
|
|
3859
|
+
`.badge{display:grid;place-items:center;color:var(--tone)}` +
|
|
3860
|
+
`.badge svg{width:30px;height:30px}` +
|
|
3861
|
+
`.ok{--tone:var(--good);--tone-bg:var(--good-bg)}` +
|
|
3862
|
+
`.bad{--tone:var(--bad);--tone-bg:var(--bad-bg)}` +
|
|
3863
|
+
`h1{margin:18px 0 0;font-size:17px;font-weight:600;letter-spacing:-.012em;line-height:1.4}` +
|
|
3864
|
+
`p{margin:9px 0 0;font-size:13.5px;color:var(--dim)}` +
|
|
3865
|
+
// The mark, at a size that says whose page this is without competing with
|
|
3866
|
+
// the one sentence it exists to show. No rule above it: the gap already
|
|
3867
|
+
// separates it, and a divider on a card this short is a second border
|
|
3868
|
+
// around almost nothing.
|
|
3869
|
+
`footer{display:flex;align-items:center;justify-content:center;gap:7px;` +
|
|
3870
|
+
`margin-top:26px;opacity:.75;` +
|
|
3871
|
+
`font-size:12px;font-weight:550;letter-spacing:.01em;color:var(--dim)}` +
|
|
3872
|
+
`footer svg{width:15px;height:15px;color:var(--mark)}` +
|
|
3400
3873
|
`</style>` +
|
|
3401
3874
|
`<body><main>` +
|
|
3875
|
+
`<div class="badge ${ok ? "ok" : "bad"}">` +
|
|
3876
|
+
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ` +
|
|
3877
|
+
`stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
|
|
3878
|
+
(ok
|
|
3879
|
+
? `<path d="M4 12.5 9.5 18 20 6.5"/>`
|
|
3880
|
+
: // The ring belongs to the glyph, not behind it: a bare exclamation
|
|
3881
|
+
// stroke, with nothing around it, reads as a piece of punctuation
|
|
3882
|
+
// that lost its sentence.
|
|
3883
|
+
`<circle cx="12" cy="12" r="9.2" stroke-width="1.9"/><path d="M12 7.4v5.4"/>` +
|
|
3884
|
+
`<circle cx="12" cy="16.4" r="1.15" fill="currentColor" stroke="none"/>`) +
|
|
3885
|
+
`</svg></div>` +
|
|
3886
|
+
`<h1>${heading}</h1>` +
|
|
3887
|
+
`<p>${body}</p>` +
|
|
3888
|
+
`<footer>` +
|
|
3402
3889
|
`<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" ` +
|
|
3403
3890
|
`stroke-linecap="round" aria-hidden="true">` +
|
|
3404
3891
|
`<path d="M20 6h-8a6 6 0 0 0-6 6v8"/><path d="M12 26h8a6 6 0 0 0 6-6v-8"/>` +
|
|
3405
3892
|
`<circle cx="16" cy="16" r="3.5" fill="currentColor" stroke="none"/></svg>` +
|
|
3406
|
-
|
|
3407
|
-
(ok ? `<p>You can close this tab and go back to OHarness.</p>` : "") +
|
|
3893
|
+
`OHarness</footer>` +
|
|
3408
3894
|
`</main>`;
|
|
3409
3895
|
res.writeHead(status, {
|
|
3410
3896
|
"content-type": "text/html; charset=utf-8",
|