@oharness/server 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/i18n.js +166 -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 +942 -106
- 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 +238 -10
- 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
|
/**
|
|
@@ -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,
|
|
@@ -2598,6 +2765,44 @@ export async function startWebServer(opts) {
|
|
|
2598
2765
|
watchConnections(harness);
|
|
2599
2766
|
return harness;
|
|
2600
2767
|
}
|
|
2768
|
+
/*
|
|
2769
|
+
* Routines: which exist, where each runs, and — when this server was asked
|
|
2770
|
+
* to — the clock that fires them. A run lands in the page as a notice and,
|
|
2771
|
+
* through onRoutineRun, wherever the desktop can reach someone who is not
|
|
2772
|
+
* looking at the page.
|
|
2773
|
+
*/
|
|
2774
|
+
const routines = new RoutineRunner({
|
|
2775
|
+
defaultCwd: opts.session.cwd,
|
|
2776
|
+
openHarnesses: () => harnesses.values(),
|
|
2777
|
+
createHarness: (cwd) => opts.openProject
|
|
2778
|
+
? opts.openProject(cwd)
|
|
2779
|
+
: Harness.create({
|
|
2780
|
+
cwd,
|
|
2781
|
+
permissionPrompt: async () => false,
|
|
2782
|
+
questionPrompt: async () => ({ outcome: "unavailable" }),
|
|
2783
|
+
}),
|
|
2784
|
+
onStart: (routine, meta) => {
|
|
2785
|
+
if (meta.catchUp)
|
|
2786
|
+
broadcast({ type: "notice", message: `routine ${routine.def.name} is catching up on a run it missed` });
|
|
2787
|
+
opts.onRoutineStart?.(routine.def.name, meta);
|
|
2788
|
+
},
|
|
2789
|
+
onRun: (run, routine) => reportRoutineRun(run, routine.cwd),
|
|
2790
|
+
onError: (routine, error) => broadcast({ type: "notice", message: `routine ${routine.def.name} could not start: ${error}` }),
|
|
2791
|
+
});
|
|
2792
|
+
function reportRoutineRun(run, cwd) {
|
|
2793
|
+
broadcast({
|
|
2794
|
+
type: "notice",
|
|
2795
|
+
message: `routine ${run.schedule} ${run.ok ? "finished" : "failed"} in ${run.turns} turns`,
|
|
2796
|
+
});
|
|
2797
|
+
// The run's session is new, and the sidebar has a group for it.
|
|
2798
|
+
broadcast({ type: "sessions" });
|
|
2799
|
+
try {
|
|
2800
|
+
opts.onRoutineRun?.(run, cwd);
|
|
2801
|
+
}
|
|
2802
|
+
catch {
|
|
2803
|
+
// A notifier that throws must not lose the run it was told about.
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2601
2806
|
/**
|
|
2602
2807
|
* Open a different project.
|
|
2603
2808
|
*
|
|
@@ -2930,6 +3135,27 @@ export async function startWebServer(opts) {
|
|
|
2930
3135
|
return a.length === b.length && timingSafeEqual(a, b);
|
|
2931
3136
|
}
|
|
2932
3137
|
await listenPreferring(server, opts.port, host);
|
|
3138
|
+
// The model list goes stale as vendors withdraw models, and is otherwise
|
|
3139
|
+
// only re-fetched on sign-in or a press of refresh. Checked at start and
|
|
3140
|
+
// every six hours after, it is fetched again once it is three days old — so
|
|
3141
|
+
// a window left open for a week still catches up. Never while a turn runs:
|
|
3142
|
+
// the sweep swaps the catalog the turn is resolving its model from.
|
|
3143
|
+
const refreshStale = () => {
|
|
3144
|
+
if (anyRunning())
|
|
3145
|
+
return;
|
|
3146
|
+
void current.harness.refreshStaleModels().catch(() => {
|
|
3147
|
+
// Offline, or a vendor down: the saved list still works; try next tick.
|
|
3148
|
+
});
|
|
3149
|
+
};
|
|
3150
|
+
refreshStale();
|
|
3151
|
+
const staleTimer = setInterval(refreshStale, 6 * 60 * 60 * 1000);
|
|
3152
|
+
staleTimer.unref?.();
|
|
3153
|
+
if (opts.routines)
|
|
3154
|
+
routines.start();
|
|
3155
|
+
if (opts.onKeepAwake) {
|
|
3156
|
+
const keepAwake = opts.onKeepAwake;
|
|
3157
|
+
void readPrefs().then((prefs) => keepAwake(prefs.keepAwake === true)).catch(() => { });
|
|
3158
|
+
}
|
|
2933
3159
|
const address = server.address();
|
|
2934
3160
|
const port = typeof address === "object" && address ? address.port : opts.port;
|
|
2935
3161
|
return {
|
|
@@ -2938,6 +3164,8 @@ export async function startWebServer(opts) {
|
|
|
2938
3164
|
permissionPrompt,
|
|
2939
3165
|
questionPrompt,
|
|
2940
3166
|
async close() {
|
|
3167
|
+
clearInterval(staleTimer);
|
|
3168
|
+
await routines.close();
|
|
2941
3169
|
for (const unsubscribe of connectionSubscriptions)
|
|
2942
3170
|
unsubscribe();
|
|
2943
3171
|
for (const harness of harnesses.values())
|