@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.
Files changed (43) hide show
  1. package/dist/account-usage.d.ts +29 -0
  2. package/dist/account-usage.js +56 -0
  3. package/dist/account-usage.js.map +1 -0
  4. package/dist/files.d.ts +40 -1
  5. package/dist/files.js +94 -3
  6. package/dist/files.js.map +1 -1
  7. package/dist/i18n.js +126 -2
  8. package/dist/i18n.js.map +1 -1
  9. package/dist/page/client/base.js +4 -0
  10. package/dist/page/client/base.js.map +1 -1
  11. package/dist/page/client/browser.js +3 -1
  12. package/dist/page/client/browser.js.map +1 -1
  13. package/dist/page/client/composer.js +20 -0
  14. package/dist/page/client/composer.js.map +1 -1
  15. package/dist/page/client/files.d.ts +7 -0
  16. package/dist/page/client/files.js +412 -0
  17. package/dist/page/client/files.js.map +1 -0
  18. package/dist/page/client/index.js +4 -0
  19. package/dist/page/client/index.js.map +1 -1
  20. package/dist/page/client/panels.js +1 -1
  21. package/dist/page/client/routines.js +19 -1
  22. package/dist/page/client/routines.js.map +1 -1
  23. package/dist/page/client/shell.js +12 -1
  24. package/dist/page/client/shell.js.map +1 -1
  25. package/dist/page/client/state.js +12 -4
  26. package/dist/page/client/state.js.map +1 -1
  27. package/dist/page/client/terminal.d.ts +7 -0
  28. package/dist/page/client/terminal.js +347 -0
  29. package/dist/page/client/terminal.js.map +1 -0
  30. package/dist/page/client/transcript.js +167 -0
  31. package/dist/page/client/transcript.js.map +1 -1
  32. package/dist/page/client/usage.js +400 -70
  33. package/dist/page/client/usage.js.map +1 -1
  34. package/dist/page/style.js +258 -3
  35. package/dist/page/style.js.map +1 -1
  36. package/dist/page.js +35 -1
  37. package/dist/page.js.map +1 -1
  38. package/dist/server.js +238 -31
  39. package/dist/server.js.map +1 -1
  40. package/dist/terminal.d.ts +34 -0
  41. package/dist/terminal.js +211 -0
  42. package/dist/terminal.js.map +1 -0
  43. 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 { EXTENSION_ID, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/host-manifest.ts";
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 { browseFiles, searchFiles, within } from "./files.js";
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://${EXTENSION_ID}`,
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 current.harness.listSessions(USAGE_SESSION_SCAN);
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, 20).map((row) => ({
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 {