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