@oharness/server 0.8.0 → 0.9.1
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/account-usage.d.ts +29 -0
- package/dist/account-usage.js +56 -0
- package/dist/account-usage.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 +126 -2
- package/dist/i18n.js.map +1 -1
- package/dist/page/client/base.js +4 -0
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +3 -1
- package/dist/page/client/browser.js.map +1 -1
- package/dist/page/client/composer.js +20 -0
- package/dist/page/client/composer.js.map +1 -1
- 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.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +1 -1
- package/dist/page/client/routines.js +19 -1
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +12 -1
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +12 -4
- package/dist/page/client/state.js.map +1 -1
- 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.js +167 -0
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/client/usage.js +400 -70
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +258 -3
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +35 -1
- package/dist/page.js.map +1 -1
- package/dist/server.js +238 -31
- 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,7 +18,7 @@ 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";
|
|
20
|
-
import {
|
|
21
|
+
import { EXTENSION_IDS, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/host-manifest.ts";
|
|
21
22
|
import { announcePresence } from "@oharness/engine/browser/presence.ts";
|
|
22
23
|
import { extensionHostReachable } from "@oharness/engine/browser/socket.ts";
|
|
23
24
|
import { RoutineRunner } from "@oharness/engine/schedule/runner.ts";
|
|
@@ -28,7 +29,8 @@ import { readPrefs, writePrefs } from "./prefs.js";
|
|
|
28
29
|
import { sortModelsForDisplay } from "./model-order.js";
|
|
29
30
|
import { loadModelsDev, releaseDateOf, releaseDates } from "@oharness/engine/providers/models-dev.ts";
|
|
30
31
|
import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
|
|
31
|
-
import {
|
|
32
|
+
import { TerminalHub, terminalAsset } from "./terminal.js";
|
|
33
|
+
import { PREVIEW_MEDIA, browseFiles, previewFile, resolveFile, searchFiles, within } from "./files.js";
|
|
32
34
|
import { analyticsState } from "./analytics.js";
|
|
33
35
|
import { PAGE } from "./page.js";
|
|
34
36
|
import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
|
|
@@ -36,6 +38,7 @@ import { artifactDocument, sendArtifact } from "./artifacts.js";
|
|
|
36
38
|
import { sendProjectExport } from "./project-export.js";
|
|
37
39
|
import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
|
|
38
40
|
import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
|
|
41
|
+
import { accountUsage } from "./account-usage.js";
|
|
39
42
|
/**
|
|
40
43
|
* How many stored sessions the usage page counts.
|
|
41
44
|
*
|
|
@@ -45,6 +48,15 @@ import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engi
|
|
|
45
48
|
* stops short says so rather than reading as the whole history.
|
|
46
49
|
*/
|
|
47
50
|
const USAGE_SESSION_SCAN = 200;
|
|
51
|
+
/**
|
|
52
|
+
* How far back the sessions page reads, and how many rows it sends at a time.
|
|
53
|
+
*
|
|
54
|
+
* The page is "every session that spent something", so it reads further back
|
|
55
|
+
* than the usage summary does — but still a bounded number, and it says when
|
|
56
|
+
* it stopped. A page at a time because the list is drawn as it is scrolled.
|
|
57
|
+
*/
|
|
58
|
+
const USAGE_SESSIONS_SCAN = 5000;
|
|
59
|
+
const USAGE_SESSIONS_PAGE = 50;
|
|
48
60
|
/**
|
|
49
61
|
* How many trail entries one read of every session may return.
|
|
50
62
|
*
|
|
@@ -433,6 +445,7 @@ export async function startWebServer(opts) {
|
|
|
433
445
|
broadcast(announce(entry));
|
|
434
446
|
notify(entry);
|
|
435
447
|
});
|
|
448
|
+
const terminals = new TerminalHub();
|
|
436
449
|
const server = createServer((req, res) => {
|
|
437
450
|
void handle(req, res).catch((err) => {
|
|
438
451
|
send(res, 500, { error: errorMessage(err) });
|
|
@@ -449,7 +462,7 @@ export async function startWebServer(opts) {
|
|
|
449
462
|
"content-type": "text/html; charset=utf-8",
|
|
450
463
|
// Framed by itself and by the OHarness extension's side panel, and by
|
|
451
464
|
// nothing else: a page that drives a shell is not one to embed.
|
|
452
|
-
"content-security-policy": `frame-ancestors 'self' chrome-extension://${
|
|
465
|
+
"content-security-policy": `frame-ancestors 'self' ${EXTENSION_IDS.map((id) => `chrome-extension://${id}`).join(" ")}`,
|
|
453
466
|
});
|
|
454
467
|
res.end(PAGE);
|
|
455
468
|
return;
|
|
@@ -1069,6 +1082,137 @@ export async function startWebServer(opts) {
|
|
|
1069
1082
|
const result = await revealDirectory(dir);
|
|
1070
1083
|
return send(res, "error" in result ? 500 : 200, result);
|
|
1071
1084
|
}
|
|
1085
|
+
/*
|
|
1086
|
+
* The files pane: a project's tree one folder at a time, one file's
|
|
1087
|
+
* contents, and an image's bytes. Addressed by the project's folder
|
|
1088
|
+
* rather than a session, because the pane opens from the sidebar's
|
|
1089
|
+
* project menu too, where there may be no session in that tree at all.
|
|
1090
|
+
*/
|
|
1091
|
+
// The embedded terminal. One shell per project folder; see terminal.ts.
|
|
1092
|
+
case "POST /term/open": {
|
|
1093
|
+
const body = await readJson(req);
|
|
1094
|
+
const root = await projectRoot(typeof body.cwd === "string" ? body.cwd : null);
|
|
1095
|
+
if (!root)
|
|
1096
|
+
return send(res, 400, { error: "not a folder" });
|
|
1097
|
+
const replace = typeof body.replace === "string" ? body.replace : undefined;
|
|
1098
|
+
const result = await terminals.open(root, { cols: body.cols, rows: body.rows }, replace);
|
|
1099
|
+
return send(res, "error" in result ? 500 : 200, result);
|
|
1100
|
+
}
|
|
1101
|
+
case "GET /term/list": {
|
|
1102
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1103
|
+
if (!root)
|
|
1104
|
+
return send(res, 400, { error: "not a folder" });
|
|
1105
|
+
return send(res, 200, { terminals: terminals.list(root) });
|
|
1106
|
+
}
|
|
1107
|
+
case "GET /term/stream": {
|
|
1108
|
+
if (!terminals.stream(url.searchParams.get("id") ?? "", res)) {
|
|
1109
|
+
return send(res, 404, { error: "no such terminal" });
|
|
1110
|
+
}
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
case "POST /term/input": {
|
|
1114
|
+
const body = await readJson(req);
|
|
1115
|
+
const ok = terminals.input(String(body.id ?? ""), body.data);
|
|
1116
|
+
return send(res, ok ? 200 : 404, ok ? { ok } : { error: "no such terminal" });
|
|
1117
|
+
}
|
|
1118
|
+
case "POST /term/resize": {
|
|
1119
|
+
const body = await readJson(req);
|
|
1120
|
+
const ok = terminals.resize(String(body.id ?? ""), body.cols, body.rows);
|
|
1121
|
+
return send(res, ok ? 200 : 404, ok ? { ok } : { error: "no such terminal" });
|
|
1122
|
+
}
|
|
1123
|
+
case "POST /term/close": {
|
|
1124
|
+
const body = await readJson(req);
|
|
1125
|
+
terminals.close(String(body.id ?? ""));
|
|
1126
|
+
return send(res, 200, { ok: true });
|
|
1127
|
+
}
|
|
1128
|
+
case "GET /vendor/xterm.js":
|
|
1129
|
+
case "GET /vendor/xterm.css":
|
|
1130
|
+
case "GET /vendor/xterm-fit.js": {
|
|
1131
|
+
const file = terminalAsset(url.pathname);
|
|
1132
|
+
if (!file)
|
|
1133
|
+
return send(res, 404, { error: "terminal assets are not installed" });
|
|
1134
|
+
const data = await readFile(file);
|
|
1135
|
+
res.writeHead(200, {
|
|
1136
|
+
"content-type": url.pathname.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8",
|
|
1137
|
+
"content-length": data.byteLength,
|
|
1138
|
+
"cache-control": "private, max-age=3600",
|
|
1139
|
+
});
|
|
1140
|
+
res.end(data);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
case "GET /files/tree": {
|
|
1144
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1145
|
+
if (!root)
|
|
1146
|
+
return send(res, 400, { error: "not a folder" });
|
|
1147
|
+
return send(res, 200, await browseFiles(root, url.searchParams.get("dir") ?? "", { all: true }));
|
|
1148
|
+
}
|
|
1149
|
+
case "GET /files/read": {
|
|
1150
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1151
|
+
if (!root)
|
|
1152
|
+
return send(res, 400, { error: "not a folder" });
|
|
1153
|
+
const result = await previewFile(root, url.searchParams.get("path") ?? "");
|
|
1154
|
+
return send(res, "error" in result ? 404 : 200, result);
|
|
1155
|
+
}
|
|
1156
|
+
case "GET /files/raw": {
|
|
1157
|
+
const root = await projectRoot(url.searchParams.get("cwd"));
|
|
1158
|
+
const full = root && resolveFile(root, url.searchParams.get("path") ?? "");
|
|
1159
|
+
const media = full && PREVIEW_MEDIA[path.extname(full).toLowerCase()];
|
|
1160
|
+
if (!full || !media)
|
|
1161
|
+
return send(res, 415, { error: "not previewable" });
|
|
1162
|
+
let size;
|
|
1163
|
+
try {
|
|
1164
|
+
const info = await stat(full);
|
|
1165
|
+
if (!info.isFile())
|
|
1166
|
+
return send(res, 404, { error: "not a file" });
|
|
1167
|
+
size = info.size;
|
|
1168
|
+
}
|
|
1169
|
+
catch {
|
|
1170
|
+
return send(res, 404, { error: "no such file" });
|
|
1171
|
+
}
|
|
1172
|
+
// A video is seeked by asking for the bytes at the new position, and
|
|
1173
|
+
// one too long to buffer whole would not play at all without this.
|
|
1174
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
|
|
1175
|
+
let first = 0;
|
|
1176
|
+
let last = size - 1;
|
|
1177
|
+
if (range && (range[1] || range[2])) {
|
|
1178
|
+
if (range[1]) {
|
|
1179
|
+
first = Number(range[1]);
|
|
1180
|
+
if (range[2])
|
|
1181
|
+
last = Math.min(Number(range[2]), size - 1);
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
first = Math.max(0, size - Number(range[2]));
|
|
1185
|
+
}
|
|
1186
|
+
if (first > last || first >= size) {
|
|
1187
|
+
res.writeHead(416, { "content-range": `bytes */${size}` });
|
|
1188
|
+
res.end();
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
const partial = Boolean(range && (range[1] || range[2]));
|
|
1193
|
+
res.writeHead(partial ? 206 : 200, {
|
|
1194
|
+
"content-type": media.type,
|
|
1195
|
+
"content-length": last - first + 1,
|
|
1196
|
+
"accept-ranges": "bytes",
|
|
1197
|
+
...(partial ? { "content-range": `bytes ${first}-${last}/${size}` } : {}),
|
|
1198
|
+
"x-content-type-options": "nosniff",
|
|
1199
|
+
// Served inert: this is same-origin to a page that holds the session
|
|
1200
|
+
// token, and an SVG is a document that can script. A PDF is left
|
|
1201
|
+
// unsandboxed, because the browser's viewer will not open inside a
|
|
1202
|
+
// sandbox; the viewer runs in its own process, apart from the page.
|
|
1203
|
+
...(media.kind === "pdf"
|
|
1204
|
+
? {}
|
|
1205
|
+
: { "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox" }),
|
|
1206
|
+
"cache-control": "private, no-store",
|
|
1207
|
+
});
|
|
1208
|
+
if (req.method === "HEAD" || size === 0)
|
|
1209
|
+
return void res.end();
|
|
1210
|
+
const stream = createReadStream(full, { start: first, end: last });
|
|
1211
|
+
stream.on("error", () => res.destroy());
|
|
1212
|
+
res.on("close", () => stream.destroy());
|
|
1213
|
+
stream.pipe(res);
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1072
1216
|
case "POST /pick": {
|
|
1073
1217
|
const body = await readJson(req);
|
|
1074
1218
|
// The dialog opens on this machine's desktop, which is the same
|
|
@@ -1990,6 +2134,22 @@ export async function startWebServer(opts) {
|
|
|
1990
2134
|
})),
|
|
1991
2135
|
});
|
|
1992
2136
|
}
|
|
2137
|
+
/**
|
|
2138
|
+
* The account's spend, from the site's ledger rather than this machine.
|
|
2139
|
+
*
|
|
2140
|
+
* What `GET /usage` cannot see: chat from other devices, and every image,
|
|
2141
|
+
* video and clip of audio, which are billed by the gateway and settled
|
|
2142
|
+
* after the call that started them. Answers `signedIn: false` for a
|
|
2143
|
+
* machine with no account, so the page falls back to its own figures.
|
|
2144
|
+
*/
|
|
2145
|
+
case "GET /usage/account": {
|
|
2146
|
+
return send(res, 200, await accountUsage({
|
|
2147
|
+
token: await current.harness.accountToken(),
|
|
2148
|
+
team: officialTeam(),
|
|
2149
|
+
site: siteUrl(),
|
|
2150
|
+
query: url.searchParams,
|
|
2151
|
+
}));
|
|
2152
|
+
}
|
|
1993
2153
|
/**
|
|
1994
2154
|
* What has been spent on this machine, and on what.
|
|
1995
2155
|
*
|
|
@@ -2003,6 +2163,33 @@ export async function startWebServer(opts) {
|
|
|
2003
2163
|
* mid-turn has already spent money its metadata will not know about
|
|
2004
2164
|
* until the turn lands, and the page asking is the page that spent it.
|
|
2005
2165
|
*/
|
|
2166
|
+
/**
|
|
2167
|
+
* Every session that spent something, newest first, a page at a time.
|
|
2168
|
+
*
|
|
2169
|
+
* The usage page lists the latest few; this is the rest of them, with a
|
|
2170
|
+
* search over the title and the model. Filtered here rather than in the
|
|
2171
|
+
* page so a search reaches sessions the page has not loaded yet.
|
|
2172
|
+
*/
|
|
2173
|
+
case "GET /usage/sessions": {
|
|
2174
|
+
const { stored, rows } = await usageRows(USAGE_SESSIONS_SCAN);
|
|
2175
|
+
const query = (url.searchParams.get("q") || "").trim().toLowerCase();
|
|
2176
|
+
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
2177
|
+
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit")) || USAGE_SESSIONS_PAGE));
|
|
2178
|
+
const list = [...rows.values()]
|
|
2179
|
+
.filter((row) => row.costUsd > 0 || row.input || row.output || row.cacheRead || row.cacheWrite)
|
|
2180
|
+
.filter((row) => !query || (row.title || "").toLowerCase().includes(query) || row.model.toLowerCase().includes(query))
|
|
2181
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
2182
|
+
return send(res, 200, {
|
|
2183
|
+
sessions: list.slice(offset, offset + limit).map((row) => ({
|
|
2184
|
+
...row,
|
|
2185
|
+
current: row.id === current.session.id,
|
|
2186
|
+
})),
|
|
2187
|
+
total: list.length,
|
|
2188
|
+
offset,
|
|
2189
|
+
more: offset + limit < list.length,
|
|
2190
|
+
truncated: stored.length >= USAGE_SESSIONS_SCAN,
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2006
2193
|
case "GET /usage": {
|
|
2007
2194
|
// How far back the page is asking about. Everything below — the
|
|
2008
2195
|
// totals, the models, the sessions and the curve — is that window and
|
|
@@ -2010,33 +2197,7 @@ export async function startWebServer(opts) {
|
|
|
2010
2197
|
// from the chart under it is worse than no figure.
|
|
2011
2198
|
const window = usageWindow(url.searchParams.get("range"), url.searchParams.get("from"), url.searchParams.get("to"));
|
|
2012
2199
|
const { since, until } = window;
|
|
2013
|
-
const stored = await
|
|
2014
|
-
const rows = new Map();
|
|
2015
|
-
for (const meta of stored) {
|
|
2016
|
-
rows.set(meta.id, {
|
|
2017
|
-
id: meta.id,
|
|
2018
|
-
title: meta.title,
|
|
2019
|
-
model: meta.model,
|
|
2020
|
-
updatedAt: meta.updatedAt,
|
|
2021
|
-
turns: meta.turns,
|
|
2022
|
-
costUsd: meta.costUsd || 0,
|
|
2023
|
-
...tokensOf(meta.usage),
|
|
2024
|
-
});
|
|
2025
|
-
}
|
|
2026
|
-
for (const runner of runners.values()) {
|
|
2027
|
-
const sess = runner.session;
|
|
2028
|
-
rows.set(sess.id, {
|
|
2029
|
-
id: sess.id,
|
|
2030
|
-
title: sess.title,
|
|
2031
|
-
model: sess.model.id,
|
|
2032
|
-
// A session opened but not yet spoken to has no stored copy and no
|
|
2033
|
-
// last-updated time of its own; it is here, so it is now.
|
|
2034
|
-
updatedAt: rows.get(sess.id)?.updatedAt ?? Date.now(),
|
|
2035
|
-
turns: sess.turns,
|
|
2036
|
-
costUsd: sess.costUsd,
|
|
2037
|
-
...tokensOf(sess.usage),
|
|
2038
|
-
});
|
|
2039
|
-
}
|
|
2200
|
+
const { stored, rows } = await usageRows(USAGE_SESSION_SCAN);
|
|
2040
2201
|
// Sessions that never sent anything are dropped: they contribute zero
|
|
2041
2202
|
// to every total here, and a page about what was spent that opens with
|
|
2042
2203
|
// a screen of "$0.0000 · 0 tokens" buries the ones that cost money.
|
|
@@ -2082,7 +2243,7 @@ export async function startWebServer(opts) {
|
|
|
2082
2243
|
models: [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd),
|
|
2083
2244
|
// A page of them is a page nobody reads. The rest are counted in the
|
|
2084
2245
|
// totals above, which is where a number that large belongs.
|
|
2085
|
-
sessions: list.slice(0,
|
|
2246
|
+
sessions: list.slice(0, 10).map((row) => ({
|
|
2086
2247
|
...row,
|
|
2087
2248
|
current: row.id === current.session.id,
|
|
2088
2249
|
})),
|
|
@@ -3437,6 +3598,44 @@ export async function startWebServer(opts) {
|
|
|
3437
3598
|
* log carries. Everything else is folded from the revision logs, which is
|
|
3438
3599
|
* what makes the panel's answer independent of which tabs are open.
|
|
3439
3600
|
*/
|
|
3601
|
+
/**
|
|
3602
|
+
* One row per session: the stored ones, with the open ones laid over their
|
|
3603
|
+
* stored copy. A session mid-turn has already spent money its metadata will
|
|
3604
|
+
* not know about until the turn lands, and the page asking is the page that
|
|
3605
|
+
* spent it.
|
|
3606
|
+
*/
|
|
3607
|
+
async function usageRows(scan) {
|
|
3608
|
+
const stored = await current.harness.listSessions(scan);
|
|
3609
|
+
const rows = new Map();
|
|
3610
|
+
for (const meta of stored) {
|
|
3611
|
+
rows.set(meta.id, {
|
|
3612
|
+
id: meta.id,
|
|
3613
|
+
cwd: meta.cwd,
|
|
3614
|
+
title: meta.title,
|
|
3615
|
+
model: meta.model,
|
|
3616
|
+
updatedAt: meta.updatedAt,
|
|
3617
|
+
turns: meta.turns,
|
|
3618
|
+
costUsd: meta.costUsd || 0,
|
|
3619
|
+
...tokensOf(meta.usage),
|
|
3620
|
+
});
|
|
3621
|
+
}
|
|
3622
|
+
for (const runner of runners.values()) {
|
|
3623
|
+
const sess = runner.session;
|
|
3624
|
+
rows.set(sess.id, {
|
|
3625
|
+
id: sess.id,
|
|
3626
|
+
cwd: rows.get(sess.id)?.cwd ?? runner.harness.cwd,
|
|
3627
|
+
title: sess.title,
|
|
3628
|
+
model: sess.model.id,
|
|
3629
|
+
// A session opened but not yet spoken to has no stored copy and no
|
|
3630
|
+
// last-updated time of its own; it is here, so it is now.
|
|
3631
|
+
updatedAt: rows.get(sess.id)?.updatedAt ?? Date.now(),
|
|
3632
|
+
turns: sess.turns,
|
|
3633
|
+
costUsd: sess.costUsd,
|
|
3634
|
+
...tokensOf(sess.usage),
|
|
3635
|
+
});
|
|
3636
|
+
}
|
|
3637
|
+
return { stored, rows };
|
|
3638
|
+
}
|
|
3440
3639
|
async function artifactCatalogue() {
|
|
3441
3640
|
const groups = [];
|
|
3442
3641
|
for (const runner of runners.values()) {
|
|
@@ -3603,6 +3802,7 @@ export async function startWebServer(opts) {
|
|
|
3603
3802
|
unsubscribe();
|
|
3604
3803
|
for (const harness of harnesses.values())
|
|
3605
3804
|
harness.cloudConnectors.close();
|
|
3805
|
+
terminals.closeAll();
|
|
3606
3806
|
for (const client of clients)
|
|
3607
3807
|
client.end();
|
|
3608
3808
|
clients.clear();
|
|
@@ -3946,6 +4146,13 @@ async function readBody(req, limit) {
|
|
|
3946
4146
|
* shape. Anything else is inlined as text under its filename — a base64 blob
|
|
3947
4147
|
* of a zip would burn context and tell the model nothing.
|
|
3948
4148
|
*/
|
|
4149
|
+
/** A project folder named by the page, resolved, or undefined if it is not one. */
|
|
4150
|
+
async function projectRoot(dir) {
|
|
4151
|
+
if (!dir)
|
|
4152
|
+
return undefined;
|
|
4153
|
+
const full = path.resolve(dir);
|
|
4154
|
+
return (await isDirectory(full)) ? full : undefined;
|
|
4155
|
+
}
|
|
3949
4156
|
/** Whether a chosen path is a folder; anything unreadable is treated as a file. */
|
|
3950
4157
|
async function isDirectory(target) {
|
|
3951
4158
|
try {
|