@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/i18n.js +294 -4
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +3 -0
- package/dist/icons.js +68 -3
- package/dist/icons.js.map +1 -1
- package/dist/page/client/base.js +2 -0
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/composer.js +530 -40
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/events.js +8 -3
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/panels.js +225 -1
- package/dist/page/client/panels.js.map +1 -1
- package/dist/page/client/routines.js +67 -4
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +8 -1
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +15 -2
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/tasks.js +153 -7
- package/dist/page/client/tasks.js.map +1 -1
- package/dist/page/client/usage.js +6 -1
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +182 -3
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +1 -0
- package/dist/page.js.map +1 -1
- package/dist/server.js +303 -8
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +7 -0
- package/dist/terminal.js +15 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tool-icons.d.ts +12 -0
- package/dist/tool-icons.js +16 -0
- package/dist/tool-icons.js.map +1 -0
- package/package.json +2 -2
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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() {
|