@oharness/server 0.7.1 → 0.9.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/files.d.ts +40 -1
- package/dist/files.js +94 -3
- package/dist/files.js.map +1 -1
- package/dist/i18n.js +190 -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 +404 -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 +906 -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 +1714 -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/files.d.ts +7 -0
- package/dist/page/client/files.js +412 -0
- package/dist/page/client/files.js.map +1 -0
- package/dist/page/client/index.d.ts +16 -0
- package/dist/page/client/index.js +50 -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 +928 -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 +700 -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 +1283 -0
- package/dist/page/client/state.js.map +1 -0
- package/dist/page/client/terminal.d.ts +7 -0
- package/dist/page/client/terminal.js +347 -0
- package/dist/page/client/terminal.js.map +1 -0
- package/dist/page/client/transcript.d.ts +8 -0
- package/dist/page/client/transcript.js +866 -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 +3016 -0
- package/dist/page/style.js.map +1 -0
- package/dist/page.js +96 -13871
- package/dist/page.js.map +1 -1
- package/dist/server.d.ts +24 -0
- package/dist/server.js +492 -23
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +34 -0
- package/dist/terminal.js +211 -0
- package/dist/terminal.js.map +1 -0
- package/package.json +9 -8
package/dist/server.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
2
|
import { randomUUID, timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
3
4
|
import { readFile, stat } from "node:fs/promises";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { homedir } from "node:os";
|
|
@@ -17,6 +18,9 @@ import { visibleText } from "@oharness/engine/core/types.ts";
|
|
|
17
18
|
import { errorMessage } from "@oharness/engine/core/errors.ts";
|
|
18
19
|
import { officialBaseUrl, officialTeam } from "@oharness/engine/auth/providers/official.ts";
|
|
19
20
|
import { loadUserConfig, updateUserConfig } from "@oharness/engine/config.ts";
|
|
21
|
+
import { EXTENSION_ID, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/host-manifest.ts";
|
|
22
|
+
import { announcePresence } from "@oharness/engine/browser/presence.ts";
|
|
23
|
+
import { extensionHostReachable } from "@oharness/engine/browser/socket.ts";
|
|
20
24
|
import { RoutineRunner } from "@oharness/engine/schedule/runner.ts";
|
|
21
25
|
import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
|
|
22
26
|
import { ChangeLedger } from "./changes.js";
|
|
@@ -25,9 +29,11 @@ import { readPrefs, writePrefs } from "./prefs.js";
|
|
|
25
29
|
import { sortModelsForDisplay } from "./model-order.js";
|
|
26
30
|
import { loadModelsDev, releaseDateOf, releaseDates } from "@oharness/engine/providers/models-dev.ts";
|
|
27
31
|
import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
|
|
28
|
-
import {
|
|
32
|
+
import { TerminalHub, terminalAsset } from "./terminal.js";
|
|
33
|
+
import { PREVIEW_MEDIA, browseFiles, previewFile, resolveFile, searchFiles, within } from "./files.js";
|
|
29
34
|
import { analyticsState } from "./analytics.js";
|
|
30
35
|
import { PAGE } from "./page.js";
|
|
36
|
+
import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
|
|
31
37
|
import { artifactDocument, sendArtifact } from "./artifacts.js";
|
|
32
38
|
import { sendProjectExport } from "./project-export.js";
|
|
33
39
|
import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
|
|
@@ -204,12 +210,23 @@ export async function startWebServer(opts) {
|
|
|
204
210
|
const makeRunner = (session, harness) => {
|
|
205
211
|
const ledger = new ChangeLedger(session.cwd);
|
|
206
212
|
ledger.attach(harness.hooks);
|
|
207
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
session,
|
|
215
|
+
agent: harness.agent(session),
|
|
216
|
+
harness,
|
|
217
|
+
browser: harness.browserFor(session.id),
|
|
218
|
+
ledger,
|
|
219
|
+
queue: [],
|
|
220
|
+
};
|
|
208
221
|
};
|
|
209
222
|
/** The runner a request names, or the most recently focused one. */
|
|
210
223
|
let focused = opts.session.id;
|
|
211
224
|
const runnerFor = (id) => runners.get(typeof id === "string" && id ? id : focused);
|
|
212
|
-
|
|
225
|
+
const initial = makeRunner(opts.session, opts.harness);
|
|
226
|
+
// Preserve caller-supplied agent overrides for the initial session. Its
|
|
227
|
+
// browser is still the same one bound by Harness.agent(session).
|
|
228
|
+
initial.agent = opts.agent;
|
|
229
|
+
runners.set(opts.session.id, initial);
|
|
213
230
|
let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
|
|
214
231
|
/**
|
|
215
232
|
* Sign-ins waiting for their emailed code.
|
|
@@ -418,6 +435,7 @@ export async function startWebServer(opts) {
|
|
|
418
435
|
broadcast(announce(entry));
|
|
419
436
|
notify(entry);
|
|
420
437
|
});
|
|
438
|
+
const terminals = new TerminalHub();
|
|
421
439
|
const server = createServer((req, res) => {
|
|
422
440
|
void handle(req, res).catch((err) => {
|
|
423
441
|
send(res, 500, { error: errorMessage(err) });
|
|
@@ -430,7 +448,12 @@ export async function startWebServer(opts) {
|
|
|
430
448
|
if (url.pathname === "/" && req.method === "GET") {
|
|
431
449
|
if (!authorized(url, req))
|
|
432
450
|
return send(res, 401, { error: "missing or bad token" });
|
|
433
|
-
res.writeHead(200, {
|
|
451
|
+
res.writeHead(200, {
|
|
452
|
+
"content-type": "text/html; charset=utf-8",
|
|
453
|
+
// Framed by itself and by the OHarness extension's side panel, and by
|
|
454
|
+
// nothing else: a page that drives a shell is not one to embed.
|
|
455
|
+
"content-security-policy": `frame-ancestors 'self' chrome-extension://${EXTENSION_ID}`,
|
|
456
|
+
});
|
|
434
457
|
res.end(PAGE);
|
|
435
458
|
return;
|
|
436
459
|
}
|
|
@@ -458,6 +481,201 @@ export async function startWebServer(opts) {
|
|
|
458
481
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
459
482
|
return send(res, 200, { pages: runner?.harness.extensions.listPages() ?? [] });
|
|
460
483
|
}
|
|
484
|
+
/*
|
|
485
|
+
* The browser pane: what state it is in, its picture, and the input of
|
|
486
|
+
* somebody who has taken it over.
|
|
487
|
+
*
|
|
488
|
+
* State is polled by every open page, so it is answered from what the
|
|
489
|
+
* session already knows and never starts a browser to find out — a person
|
|
490
|
+
* who opened the pane has not asked for Chrome.
|
|
491
|
+
*/
|
|
492
|
+
case "GET /browser/state": {
|
|
493
|
+
res.setHeader("cache-control", "no-store");
|
|
494
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
495
|
+
return send(res, 200, await browserState(runner?.browser));
|
|
496
|
+
}
|
|
497
|
+
case "GET /browser/stream": {
|
|
498
|
+
const runner = runnerFor(url.searchParams.get("session"));
|
|
499
|
+
const quality = Number(url.searchParams.get("quality") ?? "85");
|
|
500
|
+
return streamFrames(runner?.browser, res, Number.isFinite(quality) ? Math.min(90, Math.max(20, quality)) : 85);
|
|
501
|
+
}
|
|
502
|
+
case "POST /browser/input": {
|
|
503
|
+
res.setHeader("cache-control", "no-store");
|
|
504
|
+
const body = await readJson(req);
|
|
505
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
506
|
+
const session = runner?.browser;
|
|
507
|
+
if (!session || !session.state().running)
|
|
508
|
+
return send(res, 409, { error: "no browser is running" });
|
|
509
|
+
const event = parseInputEvent(body.event);
|
|
510
|
+
if (!event)
|
|
511
|
+
return send(res, 400, { error: "unrecognised input event" });
|
|
512
|
+
await session.forward(event);
|
|
513
|
+
return send(res, 200, { ok: true });
|
|
514
|
+
}
|
|
515
|
+
case "POST /browser/navigate": {
|
|
516
|
+
res.setHeader("cache-control", "no-store");
|
|
517
|
+
const body = await readJson(req);
|
|
518
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
519
|
+
const session = runner?.browser;
|
|
520
|
+
if (!session)
|
|
521
|
+
return send(res, 409, { error: "browser is disabled" });
|
|
522
|
+
if (body.action === "reload") {
|
|
523
|
+
if (!session.running)
|
|
524
|
+
return send(res, 409, { error: "no browser is running" });
|
|
525
|
+
try {
|
|
526
|
+
return send(res, 200, await session.reload());
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const raw = typeof body.url === "string" ? body.url.trim() : "";
|
|
533
|
+
if (!raw)
|
|
534
|
+
return send(res, 400, { error: "url is required" });
|
|
535
|
+
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::|\/|$)/i.test(raw);
|
|
536
|
+
const value = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `${local ? "http" : "https"}://${raw}`;
|
|
537
|
+
let parsed;
|
|
538
|
+
try {
|
|
539
|
+
parsed = new URL(value);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return send(res, 400, { error: "invalid url" });
|
|
543
|
+
}
|
|
544
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
545
|
+
return send(res, 400, { error: "only http and https URLs are supported" });
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
return send(res, 200, await session.navigate(parsed.href));
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
case "POST /browser/tab": {
|
|
555
|
+
res.setHeader("cache-control", "no-store");
|
|
556
|
+
const body = await readJson(req);
|
|
557
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
558
|
+
const session = runner?.browser;
|
|
559
|
+
if (!session || !session.running)
|
|
560
|
+
return send(res, 409, { error: "no browser is running" });
|
|
561
|
+
if (typeof body.id !== "string" || !body.id)
|
|
562
|
+
return send(res, 400, { error: "tab id is required" });
|
|
563
|
+
if (body.action === "close") {
|
|
564
|
+
if (runner?.running)
|
|
565
|
+
return send(res, 409, { error: "stop the running turn before closing its browser tab" });
|
|
566
|
+
if (!session.closePaneTab)
|
|
567
|
+
return send(res, 501, { error: "this browser provider cannot close tabs" });
|
|
568
|
+
try {
|
|
569
|
+
return send(res, 200, await session.closePaneTab(body.id));
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (!session.selectPaneTab)
|
|
576
|
+
return send(res, 501, { error: "this browser provider cannot switch tabs" });
|
|
577
|
+
try {
|
|
578
|
+
return send(res, 200, await session.selectPaneTab(body.id));
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
case "POST /browser/close": {
|
|
585
|
+
res.setHeader("cache-control", "no-store");
|
|
586
|
+
const body = await readJson(req);
|
|
587
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
588
|
+
const session = runner?.browser;
|
|
589
|
+
if (!runner || !session)
|
|
590
|
+
return send(res, 404, { error: "no such browser session" });
|
|
591
|
+
if (runner.running)
|
|
592
|
+
return send(res, 409, { error: "stop the running turn before stopping its browser" });
|
|
593
|
+
if (!session.running)
|
|
594
|
+
return send(res, 200, { closed: false, running: false });
|
|
595
|
+
await session.close();
|
|
596
|
+
return send(res, 200, { closed: true, running: false });
|
|
597
|
+
}
|
|
598
|
+
/*
|
|
599
|
+
* Importing a site's sign-in from the person's own browser.
|
|
600
|
+
*
|
|
601
|
+
* GET lists what could be imported and names the browser; it lists tabs
|
|
602
|
+
* and attaches to none. POST is the dialog's confirmation — the only way
|
|
603
|
+
* anything is read out of that browser — or its "don't ask again".
|
|
604
|
+
*/
|
|
605
|
+
/**
|
|
606
|
+
* Hand the person's current tab to a conversation. Asked for by the
|
|
607
|
+
* extension's side panel, from the page the person is looking at — a
|
|
608
|
+
* click of theirs, never something the agent can call.
|
|
609
|
+
*/
|
|
610
|
+
case "POST /browser/adopt": {
|
|
611
|
+
res.setHeader("cache-control", "no-store");
|
|
612
|
+
const body = await readJson(req);
|
|
613
|
+
const session = runnerFor(typeof body.session === "string" ? body.session : null)?.browser;
|
|
614
|
+
if (!session)
|
|
615
|
+
return send(res, 404, { error: "browser is disabled" });
|
|
616
|
+
if (!session.adoptYourTab)
|
|
617
|
+
return send(res, 501, { error: "this browser cannot take one of your tabs" });
|
|
618
|
+
const tabId = typeof body.tabId === "number" || typeof body.tabId === "string" ? String(body.tabId) : "";
|
|
619
|
+
if (!tabId)
|
|
620
|
+
return send(res, 400, { error: "tabId is required" });
|
|
621
|
+
try {
|
|
622
|
+
return send(res, 200, await session.adoptYourTab(tabId));
|
|
623
|
+
}
|
|
624
|
+
catch (error) {
|
|
625
|
+
return send(res, 409, { error: errorMessage(error) });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** Whether the extension is connected, and how to get it; see extensionInfo. */
|
|
629
|
+
case "GET /browser/extension":
|
|
630
|
+
res.setHeader("cache-control", "no-store");
|
|
631
|
+
return send(res, 200, await extensionInfo());
|
|
632
|
+
/** Open the extension's folder, for loading it into Chrome by hand. */
|
|
633
|
+
case "POST /browser/extension/reveal": {
|
|
634
|
+
if (!opts.extensionDir || !(await isDirectory(opts.extensionDir)))
|
|
635
|
+
return send(res, 404, { error: "no extension folder here" });
|
|
636
|
+
const result = await revealDirectory(opts.extensionDir);
|
|
637
|
+
return send(res, "error" in result ? 500 : 200, result);
|
|
638
|
+
}
|
|
639
|
+
case "GET /browser/import": {
|
|
640
|
+
res.setHeader("cache-control", "no-store");
|
|
641
|
+
const session = runnerFor(url.searchParams.get("session"))?.browser;
|
|
642
|
+
if (!session)
|
|
643
|
+
return send(res, 404, { error: "browser is disabled" });
|
|
644
|
+
if (!session.loginImportSources)
|
|
645
|
+
return send(res, 501, { error: "this browser provider cannot import sign-ins" });
|
|
646
|
+
try {
|
|
647
|
+
return send(res, 200, await session.loginImportSources());
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
return send(res, 409, { error: errorMessage(error) });
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
case "POST /browser/import": {
|
|
654
|
+
res.setHeader("cache-control", "no-store");
|
|
655
|
+
const body = await readJson(req);
|
|
656
|
+
const runner = runnerFor(typeof body.session === "string" ? body.session : undefined);
|
|
657
|
+
const session = runner?.browser;
|
|
658
|
+
if (!runner || !session)
|
|
659
|
+
return send(res, 404, { error: "no such browser session" });
|
|
660
|
+
if (body.action === "dismiss") {
|
|
661
|
+
if (typeof body.site !== "string")
|
|
662
|
+
return send(res, 400, { error: "site is required" });
|
|
663
|
+
await session.dismissLoginImport?.(body.site);
|
|
664
|
+
return send(res, 200, { ok: true });
|
|
665
|
+
}
|
|
666
|
+
// The import navigates the page the agent is using.
|
|
667
|
+
if (runner.running)
|
|
668
|
+
return send(res, 409, { error: "wait for the running turn to finish before importing a sign-in" });
|
|
669
|
+
if (!session.importLogin)
|
|
670
|
+
return send(res, 501, { error: "this browser provider cannot import sign-ins" });
|
|
671
|
+
const sites = Array.isArray(body.sites) ? body.sites.filter((site) => typeof site === "string").slice(0, 20) : [];
|
|
672
|
+
try {
|
|
673
|
+
return send(res, 200, await session.importLogin(sites));
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
return send(res, 400, { error: errorMessage(error) });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
461
679
|
case "GET /plugin-page": {
|
|
462
680
|
res.setHeader("cache-control", "no-store");
|
|
463
681
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
@@ -854,6 +1072,137 @@ export async function startWebServer(opts) {
|
|
|
854
1072
|
const result = await revealDirectory(dir);
|
|
855
1073
|
return send(res, "error" in result ? 500 : 200, result);
|
|
856
1074
|
}
|
|
1075
|
+
/*
|
|
1076
|
+
* The files pane: a project's tree one folder at a time, one file's
|
|
1077
|
+
* contents, and an image's bytes. Addressed by the project's folder
|
|
1078
|
+
* rather than a session, because the pane opens from the sidebar's
|
|
1079
|
+
* project menu too, where there may be no session in that tree at all.
|
|
1080
|
+
*/
|
|
1081
|
+
// The embedded terminal. One shell per project folder; see terminal.ts.
|
|
1082
|
+
case "POST /term/open": {
|
|
1083
|
+
const body = await readJson(req);
|
|
1084
|
+
const root = await projectRoot(typeof body.cwd === "string" ? body.cwd : null);
|
|
1085
|
+
if (!root)
|
|
1086
|
+
return send(res, 400, { error: "not a folder" });
|
|
1087
|
+
const replace = typeof body.replace === "string" ? body.replace : undefined;
|
|
1088
|
+
const result = await terminals.open(root, { cols: body.cols, rows: body.rows }, replace);
|
|
1089
|
+
return send(res, "error" in result ? 500 : 200, result);
|
|
1090
|
+
}
|
|
1091
|
+
case "GET /term/list": {
|
|
1092
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1093
|
+
if (!root)
|
|
1094
|
+
return send(res, 400, { error: "not a folder" });
|
|
1095
|
+
return send(res, 200, { terminals: terminals.list(root) });
|
|
1096
|
+
}
|
|
1097
|
+
case "GET /term/stream": {
|
|
1098
|
+
if (!terminals.stream(url.searchParams.get("id") ?? "", res)) {
|
|
1099
|
+
return send(res, 404, { error: "no such terminal" });
|
|
1100
|
+
}
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
case "POST /term/input": {
|
|
1104
|
+
const body = await readJson(req);
|
|
1105
|
+
const ok = terminals.input(String(body.id ?? ""), body.data);
|
|
1106
|
+
return send(res, ok ? 200 : 404, ok ? { ok } : { error: "no such terminal" });
|
|
1107
|
+
}
|
|
1108
|
+
case "POST /term/resize": {
|
|
1109
|
+
const body = await readJson(req);
|
|
1110
|
+
const ok = terminals.resize(String(body.id ?? ""), body.cols, body.rows);
|
|
1111
|
+
return send(res, ok ? 200 : 404, ok ? { ok } : { error: "no such terminal" });
|
|
1112
|
+
}
|
|
1113
|
+
case "POST /term/close": {
|
|
1114
|
+
const body = await readJson(req);
|
|
1115
|
+
terminals.close(String(body.id ?? ""));
|
|
1116
|
+
return send(res, 200, { ok: true });
|
|
1117
|
+
}
|
|
1118
|
+
case "GET /vendor/xterm.js":
|
|
1119
|
+
case "GET /vendor/xterm.css":
|
|
1120
|
+
case "GET /vendor/xterm-fit.js": {
|
|
1121
|
+
const file = terminalAsset(url.pathname);
|
|
1122
|
+
if (!file)
|
|
1123
|
+
return send(res, 404, { error: "terminal assets are not installed" });
|
|
1124
|
+
const data = await readFile(file);
|
|
1125
|
+
res.writeHead(200, {
|
|
1126
|
+
"content-type": url.pathname.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8",
|
|
1127
|
+
"content-length": data.byteLength,
|
|
1128
|
+
"cache-control": "private, max-age=3600",
|
|
1129
|
+
});
|
|
1130
|
+
res.end(data);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
case "GET /files/tree": {
|
|
1134
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1135
|
+
if (!root)
|
|
1136
|
+
return send(res, 400, { error: "not a folder" });
|
|
1137
|
+
return send(res, 200, await browseFiles(root, url.searchParams.get("dir") ?? "", { all: true }));
|
|
1138
|
+
}
|
|
1139
|
+
case "GET /files/read": {
|
|
1140
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1141
|
+
if (!root)
|
|
1142
|
+
return send(res, 400, { error: "not a folder" });
|
|
1143
|
+
const result = await previewFile(root, url.searchParams.get("path") ?? "");
|
|
1144
|
+
return send(res, "error" in result ? 404 : 200, result);
|
|
1145
|
+
}
|
|
1146
|
+
case "GET /files/raw": {
|
|
1147
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1148
|
+
const full = root && resolveFile(root, url.searchParams.get("path") ?? "");
|
|
1149
|
+
const media = full && PREVIEW_MEDIA[path.extname(full).toLowerCase()];
|
|
1150
|
+
if (!full || !media)
|
|
1151
|
+
return send(res, 415, { error: "not previewable" });
|
|
1152
|
+
let size;
|
|
1153
|
+
try {
|
|
1154
|
+
const info = await stat(full);
|
|
1155
|
+
if (!info.isFile())
|
|
1156
|
+
return send(res, 404, { error: "not a file" });
|
|
1157
|
+
size = info.size;
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return send(res, 404, { error: "no such file" });
|
|
1161
|
+
}
|
|
1162
|
+
// A video is seeked by asking for the bytes at the new position, and
|
|
1163
|
+
// one too long to buffer whole would not play at all without this.
|
|
1164
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
|
|
1165
|
+
let first = 0;
|
|
1166
|
+
let last = size - 1;
|
|
1167
|
+
if (range && (range[1] || range[2])) {
|
|
1168
|
+
if (range[1]) {
|
|
1169
|
+
first = Number(range[1]);
|
|
1170
|
+
if (range[2])
|
|
1171
|
+
last = Math.min(Number(range[2]), size - 1);
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
first = Math.max(0, size - Number(range[2]));
|
|
1175
|
+
}
|
|
1176
|
+
if (first > last || first >= size) {
|
|
1177
|
+
res.writeHead(416, { "content-range": `bytes */${size}` });
|
|
1178
|
+
res.end();
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
const partial = Boolean(range && (range[1] || range[2]));
|
|
1183
|
+
res.writeHead(partial ? 206 : 200, {
|
|
1184
|
+
"content-type": media.type,
|
|
1185
|
+
"content-length": last - first + 1,
|
|
1186
|
+
"accept-ranges": "bytes",
|
|
1187
|
+
...(partial ? { "content-range": `bytes ${first}-${last}/${size}` } : {}),
|
|
1188
|
+
"x-content-type-options": "nosniff",
|
|
1189
|
+
// Served inert: this is same-origin to a page that holds the session
|
|
1190
|
+
// token, and an SVG is a document that can script. A PDF is left
|
|
1191
|
+
// unsandboxed, because the browser's viewer will not open inside a
|
|
1192
|
+
// sandbox; the viewer runs in its own process, apart from the page.
|
|
1193
|
+
...(media.kind === "pdf"
|
|
1194
|
+
? {}
|
|
1195
|
+
: { "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" }),
|
|
1196
|
+
"cache-control": "private, no-store",
|
|
1197
|
+
});
|
|
1198
|
+
if (req.method === "HEAD" || size === 0)
|
|
1199
|
+
return void res.end();
|
|
1200
|
+
const stream = createReadStream(full, { start: first, end: last });
|
|
1201
|
+
stream.on("error", () => res.destroy());
|
|
1202
|
+
res.on("close", () => stream.destroy());
|
|
1203
|
+
stream.pipe(res);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
857
1206
|
case "POST /pick": {
|
|
858
1207
|
const body = await readJson(req);
|
|
859
1208
|
// The dialog opens on this machine's desktop, which is the same
|
|
@@ -1370,6 +1719,10 @@ export async function startWebServer(opts) {
|
|
|
1370
1719
|
// The whole status, not just the path: the page needs to know
|
|
1371
1720
|
// whether the switch it draws is the user's to flip.
|
|
1372
1721
|
audit: await current.harness.auditStatus(),
|
|
1722
|
+
browserMode: current.harness.browserMode(),
|
|
1723
|
+
// For the browser row: whether the person's browser can be reached
|
|
1724
|
+
// now, and where to get the extension when it cannot.
|
|
1725
|
+
browserExtension: await extensionInfo(),
|
|
1373
1726
|
settingsPaths: {
|
|
1374
1727
|
user: current.harness.settings.pathFor("user"),
|
|
1375
1728
|
project: current.harness.settings.pathFor("project"),
|
|
@@ -1392,6 +1745,23 @@ export async function startWebServer(opts) {
|
|
|
1392
1745
|
const saved = await current.harness.setWritableRoots(body.roots);
|
|
1393
1746
|
return send(res, 200, { roots: saved });
|
|
1394
1747
|
}
|
|
1748
|
+
/**
|
|
1749
|
+
* The built-in browser or the person's own, through the extension.
|
|
1750
|
+
*
|
|
1751
|
+
* A machine preference, so every project's harness moves with it — a
|
|
1752
|
+
* switch that changed only the project on screen would look ignored the
|
|
1753
|
+
* moment someone opened another one.
|
|
1754
|
+
*/
|
|
1755
|
+
case "POST /settings/browser-mode": {
|
|
1756
|
+
const body = await readJson(req);
|
|
1757
|
+
if (body.mode !== "auto" && body.mode !== "builtin" && body.mode !== "extension") {
|
|
1758
|
+
return send(res, 400, { error: "mode must be auto, builtin or extension" });
|
|
1759
|
+
}
|
|
1760
|
+
for (const harness of new Set([current.harness, ...harnesses.values()])) {
|
|
1761
|
+
await harness.setBrowserMode(body.mode);
|
|
1762
|
+
}
|
|
1763
|
+
return send(res, 200, { mode: body.mode });
|
|
1764
|
+
}
|
|
1395
1765
|
/**
|
|
1396
1766
|
* Who is signed in, if anyone.
|
|
1397
1767
|
*
|
|
@@ -2253,6 +2623,7 @@ export async function startWebServer(opts) {
|
|
|
2253
2623
|
const runner = runners.get(id);
|
|
2254
2624
|
runner?.running?.abort();
|
|
2255
2625
|
runners.delete(id);
|
|
2626
|
+
await runner?.harness.closeBrowserFor(id);
|
|
2256
2627
|
// Its artifacts move from memory to the log half of the catalogue,
|
|
2257
2628
|
// and a fold taken before this session existed does not have them.
|
|
2258
2629
|
cachedLogs = undefined;
|
|
@@ -2282,7 +2653,9 @@ export async function startWebServer(opts) {
|
|
|
2282
2653
|
// knowing about it at all, not the absence of a transcript.
|
|
2283
2654
|
if (!deleted && !runners.has(id))
|
|
2284
2655
|
return send(res, 404, { error: "no such session" });
|
|
2656
|
+
const removed = runners.get(id);
|
|
2285
2657
|
runners.delete(id);
|
|
2658
|
+
await removed?.harness.closeBrowserFor(id);
|
|
2286
2659
|
// Its artifact log went with it, and a fold taken a moment ago
|
|
2287
2660
|
// still names it.
|
|
2288
2661
|
cachedLogs = undefined;
|
|
@@ -2344,6 +2717,7 @@ export async function startWebServer(opts) {
|
|
|
2344
2717
|
const runner = runnerFor(id);
|
|
2345
2718
|
if (runner)
|
|
2346
2719
|
runner.session.title = title;
|
|
2720
|
+
broadcast({ type: "title", session: id, title });
|
|
2347
2721
|
return send(res, 200, { ok: true, id, title });
|
|
2348
2722
|
}
|
|
2349
2723
|
case "POST /upload": {
|
|
@@ -2557,14 +2931,14 @@ export async function startWebServer(opts) {
|
|
|
2557
2931
|
// attempt could be replayed by anything that saw it in the address bar.
|
|
2558
2932
|
pendingBrowserSignIns.delete(state);
|
|
2559
2933
|
if (!pending) {
|
|
2560
|
-
return closeTab(res, 400, "That sign-in has expired. Start
|
|
2934
|
+
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.");
|
|
2561
2935
|
}
|
|
2562
2936
|
const failure = url.searchParams.get("error");
|
|
2563
2937
|
if (failure)
|
|
2564
|
-
return closeTab(res, 400, failure);
|
|
2938
|
+
return closeTab(res, 400, "That sign-in did not go through.", failure);
|
|
2565
2939
|
const code = url.searchParams.get("code") ?? "";
|
|
2566
2940
|
if (!code)
|
|
2567
|
-
return closeTab(res, 400, "The site sent no sign-in back.");
|
|
2941
|
+
return closeTab(res, 400, "That sign-in did not go through.", "The site sent no sign-in back. Start it again from OHarness.");
|
|
2568
2942
|
const result = await exchangeHandoff(code, state);
|
|
2569
2943
|
if (result.type !== "success") {
|
|
2570
2944
|
const message = result.error ?? "that sign-in was not accepted";
|
|
@@ -2572,19 +2946,19 @@ export async function startWebServer(opts) {
|
|
|
2572
2946
|
type: "notice",
|
|
2573
2947
|
message: `${pending.intent === "key" ? "getting a key" : "sign-in"} failed: ${message}`,
|
|
2574
2948
|
});
|
|
2575
|
-
return closeTab(res, 400, message);
|
|
2949
|
+
return closeTab(res, 400, "That sign-in was not accepted.", message);
|
|
2576
2950
|
}
|
|
2577
2951
|
try {
|
|
2578
2952
|
await current.harness.auth.complete(pending.provider, result);
|
|
2579
2953
|
}
|
|
2580
2954
|
catch (err) {
|
|
2581
|
-
return closeTab(res, 500, errorMessage(err));
|
|
2955
|
+
return closeTab(res, 500, "That sign-in could not be saved.", errorMessage(err));
|
|
2582
2956
|
}
|
|
2583
2957
|
// What happened, in the words of the errand that was run. "Signed in" on
|
|
2584
2958
|
// a tab where somebody just created an API key is a small lie that makes
|
|
2585
2959
|
// them wonder which of the two actually took.
|
|
2586
2960
|
closeTab(res, 200, pending.intent === "key"
|
|
2587
|
-
? `That key is now in use for ${pending.provider}
|
|
2961
|
+
? `That key is now in use for ${pending.provider}.`
|
|
2588
2962
|
: `Signed in to ${pending.provider}.`);
|
|
2589
2963
|
// Same as a saved key: the credential is stored, now go ask the gateway
|
|
2590
2964
|
// what the account can actually call.
|
|
@@ -2851,6 +3225,8 @@ export async function startWebServer(opts) {
|
|
|
2851
3225
|
cwd: project.path,
|
|
2852
3226
|
permissionPrompt: (request, signal) => permissionPrompt(request, signal),
|
|
2853
3227
|
questionPrompt: (request, signal) => questionPrompt(request, signal),
|
|
3228
|
+
...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
|
|
3229
|
+
...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
|
|
2854
3230
|
}));
|
|
2855
3231
|
harnesses.set(project.path, harness);
|
|
2856
3232
|
watchConnections(harness);
|
|
@@ -2871,6 +3247,8 @@ export async function startWebServer(opts) {
|
|
|
2871
3247
|
cwd,
|
|
2872
3248
|
permissionPrompt: async () => false,
|
|
2873
3249
|
questionPrompt: async () => ({ outcome: "unavailable" }),
|
|
3250
|
+
...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
|
|
3251
|
+
...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
|
|
2874
3252
|
}),
|
|
2875
3253
|
onStart: (routine, meta) => {
|
|
2876
3254
|
if (meta.catchUp)
|
|
@@ -3039,8 +3417,16 @@ export async function startWebServer(opts) {
|
|
|
3039
3417
|
const text = note ? `${note}\n\n${prompt}` : prompt;
|
|
3040
3418
|
const input = staged.length ? buildMessage(text, staged) : text;
|
|
3041
3419
|
let failed = false;
|
|
3420
|
+
// The session names itself from its first prompt once the agent takes it
|
|
3421
|
+
// in. Said the moment that happens: waiting for the idle state left a new
|
|
3422
|
+
// tab reading "Untitled" for the whole of its first turn.
|
|
3423
|
+
let untitled = !runner.session.title;
|
|
3042
3424
|
try {
|
|
3043
3425
|
for await (const event of runner.agent.run(input, { signal: controller.signal })) {
|
|
3426
|
+
if (untitled && runner.session.title) {
|
|
3427
|
+
untitled = false;
|
|
3428
|
+
broadcast({ type: "title", session: id, title: runner.session.title });
|
|
3429
|
+
}
|
|
3044
3430
|
// The agent's own question_request is not forwarded: the page draws
|
|
3045
3431
|
// the card from questionPrompt's announce, which carries the id the
|
|
3046
3432
|
// answer is posted back to. Forwarding both drew the card twice, and
|
|
@@ -3084,6 +3470,12 @@ export async function startWebServer(opts) {
|
|
|
3084
3470
|
...(typeof err?.provider === "string"
|
|
3085
3471
|
? { provider: err.provider }
|
|
3086
3472
|
: {}),
|
|
3473
|
+
// And the code, for the failure that carries no status: nothing was
|
|
3474
|
+
// sent, so there is no HTTP answer to read — but "not signed in" is
|
|
3475
|
+
// just as much a dialog to open as a refused key is.
|
|
3476
|
+
...(typeof err?.code === "string"
|
|
3477
|
+
? { code: err.code }
|
|
3478
|
+
: {}),
|
|
3087
3479
|
fatal: true,
|
|
3088
3480
|
});
|
|
3089
3481
|
}
|
|
@@ -3275,6 +3667,18 @@ export async function startWebServer(opts) {
|
|
|
3275
3667
|
return send(res, 404, { error: "no such artifact" });
|
|
3276
3668
|
sendArtifact(res, found.artifact, found.revision);
|
|
3277
3669
|
}
|
|
3670
|
+
/**
|
|
3671
|
+
* What the page needs to help someone get the browser extension: whether it
|
|
3672
|
+
* is connected now, where to get it, and — where this app ships its files —
|
|
3673
|
+
* that there is a folder to load it from.
|
|
3674
|
+
*/
|
|
3675
|
+
async function extensionInfo() {
|
|
3676
|
+
return {
|
|
3677
|
+
connected: await extensionHostReachable(),
|
|
3678
|
+
installUrl: EXTENSION_INSTALL_URL,
|
|
3679
|
+
folder: Boolean(opts.extensionDir) && (await isDirectory(opts.extensionDir)),
|
|
3680
|
+
};
|
|
3681
|
+
}
|
|
3278
3682
|
function constantTimeEqual(supplied, expected) {
|
|
3279
3683
|
if (!supplied)
|
|
3280
3684
|
return false;
|
|
@@ -3317,6 +3721,9 @@ export async function startWebServer(opts) {
|
|
|
3317
3721
|
}
|
|
3318
3722
|
const address = server.address();
|
|
3319
3723
|
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
3724
|
+
const presence = opts.presence === false
|
|
3725
|
+
? undefined
|
|
3726
|
+
: announcePresence({ url: `http://${host}:${port}`, token, kind: opts.presence?.kind ?? "web" });
|
|
3320
3727
|
return {
|
|
3321
3728
|
url: `http://${host}:${port}/?token=${token}`,
|
|
3322
3729
|
token,
|
|
@@ -3324,11 +3731,13 @@ export async function startWebServer(opts) {
|
|
|
3324
3731
|
questionPrompt,
|
|
3325
3732
|
async close() {
|
|
3326
3733
|
clearInterval(staleTimer);
|
|
3734
|
+
presence?.close();
|
|
3327
3735
|
await routines.close();
|
|
3328
3736
|
for (const unsubscribe of connectionSubscriptions)
|
|
3329
3737
|
unsubscribe();
|
|
3330
3738
|
for (const harness of harnesses.values())
|
|
3331
3739
|
harness.cloudConnectors.close();
|
|
3740
|
+
terminals.closeAll();
|
|
3332
3741
|
for (const client of clients)
|
|
3333
3742
|
client.end();
|
|
3334
3743
|
clients.clear();
|
|
@@ -3535,35 +3944,88 @@ function isLoopbackHost(host) {
|
|
|
3535
3944
|
}
|
|
3536
3945
|
return hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "localhost";
|
|
3537
3946
|
}
|
|
3538
|
-
|
|
3539
|
-
|
|
3947
|
+
/**
|
|
3948
|
+
* The last thing a sign-in shows anybody.
|
|
3949
|
+
*
|
|
3950
|
+
* A tab the app opened, on a loopback port, that exists to say one sentence
|
|
3951
|
+
* and be closed. It is still the product's own page, though — for a lot of
|
|
3952
|
+
* people it is the first screen after "Continue with Google" — so it gets the
|
|
3953
|
+
* mark, a state that is legible at a glance, and a line saying what to do
|
|
3954
|
+
* next. `message` is the sentence; `hint` is what follows it, and defaults to
|
|
3955
|
+
* the only useful instruction a successful sign-in has.
|
|
3956
|
+
*
|
|
3957
|
+
* Failure is carried by the badge and by one tinted rule, not by a heading set
|
|
3958
|
+
* in red: a red sentence reads as the page shouting, and the sentence is
|
|
3959
|
+
* usually the calmest thing on it ("that sign-in has expired").
|
|
3960
|
+
*/
|
|
3961
|
+
function closeTab(res, status, message, hint) {
|
|
3540
3962
|
const ok = status < 400;
|
|
3963
|
+
const escape = (text) => text.replace(/[&<>]/g, (char) => (char === "&" ? "&" : char === "<" ? "<" : ">"));
|
|
3964
|
+
const heading = escape(message);
|
|
3965
|
+
const body = escape(hint ?? (ok ? "You can close this tab and go back to OHarness." : "Start it again from OHarness."));
|
|
3541
3966
|
const page = `<!doctype html><meta charset="utf-8"><title>OHarness</title>` +
|
|
3542
3967
|
`<meta name="viewport" content="width=device-width,initial-scale=1">` +
|
|
3543
3968
|
`<style>` +
|
|
3544
3969
|
`:root{color-scheme:light dark;` +
|
|
3545
|
-
`--bg:#
|
|
3970
|
+
`--bg:#fafafa;--card:#fff;--fg:#141618;--dim:#6b7280;--line:#0000001a;--mark:#111827;` +
|
|
3971
|
+
`--good:#047857;--good-bg:#0478571f;--bad:#b91c1c;--bad-bg:#b91c1c1a;--glow:#f9731640}` +
|
|
3546
3972
|
`@media(prefers-color-scheme:dark){:root{` +
|
|
3547
|
-
`--bg:#
|
|
3973
|
+
`--bg:#0a0b0d;--card:#141619;--fg:#e8e8e8;--dim:#8b93a1;--line:#ffffff14;--mark:#e5e7eb;` +
|
|
3974
|
+
`--good:#6ee7b7;--good-bg:#6ee7b71f;--bad:#f87171;--bad-bg:#f871711f;--glow:#f9731636}}` +
|
|
3548
3975
|
`*{box-sizing:border-box}` +
|
|
3549
|
-
|
|
3976
|
+
// The two washes are the site's own lighting, reduced to what a page this
|
|
3977
|
+
// small can carry: a warm pool above the card and a cooler one below it.
|
|
3978
|
+
// Fixed, so a short page does not scroll them into view as a seam.
|
|
3979
|
+
`body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;` +
|
|
3550
3980
|
`background:var(--bg);color:var(--fg);` +
|
|
3981
|
+
`background-image:radial-gradient(60% 46% at 50% 4%,var(--glow) 0%,transparent 68%),` +
|
|
3982
|
+
`radial-gradient(48% 38% at 50% 100%,#6366f11f 0%,transparent 70%);` +
|
|
3983
|
+
`background-attachment:fixed;` +
|
|
3551
3984
|
`font:15px/1.6 ui-sans-serif,system-ui,-apple-system,"Segoe UI",Inter,sans-serif;` +
|
|
3552
3985
|
`-webkit-font-smoothing:antialiased}` +
|
|
3553
|
-
`main{width:100%;max-width:
|
|
3554
|
-
`border:1px solid var(--line);box-shadow:0 1px 2px #0000000f,0
|
|
3555
|
-
`
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3986
|
+
`main{width:100%;max-width:25rem;padding:32px 28px 26px;border-radius:18px;background:var(--card);` +
|
|
3987
|
+
`border:1px solid var(--line);box-shadow:0 1px 2px #0000000f,0 24px 60px #00000026;text-align:center;` +
|
|
3988
|
+
`animation:rise .34s cubic-bezier(.2,.8,.3,1) both}` +
|
|
3989
|
+
`@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}` +
|
|
3990
|
+
`@media(prefers-reduced-motion:reduce){main{animation:none}}` +
|
|
3991
|
+
// The mark alone, in the state's colour. A disc behind it made a third
|
|
3992
|
+
// bordered shape on a card that only has one thing to say, and a filled
|
|
3993
|
+
// circle reads as a notification — nothing here needs acknowledging.
|
|
3994
|
+
`.badge{display:grid;place-items:center;color:var(--tone)}` +
|
|
3995
|
+
`.badge svg{width:30px;height:30px}` +
|
|
3996
|
+
`.ok{--tone:var(--good);--tone-bg:var(--good-bg)}` +
|
|
3997
|
+
`.bad{--tone:var(--bad);--tone-bg:var(--bad-bg)}` +
|
|
3998
|
+
`h1{margin:18px 0 0;font-size:17px;font-weight:600;letter-spacing:-.012em;line-height:1.4}` +
|
|
3999
|
+
`p{margin:9px 0 0;font-size:13.5px;color:var(--dim)}` +
|
|
4000
|
+
// The mark, at a size that says whose page this is without competing with
|
|
4001
|
+
// the one sentence it exists to show. No rule above it: the gap already
|
|
4002
|
+
// separates it, and a divider on a card this short is a second border
|
|
4003
|
+
// around almost nothing.
|
|
4004
|
+
`footer{display:flex;align-items:center;justify-content:center;gap:7px;` +
|
|
4005
|
+
`margin-top:26px;opacity:.75;` +
|
|
4006
|
+
`font-size:12px;font-weight:550;letter-spacing:.01em;color:var(--dim)}` +
|
|
4007
|
+
`footer svg{width:15px;height:15px;color:var(--mark)}` +
|
|
3559
4008
|
`</style>` +
|
|
3560
4009
|
`<body><main>` +
|
|
4010
|
+
`<div class="badge ${ok ? "ok" : "bad"}">` +
|
|
4011
|
+
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" ` +
|
|
4012
|
+
`stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
|
|
4013
|
+
(ok
|
|
4014
|
+
? `<path d="M4 12.5 9.5 18 20 6.5"/>`
|
|
4015
|
+
: // The ring belongs to the glyph, not behind it: a bare exclamation
|
|
4016
|
+
// stroke, with nothing around it, reads as a piece of punctuation
|
|
4017
|
+
// that lost its sentence.
|
|
4018
|
+
`<circle cx="12" cy="12" r="9.2" stroke-width="1.9"/><path d="M12 7.4v5.4"/>` +
|
|
4019
|
+
`<circle cx="12" cy="16.4" r="1.15" fill="currentColor" stroke="none"/>`) +
|
|
4020
|
+
`</svg></div>` +
|
|
4021
|
+
`<h1>${heading}</h1>` +
|
|
4022
|
+
`<p>${body}</p>` +
|
|
4023
|
+
`<footer>` +
|
|
3561
4024
|
`<svg viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.2" ` +
|
|
3562
4025
|
`stroke-linecap="round" aria-hidden="true">` +
|
|
3563
4026
|
`<path d="M20 6h-8a6 6 0 0 0-6 6v8"/><path d="M12 26h8a6 6 0 0 0 6-6v-8"/>` +
|
|
3564
4027
|
`<circle cx="16" cy="16" r="3.5" fill="currentColor" stroke="none"/></svg>` +
|
|
3565
|
-
|
|
3566
|
-
(ok ? `<p>You can close this tab and go back to OHarness.</p>` : "") +
|
|
4028
|
+
`OHarness</footer>` +
|
|
3567
4029
|
`</main>`;
|
|
3568
4030
|
res.writeHead(status, {
|
|
3569
4031
|
"content-type": "text/html; charset=utf-8",
|
|
@@ -3619,6 +4081,13 @@ async function readBody(req, limit) {
|
|
|
3619
4081
|
* shape. Anything else is inlined as text under its filename — a base64 blob
|
|
3620
4082
|
* of a zip would burn context and tell the model nothing.
|
|
3621
4083
|
*/
|
|
4084
|
+
/** A project folder named by the page, resolved, or undefined if it is not one. */
|
|
4085
|
+
async function projectRoot(dir) {
|
|
4086
|
+
if (!dir)
|
|
4087
|
+
return undefined;
|
|
4088
|
+
const full = path.resolve(dir);
|
|
4089
|
+
return (await isDirectory(full)) ? full : undefined;
|
|
4090
|
+
}
|
|
3622
4091
|
/** Whether a chosen path is a folder; anything unreadable is treated as a file. */
|
|
3623
4092
|
async function isDirectory(target) {
|
|
3624
4093
|
try {
|