@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/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
  /**
@@ -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,
@@ -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())