offsite 0.0.6 → 0.0.10

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.
Files changed (87) hide show
  1. package/README.md +16 -15
  2. package/apps/server/dist/main.mjs +588 -434
  3. package/apps/server/dist/main.mjs.map +1 -1
  4. package/apps/server/drizzle/20261017041447_init/migration.sql +32 -0
  5. package/apps/server/drizzle/20261017041447_init/snapshot.json +265 -0
  6. package/apps/web/dist/assets/{angular-html-B53hPuXT.js → angular-html-BEUtPJ31.js} +1 -1
  7. package/apps/web/dist/assets/{angular-ts--nhprFvo.js → angular-ts-Bnr4rzFr.js} +1 -1
  8. package/apps/web/dist/assets/{apl-BMZ6gSxk.js → apl-B-JNBTdP.js} +1 -1
  9. package/apps/web/dist/assets/{astro-BTM15MDF.js → astro-DLjK95IW.js} +1 -1
  10. package/apps/web/dist/assets/{blade-Dr8Nx-1X.js → blade-PLMCMBeb.js} +1 -1
  11. package/apps/web/dist/assets/{c-CYWvPMKX.js → c-D5z8xNe6.js} +1 -1
  12. package/apps/web/dist/assets/{chapel-C8sB6Foh.js → chapel-B9y2HxI_.js} +1 -1
  13. package/apps/web/dist/assets/{cobol-FnuKKfgs.js → cobol-BSwRu2X7.js} +1 -1
  14. package/apps/web/dist/assets/{coffee-DJrh6kIB.js → coffee-BAWFR6iF.js} +1 -1
  15. package/apps/web/dist/assets/{cpp-BLAvrTU1.js → cpp-CO-vZI4m.js} +1 -1
  16. package/apps/web/dist/assets/{crystal-BthZctDJ.js → crystal-BSfyPt6R.js} +1 -1
  17. package/apps/web/dist/assets/{css-DjklwMwW.js → css-BOzKs1dt.js} +1 -1
  18. package/apps/web/dist/assets/{edge-BunAX5o-.js → edge-OBqkt95y.js} +1 -1
  19. package/apps/web/dist/assets/{elixir-DgeksfSS.js → elixir-B14D2jMM.js} +1 -1
  20. package/apps/web/dist/assets/{elm-l1eWnD1P.js → elm-DgfIayFq.js} +1 -1
  21. package/apps/web/dist/assets/{erb-JKFWiSFc.js → erb-w-PUv7ic.js} +1 -1
  22. package/apps/web/dist/assets/{git-rebase-AsNqLj-a.js → git-rebase-DnzmyV_3.js} +1 -1
  23. package/apps/web/dist/assets/{glimmer-js-B8iwahHO.js → glimmer-js-CLephpyX.js} +1 -1
  24. package/apps/web/dist/assets/{glimmer-ts-7zfF4XN6.js → glimmer-ts-CvphZOog.js} +1 -1
  25. package/apps/web/dist/assets/{glsl-BJka2Ayq.js → glsl-Ddj2GHKO.js} +1 -1
  26. package/apps/web/dist/assets/{graphql-7lx4uUDH.js → graphql-CeGXMmoK.js} +1 -1
  27. package/apps/web/dist/assets/{hack-Jm3cv9jc.js → hack-CtBKZ5VW.js} +1 -1
  28. package/apps/web/dist/assets/{haml-Csl_BhoG.js → haml-DVvVd-Mp.js} +1 -1
  29. package/apps/web/dist/assets/{handlebars-CFp1rmy8.js → handlebars-Blx6Nluh.js} +1 -1
  30. package/apps/web/dist/assets/{html-D_zffrV4.js → html-O-gZ5M1-.js} +1 -1
  31. package/apps/web/dist/assets/{html-derivative-B-pqZuPW.js → html-derivative-BsDe9c3P.js} +1 -1
  32. package/apps/web/dist/assets/{http-CU_emOoa.js → http-CAUZeWmG.js} +1 -1
  33. package/apps/web/dist/assets/{hurl-BRBJ1eQP.js → hurl-CYnRsJrb.js} +1 -1
  34. package/apps/web/dist/assets/index-5vPpgW8A.css +2 -0
  35. package/apps/web/dist/assets/index-CGSSO_em.js +1152 -0
  36. package/apps/web/dist/assets/{java-DWuVSV9c.js → java-DjjpMT1c.js} +1 -1
  37. package/apps/web/dist/assets/{javascript-CPq3YNXl.js → javascript-BtiswSWS.js} +1 -1
  38. package/apps/web/dist/assets/{jinja-Cwr4gaQT.js → jinja-ZQGJY58d.js} +1 -1
  39. package/apps/web/dist/assets/{jison-tIc7kJPc.js → jison-VPkdeMFr.js} +1 -1
  40. package/apps/web/dist/assets/{json-CuLUmV8d.js → json-D5BAJIHo.js} +1 -1
  41. package/apps/web/dist/assets/{jsx-c0vioNM-.js → jsx-DMEKVcfT.js} +1 -1
  42. package/apps/web/dist/assets/{julia-DL1_3nCX.js → julia-BmOLQFch.js} +1 -1
  43. package/apps/web/dist/assets/{just-Dz1Oq8ln.js → just-YZGutsxo.js} +1 -1
  44. package/apps/web/dist/assets/{latex-BtJjjmHB.js → latex-CvrF79xl.js} +1 -1
  45. package/apps/web/dist/assets/{liquid-CUxfxikC.js → liquid-B--WbaSh.js} +1 -1
  46. package/apps/web/dist/assets/{lua-FURW1z3g.js → lua-BNEqLlv-.js} +1 -1
  47. package/apps/web/dist/assets/{marko-D33gqJht.js → marko-jEzbzXvj.js} +1 -1
  48. package/apps/web/dist/assets/{mdc-rJL-euOm.js → mdc-yfiYVuXh.js} +1 -1
  49. package/apps/web/dist/assets/{nginx-CChB1oIE.js → nginx-j7vnr3lB.js} +1 -1
  50. package/apps/web/dist/assets/{nim-C3VcT8nQ.js → nim-CV2OP2RC.js} +1 -1
  51. package/apps/web/dist/assets/{org-CX9ySstM.js → org-DTTS5Tv_.js} +1 -1
  52. package/apps/web/dist/assets/{perl-BmdWqdcq.js → perl-DuF7yMdF.js} +1 -1
  53. package/apps/web/dist/assets/{php-CMtd7vHw.js → php-8ufnwSAo.js} +1 -1
  54. package/apps/web/dist/assets/{pug-DRlx8d5w.js → pug-D-_t9dIn.js} +1 -1
  55. package/apps/web/dist/assets/{qml-WYwqwy0P.js → qml-DFxD5uAC.js} +1 -1
  56. package/apps/web/dist/assets/{r-C1yAXCUm.js → r-D8-Yi2KS.js} +1 -1
  57. package/apps/web/dist/assets/{razor-RKu7WkDv.js → razor-DhBq7-pb.js} +1 -1
  58. package/apps/web/dist/assets/{regexp-DuLU1P7q.js → regexp-C20CUsFr.js} +1 -1
  59. package/apps/web/dist/assets/{rst-CmK0AAXk.js → rst-DsIaukoD.js} +1 -1
  60. package/apps/web/dist/assets/{ruby-DwgaoIUu.js → ruby-C__EgK1w.js} +1 -1
  61. package/apps/web/dist/assets/{sas-iTrXuHXc.js → sas-BdDDRZUF.js} +1 -1
  62. package/apps/web/dist/assets/{scss-CEysq-zA.js → scss-nPFPh8nF.js} +1 -1
  63. package/apps/web/dist/assets/{shellscript-B0mTpBTm.js → shellscript-Cfn0DgiQ.js} +1 -1
  64. package/apps/web/dist/assets/{shellsession-BuzD9EVx.js → shellsession-DFGAcvZ4.js} +1 -1
  65. package/apps/web/dist/assets/{soy-ClDsFuFk.js → soy-8DrmW46F.js} +1 -1
  66. package/apps/web/dist/assets/{sql-Dc1h3orO.js → sql-BeU26hky.js} +1 -1
  67. package/apps/web/dist/assets/{stata-l31dxrtC.js → stata-OFfE-Kh9.js} +1 -1
  68. package/apps/web/dist/assets/{surrealql-x6LM7pkl.js → surrealql-Bwt4eVgP.js} +1 -1
  69. package/apps/web/dist/assets/{svelte-CtkYFYuK.js → svelte-DF7N9uBp.js} +1 -1
  70. package/apps/web/dist/assets/{templ-DHMinkR-.js → templ-CEirsWl9.js} +1 -1
  71. package/apps/web/dist/assets/{tex-CXbWoQMY.js → tex-BvBhDkV4.js} +1 -1
  72. package/apps/web/dist/assets/{ts-tags-BikYq-3m.js → ts-tags-Dx8bn0ts.js} +1 -1
  73. package/apps/web/dist/assets/{tsx-zHJnYG9T.js → tsx-vXk5Q55O.js} +1 -1
  74. package/apps/web/dist/assets/{twig-DKCfiSr9.js → twig-CIgLDHIW.js} +1 -1
  75. package/apps/web/dist/assets/{typescript-K9TDgxRD.js → typescript-CCW6p4Fc.js} +1 -1
  76. package/apps/web/dist/assets/{typst-BpXkFpDR.js → typst-CQL-qDz3.js} +1 -1
  77. package/apps/web/dist/assets/{vue-BYYRFXPM.js → vue-gFAUYOUp.js} +1 -1
  78. package/apps/web/dist/assets/{vue-html-BR347tzO.js → vue-html-BHuiCeAo.js} +1 -1
  79. package/apps/web/dist/assets/{vue-vine-Jv4OHYgW.js → vue-vine-DxeD8nHa.js} +1 -1
  80. package/apps/web/dist/assets/{xml-DuYGU-7M.js → xml-B43ZEHE5.js} +1 -1
  81. package/apps/web/dist/assets/{xsl-B950Zbyt.js → xsl-icvYLDXv.js} +1 -1
  82. package/apps/web/dist/assets/{yaml-XFYTn8dR.js → yaml-Bj0dlmrV.js} +1 -1
  83. package/apps/web/dist/index.html +2 -2
  84. package/apps/web/dist/sw.js +1 -1
  85. package/package.json +3 -1
  86. package/apps/web/dist/assets/index-0B-Gdd7b.css +0 -2
  87. package/apps/web/dist/assets/index-CJuLsqhP.js +0 -1148
