@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/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 { messageText } from "@oharness/engine/core/types.ts";
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 now = Date.now();
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 happening now, so the newest column is never a
72
- // sliver that reads as a collapse in spending.
73
- const end = Math.floor(now / width) * width + width;
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 = (await readPrefs()).model;
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(known ? { model: preferredModel } : {});
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
- const permissionPrompt = (request) => new Promise((resolve) => {
270
- const id = randomUUID();
271
- pending.set(id, { id, request, resolve });
272
- broadcast({ type: "permission_request", id, request });
273
- // No timeout here on purpose. A prompt that auto-denies after a while
274
- // would silently fail long-running work while the user is away; a prompt
275
- // that auto-allows would be worse. It waits.
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
- return send(res, 400, { error: errorMessage(error) });
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
- const [state, catalogue] = await Promise.all([
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
- * One session's permission trail.
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
- * Scoped to a session because that is where it is written and how it is
1324
- * meant to be read — beside the conversation it describes. Defaults to
1325
- * the focused one so the panel does not have to know an id to open.
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 id = url.searchParams.get("session") ?? current.session.id;
1329
- return send(res, 200, {
1330
- session: id,
1331
- entries: await current.harness.decisions(id),
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 range = USAGE_RANGES[url.searchParams.get("range") ?? ""] ?? USAGE_RANGES["30d"];
1394
- const since = range.days ? Date.now() - range.days * DAY_MS : 0;
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: range.id,
1454
- series: usageSeries(list, since || (list.length ? list[list.length - 1].updatedAt : Date.now())),
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
- pending.delete(entry.id);
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
- return { connectors, plugins: [...plugins, ...runtime], skills };
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 = messageText(message).trim();
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.resolve({ allow: false });
2653
- pending.clear();
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()));