@oharness/server 0.9.3 → 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 (93) 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 +500 -64
  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/main.js +4 -0
  9. package/dist/main.js.map +1 -1
  10. package/dist/model-order.js +1 -0
  11. package/dist/model-order.js.map +1 -1
  12. package/dist/page/client/account.js +570 -27
  13. package/dist/page/client/account.js.map +1 -1
  14. package/dist/page/client/base.js +55 -12
  15. package/dist/page/client/base.js.map +1 -1
  16. package/dist/page/client/browser.js +1 -1
  17. package/dist/page/client/composer.js +802 -59
  18. package/dist/page/client/composer.js.map +1 -1
  19. package/dist/page/client/computer.d.ts +27 -0
  20. package/dist/page/client/computer.js +358 -0
  21. package/dist/page/client/computer.js.map +1 -0
  22. package/dist/page/client/dialogs.js +168 -6
  23. package/dist/page/client/dialogs.js.map +1 -1
  24. package/dist/page/client/events.js +119 -9
  25. package/dist/page/client/events.js.map +1 -1
  26. package/dist/page/client/files.js +86 -1
  27. package/dist/page/client/files.js.map +1 -1
  28. package/dist/page/client/index.js +8 -0
  29. package/dist/page/client/index.js.map +1 -1
  30. package/dist/page/client/panels.js +14 -10
  31. package/dist/page/client/panels.js.map +1 -1
  32. package/dist/page/client/ripple.d.ts +13 -0
  33. package/dist/page/client/ripple.js +247 -0
  34. package/dist/page/client/ripple.js.map +1 -0
  35. package/dist/page/client/routines.js +432 -45
  36. package/dist/page/client/routines.js.map +1 -1
  37. package/dist/page/client/shell.js +386 -104
  38. package/dist/page/client/shell.js.map +1 -1
  39. package/dist/page/client/state.js +278 -19
  40. package/dist/page/client/state.js.map +1 -1
  41. package/dist/page/client/tasks.d.ts +8 -0
  42. package/dist/page/client/tasks.js +293 -0
  43. package/dist/page/client/tasks.js.map +1 -0
  44. package/dist/page/client/terminal.js +34 -7
  45. package/dist/page/client/terminal.js.map +1 -1
  46. package/dist/page/client/theme.d.ts +17 -0
  47. package/dist/page/client/theme.js +365 -0
  48. package/dist/page/client/theme.js.map +1 -0
  49. package/dist/page/client/transcript.js +196 -8
  50. package/dist/page/client/transcript.js.map +1 -1
  51. package/dist/page/client/usage.js +1 -11
  52. package/dist/page/client/usage.js.map +1 -1
  53. package/dist/page/style.js +745 -76
  54. package/dist/page/style.js.map +1 -1
  55. package/dist/page.js +94 -16
  56. package/dist/page.js.map +1 -1
  57. package/dist/prefs.d.ts +31 -0
  58. package/dist/prefs.js +13 -1
  59. package/dist/prefs.js.map +1 -1
  60. package/dist/remote-crypto.d.ts +78 -0
  61. package/dist/remote-crypto.js +216 -0
  62. package/dist/remote-crypto.js.map +1 -0
  63. package/dist/remote.d.ts +103 -0
  64. package/dist/remote.js +498 -0
  65. package/dist/remote.js.map +1 -0
  66. package/dist/server.d.ts +70 -0
  67. package/dist/server.js +1738 -73
  68. package/dist/server.js.map +1 -1
  69. package/dist/starters.d.ts +92 -0
  70. package/dist/starters.js +213 -0
  71. package/dist/starters.js.map +1 -0
  72. package/dist/suggest.d.ts +42 -0
  73. package/dist/suggest.js +215 -0
  74. package/dist/suggest.js.map +1 -0
  75. package/dist/terminal.d.ts +28 -2
  76. package/dist/terminal.js +45 -3
  77. package/dist/terminal.js.map +1 -1
  78. package/dist/theme-assets.d.ts +39 -0
  79. package/dist/theme-assets.js +109 -0
  80. package/dist/theme-assets.js.map +1 -0
  81. package/dist/theme-store.d.ts +50 -0
  82. package/dist/theme-store.js +124 -0
  83. package/dist/theme-store.js.map +1 -0
  84. package/dist/theme-tools.d.ts +25 -0
  85. package/dist/theme-tools.js +243 -0
  86. package/dist/theme-tools.js.map +1 -0
  87. package/dist/turn-shots.d.ts +43 -0
  88. package/dist/turn-shots.js +80 -0
  89. package/dist/turn-shots.js.map +1 -0
  90. package/dist/user-theme.d.ts +143 -0
  91. package/dist/user-theme.js +510 -0
  92. package/dist/user-theme.js.map +1 -0
  93. package/package.json +2 -2
package/dist/server.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createServer } from "node:http";
2
2
  import { randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import { createReadStream } from "node:fs";
4
- import { readFile, stat } from "node:fs/promises";
4
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { Harness } from "@oharness/engine/harness.ts";
@@ -14,7 +14,10 @@ import { isFree } from "@oharness/engine/providers/types.ts";
14
14
  import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
15
15
  import { canDisableThinking, effectiveEffort, isEffortChoice, thinkingFor, } from "@oharness/engine/providers/types.ts";
16
16
  import { isReadable } from "@oharness/engine/agent/workspace.ts";
17
+ import { BRANCH_TYPES, createWorktree, findWorktree, finishWorktree, listWorktrees, previewWorktree, worktreeStatus, } from "@oharness/engine/worktree/index.ts";
18
+ import { within as insideAny } from "@oharness/engine/paths.ts";
17
19
  import { visibleText } from "@oharness/engine/core/types.ts";
20
+ import { previewJson } from "@oharness/engine/util/json.ts";
18
21
  import { errorMessage } from "@oharness/engine/core/errors.ts";
19
22
  import { officialBaseUrl, officialTeam } from "@oharness/engine/auth/providers/official.ts";
20
23
  import { loadUserConfig, updateUserConfig } from "@oharness/engine/config.ts";
@@ -22,16 +25,19 @@ import { EXTENSION_IDS, EXTENSION_INSTALL_URL } from "@oharness/engine/browser/h
22
25
  import { announcePresence } from "@oharness/engine/browser/presence.ts";
23
26
  import { extensionHostReachable } from "@oharness/engine/browser/socket.ts";
24
27
  import { RoutineRunner } from "@oharness/engine/schedule/runner.ts";
28
+ import { activeRun, onRunActivity, onRunsChanged } from "@oharness/engine/schedule/scheduler.ts";
25
29
  import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
26
30
  import { ChangeLedger } from "./changes.js";
31
+ import { projectPath } from "@oharness/engine/paths.ts";
32
+ import { listTurnShots, removeTurnShots, saveTurnShots, turnShotPath, turnShotView } from "./turn-shots.js";
27
33
  import { addProject, ensureProject, listProjects, removeProject, renameProject } from "./projects.js";
28
34
  import { readPrefs, writePrefs } from "./prefs.js";
29
35
  import { sortModelsForDisplay } from "./model-order.js";
30
- import { loadModelsDev, releaseDateOf, releaseDates } from "@oharness/engine/providers/models-dev.ts";
36
+ import { loadModelsDev, orderingDate, releaseDates } from "@oharness/engine/providers/models-dev.ts";
31
37
  import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
32
- import { TerminalHub, terminalAsset } from "./terminal.js";
38
+ import { isAgentSessionId, isLauncher, TerminalHub, terminalAsset } from "./terminal.js";
33
39
  import { PREVIEW_MEDIA, browseFiles, previewFile, resolveFile, searchFiles, within } from "./files.js";
34
- import { analyticsState } from "./analytics.js";
40
+ import { analyticsState, cleanEvent, flushAnalytics, markUsed, setConsent, startHeartbeat, track } from "./analytics.js";
35
41
  import { PAGE } from "./page.js";
36
42
  import { browserState, parseInputEvent, streamFrames } from "./browser-pane.js";
37
43
  import { artifactDocument, sendArtifact } from "./artifacts.js";
@@ -39,6 +45,13 @@ import { sendProjectExport } from "./project-export.js";
39
45
  import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
40
46
  import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
41
47
  import { accountUsage } from "./account-usage.js";
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";
54
+ import { pairedDevices, remoteMachine, revokeDevice, startRemote, } from "./remote.js";
42
55
  /**
43
56
  * How many stored sessions the usage page counts.
44
57
  *
@@ -189,14 +202,18 @@ function tokensOf(usage) {
189
202
  *
190
203
  * Its date wins over a model's own: a listing's `created` is sometimes the
191
204
  * time it was listed, or a constant, and the snapshot knows most of the models
192
- * 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`.
193
207
  */
194
208
  let knownDates;
195
209
  async function withReleaseDates(models) {
196
- 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
+ }));
197
214
  return models.map((model) => {
198
- const known = releaseDateOf(dates, model.model);
199
- 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 };
200
217
  });
201
218
  }
202
219
  export async function startWebServer(opts) {
@@ -214,8 +231,181 @@ export async function startWebServer(opts) {
214
231
  const artifactToken = randomUUID();
215
232
  const host = opts.host ?? "127.0.0.1";
216
233
  const clients = new Set();
234
+ // Remote control's half of this page, filled in once the server is listening.
235
+ let pairingCards = () => [];
236
+ let answerPairing = () => false;
237
+ let remoteView = async () => ({ available: false, on: false, state: "off" });
238
+ let setRemote = async () => { };
239
+ let forgetPhone = () => { };
240
+ /**
241
+ * Whether the computer is held awake: by the setting for it, or because a
242
+ * phone may be reaching for it. A Mac asleep is a Mac the phone cannot find.
243
+ */
244
+ let remoteRunning = () => false;
245
+ const applyKeepAwake = async () => {
246
+ if (!opts.onKeepAwake)
247
+ return;
248
+ const prefs = await readPrefs().catch(() => ({}));
249
+ opts.onKeepAwake(prefs.keepAwake === true || remoteRunning());
250
+ };
217
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 });
218
304
  const runners = new Map();
305
+ /** Agents whose background tasks are being watched; see watchTasks. */
306
+ const watchedTasks = new WeakSet();
307
+ // Written in the background; the page on that project swaps them in.
308
+ const starters = createStarters((cwd, locale, written) => {
309
+ broadcast({ type: "starters", cwd, locale, starters: written });
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
+ }
336
+ /**
337
+ * What the OS lets Computer Use do, read from the helper when a conversation
338
+ * switches it on and whenever the permission card asks again. Cached because
339
+ * `state()` is synchronous and sent on every change; the grants can be
340
+ * revoked at any time, and the tools report that themselves when it happens.
341
+ */
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(() => { });
350
+ /**
351
+ * The live view: what each conversation is doing on the computer, pushed as
352
+ * it happens. The last event per conversation is kept, so a pane opened
353
+ * later, or a reloaded page, starts on the window being worked in.
354
+ */
355
+ const computerLast = new Map();
356
+ /**
357
+ * Where each conversation's last Computer use turn ended, in messages. Read
358
+ * on the idle event, which the engine sends synchronously as the turn ends:
359
+ * the pictures arrive a moment later, by which time a queued message may
360
+ * already have started the next turn.
361
+ */
362
+ const computerTurnEnd = new Map();
363
+ opts.computerUsePlugin?.runtime.onAction((event) => {
364
+ if (event.kind === "idle") {
365
+ const runner = runners.get(event.sessionId);
366
+ if (runner)
367
+ computerTurnEnd.set(event.sessionId, runner.session.messages.length);
368
+ }
369
+ computerLast.set(event.sessionId, event);
370
+ broadcast({ type: "computer", session: event.sessionId, event });
371
+ });
372
+ opts.computerUsePlugin?.runtime.onSnapshot((event) => {
373
+ const at = computerTurnEnd.get(event.sessionId);
374
+ if (at === undefined || !turnShotsOn)
375
+ return;
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
+ });
389
+ });
390
+ async function refreshComputer(plugin) {
391
+ // Grants first and on their own: they come from the host app, and a
392
+ // helper that will not start must not leave the card showing old ones.
393
+ try {
394
+ computerStatus.permissions = await plugin.runtime.permissions();
395
+ }
396
+ catch (err) {
397
+ computerStatus.error = errorMessage(err);
398
+ }
399
+ try {
400
+ const info = await plugin.runtime.info();
401
+ computerStatus.platform = info.platform;
402
+ computerStatus.background = info.background;
403
+ delete computerStatus.error;
404
+ }
405
+ catch (err) {
406
+ computerStatus.error = errorMessage(err);
407
+ }
408
+ }
219
409
  const anyRunning = () => [...runners.values()].some((runner) => Boolean(runner.running));
