@oharness/server 0.9.3 → 0.10.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 (59) hide show
  1. package/dist/i18n.js +354 -62
  2. package/dist/i18n.js.map +1 -1
  3. package/dist/main.js +4 -0
  4. package/dist/main.js.map +1 -1
  5. package/dist/page/client/account.js +430 -23
  6. package/dist/page/client/account.js.map +1 -1
  7. package/dist/page/client/base.js +20 -1
  8. package/dist/page/client/base.js.map +1 -1
  9. package/dist/page/client/composer.js +693 -54
  10. package/dist/page/client/composer.js.map +1 -1
  11. package/dist/page/client/computer.d.ts +27 -0
  12. package/dist/page/client/computer.js +358 -0
  13. package/dist/page/client/computer.js.map +1 -0
  14. package/dist/page/client/dialogs.js +93 -6
  15. package/dist/page/client/dialogs.js.map +1 -1
  16. package/dist/page/client/events.js +94 -7
  17. package/dist/page/client/events.js.map +1 -1
  18. package/dist/page/client/files.js +86 -1
  19. package/dist/page/client/files.js.map +1 -1
  20. package/dist/page/client/index.js +4 -0
  21. package/dist/page/client/index.js.map +1 -1
  22. package/dist/page/client/panels.js +10 -6
  23. package/dist/page/client/panels.js.map +1 -1
  24. package/dist/page/client/routines.js +296 -41
  25. package/dist/page/client/routines.js.map +1 -1
  26. package/dist/page/client/shell.js +187 -51
  27. package/dist/page/client/shell.js.map +1 -1
  28. package/dist/page/client/state.js +273 -19
  29. package/dist/page/client/state.js.map +1 -1
  30. package/dist/page/client/tasks.d.ts +8 -0
  31. package/dist/page/client/tasks.js +293 -0
  32. package/dist/page/client/tasks.js.map +1 -0
  33. package/dist/page/client/transcript.js +137 -5
  34. package/dist/page/client/transcript.js.map +1 -1
  35. package/dist/page/client/usage.js +1 -11
  36. package/dist/page/client/usage.js.map +1 -1
  37. package/dist/page/style.js +568 -60
  38. package/dist/page/style.js.map +1 -1
  39. package/dist/page.js +90 -11
  40. package/dist/page.js.map +1 -1
  41. package/dist/prefs.d.ts +14 -0
  42. package/dist/prefs.js +8 -1
  43. package/dist/prefs.js.map +1 -1
  44. package/dist/remote-crypto.d.ts +78 -0
  45. package/dist/remote-crypto.js +216 -0
  46. package/dist/remote-crypto.js.map +1 -0
  47. package/dist/remote.d.ts +103 -0
  48. package/dist/remote.js +498 -0
  49. package/dist/remote.js.map +1 -0
  50. package/dist/server.d.ts +70 -0
  51. package/dist/server.js +1291 -49
  52. package/dist/server.js.map +1 -1
  53. package/dist/starters.d.ts +92 -0
  54. package/dist/starters.js +213 -0
  55. package/dist/starters.js.map +1 -0
  56. package/dist/turn-shots.d.ts +27 -0
  57. package/dist/turn-shots.js +71 -0
  58. package/dist/turn-shots.js.map +1 -0
  59. 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,8 +25,11 @@ 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, turnShotSrc } 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";
@@ -39,6 +45,8 @@ 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 { pairedDevices, remoteMachine, revokeDevice, startRemote, } from "./remote.js";
42
50
  /**
43
51
  * How many stored sessions the usage page counts.
44
52
  *
@@ -214,8 +222,93 @@ export async function startWebServer(opts) {
214
222
  const artifactToken = randomUUID();
215
223
  const host = opts.host ?? "127.0.0.1";
216
224
  const clients = new Set();
225
+ // Remote control's half of this page, filled in once the server is listening.
226
+ let pairingCards = () => [];
227
+ let answerPairing = () => false;
228
+ let remoteView = async () => ({ available: false, on: false, state: "off" });
229
+ let setRemote = async () => { };
230
+ let forgetPhone = () => { };
231
+ /**
232
+ * Whether the computer is held awake: by the setting for it, or because a
233
+ * phone may be reaching for it. A Mac asleep is a Mac the phone cannot find.
234
+ */
235
+ let remoteRunning = () => false;
236
+ const applyKeepAwake = async () => {
237
+ if (!opts.onKeepAwake)
238
+ return;
239
+ const prefs = await readPrefs().catch(() => ({}));
240
+ opts.onKeepAwake(prefs.keepAwake === true || remoteRunning());
241
+ };
217
242
  const pending = new Map();
218
243
  const runners = new Map();
244
+ /** Agents whose background tasks are being watched; see watchTasks. */
245
+ const watchedTasks = new WeakSet();
246
+ // Written in the background; the page on that project swaps them in.
247
+ const starters = createStarters((cwd, locale, written) => {
248
+ broadcast({ type: "starters", cwd, locale, starters: written });
249
+ });
250
+ /**
251
+ * What the OS lets Computer Use do, read from the helper when a conversation
252
+ * switches it on and whenever the permission card asks again. Cached because
253
+ * `state()` is synchronous and sent on every change; the grants can be
254
+ * revoked at any time, and the tools report that themselves when it happens.
255
+ */
256
+ const computerStatus = {};
257
+ /**
258
+ * The live view: what each conversation is doing on the computer, pushed as
259
+ * it happens. The last event per conversation is kept, so a pane opened
260
+ * later, or a reloaded page, starts on the window being worked in.
261
+ */
262
+ const computerLast = new Map();
263
+ /**
264
+ * Where each conversation's last Computer use turn ended, in messages. Read
265
+ * on the idle event, which the engine sends synchronously as the turn ends:
266
+ * the pictures arrive a moment later, by which time a queued message may
267
+ * already have started the next turn.
268
+ */
269
+ const computerTurnEnd = new Map();
270
+ opts.computerUsePlugin?.runtime.onAction((event) => {
271
+ if (event.kind === "idle") {
272
+ const runner = runners.get(event.sessionId);
273
+ if (runner)
274
+ computerTurnEnd.set(event.sessionId, runner.session.messages.length);
275
+ }
276
+ computerLast.set(event.sessionId, event);
277
+ broadcast({ type: "computer", session: event.sessionId, event });
278
+ });
279
+ opts.computerUsePlugin?.runtime.onSnapshot((event) => {
280
+ const at = computerTurnEnd.get(event.sessionId);
281
+ if (at === undefined)
282
+ return;
283
+ void saveTurnShots(event.sessionId, at, event.shots)
284
+ .then((saved) => {
285
+ broadcast({
286
+ type: "turn_shots",
287
+ session: event.sessionId,
288
+ shots: saved.map((shot) => ({ app: shot.app, title: shot.title, src: turnShotSrc(event.sessionId, shot.file) })),
289
+ });
290
+ })
291
+ .catch(() => { });
292
+ });
293
+ async function refreshComputer(plugin) {
294
+ // Grants first and on their own: they come from the host app, and a
295
+ // helper that will not start must not leave the card showing old ones.
296
+ try {
297
+ computerStatus.permissions = await plugin.runtime.permissions();
298
+ }
299
+ catch (err) {
300
+ computerStatus.error = errorMessage(err);
301
+ }
302
+ try {
303
+ const info = await plugin.runtime.info();
304
+ computerStatus.platform = info.platform;
305
+ computerStatus.background = info.background;
306
+ delete computerStatus.error;
307
+ }
308
+ catch (err) {
309
+ computerStatus.error = errorMessage(err);
310
+ }
311
+ }
219
312
  const anyRunning = () => [...runners.values()].some((runner) => Boolean(runner.running));
