@oharness/server 0.11.0 → 0.14.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/server.js CHANGED
@@ -5,16 +5,21 @@ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { Harness } from "@oharness/engine/harness.ts";
8
+ import { BackgroundUsage } from "@oharness/engine/agent/background-usage.ts";
8
9
  import { updateUserMcpConfig } from "@oharness/engine/config-files.ts";
9
10
  import { connectorConfig, officialConnectors } from "@oharness/engine/mcp/registry.ts";
11
+ import { findForeignServer, foreignMcpServers } from "@oharness/engine/foreign.ts";
12
+ import { installMarketPlugin, marketPlugins, removeMarketPlugin } from "@oharness/engine/plugin-market.ts";
10
13
  import { missingSecrets } from "@oharness/engine/mcp/secrets.ts";
11
- import { connectorIcon, providerSites, siteIcon } from "./icons.js";
14
+ import { connectorIcon, providerSites, sameSite, siteIcon, vendorSites } from "./icons.js";
15
+ import { TOOL_ICONS } from "./tool-icons.js";
12
16
  import { checkAnswer } from "@oharness/engine/tools/questions.ts";
13
17
  import { isFree } from "@oharness/engine/providers/types.ts";
14
18
  import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
15
19
  import { canDisableThinking, effectiveEffort, isEffortChoice, thinkingFor, } from "@oharness/engine/providers/types.ts";
16
20
  import { isReadable } from "@oharness/engine/agent/workspace.ts";
17
21
  import { BRANCH_TYPES, createWorktree, findWorktree, finishWorktree, listWorktrees, previewWorktree, worktreeStatus, } from "@oharness/engine/worktree/index.ts";
22
+ import { restoredTaskView } from "@oharness/engine/agent/background.ts";
18
23
  import { within as insideAny } from "@oharness/engine/paths.ts";
19
24
  import { visibleText } from "@oharness/engine/core/types.ts";
20
25
  import { previewJson } from "@oharness/engine/util/json.ts";
