@oharness/server 0.6.0 → 0.7.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
@@ -9,22 +9,27 @@ import { connectorConfig, officialConnectors } from "@oharness/engine/mcp/regist
9
9
  import { missingSecrets } from "@oharness/engine/mcp/secrets.ts";
10
10
  import { connectorIcon, providerSites, siteIcon } from "./icons.js";
11
11
  import { checkAnswer } from "@oharness/engine/tools/questions.ts";
12
+ import { isFree } from "@oharness/engine/providers/types.ts";
12
13
  import { PERMISSION_MODES } from "@oharness/engine/agent/permissions.ts";
13
14
  import { canDisableThinking, effectiveEffort, isEffortChoice, thinkingFor, } from "@oharness/engine/providers/types.ts";
14
15
  import { isReadable } from "@oharness/engine/agent/workspace.ts";
15
16
  import { visibleText } from "@oharness/engine/core/types.ts";
16
17
  import { errorMessage } from "@oharness/engine/core/errors.ts";
17
18
  import { officialBaseUrl, officialTeam } from "@oharness/engine/auth/providers/official.ts";
18
- import { updateUserConfig } from "@oharness/engine/config.ts";
19
+ import { loadUserConfig, updateUserConfig } from "@oharness/engine/config.ts";
20
+ import { RoutineRunner } from "@oharness/engine/schedule/runner.ts";
19
21
  import { accountsConfigured, browserKeyUrl, browserSignInUrl, exchangeHandoff, siteUrl, } from "@oharness/engine/auth/providers/account.ts";
20
22
  import { ChangeLedger } from "./changes.js";
21
- import { addProject, ensureProject, listProjects, removeProject } from "./projects.js";
23
+ import { addProject, ensureProject, listProjects, removeProject, renameProject } from "./projects.js";
22
24
  import { readPrefs, writePrefs } from "./prefs.js";
23
- import { listDirectories, pickDirectory, pickFiles } from "./pick.js";
25
+ import { sortModelsForDisplay } from "./model-order.js";
26
+ import { loadModelsDev, releaseDateOf, releaseDates } from "@oharness/engine/providers/models-dev.ts";
27
+ import { listDirectories, pickDirectory, pickFiles, revealDirectory } from "./pick.js";
24
28
  import { browseFiles, searchFiles, within } from "./files.js";
25
29
  import { analyticsState } from "./analytics.js";
26
30
  import { PAGE } from "./page.js";
27
31
  import { artifactDocument, sendArtifact } from "./artifacts.js";
32
+ import { sendProjectExport } from "./project-export.js";
28
33
  import { ArtifactStore, getArtifactStore, isCanvas, isValidArtifactId, restoreArtifacts, } from "@oharness/engine/artifacts/store.ts";
29
34
  import { accountTeams, publishArtifact, unpublishArtifact } from "@oharness/engine/artifacts/publish.ts";
