@oharness/server 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/i18n.js +501 -6
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +5 -2
- package/dist/main.js.map +1 -1
- package/dist/markdown.js +512 -61
- package/dist/markdown.js.map +1 -1
- package/dist/page.js +3071 -417
- package/dist/page.js.map +1 -1
- package/dist/prefs.d.ts +7 -0
- package/dist/prefs.js +3 -0
- package/dist/prefs.js.map +1 -1
- package/dist/server.d.ts +14 -1
- package/dist/server.js +347 -45
- package/dist/server.js.map +1 -1
- package/dist/theme.d.ts +18 -0
- package/dist/theme.js +96 -0
- package/dist/theme.js.map +1 -0
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -8,11 +8,14 @@ import { updateUserMcpConfig } from "@oharness/engine/config-files.ts";
|
|
|
8
8
|
import { connectorConfig, officialConnectors } from "@oharness/engine/mcp/registry.ts";
|
|
9
9
|
import { missingSecrets } from "@oharness/engine/mcp/secrets.ts";
|
|
10
10
|
import { connectorIcon, providerSites, siteIcon } from "./icons.js";
|
|
11
|
+
import { checkAnswer } from "@oharness/engine/tools/questions.ts";
|
|
11
12
|
import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
|
|
13
|
+
import { canDisableThinking, effectiveEffort, isEffortChoice, thinkingFor, } from "@oharness/engine/providers/types.ts";
|
|
12
14
|
import { isReadable } from "@oharness/engine/agent/workspace.ts";
|
|
13
|
-
import {
|
|
15
|
+
import { visibleText } from "@oharness/engine/core/types.ts";
|
|
14
16
|
import { errorMessage } from "@oharness/engine/core/errors.ts";
|
|
15
|
-
import { officialBaseUrl } from "@oharness/engine/auth/providers/official.ts";
|
|
17
|
+
import { officialBaseUrl, officialTeam } from "@oharness/engine/auth/providers/official.ts";
|
|
18
|
+
import { updateUserConfig } from "@oharness/engine/config.ts";
|
|
16
19
|
import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
|
|
17
20
|
import { ChangeLedger } from "./changes.js";
|
|
18
21
|
import { addProject, ensureProject, listProjects, removeProject } from "./projects.js";
|
|
@@ -33,6 +36,13 @@ import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engi
|
|
|
33
36
|
* stops short says so rather than reading as the whole history.
|
|
34
37
|
*/
|
|
35
38
|
const USAGE_SESSION_SCAN = 200;
|
|
39
|
+
/**
|
|
40
|
+
* How many trail entries one read of every session may return.
|
|
41
|
+
*
|
|
42
|
+
* Generous, because the page filters and pages through what it is given and a
|
|
43
|
+
* cap that bites in the middle of a normal week would make the filter lie.
|
|
44
|
+
*/
|
|
45
|
+
const AUDIT_ENTRY_CAP = 5000;
|
|
36
46
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
37
47
|
/**
|
|
38
48
|
* The windows the usage page offers.
|
|
@@ -47,6 +57,55 @@ const USAGE_RANGES = {
|
|
|
47
57
|
"90d": { id: "90d", days: 90 },
|
|
48
58
|
all: { id: "all", days: 0 },
|
|
49
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* The longest window that may be asked for by hand.
|
|
62
|
+
*
|
|
63
|
+
* The same year the site's picker allows, so a range chosen there and typed
|
|
64
|
+
* here mean the same thing. Past it the daily buckets are hairs and the walk
|
|
65
|
+
* is every session on disk for a curve that says nothing the presets do not.
|
|
66
|
+
*/
|
|
67
|
+
const MAX_CUSTOM_DAYS = 366;
|
|
68
|
+
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
|
69
|
+
/**
|
|
70
|
+
* The window the usage page is asking about: a preset, or two days picked on
|
|
71
|
+
* a calendar.
|
|
72
|
+
*
|
|
73
|
+
* UTC days, because that is what the picker hands over and what the buckets
|
|
74
|
+
* below are floored to — a window whose edges were local midnight and whose
|
|
75
|
+
* columns were UTC would put a day's spend in the column beside its own.
|
|
76
|
+
*
|
|
77
|
+
* `until` is exclusive and never in the future; a range picked backwards is
|
|
78
|
+
* read forwards, and one longer than the cap keeps its last day. Nothing here
|
|
79
|
+
* refuses: a page that answers "bad range" for two days somebody just clicked
|
|
80
|
+
* is a page arguing with its own calendar.
|
|
81
|
+
*/
|
|
82
|
+
function usageWindow(range, from, to) {
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const midnight = Math.floor(now / DAY_MS) * DAY_MS;
|
|
85
|
+
if (range === "custom" && from && to && DAY_KEY.test(from) && DAY_KEY.test(to)) {
|
|
86
|
+
let first = Date.parse(`${from}T00:00:00Z`);
|
|
87
|
+
let last = Date.parse(`${to}T00:00:00Z`);
|
|
88
|
+
if (Number.isFinite(first) && Number.isFinite(last)) {
|
|
89
|
+
if (first > last)
|
|
90
|
+
[first, last] = [last, first];
|
|
91
|
+
last = Math.min(last, midnight);
|
|
92
|
+
first = Math.min(first, last);
|
|
93
|
+
first = Math.max(first, last - (MAX_CUSTOM_DAYS - 1) * DAY_MS);
|
|
94
|
+
const key = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
95
|
+
return {
|
|
96
|
+
id: "custom",
|
|
97
|
+
since: first,
|
|
98
|
+
// Inclusive of the last day, so a range ending today still counts what
|
|
99
|
+
// was spent in the hour this page was opened.
|
|
100
|
+
until: Math.min(last + DAY_MS, now),
|
|
101
|
+
from: key(first),
|
|
102
|
+
to: key(last),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const preset = USAGE_RANGES[range ?? ""] ?? USAGE_RANGES["30d"];
|
|
107
|
+
return { id: preset.id, since: preset.days ? now - preset.days * DAY_MS : 0, until: now };
|
|
108
|
+
}
|
|
50
109
|
/**
|
|
51
110
|
* What was spent over time, per model.
|
|
52
111
|
*
|
|
@@ -62,15 +121,16 @@ const USAGE_RANGES = {
|
|
|
62
121
|
* alternative is reading every transcript to place each turn, which is a lot
|
|
63
122
|
* of disk for a curve nobody reads to four decimal places.
|
|
64
123
|
*/
|
|
65
|
-
function usageSeries(rows, since) {
|
|
66
|
-
const
|
|
67
|
-
const span = Math.max(now - since, DAY_MS);
|
|
124
|
+
function usageSeries(rows, since, until = Date.now()) {
|
|
125
|
+
const span = Math.max(until - since, DAY_MS);
|
|
68
126
|
const step = span > 60 * DAY_MS ? "week" : "day";
|
|
69
127
|
const width = step === "week" ? 7 * DAY_MS : DAY_MS;
|
|
70
128
|
// Anchored to the end rather than to midnight of the first day: the last
|
|
71
|
-
// bucket should be the one
|
|
72
|
-
// sliver that reads as a collapse in spending.
|
|
73
|
-
|
|
129
|
+
// bucket should be the one the window ends in, so the newest column is never
|
|
130
|
+
// a sliver that reads as a collapse in spending. A window that ends on a
|
|
131
|
+
// bucket boundary keeps that bucket rather than opening an empty one after
|
|
132
|
+
// it, which is what a picked range ending yesterday would otherwise draw.
|
|
133
|
+
const end = Math.floor((until - 1) / width) * width + width;
|
|
74
134
|
const count = Math.min(Math.ceil((end - since) / width), 400);
|
|
75
135
|
const from = end - count * width;
|
|
76
136
|
const buckets = Array.from({ length: count }, (_, i) => ({
|
|
@@ -167,7 +227,7 @@ export async function startWebServer(opts) {
|
|
|
167
227
|
* choice changes, so re-reading it per session would only add a disk hit to
|
|
168
228
|
* answer a question this already knows.
|
|
169
229
|
*/
|
|
170
|
-
let preferredModel =
|
|
230
|
+
let { model: preferredModel, effort: preferredEffort } = await readPrefs();
|
|
171
231
|
/**
|
|
172
232
|
* A new session on a harness, honouring that preference.
|
|
173
233
|
*
|
|
@@ -177,7 +237,10 @@ export async function startWebServer(opts) {
|
|
|
177
237
|
*/
|
|
178
238
|
const freshSession = (harness) => {
|
|
179
239
|
const known = preferredModel && harness.listModels().some((model) => model.id === preferredModel);
|
|
180
|
-
return harness.createSession(
|
|
240
|
+
return harness.createSession({
|
|
241
|
+
...(known ? { model: preferredModel } : {}),
|
|
242
|
+
...(preferredEffort ? { effort: preferredEffort } : {}),
|
|
243
|
+
});
|
|
181
244
|
};
|
|
182
245
|
/**
|
|
183
246
|
* Files staged for the next prompt.
|
|
@@ -266,13 +329,74 @@ export async function startWebServer(opts) {
|
|
|
266
329
|
}));
|
|
267
330
|
}
|
|
268
331
|
watchConnections(opts.harness);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
332
|
+
/**
|
|
333
|
+
* Take a waiting interaction off every screen.
|
|
334
|
+
*
|
|
335
|
+
* Broadcast on answer as well as on interrupt: a second window showing the
|
|
336
|
+
* same card must not stay answerable after the first one answered it.
|
|
337
|
+
*/
|
|
338
|
+
const retire = (entry) => {
|
|
339
|
+
if (pending.get(entry.id) !== entry)
|
|
340
|
+
return false;
|
|
341
|
+
pending.delete(entry.id);
|
|
342
|
+
broadcast({ type: "interaction_closed", id: entry.id, ...(entry.session ? { session: entry.session } : {}) });
|
|
343
|
+
return true;
|
|
344
|
+
};
|
|
345
|
+
/** The event a waiting interaction is drawn from, for a live broadcast and a replay alike. */
|
|
346
|
+
const announce = (entry) => entry.kind === "permission"
|
|
347
|
+
? { type: "permission_request", id: entry.id, request: entry.request, ...(entry.session ? { session: entry.session } : {}) }
|
|
348
|
+
: { type: "question_request", id: entry.id, request: entry.request, session: entry.session };
|
|
349
|
+
const notify = (entry) => {
|
|
350
|
+
try {
|
|
351
|
+
opts.onInteraction?.({
|
|
352
|
+
kind: entry.kind,
|
|
353
|
+
...(entry.session ? { session: entry.session } : {}),
|
|
354
|
+
title: entry.kind === "permission" ? entry.request.title : (entry.request.questions[0]?.question ?? ""),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// A notifier that throws must not take the prompt down with it.
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
// No timeout on either of these, on purpose. A prompt that auto-denies after
|
|
362
|
+
// a while would silently fail long-running work while the user is away; a
|
|
363
|
+
// prompt that auto-allows would be worse. It waits — until it is answered,
|
|
364
|
+
// or the turn waiting on it is interrupted.
|
|
365
|
+
const permissionPrompt = (request, signal) => new Promise((resolve) => {
|
|
366
|
+
const entry = {
|
|
367
|
+
kind: "permission",
|
|
368
|
+
id: randomUUID(),
|
|
369
|
+
...(request.sessionId ? { session: request.sessionId } : {}),
|
|
370
|
+
request,
|
|
371
|
+
settle: (reply) => {
|
|
372
|
+
if (retire(entry))
|
|
373
|
+
resolve(reply);
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
if (signal?.aborted)
|
|
377
|
+
return resolve({ allow: false });
|
|
378
|
+
pending.set(entry.id, entry);
|
|
379
|
+
signal?.addEventListener("abort", () => entry.settle({ allow: false }), { once: true });
|
|
380
|
+
broadcast(announce(entry));
|
|
381
|
+
notify(entry);
|
|
382
|
+
});
|
|
383
|
+
const questionPrompt = (request, signal) => new Promise((resolve) => {
|
|
384
|
+
const entry = {
|
|
385
|
+
kind: "question",
|
|
386
|
+
id: randomUUID(),
|
|
387
|
+
session: request.sessionId,
|
|
388
|
+
request,
|
|
389
|
+
settle: (result) => {
|
|
390
|
+
if (retire(entry))
|
|
391
|
+
resolve(result);
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
if (signal.aborted)
|
|
395
|
+
return resolve({ outcome: "aborted" });
|
|
396
|
+
pending.set(entry.id, entry);
|
|
397
|
+
signal.addEventListener("abort", () => entry.settle({ outcome: "aborted" }), { once: true });
|
|
398
|
+
broadcast(announce(entry));
|
|
399
|
+
notify(entry);
|
|
276
400
|
});
|
|
277
401
|
const server = createServer((req, res) => {
|
|
278
402
|
void handle(req, res).catch((err) => {
|
|
@@ -319,12 +443,12 @@ export async function startWebServer(opts) {
|
|
|
319
443
|
const runner = runnerFor(url.searchParams.get("session"));
|
|
320
444
|
const page = runner?.harness.extensions.page(url.searchParams.get("id") ?? "");
|
|
321
445
|
if (!page)
|
|
322
|
-
return send(res, 404, { error: "
|
|
446
|
+
return send(res, 404, { error: "This plugin page is not enabled." });
|
|
323
447
|
try {
|
|
324
448
|
return send(res, 200, await page.read());
|
|
325
449
|
}
|
|
326
450
|
catch {
|
|
327
|
-
return send(res, 400, { error: "
|
|
451
|
+
return send(res, 400, { error: "The plugin page could not be read. Check its local files." });
|
|
328
452
|
}
|
|
329
453
|
}
|
|
330
454
|
case "POST /plugin-page": {
|
|
@@ -333,12 +457,18 @@ export async function startWebServer(opts) {
|
|
|
333
457
|
const runner = runnerFor(body.session);
|
|
334
458
|
const page = runner?.harness.extensions.page(typeof body.page === "string" ? body.page : "");
|
|
335
459
|
if (!page || !body.input || typeof body.input !== "object" || Array.isArray(body.input))
|
|
336
|
-
return send(res, 400, { error: "
|
|
460
|
+
return send(res, 400, { error: "Invalid plugin page action." });
|
|
337
461
|
try {
|
|
338
462
|
return send(res, 200, await page.act(body.input));
|
|
339
463
|
}
|
|
340
464
|
catch (error) {
|
|
341
|
-
|
|
465
|
+
// A code and its values travel with the message, so the page can say it in its own language.
|
|
466
|
+
const detail = (error && typeof error === "object" ? error : {});
|
|
467
|
+
return send(res, 400, {
|
|
468
|
+
error: errorMessage(error),
|
|
469
|
+
...(typeof detail.code === "string" ? { code: detail.code } : {}),
|
|
470
|
+
...(detail.params && typeof detail.params === "object" ? { params: detail.params } : {}),
|
|
471
|
+
});
|
|
342
472
|
}
|
|
343
473
|
}
|
|
344
474
|
case "POST /cloud-connection": {
|
|
@@ -1121,17 +1251,52 @@ export async function startWebServer(opts) {
|
|
|
1121
1251
|
// The site as well as the account: the row's menu leads to the
|
|
1122
1252
|
// dashboard, and where that lives is this process's setting rather
|
|
1123
1253
|
// than something a page should carry a copy of.
|
|
1254
|
+
//
|
|
1255
|
+
// And the teams, with which one this machine bills to. The menu
|
|
1256
|
+
// offers the switch; a team named in the config that the account
|
|
1257
|
+
// is no longer on is still reported as chosen, because the gateway
|
|
1258
|
+
// will refuse it and the menu is where that gets undone.
|
|
1259
|
+
const teams = await accountTeams({ accountToken: await current.harness.accountToken() });
|
|
1260
|
+
const team = officialTeam();
|
|
1124
1261
|
return send(res, 200, {
|
|
1125
1262
|
signedIn: true,
|
|
1126
1263
|
provider: "official",
|
|
1127
1264
|
site: siteUrl(),
|
|
1128
1265
|
...accountIdentity(credential.access),
|
|
1266
|
+
teams,
|
|
1267
|
+
...(team ? { team } : {}),
|
|
1129
1268
|
});
|
|
1130
1269
|
}
|
|
1131
1270
|
// Named even when signed out, so the button knows which sign-in to
|
|
1132
1271
|
// start without the page hard-coding a provider id of its own.
|
|
1133
1272
|
return send(res, 200, { signedIn: false, provider: "official" });
|
|
1134
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* Whose credits this machine spends: a team's, or the account's own.
|
|
1276
|
+
*
|
|
1277
|
+
* Written to the user's config, the same file `OHARNESS_TEAM` stands in
|
|
1278
|
+
* for, and read back per request by the official provider — so the
|
|
1279
|
+
* next call is on the new balance without a restart. `null` is "my own
|
|
1280
|
+
* plan". Not checked against the account's teams here: the gateway
|
|
1281
|
+
* checks on every call and refuses a team the account is not on, and a
|
|
1282
|
+
* second copy of that rule would be a second place for it to drift.
|
|
1283
|
+
*/
|
|
1284
|
+
case "POST /team": {
|
|
1285
|
+
const body = await readJson(req);
|
|
1286
|
+
const asked = body.team;
|
|
1287
|
+
const team = typeof asked === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(asked.trim())
|
|
1288
|
+
? asked.trim()
|
|
1289
|
+
: asked === null || asked === undefined || asked === ""
|
|
1290
|
+
? undefined
|
|
1291
|
+
: false;
|
|
1292
|
+
if (team === false)
|
|
1293
|
+
return send(res, 400, { error: "team must be a team id, or null" });
|
|
1294
|
+
await updateUserConfig((config) => {
|
|
1295
|
+
const { team: _was, ...rest } = config;
|
|
1296
|
+
return team ? { ...rest, team } : rest;
|
|
1297
|
+
});
|
|
1298
|
+
return send(res, 200, { team: team ?? null });
|
|
1299
|
+
}
|
|
1135
1300
|
/**
|
|
1136
1301
|
* What the account has left, and what it could buy.
|
|
1137
1302
|
*
|
|
@@ -1149,9 +1314,17 @@ export async function startWebServer(opts) {
|
|
|
1149
1314
|
const token = await current.harness.accountToken();
|
|
1150
1315
|
if (!token)
|
|
1151
1316
|
return send(res, 200, { signedIn: false, provider: "official" });
|
|
1152
|
-
|
|
1317
|
+
// The team's balance beside the account's, when a team is chosen.
|
|
1318
|
+
// Asked separately rather than instead: the account menu still says
|
|
1319
|
+
// what the person has, and the paywall needs both to say which one
|
|
1320
|
+
// just ran out.
|
|
1321
|
+
const team = officialTeam();
|
|
1322
|
+
const [state, catalogue, pooled] = await Promise.all([
|
|
1153
1323
|
gatewayJson("/api/v1/billing/balance", { token }),
|
|
1154
1324
|
gatewayJson("/api/v1/billing/plans"),
|
|
1325
|
+
team
|
|
1326
|
+
? gatewayJson(`/api/v1/billing/balance?team=${encodeURIComponent(team)}`, { token })
|
|
1327
|
+
: Promise.resolve(undefined),
|
|
1155
1328
|
]);
|
|
1156
1329
|
if (!state.ok) {
|
|
1157
1330
|
// 501 is a deployment that sells nothing — self-hosted, or a gateway
|
|
@@ -1169,6 +1342,26 @@ export async function startWebServer(opts) {
|
|
|
1169
1342
|
balanceMicro: state.value["balanceMicro"] ?? 0,
|
|
1170
1343
|
credits: state.value["credits"] ?? 0,
|
|
1171
1344
|
...(state.value["subscription"] ? { subscription: state.value["subscription"] } : {}),
|
|
1345
|
+
// The chosen team and what it has. A refusal — not on the team any
|
|
1346
|
+
// more, or a gateway that predates teams — is carried as the id
|
|
1347
|
+
// alone with `refused`, so the menu can offer the way back to the
|
|
1348
|
+
// account's own plan rather than a balance it cannot read.
|
|
1349
|
+
...(team
|
|
1350
|
+
? {
|
|
1351
|
+
team: pooled?.ok
|
|
1352
|
+
? {
|
|
1353
|
+
id: team,
|
|
1354
|
+
role: pooled.value["role"] ?? "member",
|
|
1355
|
+
// Who may pay for the team, which decides what the
|
|
1356
|
+
// paywall offers: the team page, or asking someone.
|
|
1357
|
+
billing: pooled.value["billing"] === true,
|
|
1358
|
+
balanceMicro: pooled.value["balanceMicro"] ?? 0,
|
|
1359
|
+
...(pooled.value["members"] !== undefined ? { members: pooled.value["members"] } : {}),
|
|
1360
|
+
...(pooled.value["subscription"] ? { subscription: pooled.value["subscription"] } : {}),
|
|
1361
|
+
}
|
|
1362
|
+
: { id: team, refused: pooled?.error ?? "unreachable" },
|
|
1363
|
+
}
|
|
1364
|
+
: {}),
|
|
1172
1365
|
// An empty list is the honest answer when the price list cannot be
|
|
1173
1366
|
// read; the paywall falls back to sending people to the site, which
|
|
1174
1367
|
// is where buying works without this app at all.
|
|
@@ -1318,18 +1511,50 @@ export async function startWebServer(opts) {
|
|
|
1318
1511
|
return void res.end(body);
|
|
1319
1512
|
}
|
|
1320
1513
|
/**
|
|
1321
|
-
*
|
|
1514
|
+
* The permission trail — one session's, or every session's.
|
|
1515
|
+
*
|
|
1516
|
+
* A session by default, because that is where it is written and how the
|
|
1517
|
+
* Settings row reads it: beside the conversation it describes, with no
|
|
1518
|
+
* id to know before opening.
|
|
1519
|
+
*
|
|
1520
|
+
* `session=all` is what the page asks for. The question an audit trail
|
|
1521
|
+
* is opened for — when was this ever allowed, and who said yes — is
|
|
1522
|
+
* rarely one a single session answers, and leaving it to be answered by
|
|
1523
|
+
* downloading the export and reading JSON lines by hand is not an answer
|
|
1524
|
+
* a page can hand somebody.
|
|
1322
1525
|
*
|
|
1323
|
-
*
|
|
1324
|
-
*
|
|
1325
|
-
*
|
|
1526
|
+
* Newest session first and capped, because the walk is a file read per
|
|
1527
|
+
* session. `/audit/export` already pays that cost, but it pays it for a
|
|
1528
|
+
* download somebody asked for by name; a panel that repaints has to stop
|
|
1529
|
+
* somewhere, and `more` says out loud that it did.
|
|
1326
1530
|
*/
|
|
1327
1531
|
case "GET /audit": {
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1532
|
+
const asked = url.searchParams.get("session") ?? current.session.id;
|
|
1533
|
+
if (asked !== "all") {
|
|
1534
|
+
return send(res, 200, {
|
|
1535
|
+
session: asked,
|
|
1536
|
+
entries: await current.harness.decisions(asked),
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
const metas = (await current.harness.listSessions(Number.MAX_SAFE_INTEGER))
|
|
1540
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1541
|
+
const entries = [];
|
|
1542
|
+
let scanned = 0;
|
|
1543
|
+
let more = false;
|
|
1544
|
+
for (const meta of metas) {
|
|
1545
|
+
if (entries.length >= AUDIT_ENTRY_CAP) {
|
|
1546
|
+
more = true;
|
|
1547
|
+
break;
|
|
1548
|
+
}
|
|
1549
|
+
scanned += 1;
|
|
1550
|
+
// The session id on every line, the same way the export writes it:
|
|
1551
|
+
// once the scope is more than one session, a row that cannot say
|
|
1552
|
+
// which one it came from is a row nobody can follow up on.
|
|
1553
|
+
for (const entry of await current.harness.decisions(meta.id)) {
|
|
1554
|
+
entries.push({ ...entry, session: meta.id });
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
return send(res, 200, { session: "all", entries, sessions: scanned, more });
|
|
1333
1558
|
}
|
|
1334
1559
|
// The key lives behind this rather than in the HTML: a build that has one
|
|
1335
1560
|
// still hands it to nobody until they have opted in.
|
|
@@ -1390,8 +1615,8 @@ export async function startWebServer(opts) {
|
|
|
1390
1615
|
// totals, the models, the sessions and the curve — is that window and
|
|
1391
1616
|
// nothing else, because a figure that answers a different question
|
|
1392
1617
|
// from the chart under it is worse than no figure.
|
|
1393
|
-
const
|
|
1394
|
-
const since
|
|
1618
|
+
const window = usageWindow(url.searchParams.get("range"), url.searchParams.get("from"), url.searchParams.get("to"));
|
|
1619
|
+
const { since, until } = window;
|
|
1395
1620
|
const stored = await current.harness.listSessions(USAGE_SESSION_SCAN);
|
|
1396
1621
|
const rows = new Map();
|
|
1397
1622
|
for (const meta of stored) {
|
|
@@ -1425,7 +1650,7 @@ export async function startWebServer(opts) {
|
|
|
1425
1650
|
// They are still counted in `scanned`, which is what says how far back
|
|
1426
1651
|
// the numbers above them reach.
|
|
1427
1652
|
const list = [...rows.values()]
|
|
1428
|
-
.filter((row) => row.updatedAt >= since)
|
|
1653
|
+
.filter((row) => row.updatedAt >= since && row.updatedAt < until)
|
|
1429
1654
|
.filter((row) => row.costUsd > 0 || row.input || row.output || row.cacheRead || row.cacheWrite)
|
|
1430
1655
|
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
1431
1656
|
const zero = { costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
@@ -1450,8 +1675,14 @@ export async function startWebServer(opts) {
|
|
|
1450
1675
|
byModel.set(row.model, add(bucket, row));
|
|
1451
1676
|
}
|
|
1452
1677
|
return send(res, 200, {
|
|
1453
|
-
range:
|
|
1454
|
-
|
|
1678
|
+
range: window.id,
|
|
1679
|
+
// Echoed back so the picker opens on the window being shown rather
|
|
1680
|
+
// than on what was typed: the two differ whenever a range was
|
|
1681
|
+
// clamped, and a calendar that disagrees with the figures beside it
|
|
1682
|
+
// is the one thing it must not do.
|
|
1683
|
+
from: window.from,
|
|
1684
|
+
to: window.to,
|
|
1685
|
+
series: usageSeries(list, since || (list.length ? list[list.length - 1].updatedAt : Date.now()), until),
|
|
1455
1686
|
totals: { ...totals, sessions: list.length },
|
|
1456
1687
|
// Sorted by what was spent, not by name: the row worth seeing first
|
|
1457
1688
|
// is the expensive one.
|
|
@@ -1816,6 +2047,23 @@ export async function startWebServer(opts) {
|
|
|
1816
2047
|
return send(res, 400, { error: errorMessage(err) });
|
|
1817
2048
|
}
|
|
1818
2049
|
}
|
|
2050
|
+
case "POST /effort": {
|
|
2051
|
+
const body = await readJson(req);
|
|
2052
|
+
const runner = runnerFor(body.session);
|
|
2053
|
+
if (!runner)
|
|
2054
|
+
return send(res, 404, { error: "no such session" });
|
|
2055
|
+
if (!isEffortChoice(body.effort)) {
|
|
2056
|
+
return send(res, 400, { error: `unknown effort: ${String(body.effort)}` });
|
|
2057
|
+
}
|
|
2058
|
+
// Not refused while a turn runs, unlike a model switch: the request in
|
|
2059
|
+
// flight already carries its effort, and the next one reads this.
|
|
2060
|
+
runner.session.effort = body.effort;
|
|
2061
|
+
preferredEffort = body.effort;
|
|
2062
|
+
void writePrefs({ effort: preferredEffort });
|
|
2063
|
+
void runner.session.persist().catch(() => { });
|
|
2064
|
+
broadcast({ type: "state", session: runner.session.id, state: state(runner) });
|
|
2065
|
+
return send(res, 200, state(runner));
|
|
2066
|
+
}
|
|
1819
2067
|
case "POST /mode": {
|
|
1820
2068
|
const body = await readJson(req);
|
|
1821
2069
|
const runner = runnerFor(body.session);
|
|
@@ -2007,10 +2255,9 @@ export async function startWebServer(opts) {
|
|
|
2007
2255
|
case "POST /permission": {
|
|
2008
2256
|
const body = await readJson(req);
|
|
2009
2257
|
const entry = typeof body.id === "string" ? pending.get(body.id) : undefined;
|
|
2010
|
-
if (!entry)
|
|
2258
|
+
if (!entry || entry.kind !== "permission")
|
|
2011
2259
|
return send(res, 404, { error: "no such pending request" });
|
|
2012
|
-
|
|
2013
|
-
entry.resolve({
|
|
2260
|
+
entry.settle({
|
|
2014
2261
|
allow: body.allow === true,
|
|
2015
2262
|
...(typeof body.remember === "string"
|
|
2016
2263
|
? { remember: body.remember }
|
|
@@ -2018,6 +2265,19 @@ export async function startWebServer(opts) {
|
|
|
2018
2265
|
});
|
|
2019
2266
|
return send(res, 200, { ok: true });
|
|
2020
2267
|
}
|
|
2268
|
+
case "POST /question": {
|
|
2269
|
+
const body = await readJson(req);
|
|
2270
|
+
const entry = typeof body.id === "string" ? pending.get(body.id) : undefined;
|
|
2271
|
+
if (!entry || entry.kind !== "question")
|
|
2272
|
+
return send(res, 404, { error: "no such pending question" });
|
|
2273
|
+
// Checked here rather than trusted: the page is one client of this
|
|
2274
|
+
// route, and a label the model never offered must not reach it as one.
|
|
2275
|
+
const checked = checkAnswer(entry.request.questions, body.answer);
|
|
2276
|
+
if (!checked.ok)
|
|
2277
|
+
return send(res, 400, { error: checked.error });
|
|
2278
|
+
entry.settle(checked.result);
|
|
2279
|
+
return send(res, 200, { ok: true });
|
|
2280
|
+
}
|
|
2021
2281
|
case "POST /interrupt":
|
|
2022
2282
|
{
|
|
2023
2283
|
const body = await readJson(req);
|
|
@@ -2127,6 +2387,9 @@ export async function startWebServer(opts) {
|
|
|
2127
2387
|
}));
|
|
2128
2388
|
const runtime = (harness.runtimePluginStatus?.() ?? []).map((plugin) => ({
|
|
2129
2389
|
name: plugin.id,
|
|
2390
|
+
// The name stays the id: it is what @ chips and references resolve by.
|
|
2391
|
+
// The title is only ever painted.
|
|
2392
|
+
...(plugin.title ? { title: plugin.title } : {}),
|
|
2130
2393
|
// The state only. Where it came from is the row's tag now, and saying it
|
|
2131
2394
|
// twice in two places is how they drift.
|
|
2132
2395
|
description: `${plugin.description ?? "Runtime plugin"} [${plugin.state}]${plugin.error ? ` — ${plugin.error}` : ""}`,
|
|
@@ -2149,7 +2412,20 @@ export async function startWebServer(opts) {
|
|
|
2149
2412
|
// harness; the rest name where on disk they were found.
|
|
2150
2413
|
builtin: skill.source === "builtin",
|
|
2151
2414
|
}));
|
|
2152
|
-
|
|
2415
|
+
/**
|
|
2416
|
+
* What shipped here first, then what this machine added.
|
|
2417
|
+
*
|
|
2418
|
+
* Every list that reads this — the panel, the + menu, the @ mention —
|
|
2419
|
+
* paints the rows in the order given, and a reader scanning for memory
|
|
2420
|
+
* or the media tools expects them at the top, not under eighty installed
|
|
2421
|
+
* skills. Stable within each half, so the order the harness reported
|
|
2422
|
+
* survives the split.
|
|
2423
|
+
*/
|
|
2424
|
+
const builtinFirst = (list) => [
|
|
2425
|
+
...list.filter((one) => one.source === "builtin"),
|
|
2426
|
+
...list.filter((one) => one.source !== "builtin"),
|
|
2427
|
+
];
|
|
2428
|
+
return { connectors, plugins: builtinFirst([...plugins, ...runtime]), skills: builtinFirst(skills) };
|
|
2153
2429
|
}
|
|
2154
2430
|
/**
|
|
2155
2431
|
* What the composer attached to one message, as a block above the prompt.
|
|
@@ -2209,6 +2485,13 @@ export async function startWebServer(opts) {
|
|
|
2209
2485
|
model: model.id,
|
|
2210
2486
|
provider: model.provider,
|
|
2211
2487
|
mode: sess.permissions.getMode(),
|
|
2488
|
+
// What the next request will carry, not what was picked: a pick the
|
|
2489
|
+
// model lacks shows as the tier it is clamped to, and a model that
|
|
2490
|
+
// cannot stop thinking never shows "off". Absent when the model has no
|
|
2491
|
+
// tiers at all, which is the page's cue to hide the control.
|
|
2492
|
+
effort: effectiveEffort(model, thinkingFor(runner.harness.config.thinking, sess.effort)),
|
|
2493
|
+
effortLevels: model.effortLevels,
|
|
2494
|
+
effortOff: model.effortLevels.length > 0 && canDisableThinking(model),
|
|
2212
2495
|
costUsd: sess.costUsd,
|
|
2213
2496
|
// Beside the cost, because the line under the composer says three things
|
|
2214
2497
|
// about one turn: what it cost, how many tokens that took, and how much
|
|
@@ -2235,7 +2518,7 @@ export async function startWebServer(opts) {
|
|
|
2235
2518
|
index++;
|
|
2236
2519
|
const tools = message.content.filter((block) => block.type === "tool_call");
|
|
2237
2520
|
const results = message.content.filter((block) => block.type === "tool_result");
|
|
2238
|
-
const text =
|
|
2521
|
+
const text = visibleText(message).trim();
|
|
2239
2522
|
// The bytes stay where they are; what travels is where to find them.
|
|
2240
2523
|
// Without this a reloaded conversation showed "[image: shot.png]" where
|
|
2241
2524
|
// the screenshot had been — the model still saw it, the person did not.
|
|
@@ -2308,7 +2591,8 @@ export async function startWebServer(opts) {
|
|
|
2308
2591
|
? opts.openProject(project.path)
|
|
2309
2592
|
: Harness.create({
|
|
2310
2593
|
cwd: project.path,
|
|
2311
|
-
permissionPrompt: (request) => permissionPrompt(request),
|
|
2594
|
+
permissionPrompt: (request, signal) => permissionPrompt(request, signal),
|
|
2595
|
+
questionPrompt: (request, signal) => questionPrompt(request, signal),
|
|
2312
2596
|
}));
|
|
2313
2597
|
harnesses.set(project.path, harness);
|
|
2314
2598
|
watchConnections(harness);
|
|
@@ -2420,6 +2704,12 @@ export async function startWebServer(opts) {
|
|
|
2420
2704
|
const input = staged.length ? buildMessage(text, staged) : text;
|
|
2421
2705
|
try {
|
|
2422
2706
|
for await (const event of runner.agent.run(input, { signal: controller.signal })) {
|
|
2707
|
+
// The agent's own question_request is not forwarded: the page draws
|
|
2708
|
+
// the card from questionPrompt's announce, which carries the id the
|
|
2709
|
+
// answer is posted back to. Forwarding both drew the card twice, and
|
|
2710
|
+
// the copy without that id could only ever 404.
|
|
2711
|
+
if (event.type === "question_request")
|
|
2712
|
+
continue;
|
|
2423
2713
|
broadcast({ ...event, session: id });
|
|
2424
2714
|
}
|
|
2425
2715
|
}
|
|
@@ -2471,6 +2761,14 @@ export async function startWebServer(opts) {
|
|
|
2471
2761
|
res.write(`data: ${JSON.stringify({ type: "connection_card", session: card.session, card })}\n\n`);
|
|
2472
2762
|
}
|
|
2473
2763
|
}
|
|
2764
|
+
// Whatever is still waiting on a person, again. A page that reloads while
|
|
2765
|
+
// a turn waits on it would otherwise never see the card, and the turn
|
|
2766
|
+
// would wait on nobody for as long as the process lives.
|
|
2767
|
+
for (const entry of pending.values())
|
|
2768
|
+
res.write(`data: ${JSON.stringify(announce(entry))}\n\n`);
|
|
2769
|
+
// And the full list, so a page that missed an interaction_closed while
|
|
2770
|
+
// disconnected can drop the cards that are no longer waiting.
|
|
2771
|
+
res.write(`data: ${JSON.stringify({ type: "pending_interactions", ids: [...pending.keys()] })}\n\n`);
|
|
2474
2772
|
// A heartbeat keeps intermediaries from closing an idle stream while the
|
|
2475
2773
|
// model is thinking.
|
|
2476
2774
|
const beat = setInterval(() => res.write(": ping\n\n"), 15_000);
|
|
@@ -2638,6 +2936,7 @@ export async function startWebServer(opts) {
|
|
|
2638
2936
|
url: `http://${host}:${port}/?token=${token}`,
|
|
2639
2937
|
token,
|
|
2640
2938
|
permissionPrompt,
|
|
2939
|
+
questionPrompt,
|
|
2641
2940
|
async close() {
|
|
2642
2941
|
for (const unsubscribe of connectionSubscriptions)
|
|
2643
2942
|
unsubscribe();
|
|
@@ -2648,9 +2947,12 @@ export async function startWebServer(opts) {
|
|
|
2648
2947
|
clients.clear();
|
|
2649
2948
|
// Any prompt still waiting has to be answered, or the agent's turn hangs
|
|
2650
2949
|
// forever holding the process open. Shutting down denies.
|
|
2651
|
-
for (const entry of pending.values())
|
|
2652
|
-
entry.
|
|
2653
|
-
|
|
2950
|
+
for (const entry of [...pending.values()]) {
|
|
2951
|
+
if (entry.kind === "permission")
|
|
2952
|
+
entry.settle({ allow: false });
|
|
2953
|
+
else
|
|
2954
|
+
entry.settle({ outcome: "unavailable" });
|
|
2955
|
+
}
|
|
2654
2956
|
for (const runner of runners.values())
|
|
2655
2957
|
runner.running?.abort();
|
|
2656
2958
|
await new Promise((resolve) => server.close(() => resolve()));
|