@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.
Files changed (70) hide show
  1. package/dist/analytics.d.ts +32 -29
  2. package/dist/analytics.js +225 -278
  3. package/dist/analytics.js.map +1 -1
  4. package/dist/i18n.js +152 -8
  5. package/dist/i18n.js.map +1 -1
  6. package/dist/icons.js +1 -0
  7. package/dist/icons.js.map +1 -1
  8. package/dist/model-order.js +1 -0
  9. package/dist/model-order.js.map +1 -1
  10. package/dist/page/client/account.js +140 -4
  11. package/dist/page/client/account.js.map +1 -1
  12. package/dist/page/client/base.js +40 -16
  13. package/dist/page/client/base.js.map +1 -1
  14. package/dist/page/client/browser.js +1 -1
  15. package/dist/page/client/composer.js +111 -7
  16. package/dist/page/client/composer.js.map +1 -1
  17. package/dist/page/client/dialogs.js +75 -0
  18. package/dist/page/client/dialogs.js.map +1 -1
  19. package/dist/page/client/events.js +25 -2
  20. package/dist/page/client/events.js.map +1 -1
  21. package/dist/page/client/index.js +4 -0
  22. package/dist/page/client/index.js.map +1 -1
  23. package/dist/page/client/panels.js +4 -4
  24. package/dist/page/client/ripple.d.ts +13 -0
  25. package/dist/page/client/ripple.js +247 -0
  26. package/dist/page/client/ripple.js.map +1 -0
  27. package/dist/page/client/routines.js +173 -41
  28. package/dist/page/client/routines.js.map +1 -1
  29. package/dist/page/client/shell.js +200 -54
  30. package/dist/page/client/shell.js.map +1 -1
  31. package/dist/page/client/state.js +7 -2
  32. package/dist/page/client/state.js.map +1 -1
  33. package/dist/page/client/terminal.js +34 -7
  34. package/dist/page/client/terminal.js.map +1 -1
  35. package/dist/page/client/theme.d.ts +17 -0
  36. package/dist/page/client/theme.js +365 -0
  37. package/dist/page/client/theme.js.map +1 -0
  38. package/dist/page/client/transcript.js +63 -7
  39. package/dist/page/client/transcript.js.map +1 -1
  40. package/dist/page/style.js +183 -22
  41. package/dist/page/style.js.map +1 -1
  42. package/dist/page.js +4 -5
  43. package/dist/page.js.map +1 -1
  44. package/dist/prefs.d.ts +17 -0
  45. package/dist/prefs.js +5 -0
  46. package/dist/prefs.js.map +1 -1
  47. package/dist/server.js +465 -42
  48. package/dist/server.js.map +1 -1
  49. package/dist/suggest.d.ts +42 -0
  50. package/dist/suggest.js +215 -0
  51. package/dist/suggest.js.map +1 -0
  52. package/dist/terminal.d.ts +28 -2
  53. package/dist/terminal.js +45 -3
  54. package/dist/terminal.js.map +1 -1
  55. package/dist/theme-assets.d.ts +39 -0
  56. package/dist/theme-assets.js +109 -0
  57. package/dist/theme-assets.js.map +1 -0
  58. package/dist/theme-store.d.ts +50 -0
  59. package/dist/theme-store.js +124 -0
  60. package/dist/theme-store.js.map +1 -0
  61. package/dist/theme-tools.d.ts +25 -0
  62. package/dist/theme-tools.js +243 -0
  63. package/dist/theme-tools.js.map +1 -0
  64. package/dist/turn-shots.d.ts +18 -2
  65. package/dist/turn-shots.js +10 -1
  66. package/dist/turn-shots.js.map +1 -1
  67. package/dist/user-theme.d.ts +143 -0
  68. package/dist/user-theme.js +510 -0
  69. package/dist/user-theme.js.map +1 -0
  70. 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, turnShotSrc } from "./turn-shots.js";
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, releaseDateOf, releaseDates } from "@oharness/engine/providers/models-dev.ts";
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(releaseDates));
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 known = releaseDateOf(dates, model.model);
207
- return known === undefined || known === model.releasedAt ? model : { ...model, releasedAt: known };
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
- void saveTurnShots(event.sessionId, at, event.shots)
284
- .then((saved) => {
285
- broadcast({
286
- type: "turn_shots",
287
- session: event.sessionId,
288
- shots: saved.map((shot) => ({ app: shot.app, title: shot.title, src: turnShotSrc(event.sessionId, shot.file) })),
289
- });
290
- })
291
- .catch(() => { });
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
- res.end(PAGE);
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 result = await terminals.open(root, { cols: body.cols, rows: body.rows }, replace);
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
- if (anyRunning())
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
- const deleted = await current.harness.deleteSessionsIn(dir);
1937
- for (const id of deleted)
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
- const index = provider.methods.findIndex((method) => method.type === "oauth");
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
- return { connectors, plugins: builtinFirst([...plugins, ...runtime]), skills: builtinFirst(skills) };
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) => adoptRoutineModel(await (opts.openProject
4251
- ? opts.openProject(cwd)
4252
- : Harness.create({
4253
- cwd,
4254
- permissionPrompt: async () => false,
4255
- questionPrompt: async () => ({ outcome: "unavailable" }),
4256
- ...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
4257
- ...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
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
- drainQueue(runner);
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(() => { });