@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.
- package/dist/i18n.js +354 -62
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -1
- package/dist/page/client/account.js +430 -23
- package/dist/page/client/account.js.map +1 -1
- package/dist/page/client/base.js +20 -1
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/composer.js +693 -54
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/client/computer.d.ts +27 -0
- package/dist/page/client/computer.js +358 -0
- package/dist/page/client/computer.js.map +1 -0
- package/dist/page/client/dialogs.js +93 -6
- package/dist/page/client/dialogs.js.map +1 -1
- package/dist/page/client/events.js +94 -7
- package/dist/page/client/events.js.map +1 -1
- package/dist/page/client/files.js +86 -1
- package/dist/page/client/files.js.map +1 -1
- package/dist/page/client/index.js +4 -0
- package/dist/page/client/index.js.map +1 -1
- package/dist/page/client/panels.js +10 -6
- package/dist/page/client/panels.js.map +1 -1
- package/dist/page/client/routines.js +296 -41
- package/dist/page/client/routines.js.map +1 -1
- package/dist/page/client/shell.js +187 -51
- package/dist/page/client/shell.js.map +1 -1
- package/dist/page/client/state.js +273 -19
- package/dist/page/client/state.js.map +1 -1
- package/dist/page/client/tasks.d.ts +8 -0
- package/dist/page/client/tasks.js +293 -0
- package/dist/page/client/tasks.js.map +1 -0
- package/dist/page/client/transcript.js +137 -5
- package/dist/page/client/transcript.js.map +1 -1
- package/dist/page/client/usage.js +1 -11
- package/dist/page/client/usage.js.map +1 -1
- package/dist/page/style.js +568 -60
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +90 -11
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +14 -0
- package/dist/prefs.js +8 -1
- package/dist/prefs.js.map +1 -1
- package/dist/remote-crypto.d.ts +78 -0
- package/dist/remote-crypto.js +216 -0
- package/dist/remote-crypto.js.map +1 -0
- package/dist/remote.d.ts +103 -0
- package/dist/remote.js +498 -0
- package/dist/remote.js.map +1 -0
- package/dist/server.d.ts +70 -0
- package/dist/server.js +1291 -49
- package/dist/server.js.map +1 -1
- package/dist/starters.d.ts +92 -0
- package/dist/starters.js +213 -0
- package/dist/starters.js.map +1 -0
- package/dist/turn-shots.d.ts +27 -0
- package/dist/turn-shots.js +71 -0
- package/dist/turn-shots.js.map +1 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3200
|
-
|
|
3201
|
-
.map((part) => (
|
|
3202
|
-
.
|
|
3203
|
-
.
|
|
3204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|