@oharness/server 0.10.0 → 0.12.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/analytics.d.ts +32 -29
- package/dist/analytics.js +225 -278
- package/dist/analytics.js.map +1 -1
- package/dist/i18n.js +242 -12
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +3 -0
- package/dist/icons.js +69 -3
- package/dist/icons.js.map +1 -1
- package/dist/model-order.js +1 -0
- package/dist/model-order.js.map +1 -1
- package/dist/page/client/account.js +140 -4
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +42 -16
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +1 -1
- package/dist/page/client/composer.js +641 -47
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/dialogs.js +75 -0
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +25 -2
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/index.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +4 -4
- package/dist/page/client/ripple.d.ts +13 -0
- package/dist/page/client/ripple.js +247 -0
- package/dist/page/client/ripple.js.map +1 -0
- package/dist/page/client/routines.js +173 -41
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +200 -54
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +7 -2
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/terminal.js +34 -7
- package/dist/page/client/terminal.js.map +1 -1
- package/dist/page/client/theme.d.ts +17 -0
- package/dist/page/client/theme.js +365 -0
- package/dist/page/client/theme.js.map +1 -0
- package/dist/page/client/transcript.js +63 -7
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/style.js +273 -22
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +5 -5
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +17 -0
- package/dist/prefs.js +5 -0
- package/dist/prefs.js.map +1 -1
- package/dist/server.js +710 -45
- package/dist/server.js.map +1 -1
- package/dist/suggest.d.ts +42 -0
- package/dist/suggest.js +215 -0
- package/dist/suggest.js.map +1 -0
- package/dist/terminal.d.ts +35 -2
- package/dist/terminal.js +60 -4
- package/dist/terminal.js.map +1 -1
- package/dist/theme-assets.d.ts +39 -0
- package/dist/theme-assets.js +109 -0
- package/dist/theme-assets.js.map +1 -0
- package/dist/theme-store.d.ts +50 -0
- package/dist/theme-store.js +124 -0
- package/dist/theme-store.js.map +1 -0
- package/dist/theme-tools.d.ts +25 -0
- package/dist/theme-tools.js +243 -0
- package/dist/theme-tools.js.map +1 -0
- package/dist/tool-icons.d.ts +12 -0
- package/dist/tool-icons.js +16 -0
- package/dist/tool-icons.js.map +1 -0
- package/dist/turn-shots.d.ts +18 -2
- package/dist/turn-shots.js +10 -1
- package/dist/turn-shots.js.map +1 -1
- package/dist/user-theme.d.ts +143 -0
- package/dist/user-theme.js +510 -0
- package/dist/user-theme.js.map +1 -0
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -7,8 +7,11 @@ import { homedir } from "node:os";
|
|
|
7
7
|
import { Harness } from "@oharness/engine/harness.ts";
|
|
8
8
|
import { updateUserMcpConfig } from "@oharness/engine/config-files.ts";
|
|
9
9
|
import { connectorConfig, officialConnectors } from "@oharness/engine/mcp/registry.ts";
|
|
10
|
+
import { findForeignServer, foreignMcpServers } from "@oharness/engine/foreign.ts";
|
|
11
|
+
import { installMarketPlugin, marketPlugins, removeMarketPlugin } from "@oharness/engine/plugin-market.ts";
|
|
10
12
|
import { missingSecrets } from "@oharness/engine/mcp/secrets.ts";
|
|
11
|
-
import { connectorIcon, providerSites, siteIcon } from "./icons.js";
|
|
13
|
+
import { connectorIcon, providerSites, sameSite, siteIcon, vendorSites } from "./icons.js";
|
|
14
|
+
import { TOOL_ICONS } from "./tool-icons.js";
|
|
12
15
|
import { checkAnswer } from "@oharness/engine/tools/questions.ts";
|
|
13
16
|
import { isFree } from "@oharness/engine/providers/types.ts";
|
|
14
17
|
import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
|
|
@@ -29,15 +32,15 @@ import { activeRun, onRunActivity, onRunsChanged } from "@oharness/engine/schedu
|
|
|
29
32
|
import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
|
|
30
33
|
import { ChangeLedger } from "./changes.js";
|
|
31
34
|
import { projectPath } from "@oharness/engine/paths.ts";
|
|
32
|
-
import { listTurnShots, removeTurnShots, saveTurnShots, turnShotPath,
|
|
35
|
+
import { listTurnShots, removeTurnShots, saveTurnShots, turnShotPath, turnShotView } from "./turn-shots.js";
|
|
33
36
|
import { addProject, ensureProject, listProjects, removeProject, renameProject } from "./projects.js";
|
|
34
37
|
import { readPrefs, writePrefs } from "./prefs.js";
|
|
35
38
|
import { sortModelsForDisplay } from "./model-order.js";
|
|
36
|
-
import { loadModelsDev,
|
|
39
|
+
import { loadModelsDev, orderingDate, releaseDates } from "@oharness/engine/providers/models-dev.ts";
|
|
37
40
|
import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
|
|
38
|
-
import { TerminalHub, terminalAsset } from "./terminal.js";
|
|
41
|
+
import { isAgentSessionId, isLauncher, TerminalHub, terminalAsset } from "./terminal.js";
|
|
39
42
|
import { PREVIEW_MEDIA, browseFiles, previewFile, resolveFile, searchFiles, within } from "./files.js";
|
|
40
|
-
import { analyticsState } from "./analytics.js";
|
|
43
|
+
import { analyticsState, cleanEvent, flushAnalytics, markUsed, setConsent, startHeartbeat, track } from "./analytics.js";
|
|
41
44
|
import { PAGE } from "./page.js";
|
|
42
45
|
import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
|
|
43
46
|
import { artifactDocument, sendArtifact } from "./artifacts.js";
|
|
@@ -46,6 +49,11 @@ import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreAr
|
|
|
46
49
|
import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
|
|
47
50
|
import { accountUsage } from "./account-usage.js";
|
|
48
51
|
import { createStarters } from "./starters.js";
|
|
52
|
+
import { suggestNext } from "./suggest.js";
|
|
53
|
+
import { ThemeStore } from "./theme-store.js";
|
|
54
|
+
import { ASSET_TYPES, assetExists, assetPath as themeAssetPath } from "./theme-assets.js";
|
|
55
|
+
import { createThemeTools } from "./theme-tools.js";
|
|
56
|
+
import { ASSET_ID, assetUrl, swatches, themeCss } from "./user-theme.js";
|
|
49
57
|
import { pairedDevices, remoteMachine, revokeDevice, startRemote, } from "./remote.js";
|
|
50
58
|
/**
|
|
51
59
|
* How many stored sessions the usage page counts.
|
|
@@ -197,14 +205,18 @@ function tokensOf(usage) {
|
|
|
197
205
|
*
|
|
198
206
|
* Its date wins over a model's own: a listing's `created` is sometimes the
|
|
199
207
|
* time it was listed, or a constant, and the snapshot knows most of the models
|
|
200
|
-
* a gateway or an aggregator relays under the same name.
|
|
208
|
+
* a gateway or an aggregator relays under the same name. A model with neither
|
|
209
|
+
* is dated to the snapshot itself — see `orderingDate`.
|
|
201
210
|
*/
|
|
202
211
|
let knownDates;
|
|
203
212
|
async function withReleaseDates(models) {
|
|
204
|
-
const dates = await (knownDates ??= loadModelsDev().then(
|
|
213
|
+
const { dates, at } = await (knownDates ??= loadModelsDev().then((snapshot) => {
|
|
214
|
+
const fetched = Date.parse(snapshot.fetchedAt ?? "");
|
|
215
|
+
return { dates: releaseDates(snapshot), at: Number.isFinite(fetched) ? fetched : undefined };
|
|
216
|
+
}));
|
|
205
217
|
return models.map((model) => {
|
|
206
|
-
const
|
|
207
|
-
return
|
|
218
|
+
const date = orderingDate(dates, at, model);
|
|
219
|
+
return date === undefined || date === model.releasedAt ? model : { ...model, releasedAt: date };
|
|
208
220
|
});
|
|
209
221
|
}
|
|
210
222
|
export async function startWebServer(opts) {
|
|
@@ -240,6 +252,58 @@ export async function startWebServer(opts) {
|
|
|
240
252
|
opts.onKeepAwake(prefs.keepAwake === true || remoteRunning());
|
|
241
253
|
};
|
|
242
254
|
const pending = new Map();
|
|
255
|
+
/*
|
|
256
|
+
* Themes the agent proposed, and the one in use.
|
|
257
|
+
*
|
|
258
|
+
* A proposal is a card, not a change: it waits here until the person applies
|
|
259
|
+
* or discards it, and is kept in memory only, so a restart drops the ones
|
|
260
|
+
* nobody acted on. What is applied lives in theme.json via the store, and its
|
|
261
|
+
* CSS is printed into the page on every load — see `GET /` — and pushed to
|
|
262
|
+
* open pages as a `theme` event when it changes.
|
|
263
|
+
*/
|
|
264
|
+
const themes = await ThemeStore.load();
|
|
265
|
+
const themePreviews = new Map();
|
|
266
|
+
const themePreviewView = (p) => ({
|
|
267
|
+
id: p.id,
|
|
268
|
+
session: p.session,
|
|
269
|
+
name: p.theme.input.name,
|
|
270
|
+
mode: p.theme.input.mode,
|
|
271
|
+
css: themeCss(p.theme),
|
|
272
|
+
swatches: swatches(p.theme),
|
|
273
|
+
// One thumbnail per filled slot, in slot order, for the card.
|
|
274
|
+
images: Object.entries(p.theme.input.images ?? {}).map(([slot, value]) => ({ slot, url: assetUrl(value.asset) })),
|
|
275
|
+
report: p.theme.report,
|
|
276
|
+
});
|
|
277
|
+
const themeTools = createThemeTools({
|
|
278
|
+
activeInput: () => themes.activeInput(),
|
|
279
|
+
propose: (session, theme) => {
|
|
280
|
+
const preview = { id: randomUUID(), session, theme, createdAt: Date.now() };
|
|
281
|
+
themePreviews.set(preview.id, preview);
|
|
282
|
+
// Bounded: a model looping on set_theme must not grow this forever.
|
|
283
|
+
if (themePreviews.size > 20)
|
|
284
|
+
themePreviews.delete(themePreviews.keys().next().value);
|
|
285
|
+
broadcast({ type: "theme_preview", session, preview: themePreviewView(preview) });
|
|
286
|
+
return preview.id;
|
|
287
|
+
},
|
|
288
|
+
reset: async () => {
|
|
289
|
+
const was = themes.state().active?.name;
|
|
290
|
+
if (!was)
|
|
291
|
+
return undefined;
|
|
292
|
+
themeChanged(await themes.reset());
|
|
293
|
+
return was;
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
/** Give a harness the theme tools; the page is what makes them meaningful. */
|
|
297
|
+
const equipThemes = (tools) => {
|
|
298
|
+
for (const tool of themeTools)
|
|
299
|
+
if (!tools.has(tool.name))
|
|
300
|
+
tools.register(tool);
|
|
301
|
+
};
|
|
302
|
+
equipThemes(opts.harness.tools);
|
|
303
|
+
// The first agent was built before this server existed, and may hold its own
|
|
304
|
+
// copy of the registry rather than the harness's.
|
|
305
|
+
equipThemes(opts.agent.tools);
|
|
306
|
+
const themeChanged = (state) => broadcast({ type: "theme", state });
|
|
243
307
|
const runners = new Map();
|
|
244
308
|
/** Agents whose background tasks are being watched; see watchTasks. */
|
|
245
309
|
const watchedTasks = new WeakSet();
|
|
@@ -247,6 +311,31 @@ export async function startWebServer(opts) {
|
|
|
247
311
|
const starters = createStarters((cwd, locale, written) => {
|
|
248
312
|
broadcast({ type: "starters", cwd, locale, starters: written });
|
|
249
313
|
});
|
|
314
|
+
/**
|
|
315
|
+
* The next-message guess being written for each conversation. Dropped the
|
|
316
|
+
* moment that conversation starts another turn: a guess about the turn
|
|
317
|
+
* before is wrong once there is a new one.
|
|
318
|
+
*/
|
|
319
|
+
const suggesting = new Map();
|
|
320
|
+
let suggestionsOn = true;
|
|
321
|
+
void readPrefs().then((prefs) => { suggestionsOn = prefs.suggestions !== false; }).catch(() => { });
|
|
322
|
+
function suggest(runner) {
|
|
323
|
+
if (!suggestionsOn)
|
|
324
|
+
return;
|
|
325
|
+
const id = runner.session.id;
|
|
326
|
+
const controller = new AbortController();
|
|
327
|
+
suggesting.set(id, controller);
|
|
328
|
+
void suggestNext(runner.harness, runner.session.messages, runner.session.model.id, controller.signal)
|
|
329
|
+
.then((text) => {
|
|
330
|
+
if (text && !controller.signal.aborted)
|
|
331
|
+
broadcast({ type: "suggestion", session: id, text });
|
|
332
|
+
})
|
|
333
|
+
.catch(() => { })
|
|
334
|
+
.finally(() => {
|
|
335
|
+
if (suggesting.get(id) === controller)
|
|
336
|
+
suggesting.delete(id);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
250
339
|
/**
|
|
251
340
|
* What the OS lets Computer Use do, read from the helper when a conversation
|
|
252
341
|
* switches it on and whenever the permission card asks again. Cached because
|
|
@@ -254,6 +343,13 @@ export async function startWebServer(opts) {
|
|
|
254
343
|
* revoked at any time, and the tools report that themselves when it happens.
|
|
255
344
|
*/
|
|
256
345
|
const computerStatus = {};
|
|
346
|
+
/**
|
|
347
|
+
* Whether a turn's windows and tabs are pictured when it ends: the one
|
|
348
|
+
* setting for Computer use and the browser. Kept here because the engine
|
|
349
|
+
* asks synchronously; read once, then updated by the setting's own route.
|
|
350
|
+
*/
|
|
351
|
+
let turnShotsOn = true;
|
|
352
|
+
void readPrefs().then((prefs) => { turnShotsOn = prefs.turnShots !== false; }).catch(() => { });
|
|
257
353
|
/**
|
|
258
354
|
* The live view: what each conversation is doing on the computer, pushed as
|
|
259
355
|
* it happens. The last event per conversation is kept, so a pane opened
|
|
@@ -278,17 +374,21 @@ export async function startWebServer(opts) {
|
|
|
278
374
|
});
|
|
279
375
|
opts.computerUsePlugin?.runtime.onSnapshot((event) => {
|
|
280
376
|
const at = computerTurnEnd.get(event.sessionId);
|
|
281
|
-
if (at === undefined)
|
|
377
|
+
if (at === undefined || !turnShotsOn)
|
|
282
378
|
return;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
379
|
+
keepTurnShots(event.sessionId, at, event.shots);
|
|
380
|
+
});
|
|
381
|
+
// The agent switched it on with computer_enable: what the + menu route does
|
|
382
|
+
// after flipping the flag, so the chip, the permission card and a resumed
|
|
383
|
+
// conversation all see it.
|
|
384
|
+
opts.computerUsePlugin?.runtime.onEnabled((sessionId) => {
|
|
385
|
+
const runner = runners.get(sessionId);
|
|
386
|
+
if (!runner)
|
|
387
|
+
return;
|
|
388
|
+
void runner.session.persist().catch(() => { });
|
|
389
|
+
void refreshComputer(opts.computerUsePlugin).finally(() => {
|
|
390
|
+
broadcast({ type: "state", session: runner.session.id, state: state(runner) });
|
|
391
|
+
});
|
|
292
392
|
});
|
|
293
393
|
async function refreshComputer(plugin) {
|
|
294
394
|
// Grants first and on their own: they come from the host app, and a
|
|
@@ -511,8 +611,38 @@ export async function startWebServer(opts) {
|
|
|
511
611
|
connectionSubscriptions.push(harness.cloudConnectors.subscribe((card) => {
|
|
512
612
|
broadcast({ type: "connection_card", session: card.session, card });
|
|
513
613
|
}));
|
|
614
|
+
pictureBrowserTurns(harness);
|
|
514
615
|
}
|
|
515
616
|
watchConnections(opts.harness);
|
|
617
|
+
/** Keep a turn's pictures beside its session, and show them under its last reply. */
|
|
618
|
+
function keepTurnShots(session, at, shots) {
|
|
619
|
+
void saveTurnShots(session, at, shots)
|
|
620
|
+
.then((saved) => {
|
|
621
|
+
broadcast({ type: "turn_shots", session, shots: saved.map((shot) => turnShotView(session, shot)) });
|
|
622
|
+
})
|
|
623
|
+
.catch(() => { });
|
|
624
|
+
}
|
|
625
|
+
/** A tab's caption when it has no title: the site, not the whole address. */
|
|
626
|
+
function hostOf(url) {
|
|
627
|
+
try {
|
|
628
|
+
return url ? new URL(url).host || url : "";
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
return url ?? "";
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** The browser tabs a turn left, the same as the windows Computer use did. */
|
|
635
|
+
function pictureBrowserTurns(harness) {
|
|
636
|
+
harness.turnShotSink = {
|
|
637
|
+
enabled: () => turnShotsOn,
|
|
638
|
+
save: (session, at, shots) => keepTurnShots(session, at, shots.map((shot) => ({
|
|
639
|
+
app: hostOf(shot.url),
|
|
640
|
+
...(shot.title ? { title: shot.title } : {}),
|
|
641
|
+
kind: "browser",
|
|
642
|
+
data: shot.data,
|
|
643
|
+
}))),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
516
646
|
/**
|
|
517
647
|
* Take a waiting interaction off every screen.
|
|
518
648
|
*
|
|
@@ -601,7 +731,14 @@ export async function startWebServer(opts) {
|
|
|
601
731
|
// nothing else: a page that drives a shell is not one to embed.
|
|
602
732
|
"content-security-policy": `frame-ancestors 'self' ${EXTENSION_IDS.map((id) => `chrome-extension://${id}`).join(" ")}`,
|
|
603
733
|
});
|
|
604
|
-
|
|
734
|
+
// The applied theme, after the built-in stylesheet so it wins on order.
|
|
735
|
+
// `?theme=default` is safe mode: a page that cannot be read must still
|
|
736
|
+
// be reachable, so the way out does not depend on the theme it escapes.
|
|
737
|
+
// The CSS is ours, printed from validated colours; `</` cannot occur in
|
|
738
|
+
// it, but is refused anyway so nothing could ever close the tag early.
|
|
739
|
+
const css = url.searchParams.get("theme") === "default" ? "" : themes.state().css;
|
|
740
|
+
const tag = css && !css.includes("</") ? `<style id="oh-user-theme">${css}</style>` : `<style id="oh-user-theme"></style>`;
|
|
741
|
+
res.end(PAGE.replace("</head>", `${tag}\n</head>`));
|
|
605
742
|
return;
|
|
606
743
|
}
|
|
607
744
|
// The other exception, and for the same kind of reason: this one is
|
|
@@ -621,6 +758,21 @@ export async function startWebServer(opts) {
|
|
|
621
758
|
}
|
|
622
759
|
return serveArtifactBody(url, res);
|
|
623
760
|
}
|
|
761
|
+
// Fourth exception: a theme's images, loaded by CSS `url()`, which cannot
|
|
762
|
+
// carry the token. The name is the sha-256 of the bytes (theme-assets.ts),
|
|
763
|
+
// so knowing it already means having the picture; nothing else is served.
|
|
764
|
+
if (url.pathname.startsWith("/theme-asset/") && req.method === "GET") {
|
|
765
|
+
const id = url.pathname.slice("/theme-asset/".length);
|
|
766
|
+
if (!ASSET_ID.test(id) || !(await assetExists(id)))
|
|
767
|
+
return send(res, 404, { error: "no such asset" });
|
|
768
|
+
res.writeHead(200, {
|
|
769
|
+
"content-type": ASSET_TYPES[id.slice(id.lastIndexOf(".") + 1)],
|
|
770
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
771
|
+
"x-content-type-options": "nosniff",
|
|
772
|
+
});
|
|
773
|
+
res.end(await readFile(themeAssetPath(id)));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
624
776
|
if (!authorized(url, req))
|
|
625
777
|
return send(res, 401, { error: "missing or bad token" });
|
|
626
778
|
switch (`${req.method} ${url.pathname}`) {
|
|
@@ -1275,6 +1427,8 @@ export async function startWebServer(opts) {
|
|
|
1275
1427
|
const pinned = new Set(prefs.pinnedProjects ?? []);
|
|
1276
1428
|
return send(res, 200, {
|
|
1277
1429
|
order: prefs.projectOrder ?? [],
|
|
1430
|
+
// So the picker can write a path the way people say it: ~/code/app.
|
|
1431
|
+
home: homedir(),
|
|
1278
1432
|
projects: projects.map((project) => ({
|
|
1279
1433
|
...project,
|
|
1280
1434
|
pinned: pinned.has(project.path),
|
|
@@ -1356,7 +1510,11 @@ export async function startWebServer(opts) {
|
|
|
1356
1510
|
if (!root)
|
|
1357
1511
|
return send(res, 400, { error: "not a folder" });
|
|
1358
1512
|
const replace = typeof body.replace === "string" ? body.replace : undefined;
|
|
1359
|
-
const
|
|
1513
|
+
const run = isLauncher(body.run) ? body.run : undefined;
|
|
1514
|
+
// Only for the claude launcher, and only an id shaped like one: it is
|
|
1515
|
+
// typed into a shell, so nothing else gets near the command line.
|
|
1516
|
+
const resume = run === "claude" && isAgentSessionId(body.resume) ? body.resume : undefined;
|
|
1517
|
+
const result = await terminals.open(root, { cols: body.cols, rows: body.rows }, replace, run, resume);
|
|
1360
1518
|
return send(res, "error" in result ? 500 : 200, result);
|
|
1361
1519
|
}
|
|
1362
1520
|
case "GET /term/list": {
|
|
@@ -1558,13 +1716,19 @@ export async function startWebServer(opts) {
|
|
|
1558
1716
|
case "GET /registry": {
|
|
1559
1717
|
const installed = new Set(contextOffer().connectors.map((one) => one.name));
|
|
1560
1718
|
const entries = await officialConnectors();
|
|
1719
|
+
const found = await foreignMcpServers(current.session.cwd, current.harness.config.imports ?? {}, current.harness.plugins);
|
|
1561
1720
|
return send(res, 200, {
|
|
1721
|
+
// What Claude Code and Codex already have, offered rather than
|
|
1722
|
+
// started: each one is a process, and often a token, that the user
|
|
1723
|
+
// set up for another program. See `foreign.ts`.
|
|
1724
|
+
imports: found.map((one) => foreignRow(one, installed)),
|
|
1562
1725
|
connectors: entries.map((entry) => ({
|
|
1563
1726
|
id: entry.id,
|
|
1564
1727
|
label: entry.label,
|
|
1565
1728
|
...(entry.description ? { description: entry.description } : {}),
|
|
1566
1729
|
...(entry.publisher ? { publisher: entry.publisher } : {}),
|
|
1567
1730
|
...(entry.docsUrl ? { docsUrl: entry.docsUrl } : {}),
|
|
1731
|
+
...(entry.category ? { category: entry.category } : {}),
|
|
1568
1732
|
installed: installed.has(entry.id),
|
|
1569
1733
|
// Said before the click, not after it. An entry that needs a key
|
|
1570
1734
|
// fails at connect with a perfectly clear message, but a menu that
|
|
@@ -1582,10 +1746,35 @@ export async function startWebServer(opts) {
|
|
|
1582
1746
|
* otherwise be twelve third-party requests, each one telling a vendor
|
|
1583
1747
|
* that this user opened the menu before connecting anything.
|
|
1584
1748
|
*/
|
|
1749
|
+
// Claude Code's and Codex's own marks; see tool-icons.ts.
|
|
1750
|
+
case "GET /tool-icon": {
|
|
1751
|
+
const data = TOOL_ICONS[url.searchParams.get("id") ?? ""];
|
|
1752
|
+
if (!data)
|
|
1753
|
+
return send(res, 404, { error: "no icon" });
|
|
1754
|
+
const bytes = Buffer.from(data, "base64");
|
|
1755
|
+
res.writeHead(200, {
|
|
1756
|
+
"content-type": "image/png",
|
|
1757
|
+
"content-length": bytes.length,
|
|
1758
|
+
"cache-control": "private, max-age=604800",
|
|
1759
|
+
});
|
|
1760
|
+
res.end(bytes);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1585
1763
|
case "GET /connector-icon": {
|
|
1586
1764
|
const id = url.searchParams.get("id") ?? "";
|
|
1587
1765
|
const entry = (await officialConnectors()).find((one) => one.id === id);
|
|
1588
|
-
|
|
1766
|
+
let icon = entry?.siteUrl ? await connectorIcon(entry.id, entry.siteUrl) : undefined;
|
|
1767
|
+
// One brought over from Claude Code or Codex, or added from them: its
|
|
1768
|
+
// vendor's site, from the host it talks to — mcp.zernio.com is
|
|
1769
|
+
// zernio.com. Looked up by name here, never taken from the request.
|
|
1770
|
+
if (!icon && !entry) {
|
|
1771
|
+
const site = foreignSite((current.harness.mcpStatus?.() ?? []).some((one) => one.name === id)
|
|
1772
|
+
? current.harness.mcp.configFor(id)
|
|
1773
|
+
: (await foreignMcpServers(current.session.cwd, current.harness.config.imports ?? {}, current.harness.plugins))
|
|
1774
|
+
.find((one) => one.name === id && one.config?.type === "http")?.config);
|
|
1775
|
+
if (site)
|
|
1776
|
+
icon = await siteIcon(`site-${new URL(site).hostname}`, vendorSites(site));
|
|
1777
|
+
}
|
|
1589
1778
|
// A row without a picture draws a monogram, so this is a normal answer
|
|
1590
1779
|
// rather than something worth reporting.
|
|
1591
1780
|
if (!icon)
|
|
@@ -1639,6 +1828,42 @@ export async function startWebServer(opts) {
|
|
|
1639
1828
|
*/
|
|
1640
1829
|
case "POST /connector": {
|
|
1641
1830
|
const body = await readJson(req);
|
|
1831
|
+
/**
|
|
1832
|
+
* One found in Claude Code's or Codex's config, named by where it was
|
|
1833
|
+
* found rather than sent whole. The declaration is read again here,
|
|
1834
|
+
* so what starts is what is in that file — not whatever a page that
|
|
1835
|
+
* can reach this port chose to put in a request body.
|
|
1836
|
+
*/
|
|
1837
|
+
if (body.import && typeof body.import === "object") {
|
|
1838
|
+
const key = body.import;
|
|
1839
|
+
const found = await findForeignServer(current.session.cwd, {
|
|
1840
|
+
source: String(key.source ?? ""),
|
|
1841
|
+
scope: String(key.scope ?? ""),
|
|
1842
|
+
name: String(key.name ?? ""),
|
|
1843
|
+
...(typeof key.plugin === "string" ? { plugin: key.plugin } : {}),
|
|
1844
|
+
}, current.harness.config.imports ?? {}, current.harness.plugins);
|
|
1845
|
+
if (!found)
|
|
1846
|
+
return send(res, 404, { error: "that server is no longer in its config file" });
|
|
1847
|
+
if (!found.config)
|
|
1848
|
+
return send(res, 400, { error: `${found.name} cannot be used here` });
|
|
1849
|
+
const name = found.name;
|
|
1850
|
+
if (current.harness.mcpStatus().some((one) => one.name === name)) {
|
|
1851
|
+
return send(res, 409, { error: `a connector named "${name}" is already here` });
|
|
1852
|
+
}
|
|
1853
|
+
try {
|
|
1854
|
+
const status = await current.harness.addMcpServer(name, found.config);
|
|
1855
|
+
if (!status.connected && !status.needsAuth) {
|
|
1856
|
+
await current.harness.removeMcpServer(name);
|
|
1857
|
+
return send(res, 400, { error: status.error ?? `${name} did not start` });
|
|
1858
|
+
}
|
|
1859
|
+
await updateUserMcpConfig((existing) => ({ ...existing, [name]: found.config }));
|
|
1860
|
+
return send(res, 200, { status, ...contextOffer() });
|
|
1861
|
+
}
|
|
1862
|
+
catch (err) {
|
|
1863
|
+
await current.harness.removeMcpServer(name).catch(() => { });
|
|
1864
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1642
1867
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1643
1868
|
if (!id)
|
|
1644
1869
|
return send(res, 400, { error: "id is required" });
|
|
@@ -1679,6 +1904,103 @@ export async function startWebServer(opts) {
|
|
|
1679
1904
|
return send(res, 400, { error: errorMessage(err) });
|
|
1680
1905
|
}
|
|
1681
1906
|
}
|
|
1907
|
+
/**
|
|
1908
|
+
* The plugins on offer, as the connectors are: a file, plus what is here.
|
|
1909
|
+
*
|
|
1910
|
+
* A plugin counts as here when one of that name is loaded from anywhere —
|
|
1911
|
+
* Claude Code's cache included. Offering a second copy of something the
|
|
1912
|
+
* Installed list already shows is how a menu ends up with two PostHogs.
|
|
1913
|
+
*/
|
|
1914
|
+
case "GET /plugin-market": {
|
|
1915
|
+
const loaded = current.harness.plugins ?? [];
|
|
1916
|
+
const entries = await marketPlugins();
|
|
1917
|
+
return send(res, 200, {
|
|
1918
|
+
plugins: entries.map((entry) => ({
|
|
1919
|
+
id: entry.id,
|
|
1920
|
+
label: entry.label,
|
|
1921
|
+
publisher: entry.publisher,
|
|
1922
|
+
category: entry.category,
|
|
1923
|
+
description: entry.description,
|
|
1924
|
+
...(entry.homepage ? { homepage: entry.homepage } : {}),
|
|
1925
|
+
provides: entry.provides,
|
|
1926
|
+
installed: loaded.some((one) => one.market?.id === entry.id || one.name === entry.id),
|
|
1927
|
+
})),
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
/**
|
|
1931
|
+
* Install a plugin from the list, or remove one it installed.
|
|
1932
|
+
*
|
|
1933
|
+
* Every open project's harness takes the change, not just the focused
|
|
1934
|
+
* one: the plugin directory is the user's, so it is in every project,
|
|
1935
|
+
* and a tab in another tree that kept the old set would disagree with
|
|
1936
|
+
* the menu about what is installed.
|
|
1937
|
+
*/
|
|
1938
|
+
case "POST /plugin-market": {
|
|
1939
|
+
const body = await readJson(req);
|
|
1940
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1941
|
+
if (!id)
|
|
1942
|
+
return send(res, 400, { error: "id is required" });
|
|
1943
|
+
const open = new Set([current.harness, ...harnesses.values()]);
|
|
1944
|
+
if (body.remove === true) {
|
|
1945
|
+
const installed = (current.harness.plugins ?? []).find((one) => one.market?.id === id);
|
|
1946
|
+
if (!installed)
|
|
1947
|
+
return send(res, 404, { error: `${id} was not installed from the plugin list` });
|
|
1948
|
+
for (const harness of open)
|
|
1949
|
+
await harness.dropPlugin(installed.directory);
|
|
1950
|
+
await removeMarketPlugin(id);
|
|
1951
|
+
return send(res, 200, { removed: id, ...contextOffer() });
|
|
1952
|
+
}
|
|
1953
|
+
const entry = (await marketPlugins()).find((one) => one.id === id);
|
|
1954
|
+
if (!entry)
|
|
1955
|
+
return send(res, 404, { error: `no plugin named "${id}"` });
|
|
1956
|
+
try {
|
|
1957
|
+
const directory = await installMarketPlugin(entry);
|
|
1958
|
+
for (const harness of open)
|
|
1959
|
+
await harness.adoptPlugin(directory);
|
|
1960
|
+
return send(res, 200, { installed: id, ...contextOffer() });
|
|
1961
|
+
}
|
|
1962
|
+
catch (err) {
|
|
1963
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* A plugin's vendor icon, on the same terms as a connector's.
|
|
1968
|
+
*
|
|
1969
|
+
* The marketplace's site for the plugin first, because a manifest's
|
|
1970
|
+
* homepage is as often a GitHub repository as the vendor's own page, and
|
|
1971
|
+
* every one of those would come back wearing GitHub's mark.
|
|
1972
|
+
*/
|
|
1973
|
+
case "GET /plugin-icon": {
|
|
1974
|
+
const id = url.searchParams.get("id") ?? "";
|
|
1975
|
+
const loaded = (current.harness.plugins ?? []).find((one) => one.name === id || one.market?.id === id);
|
|
1976
|
+
// A Codex plugin ships its own mark, which beats any guess from its site.
|
|
1977
|
+
if (loaded?.logo) {
|
|
1978
|
+
const type = { ".png": "image/png", ".svg": "image/svg+xml", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp" }[path.extname(loaded.logo).toLowerCase()];
|
|
1979
|
+
const bytes = type ? await readFile(loaded.logo).catch(() => undefined) : undefined;
|
|
1980
|
+
if (bytes && type) {
|
|
1981
|
+
res.writeHead(200, { "content-type": type, "content-length": bytes.length, "cache-control": "private, max-age=86400" });
|
|
1982
|
+
res.end(bytes);
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
const listed = (await marketPlugins()).find((one) => one.id === id || one.id === loaded?.market?.id);
|
|
1987
|
+
const site = listed?.siteUrl ?? loaded?.siteUrl;
|
|
1988
|
+
// A vendor with a connector too wears the connector's mark: the two
|
|
1989
|
+
// dialogs then show one vendor one way, and a mark the connector list
|
|
1990
|
+
// already fetched is not lost to this one's failed attempt.
|
|
1991
|
+
const sibling = site ? (await officialConnectors()).find((one) => sameSite(one.siteUrl, site)) : undefined;
|
|
1992
|
+
const icon = (sibling?.siteUrl ? await connectorIcon(sibling.id, sibling.siteUrl) : undefined) ??
|
|
1993
|
+
(site ? await siteIcon(`plugin-${listed?.id ?? id}`, vendorSites(site)) : undefined);
|
|
1994
|
+
if (!icon)
|
|
1995
|
+
return send(res, 404, { error: "no icon" });
|
|
1996
|
+
res.writeHead(200, {
|
|
1997
|
+
"content-type": icon.type,
|
|
1998
|
+
"content-length": icon.bytes.length,
|
|
1999
|
+
"cache-control": "private, max-age=86400",
|
|
2000
|
+
});
|
|
2001
|
+
res.end(icon.bytes);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
1682
2004
|
/**
|
|
1683
2005
|
* Log in to a connector that asked for it.
|
|
1684
2006
|
*
|
|
@@ -1884,7 +2206,13 @@ export async function startWebServer(opts) {
|
|
|
1884
2206
|
const body = await readJson(req);
|
|
1885
2207
|
const dir = typeof body.path === "string" ? body.path : "";
|
|
1886
2208
|
// Any tab: switching projects rebuilds the harness under every runner.
|
|
1887
|
-
|
|
2209
|
+
// Except a purge, which stops the turns in its own tree itself — that
|
|
2210
|
+
// is half of what deleting permanently means — so only a turn running
|
|
2211
|
+
// somewhere else stands in its way.
|
|
2212
|
+
const blocking = body.purge === true
|
|
2213
|
+
? [...runners.values()].some((runner) => runner.running && runner.session.cwd !== path.resolve(dir))
|
|
2214
|
+
: anyRunning();
|
|
2215
|
+
if (blocking)
|
|
1888
2216
|
return send(res, 409, { error: "a turn is running" });
|
|
1889
2217
|
if (typeof body.rename === "string") {
|
|
1890
2218
|
const oldPath = path.resolve(dir);
|
|
@@ -1933,9 +2261,34 @@ export async function startWebServer(opts) {
|
|
|
1933
2261
|
* the work.
|
|
1934
2262
|
*/
|
|
1935
2263
|
if (body.purge === true) {
|
|
1936
|
-
|
|
1937
|
-
|
|
2264
|
+
// Every session open in this tree, not only those the store finds:
|
|
2265
|
+
// one nobody has spoken to, or whose transcript is already gone, is
|
|
2266
|
+
// still a runner here and a row in the sidebar, and closing only
|
|
2267
|
+
// what was on disk left exactly those behind — the delete looked
|
|
2268
|
+
// like it did nothing. "Delete permanently" is the answer to whether
|
|
2269
|
+
// a turn still going should go on, so it is stopped, and given a
|
|
2270
|
+
// moment to end before the store goes, or its last write would
|
|
2271
|
+
// bring the transcript back. A wedged turn is not waited on forever.
|
|
2272
|
+
const open = [...runners.values()].filter((runner) => runner.session.cwd === dir);
|
|
2273
|
+
for (const runner of open) {
|
|
2274
|
+
runner.queue.length = 0;
|
|
2275
|
+
runner.running?.abort();
|
|
2276
|
+
}
|
|
2277
|
+
for (let waited = 0; open.some((runner) => runner.running) && waited < 5000; waited += 50) {
|
|
2278
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2279
|
+
}
|
|
2280
|
+
// Before the store goes, as for one session: a task stopped
|
|
2281
|
+
// afterwards would write its record back.
|
|
2282
|
+
for (const runner of open)
|
|
2283
|
+
await runner.agent.discardBackgroundTasks();
|
|
2284
|
+
const onDisk = await current.harness.deleteSessionsIn(dir);
|
|
2285
|
+
const deleted = [...new Set([...onDisk, ...open.map((runner) => runner.session.id)])];
|
|
2286
|
+
for (const id of deleted) {
|
|
2287
|
+
const removed = runners.get(id);
|
|
1938
2288
|
runners.delete(id);
|
|
2289
|
+
await removed?.harness.closeBrowserFor(id);
|
|
2290
|
+
await removeTurnShots(id);
|
|
2291
|
+
}
|
|
1939
2292
|
// Their artifact logs went with them, and a listing built a moment
|
|
1940
2293
|
// ago still names them.
|
|
1941
2294
|
cachedLogs = undefined;
|
|
@@ -2064,6 +2417,32 @@ export async function startWebServer(opts) {
|
|
|
2064
2417
|
const runs = await current.harness.scheduler.history(name, 30);
|
|
2065
2418
|
return send(res, 200, { runs: going ? [{ ...going, running: true }, ...runs] : runs });
|
|
2066
2419
|
}
|
|
2420
|
+
case "GET /turn-shots":
|
|
2421
|
+
return send(res, 200, { enabled: turnShotsOn });
|
|
2422
|
+
case "POST /turn-shots": {
|
|
2423
|
+
const body = await readJson(req);
|
|
2424
|
+
if (typeof body.enabled !== "boolean")
|
|
2425
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2426
|
+
await writePrefs({ turnShots: body.enabled });
|
|
2427
|
+
turnShotsOn = body.enabled;
|
|
2428
|
+
return send(res, 200, { enabled: body.enabled });
|
|
2429
|
+
}
|
|
2430
|
+
case "GET /suggestions":
|
|
2431
|
+
return send(res, 200, { enabled: suggestionsOn });
|
|
2432
|
+
case "POST /suggestions": {
|
|
2433
|
+
const body = await readJson(req);
|
|
2434
|
+
if (typeof body.enabled !== "boolean")
|
|
2435
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2436
|
+
await writePrefs({ suggestions: body.enabled });
|
|
2437
|
+
suggestionsOn = body.enabled;
|
|
2438
|
+
// Off means gone, not only no new ones: a guess already in a box would
|
|
2439
|
+
// keep offering itself after the switch said not to.
|
|
2440
|
+
if (!body.enabled)
|
|
2441
|
+
for (const controller of suggesting.values())
|
|
2442
|
+
controller.abort();
|
|
2443
|
+
broadcast({ type: "suggestions", enabled: body.enabled });
|
|
2444
|
+
return send(res, 200, { enabled: body.enabled });
|
|
2445
|
+
}
|
|
2067
2446
|
case "GET /keep-awake":
|
|
2068
2447
|
return send(res, 200, {
|
|
2069
2448
|
available: !!opts.onKeepAwake,
|
|
@@ -2129,6 +2508,8 @@ export async function startWebServer(opts) {
|
|
|
2129
2508
|
// whether the switch it draws is the user's to flip.
|
|
2130
2509
|
audit: await current.harness.auditStatus(),
|
|
2131
2510
|
browserMode: current.harness.browserMode(),
|
|
2511
|
+
// Off unless the user turned it on: it spends their Claude plan.
|
|
2512
|
+
claudeAgent: await current.harness.claudeAgentStatus(),
|
|
2132
2513
|
// For the browser row: whether the person's browser can be reached
|
|
2133
2514
|
// now, and where to get the extension when it cannot.
|
|
2134
2515
|
browserExtension: await extensionInfo(),
|
|
@@ -2161,6 +2542,39 @@ export async function startWebServer(opts) {
|
|
|
2161
2542
|
* switch that changed only the project on screen would look ignored the
|
|
2162
2543
|
* moment someone opened another one.
|
|
2163
2544
|
*/
|
|
2545
|
+
/**
|
|
2546
|
+
* Claude Agent on or off. Every open project's harness at once, the way
|
|
2547
|
+
* the browser mode is set: it is one person's choice about their own
|
|
2548
|
+
* Claude account, not a property of a folder.
|
|
2549
|
+
*/
|
|
2550
|
+
case "POST /settings/claude-agent": {
|
|
2551
|
+
const body = await readJson(req);
|
|
2552
|
+
if (typeof body.enabled !== "boolean")
|
|
2553
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2554
|
+
for (const harness of new Set([current.harness, ...harnesses.values()])) {
|
|
2555
|
+
await harness.setClaudeAgent(body.enabled);
|
|
2556
|
+
}
|
|
2557
|
+
// The @ menu lists the agents a message can go to; it is now out of date.
|
|
2558
|
+
broadcast({ type: "connectors_changed", ...contextOffer() });
|
|
2559
|
+
return send(res, 200, await current.harness.claudeAgentStatus());
|
|
2560
|
+
}
|
|
2561
|
+
/**
|
|
2562
|
+
* Switch one skill off or on. Every open project's harness, as with
|
|
2563
|
+
* Claude Agent: it is a setting of this machine, not of one project.
|
|
2564
|
+
*/
|
|
2565
|
+
case "POST /skill": {
|
|
2566
|
+
const body = await readJson(req);
|
|
2567
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
2568
|
+
if (!name)
|
|
2569
|
+
return send(res, 400, { error: "name is required" });
|
|
2570
|
+
if (typeof body.enabled !== "boolean")
|
|
2571
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2572
|
+
for (const harness of new Set([current.harness, ...harnesses.values()])) {
|
|
2573
|
+
await harness.setSkillEnabled(name, body.enabled);
|
|
2574
|
+
}
|
|
2575
|
+
broadcast({ type: "connectors_changed", ...contextOffer() });
|
|
2576
|
+
return send(res, 200, contextOffer());
|
|
2577
|
+
}
|
|
2164
2578
|
case "POST /settings/browser-mode": {
|
|
2165
2579
|
const body = await readJson(req);
|
|
2166
2580
|
if (body.mode !== "auto" && body.mode !== "builtin" && body.mode !== "extension") {
|
|
@@ -2579,8 +2993,17 @@ export async function startWebServer(opts) {
|
|
|
2579
2993
|
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2580
2994
|
}
|
|
2581
2995
|
await writePrefs({ analytics: body.enabled });
|
|
2996
|
+
setConsent(body.enabled);
|
|
2582
2997
|
return send(res, 200, await analyticsState());
|
|
2583
2998
|
}
|
|
2999
|
+
// The page's usage events, checked and forwarded — or dropped, when the
|
|
3000
|
+
// user has not opted in. The page never learns which.
|
|
3001
|
+
case "POST /track": {
|
|
3002
|
+
const cleaned = cleanEvent(await readJson(req, 10_000));
|
|
3003
|
+
if (cleaned)
|
|
3004
|
+
void track(cleaned.event, cleaned.props);
|
|
3005
|
+
return send(res, 200, { ok: true });
|
|
3006
|
+
}
|
|
2584
3007
|
case "GET /changes": {
|
|
2585
3008
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
2586
3009
|
return send(res, 200, { changes: runner ? runner.ledger.list() : [] });
|
|
@@ -2828,7 +3251,7 @@ export async function startWebServer(opts) {
|
|
|
2828
3251
|
// and a false one say the same thing to the one reader there is.
|
|
2829
3252
|
...(isFree(model) ? { free: true } : {}),
|
|
2830
3253
|
})),
|
|
2831
|
-
providers: current.harness.listCatalogProviders().map((entry) => ({
|
|
3254
|
+
providers: (await withSpend(current.harness.listCatalogProviders())).map(({ entry, spend30d }) => ({
|
|
2832
3255
|
id: entry.id,
|
|
2833
3256
|
label: entry.label,
|
|
2834
3257
|
envVars: entry.envVars,
|
|
@@ -2859,6 +3282,17 @@ export async function startWebServer(opts) {
|
|
|
2859
3282
|
// key into. The panel has no other way to tell, and offering a key
|
|
2860
3283
|
// field for an account-backed plan is a dead end.
|
|
2861
3284
|
signIn: signInMethod(entry.id),
|
|
3285
|
+
// A vendor's own browser sign-in — Sign in with ChatGPT, OpenRouter
|
|
3286
|
+
// — which is one button and a loopback redirect, not the account
|
|
3287
|
+
// dialog. Separate from signIn so the panel never offers an email
|
|
3288
|
+
// field for a vendor that has no use for one.
|
|
3289
|
+
...(vendorSignInMethod(entry.id) ? { vendorSignIn: vendorSignInMethod(entry.id).label } : {}),
|
|
3290
|
+
// What this machine spent with the vendor lately, from its own
|
|
3291
|
+
// session records. Absent when nothing was, which is also the
|
|
3292
|
+
// answer for a plan (no per-token price). Never for the account:
|
|
3293
|
+
// it is billed in credits and has a usage page of its own, and an
|
|
3294
|
+
// estimate beside the ledger would be a second, different number.
|
|
3295
|
+
...(spend30d > 0 && entry.id !== "official" ? { spend30d } : {}),
|
|
2862
3296
|
})),
|
|
2863
3297
|
});
|
|
2864
3298
|
}
|
|
@@ -2935,6 +3369,49 @@ export async function startWebServer(opts) {
|
|
|
2935
3369
|
return send(res, 400, { error: errorMessage(err) });
|
|
2936
3370
|
}
|
|
2937
3371
|
}
|
|
3372
|
+
case "POST /signin/vendor": {
|
|
3373
|
+
const body = await readJson(req);
|
|
3374
|
+
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
3375
|
+
const method = providerId ? vendorSignInMethod(providerId) : undefined;
|
|
3376
|
+
if (!method)
|
|
3377
|
+
return send(res, 400, { error: "that provider has no browser sign-in" });
|
|
3378
|
+
// The vendor redirects to a listener on this machine's loopback, so
|
|
3379
|
+
// the browser that signs in has to be on this machine too. A window
|
|
3380
|
+
// reached over a LAN address or a tunnel would send its browser to
|
|
3381
|
+
// its own 127.0.0.1, where nothing is listening.
|
|
3382
|
+
const host = req.headers.host ?? "127.0.0.1";
|
|
3383
|
+
if (!isLoopbackHost(host)) {
|
|
3384
|
+
return send(res, 400, {
|
|
3385
|
+
error: `this window is open at ${host}, and the sign-in can only come back to the machine OHarness runs on — paste a key instead`,
|
|
3386
|
+
});
|
|
3387
|
+
}
|
|
3388
|
+
let flow;
|
|
3389
|
+
try {
|
|
3390
|
+
flow = await current.harness.auth.authorize(providerId, method.index, {});
|
|
3391
|
+
}
|
|
3392
|
+
catch (err) {
|
|
3393
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
3394
|
+
}
|
|
3395
|
+
if (flow.method !== "auto" || !flow.url) {
|
|
3396
|
+
return send(res, 400, { error: `${providerId} signs in a way this page cannot drive` });
|
|
3397
|
+
}
|
|
3398
|
+
send(res, 200, { url: flow.url, instructions: flow.instructions });
|
|
3399
|
+
// The browser comes back to the vendor's redirect, not to this
|
|
3400
|
+
// request, so the rest happens here in the background and is told to
|
|
3401
|
+
// the page the way a website sign-in is: models_changed on success.
|
|
3402
|
+
const harness = current.harness;
|
|
3403
|
+
void flow
|
|
3404
|
+
.callback()
|
|
3405
|
+
.then(async (result) => {
|
|
3406
|
+
await harness.auth.complete(providerId, result);
|
|
3407
|
+
const found = await harness.refreshModels(providerId).catch(() => []);
|
|
3408
|
+
broadcast({ type: "models_changed", provider: providerId, discovered: found.length });
|
|
3409
|
+
})
|
|
3410
|
+
.catch((err) => {
|
|
3411
|
+
broadcast({ type: "notice", message: `signing in to ${providerId} failed: ${errorMessage(err)}` });
|
|
3412
|
+
});
|
|
3413
|
+
return;
|
|
3414
|
+
}
|
|
2938
3415
|
case "POST /signin/browser": {
|
|
2939
3416
|
const body = await readJson(req);
|
|
2940
3417
|
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
@@ -3178,6 +3655,12 @@ export async function startWebServer(opts) {
|
|
|
3178
3655
|
// next run: picking a model is a decision, not a per-session detail.
|
|
3179
3656
|
preferredModel = change.to.id;
|
|
3180
3657
|
void writePrefs({ model: preferredModel });
|
|
3658
|
+
// Kept with the conversation now, not at its next turn: picked and
|
|
3659
|
+
// closed before sending, it would otherwise reopen on the old one.
|
|
3660
|
+
// A conversation with nothing in it yet is not written, or every
|
|
3661
|
+
// pick in a new tab would leave an empty session in the list.
|
|
3662
|
+
if (runner.session.messages.length)
|
|
3663
|
+
void runner.session.persist().catch(() => { });
|
|
3181
3664
|
broadcast({
|
|
3182
3665
|
type: "model_changed",
|
|
3183
3666
|
session: runner.session.id,
|
|
@@ -3268,6 +3751,7 @@ export async function startWebServer(opts) {
|
|
|
3268
3751
|
return send(res, 200, {
|
|
3269
3752
|
available: Boolean(opts.computerUsePlugin),
|
|
3270
3753
|
takeover: prefs.computerTakeover === "never" ? "never" : "ask",
|
|
3754
|
+
enable: prefs.computerEnable ?? "ask",
|
|
3271
3755
|
blockedApps: prefs.computerBlockedApps ?? [],
|
|
3272
3756
|
});
|
|
3273
3757
|
}
|
|
@@ -3278,6 +3762,8 @@ export async function startWebServer(opts) {
|
|
|
3278
3762
|
const patch = {};
|
|
3279
3763
|
if (body.takeover === "ask" || body.takeover === "never")
|
|
3280
3764
|
patch.computerTakeover = body.takeover;
|
|
3765
|
+
if (body.enable === "ask" || body.enable === "auto" || body.enable === "off")
|
|
3766
|
+
patch.computerEnable = body.enable;
|
|
3281
3767
|
if (Array.isArray(body.blockedApps)) {
|
|
3282
3768
|
patch.computerBlockedApps = [...new Set(body.blockedApps.map((a) => String(a).trim()).filter(Boolean))];
|
|
3283
3769
|
}
|
|
@@ -3548,6 +4034,7 @@ export async function startWebServer(opts) {
|
|
|
3548
4034
|
if (!prompt && !ids.length && !references.length) {
|
|
3549
4035
|
return send(res, 400, { error: "prompt is required" });
|
|
3550
4036
|
}
|
|
4037
|
+
void markUsed();
|
|
3551
4038
|
// Goal mode: the text is the goal, and the turn keeps going until the
|
|
3552
4039
|
// model reports it achieved or blocked. A goal needs words to be one.
|
|
3553
4040
|
const goal = body.goal === true;
|
|
@@ -3605,6 +4092,54 @@ export async function startWebServer(opts) {
|
|
|
3605
4092
|
entry.settle(checked.result);
|
|
3606
4093
|
return send(res, 200, { ok: true });
|
|
3607
4094
|
}
|
|
4095
|
+
/*
|
|
4096
|
+
* Themes: what is in use, the proposals waiting on a card, and the four
|
|
4097
|
+
* things a person can do about them. Every change is pushed to open
|
|
4098
|
+
* pages as a `theme` event, so a second window follows the first.
|
|
4099
|
+
*/
|
|
4100
|
+
case "GET /theme": {
|
|
4101
|
+
return send(res, 200, { state: themes.state(), previews: [...themePreviews.values()].map(themePreviewView) });
|
|
4102
|
+
}
|
|
4103
|
+
case "POST /theme/apply": {
|
|
4104
|
+
const body = await readJson(req);
|
|
4105
|
+
if (typeof body.preview === "string") {
|
|
4106
|
+
const preview = themePreviews.get(body.preview);
|
|
4107
|
+
if (!preview)
|
|
4108
|
+
return send(res, 404, { error: "that preview is gone; ask for the theme again" });
|
|
4109
|
+
const state = await themes.apply(preview.theme.input);
|
|
4110
|
+
themePreviews.delete(preview.id);
|
|
4111
|
+
broadcast({ type: "theme_preview_closed", id: preview.id, session: preview.session, outcome: "applied" });
|
|
4112
|
+
themeChanged(state);
|
|
4113
|
+
return send(res, 200, { state });
|
|
4114
|
+
}
|
|
4115
|
+
if (typeof body.history === "string") {
|
|
4116
|
+
const state = await themes.applyHistory(body.history);
|
|
4117
|
+
if (!state)
|
|
4118
|
+
return send(res, 404, { error: "no such theme in history" });
|
|
4119
|
+
themeChanged(state);
|
|
4120
|
+
return send(res, 200, { state });
|
|
4121
|
+
}
|
|
4122
|
+
return send(res, 400, { error: "preview or history is required" });
|
|
4123
|
+
}
|
|
4124
|
+
case "POST /theme/discard": {
|
|
4125
|
+
const body = await readJson(req);
|
|
4126
|
+
const preview = typeof body.preview === "string" ? themePreviews.get(body.preview) : undefined;
|
|
4127
|
+
if (preview) {
|
|
4128
|
+
themePreviews.delete(preview.id);
|
|
4129
|
+
broadcast({ type: "theme_preview_closed", id: preview.id, session: preview.session, outcome: "discarded" });
|
|
4130
|
+
}
|
|
4131
|
+
return send(res, 200, { ok: true });
|
|
4132
|
+
}
|
|
4133
|
+
case "POST /theme/undo": {
|
|
4134
|
+
const state = await themes.undo();
|
|
4135
|
+
themeChanged(state);
|
|
4136
|
+
return send(res, 200, { state });
|
|
4137
|
+
}
|
|
4138
|
+
case "POST /theme/reset": {
|
|
4139
|
+
const state = await themes.reset();
|
|
4140
|
+
themeChanged(state);
|
|
4141
|
+
return send(res, 200, { state });
|
|
4142
|
+
}
|
|
3608
4143
|
case "POST /interrupt":
|
|
3609
4144
|
{
|
|
3610
4145
|
const body = await readJson(req);
|
|
@@ -3763,7 +4298,20 @@ export async function startWebServer(opts) {
|
|
|
3763
4298
|
const provider = current.harness.auth.get(providerId);
|
|
3764
4299
|
if (!provider)
|
|
3765
4300
|
return undefined;
|
|
3766
|
-
|
|
4301
|
+
// The account sign-ins only. A vendor's browser login is an OAuth method
|
|
4302
|
+
// too, but it has no email step and no website to go through, and
|
|
4303
|
+
// treating it as an account would put both in front of it.
|
|
4304
|
+
const index = provider.methods.findIndex((method) => method.type === "oauth" && !method.browser);
|
|
4305
|
+
if (index === -1)
|
|
4306
|
+
return undefined;
|
|
4307
|
+
return { index, label: provider.methods[index].label };
|
|
4308
|
+
}
|
|
4309
|
+
/** A vendor's own browser sign-in, when it has one. */
|
|
4310
|
+
function vendorSignInMethod(providerId) {
|
|
4311
|
+
const provider = current.harness.auth.get(providerId);
|
|
4312
|
+
if (!provider)
|
|
4313
|
+
return undefined;
|
|
4314
|
+
const index = provider.methods.findIndex((method) => method.type === "oauth" && method.browser === true);
|
|
3767
4315
|
if (index === -1)
|
|
3768
4316
|
return undefined;
|
|
3769
4317
|
return { index, label: provider.methods[index].label };
|
|
@@ -3775,6 +4323,56 @@ export async function startWebServer(opts) {
|
|
|
3775
4323
|
* is declared but failed to start is worth showing as exactly that, and a
|
|
3776
4324
|
* menu built from the config file would claim it was there.
|
|
3777
4325
|
*/
|
|
4326
|
+
/**
|
|
4327
|
+
* One server found in Claude Code's or Codex's config, as the connector
|
|
4328
|
+
* list shows it. What it runs is summarised — a host, a command's name —
|
|
4329
|
+
* rather than echoed: an argument or a URL can carry a key, and the row
|
|
4330
|
+
* only needs to say which server this is.
|
|
4331
|
+
*/
|
|
4332
|
+
/** The vendor's site for an HTTP server, from its host; none for a local one. */
|
|
4333
|
+
function foreignSite(config) {
|
|
4334
|
+
if (config?.type !== "http")
|
|
4335
|
+
return undefined;
|
|
4336
|
+
let host;
|
|
4337
|
+
try {
|
|
4338
|
+
host = new URL(config.url).hostname.toLowerCase();
|
|
4339
|
+
}
|
|
4340
|
+
catch {
|
|
4341
|
+
return undefined;
|
|
4342
|
+
}
|
|
4343
|
+
if (host === "localhost" || /^[\d.]+$/.test(host) || host.endsWith(".local") || !host.includes("."))
|
|
4344
|
+
return undefined;
|
|
4345
|
+
const bare = host.replace(/^(mcp|api|connect|server)\./, "");
|
|
4346
|
+
return "https://" + bare + "/";
|
|
4347
|
+
}
|
|
4348
|
+
function foreignRow(one, installed) {
|
|
4349
|
+
const config = one.config;
|
|
4350
|
+
let target = "";
|
|
4351
|
+
if (config?.type === "http") {
|
|
4352
|
+
try {
|
|
4353
|
+
target = new URL(config.url).host;
|
|
4354
|
+
}
|
|
4355
|
+
catch {
|
|
4356
|
+
target = "";
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
else if (config) {
|
|
4360
|
+
target = path.basename(config.command);
|
|
4361
|
+
}
|
|
4362
|
+
return {
|
|
4363
|
+
source: one.source,
|
|
4364
|
+
scope: one.scope,
|
|
4365
|
+
name: one.name,
|
|
4366
|
+
...(one.plugin ? { plugin: one.plugin } : {}),
|
|
4367
|
+
...(one.pluginTitle ? { pluginTitle: one.pluginTitle } : {}),
|
|
4368
|
+
file: one.file.startsWith(homedir()) ? "~" + one.file.slice(homedir().length) : one.file,
|
|
4369
|
+
...(config ? { transport: config.type, target } : {}),
|
|
4370
|
+
...(one.reason ? { reason: one.reason } : {}),
|
|
4371
|
+
...(one.inlineSecret ? { inlineSecret: true } : {}),
|
|
4372
|
+
...(config && missingSecrets(config).length ? { missingEnv: missingSecrets(config) } : {}),
|
|
4373
|
+
installed: installed.has(one.name),
|
|
4374
|
+
};
|
|
4375
|
+
}
|
|
3778
4376
|
function contextOffer() {
|
|
3779
4377
|
const harness = current.harness;
|
|
3780
4378
|
const connectors = (harness.mcpStatus?.() ?? []).map((status) => ({
|
|
@@ -3787,10 +4385,14 @@ export async function startWebServer(opts) {
|
|
|
3787
4385
|
}));
|
|
3788
4386
|
const plugins = (harness.plugins ?? []).map((plugin) => ({
|
|
3789
4387
|
name: plugin.name,
|
|
4388
|
+
...(plugin.title ? { title: plugin.title } : {}),
|
|
3790
4389
|
...(plugin.description ? { description: plugin.description } : {}),
|
|
3791
4390
|
source: plugin.source,
|
|
3792
4391
|
servers: Object.keys(plugin.mcpServers ?? {}),
|
|
3793
4392
|
subagents: plugin.subagents?.length ?? 0,
|
|
4393
|
+
// The marketplace id, for the row's remove button. Only a directory the
|
|
4394
|
+
// marketplace put there carries one; nothing else is removed from here.
|
|
4395
|
+
...(plugin.market ? { market: plugin.market.id } : {}),
|
|
3794
4396
|
}));
|
|
3795
4397
|
const runtime = (harness.runtimePluginStatus?.() ?? []).map((plugin) => ({
|
|
3796
4398
|
name: plugin.id,
|
|
@@ -3811,13 +4413,17 @@ export async function startWebServer(opts) {
|
|
|
3811
4413
|
* in the model's own system prompt. Nobody driving the composer could see
|
|
3812
4414
|
* what was there, let alone point at one.
|
|
3813
4415
|
*/
|
|
3814
|
-
|
|
4416
|
+
// Switched-off ones included, so the list that switched one off still
|
|
4417
|
+
// has the row to switch it back on. Everything that only wants what the
|
|
4418
|
+
// model can load filters on `disabled`.
|
|
4419
|
+
const skills = (harness.skills?.listAll?.() ?? harness.skills?.list?.() ?? []).map((skill) => ({
|
|
3815
4420
|
name: skill.name,
|
|
3816
4421
|
description: skill.description,
|
|
3817
4422
|
source: skill.source,
|
|
3818
4423
|
// "builtin" is the registry's own word for one that ships with the
|
|
3819
4424
|
// harness; the rest name where on disk they were found.
|
|
3820
4425
|
builtin: skill.source === "builtin",
|
|
4426
|
+
...(harness.skills?.isDisabled?.(skill.name) ? { disabled: true } : {}),
|
|
3821
4427
|
}));
|
|
3822
4428
|
/**
|
|
3823
4429
|
* What shipped here first, then what this machine added.
|
|
@@ -3832,7 +4438,15 @@ export async function startWebServer(opts) {
|
|
|
3832
4438
|
...list.filter((one) => one.source === "builtin"),
|
|
3833
4439
|
...list.filter((one) => one.source !== "builtin"),
|
|
3834
4440
|
];
|
|
3835
|
-
|
|
4441
|
+
/**
|
|
4442
|
+
* The external agents a message can be addressed to — `@claude …` sends
|
|
4443
|
+
* it to the user's own Claude Code instead of the model. Only those with
|
|
4444
|
+
* an ACP runtime answer to an @ at the start of a message.
|
|
4445
|
+
*/
|
|
4446
|
+
const agents = (harness.subagents?.list?.() ?? [])
|
|
4447
|
+
.filter((definition) => definition.runtime?.kind === "acp")
|
|
4448
|
+
.map((definition) => ({ name: definition.name, description: definition.description }));
|
|
4449
|
+
return { connectors, plugins: builtinFirst([...plugins, ...runtime]), skills: builtinFirst(skills), agents };
|
|
3836
4450
|
}
|
|
3837
4451
|
/**
|
|
3838
4452
|
* What the composer attached to one message, as a block above the prompt.
|
|
@@ -4102,9 +4716,20 @@ export async function startWebServer(opts) {
|
|
|
4102
4716
|
}
|
|
4103
4717
|
if (!text && !tools.length && !thinking && !images.length)
|
|
4104
4718
|
continue;
|
|
4719
|
+
const agentName = message.role === "assistant" ? message.meta?.["agent"] : undefined;
|
|
4720
|
+
const agentSession = message.meta?.["acpSession"];
|
|
4105
4721
|
entries.push({
|
|
4106
4722
|
role: message.role === "assistant" ? "assistant" : "user",
|
|
4107
4723
|
keepMessages: index,
|
|
4724
|
+
...(typeof agentName === "string"
|
|
4725
|
+
? {
|
|
4726
|
+
agent: {
|
|
4727
|
+
name: agentName,
|
|
4728
|
+
...(typeof agentSession?.id === "string" ? { session: agentSession.id } : {}),
|
|
4729
|
+
...(typeof agentSession?.cwd === "string" ? { cwd: agentSession.cwd } : {}),
|
|
4730
|
+
},
|
|
4731
|
+
}
|
|
4732
|
+
: {}),
|
|
4108
4733
|
...(images.length ? { images } : {}),
|
|
4109
4734
|
...(thinking ? { thinking } : {}),
|
|
4110
4735
|
...(text ? { text } : {}),
|
|
@@ -4119,11 +4744,7 @@ export async function startWebServer(opts) {
|
|
|
4119
4744
|
const target = entries.findLast((entry) => entry.role === "assistant" && (entry.keepMessages ?? 0) <= shot.at);
|
|
4120
4745
|
if (!target)
|
|
4121
4746
|
continue;
|
|
4122
|
-
(target.shots ??= []).push(
|
|
4123
|
-
app: shot.app,
|
|
4124
|
-
...(shot.title ? { title: shot.title } : {}),
|
|
4125
|
-
src: turnShotSrc(runner.session.id, shot.file),
|
|
4126
|
-
});
|
|
4747
|
+
(target.shots ??= []).push(turnShotView(runner.session.id, shot));
|
|
4127
4748
|
}
|
|
4128
4749
|
return entries;
|
|
4129
4750
|
}
|
|
@@ -4157,6 +4778,7 @@ export async function startWebServer(opts) {
|
|
|
4157
4778
|
...(opts.computerUsePlugin ? { computerUsePlugin: opts.computerUsePlugin } : {}),
|
|
4158
4779
|
}));
|
|
4159
4780
|
harnesses.set(project.path, adoptRoutineModel(harness));
|
|
4781
|
+
equipThemes(harness.tools);
|
|
4160
4782
|
harness.worktreeLauncher = launchWorktree;
|
|
4161
4783
|
harness.taskWaker = wakeForTasks;
|
|
4162
4784
|
watchConnections(harness);
|
|
@@ -4247,15 +4869,19 @@ export async function startWebServer(opts) {
|
|
|
4247
4869
|
const routines = new RoutineRunner({
|
|
4248
4870
|
defaultCwd: opts.session.cwd,
|
|
4249
4871
|
openHarnesses: () => harnesses.values(),
|
|
4250
|
-
createHarness: async (cwd) =>
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4872
|
+
createHarness: async (cwd) => {
|
|
4873
|
+
const harness = adoptRoutineModel(await (opts.openProject
|
|
4874
|
+
? opts.openProject(cwd)
|
|
4875
|
+
: Harness.create({
|
|
4876
|
+
cwd,
|
|
4877
|
+
permissionPrompt: async () => false,
|
|
4878
|
+
questionPrompt: async () => ({ outcome: "unavailable" }),
|
|
4879
|
+
...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
|
|
4880
|
+
...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
|
|
4881
|
+
})));
|
|
4882
|
+
pictureBrowserTurns(harness);
|
|
4883
|
+
return harness;
|
|
4884
|
+
},
|
|
4259
4885
|
onStart: (routine, meta) => {
|
|
4260
4886
|
if (meta.catchUp)
|
|
4261
4887
|
broadcast({ type: "notice", message: `routine ${routine.def.name} is catching up on a run it missed` });
|
|
@@ -4412,6 +5038,8 @@ export async function startWebServer(opts) {
|
|
|
4412
5038
|
const controller = new AbortController();
|
|
4413
5039
|
runner.running = controller;
|
|
4414
5040
|
const id = runner.session.id;
|
|
5041
|
+
suggesting.get(id)?.abort();
|
|
5042
|
+
suggesting.delete(id);
|
|
4415
5043
|
// Background tasks only ever start inside a turn, so this is the first
|
|
4416
5044
|
// moment worth watching them.
|
|
4417
5045
|
watchTasks(runner);
|
|
@@ -4544,8 +5172,8 @@ export async function startWebServer(opts) {
|
|
|
4544
5172
|
// Only after a turn that ended on its own. An interrupt means stop, and
|
|
4545
5173
|
// an error would usually meet the next prompt with the same error, so
|
|
4546
5174
|
// both leave the queue standing for the person to send or drop.
|
|
4547
|
-
if (!controller.signal.aborted && !failed)
|
|
4548
|
-
|
|
5175
|
+
if (!controller.signal.aborted && !failed && !drainQueue(runner))
|
|
5176
|
+
suggest(runner);
|
|
4549
5177
|
}
|
|
4550
5178
|
}
|
|
4551
5179
|
function stream(res) {
|
|
@@ -4626,6 +5254,40 @@ export async function startWebServer(opts) {
|
|
|
4626
5254
|
* not know about until the turn lands, and the page asking is the page that
|
|
4627
5255
|
* spent it.
|
|
4628
5256
|
*/
|
|
5257
|
+
/**
|
|
5258
|
+
* Each provider beside what this machine spent with it in the last thirty
|
|
5259
|
+
* days, for the provider cards.
|
|
5260
|
+
*
|
|
5261
|
+
* An estimate, and labelled as one: a session's cost is put down to the
|
|
5262
|
+
* model it used last, so a session that switched vendors part way through
|
|
5263
|
+
* is counted against the second. Read from the same records the usage page
|
|
5264
|
+
* reads — the few hundred most recent sessions — and kept for a minute,
|
|
5265
|
+
* because the models panel asks every time it is painted.
|
|
5266
|
+
*/
|
|
5267
|
+
let spendCache;
|
|
5268
|
+
async function withSpend(entries) {
|
|
5269
|
+
if (!spendCache || Date.now() - spendCache.at > 60_000) {
|
|
5270
|
+
const byProvider = new Map();
|
|
5271
|
+
try {
|
|
5272
|
+
const providerOf = new Map(current.harness.listModels().map((model) => [model.id, model.provider]));
|
|
5273
|
+
const since = Date.now() - 30 * 86_400_000;
|
|
5274
|
+
const { rows } = await usageRows(USAGE_SESSION_SCAN);
|
|
5275
|
+
for (const row of rows.values()) {
|
|
5276
|
+
if (row.updatedAt < since || !(row.costUsd > 0))
|
|
5277
|
+
continue;
|
|
5278
|
+
const provider = providerOf.get(row.model) ?? row.model.split("/")[0];
|
|
5279
|
+
if (provider)
|
|
5280
|
+
byProvider.set(provider, (byProvider.get(provider) ?? 0) + row.costUsd);
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5283
|
+
catch {
|
|
5284
|
+
// A figure that cannot be worked out is left off, not shown as zero.
|
|
5285
|
+
}
|
|
5286
|
+
spendCache = { at: Date.now(), byProvider };
|
|
5287
|
+
}
|
|
5288
|
+
const spent = spendCache.byProvider;
|
|
5289
|
+
return entries.map((entry) => ({ entry, spend30d: spent.get(entry.id) ?? 0 }));
|
|
5290
|
+
}
|
|
4629
5291
|
async function usageRows(scan) {
|
|
4630
5292
|
const stored = await current.harness.listSessions(scan);
|
|
4631
5293
|
const rows = new Map();
|
|
@@ -4800,6 +5462,7 @@ export async function startWebServer(opts) {
|
|
|
4800
5462
|
refreshStale();
|
|
4801
5463
|
const staleTimer = setInterval(refreshStale, 6 * 60 * 60 * 1000);
|
|
4802
5464
|
staleTimer.unref?.();
|
|
5465
|
+
const stopHeartbeat = startHeartbeat();
|
|
4803
5466
|
if (opts.routines)
|
|
4804
5467
|
routines.start();
|
|
4805
5468
|
void applyKeepAwake().catch(() => { });
|
|
@@ -4902,6 +5565,8 @@ export async function startWebServer(opts) {
|
|
|
4902
5565
|
},
|
|
4903
5566
|
async close() {
|
|
4904
5567
|
clearInterval(staleTimer);
|
|
5568
|
+
stopHeartbeat();
|
|
5569
|
+
await flushAnalytics();
|
|
4905
5570
|
presence?.close();
|
|
4906
5571
|
closed = true;
|
|
4907
5572
|
await remoteChange.catch(() => { });
|