@oharness/server 0.10.0 → 0.11.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 +152 -8
- package/dist/i18n.js.map +1 -1
- package/dist/icons.js +1 -0
- 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 +40 -16
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/browser.js +1 -1
- package/dist/page/client/composer.js +111 -7
- 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 +183 -22
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +4 -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 +465 -42
- 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 +28 -2
- package/dist/terminal.js +45 -3
- 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/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
|
@@ -29,15 +29,15 @@ import { activeRun, onRunActivity, onRunsChanged } from "@oharness/engine/schedu
|
|
|
29
29
|
import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
|
|
30
30
|
import { ChangeLedger } from "./changes.js";
|
|
31
31
|
import { projectPath } from "@oharness/engine/paths.ts";
|
|
32
|
-
import { listTurnShots, removeTurnShots, saveTurnShots, turnShotPath,
|
|
32
|
+
import { listTurnShots, removeTurnShots, saveTurnShots, turnShotPath, turnShotView } from "./turn-shots.js";
|
|
33
33
|
import { addProject, ensureProject, listProjects, removeProject, renameProject } from "./projects.js";
|
|
34
34
|
import { readPrefs, writePrefs } from "./prefs.js";
|
|
35
35
|
import { sortModelsForDisplay } from "./model-order.js";
|
|
36
|
-
import { loadModelsDev,
|
|
36
|
+
import { loadModelsDev, orderingDate, releaseDates } from "@oharness/engine/providers/models-dev.ts";
|
|
37
37
|
import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
|
|
38
|
-
import { TerminalHub, terminalAsset } from "./terminal.js";
|
|
38
|
+
import { isAgentSessionId, isLauncher, TerminalHub, terminalAsset } from "./terminal.js";
|
|
39
39
|
import { PREVIEW_MEDIA, browseFiles, previewFile, resolveFile, searchFiles, within } from "./files.js";
|
|
40
|
-
import { analyticsState } from "./analytics.js";
|
|
40
|
+
import { analyticsState, cleanEvent, flushAnalytics, markUsed, setConsent, startHeartbeat, track } from "./analytics.js";
|
|
41
41
|
import { PAGE } from "./page.js";
|
|
42
42
|
import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
|
|
43
43
|
import { artifactDocument, sendArtifact } from "./artifacts.js";
|
|
@@ -46,6 +46,11 @@ import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreAr
|
|
|
46
46
|
import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
|
|
47
47
|
import { accountUsage } from "./account-usage.js";
|
|
48
48
|
import { createStarters } from "./starters.js";
|
|
49
|
+
import { suggestNext } from "./suggest.js";
|
|
50
|
+
import { ThemeStore } from "./theme-store.js";
|
|
51
|
+
import { ASSET_TYPES, assetExists, assetPath as themeAssetPath } from "./theme-assets.js";
|
|
52
|
+
import { createThemeTools } from "./theme-tools.js";
|
|
53
|
+
import { ASSET_ID, assetUrl, swatches, themeCss } from "./user-theme.js";
|
|
49
54
|
import { pairedDevices, remoteMachine, revokeDevice, startRemote, } from "./remote.js";
|
|
50
55
|
/**
|
|
51
56
|
* How many stored sessions the usage page counts.
|
|
@@ -197,14 +202,18 @@ function tokensOf(usage) {
|
|
|
197
202
|
*
|
|
198
203
|
* Its date wins over a model's own: a listing's `created` is sometimes the
|
|
199
204
|
* 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.
|
|
205
|
+
* a gateway or an aggregator relays under the same name. A model with neither
|
|
206
|
+
* is dated to the snapshot itself — see `orderingDate`.
|
|
201
207
|
*/
|
|
202
208
|
let knownDates;
|
|
203
209
|
async function withReleaseDates(models) {
|
|
204
|
-
const dates = await (knownDates ??= loadModelsDev().then(
|
|
210
|
+
const { dates, at } = await (knownDates ??= loadModelsDev().then((snapshot) => {
|
|
211
|
+
const fetched = Date.parse(snapshot.fetchedAt ?? "");
|
|
212
|
+
return { dates: releaseDates(snapshot), at: Number.isFinite(fetched) ? fetched : undefined };
|
|
213
|
+
}));
|
|
205
214
|
return models.map((model) => {
|
|
206
|
-
const
|
|
207
|
-
return
|
|
215
|
+
const date = orderingDate(dates, at, model);
|
|
216
|
+
return date === undefined || date === model.releasedAt ? model : { ...model, releasedAt: date };
|
|
208
217
|
});
|
|
209
218
|
}
|
|
210
219
|
export async function startWebServer(opts) {
|
|
@@ -240,6 +249,58 @@ export async function startWebServer(opts) {
|
|
|
240
249
|
opts.onKeepAwake(prefs.keepAwake === true || remoteRunning());
|
|
241
250
|
};
|
|
242
251
|
const pending = new Map();
|
|
252
|
+
/*
|
|
253
|
+
* Themes the agent proposed, and the one in use.
|
|
254
|
+
*
|
|
255
|
+
* A proposal is a card, not a change: it waits here until the person applies
|
|
256
|
+
* or discards it, and is kept in memory only, so a restart drops the ones
|
|
257
|
+
* nobody acted on. What is applied lives in theme.json via the store, and its
|
|
258
|
+
* CSS is printed into the page on every load — see `GET /` — and pushed to
|
|
259
|
+
* open pages as a `theme` event when it changes.
|
|
260
|
+
*/
|
|
261
|
+
const themes = await ThemeStore.load();
|
|
262
|
+
const themePreviews = new Map();
|
|
263
|
+
const themePreviewView = (p) => ({
|
|
264
|
+
id: p.id,
|
|
265
|
+
session: p.session,
|
|
266
|
+
name: p.theme.input.name,
|
|
267
|
+
mode: p.theme.input.mode,
|
|
268
|
+
css: themeCss(p.theme),
|
|
269
|
+
swatches: swatches(p.theme),
|
|
270
|
+
// One thumbnail per filled slot, in slot order, for the card.
|
|
271
|
+
images: Object.entries(p.theme.input.images ?? {}).map(([slot, value]) => ({ slot, url: assetUrl(value.asset) })),
|
|
272
|
+
report: p.theme.report,
|
|
273
|
+
});
|
|
274
|
+
const themeTools = createThemeTools({
|
|
275
|
+
activeInput: () => themes.activeInput(),
|
|
276
|
+
propose: (session, theme) => {
|
|
277
|
+
const preview = { id: randomUUID(), session, theme, createdAt: Date.now() };
|
|
278
|
+
themePreviews.set(preview.id, preview);
|
|
279
|
+
// Bounded: a model looping on set_theme must not grow this forever.
|
|
280
|
+
if (themePreviews.size > 20)
|
|
281
|
+
themePreviews.delete(themePreviews.keys().next().value);
|
|
282
|
+
broadcast({ type: "theme_preview", session, preview: themePreviewView(preview) });
|
|
283
|
+
return preview.id;
|
|
284
|
+
},
|
|
285
|
+
reset: async () => {
|
|
286
|
+
const was = themes.state().active?.name;
|
|
287
|
+
if (!was)
|
|
288
|
+
return undefined;
|
|
289
|
+
themeChanged(await themes.reset());
|
|
290
|
+
return was;
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
/** Give a harness the theme tools; the page is what makes them meaningful. */
|
|
294
|
+
const equipThemes = (tools) => {
|
|
295
|
+
for (const tool of themeTools)
|
|
296
|
+
if (!tools.has(tool.name))
|
|
297
|
+
tools.register(tool);
|
|
298
|
+
};
|
|
299
|
+
equipThemes(opts.harness.tools);
|
|
300
|
+
// The first agent was built before this server existed, and may hold its own
|
|
301
|
+
// copy of the registry rather than the harness's.
|
|
302
|
+
equipThemes(opts.agent.tools);
|
|
303
|
+
const themeChanged = (state) => broadcast({ type: "theme", state });
|
|
243
304
|
const runners = new Map();
|
|
244
305
|
/** Agents whose background tasks are being watched; see watchTasks. */
|
|
245
306
|
const watchedTasks = new WeakSet();
|
|
@@ -247,6 +308,31 @@ export async function startWebServer(opts) {
|
|
|
247
308
|
const starters = createStarters((cwd, locale, written) => {
|
|
248
309
|
broadcast({ type: "starters", cwd, locale, starters: written });
|
|
249
310
|
});
|
|
311
|
+
/**
|
|
312
|
+
* The next-message guess being written for each conversation. Dropped the
|
|
313
|
+
* moment that conversation starts another turn: a guess about the turn
|
|
314
|
+
* before is wrong once there is a new one.
|
|
315
|
+
*/
|
|
316
|
+
const suggesting = new Map();
|
|
317
|
+
let suggestionsOn = true;
|
|
318
|
+
void readPrefs().then((prefs) => { suggestionsOn = prefs.suggestions !== false; }).catch(() => { });
|
|
319
|
+
function suggest(runner) {
|
|
320
|
+
if (!suggestionsOn)
|
|
321
|
+
return;
|
|
322
|
+
const id = runner.session.id;
|
|
323
|
+
const controller = new AbortController();
|
|
324
|
+
suggesting.set(id, controller);
|
|
325
|
+
void suggestNext(runner.harness, runner.session.messages, runner.session.model.id, controller.signal)
|
|
326
|
+
.then((text) => {
|
|
327
|
+
if (text && !controller.signal.aborted)
|
|
328
|
+
broadcast({ type: "suggestion", session: id, text });
|
|
329
|
+
})
|
|
330
|
+
.catch(() => { })
|
|
331
|
+
.finally(() => {
|
|
332
|
+
if (suggesting.get(id) === controller)
|
|
333
|
+
suggesting.delete(id);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
250
336
|
/**
|
|
251
337
|
* What the OS lets Computer Use do, read from the helper when a conversation
|
|
252
338
|
* switches it on and whenever the permission card asks again. Cached because
|
|
@@ -254,6 +340,13 @@ export async function startWebServer(opts) {
|
|
|
254
340
|
* revoked at any time, and the tools report that themselves when it happens.
|
|
255
341
|
*/
|
|
256
342
|
const computerStatus = {};
|
|
343
|
+
/**
|
|
344
|
+
* Whether a turn's windows and tabs are pictured when it ends: the one
|
|
345
|
+
* setting for Computer use and the browser. Kept here because the engine
|
|
346
|
+
* asks synchronously; read once, then updated by the setting's own route.
|
|
347
|
+
*/
|
|
348
|
+
let turnShotsOn = true;
|
|
349
|
+
void readPrefs().then((prefs) => { turnShotsOn = prefs.turnShots !== false; }).catch(() => { });
|
|
257
350
|
/**
|
|
258
351
|
* The live view: what each conversation is doing on the computer, pushed as
|
|
259
352
|
* it happens. The last event per conversation is kept, so a pane opened
|
|
@@ -278,17 +371,21 @@ export async function startWebServer(opts) {
|
|
|
278
371
|
});
|
|
279
372
|
opts.computerUsePlugin?.runtime.onSnapshot((event) => {
|
|
280
373
|
const at = computerTurnEnd.get(event.sessionId);
|
|
281
|
-
if (at === undefined)
|
|
374
|
+
if (at === undefined || !turnShotsOn)
|
|
282
375
|
return;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
376
|
+
keepTurnShots(event.sessionId, at, event.shots);
|
|
377
|
+
});
|
|
378
|
+
// The agent switched it on with computer_enable: what the + menu route does
|
|
379
|
+
// after flipping the flag, so the chip, the permission card and a resumed
|
|
380
|
+
// conversation all see it.
|
|
381
|
+
opts.computerUsePlugin?.runtime.onEnabled((sessionId) => {
|
|
382
|
+
const runner = runners.get(sessionId);
|
|
383
|
+
if (!runner)
|
|
384
|
+
return;
|
|
385
|
+
void runner.session.persist().catch(() => { });
|
|
386
|
+
void refreshComputer(opts.computerUsePlugin).finally(() => {
|
|
387
|
+
broadcast({ type: "state", session: runner.session.id, state: state(runner) });
|
|
388
|
+
});
|
|
292
389
|
});
|
|
293
390
|
async function refreshComputer(plugin) {
|
|
294
391
|
// Grants first and on their own: they come from the host app, and a
|
|
@@ -511,8 +608,38 @@ export async function startWebServer(opts) {
|
|
|
511
608
|
connectionSubscriptions.push(harness.cloudConnectors.subscribe((card) => {
|
|
512
609
|
broadcast({ type: "connection_card", session: card.session, card });
|
|
513
610
|
}));
|
|
611
|
+
pictureBrowserTurns(harness);
|
|
514
612
|
}
|
|
515
613
|
watchConnections(opts.harness);
|
|
614
|
+
/** Keep a turn's pictures beside its session, and show them under its last reply. */
|
|
615
|
+
function keepTurnShots(session, at, shots) {
|
|
616
|
+
void saveTurnShots(session, at, shots)
|
|
617
|
+
.then((saved) => {
|
|
618
|
+
broadcast({ type: "turn_shots", session, shots: saved.map((shot) => turnShotView(session, shot)) });
|
|
619
|
+
})
|
|
620
|
+
.catch(() => { });
|
|
621
|
+
}
|
|
622
|
+
/** A tab's caption when it has no title: the site, not the whole address. */
|
|
623
|
+
function hostOf(url) {
|
|
624
|
+
try {
|
|
625
|
+
return url ? new URL(url).host || url : "";
|
|
626
|
+
}
|
|
627
|
+
catch {
|
|
628
|
+
return url ?? "";
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
/** The browser tabs a turn left, the same as the windows Computer use did. */
|
|
632
|
+
function pictureBrowserTurns(harness) {
|
|
633
|
+
harness.turnShotSink = {
|
|
634
|
+
enabled: () => turnShotsOn,
|
|
635
|
+
save: (session, at, shots) => keepTurnShots(session, at, shots.map((shot) => ({
|
|
636
|
+
app: hostOf(shot.url),
|
|
637
|
+
...(shot.title ? { title: shot.title } : {}),
|
|
638
|
+
kind: "browser",
|
|
639
|
+
data: shot.data,
|
|
640
|
+
}))),
|
|
641
|
+
};
|
|
642
|
+
}
|
|
516
643
|
/**
|
|
517
644
|
* Take a waiting interaction off every screen.
|
|
518
645
|
*
|
|
@@ -601,7 +728,14 @@ export async function startWebServer(opts) {
|
|
|
601
728
|
// nothing else: a page that drives a shell is not one to embed.
|
|
602
729
|
"content-security-policy": `frame-ancestors 'self' ${EXTENSION_IDS.map((id) => `chrome-extension://${id}`).join(" ")}`,
|
|
603
730
|
});
|
|
604
|
-
|
|
731
|
+
// The applied theme, after the built-in stylesheet so it wins on order.
|
|
732
|
+
// `?theme=default` is safe mode: a page that cannot be read must still
|
|
733
|
+
// be reachable, so the way out does not depend on the theme it escapes.
|
|
734
|
+
// The CSS is ours, printed from validated colours; `</` cannot occur in
|
|
735
|
+
// it, but is refused anyway so nothing could ever close the tag early.
|
|
736
|
+
const css = url.searchParams.get("theme") === "default" ? "" : themes.state().css;
|
|
737
|
+
const tag = css && !css.includes("</") ? `<style id="oh-user-theme">${css}</style>` : `<style id="oh-user-theme"></style>`;
|
|
738
|
+
res.end(PAGE.replace("</head>", `${tag}\n</head>`));
|
|
605
739
|
return;
|
|
606
740
|
}
|
|
607
741
|
// The other exception, and for the same kind of reason: this one is
|
|
@@ -621,6 +755,21 @@ export async function startWebServer(opts) {
|
|
|
621
755
|
}
|
|
622
756
|
return serveArtifactBody(url, res);
|
|
623
757
|
}
|
|
758
|
+
// Fourth exception: a theme's images, loaded by CSS `url()`, which cannot
|
|
759
|
+
// carry the token. The name is the sha-256 of the bytes (theme-assets.ts),
|
|
760
|
+
// so knowing it already means having the picture; nothing else is served.
|
|
761
|
+
if (url.pathname.startsWith("/theme-asset/") && req.method === "GET") {
|
|
762
|
+
const id = url.pathname.slice("/theme-asset/".length);
|
|
763
|
+
if (!ASSET_ID.test(id) || !(await assetExists(id)))
|
|
764
|
+
return send(res, 404, { error: "no such asset" });
|
|
765
|
+
res.writeHead(200, {
|
|
766
|
+
"content-type": ASSET_TYPES[id.slice(id.lastIndexOf(".") + 1)],
|
|
767
|
+
"cache-control": "private, max-age=31536000, immutable",
|
|
768
|
+
"x-content-type-options": "nosniff",
|
|
769
|
+
});
|
|
770
|
+
res.end(await readFile(themeAssetPath(id)));
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
624
773
|
if (!authorized(url, req))
|
|
625
774
|
return send(res, 401, { error: "missing or bad token" });
|
|
626
775
|
switch (`${req.method} ${url.pathname}`) {
|
|
@@ -1275,6 +1424,8 @@ export async function startWebServer(opts) {
|
|
|
1275
1424
|
const pinned = new Set(prefs.pinnedProjects ?? []);
|
|
1276
1425
|
return send(res, 200, {
|
|
1277
1426
|
order: prefs.projectOrder ?? [],
|
|
1427
|
+
// So the picker can write a path the way people say it: ~/code/app.
|
|
1428
|
+
home: homedir(),
|
|
1278
1429
|
projects: projects.map((project) => ({
|
|
1279
1430
|
...project,
|
|
1280
1431
|
pinned: pinned.has(project.path),
|
|
@@ -1356,7 +1507,11 @@ export async function startWebServer(opts) {
|
|
|
1356
1507
|
if (!root)
|
|
1357
1508
|
return send(res, 400, { error: "not a folder" });
|
|
1358
1509
|
const replace = typeof body.replace === "string" ? body.replace : undefined;
|
|
1359
|
-
const
|
|
1510
|
+
const run = isLauncher(body.run) ? body.run : undefined;
|
|
1511
|
+
// Only for the claude launcher, and only an id shaped like one: it is
|
|
1512
|
+
// typed into a shell, so nothing else gets near the command line.
|
|
1513
|
+
const resume = run === "claude" && isAgentSessionId(body.resume) ? body.resume : undefined;
|
|
1514
|
+
const result = await terminals.open(root, { cols: body.cols, rows: body.rows }, replace, run, resume);
|
|
1360
1515
|
return send(res, "error" in result ? 500 : 200, result);
|
|
1361
1516
|
}
|
|
1362
1517
|
case "GET /term/list": {
|
|
@@ -1884,7 +2039,13 @@ export async function startWebServer(opts) {
|
|
|
1884
2039
|
const body = await readJson(req);
|
|
1885
2040
|
const dir = typeof body.path === "string" ? body.path : "";
|
|
1886
2041
|
// Any tab: switching projects rebuilds the harness under every runner.
|
|
1887
|
-
|
|
2042
|
+
// Except a purge, which stops the turns in its own tree itself — that
|
|
2043
|
+
// is half of what deleting permanently means — so only a turn running
|
|
2044
|
+
// somewhere else stands in its way.
|
|
2045
|
+
const blocking = body.purge === true
|
|
2046
|
+
? [...runners.values()].some((runner) => runner.running && runner.session.cwd !== path.resolve(dir))
|
|
2047
|
+
: anyRunning();
|
|
2048
|
+
if (blocking)
|
|
1888
2049
|
return send(res, 409, { error: "a turn is running" });
|
|
1889
2050
|
if (typeof body.rename === "string") {
|
|
1890
2051
|
const oldPath = path.resolve(dir);
|
|
@@ -1933,9 +2094,34 @@ export async function startWebServer(opts) {
|
|
|
1933
2094
|
* the work.
|
|
1934
2095
|
*/
|
|
1935
2096
|
if (body.purge === true) {
|
|
1936
|
-
|
|
1937
|
-
|
|
2097
|
+
// Every session open in this tree, not only those the store finds:
|
|
2098
|
+
// one nobody has spoken to, or whose transcript is already gone, is
|
|
2099
|
+
// still a runner here and a row in the sidebar, and closing only
|
|
2100
|
+
// what was on disk left exactly those behind — the delete looked
|
|
2101
|
+
// like it did nothing. "Delete permanently" is the answer to whether
|
|
2102
|
+
// a turn still going should go on, so it is stopped, and given a
|
|
2103
|
+
// moment to end before the store goes, or its last write would
|
|
2104
|
+
// bring the transcript back. A wedged turn is not waited on forever.
|
|
2105
|
+
const open = [...runners.values()].filter((runner) => runner.session.cwd === dir);
|
|
2106
|
+
for (const runner of open) {
|
|
2107
|
+
runner.queue.length = 0;
|
|
2108
|
+
runner.running?.abort();
|
|
2109
|
+
}
|
|
2110
|
+
for (let waited = 0; open.some((runner) => runner.running) && waited < 5000; waited += 50) {
|
|
2111
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
2112
|
+
}
|
|
2113
|
+
// Before the store goes, as for one session: a task stopped
|
|
2114
|
+
// afterwards would write its record back.
|
|
2115
|
+
for (const runner of open)
|
|
2116
|
+
await runner.agent.discardBackgroundTasks();
|
|
2117
|
+
const onDisk = await current.harness.deleteSessionsIn(dir);
|
|
2118
|
+
const deleted = [...new Set([...onDisk, ...open.map((runner) => runner.session.id)])];
|
|
2119
|
+
for (const id of deleted) {
|
|
2120
|
+
const removed = runners.get(id);
|
|
1938
2121
|
runners.delete(id);
|
|
2122
|
+
await removed?.harness.closeBrowserFor(id);
|
|
2123
|
+
await removeTurnShots(id);
|
|
2124
|
+
}
|
|
1939
2125
|
// Their artifact logs went with them, and a listing built a moment
|
|
1940
2126
|
// ago still names them.
|
|
1941
2127
|
cachedLogs = undefined;
|
|
@@ -2064,6 +2250,32 @@ export async function startWebServer(opts) {
|
|
|
2064
2250
|
const runs = await current.harness.scheduler.history(name, 30);
|
|
2065
2251
|
return send(res, 200, { runs: going ? [{ ...going, running: true }, ...runs] : runs });
|
|
2066
2252
|
}
|
|
2253
|
+
case "GET /turn-shots":
|
|
2254
|
+
return send(res, 200, { enabled: turnShotsOn });
|
|
2255
|
+
case "POST /turn-shots": {
|
|
2256
|
+
const body = await readJson(req);
|
|
2257
|
+
if (typeof body.enabled !== "boolean")
|
|
2258
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2259
|
+
await writePrefs({ turnShots: body.enabled });
|
|
2260
|
+
turnShotsOn = body.enabled;
|
|
2261
|
+
return send(res, 200, { enabled: body.enabled });
|
|
2262
|
+
}
|
|
2263
|
+
case "GET /suggestions":
|
|
2264
|
+
return send(res, 200, { enabled: suggestionsOn });
|
|
2265
|
+
case "POST /suggestions": {
|
|
2266
|
+
const body = await readJson(req);
|
|
2267
|
+
if (typeof body.enabled !== "boolean")
|
|
2268
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2269
|
+
await writePrefs({ suggestions: body.enabled });
|
|
2270
|
+
suggestionsOn = body.enabled;
|
|
2271
|
+
// Off means gone, not only no new ones: a guess already in a box would
|
|
2272
|
+
// keep offering itself after the switch said not to.
|
|
2273
|
+
if (!body.enabled)
|
|
2274
|
+
for (const controller of suggesting.values())
|
|
2275
|
+
controller.abort();
|
|
2276
|
+
broadcast({ type: "suggestions", enabled: body.enabled });
|
|
2277
|
+
return send(res, 200, { enabled: body.enabled });
|
|
2278
|
+
}
|
|
2067
2279
|
case "GET /keep-awake":
|
|
2068
2280
|
return send(res, 200, {
|
|
2069
2281
|
available: !!opts.onKeepAwake,
|
|
@@ -2129,6 +2341,8 @@ export async function startWebServer(opts) {
|
|
|
2129
2341
|
// whether the switch it draws is the user's to flip.
|
|
2130
2342
|
audit: await current.harness.auditStatus(),
|
|
2131
2343
|
browserMode: current.harness.browserMode(),
|
|
2344
|
+
// Off unless the user turned it on: it spends their Claude plan.
|
|
2345
|
+
claudeAgent: await current.harness.claudeAgentStatus(),
|
|
2132
2346
|
// For the browser row: whether the person's browser can be reached
|
|
2133
2347
|
// now, and where to get the extension when it cannot.
|
|
2134
2348
|
browserExtension: await extensionInfo(),
|
|
@@ -2161,6 +2375,22 @@ export async function startWebServer(opts) {
|
|
|
2161
2375
|
* switch that changed only the project on screen would look ignored the
|
|
2162
2376
|
* moment someone opened another one.
|
|
2163
2377
|
*/
|
|
2378
|
+
/**
|
|
2379
|
+
* Claude Agent on or off. Every open project's harness at once, the way
|
|
2380
|
+
* the browser mode is set: it is one person's choice about their own
|
|
2381
|
+
* Claude account, not a property of a folder.
|
|
2382
|
+
*/
|
|
2383
|
+
case "POST /settings/claude-agent": {
|
|
2384
|
+
const body = await readJson(req);
|
|
2385
|
+
if (typeof body.enabled !== "boolean")
|
|
2386
|
+
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2387
|
+
for (const harness of new Set([current.harness, ...harnesses.values()])) {
|
|
2388
|
+
await harness.setClaudeAgent(body.enabled);
|
|
2389
|
+
}
|
|
2390
|
+
// The @ menu lists the agents a message can go to; it is now out of date.
|
|
2391
|
+
broadcast({ type: "connectors_changed", ...contextOffer() });
|
|
2392
|
+
return send(res, 200, await current.harness.claudeAgentStatus());
|
|
2393
|
+
}
|
|
2164
2394
|
case "POST /settings/browser-mode": {
|
|
2165
2395
|
const body = await readJson(req);
|
|
2166
2396
|
if (body.mode !== "auto" && body.mode !== "builtin" && body.mode !== "extension") {
|
|
@@ -2579,8 +2809,17 @@ export async function startWebServer(opts) {
|
|
|
2579
2809
|
return send(res, 400, { error: "enabled must be a boolean" });
|
|
2580
2810
|
}
|
|
2581
2811
|
await writePrefs({ analytics: body.enabled });
|
|
2812
|
+
setConsent(body.enabled);
|
|
2582
2813
|
return send(res, 200, await analyticsState());
|
|
2583
2814
|
}
|
|
2815
|
+
// The page's usage events, checked and forwarded — or dropped, when the
|
|
2816
|
+
// user has not opted in. The page never learns which.
|
|
2817
|
+
case "POST /track": {
|
|
2818
|
+
const cleaned = cleanEvent(await readJson(req, 10_000));
|
|
2819
|
+
if (cleaned)
|
|
2820
|
+
void track(cleaned.event, cleaned.props);
|
|
2821
|
+
return send(res, 200, { ok: true });
|
|
2822
|
+
}
|
|
2584
2823
|
case "GET /changes": {
|
|
2585
2824
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
2586
2825
|
return send(res, 200, { changes: runner ? runner.ledger.list() : [] });
|
|
@@ -2828,7 +3067,7 @@ export async function startWebServer(opts) {
|
|
|
2828
3067
|
// and a false one say the same thing to the one reader there is.
|
|
2829
3068
|
...(isFree(model) ? { free: true } : {}),
|
|
2830
3069
|
})),
|
|
2831
|
-
providers: current.harness.listCatalogProviders().map((entry) => ({
|
|
3070
|
+
providers: (await withSpend(current.harness.listCatalogProviders())).map(({ entry, spend30d }) => ({
|
|
2832
3071
|
id: entry.id,
|
|
2833
3072
|
label: entry.label,
|
|
2834
3073
|
envVars: entry.envVars,
|
|
@@ -2859,6 +3098,17 @@ export async function startWebServer(opts) {
|
|
|
2859
3098
|
// key into. The panel has no other way to tell, and offering a key
|
|
2860
3099
|
// field for an account-backed plan is a dead end.
|
|
2861
3100
|
signIn: signInMethod(entry.id),
|
|
3101
|
+
// A vendor's own browser sign-in — Sign in with ChatGPT, OpenRouter
|
|
3102
|
+
// — which is one button and a loopback redirect, not the account
|
|
3103
|
+
// dialog. Separate from signIn so the panel never offers an email
|
|
3104
|
+
// field for a vendor that has no use for one.
|
|
3105
|
+
...(vendorSignInMethod(entry.id) ? { vendorSignIn: vendorSignInMethod(entry.id).label } : {}),
|
|
3106
|
+
// What this machine spent with the vendor lately, from its own
|
|
3107
|
+
// session records. Absent when nothing was, which is also the
|
|
3108
|
+
// answer for a plan (no per-token price). Never for the account:
|
|
3109
|
+
// it is billed in credits and has a usage page of its own, and an
|
|
3110
|
+
// estimate beside the ledger would be a second, different number.
|
|
3111
|
+
...(spend30d > 0 && entry.id !== "official" ? { spend30d } : {}),
|
|
2862
3112
|
})),
|
|
2863
3113
|
});
|
|
2864
3114
|
}
|
|
@@ -2935,6 +3185,49 @@ export async function startWebServer(opts) {
|
|
|
2935
3185
|
return send(res, 400, { error: errorMessage(err) });
|
|
2936
3186
|
}
|
|
2937
3187
|
}
|
|
3188
|
+
case "POST /signin/vendor": {
|
|
3189
|
+
const body = await readJson(req);
|
|
3190
|
+
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
3191
|
+
const method = providerId ? vendorSignInMethod(providerId) : undefined;
|
|
3192
|
+
if (!method)
|
|
3193
|
+
return send(res, 400, { error: "that provider has no browser sign-in" });
|
|
3194
|
+
// The vendor redirects to a listener on this machine's loopback, so
|
|
3195
|
+
// the browser that signs in has to be on this machine too. A window
|
|
3196
|
+
// reached over a LAN address or a tunnel would send its browser to
|
|
3197
|
+
// its own 127.0.0.1, where nothing is listening.
|
|
3198
|
+
const host = req.headers.host ?? "127.0.0.1";
|
|
3199
|
+
if (!isLoopbackHost(host)) {
|
|
3200
|
+
return send(res, 400, {
|
|
3201
|
+
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`,
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
let flow;
|
|
3205
|
+
try {
|
|
3206
|
+
flow = await current.harness.auth.authorize(providerId, method.index, {});
|
|
3207
|
+
}
|
|
3208
|
+
catch (err) {
|
|
3209
|
+
return send(res, 400, { error: errorMessage(err) });
|
|
3210
|
+
}
|
|
3211
|
+
if (flow.method !== "auto" || !flow.url) {
|
|
3212
|
+
return send(res, 400, { error: `${providerId} signs in a way this page cannot drive` });
|
|
3213
|
+
}
|
|
3214
|
+
send(res, 200, { url: flow.url, instructions: flow.instructions });
|
|
3215
|
+
// The browser comes back to the vendor's redirect, not to this
|
|
3216
|
+
// request, so the rest happens here in the background and is told to
|
|
3217
|
+
// the page the way a website sign-in is: models_changed on success.
|
|
3218
|
+
const harness = current.harness;
|
|
3219
|
+
void flow
|
|
3220
|
+
.callback()
|
|
3221
|
+
.then(async (result) => {
|
|
3222
|
+
await harness.auth.complete(providerId, result);
|
|
3223
|
+
const found = await harness.refreshModels(providerId).catch(() => []);
|
|
3224
|
+
broadcast({ type: "models_changed", provider: providerId, discovered: found.length });
|
|
3225
|
+
})
|
|
3226
|
+
.catch((err) => {
|
|
3227
|
+
broadcast({ type: "notice", message: `signing in to ${providerId} failed: ${errorMessage(err)}` });
|
|
3228
|
+
});
|
|
3229
|
+
return;
|
|
3230
|
+
}
|
|
2938
3231
|
case "POST /signin/browser": {
|
|
2939
3232
|
const body = await readJson(req);
|
|
2940
3233
|
const providerId = typeof body.provider === "string" ? body.provider : "";
|
|
@@ -3178,6 +3471,12 @@ export async function startWebServer(opts) {
|
|
|
3178
3471
|
// next run: picking a model is a decision, not a per-session detail.
|
|
3179
3472
|
preferredModel = change.to.id;
|
|
3180
3473
|
void writePrefs({ model: preferredModel });
|
|
3474
|
+
// Kept with the conversation now, not at its next turn: picked and
|
|
3475
|
+
// closed before sending, it would otherwise reopen on the old one.
|
|
3476
|
+
// A conversation with nothing in it yet is not written, or every
|
|
3477
|
+
// pick in a new tab would leave an empty session in the list.
|
|
3478
|
+
if (runner.session.messages.length)
|
|
3479
|
+
void runner.session.persist().catch(() => { });
|
|
3181
3480
|
broadcast({
|
|
3182
3481
|
type: "model_changed",
|
|
3183
3482
|
session: runner.session.id,
|
|
@@ -3268,6 +3567,7 @@ export async function startWebServer(opts) {
|
|
|
3268
3567
|
return send(res, 200, {
|
|
3269
3568
|
available: Boolean(opts.computerUsePlugin),
|
|
3270
3569
|
takeover: prefs.computerTakeover === "never" ? "never" : "ask",
|
|
3570
|
+
enable: prefs.computerEnable ?? "ask",
|
|
3271
3571
|
blockedApps: prefs.computerBlockedApps ?? [],
|
|
3272
3572
|
});
|
|
3273
3573
|
}
|
|
@@ -3278,6 +3578,8 @@ export async function startWebServer(opts) {
|
|
|
3278
3578
|
const patch = {};
|
|
3279
3579
|
if (body.takeover === "ask" || body.takeover === "never")
|
|
3280
3580
|
patch.computerTakeover = body.takeover;
|
|
3581
|
+
if (body.enable === "ask" || body.enable === "auto" || body.enable === "off")
|
|
3582
|
+
patch.computerEnable = body.enable;
|
|
3281
3583
|
if (Array.isArray(body.blockedApps)) {
|
|
3282
3584
|
patch.computerBlockedApps = [...new Set(body.blockedApps.map((a) => String(a).trim()).filter(Boolean))];
|
|
3283
3585
|
}
|
|
@@ -3548,6 +3850,7 @@ export async function startWebServer(opts) {
|
|
|
3548
3850
|
if (!prompt && !ids.length && !references.length) {
|
|
3549
3851
|
return send(res, 400, { error: "prompt is required" });
|
|
3550
3852
|
}
|
|
3853
|
+
void markUsed();
|
|
3551
3854
|
// Goal mode: the text is the goal, and the turn keeps going until the
|
|
3552
3855
|
// model reports it achieved or blocked. A goal needs words to be one.
|
|
3553
3856
|
const goal = body.goal === true;
|
|
@@ -3605,6 +3908,54 @@ export async function startWebServer(opts) {
|
|
|
3605
3908
|
entry.settle(checked.result);
|
|
3606
3909
|
return send(res, 200, { ok: true });
|
|
3607
3910
|
}
|
|
3911
|
+
/*
|
|
3912
|
+
* Themes: what is in use, the proposals waiting on a card, and the four
|
|
3913
|
+
* things a person can do about them. Every change is pushed to open
|
|
3914
|
+
* pages as a `theme` event, so a second window follows the first.
|
|
3915
|
+
*/
|
|
3916
|
+
case "GET /theme": {
|
|
3917
|
+
return send(res, 200, { state: themes.state(), previews: [...themePreviews.values()].map(themePreviewView) });
|
|
3918
|
+
}
|
|
3919
|
+
case "POST /theme/apply": {
|
|
3920
|
+
const body = await readJson(req);
|
|
3921
|
+
if (typeof body.preview === "string") {
|
|
3922
|
+
const preview = themePreviews.get(body.preview);
|
|
3923
|
+
if (!preview)
|
|
3924
|
+
return send(res, 404, { error: "that preview is gone; ask for the theme again" });
|
|
3925
|
+
const state = await themes.apply(preview.theme.input);
|
|
3926
|
+
themePreviews.delete(preview.id);
|
|
3927
|
+
broadcast({ type: "theme_preview_closed", id: preview.id, session: preview.session, outcome: "applied" });
|
|
3928
|
+
themeChanged(state);
|
|
3929
|
+
return send(res, 200, { state });
|
|
3930
|
+
}
|
|
3931
|
+
if (typeof body.history === "string") {
|
|
3932
|
+
const state = await themes.applyHistory(body.history);
|
|
3933
|
+
if (!state)
|
|
3934
|
+
return send(res, 404, { error: "no such theme in history" });
|
|
3935
|
+
themeChanged(state);
|
|
3936
|
+
return send(res, 200, { state });
|
|
3937
|
+
}
|
|
3938
|
+
return send(res, 400, { error: "preview or history is required" });
|
|
3939
|
+
}
|
|
3940
|
+
case "POST /theme/discard": {
|
|
3941
|
+
const body = await readJson(req);
|
|
3942
|
+
const preview = typeof body.preview === "string" ? themePreviews.get(body.preview) : undefined;
|
|
3943
|
+
if (preview) {
|
|
3944
|
+
themePreviews.delete(preview.id);
|
|
3945
|
+
broadcast({ type: "theme_preview_closed", id: preview.id, session: preview.session, outcome: "discarded" });
|
|
3946
|
+
}
|
|
3947
|
+
return send(res, 200, { ok: true });
|
|
3948
|
+
}
|
|
3949
|
+
case "POST /theme/undo": {
|
|
3950
|
+
const state = await themes.undo();
|
|
3951
|
+
themeChanged(state);
|
|
3952
|
+
return send(res, 200, { state });
|
|
3953
|
+
}
|
|
3954
|
+
case "POST /theme/reset": {
|
|
3955
|
+
const state = await themes.reset();
|
|
3956
|
+
themeChanged(state);
|
|
3957
|
+
return send(res, 200, { state });
|
|
3958
|
+
}
|
|
3608
3959
|
case "POST /interrupt":
|
|
3609
3960
|
{
|
|
3610
3961
|
const body = await readJson(req);
|
|
@@ -3763,7 +4114,20 @@ export async function startWebServer(opts) {
|
|
|
3763
4114
|
const provider = current.harness.auth.get(providerId);
|
|
3764
4115
|
if (!provider)
|
|
3765
4116
|
return undefined;
|
|
3766
|
-
|
|
4117
|
+
// The account sign-ins only. A vendor's browser login is an OAuth method
|
|
4118
|
+
// too, but it has no email step and no website to go through, and
|
|
4119
|
+
// treating it as an account would put both in front of it.
|
|
4120
|
+
const index = provider.methods.findIndex((method) => method.type === "oauth" && !method.browser);
|
|
4121
|
+
if (index === -1)
|
|
4122
|
+
return undefined;
|
|
4123
|
+
return { index, label: provider.methods[index].label };
|
|
4124
|
+
}
|
|
4125
|
+
/** A vendor's own browser sign-in, when it has one. */
|
|
4126
|
+
function vendorSignInMethod(providerId) {
|
|
4127
|
+
const provider = current.harness.auth.get(providerId);
|
|
4128
|
+
if (!provider)
|
|
4129
|
+
return undefined;
|
|
4130
|
+
const index = provider.methods.findIndex((method) => method.type === "oauth" && method.browser === true);
|
|
3767
4131
|
if (index === -1)
|
|
3768
4132
|
return undefined;
|
|
3769
4133
|
return { index, label: provider.methods[index].label };
|
|
@@ -3832,7 +4196,15 @@ export async function startWebServer(opts) {
|
|
|
3832
4196
|
...list.filter((one) => one.source === "builtin"),
|
|
3833
4197
|
...list.filter((one) => one.source !== "builtin"),
|
|
3834
4198
|
];
|
|
3835
|
-
|
|
4199
|
+
/**
|
|
4200
|
+
* The external agents a message can be addressed to — `@claude …` sends
|
|
4201
|
+
* it to the user's own Claude Code instead of the model. Only those with
|
|
4202
|
+
* an ACP runtime answer to an @ at the start of a message.
|
|
4203
|
+
*/
|
|
4204
|
+
const agents = (harness.subagents?.list?.() ?? [])
|
|
4205
|
+
.filter((definition) => definition.runtime?.kind === "acp")
|
|
4206
|
+
.map((definition) => ({ name: definition.name, description: definition.description }));
|
|
4207
|
+
return { connectors, plugins: builtinFirst([...plugins, ...runtime]), skills: builtinFirst(skills), agents };
|
|
3836
4208
|
}
|
|
3837
4209
|
/**
|
|
3838
4210
|
* What the composer attached to one message, as a block above the prompt.
|
|
@@ -4102,9 +4474,20 @@ export async function startWebServer(opts) {
|
|
|
4102
4474
|
}
|
|
4103
4475
|
if (!text && !tools.length && !thinking && !images.length)
|
|
4104
4476
|
continue;
|
|
4477
|
+
const agentName = message.role === "assistant" ? message.meta?.["agent"] : undefined;
|
|
4478
|
+
const agentSession = message.meta?.["acpSession"];
|
|
4105
4479
|
entries.push({
|
|
4106
4480
|
role: message.role === "assistant" ? "assistant" : "user",
|
|
4107
4481
|
keepMessages: index,
|
|
4482
|
+
...(typeof agentName === "string"
|
|
4483
|
+
? {
|
|
4484
|
+
agent: {
|
|
4485
|
+
name: agentName,
|
|
4486
|
+
...(typeof agentSession?.id === "string" ? { session: agentSession.id } : {}),
|
|
4487
|
+
...(typeof agentSession?.cwd === "string" ? { cwd: agentSession.cwd } : {}),
|
|
4488
|
+
},
|
|
4489
|
+
}
|
|
4490
|
+
: {}),
|
|
4108
4491
|
...(images.length ? { images } : {}),
|
|
4109
4492
|
...(thinking ? { thinking } : {}),
|
|
4110
4493
|
...(text ? { text } : {}),
|
|
@@ -4119,11 +4502,7 @@ export async function startWebServer(opts) {
|
|
|
4119
4502
|
const target = entries.findLast((entry) => entry.role === "assistant" && (entry.keepMessages ?? 0) <= shot.at);
|
|
4120
4503
|
if (!target)
|
|
4121
4504
|
continue;
|
|
4122
|
-
(target.shots ??= []).push(
|
|
4123
|
-
app: shot.app,
|
|
4124
|
-
...(shot.title ? { title: shot.title } : {}),
|
|
4125
|
-
src: turnShotSrc(runner.session.id, shot.file),
|
|
4126
|
-
});
|
|
4505
|
+
(target.shots ??= []).push(turnShotView(runner.session.id, shot));
|
|
4127
4506
|
}
|
|
4128
4507
|
return entries;
|
|
4129
4508
|
}
|
|
@@ -4157,6 +4536,7 @@ export async function startWebServer(opts) {
|
|
|
4157
4536
|
...(opts.computerUsePlugin ? { computerUsePlugin: opts.computerUsePlugin } : {}),
|
|
4158
4537
|
}));
|
|
4159
4538
|
harnesses.set(project.path, adoptRoutineModel(harness));
|
|
4539
|
+
equipThemes(harness.tools);
|
|
4160
4540
|
harness.worktreeLauncher = launchWorktree;
|
|
4161
4541
|
harness.taskWaker = wakeForTasks;
|
|
4162
4542
|
watchConnections(harness);
|
|
@@ -4247,15 +4627,19 @@ export async function startWebServer(opts) {
|
|
|
4247
4627
|
const routines = new RoutineRunner({
|
|
4248
4628
|
defaultCwd: opts.session.cwd,
|
|
4249
4629
|
openHarnesses: () => harnesses.values(),
|
|
4250
|
-
createHarness: async (cwd) =>
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4630
|
+
createHarness: async (cwd) => {
|
|
4631
|
+
const harness = adoptRoutineModel(await (opts.openProject
|
|
4632
|
+
? opts.openProject(cwd)
|
|
4633
|
+
: Harness.create({
|
|
4634
|
+
cwd,
|
|
4635
|
+
permissionPrompt: async () => false,
|
|
4636
|
+
questionPrompt: async () => ({ outcome: "unavailable" }),
|
|
4637
|
+
...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
|
|
4638
|
+
...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
|
|
4639
|
+
})));
|
|
4640
|
+
pictureBrowserTurns(harness);
|
|
4641
|
+
return harness;
|
|
4642
|
+
},
|
|
4259
4643
|
onStart: (routine, meta) => {
|
|
4260
4644
|
if (meta.catchUp)
|
|
4261
4645
|
broadcast({ type: "notice", message: `routine ${routine.def.name} is catching up on a run it missed` });
|
|
@@ -4412,6 +4796,8 @@ export async function startWebServer(opts) {
|
|
|
4412
4796
|
const controller = new AbortController();
|
|
4413
4797
|
runner.running = controller;
|
|
4414
4798
|
const id = runner.session.id;
|
|
4799
|
+
suggesting.get(id)?.abort();
|
|
4800
|
+
suggesting.delete(id);
|
|
4415
4801
|
// Background tasks only ever start inside a turn, so this is the first
|
|
4416
4802
|
// moment worth watching them.
|
|
4417
4803
|
watchTasks(runner);
|
|
@@ -4544,8 +4930,8 @@ export async function startWebServer(opts) {
|
|
|
4544
4930
|
// Only after a turn that ended on its own. An interrupt means stop, and
|
|
4545
4931
|
// an error would usually meet the next prompt with the same error, so
|
|
4546
4932
|
// both leave the queue standing for the person to send or drop.
|
|
4547
|
-
if (!controller.signal.aborted && !failed)
|
|
4548
|
-
|
|
4933
|
+
if (!controller.signal.aborted && !failed && !drainQueue(runner))
|
|
4934
|
+
suggest(runner);
|
|
4549
4935
|
}
|
|
4550
4936
|
}
|
|
4551
4937
|
function stream(res) {
|
|
@@ -4626,6 +5012,40 @@ export async function startWebServer(opts) {
|
|
|
4626
5012
|
* not know about until the turn lands, and the page asking is the page that
|
|
4627
5013
|
* spent it.
|
|
4628
5014
|
*/
|
|
5015
|
+
/**
|
|
5016
|
+
* Each provider beside what this machine spent with it in the last thirty
|
|
5017
|
+
* days, for the provider cards.
|
|
5018
|
+
*
|
|
5019
|
+
* An estimate, and labelled as one: a session's cost is put down to the
|
|
5020
|
+
* model it used last, so a session that switched vendors part way through
|
|
5021
|
+
* is counted against the second. Read from the same records the usage page
|
|
5022
|
+
* reads — the few hundred most recent sessions — and kept for a minute,
|
|
5023
|
+
* because the models panel asks every time it is painted.
|
|
5024
|
+
*/
|
|
5025
|
+
let spendCache;
|
|
5026
|
+
async function withSpend(entries) {
|
|
5027
|
+
if (!spendCache || Date.now() - spendCache.at > 60_000) {
|
|
5028
|
+
const byProvider = new Map();
|
|
5029
|
+
try {
|
|
5030
|
+
const providerOf = new Map(current.harness.listModels().map((model) => [model.id, model.provider]));
|
|
5031
|
+
const since = Date.now() - 30 * 86_400_000;
|
|
5032
|
+
const { rows } = await usageRows(USAGE_SESSION_SCAN);
|
|
5033
|
+
for (const row of rows.values()) {
|
|
5034
|
+
if (row.updatedAt < since || !(row.costUsd > 0))
|
|
5035
|
+
continue;
|
|
5036
|
+
const provider = providerOf.get(row.model) ?? row.model.split("/")[0];
|
|
5037
|
+
if (provider)
|
|
5038
|
+
byProvider.set(provider, (byProvider.get(provider) ?? 0) + row.costUsd);
|
|
5039
|
+
}
|
|
5040
|
+
}
|
|
5041
|
+
catch {
|
|
5042
|
+
// A figure that cannot be worked out is left off, not shown as zero.
|
|
5043
|
+
}
|
|
5044
|
+
spendCache = { at: Date.now(), byProvider };
|
|
5045
|
+
}
|
|
5046
|
+
const spent = spendCache.byProvider;
|
|
5047
|
+
return entries.map((entry) => ({ entry, spend30d: spent.get(entry.id) ?? 0 }));
|
|
5048
|
+
}
|
|
4629
5049
|
async function usageRows(scan) {
|
|
4630
5050
|
const stored = await current.harness.listSessions(scan);
|
|
4631
5051
|
const rows = new Map();
|
|
@@ -4800,6 +5220,7 @@ export async function startWebServer(opts) {
|
|
|
4800
5220
|
refreshStale();
|
|
4801
5221
|
const staleTimer = setInterval(refreshStale, 6 * 60 * 60 * 1000);
|
|
4802
5222
|
staleTimer.unref?.();
|
|
5223
|
+
const stopHeartbeat = startHeartbeat();
|
|
4803
5224
|
if (opts.routines)
|
|
4804
5225
|
routines.start();
|
|
4805
5226
|
void applyKeepAwake().catch(() => { });
|
|
@@ -4902,6 +5323,8 @@ export async function startWebServer(opts) {
|
|
|
4902
5323
|
},
|
|
4903
5324
|
async close() {
|
|
4904
5325
|
clearInterval(staleTimer);
|
|
5326
|
+
stopHeartbeat();
|
|
5327
|
+
await flushAnalytics();
|
|
4905
5328
|
presence?.close();
|
|
4906
5329
|
closed = true;
|
|
4907
5330
|
await remoteChange.catch(() => { });
|