220
410
  const makeRunner = (session, harness) => {
221
411
  const ledger = new ChangeLedger(session.cwd);
@@ -237,6 +427,7 @@ export async function startWebServer(opts) {
237
427
  // browser is still the same one bound by Harness.agent(session).
238
428
  initial.agent = opts.agent;
239
429
  runners.set(opts.session.id, initial);
430
+ adoptTasks(initial);
240
431
  let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
241
432
  /**
242
433
  * Sign-ins waiting for their emailed code.
@@ -266,6 +457,40 @@ export async function startWebServer(opts) {
266
457
  * because rebuilding one reconnects every MCP server.
267
458
  */
268
459
  const harnesses = new Map([[opts.session.cwd, opts.harness]]);
460
+ /**
461
+ * The mode new conversations start in: the last one chosen, with two
462
+ * exceptions. Bypass permissions approves everything without asking, and a
463
+ * mode that strong is chosen for a conversation, not inherited by every one
464
+ * after it — a new conversation gets Auto instead. Plan is a step within a
465
+ * task, so choosing it leaves the default where it was.
466
+ *
467
+ * Written to the user config, where new conversations already take their
468
+ * mode from, and to each harness this server holds, which read that config
469
+ * once when they were built.
470
+ */
471
+ async function rememberMode(mode) {
472
+ if (mode === "plan")
473
+ return;
474
+ const next = mode === "bypassPermissions" ? "auto" : mode;
475
+ for (const harness of new Set([current.harness, ...harnesses.values()])) {
476
+ harness.config.permissionMode = next;
477
+ }
478
+ await updateUserConfig((config) => {
479
+ const { permissionMode: _was, ...rest } = config;
480
+ return next === "default" ? rest : { ...rest, permissionMode: next };
481
+ }).catch(() => { });
482
+ }
483
+ /**
484
+ * Routines that name no model — or one that is gone — run on the one picked
485
+ * in the composer, when this harness has it. Without this they took the
486
+ * first-configured model, which can be a local server that is not running
487
+ * or a gated one the account never unlocked.
488
+ */
489
+ const adoptRoutineModel = (harness) => {
490
+ harness.routineModel = () => preferredModel && harness.listModels().some((model) => model.id === preferredModel) ? preferredModel : undefined;
491
+ return harness;
492
+ };
493
+ adoptRoutineModel(opts.harness);
269
494
  void ensureProject(opts.session.cwd);
270
495
  /**
271
496
  * The model the user last picked, carried into every session started here.
@@ -302,6 +527,14 @@ export async function startWebServer(opts) {
302
527
  * reclaimed by `sweepAttachments`.
303
528
  */
304
529
  const attachments = new Map();
530
+ /**
531
+ * Files the person chose in the system file dialog, which the page may show
532
+ * a picture of — and only those. Outside the project the page has no other
533
+ * way to see a file (/files/raw stays inside it), and a path the person
534
+ * picked themselves, a moment ago, in a dialog of their own OS is the one
535
+ * kind it is safe to open on their behalf.
536
+ */
537
+ const pickedPaths = new Set();
305
538
  /**
306
539
  * Carry a chosen file whose bytes are the only way it can mean anything.
307
540
  *
@@ -322,7 +555,8 @@ export async function startWebServer(opts) {
322
555
  // Images by extension, and everything else left unnamed: `buildMessage`
323
556
  // decides text from bytes rather than from a type, so guessing one for
324
557
  // a .log would add nothing and could be wrong.
325
- const mediaType = PICKED_IMAGE_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
558
+ const ext = path.extname(target).toLowerCase();
559
+ const mediaType = PICKED_IMAGE_TYPES[ext] ?? PICKED_VIDEO_TYPES[ext] ?? "application/octet-stream";
326
560
  const id = randomUUID();
327
561
  attachments.set(id, { name, mediaType, data, at: Date.now() });
328
562
  sweepAttachments();
@@ -374,8 +608,38 @@ export async function startWebServer(opts) {
374
608
  connectionSubscriptions.push(harness.cloudConnectors.subscribe((card) => {
375
609
  broadcast({ type: "connection_card", session: card.session, card });
376
610
  }));
611
+ pictureBrowserTurns(harness);
377
612
  }
378
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
+ }
379
643
  /**
380
644
  * Take a waiting interaction off every screen.
381
645
  *
@@ -464,7 +728,14 @@ export async function startWebServer(opts) {
464
728
  // nothing else: a page that drives a shell is not one to embed.
465
729
  "content-security-policy": `frame-ancestors 'self' ${EXTENSION_IDS.map((id) => `chrome-extension://${id}`).join(" ")}`,
466
730
  });
467
- 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>`));
468
739
  return;
469
740
  }
470
741
  // The other exception, and for the same kind of reason: this one is
@@ -484,6 +755,21 @@ export async function startWebServer(opts) {
484
755
  }
485
756
  return serveArtifactBody(url, res);
486
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
+ }
487
773
  if (!authorized(url, req))
488
774
  return send(res, 401, { error: "missing or bad token" });
489
775
  switch (`${req.method} ${url.pathname}`) {
@@ -742,6 +1028,25 @@ export async function startWebServer(opts) {
742
1028
  }
743
1029
  case "GET /events":
744
1030
  return stream(res);
1031
+ /**
1032
+ * What the empty state offers, for the session's project: the fixed
1033
+ * three while the project has no memory, three written from its notes
1034
+ * once it does. Asked separately from /state because writing them can
1035
+ * take the small model a few seconds, and the page must not wait on it.
1036
+ */
1037
+ case "GET /starters": {
1038
+ // Only a project already open: asking must not open one, which would
1039
+ // connect its MCP servers and make it the one the next launch opens.
1040
+ const cwd = url.searchParams.get("cwd") || current.session.cwd;
1041
+ const harness = cwd === current.session.cwd ? current.harness : harnesses.get(cwd);
1042
+ if (!harness)
1043
+ return send(res, 200, { kind: "fixed" });
1044
+ const locale = url.searchParams.get("locale") ?? "en";
1045
+ // The model picked in the composer is one the user has reached; the
1046
+ // first-configured one may sit behind a gate they never passed.
1047
+ const picked = (await readPrefs()).model;
1048
+ return send(res, 200, await starters(harness, cwd, locale, picked));
1049
+ }
745
1050
  case "GET /state": {
746
1051
  const runner = runnerFor(url.searchParams.get("session"));
747
1052
  if (!runner)
@@ -757,13 +1062,41 @@ export async function startWebServer(opts) {
757
1062
  * the page, and without this its chip would be the only one in the row
758
1063
  * with no picture on it.
759
1064
  */
1065
+ case "GET /pick/preview": {
1066
+ // A picture of a file picked in the dialog: an image, or a clip the
1067
+ // tray plays muted. Nothing else, and nothing not picked.
1068
+ const target = path.resolve(url.searchParams.get("path") ?? "");
1069
+ const type = PICKED_IMAGE_TYPES[path.extname(target).toLowerCase()] ?? PICKED_VIDEO_TYPES[path.extname(target).toLowerCase()];
1070
+ if (!pickedPaths.has(target) || !type)
1071
+ return send(res, 404, { error: "not a picked picture" });
1072
+ let size = 0;
1073
+ try {
1074
+ size = (await stat(target)).size;
1075
+ }
1076
+ catch {
1077
+ return send(res, 404, { error: "not a picked picture" });
1078
+ }
1079
+ res.writeHead(200, {
1080
+ "content-type": type,
1081
+ "content-length": size,
1082
+ "x-content-type-options": "nosniff",
1083
+ "content-security-policy": "default-src 'none'; sandbox",
1084
+ "cache-control": "private, no-store",
1085
+ });
1086
+ const stream = createReadStream(target);
1087
+ stream.on("error", () => res.destroy());
1088
+ res.on("close", () => stream.destroy());
1089
+ stream.pipe(res);
1090
+ return;
1091
+ }
760
1092
  case "GET /upload/preview": {
761
1093
  const staged = attachments.get(url.searchParams.get("id") ?? "");
762
1094
  if (!staged)
763
1095
  return send(res, 404, { error: "no such upload" });
764
1096
  // The same allowlist as the transcript's images, for the same reason:
765
- // this is served same-origin to a page holding the session token.
766
- if (!VIEWABLE_IMAGE_TYPES.has(staged.mediaType)) {
1097
+ // this is served same-origin to a page holding the session token —
1098
+ // and the clips the tray can play, which no browser runs as a page.
1099
+ if (!VIEWABLE_IMAGE_TYPES.has(staged.mediaType) && !Object.values(PICKED_VIDEO_TYPES).includes(staged.mediaType)) {
767
1100
  return send(res, 415, { error: `cannot display ${staged.mediaType}` });
768
1101
  }
769
1102
  res.writeHead(200, {
@@ -795,7 +1128,13 @@ export async function startWebServer(opts) {
795
1128
  const index = Number(url.searchParams.get("message"));
796
1129
  const position = Number(url.searchParams.get("block"));
797
1130
  const message = Number.isInteger(index) ? runner.session.messages[index - 1] : undefined;
798
- const block = Number.isInteger(position) ? message?.content[position] : undefined;
1131
+ let block = Number.isInteger(position) ? message?.content[position] : undefined;
1132
+ // A tool's own image, such as a screenshot, sits one level down: inside
1133
+ // the tool_result, at `part`.
1134
+ if (block?.type === "tool_result" && url.searchParams.has("part")) {
1135
+ const part = Number(url.searchParams.get("part"));
1136
+ block = Number.isInteger(part) ? block.content[part] : undefined;
1137
+ }
799
1138
  if (!block || block.type !== "image")
800
1139
  return send(res, 404, { error: "no such image" });
801
1140
  // The media type on a block is whatever the upload claimed, and this
@@ -819,6 +1158,28 @@ export async function startWebServer(opts) {
819
1158
  res.end(data);
820
1159
  return;
821
1160
  }
1161
+ case "GET /turn-shot": {
1162
+ const file = turnShotPath(String(url.searchParams.get("session") ?? ""), String(url.searchParams.get("file") ?? ""));
1163
+ if (!file)
1164
+ return send(res, 404, { error: "no such picture" });
1165
+ let data;
1166
+ try {
1167
+ data = await readFile(file);
1168
+ }
1169
+ catch {
1170
+ return send(res, 404, { error: "no such picture" });
1171
+ }
1172
+ res.writeHead(200, {
1173
+ "content-type": "image/jpeg",
1174
+ "content-length": data.byteLength,
1175
+ "x-content-type-options": "nosniff",
1176
+ "content-security-policy": "default-src 'none'; sandbox",
1177
+ // Each name is written once and never rewritten.
1178
+ "cache-control": "private, max-age=31536000, immutable",
1179
+ });
1180
+ res.end(data);
1181
+ return;
1182
+ }
822
1183
  case "GET /artifacts": {
823
1184
  const runner = runnerFor(url.searchParams.get("session"));
824
1185
  if (!runner)
@@ -1002,6 +1363,52 @@ export async function startWebServer(opts) {
1002
1363
  teams: await accountTeams({ accountToken: await current.harness.accountToken() }),
1003
1364
  });
1004
1365
  }
1366
+ // Background tasks: the list, one task's conversation, and the two
1367
+ // things a person can do to one — stop it, or tell it something.
1368
+ case "GET /tasks": {
1369
+ const runner = runnerFor(url.searchParams.get("session"));
1370
+ if (!runner)
1371
+ return send(res, 404, { error: "no such session" });
1372
+ return send(res, 200, { tasks: runner.agent.backgroundTasks?.list() ?? [] });
1373
+ }
1374
+ case "GET /task": {
1375
+ const runner = runnerFor(url.searchParams.get("session"));
1376
+ if (!runner)
1377
+ return send(res, 404, { error: "no such session" });
1378
+ const id = url.searchParams.get("id") ?? "";
1379
+ const task = runner.agent.backgroundTasks?.get(id);
1380
+ if (!task)
1381
+ return send(res, 404, { error: "no such task" });
1382
+ return send(res, 200, { task, transcript: taskTranscript(runner.agent.backgroundTranscript(id) ?? []) });
1383
+ }
1384
+ case "POST /task": {
1385
+ const body = await readJson(req);
1386
+ const runner = runnerFor(body.session);
1387
+ const tasks = runner?.agent.backgroundTasks;
1388
+ if (!runner || !tasks)
1389
+ return send(res, 404, { error: "no such session" });
1390
+ const id = String(body.id ?? "");
1391
+ if (!tasks.get(id))
1392
+ return send(res, 404, { error: "no such task" });
1393
+ if (body.action === "stop")
1394
+ return send(res, 200, { stopped: tasks.stop(id), task: tasks.get(id) });
1395
+ if (body.action === "dismiss")
1396
+ return send(res, 200, { dismissed: tasks.dismiss(id), task: tasks.get(id) });
1397
+ if (body.action === "message") {
1398
+ const text = String(body.text ?? "").trim();
1399
+ if (!text)
1400
+ return send(res, 400, { error: "text is required" });
1401
+ // Said so, because the subagent's "user" is otherwise its parent
1402
+ // agent: this one came from the person, past the parent.
1403
+ const sent = tasks.send(id, `[From the person, directly:]\n${text}`);
1404
+ if (sent === "stopped")
1405
+ return send(res, 409, { error: "this task was stopped and cannot be continued" });
1406
+ if (sent === "busy")
1407
+ return send(res, 409, { error: "the task is between steps; try again in a moment" });
1408
+ return send(res, 200, { sent, task: tasks.get(id) });
1409
+ }
1410
+ return send(res, 400, { error: "action must be stop, dismiss or message" });
1411
+ }
1005
1412
  case "GET /history":
1006
1413
  {
1007
1414
  // Opening the page mid-session should show what has happened, not an
@@ -1017,11 +1424,16 @@ export async function startWebServer(opts) {
1017
1424
  const pinned = new Set(prefs.pinnedProjects ?? []);
1018
1425
  return send(res, 200, {
1019
1426
  order: prefs.projectOrder ?? [],
1427
+ // So the picker can write a path the way people say it: ~/code/app.
1428
+ home: homedir(),
1020
1429
  projects: projects.map((project) => ({
1021
1430
  ...project,
1022
1431
  pinned: pinned.has(project.path),
1023
1432
  current: project.path === current.session.cwd,
1024
1433
  })),
1434
+ // Which session directories are worktrees, and of what: the sidebar
1435
+ // nests them under the project they came from.
1436
+ worktrees: (await listWorktrees()).map(worktreeView),
1025
1437
  });
1026
1438
  }
1027
1439
  /**
@@ -1095,7 +1507,11 @@ export async function startWebServer(opts) {
1095
1507
  if (!root)
1096
1508
  return send(res, 400, { error: "not a folder" });
1097
1509
  const replace = typeof body.replace === "string" ? body.replace : undefined;
1098
- 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);
1099
1515
  return send(res, "error" in result ? 500 : 200, result);
1100
1516
  }
1101
1517
  case "GET /term/list": {
@@ -1146,6 +1562,12 @@ export async function startWebServer(opts) {
1146
1562
  return send(res, 400, { error: "not a folder" });
1147
1563
  return send(res, 200, await browseFiles(root, url.searchParams.get("dir") ?? "", { all: true }));
1148
1564
  }
1565
+ case "GET /files/search": {
1566
+ const root = await projectRoot(url.searchParams.get("cwd"));
1567
+ if (!root)
1568
+ return send(res, 400, { error: "not a folder" });
1569
+ return send(res, 200, await searchFiles(root, (url.searchParams.get("q") ?? "").slice(0, 200)));
1570
+ }
1149
1571
  case "GET /files/read": {
1150
1572
  const root = await projectRoot(url.searchParams.get("cwd"));
1151
1573
  if (!root)
@@ -1229,7 +1651,14 @@ export async function startWebServer(opts) {
1229
1651
  const entries = await Promise.all(chosen.paths.map(async (chosenPath) => {
1230
1652
  const dir = await isDirectory(chosenPath);
1231
1653
  const reachable = isReadable(current.harness.workspace, chosenPath);
1232
- const carried = dir || reachable ? undefined : await stageFromDisk(chosenPath);
1654
+ if (!dir)
1655
+ pickedPaths.add(path.resolve(chosenPath));
1656
+ // An image or a PDF is carried even from inside the project: the
1657
+ // model looks at it directly, rather than learning a path and
1658
+ // spending a tool call to open it. Anything else in reach stays
1659
+ // a reference the agent reads with its own tools.
1660
+ const media = !dir && /\.(png|jpe?g|gif|webp|pdf)$/i.test(chosenPath);
1661
+ const carried = dir || (reachable && !media) ? undefined : await stageFromDisk(chosenPath);
1233
1662
  return {
1234
1663
  path: within(current.session.cwd, chosenPath),
1235
1664
  dir,
@@ -1484,11 +1913,139 @@ export async function startWebServer(opts) {
1484
1913
  return send(res, 400, { error: errorMessage(err) });
1485
1914
  }
1486
1915
  }
1916
+ /**
1917
+ * What a worktree for this task would be called, before it is made.
1918
+ */
1919
+ case "GET /worktree/preview": {
1920
+ const task = (url.searchParams.get("task") ?? "").trim();
1921
+ const from = url.searchParams.get("cwd") || current.session.cwd;
1922
+ if (!task)
1923
+ return send(res, 400, { error: "task is required" });
1924
+ try {
1925
+ return send(res, 200, await previewWorktree(from, { task, slug: url.searchParams.get("slug") ?? undefined }));
1926
+ }
1927
+ catch (err) {
1928
+ return send(res, 400, { error: errorMessage(err) });
1929
+ }
1930
+ }
1931
+ /**
1932
+ * A new session in a new worktree beside a project, and optionally its
1933
+ * first message — the composer's "In a new worktree".
1934
+ */
1935
+ case "POST /worktree": {
1936
+ const body = await readJson(req);
1937
+ const task = typeof body.task === "string" ? body.task.trim() : "";
1938
+ if (!task)
1939
+ return send(res, 400, { error: "task is required" });
1940
+ const from = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : current.session.cwd;
1941
+ const type = BRANCH_TYPES.includes(body.type) ? body.type : undefined;
1942
+ let worktree;
1943
+ try {
1944
+ worktree = await createWorktree(from, {
1945
+ task,
1946
+ ...(typeof body.slug === "string" && body.slug.trim() ? { slug: body.slug.trim() } : {}),
1947
+ ...(type ? { type } : {}),
1948
+ });
1949
+ }
1950
+ catch (err) {
1951
+ return send(res, 400, { error: errorMessage(err) });
1952
+ }
1953
+ const snapshot = await switchSession({ action: "new", cwd: worktree.cwd });
1954
+ if ("error" in snapshot)
1955
+ return send(res, 400, snapshot);
1956
+ broadcast({ type: "projects" });
1957
+ broadcast({ type: "sessions" });
1958
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1959
+ const runner = runners.get(snapshot.sessionId);
1960
+ if (prompt && runner) {
1961
+ const ids = Array.isArray(body.attachments) ? body.attachments : [];
1962
+ const staged = ids
1963
+ .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
1964
+ .filter((entry) => Boolean(entry));
1965
+ for (const id of ids)
1966
+ if (typeof id === "string")
1967
+ attachments.delete(id);
1968
+ void runTurn(runner, prompt, staged, readReferences(body.references), body.goal === true);
1969
+ }
1970
+ return send(res, 200, { ...snapshot, worktree: worktreeView(worktree) });
1971
+ }
1972
+ case "GET /worktree/status": {
1973
+ const dir = url.searchParams.get("path") ?? "";
1974
+ const worktree = dir ? await findWorktree(dir) : undefined;
1975
+ if (!worktree)
1976
+ return send(res, 404, { error: "not a worktree" });
1977
+ try {
1978
+ const status = await worktreeStatus(worktree);
1979
+ return send(res, 200, { worktree: worktreeView(worktree), status, actions: finishActions(worktree, status) });
1980
+ }
1981
+ catch (err) {
1982
+ return send(res, 400, { error: errorMessage(err) });
1983
+ }
1984
+ }
1985
+ /**
1986
+ * Leave a worktree: commit, push, merge, apply back, remove or discard.
1987
+ *
1988
+ * The sessions recorded in it stay in history; what goes, for the
1989
+ * actions that remove it, is the open tabs and the harness bound to a
1990
+ * directory that is about to disappear.
1991
+ */
1992
+ case "POST /worktree/finish": {
1993
+ const body = await readJson(req);
1994
+ const dir = typeof body.path === "string" ? body.path : "";
1995
+ const action = String(body.action ?? "");
1996
+ if (!["remove", "discard", "commit", "push", "merge", "apply"].includes(action)) {
1997
+ return send(res, 400, { error: `unknown action: ${action}` });
1998
+ }
1999
+ const worktree = dir ? await findWorktree(dir) : undefined;
2000
+ if (!worktree)
2001
+ return send(res, 404, { error: "not a worktree" });
2002
+ const inside = [...runners.values()].filter((runner) => insideAny([worktree.path], runner.session.cwd));
2003
+ if (inside.some((runner) => runner.running))
2004
+ return send(res, 409, { error: "a turn is running in that worktree" });
2005
+ let result;
2006
+ try {
2007
+ result = await finishWorktree(worktree, action, {
2008
+ ...(typeof body.message === "string" ? { message: body.message } : {}),
2009
+ });
2010
+ }
2011
+ catch (err) {
2012
+ return send(res, 400, { error: errorMessage(err) });
2013
+ }
2014
+ let moved;
2015
+ if (result.removed) {
2016
+ for (const runner of inside) {
2017
+ await runner.agent.closeBackgroundTasks();
2018
+ runners.delete(runner.session.id);
2019
+ await runner.harness.closeBrowserFor(runner.session.id);
2020
+ }
2021
+ for (const [dirKey, harness] of [...harnesses]) {
2022
+ if (!insideAny([worktree.path], dirKey))
2023
+ continue;
2024
+ harnesses.delete(dirKey);
2025
+ await harness.close().catch(() => { });
2026
+ }
2027
+ await removeProject(worktree.cwd).catch(() => false);
2028
+ cachedLogs = undefined;
2029
+ // The window was standing in it: back to the project it came from.
2030
+ if (insideAny([worktree.path], current.session.cwd) || !runners.has(focused)) {
2031
+ moved = await switchProject(worktree.source).catch(() => undefined);
2032
+ }
2033
+ }
2034
+ broadcast({ type: "projects" });
2035
+ broadcast({ type: "sessions" });
2036
+ return send(res, 200, { result, open: [...runners.keys()], ...(moved ? { state: moved } : {}) });
2037
+ }
1487
2038
  case "POST /project": {
1488
2039
  const body = await readJson(req);
1489
2040
  const dir = typeof body.path === "string" ? body.path : "";
1490
2041
  // Any tab: switching projects rebuilds the harness under every runner.
1491
- 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)
1492
2049
  return send(res, 409, { error: "a turn is running" });
1493
2050
  if (typeof body.rename === "string") {
1494
2051
  const oldPath = path.resolve(dir);
@@ -1537,9 +2094,34 @@ export async function startWebServer(opts) {
1537
2094
  * the work.
1538
2095
  */
1539
2096
  if (body.purge === true) {
1540
- const deleted = await current.harness.deleteSessionsIn(dir);
1541
- 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);
1542
2121
  runners.delete(id);
2122
+ await removed?.harness.closeBrowserFor(id);
2123
+ await removeTurnShots(id);
2124
+ }
1543
2125
  // Their artifact logs went with them, and a listing built a moment
1544
2126
  // ago still names them.
1545
2127
  cachedLogs = undefined;
@@ -1663,7 +2245,36 @@ export async function startWebServer(opts) {
1663
2245
  const name = url.searchParams.get("name") ?? "";
1664
2246
  if (!name)
1665
2247
  return send(res, 400, { error: "name is required" });
1666
- return send(res, 200, { runs: await current.harness.scheduler.history(name, 30) });
2248
+ // The one going now first: its line is only written when it ends.
2249
+ const going = activeRun(name);
2250
+ const runs = await current.harness.scheduler.history(name, 30);
2251
+ return send(res, 200, { runs: going ? [{ ...going, running: true }, ...runs] : runs });
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 });
1667
2278
  }
1668
2279
  case "GET /keep-awake":
1669
2280
  return send(res, 200, {
@@ -1677,7 +2288,7 @@ export async function startWebServer(opts) {
1677
2288
  if (typeof body.enabled !== "boolean")
1678
2289
  return send(res, 400, { error: "enabled must be a boolean" });
1679
2290
  await writePrefs({ keepAwake: body.enabled });
1680
- opts.onKeepAwake(body.enabled);
2291
+ await applyKeepAwake();
1681
2292
  return send(res, 200, { available: true, enabled: body.enabled });
1682
2293
  }
1683
2294
  case "POST /routine/remove": {
@@ -1730,6 +2341,8 @@ export async function startWebServer(opts) {
1730
2341
  // whether the switch it draws is the user's to flip.
1731
2342
  audit: await current.harness.auditStatus(),
1732
2343
  browserMode: current.harness.browserMode(),
2344
+ // Off unless the user turned it on: it spends their Claude plan.
2345
+ claudeAgent: await current.harness.claudeAgentStatus(),
1733
2346
  // For the browser row: whether the person's browser can be reached
1734
2347
  // now, and where to get the extension when it cannot.
1735
2348
  browserExtension: await extensionInfo(),
@@ -1762,6 +2375,22 @@ export async function startWebServer(opts) {
1762
2375
  * switch that changed only the project on screen would look ignored the
1763
2376
  * moment someone opened another one.
1764
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
+ }
1765
2394
  case "POST /settings/browser-mode": {
1766
2395
  const body = await readJson(req);
1767
2396
  if (body.mode !== "auto" && body.mode !== "builtin" && body.mode !== "extension") {
@@ -1953,6 +2582,85 @@ export async function startWebServer(opts) {
1953
2582
  signedIn: Boolean(await current.harness.accountToken()),
1954
2583
  });
1955
2584
  }
2585
+ /**
2586
+ * Dictation, step one: what the microphone recorded, as text. Through
2587
+ * the account's gateway (OttoPort's speech-to-text, billed per second
2588
+ * from the account's credits) when signed in; otherwise straight to
2589
+ * OpenAI with the user's own key when there is one. Neither: the page
2590
+ * says to sign in or add a key.
2591
+ */
2592
+ case "POST /voice/transcribe": {
2593
+ const body = await readJson(req, VOICE_MAX_BODY_BYTES);
2594
+ const audio = typeof body.audio === "string" ? body.audio : "";
2595
+ const format = typeof body.format === "string" && VOICE_FORMATS.has(body.format) ? body.format : "";
2596
+ const duration = typeof body.duration === "number" && Number.isFinite(body.duration) ? body.duration : 0;
2597
+ const language = typeof body.language === "string" ? body.language.slice(0, 2).toLowerCase() : undefined;
2598
+ if (!audio || !format || duration <= 0)
2599
+ return send(res, 400, { error: "audio, format and duration are required" });
2600
+ if (duration > VOICE_MAX_SECONDS)
2601
+ return send(res, 413, { error: `a recording can be up to ${VOICE_MAX_SECONDS / 60} minutes` });
2602
+ const token = await current.harness.accountToken();
2603
+ if (token) {
2604
+ const team = officialTeam();
2605
+ // A team pays when one is chosen, by the gateway's path prefix.
2606
+ const path = `${team ? `/teams/${encodeURIComponent(team)}` : ""}/api/v1/audio/transcriptions`;
2607
+ const ask = (model) => gatewayJson(path, {
2608
+ token,
2609
+ body: { model, audio, format, duration, ...(language ? { language } : {}) },
2610
+ });
2611
+ // Chinese is Qwen's strength; everything else gpt-4o-mini-transcribe.
2612
+ // Should the Qwen model be missing upstream, the default answers instead.
2613
+ const preferred = language === "zh" ? VOICE_TRANSCRIBE_MODEL_ZH : VOICE_TRANSCRIBE_MODEL;
2614
+ let result = await ask(preferred);
2615
+ if (!result.ok && preferred !== VOICE_TRANSCRIBE_MODEL && (result.status === 400 || result.status === 404)) {
2616
+ result = await ask(VOICE_TRANSCRIBE_MODEL);
2617
+ }
2618
+ if (!result.ok)
2619
+ return send(res, result.status === 402 ? 402 : 502, { error: result.error });
2620
+ return send(res, 200, { text: typeof result.value.text === "string" ? result.value.text : "", via: "oharness" });
2621
+ }
2622
+ const openai = await current.harness.auth.secret("openai").catch(() => undefined);
2623
+ if (openai) {
2624
+ const text = await transcribeWithOpenAI(openai, audio, format, language);
2625
+ return text.ok ? send(res, 200, { text: text.value, via: "openai" }) : send(res, 502, { error: text.error });
2626
+ }
2627
+ return send(res, 401, { error: "Sign in to your OHarness account, or add an OpenAI key, to dictate.", code: "sign_in_required" });
2628
+ }
2629
+ /**
2630
+ * Dictation, step two: the transcript tidied into the message meant.
2631
+ * A perk of a paid OHarness plan, through the gateway's fast model; or,
2632
+ * for someone who brought their own key, the small model they set up,
2633
+ * run here. Anyone else gets the words back as they were heard.
2634
+ */
2635
+ case "POST /voice/tidy": {
2636
+ const body = await readJson(req);
2637
+ const text = typeof body.text === "string" ? body.text.trim() : "";
2638
+ const language = typeof body.language === "string" ? body.language.slice(0, 16) : undefined;
2639
+ if (!text)
2640
+ return send(res, 400, { error: "text is required" });
2641
+ const asHeard = { text, tidied: false };
2642
+ const token = await current.harness.accountToken();
2643
+ if (token) {
2644
+ const result = await gatewayJson("/api/v1/voice/tidy", { token, body: { text, ...(language ? { language } : {}) } });
2645
+ if (result.ok && result.value.tidied === true && typeof result.value.text === "string") {
2646
+ return send(res, 200, { text: result.value.text, tidied: true, via: "oharness" });
2647
+ }
2648
+ }
2649
+ const own = await ownSmallModel(current.harness);
2650
+ if (!own)
2651
+ return send(res, 200, asHeard);
2652
+ try {
2653
+ const tidied = (await current.harness.generateText(VOICE_TIDY_PROMPT + (language ? `\nThe speaker's language setting is ${language}.` : ""), text, AbortSignal.timeout(10_000), Math.min(2048, text.length * 2 + 64), own)).trim();
2654
+ // A tidy is about as long as what was said; much longer is the model
2655
+ // answering the dictation instead of cleaning it.
2656
+ if (!tidied || tidied.length > text.length * 1.6 + 40)
2657
+ return send(res, 200, asHeard);
2658
+ return send(res, 200, { text: tidied, tidied: true, via: "own-key" });
2659
+ }
2660
+ catch {
2661
+ return send(res, 200, asHeard);
2662
+ }
2663
+ }
1956
2664
  case "POST /sync": {
1957
2665
  const body = await readJson(req);
1958
2666
  if (typeof body.enabled !== "boolean") {
@@ -2101,8 +2809,17 @@ export async function startWebServer(opts) {
2101
2809
  return send(res, 400, { error: "enabled must be a boolean" });
2102
2810
  }
2103
2811
  await writePrefs({ analytics: body.enabled });
2812
+ setConsent(body.enabled);
2104
2813
  return send(res, 200, await analyticsState());
2105
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
+ }
2106
2823
  case "GET /changes": {
2107
2824
  const runner = runnerFor(url.searchParams.get("session"));
2108
2825
  return send(res, 200, { changes: runner ? runner.ledger.list() : [] });
@@ -2350,7 +3067,7 @@ export async function startWebServer(opts) {
2350
3067
  // and a false one say the same thing to the one reader there is.
2351
3068
  ...(isFree(model) ? { free: true } : {}),
2352
3069
  })),
2353
- providers: current.harness.listCatalogProviders().map((entry) => ({
3070
+ providers: (await withSpend(current.harness.listCatalogProviders())).map(({ entry, spend30d }) => ({
2354
3071
  id: entry.id,
2355
3072
  label: entry.label,
2356
3073
  envVars: entry.envVars,
@@ -2381,6 +3098,17 @@ export async function startWebServer(opts) {
2381
3098
  // key into. The panel has no other way to tell, and offering a key
2382
3099
  // field for an account-backed plan is a dead end.
2383
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 } : {}),
2384
3112
  })),
2385
3113
  });
2386
3114
  }
@@ -2457,6 +3185,49 @@ export async function startWebServer(opts) {
2457
3185
  return send(res, 400, { error: errorMessage(err) });
2458
3186
  }
2459
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
+ }
2460
3231
  case "POST /signin/browser": {
2461
3232
  const body = await readJson(req);
2462
3233
  const providerId = typeof body.provider === "string" ? body.provider : "";
@@ -2556,6 +3327,113 @@ export async function startWebServer(opts) {
2556
3327
  });
2557
3328
  return;
2558
3329
  }
3330
+ /**
3331
+ * Feedback, sent to the site as the signed-in account.
3332
+ *
3333
+ * Through this server rather than from the page, because the account's
3334
+ * token lives here and never in the page. The site writes it as that
3335
+ * account, so there is nothing to send for someone who is not signed in.
3336
+ */
3337
+ case "POST /feedback": {
3338
+ const body = await readJson(req);
3339
+ const message = typeof body.message === "string" ? body.message.trim() : "";
3340
+ if (!message)
3341
+ return send(res, 400, { error: "message is required" });
3342
+ const token = await current.harness.accountToken();
3343
+ if (!token)
3344
+ return send(res, 401, { error: "sign in to send feedback" });
3345
+ try {
3346
+ const reply = await fetch(`${siteUrl()}/api/feedback`, {
3347
+ method: "POST",
3348
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
3349
+ body: JSON.stringify({
3350
+ kind: body.kind,
3351
+ message,
3352
+ source: typeof body.source === "string" ? body.source : "app",
3353
+ appVersion: typeof body.appVersion === "string" ? body.appVersion : undefined,
3354
+ // Paths from `/feedback/upload`; the site keeps only this account's.
3355
+ attachments: Array.isArray(body.attachments) ? body.attachments.slice(0, 5) : undefined,
3356
+ // The machine this server runs on, which the page cannot see.
3357
+ platform: `${process.platform} ${process.arch}`,
3358
+ }),
3359
+ signal: AbortSignal.timeout(15_000),
3360
+ });
3361
+ const answer = (await reply.json().catch(() => ({})));
3362
+ if (!reply.ok) {
3363
+ // A 404 is a site that has not shipped feedback yet, not one that is down.
3364
+ const error = reply.status === 404
3365
+ ? "feedback is not available on this site yet"
3366
+ : typeof answer.error === "string" ? answer.error : `the site refused it (HTTP ${reply.status})`;
3367
+ return send(res, reply.status === 404 ? 502 : reply.status, { error });
3368
+ }
3369
+ return send(res, 200, { ok: true });
3370
+ }
3371
+ catch (error) {
3372
+ return send(res, 502, { error: error instanceof Error ? error.message : String(error) });
3373
+ }
3374
+ }
3375
+ /**
3376
+ * One screenshot or recording for a feedback message.
3377
+ *
3378
+ * The page sends the bytes here, as `/upload` does, and this server asks
3379
+ * the site for a one-time upload URL under the account's token and puts
3380
+ * the file straight into storage there. The site only names the file;
3381
+ * the bytes never pass through it. Answers what `/feedback` then sends
3382
+ * as an attachment.
3383
+ */
3384
+ case "POST /feedback/upload": {
3385
+ const token = await current.harness.accountToken();
3386
+ if (!token)
3387
+ return send(res, 401, { error: "sign in to send feedback" });
3388
+ const type = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
3389
+ const rawName = String(req.headers["x-filename"] ?? "file");
3390
+ let name = rawName;
3391
+ try {
3392
+ name = decodeURIComponent(rawName);
3393
+ }
3394
+ catch { /* sent unencoded */ }
3395
+ let bytes;
3396
+ try {
3397
+ bytes = await readBody(req, FEEDBACK_MAX_BYTES);
3398
+ }
3399
+ catch {
3400
+ return send(res, 413, { error: "each file can be up to 50 MB" });
3401
+ }
3402
+ if (!bytes.length)
3403
+ return send(res, 400, { error: "that file is empty" });
3404
+ try {
3405
+ const ticket = await fetch(`${siteUrl()}/api/feedback/upload`, {
3406
+ method: "POST",
3407
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
3408
+ body: JSON.stringify({ name, type, size: bytes.length }),
3409
+ signal: AbortSignal.timeout(15_000),
3410
+ });
3411
+ const granted = (await ticket.json().catch(() => ({})));
3412
+ if (!ticket.ok || typeof granted.path !== "string" || typeof granted.signedUrl !== "string") {
3413
+ const error = ticket.status === 404
3414
+ ? "attachments are not available on this site yet"
3415
+ : typeof granted.error === "string" ? granted.error : `the site refused it (HTTP ${ticket.status})`;
3416
+ return send(res, ticket.status === 404 ? 502 : ticket.status || 502, { error });
3417
+ }
3418
+ // A minute a megabyte, at least two: a recording on a slow uplink
3419
+ // takes a while, and a timeout that cuts it off helps nobody.
3420
+ const put = await fetch(granted.signedUrl, {
3421
+ method: "PUT",
3422
+ headers: { "content-type": type, "x-upsert": "false" },
3423
+ body: new Uint8Array(bytes),
3424
+ signal: AbortSignal.timeout(Math.max(120_000, Math.ceil((bytes.length / 1_048_576) * 60_000))),
3425
+ });
3426
+ if (!put.ok) {
3427
+ const detail = (await put.json().catch(() => ({})));
3428
+ const message = typeof detail.message === "string" ? detail.message : typeof detail.error === "string" ? detail.error : `HTTP ${put.status}`;
3429
+ return send(res, 502, { error: `upload failed: ${message}` });
3430
+ }
3431
+ return send(res, 200, { path: granted.path, name, type, size: bytes.length });
3432
+ }
3433
+ catch (error) {
3434
+ return send(res, 502, { error: error instanceof Error ? error.message : String(error) });
3435
+ }
3436
+ }
2559
3437
  case "POST /logout": {
2560
3438
  const body = await readJson(req);
2561
3439
  const providerId = typeof body.provider === "string" ? body.provider : "";
@@ -2593,6 +3471,12 @@ export async function startWebServer(opts) {
2593
3471
  // next run: picking a model is a decision, not a per-session detail.
2594
3472
  preferredModel = change.to.id;
2595
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(() => { });
2596
3480
  broadcast({
2597
3481
  type: "model_changed",
2598
3482
  session: runner.session.id,
@@ -2605,6 +3489,129 @@ export async function startWebServer(opts) {
2605
3489
  return send(res, 400, { error: errorMessage(err) });
2606
3490
  }
2607
3491
  }
3492
+ case "POST /goal": {
3493
+ const body = await readJson(req);
3494
+ const runner = runnerFor(body.session);
3495
+ if (!runner)
3496
+ return send(res, 404, { error: "no such session" });
3497
+ // Setting one goes through /prompt, since it is also the first
3498
+ // message; this is the way out of one.
3499
+ runner.session.goal = undefined;
3500
+ void runner.session.persist().catch(() => { });
3501
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3502
+ return send(res, 200, state(runner));
3503
+ }
3504
+ // /compact: summarize the conversation now rather than when it fills.
3505
+ case "POST /compact": {
3506
+ const body = await readJson(req);
3507
+ const runner = runnerFor(body.session);
3508
+ if (!runner)
3509
+ return send(res, 404, { error: "no such session" });
3510
+ // The running turn owns the transcript; cutting it underneath would
3511
+ // drop the messages the next request is being built from.
3512
+ if (runner.running)
3513
+ return send(res, 409, { error: "wait for the running turn to finish before compacting" });
3514
+ const event = await runner.agent.compactNow();
3515
+ if (!event)
3516
+ return send(res, 200, { ...state(runner), compacted: false });
3517
+ // The same notice the automatic kind leaves in the transcript.
3518
+ broadcast({ ...event, session: runner.session.id });
3519
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3520
+ return send(res, 200, { ...state(runner), compacted: event.type === "compacted" });
3521
+ }
3522
+ // Computer Use: switched per conversation from the + menu.
3523
+ case "POST /computer-use": {
3524
+ const body = await readJson(req);
3525
+ const runner = runnerFor(body.session);
3526
+ if (!runner)
3527
+ return send(res, 404, { error: "no such session" });
3528
+ if (!runner.harness.computerUse)
3529
+ return send(res, 404, { error: "Computer use is only available in the desktop app" });
3530
+ const on = body.on === true;
3531
+ runner.session.computerUse = on;
3532
+ if (!on)
3533
+ runner.harness.computerFor(runner.session.id)?.endTurn();
3534
+ void runner.session.persist().catch(() => { });
3535
+ if (on)
3536
+ await refreshComputer(runner.harness.computerUse);
3537
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3538
+ return send(res, 200, state(runner));
3539
+ }
3540
+ // The grants the OS holds, read again: the card polls this while the
3541
+ // person is in System Settings, and `request` shows the OS prompt first.
3542
+ case "POST /computer-use/permissions": {
3543
+ const body = await readJson(req);
3544
+ const plugin = opts.computerUsePlugin ?? runners.get(focused)?.harness.computerUse;
3545
+ if (!plugin)
3546
+ return send(res, 404, { error: "not available here" });
3547
+ if (body.request) {
3548
+ const grant = body.grant === "screen" || body.grant === "accessibility" ? body.grant : undefined;
3549
+ await plugin.runtime.requestPermissions(grant).catch((err) => {
3550
+ computerStatus.error = errorMessage(err);
3551
+ });
3552
+ }
3553
+ await refreshComputer(plugin);
3554
+ for (const runner of runners.values()) {
3555
+ if (runner.session.computerUse)
3556
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3557
+ }
3558
+ return send(res, 200, {
3559
+ ...computerStatus,
3560
+ relaunch: Boolean(opts.onRelaunch),
3561
+ ...(opts.computerGrantee ? { grantee: opts.computerGrantee } : {}),
3562
+ });
3563
+ }
3564
+ // The two settings the person owns, read by the desktop's plugin.
3565
+ case "GET /computer-use/settings": {
3566
+ const prefs = await readPrefs();
3567
+ return send(res, 200, {
3568
+ available: Boolean(opts.computerUsePlugin),
3569
+ takeover: prefs.computerTakeover === "never" ? "never" : "ask",
3570
+ enable: prefs.computerEnable ?? "ask",
3571
+ blockedApps: prefs.computerBlockedApps ?? [],
3572
+ });
3573
+ }
3574
+ case "POST /computer-use/settings": {
3575
+ if (!opts.computerUsePlugin)
3576
+ return send(res, 404, { error: "not available here" });
3577
+ const body = await readJson(req);
3578
+ const patch = {};
3579
+ if (body.takeover === "ask" || body.takeover === "never")
3580
+ patch.computerTakeover = body.takeover;
3581
+ if (body.enable === "ask" || body.enable === "auto" || body.enable === "off")
3582
+ patch.computerEnable = body.enable;
3583
+ if (Array.isArray(body.blockedApps)) {
3584
+ patch.computerBlockedApps = [...new Set(body.blockedApps.map((a) => String(a).trim()).filter(Boolean))];
3585
+ }
3586
+ await writePrefs(patch);
3587
+ return send(res, 200, { ok: true });
3588
+ }
3589
+ /** What the live view shows when it opens: the conversation's last action. */
3590
+ case "GET /computer-use/view": {
3591
+ const id = url.searchParams.get("session") ?? "";
3592
+ return send(res, 200, { event: computerLast.get(id) ?? null });
3593
+ }
3594
+ /**
3595
+ * The live view's stop button: end the turn, and let go of every window
3596
+ * and of the mouse and keyboard at once, rather than when the turn
3597
+ * notices it was stopped.
3598
+ */
3599
+ case "POST /computer-use/stop": {
3600
+ const body = await readJson(req);
3601
+ const runner = runnerFor(body.session);
3602
+ if (!runner)
3603
+ return send(res, 404, { error: "no such session" });
3604
+ runner.running?.abort();
3605
+ runner.harness.computerFor(runner.session.id)?.endTurn();
3606
+ return send(res, 200, { ok: true });
3607
+ }
3608
+ case "POST /computer-use/relaunch": {
3609
+ if (!opts.onRelaunch)
3610
+ return send(res, 404, { error: "not available here" });
3611
+ send(res, 200, { ok: true });
3612
+ opts.onRelaunch();
3613
+ return;
3614
+ }
2608
3615
  case "POST /effort": {
2609
3616
  const body = await readJson(req);
2610
3617
  const runner = runnerFor(body.session);
@@ -2632,6 +3639,12 @@ export async function startWebServer(opts) {
2632
3639
  return send(res, 400, { error: `unknown mode: ${mode}` });
2633
3640
  }
2634
3641
  runner.session.permissions.setMode(mode);
3642
+ // Kept with the conversation, so it reopens in this mode. Only one
3643
+ // that is already on disk: a new one writes its record on its first
3644
+ // message, and that record carries the mode.
3645
+ if (runner.session.messages.length)
3646
+ await runner.session.persist().catch(() => { });
3647
+ await rememberMode(mode);
2635
3648
  broadcast({ type: "state", session: runner.session.id, state: state(runner) });
2636
3649
  return send(res, 200, state(runner));
2637
3650
  }
@@ -2643,6 +3656,9 @@ export async function startWebServer(opts) {
2643
3656
  const id = String(body.id ?? "");
2644
3657
  const runner = runners.get(id);
2645
3658
  runner?.running?.abort();
3659
+ // Its background tasks stop with it, written down as they stood, so
3660
+ // reopening it brings them back the way a restart would.
3661
+ await runner?.agent.closeBackgroundTasks();
2646
3662
  runners.delete(id);
2647
3663
  await runner?.harness.closeBrowserFor(id);
2648
3664
  // Its artifacts move from memory to the log half of the catalogue,
@@ -2665,8 +3681,12 @@ export async function startWebServer(opts) {
2665
3681
  // Reported rather than blocked: a fork records its parent, so the
2666
3682
  // page can say what the lineage loses instead of the branches
2667
3683
  // quietly pointing at nothing.
3684
+ // Before the store goes: a task stopped afterwards would write its
3685
+ // record back for a session that no longer exists.
3686
+ await runners.get(id)?.agent.discardBackgroundTasks();
2668
3687
  const forks = (await current.harness.store.children(id)).length;
2669
3688
  const deleted = await current.harness.deleteSession(id);
3689
+ await removeTurnShots(id);
2670
3690
  // A session nobody has spoken to has nothing on disk yet — the store
2671
3691
  // writes on the first message — but it is still a session someone
2672
3692
  // opened, and it is exactly the kind that gets thrown away when the
@@ -2705,6 +3725,38 @@ export async function startWebServer(opts) {
2705
3725
  }
2706
3726
  return send(res, 200, await switchSession(body));
2707
3727
  }
3728
+ /**
3729
+ * Remote control, as this Mac's settings show it: whether it is on, the
3730
+ * phones that may connect, and any waiting to be allowed.
3731
+ */
3732
+ case "GET /remote":
3733
+ return send(res, 200, await remoteView());
3734
+ /** The switch: saved, then started or stopped in this process, no restart. */
3735
+ case "POST /remote": {
3736
+ if (!opts.remote)
3737
+ return send(res, 404, { error: "not available here" });
3738
+ const body = await readJson(req);
3739
+ if (typeof body.enabled !== "boolean")
3740
+ return send(res, 400, { error: "enabled must be a boolean" });
3741
+ await writePrefs({ remote: body.enabled });
3742
+ await setRemote(body.enabled);
3743
+ return send(res, 200, await remoteView());
3744
+ }
3745
+ case "POST /remote/pair": {
3746
+ const body = await readJson(req);
3747
+ if (typeof body.id !== "string" || !answerPairing(body.id, body.allow === true)) {
3748
+ return send(res, 404, { error: "no phone is waiting for that" });
3749
+ }
3750
+ return send(res, 200, { ok: true });
3751
+ }
3752
+ case "POST /remote/revoke": {
3753
+ const body = await readJson(req);
3754
+ if (typeof body.id !== "string" || !(await revokeDevice(body.id)))
3755
+ return send(res, 404, { error: "no such phone" });
3756
+ // Forgotten means gone now, not at its next reconnect.
3757
+ forgetPhone(body.id);
3758
+ return send(res, 200, { ok: true });
3759
+ }
2708
3760
  case "GET /open":
2709
3761
  // What the page should draw tabs for after a reload.
2710
3762
  return send(res, 200, {
@@ -2798,6 +3850,12 @@ export async function startWebServer(opts) {
2798
3850
  if (!prompt && !ids.length && !references.length) {
2799
3851
  return send(res, 400, { error: "prompt is required" });
2800
3852
  }
3853
+ void markUsed();
3854
+ // Goal mode: the text is the goal, and the turn keeps going until the
3855
+ // model reports it achieved or blocked. A goal needs words to be one.
3856
+ const goal = body.goal === true;
3857
+ if (goal && !prompt)
3858
+ return send(res, 400, { error: "a goal needs a description" });
2801
3859
  const staged = ids
2802
3860
  .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
2803
3861
  .filter((entry) => Boolean(entry));
@@ -2817,11 +3875,11 @@ export async function startWebServer(opts) {
2817
3875
  // This used to 409, and the page discarded the reply, so an impatient
2818
3876
  // Enter cleared the composer and lost what had been typed.
2819
3877
  if (runner.running) {
2820
- runner.queue.push({ id: randomUUID(), prompt, staged, references });
3878
+ runner.queue.push({ id: randomUUID(), prompt, staged, references, ...(goal ? { goal } : {}) });
2821
3879
  broadcastQueue(runner);
2822
3880
  return send(res, 202, { ok: true, queued: true });
2823
3881
  }
2824
- void runTurn(runner, prompt, staged, references);
3882
+ void runTurn(runner, prompt, staged, references, goal);
2825
3883
  return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
2826
3884
  }
2827
3885
  case "POST /permission": {
@@ -2850,6 +3908,54 @@ export async function startWebServer(opts) {
2850
3908
  entry.settle(checked.result);
2851
3909
  return send(res, 200, { ok: true });
2852
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
+ }
2853
3959
  case "POST /interrupt":
2854
3960
  {
2855
3961
  const body = await readJson(req);
@@ -2899,10 +4005,12 @@ export async function startWebServer(opts) {
2899
4005
  if (!runner.running) {
2900
4006
  runner.queue.splice(at, 1);
2901
4007
  broadcastQueue(runner);
2902
- void runTurn(runner, entry.prompt, entry.staged, entry.references);
4008
+ void runTurn(runner, entry.prompt, entry.staged, entry.references, entry.goal);
2903
4009
  return send(res, 202, { ok: true, sent: true });
2904
4010
  }
2905
- const plain = entry.prompt && !entry.staged.length && !entry.references.length;
4011
+ // A goal starts a run of its own; slipped into another turn it would
4012
+ // be read as a remark and never set.
4013
+ const plain = entry.prompt && !entry.staged.length && !entry.references.length && !entry.goal;
2906
4014
  if (plain && runner.agent.interject(entry.prompt)) {
2907
4015
  runner.queue.splice(at, 1);
2908
4016
  broadcastQueue(runner);
@@ -3006,7 +4114,20 @@ export async function startWebServer(opts) {
3006
4114
  const provider = current.harness.auth.get(providerId);
3007
4115
  if (!provider)
3008
4116
  return undefined;
3009
- 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);
3010
4131
  if (index === -1)
3011
4132
  return undefined;
3012
4133
  return { index, label: provider.methods[index].label };
@@ -3075,7 +4196,15 @@ export async function startWebServer(opts) {
3075
4196
  ...list.filter((one) => one.source === "builtin"),
3076
4197
  ...list.filter((one) => one.source !== "builtin"),
3077
4198
  ];
3078
- 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 };
3079
4208
  }
3080
4209
  /**
3081
4210
  * What the composer attached to one message, as a block above the prompt.
@@ -3125,9 +4254,13 @@ export async function startWebServer(opts) {
3125
4254
  function state(runner = runners.get(focused)) {
3126
4255
  const { session: sess } = runner;
3127
4256
  const model = sess.model;
3128
- const used = sess.usage.inputTokens + sess.usage.outputTokens;
4257
+ const context = runner.agent.contextBreakdown();
4258
+ const used = context.total;
3129
4259
  return {
3130
4260
  sessionId: sess.id,
4261
+ // Its background tasks, so a reloaded page or a newly opened tab
4262
+ // shows them without waiting for the next change.
4263
+ tasks: runner.agent.backgroundTasks?.list() ?? [],
3131
4264
  // The page labels its tabs from this. Without it every tab read
3132
4265
  // "Untitled", including sessions the store had a title for all along.
3133
4266
  title: sess.title,
@@ -3135,6 +4268,20 @@ export async function startWebServer(opts) {
3135
4268
  model: model.id,
3136
4269
  provider: model.provider,
3137
4270
  mode: sess.permissions.getMode(),
4271
+ // Only while it is being worked toward. Achieved or blocked, the
4272
+ // transcript already says so, and the composer goes back to normal.
4273
+ goal: sess.goal?.status === "active" ? sess.goal.objective : undefined,
4274
+ // Absent outside the desktop app, which is the page's cue to hide the row.
4275
+ computerUse: runner.harness.computerUse
4276
+ ? {
4277
+ on: sess.computerUse,
4278
+ ...(computerStatus.platform ? { platform: computerStatus.platform } : {}),
4279
+ ...(computerStatus.permissions ? { permissions: computerStatus.permissions } : {}),
4280
+ ...(computerStatus.error ? { error: computerStatus.error } : {}),
4281
+ relaunch: Boolean(opts.onRelaunch),
4282
+ ...(opts.computerGrantee ? { grantee: opts.computerGrantee } : {}),
4283
+ }
4284
+ : undefined,
3138
4285
  // What the next request will carry, not what was picked: a pick the
3139
4286
  // model lacks shows as the tier it is clamped to, and a model that
3140
4287
  // cannot stop thinking never shows "off". Absent when the model has no
@@ -3151,6 +4298,9 @@ export async function startWebServer(opts) {
3151
4298
  turns: sess.turns,
3152
4299
  contextWindow: model.contextWindow,
3153
4300
  contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
4301
+ contextTokens: used,
4302
+ // What the context panel splits the ring into.
4303
+ context,
3154
4304
  busy: Boolean(runner.running),
3155
4305
  // So a reload, or a second tab, sees what is still waiting. The queue
3156
4306
  // lives on the runner rather than in the page for exactly this reason.
@@ -3164,14 +4314,101 @@ export async function startWebServer(opts) {
3164
4314
  * than left as the user messages they are on the wire, because the page
3165
4315
  * shows a call and its result as one card.
3166
4316
  */
4317
+ /**
4318
+ * Tell the page whenever a conversation's background tasks change.
4319
+ *
4320
+ * A running task changes on every turn and every tool it starts, which is
4321
+ * more often than anybody can read; changes inside a quarter second go out
4322
+ * as one list.
4323
+ */
4324
+ function watchTasks(runner) {
4325
+ if (watchedTasks.has(runner.agent))
4326
+ return;
4327
+ watchedTasks.add(runner.agent);
4328
+ let timer;
4329
+ runner.agent.onBackgroundChange(() => {
4330
+ if (timer)
4331
+ return;
4332
+ timer = setTimeout(() => {
4333
+ timer = undefined;
4334
+ broadcast({ type: "tasks", session: runner.session.id, tasks: runner.agent.backgroundTasks?.list() ?? [] });
4335
+ }, 250);
4336
+ });
4337
+ }
4338
+ /**
4339
+ * A conversation just opened: watch its background tasks, and bring back
4340
+ * the ones it had before a restart.
4341
+ */
4342
+ function adoptTasks(runner) {
4343
+ watchTasks(runner);
4344
+ void runner.agent.restoreBackgroundTasks().then((restored) => {
4345
+ if (restored)
4346
+ broadcast({ type: "tasks", session: runner.session.id, tasks: runner.agent.backgroundTasks?.list() ?? [] });
4347
+ }, () => { });
4348
+ }
4349
+ /**
4350
+ * A background task's conversation, as the task view draws it: what it was
4351
+ * told, what it said, and each tool it ran with the start of its result.
4352
+ * Plainer than the main transcript's entries — nothing here can be forked
4353
+ * from or replayed, so it carries no positions.
4354
+ */
4355
+ function taskTranscript(messages) {
4356
+ const entries = [];
4357
+ const results = new Map();
4358
+ for (const message of messages) {
4359
+ for (const block of message.content) {
4360
+ if (block.type !== "tool_result")
4361
+ continue;
4362
+ const text = block.content.map((part) => (part.type === "text" ? part.text : "")).join("");
4363
+ results.set(block.toolCallId, { text: text.slice(0, 1200), ...(block.isError ? { isError: true } : {}) });
4364
+ }
4365
+ }
4366
+ for (const message of messages) {
4367
+ const text = visibleText(message).trim();
4368
+ const tools = message.content.filter((block) => block.type === "tool_call");
4369
+ if (message.role === "user") {
4370
+ if (text)
4371
+ entries.push({ role: "user", text });
4372
+ continue;
4373
+ }
4374
+ if (text)
4375
+ entries.push({ role: "assistant", text });
4376
+ for (const call of tools) {
4377
+ const result = results.get(call.id);
4378
+ entries.push({
4379
+ role: "tool",
4380
+ name: call.name,
4381
+ text: previewJson(call.input, 160),
4382
+ ...(result ? { result: result.text, ...(result.isError ? { isError: true } : {}) } : {}),
4383
+ });
4384
+ }
4385
+ }
4386
+ return entries;
4387
+ }
3167
4388
  function history(runner = runners.get(focused)) {
3168
4389
  const entries = [];
3169
4390
  let index = 0;
3170
4391
  for (const message of runner.session.messages) {
3171
4392
  index++;
4393
+ // The loop's "keep going" is the harness talking to the model, not
4394
+ // something the person said.
4395
+ if (message.meta?.["goalNudge"])
4396
+ continue;
4397
+ // Background results read in: a card naming the tasks, never the raw
4398
+ // notification text as if the person had written it.
4399
+ const notified = message.meta?.["taskNotification"];
4400
+ if (Array.isArray(notified)) {
4401
+ const tasks = notified.filter((task) => typeof task?.id === "string" && typeof task?.description === "string");
4402
+ if (tasks.length)
4403
+ entries.push({ role: "user", keepMessages: index, tasks });
4404
+ continue;
4405
+ }
3172
4406
  const tools = message.content.filter((block) => block.type === "tool_call");
3173
4407
  const results = message.content.filter((block) => block.type === "tool_result");
3174
- const text = visibleText(message).trim();
4408
+ // The notes buildMessage writes for the model about what was attached —
4409
+ // "[image: shot.png]", "[file attached: … saved at …]" — are not what the
4410
+ // person typed, and the bubble now shows the attachments themselves.
4411
+ const text = (message.role === "user" ? withoutAttachmentNotes(visibleText(message)) : visibleText(message)).trim();
3175
4412
  // The bytes stay where they are; what travels is where to find them.
3176
4413
  // Without this a reloaded conversation showed "[image: shot.png]" where
3177
4414
  // the screenshot had been — the model still saw it, the person did not.
@@ -3182,6 +4419,23 @@ export async function startWebServer(opts) {
3182
4419
  src: `/attachment?session=${encodeURIComponent(runner.session.id)}` +
3183
4420
  `&message=${index}&block=${entry.position}`,
3184
4421
  }));
4422
+ // Clips the message carried, played from the copy saved in the project
4423
+ // (see buildMessage). A copy since deleted just shows as a name.
4424
+ const sent = message.meta?.["attachments"];
4425
+ if (message.role === "user" && Array.isArray(sent)) {
4426
+ for (const clip of sent) {
4427
+ if (clip.kind !== "video" || typeof clip.path !== "string")
4428
+ continue;
4429
+ const rel = path.relative(runner.session.cwd, clip.path);
4430
+ if (rel.startsWith("..") || path.isAbsolute(rel))
4431
+ continue;
4432
+ images.push({
4433
+ src: `/files/raw?cwd=${encodeURIComponent(runner.session.cwd)}&path=${encodeURIComponent(rel)}`,
4434
+ ...(typeof clip.name === "string" ? { name: clip.name } : {}),
4435
+ kind: "video",
4436
+ });
4437
+ }
4438
+ }
3185
4439
  // messageText drops thinking blocks by design, so they are read directly.
3186
4440
  const thinking = message.content
3187
4441
  .filter((block) => block.type === "thinking")
@@ -3195,23 +4449,45 @@ export async function startWebServer(opts) {
3195
4449
  // reply" has to include it or the branch reopens with tool calls
3196
4450
  // that have no answers — a request every provider rejects.
3197
4451
  target.keepMessages = index;
3198
- target.results = results.map((block) => ({
3199
- id: block.toolCallId,
3200
- text: block.content
3201
- .map((part) => (part.type === "text" ? part.text : ""))
3202
- .join("")
3203
- .slice(0, 4000),
3204
- ...(block.isError ? { isError: true } : {}),
3205
- }));
4452
+ target.results = results.map((block) => {
4453
+ const position = message.content.indexOf(block);
4454
+ const shots = block.content
4455
+ .map((part, place) => ({ part, place }))
4456
+ .filter((entry) => entry.part.type === "image")
4457
+ .map((entry) => ({
4458
+ src: `/attachment?session=${encodeURIComponent(runner.session.id)}` +
4459
+ `&message=${index}&block=${position}&part=${entry.place}`,
4460
+ }));
4461
+ return {
4462
+ id: block.toolCallId,
4463
+ text: block.content
4464
+ .map((part) => (part.type === "text" ? part.text : ""))
4465
+ .join("")
4466
+ .slice(0, 4000),
4467
+ ...(block.isError ? { isError: true } : {}),
4468
+ ...(shots.length ? { images: shots } : {}),
4469
+ };
4470
+ });
3206
4471
  }
3207
4472
  if (!text && !thinking && !images.length)
3208
4473
  continue;
3209
4474
  }
3210
4475
  if (!text && !tools.length && !thinking && !images.length)
3211
4476
  continue;
4477
+ const agentName = message.role === "assistant" ? message.meta?.["agent"] : undefined;
4478
+ const agentSession = message.meta?.["acpSession"];
3212
4479
  entries.push({
3213
4480
  role: message.role === "assistant" ? "assistant" : "user",
3214
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
+ : {}),
3215
4491
  ...(images.length ? { images } : {}),
3216
4492
  ...(thinking ? { thinking } : {}),
3217
4493
  ...(text ? { text } : {}),
@@ -3220,6 +4496,14 @@ export async function startWebServer(opts) {
3220
4496
  : {}),
3221
4497
  });
3222
4498
  }
4499
+ // Each picture goes under the last reply of the turn it ended: the last
4500
+ // assistant entry at or before the point the turn ended at.
4501
+ for (const shot of listTurnShots(runner.session.id)) {
4502
+ const target = entries.findLast((entry) => entry.role === "assistant" && (entry.keepMessages ?? 0) <= shot.at);
4503
+ if (!target)
4504
+ continue;
4505
+ (target.shots ??= []).push(turnShotView(runner.session.id, shot));
4506
+ }
3223
4507
  return entries;
3224
4508
  }
3225
4509
  /**
@@ -3234,9 +4518,10 @@ export async function startWebServer(opts) {
3234
4518
  * Choosing a directory here is also a statement about what the user is
3235
4519
  * working on, so it is what the next launch opens.
3236
4520
  */
3237
- async function harnessFor(dir) {
4521
+ async function harnessFor(dir, remember = true) {
3238
4522
  const project = await addProject(dir);
3239
- void writePrefs({ project: project.path });
4523
+ if (remember)
4524
+ void writePrefs({ project: project.path });
3240
4525
  const existing = harnesses.get(project.path);
3241
4526
  if (existing)
3242
4527
  return existing;
@@ -3248,29 +4533,113 @@ export async function startWebServer(opts) {
3248
4533
  questionPrompt: (request, signal) => questionPrompt(request, signal),
3249
4534
  ...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
3250
4535
  ...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
4536
+ ...(opts.computerUsePlugin ? { computerUsePlugin: opts.computerUsePlugin } : {}),
3251
4537
  }));
3252
- harnesses.set(project.path, harness);
4538
+ harnesses.set(project.path, adoptRoutineModel(harness));
4539
+ equipThemes(harness.tools);
4540
+ harness.worktreeLauncher = launchWorktree;
4541
+ harness.taskWaker = wakeForTasks;
3253
4542
  watchConnections(harness);
3254
4543
  return harness;
3255
4544
  }
4545
+ /**
4546
+ * `worktree_start`, from a conversation: the worktree, a session in it, and
4547
+ * that session's first turn — in the background, leaving the asking tab on
4548
+ * screen. Not remembered as the startup project: the person did not go there.
4549
+ */
4550
+ const launchWorktree = async (request) => {
4551
+ const worktree = await createWorktree(request.from.cwd, {
4552
+ task: request.task,
4553
+ ...(request.slug ? { slug: request.slug } : {}),
4554
+ ...(request.type ? { type: request.type } : {}),
4555
+ });
4556
+ const harness = await harnessFor(worktree.cwd, false);
4557
+ const session = freshSession(harness);
4558
+ await session.init();
4559
+ session.title = request.task;
4560
+ const runner = makeRunner(session, harness);
4561
+ runners.set(session.id, runner);
4562
+ adoptTasks(runner);
4563
+ void runTurn(runner, request.prompt);
4564
+ broadcast({ type: "projects" });
4565
+ broadcast({ type: "sessions" });
4566
+ broadcast({ type: "notice", message: `started "${request.task}" in ${path.basename(worktree.path)}` });
4567
+ return {
4568
+ sessionId: session.id,
4569
+ path: worktree.path,
4570
+ kind: worktree.kind,
4571
+ ...(worktree.branch ? { branch: worktree.branch } : {}),
4572
+ };
4573
+ };
4574
+ for (const harness of harnesses.values())
4575
+ harness.worktreeLauncher = launchWorktree;
4576
+ /**
4577
+ * Background tasks ended while their conversation sat idle: give it a turn
4578
+ * to read their results in and act on them.
4579
+ *
4580
+ * A conversation mid-turn reads them in by itself at its next gap. One with
4581
+ * prompts still queued is left alone: that queue stands after an interrupt
4582
+ * or a failure for the person to send or drop, and a turn starting on its
4583
+ * own would decide for them. The results ride along with whatever runs next.
4584
+ */
4585
+ function wakeForTasks(sessionId) {
4586
+ const runner = runners.get(sessionId);
4587
+ if (!runner || runner.running || runner.queue.length)
4588
+ return;
4589
+ void runTurn(runner, "", [], [], false, true);
4590
+ }
4591
+ for (const harness of harnesses.values())
4592
+ harness.taskWaker = wakeForTasks;
4593
+ /** A worktree as the page sees it: keyed by the directory its sessions run in. */
4594
+ function worktreeView(worktree) {
4595
+ return {
4596
+ path: worktree.cwd,
4597
+ root: worktree.path,
4598
+ parent: worktree.source,
4599
+ kind: worktree.kind,
4600
+ ...(worktree.branch ? { branch: worktree.branch } : {}),
4601
+ task: worktree.task,
4602
+ };
4603
+ }
4604
+ /** The ways out of a worktree that make sense in its present state, best first. */
4605
+ function finishActions(worktree, status) {
4606
+ if (!status.exists)
4607
+ return ["discard"];
4608
+ if (status.clean)
4609
+ return ["remove"];
4610
+ if (worktree.kind === "clone")
4611
+ return worktree.shadow ? ["apply", "discard"] : ["discard"];
4612
+ if (status.changes.length)
4613
+ return ["commit", "discard"];
4614
+ return ["push", "merge", "discard"];
4615
+ }
3256
4616
  /*
3257
4617
  * Routines: which exist, where each runs, and — when this server was asked
3258
4618
  * to — the clock that fires them. A run lands in the page as a notice and,
3259
4619
  * through onRoutineRun, wherever the desktop can reach someone who is not
3260
4620
  * looking at the page.
3261
4621
  */
4622
+ // A routine starting or ending repaints the Routines page wherever it is open.
4623
+ const stopRunUpdates = onRunsChanged(() => broadcast({ type: "routines" }));
4624
+ // What a run is doing now, patched into its row in place: a repaint per
4625
+ // tool call would refetch every history on the page.
4626
+ const stopRunActivity = onRunActivity((run) => broadcast({ type: "routine_activity", name: run.schedule, activity: run.activity ?? "", turns: run.turns }));
3262
4627
  const routines = new RoutineRunner({
3263
4628
  defaultCwd: opts.session.cwd,
3264
4629
  openHarnesses: () => harnesses.values(),
3265
- createHarness: (cwd) => opts.openProject
3266
- ? opts.openProject(cwd)
3267
- : Harness.create({
3268
- cwd,
3269
- permissionPrompt: async () => false,
3270
- questionPrompt: async () => ({ outcome: "unavailable" }),
3271
- ...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
3272
- ...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
3273
- }),
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
+ },
3274
4643
  onStart: (routine, meta) => {
3275
4644
  if (meta.catchUp)
3276
4645
  broadcast({ type: "notice", message: `routine ${routine.def.name} is catching up on a run it missed` });
@@ -3311,6 +4680,7 @@ export async function startWebServer(opts) {
3311
4680
  await session.init();
3312
4681
  const runner = makeRunner(session, harness);
3313
4682
  runners.set(session.id, runner);
4683
+ adoptTasks(runner);
3314
4684
  focused = session.id;
3315
4685
  current = { session, agent: runner.agent, harness };
3316
4686
  const snapshot = state(runner);
@@ -3361,6 +4731,7 @@ export async function startWebServer(opts) {
3361
4731
  if (!runner) {
3362
4732
  runner = makeRunner(next, harness);
3363
4733
  runners.set(next.id, runner);
4734
+ adoptTasks(runner);
3364
4735
  }
3365
4736
  focused = next.id;
3366
4737
  // A reopened tab keeps the harness it was built on, which is the one its
@@ -3412,31 +4783,59 @@ export async function startWebServer(opts) {
3412
4783
  if (!next)
3413
4784
  return false;
3414
4785
  broadcastQueue(runner);
3415
- void runTurn(runner, next.prompt, next.staged, next.references);
4786
+ void runTurn(runner, next.prompt, next.staged, next.references, next.goal);
3416
4787
  return true;
3417
4788
  }
3418
- async function runTurn(runner, prompt, staged = [], references = []) {
4789
+ async function runTurn(runner, prompt, staged = [], references = [], goal = false,
4790
+ /**
4791
+ * Started by background tasks ending while the conversation was idle:
4792
+ * nobody typed anything, and the run's only input is their results,
4793
+ * which the agent reads in itself.
4794
+ */
4795
+ wake = false) {
3419
4796
  const controller = new AbortController();
3420
4797
  runner.running = controller;
3421
4798
  const id = runner.session.id;
4799
+ suggesting.get(id)?.abort();
4800
+ suggesting.delete(id);
4801
+ // Background tasks only ever start inside a turn, so this is the first
4802
+ // moment worth watching them.
4803
+ watchTasks(runner);
4804
+ // A subagent asked for isolation makes a worktree inside the turn, where
4805
+ // nothing tells the page; comparing before and after is what does. Read
4806
+ // alongside the turn, never in its way.
4807
+ const worktreesBefore = listWorktrees().then((list) => list.length, () => -1);
4808
+ if (goal) {
4809
+ runner.session.setGoal(prompt);
4810
+ void runner.session.persist().catch(() => { });
4811
+ }
3422
4812
  // Every event carries its session so the page can route it to the tab it
3423
4813
  // belongs to; without it a reply from a background tab would stream into
3424
4814
  // whichever one happens to be on screen.
3425
- broadcast({
3426
- type: "prompt",
3427
- session: id,
3428
- text: prompt,
3429
- ...(staged.length ? { attachments: staged.map((file) => file.name) } : {}),
3430
- // The transcript shows what was referenced, not just what was typed:
3431
- // otherwise a turn that reads three files looks like it went and found
3432
- // them on its own.
3433
- ...(references.length ? { references } : {}),
3434
- });
4815
+ if (!wake)
4816
+ broadcast({
4817
+ type: "prompt",
4818
+ session: id,
4819
+ text: prompt,
4820
+ ...(staged.length ? { attachments: staged.map((file) => file.name) } : {}),
4821
+ // The transcript shows what was referenced, not just what was typed:
4822
+ // otherwise a turn that reads three files looks like it went and found
4823
+ // them on its own.
4824
+ ...(references.length ? { references } : {}),
4825
+ ...(goal ? { goal: true } : {}),
4826
+ });
4827
+ if (goal)
4828
+ broadcast({ type: "state", session: id, state: state(runner) });
3435
4829
  // The note goes above the prompt rather than after it, so the model has
3436
4830
  // the context before it reads the request that depends on it.
3437
4831
  const note = referenceNote(references);
3438
4832
  const text = note ? `${note}\n\n${prompt}` : prompt;
3439
- const input = staged.length ? buildMessage(text, staged) : text;
4833
+ const input = wake ? [] : staged.length
4834
+ ? await buildMessage(text, staged, {
4835
+ documents: runner.session.model.supportsDocuments === true,
4836
+ saveDir: projectPath(runner.session.cwd, "attachments", id),
4837
+ })
4838
+ : text;
3440
4839
  let failed = false;
3441
4840
  // The session names itself from its first prompt once the agent takes it
3442
4841
  // in. Said the moment that happens: waiting for the idle state left a new
@@ -3454,6 +4853,12 @@ export async function startWebServer(opts) {
3454
4853
  // the copy without that id could only ever 404.
3455
4854
  if (event.type === "question_request")
3456
4855
  continue;
4856
+ // The same for permissions: the card comes from permissionPrompt's
4857
+ // announce, which carries the id it is answered by. The agent's own
4858
+ // event has none, and with two calls asking at once a card drawn
4859
+ // from it outlived its answer.
4860
+ if (event.type === "permission_request")
4861
+ continue;
3457
4862
  // The agent reports a provider failure as an event and breaks its own
3458
4863
  // loop rather than throwing, so the catch below never sees it — and a
3459
4864
  // queue that drained into a dead provider would replay the failure
@@ -3469,6 +4874,9 @@ export async function startWebServer(opts) {
3469
4874
  continue;
3470
4875
  }
3471
4876
  broadcast({ ...event, session: id });
4877
+ // The line over the composer reads the goal off the state.
4878
+ if (event.type === "goal")
4879
+ broadcast({ type: "state", session: id, state: state(runner) });
3472
4880
  }
3473
4881
  }
3474
4882
  catch (err) {
@@ -3515,11 +4923,15 @@ export async function startWebServer(opts) {
3515
4923
  state: state(runner),
3516
4924
  changes: runner.ledger.list(),
3517
4925
  });
4926
+ void Promise.all([worktreesBefore, listWorktrees()]).then(([before, after]) => {
4927
+ if (before !== after.length)
4928
+ broadcast({ type: "projects" });
4929
+ }, () => { });
3518
4930
  // Only after a turn that ended on its own. An interrupt means stop, and
3519
4931
  // an error would usually meet the next prompt with the same error, so
3520
4932
  // both leave the queue standing for the person to send or drop.
3521
- if (!controller.signal.aborted && !failed)
3522
- drainQueue(runner);
4933
+ if (!controller.signal.aborted && !failed && !drainQueue(runner))
4934
+ suggest(runner);
3523
4935
  }
3524
4936
  }
3525
4937
  function stream(res) {
@@ -3533,6 +4945,8 @@ export async function startWebServer(opts) {
3533
4945
  });
3534
4946
  res.write(": connected\n\n");
3535
4947
  clients.add(res);
4948
+ for (const card of pairingCards())
4949
+ res.write(`data: ${JSON.stringify(card)}\n\n`);
3536
4950
  for (const harness of harnesses.values()) {
3537
4951
  for (const card of harness.cloudConnectors.snapshots()) {
3538
4952
  res.write(`data: ${JSON.stringify({ type: "connection_card", session: card.session, card })}\n\n`);
@@ -3598,6 +5012,40 @@ export async function startWebServer(opts) {
3598
5012
  * not know about until the turn lands, and the page asking is the page that
3599
5013
  * spent it.
3600
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
+ }
3601
5049
  async function usageRows(scan) {
3602
5050
  const stored = await current.harness.listSessions(scan);
3603
5051
  const rows = new Map();
@@ -3772,25 +5220,120 @@ export async function startWebServer(opts) {
3772
5220
  refreshStale();
3773
5221
  const staleTimer = setInterval(refreshStale, 6 * 60 * 60 * 1000);
3774
5222
  staleTimer.unref?.();
5223
+ const stopHeartbeat = startHeartbeat();
3775
5224
  if (opts.routines)
3776
5225
  routines.start();
3777
- if (opts.onKeepAwake) {
3778
- const keepAwake = opts.onKeepAwake;
3779
- void readPrefs().then((prefs) => keepAwake(prefs.keepAwake === true)).catch(() => { });
3780
- }
5226
+ void applyKeepAwake().catch(() => { });
3781
5227
  const address = server.address();
3782
5228
  const port = typeof address === "object" && address ? address.port : opts.port;
3783
5229
  const presence = opts.presence === false
3784
5230
  ? undefined
3785
5231
  : announcePresence({ url: `http://${host}:${port}`, token, kind: opts.presence?.kind ?? "web" });
5232
+ // Phones waiting at the door: the card on this page is the only way in.
5233
+ const pairings = new Map();
5234
+ const announcePairing = (request) => ({ type: "remote_pair_request", ...request });
5235
+ const closePairing = (id) => {
5236
+ if (pairings.delete(id))
5237
+ broadcast({ type: "remote_pair_closed", id });
5238
+ };
5239
+ pairingCards = () => [...pairings.values()].map((entry) => announcePairing(entry.request));
5240
+ answerPairing = (id, allowed) => {
5241
+ const entry = pairings.get(id);
5242
+ if (!entry)
5243
+ return false;
5244
+ entry.settle(allowed);
5245
+ return true;
5246
+ };
5247
+ let closed = false;
5248
+ const remoteToken = opts.remote?.token ?? (async () => process.env["OHARNESS_REMOTE_TOKEN"] || (await current.harness.accountToken()));
5249
+ let remote;
5250
+ // One change at a time: a quick off-on-off must not leave two connections,
5251
+ // or one still running behind a switch that says off.
5252
+ let remoteChange = Promise.resolve();
5253
+ setRemote = (on) => {
5254
+ remoteChange = remoteChange.then(async () => {
5255
+ if (!opts.remote || closed)
5256
+ return;
5257
+ if (on && !remote) {
5258
+ const identity = await remoteMachine();
5259
+ if (closed)
5260
+ return;
5261
+ const relay = opts.remote.relay;
5262
+ remote = startRemote({
5263
+ relay,
5264
+ token: remoteToken,
5265
+ ...identity,
5266
+ local: `http://${host}:${port}/?token=${token}`,
5267
+ approve: (request, signal) => new Promise((resolve) => {
5268
+ const settle = (allowed) => {
5269
+ closePairing(request.id);
5270
+ resolve(allowed);
5271
+ };
5272
+ pairings.set(request.id, { request, settle });
5273
+ signal.addEventListener("abort", () => settle(false), { once: true });
5274
+ broadcast(announcePairing(request));
5275
+ }),
5276
+ withdrawn: closePairing,
5277
+ log: (line) => console.error(line),
5278
+ });
5279
+ console.error(`remote control on through ${relay} — new phones need your Allow on this page`);
5280
+ }
5281
+ else if (!on && remote) {
5282
+ remote.close();
5283
+ remote = undefined;
5284
+ // A phone still waiting at the door is turned away with the rest.
5285
+ for (const entry of [...pairings.values()])
5286
+ entry.settle(false);
5287
+ console.error("remote control off");
5288
+ }
5289
+ await applyKeepAwake();
5290
+ });
5291
+ return remoteChange;
5292
+ };
5293
+ remoteRunning = () => Boolean(remote);
5294
+ forgetPhone = (id) => remote?.forget(id);
5295
+ remoteView = async () => {
5296
+ const signedIn = opts.remote ? Boolean(await remoteToken().catch(() => undefined)) : false;
5297
+ return {
5298
+ available: Boolean(opts.remote),
5299
+ on: Boolean(remote),
5300
+ connected: remote?.connected ?? false,
5301
+ // What the switch row says: off, on but nobody to be (no account), on
5302
+ // and reaching for the relay, or there and reachable.
5303
+ state: !remote ? "off" : remote.connected ? "online" : signedIn ? "connecting" : "signed-out",
5304
+ signedIn,
5305
+ // The desktop holds the computer awake while remote is on; a browser tab cannot.
5306
+ keepsAwake: Boolean(opts.onKeepAwake),
5307
+ devices: (await pairedDevices()).map(({ id, name, pairedAt }) => ({ id, name, pairedAt })),
5308
+ pending: pairingCards(),
5309
+ };
5310
+ };
5311
+ if (opts.remote) {
5312
+ const saved = (await readPrefs().catch(() => ({}))).remote === true;
5313
+ if (opts.remote.always || saved)
5314
+ await setRemote(true);
5315
+ }
3786
5316
  return {
3787
5317
  url: `http://${host}:${port}/?token=${token}`,
3788
5318
  token,
3789
5319
  permissionPrompt,
3790
5320
  questionPrompt,
5321
+ interrupt(sessionId) {
5322
+ runners.get(sessionId)?.running?.abort();
5323
+ },
3791
5324
  async close() {
3792
5325
  clearInterval(staleTimer);
5326
+ stopHeartbeat();
5327
+ await flushAnalytics();
3793
5328
  presence?.close();
5329
+ closed = true;
5330
+ await remoteChange.catch(() => { });
5331
+ remote?.close();
5332
+ remote = undefined;
5333
+ for (const entry of [...pairings.values()])
5334
+ entry.settle(false);
5335
+ stopRunUpdates();
5336
+ stopRunActivity();
3794
5337
  await routines.close();
3795
5338
  for (const unsubscribe of connectionSubscriptions)
3796
5339
  unsubscribe();
@@ -4093,6 +5636,13 @@ function closeTab(res, status, message, hint) {
4093
5636
  res.end(page);
4094
5637
  }
4095
5638
  /** The extensions a picked file is carried as an image under. */
5639
+ /** Clips the tray can play as a preview of a picked file. */
5640
+ const PICKED_VIDEO_TYPES = {
5641
+ ".mp4": "video/mp4",
5642
+ ".m4v": "video/mp4",
5643
+ ".mov": "video/quicktime",
5644
+ ".webm": "video/webm",
5645
+ };
4096
5646
  const PICKED_IMAGE_TYPES = {
4097
5647
  ".png": "image/png",
4098
5648
  ".jpg": "image/jpeg",
@@ -4118,6 +5668,8 @@ const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
4118
5668
  const STAGED_TTL_MS = 30 * 60 * 1000;
4119
5669
  /** The ceiling on everything staged at once, across every tab. */
4120
5670
  const MAX_STAGED_BYTES = 64 * 1024 * 1024;
5671
+ /** One feedback screenshot or recording; the site's bucket takes no more. */
5672
+ const FEEDBACK_MAX_BYTES = 50 * 1024 * 1024;
4121
5673
  /** How much of a text attachment is inlined into the prompt. */
4122
5674
  const MAX_INLINE_CHARS = 200_000;
4123
5675
  async function readBody(req, limit) {
@@ -4195,9 +5747,47 @@ function readReferences(value) {
4195
5747
  }
4196
5748
  return out;
4197
5749
  }
4198
- function buildMessage(prompt, staged) {
5750
+ /**
5751
+ * The user's message, with what they attached.
5752
+ *
5753
+ * An image goes to the model as an image. A PDF goes whole, as a document the
5754
+ * model reads page by page, when the model reads them (`documents`). A text
5755
+ * file is pasted in. Anything else — and every PDF too — is saved in the
5756
+ * project, under .oharness/attachments/<session>/, and its path given, so the
5757
+ * agent can open it with its own tools: a spreadsheet with Python, a model
5758
+ * that takes no PDFs with a PDF tool. Binary files used to be skipped, and
5759
+ * the person was left believing the agent had read what it never received.
5760
+ */
5761
+ /** A user message's text without the attachment lines buildMessage adds to it. */
5762
+ export function withoutAttachmentNotes(text) {
5763
+ return text
5764
+ .split("\n")
5765
+ .filter((line) => !/^\[(image|file attached|PDF attached|skipped [^\]]*): /.test(line))
5766
+ .join("\n")
5767
+ .replace(/\n{3,}/g, "\n\n");
5768
+ }
5769
+ export async function buildMessage(prompt, staged, opts) {
4199
5770
  const content = [];
4200
5771
  const notes = [];
5772
+ /**
5773
+ * Clips saved for the agent, kept on the message's meta (which never
5774
+ * reaches the model) so a reloaded transcript can play them in the bubble
5775
+ * they were sent in, from the copy in the project.
5776
+ */
5777
+ const clips = [];
5778
+ const save = async (file) => {
5779
+ await mkdir(opts.saveDir, { recursive: true });
5780
+ // The name as uploaded, made safe as a file name; a second file of the
5781
+ // same name in the session gets a number rather than replacing the first.
5782
+ const base = path.basename(file.name).replace(/[\u0000-\u001f/\\:]/g, "_") || "attachment";
5783
+ const ext = path.extname(base);
5784
+ let target = path.join(opts.saveDir, base);
5785
+ for (let n = 2; await stat(target).then(() => true, () => false); n++) {
5786
+ target = path.join(opts.saveDir, `${base.slice(0, base.length - ext.length)} (${n})${ext}`);
5787
+ }
5788
+ await writeFile(target, file.data);
5789
+ return target;
5790
+ };
4201
5791
  for (const file of staged) {
4202
5792
  if (file.mediaType.startsWith("image/")) {
4203
5793
  content.push({
@@ -4208,11 +5798,27 @@ function buildMessage(prompt, staged) {
4208
5798
  notes.push(`[image: ${file.name}]`);
4209
5799
  continue;
4210
5800
  }
5801
+ const pdf = file.mediaType === "application/pdf" || /\.pdf$/i.test(file.name);
5802
+ if (pdf) {
5803
+ const saved = await save(file);
5804
+ if (opts.documents) {
5805
+ content.push({ type: "document", mimeType: "application/pdf", data: file.data.toString("base64"), name: file.name });
5806
+ notes.push(`[PDF attached: ${file.name} — also saved at ${saved}]`);
5807
+ }
5808
+ else {
5809
+ notes.push(`[PDF attached: ${file.name} — this model cannot read PDFs directly; it is saved at ${saved}, open it with your tools]`);
5810
+ }
5811
+ continue;
5812
+ }
4211
5813
  const text = file.data.toString("utf8");
4212
5814
  // A NUL early on means binary, and pasting its bytes into the prompt
4213
5815
  // wastes context without conveying anything.
4214
5816
  if (file.data.subarray(0, 8000).includes(0)) {
4215
- notes.push(`[skipped ${file.name}: binary, ${file.data.byteLength} bytes]`);
5817
+ const saved = await save(file);
5818
+ notes.push(`[file attached: ${file.name} (${file.mediaType}, ${file.data.byteLength} bytes) — saved at ${saved}, open it with your tools]`);
5819
+ if (file.mediaType.startsWith("video/") || /\.(mp4|m4v|mov|webm)$/i.test(file.name)) {
5820
+ clips.push({ kind: "video", name: file.name, path: saved });
5821
+ }
4216
5822
  continue;
4217
5823
  }
4218
5824
  const clipped = text.length > MAX_INLINE_CHARS
@@ -4222,16 +5828,75 @@ function buildMessage(prompt, staged) {
4222
5828
  }
4223
5829
  const body = [prompt, ...notes].filter(Boolean).join("\n\n");
4224
5830
  content.unshift({ type: "text", text: body });
4225
- return [{ role: "user", content }];
5831
+ return [{ role: "user", content, ...(clips.length ? { meta: { attachments: clips } } : {}) }];
5832
+ }
5833
+ /** Dictation: the formats a browser's MediaRecorder writes, the cap, the models. */
5834
+ const VOICE_FORMATS = new Set(["webm", "ogg", "m4a", "mp4", "mp3", "wav"]);
5835
+ const VOICE_MAX_SECONDS = 300;
5836
+ const VOICE_MAX_BODY_BYTES = 20_000_000;
5837
+ const VOICE_TRANSCRIBE_MODEL = "gpt-4o-mini-transcribe";
5838
+ /** For a Chinese-speaking user: Qwen's recogniser, strongest on Chinese and its dialects. */
5839
+ const VOICE_TRANSCRIBE_MODEL_ZH = "qwen3-asr-flash";
5840
+ const VOICE_TIDY_PROMPT = [
5841
+ "You tidy dictated text into the message the speaker meant to type to an AI assistant.",
5842
+ "Remove filler words, false starts, stutters and repeated words; fix punctuation, casing and obvious mis-hearings; turn spoken list markers into a list only when the speaker clearly dictated one.",
5843
+ "Keep the speaker's language (do not translate), their wording, meaning, names, numbers, code and technical terms. Do not add, answer, explain, summarise or comment.",
5844
+ "If the text is already clean, return it unchanged. Reply with the tidied text only.",
5845
+ ].join("\n");
5846
+ /**
5847
+ * The small model to tidy dictation with when the user brought their own
5848
+ * key: the configured small model (or default), if its provider is not the
5849
+ * OHarness plan and has credentials here. Undefined otherwise — tidying on the
5850
+ * plan is the gateway's to decide, by subscription.
5851
+ */
5852
+ async function ownSmallModel(harness) {
5853
+ const ref = harness.config.smallModel ?? harness.config.model;
5854
+ if (!ref)
5855
+ return undefined;
5856
+ try {
5857
+ const model = harness.providers.resolve(ref);
5858
+ if (model.provider === "official")
5859
+ return undefined;
5860
+ return (await harness.configuredProviders()).has(model.provider) ? model.id : undefined;
5861
+ }
5862
+ catch {
5863
+ return undefined;
5864
+ }
4226
5865
  }
4227
- async function readJson(req) {
5866
+ /** Straight to OpenAI's transcription API with the user's own key. */
5867
+ async function transcribeWithOpenAI(key, audio, format, language) {
5868
+ const bytes = Buffer.from(audio.replace(/^data:[^,]*,/, ""), "base64");
5869
+ const form = new FormData();
5870
+ form.set("file", new Blob([bytes]), `dictation.${format}`);
5871
+ form.set("model", VOICE_TRANSCRIBE_MODEL);
5872
+ form.set("response_format", "json");
5873
+ if (language)
5874
+ form.set("language", language);
5875
+ try {
5876
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
5877
+ method: "POST",
5878
+ headers: { authorization: `Bearer ${key}` },
5879
+ body: form,
5880
+ signal: AbortSignal.timeout(60_000),
5881
+ });
5882
+ const parsed = (await response.json().catch(() => ({})));
5883
+ if (!response.ok)
5884
+ return { ok: false, error: typeof parsed.error?.message === "string" ? parsed.error.message : `OpenAI answered ${response.status}` };
5885
+ return { ok: true, value: typeof parsed.text === "string" ? parsed.text : "" };
5886
+ }
5887
+ catch (err) {
5888
+ return { ok: false, error: `could not reach OpenAI: ${errorMessage(err)}` };
5889
+ }
5890
+ }
5891
+ async function readJson(req, limit = 1_000_000) {
4228
5892
  const chunks = [];
4229
5893
  let size = 0;
4230
5894
  for await (const chunk of req) {
4231
5895
  size += chunk.length;
4232
5896
  // A prompt is text; anything this large is a mistake or an attempt to
4233
5897
  // exhaust memory, and reading it all first would be the vulnerability.
4234
- if (size > 1_000_000)
5898
+ // Dictation is the one caller that sends more: a recording, as base64.
5899
+ if (size > limit)
4235
5900
  throw new Error("request body too large");
4236
5901
  chunks.push(chunk);
4237
5902
  }