@@ -302,6 +307,14 @@ export async function startWebServer(opts) {
302
307
  equipThemes(opts.agent.tools);
303
308
  const themeChanged = (state) => broadcast({ type: "theme", state });
304
309
  const runners = new Map();
310
+ /** Sidebar rows of sessions that are not loaded, keyed by tasks-file stamp. */
311
+ const storedTaskRows = new Map();
312
+ const sidebarTaskRows = (views) => views
313
+ .filter((task) => !task.dismissed)
314
+ .map((task) => ({
315
+ id: task.id, description: task.description, status: task.status,
316
+ queuedAt: task.queuedAt, endedAt: task.endedAt, activity: task.activity,
317
+ }));
305
318
  /** Agents whose background tasks are being watched; see watchTasks. */
306
319
  const watchedTasks = new WeakSet();
307
320
  // Written in the background; the page on that project swaps them in.
@@ -427,7 +440,6 @@ export async function startWebServer(opts) {
427
440
  // browser is still the same one bound by Harness.agent(session).
428
441
  initial.agent = opts.agent;
429
442
  runners.set(opts.session.id, initial);
430
- adoptTasks(initial);
431
443
  let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
432
444
  /**
433
445
  * Sign-ins waiting for their emailed code.
@@ -603,6 +615,8 @@ export async function startWebServer(opts) {
603
615
  for (const client of clients)
604
616
  client.write(payload);
605
617
  };
618
+ // Restored tasks may resolve immediately, so the broadcaster must exist.
619
+ adoptTasks(initial);
606
620
  const connectionSubscriptions = [];
607
621
  function watchConnections(harness) {
608
622
  connectionSubscriptions.push(harness.cloudConnectors.subscribe((card) => {
@@ -1713,13 +1727,19 @@ export async function startWebServer(opts) {
1713
1727
  case "GET /registry": {
1714
1728
  const installed = new Set(contextOffer().connectors.map((one) => one.name));
1715
1729
  const entries = await officialConnectors();
1730
+ const found = await foreignMcpServers(current.session.cwd, current.harness.config.imports ?? {}, current.harness.plugins);
1716
1731
  return send(res, 200, {
1732
+ // What Claude Code and Codex already have, offered rather than
1733
+ // started: each one is a process, and often a token, that the user
1734
+ // set up for another program. See `foreign.ts`.
1735
+ imports: found.map((one) => foreignRow(one, installed)),
1717
1736
  connectors: entries.map((entry) => ({
1718
1737
  id: entry.id,
1719
1738
  label: entry.label,
1720
1739
  ...(entry.description ? { description: entry.description } : {}),
1721
1740
  ...(entry.publisher ? { publisher: entry.publisher } : {}),
1722
1741
  ...(entry.docsUrl ? { docsUrl: entry.docsUrl } : {}),
1742
+ ...(entry.category ? { category: entry.category } : {}),
1723
1743
  installed: installed.has(entry.id),
1724
1744
  // Said before the click, not after it. An entry that needs a key
1725
1745
  // fails at connect with a perfectly clear message, but a menu that
@@ -1737,10 +1757,35 @@ export async function startWebServer(opts) {
1737
1757
  * otherwise be twelve third-party requests, each one telling a vendor
1738
1758
  * that this user opened the menu before connecting anything.
1739
1759
  */
1760
+ // Claude Code's and Codex's own marks; see tool-icons.ts.
1761
+ case "GET /tool-icon": {
1762
+ const data = TOOL_ICONS[url.searchParams.get("id") ?? ""];
1763
+ if (!data)
1764
+ return send(res, 404, { error: "no icon" });
1765
+ const bytes = Buffer.from(data, "base64");
1766
+ res.writeHead(200, {
1767
+ "content-type": "image/png",
1768
+ "content-length": bytes.length,
1769
+ "cache-control": "private, max-age=604800",
1770
+ });
1771
+ res.end(bytes);
1772
+ return;
1773
+ }
1740
1774
  case "GET /connector-icon": {
1741
1775
  const id = url.searchParams.get("id") ?? "";
1742
1776
  const entry = (await officialConnectors()).find((one) => one.id === id);
1743
- const icon = entry?.siteUrl ? await connectorIcon(entry.id, entry.siteUrl) : undefined;
1777
+ let icon = entry?.siteUrl ? await connectorIcon(entry.id, entry.siteUrl) : undefined;
1778
+ // One brought over from Claude Code or Codex, or added from them: its
1779
+ // vendor's site, from the host it talks to — mcp.zernio.com is
1780
+ // zernio.com. Looked up by name here, never taken from the request.
1781
+ if (!icon && !entry) {
1782
+ const site = foreignSite((current.harness.mcpStatus?.() ?? []).some((one) => one.name === id)
1783
+ ? current.harness.mcp.configFor(id)
1784
+ : (await foreignMcpServers(current.session.cwd, current.harness.config.imports ?? {}, current.harness.plugins))
1785
+ .find((one) => one.name === id && one.config?.type === "http")?.config);
1786
+ if (site)
1787
+ icon = await siteIcon(`site-${new URL(site).hostname}`, vendorSites(site));
1788
+ }
1744
1789
  // A row without a picture draws a monogram, so this is a normal answer
1745
1790
  // rather than something worth reporting.
1746
1791
  if (!icon)
@@ -1794,6 +1839,42 @@ export async function startWebServer(opts) {
1794
1839
  */
1795
1840
  case "POST /connector": {
1796
1841
  const body = await readJson(req);
1842
+ /**
1843
+ * One found in Claude Code's or Codex's config, named by where it was
1844
+ * found rather than sent whole. The declaration is read again here,
1845
+ * so what starts is what is in that file — not whatever a page that
1846
+ * can reach this port chose to put in a request body.
1847
+ */
1848
+ if (body.import && typeof body.import === "object") {
1849
+ const key = body.import;
1850
+ const found = await findForeignServer(current.session.cwd, {
1851
+ source: String(key.source ?? ""),
1852
+ scope: String(key.scope ?? ""),
1853
+ name: String(key.name ?? ""),
1854
+ ...(typeof key.plugin === "string" ? { plugin: key.plugin } : {}),
1855
+ }, current.harness.config.imports ?? {}, current.harness.plugins);
1856
+ if (!found)
1857
+ return send(res, 404, { error: "that server is no longer in its config file" });
1858
+ if (!found.config)
1859
+ return send(res, 400, { error: `${found.name} cannot be used here` });
1860
+ const name = found.name;
1861
+ if (current.harness.mcpStatus().some((one) => one.name === name)) {
1862
+ return send(res, 409, { error: `a connector named "${name}" is already here` });
1863
+ }
1864
+ try {
1865
+ const status = await current.harness.addMcpServer(name, found.config);
1866
+ if (!status.connected && !status.needsAuth) {
1867
+ await current.harness.removeMcpServer(name);
1868
+ return send(res, 400, { error: status.error ?? `${name} did not start` });
1869
+ }
1870
+ await updateUserMcpConfig((existing) => ({ ...existing, [name]: found.config }));
1871
+ return send(res, 200, { status, ...contextOffer() });
1872
+ }
1873
+ catch (err) {
1874
+ await current.harness.removeMcpServer(name).catch(() => { });
1875
+ return send(res, 400, { error: errorMessage(err) });
1876
+ }
1877
+ }
1797
1878
  const id = typeof body.id === "string" ? body.id.trim() : "";
1798
1879
  if (!id)
1799
1880
  return send(res, 400, { error: "id is required" });
@@ -1834,6 +1915,103 @@ export async function startWebServer(opts) {
1834
1915
  return send(res, 400, { error: errorMessage(err) });
1835
1916
  }
1836
1917
  }
1918
+ /**
1919
+ * The plugins on offer, as the connectors are: a file, plus what is here.
1920
+ *
1921
+ * A plugin counts as here when one of that name is loaded from anywhere —
1922
+ * Claude Code's cache included. Offering a second copy of something the
1923
+ * Installed list already shows is how a menu ends up with two PostHogs.
1924
+ */
1925
+ case "GET /plugin-market": {
1926
+ const loaded = current.harness.plugins ?? [];
1927
+ const entries = await marketPlugins();
1928
+ return send(res, 200, {
1929
+ plugins: entries.map((entry) => ({
1930
+ id: entry.id,
1931
+ label: entry.label,
1932
+ publisher: entry.publisher,
1933
+ category: entry.category,
1934
+ description: entry.description,
1935
+ ...(entry.homepage ? { homepage: entry.homepage } : {}),
1936
+ provides: entry.provides,
1937
+ installed: loaded.some((one) => one.market?.id === entry.id || one.name === entry.id),
1938
+ })),
1939
+ });
1940
+ }
1941
+ /**
1942
+ * Install a plugin from the list, or remove one it installed.
1943
+ *
1944
+ * Every open project's harness takes the change, not just the focused
1945
+ * one: the plugin directory is the user's, so it is in every project,
1946
+ * and a tab in another tree that kept the old set would disagree with
1947
+ * the menu about what is installed.
1948
+ */
1949
+ case "POST /plugin-market": {
1950
+ const body = await readJson(req);
1951
+ const id = typeof body.id === "string" ? body.id.trim() : "";
1952
+ if (!id)
1953
+ return send(res, 400, { error: "id is required" });
1954
+ const open = new Set([current.harness, ...harnesses.values()]);
1955
+ if (body.remove === true) {
1956
+ const installed = (current.harness.plugins ?? []).find((one) => one.market?.id === id);
1957
+ if (!installed)
1958
+ return send(res, 404, { error: `${id} was not installed from the plugin list` });
1959
+ for (const harness of open)
1960
+ await harness.dropPlugin(installed.directory);
1961
+ await removeMarketPlugin(id);
1962
+ return send(res, 200, { removed: id, ...contextOffer() });
1963
+ }
1964
+ const entry = (await marketPlugins()).find((one) => one.id === id);
1965
+ if (!entry)
1966
+ return send(res, 404, { error: `no plugin named "${id}"` });
1967
+ try {
1968
+ const directory = await installMarketPlugin(entry);
1969
+ for (const harness of open)
1970
+ await harness.adoptPlugin(directory);
1971
+ return send(res, 200, { installed: id, ...contextOffer() });
1972
+ }
1973
+ catch (err) {
1974
+ return send(res, 400, { error: errorMessage(err) });
1975
+ }
1976
+ }
1977
+ /**
1978
+ * A plugin's vendor icon, on the same terms as a connector's.
1979
+ *
1980
+ * The marketplace's site for the plugin first, because a manifest's
1981
+ * homepage is as often a GitHub repository as the vendor's own page, and
1982
+ * every one of those would come back wearing GitHub's mark.
1983
+ */
1984
+ case "GET /plugin-icon": {
1985
+ const id = url.searchParams.get("id") ?? "";
1986
+ const loaded = (current.harness.plugins ?? []).find((one) => one.name === id || one.market?.id === id);
1987
+ // A Codex plugin ships its own mark, which beats any guess from its site.
1988
+ if (loaded?.logo) {
1989
+ const type = { ".png": "image/png", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" }[path.extname(loaded.logo).toLowerCase()];
1990
+ const bytes = type ? await readFile(loaded.logo).catch(() => undefined) : undefined;
1991
+ if (bytes && type) {
1992
+ res.writeHead(200, { "content-type": type, "content-length": bytes.length, "cache-control": "private, max-age=86400" });
1993
+ res.end(bytes);
1994
+ return;
1995
+ }
1996
+ }
1997
+ const listed = (await marketPlugins()).find((one) => one.id === id || one.id === loaded?.market?.id);
1998
+ const site = listed?.siteUrl ?? loaded?.siteUrl;
1999
+ // A vendor with a connector too wears the connector's mark: the two
2000
+ // dialogs then show one vendor one way, and a mark the connector list
2001
+ // already fetched is not lost to this one's failed attempt.
2002
+ const sibling = site ? (await officialConnectors()).find((one) => sameSite(one.siteUrl, site)) : undefined;
2003
+ const icon = (sibling?.siteUrl ? await connectorIcon(sibling.id, sibling.siteUrl) : undefined) ??
2004
+ (site ? await siteIcon(`plugin-${listed?.id ?? id}`, vendorSites(site)) : undefined);
2005
+ if (!icon)
2006
+ return send(res, 404, { error: "no icon" });
2007
+ res.writeHead(200, {
2008
+ "content-type": icon.type,
2009
+ "content-length": icon.bytes.length,
2010
+ "cache-control": "private, max-age=86400",
2011
+ });
2012
+ res.end(icon.bytes);
2013
+ return;
2014
+ }
1837
2015
  /**
1838
2016
  * Log in to a connector that asked for it.
1839
2017
  *
@@ -2391,6 +2569,23 @@ export async function startWebServer(opts) {
2391
2569
  broadcast({ type: "connectors_changed", ...contextOffer() });
2392
2570
  return send(res, 200, await current.harness.claudeAgentStatus());
2393
2571
  }
2572
+ /**
2573
+ * Switch one skill off or on. Every open project's harness, as with
2574
+ * Claude Agent: it is a setting of this machine, not of one project.
2575
+ */
2576
+ case "POST /skill": {
2577
+ const body = await readJson(req);
2578
+ const name = typeof body.name === "string" ? body.name.trim() : "";
2579
+ if (!name)
2580
+ return send(res, 400, { error: "name is required" });
2581
+ if (typeof body.enabled !== "boolean")
2582
+ return send(res, 400, { error: "enabled must be a boolean" });
2583
+ for (const harness of new Set([current.harness, ...harnesses.values()])) {
2584
+ await harness.setSkillEnabled(name, body.enabled);
2585
+ }
2586
+ broadcast({ type: "connectors_changed", ...contextOffer() });
2587
+ return send(res, 200, contextOffer());
2588
+ }
2394
2589
  case "POST /settings/browser-mode": {
2395
2590
  const body = await readJson(req);
2396
2591
  if (body.mode !== "auto" && body.mode !== "builtin" && body.mode !== "extension") {
@@ -2827,13 +3022,39 @@ export async function startWebServer(opts) {
2827
3022
  case "GET /sessions": {
2828
3023
  const prefs = await readPrefs();
2829
3024
  const pinned = new Set(prefs.pinnedSessions ?? []);
3025
+ const metas = await current.harness.listSessions(50);
3026
+ const taskRows = new Map();
3027
+ // Bound disk concurrency. Read stored views only: listing history must
3028
+ // never create agents, switch projects or deliver pending results.
3029
+ for (let offset = 0; offset < metas.length; offset += 5) {
3030
+ await Promise.all(metas.slice(offset, offset + 5).map(async (meta) => {
3031
+ const runner = runners.get(meta.id);
3032
+ if (runner) {
3033
+ taskRows.set(meta.id, sidebarTaskRows(runner.agent.backgroundTasks?.list() ?? []));
3034
+ return;
3035
+ }
3036
+ // The stored document carries every task's conversation; parse it
3037
+ // again only when it has been rewritten since the last listing.
3038
+ const stamp = await current.harness.store.tasksStamp(meta.id);
3039
+ const cached = storedTaskRows.get(meta.id);
3040
+ if (cached && cached.stamp === stamp)
3041
+ return void taskRows.set(meta.id, cached.rows);
3042
+ const rows = stamp === undefined ? [] : sidebarTaskRows((await current.harness.store.readTasks(meta.id)).flatMap((raw) => {
3043
+ const view = raw?.view;
3044
+ return view?.id ? [restoredTaskView(view)] : [];
3045
+ }));
3046
+ storedTaskRows.set(meta.id, { stamp, rows });
3047
+ taskRows.set(meta.id, rows);
3048
+ }));
3049
+ }
2830
3050
  return send(res, 200, {
2831
3051
  // What somebody arranged by hand travels beside the list rather than
2832
3052
  // being applied to it here: the sidebar groups by project and hides
2833
3053
  // the tail of each group, so it is the only side that knows which
2834
3054
  // rows an order has to survive.
2835
3055
  order: prefs.sessionOrder ?? {},
2836
- sessions: (await current.harness.listSessions(50)).map((meta) => ({
3056
+ sessions: metas.map((meta) => ({
3057
+ tasks: taskRows.get(meta.id) ?? [],
2837
3058
  id: meta.id,
2838
3059
  pinned: pinned.has(meta.id),
2839
3060
  title: meta.title,
@@ -2901,7 +3122,8 @@ export async function startWebServer(opts) {
2901
3122
  ...row,
2902
3123
  current: row.id === current.session.id,
2903
3124
  })),
2904
- total: list.length,
3125
+ // Sessions, not rows: a day of background calls is listed but is not one.
3126
+ total: list.filter((row) => !row.background).length,
2905
3127
  offset,
2906
3128
  more: offset + limit < list.length,
2907
3129
  truncated: stored.length >= USAGE_SESSIONS_SCAN,
@@ -2934,6 +3156,9 @@ export async function startWebServer(opts) {
2934
3156
  return into;
2935
3157
  };
2936
3158
  const totals = list.reduce(add, { ...zero });
3159
+ // A background row is spending, not a session: it counts toward the
3160
+ // money and tokens, not toward how many sessions they came from.
3161
+ const sessionCount = list.filter((row) => !row.background).length;
2937
3162
  // Per model, because that is the axis a bill is read along: the same
2938
3163
  // work costs an order of magnitude more on one model than another, and
2939
3164
  // the only way to see that is to have them side by side.
@@ -2941,7 +3166,8 @@ export async function startWebServer(opts) {
2941
3166
  for (const row of list) {
2942
3167
  const bucket = byModel.get(row.model)
2943
3168
  ?? { model: row.model, sessions: 0, lastUsedAt: 0, ...zero };
2944
- bucket.sessions += 1;
3169
+ if (!row.background)
3170
+ bucket.sessions += 1;
2945
3171
  bucket.lastUsedAt = Math.max(bucket.lastUsedAt, row.updatedAt);
2946
3172
  byModel.set(row.model, add(bucket, row));
2947
3173
  }
@@ -2954,7 +3180,7 @@ export async function startWebServer(opts) {
2954
3180
  from: window.from,
2955
3181
  to: window.to,
2956
3182
  series: usageSeries(list, since || (list.length ? list[list.length - 1].updatedAt : Date.now()), until),
2957
- totals: { ...totals, sessions: list.length },
3183
+ totals: { ...totals, sessions: sessionCount },
2958
3184
  // Sorted by what was spent, not by name: the row worth seeing first
2959
3185
  // is the expensive one.
2960
3186
  models: [...byModel.values()].sort((a, b) => b.costUsd - a.costUsd),
@@ -3686,6 +3912,7 @@ export async function startWebServer(opts) {
3686
3912
  await runners.get(id)?.agent.discardBackgroundTasks();
3687
3913
  const forks = (await current.harness.store.children(id)).length;
3688
3914
  const deleted = await current.harness.deleteSession(id);
3915
+ storedTaskRows.delete(id);
3689
3916
  await removeTurnShots(id);
3690
3917
  // A session nobody has spoken to has nothing on disk yet — the store
3691
3918
  // writes on the first message — but it is still a session someone
@@ -4139,6 +4366,56 @@ export async function startWebServer(opts) {
4139
4366
  * is declared but failed to start is worth showing as exactly that, and a
4140
4367
  * menu built from the config file would claim it was there.
4141
4368
  */
4369
+ /**
4370
+ * One server found in Claude Code's or Codex's config, as the connector
4371
+ * list shows it. What it runs is summarised — a host, a command's name —
4372
+ * rather than echoed: an argument or a URL can carry a key, and the row
4373
+ * only needs to say which server this is.
4374
+ */
4375
+ /** The vendor's site for an HTTP server, from its host; none for a local one. */
4376
+ function foreignSite(config) {
4377
+ if (config?.type !== "http")
4378
+ return undefined;
4379
+ let host;
4380
+ try {
4381
+ host = new URL(config.url).hostname.toLowerCase();
4382
+ }
4383
+ catch {
4384
+ return undefined;
4385
+ }
4386
+ if (host === "localhost" || /^[\d.]+$/.test(host) || host.endsWith(".local") || !host.includes("."))
4387
+ return undefined;
4388
+ const bare = host.replace(/^(mcp|api|connect|server)\./, "");
4389
+ return "https://" + bare + "/";
4390
+ }
4391
+ function foreignRow(one, installed) {
4392
+ const config = one.config;
4393
+ let target = "";
4394
+ if (config?.type === "http") {
4395
+ try {
4396
+ target = new URL(config.url).host;
4397
+ }
4398
+ catch {
4399
+ target = "";
4400
+ }
4401
+ }
4402
+ else if (config) {
4403
+ target = path.basename(config.command);
4404
+ }
4405
+ return {
4406
+ source: one.source,
4407
+ scope: one.scope,
4408
+ name: one.name,
4409
+ ...(one.plugin ? { plugin: one.plugin } : {}),
4410
+ ...(one.pluginTitle ? { pluginTitle: one.pluginTitle } : {}),
4411
+ file: one.file.startsWith(homedir()) ? "~" + one.file.slice(homedir().length) : one.file,
4412
+ ...(config ? { transport: config.type, target } : {}),
4413
+ ...(one.reason ? { reason: one.reason } : {}),
4414
+ ...(one.inlineSecret ? { inlineSecret: true } : {}),
4415
+ ...(config && missingSecrets(config).length ? { missingEnv: missingSecrets(config) } : {}),
4416
+ installed: installed.has(one.name),
4417
+ };
4418
+ }
4142
4419
  function contextOffer() {
4143
4420
  const harness = current.harness;
4144
4421
  const connectors = (harness.mcpStatus?.() ?? []).map((status) => ({
@@ -4151,10 +4428,14 @@ export async function startWebServer(opts) {
4151
4428
  }));
4152
4429
  const plugins = (harness.plugins ?? []).map((plugin) => ({
4153
4430
  name: plugin.name,
4431
+ ...(plugin.title ? { title: plugin.title } : {}),
4154
4432
  ...(plugin.description ? { description: plugin.description } : {}),
4155
4433
  source: plugin.source,
4156
4434
  servers: Object.keys(plugin.mcpServers ?? {}),
4157
4435
  subagents: plugin.subagents?.length ?? 0,
4436
+ // The marketplace id, for the row's remove button. Only a directory the
4437
+ // marketplace put there carries one; nothing else is removed from here.
4438
+ ...(plugin.market ? { market: plugin.market.id } : {}),
4158
4439
  }));
4159
4440
  const runtime = (harness.runtimePluginStatus?.() ?? []).map((plugin) => ({
4160
4441
  name: plugin.id,
@@ -4175,13 +4456,17 @@ export async function startWebServer(opts) {
4175
4456
  * in the model's own system prompt. Nobody driving the composer could see
4176
4457
  * what was there, let alone point at one.
4177
4458
  */
4178
- const skills = (harness.skills?.list?.() ?? []).map((skill) => ({
4459
+ // Switched-off ones included, so the list that switched one off still
4460
+ // has the row to switch it back on. Everything that only wants what the
4461
+ // model can load filters on `disabled`.
4462
+ const skills = (harness.skills?.listAll?.() ?? harness.skills?.list?.() ?? []).map((skill) => ({
4179
4463
  name: skill.name,
4180
4464
  description: skill.description,
4181
4465
  source: skill.source,
4182
4466
  // "builtin" is the registry's own word for one that ships with the
4183
4467
  // harness; the rest name where on disk they were found.
4184
4468
  builtin: skill.source === "builtin",
4469
+ ...(harness.skills?.isDisabled?.(skill.name) ? { disabled: true } : {}),
4185
4470
  }));
4186
4471
  /**
4187
4472
  * What shipped here first, then what this machine added.
@@ -5076,6 +5361,16 @@ export async function startWebServer(opts) {
5076
5361
  ...tokensOf(sess.usage),
5077
5362
  });
5078
5363
  }
5364
+ // Calls made outside any conversation (dreaming) have no transcript to sum;
5365
+ // they are recorded per day, purpose and model, and join the totals here.
5366
+ for (const record of await BackgroundUsage.shared.records().catch(() => [])) {
5367
+ const id = `background:${record.purpose}:${record.day}:${record.model}`;
5368
+ rows.set(id, {
5369
+ id, title: record.purpose, model: record.model, updatedAt: record.updatedAt, turns: 0,
5370
+ costUsd: record.costUsd, input: record.input, output: record.output, cacheRead: record.cacheRead, cacheWrite: record.cacheWrite,
5371
+ background: record.purpose,
5372
+ });
5373
+ }
5079
5374
  return { stored, rows };
5080
5375
  }
5081
5376
  async function artifactCatalogue() {