220
313
  const makeRunner = (session, harness) => {
221
314
  const ledger = new ChangeLedger(session.cwd);
@@ -237,6 +330,7 @@ export async function startWebServer(opts) {
237
330
  // browser is still the same one bound by Harness.agent(session).
238
331
  initial.agent = opts.agent;
239
332
  runners.set(opts.session.id, initial);
333
+ adoptTasks(initial);
240
334
  let current = { session: opts.session, agent: opts.agent, harness: opts.harness };
241
335
  /**
242
336
  * Sign-ins waiting for their emailed code.
@@ -266,6 +360,40 @@ export async function startWebServer(opts) {
266
360
  * because rebuilding one reconnects every MCP server.
267
361
  */
268
362
  const harnesses = new Map([[opts.session.cwd, opts.harness]]);
363
+ /**
364
+ * The mode new conversations start in: the last one chosen, with two
365
+ * exceptions. Bypass permissions approves everything without asking, and a
366
+ * mode that strong is chosen for a conversation, not inherited by every one
367
+ * after it — a new conversation gets Auto instead. Plan is a step within a
368
+ * task, so choosing it leaves the default where it was.
369
+ *
370
+ * Written to the user config, where new conversations already take their
371
+ * mode from, and to each harness this server holds, which read that config
372
+ * once when they were built.
373
+ */
374
+ async function rememberMode(mode) {
375
+ if (mode === "plan")
376
+ return;
377
+ const next = mode === "bypassPermissions" ? "auto" : mode;
378
+ for (const harness of new Set([current.harness, ...harnesses.values()])) {
379
+ harness.config.permissionMode = next;
380
+ }
381
+ await updateUserConfig((config) => {
382
+ const { permissionMode: _was, ...rest } = config;
383
+ return next === "default" ? rest : { ...rest, permissionMode: next };
384
+ }).catch(() => { });
385
+ }
386
+ /**
387
+ * Routines that name no model — or one that is gone — run on the one picked
388
+ * in the composer, when this harness has it. Without this they took the
389
+ * first-configured model, which can be a local server that is not running
390
+ * or a gated one the account never unlocked.
391
+ */
392
+ const adoptRoutineModel = (harness) => {
393
+ harness.routineModel = () => preferredModel && harness.listModels().some((model) => model.id === preferredModel) ? preferredModel : undefined;
394
+ return harness;
395
+ };
396
+ adoptRoutineModel(opts.harness);
269
397
  void ensureProject(opts.session.cwd);
270
398
  /**
271
399
  * The model the user last picked, carried into every session started here.
@@ -302,6 +430,14 @@ export async function startWebServer(opts) {
302
430
  * reclaimed by `sweepAttachments`.
303
431
  */
304
432
  const attachments = new Map();
433
+ /**
434
+ * Files the person chose in the system file dialog, which the page may show
435
+ * a picture of — and only those. Outside the project the page has no other
436
+ * way to see a file (/files/raw stays inside it), and a path the person
437
+ * picked themselves, a moment ago, in a dialog of their own OS is the one
438
+ * kind it is safe to open on their behalf.
439
+ */
440
+ const pickedPaths = new Set();
305
441
  /**
306
442
  * Carry a chosen file whose bytes are the only way it can mean anything.
307
443
  *
@@ -322,7 +458,8 @@ export async function startWebServer(opts) {
322
458
  // Images by extension, and everything else left unnamed: `buildMessage`
323
459
  // decides text from bytes rather than from a type, so guessing one for
324
460
  // a .log would add nothing and could be wrong.
325
- const mediaType = PICKED_IMAGE_TYPES[path.extname(target).toLowerCase()] ?? "application/octet-stream";
461
+ const ext = path.extname(target).toLowerCase();
462
+ const mediaType = PICKED_IMAGE_TYPES[ext] ?? PICKED_VIDEO_TYPES[ext] ?? "application/octet-stream";
326
463
  const id = randomUUID();
327
464
  attachments.set(id, { name, mediaType, data, at: Date.now() });
328
465
  sweepAttachments();
@@ -742,6 +879,25 @@ export async function startWebServer(opts) {
742
879
  }
743
880
  case "GET /events":
744
881
  return stream(res);
882
+ /**
883
+ * What the empty state offers, for the session's project: the fixed
884
+ * three while the project has no memory, three written from its notes
885
+ * once it does. Asked separately from /state because writing them can
886
+ * take the small model a few seconds, and the page must not wait on it.
887
+ */
888
+ case "GET /starters": {
889
+ // Only a project already open: asking must not open one, which would
890
+ // connect its MCP servers and make it the one the next launch opens.
891
+ const cwd = url.searchParams.get("cwd") || current.session.cwd;
892
+ const harness = cwd === current.session.cwd ? current.harness : harnesses.get(cwd);
893
+ if (!harness)
894
+ return send(res, 200, { kind: "fixed" });
895
+ const locale = url.searchParams.get("locale") ?? "en";
896
+ // The model picked in the composer is one the user has reached; the
897
+ // first-configured one may sit behind a gate they never passed.
898
+ const picked = (await readPrefs()).model;
899
+ return send(res, 200, await starters(harness, cwd, locale, picked));
900
+ }
745
901
  case "GET /state": {
746
902
  const runner = runnerFor(url.searchParams.get("session"));
747
903
  if (!runner)
@@ -757,13 +913,41 @@ export async function startWebServer(opts) {
757
913
  * the page, and without this its chip would be the only one in the row
758
914
  * with no picture on it.
759
915
  */
916
+ case "GET /pick/preview": {
917
+ // A picture of a file picked in the dialog: an image, or a clip the
918
+ // tray plays muted. Nothing else, and nothing not picked.
919
+ const target = path.resolve(url.searchParams.get("path") ?? "");
920
+ const type = PICKED_IMAGE_TYPES[path.extname(target).toLowerCase()] ?? PICKED_VIDEO_TYPES[path.extname(target).toLowerCase()];
921
+ if (!pickedPaths.has(target) || !type)
922
+ return send(res, 404, { error: "not a picked picture" });
923
+ let size = 0;
924
+ try {
925
+ size = (await stat(target)).size;
926
+ }
927
+ catch {
928
+ return send(res, 404, { error: "not a picked picture" });
929
+ }
930
+ res.writeHead(200, {
931
+ "content-type": type,
932
+ "content-length": size,
933
+ "x-content-type-options": "nosniff",
934
+ "content-security-policy": "default-src 'none'; sandbox",
935
+ "cache-control": "private, no-store",
936
+ });
937
+ const stream = createReadStream(target);
938
+ stream.on("error", () => res.destroy());
939
+ res.on("close", () => stream.destroy());
940
+ stream.pipe(res);
941
+ return;
942
+ }
760
943
  case "GET /upload/preview": {
761
944
  const staged = attachments.get(url.searchParams.get("id") ?? "");
762
945
  if (!staged)
763
946
  return send(res, 404, { error: "no such upload" });
764
947
  // 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)) {
948
+ // this is served same-origin to a page holding the session token —
949
+ // and the clips the tray can play, which no browser runs as a page.
950
+ if (!VIEWABLE_IMAGE_TYPES.has(staged.mediaType) && !Object.values(PICKED_VIDEO_TYPES).includes(staged.mediaType)) {
767
951
  return send(res, 415, { error: `cannot display ${staged.mediaType}` });
768
952
  }
769
953
  res.writeHead(200, {
@@ -795,7 +979,13 @@ export async function startWebServer(opts) {
795
979
  const index = Number(url.searchParams.get("message"));
796
980
  const position = Number(url.searchParams.get("block"));
797
981
  const message = Number.isInteger(index) ? runner.session.messages[index - 1] : undefined;
798
- const block = Number.isInteger(position) ? message?.content[position] : undefined;
982
+ let block = Number.isInteger(position) ? message?.content[position] : undefined;
983
+ // A tool's own image, such as a screenshot, sits one level down: inside
984
+ // the tool_result, at `part`.
985
+ if (block?.type === "tool_result" && url.searchParams.has("part")) {
986
+ const part = Number(url.searchParams.get("part"));
987
+ block = Number.isInteger(part) ? block.content[part] : undefined;
988
+ }
799
989
  if (!block || block.type !== "image")
800
990
  return send(res, 404, { error: "no such image" });
801
991
  // The media type on a block is whatever the upload claimed, and this
@@ -819,6 +1009,28 @@ export async function startWebServer(opts) {
819
1009
  res.end(data);
820
1010
  return;
821
1011
  }
1012
+ case "GET /turn-shot": {
1013
+ const file = turnShotPath(String(url.searchParams.get("session") ?? ""), String(url.searchParams.get("file") ?? ""));
1014
+ if (!file)
1015
+ return send(res, 404, { error: "no such picture" });
1016
+ let data;
1017
+ try {
1018
+ data = await readFile(file);
1019
+ }
1020
+ catch {
1021
+ return send(res, 404, { error: "no such picture" });
1022
+ }
1023
+ res.writeHead(200, {
1024
+ "content-type": "image/jpeg",
1025
+ "content-length": data.byteLength,
1026
+ "x-content-type-options": "nosniff",
1027
+ "content-security-policy": "default-src 'none'; sandbox",
1028
+ // Each name is written once and never rewritten.
1029
+ "cache-control": "private, max-age=31536000, immutable",
1030
+ });
1031
+ res.end(data);
1032
+ return;
1033
+ }
822
1034
  case "GET /artifacts": {
823
1035
  const runner = runnerFor(url.searchParams.get("session"));
824
1036
  if (!runner)
@@ -1002,6 +1214,52 @@ export async function startWebServer(opts) {
1002
1214
  teams: await accountTeams({ accountToken: await current.harness.accountToken() }),
1003
1215
  });
1004
1216
  }
1217
+ // Background tasks: the list, one task's conversation, and the two
1218
+ // things a person can do to one — stop it, or tell it something.
1219
+ case "GET /tasks": {
1220
+ const runner = runnerFor(url.searchParams.get("session"));
1221
+ if (!runner)
1222
+ return send(res, 404, { error: "no such session" });
1223
+ return send(res, 200, { tasks: runner.agent.backgroundTasks?.list() ?? [] });
1224
+ }
1225
+ case "GET /task": {
1226
+ const runner = runnerFor(url.searchParams.get("session"));
1227
+ if (!runner)
1228
+ return send(res, 404, { error: "no such session" });
1229
+ const id = url.searchParams.get("id") ?? "";
1230
+ const task = runner.agent.backgroundTasks?.get(id);
1231
+ if (!task)
1232
+ return send(res, 404, { error: "no such task" });
1233
+ return send(res, 200, { task, transcript: taskTranscript(runner.agent.backgroundTranscript(id) ?? []) });
1234
+ }
1235
+ case "POST /task": {
1236
+ const body = await readJson(req);
1237
+ const runner = runnerFor(body.session);
1238
+ const tasks = runner?.agent.backgroundTasks;
1239
+ if (!runner || !tasks)
1240
+ return send(res, 404, { error: "no such session" });
1241
+ const id = String(body.id ?? "");
1242
+ if (!tasks.get(id))
1243
+ return send(res, 404, { error: "no such task" });
1244
+ if (body.action === "stop")
1245
+ return send(res, 200, { stopped: tasks.stop(id), task: tasks.get(id) });
1246
+ if (body.action === "dismiss")
1247
+ return send(res, 200, { dismissed: tasks.dismiss(id), task: tasks.get(id) });
1248
+ if (body.action === "message") {
1249
+ const text = String(body.text ?? "").trim();
1250
+ if (!text)
1251
+ return send(res, 400, { error: "text is required" });
1252
+ // Said so, because the subagent's "user" is otherwise its parent
1253
+ // agent: this one came from the person, past the parent.
1254
+ const sent = tasks.send(id, `[From the person, directly:]\n${text}`);
1255
+ if (sent === "stopped")
1256
+ return send(res, 409, { error: "this task was stopped and cannot be continued" });
1257
+ if (sent === "busy")
1258
+ return send(res, 409, { error: "the task is between steps; try again in a moment" });
1259
+ return send(res, 200, { sent, task: tasks.get(id) });
1260
+ }
1261
+ return send(res, 400, { error: "action must be stop, dismiss or message" });
1262
+ }
1005
1263
  case "GET /history":
1006
1264
  {
1007
1265
  // Opening the page mid-session should show what has happened, not an
@@ -1022,6 +1280,9 @@ export async function startWebServer(opts) {
1022
1280
  pinned: pinned.has(project.path),
1023
1281
  current: project.path === current.session.cwd,
1024
1282
  })),
1283
+ // Which session directories are worktrees, and of what: the sidebar
1284
+ // nests them under the project they came from.
1285
+ worktrees: (await listWorktrees()).map(worktreeView),
1025
1286
  });
1026
1287
  }
1027
1288
  /**
@@ -1146,6 +1407,12 @@ export async function startWebServer(opts) {
1146
1407
  return send(res, 400, { error: "not a folder" });
1147
1408
  return send(res, 200, await browseFiles(root, url.searchParams.get("dir") ?? "", { all: true }));
1148
1409
  }
1410
+ case "GET /files/search": {
1411
+ const root = await projectRoot(url.searchParams.get("cwd"));
1412
+ if (!root)
1413
+ return send(res, 400, { error: "not a folder" });
1414
+ return send(res, 200, await searchFiles(root, (url.searchParams.get("q") ?? "").slice(0, 200)));
1415
+ }
1149
1416
  case "GET /files/read": {
1150
1417
  const root = await projectRoot(url.searchParams.get("cwd"));
1151
1418
  if (!root)
@@ -1229,7 +1496,14 @@ export async function startWebServer(opts) {
1229
1496
  const entries = await Promise.all(chosen.paths.map(async (chosenPath) => {
1230
1497
  const dir = await isDirectory(chosenPath);
1231
1498
  const reachable = isReadable(current.harness.workspace, chosenPath);
1232
- const carried = dir || reachable ? undefined : await stageFromDisk(chosenPath);
1499
+ if (!dir)
1500
+ pickedPaths.add(path.resolve(chosenPath));
1501
+ // An image or a PDF is carried even from inside the project: the
1502
+ // model looks at it directly, rather than learning a path and
1503
+ // spending a tool call to open it. Anything else in reach stays
1504
+ // a reference the agent reads with its own tools.
1505
+ const media = !dir && /\.(png|jpe?g|gif|webp|pdf)$/i.test(chosenPath);
1506
+ const carried = dir || (reachable && !media) ? undefined : await stageFromDisk(chosenPath);
1233
1507
  return {
1234
1508
  path: within(current.session.cwd, chosenPath),
1235
1509
  dir,
@@ -1484,6 +1758,128 @@ export async function startWebServer(opts) {
1484
1758
  return send(res, 400, { error: errorMessage(err) });
1485
1759
  }
1486
1760
  }
1761
+ /**
1762
+ * What a worktree for this task would be called, before it is made.
1763
+ */
1764
+ case "GET /worktree/preview": {
1765
+ const task = (url.searchParams.get("task") ?? "").trim();
1766
+ const from = url.searchParams.get("cwd") || current.session.cwd;
1767
+ if (!task)
1768
+ return send(res, 400, { error: "task is required" });
1769
+ try {
1770
+ return send(res, 200, await previewWorktree(from, { task, slug: url.searchParams.get("slug") ?? undefined }));
1771
+ }
1772
+ catch (err) {
1773
+ return send(res, 400, { error: errorMessage(err) });
1774
+ }
1775
+ }
1776
+ /**
1777
+ * A new session in a new worktree beside a project, and optionally its
1778
+ * first message — the composer's "In a new worktree".
1779
+ */
1780
+ case "POST /worktree": {
1781
+ const body = await readJson(req);
1782
+ const task = typeof body.task === "string" ? body.task.trim() : "";
1783
+ if (!task)
1784
+ return send(res, 400, { error: "task is required" });
1785
+ const from = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : current.session.cwd;
1786
+ const type = BRANCH_TYPES.includes(body.type) ? body.type : undefined;
1787
+ let worktree;
1788
+ try {
1789
+ worktree = await createWorktree(from, {
1790
+ task,
1791
+ ...(typeof body.slug === "string" && body.slug.trim() ? { slug: body.slug.trim() } : {}),
1792
+ ...(type ? { type } : {}),
1793
+ });
1794
+ }
1795
+ catch (err) {
1796
+ return send(res, 400, { error: errorMessage(err) });
1797
+ }
1798
+ const snapshot = await switchSession({ action: "new", cwd: worktree.cwd });
1799
+ if ("error" in snapshot)
1800
+ return send(res, 400, snapshot);
1801
+ broadcast({ type: "projects" });
1802
+ broadcast({ type: "sessions" });
1803
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
1804
+ const runner = runners.get(snapshot.sessionId);
1805
+ if (prompt && runner) {
1806
+ const ids = Array.isArray(body.attachments) ? body.attachments : [];
1807
+ const staged = ids
1808
+ .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
1809
+ .filter((entry) => Boolean(entry));
1810
+ for (const id of ids)
1811
+ if (typeof id === "string")
1812
+ attachments.delete(id);
1813
+ void runTurn(runner, prompt, staged, readReferences(body.references), body.goal === true);
1814
+ }
1815
+ return send(res, 200, { ...snapshot, worktree: worktreeView(worktree) });
1816
+ }
1817
+ case "GET /worktree/status": {
1818
+ const dir = url.searchParams.get("path") ?? "";
1819
+ const worktree = dir ? await findWorktree(dir) : undefined;
1820
+ if (!worktree)
1821
+ return send(res, 404, { error: "not a worktree" });
1822
+ try {
1823
+ const status = await worktreeStatus(worktree);
1824
+ return send(res, 200, { worktree: worktreeView(worktree), status, actions: finishActions(worktree, status) });
1825
+ }
1826
+ catch (err) {
1827
+ return send(res, 400, { error: errorMessage(err) });
1828
+ }
1829
+ }
1830
+ /**
1831
+ * Leave a worktree: commit, push, merge, apply back, remove or discard.
1832
+ *
1833
+ * The sessions recorded in it stay in history; what goes, for the
1834
+ * actions that remove it, is the open tabs and the harness bound to a
1835
+ * directory that is about to disappear.
1836
+ */
1837
+ case "POST /worktree/finish": {
1838
+ const body = await readJson(req);
1839
+ const dir = typeof body.path === "string" ? body.path : "";
1840
+ const action = String(body.action ?? "");
1841
+ if (!["remove", "discard", "commit", "push", "merge", "apply"].includes(action)) {
1842
+ return send(res, 400, { error: `unknown action: ${action}` });
1843
+ }
1844
+ const worktree = dir ? await findWorktree(dir) : undefined;
1845
+ if (!worktree)
1846
+ return send(res, 404, { error: "not a worktree" });
1847
+ const inside = [...runners.values()].filter((runner) => insideAny([worktree.path], runner.session.cwd));
1848
+ if (inside.some((runner) => runner.running))
1849
+ return send(res, 409, { error: "a turn is running in that worktree" });
1850
+ let result;
1851
+ try {
1852
+ result = await finishWorktree(worktree, action, {
1853
+ ...(typeof body.message === "string" ? { message: body.message } : {}),
1854
+ });
1855
+ }
1856
+ catch (err) {
1857
+ return send(res, 400, { error: errorMessage(err) });
1858
+ }
1859
+ let moved;
1860
+ if (result.removed) {
1861
+ for (const runner of inside) {
1862
+ await runner.agent.closeBackgroundTasks();
1863
+ runners.delete(runner.session.id);
1864
+ await runner.harness.closeBrowserFor(runner.session.id);
1865
+ }
1866
+ for (const [dirKey, harness] of [...harnesses]) {
1867
+ if (!insideAny([worktree.path], dirKey))
1868
+ continue;
1869
+ harnesses.delete(dirKey);
1870
+ await harness.close().catch(() => { });
1871
+ }
1872
+ await removeProject(worktree.cwd).catch(() => false);
1873
+ cachedLogs = undefined;
1874
+ // The window was standing in it: back to the project it came from.
1875
+ if (insideAny([worktree.path], current.session.cwd) || !runners.has(focused)) {
1876
+ moved = await switchProject(worktree.source).catch(() => undefined);
1877
+ }
1878
+ }
1879
+ broadcast({ type: "projects" });
1880
+ broadcast({ type: "sessions" });
1881
+ return send(res, 200, { result, open: [...runners.keys()], ...(moved ? { state: moved } : {}) });
1882
+ }
1487
1883
  case "POST /project": {
1488
1884
  const body = await readJson(req);
1489
1885
  const dir = typeof body.path === "string" ? body.path : "";
@@ -1663,7 +2059,10 @@ export async function startWebServer(opts) {
1663
2059
  const name = url.searchParams.get("name") ?? "";
1664
2060
  if (!name)
1665
2061
  return send(res, 400, { error: "name is required" });
1666
- return send(res, 200, { runs: await current.harness.scheduler.history(name, 30) });
2062
+ // The one going now first: its line is only written when it ends.
2063
+ const going = activeRun(name);
2064
+ const runs = await current.harness.scheduler.history(name, 30);
2065
+ return send(res, 200, { runs: going ? [{ ...going, running: true }, ...runs] : runs });
1667
2066
  }
1668
2067
  case "GET /keep-awake":
1669
2068
  return send(res, 200, {
@@ -1677,7 +2076,7 @@ export async function startWebServer(opts) {
1677
2076
  if (typeof body.enabled !== "boolean")
1678
2077
  return send(res, 400, { error: "enabled must be a boolean" });
1679
2078
  await writePrefs({ keepAwake: body.enabled });
1680
- opts.onKeepAwake(body.enabled);
2079
+ await applyKeepAwake();
1681
2080
  return send(res, 200, { available: true, enabled: body.enabled });
1682
2081
  }
1683
2082
  case "POST /routine/remove": {
@@ -1953,6 +2352,85 @@ export async function startWebServer(opts) {
1953
2352
  signedIn: Boolean(await current.harness.accountToken()),
1954
2353
  });
1955
2354
  }
2355
+ /**
2356
+ * Dictation, step one: what the microphone recorded, as text. Through
2357
+ * the account's gateway (OttoPort's speech-to-text, billed per second
2358
+ * from the account's credits) when signed in; otherwise straight to
2359
+ * OpenAI with the user's own key when there is one. Neither: the page
2360
+ * says to sign in or add a key.
2361
+ */
2362
+ case "POST /voice/transcribe": {
2363
+ const body = await readJson(req, VOICE_MAX_BODY_BYTES);
2364
+ const audio = typeof body.audio === "string" ? body.audio : "";
2365
+ const format = typeof body.format === "string" && VOICE_FORMATS.has(body.format) ? body.format : "";
2366
+ const duration = typeof body.duration === "number" && Number.isFinite(body.duration) ? body.duration : 0;
2367
+ const language = typeof body.language === "string" ? body.language.slice(0, 2).toLowerCase() : undefined;
2368
+ if (!audio || !format || duration <= 0)
2369
+ return send(res, 400, { error: "audio, format and duration are required" });
2370
+ if (duration > VOICE_MAX_SECONDS)
2371
+ return send(res, 413, { error: `a recording can be up to ${VOICE_MAX_SECONDS / 60} minutes` });
2372
+ const token = await current.harness.accountToken();
2373
+ if (token) {
2374
+ const team = officialTeam();
2375
+ // A team pays when one is chosen, by the gateway's path prefix.
2376
+ const path = `${team ? `/teams/${encodeURIComponent(team)}` : ""}/api/v1/audio/transcriptions`;
2377
+ const ask = (model) => gatewayJson(path, {
2378
+ token,
2379
+ body: { model, audio, format, duration, ...(language ? { language } : {}) },
2380
+ });
2381
+ // Chinese is Qwen's strength; everything else gpt-4o-mini-transcribe.
2382
+ // Should the Qwen model be missing upstream, the default answers instead.
2383
+ const preferred = language === "zh" ? VOICE_TRANSCRIBE_MODEL_ZH : VOICE_TRANSCRIBE_MODEL;
2384
+ let result = await ask(preferred);
2385
+ if (!result.ok && preferred !== VOICE_TRANSCRIBE_MODEL && (result.status === 400 || result.status === 404)) {
2386
+ result = await ask(VOICE_TRANSCRIBE_MODEL);
2387
+ }
2388
+ if (!result.ok)
2389
+ return send(res, result.status === 402 ? 402 : 502, { error: result.error });
2390
+ return send(res, 200, { text: typeof result.value.text === "string" ? result.value.text : "", via: "oharness" });
2391
+ }
2392
+ const openai = await current.harness.auth.secret("openai").catch(() => undefined);
2393
+ if (openai) {
2394
+ const text = await transcribeWithOpenAI(openai, audio, format, language);
2395
+ return text.ok ? send(res, 200, { text: text.value, via: "openai" }) : send(res, 502, { error: text.error });
2396
+ }
2397
+ return send(res, 401, { error: "Sign in to your OHarness account, or add an OpenAI key, to dictate.", code: "sign_in_required" });
2398
+ }
2399
+ /**
2400
+ * Dictation, step two: the transcript tidied into the message meant.
2401
+ * A perk of a paid OHarness plan, through the gateway's fast model; or,
2402
+ * for someone who brought their own key, the small model they set up,
2403
+ * run here. Anyone else gets the words back as they were heard.
2404
+ */
2405
+ case "POST /voice/tidy": {
2406
+ const body = await readJson(req);
2407
+ const text = typeof body.text === "string" ? body.text.trim() : "";
2408
+ const language = typeof body.language === "string" ? body.language.slice(0, 16) : undefined;
2409
+ if (!text)
2410
+ return send(res, 400, { error: "text is required" });
2411
+ const asHeard = { text, tidied: false };
2412
+ const token = await current.harness.accountToken();
2413
+ if (token) {
2414
+ const result = await gatewayJson("/api/v1/voice/tidy", { token, body: { text, ...(language ? { language } : {}) } });
2415
+ if (result.ok && result.value.tidied === true && typeof result.value.text === "string") {
2416
+ return send(res, 200, { text: result.value.text, tidied: true, via: "oharness" });
2417
+ }
2418
+ }
2419
+ const own = await ownSmallModel(current.harness);
2420
+ if (!own)
2421
+ return send(res, 200, asHeard);
2422
+ try {
2423
+ 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();
2424
+ // A tidy is about as long as what was said; much longer is the model
2425
+ // answering the dictation instead of cleaning it.
2426
+ if (!tidied || tidied.length > text.length * 1.6 + 40)
2427
+ return send(res, 200, asHeard);
2428
+ return send(res, 200, { text: tidied, tidied: true, via: "own-key" });
2429
+ }
2430
+ catch {
2431
+ return send(res, 200, asHeard);
2432
+ }
2433
+ }
1956
2434
  case "POST /sync": {
1957
2435
  const body = await readJson(req);
1958
2436
  if (typeof body.enabled !== "boolean") {
@@ -2556,6 +3034,113 @@ export async function startWebServer(opts) {
2556
3034
  });
2557
3035
  return;
2558
3036
  }
3037
+ /**
3038
+ * Feedback, sent to the site as the signed-in account.
3039
+ *
3040
+ * Through this server rather than from the page, because the account's
3041
+ * token lives here and never in the page. The site writes it as that
3042
+ * account, so there is nothing to send for someone who is not signed in.
3043
+ */
3044
+ case "POST /feedback": {
3045
+ const body = await readJson(req);
3046
+ const message = typeof body.message === "string" ? body.message.trim() : "";
3047
+ if (!message)
3048
+ return send(res, 400, { error: "message is required" });
3049
+ const token = await current.harness.accountToken();
3050
+ if (!token)
3051
+ return send(res, 401, { error: "sign in to send feedback" });
3052
+ try {
3053
+ const reply = await fetch(`${siteUrl()}/api/feedback`, {
3054
+ method: "POST",
3055
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
3056
+ body: JSON.stringify({
3057
+ kind: body.kind,
3058
+ message,
3059
+ source: typeof body.source === "string" ? body.source : "app",
3060
+ appVersion: typeof body.appVersion === "string" ? body.appVersion : undefined,
3061
+ // Paths from `/feedback/upload`; the site keeps only this account's.
3062
+ attachments: Array.isArray(body.attachments) ? body.attachments.slice(0, 5) : undefined,
3063
+ // The machine this server runs on, which the page cannot see.
3064
+ platform: `${process.platform} ${process.arch}`,
3065
+ }),
3066
+ signal: AbortSignal.timeout(15_000),
3067
+ });
3068
+ const answer = (await reply.json().catch(() => ({})));
3069
+ if (!reply.ok) {
3070
+ // A 404 is a site that has not shipped feedback yet, not one that is down.
3071
+ const error = reply.status === 404
3072
+ ? "feedback is not available on this site yet"
3073
+ : typeof answer.error === "string" ? answer.error : `the site refused it (HTTP ${reply.status})`;
3074
+ return send(res, reply.status === 404 ? 502 : reply.status, { error });
3075
+ }
3076
+ return send(res, 200, { ok: true });
3077
+ }
3078
+ catch (error) {
3079
+ return send(res, 502, { error: error instanceof Error ? error.message : String(error) });
3080
+ }
3081
+ }
3082
+ /**
3083
+ * One screenshot or recording for a feedback message.
3084
+ *
3085
+ * The page sends the bytes here, as `/upload` does, and this server asks
3086
+ * the site for a one-time upload URL under the account's token and puts
3087
+ * the file straight into storage there. The site only names the file;
3088
+ * the bytes never pass through it. Answers what `/feedback` then sends
3089
+ * as an attachment.
3090
+ */
3091
+ case "POST /feedback/upload": {
3092
+ const token = await current.harness.accountToken();
3093
+ if (!token)
3094
+ return send(res, 401, { error: "sign in to send feedback" });
3095
+ const type = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
3096
+ const rawName = String(req.headers["x-filename"] ?? "file");
3097
+ let name = rawName;
3098
+ try {
3099
+ name = decodeURIComponent(rawName);
3100
+ }
3101
+ catch { /* sent unencoded */ }
3102
+ let bytes;
3103
+ try {
3104
+ bytes = await readBody(req, FEEDBACK_MAX_BYTES);
3105
+ }
3106
+ catch {
3107
+ return send(res, 413, { error: "each file can be up to 50 MB" });
3108
+ }
3109
+ if (!bytes.length)
3110
+ return send(res, 400, { error: "that file is empty" });
3111
+ try {
3112
+ const ticket = await fetch(`${siteUrl()}/api/feedback/upload`, {
3113
+ method: "POST",
3114
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json", accept: "application/json" },
3115
+ body: JSON.stringify({ name, type, size: bytes.length }),
3116
+ signal: AbortSignal.timeout(15_000),
3117
+ });
3118
+ const granted = (await ticket.json().catch(() => ({})));
3119
+ if (!ticket.ok || typeof granted.path !== "string" || typeof granted.signedUrl !== "string") {
3120
+ const error = ticket.status === 404
3121
+ ? "attachments are not available on this site yet"
3122
+ : typeof granted.error === "string" ? granted.error : `the site refused it (HTTP ${ticket.status})`;
3123
+ return send(res, ticket.status === 404 ? 502 : ticket.status || 502, { error });
3124
+ }
3125
+ // A minute a megabyte, at least two: a recording on a slow uplink
3126
+ // takes a while, and a timeout that cuts it off helps nobody.
3127
+ const put = await fetch(granted.signedUrl, {
3128
+ method: "PUT",
3129
+ headers: { "content-type": type, "x-upsert": "false" },
3130
+ body: new Uint8Array(bytes),
3131
+ signal: AbortSignal.timeout(Math.max(120_000, Math.ceil((bytes.length / 1_048_576) * 60_000))),
3132
+ });
3133
+ if (!put.ok) {
3134
+ const detail = (await put.json().catch(() => ({})));
3135
+ const message = typeof detail.message === "string" ? detail.message : typeof detail.error === "string" ? detail.error : `HTTP ${put.status}`;
3136
+ return send(res, 502, { error: `upload failed: ${message}` });
3137
+ }
3138
+ return send(res, 200, { path: granted.path, name, type, size: bytes.length });
3139
+ }
3140
+ catch (error) {
3141
+ return send(res, 502, { error: error instanceof Error ? error.message : String(error) });
3142
+ }
3143
+ }
2559
3144
  case "POST /logout": {
2560
3145
  const body = await readJson(req);
2561
3146
  const providerId = typeof body.provider === "string" ? body.provider : "";
@@ -2605,6 +3190,126 @@ export async function startWebServer(opts) {
2605
3190
  return send(res, 400, { error: errorMessage(err) });
2606
3191
  }
2607
3192
  }
3193
+ case "POST /goal": {
3194
+ const body = await readJson(req);
3195
+ const runner = runnerFor(body.session);
3196
+ if (!runner)
3197
+ return send(res, 404, { error: "no such session" });
3198
+ // Setting one goes through /prompt, since it is also the first
3199
+ // message; this is the way out of one.
3200
+ runner.session.goal = undefined;
3201
+ void runner.session.persist().catch(() => { });
3202
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3203
+ return send(res, 200, state(runner));
3204
+ }
3205
+ // /compact: summarize the conversation now rather than when it fills.
3206
+ case "POST /compact": {
3207
+ const body = await readJson(req);
3208
+ const runner = runnerFor(body.session);
3209
+ if (!runner)
3210
+ return send(res, 404, { error: "no such session" });
3211
+ // The running turn owns the transcript; cutting it underneath would
3212
+ // drop the messages the next request is being built from.
3213
+ if (runner.running)
3214
+ return send(res, 409, { error: "wait for the running turn to finish before compacting" });
3215
+ const event = await runner.agent.compactNow();
3216
+ if (!event)
3217
+ return send(res, 200, { ...state(runner), compacted: false });
3218
+ // The same notice the automatic kind leaves in the transcript.
3219
+ broadcast({ ...event, session: runner.session.id });
3220
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3221
+ return send(res, 200, { ...state(runner), compacted: event.type === "compacted" });
3222
+ }
3223
+ // Computer Use: switched per conversation from the + menu.
3224
+ case "POST /computer-use": {
3225
+ const body = await readJson(req);
3226
+ const runner = runnerFor(body.session);
3227
+ if (!runner)
3228
+ return send(res, 404, { error: "no such session" });
3229
+ if (!runner.harness.computerUse)
3230
+ return send(res, 404, { error: "Computer use is only available in the desktop app" });
3231
+ const on = body.on === true;
3232
+ runner.session.computerUse = on;
3233
+ if (!on)
3234
+ runner.harness.computerFor(runner.session.id)?.endTurn();
3235
+ void runner.session.persist().catch(() => { });
3236
+ if (on)
3237
+ await refreshComputer(runner.harness.computerUse);
3238
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3239
+ return send(res, 200, state(runner));
3240
+ }
3241
+ // The grants the OS holds, read again: the card polls this while the
3242
+ // person is in System Settings, and `request` shows the OS prompt first.
3243
+ case "POST /computer-use/permissions": {
3244
+ const body = await readJson(req);
3245
+ const plugin = opts.computerUsePlugin ?? runners.get(focused)?.harness.computerUse;
3246
+ if (!plugin)
3247
+ return send(res, 404, { error: "not available here" });
3248
+ if (body.request) {
3249
+ const grant = body.grant === "screen" || body.grant === "accessibility" ? body.grant : undefined;
3250
+ await plugin.runtime.requestPermissions(grant).catch((err) => {
3251
+ computerStatus.error = errorMessage(err);
3252
+ });
3253
+ }
3254
+ await refreshComputer(plugin);
3255
+ for (const runner of runners.values()) {
3256
+ if (runner.session.computerUse)
3257
+ broadcast({ type: "state", session: runner.session.id, state: state(runner) });
3258
+ }
3259
+ return send(res, 200, {
3260
+ ...computerStatus,
3261
+ relaunch: Boolean(opts.onRelaunch),
3262
+ ...(opts.computerGrantee ? { grantee: opts.computerGrantee } : {}),
3263
+ });
3264
+ }
3265
+ // The two settings the person owns, read by the desktop's plugin.
3266
+ case "GET /computer-use/settings": {
3267
+ const prefs = await readPrefs();
3268
+ return send(res, 200, {
3269
+ available: Boolean(opts.computerUsePlugin),
3270
+ takeover: prefs.computerTakeover === "never" ? "never" : "ask",
3271
+ blockedApps: prefs.computerBlockedApps ?? [],
3272
+ });
3273
+ }
3274
+ case "POST /computer-use/settings": {
3275
+ if (!opts.computerUsePlugin)
3276
+ return send(res, 404, { error: "not available here" });
3277
+ const body = await readJson(req);
3278
+ const patch = {};
3279
+ if (body.takeover === "ask" || body.takeover === "never")
3280
+ patch.computerTakeover = body.takeover;
3281
+ if (Array.isArray(body.blockedApps)) {
3282
+ patch.computerBlockedApps = [...new Set(body.blockedApps.map((a) => String(a).trim()).filter(Boolean))];
3283
+ }
3284
+ await writePrefs(patch);
3285
+ return send(res, 200, { ok: true });
3286
+ }
3287
+ /** What the live view shows when it opens: the conversation's last action. */
3288
+ case "GET /computer-use/view": {
3289
+ const id = url.searchParams.get("session") ?? "";
3290
+ return send(res, 200, { event: computerLast.get(id) ?? null });
3291
+ }
3292
+ /**
3293
+ * The live view's stop button: end the turn, and let go of every window
3294
+ * and of the mouse and keyboard at once, rather than when the turn
3295
+ * notices it was stopped.
3296
+ */
3297
+ case "POST /computer-use/stop": {
3298
+ const body = await readJson(req);
3299
+ const runner = runnerFor(body.session);
3300
+ if (!runner)
3301
+ return send(res, 404, { error: "no such session" });
3302
+ runner.running?.abort();
3303
+ runner.harness.computerFor(runner.session.id)?.endTurn();
3304
+ return send(res, 200, { ok: true });
3305
+ }
3306
+ case "POST /computer-use/relaunch": {
3307
+ if (!opts.onRelaunch)
3308
+ return send(res, 404, { error: "not available here" });
3309
+ send(res, 200, { ok: true });
3310
+ opts.onRelaunch();
3311
+ return;
3312
+ }
2608
3313
  case "POST /effort": {
2609
3314
  const body = await readJson(req);
2610
3315
  const runner = runnerFor(body.session);
@@ -2632,6 +3337,12 @@ export async function startWebServer(opts) {
2632
3337
  return send(res, 400, { error: `unknown mode: ${mode}` });
2633
3338
  }
2634
3339
  runner.session.permissions.setMode(mode);
3340
+ // Kept with the conversation, so it reopens in this mode. Only one
3341
+ // that is already on disk: a new one writes its record on its first
3342
+ // message, and that record carries the mode.
3343
+ if (runner.session.messages.length)
3344
+ await runner.session.persist().catch(() => { });
3345
+ await rememberMode(mode);
2635
3346
  broadcast({ type: "state", session: runner.session.id, state: state(runner) });
2636
3347
  return send(res, 200, state(runner));
2637
3348
  }
@@ -2643,6 +3354,9 @@ export async function startWebServer(opts) {
2643
3354
  const id = String(body.id ?? "");
2644
3355
  const runner = runners.get(id);
2645
3356
  runner?.running?.abort();
3357
+ // Its background tasks stop with it, written down as they stood, so
3358
+ // reopening it brings them back the way a restart would.
3359
+ await runner?.agent.closeBackgroundTasks();
2646
3360
  runners.delete(id);
2647
3361
  await runner?.harness.closeBrowserFor(id);
2648
3362
  // Its artifacts move from memory to the log half of the catalogue,
@@ -2665,8 +3379,12 @@ export async function startWebServer(opts) {
2665
3379
  // Reported rather than blocked: a fork records its parent, so the
2666
3380
  // page can say what the lineage loses instead of the branches
2667
3381
  // quietly pointing at nothing.
3382
+ // Before the store goes: a task stopped afterwards would write its
3383
+ // record back for a session that no longer exists.
3384
+ await runners.get(id)?.agent.discardBackgroundTasks();
2668
3385
  const forks = (await current.harness.store.children(id)).length;
2669
3386
  const deleted = await current.harness.deleteSession(id);
3387
+ await removeTurnShots(id);
2670
3388
  // A session nobody has spoken to has nothing on disk yet — the store
2671
3389
  // writes on the first message — but it is still a session someone
2672
3390
  // opened, and it is exactly the kind that gets thrown away when the
@@ -2705,6 +3423,38 @@ export async function startWebServer(opts) {
2705
3423
  }
2706
3424
  return send(res, 200, await switchSession(body));
2707
3425
  }
3426
+ /**
3427
+ * Remote control, as this Mac's settings show it: whether it is on, the
3428
+ * phones that may connect, and any waiting to be allowed.
3429
+ */
3430
+ case "GET /remote":
3431
+ return send(res, 200, await remoteView());
3432
+ /** The switch: saved, then started or stopped in this process, no restart. */
3433
+ case "POST /remote": {
3434
+ if (!opts.remote)
3435
+ return send(res, 404, { error: "not available here" });
3436
+ const body = await readJson(req);
3437
+ if (typeof body.enabled !== "boolean")
3438
+ return send(res, 400, { error: "enabled must be a boolean" });
3439
+ await writePrefs({ remote: body.enabled });
3440
+ await setRemote(body.enabled);
3441
+ return send(res, 200, await remoteView());
3442
+ }
3443
+ case "POST /remote/pair": {
3444
+ const body = await readJson(req);
3445
+ if (typeof body.id !== "string" || !answerPairing(body.id, body.allow === true)) {
3446
+ return send(res, 404, { error: "no phone is waiting for that" });
3447
+ }
3448
+ return send(res, 200, { ok: true });
3449
+ }
3450
+ case "POST /remote/revoke": {
3451
+ const body = await readJson(req);
3452
+ if (typeof body.id !== "string" || !(await revokeDevice(body.id)))
3453
+ return send(res, 404, { error: "no such phone" });
3454
+ // Forgotten means gone now, not at its next reconnect.
3455
+ forgetPhone(body.id);
3456
+ return send(res, 200, { ok: true });
3457
+ }
2708
3458
  case "GET /open":
2709
3459
  // What the page should draw tabs for after a reload.
2710
3460
  return send(res, 200, {
@@ -2798,6 +3548,11 @@ export async function startWebServer(opts) {
2798
3548
  if (!prompt && !ids.length && !references.length) {
2799
3549
  return send(res, 400, { error: "prompt is required" });
2800
3550
  }
3551
+ // Goal mode: the text is the goal, and the turn keeps going until the
3552
+ // model reports it achieved or blocked. A goal needs words to be one.
3553
+ const goal = body.goal === true;
3554
+ if (goal && !prompt)
3555
+ return send(res, 400, { error: "a goal needs a description" });
2801
3556
  const staged = ids
2802
3557
  .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
2803
3558
  .filter((entry) => Boolean(entry));
@@ -2817,11 +3572,11 @@ export async function startWebServer(opts) {
2817
3572
  // This used to 409, and the page discarded the reply, so an impatient
2818
3573
  // Enter cleared the composer and lost what had been typed.
2819
3574
  if (runner.running) {
2820
- runner.queue.push({ id: randomUUID(), prompt, staged, references });
3575
+ runner.queue.push({ id: randomUUID(), prompt, staged, references, ...(goal ? { goal } : {}) });
2821
3576
  broadcastQueue(runner);
2822
3577
  return send(res, 202, { ok: true, queued: true });
2823
3578
  }
2824
- void runTurn(runner, prompt, staged, references);
3579
+ void runTurn(runner, prompt, staged, references, goal);
2825
3580
  return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
2826
3581
  }
2827
3582
  case "POST /permission": {
@@ -2899,10 +3654,12 @@ export async function startWebServer(opts) {
2899
3654
  if (!runner.running) {
2900
3655
  runner.queue.splice(at, 1);
2901
3656
  broadcastQueue(runner);
2902
- void runTurn(runner, entry.prompt, entry.staged, entry.references);
3657
+ void runTurn(runner, entry.prompt, entry.staged, entry.references, entry.goal);
2903
3658
  return send(res, 202, { ok: true, sent: true });
2904
3659
  }
2905
- const plain = entry.prompt && !entry.staged.length && !entry.references.length;
3660
+ // A goal starts a run of its own; slipped into another turn it would
3661
+ // be read as a remark and never set.
3662
+ const plain = entry.prompt && !entry.staged.length && !entry.references.length && !entry.goal;
2906
3663
  if (plain && runner.agent.interject(entry.prompt)) {
2907
3664
  runner.queue.splice(at, 1);
2908
3665
  broadcastQueue(runner);
@@ -3125,9 +3882,13 @@ export async function startWebServer(opts) {
3125
3882
  function state(runner = runners.get(focused)) {
3126
3883
  const { session: sess } = runner;
3127
3884
  const model = sess.model;
3128
- const used = sess.usage.inputTokens + sess.usage.outputTokens;
3885
+ const context = runner.agent.contextBreakdown();
3886
+ const used = context.total;
3129
3887
  return {
3130
3888
  sessionId: sess.id,
3889
+ // Its background tasks, so a reloaded page or a newly opened tab
3890
+ // shows them without waiting for the next change.
3891
+ tasks: runner.agent.backgroundTasks?.list() ?? [],
3131
3892
  // The page labels its tabs from this. Without it every tab read
3132
3893
  // "Untitled", including sessions the store had a title for all along.
3133
3894
  title: sess.title,
@@ -3135,6 +3896,20 @@ export async function startWebServer(opts) {
3135
3896
  model: model.id,
3136
3897
  provider: model.provider,
3137
3898
  mode: sess.permissions.getMode(),
3899
+ // Only while it is being worked toward. Achieved or blocked, the
3900
+ // transcript already says so, and the composer goes back to normal.
3901
+ goal: sess.goal?.status === "active" ? sess.goal.objective : undefined,
3902
+ // Absent outside the desktop app, which is the page's cue to hide the row.
3903
+ computerUse: runner.harness.computerUse
3904
+ ? {
3905
+ on: sess.computerUse,
3906
+ ...(computerStatus.platform ? { platform: computerStatus.platform } : {}),
3907
+ ...(computerStatus.permissions ? { permissions: computerStatus.permissions } : {}),
3908
+ ...(computerStatus.error ? { error: computerStatus.error } : {}),
3909
+ relaunch: Boolean(opts.onRelaunch),
3910
+ ...(opts.computerGrantee ? { grantee: opts.computerGrantee } : {}),
3911
+ }
3912
+ : undefined,
3138
3913
  // What the next request will carry, not what was picked: a pick the
3139
3914
  // model lacks shows as the tier it is clamped to, and a model that
3140
3915
  // cannot stop thinking never shows "off". Absent when the model has no
@@ -3151,6 +3926,9 @@ export async function startWebServer(opts) {
3151
3926
  turns: sess.turns,
3152
3927
  contextWindow: model.contextWindow,
3153
3928
  contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
3929
+ contextTokens: used,
3930
+ // What the context panel splits the ring into.
3931
+ context,
3154
3932
  busy: Boolean(runner.running),
3155
3933
  // So a reload, or a second tab, sees what is still waiting. The queue
3156
3934
  // lives on the runner rather than in the page for exactly this reason.
@@ -3164,14 +3942,101 @@ export async function startWebServer(opts) {
3164
3942
  * than left as the user messages they are on the wire, because the page
3165
3943
  * shows a call and its result as one card.
3166
3944
  */
3945
+ /**
3946
+ * Tell the page whenever a conversation's background tasks change.
3947
+ *
3948
+ * A running task changes on every turn and every tool it starts, which is
3949
+ * more often than anybody can read; changes inside a quarter second go out
3950
+ * as one list.
3951
+ */
3952
+ function watchTasks(runner) {
3953
+ if (watchedTasks.has(runner.agent))
3954
+ return;
3955
+ watchedTasks.add(runner.agent);
3956
+ let timer;
3957
+ runner.agent.onBackgroundChange(() => {
3958
+ if (timer)
3959
+ return;
3960
+ timer = setTimeout(() => {
3961
+ timer = undefined;
3962
+ broadcast({ type: "tasks", session: runner.session.id, tasks: runner.agent.backgroundTasks?.list() ?? [] });
3963
+ }, 250);
3964
+ });
3965
+ }
3966
+ /**
3967
+ * A conversation just opened: watch its background tasks, and bring back
3968
+ * the ones it had before a restart.
3969
+ */
3970
+ function adoptTasks(runner) {
3971
+ watchTasks(runner);
3972
+ void runner.agent.restoreBackgroundTasks().then((restored) => {
3973
+ if (restored)
3974
+ broadcast({ type: "tasks", session: runner.session.id, tasks: runner.agent.backgroundTasks?.list() ?? [] });
3975
+ }, () => { });
3976
+ }
3977
+ /**
3978
+ * A background task's conversation, as the task view draws it: what it was
3979
+ * told, what it said, and each tool it ran with the start of its result.
3980
+ * Plainer than the main transcript's entries — nothing here can be forked
3981
+ * from or replayed, so it carries no positions.
3982
+ */
3983
+ function taskTranscript(messages) {
3984
+ const entries = [];
3985
+ const results = new Map();
3986
+ for (const message of messages) {
3987
+ for (const block of message.content) {
3988
+ if (block.type !== "tool_result")
3989
+ continue;
3990
+ const text = block.content.map((part) => (part.type === "text" ? part.text : "")).join("");
3991
+ results.set(block.toolCallId, { text: text.slice(0, 1200), ...(block.isError ? { isError: true } : {}) });
3992
+ }
3993
+ }
3994
+ for (const message of messages) {
3995
+ const text = visibleText(message).trim();
3996
+ const tools = message.content.filter((block) => block.type === "tool_call");
3997
+ if (message.role === "user") {
3998
+ if (text)
3999
+ entries.push({ role: "user", text });
4000
+ continue;
4001
+ }
4002
+ if (text)
4003
+ entries.push({ role: "assistant", text });
4004
+ for (const call of tools) {
4005
+ const result = results.get(call.id);
4006
+ entries.push({
4007
+ role: "tool",
4008
+ name: call.name,
4009
+ text: previewJson(call.input, 160),
4010
+ ...(result ? { result: result.text, ...(result.isError ? { isError: true } : {}) } : {}),
4011
+ });
4012
+ }
4013
+ }
4014
+ return entries;
4015
+ }
3167
4016
  function history(runner = runners.get(focused)) {
3168
4017
  const entries = [];
3169
4018
  let index = 0;
3170
4019
  for (const message of runner.session.messages) {
3171
4020
  index++;
4021
+ // The loop's "keep going" is the harness talking to the model, not
4022
+ // something the person said.
4023
+ if (message.meta?.["goalNudge"])
4024
+ continue;
4025
+ // Background results read in: a card naming the tasks, never the raw
4026
+ // notification text as if the person had written it.
4027
+ const notified = message.meta?.["taskNotification"];
4028
+ if (Array.isArray(notified)) {
4029
+ const tasks = notified.filter((task) => typeof task?.id === "string" && typeof task?.description === "string");
4030
+ if (tasks.length)
4031
+ entries.push({ role: "user", keepMessages: index, tasks });
4032
+ continue;
4033
+ }
3172
4034
  const tools = message.content.filter((block) => block.type === "tool_call");
3173
4035
  const results = message.content.filter((block) => block.type === "tool_result");
3174
- const text = visibleText(message).trim();
4036
+ // The notes buildMessage writes for the model about what was attached —
4037
+ // "[image: shot.png]", "[file attached: … saved at …]" — are not what the
4038
+ // person typed, and the bubble now shows the attachments themselves.
4039
+ const text = (message.role === "user" ? withoutAttachmentNotes(visibleText(message)) : visibleText(message)).trim();
3175
4040
  // The bytes stay where they are; what travels is where to find them.
3176
4041
  // Without this a reloaded conversation showed "[image: shot.png]" where
3177
4042
  // the screenshot had been — the model still saw it, the person did not.
@@ -3182,6 +4047,23 @@ export async function startWebServer(opts) {
3182
4047
  src: `/attachment?session=${encodeURIComponent(runner.session.id)}` +
3183
4048
  `&message=${index}&block=${entry.position}`,
3184
4049
  }));
4050
+ // Clips the message carried, played from the copy saved in the project
4051
+ // (see buildMessage). A copy since deleted just shows as a name.
4052
+ const sent = message.meta?.["attachments"];
4053
+ if (message.role === "user" && Array.isArray(sent)) {
4054
+ for (const clip of sent) {
4055
+ if (clip.kind !== "video" || typeof clip.path !== "string")
4056
+ continue;
4057
+ const rel = path.relative(runner.session.cwd, clip.path);
4058
+ if (rel.startsWith("..") || path.isAbsolute(rel))
4059
+ continue;
4060
+ images.push({
4061
+ src: `/files/raw?cwd=${encodeURIComponent(runner.session.cwd)}&path=${encodeURIComponent(rel)}`,
4062
+ ...(typeof clip.name === "string" ? { name: clip.name } : {}),
4063
+ kind: "video",
4064
+ });
4065
+ }
4066
+ }
3185
4067
  // messageText drops thinking blocks by design, so they are read directly.
3186
4068
  const thinking = message.content
3187
4069
  .filter((block) => block.type === "thinking")
@@ -3195,14 +4077,25 @@ export async function startWebServer(opts) {
3195
4077
  // reply" has to include it or the branch reopens with tool calls
3196
4078
  // that have no answers — a request every provider rejects.
3197
4079
  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
- }));
4080
+ target.results = results.map((block) => {
4081
+ const position = message.content.indexOf(block);
4082
+ const shots = block.content
4083
+ .map((part, place) => ({ part, place }))
4084
+ .filter((entry) => entry.part.type === "image")
4085
+ .map((entry) => ({
4086
+ src: `/attachment?session=${encodeURIComponent(runner.session.id)}` +
4087
+ `&message=${index}&block=${position}&part=${entry.place}`,
4088
+ }));
4089
+ return {
4090
+ id: block.toolCallId,
4091
+ text: block.content
4092
+ .map((part) => (part.type === "text" ? part.text : ""))
4093
+ .join("")
4094
+ .slice(0, 4000),
4095
+ ...(block.isError ? { isError: true } : {}),
4096
+ ...(shots.length ? { images: shots } : {}),
4097
+ };
4098
+ });
3206
4099
  }
3207
4100
  if (!text && !thinking && !images.length)
3208
4101
  continue;
@@ -3220,6 +4113,18 @@ export async function startWebServer(opts) {
3220
4113
  : {}),
3221
4114
  });
3222
4115
  }
4116
+ // Each picture goes under the last reply of the turn it ended: the last
4117
+ // assistant entry at or before the point the turn ended at.
4118
+ for (const shot of listTurnShots(runner.session.id)) {
4119
+ const target = entries.findLast((entry) => entry.role === "assistant" && (entry.keepMessages ?? 0) <= shot.at);
4120
+ if (!target)
4121
+ continue;
4122
+ (target.shots ??= []).push({
4123
+ app: shot.app,
4124
+ ...(shot.title ? { title: shot.title } : {}),
4125
+ src: turnShotSrc(runner.session.id, shot.file),
4126
+ });
4127
+ }
3223
4128
  return entries;
3224
4129
  }
3225
4130
  /**
@@ -3234,9 +4139,10 @@ export async function startWebServer(opts) {
3234
4139
  * Choosing a directory here is also a statement about what the user is
3235
4140
  * working on, so it is what the next launch opens.
3236
4141
  */
3237
- async function harnessFor(dir) {
4142
+ async function harnessFor(dir, remember = true) {
3238
4143
  const project = await addProject(dir);
3239
- void writePrefs({ project: project.path });
4144
+ if (remember)
4145
+ void writePrefs({ project: project.path });
3240
4146
  const existing = harnesses.get(project.path);
3241
4147
  if (existing)
3242
4148
  return existing;
@@ -3248,21 +4154,100 @@ export async function startWebServer(opts) {
3248
4154
  questionPrompt: (request, signal) => questionPrompt(request, signal),
3249
4155
  ...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
3250
4156
  ...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
4157
+ ...(opts.computerUsePlugin ? { computerUsePlugin: opts.computerUsePlugin } : {}),
3251
4158
  }));
3252
- harnesses.set(project.path, harness);
4159
+ harnesses.set(project.path, adoptRoutineModel(harness));
4160
+ harness.worktreeLauncher = launchWorktree;
4161
+ harness.taskWaker = wakeForTasks;
3253
4162
  watchConnections(harness);
3254
4163
  return harness;
3255
4164
  }
4165
+ /**
4166
+ * `worktree_start`, from a conversation: the worktree, a session in it, and
4167
+ * that session's first turn — in the background, leaving the asking tab on
4168
+ * screen. Not remembered as the startup project: the person did not go there.
4169
+ */
4170
+ const launchWorktree = async (request) => {
4171
+ const worktree = await createWorktree(request.from.cwd, {
4172
+ task: request.task,
4173
+ ...(request.slug ? { slug: request.slug } : {}),
4174
+ ...(request.type ? { type: request.type } : {}),
4175
+ });
4176
+ const harness = await harnessFor(worktree.cwd, false);
4177
+ const session = freshSession(harness);
4178
+ await session.init();
4179
+ session.title = request.task;
4180
+ const runner = makeRunner(session, harness);
4181
+ runners.set(session.id, runner);
4182
+ adoptTasks(runner);
4183
+ void runTurn(runner, request.prompt);
4184
+ broadcast({ type: "projects" });
4185
+ broadcast({ type: "sessions" });
4186
+ broadcast({ type: "notice", message: `started "${request.task}" in ${path.basename(worktree.path)}` });
4187
+ return {
4188
+ sessionId: session.id,
4189
+ path: worktree.path,
4190
+ kind: worktree.kind,
4191
+ ...(worktree.branch ? { branch: worktree.branch } : {}),
4192
+ };
4193
+ };
4194
+ for (const harness of harnesses.values())
4195
+ harness.worktreeLauncher = launchWorktree;
4196
+ /**
4197
+ * Background tasks ended while their conversation sat idle: give it a turn
4198
+ * to read their results in and act on them.
4199
+ *
4200
+ * A conversation mid-turn reads them in by itself at its next gap. One with
4201
+ * prompts still queued is left alone: that queue stands after an interrupt
4202
+ * or a failure for the person to send or drop, and a turn starting on its
4203
+ * own would decide for them. The results ride along with whatever runs next.
4204
+ */
4205
+ function wakeForTasks(sessionId) {
4206
+ const runner = runners.get(sessionId);
4207
+ if (!runner || runner.running || runner.queue.length)
4208
+ return;
4209
+ void runTurn(runner, "", [], [], false, true);
4210
+ }
4211
+ for (const harness of harnesses.values())
4212
+ harness.taskWaker = wakeForTasks;
4213
+ /** A worktree as the page sees it: keyed by the directory its sessions run in. */
4214
+ function worktreeView(worktree) {
4215
+ return {
4216
+ path: worktree.cwd,
4217
+ root: worktree.path,
4218
+ parent: worktree.source,
4219
+ kind: worktree.kind,
4220
+ ...(worktree.branch ? { branch: worktree.branch } : {}),
4221
+ task: worktree.task,
4222
+ };
4223
+ }
4224
+ /** The ways out of a worktree that make sense in its present state, best first. */
4225
+ function finishActions(worktree, status) {
4226
+ if (!status.exists)
4227
+ return ["discard"];
4228
+ if (status.clean)
4229
+ return ["remove"];
4230
+ if (worktree.kind === "clone")
4231
+ return worktree.shadow ? ["apply", "discard"] : ["discard"];
4232
+ if (status.changes.length)
4233
+ return ["commit", "discard"];
4234
+ return ["push", "merge", "discard"];
4235
+ }
3256
4236
  /*
3257
4237
  * Routines: which exist, where each runs, and — when this server was asked
3258
4238
  * to — the clock that fires them. A run lands in the page as a notice and,
3259
4239
  * through onRoutineRun, wherever the desktop can reach someone who is not
3260
4240
  * looking at the page.
3261
4241
  */
4242
+ // A routine starting or ending repaints the Routines page wherever it is open.
4243
+ const stopRunUpdates = onRunsChanged(() => broadcast({ type: "routines" }));
4244
+ // What a run is doing now, patched into its row in place: a repaint per
4245
+ // tool call would refetch every history on the page.
4246
+ const stopRunActivity = onRunActivity((run) => broadcast({ type: "routine_activity", name: run.schedule, activity: run.activity ?? "", turns: run.turns }));
3262
4247
  const routines = new RoutineRunner({
3263
4248
  defaultCwd: opts.session.cwd,
3264
4249
  openHarnesses: () => harnesses.values(),
3265
- createHarness: (cwd) => opts.openProject
4250
+ createHarness: async (cwd) => adoptRoutineModel(await (opts.openProject
3266
4251
  ? opts.openProject(cwd)
3267
4252
  : Harness.create({
3268
4253
  cwd,
@@ -3270,7 +4255,7 @@ export async function startWebServer(opts) {
3270
4255
  questionPrompt: async () => ({ outcome: "unavailable" }),
3271
4256
  ...(opts.browserTarget ? { browserTarget: opts.browserTarget } : {}),
3272
4257
  ...(opts.browserUsePlugin ? { browserUsePlugin: opts.browserUsePlugin } : {}),
3273
- }),
4258
+ }))),
3274
4259
  onStart: (routine, meta) => {
3275
4260
  if (meta.catchUp)
3276
4261
  broadcast({ type: "notice", message: `routine ${routine.def.name} is catching up on a run it missed` });
@@ -3311,6 +4296,7 @@ export async function startWebServer(opts) {
3311
4296
  await session.init();
3312
4297
  const runner = makeRunner(session, harness);
3313
4298
  runners.set(session.id, runner);
4299
+ adoptTasks(runner);
3314
4300
  focused = session.id;
3315
4301
  current = { session, agent: runner.agent, harness };
3316
4302
  const snapshot = state(runner);
@@ -3361,6 +4347,7 @@ export async function startWebServer(opts) {
3361
4347
  if (!runner) {
3362
4348
  runner = makeRunner(next, harness);
3363
4349
  runners.set(next.id, runner);
4350
+ adoptTasks(runner);
3364
4351
  }
3365
4352
  focused = next.id;
3366
4353
  // A reopened tab keeps the harness it was built on, which is the one its
@@ -3412,31 +4399,57 @@ export async function startWebServer(opts) {
3412
4399
  if (!next)
3413
4400
  return false;
3414
4401
  broadcastQueue(runner);
3415
- void runTurn(runner, next.prompt, next.staged, next.references);
4402
+ void runTurn(runner, next.prompt, next.staged, next.references, next.goal);
3416
4403
  return true;
3417
4404
  }
3418
- async function runTurn(runner, prompt, staged = [], references = []) {
4405
+ async function runTurn(runner, prompt, staged = [], references = [], goal = false,
4406
+ /**
4407
+ * Started by background tasks ending while the conversation was idle:
4408
+ * nobody typed anything, and the run's only input is their results,
4409
+ * which the agent reads in itself.
4410
+ */
4411
+ wake = false) {
3419
4412
  const controller = new AbortController();
3420
4413
  runner.running = controller;
3421
4414
  const id = runner.session.id;
4415
+ // Background tasks only ever start inside a turn, so this is the first
4416
+ // moment worth watching them.
4417
+ watchTasks(runner);
4418
+ // A subagent asked for isolation makes a worktree inside the turn, where
4419
+ // nothing tells the page; comparing before and after is what does. Read
4420
+ // alongside the turn, never in its way.
4421
+ const worktreesBefore = listWorktrees().then((list) => list.length, () => -1);
4422
+ if (goal) {
4423
+ runner.session.setGoal(prompt);
4424
+ void runner.session.persist().catch(() => { });
4425
+ }
3422
4426
  // Every event carries its session so the page can route it to the tab it
3423
4427
  // belongs to; without it a reply from a background tab would stream into
3424
4428
  // 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
- });
4429
+ if (!wake)
4430
+ broadcast({
4431
+ type: "prompt",
4432
+ session: id,
4433
+ text: prompt,
4434
+ ...(staged.length ? { attachments: staged.map((file) => file.name) } : {}),
4435
+ // The transcript shows what was referenced, not just what was typed:
4436
+ // otherwise a turn that reads three files looks like it went and found
4437
+ // them on its own.
4438
+ ...(references.length ? { references } : {}),
4439
+ ...(goal ? { goal: true } : {}),
4440
+ });
4441
+ if (goal)
4442
+ broadcast({ type: "state", session: id, state: state(runner) });
3435
4443
  // The note goes above the prompt rather than after it, so the model has
3436
4444
  // the context before it reads the request that depends on it.
3437
4445
  const note = referenceNote(references);
3438
4446
  const text = note ? `${note}\n\n${prompt}` : prompt;
3439
- const input = staged.length ? buildMessage(text, staged) : text;
4447
+ const input = wake ? [] : staged.length
4448
+ ? await buildMessage(text, staged, {
4449
+ documents: runner.session.model.supportsDocuments === true,
4450
+ saveDir: projectPath(runner.session.cwd, "attachments", id),
4451
+ })
4452
+ : text;
3440
4453
  let failed = false;
3441
4454
  // The session names itself from its first prompt once the agent takes it
3442
4455
  // in. Said the moment that happens: waiting for the idle state left a new
@@ -3454,6 +4467,12 @@ export async function startWebServer(opts) {
3454
4467
  // the copy without that id could only ever 404.
3455
4468
  if (event.type === "question_request")
3456
4469
  continue;
4470
+ // The same for permissions: the card comes from permissionPrompt's
4471
+ // announce, which carries the id it is answered by. The agent's own
4472
+ // event has none, and with two calls asking at once a card drawn
4473
+ // from it outlived its answer.
4474
+ if (event.type === "permission_request")
4475
+ continue;
3457
4476
  // The agent reports a provider failure as an event and breaks its own
3458
4477
  // loop rather than throwing, so the catch below never sees it — and a
3459
4478
  // queue that drained into a dead provider would replay the failure
@@ -3469,6 +4488,9 @@ export async function startWebServer(opts) {
3469
4488
  continue;
3470
4489
  }
3471
4490
  broadcast({ ...event, session: id });
4491
+ // The line over the composer reads the goal off the state.
4492
+ if (event.type === "goal")
4493
+ broadcast({ type: "state", session: id, state: state(runner) });
3472
4494
  }
3473
4495
  }
3474
4496
  catch (err) {
@@ -3515,6 +4537,10 @@ export async function startWebServer(opts) {
3515
4537
  state: state(runner),
3516
4538
  changes: runner.ledger.list(),
3517
4539
  });
4540
+ void Promise.all([worktreesBefore, listWorktrees()]).then(([before, after]) => {
4541
+ if (before !== after.length)
4542
+ broadcast({ type: "projects" });
4543
+ }, () => { });
3518
4544
  // Only after a turn that ended on its own. An interrupt means stop, and
3519
4545
  // an error would usually meet the next prompt with the same error, so
3520
4546
  // both leave the queue standing for the person to send or drop.
@@ -3533,6 +4559,8 @@ export async function startWebServer(opts) {
3533
4559
  });
3534
4560
  res.write(": connected\n\n");
3535
4561
  clients.add(res);
4562
+ for (const card of pairingCards())
4563
+ res.write(`data: ${JSON.stringify(card)}\n\n`);
3536
4564
  for (const harness of harnesses.values()) {
3537
4565
  for (const card of harness.cloudConnectors.snapshots()) {
3538
4566
  res.write(`data: ${JSON.stringify({ type: "connection_card", session: card.session, card })}\n\n`);
@@ -3774,23 +4802,115 @@ export async function startWebServer(opts) {
3774
4802
  staleTimer.unref?.();
3775
4803
  if (opts.routines)
3776
4804
  routines.start();
3777
- if (opts.onKeepAwake) {
3778
- const keepAwake = opts.onKeepAwake;
3779
- void readPrefs().then((prefs) => keepAwake(prefs.keepAwake === true)).catch(() => { });
3780
- }
4805
+ void applyKeepAwake().catch(() => { });
3781
4806
  const address = server.address();
3782
4807
  const port = typeof address === "object" && address ? address.port : opts.port;
3783
4808
  const presence = opts.presence === false
3784
4809
  ? undefined
3785
4810
  : announcePresence({ url: `http://${host}:${port}`, token, kind: opts.presence?.kind ?? "web" });
4811
+ // Phones waiting at the door: the card on this page is the only way in.
4812
+ const pairings = new Map();
4813
+ const announcePairing = (request) => ({ type: "remote_pair_request", ...request });
4814
+ const closePairing = (id) => {
4815
+ if (pairings.delete(id))
4816
+ broadcast({ type: "remote_pair_closed", id });
4817
+ };
4818
+ pairingCards = () => [...pairings.values()].map((entry) => announcePairing(entry.request));
4819
+ answerPairing = (id, allowed) => {
4820
+ const entry = pairings.get(id);
4821
+ if (!entry)
4822
+ return false;
4823
+ entry.settle(allowed);
4824
+ return true;
4825
+ };
4826
+ let closed = false;
4827
+ const remoteToken = opts.remote?.token ?? (async () => process.env["OHARNESS_REMOTE_TOKEN"] || (await current.harness.accountToken()));
4828
+ let remote;
4829
+ // One change at a time: a quick off-on-off must not leave two connections,
4830
+ // or one still running behind a switch that says off.
4831
+ let remoteChange = Promise.resolve();
4832
+ setRemote = (on) => {
4833
+ remoteChange = remoteChange.then(async () => {
4834
+ if (!opts.remote || closed)
4835
+ return;
4836
+ if (on && !remote) {
4837
+ const identity = await remoteMachine();
4838
+ if (closed)
4839
+ return;
4840
+ const relay = opts.remote.relay;
4841
+ remote = startRemote({
4842
+ relay,
4843
+ token: remoteToken,
4844
+ ...identity,
4845
+ local: `http://${host}:${port}/?token=${token}`,
4846
+ approve: (request, signal) => new Promise((resolve) => {
4847
+ const settle = (allowed) => {
4848
+ closePairing(request.id);
4849
+ resolve(allowed);
4850
+ };
4851
+ pairings.set(request.id, { request, settle });
4852
+ signal.addEventListener("abort", () => settle(false), { once: true });
4853
+ broadcast(announcePairing(request));
4854
+ }),
4855
+ withdrawn: closePairing,
4856
+ log: (line) => console.error(line),
4857
+ });
4858
+ console.error(`remote control on through ${relay} — new phones need your Allow on this page`);
4859
+ }
4860
+ else if (!on && remote) {
4861
+ remote.close();
4862
+ remote = undefined;
4863
+ // A phone still waiting at the door is turned away with the rest.
4864
+ for (const entry of [...pairings.values()])
4865
+ entry.settle(false);
4866
+ console.error("remote control off");
4867
+ }
4868
+ await applyKeepAwake();
4869
+ });
4870
+ return remoteChange;
4871
+ };
4872
+ remoteRunning = () => Boolean(remote);
4873
+ forgetPhone = (id) => remote?.forget(id);
4874
+ remoteView = async () => {
4875
+ const signedIn = opts.remote ? Boolean(await remoteToken().catch(() => undefined)) : false;
4876
+ return {
4877
+ available: Boolean(opts.remote),
4878
+ on: Boolean(remote),
4879
+ connected: remote?.connected ?? false,
4880
+ // What the switch row says: off, on but nobody to be (no account), on
4881
+ // and reaching for the relay, or there and reachable.
4882
+ state: !remote ? "off" : remote.connected ? "online" : signedIn ? "connecting" : "signed-out",
4883
+ signedIn,
4884
+ // The desktop holds the computer awake while remote is on; a browser tab cannot.
4885
+ keepsAwake: Boolean(opts.onKeepAwake),
4886
+ devices: (await pairedDevices()).map(({ id, name, pairedAt }) => ({ id, name, pairedAt })),
4887
+ pending: pairingCards(),
4888
+ };
4889
+ };
4890
+ if (opts.remote) {
4891
+ const saved = (await readPrefs().catch(() => ({}))).remote === true;
4892
+ if (opts.remote.always || saved)
4893
+ await setRemote(true);
4894
+ }
3786
4895
  return {
3787
4896
  url: `http://${host}:${port}/?token=${token}`,
3788
4897
  token,
3789
4898
  permissionPrompt,
3790
4899
  questionPrompt,
4900
+ interrupt(sessionId) {
4901
+ runners.get(sessionId)?.running?.abort();
4902
+ },
3791
4903
  async close() {
3792
4904
  clearInterval(staleTimer);
3793
4905
  presence?.close();
4906
+ closed = true;
4907
+ await remoteChange.catch(() => { });
4908
+ remote?.close();
4909
+ remote = undefined;
4910
+ for (const entry of [...pairings.values()])
4911
+ entry.settle(false);
4912
+ stopRunUpdates();
4913
+ stopRunActivity();
3794
4914
  await routines.close();
3795
4915
  for (const unsubscribe of connectionSubscriptions)
3796
4916
  unsubscribe();
@@ -4093,6 +5213,13 @@ function closeTab(res, status, message, hint) {
4093
5213
  res.end(page);
4094
5214
  }
4095
5215
  /** The extensions a picked file is carried as an image under. */
5216
+ /** Clips the tray can play as a preview of a picked file. */
5217
+ const PICKED_VIDEO_TYPES = {
5218
+ ".mp4": "video/mp4",
5219
+ ".m4v": "video/mp4",
5220
+ ".mov": "video/quicktime",
5221
+ ".webm": "video/webm",
5222
+ };
4096
5223
  const PICKED_IMAGE_TYPES = {
4097
5224
  ".png": "image/png",
4098
5225
  ".jpg": "image/jpeg",
@@ -4118,6 +5245,8 @@ const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
4118
5245
  const STAGED_TTL_MS = 30 * 60 * 1000;
4119
5246
  /** The ceiling on everything staged at once, across every tab. */
4120
5247
  const MAX_STAGED_BYTES = 64 * 1024 * 1024;
5248
+ /** One feedback screenshot or recording; the site's bucket takes no more. */
5249
+ const FEEDBACK_MAX_BYTES = 50 * 1024 * 1024;
4121
5250
  /** How much of a text attachment is inlined into the prompt. */
4122
5251
  const MAX_INLINE_CHARS = 200_000;
4123
5252
  async function readBody(req, limit) {
@@ -4195,9 +5324,47 @@ function readReferences(value) {
4195
5324
  }
4196
5325
  return out;
4197
5326
  }
4198
- function buildMessage(prompt, staged) {
5327
+ /**
5328
+ * The user's message, with what they attached.
5329
+ *
5330
+ * An image goes to the model as an image. A PDF goes whole, as a document the
5331
+ * model reads page by page, when the model reads them (`documents`). A text
5332
+ * file is pasted in. Anything else — and every PDF too — is saved in the
5333
+ * project, under .oharness/attachments/<session>/, and its path given, so the
5334
+ * agent can open it with its own tools: a spreadsheet with Python, a model
5335
+ * that takes no PDFs with a PDF tool. Binary files used to be skipped, and
5336
+ * the person was left believing the agent had read what it never received.
5337
+ */
5338
+ /** A user message's text without the attachment lines buildMessage adds to it. */
5339
+ export function withoutAttachmentNotes(text) {
5340
+ return text
5341
+ .split("\n")
5342
+ .filter((line) => !/^\[(image|file attached|PDF attached|skipped [^\]]*): /.test(line))
5343
+ .join("\n")
5344
+ .replace(/\n{3,}/g, "\n\n");
5345
+ }
5346
+ export async function buildMessage(prompt, staged, opts) {
4199
5347
  const content = [];
4200
5348
  const notes = [];
5349
+ /**
5350
+ * Clips saved for the agent, kept on the message's meta (which never
5351
+ * reaches the model) so a reloaded transcript can play them in the bubble
5352
+ * they were sent in, from the copy in the project.
5353
+ */
5354
+ const clips = [];
5355
+ const save = async (file) => {
5356
+ await mkdir(opts.saveDir, { recursive: true });
5357
+ // The name as uploaded, made safe as a file name; a second file of the
5358
+ // same name in the session gets a number rather than replacing the first.
5359
+ const base = path.basename(file.name).replace(/[\u0000-\u001f/\\:]/g, "_") || "attachment";
5360
+ const ext = path.extname(base);
5361
+ let target = path.join(opts.saveDir, base);
5362
+ for (let n = 2; await stat(target).then(() => true, () => false); n++) {
5363
+ target = path.join(opts.saveDir, `${base.slice(0, base.length - ext.length)} (${n})${ext}`);
5364
+ }
5365
+ await writeFile(target, file.data);
5366
+ return target;
5367
+ };
4201
5368
  for (const file of staged) {
4202
5369
  if (file.mediaType.startsWith("image/")) {
4203
5370
  content.push({
@@ -4208,11 +5375,27 @@ function buildMessage(prompt, staged) {
4208
5375
  notes.push(`[image: ${file.name}]`);
4209
5376
  continue;
4210
5377
  }
5378
+ const pdf = file.mediaType === "application/pdf" || /\.pdf$/i.test(file.name);
5379
+ if (pdf) {
5380
+ const saved = await save(file);
5381
+ if (opts.documents) {
5382
+ content.push({ type: "document", mimeType: "application/pdf", data: file.data.toString("base64"), name: file.name });
5383
+ notes.push(`[PDF attached: ${file.name} — also saved at ${saved}]`);
5384
+ }
5385
+ else {
5386
+ notes.push(`[PDF attached: ${file.name} — this model cannot read PDFs directly; it is saved at ${saved}, open it with your tools]`);
5387
+ }
5388
+ continue;
5389
+ }
4211
5390
  const text = file.data.toString("utf8");
4212
5391
  // A NUL early on means binary, and pasting its bytes into the prompt
4213
5392
  // wastes context without conveying anything.
4214
5393
  if (file.data.subarray(0, 8000).includes(0)) {
4215
- notes.push(`[skipped ${file.name}: binary, ${file.data.byteLength} bytes]`);
5394
+ const saved = await save(file);
5395
+ notes.push(`[file attached: ${file.name} (${file.mediaType}, ${file.data.byteLength} bytes) — saved at ${saved}, open it with your tools]`);
5396
+ if (file.mediaType.startsWith("video/") || /\.(mp4|m4v|mov|webm)$/i.test(file.name)) {
5397
+ clips.push({ kind: "video", name: file.name, path: saved });
5398
+ }
4216
5399
  continue;
4217
5400
  }
4218
5401
  const clipped = text.length > MAX_INLINE_CHARS
@@ -4222,16 +5405,75 @@ function buildMessage(prompt, staged) {
4222
5405
  }
4223
5406
  const body = [prompt, ...notes].filter(Boolean).join("\n\n");
4224
5407
  content.unshift({ type: "text", text: body });
4225
- return [{ role: "user", content }];
5408
+ return [{ role: "user", content, ...(clips.length ? { meta: { attachments: clips } } : {}) }];
4226
5409
  }
4227
- async function readJson(req) {
5410
+ /** Dictation: the formats a browser's MediaRecorder writes, the cap, the models. */
5411
+ const VOICE_FORMATS = new Set(["webm", "ogg", "m4a", "mp4", "mp3", "wav"]);
5412
+ const VOICE_MAX_SECONDS = 300;
5413
+ const VOICE_MAX_BODY_BYTES = 20_000_000;
5414
+ const VOICE_TRANSCRIBE_MODEL = "gpt-4o-mini-transcribe";
5415
+ /** For a Chinese-speaking user: Qwen's recogniser, strongest on Chinese and its dialects. */
5416
+ const VOICE_TRANSCRIBE_MODEL_ZH = "qwen3-asr-flash";
5417
+ const VOICE_TIDY_PROMPT = [
5418
+ "You tidy dictated text into the message the speaker meant to type to an AI assistant.",
5419
+ "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.",
5420
+ "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.",
5421
+ "If the text is already clean, return it unchanged. Reply with the tidied text only.",
5422
+ ].join("\n");
5423
+ /**
5424
+ * The small model to tidy dictation with when the user brought their own
5425
+ * key: the configured small model (or default), if its provider is not the
5426
+ * OHarness plan and has credentials here. Undefined otherwise — tidying on the
5427
+ * plan is the gateway's to decide, by subscription.
5428
+ */
5429
+ async function ownSmallModel(harness) {
5430
+ const ref = harness.config.smallModel ?? harness.config.model;
5431
+ if (!ref)
5432
+ return undefined;
5433
+ try {
5434
+ const model = harness.providers.resolve(ref);
5435
+ if (model.provider === "official")
5436
+ return undefined;
5437
+ return (await harness.configuredProviders()).has(model.provider) ? model.id : undefined;
5438
+ }
5439
+ catch {
5440
+ return undefined;
5441
+ }
5442
+ }
5443
+ /** Straight to OpenAI's transcription API with the user's own key. */
5444
+ async function transcribeWithOpenAI(key, audio, format, language) {
5445
+ const bytes = Buffer.from(audio.replace(/^data:[^,]*,/, ""), "base64");
5446
+ const form = new FormData();
5447
+ form.set("file", new Blob([bytes]), `dictation.${format}`);
5448
+ form.set("model", VOICE_TRANSCRIBE_MODEL);
5449
+ form.set("response_format", "json");
5450
+ if (language)
5451
+ form.set("language", language);
5452
+ try {
5453
+ const response = await fetch("https://api.openai.com/v1/audio/transcriptions", {
5454
+ method: "POST",
5455
+ headers: { authorization: `Bearer ${key}` },
5456
+ body: form,
5457
+ signal: AbortSignal.timeout(60_000),
5458
+ });
5459
+ const parsed = (await response.json().catch(() => ({})));
5460
+ if (!response.ok)
5461
+ return { ok: false, error: typeof parsed.error?.message === "string" ? parsed.error.message : `OpenAI answered ${response.status}` };
5462
+ return { ok: true, value: typeof parsed.text === "string" ? parsed.text : "" };
5463
+ }
5464
+ catch (err) {
5465
+ return { ok: false, error: `could not reach OpenAI: ${errorMessage(err)}` };
5466
+ }
5467
+ }
5468
+ async function readJson(req, limit = 1_000_000) {
4228
5469
  const chunks = [];
4229
5470
  let size = 0;
4230
5471
  for await (const chunk of req) {
4231
5472
  size += chunk.length;
4232
5473
  // A prompt is text; anything this large is a mistake or an attempt to
4233
5474
  // exhaust memory, and reading it all first would be the vulnerability.
4234
- if (size > 1_000_000)
5475
+ // Dictation is the one caller that sends more: a recording, as base64.
5476
+ if (size > limit)
4235
5477
  throw new Error("request body too large");
4236
5478
  chunks.push(chunk);
4237
5479
  }