@@ -1,21 +1,26 @@
1
- import { createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
1
+ import { chmodSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
2
  import { createServer } from "node:http";
3
3
  import path from "node:path";
4
4
  import { consola } from "consola";
5
+ import { renderUnicodeCompact } from "uqr";
5
6
  import { WebSocketServer } from "ws";
6
7
  import os from "node:os";
7
8
  import { deleteSession, forkSession, getSessionMessages, getSubagentMessages, listSessions, query, renameSession } from "@anthropic-ai/claude-agent-sdk";
8
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
9
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
9
10
  import { execFile, spawn } from "node:child_process";
10
11
  import readline from "node:readline";
11
12
  import { Command } from "@commander-js/extra-typings";
12
13
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
13
14
  import { OpenCode } from "@opencode/client";
14
15
  import { ensure, headers } from "@opencode/client/service";
16
+ import { and, eq, inArray, sql } from "drizzle-orm";
15
17
  import { DatabaseSync } from "node:sqlite";
18
+ import { drizzle } from "drizzle-orm/node-sqlite";
19
+ import { migrate } from "drizzle-orm/node-sqlite/migrator";
20
+ import webPush from "web-push";
21
+ import { integer, snakeCase, text } from "drizzle-orm/sqlite-core";
16
22
  import { lstat, open, readFile, readdir, readlink, stat } from "node:fs/promises";
17
23
  import { promisify } from "node:util";
18
- import webPush from "web-push";
19
24
  import { spawn as spawn$1 } from "@lydell/node-pty";
20
25
  //#region ../core/src/protocol.ts
21
26
  const HOSTED_APP = "https://offsite.sayad.dev";
@@ -47,7 +52,7 @@ function broadcast(event) {
47
52
  //#region src/args.ts
48
53
  let command = "web";
49
54
  let name = null;
50
- const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without the pairing token").option("--no-pair", "Don't create a pairing token or print pairing links");
55
+ const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without the pairing token").option("--no-pair", "Don't create a pairing token or print pairing links").option("--fallback <url>", "Address to try when this one is unreachable, e.g. a Cloudflare tunnel");
51
56
  program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
52
57
  program.command("serve").description("Starts only the server, without the web app").action(() => {
53
58
  command = "serve";
@@ -177,27 +182,6 @@ const defaults = {
177
182
  }
178
183
  }
179
184
  };
180
- const retired = [
181
- "theme",
182
- "diff",
183
- "terminal.font",
184
- "terminal.cursor",
185
- "terminal.padding",
186
- "terminal.scrollback",
187
- "terminal.boldIsBright",
188
- "terminal.behavior.modernizeShiftEnter",
189
- "terminal.behavior.pasteBrowserImages",
190
- "terminal.behavior.captureShortcuts",
191
- "terminal.behavior.bellFlash",
192
- "terminal.behavior.bellSound",
193
- "terminal.behavior.clickToMoveCursor",
194
- "terminal.behavior.optionAsMeta",
195
- "terminal.behavior.confirmCloseBusyTab",
196
- "composer.accountId",
197
- "composer.model",
198
- "composer.effort",
199
- "composer.permissionMode"
200
- ];
201
185
  const choices = {
202
186
  "composer.provider": [
203
187
  "claude",
@@ -298,7 +282,6 @@ function saveConfig(value) {
298
282
  return value;
299
283
  }
300
284
  function merge(config, keys, user) {
301
- if (retired.includes(keys.join("."))) return config;
302
285
  if (isObject(keys.reduce((value, key) => isObject(value) ? value[key] : void 0, config)) && isObject(user)) {
303
286
  let next = config;
304
287
  for (const [key, value] of Object.entries(user)) next = merge(next, [...keys, key], value);
@@ -343,6 +326,8 @@ function isObject(value) {
343
326
  //#endregion
344
327
  //#region src/lib/codex/app-server.ts
345
328
  const IDLE_LIMIT$3 = 18e5;
329
+ const START_TIMEOUT = 15e3;
330
+ const REQUEST_TIMEOUT = 3e4;
346
331
  const codexBaseDir = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
347
332
  const servers = /* @__PURE__ */ new Map();
348
333
  function appServer(home) {
@@ -400,13 +385,16 @@ var AppServer = class {
400
385
  experimentalApi: true,
401
386
  requestAttestation: false
402
387
  }
403
- }).then(() => this.write({ method: "initialized" }));
388
+ }, START_TIMEOUT).then(() => this.write({ method: "initialized" }), (error) => {
389
+ this.child.kill();
390
+ throw error;
391
+ });
404
392
  this.armIdleTimer();
405
393
  }
406
394
  async request(method, params) {
407
395
  await this.ready;
408
396
  this.armIdleTimer();
409
- return await this.call(method, params);
397
+ return await this.call(method, params, REQUEST_TIMEOUT);
410
398
  }
411
399
  reply(id, result) {
412
400
  this.write({
@@ -428,14 +416,26 @@ var AppServer = class {
428
416
  if (servers.get(this.home) === this) servers.delete(this.home);
429
417
  this.child.stdin.end();
430
418
  }
431
- call(method, params) {
419
+ call(method, params, timeout) {
432
420
  if (this.exited !== null) return Promise.reject(this.exited);
433
421
  this.next += 1;
434
422
  const id = this.next;
435
- const result = new Promise((resolve, reject) => this.waiting.set(id, {
436
- resolve,
437
- reject
438
- }));
423
+ const result = new Promise((resolve, reject) => {
424
+ const timer = setTimeout(() => {
425
+ this.waiting.delete(id);
426
+ reject(/* @__PURE__ */ new Error(`Codex did not answer ${method} within ${timeout / 1e3} seconds`));
427
+ }, timeout);
428
+ this.waiting.set(id, {
429
+ resolve: (value) => {
430
+ clearTimeout(timer);
431
+ resolve(value);
432
+ },
433
+ reject: (error) => {
434
+ clearTimeout(timer);
435
+ reject(error);
436
+ }
437
+ });
438
+ });
439
439
  this.write({
440
440
  id,
441
441
  method,
@@ -617,7 +617,7 @@ function isMirror(dir) {
617
617
  }
618
618
  //#endregion
619
619
  //#region src/lib/codex/items.ts
620
- const statuses = {
620
+ const statuses$1 = {
621
621
  inProgress: "running",
622
622
  completed: "completed",
623
623
  failed: "failed",
@@ -683,7 +683,7 @@ function toItems$1(turnId, item, parent, time) {
683
683
  command: item.command,
684
684
  description: "",
685
685
  output: item.aggregatedOutput ?? "",
686
- status: statuses[item.status],
686
+ status: statuses$1[item.status],
687
687
  background: null
688
688
  }];
689
689
  case "fileChange": return item.changes.map((change, index) => ({
@@ -699,7 +699,7 @@ function toItems$1(turnId, item, parent, time) {
699
699
  after: ""
700
700
  }] : hunks(change.diff),
701
701
  output: "",
702
- status: statuses[item.status]
702
+ status: statuses$1[item.status]
703
703
  }));
704
704
  case "mcpToolCall": return [{
705
705
  ...base,
@@ -707,7 +707,7 @@ function toItems$1(turnId, item, parent, time) {
707
707
  name: `${item.server}.${item.tool}`,
708
708
  input: record$1(item.arguments),
709
709
  output: item.error?.message ?? (item.result === null ? "" : contentText$1(item.result.content)),
710
- status: statuses[item.status]
710
+ status: statuses$1[item.status]
711
711
  }];
712
712
  case "dynamicToolCall": return [{
713
713
  ...base,
@@ -715,7 +715,7 @@ function toItems$1(turnId, item, parent, time) {
715
715
  name: item.tool,
716
716
  input: record$1(item.arguments),
717
717
  output: contentText$1(item.contentItems ?? []),
718
- status: statuses[item.status]
718
+ status: statuses$1[item.status]
719
719
  }];
720
720
  case "collabAgentToolCall":
721
721
  if (item.tool === "spawnAgent") return [{
@@ -724,14 +724,14 @@ function toItems$1(turnId, item, parent, time) {
724
724
  agent: item.model ?? "Agent",
725
725
  description: item.prompt ?? "",
726
726
  agentId: item.receiverThreadIds[0] ?? null,
727
- status: statuses[item.status]
727
+ status: statuses$1[item.status]
728
728
  }];
729
729
  return [{
730
730
  ...base,
731
731
  kind: "agent_message",
732
732
  target: item.receiverThreadIds[0] ?? null,
733
733
  summary: item.prompt ?? item.tool,
734
- status: statuses[item.status]
734
+ status: statuses$1[item.status]
735
735
  }];
736
736
  case "webSearch": return [{
737
737
  ...base,
@@ -974,6 +974,7 @@ var CodexRun = class CodexRun {
974
974
  prompted = false;
975
975
  backgroundTasks = 0;
976
976
  tasks = {};
977
+ activeAt = Date.now();
977
978
  model;
978
979
  close;
979
980
  turnId = null;
@@ -1078,11 +1079,12 @@ var CodexRun = class CodexRun {
1078
1079
  this.server.request("thread/unsubscribe", { threadId: this.sessionId }).catch((error) => console.warn(`could not unload Codex thread: ${error}`)).finally(() => this.end());
1079
1080
  }
1080
1081
  exited() {
1081
- this.put(errorItem(randomUUID(), "Codex stopped"));
1082
+ this.put(errorItem(randomUUID(), this.pending.size === 0 ? "Codex stopped" : "Codex stopped, its open requests expired"));
1082
1083
  if (runs.get(this.sessionId) === this) runs.delete(this.sessionId);
1083
1084
  this.end();
1084
1085
  }
1085
1086
  notify(method, params) {
1087
+ this.activeAt = Date.now();
1086
1088
  switch (method) {
1087
1089
  case "turn/started": {
1088
1090
  const turn = params.turn;
@@ -1437,23 +1439,67 @@ async function readThread(threadId) {
1437
1439
  })).thread;
1438
1440
  }
1439
1441
  //#endregion
1442
+ //#region src/storage/schema.ts
1443
+ const machine = snakeCase.table("machine", {
1444
+ id: text().primaryKey(),
1445
+ pushPublicKey: text().notNull(),
1446
+ pushPrivateKey: text().notNull()
1447
+ });
1448
+ const projects$2 = snakeCase.table("projects", {
1449
+ id: text().primaryKey(),
1450
+ path: text().notNull().unique(),
1451
+ name: text().notNull(),
1452
+ logo: text()
1453
+ });
1454
+ const sessions$1 = snakeCase.table("sessions", {
1455
+ sessionId: text().primaryKey(),
1456
+ interactedAt: integer(),
1457
+ status: text({ enum: [
1458
+ "active",
1459
+ "pinned",
1460
+ "minimized"
1461
+ ] }).notNull()
1462
+ });
1463
+ const tokens = snakeCase.table("tokens", {
1464
+ hash: text().primaryKey(),
1465
+ name: text(),
1466
+ createdAt: integer().notNull(),
1467
+ lastUsedAt: integer()
1468
+ });
1469
+ const pushSubscriptions = snakeCase.table("push_subscriptions", {
1470
+ endpoint: text().primaryKey(),
1471
+ p256dh: text().notNull(),
1472
+ auth: text().notNull(),
1473
+ server: text().notNull()
1474
+ });
1475
+ //#endregion
1440
1476
  //#region src/storage/db.ts
1441
- const db = new DatabaseSync(path.join(home, "offsite.db"));
1442
- db.exec(`
1443
- CREATE TABLE IF NOT EXISTS projects (
1444
- path TEXT PRIMARY KEY,
1445
- name TEXT NOT NULL,
1446
- logo TEXT
1447
- )
1448
- `);
1449
- db.exec("DROP TABLE IF EXISTS prompts");
1450
- db.exec(`
1451
- CREATE TABLE IF NOT EXISTS sessions (
1452
- session_id TEXT PRIMARY KEY,
1453
- interacted_at INTEGER,
1454
- status TEXT NOT NULL CHECK (status IN ('active', 'pinned', 'minimized'))
1455
- )
1477
+ const file = path.join(home, "offsite.db");
1478
+ closeSync(openSync(file, "a", 384));
1479
+ chmodSync(file, 384);
1480
+ const client = new DatabaseSync(file);
1481
+ client.exec(`
1482
+ PRAGMA journal_mode = WAL;
1483
+ PRAGMA busy_timeout = 5000;
1484
+ PRAGMA synchronous = NORMAL;
1485
+ PRAGMA foreign_keys = ON;
1456
1486
  `);
1487
+ const db = drizzle({ client });
1488
+ migrate(db, { migrationsFolder: path.join(serverDir, "drizzle") });
1489
+ db.transaction((tx) => {
1490
+ if (tx.select().from(machine).get() !== void 0) return;
1491
+ const keys = webPush.generateVAPIDKeys();
1492
+ tx.insert(machine).values({
1493
+ id: randomUUID(),
1494
+ pushPublicKey: keys.publicKey,
1495
+ pushPrivateKey: keys.privateKey
1496
+ }).run();
1497
+ }, { behavior: "immediate" });
1498
+ function machineInfo() {
1499
+ const row = db.select().from(machine).get();
1500
+ if (row === void 0) throw new Error("the machine row is missing");
1501
+ return row;
1502
+ }
1457
1503
  //#endregion
1458
1504
  //#region src/services/projects.ts
1459
1505
  const LOGO_TYPES = [
@@ -1467,32 +1513,42 @@ const LOGO_TYPES = [
1467
1513
  ];
1468
1514
  const LOGO_LIMIT = 524288;
1469
1515
  function projects$1() {
1470
- return db.prepare("SELECT path, name, logo FROM projects").all().map(toProject);
1516
+ return db.select().from(projects$2).all();
1517
+ }
1518
+ function projectPath(id) {
1519
+ const project = db.select().from(projects$2).where(eq(projects$2.id, id)).get();
1520
+ if (project === void 0) throw new Error("That project was removed");
1521
+ return project.path;
1471
1522
  }
1472
1523
  async function addProject(input) {
1473
1524
  const folder = path.resolve(expandHome(input.trim()));
1474
1525
  if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
1475
- db.prepare("INSERT OR IGNORE INTO projects (path, name, logo) VALUES (?, ?, NULL)").run(folder, path.basename(folder) || folder);
1526
+ db.insert(projects$2).values({
1527
+ id: randomUUID(),
1528
+ path: folder,
1529
+ name: path.basename(folder) || folder,
1530
+ logo: null
1531
+ }).onConflictDoNothing({ target: projects$2.path }).run();
1476
1532
  publish$1();
1477
1533
  const project = projects$1().find((item) => item.path === folder);
1478
1534
  if (project === void 0) throw new Error(`Could not add ${folder}`);
1479
1535
  return project;
1480
1536
  }
1481
- function updateProject(folder, patch) {
1537
+ function updateProject(id, patch) {
1482
1538
  if (patch.name !== void 0) {
1483
1539
  const name = patch.name.trim();
1484
1540
  if (name === "") throw new Error("Project name cannot be empty");
1485
- db.prepare("UPDATE projects SET name = ? WHERE path = ?").run(name, folder);
1541
+ db.update(projects$2).set({ name }).where(eq(projects$2.id, id)).run();
1486
1542
  }
1487
1543
  if (patch.logo !== void 0) {
1488
1544
  if (patch.logo !== null) checkLogo(patch.logo);
1489
- db.prepare("UPDATE projects SET logo = ? WHERE path = ?").run(patch.logo, folder);
1545
+ db.update(projects$2).set({ logo: patch.logo }).where(eq(projects$2.id, id)).run();
1490
1546
  }
1491
1547
  publish$1();
1492
1548
  return null;
1493
1549
  }
1494
- function removeProject(folder) {
1495
- db.prepare("DELETE FROM projects WHERE path = ?").run(folder);
1550
+ function removeProject(id) {
1551
+ db.delete(projects$2).where(eq(projects$2.id, id)).run();
1496
1552
  publish$1();
1497
1553
  }
1498
1554
  async function directories(input) {
@@ -1514,14 +1570,6 @@ function publish$1() {
1514
1570
  projects: projects$1()
1515
1571
  });
1516
1572
  }
1517
- function toProject(row) {
1518
- if (typeof row.path !== "string" || typeof row.name !== "string" || typeof row.logo !== "string" && row.logo !== null) throw new Error("The projects table has an unexpected row");
1519
- return {
1520
- path: row.path,
1521
- name: row.name,
1522
- logo: row.logo
1523
- };
1524
- }
1525
1573
  function checkLogo(logo) {
1526
1574
  const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
1527
1575
  if (type === void 0 || !LOGO_TYPES.includes(type)) throw new Error("Use a PNG, JPEG, GIF, WebP, SVG or ICO image");
@@ -1535,54 +1583,43 @@ function expandHome(input) {
1535
1583
  //#endregion
1536
1584
  //#region src/services/sessions.ts
1537
1585
  const day = 864e5;
1586
+ const LIST_TIMEOUT = 1e4;
1587
+ const REFRESH_DELAY = 1e3;
1588
+ const allProviders = [
1589
+ "claude",
1590
+ "codex",
1591
+ "opencode"
1592
+ ];
1593
+ const listed = {
1594
+ claude: [],
1595
+ codex: [],
1596
+ opencode: []
1597
+ };
1598
+ const statuses = {
1599
+ claude: {
1600
+ provider: "claude",
1601
+ state: "loading",
1602
+ error: null
1603
+ },
1604
+ codex: {
1605
+ provider: "codex",
1606
+ state: "loading",
1607
+ error: null
1608
+ },
1609
+ opencode: {
1610
+ provider: "opencode",
1611
+ state: "loading",
1612
+ error: null
1613
+ }
1614
+ };
1615
+ const refreshing = /* @__PURE__ */ new Map();
1616
+ const timers = /* @__PURE__ */ new Map();
1538
1617
  const providers = /* @__PURE__ */ new Map();
1539
1618
  let watchers = [];
1540
- let timer;
1541
- async function sessions() {
1542
- const records = readRecords();
1543
- const paths = projects$1().map((project) => project.path);
1544
- const [claude, codex, opencode] = await Promise.all([
1545
- Promise.all(paths.map(async (cwd) => (await listSessions({
1546
- dir: cwd,
1547
- includeWorktrees: false,
1548
- includeProgrammatic: false
1549
- })).map((item) => ({
1550
- id: item.sessionId,
1551
- provider: "claude",
1552
- title: item.summary,
1553
- cwd,
1554
- branch: item.gitBranch ?? null,
1555
- updatedAt: item.lastModified
1556
- })))),
1557
- codexThreads(paths).catch((error) => {
1558
- console.warn(`could not list Codex threads: ${error}`);
1559
- return [];
1560
- }),
1561
- openCodeSessions(paths).catch((error) => {
1562
- console.warn(`could not list OpenCode sessions: ${error}`);
1563
- return [];
1564
- })
1565
- ]);
1566
- return [
1567
- ...claude.flat(),
1568
- ...codex.map((thread) => ({
1569
- id: thread.id,
1570
- provider: "codex",
1571
- title: thread.name ?? thread.preview,
1572
- cwd: thread.cwd,
1573
- branch: thread.gitInfo?.branch ?? null,
1574
- updatedAt: thread.updatedAt * 1e3
1575
- })),
1576
- ...opencode.map((session) => ({
1577
- id: session.id,
1578
- provider: "opencode",
1579
- title: session.title ?? "New session",
1580
- cwd: session.location.directory,
1581
- branch: null,
1582
- updatedAt: session.time.updated
1583
- }))
1584
- ].map((item) => {
1585
- providers.set(item.id, item.provider);
1619
+ function sessions() {
1620
+ const records = new Map(db.select().from(sessions$1).all().map((record) => [record.sessionId, record]));
1621
+ const known = new Set(projects$1().map((project) => project.id));
1622
+ return allProviders.flatMap((provider) => listed[provider].filter((item) => known.has(item.projectId)).map((item) => {
1586
1623
  const record = records.get(item.id);
1587
1624
  const updatedAt = record?.interactedAt ?? item.updatedAt;
1588
1625
  return {
@@ -1590,7 +1627,13 @@ async function sessions() {
1590
1627
  updatedAt,
1591
1628
  status: record === void 0 || record.status === "active" && Date.now() - updatedAt > day ? "inactive" : record.status
1592
1629
  };
1593
- });
1630
+ }));
1631
+ }
1632
+ function providerStatuses() {
1633
+ return allProviders.map((provider) => statuses[provider]);
1634
+ }
1635
+ function listedUpdatedAt(provider, sessionId) {
1636
+ return listed[provider].find((item) => item.id === sessionId)?.updatedAt ?? null;
1594
1637
  }
1595
1638
  function providerOf(sessionId) {
1596
1639
  const provider = runs.get(sessionId)?.provider ?? providers.get(sessionId);
@@ -1598,23 +1641,138 @@ function providerOf(sessionId) {
1598
1641
  return provider;
1599
1642
  }
1600
1643
  function recordInteraction(id) {
1601
- db.prepare("INSERT INTO sessions (session_id, interacted_at, status) VALUES (?, ?, 'active') ON CONFLICT (session_id) DO UPDATE SET interacted_at = excluded.interacted_at, status = CASE status WHEN 'pinned' THEN 'pinned' WHEN 'minimized' THEN 'minimized' ELSE 'active' END").run(id, Date.now());
1644
+ db.insert(sessions$1).values({
1645
+ sessionId: id,
1646
+ interactedAt: Date.now(),
1647
+ status: "active"
1648
+ }).onConflictDoUpdate({
1649
+ target: sessions$1.sessionId,
1650
+ set: {
1651
+ interactedAt: Date.now(),
1652
+ status: sql`CASE ${sessions$1.status} WHEN 'pinned' THEN 'pinned' WHEN 'minimized' THEN 'minimized' ELSE 'active' END`
1653
+ }
1654
+ }).run();
1655
+ }
1656
+ function publishSessions() {
1657
+ broadcast({
1658
+ type: "sessions",
1659
+ sessions: sessions()
1660
+ });
1661
+ }
1662
+ function refreshAll() {
1663
+ for (const provider of allProviders) refresh(provider);
1664
+ }
1665
+ function refreshSessions(provider) {
1666
+ if (timers.has(provider)) return;
1667
+ timers.set(provider, setTimeout(() => {
1668
+ timers.delete(provider);
1669
+ refresh(provider);
1670
+ }, REFRESH_DELAY));
1671
+ }
1672
+ async function refresh(provider) {
1673
+ const current = refreshing.get(provider);
1674
+ if (current !== void 0) {
1675
+ current.again = true;
1676
+ return;
1677
+ }
1678
+ const entry = { again: true };
1679
+ refreshing.set(provider, entry);
1680
+ while (entry.again) {
1681
+ entry.again = false;
1682
+ await list$1(provider);
1683
+ }
1684
+ refreshing.delete(provider);
1602
1685
  }
1603
- async function publishSessions() {
1686
+ async function list$1(provider) {
1687
+ const paths = new Map(projects$1().map((project) => [project.path, project.id]));
1688
+ const before = statuses[provider];
1689
+ let timer;
1604
1690
  try {
1605
- broadcast({
1606
- type: "sessions",
1607
- sessions: await sessions()
1608
- });
1691
+ const found = await Promise.race([find(provider, paths), new Promise((_, reject) => {
1692
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${providerName(provider)} did not list its sessions within ${LIST_TIMEOUT / 1e3} seconds`)), LIST_TIMEOUT);
1693
+ })]);
1694
+ if (found === null) {
1695
+ listed[provider] = [];
1696
+ statuses[provider] = {
1697
+ provider,
1698
+ state: "missing",
1699
+ error: null
1700
+ };
1701
+ } else {
1702
+ listed[provider] = found;
1703
+ statuses[provider] = {
1704
+ provider,
1705
+ state: "ready",
1706
+ error: null
1707
+ };
1708
+ for (const item of found) providers.set(item.id, provider);
1709
+ }
1609
1710
  } catch (error) {
1610
- console.warn(`could not list threads: ${error}`);
1711
+ const message = error instanceof Error ? error.message : String(error);
1712
+ console.warn(`could not list ${providerName(provider)} sessions: ${message}`);
1713
+ statuses[provider] = {
1714
+ provider,
1715
+ state: "error",
1716
+ error: message
1717
+ };
1718
+ } finally {
1719
+ clearTimeout(timer);
1720
+ }
1721
+ publishSessions();
1722
+ if (JSON.stringify(statuses[provider]) !== JSON.stringify(before)) broadcast({
1723
+ type: "providers",
1724
+ providers: providerStatuses()
1725
+ });
1726
+ }
1727
+ async function find(provider, paths) {
1728
+ switch (provider) {
1729
+ case "claude": return (await Promise.all([...paths].map(async ([cwd, projectId]) => (await listSessions({
1730
+ dir: cwd,
1731
+ includeWorktrees: false,
1732
+ includeProgrammatic: false
1733
+ })).map((item) => ({
1734
+ id: item.sessionId,
1735
+ provider: "claude",
1736
+ title: item.summary,
1737
+ projectId,
1738
+ branch: item.gitBranch ?? null,
1739
+ updatedAt: item.lastModified
1740
+ }))))).flat();
1741
+ case "codex":
1742
+ if (!codexInstalled()) return null;
1743
+ return (await codexThreads([...paths.keys()])).flatMap((thread) => {
1744
+ const projectId = paths.get(thread.cwd);
1745
+ if (projectId === void 0) return [];
1746
+ return [{
1747
+ id: thread.id,
1748
+ provider: "codex",
1749
+ title: thread.name ?? thread.preview,
1750
+ projectId,
1751
+ branch: thread.gitInfo?.branch ?? null,
1752
+ updatedAt: thread.updatedAt * 1e3
1753
+ }];
1754
+ });
1755
+ case "opencode":
1756
+ if (!openCodeInstalled()) return null;
1757
+ return (await openCodeSessions([...paths.keys()])).flatMap((session) => {
1758
+ const projectId = paths.get(session.location.directory);
1759
+ if (projectId === void 0) return [];
1760
+ return [{
1761
+ id: session.id,
1762
+ provider: "opencode",
1763
+ title: session.title ?? "New session",
1764
+ projectId,
1765
+ branch: null,
1766
+ updatedAt: session.time.updated
1767
+ }];
1768
+ });
1611
1769
  }
1612
1770
  }
1613
1771
  function watchSessions() {
1614
1772
  for (const watcher of watchers) watcher.close();
1615
- watchers = [path.join(readConfig().claudeBaseDir, "projects"), path.join(codexBaseDir, "sessions")].flatMap((dir) => {
1773
+ watchers = [["claude", path.join(readConfig().claudeBaseDir, "projects")], ["codex", path.join(codexBaseDir, "sessions")]].flatMap(([provider, dir]) => {
1616
1774
  try {
1617
- const watcher = watch(dir, { recursive: true }, refreshSessions);
1775
+ const watcher = watch(dir, { recursive: true }, () => refreshSessions(provider));
1618
1776
  watcher.on("error", (error) => console.warn(`stopped watching ${dir}: ${error}`));
1619
1777
  return [watcher];
1620
1778
  } catch (error) {
@@ -1623,12 +1781,6 @@ function watchSessions() {
1623
1781
  }
1624
1782
  });
1625
1783
  }
1626
- function refreshSessions() {
1627
- timer ??= setTimeout(() => {
1628
- timer = void 0;
1629
- publishSessions();
1630
- }, 1e3);
1631
- }
1632
1784
  async function updateSession(id, patch) {
1633
1785
  if (patch.title !== void 0) switch (providerOf(id)) {
1634
1786
  case "claude":
@@ -1639,8 +1791,15 @@ async function updateSession(id, patch) {
1639
1791
  break;
1640
1792
  case "opencode": await renameOpenCode(id, patch.title);
1641
1793
  }
1642
- if (patch.status === "inactive") db.prepare("DELETE FROM sessions WHERE session_id = ?").run(id);
1643
- else if (patch.status !== void 0) db.prepare("INSERT INTO sessions (session_id, status) VALUES (?, ?) ON CONFLICT (session_id) DO UPDATE SET status = excluded.status").run(id, patch.status);
1794
+ if (patch.status === "inactive") db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
1795
+ else if (patch.status !== void 0) db.insert(sessions$1).values({
1796
+ sessionId: id,
1797
+ status: patch.status
1798
+ }).onConflictDoUpdate({
1799
+ target: sessions$1.sessionId,
1800
+ set: { status: patch.status }
1801
+ }).run();
1802
+ if (patch.title !== void 0) refreshSessions(providerOf(id));
1644
1803
  }
1645
1804
  async function removeSession(id) {
1646
1805
  const provider = providerOf(id);
@@ -1658,13 +1817,15 @@ async function removeSession(id) {
1658
1817
  break;
1659
1818
  case "opencode": await removeOpenCode(id);
1660
1819
  }
1661
- db.prepare("DELETE FROM sessions WHERE session_id = ?").run(id);
1820
+ db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
1821
+ listed[provider] = listed[provider].filter((item) => item.id !== id);
1662
1822
  }
1663
- async function fork(sessionId, cwd, messageId) {
1823
+ async function fork(sessionId, projectId, messageId) {
1664
1824
  const provider = providerOf(sessionId);
1665
- const result = await forkWith(provider, sessionId, cwd, messageId);
1825
+ const result = await forkWith(provider, sessionId, projectPath(projectId), messageId);
1666
1826
  providers.set(result.sessionId, provider);
1667
1827
  recordInteraction(result.sessionId);
1828
+ refreshSessions(provider);
1668
1829
  return result;
1669
1830
  }
1670
1831
  async function forkWith(provider, sessionId, cwd, messageId) {
@@ -1676,17 +1837,6 @@ async function forkWith(provider, sessionId, cwd, messageId) {
1676
1837
  upToMessageId: messageId
1677
1838
  })).sessionId };
1678
1839
  }
1679
- function readRecords() {
1680
- const records = /* @__PURE__ */ new Map();
1681
- for (const row of db.prepare("SELECT session_id, interacted_at, status FROM sessions").all()) {
1682
- if (typeof row.session_id !== "string" || typeof row.interacted_at !== "number" && row.interacted_at !== null || row.status !== "active" && row.status !== "pinned" && row.status !== "minimized") throw new Error("The sessions table has an unexpected row");
1683
- records.set(row.session_id, {
1684
- interactedAt: row.interacted_at,
1685
- status: row.status
1686
- });
1687
- }
1688
- return records;
1689
- }
1690
1840
  //#endregion
1691
1841
  //#region src/lib/opencode/client.ts
1692
1842
  const listChanges = /* @__PURE__ */ new Set([
@@ -1699,6 +1849,9 @@ const listChanges = /* @__PURE__ */ new Set([
1699
1849
  "session.execution.failed",
1700
1850
  "session.execution.interrupted"
1701
1851
  ]);
1852
+ const SILENCE_LIMIT$1 = 45e3;
1853
+ const RECONNECT_ATTEMPTS = 5;
1854
+ const RECONNECT_DELAY = 2e3;
1702
1855
  let connection;
1703
1856
  function findOpenCode() {
1704
1857
  const name = process.platform === "win32" ? "opencode.exe" : "opencode";
@@ -1709,16 +1862,12 @@ function findOpenCode() {
1709
1862
  return null;
1710
1863
  }
1711
1864
  function openCode() {
1712
- connection ??= connect();
1865
+ connection ??= start();
1713
1866
  return connection;
1714
1867
  }
1715
- async function connect() {
1868
+ async function start() {
1716
1869
  try {
1717
- const endpoint = await ensure();
1718
- const client = OpenCode.make({
1719
- baseUrl: endpoint.url,
1720
- headers: headers(endpoint)
1721
- });
1870
+ const client = await connect();
1722
1871
  listen(client);
1723
1872
  return client;
1724
1873
  } catch (error) {
@@ -1726,27 +1875,61 @@ async function connect() {
1726
1875
  throw error;
1727
1876
  }
1728
1877
  }
1878
+ async function connect() {
1879
+ const endpoint = await ensure();
1880
+ return OpenCode.make({
1881
+ baseUrl: endpoint.url,
1882
+ headers: headers(endpoint)
1883
+ });
1884
+ }
1729
1885
  async function listen(client) {
1886
+ const controller = new AbortController();
1887
+ const silence = setTimeout(() => controller.abort(), SILENCE_LIMIT$1);
1730
1888
  try {
1731
- for await (const event of client.event.subscribe()) route(event);
1889
+ for await (const event of client.event.subscribe({
1890
+ signal: controller.signal,
1891
+ onActivity: () => silence.refresh()
1892
+ })) route(event);
1732
1893
  console.warn("the OpenCode event stream ended");
1733
1894
  } catch (error) {
1734
- console.warn(`lost the OpenCode event stream: ${error}`);
1895
+ console.warn(controller.signal.aborted ? `the OpenCode event stream was silent for ${SILENCE_LIMIT$1 / 1e3} seconds` : `lost the OpenCode event stream: ${error}`);
1896
+ } finally {
1897
+ clearTimeout(silence);
1898
+ }
1899
+ connection = reconnect();
1900
+ connection.catch(() => void 0);
1901
+ }
1902
+ async function reconnect() {
1903
+ let failure;
1904
+ for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
1905
+ await setTimeout$1(RECONNECT_DELAY);
1906
+ let client;
1907
+ try {
1908
+ client = await connect();
1909
+ } catch (error) {
1910
+ failure = error;
1911
+ continue;
1912
+ }
1913
+ listen(client);
1914
+ await catchUp(client).catch((error) => console.warn(`could not catch up with OpenCode: ${error}`));
1915
+ return client;
1735
1916
  }
1917
+ console.warn(`could not reconnect to OpenCode: ${failure}`);
1736
1918
  connection = void 0;
1737
- if (openCodeRuns().length === 0) return;
1738
- await setTimeout$1(1e3);
1739
- try {
1740
- const active = await (await openCode()).session.active();
1741
- for (const run of openCodeRuns()) run.resync(run.sessionId in active);
1742
- refreshSessions();
1743
- } catch (error) {
1744
- console.warn(`could not reconnect to OpenCode: ${error}`);
1745
- for (const run of openCodeRuns()) run.exited();
1919
+ for (const run of openCodeRuns()) run.exited();
1920
+ refreshSessions("opencode");
1921
+ throw failure;
1922
+ }
1923
+ async function catchUp(client) {
1924
+ const active = await client.session.active();
1925
+ for (const run of openCodeRuns()) {
1926
+ const [permissions, forms] = await Promise.all([client.permission.request.list({ location: { directory: run.cwd } }), client.session.form.list({ sessionID: run.sessionId })]);
1927
+ run.reconcile(client, run.sessionId in active, permissions.data.filter((permission) => permission.sessionID === run.sessionId), forms);
1746
1928
  }
1929
+ refreshSessions("opencode");
1747
1930
  }
1748
1931
  function route(event) {
1749
- if (listChanges.has(event.type)) refreshSessions();
1932
+ if (listChanges.has(event.type)) refreshSessions("opencode");
1750
1933
  const sessionId = sessionOf(event);
1751
1934
  if (sessionId === null) return;
1752
1935
  const run = runs.get(sessionId);
@@ -1887,8 +2070,8 @@ function toolItems(base, tool) {
1887
2070
  case "shell": return [{
1888
2071
  ...base,
1889
2072
  kind: "command",
1890
- command: text$1(input.command),
1891
- description: text$1(input.description),
2073
+ command: text$2(input.command),
2074
+ description: text$2(input.description),
1892
2075
  output,
1893
2076
  status,
1894
2077
  background: null
@@ -1896,10 +2079,10 @@ function toolItems(base, tool) {
1896
2079
  case "edit": return [{
1897
2080
  ...base,
1898
2081
  kind: "file_change",
1899
- path: text$1(input.path ?? input.filePath),
2082
+ path: text$2(input.path ?? input.filePath),
1900
2083
  diffs: [{
1901
- before: text$1(input.oldString),
1902
- after: text$1(input.newString)
2084
+ before: text$2(input.oldString),
2085
+ after: text$2(input.newString)
1903
2086
  }],
1904
2087
  output,
1905
2088
  status
@@ -1907,10 +2090,10 @@ function toolItems(base, tool) {
1907
2090
  case "write": return [{
1908
2091
  ...base,
1909
2092
  kind: "file_change",
1910
- path: text$1(input.path ?? input.filePath),
2093
+ path: text$2(input.path ?? input.filePath),
1911
2094
  diffs: [{
1912
2095
  before: "",
1913
- after: text$1(input.content)
2096
+ after: text$2(input.content)
1914
2097
  }],
1915
2098
  output,
1916
2099
  status
@@ -1922,8 +2105,8 @@ function toolItems(base, tool) {
1922
2105
  ...base,
1923
2106
  id: `${base.id}:${index}`,
1924
2107
  kind: "file_change",
1925
- path: text$1(record(file).file),
1926
- diffs: hunks(text$1(record(file).patch)),
2108
+ path: text$2(record(file).file),
2109
+ diffs: hunks(text$2(record(file).patch)),
1927
2110
  output,
1928
2111
  status
1929
2112
  }));
@@ -1931,16 +2114,16 @@ function toolItems(base, tool) {
1931
2114
  ...base,
1932
2115
  kind: "todo_list",
1933
2116
  steps: list(input.todos).map((todo) => ({
1934
- text: text$1(record(todo).content),
1935
- status: todoStatuses$1[text$1(record(todo).status)] ?? "pending"
2117
+ text: text$2(record(todo).content),
2118
+ status: todoStatuses$1[text$2(record(todo).status)] ?? "pending"
1936
2119
  }))
1937
2120
  }];
1938
2121
  case "subagent":
1939
2122
  case "task": return [{
1940
2123
  ...base,
1941
2124
  kind: "subagent",
1942
- agent: text$1(input.agent ?? input.subagent_type) || "Agent",
1943
- description: text$1(input.description),
2125
+ agent: text$2(input.agent ?? input.subagent_type) || "Agent",
2126
+ description: text$2(input.description),
1944
2127
  agentId: typeof metadata.sessionID === "string" ? metadata.sessionID : null,
1945
2128
  status
1946
2129
  }];
@@ -1948,20 +2131,20 @@ function toolItems(base, tool) {
1948
2131
  case "websearch": return [{
1949
2132
  ...base,
1950
2133
  kind: "web_search",
1951
- query: tool.name === "websearch" ? text$1(input.query) : null,
1952
- url: tool.name === "webfetch" ? text$1(input.url) : null,
2134
+ query: tool.name === "websearch" ? text$2(input.query) : null,
2135
+ url: tool.name === "webfetch" ? text$2(input.url) : null,
1953
2136
  results: [],
1954
2137
  output,
1955
2138
  status
1956
2139
  }];
1957
2140
  case "question": {
1958
2141
  const questions = list(input.questions).map((item) => ({
1959
- question: text$1(record(item).question),
1960
- header: text$1(record(item).header),
2142
+ question: text$2(record(item).question),
2143
+ header: text$2(record(item).header),
1961
2144
  multiSelect: record(item).multiple === true,
1962
2145
  options: list(record(item).options).map((option) => ({
1963
- label: text$1(record(option).label),
1964
- description: text$1(record(option).description)
2146
+ label: text$2(record(option).label),
2147
+ description: text$2(record(option).description)
1965
2148
  }))
1966
2149
  }));
1967
2150
  const answers = metadata.answers;
@@ -1969,14 +2152,14 @@ function toolItems(base, tool) {
1969
2152
  ...base,
1970
2153
  kind: "question",
1971
2154
  questions,
1972
- answers: Array.isArray(answers) ? Object.fromEntries(questions.map((question, index) => [question.question, list(answers[index]).map(text$1).join(", ")])) : null,
2155
+ answers: Array.isArray(answers) ? Object.fromEntries(questions.map((question, index) => [question.question, list(answers[index]).map(text$2).join(", ")])) : null,
1973
2156
  status
1974
2157
  }];
1975
2158
  }
1976
2159
  case "skill": return [{
1977
2160
  ...base,
1978
2161
  kind: "skill",
1979
- skill: text$1(input.name),
2162
+ skill: text$2(input.name),
1980
2163
  output,
1981
2164
  status
1982
2165
  }];
@@ -2017,7 +2200,7 @@ function optionValue(options, label) {
2017
2200
  function contentText(content) {
2018
2201
  return content.map((block) => block.type === "text" ? block.text : block.uri).join("\n");
2019
2202
  }
2020
- function text$1(value) {
2203
+ function text$2(value) {
2021
2204
  return typeof value === "string" ? value : "";
2022
2205
  }
2023
2206
  function list(value) {
@@ -2255,28 +2438,24 @@ var OpenCodeRun = class OpenCodeRun {
2255
2438
  prompted = false;
2256
2439
  backgroundTasks = 0;
2257
2440
  tasks = {};
2258
- client;
2259
2441
  close;
2260
2442
  applied;
2261
2443
  loading = Promise.resolve();
2262
2444
  refreshTimer;
2263
2445
  idleTimer;
2264
2446
  static async start(cwd, settings) {
2265
- const client = await openCode();
2266
- const session = await client.session.create({
2447
+ const session = await (await openCode()).session.create({
2267
2448
  location: { directory: cwd },
2268
2449
  ...settings.mode === null ? {} : { agent: settings.mode },
2269
2450
  ...settings.model === null ? {} : { model: modelRef(settings.model, settings.effort) }
2270
2451
  });
2271
- return new OpenCodeRun(client, session.id, cwd, settings, settings);
2452
+ return new OpenCodeRun(session.id, cwd, settings, settings);
2272
2453
  }
2273
2454
  static async resume(sessionId, cwd, settings) {
2274
- const client = await openCode();
2275
- const session = await client.session.get({ sessionID: sessionId });
2276
- return new OpenCodeRun(client, sessionId, cwd, settings, sessionSettings(session, settings));
2455
+ const session = await (await openCode()).session.get({ sessionID: sessionId });
2456
+ return new OpenCodeRun(sessionId, cwd, settings, sessionSettings(session, settings));
2277
2457
  }
2278
- constructor(client, sessionId, cwd, settings, applied) {
2279
- this.client = client;
2458
+ constructor(sessionId, cwd, settings, applied) {
2280
2459
  this.sessionId = sessionId;
2281
2460
  this.cwd = cwd;
2282
2461
  this.settings = settings;
@@ -2294,7 +2473,7 @@ var OpenCodeRun = class OpenCodeRun {
2294
2473
  }
2295
2474
  switch (delivery) {
2296
2475
  case "steer":
2297
- await this.client.session.prompt({
2476
+ await (await openCode()).session.prompt({
2298
2477
  sessionID: this.sessionId,
2299
2478
  text: prompt.text,
2300
2479
  files: files(prompt),
@@ -2331,7 +2510,7 @@ var OpenCodeRun = class OpenCodeRun {
2331
2510
  });
2332
2511
  }
2333
2512
  async interrupt() {
2334
- await this.client.session.interrupt({ sessionID: this.sessionId });
2513
+ await (await openCode()).session.interrupt({ sessionID: this.sessionId });
2335
2514
  }
2336
2515
  stopTask() {
2337
2516
  throw new Error("OpenCode has no background tasks to stop");
@@ -2340,16 +2519,17 @@ var OpenCodeRun = class OpenCodeRun {
2340
2519
  const pending = this.pending.get(requestId);
2341
2520
  if (pending === void 0) return;
2342
2521
  const sessionID = this.sessionId;
2343
- if (pending.kind === "permission") await this.client.permission.reply({
2522
+ const client = await openCode();
2523
+ if (pending.kind === "permission") await client.permission.reply({
2344
2524
  sessionID,
2345
2525
  requestID: requestId,
2346
2526
  decision: replies[decision]
2347
2527
  });
2348
- else if (decision === "deny") await this.client.session.form.cancel({
2528
+ else if (decision === "deny") await client.session.form.cancel({
2349
2529
  sessionID,
2350
2530
  formID: requestId
2351
2531
  });
2352
- else await this.client.session.form.reply({
2532
+ else await client.session.form.reply({
2353
2533
  sessionID,
2354
2534
  formID: requestId,
2355
2535
  answer: formAnswer(pending.form, answers ?? {})
@@ -2361,11 +2541,15 @@ var OpenCodeRun = class OpenCodeRun {
2361
2541
  this.end();
2362
2542
  }
2363
2543
  exited() {
2364
- this.put([errorItem(randomUUID(), "Lost the connection to OpenCode")]);
2544
+ this.put([errorItem(randomUUID(), this.pending.size === 0 ? "Lost the connection to OpenCode" : "Lost the connection to OpenCode, its open requests expired")]);
2365
2545
  this.stop();
2366
2546
  }
2367
- resync(active) {
2368
- this.refresh();
2547
+ reconcile(client, active, permissions, forms) {
2548
+ this.refresh(client);
2549
+ const open = /* @__PURE__ */ new Set([...permissions.map((permission) => permission.id), ...forms.map((form) => form.id)]);
2550
+ for (const requestId of [...this.pending.keys()]) if (!open.has(requestId)) this.settle(requestId);
2551
+ for (const permission of permissions) if (!this.pending.has(permission.id)) this.ask(permission);
2552
+ for (const form of forms) if (!this.pending.has(form.id)) this.askForm(form);
2369
2553
  if (this.running && !active) this.finishTurn();
2370
2554
  }
2371
2555
  receive(event) {
@@ -2401,15 +2585,7 @@ var OpenCodeRun = class OpenCodeRun {
2401
2585
  this.settle(event.data.requestID);
2402
2586
  return;
2403
2587
  case "form.created":
2404
- this.addPending({
2405
- kind: "form",
2406
- request: {
2407
- id: event.data.form.id,
2408
- kind: "question",
2409
- questions: formQuestions(event.data.form)
2410
- },
2411
- form: event.data.form
2412
- });
2588
+ this.askForm(event.data.form);
2413
2589
  return;
2414
2590
  case "form.replied":
2415
2591
  case "form.cancelled":
@@ -2420,18 +2596,18 @@ var OpenCodeRun = class OpenCodeRun {
2420
2596
  this.refreshTimer = setTimeout(() => this.refresh(), 100);
2421
2597
  }
2422
2598
  }
2423
- refresh() {
2599
+ refresh(client) {
2424
2600
  clearTimeout(this.refreshTimer);
2425
- this.loading = this.loading.then(() => this.load()).catch((error) => console.warn(`could not load OpenCode messages: ${error}`));
2601
+ this.loading = this.loading.then(async () => this.load(client ?? await openCode())).catch((error) => console.warn(`could not load OpenCode messages: ${error}`));
2426
2602
  }
2427
- async load() {
2428
- const messages = (await this.client.message.list({
2603
+ async load(client) {
2604
+ const messages = (await client.message.list({
2429
2605
  sessionID: this.sessionId,
2430
2606
  limit: RECENT,
2431
2607
  order: "desc"
2432
2608
  })).data.toReversed();
2433
2609
  this.put(messages.flatMap((message) => messageItems(message, null)).filter((item) => JSON.stringify(this.items.get(item.id)) !== JSON.stringify(item)));
2434
- const context = await contextOf(this.client, this.cwd, messages);
2610
+ const context = await contextOf(client, this.cwd, messages);
2435
2611
  if (context === null || context.tokens === this.context?.tokens) return;
2436
2612
  this.context = context;
2437
2613
  publish$2(this.sessionId, {
@@ -2456,16 +2632,17 @@ var OpenCodeRun = class OpenCodeRun {
2456
2632
  }
2457
2633
  }
2458
2634
  async send(prompt) {
2635
+ const client = await openCode();
2459
2636
  const sessionID = this.sessionId;
2460
2637
  const location = { directory: this.cwd };
2461
2638
  const match = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.text);
2462
2639
  if (match !== null) {
2463
2640
  if (match[1] === "compact" && prompt.images.length === 0) {
2464
- await this.client.session.compact({ sessionID });
2641
+ await client.session.compact({ sessionID });
2465
2642
  return;
2466
2643
  }
2467
- if ((await this.client.command.list({ location })).data.some((command) => command.name === match[1])) {
2468
- await this.client.session.command({
2644
+ if ((await client.command.list({ location })).data.some((command) => command.name === match[1])) {
2645
+ await client.session.command({
2469
2646
  sessionID,
2470
2647
  name: match[1],
2471
2648
  text: match[2],
@@ -2473,9 +2650,9 @@ var OpenCodeRun = class OpenCodeRun {
2473
2650
  });
2474
2651
  return;
2475
2652
  }
2476
- const skill = (await this.client.skill.list({ location })).data.find((item) => item.name === match[1]);
2653
+ const skill = (await client.skill.list({ location })).data.find((item) => item.name === match[1]);
2477
2654
  if (skill !== void 0) {
2478
- await this.client.session.prompt({
2655
+ await client.session.prompt({
2479
2656
  sessionID,
2480
2657
  text: match[2],
2481
2658
  files: files(prompt),
@@ -2484,20 +2661,21 @@ var OpenCodeRun = class OpenCodeRun {
2484
2661
  return;
2485
2662
  }
2486
2663
  }
2487
- await this.client.session.prompt({
2664
+ await client.session.prompt({
2488
2665
  sessionID,
2489
2666
  text: prompt.text,
2490
2667
  files: files(prompt)
2491
2668
  });
2492
2669
  }
2493
2670
  async apply() {
2671
+ const client = await openCode();
2494
2672
  const settings = this.settings;
2495
2673
  const model = settings.model ?? this.applied.model;
2496
- if (model !== null && (model !== this.applied.model || settings.effort !== this.applied.effort)) await this.client.session.switchModel({
2674
+ if (model !== null && (model !== this.applied.model || settings.effort !== this.applied.effort)) await client.session.switchModel({
2497
2675
  sessionID: this.sessionId,
2498
2676
  model: modelRef(model, settings.effort)
2499
2677
  });
2500
- if (settings.mode !== null && settings.mode !== this.applied.mode) await this.client.session.switchAgent({
2678
+ if (settings.mode !== null && settings.mode !== this.applied.mode) await client.session.switchAgent({
2501
2679
  sessionID: this.sessionId,
2502
2680
  agent: settings.mode
2503
2681
  });
@@ -2530,6 +2708,17 @@ var OpenCodeRun = class OpenCodeRun {
2530
2708
  }
2531
2709
  });
2532
2710
  }
2711
+ askForm(form) {
2712
+ this.addPending({
2713
+ kind: "form",
2714
+ request: {
2715
+ id: form.id,
2716
+ kind: "question",
2717
+ questions: formQuestions(form)
2718
+ },
2719
+ form
2720
+ });
2721
+ }
2533
2722
  addPending(pending) {
2534
2723
  this.pending.set(pending.request.id, pending);
2535
2724
  publish$2(this.sessionId, {
@@ -2620,10 +2809,7 @@ function providerName(provider) {
2620
2809
  return providerNames[provider];
2621
2810
  }
2622
2811
  function storedAccounts() {
2623
- return (readJson("accounts.json") ?? []).map((item) => ({
2624
- ...item,
2625
- provider: item.provider ?? "claude"
2626
- }));
2812
+ return readJson("accounts.json") ?? [];
2627
2813
  }
2628
2814
  function storedAccount(provider, accountId) {
2629
2815
  if (accountId === null) throw new Error(`Add a ${providerName(provider)} account in Settings → Accounts`);
@@ -3030,10 +3216,10 @@ function toItems(messages) {
3030
3216
  break;
3031
3217
  case "tool_use":
3032
3218
  if (block.name === "SendMessage") {
3033
- const owner = owners.get(text(block.input.to));
3219
+ const owner = owners.get(text$1(block.input.to));
3034
3220
  if (owner !== void 0) change(owner, { status: "running" });
3035
3221
  }
3036
- if (block.name === "TaskStop") stops.set(block.id, text(block.input.task_id));
3222
+ if (block.name === "TaskStop") stops.set(block.id, text$1(block.input.task_id));
3037
3223
  tools.set(block.id, items.length);
3038
3224
  items.push(toolItem(block, parent, message.time, (to) => {
3039
3225
  const owner = owners.get(to);
@@ -3124,7 +3310,7 @@ function toRequest(options) {
3124
3310
  case "ExitPlanMode": return {
3125
3311
  id: options.id,
3126
3312
  kind: "plan",
3127
- plan: text(options.input.plan)
3313
+ plan: text$1(options.input.plan)
3128
3314
  };
3129
3315
  default: return {
3130
3316
  id: options.id,
@@ -3151,8 +3337,8 @@ function toolItem(tool, parent, time, ownerOf) {
3151
3337
  case "Bash": return {
3152
3338
  ...base,
3153
3339
  kind: "command",
3154
- command: text(input.command),
3155
- description: text(input.description),
3340
+ command: text$1(input.command),
3341
+ description: text$1(input.description),
3156
3342
  output: "",
3157
3343
  status: "running",
3158
3344
  background: input.run_in_background === true ? {
@@ -3164,8 +3350,8 @@ function toolItem(tool, parent, time, ownerOf) {
3164
3350
  case "Monitor": return {
3165
3351
  ...base,
3166
3352
  kind: "command",
3167
- command: text(input.command) || (isRecord(input.ws) ? text(input.ws.url) : ""),
3168
- description: text(input.description),
3353
+ command: text$1(input.command) || (isRecord(input.ws) ? text$1(input.ws.url) : ""),
3354
+ description: text$1(input.description),
3169
3355
  output: "",
3170
3356
  status: "running",
3171
3357
  background: {
@@ -3177,10 +3363,10 @@ function toolItem(tool, parent, time, ownerOf) {
3177
3363
  case "Edit": return {
3178
3364
  ...base,
3179
3365
  kind: "file_change",
3180
- path: text(input.file_path),
3366
+ path: text$1(input.file_path),
3181
3367
  diffs: [{
3182
- before: text(input.old_string),
3183
- after: text(input.new_string)
3368
+ before: text$1(input.old_string),
3369
+ after: text$1(input.new_string)
3184
3370
  }],
3185
3371
  output: "",
3186
3372
  status: "running"
@@ -3188,7 +3374,7 @@ function toolItem(tool, parent, time, ownerOf) {
3188
3374
  case "MultiEdit": return {
3189
3375
  ...base,
3190
3376
  kind: "file_change",
3191
- path: text(input.file_path),
3377
+ path: text$1(input.file_path),
3192
3378
  diffs: input.edits.map((edit) => ({
3193
3379
  before: edit.old_string,
3194
3380
  after: edit.new_string
@@ -3199,10 +3385,10 @@ function toolItem(tool, parent, time, ownerOf) {
3199
3385
  case "Write": return {
3200
3386
  ...base,
3201
3387
  kind: "file_change",
3202
- path: text(input.file_path),
3388
+ path: text$1(input.file_path),
3203
3389
  diffs: [{
3204
3390
  before: "",
3205
- after: text(input.content)
3391
+ after: text$1(input.content)
3206
3392
  }],
3207
3393
  output: "",
3208
3394
  status: "running"
@@ -3210,7 +3396,7 @@ function toolItem(tool, parent, time, ownerOf) {
3210
3396
  case "NotebookEdit": return {
3211
3397
  ...base,
3212
3398
  kind: "file_change",
3213
- path: text(input.notebook_path),
3399
+ path: text$1(input.notebook_path),
3214
3400
  diffs: [],
3215
3401
  output: "",
3216
3402
  status: "running"
@@ -3219,7 +3405,7 @@ function toolItem(tool, parent, time, ownerOf) {
3219
3405
  ...base,
3220
3406
  kind: "web_search",
3221
3407
  query: null,
3222
- url: text(input.url),
3408
+ url: text$1(input.url),
3223
3409
  results: [],
3224
3410
  output: "",
3225
3411
  status: "running"
@@ -3227,7 +3413,7 @@ function toolItem(tool, parent, time, ownerOf) {
3227
3413
  case "WebSearch": return {
3228
3414
  ...base,
3229
3415
  kind: "web_search",
3230
- query: text(input.query),
3416
+ query: text$1(input.query),
3231
3417
  url: null,
3232
3418
  results: [],
3233
3419
  output: "",
@@ -3236,7 +3422,7 @@ function toolItem(tool, parent, time, ownerOf) {
3236
3422
  case "Skill": return {
3237
3423
  ...base,
3238
3424
  kind: "skill",
3239
- skill: text(input.skill),
3425
+ skill: text$1(input.skill),
3240
3426
  output: "",
3241
3427
  status: "running"
3242
3428
  };
@@ -3251,7 +3437,7 @@ function toolItem(tool, parent, time, ownerOf) {
3251
3437
  case "ExitPlanMode": return {
3252
3438
  ...base,
3253
3439
  kind: "plan",
3254
- plan: text(input.plan)
3440
+ plan: text$1(input.plan)
3255
3441
  };
3256
3442
  case "AskUserQuestion": return {
3257
3443
  ...base,
@@ -3262,23 +3448,23 @@ function toolItem(tool, parent, time, ownerOf) {
3262
3448
  };
3263
3449
  case "Agent":
3264
3450
  case "Task": {
3265
- const agent = text(input.subagent_type);
3451
+ const agent = text$1(input.subagent_type);
3266
3452
  return {
3267
3453
  ...base,
3268
3454
  kind: "subagent",
3269
3455
  agent: agent === "" ? "general-purpose" : agent,
3270
- description: text(input.description),
3456
+ description: text$1(input.description),
3271
3457
  agentId: null,
3272
3458
  status: "running"
3273
3459
  };
3274
3460
  }
3275
3461
  case "SendMessage": {
3276
- const summary = text(input.summary);
3462
+ const summary = text$1(input.summary);
3277
3463
  return {
3278
3464
  ...base,
3279
3465
  kind: "agent_message",
3280
- target: ownerOf(text(input.to)),
3281
- summary: summary === "" ? text(input.message).split("\n")[0] : summary,
3466
+ target: ownerOf(text$1(input.to)),
3467
+ summary: summary === "" ? text$1(input.message).split("\n")[0] : summary,
3282
3468
  status: "running"
3283
3469
  };
3284
3470
  }
@@ -3368,7 +3554,7 @@ function userText(value) {
3368
3554
  text: value
3369
3555
  };
3370
3556
  }
3371
- function text(value) {
3557
+ function text$1(value) {
3372
3558
  return typeof value === "string" ? value : "";
3373
3559
  }
3374
3560
  //#endregion
@@ -3497,11 +3683,13 @@ async function history(sessionId, cwd) {
3497
3683
  const seen = new Set(messages.map((item) => item.uuid));
3498
3684
  const last = kept.findLast((item) => item.type === "assistant" && item.parent_tool_use_id === null);
3499
3685
  const tokens = last === void 0 ? null : contextTokens(last.message);
3686
+ const live = run?.messages ?? [];
3687
+ const compacted = live.findLastIndex((item) => item.type === "compaction");
3500
3688
  return {
3501
3689
  type: "history",
3502
3690
  sessionId,
3503
3691
  provider: "claude",
3504
- items: toItems([...messages, ...run?.messages.filter((item) => !seen.has(item.uuid)) ?? []]),
3692
+ items: toItems([...messages, ...live.slice(compacted + 1).filter((item) => !seen.has(item.uuid))]),
3505
3693
  requests: [...run?.pending.values() ?? []].map((item) => item.request),
3506
3694
  queue: run?.queue ?? [],
3507
3695
  settings: run?.settings ?? null,
@@ -4253,60 +4441,43 @@ function walk(root) {
4253
4441
  }
4254
4442
  //#endregion
4255
4443
  //#region src/services/push.ts
4256
- const pushFile = path.join(home, "push.json");
4257
4444
  const TITLE_LIMIT = 100;
4258
4445
  const BODY_LIMIT = 500;
4259
4446
  const TIME_TO_LIVE = 900;
4260
4447
  const SEND_TIMEOUT = 1e4;
4261
4448
  const SUBJECT = "https://github.com/NazmusSayad/offsite";
4262
4449
  let server$1 = "";
4263
- let active = false;
4264
4450
  function startPush(address) {
4265
4451
  server$1 = address;
4266
- try {
4267
- const store = readJson("push.json");
4268
- if (store !== null) track(store);
4269
- } catch (error) {
4270
- console.warn(`push notifications are off until a browser turns them on again, could not read ${pushFile}: ${error}`);
4271
- }
4272
4452
  }
4273
4453
  function pushActive() {
4274
- return active;
4454
+ return ours().length > 0;
4275
4455
  }
4276
4456
  function pushKey() {
4277
- return { publicKey: read().publicKey };
4457
+ return { publicKey: machineInfo().pushPublicKey };
4278
4458
  }
4279
4459
  function subscribe(subscription) {
4280
4460
  if (new URL(subscription.endpoint).protocol !== "https:") throw new Error("the endpoint must be an https URL");
4281
- update((store) => {
4282
- store.subscriptions = store.subscriptions.filter((existing) => existing.endpoint !== subscription.endpoint);
4283
- store.subscriptions.push({
4284
- endpoint: subscription.endpoint,
4285
- keys: {
4286
- p256dh: subscription.keys.p256dh,
4287
- auth: subscription.keys.auth
4288
- },
4289
- server: server$1
4290
- });
4291
- });
4461
+ const values = {
4462
+ endpoint: subscription.endpoint,
4463
+ p256dh: subscription.keys.p256dh,
4464
+ auth: subscription.keys.auth,
4465
+ server: server$1
4466
+ };
4467
+ db.insert(pushSubscriptions).values(values).onConflictDoUpdate({
4468
+ target: pushSubscriptions.endpoint,
4469
+ set: values
4470
+ }).run();
4292
4471
  console.log("a browser subscribed to push notifications");
4293
4472
  }
4294
4473
  function unsubscribe(endpoint) {
4295
- update((store) => {
4296
- store.subscriptions = store.subscriptions.filter((existing) => existing.endpoint !== endpoint);
4297
- });
4474
+ db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)).run();
4298
4475
  console.log("a browser unsubscribed from push notifications");
4299
4476
  }
4300
4477
  async function sendPush(message) {
4301
- let store;
4302
- try {
4303
- store = read();
4304
- } catch (error) {
4305
- console.error(`could not read push subscriptions: ${error}`);
4306
- return;
4307
- }
4308
- const subscriptions = store.subscriptions.filter(ours);
4478
+ const subscriptions = ours();
4309
4479
  if (subscriptions.length === 0) return;
4480
+ const keys = machineInfo();
4310
4481
  const payload = JSON.stringify({
4311
4482
  ...message,
4312
4483
  title: [...message.title].slice(0, TITLE_LIMIT).join(""),
@@ -4315,11 +4486,17 @@ async function sendPush(message) {
4315
4486
  const gone = [];
4316
4487
  await Promise.all(subscriptions.map(async (subscription) => {
4317
4488
  try {
4318
- await webPush.sendNotification(subscription, payload, {
4489
+ await webPush.sendNotification({
4490
+ endpoint: subscription.endpoint,
4491
+ keys: {
4492
+ p256dh: subscription.p256dh,
4493
+ auth: subscription.auth
4494
+ }
4495
+ }, payload, {
4319
4496
  vapidDetails: {
4320
4497
  subject: SUBJECT,
4321
- publicKey: store.publicKey,
4322
- privateKey: store.privateKey
4498
+ publicKey: keys.pushPublicKey,
4499
+ privateKey: keys.pushPrivateKey
4323
4500
  },
4324
4501
  TTL: TIME_TO_LIVE,
4325
4502
  urgency: "high",
@@ -4341,59 +4518,10 @@ async function sendPush(message) {
4341
4518
  }
4342
4519
  }));
4343
4520
  if (gone.length === 0) return;
4344
- try {
4345
- update((current) => {
4346
- current.subscriptions = current.subscriptions.filter((subscription) => !gone.includes(subscription.endpoint));
4347
- });
4348
- } catch (error) {
4349
- console.error(`could not remove gone push subscriptions: ${error}`);
4350
- }
4521
+ db.delete(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), inArray(pushSubscriptions.endpoint, gone))).run();
4351
4522
  }
4352
- function ours(subscription) {
4353
- return subscription.server === server$1;
4354
- }
4355
- function track(store) {
4356
- active = store.subscriptions.some(ours);
4357
- }
4358
- function load() {
4359
- let store;
4360
- try {
4361
- store = readJson("push.json");
4362
- } catch (error) {
4363
- if (!(error instanceof SyntaxError)) throw error;
4364
- return replaceCorrupt(error);
4365
- }
4366
- if (store === null) return create();
4367
- if (typeof store.publicKey !== "string" || typeof store.privateKey !== "string" || !Array.isArray(store.subscriptions)) return replaceCorrupt(/* @__PURE__ */ new Error("it is missing its keys or subscriptions"));
4368
- return store;
4369
- }
4370
- function replaceCorrupt(error) {
4371
- const aside = `${pushFile}.corrupt-${randomUUID()}`;
4372
- renameSync(pushFile, aside);
4373
- console.warn(`${pushFile} is corrupt, moved it to ${aside} and created a new one: ${error}`);
4374
- return create();
4375
- }
4376
- function create() {
4377
- const store = {
4378
- ...webPush.generateVAPIDKeys(),
4379
- subscriptions: []
4380
- };
4381
- save(store);
4382
- return store;
4383
- }
4384
- function save(store) {
4385
- writeJson("push.json", store);
4386
- }
4387
- function read() {
4388
- const store = load();
4389
- track(store);
4390
- return store;
4391
- }
4392
- function update(change) {
4393
- const store = load();
4394
- change(store);
4395
- save(store);
4396
- track(store);
4523
+ function ours() {
4524
+ return db.select().from(pushSubscriptions).where(eq(pushSubscriptions.server, server$1)).all();
4397
4525
  }
4398
4526
  //#endregion
4399
4527
  //#region src/services/runs.ts
@@ -4414,9 +4542,10 @@ function rewind(request) {
4414
4542
  }
4415
4543
  async function rewindNow(request) {
4416
4544
  const sessionId = request.sessionId;
4417
- if (runs.get(sessionId)?.running === true) throw new Error("Stop Claude before rewinding");
4545
+ const cwd = projectPath(request.projectId);
4546
+ if (runs.get(sessionId)?.running === true) throw new Error(`Stop ${providerName(providerOf(sessionId))} before rewinding`);
4418
4547
  if ((runs.get(sessionId)?.backgroundTasks ?? 0) > 0) throw new Error("Wait for background tasks to finish before rewinding");
4419
- const run = await runFor(sessionId, request.cwd, request.settings);
4548
+ const run = await runFor(sessionId, cwd, request.settings);
4420
4549
  if (run.provider === "codex") {
4421
4550
  if (request.code) throw new Error("Codex cannot restore files");
4422
4551
  if (request.dryRun || !request.conversation) return null;
@@ -4429,7 +4558,7 @@ async function rewindNow(request) {
4429
4558
  const result = await rewindOpenCode(request);
4430
4559
  if (request.dryRun) return result;
4431
4560
  run.items.clear();
4432
- publish$2(sessionId, await openCodeHistory(sessionId, request.cwd));
4561
+ publish$2(sessionId, await openCodeHistory(sessionId, cwd));
4433
4562
  return result;
4434
4563
  }
4435
4564
  let result = null;
@@ -4443,58 +4572,63 @@ async function rewindNow(request) {
4443
4572
  };
4444
4573
  }
4445
4574
  if (request.dryRun || !request.conversation) return result;
4446
- const disk = await getSessionMessages(sessionId, { dir: request.cwd });
4575
+ const disk = await getSessionMessages(sessionId, { dir: cwd });
4447
4576
  const index = disk.findIndex((item) => item.uuid === request.messageId);
4448
4577
  if (index < 1) throw new Error("Cannot rewind the conversation to that point");
4449
4578
  run.stop();
4450
4579
  await run.closed;
4451
4580
  new Run({
4452
4581
  sessionId,
4453
- cwd: request.cwd,
4582
+ cwd,
4454
4583
  resume: true,
4455
4584
  resumeAt: disk[index - 1].uuid,
4456
4585
  settings: request.settings
4457
4586
  });
4458
- publish$2(sessionId, await history(sessionId, request.cwd));
4587
+ publish$2(sessionId, await history(sessionId, cwd));
4459
4588
  return result;
4460
4589
  }
4461
- async function open$1(client, sessionId, cwd) {
4590
+ async function open$1(client, sessionId, projectId) {
4462
4591
  client.opening = sessionId;
4463
- const event = await sessionHistory(sessionId, cwd);
4592
+ const event = await sessionHistory(sessionId, projectPath(projectId));
4464
4593
  if (client.opening !== sessionId) return;
4465
4594
  client.sessionId = sessionId;
4466
4595
  client.send(event);
4467
4596
  }
4468
4597
  async function send$1(client, request) {
4469
- if (!statSync(request.cwd, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${request.cwd} is not a folder`);
4598
+ const cwd = projectPath(request.projectId);
4599
+ if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${cwd} is not a folder`);
4470
4600
  if (request.sessionId === null) {
4471
- const run = await startRun(request.cwd, request.settings);
4601
+ const run = await startRun(cwd, request.settings);
4472
4602
  const sessionId = run.sessionId;
4473
4603
  client.opening = sessionId;
4474
4604
  client.sessionId = sessionId;
4475
4605
  client.send({
4476
4606
  type: "started",
4477
4607
  sessionId,
4478
- cwd: request.cwd
4608
+ projectId: request.projectId
4479
4609
  });
4480
4610
  await run.submit(request.prompt, request.delivery);
4481
4611
  recordInteraction(sessionId);
4482
4612
  return;
4483
4613
  }
4484
4614
  const sessionId = request.sessionId;
4485
- await locked(sessionId, () => sendExisting(client, sessionId, request));
4615
+ await locked(sessionId, () => sendExisting(client, sessionId, cwd, request));
4486
4616
  }
4487
- async function sendExisting(client, sessionId, request) {
4617
+ async function sendExisting(client, sessionId, cwd, request) {
4488
4618
  const provider = providerOf(sessionId);
4489
4619
  if (request.settings.provider !== provider) throw new Error(`This session belongs to ${providerName(provider)}`);
4490
4620
  const existing = runs.get(sessionId);
4491
- if (existing?.provider === "claude" && !existing.running && existing.backgroundTasks === 0 && await changedElsewhere(existing, request.cwd)) {
4621
+ if (existing?.provider === "claude" && !existing.running && existing.backgroundTasks === 0 && await changedElsewhere(existing, cwd)) {
4622
+ existing.stop();
4623
+ await existing.closed;
4624
+ }
4625
+ if (existing?.provider === "codex" && !existing.running && existing.pending.size === 0 && (listedUpdatedAt("codex", sessionId) ?? 0) > existing.activeAt) {
4492
4626
  existing.stop();
4493
4627
  await existing.closed;
4494
4628
  }
4495
- const run = await runFor(sessionId, request.cwd, request.settings);
4629
+ const run = await runFor(sessionId, cwd, request.settings);
4496
4630
  if (run === existing) await run.configure(request.settings);
4497
- else await open$1(client, sessionId, request.cwd);
4631
+ else await open$1(client, sessionId, request.projectId);
4498
4632
  await run.submit(request.prompt, request.delivery);
4499
4633
  recordInteraction(sessionId);
4500
4634
  }
@@ -5138,7 +5272,7 @@ async function request(connection, message) {
5138
5272
  return;
5139
5273
  }
5140
5274
  case "createTab":
5141
- await createTab(message.cwd);
5275
+ await createTab(message.projectId);
5142
5276
  break;
5143
5277
  case "closeTab":
5144
5278
  closeTab(message.id);
@@ -5162,17 +5296,17 @@ async function request(connection, message) {
5162
5296
  break;
5163
5297
  }
5164
5298
  case "select": {
5165
- const project = projects.get(message.cwd) ?? {
5299
+ const project = projects.get(message.projectId) ?? {
5166
5300
  tabs: [],
5167
5301
  activeTab: null
5168
5302
  };
5169
- projects.set(message.cwd, project);
5303
+ projects.set(message.projectId, project);
5170
5304
  const tab = project.tabs.find((item) => item.id === message.tabId);
5171
5305
  if (tab !== void 0) {
5172
5306
  tab.minimized = false;
5173
5307
  project.activeTab = tab.id;
5174
5308
  }
5175
- if (project.tabs.length === 0) await createTab(message.cwd);
5309
+ if (project.tabs.length === 0) await createTab(message.projectId);
5176
5310
  break;
5177
5311
  }
5178
5312
  }
@@ -5205,16 +5339,17 @@ function input(connection, bytes) {
5205
5339
  }
5206
5340
  terminal.pty.write(Buffer.from(frame.data));
5207
5341
  }
5208
- async function createTab(cwd) {
5342
+ async function createTab(projectId) {
5343
+ const cwd = projectPath(projectId);
5209
5344
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
5210
5345
  console.error(`${cwd} is not a folder, terminal not created`);
5211
5346
  return;
5212
5347
  }
5213
- const project = projects.get(cwd) ?? {
5348
+ const project = projects.get(projectId) ?? {
5214
5349
  tabs: [],
5215
5350
  activeTab: null
5216
5351
  };
5217
- projects.set(cwd, project);
5352
+ projects.set(projectId, project);
5218
5353
  const active = project.activeTab === null ? void 0 : terminals.get(project.activeTab);
5219
5354
  let directory = cwd;
5220
5355
  if (active !== void 0) try {
@@ -5347,7 +5482,8 @@ function push(id, alert, windowTitle) {
5347
5482
  sendPush({
5348
5483
  title,
5349
5484
  body,
5350
- cwd: entry[0],
5485
+ machineId: machineInfo().id,
5486
+ projectId: entry[0],
5351
5487
  tabId: id
5352
5488
  });
5353
5489
  }
@@ -5387,33 +5523,38 @@ function send(connection, event) {
5387
5523
  //#region src/api/socket.ts
5388
5524
  const handlers = {
5389
5525
  ping: () => null,
5390
- sync: async () => ({
5391
- ...configInfo(),
5392
- protocol: 2,
5393
- hostname: os.hostname(),
5394
- projects: projects$1(),
5395
- sessions: await sessions(),
5396
- activity: activity()
5397
- }),
5526
+ sync: () => {
5527
+ refreshAll();
5528
+ return {
5529
+ ...configInfo(),
5530
+ protocol: 3,
5531
+ machineId: machineInfo().id,
5532
+ hostname: os.hostname(),
5533
+ projects: projects$1(),
5534
+ sessions: sessions(),
5535
+ providers: providerStatuses(),
5536
+ activity: activity()
5537
+ };
5538
+ },
5398
5539
  "config.get": () => configInfo(),
5399
5540
  "config.save": (params) => {
5400
5541
  const before = readConfig().claudeBaseDir;
5401
5542
  const saved = saveConfig(params);
5402
5543
  if (saved.claudeBaseDir === before) return saved;
5403
5544
  watchSessions();
5404
- publishSessions();
5545
+ refreshAll();
5405
5546
  publishAccounts();
5406
5547
  return saved;
5407
5548
  },
5408
5549
  "projects.add": async (params) => {
5409
5550
  const project = await addProject(params.path);
5410
- publishSessions();
5551
+ refreshAll();
5411
5552
  return project;
5412
5553
  },
5413
- "projects.update": (params) => updateProject(params.path, params.patch),
5554
+ "projects.update": (params) => updateProject(params.id, params.patch),
5414
5555
  "projects.remove": (params) => {
5415
- removeProject(params.path);
5416
- publishSessions();
5556
+ removeProject(params.id);
5557
+ refreshAll();
5417
5558
  return null;
5418
5559
  },
5419
5560
  "directories.list": (params) => directories(params.path),
@@ -5434,14 +5575,14 @@ const handlers = {
5434
5575
  return null;
5435
5576
  },
5436
5577
  "sessions.open": async (params, client) => {
5437
- await open$1(client, params.sessionId, params.cwd);
5578
+ await open$1(client, params.sessionId, params.projectId);
5438
5579
  return null;
5439
5580
  },
5440
5581
  "sessions.earlier": (params) => earlier(params.sessionId, params.before),
5441
5582
  "sessions.image": (params) => image(params.sessionId, params.itemId, params.index),
5442
5583
  "sessions.agent": (params) => {
5443
5584
  switch (providerOf(params.sessionId)) {
5444
- case "claude": return agentMessages(params.sessionId, params.cwd, params.agentId, params.parent);
5585
+ case "claude": return agentMessages(params.sessionId, projectPath(params.projectId), params.agentId, params.parent);
5445
5586
  case "codex": return codexAgent(params.agentId, params.parent);
5446
5587
  case "opencode": return openCodeAgent(params.agentId, params.parent);
5447
5588
  }
@@ -5479,7 +5620,7 @@ const handlers = {
5479
5620
  },
5480
5621
  "sessions.rewind": (params) => rewind(params),
5481
5622
  "sessions.fork": async (params) => {
5482
- const result = await fork(params.sessionId, params.cwd, params.messageId);
5623
+ const result = await fork(params.sessionId, params.projectId, params.messageId);
5483
5624
  publishSessions();
5484
5625
  return result;
5485
5626
  },
@@ -5499,17 +5640,17 @@ const handlers = {
5499
5640
  publishAccounts();
5500
5641
  return null;
5501
5642
  },
5502
- capabilities: (params) => capabilities(params.accountId, params.cwd),
5503
- "files.list": (params) => listFiles(params.cwd),
5504
- "files.read": (params) => readProjectFile(params.cwd, params.path),
5643
+ capabilities: (params) => capabilities(params.accountId, projectPath(params.projectId)),
5644
+ "files.list": (params) => listFiles(projectPath(params.projectId)),
5645
+ "files.read": (params) => readProjectFile(projectPath(params.projectId), params.path),
5505
5646
  "files.upload": (params) => saveUpload(params.name, Buffer.from(params.data, "base64")),
5506
5647
  "tasks.output": (params) => taskOutput(params.sessionId, params.path),
5507
- "git.status": (params) => gitStatus(params.cwd),
5508
- "git.diff": (params) => projectDiff(params.cwd),
5509
- "git.commit": (params) => commit(params.cwd, params.message),
5510
- "git.push": (params) => push$1(params.cwd),
5511
- "git.pullRequest": (params) => createPullRequest(params.cwd),
5512
- "git.message": (params) => suggestCommitMessage(params.provider, params.accountId, params.cwd),
5648
+ "git.status": (params) => gitStatus(projectPath(params.projectId)),
5649
+ "git.diff": (params) => projectDiff(projectPath(params.projectId)),
5650
+ "git.commit": (params) => commit(projectPath(params.projectId), params.message),
5651
+ "git.push": (params) => push$1(projectPath(params.projectId)),
5652
+ "git.pullRequest": (params) => createPullRequest(projectPath(params.projectId)),
5653
+ "git.message": (params) => suggestCommitMessage(params.provider, params.accountId, projectPath(params.projectId)),
5513
5654
  "push.key": () => pushKey(),
5514
5655
  "push.subscribe": (params) => {
5515
5656
  subscribe(params);
@@ -5578,40 +5719,32 @@ async function answer(client, text) {
5578
5719
  //#endregion
5579
5720
  //#region src/storage/token.ts
5580
5721
  function readTokens() {
5581
- const stored = readJson("tokens.json");
5582
- return stored === null ? [] : stored.tokens;
5722
+ return db.select().from(tokens).orderBy(tokens.createdAt).all();
5583
5723
  }
5584
5724
  function addToken(name) {
5585
5725
  const token = randomBytes(32).toString("base64url");
5586
- writeJson("tokens.json", { tokens: [...readTokens(), {
5587
- token,
5726
+ db.insert(tokens).values({
5727
+ hash: hashToken(token),
5588
5728
  name,
5589
5729
  createdAt: Date.now()
5590
- }] });
5730
+ }).run();
5591
5731
  return token;
5592
5732
  }
5593
- function removeTokens(tokens) {
5594
- writeJson("tokens.json", { tokens: readTokens().filter((item) => !tokens.includes(item.token)) });
5733
+ function removeTokens(hashes) {
5734
+ db.delete(tokens).where(inArray(tokens.hash, hashes)).run();
5595
5735
  }
5596
- function firstToken() {
5597
- return readTokens().at(0)?.token ?? addToken(null);
5736
+ function hasTokens() {
5737
+ return db.select().from(tokens).limit(1).get() !== void 0;
5598
5738
  }
5599
5739
  function matchesToken(given) {
5600
5740
  if (given === null) return false;
5601
- const actual = Buffer.from(given);
5602
- return readTokens().some((item) => {
5603
- const expected = Buffer.from(item.token);
5604
- return expected.length === actual.length && timingSafeEqual(expected, actual);
5605
- });
5606
- }
5607
- function readUsage() {
5608
- return readJson("token-usage.json") ?? {};
5741
+ return db.select().from(tokens).where(eq(tokens.hash, hashToken(given))).get() !== void 0;
5609
5742
  }
5610
5743
  function recordUse(token) {
5611
- writeJson("token-usage.json", {
5612
- ...readUsage(),
5613
- [token]: Date.now()
5614
- });
5744
+ db.update(tokens).set({ lastUsedAt: Date.now() }).where(eq(tokens.hash, hashToken(token))).run();
5745
+ }
5746
+ function hashToken(token) {
5747
+ return createHash("sha256").update(token).digest("hex");
5615
5748
  }
5616
5749
  //#endregion
5617
5750
  //#region src/main.ts
@@ -5633,11 +5766,17 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5633
5766
  process.exit(1);
5634
5767
  }
5635
5768
  const address = `http://${args.host}:${port}`;
5769
+ const fallback = args.fallback === void 0 ? null : readFallback(args.fallback);
5636
5770
  if (args.command === "pair") {
5637
5771
  const links = pairingLinks(addToken(args.name));
5638
- console.log("Open one of these links in the browser to pair:");
5639
- console.log(` ${links.local}`);
5772
+ console.log(`Scan this to pair a phone with ${HOSTED_APP}:`);
5773
+ console.log("");
5774
+ console.log(renderUnicodeCompact(links.hosted, { border: 2 }));
5775
+ console.log("");
5776
+ console.log("Or open one of these links in the browser to pair:");
5640
5777
  console.log(` ${links.hosted}`);
5778
+ console.log(` ${links.local}`);
5779
+ if (fallback !== null) console.log(`Fallback: ${fallback}`);
5641
5780
  process.exit(0);
5642
5781
  }
5643
5782
  if (args.command === "unpair") {
@@ -5647,12 +5786,13 @@ if (args.command === "unpair") {
5647
5786
  lockHome();
5648
5787
  process.env.CLAUDE_CONFIG_DIR = readConfig().claudeBaseDir;
5649
5788
  watchSessions();
5789
+ refreshAll();
5650
5790
  if (findClaude() === null) {
5651
5791
  console.error("could not find claude on PATH; install Claude Code first");
5652
5792
  process.exit(1);
5653
5793
  }
5654
5794
  const publicDir = path.join(import.meta.dirname, "..", "..", "web", "dist");
5655
- const token = args.auth && args.pair ? firstToken() : null;
5795
+ const token = args.auth && args.pair && !hasTokens() ? addToken(null) : null;
5656
5796
  const server = createServer((request, response) => {
5657
5797
  const url = new URL(request.url ?? "/", "http://localhost");
5658
5798
  if (url.pathname === "/version") {
@@ -5660,7 +5800,8 @@ const server = createServer((request, response) => {
5660
5800
  response.setHeader("content-type", "application/json");
5661
5801
  response.end(JSON.stringify({
5662
5802
  version,
5663
- protocol: 2,
5803
+ protocol: 3,
5804
+ machineId: machineInfo().id,
5664
5805
  authorized: authorized(url)
5665
5806
  }));
5666
5807
  return;
@@ -5677,7 +5818,7 @@ const paired = /* @__PURE__ */ new Map();
5677
5818
  server.on("upgrade", (request, socket, head) => {
5678
5819
  const url = new URL(request.url ?? "/", "http://localhost");
5679
5820
  const given = url.searchParams.get("token");
5680
- if (url.pathname !== "/ws" || url.searchParams.get("protocol") !== String(2) || !authorized(url)) {
5821
+ if (url.pathname !== "/ws" || url.searchParams.get("protocol") !== String(3) || !authorized(url)) {
5681
5822
  socket.destroy();
5682
5823
  return;
5683
5824
  }
@@ -5694,10 +5835,13 @@ if (args.auth) setInterval(() => {
5694
5835
  }, UNPAIR_CHECK);
5695
5836
  server.listen(port, args.host, () => {
5696
5837
  startPush(`${args.host}:${port}`);
5697
- if (args.pair) {
5838
+ if (args.pair && (token !== null || !args.auth)) {
5698
5839
  const links = pairingLinks(token);
5699
5840
  if (args.command === "web") console.log(`offsite is running at ${links.local}`);
5700
5841
  console.log(`Open it on ${HOSTED_APP}: ${links.hosted}`);
5842
+ } else if (args.pair) {
5843
+ console.log(`offsite is running at ${address}`);
5844
+ console.log("Run offsite pair to pair another browser");
5701
5845
  } else {
5702
5846
  console.log(`offsite is running at ${address}`);
5703
5847
  console.log(`Open it on ${HOSTED_APP}`);
@@ -5723,23 +5867,18 @@ async function unpair() {
5723
5867
  console.error("offsite unpair needs an interactive terminal");
5724
5868
  process.exit(1);
5725
5869
  }
5726
- const usage = readUsage();
5727
5870
  const picked = await consola.prompt("Which browsers should be unpaired?", {
5728
5871
  type: "multiselect",
5729
5872
  required: false,
5730
5873
  cancel: "null",
5731
- options: tokens.map((item) => {
5732
- const used = usage[item.token];
5733
- return {
5734
- value: item.token,
5735
- label: [
5736
- item.name ?? "Unnamed",
5737
- `${item.token.slice(0, 6)}…`,
5738
- `paired ${new Date(item.createdAt).toLocaleString()}`,
5739
- used === void 0 ? "never used" : `last used ${new Date(used).toLocaleString()}`
5740
- ].join(" · ")
5741
- };
5742
- })
5874
+ options: tokens.map((item) => ({
5875
+ value: item.hash,
5876
+ label: [
5877
+ item.name ?? "Unnamed",
5878
+ `paired ${new Date(item.createdAt).toLocaleString()}`,
5879
+ item.lastUsedAt === null ? "never used" : `last used ${new Date(item.lastUsedAt).toLocaleString()}`
5880
+ ].join(" · ")
5881
+ }))
5743
5882
  });
5744
5883
  if (picked === null || picked.length === 0) {
5745
5884
  console.log("Nothing unpaired");
@@ -5748,9 +5887,24 @@ async function unpair() {
5748
5887
  removeTokens(picked);
5749
5888
  console.log(`Unpaired ${picked.length} ${picked.length === 1 ? "browser" : "browsers"}; a running offsite disconnects them within seconds`);
5750
5889
  }
5890
+ function readFallback(link) {
5891
+ let url;
5892
+ try {
5893
+ url = new URL(link);
5894
+ } catch {
5895
+ console.error(`invalid fallback URL ${link}`);
5896
+ process.exit(1);
5897
+ }
5898
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
5899
+ console.error(`the fallback URL ${link} needs http:// or https://`);
5900
+ process.exit(1);
5901
+ }
5902
+ return url.origin;
5903
+ }
5751
5904
  function pairingLinks(token) {
5752
5905
  const hosted = new URLSearchParams({
5753
5906
  machine: address,
5907
+ ...fallback !== null && { fallback },
5754
5908
  ...token !== null && { token }
5755
5909
  });
5756
5910
  return {