@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/i18n.js +178 -8
- package/dist/i18n.js.map +1 -1
- package/dist/main.js +7 -3
- package/dist/main.js.map +1 -1
- package/dist/markdown.js +6 -4
- package/dist/markdown.js.map +1 -1
- package/dist/model-order.d.ts +64 -0
- package/dist/model-order.js +249 -0
- package/dist/model-order.js.map +1 -0
- package/dist/page.js +1253 -121
- package/dist/page.js.map +1 -1
- package/dist/pick.d.ts +13 -0
- package/dist/pick.js +25 -0
- package/dist/pick.js.map +1 -1
- package/dist/prefs.d.ts +5 -0
- package/dist/prefs.js +3 -2
- package/dist/prefs.js.map +1 -1
- package/dist/project-export.d.ts +4 -0
- package/dist/project-export.js +108 -0
- package/dist/project-export.js.map +1 -0
- package/dist/projects.d.ts +8 -0
- package/dist/projects.js +103 -23
- package/dist/projects.js.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.js +401 -14
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
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())
|