30
35
  /**
@@ -163,6 +168,21 @@ function tokensOf(usage) {
163
168
  cacheWrite: usage.cacheWriteTokens || 0,
164
169
  };
165
170
  }
171
+ /**
172
+ * Release dates from the bundled models.dev snapshot, read once.
173
+ *
174
+ * Its date wins over a model's own: a listing's `created` is sometimes the
175
+ * time it was listed, or a constant, and the snapshot knows most of the models
176
+ * a gateway or an aggregator relays under the same name.
177
+ */
178
+ let knownDates;
179
+ async function withReleaseDates(models) {
180
+ const dates = await (knownDates ??= loadModelsDev().then(releaseDates));
181
+ return models.map((model) => {
182
+ const known = releaseDateOf(dates, model.model);
183
+ return known === undefined || known === model.releasedAt ? model : { ...model, releasedAt: known };
184
+ });
185
+ }
166
186
  export async function startWebServer(opts) {
167
187
  const token = randomUUID();
168
188
  /**
@@ -184,7 +204,7 @@ export async function startWebServer(opts) {
184
204
  const makeRunner = (session, harness) => {
185
205
  const ledger = new ChangeLedger(session.cwd);
186
206
  ledger.attach(harness.hooks);
187
- return { session, agent: harness.agent(session), harness, ledger };
207
+ return { session, agent: harness.agent(session), harness, ledger, queue: [] };
188
208
  };
189
209
  /** The runner a request names, or the most recently focused one. */
190
210
  let focused = opts.session.id;
@@ -824,6 +844,16 @@ export async function startWebServer(opts) {
824
844
  await writePrefs({ sessionOrder: order });
825
845
  return send(res, 200, { order: ids });
826
846
  }
847
+ case "POST /reveal": {
848
+ const body = await readJson(req);
849
+ // A folder, and only one that exists: this opens a window on the
850
+ // desktop the agent runs on, and a stale path would open nothing.
851
+ const dir = typeof body.path === "string" && body.path ? path.resolve(body.path) : "";
852
+ if (!dir || !(await isDirectory(dir)))
853
+ return send(res, 400, { error: "not a folder" });
854
+ const result = await revealDirectory(dir);
855
+ return send(res, "error" in result ? 500 : 200, result);
856
+ }
827
857
  case "POST /pick": {
828
858
  const body = await readJson(req);
829
859
  // The dialog opens on this machine's desktop, which is the same
@@ -1101,6 +1131,40 @@ export async function startWebServer(opts) {
1101
1131
  // Any tab: switching projects rebuilds the harness under every runner.
1102
1132
  if (anyRunning())
1103
1133
  return send(res, 409, { error: "a turn is running" });
1134
+ if (typeof body.rename === "string") {
1135
+ const oldPath = path.resolve(dir);
1136
+ const oldSession = current.session.cwd === oldPath ? current.session.id : undefined;
1137
+ const oldHarness = harnesses.get(oldPath);
1138
+ const store = oldHarness?.store ?? current.harness.store;
1139
+ let reopenAt = oldPath;
1140
+ try {
1141
+ if (oldHarness)
1142
+ await oldHarness.close();
1143
+ harnesses.delete(oldPath);
1144
+ const renamed = await renameProject(oldPath, body.rename);
1145
+ reopenAt = renamed.project.path;
1146
+ const relocated = await store.relocateProject(renamed.project.id, oldPath, renamed.project.path);
1147
+ for (const [id, runner] of [...runners]) {
1148
+ if (runner.session.cwd === oldPath)
1149
+ runners.delete(id);
1150
+ }
1151
+ cachedLogs = undefined;
1152
+ const moved = oldSession ? await switchProject(renamed.project.path, oldSession) : undefined;
1153
+ return send(res, 200, {
1154
+ project: renamed.project,
1155
+ relocated,
1156
+ open: [...runners.keys()],
1157
+ ...(moved ? { state: moved } : {}),
1158
+ });
1159
+ }
1160
+ catch (err) {
1161
+ // If preparation closed the current harness but the rename failed,
1162
+ // rebuild it so the window is not left attached to a dead runtime.
1163
+ if (oldSession && !harnesses.has(reopenAt))
1164
+ await switchProject(reopenAt, oldSession).catch(() => { });
1165
+ return send(res, 400, { error: errorMessage(err) });
1166
+ }
1167
+ }
1104
1168
  if (body.remove === true) {
1105
1169
  return send(res, 200, { removed: await removeProject(dir) });
1106
1170
  }
@@ -1180,10 +1244,98 @@ export async function startWebServer(opts) {
1180
1244
  return send(res, 400, { error: errorMessage(err) });
1181
1245
  }
1182
1246
  }
1247
+ case "GET /project/export": {
1248
+ const dir = path.resolve(url.searchParams.get("path") ?? current.session.cwd);
1249
+ const project = (await listProjects()).find((one) => one.path === dir);
1250
+ if (!project || project.missing)
1251
+ return send(res, 404, { error: "project not found" });
1252
+ try {
1253
+ await sendProjectExport(res, project.path, project.name);
1254
+ }
1255
+ catch (err) {
1256
+ if (!res.headersSent)
1257
+ return send(res, 400, { error: errorMessage(err) });
1258
+ res.destroy(err instanceof Error ? err : new Error(String(err)));
1259
+ }
1260
+ return;
1261
+ }
1183
1262
  case "GET /routines":
1184
1263
  // The scheduler is real machinery; a panel that invented its own list
1185
1264
  // would drift from what actually fires.
1186
- return send(res, 200, { routines: await current.harness.scheduler.status() });
1265
+ {
1266
+ // Which ones this machine's config holds: those are the ones the
1267
+ // panel can delete. A project's are the repository's to change.
1268
+ // A routine the agent added in another project's session is in the
1269
+ // file but not yet in this project's scheduler.
1270
+ await current.harness.syncUserSchedules();
1271
+ const user = (await loadUserConfig()).schedules ?? [];
1272
+ const mine = new Map(user.map((one) => [one.name, one]));
1273
+ const list = (await current.harness.scheduler.status()).map((one) => ({
1274
+ ...one,
1275
+ removable: mine.has(one.name),
1276
+ // Where it runs: the directory it was made for, or this project
1277
+ // for one that names none.
1278
+ cwd: mine.get(one.name)?.cwd ?? current.harness.cwd,
1279
+ }));
1280
+ return send(res, 200, { routines: list });
1281
+ }
1282
+ /** Pause a routine, or resume it. The user's own only. */
1283
+ case "POST /routine/enabled": {
1284
+ const body = await readJson(req);
1285
+ const name = typeof body.name === "string" ? body.name : "";
1286
+ if (!name || typeof body.enabled !== "boolean") {
1287
+ return send(res, 400, { error: "name and enabled are required" });
1288
+ }
1289
+ try {
1290
+ await current.harness.setScheduleEnabled(name, body.enabled);
1291
+ for (const harness of harnesses.values()) {
1292
+ const held = harness.scheduler.list().find((one) => one.name === name);
1293
+ if (held)
1294
+ harness.scheduler.add({ ...held, enabled: body.enabled });
1295
+ }
1296
+ return send(res, 200, { name, enabled: body.enabled });
1297
+ }
1298
+ catch (err) {
1299
+ return send(res, 400, { error: errorMessage(err) });
1300
+ }
1301
+ }
1302
+ /** A routine's runs and skipped times, newest first. */
1303
+ case "GET /routine/history": {
1304
+ const name = url.searchParams.get("name") ?? "";
1305
+ if (!name)
1306
+ return send(res, 400, { error: "name is required" });
1307
+ return send(res, 200, { runs: await current.harness.scheduler.history(name, 30) });
1308
+ }
1309
+ case "GET /keep-awake":
1310
+ return send(res, 200, {
1311
+ available: !!opts.onKeepAwake,
1312
+ enabled: (await readPrefs()).keepAwake === true,
1313
+ });
1314
+ case "POST /keep-awake": {
1315
+ if (!opts.onKeepAwake)
1316
+ return send(res, 404, { error: "not available here" });
1317
+ const body = await readJson(req);
1318
+ if (typeof body.enabled !== "boolean")
1319
+ return send(res, 400, { error: "enabled must be a boolean" });
1320
+ await writePrefs({ keepAwake: body.enabled });
1321
+ opts.onKeepAwake(body.enabled);
1322
+ return send(res, 200, { available: true, enabled: body.enabled });
1323
+ }
1324
+ case "POST /routine/remove": {
1325
+ const body = await readJson(req);
1326
+ const name = typeof body.name === "string" ? body.name : "";
1327
+ if (!name)
1328
+ return send(res, 400, { error: "name is required" });
1329
+ try {
1330
+ await current.harness.removeSchedule(name);
1331
+ for (const harness of harnesses.values())
1332
+ harness.scheduler.remove(name);
1333
+ return send(res, 200, { removed: name });
1334
+ }
1335
+ catch (err) {
1336
+ return send(res, 400, { error: errorMessage(err) });
1337
+ }
1338
+ }
1187
1339
  case "POST /routine": {
1188
1340
  const body = await readJson(req);
1189
1341
  const name = typeof body.name === "string" ? body.name : "";
@@ -1192,11 +1344,14 @@ export async function startWebServer(opts) {
1192
1344
  if (anyRunning())
1193
1345
  return send(res, 409, { error: "a turn is running" });
1194
1346
  try {
1195
- const run = await current.harness.scheduler.runNow(name);
1196
- broadcast({
1197
- type: "notice",
1198
- message: `routine ${name} ${run.ok ? "finished" : "failed"} in ${run.turns} turns`,
1199
- });
1347
+ // In the routine's own project, the same as when the clock fires it.
1348
+ const target = await routines.find(name, current.harness.cwd);
1349
+ if (!target)
1350
+ return send(res, 400, { error: `no routine named "${name}"` });
1351
+ const run = await routines.run(target);
1352
+ if (!run)
1353
+ return send(res, 409, { error: `routine ${name} is already running` });
1354
+ reportRoutineRun(run, target.cwd);
1200
1355
  return send(res, 200, { run });
1201
1356
  }
1202
1357
  catch (err) {
@@ -1590,6 +1745,8 @@ export async function startWebServer(opts) {
1590
1745
  costUsd: meta.costUsd,
1591
1746
  current: meta.id === current.session.id,
1592
1747
  forkedFrom: meta.forkedFrom,
1748
+ // A routine's run, which the sidebar keeps in a group of its own.
1749
+ routine: meta.routine,
1593
1750
  // Sessions are stored per home, not per project, so this list spans
1594
1751
  // every tree opened here. The page groups by it rather than showing
1595
1752
  // one project's transcripts as another's history.
@@ -1785,12 +1942,22 @@ export async function startWebServer(opts) {
1785
1942
  // Where to go for a key. Has a default even on a build that knows no
1786
1943
  // account project, which is exactly the build that needs the link.
1787
1944
  site: siteUrl(),
1788
- models: current.harness.listModels().map((model) => ({
1945
+ // Newest release first within each provider. See model-order.ts;
1946
+ // the picker and the Models page both keep this order.
1947
+ models: sortModelsForDisplay(await withReleaseDates(current.harness.listModels())).map((model) => ({
1789
1948
  id: model.id,
1790
1949
  provider: model.provider,
1791
1950
  contextWindow: model.contextWindow,
1792
1951
  configured: configured.has(model.provider),
1793
1952
  current: model.id === current.session.model.id,
1953
+ // The fact, not the two numbers it is derived from. This panel has
1954
+ // no price column and is not getting one — a rate belongs on the
1955
+ // rate card, where the markup that applies to it is known — so
1956
+ // shipping the pair here would be shipping something only a second
1957
+ // copy of `isFree` could read correctly. Present only when true,
1958
+ // because the list runs to nine hundred entries and an absent key
1959
+ // and a false one say the same thing to the one reader there is.
1960
+ ...(isFree(model) ? { free: true } : {}),
1794
1961
  })),
1795
1962
  providers: current.harness.listCatalogProviders().map((entry) => ({
1796
1963
  id: entry.id,
@@ -2236,9 +2403,6 @@ export async function startWebServer(opts) {
2236
2403
  if (!prompt && !ids.length && !references.length) {
2237
2404
  return send(res, 400, { error: "prompt is required" });
2238
2405
  }
2239
- // Per runner, not global: a turn in one tab must not block another.
2240
- if (runner.running)
2241
- return send(res, 409, { error: "a turn is already running" });
2242
2406
  const staged = ids
2243
2407
  .map((id) => (typeof id === "string" ? attachments.get(id) : undefined))
2244
2408
  .filter((entry) => Boolean(entry));
@@ -2249,6 +2413,19 @@ export async function startWebServer(opts) {
2249
2413
  // a composer is known to have moved on, so it is a good moment to ask
2250
2414
  // what else has been abandoned.
2251
2415
  sweepAttachments();
2416
+ // Per runner, not global: a turn in one tab must not block another.
2417
+ // A second prompt into a busy runner waits, and is shown waiting. It
2418
+ // is not handed to the running turn on its own: putting words in front
2419
+ // of a model mid-thought is a thing to choose, not a thing that
2420
+ // happens because you typed fast. /sendnow is that choice.
2421
+ //
2422
+ // This used to 409, and the page discarded the reply, so an impatient
2423
+ // Enter cleared the composer and lost what had been typed.
2424
+ if (runner.running) {
2425
+ runner.queue.push({ id: randomUUID(), prompt, staged, references });
2426
+ broadcastQueue(runner);
2427
+ return send(res, 202, { ok: true, queued: true });
2428
+ }
2252
2429
  void runTurn(runner, prompt, staged, references);
2253
2430
  return send(res, 202, { ok: true, attached: staged.length, referenced: references.length });
2254
2431
  }
@@ -2284,6 +2461,84 @@ export async function startWebServer(opts) {
2284
2461
  runnerFor(body.session)?.running?.abort();
2285
2462
  }
2286
2463
  return send(res, 200, { ok: true });
2464
+ case "POST /unqueue": {
2465
+ const body = await readJson(req);
2466
+ const runner = runnerFor(body.session);
2467
+ if (!runner)
2468
+ return send(res, 404, { error: "no such session" });
2469
+ // Either kind: one handed to the running turn but not yet read, or
2470
+ // one still waiting for the turn to end.
2471
+ if (typeof body.id === "string" && runner.agent.retract(body.id)) {
2472
+ broadcastQueue(runner);
2473
+ return send(res, 200, { ok: true });
2474
+ }
2475
+ const at = runner.queue.findIndex((entry) => entry.id === body.id);
2476
+ if (at >= 0) {
2477
+ // The bytes came out of the staging map when this was queued, so
2478
+ // they are held by the entry alone and go when it does.
2479
+ runner.queue.splice(at, 1);
2480
+ broadcastQueue(runner);
2481
+ }
2482
+ return send(res, 200, { ok: true });
2483
+ }
2484
+ /**
2485
+ * One waiting message, now, without waiting for its place in the queue.
2486
+ *
2487
+ * While a turn runs that means handing it to the turn — the same path a
2488
+ * prompt typed mid-turn takes — so it is read at the next gap rather
2489
+ * than after the whole thing. Nothing is interrupted: "now" here is as
2490
+ * soon as the conversation has a place to put it.
2491
+ *
2492
+ * A message carrying files cannot ride the inbox, so it moves to the
2493
+ * head of the queue instead and the reply says so.
2494
+ */
2495
+ case "POST /sendnow": {
2496
+ const body = await readJson(req);
2497
+ const runner = runnerFor(body.session);
2498
+ if (!runner)
2499
+ return send(res, 404, { error: "no such session" });
2500
+ const at = runner.queue.findIndex((entry) => entry.id === body.id);
2501
+ if (at < 0)
2502
+ return send(res, 404, { error: "no such queued message" });
2503
+ const entry = runner.queue[at];
2504
+ if (!runner.running) {
2505
+ runner.queue.splice(at, 1);
2506
+ broadcastQueue(runner);
2507
+ void runTurn(runner, entry.prompt, entry.staged, entry.references);
2508
+ return send(res, 202, { ok: true, sent: true });
2509
+ }
2510
+ const plain = entry.prompt && !entry.staged.length && !entry.references.length;
2511
+ if (plain && runner.agent.interject(entry.prompt)) {
2512
+ runner.queue.splice(at, 1);
2513
+ broadcastQueue(runner);
2514
+ return send(res, 202, { ok: true, interjected: true });
2515
+ }
2516
+ // Cannot be handed over, so the most that can be done is to put it
2517
+ // first in line for when the turn ends.
2518
+ runner.queue.splice(at, 1);
2519
+ runner.queue.unshift(entry);
2520
+ broadcastQueue(runner);
2521
+ return send(res, 200, { ok: true, moved: true });
2522
+ }
2523
+ /**
2524
+ * Send what is waiting, now.
2525
+ *
2526
+ * The queue drains itself after a turn that ends on its own, but not
2527
+ * after an interrupt or an error — stopping means stop, and a message
2528
+ * typed before the failure should not fire into whatever caused it.
2529
+ * This is how the page restarts a queue left standing that way.
2530
+ */
2531
+ case "POST /flush": {
2532
+ const body = await readJson(req);
2533
+ const runner = runnerFor(body.session);
2534
+ if (!runner)
2535
+ return send(res, 404, { error: "no such session" });
2536
+ if (runner.running)
2537
+ return send(res, 409, { error: "a turn is already running" });
2538
+ if (!drainQueue(runner))
2539
+ return send(res, 200, { ok: true, sent: false });
2540
+ return send(res, 202, { ok: true, sent: true });
2541
+ }
2287
2542
  default:
2288
2543
  return send(res, 404, { error: "not found" });
2289
2544
  }
@@ -2502,6 +2757,9 @@ export async function startWebServer(opts) {
2502
2757
  contextWindow: model.contextWindow,
2503
2758
  contextUsed: model.contextWindow ? Math.min(1, used / model.contextWindow) : undefined,
2504
2759
  busy: Boolean(runner.running),
2760
+ // So a reload, or a second tab, sees what is still waiting. The queue
2761
+ // lives on the runner rather than in the page for exactly this reason.
2762
+ queued: queueView(runner),
2505
2763
  };
2506
2764
  }
2507
2765
  /**
@@ -2598,6 +2856,44 @@ export async function startWebServer(opts) {
2598
2856
  watchConnections(harness);
2599
2857
  return harness;
2600
2858
  }
2859
+ /*
2860
+ * Routines: which exist, where each runs, and — when this server was asked
2861
+ * to — the clock that fires them. A run lands in the page as a notice and,
2862
+ * through onRoutineRun, wherever the desktop can reach someone who is not
2863
+ * looking at the page.
2864
+ */
2865
+ const routines = new RoutineRunner({
2866
+ defaultCwd: opts.session.cwd,
2867
+ openHarnesses: () => harnesses.values(),
2868
+ createHarness: (cwd) => opts.openProject
2869
+ ? opts.openProject(cwd)
2870
+ : Harness.create({
2871
+ cwd,
2872
+ permissionPrompt: async () => false,
2873
+ questionPrompt: async () => ({ outcome: "unavailable" }),
2874
+ }),
2875
+ onStart: (routine, meta) => {
2876
+ if (meta.catchUp)
2877
+ broadcast({ type: "notice", message: `routine ${routine.def.name} is catching up on a run it missed` });
2878
+ opts.onRoutineStart?.(routine.def.name, meta);
2879
+ },
2880
+ onRun: (run, routine) => reportRoutineRun(run, routine.cwd),
2881
+ onError: (routine, error) => broadcast({ type: "notice", message: `routine ${routine.def.name} could not start: ${error}` }),
2882
+ });
2883
+ function reportRoutineRun(run, cwd) {
2884
+ broadcast({
2885
+ type: "notice",
2886
+ message: `routine ${run.schedule} ${run.ok ? "finished" : "failed"} in ${run.turns} turns`,
2887
+ });
2888
+ // The run's session is new, and the sidebar has a group for it.
2889
+ broadcast({ type: "sessions" });
2890
+ try {
2891
+ opts.onRoutineRun?.(run, cwd);
2892
+ }
2893
+ catch {
2894
+ // A notifier that throws must not lose the run it was told about.
2895
+ }
2896
+ }
2601
2897
  /**
2602
2898
  * Open a different project.
2603
2899
  *
@@ -2680,6 +2976,46 @@ export async function startWebServer(opts) {
2680
2976
  });
2681
2977
  return snapshot;
2682
2978
  }
2979
+ /**
2980
+ * What the page needs to draw the waiting chips, without the bytes.
2981
+ *
2982
+ * Interjections first, and marked as such: they reach the model during the
2983
+ * running turn, while the queue waits for it to finish. Drawn as one list
2984
+ * because that is the order the conversation will read them in.
2985
+ */
2986
+ const queueView = (runner) => [
2987
+ ...runner.agent.pendingInterjections.map((entry) => ({
2988
+ id: entry.id,
2989
+ text: entry.text,
2990
+ attachments: [],
2991
+ references: [],
2992
+ soon: true,
2993
+ })),
2994
+ ...runner.queue.map((entry) => ({
2995
+ id: entry.id,
2996
+ text: entry.prompt,
2997
+ attachments: entry.staged.map((file) => file.name),
2998
+ references: entry.references,
2999
+ soon: false,
3000
+ })),
3001
+ ];
3002
+ const broadcastQueue = (runner) => {
3003
+ broadcast({ type: "queue", session: runner.session.id, queued: queueView(runner) });
3004
+ };
3005
+ /**
3006
+ * Start the next waiting prompt, if there is one.
3007
+ *
3008
+ * Returns whether a turn was started, so a caller can tell an empty queue
3009
+ * from one that is now running.
3010
+ */
3011
+ function drainQueue(runner) {
3012
+ const next = runner.queue.shift();
3013
+ if (!next)
3014
+ return false;
3015
+ broadcastQueue(runner);
3016
+ void runTurn(runner, next.prompt, next.staged, next.references);
3017
+ return true;
3018
+ }
2683
3019
  async function runTurn(runner, prompt, staged = [], references = []) {
2684
3020
  const controller = new AbortController();
2685
3021
  runner.running = controller;
@@ -2702,6 +3038,7 @@ export async function startWebServer(opts) {
2702
3038
  const note = referenceNote(references);
2703
3039
  const text = note ? `${note}\n\n${prompt}` : prompt;
2704
3040
  const input = staged.length ? buildMessage(text, staged) : text;
3041
+ let failed = false;
2705
3042
  try {
2706
3043
  for await (const event of runner.agent.run(input, { signal: controller.signal })) {
2707
3044
  // The agent's own question_request is not forwarded: the page draws
@@ -2710,10 +3047,25 @@ export async function startWebServer(opts) {
2710
3047
  // the copy without that id could only ever 404.
2711
3048
  if (event.type === "question_request")
2712
3049
  continue;
3050
+ // The agent reports a provider failure as an event and breaks its own
3051
+ // loop rather than throwing, so the catch below never sees it — and a
3052
+ // queue that drained into a dead provider would replay the failure
3053
+ // once per waiting message.
3054
+ if (event.type === "error" && event.fatal)
3055
+ failed = true;
3056
+ // Read into the conversation: the chip goes, and the words appear in
3057
+ // the transcript where the model saw them rather than where they were
3058
+ // typed. `prompt` is what the page draws a user message from.
3059
+ if (event.type === "interjected") {
3060
+ broadcastQueue(runner);
3061
+ broadcast({ type: "prompt", session: id, text: event.text, interjected: true });
3062
+ continue;
3063
+ }
2713
3064
  broadcast({ ...event, session: id });
2714
3065
  }
2715
3066
  }
2716
3067
  catch (err) {
3068
+ failed = true;
2717
3069
  broadcast({
2718
3070
  type: "error",
2719
3071
  session: id,
@@ -2737,12 +3089,24 @@ export async function startWebServer(opts) {
2737
3089
  }
2738
3090
  finally {
2739
3091
  runner.running = undefined;
3092
+ // The window between the loop's last read of the inbox and the run
3093
+ // admitting it is over. Whatever landed in it is put at the head of the
3094
+ // queue rather than left unread inside a finished agent.
3095
+ const unread = runner.agent.takeUnread();
3096
+ for (let i = unread.length - 1; i >= 0; i--) {
3097
+ runner.queue.unshift({ id: unread[i].id, prompt: unread[i].text, staged: [], references: [] });
3098
+ }
2740
3099
  broadcast({
2741
3100
  type: "idle",
2742
3101
  session: id,
2743
3102
  state: state(runner),
2744
3103
  changes: runner.ledger.list(),
2745
3104
  });
3105
+ // Only after a turn that ended on its own. An interrupt means stop, and
3106
+ // an error would usually meet the next prompt with the same error, so
3107
+ // both leave the queue standing for the person to send or drop.
3108
+ if (!controller.signal.aborted && !failed)
3109
+ drainQueue(runner);
2746
3110
  }
2747
3111
  }
2748
3112
  function stream(res) {
@@ -2930,6 +3294,27 @@ export async function startWebServer(opts) {
2930
3294
  return a.length === b.length && timingSafeEqual(a, b);
2931
3295
  }
2932
3296
  await listenPreferring(server, opts.port, host);
3297
+ // The model list goes stale as vendors withdraw models, and is otherwise
3298
+ // only re-fetched on sign-in or a press of refresh. Checked at start and
3299
+ // every six hours after, it is fetched again once it is three days old — so
3300
+ // a window left open for a week still catches up. Never while a turn runs:
3301
+ // the sweep swaps the catalog the turn is resolving its model from.
3302
+ const refreshStale = () => {
3303
+ if (anyRunning())
3304
+ return;
3305
+ void current.harness.refreshStaleModels().catch(() => {
3306
+ // Offline, or a vendor down: the saved list still works; try next tick.
3307
+ });
3308
+ };
3309
+ refreshStale();
3310
+ const staleTimer = setInterval(refreshStale, 6 * 60 * 60 * 1000);
3311
+ staleTimer.unref?.();
3312
+ if (opts.routines)
3313
+ routines.start();
3314
+ if (opts.onKeepAwake) {
3315
+ const keepAwake = opts.onKeepAwake;
3316
+ void readPrefs().then((prefs) => keepAwake(prefs.keepAwake === true)).catch(() => { });
3317
+ }
2933
3318
  const address = server.address();
2934
3319
  const port = typeof address === "object" && address ? address.port : opts.port;
2935
3320
  return {
@@ -2938,6 +3323,8 @@ export async function startWebServer(opts) {
2938
3323
  permissionPrompt,
2939
3324
  questionPrompt,
2940
3325
  async close() {
3326
+ clearInterval(staleTimer);
3327
+ await routines.close();
2941
3328
  for (const unsubscribe of connectionSubscriptions)
2942
3329
  unsubscribe();
2943
3330
  for (const harness of harnesses.values())