offsite 0.0.7 → 0.0.11

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 (91) hide show
  1. package/README.md +7 -7
  2. package/apps/server/dist/main.mjs +1855 -1644
  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/server/drizzle/20261017103559_browser_push_keys/migration.sql +11 -0
  7. package/apps/server/drizzle/20261017103559_browser_push_keys/snapshot.json +265 -0
  8. package/apps/server/drizzle/20261017110336_browser_machine_ids/migration.sql +3 -0
  9. package/apps/server/drizzle/20261017110336_browser_machine_ids/snapshot.json +252 -0
  10. package/apps/web/dist/assets/{angular-html-CxKqLP98.js → angular-html-DTNmgvth.js} +1 -1
  11. package/apps/web/dist/assets/{angular-ts-ChaIimso.js → angular-ts-DOOLa99A.js} +1 -1
  12. package/apps/web/dist/assets/{apl-C79wKZ96.js → apl-CSWmNqJI.js} +1 -1
  13. package/apps/web/dist/assets/{astro-Bjd0-rUa.js → astro-CK7We5bo.js} +1 -1
  14. package/apps/web/dist/assets/{blade-CwnKAXvb.js → blade-B9Q94PMc.js} +1 -1
  15. package/apps/web/dist/assets/{c-Z516m-0_.js → c-D05zRIsd.js} +1 -1
  16. package/apps/web/dist/assets/{chapel-CjZ9_INP.js → chapel-CJbjuVPR.js} +1 -1
  17. package/apps/web/dist/assets/{cobol-loY0YFmk.js → cobol-CE0VhU2R.js} +1 -1
  18. package/apps/web/dist/assets/{coffee-UH3lFVdK.js → coffee-C0i-RL39.js} +1 -1
  19. package/apps/web/dist/assets/{cpp-DglnXsh6.js → cpp-CBJhfD_V.js} +1 -1
  20. package/apps/web/dist/assets/{crystal-8bvlXru4.js → crystal-DRdgVTW1.js} +1 -1
  21. package/apps/web/dist/assets/{css-BR1q7k6_.js → css-DgMip4qW.js} +1 -1
  22. package/apps/web/dist/assets/{edge-BxokJ4MN.js → edge-BCmeW_3H.js} +1 -1
  23. package/apps/web/dist/assets/{elixir-BUaqVZQr.js → elixir-BiRSxKnc.js} +1 -1
  24. package/apps/web/dist/assets/{elm-D_9smeEe.js → elm-zBiqCp9W.js} +1 -1
  25. package/apps/web/dist/assets/{erb-DOZIqXQH.js → erb-1QviMyT9.js} +1 -1
  26. package/apps/web/dist/assets/{git-rebase-DN-oOLvo.js → git-rebase-TjFhp_Gn.js} +1 -1
  27. package/apps/web/dist/assets/{glimmer-js-dSW-hX4N.js → glimmer-js-dThwC_hZ.js} +1 -1
  28. package/apps/web/dist/assets/{glimmer-ts-BgJbFWVH.js → glimmer-ts-cCThNW17.js} +1 -1
  29. package/apps/web/dist/assets/{glsl-FkhRNxnG.js → glsl-BxmyIQYG.js} +1 -1
  30. package/apps/web/dist/assets/{graphql-BKHVf0BT.js → graphql-DygyczJj.js} +1 -1
  31. package/apps/web/dist/assets/{hack-obS7Vybj.js → hack-Bl1_oIjY.js} +1 -1
  32. package/apps/web/dist/assets/{haml-8M07SKZy.js → haml-CeOBocB0.js} +1 -1
  33. package/apps/web/dist/assets/{handlebars-Cyb9PeHi.js → handlebars-Bw1KMTCv.js} +1 -1
  34. package/apps/web/dist/assets/{html-Dwl7QR96.js → html-BaNo1IFo.js} +1 -1
  35. package/apps/web/dist/assets/{html-derivative-Dx3lESlT.js → html-derivative-f4LhlsLO.js} +1 -1
  36. package/apps/web/dist/assets/{http-CKSR21d-.js → http-Be2FOF-U.js} +1 -1
  37. package/apps/web/dist/assets/{hurl-DGaDIWma.js → hurl-BrtPQQhS.js} +1 -1
  38. package/apps/web/dist/assets/index-6ofGnhX4.css +2 -0
  39. package/apps/web/dist/assets/index-NWUHLKte.js +1203 -0
  40. package/apps/web/dist/assets/{java-BmaAOMbe.js → java-Bum5bcia.js} +1 -1
  41. package/apps/web/dist/assets/{javascript-Dh-l17xw.js → javascript-C5oYhZKk.js} +1 -1
  42. package/apps/web/dist/assets/{jinja-C14B9X7r.js → jinja-CNXoe8pn.js} +1 -1
  43. package/apps/web/dist/assets/{jison-DOLQZnVk.js → jison-g1hzxo4W.js} +1 -1
  44. package/apps/web/dist/assets/{json-CKQXRUCT.js → json-CiAAuc5j.js} +1 -1
  45. package/apps/web/dist/assets/{jsx-D3LXQmFh.js → jsx-Z0awY4ip.js} +1 -1
  46. package/apps/web/dist/assets/{julia-BONGpLSW.js → julia-DX3JjwcM.js} +1 -1
  47. package/apps/web/dist/assets/{just-1ImCRd19.js → just-DS6eCX0g.js} +1 -1
  48. package/apps/web/dist/assets/{latex-C6nf8qng.js → latex-CaBic_pJ.js} +1 -1
  49. package/apps/web/dist/assets/{liquid-OyHcMLT8.js → liquid-DMaOBf3v.js} +1 -1
  50. package/apps/web/dist/assets/{lua-CFyXSn73.js → lua-DeLz5pfA.js} +1 -1
  51. package/apps/web/dist/assets/{marko-w4Xaqrn8.js → marko-DqlzpgnR.js} +1 -1
  52. package/apps/web/dist/assets/{mdc-CipE4q24.js → mdc-CRcSVpSn.js} +1 -1
  53. package/apps/web/dist/assets/{nginx-CidGWCuA.js → nginx-BvszHPa-.js} +1 -1
  54. package/apps/web/dist/assets/{nim-DqvuZ6-V.js → nim-B19-Hebo.js} +1 -1
  55. package/apps/web/dist/assets/{org-C8k2emY1.js → org-Dx7yMTgG.js} +1 -1
  56. package/apps/web/dist/assets/{perl-D06UK6ou.js → perl-C-dISCJW.js} +1 -1
  57. package/apps/web/dist/assets/{php-POxwc-vY.js → php-BHBxrwsl.js} +1 -1
  58. package/apps/web/dist/assets/{pug-BzSRzYD8.js → pug-4a3x4vRs.js} +1 -1
  59. package/apps/web/dist/assets/{qml-DZF7i2J0.js → qml-CGOhzFqi.js} +1 -1
  60. package/apps/web/dist/assets/{r-Cem3rJmc.js → r-8jr0rtQL.js} +1 -1
  61. package/apps/web/dist/assets/{razor-DG8dmRN8.js → razor-BDrURPIA.js} +1 -1
  62. package/apps/web/dist/assets/{regexp-B4kXooBf.js → regexp-Cg-MfP18.js} +1 -1
  63. package/apps/web/dist/assets/{rst-DKLtDtVi.js → rst-0OrP55QZ.js} +1 -1
  64. package/apps/web/dist/assets/{ruby-CamS7fke.js → ruby-dAApWLhj.js} +1 -1
  65. package/apps/web/dist/assets/{sas-B0QV8BKG.js → sas-3_hKTjL9.js} +1 -1
  66. package/apps/web/dist/assets/{scss-PDWZBsRp.js → scss-D6ollEQv.js} +1 -1
  67. package/apps/web/dist/assets/{shellscript-CdzFXVZ3.js → shellscript-V3H5Rwnn.js} +1 -1
  68. package/apps/web/dist/assets/{shellsession-BTvaJuzj.js → shellsession-TxydKEoU.js} +1 -1
  69. package/apps/web/dist/assets/{soy-CKjkt_DB.js → soy-t3rHc1tL.js} +1 -1
  70. package/apps/web/dist/assets/{sql-CvyoBOik.js → sql-B-NkeM8R.js} +1 -1
  71. package/apps/web/dist/assets/{stata-DKSZtLec.js → stata-BW6neeVL.js} +1 -1
  72. package/apps/web/dist/assets/{surrealql-DLiWChn8.js → surrealql-CRBKdx6K.js} +1 -1
  73. package/apps/web/dist/assets/{svelte-BEKUDT0w.js → svelte-DTTmtyn-.js} +1 -1
  74. package/apps/web/dist/assets/{templ-Cwpj8Ert.js → templ-CCBYQttY.js} +1 -1
  75. package/apps/web/dist/assets/{tex-eE29FuOr.js → tex-D9XxqR5N.js} +1 -1
  76. package/apps/web/dist/assets/{ts-tags-CS_oR42m.js → ts-tags-CI4pWHnF.js} +1 -1
  77. package/apps/web/dist/assets/{tsx-CXh89N3W.js → tsx-DlfK3Vuf.js} +1 -1
  78. package/apps/web/dist/assets/{twig-DE2-3Vmd.js → twig-D-C2s1f2.js} +1 -1
  79. package/apps/web/dist/assets/{typescript-xOdvp9de.js → typescript-BnqYTwqM.js} +1 -1
  80. package/apps/web/dist/assets/{typst-B_JeYRUR.js → typst-DdTyavvE.js} +1 -1
  81. package/apps/web/dist/assets/{vue-html-UHMeN4EH.js → vue-html-CIXNcZez.js} +1 -1
  82. package/apps/web/dist/assets/{vue-vine-Cl2xkrIS.js → vue-vine-Czo_W6u3.js} +1 -1
  83. package/apps/web/dist/assets/{vue-dnoj_d3g.js → vue-zbXTTw4n.js} +1 -1
  84. package/apps/web/dist/assets/{xml-BgkAAaUc.js → xml-DWv3jkXo.js} +1 -1
  85. package/apps/web/dist/assets/{xsl-C4H21Kbc.js → xsl-BiggF1J3.js} +1 -1
  86. package/apps/web/dist/assets/{yaml-Ce1Tbjry.js → yaml-D9oC3m1d.js} +1 -1
  87. package/apps/web/dist/index.html +2 -2
  88. package/apps/web/dist/sw.js +1 -1
  89. package/package.json +5 -3
  90. package/apps/web/dist/assets/index-5vPpgW8A.css +0 -2
  91. package/apps/web/dist/assets/index-BJCHT3_X.js +0 -1148
@@ -1,25 +1,29 @@
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";
6
5
  import { WebSocketServer } from "ws";
7
6
  import os from "node:os";
8
7
  import { deleteSession, forkSession, getSessionMessages, getSubagentMessages, listSessions, query, renameSession } from "@anthropic-ai/claude-agent-sdk";
9
- import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
8
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
10
9
  import { execFile, spawn } from "node:child_process";
11
10
  import readline from "node:readline";
11
+ import { z } from "zod";
12
12
  import { Command } from "@commander-js/extra-typings";
13
13
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
14
14
  import { OpenCode } from "@opencode/client";
15
15
  import { ensure, headers } from "@opencode/client/service";
16
+ import { and, eq, inArray, sql } from "drizzle-orm";
17
+ import { sumBy } from "es-toolkit";
16
18
  import { DatabaseSync } from "node:sqlite";
19
+ import { drizzle } from "drizzle-orm/node-sqlite";
20
+ import { migrate } from "drizzle-orm/node-sqlite/migrator";
21
+ import { integer, snakeCase, text } from "drizzle-orm/sqlite-core";
17
22
  import { lstat, open, readFile, readdir, readlink, stat } from "node:fs/promises";
18
23
  import { promisify } from "node:util";
19
24
  import webPush from "web-push";
20
25
  import { spawn as spawn$1 } from "@lydell/node-pty";
21
26
  //#region ../core/src/protocol.ts
22
- const HOSTED_APP = "https://offsite.sayad.dev";
23
27
  function encodeFrame(kind, id, data) {
24
28
  const frame = new Uint8Array(5 + data.length);
25
29
  frame[0] = kind;
@@ -36,6 +40,27 @@ function decodeFrame(frame) {
36
40
  };
37
41
  }
38
42
  //#endregion
43
+ //#region src/constants.ts
44
+ const deprecatedModels = {
45
+ claude: [
46
+ "claude-sonnet-5",
47
+ "claude-opus-5",
48
+ "claude-fable-5",
49
+ "claude-haiku-4-5-20251001",
50
+ "claude-opus-4-8",
51
+ "claude-opus-4-7",
52
+ "claude-opus-4-6",
53
+ "claude-sonnet-4-6"
54
+ ],
55
+ codex: [],
56
+ opencode: []
57
+ };
58
+ const defaultModels = {
59
+ claude: "claude-opus-5-5",
60
+ codex: null,
61
+ opencode: null
62
+ };
63
+ //#endregion
39
64
  //#region src/lib/hub.ts
40
65
  const clients = /* @__PURE__ */ new Set();
41
66
  function publish$2(sessionId, event) {
@@ -45,305 +70,10 @@ function broadcast(event) {
45
70
  for (const client of clients) client.send(event);
46
71
  }
47
72
  //#endregion
48
- //#region src/args.ts
49
- let command = "web";
50
- let name = null;
51
- 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");
52
- program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
53
- program.command("serve").description("Starts only the server, without the web app").action(() => {
54
- command = "serve";
55
- });
56
- program.command("pair").description("Creates a pairing token for another browser and prints its links").option("--name <name>", "Name to recognize this browser by").action((options) => {
57
- command = "pair";
58
- name = options.name ?? null;
59
- });
60
- program.command("unpair").description("Picks paired browsers to unpair").action(() => {
61
- command = "unpair";
62
- });
63
- program.parse();
64
- const options = program.opts();
65
- const args = {
66
- ...options,
67
- dir: path.resolve(options.dir),
68
- command,
69
- name
70
- };
71
- const serverDir = path.join(import.meta.dirname, "..");
72
- //#endregion
73
- //#region src/storage/storage.ts
74
- const home = args.dir;
75
- const lockFile = path.join(home, "server.pid");
76
- mkdirSync(home, { recursive: true });
77
- function lockHome() {
78
- lock();
79
- process.on("exit", () => unlinkSync(lockFile));
80
- }
81
- function lock() {
82
- try {
83
- writeFileSync(lockFile, `${process.pid}\n`, { flag: "wx" });
84
- return;
85
- } catch (error) {
86
- if (!hasCode(error, "EEXIST")) throw error;
87
- }
88
- let content;
89
- let modified;
90
- try {
91
- content = readFileSync(lockFile, "utf8");
92
- modified = statSync(lockFile).mtimeMs;
93
- } catch (error) {
94
- if (hasCode(error, "ENOENT")) return lock();
95
- throw error;
96
- }
97
- const pid = Number(content);
98
- const validPid = Number.isInteger(pid) && pid > 0;
99
- const writtenThisBoot = modified > Date.now() - os.uptime() * 1e3;
100
- if (validPid && writtenThisBoot && pid !== process.pid && isRunning(pid)) {
101
- consola.error(`offsite is already running for ${home} (pid ${pid})`);
102
- process.exit(1);
103
- }
104
- unlinkSync(lockFile);
105
- lock();
106
- }
107
- function isRunning(pid) {
108
- try {
109
- process.kill(pid, 0);
110
- return true;
111
- } catch (error) {
112
- if (hasCode(error, "ESRCH")) return false;
113
- if (hasCode(error, "EPERM")) return true;
114
- throw error;
115
- }
116
- }
117
- function hasCode(error, code) {
118
- return error instanceof Error && "code" in error && error.code === code;
119
- }
120
- function readJson(name) {
121
- try {
122
- return JSON.parse(readFileSync(path.join(home, name), "utf8"));
123
- } catch (error) {
124
- if (error.code === "ENOENT") return null;
125
- throw error;
126
- }
127
- }
128
- function writeJson(name, value) {
129
- const file = path.join(home, name);
130
- mkdirSync(home, { recursive: true });
131
- writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
132
- renameSync(`${file}.tmp`, file);
133
- }
134
- //#endregion
135
- //#region src/storage/config.ts
136
- const version = "0.0.0";
137
- const configFile = path.join(home, "config.json");
138
- const defaults = {
139
- claudeBaseDir: path.join(os.homedir(), ".claude"),
140
- composer: {
141
- provider: "claude",
142
- claude: {
143
- accountId: null,
144
- model: "haiku",
145
- effort: "low",
146
- mode: "bypassPermissions",
147
- plan: false
148
- },
149
- codex: {
150
- accountId: null,
151
- model: null,
152
- effort: null,
153
- mode: "auto",
154
- plan: false
155
- },
156
- opencode: {
157
- accountId: "opencode",
158
- model: null,
159
- effort: null,
160
- mode: null,
161
- plan: false
162
- }
163
- },
164
- commitModels: {
165
- claude: "haiku",
166
- codex: "gpt-6-luna",
167
- opencode: null
168
- },
169
- terminal: {
170
- shell: {
171
- macos: null,
172
- linux: null,
173
- windows: null
174
- },
175
- behavior: {
176
- terminalIdentity: "offsite",
177
- shellIntegration: true
178
- }
179
- }
180
- };
181
- const retired = [
182
- "theme",
183
- "diff",
184
- "terminal.font",
185
- "terminal.cursor",
186
- "terminal.padding",
187
- "terminal.scrollback",
188
- "terminal.boldIsBright",
189
- "terminal.behavior.modernizeShiftEnter",
190
- "terminal.behavior.pasteBrowserImages",
191
- "terminal.behavior.captureShortcuts",
192
- "terminal.behavior.bellFlash",
193
- "terminal.behavior.bellSound",
194
- "terminal.behavior.clickToMoveCursor",
195
- "terminal.behavior.optionAsMeta",
196
- "terminal.behavior.confirmCloseBusyTab",
197
- "composer.accountId",
198
- "composer.model",
199
- "composer.effort",
200
- "composer.permissionMode"
201
- ];
202
- const choices = {
203
- "composer.provider": [
204
- "claude",
205
- "codex",
206
- "opencode"
207
- ],
208
- "composer.claude.effort": [
209
- "low",
210
- "medium",
211
- "high",
212
- "xhigh",
213
- "max"
214
- ],
215
- "composer.claude.mode": [
216
- "default",
217
- "acceptEdits",
218
- "bypassPermissions",
219
- "plan",
220
- "dontAsk",
221
- "auto"
222
- ],
223
- "composer.codex.mode": [
224
- "read-only",
225
- "auto",
226
- "full-access"
227
- ],
228
- "terminal.behavior.terminalIdentity": [
229
- "offsite",
230
- "ghostty",
231
- "iterm2"
232
- ]
233
- };
234
- const nullable = [
235
- "composer.claude.accountId",
236
- "composer.claude.model",
237
- "composer.claude.effort",
238
- "composer.claude.mode",
239
- "composer.codex.accountId",
240
- "composer.codex.model",
241
- "composer.codex.effort",
242
- "composer.codex.mode",
243
- "composer.opencode.accountId",
244
- "composer.opencode.model",
245
- "composer.opencode.effort",
246
- "composer.opencode.mode",
247
- "commitModels.claude",
248
- "commitModels.codex",
249
- "commitModels.opencode",
250
- "terminal.shell.macos",
251
- "terminal.shell.linux",
252
- "terminal.shell.windows"
253
- ];
254
- function platform() {
255
- if (process.platform === "darwin") return "macos";
256
- if (process.platform === "linux") return "linux";
257
- if (process.platform === "win32") return "windows";
258
- throw new Error(`unsupported platform ${process.platform}`);
259
- }
260
- function defaultShell() {
261
- if (process.platform === "win32") return process.env.COMSPEC ?? "cmd.exe";
262
- return os.userInfo().shell ?? "/bin/sh";
263
- }
264
- function configInfo() {
265
- return {
266
- config: readConfig(),
267
- defaults,
268
- platform: platform(),
269
- version,
270
- defaultShell: defaultShell()
271
- };
272
- }
273
- function readConfig() {
274
- let user;
275
- try {
276
- user = readJson("config.json");
277
- } catch (error) {
278
- console.warn(`invalid ${configFile}, using the default config: ${error}`);
279
- return structuredClone(defaults);
280
- }
281
- if (user === null) return structuredClone(defaults);
282
- if (!isObject(user)) {
283
- console.warn(`${configFile} is not an object, using the default config`);
284
- return structuredClone(defaults);
285
- }
286
- let config = structuredClone(defaults);
287
- for (const [key, value] of Object.entries(user)) config = merge(config, [key], value);
288
- return config;
289
- }
290
- function saveConfig(value) {
291
- const error = invalid(value, defaults, []);
292
- if (error !== null) throw new Error(error);
293
- writeJson("config.json", value);
294
- process.env.CLAUDE_CONFIG_DIR = value.claudeBaseDir;
295
- broadcast({
296
- type: "config",
297
- config: value
298
- });
299
- return value;
300
- }
301
- function merge(config, keys, user) {
302
- if (retired.includes(keys.join("."))) return config;
303
- if (isObject(keys.reduce((value, key) => isObject(value) ? value[key] : void 0, config)) && isObject(user)) {
304
- let next = config;
305
- for (const [key, value] of Object.entries(user)) next = merge(next, [...keys, key], value);
306
- return next;
307
- }
308
- const candidate = structuredClone(config);
309
- const parent = keys.slice(0, -1).reduce((value, key) => value[key], candidate);
310
- parent[keys[keys.length - 1]] = user;
311
- const error = invalid(candidate, defaults, []);
312
- if (error === null) return candidate;
313
- console.warn(`invalid ${keys.join(".")} in ${configFile}, using its default: ${error}`);
314
- return config;
315
- }
316
- function invalid(value, template, keys) {
317
- const name = keys.join(".");
318
- if (isObject(template)) {
319
- if (!isObject(value)) return `${name || "config"} must be an object`;
320
- for (const key of Object.keys(value)) if (!(key in template)) return `unknown setting ${[...keys, key].join(".")}`;
321
- for (const key of Object.keys(template)) {
322
- if (!(key in value)) return `missing setting ${[...keys, key].join(".")}`;
323
- const error = invalid(value[key], template[key], [...keys, key]);
324
- if (error !== null) return error;
325
- }
326
- return null;
327
- }
328
- if (nullable.includes(name) && value === null) return null;
329
- if (name === "claudeBaseDir") return typeof value === "string" && path.isAbsolute(value) ? null : "claudeBaseDir must be an absolute path";
330
- if (name in choices) {
331
- if (choices[name].includes(value)) return null;
332
- return `${name} must be one of ${choices[name].join(", ")}`;
333
- }
334
- if (template === null) {
335
- if (typeof value === "string") return null;
336
- return `${name} must be text or null`;
337
- }
338
- if (typeof value !== typeof template) return `${name} must be a ${typeof template}`;
339
- return null;
340
- }
341
- function isObject(value) {
342
- return typeof value === "object" && value !== null && !Array.isArray(value);
343
- }
344
- //#endregion
345
73
  //#region src/lib/codex/app-server.ts
346
74
  const IDLE_LIMIT$3 = 18e5;
75
+ const START_TIMEOUT = 15e3;
76
+ const REQUEST_TIMEOUT = 3e4;
347
77
  const codexBaseDir = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
348
78
  const servers = /* @__PURE__ */ new Map();
349
79
  function appServer(home) {
@@ -356,6 +86,9 @@ function appServer(home) {
356
86
  function stopAppServers() {
357
87
  for (const server of servers.values()) server.stop();
358
88
  }
89
+ function stopAppServer(home) {
90
+ servers.get(home)?.stop();
91
+ }
359
92
  function findCodex() {
360
93
  const name = process.platform === "win32" ? "codex.exe" : "codex";
361
94
  for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
@@ -395,19 +128,22 @@ var AppServer = class {
395
128
  clientInfo: {
396
129
  name: "offsite",
397
130
  title: "Offsite",
398
- version
131
+ version: "0.0.0"
399
132
  },
400
133
  capabilities: {
401
134
  experimentalApi: true,
402
135
  requestAttestation: false
403
136
  }
404
- }).then(() => this.write({ method: "initialized" }));
137
+ }, START_TIMEOUT).then(() => this.write({ method: "initialized" }), (error) => {
138
+ this.child.kill();
139
+ throw error;
140
+ });
405
141
  this.armIdleTimer();
406
142
  }
407
143
  async request(method, params) {
408
144
  await this.ready;
409
145
  this.armIdleTimer();
410
- return await this.call(method, params);
146
+ return await this.call(method, params, REQUEST_TIMEOUT);
411
147
  }
412
148
  reply(id, result) {
413
149
  this.write({
@@ -429,14 +165,26 @@ var AppServer = class {
429
165
  if (servers.get(this.home) === this) servers.delete(this.home);
430
166
  this.child.stdin.end();
431
167
  }
432
- call(method, params) {
168
+ call(method, params, timeout) {
433
169
  if (this.exited !== null) return Promise.reject(this.exited);
434
170
  this.next += 1;
435
171
  const id = this.next;
436
- const result = new Promise((resolve, reject) => this.waiting.set(id, {
437
- resolve,
438
- reject
439
- }));
172
+ const result = new Promise((resolve, reject) => {
173
+ const timer = setTimeout(() => {
174
+ this.waiting.delete(id);
175
+ reject(/* @__PURE__ */ new Error(`Codex did not answer ${method} within ${timeout / 1e3} seconds`));
176
+ }, timeout);
177
+ this.waiting.set(id, {
178
+ resolve: (value) => {
179
+ clearTimeout(timer);
180
+ resolve(value);
181
+ },
182
+ reject: (error) => {
183
+ clearTimeout(timer);
184
+ reject(error);
185
+ }
186
+ });
187
+ });
440
188
  this.write({
441
189
  id,
442
190
  method,
@@ -617,79 +365,207 @@ function isMirror(dir) {
617
365
  }
618
366
  }
619
367
  //#endregion
620
- //#region src/lib/codex/items.ts
621
- const statuses = {
622
- inProgress: "running",
623
- completed: "completed",
624
- failed: "failed",
625
- declined: "stopped",
626
- interrupted: "stopped"
627
- };
628
- function turnItems(turns, parent) {
629
- return turns.flatMap((turn) => [...turn.items.flatMap((item) => toItems$1(turn.id, item, parent, turn.startedAt === null ? null : turn.startedAt * 1e3)), ...turn.status === "failed" && turn.error !== null ? [errorItem(`${turn.id}:error`, turn.error.message)] : []]);
630
- }
631
- function itemId(turnId, id) {
632
- return `${turnId}:${id}`;
368
+ //#region src/lib/claude/content.ts
369
+ const imageTypes = [
370
+ "image/png",
371
+ "image/jpeg",
372
+ "image/gif",
373
+ "image/webp"
374
+ ];
375
+ function toMessage(type, item, time) {
376
+ return {
377
+ type,
378
+ uuid: item.uuid,
379
+ time,
380
+ content: isRecord(item.message) ? toBlocks(item.message.content, answersOf(item.tool_use_result)) : [],
381
+ parent: item.parent_tool_use_id
382
+ };
633
383
  }
634
- function turnOf(itemId) {
635
- const index = itemId.indexOf(":");
636
- if (index < 0) throw new Error(`${itemId} is not a Codex item`);
637
- return itemId.slice(0, index);
384
+ function toUserInput(prompt, uuid, priority) {
385
+ for (const image of prompt.images) if (!imageTypes.includes(image.mediaType)) throw new Error(`${image.mediaType} images are not supported`);
386
+ return {
387
+ type: "user",
388
+ message: {
389
+ role: "user",
390
+ content: [...prompt.images.map((image) => ({
391
+ type: "image",
392
+ source: {
393
+ type: "base64",
394
+ media_type: image.mediaType,
395
+ data: image.data
396
+ }
397
+ })), ...prompt.text === "" ? [] : [{
398
+ type: "text",
399
+ text: prompt.text
400
+ }]]
401
+ },
402
+ parent_tool_use_id: null,
403
+ uuid,
404
+ ...priority === void 0 ? {} : { priority }
405
+ };
638
406
  }
639
- function errorItem(id, text) {
407
+ function promptMessage(prompt, uuid) {
640
408
  return {
641
- id,
642
- parent: null,
409
+ type: "user",
410
+ uuid,
643
411
  time: Date.now(),
644
- kind: "error",
645
- text
412
+ content: [...prompt.images.map((image) => ({
413
+ type: "image",
414
+ ...image
415
+ })), ...prompt.text === "" ? [] : [{
416
+ type: "text",
417
+ text: prompt.text
418
+ }]],
419
+ parent: null
646
420
  };
647
421
  }
648
- function toItems$1(turnId, item, parent, time) {
649
- const base = {
650
- id: itemId(turnId, item.id),
651
- parent,
652
- time
422
+ function notice(text) {
423
+ return {
424
+ type: "notice",
425
+ uuid: randomUUID(),
426
+ time: Date.now(),
427
+ text,
428
+ isError: true
653
429
  };
654
- switch (item.type) {
655
- case "userMessage": return [{
656
- ...base,
657
- kind: "user",
658
- text: item.content.flatMap((input) => userText$1(input)).join("\n").trim(),
659
- images: item.content.flatMap((input) => userImages(input)),
660
- format: "text"
661
- }];
662
- case "agentMessage": return item.text.trim() === "" ? [] : [{
663
- ...base,
664
- kind: "assistant",
665
- text: item.text,
666
- message: base.id
667
- }];
668
- case "reasoning": {
669
- const text = [...item.summary, ...item.content].join("\n\n").trim();
670
- return text === "" ? [] : [{
671
- ...base,
672
- kind: "reasoning",
673
- text
674
- }];
675
- }
676
- case "plan": return [{
677
- ...base,
678
- kind: "plan",
679
- plan: item.text
680
- }];
681
- case "commandExecution": return [{
682
- ...base,
683
- kind: "command",
684
- command: item.command,
685
- description: "",
686
- output: item.aggregatedOutput ?? "",
687
- status: statuses[item.status],
688
- background: null
689
- }];
690
- case "fileChange": return item.changes.map((change, index) => ({
691
- ...base,
692
- id: `${base.id}:${index}`,
430
+ }
431
+ function contextTokens(message) {
432
+ if (!isRecord(message) || !isRecord(message.usage)) return null;
433
+ const usage = message.usage;
434
+ return [
435
+ usage.input_tokens,
436
+ usage.cache_creation_input_tokens,
437
+ usage.cache_read_input_tokens,
438
+ usage.output_tokens
439
+ ].reduce((total, value) => total + (typeof value === "number" ? value : 0), 0);
440
+ }
441
+ function answersOf(output) {
442
+ if (!isRecord(output) || !isRecord(output.answers)) return null;
443
+ const answers = {};
444
+ for (const [question, answer] of Object.entries(output.answers)) {
445
+ if (typeof answer !== "string") return null;
446
+ answers[question] = answer;
447
+ }
448
+ return answers;
449
+ }
450
+ function toBlocks(content, answers) {
451
+ if (typeof content === "string") return [{
452
+ type: "text",
453
+ text: content
454
+ }];
455
+ if (!Array.isArray(content)) return [];
456
+ return content.flatMap((block) => {
457
+ if (!isRecord(block)) return [];
458
+ switch (block.type) {
459
+ case "text": return typeof block.text === "string" && !block.text.startsWith("<system-reminder>") && !block.text.startsWith("<local-command-caveat>") ? [{
460
+ type: "text",
461
+ text: block.text
462
+ }] : [];
463
+ case "thinking": return typeof block.thinking === "string" && block.thinking !== "" ? [{
464
+ type: "thinking",
465
+ thinking: block.thinking
466
+ }] : [];
467
+ case "image": {
468
+ const source = block.source;
469
+ return isRecord(source) && source.type === "base64" && typeof source.media_type === "string" && typeof source.data === "string" ? [{
470
+ type: "image",
471
+ mediaType: source.media_type,
472
+ data: source.data
473
+ }] : [];
474
+ }
475
+ case "tool_use": return typeof block.id === "string" && typeof block.name === "string" && isRecord(block.input) ? [{
476
+ type: "tool_use",
477
+ id: block.id,
478
+ name: block.name,
479
+ input: block.input
480
+ }] : [];
481
+ case "tool_result": return typeof block.tool_use_id === "string" ? [{
482
+ type: "tool_result",
483
+ toolUseId: block.tool_use_id,
484
+ content: toBlocks(block.content, null).filter((item) => item.type === "text"),
485
+ isError: block.is_error === true,
486
+ answers
487
+ }] : [];
488
+ default: return [];
489
+ }
490
+ });
491
+ }
492
+ function isRecord(value) {
493
+ return typeof value === "object" && value !== null && !Array.isArray(value);
494
+ }
495
+ //#endregion
496
+ //#region src/lib/codex/items.ts
497
+ const statuses$1 = {
498
+ inProgress: "running",
499
+ completed: "completed",
500
+ failed: "failed",
501
+ declined: "stopped",
502
+ interrupted: "stopped"
503
+ };
504
+ function turnItems(turns, parent) {
505
+ return turns.flatMap((turn) => [...turn.items.flatMap((item) => toItems$1(turn.id, item, parent, turn.startedAt === null ? null : turn.startedAt * 1e3)), ...turn.status === "failed" && turn.error !== null ? [errorItem(`${turn.id}:error`, turn.error.message)] : []]);
506
+ }
507
+ function itemId(turnId, id) {
508
+ return `${turnId}:${id}`;
509
+ }
510
+ function turnOf(itemId) {
511
+ const index = itemId.indexOf(":");
512
+ if (index < 0) throw new Error(`${itemId} is not a Codex item`);
513
+ return itemId.slice(0, index);
514
+ }
515
+ function errorItem(id, text) {
516
+ return {
517
+ id,
518
+ parent: null,
519
+ time: Date.now(),
520
+ kind: "error",
521
+ text
522
+ };
523
+ }
524
+ function toItems$1(turnId, item, parent, time) {
525
+ const base = {
526
+ id: itemId(turnId, item.id),
527
+ parent,
528
+ time
529
+ };
530
+ switch (item.type) {
531
+ case "userMessage": return [{
532
+ ...base,
533
+ kind: "user",
534
+ text: item.content.flatMap(userText$1).join("\n").trim(),
535
+ images: item.content.flatMap(userImages),
536
+ format: "text"
537
+ }];
538
+ case "agentMessage": return item.text.trim() === "" ? [] : [{
539
+ ...base,
540
+ kind: "assistant",
541
+ text: item.text,
542
+ message: base.id
543
+ }];
544
+ case "reasoning": {
545
+ const text = [...item.summary, ...item.content].join("\n\n").trim();
546
+ return text === "" ? [] : [{
547
+ ...base,
548
+ kind: "reasoning",
549
+ text
550
+ }];
551
+ }
552
+ case "plan": return [{
553
+ ...base,
554
+ kind: "plan",
555
+ plan: item.text
556
+ }];
557
+ case "commandExecution": return [{
558
+ ...base,
559
+ kind: "command",
560
+ command: item.command,
561
+ description: "",
562
+ output: item.aggregatedOutput ?? "",
563
+ status: statuses$1[item.status],
564
+ background: null
565
+ }];
566
+ case "fileChange": return item.changes.map((change, index) => ({
567
+ ...base,
568
+ id: `${base.id}:${index}`,
693
569
  kind: "file_change",
694
570
  path: change.path,
695
571
  diffs: change.kind.type === "add" ? [{
@@ -700,7 +576,7 @@ function toItems$1(turnId, item, parent, time) {
700
576
  after: ""
701
577
  }] : hunks(change.diff),
702
578
  output: "",
703
- status: statuses[item.status]
579
+ status: statuses$1[item.status]
704
580
  }));
705
581
  case "mcpToolCall": return [{
706
582
  ...base,
@@ -708,7 +584,7 @@ function toItems$1(turnId, item, parent, time) {
708
584
  name: `${item.server}.${item.tool}`,
709
585
  input: record$1(item.arguments),
710
586
  output: item.error?.message ?? (item.result === null ? "" : contentText$1(item.result.content)),
711
- status: statuses[item.status]
587
+ status: statuses$1[item.status]
712
588
  }];
713
589
  case "dynamicToolCall": return [{
714
590
  ...base,
@@ -716,7 +592,7 @@ function toItems$1(turnId, item, parent, time) {
716
592
  name: item.tool,
717
593
  input: record$1(item.arguments),
718
594
  output: contentText$1(item.contentItems ?? []),
719
- status: statuses[item.status]
595
+ status: statuses$1[item.status]
720
596
  }];
721
597
  case "collabAgentToolCall":
722
598
  if (item.tool === "spawnAgent") return [{
@@ -725,14 +601,14 @@ function toItems$1(turnId, item, parent, time) {
725
601
  agent: item.model ?? "Agent",
726
602
  description: item.prompt ?? "",
727
603
  agentId: item.receiverThreadIds[0] ?? null,
728
- status: statuses[item.status]
604
+ status: statuses$1[item.status]
729
605
  }];
730
606
  return [{
731
607
  ...base,
732
608
  kind: "agent_message",
733
609
  target: item.receiverThreadIds[0] ?? null,
734
610
  summary: item.prompt ?? item.tool,
735
- status: statuses[item.status]
611
+ status: statuses$1[item.status]
736
612
  }];
737
613
  case "webSearch": return [{
738
614
  ...base,
@@ -813,53 +689,296 @@ function hunks(diff) {
813
689
  return result;
814
690
  }
815
691
  function contentText$1(content) {
816
- return content.map((block) => typeof block === "object" && block !== null && "text" in block && typeof block.text === "string" ? block.text : JSON.stringify(block)).join("\n");
692
+ return content.map((block) => isRecord(block) && typeof block.text === "string" ? block.text : JSON.stringify(block)).join("\n");
817
693
  }
818
694
  function record$1(value) {
819
- if (typeof value === "object" && value !== null && !Array.isArray(value)) return value;
820
- return { value };
695
+ return isRecord(value) ? value : { value };
821
696
  }
822
697
  //#endregion
823
- //#region src/lib/live.ts
824
- const runs = /* @__PURE__ */ new Map();
825
- function activity() {
826
- const result = {};
827
- for (const run of runs.values()) result[run.sessionId] = {
828
- running: run.running,
829
- waiting: run.pending.size > 0,
830
- prompted: run.prompted,
831
- tasks: run.tasks
832
- };
833
- return result;
698
+ //#region src/args.ts
699
+ let command = "web";
700
+ let name = null;
701
+ 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");
702
+ program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
703
+ program.command("serve").description("Starts only the server, without the web app").action(() => {
704
+ command = "serve";
705
+ });
706
+ program.command("pair").description("Creates a pairing token for another browser and prints its links").option("--name <name>", "Name to recognize this browser by").action((options) => {
707
+ command = "pair";
708
+ name = options.name ?? null;
709
+ });
710
+ program.command("unpair").description("Picks paired browsers to unpair").action(() => {
711
+ command = "unpair";
712
+ });
713
+ program.parse();
714
+ const options = program.opts();
715
+ const args = {
716
+ ...options,
717
+ dir: path.resolve(options.dir),
718
+ command,
719
+ name
720
+ };
721
+ const serverDir = path.join(import.meta.dirname, "..");
722
+ //#endregion
723
+ //#region src/storage/storage.ts
724
+ const home = args.dir;
725
+ const lockFile = path.join(home, "server.pid");
726
+ mkdirSync(home, { recursive: true });
727
+ function lockHome() {
728
+ lock();
729
+ process.on("exit", () => unlinkSync(lockFile));
834
730
  }
835
- function broadcastActivity() {
836
- broadcast({
837
- type: "activity",
838
- activity: activity()
839
- });
731
+ function lock() {
732
+ try {
733
+ writeFileSync(lockFile, `${process.pid}\n`, { flag: "wx" });
734
+ return;
735
+ } catch (error) {
736
+ if (!hasCode(error, "EEXIST")) throw error;
737
+ }
738
+ let content;
739
+ let modified;
740
+ try {
741
+ content = readFileSync(lockFile, "utf8");
742
+ modified = statSync(lockFile).mtimeMs;
743
+ } catch (error) {
744
+ if (hasCode(error, "ENOENT")) return lock();
745
+ throw error;
746
+ }
747
+ const pid = Number(content);
748
+ const validPid = Number.isInteger(pid) && pid > 0;
749
+ const writtenThisBoot = modified > Date.now() - os.uptime() * 1e3;
750
+ if (validPid && writtenThisBoot && pid !== process.pid && isRunning(pid)) {
751
+ consola.error(`offsite is already running for ${home} (pid ${pid})`);
752
+ process.exit(1);
753
+ }
754
+ unlinkSync(lockFile);
755
+ lock();
756
+ }
757
+ function isRunning(pid) {
758
+ try {
759
+ process.kill(pid, 0);
760
+ return true;
761
+ } catch (error) {
762
+ if (hasCode(error, "ESRCH")) return false;
763
+ if (hasCode(error, "EPERM")) return true;
764
+ throw error;
765
+ }
766
+ }
767
+ function hasCode(error, code) {
768
+ return error instanceof Error && "code" in error && error.code === code;
769
+ }
770
+ function readJson(name) {
771
+ try {
772
+ return JSON.parse(readFileSync(path.join(home, name), "utf8"));
773
+ } catch (error) {
774
+ if (error.code === "ENOENT") return null;
775
+ throw error;
776
+ }
777
+ }
778
+ function writeJson(name, value) {
779
+ const file = path.join(home, name);
780
+ mkdirSync(home, { recursive: true });
781
+ writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
782
+ renameSync(`${file}.tmp`, file);
840
783
  }
841
784
  //#endregion
842
- //#region src/lib/codex/requests.ts
843
- function toRequest$1(requestId, method, params, items) {
844
- const turnId = params.turnId;
845
- switch (method) {
846
- case "item/commandExecution/requestApproval": return {
847
- id: requestId,
848
- kind: "approval",
849
- title: params.reason ?? "Run this command?",
850
- canAlwaysAllow: true,
851
- item: items.get(itemId(turnId, params.itemId)) ?? {
852
- id: itemId(turnId, params.itemId),
853
- parent: null,
854
- time: Date.now(),
855
- kind: "command",
856
- command: params.command ?? "",
857
- description: "",
858
- output: "",
859
- status: "running",
860
- background: null
861
- }
862
- };
785
+ //#region src/storage/config.ts
786
+ const configFile = path.join(home, "config.json");
787
+ const modelSetting = z.strictObject({
788
+ hidden: z.boolean(),
789
+ effort: z.string().nullable()
790
+ });
791
+ const configSchema = z.strictObject({
792
+ claudeBaseDir: z.string().refine((value) => path.isAbsolute(value), "must be an absolute path").default(path.join(os.homedir(), ".claude")),
793
+ enabledProviders: z.strictObject({
794
+ claude: z.boolean().default(true),
795
+ codex: z.boolean().default(true),
796
+ opencode: z.boolean().default(true)
797
+ }).prefault({}),
798
+ composer: z.strictObject({
799
+ provider: z.enum([
800
+ "claude",
801
+ "codex",
802
+ "opencode"
803
+ ]).default("claude"),
804
+ claude: z.strictObject({
805
+ accountId: z.string().nullable().default(null),
806
+ model: z.string().nullable().default(null),
807
+ effort: z.enum([
808
+ "low",
809
+ "medium",
810
+ "high",
811
+ "xhigh",
812
+ "max"
813
+ ]).nullable().default(null),
814
+ mode: z.enum([
815
+ "default",
816
+ "acceptEdits",
817
+ "bypassPermissions",
818
+ "plan",
819
+ "dontAsk",
820
+ "auto"
821
+ ]).nullable().default("bypassPermissions"),
822
+ plan: z.boolean().default(false)
823
+ }).prefault({}),
824
+ codex: z.strictObject({
825
+ accountId: z.string().nullable().default(null),
826
+ model: z.string().nullable().default(null),
827
+ effort: z.string().nullable().default(null),
828
+ mode: z.enum([
829
+ "read-only",
830
+ "auto",
831
+ "full-access"
832
+ ]).nullable().default("auto"),
833
+ plan: z.boolean().default(false)
834
+ }).prefault({}),
835
+ opencode: z.strictObject({
836
+ accountId: z.null().default(null),
837
+ model: z.string().nullable().default(null),
838
+ effort: z.string().nullable().default(null),
839
+ mode: z.string().nullable().default(null),
840
+ plan: z.boolean().default(false)
841
+ }).prefault({})
842
+ }).prefault({}),
843
+ commitMessage: z.strictObject({
844
+ provider: z.enum([
845
+ "claude",
846
+ "codex",
847
+ "opencode"
848
+ ]).nullable().default(null),
849
+ accountId: z.string().nullable().default(null),
850
+ model: z.string().nullable().default(null)
851
+ }).prefault({}),
852
+ models: z.strictObject({
853
+ claude: z.record(z.string(), modelSetting).default({}),
854
+ codex: z.record(z.string(), modelSetting).default({}),
855
+ opencode: z.record(z.string(), modelSetting).default({})
856
+ }).prefault({}),
857
+ delivery: z.enum(["queue", "steer"]).default("steer"),
858
+ autoResolve: z.strictObject({
859
+ questions: z.number().positive().nullable().default(3),
860
+ permissions: z.number().positive().nullable().default(1),
861
+ permissionDecision: z.enum(["allow", "deny"]).default("allow")
862
+ }).prefault({}),
863
+ terminal: z.strictObject({
864
+ shell: z.strictObject({
865
+ macos: z.string().nullable().default(null),
866
+ linux: z.string().nullable().default(null),
867
+ windows: z.string().nullable().default(null)
868
+ }).prefault({}),
869
+ behavior: z.strictObject({
870
+ terminalIdentity: z.enum([
871
+ "offsite",
872
+ "ghostty",
873
+ "iterm2"
874
+ ]).default("offsite"),
875
+ shellIntegration: z.boolean().default(true)
876
+ }).prefault({})
877
+ }).prefault({})
878
+ });
879
+ const defaults = configSchema.parse({});
880
+ function platform() {
881
+ if (process.platform === "darwin") return "macos";
882
+ if (process.platform === "linux") return "linux";
883
+ if (process.platform === "win32") return "windows";
884
+ throw new Error(`unsupported platform ${process.platform}`);
885
+ }
886
+ function defaultShell() {
887
+ if (process.platform === "win32") return process.env.COMSPEC ?? "cmd.exe";
888
+ return os.userInfo().shell ?? "/bin/sh";
889
+ }
890
+ function configInfo() {
891
+ return {
892
+ config: readConfig(),
893
+ defaults,
894
+ platform: platform(),
895
+ defaultShell: defaultShell()
896
+ };
897
+ }
898
+ function readConfig() {
899
+ let user;
900
+ try {
901
+ user = readJson("config.json");
902
+ } catch (error) {
903
+ console.warn(`invalid ${configFile}, using the default config: ${error}`);
904
+ return structuredClone(defaults);
905
+ }
906
+ if (user === null) return structuredClone(defaults);
907
+ const result = configSchema.safeParse(user);
908
+ if (!result.success) {
909
+ console.warn(`invalid ${configFile}, using the default config:\n${z.prettifyError(result.error)}`);
910
+ return structuredClone(defaults);
911
+ }
912
+ return result.data;
913
+ }
914
+ function saveConfig(value) {
915
+ const result = configSchema.safeParse(value);
916
+ if (!result.success) throw new Error(z.prettifyError(result.error));
917
+ writeJson("config.json", result.data);
918
+ process.env.CLAUDE_CONFIG_DIR = result.data.claudeBaseDir;
919
+ broadcast({
920
+ type: "config",
921
+ config: result.data
922
+ });
923
+ return result.data;
924
+ }
925
+ //#endregion
926
+ //#region src/lib/live.ts
927
+ const runs = /* @__PURE__ */ new Map();
928
+ function activity() {
929
+ const result = {};
930
+ for (const run of runs.values()) result[run.sessionId] = {
931
+ running: run.running,
932
+ waiting: run.pending.size > 0,
933
+ prompted: run.prompted,
934
+ tasks: run.tasks
935
+ };
936
+ return result;
937
+ }
938
+ function broadcastActivity() {
939
+ broadcast({
940
+ type: "activity",
941
+ activity: activity()
942
+ });
943
+ }
944
+ function scheduleAutoResolve(sessionId, request) {
945
+ if (request.kind === "plan") return;
946
+ const settings = readConfig().autoResolve;
947
+ const minutes = request.kind === "question" ? settings.questions : settings.permissions;
948
+ if (minutes === null) return;
949
+ setTimeout(async () => {
950
+ const run = runs.get(sessionId);
951
+ if (run === void 0) return;
952
+ try {
953
+ if (request.kind === "question") await run.respond(request.id, "allow", {});
954
+ else await run.respond(request.id, readConfig().autoResolve.permissionDecision, null);
955
+ } catch (error) {
956
+ console.warn(`could not auto-resolve ${request.id}: ${error}`);
957
+ }
958
+ }, minutes * 6e4);
959
+ }
960
+ //#endregion
961
+ //#region src/lib/codex/requests.ts
962
+ function toRequest$1(requestId, method, params, items) {
963
+ const turnId = params.turnId;
964
+ switch (method) {
965
+ case "item/commandExecution/requestApproval": return {
966
+ id: requestId,
967
+ kind: "approval",
968
+ title: params.reason ?? "Run this command?",
969
+ canAlwaysAllow: true,
970
+ item: items.get(itemId(turnId, params.itemId)) ?? {
971
+ id: itemId(turnId, params.itemId),
972
+ parent: null,
973
+ time: Date.now(),
974
+ kind: "command",
975
+ command: params.command ?? "",
976
+ description: "",
977
+ output: "",
978
+ status: "running",
979
+ background: null
980
+ }
981
+ };
863
982
  case "item/fileChange/requestApproval": return {
864
983
  id: requestId,
865
984
  kind: "approval",
@@ -975,6 +1094,7 @@ var CodexRun = class CodexRun {
975
1094
  prompted = false;
976
1095
  backgroundTasks = 0;
977
1096
  tasks = {};
1097
+ activeAt = Date.now();
978
1098
  model;
979
1099
  close;
980
1100
  turnId = null;
@@ -1079,11 +1199,12 @@ var CodexRun = class CodexRun {
1079
1199
  this.server.request("thread/unsubscribe", { threadId: this.sessionId }).catch((error) => console.warn(`could not unload Codex thread: ${error}`)).finally(() => this.end());
1080
1200
  }
1081
1201
  exited() {
1082
- this.put(errorItem(randomUUID(), "Codex stopped"));
1202
+ this.put(errorItem(randomUUID(), this.pending.size === 0 ? "Codex stopped" : "Codex stopped, its open requests expired"));
1083
1203
  if (runs.get(this.sessionId) === this) runs.delete(this.sessionId);
1084
1204
  this.end();
1085
1205
  }
1086
1206
  notify(method, params) {
1207
+ this.activeAt = Date.now();
1087
1208
  switch (method) {
1088
1209
  case "turn/started": {
1089
1210
  const turn = params.turn;
@@ -1181,6 +1302,7 @@ var CodexRun = class CodexRun {
1181
1302
  request
1182
1303
  });
1183
1304
  broadcastActivity();
1305
+ scheduleAutoResolve(this.sessionId, request);
1184
1306
  }
1185
1307
  async startTurn(prompt) {
1186
1308
  clearTimeout(this.idleTimer);
@@ -1438,445 +1560,120 @@ async function readThread(threadId) {
1438
1560
  })).thread;
1439
1561
  }
1440
1562
  //#endregion
1441
- //#region src/storage/db.ts
1442
- const db = new DatabaseSync(path.join(home, "offsite.db"));
1443
- db.exec(`
1444
- CREATE TABLE IF NOT EXISTS projects (
1445
- path TEXT PRIMARY KEY,
1446
- name TEXT NOT NULL,
1447
- logo TEXT
1448
- )
1449
- `);
1450
- db.exec("DROP TABLE IF EXISTS prompts");
1451
- db.exec(`
1452
- CREATE TABLE IF NOT EXISTS sessions (
1453
- session_id TEXT PRIMARY KEY,
1454
- interacted_at INTEGER,
1455
- status TEXT NOT NULL CHECK (status IN ('active', 'pinned', 'minimized'))
1456
- )
1457
- `);
1458
- //#endregion
1459
- //#region src/services/projects.ts
1460
- const LOGO_TYPES = [
1461
- "image/png",
1462
- "image/jpeg",
1463
- "image/gif",
1464
- "image/webp",
1465
- "image/svg+xml",
1466
- "image/x-icon",
1467
- "image/vnd.microsoft.icon"
1468
- ];
1469
- const LOGO_LIMIT = 524288;
1470
- function projects$1() {
1471
- return db.prepare("SELECT path, name, logo FROM projects").all().map(toProject);
1472
- }
1473
- async function addProject(input) {
1474
- const folder = path.resolve(expandHome(input.trim()));
1475
- if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
1476
- db.prepare("INSERT OR IGNORE INTO projects (path, name, logo) VALUES (?, ?, NULL)").run(folder, path.basename(folder) || folder);
1477
- publish$1();
1478
- const project = projects$1().find((item) => item.path === folder);
1479
- if (project === void 0) throw new Error(`Could not add ${folder}`);
1480
- return project;
1481
- }
1482
- function updateProject(folder, patch) {
1483
- if (patch.name !== void 0) {
1484
- const name = patch.name.trim();
1485
- if (name === "") throw new Error("Project name cannot be empty");
1486
- db.prepare("UPDATE projects SET name = ? WHERE path = ?").run(name, folder);
1487
- }
1488
- if (patch.logo !== void 0) {
1489
- if (patch.logo !== null) checkLogo(patch.logo);
1490
- db.prepare("UPDATE projects SET logo = ? WHERE path = ?").run(patch.logo, folder);
1563
+ //#region src/lib/opencode/items.ts
1564
+ const toolStatuses = {
1565
+ streaming: "running",
1566
+ running: "running",
1567
+ completed: "completed",
1568
+ error: "failed"
1569
+ };
1570
+ const todoStatuses$1 = {
1571
+ pending: "pending",
1572
+ in_progress: "running",
1573
+ completed: "completed",
1574
+ cancelled: "completed"
1575
+ };
1576
+ function messageItems(message, parent) {
1577
+ const base = {
1578
+ id: message.id,
1579
+ parent,
1580
+ time: message.time.created
1581
+ };
1582
+ switch (message.type) {
1583
+ case "user": return [{
1584
+ ...base,
1585
+ kind: "user",
1586
+ text: message.text,
1587
+ images: (message.files ?? []).filter((file) => file.mime.startsWith("image/")).map((file) => ({
1588
+ mediaType: file.mime,
1589
+ data: file.data
1590
+ })),
1591
+ format: "text"
1592
+ }];
1593
+ case "skill": return [{
1594
+ ...base,
1595
+ kind: "skill",
1596
+ skill: message.name,
1597
+ output: "",
1598
+ status: "completed"
1599
+ }];
1600
+ case "shell": return [{
1601
+ ...base,
1602
+ kind: "command",
1603
+ command: message.command,
1604
+ description: "",
1605
+ output: message.output?.output ?? "",
1606
+ status: message.status === "running" ? "running" : message.status === "exited" ? message.exit === 0 ? "completed" : "failed" : "stopped",
1607
+ background: null
1608
+ }];
1609
+ case "assistant": return message.content.flatMap((part, index) => partItems(message, part, index, parent));
1610
+ case "compaction": return message.status === "failed" ? [{
1611
+ ...errorItem(message.id, message.error.message),
1612
+ parent
1613
+ }] : [{
1614
+ ...base,
1615
+ kind: "compaction"
1616
+ }];
1617
+ case "idle": return message.outcome === "failed" && message.error !== void 0 ? [{
1618
+ ...errorItem(message.id, message.error.message),
1619
+ parent
1620
+ }] : [];
1621
+ case "agent-switched":
1622
+ case "model-switched":
1623
+ case "location-switched":
1624
+ case "synthetic":
1625
+ case "system": return [];
1491
1626
  }
1492
- publish$1();
1493
- return null;
1494
1627
  }
1495
- function removeProject(folder) {
1496
- db.prepare("DELETE FROM projects WHERE path = ?").run(folder);
1497
- publish$1();
1498
- }
1499
- async function directories(input) {
1500
- const folder = path.resolve(expandHome(input.trim()));
1501
- const entries = await readdir(folder, { withFileTypes: true });
1502
- const parent = path.dirname(folder);
1503
- return {
1504
- path: folder,
1505
- parent: parent === folder ? null : parent,
1506
- directories: entries.filter((entry) => entry.isDirectory()).map((entry) => ({
1507
- name: entry.name,
1508
- path: path.join(folder, entry.name)
1509
- })).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
1510
- };
1628
+ function formQuestions(form) {
1629
+ return form.fields.flatMap((field) => field.type === "external" ? [] : [{
1630
+ question: questionText(field),
1631
+ header: field.title ?? "",
1632
+ multiSelect: field.type === "multiselect",
1633
+ options: fieldOptions(field)
1634
+ }]);
1511
1635
  }
1512
- function publish$1() {
1513
- broadcast({
1514
- type: "projects",
1515
- projects: projects$1()
1516
- });
1636
+ function formAnswer(form, answers) {
1637
+ return Object.fromEntries(form.fields.flatMap((field) => {
1638
+ if (field.type === "external") return [];
1639
+ const answer = answers[questionText(field)];
1640
+ if (answer === void 0 || answer === "") return [];
1641
+ switch (field.type) {
1642
+ case "string": return [[field.key, optionValue(field.options ?? [], answer)]];
1643
+ case "multiselect": return [[field.key, answer.split(", ").map((label) => optionValue(field.options, label))]];
1644
+ case "boolean":
1645
+ if (answer === "Yes") return [[field.key, true]];
1646
+ if (answer === "No") return [[field.key, false]];
1647
+ throw new Error(`${field.key} needs Yes or No`);
1648
+ case "number":
1649
+ case "integer": return [[field.key, Number(answer)]];
1650
+ }
1651
+ }));
1517
1652
  }
1518
- function toProject(row) {
1519
- 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");
1520
- return {
1521
- path: row.path,
1522
- name: row.name,
1523
- logo: row.logo
1653
+ function partItems(message, part, index, parent) {
1654
+ const base = {
1655
+ id: `${message.id}:${index}`,
1656
+ parent,
1657
+ time: message.time.created
1524
1658
  };
1525
- }
1526
- function checkLogo(logo) {
1527
- const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
1528
- if (type === void 0 || !LOGO_TYPES.includes(type)) throw new Error("Use a PNG, JPEG, GIF, WebP, SVG or ICO image");
1529
- if (logo.length > LOGO_LIMIT) throw new Error("Use an image smaller than 384 KB");
1530
- }
1531
- function expandHome(input) {
1532
- if (input === "~") return os.homedir();
1533
- if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
1534
- return input;
1535
- }
1536
- //#endregion
1537
- //#region src/services/sessions.ts
1538
- const day = 864e5;
1539
- const providers = /* @__PURE__ */ new Map();
1540
- let watchers = [];
1541
- let timer;
1542
- async function sessions() {
1543
- const records = readRecords();
1544
- const paths = projects$1().map((project) => project.path);
1545
- const [claude, codex, opencode] = await Promise.all([
1546
- Promise.all(paths.map(async (cwd) => (await listSessions({
1547
- dir: cwd,
1548
- includeWorktrees: false,
1549
- includeProgrammatic: false
1550
- })).map((item) => ({
1551
- id: item.sessionId,
1552
- provider: "claude",
1553
- title: item.summary,
1554
- cwd,
1555
- branch: item.gitBranch ?? null,
1556
- updatedAt: item.lastModified
1557
- })))),
1558
- codexThreads(paths).catch((error) => {
1559
- console.warn(`could not list Codex threads: ${error}`);
1560
- return [];
1561
- }),
1562
- openCodeSessions(paths).catch((error) => {
1563
- console.warn(`could not list OpenCode sessions: ${error}`);
1564
- return [];
1565
- })
1566
- ]);
1567
- return [
1568
- ...claude.flat(),
1569
- ...codex.map((thread) => ({
1570
- id: thread.id,
1571
- provider: "codex",
1572
- title: thread.name ?? thread.preview,
1573
- cwd: thread.cwd,
1574
- branch: thread.gitInfo?.branch ?? null,
1575
- updatedAt: thread.updatedAt * 1e3
1576
- })),
1577
- ...opencode.map((session) => ({
1578
- id: session.id,
1579
- provider: "opencode",
1580
- title: session.title ?? "New session",
1581
- cwd: session.location.directory,
1582
- branch: null,
1583
- updatedAt: session.time.updated
1584
- }))
1585
- ].map((item) => {
1586
- providers.set(item.id, item.provider);
1587
- const record = records.get(item.id);
1588
- const updatedAt = record?.interactedAt ?? item.updatedAt;
1589
- return {
1590
- ...item,
1591
- updatedAt,
1592
- status: record === void 0 || record.status === "active" && Date.now() - updatedAt > day ? "inactive" : record.status
1593
- };
1594
- });
1595
- }
1596
- function providerOf(sessionId) {
1597
- const provider = runs.get(sessionId)?.provider ?? providers.get(sessionId);
1598
- if (provider === void 0) throw new Error("offsite does not know this session yet, reload the page");
1599
- return provider;
1600
- }
1601
- function recordInteraction(id) {
1602
- 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());
1603
- }
1604
- async function publishSessions() {
1605
- try {
1606
- broadcast({
1607
- type: "sessions",
1608
- sessions: await sessions()
1609
- });
1610
- } catch (error) {
1611
- console.warn(`could not list threads: ${error}`);
1612
- }
1613
- }
1614
- function watchSessions() {
1615
- for (const watcher of watchers) watcher.close();
1616
- watchers = [path.join(readConfig().claudeBaseDir, "projects"), path.join(codexBaseDir, "sessions")].flatMap((dir) => {
1617
- try {
1618
- const watcher = watch(dir, { recursive: true }, refreshSessions);
1619
- watcher.on("error", (error) => console.warn(`stopped watching ${dir}: ${error}`));
1620
- return [watcher];
1621
- } catch (error) {
1622
- if (error.code !== "ENOENT") throw error;
1623
- return [];
1624
- }
1625
- });
1626
- }
1627
- function refreshSessions() {
1628
- timer ??= setTimeout(() => {
1629
- timer = void 0;
1630
- publishSessions();
1631
- }, 1e3);
1632
- }
1633
- async function updateSession(id, patch) {
1634
- if (patch.title !== void 0) switch (providerOf(id)) {
1635
- case "claude":
1636
- await renameSession(id, patch.title);
1637
- break;
1638
- case "codex":
1639
- await renameThread(id, patch.title);
1640
- break;
1641
- case "opencode": await renameOpenCode(id, patch.title);
1642
- }
1643
- if (patch.status === "inactive") db.prepare("DELETE FROM sessions WHERE session_id = ?").run(id);
1644
- 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);
1645
- }
1646
- async function removeSession(id) {
1647
- const provider = providerOf(id);
1648
- const run = runs.get(id);
1649
- if (run !== void 0) {
1650
- run.stop();
1651
- await run.closed;
1652
- }
1653
- switch (provider) {
1654
- case "claude":
1655
- await deleteSession(id);
1656
- break;
1657
- case "codex":
1658
- await deleteThread(id);
1659
- break;
1660
- case "opencode": await removeOpenCode(id);
1661
- }
1662
- db.prepare("DELETE FROM sessions WHERE session_id = ?").run(id);
1663
- }
1664
- async function fork(sessionId, cwd, messageId) {
1665
- const provider = providerOf(sessionId);
1666
- const result = await forkWith(provider, sessionId, cwd, messageId);
1667
- providers.set(result.sessionId, provider);
1668
- recordInteraction(result.sessionId);
1669
- return result;
1670
- }
1671
- async function forkWith(provider, sessionId, cwd, messageId) {
1672
- if (provider === "codex") return forkThread(sessionId, messageId);
1673
- if (provider === "opencode") return forkOpenCode(sessionId, messageId);
1674
- if ((await getSessionMessages(sessionId, { dir: cwd })).findIndex((item) => item.uuid === messageId) < 0) throw new Error("Cannot fork the session at that point");
1675
- return { sessionId: (await forkSession(sessionId, {
1676
- dir: cwd,
1677
- upToMessageId: messageId
1678
- })).sessionId };
1679
- }
1680
- function readRecords() {
1681
- const records = /* @__PURE__ */ new Map();
1682
- for (const row of db.prepare("SELECT session_id, interacted_at, status FROM sessions").all()) {
1683
- 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");
1684
- records.set(row.session_id, {
1685
- interactedAt: row.interacted_at,
1686
- status: row.status
1687
- });
1688
- }
1689
- return records;
1690
- }
1691
- //#endregion
1692
- //#region src/lib/opencode/client.ts
1693
- const listChanges = /* @__PURE__ */ new Set([
1694
- "session.created",
1695
- "session.renamed",
1696
- "session.moved",
1697
- "session.forked",
1698
- "session.deleted",
1699
- "session.execution.succeeded",
1700
- "session.execution.failed",
1701
- "session.execution.interrupted"
1702
- ]);
1703
- let connection;
1704
- function findOpenCode() {
1705
- const name = process.platform === "win32" ? "opencode.exe" : "opencode";
1706
- for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
1707
- const candidate = path.join(dir, name);
1708
- if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
1709
- }
1710
- return null;
1711
- }
1712
- function openCode() {
1713
- connection ??= connect();
1714
- return connection;
1715
- }
1716
- async function connect() {
1717
- try {
1718
- const endpoint = await ensure();
1719
- const client = OpenCode.make({
1720
- baseUrl: endpoint.url,
1721
- headers: headers(endpoint)
1722
- });
1723
- listen(client);
1724
- return client;
1725
- } catch (error) {
1726
- connection = void 0;
1727
- throw error;
1728
- }
1729
- }
1730
- async function listen(client) {
1731
- try {
1732
- for await (const event of client.event.subscribe()) route(event);
1733
- console.warn("the OpenCode event stream ended");
1734
- } catch (error) {
1735
- console.warn(`lost the OpenCode event stream: ${error}`);
1736
- }
1737
- connection = void 0;
1738
- if (openCodeRuns().length === 0) return;
1739
- await setTimeout$1(1e3);
1740
- try {
1741
- const active = await (await openCode()).session.active();
1742
- for (const run of openCodeRuns()) run.resync(run.sessionId in active);
1743
- refreshSessions();
1744
- } catch (error) {
1745
- console.warn(`could not reconnect to OpenCode: ${error}`);
1746
- for (const run of openCodeRuns()) run.exited();
1747
- }
1748
- }
1749
- function route(event) {
1750
- if (listChanges.has(event.type)) refreshSessions();
1751
- const sessionId = sessionOf(event);
1752
- if (sessionId === null) return;
1753
- const run = runs.get(sessionId);
1754
- if (run?.provider === "opencode") run.receive(event);
1755
- }
1756
- function sessionOf(event) {
1757
- if (event.type === "form.created") return event.data.form.sessionID;
1758
- const data = "data" in event ? event.data : null;
1759
- if (typeof data === "object" && data !== null && "sessionID" in data && typeof data.sessionID === "string") return data.sessionID;
1760
- return null;
1761
- }
1762
- function openCodeRuns() {
1763
- return [...runs.values()].flatMap((run) => run.provider === "opencode" ? [run] : []);
1764
- }
1765
- //#endregion
1766
- //#region src/lib/opencode/items.ts
1767
- const toolStatuses = {
1768
- streaming: "running",
1769
- running: "running",
1770
- completed: "completed",
1771
- error: "failed"
1772
- };
1773
- const todoStatuses$1 = {
1774
- pending: "pending",
1775
- in_progress: "running",
1776
- completed: "completed",
1777
- cancelled: "completed"
1778
- };
1779
- function messageItems(message, parent) {
1780
- const base = {
1781
- id: message.id,
1782
- parent,
1783
- time: message.time.created
1784
- };
1785
- switch (message.type) {
1786
- case "user": return [{
1787
- ...base,
1788
- kind: "user",
1789
- text: message.text,
1790
- images: (message.files ?? []).filter((file) => file.mime.startsWith("image/")).map((file) => ({
1791
- mediaType: file.mime,
1792
- data: file.data
1793
- })),
1794
- format: "text"
1795
- }];
1796
- case "skill": return [{
1797
- ...base,
1798
- kind: "skill",
1799
- skill: message.name,
1800
- output: "",
1801
- status: "completed"
1802
- }];
1803
- case "shell": return [{
1804
- ...base,
1805
- kind: "command",
1806
- command: message.command,
1807
- description: "",
1808
- output: message.output?.output ?? "",
1809
- status: message.status === "running" ? "running" : message.status === "exited" ? message.exit === 0 ? "completed" : "failed" : "stopped",
1810
- background: null
1811
- }];
1812
- case "assistant": return message.content.flatMap((part, index) => partItems(message, part, index, parent));
1813
- case "compaction": return message.status === "failed" ? [{
1814
- ...errorItem(message.id, message.error.message),
1815
- parent
1816
- }] : [{
1817
- ...base,
1818
- kind: "compaction"
1819
- }];
1820
- case "idle": return message.outcome === "failed" && message.error !== void 0 ? [{
1821
- ...errorItem(message.id, message.error.message),
1822
- parent
1823
- }] : [];
1824
- case "agent-switched":
1825
- case "model-switched":
1826
- case "location-switched":
1827
- case "synthetic":
1828
- case "system": return [];
1829
- }
1830
- }
1831
- function formQuestions(form) {
1832
- return form.fields.flatMap((field) => field.type === "external" ? [] : [{
1833
- question: questionText(field),
1834
- header: field.title ?? "",
1835
- multiSelect: field.type === "multiselect",
1836
- options: fieldOptions(field)
1837
- }]);
1838
- }
1839
- function formAnswer(form, answers) {
1840
- return Object.fromEntries(form.fields.flatMap((field) => {
1841
- if (field.type === "external") return [];
1842
- const answer = answers[questionText(field)];
1843
- if (answer === void 0 || answer === "") return [];
1844
- switch (field.type) {
1845
- case "string": return [[field.key, optionValue(field.options ?? [], answer)]];
1846
- case "multiselect": return [[field.key, answer.split(", ").map((label) => optionValue(field.options, label))]];
1847
- case "boolean":
1848
- if (answer === "Yes") return [[field.key, true]];
1849
- if (answer === "No") return [[field.key, false]];
1850
- throw new Error(`${field.key} needs Yes or No`);
1851
- case "number":
1852
- case "integer": return [[field.key, Number(answer)]];
1853
- }
1854
- }));
1855
- }
1856
- function partItems(message, part, index, parent) {
1857
- const base = {
1858
- id: `${message.id}:${index}`,
1859
- parent,
1860
- time: message.time.created
1861
- };
1862
- switch (part.type) {
1863
- case "text": return part.text.trim() === "" ? [] : [{
1864
- ...base,
1865
- kind: "assistant",
1866
- text: part.text,
1867
- message: message.id
1868
- }];
1869
- case "reasoning": return part.text.trim() === "" ? [] : [{
1870
- ...base,
1871
- kind: "reasoning",
1872
- text: part.text.trim()
1873
- }];
1874
- case "tool": return toolItems({
1875
- id: `${message.id}:${part.id}`,
1876
- parent,
1877
- time: part.time.created
1878
- }, part);
1879
- }
1659
+ switch (part.type) {
1660
+ case "text": return part.text.trim() === "" ? [] : [{
1661
+ ...base,
1662
+ kind: "assistant",
1663
+ text: part.text,
1664
+ message: message.id
1665
+ }];
1666
+ case "reasoning": return part.text.trim() === "" ? [] : [{
1667
+ ...base,
1668
+ kind: "reasoning",
1669
+ text: part.text.trim()
1670
+ }];
1671
+ case "tool": return toolItems({
1672
+ id: `${message.id}:${part.id}`,
1673
+ parent,
1674
+ time: part.time.created
1675
+ }, part);
1676
+ }
1880
1677
  }
1881
1678
  function toolItems(base, tool) {
1882
1679
  const state = tool.state;
@@ -1888,8 +1685,8 @@ function toolItems(base, tool) {
1888
1685
  case "shell": return [{
1889
1686
  ...base,
1890
1687
  kind: "command",
1891
- command: text$1(input.command),
1892
- description: text$1(input.description),
1688
+ command: text$2(input.command),
1689
+ description: text$2(input.description),
1893
1690
  output,
1894
1691
  status,
1895
1692
  background: null
@@ -1897,10 +1694,10 @@ function toolItems(base, tool) {
1897
1694
  case "edit": return [{
1898
1695
  ...base,
1899
1696
  kind: "file_change",
1900
- path: text$1(input.path ?? input.filePath),
1697
+ path: text$2(input.path ?? input.filePath),
1901
1698
  diffs: [{
1902
- before: text$1(input.oldString),
1903
- after: text$1(input.newString)
1699
+ before: text$2(input.oldString),
1700
+ after: text$2(input.newString)
1904
1701
  }],
1905
1702
  output,
1906
1703
  status
@@ -1908,10 +1705,10 @@ function toolItems(base, tool) {
1908
1705
  case "write": return [{
1909
1706
  ...base,
1910
1707
  kind: "file_change",
1911
- path: text$1(input.path ?? input.filePath),
1708
+ path: text$2(input.path ?? input.filePath),
1912
1709
  diffs: [{
1913
1710
  before: "",
1914
- after: text$1(input.content)
1711
+ after: text$2(input.content)
1915
1712
  }],
1916
1713
  output,
1917
1714
  status
@@ -1923,25 +1720,25 @@ function toolItems(base, tool) {
1923
1720
  ...base,
1924
1721
  id: `${base.id}:${index}`,
1925
1722
  kind: "file_change",
1926
- path: text$1(record(file).file),
1927
- diffs: hunks(text$1(record(file).patch)),
1723
+ path: text$2(record(file).file),
1724
+ diffs: hunks(text$2(record(file).patch)),
1928
1725
  output,
1929
1726
  status
1930
1727
  }));
1931
1728
  case "todowrite": return [{
1932
1729
  ...base,
1933
1730
  kind: "todo_list",
1934
- steps: list(input.todos).map((todo) => ({
1935
- text: text$1(record(todo).content),
1936
- status: todoStatuses$1[text$1(record(todo).status)] ?? "pending"
1731
+ steps: list$1(input.todos).map((todo) => ({
1732
+ text: text$2(record(todo).content),
1733
+ status: todoStatuses$1[text$2(record(todo).status)] ?? "pending"
1937
1734
  }))
1938
1735
  }];
1939
1736
  case "subagent":
1940
1737
  case "task": return [{
1941
1738
  ...base,
1942
1739
  kind: "subagent",
1943
- agent: text$1(input.agent ?? input.subagent_type) || "Agent",
1944
- description: text$1(input.description),
1740
+ agent: text$2(input.agent ?? input.subagent_type) || "Agent",
1741
+ description: text$2(input.description),
1945
1742
  agentId: typeof metadata.sessionID === "string" ? metadata.sessionID : null,
1946
1743
  status
1947
1744
  }];
@@ -1949,20 +1746,20 @@ function toolItems(base, tool) {
1949
1746
  case "websearch": return [{
1950
1747
  ...base,
1951
1748
  kind: "web_search",
1952
- query: tool.name === "websearch" ? text$1(input.query) : null,
1953
- url: tool.name === "webfetch" ? text$1(input.url) : null,
1749
+ query: tool.name === "websearch" ? text$2(input.query) : null,
1750
+ url: tool.name === "webfetch" ? text$2(input.url) : null,
1954
1751
  results: [],
1955
1752
  output,
1956
1753
  status
1957
1754
  }];
1958
1755
  case "question": {
1959
- const questions = list(input.questions).map((item) => ({
1960
- question: text$1(record(item).question),
1961
- header: text$1(record(item).header),
1756
+ const questions = list$1(input.questions).map((item) => ({
1757
+ question: text$2(record(item).question),
1758
+ header: text$2(record(item).header),
1962
1759
  multiSelect: record(item).multiple === true,
1963
- options: list(record(item).options).map((option) => ({
1964
- label: text$1(record(option).label),
1965
- description: text$1(record(option).description)
1760
+ options: list$1(record(item).options).map((option) => ({
1761
+ label: text$2(record(option).label),
1762
+ description: text$2(record(option).description)
1966
1763
  }))
1967
1764
  }));
1968
1765
  const answers = metadata.answers;
@@ -1970,14 +1767,14 @@ function toolItems(base, tool) {
1970
1767
  ...base,
1971
1768
  kind: "question",
1972
1769
  questions,
1973
- answers: Array.isArray(answers) ? Object.fromEntries(questions.map((question, index) => [question.question, list(answers[index]).map(text$1).join(", ")])) : null,
1770
+ answers: Array.isArray(answers) ? Object.fromEntries(questions.map((question, index) => [question.question, list$1(answers[index]).map(text$2).join(", ")])) : null,
1974
1771
  status
1975
1772
  }];
1976
1773
  }
1977
1774
  case "skill": return [{
1978
1775
  ...base,
1979
1776
  kind: "skill",
1980
- skill: text$1(input.name),
1777
+ skill: text$2(input.name),
1981
1778
  output,
1982
1779
  status
1983
1780
  }];
@@ -2018,34 +1815,20 @@ function optionValue(options, label) {
2018
1815
  function contentText(content) {
2019
1816
  return content.map((block) => block.type === "text" ? block.text : block.uri).join("\n");
2020
1817
  }
2021
- function text$1(value) {
1818
+ function text$2(value) {
2022
1819
  return typeof value === "string" ? value : "";
2023
1820
  }
2024
- function list(value) {
2025
- return Array.isArray(value) ? value : [];
2026
- }
2027
- function record(value) {
2028
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
2029
- }
2030
- //#endregion
2031
- //#region src/lib/opencode/sessions.ts
2032
- const PAGE = 100;
2033
- const COMMIT_LIMIT = 12e4;
2034
- const temporary = /* @__PURE__ */ new Set();
2035
- function openCodeAccount() {
2036
- return {
2037
- id: "opencode",
2038
- provider: "opencode",
2039
- name: "OpenCode",
2040
- configDir: "",
2041
- email: null,
2042
- plan: null,
2043
- error: null,
2044
- limits: [],
2045
- linked: true,
2046
- valid: true
2047
- };
1821
+ function list$1(value) {
1822
+ return Array.isArray(value) ? value : [];
1823
+ }
1824
+ function record(value) {
1825
+ return isRecord(value) ? value : {};
2048
1826
  }
1827
+ //#endregion
1828
+ //#region src/lib/opencode/sessions.ts
1829
+ const PAGE = 100;
1830
+ const COMMIT_LIMIT = 12e4;
1831
+ const temporary = /* @__PURE__ */ new Set();
2049
1832
  function openCodeInstalled() {
2050
1833
  return findOpenCode() !== null;
2051
1834
  }
@@ -2065,23 +1848,15 @@ async function openCodeCapabilities(cwd) {
2065
1848
  description: model.providerID,
2066
1849
  efforts: model.variants.map((variant) => variant.id)
2067
1850
  })),
2068
- commands: [
2069
- {
2070
- name: "compact",
2071
- description: "Summarize the conversation to free up context",
2072
- hint: ""
2073
- },
2074
- ...commands.data.map((command) => ({
2075
- name: command.name,
2076
- description: command.description ?? "",
2077
- hint: ""
2078
- })),
2079
- ...skills.data.map((skill) => ({
2080
- name: skill.name,
2081
- description: skill.description ?? "",
2082
- hint: ""
2083
- }))
2084
- ],
1851
+ commands: [{
1852
+ name: "compact",
1853
+ description: "Summarize the conversation to free up context",
1854
+ hint: ""
1855
+ }, ...[...commands.data, ...skills.data].map((command) => ({
1856
+ name: command.name,
1857
+ description: command.description ?? "",
1858
+ hint: ""
1859
+ }))],
2085
1860
  agents: agents.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden).map((agent) => ({
2086
1861
  id: agent.id,
2087
1862
  name: agent.name,
@@ -2158,8 +1933,8 @@ async function rewindOpenCode(request) {
2158
1933
  })).files ?? [];
2159
1934
  const result = request.code ? {
2160
1935
  files: files.map((file) => file.file),
2161
- insertions: files.reduce((sum, file) => sum + file.additions, 0),
2162
- deletions: files.reduce((sum, file) => sum + file.deletions, 0)
1936
+ insertions: sumBy(files, (file) => file.additions),
1937
+ deletions: sumBy(files, (file) => file.deletions)
2163
1938
  } : null;
2164
1939
  if (request.dryRun) await client.session.revert.clear({ sessionID });
2165
1940
  else await client.session.revert.commit({ sessionID });
@@ -2256,28 +2031,24 @@ var OpenCodeRun = class OpenCodeRun {
2256
2031
  prompted = false;
2257
2032
  backgroundTasks = 0;
2258
2033
  tasks = {};
2259
- client;
2260
2034
  close;
2261
2035
  applied;
2262
2036
  loading = Promise.resolve();
2263
2037
  refreshTimer;
2264
2038
  idleTimer;
2265
2039
  static async start(cwd, settings) {
2266
- const client = await openCode();
2267
- const session = await client.session.create({
2040
+ const session = await (await openCode()).session.create({
2268
2041
  location: { directory: cwd },
2269
2042
  ...settings.mode === null ? {} : { agent: settings.mode },
2270
2043
  ...settings.model === null ? {} : { model: modelRef(settings.model, settings.effort) }
2271
2044
  });
2272
- return new OpenCodeRun(client, session.id, cwd, settings, settings);
2045
+ return new OpenCodeRun(session.id, cwd, settings, settings);
2273
2046
  }
2274
2047
  static async resume(sessionId, cwd, settings) {
2275
- const client = await openCode();
2276
- const session = await client.session.get({ sessionID: sessionId });
2277
- return new OpenCodeRun(client, sessionId, cwd, settings, sessionSettings(session, settings));
2048
+ const session = await (await openCode()).session.get({ sessionID: sessionId });
2049
+ return new OpenCodeRun(sessionId, cwd, settings, sessionSettings(session, settings));
2278
2050
  }
2279
- constructor(client, sessionId, cwd, settings, applied) {
2280
- this.client = client;
2051
+ constructor(sessionId, cwd, settings, applied) {
2281
2052
  this.sessionId = sessionId;
2282
2053
  this.cwd = cwd;
2283
2054
  this.settings = settings;
@@ -2293,322 +2064,843 @@ var OpenCodeRun = class OpenCodeRun {
2293
2064
  await this.startTurn(prompt);
2294
2065
  return;
2295
2066
  }
2296
- switch (delivery) {
2297
- case "steer":
2298
- await this.client.session.prompt({
2299
- sessionID: this.sessionId,
2300
- text: prompt.text,
2301
- files: files(prompt),
2302
- delivery: "steer"
2303
- });
2304
- return;
2305
- case "queue":
2306
- this.queue = [...this.queue, {
2307
- id: randomUUID(),
2308
- prompt
2309
- }];
2310
- this.publishQueue();
2311
- }
2312
- }
2313
- unqueue(id) {
2314
- this.queue = this.queue.filter((item) => item.id !== id);
2315
- this.publishQueue();
2067
+ switch (delivery) {
2068
+ case "steer":
2069
+ await (await openCode()).session.prompt({
2070
+ sessionID: this.sessionId,
2071
+ text: prompt.text,
2072
+ files: files(prompt),
2073
+ delivery: "steer"
2074
+ });
2075
+ return;
2076
+ case "queue":
2077
+ this.queue = [...this.queue, {
2078
+ id: randomUUID(),
2079
+ prompt
2080
+ }];
2081
+ this.publishQueue();
2082
+ }
2083
+ }
2084
+ unqueue(id) {
2085
+ this.queue = this.queue.filter((item) => item.id !== id);
2086
+ this.publishQueue();
2087
+ }
2088
+ steer(id) {
2089
+ const item = this.queue.find((queued) => queued.id === id);
2090
+ if (item === void 0) return;
2091
+ this.unqueue(id);
2092
+ this.submit(item.prompt, "steer").catch((error) => this.fail(error));
2093
+ }
2094
+ configure(settings) {
2095
+ this.settings = {
2096
+ ...settings,
2097
+ accountId: this.settings.accountId
2098
+ };
2099
+ publish$2(this.sessionId, {
2100
+ type: "settings",
2101
+ sessionId: this.sessionId,
2102
+ settings: this.settings
2103
+ });
2104
+ }
2105
+ async interrupt() {
2106
+ await (await openCode()).session.interrupt({ sessionID: this.sessionId });
2107
+ }
2108
+ stopTask() {
2109
+ throw new Error("OpenCode has no background tasks to stop");
2110
+ }
2111
+ async respond(requestId, decision, answers) {
2112
+ const pending = this.pending.get(requestId);
2113
+ if (pending === void 0) return;
2114
+ const sessionID = this.sessionId;
2115
+ const client = await openCode();
2116
+ if (pending.kind === "permission") await client.permission.reply({
2117
+ sessionID,
2118
+ requestID: requestId,
2119
+ decision: replies[decision]
2120
+ });
2121
+ else if (decision === "deny") await client.session.form.cancel({
2122
+ sessionID,
2123
+ formID: requestId
2124
+ });
2125
+ else await client.session.form.reply({
2126
+ sessionID,
2127
+ formID: requestId,
2128
+ answer: formAnswer(pending.form, answers ?? {})
2129
+ });
2130
+ this.settle(requestId);
2131
+ }
2132
+ stop() {
2133
+ if (runs.get(this.sessionId) === this) runs.delete(this.sessionId);
2134
+ this.end();
2135
+ }
2136
+ exited() {
2137
+ this.put([errorItem(randomUUID(), this.pending.size === 0 ? "Lost the connection to OpenCode" : "Lost the connection to OpenCode, its open requests expired")]);
2138
+ this.stop();
2139
+ }
2140
+ reconcile(client, active, permissions, forms) {
2141
+ this.refresh(client);
2142
+ const open = /* @__PURE__ */ new Set([...permissions.map((permission) => permission.id), ...forms.map((form) => form.id)]);
2143
+ for (const requestId of [...this.pending.keys()]) if (!open.has(requestId)) this.settle(requestId);
2144
+ for (const permission of permissions) if (!this.pending.has(permission.id)) this.ask(permission);
2145
+ for (const form of forms) if (!this.pending.has(form.id)) this.askForm(form);
2146
+ if (this.running && !active) this.finishTurn();
2147
+ }
2148
+ receive(event) {
2149
+ switch (event.type) {
2150
+ case "session.reasoning.delta":
2151
+ case "session.tool.input.delta":
2152
+ case "session.compaction.delta": return;
2153
+ case "session.execution.started":
2154
+ clearTimeout(this.idleTimer);
2155
+ if (!this.running) this.prompted = false;
2156
+ this.running = true;
2157
+ broadcastActivity();
2158
+ return;
2159
+ case "session.execution.succeeded":
2160
+ case "session.execution.failed":
2161
+ case "session.execution.interrupted":
2162
+ this.refresh();
2163
+ this.loading.then(() => this.finishTurn());
2164
+ return;
2165
+ case "session.text.delta":
2166
+ this.partial += event.data.delta;
2167
+ publish$2(this.sessionId, {
2168
+ type: "delta",
2169
+ sessionId: this.sessionId,
2170
+ text: event.data.delta
2171
+ });
2172
+ return;
2173
+ case "permission.asked":
2174
+ this.refresh();
2175
+ this.loading.then(() => this.ask(event.data));
2176
+ return;
2177
+ case "permission.replied":
2178
+ this.settle(event.data.requestID);
2179
+ return;
2180
+ case "form.created":
2181
+ this.askForm(event.data.form);
2182
+ return;
2183
+ case "form.replied":
2184
+ case "form.cancelled":
2185
+ this.settle(event.data.id);
2186
+ return;
2187
+ default:
2188
+ clearTimeout(this.refreshTimer);
2189
+ this.refreshTimer = setTimeout(() => this.refresh(), 100);
2190
+ }
2191
+ }
2192
+ refresh(client) {
2193
+ clearTimeout(this.refreshTimer);
2194
+ this.loading = this.loading.then(async () => this.load(client ?? await openCode())).catch((error) => console.warn(`could not load OpenCode messages: ${error}`));
2195
+ }
2196
+ async load(client) {
2197
+ const messages = (await client.message.list({
2198
+ sessionID: this.sessionId,
2199
+ limit: RECENT,
2200
+ order: "desc"
2201
+ })).data.toReversed();
2202
+ this.put(messages.flatMap((message) => messageItems(message, null)).filter((item) => JSON.stringify(this.items.get(item.id)) !== JSON.stringify(item)));
2203
+ const context = await contextOf(client, this.cwd, messages);
2204
+ if (context === null || context.tokens === this.context?.tokens) return;
2205
+ this.context = context;
2206
+ publish$2(this.sessionId, {
2207
+ type: "context",
2208
+ sessionId: this.sessionId,
2209
+ context
2210
+ });
2211
+ }
2212
+ async startTurn(prompt) {
2213
+ clearTimeout(this.idleTimer);
2214
+ this.running = true;
2215
+ this.prompted = true;
2216
+ broadcastActivity();
2217
+ try {
2218
+ await this.apply();
2219
+ await this.send(prompt);
2220
+ } catch (error) {
2221
+ this.running = false;
2222
+ broadcastActivity();
2223
+ this.armIdleTimer();
2224
+ throw error;
2225
+ }
2226
+ }
2227
+ async send(prompt) {
2228
+ const client = await openCode();
2229
+ const sessionID = this.sessionId;
2230
+ const location = { directory: this.cwd };
2231
+ const match = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.text);
2232
+ if (match !== null) {
2233
+ if (match[1] === "compact" && prompt.images.length === 0) {
2234
+ await client.session.compact({ sessionID });
2235
+ return;
2236
+ }
2237
+ if ((await client.command.list({ location })).data.some((command) => command.name === match[1])) {
2238
+ await client.session.command({
2239
+ sessionID,
2240
+ name: match[1],
2241
+ text: match[2],
2242
+ files: files(prompt)
2243
+ });
2244
+ return;
2245
+ }
2246
+ const skill = (await client.skill.list({ location })).data.find((item) => item.name === match[1]);
2247
+ if (skill !== void 0) {
2248
+ await client.session.prompt({
2249
+ sessionID,
2250
+ text: match[2],
2251
+ files: files(prompt),
2252
+ skills: [{ id: skill.id }]
2253
+ });
2254
+ return;
2255
+ }
2256
+ }
2257
+ await client.session.prompt({
2258
+ sessionID,
2259
+ text: prompt.text,
2260
+ files: files(prompt)
2261
+ });
2262
+ }
2263
+ async apply() {
2264
+ const client = await openCode();
2265
+ const settings = this.settings;
2266
+ const model = settings.model ?? this.applied.model;
2267
+ if (model !== null && (model !== this.applied.model || settings.effort !== this.applied.effort)) await client.session.switchModel({
2268
+ sessionID: this.sessionId,
2269
+ model: modelRef(model, settings.effort)
2270
+ });
2271
+ if (settings.mode !== null && settings.mode !== this.applied.mode) await client.session.switchAgent({
2272
+ sessionID: this.sessionId,
2273
+ agent: settings.mode
2274
+ });
2275
+ this.applied = {
2276
+ ...settings,
2277
+ model,
2278
+ mode: settings.mode ?? this.applied.mode
2279
+ };
2280
+ }
2281
+ ask(permission) {
2282
+ const source = permission.source;
2283
+ const item = source === void 0 ? void 0 : this.items.get(`${source.messageID}:${source.id}`);
2284
+ this.addPending({
2285
+ kind: "permission",
2286
+ request: {
2287
+ id: permission.id,
2288
+ kind: "approval",
2289
+ title: permission.message ?? `Allow ${permission.action} ${permission.resources.join(", ")}?`,
2290
+ canAlwaysAllow: (permission.save ?? []).length > 0,
2291
+ item: item ?? {
2292
+ id: permission.id,
2293
+ parent: null,
2294
+ time: Date.now(),
2295
+ kind: "tool",
2296
+ name: permission.action,
2297
+ input: { resources: permission.resources },
2298
+ output: "",
2299
+ status: "running"
2300
+ }
2301
+ }
2302
+ });
2303
+ }
2304
+ askForm(form) {
2305
+ this.addPending({
2306
+ kind: "form",
2307
+ request: {
2308
+ id: form.id,
2309
+ kind: "question",
2310
+ questions: formQuestions(form)
2311
+ },
2312
+ form
2313
+ });
2314
+ }
2315
+ addPending(pending) {
2316
+ this.pending.set(pending.request.id, pending);
2317
+ publish$2(this.sessionId, {
2318
+ type: "request",
2319
+ sessionId: this.sessionId,
2320
+ request: pending.request
2321
+ });
2322
+ broadcastActivity();
2323
+ scheduleAutoResolve(this.sessionId, pending.request);
2324
+ }
2325
+ finishTurn() {
2326
+ this.partial = "";
2327
+ const [next, ...rest] = this.queue;
2328
+ if (next !== void 0) {
2329
+ this.queue = rest;
2330
+ this.publishQueue();
2331
+ this.startTurn(next.prompt).catch((error) => this.fail(error));
2332
+ return;
2333
+ }
2334
+ this.running = false;
2335
+ broadcastActivity();
2336
+ this.armIdleTimer();
2316
2337
  }
2317
- steer(id) {
2318
- const item = this.queue.find((queued) => queued.id === id);
2319
- if (item === void 0) return;
2320
- this.unqueue(id);
2321
- this.submit(item.prompt, "steer").catch((error) => this.fail(error));
2338
+ settle(requestId) {
2339
+ if (!this.pending.delete(requestId)) return;
2340
+ publish$2(this.sessionId, {
2341
+ type: "requestDone",
2342
+ sessionId: this.sessionId,
2343
+ requestId
2344
+ });
2345
+ broadcastActivity();
2322
2346
  }
2323
- configure(settings) {
2324
- this.settings = {
2325
- ...settings,
2326
- accountId: this.settings.accountId
2327
- };
2347
+ put(items) {
2348
+ if (items.length === 0) return;
2349
+ for (const item of items) this.items.set(item.id, item);
2350
+ if (items.some((item) => item.kind === "assistant")) this.partial = "";
2328
2351
  publish$2(this.sessionId, {
2329
- type: "settings",
2352
+ type: "items",
2330
2353
  sessionId: this.sessionId,
2331
- settings: this.settings
2354
+ items
2332
2355
  });
2333
2356
  }
2334
- async interrupt() {
2335
- await this.client.session.interrupt({ sessionID: this.sessionId });
2357
+ fail(error) {
2358
+ this.put([errorItem(randomUUID(), error instanceof Error ? error.message : String(error))]);
2336
2359
  }
2337
- stopTask() {
2338
- throw new Error("OpenCode has no background tasks to stop");
2360
+ armIdleTimer() {
2361
+ clearTimeout(this.idleTimer);
2362
+ if (this.running) return;
2363
+ this.idleTimer = setTimeout(() => {
2364
+ if (!this.running && this.pending.size === 0) this.stop();
2365
+ }, IDLE_LIMIT$1);
2339
2366
  }
2340
- async respond(requestId, decision, answers) {
2341
- const pending = this.pending.get(requestId);
2342
- if (pending === void 0) return;
2343
- const sessionID = this.sessionId;
2344
- if (pending.kind === "permission") await this.client.permission.reply({
2345
- sessionID,
2346
- requestID: requestId,
2347
- decision: replies[decision]
2348
- });
2349
- else if (decision === "deny") await this.client.session.form.cancel({
2350
- sessionID,
2351
- formID: requestId
2352
- });
2353
- else await this.client.session.form.reply({
2354
- sessionID,
2355
- formID: requestId,
2356
- answer: formAnswer(pending.form, answers ?? {})
2367
+ end() {
2368
+ clearTimeout(this.idleTimer);
2369
+ clearTimeout(this.refreshTimer);
2370
+ for (const requestId of [...this.pending.keys()]) this.settle(requestId);
2371
+ this.running = false;
2372
+ broadcastActivity();
2373
+ this.close();
2374
+ }
2375
+ publishQueue() {
2376
+ publish$2(this.sessionId, {
2377
+ type: "queue",
2378
+ sessionId: this.sessionId,
2379
+ queue: this.queue
2357
2380
  });
2358
- this.settle(requestId);
2359
2381
  }
2360
- stop() {
2361
- if (runs.get(this.sessionId) === this) runs.delete(this.sessionId);
2362
- this.end();
2382
+ };
2383
+ function sessionSettings(session, settings) {
2384
+ const variant = session.model?.variant;
2385
+ return {
2386
+ ...settings,
2387
+ model: session.model === void 0 ? null : `${session.model.providerID}/${session.model.id}`,
2388
+ effort: variant === void 0 || variant === "default" ? null : variant,
2389
+ mode: session.agent ?? null
2390
+ };
2391
+ }
2392
+ function files(prompt) {
2393
+ return prompt.images.map((image) => ({ uri: `data:${image.mediaType};base64,${image.data}` }));
2394
+ }
2395
+ //#endregion
2396
+ //#region src/storage/db.ts
2397
+ const file = path.join(home, "offsite.db");
2398
+ closeSync(openSync(file, "a", 384));
2399
+ chmodSync(file, 384);
2400
+ const client = new DatabaseSync(file);
2401
+ client.exec(`
2402
+ PRAGMA journal_mode = WAL;
2403
+ PRAGMA busy_timeout = 5000;
2404
+ PRAGMA synchronous = NORMAL;
2405
+ PRAGMA foreign_keys = ON;
2406
+ `);
2407
+ const db = drizzle({ client });
2408
+ migrate(db, { migrationsFolder: path.join(serverDir, "drizzle") });
2409
+ //#endregion
2410
+ //#region src/storage/schema.ts
2411
+ const projects$2 = snakeCase.table("projects", {
2412
+ id: text().primaryKey(),
2413
+ path: text().notNull().unique(),
2414
+ name: text().notNull(),
2415
+ logo: text()
2416
+ });
2417
+ const sessions$1 = snakeCase.table("sessions", {
2418
+ sessionId: text().primaryKey(),
2419
+ interactedAt: integer(),
2420
+ status: text({ enum: [
2421
+ "active",
2422
+ "pinned",
2423
+ "minimized"
2424
+ ] }).notNull()
2425
+ });
2426
+ const tokens = snakeCase.table("tokens", {
2427
+ hash: text().primaryKey(),
2428
+ name: text(),
2429
+ createdAt: integer().notNull(),
2430
+ lastUsedAt: integer()
2431
+ });
2432
+ const pushSubscriptions = snakeCase.table("push_subscriptions", {
2433
+ endpoint: text().primaryKey(),
2434
+ p256dh: text().notNull(),
2435
+ auth: text().notNull(),
2436
+ publicKey: text().notNull(),
2437
+ privateKey: text().notNull(),
2438
+ machine: text().notNull(),
2439
+ server: text().notNull()
2440
+ });
2441
+ //#endregion
2442
+ //#region src/services/projects.ts
2443
+ const LOGO_TYPES = [
2444
+ "image/png",
2445
+ "image/jpeg",
2446
+ "image/gif",
2447
+ "image/webp",
2448
+ "image/svg+xml",
2449
+ "image/x-icon",
2450
+ "image/vnd.microsoft.icon"
2451
+ ];
2452
+ const LOGO_LIMIT = 524288;
2453
+ function projects$1() {
2454
+ return db.select().from(projects$2).all();
2455
+ }
2456
+ function projectPath(id) {
2457
+ const project = db.select().from(projects$2).where(eq(projects$2.id, id)).get();
2458
+ if (project === void 0) throw new Error("That project was removed");
2459
+ return project.path;
2460
+ }
2461
+ async function addProject(input) {
2462
+ const folder = path.resolve(expandHome(input.trim()));
2463
+ if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
2464
+ db.insert(projects$2).values({
2465
+ id: randomUUID(),
2466
+ path: folder,
2467
+ name: path.basename(folder) || folder,
2468
+ logo: null
2469
+ }).onConflictDoNothing({ target: projects$2.path }).run();
2470
+ publish$1();
2471
+ const project = projects$1().find((item) => item.path === folder);
2472
+ if (project === void 0) throw new Error(`Could not add ${folder}`);
2473
+ return project;
2474
+ }
2475
+ function updateProject(id, patch) {
2476
+ if (patch.name !== void 0) {
2477
+ const name = patch.name.trim();
2478
+ if (name === "") throw new Error("Project name cannot be empty");
2479
+ db.update(projects$2).set({ name }).where(eq(projects$2.id, id)).run();
2363
2480
  }
2364
- exited() {
2365
- this.put([errorItem(randomUUID(), "Lost the connection to OpenCode")]);
2366
- this.stop();
2481
+ if (patch.logo !== void 0) {
2482
+ if (patch.logo !== null) checkLogo(patch.logo);
2483
+ db.update(projects$2).set({ logo: patch.logo }).where(eq(projects$2.id, id)).run();
2367
2484
  }
2368
- resync(active) {
2369
- this.refresh();
2370
- if (this.running && !active) this.finishTurn();
2485
+ publish$1();
2486
+ return null;
2487
+ }
2488
+ function removeProject(id) {
2489
+ db.delete(projects$2).where(eq(projects$2.id, id)).run();
2490
+ publish$1();
2491
+ }
2492
+ async function directories(input) {
2493
+ const folder = path.resolve(expandHome(input.trim()));
2494
+ const entries = await readdir(folder, { withFileTypes: true });
2495
+ const parent = path.dirname(folder);
2496
+ return {
2497
+ path: folder,
2498
+ parent: parent === folder ? null : parent,
2499
+ directories: entries.filter((entry) => entry.isDirectory()).map((entry) => ({
2500
+ name: entry.name,
2501
+ path: path.join(folder, entry.name)
2502
+ })).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
2503
+ };
2504
+ }
2505
+ function publish$1() {
2506
+ broadcast({
2507
+ type: "projects",
2508
+ projects: projects$1()
2509
+ });
2510
+ }
2511
+ function checkLogo(logo) {
2512
+ const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
2513
+ if (type === void 0 || !LOGO_TYPES.includes(type)) throw new Error("Use a PNG, JPEG, GIF, WebP, SVG or ICO image");
2514
+ if (logo.length > LOGO_LIMIT) throw new Error("Use an image smaller than 384 KB");
2515
+ }
2516
+ function expandHome(input) {
2517
+ if (input === "~") return os.homedir();
2518
+ if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
2519
+ return input;
2520
+ }
2521
+ //#endregion
2522
+ //#region src/services/sessions.ts
2523
+ const day = 864e5;
2524
+ const LIST_TIMEOUT = 1e4;
2525
+ const REFRESH_DELAY = 1e3;
2526
+ const allProviders = [
2527
+ "claude",
2528
+ "codex",
2529
+ "opencode"
2530
+ ];
2531
+ const listed = {
2532
+ claude: [],
2533
+ codex: [],
2534
+ opencode: []
2535
+ };
2536
+ const statuses = {
2537
+ claude: {
2538
+ provider: "claude",
2539
+ state: "loading",
2540
+ error: null
2541
+ },
2542
+ codex: {
2543
+ provider: "codex",
2544
+ state: "loading",
2545
+ error: null
2546
+ },
2547
+ opencode: {
2548
+ provider: "opencode",
2549
+ state: "loading",
2550
+ error: null
2371
2551
  }
2372
- receive(event) {
2373
- switch (event.type) {
2374
- case "session.reasoning.delta":
2375
- case "session.tool.input.delta":
2376
- case "session.compaction.delta": return;
2377
- case "session.execution.started":
2378
- clearTimeout(this.idleTimer);
2379
- if (!this.running) this.prompted = false;
2380
- this.running = true;
2381
- broadcastActivity();
2382
- return;
2383
- case "session.execution.succeeded":
2384
- case "session.execution.failed":
2385
- case "session.execution.interrupted":
2386
- this.refresh();
2387
- this.loading.then(() => this.finishTurn());
2388
- return;
2389
- case "session.text.delta":
2390
- this.partial += event.data.delta;
2391
- publish$2(this.sessionId, {
2392
- type: "delta",
2393
- sessionId: this.sessionId,
2394
- text: event.data.delta
2395
- });
2396
- return;
2397
- case "permission.asked":
2398
- this.refresh();
2399
- this.loading.then(() => this.ask(event.data));
2400
- return;
2401
- case "permission.replied":
2402
- this.settle(event.data.requestID);
2403
- return;
2404
- case "form.created":
2405
- this.addPending({
2406
- kind: "form",
2407
- request: {
2408
- id: event.data.form.id,
2409
- kind: "question",
2410
- questions: formQuestions(event.data.form)
2411
- },
2412
- form: event.data.form
2413
- });
2414
- return;
2415
- case "form.replied":
2416
- case "form.cancelled":
2417
- this.settle(event.data.id);
2418
- return;
2419
- default:
2420
- clearTimeout(this.refreshTimer);
2421
- this.refreshTimer = setTimeout(() => this.refresh(), 100);
2552
+ };
2553
+ const refreshing = /* @__PURE__ */ new Map();
2554
+ const timers = /* @__PURE__ */ new Map();
2555
+ const providers = /* @__PURE__ */ new Map();
2556
+ let watchers = [];
2557
+ function sessions() {
2558
+ const records = new Map(db.select().from(sessions$1).all().map((record) => [record.sessionId, record]));
2559
+ const known = new Set(projects$1().map((project) => project.id));
2560
+ return allProviders.flatMap((provider) => listed[provider].filter((item) => known.has(item.projectId)).map((item) => {
2561
+ const record = records.get(item.id);
2562
+ const updatedAt = record?.interactedAt ?? item.updatedAt;
2563
+ return {
2564
+ ...item,
2565
+ updatedAt,
2566
+ status: record === void 0 || record.status === "active" && Date.now() - updatedAt > day ? "inactive" : record.status
2567
+ };
2568
+ }));
2569
+ }
2570
+ function providerStatuses() {
2571
+ return allProviders.map((provider) => statuses[provider]);
2572
+ }
2573
+ function listedUpdatedAt(provider, sessionId) {
2574
+ return listed[provider].find((item) => item.id === sessionId)?.updatedAt ?? null;
2575
+ }
2576
+ function providerOf(sessionId) {
2577
+ const provider = runs.get(sessionId)?.provider ?? providers.get(sessionId);
2578
+ if (provider === void 0) throw new Error("offsite does not know this session yet, reload the page");
2579
+ return provider;
2580
+ }
2581
+ function recordInteraction(id) {
2582
+ db.insert(sessions$1).values({
2583
+ sessionId: id,
2584
+ interactedAt: Date.now(),
2585
+ status: "active"
2586
+ }).onConflictDoUpdate({
2587
+ target: sessions$1.sessionId,
2588
+ set: {
2589
+ interactedAt: Date.now(),
2590
+ status: sql`CASE ${sessions$1.status} WHEN 'pinned' THEN 'pinned' WHEN 'minimized' THEN 'minimized' ELSE 'active' END`
2422
2591
  }
2592
+ }).run();
2593
+ }
2594
+ function publishSessions() {
2595
+ broadcast({
2596
+ type: "sessions",
2597
+ sessions: sessions()
2598
+ });
2599
+ }
2600
+ function refreshAll() {
2601
+ for (const provider of allProviders) refresh(provider);
2602
+ }
2603
+ function refreshSessions(provider) {
2604
+ if (timers.has(provider)) return;
2605
+ timers.set(provider, setTimeout(() => {
2606
+ timers.delete(provider);
2607
+ refresh(provider);
2608
+ }, REFRESH_DELAY));
2609
+ }
2610
+ async function refresh(provider) {
2611
+ const current = refreshing.get(provider);
2612
+ if (current !== void 0) {
2613
+ current.again = true;
2614
+ return;
2423
2615
  }
2424
- refresh() {
2425
- clearTimeout(this.refreshTimer);
2426
- this.loading = this.loading.then(() => this.load()).catch((error) => console.warn(`could not load OpenCode messages: ${error}`));
2427
- }
2428
- async load() {
2429
- const messages = (await this.client.message.list({
2430
- sessionID: this.sessionId,
2431
- limit: RECENT,
2432
- order: "desc"
2433
- })).data.toReversed();
2434
- this.put(messages.flatMap((message) => messageItems(message, null)).filter((item) => JSON.stringify(this.items.get(item.id)) !== JSON.stringify(item)));
2435
- const context = await contextOf(this.client, this.cwd, messages);
2436
- if (context === null || context.tokens === this.context?.tokens) return;
2437
- this.context = context;
2438
- publish$2(this.sessionId, {
2439
- type: "context",
2440
- sessionId: this.sessionId,
2441
- context
2442
- });
2616
+ const entry = { again: true };
2617
+ refreshing.set(provider, entry);
2618
+ while (entry.again) {
2619
+ entry.again = false;
2620
+ await list(provider);
2443
2621
  }
2444
- async startTurn(prompt) {
2445
- clearTimeout(this.idleTimer);
2446
- this.running = true;
2447
- this.prompted = true;
2448
- broadcastActivity();
2449
- try {
2450
- await this.apply();
2451
- await this.send(prompt);
2452
- } catch (error) {
2453
- this.running = false;
2454
- broadcastActivity();
2455
- this.armIdleTimer();
2456
- throw error;
2622
+ refreshing.delete(provider);
2623
+ }
2624
+ async function list(provider) {
2625
+ const paths = new Map(projects$1().map((project) => [project.path, project.id]));
2626
+ const before = statuses[provider];
2627
+ let timer;
2628
+ try {
2629
+ if (!readConfig().enabledProviders[provider]) {
2630
+ listed[provider] = [];
2631
+ statuses[provider] = {
2632
+ provider,
2633
+ state: "disabled",
2634
+ error: null
2635
+ };
2636
+ return;
2457
2637
  }
2458
- }
2459
- async send(prompt) {
2460
- const sessionID = this.sessionId;
2461
- const location = { directory: this.cwd };
2462
- const match = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.text);
2463
- if (match !== null) {
2464
- if (match[1] === "compact" && prompt.images.length === 0) {
2465
- await this.client.session.compact({ sessionID });
2466
- return;
2467
- }
2468
- if ((await this.client.command.list({ location })).data.some((command) => command.name === match[1])) {
2469
- await this.client.session.command({
2470
- sessionID,
2471
- name: match[1],
2472
- text: match[2],
2473
- files: files(prompt)
2474
- });
2475
- return;
2476
- }
2477
- const skill = (await this.client.skill.list({ location })).data.find((item) => item.name === match[1]);
2478
- if (skill !== void 0) {
2479
- await this.client.session.prompt({
2480
- sessionID,
2481
- text: match[2],
2482
- files: files(prompt),
2483
- skills: [{ id: skill.id }]
2484
- });
2485
- return;
2486
- }
2638
+ const found = await Promise.race([find(provider, paths), new Promise((_, reject) => {
2639
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${providerName(provider)} did not list its sessions within ${LIST_TIMEOUT / 1e3} seconds`)), LIST_TIMEOUT);
2640
+ })]);
2641
+ if (found === null) {
2642
+ listed[provider] = [];
2643
+ statuses[provider] = {
2644
+ provider,
2645
+ state: "missing",
2646
+ error: null
2647
+ };
2648
+ } else {
2649
+ listed[provider] = found;
2650
+ statuses[provider] = {
2651
+ provider,
2652
+ state: "ready",
2653
+ error: null
2654
+ };
2655
+ for (const item of found) providers.set(item.id, provider);
2487
2656
  }
2488
- await this.client.session.prompt({
2489
- sessionID,
2490
- text: prompt.text,
2491
- files: files(prompt)
2492
- });
2493
- }
2494
- async apply() {
2495
- const settings = this.settings;
2496
- const model = settings.model ?? this.applied.model;
2497
- if (model !== null && (model !== this.applied.model || settings.effort !== this.applied.effort)) await this.client.session.switchModel({
2498
- sessionID: this.sessionId,
2499
- model: modelRef(model, settings.effort)
2500
- });
2501
- if (settings.mode !== null && settings.mode !== this.applied.mode) await this.client.session.switchAgent({
2502
- sessionID: this.sessionId,
2503
- agent: settings.mode
2504
- });
2505
- this.applied = {
2506
- ...settings,
2507
- model,
2508
- mode: settings.mode ?? this.applied.mode
2657
+ } catch (error) {
2658
+ const message = error instanceof Error ? error.message : String(error);
2659
+ console.warn(`could not list ${providerName(provider)} sessions: ${message}`);
2660
+ statuses[provider] = {
2661
+ provider,
2662
+ state: "error",
2663
+ error: message
2509
2664
  };
2510
- }
2511
- ask(permission) {
2512
- const source = permission.source;
2513
- const item = source === void 0 ? void 0 : this.items.get(`${source.messageID}:${source.id}`);
2514
- this.addPending({
2515
- kind: "permission",
2516
- request: {
2517
- id: permission.id,
2518
- kind: "approval",
2519
- title: permission.message ?? `Allow ${permission.action} ${permission.resources.join(", ")}?`,
2520
- canAlwaysAllow: (permission.save ?? []).length > 0,
2521
- item: item ?? {
2522
- id: permission.id,
2523
- parent: null,
2524
- time: Date.now(),
2525
- kind: "tool",
2526
- name: permission.action,
2527
- input: { resources: permission.resources },
2528
- output: "",
2529
- status: "running"
2530
- }
2531
- }
2665
+ } finally {
2666
+ clearTimeout(timer);
2667
+ publishSessions();
2668
+ if (JSON.stringify(statuses[provider]) !== JSON.stringify(before)) broadcast({
2669
+ type: "providers",
2670
+ providers: providerStatuses()
2532
2671
  });
2533
2672
  }
2534
- addPending(pending) {
2535
- this.pending.set(pending.request.id, pending);
2536
- publish$2(this.sessionId, {
2537
- type: "request",
2538
- sessionId: this.sessionId,
2539
- request: pending.request
2540
- });
2541
- broadcastActivity();
2673
+ }
2674
+ async function find(provider, paths) {
2675
+ switch (provider) {
2676
+ case "claude": return (await Promise.all([...paths].map(async ([cwd, projectId]) => (await listSessions({
2677
+ dir: cwd,
2678
+ includeWorktrees: false,
2679
+ includeProgrammatic: false
2680
+ })).map((item) => ({
2681
+ id: item.sessionId,
2682
+ provider: "claude",
2683
+ title: item.summary,
2684
+ projectId,
2685
+ branch: item.gitBranch ?? null,
2686
+ updatedAt: item.lastModified
2687
+ }))))).flat();
2688
+ case "codex":
2689
+ if (!codexInstalled()) return null;
2690
+ return (await codexThreads([...paths.keys()])).flatMap((thread) => {
2691
+ const projectId = paths.get(thread.cwd);
2692
+ if (projectId === void 0) return [];
2693
+ return [{
2694
+ id: thread.id,
2695
+ provider: "codex",
2696
+ title: thread.name ?? thread.preview,
2697
+ projectId,
2698
+ branch: thread.gitInfo?.branch ?? null,
2699
+ updatedAt: thread.updatedAt * 1e3
2700
+ }];
2701
+ });
2702
+ case "opencode":
2703
+ if (!openCodeInstalled()) return null;
2704
+ return (await openCodeSessions([...paths.keys()])).flatMap((session) => {
2705
+ const projectId = paths.get(session.location.directory);
2706
+ if (projectId === void 0) return [];
2707
+ return [{
2708
+ id: session.id,
2709
+ provider: "opencode",
2710
+ title: session.title ?? "New session",
2711
+ projectId,
2712
+ branch: null,
2713
+ updatedAt: session.time.updated
2714
+ }];
2715
+ });
2542
2716
  }
2543
- finishTurn() {
2544
- this.partial = "";
2545
- const [next, ...rest] = this.queue;
2546
- if (next !== void 0) {
2547
- this.queue = rest;
2548
- this.publishQueue();
2549
- this.startTurn(next.prompt).catch((error) => this.fail(error));
2550
- return;
2717
+ }
2718
+ function watchSessions() {
2719
+ for (const watcher of watchers) watcher.close();
2720
+ watchers = [["claude", path.join(readConfig().claudeBaseDir, "projects")], ["codex", path.join(codexBaseDir, "sessions")]].flatMap(([provider, dir]) => {
2721
+ if (!readConfig().enabledProviders[provider]) return [];
2722
+ try {
2723
+ const watcher = watch(dir, { recursive: true }, () => refreshSessions(provider));
2724
+ watcher.on("error", (error) => console.warn(`stopped watching ${dir}: ${error}`));
2725
+ return [watcher];
2726
+ } catch (error) {
2727
+ if (error.code !== "ENOENT") throw error;
2728
+ return [];
2551
2729
  }
2552
- this.running = false;
2553
- broadcastActivity();
2554
- this.armIdleTimer();
2730
+ });
2731
+ }
2732
+ async function updateSession(id, patch) {
2733
+ if (patch.title !== void 0) switch (providerOf(id)) {
2734
+ case "claude":
2735
+ await renameSession(id, patch.title);
2736
+ break;
2737
+ case "codex":
2738
+ await renameThread(id, patch.title);
2739
+ break;
2740
+ case "opencode": await renameOpenCode(id, patch.title);
2555
2741
  }
2556
- settle(requestId) {
2557
- if (!this.pending.delete(requestId)) return;
2558
- publish$2(this.sessionId, {
2559
- type: "requestDone",
2560
- sessionId: this.sessionId,
2561
- requestId
2562
- });
2563
- broadcastActivity();
2742
+ if (patch.status === "inactive") db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
2743
+ else if (patch.status !== void 0) db.insert(sessions$1).values({
2744
+ sessionId: id,
2745
+ status: patch.status
2746
+ }).onConflictDoUpdate({
2747
+ target: sessions$1.sessionId,
2748
+ set: { status: patch.status }
2749
+ }).run();
2750
+ if (patch.title !== void 0) refreshSessions(providerOf(id));
2751
+ }
2752
+ async function removeSession(id) {
2753
+ const provider = providerOf(id);
2754
+ const run = runs.get(id);
2755
+ if (run !== void 0) {
2756
+ run.stop();
2757
+ await run.closed;
2564
2758
  }
2565
- put(items) {
2566
- if (items.length === 0) return;
2567
- for (const item of items) this.items.set(item.id, item);
2568
- if (items.some((item) => item.kind === "assistant")) this.partial = "";
2569
- publish$2(this.sessionId, {
2570
- type: "items",
2571
- sessionId: this.sessionId,
2572
- items
2573
- });
2759
+ switch (provider) {
2760
+ case "claude":
2761
+ await deleteSession(id);
2762
+ break;
2763
+ case "codex":
2764
+ await deleteThread(id);
2765
+ break;
2766
+ case "opencode": await removeOpenCode(id);
2574
2767
  }
2575
- fail(error) {
2576
- this.put([errorItem(randomUUID(), error instanceof Error ? error.message : String(error))]);
2768
+ db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
2769
+ listed[provider] = listed[provider].filter((item) => item.id !== id);
2770
+ }
2771
+ async function fork(sessionId, projectId, messageId) {
2772
+ const provider = providerOf(sessionId);
2773
+ const result = await forkWith(provider, sessionId, projectPath(projectId), messageId);
2774
+ providers.set(result.sessionId, provider);
2775
+ recordInteraction(result.sessionId);
2776
+ refreshSessions(provider);
2777
+ return result;
2778
+ }
2779
+ async function forkWith(provider, sessionId, cwd, messageId) {
2780
+ if (provider === "codex") return forkThread(sessionId, messageId);
2781
+ if (provider === "opencode") return forkOpenCode(sessionId, messageId);
2782
+ if ((await getSessionMessages(sessionId, { dir: cwd })).findIndex((item) => item.uuid === messageId) < 0) throw new Error("Cannot fork the session at that point");
2783
+ return { sessionId: (await forkSession(sessionId, {
2784
+ dir: cwd,
2785
+ upToMessageId: messageId
2786
+ })).sessionId };
2787
+ }
2788
+ //#endregion
2789
+ //#region src/lib/opencode/client.ts
2790
+ const listChanges = /* @__PURE__ */ new Set([
2791
+ "session.created",
2792
+ "session.renamed",
2793
+ "session.moved",
2794
+ "session.forked",
2795
+ "session.deleted",
2796
+ "session.execution.succeeded",
2797
+ "session.execution.failed",
2798
+ "session.execution.interrupted"
2799
+ ]);
2800
+ const SILENCE_LIMIT$1 = 45e3;
2801
+ const RECONNECT_ATTEMPTS = 5;
2802
+ const RECONNECT_DELAY = 2e3;
2803
+ let connection;
2804
+ let listener;
2805
+ function findOpenCode() {
2806
+ const name = process.platform === "win32" ? "opencode.exe" : "opencode";
2807
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
2808
+ const candidate = path.join(dir, name);
2809
+ if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
2577
2810
  }
2578
- armIdleTimer() {
2579
- clearTimeout(this.idleTimer);
2580
- if (this.running) return;
2581
- this.idleTimer = setTimeout(() => {
2582
- if (!this.running && this.pending.size === 0) this.stop();
2583
- }, IDLE_LIMIT$1);
2811
+ return null;
2812
+ }
2813
+ function openCode() {
2814
+ connection ??= start();
2815
+ return connection;
2816
+ }
2817
+ function disconnectOpenCode() {
2818
+ const controller = listener;
2819
+ listener = void 0;
2820
+ connection = void 0;
2821
+ controller?.abort();
2822
+ }
2823
+ async function start() {
2824
+ try {
2825
+ const client = await connect();
2826
+ listen(client);
2827
+ return client;
2828
+ } catch (error) {
2829
+ connection = void 0;
2830
+ throw error;
2584
2831
  }
2585
- end() {
2586
- clearTimeout(this.idleTimer);
2587
- clearTimeout(this.refreshTimer);
2588
- for (const requestId of [...this.pending.keys()]) this.settle(requestId);
2589
- this.running = false;
2590
- broadcastActivity();
2591
- this.close();
2832
+ }
2833
+ async function connect() {
2834
+ const endpoint = await ensure();
2835
+ return OpenCode.make({
2836
+ baseUrl: endpoint.url,
2837
+ headers: headers(endpoint)
2838
+ });
2839
+ }
2840
+ async function listen(client) {
2841
+ const controller = new AbortController();
2842
+ listener = controller;
2843
+ const silence = setTimeout(() => controller.abort(), SILENCE_LIMIT$1);
2844
+ try {
2845
+ for await (const event of client.event.subscribe({
2846
+ signal: controller.signal,
2847
+ onActivity: () => silence.refresh()
2848
+ })) route(event);
2849
+ console.warn("the OpenCode event stream ended");
2850
+ } catch (error) {
2851
+ if (listener !== controller) return;
2852
+ console.warn(controller.signal.aborted ? `the OpenCode event stream was silent for ${SILENCE_LIMIT$1 / 1e3} seconds` : `lost the OpenCode event stream: ${error}`);
2853
+ } finally {
2854
+ clearTimeout(silence);
2592
2855
  }
2593
- publishQueue() {
2594
- publish$2(this.sessionId, {
2595
- type: "queue",
2596
- sessionId: this.sessionId,
2597
- queue: this.queue
2598
- });
2856
+ if (listener !== controller) return;
2857
+ connection = reconnect();
2858
+ connection.catch(() => void 0);
2859
+ }
2860
+ async function reconnect() {
2861
+ let failure;
2862
+ for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
2863
+ await setTimeout$1(RECONNECT_DELAY);
2864
+ let client;
2865
+ try {
2866
+ client = await connect();
2867
+ } catch (error) {
2868
+ failure = error;
2869
+ continue;
2870
+ }
2871
+ listen(client);
2872
+ await catchUp(client).catch((error) => console.warn(`could not catch up with OpenCode: ${error}`));
2873
+ return client;
2599
2874
  }
2600
- };
2601
- function sessionSettings(session, settings) {
2602
- const variant = session.model?.variant;
2603
- return {
2604
- ...settings,
2605
- model: session.model === void 0 ? null : `${session.model.providerID}/${session.model.id}`,
2606
- effort: variant === void 0 || variant === "default" ? null : variant,
2607
- mode: session.agent ?? null
2608
- };
2875
+ console.warn(`could not reconnect to OpenCode: ${failure}`);
2876
+ connection = void 0;
2877
+ for (const run of openCodeRuns()) run.exited();
2878
+ refreshSessions("opencode");
2879
+ throw failure;
2609
2880
  }
2610
- function files(prompt) {
2611
- return prompt.images.map((image) => ({ uri: `data:${image.mediaType};base64,${image.data}` }));
2881
+ async function catchUp(client) {
2882
+ const active = await client.session.active();
2883
+ for (const run of openCodeRuns()) {
2884
+ const [permissions, forms] = await Promise.all([client.permission.request.list({ location: { directory: run.cwd } }), client.session.form.list({ sessionID: run.sessionId })]);
2885
+ run.reconcile(client, run.sessionId in active, permissions.data.filter((permission) => permission.sessionID === run.sessionId), forms);
2886
+ }
2887
+ refreshSessions("opencode");
2888
+ }
2889
+ function route(event) {
2890
+ if (listChanges.has(event.type)) refreshSessions("opencode");
2891
+ const sessionId = sessionOf(event);
2892
+ if (sessionId === null) return;
2893
+ const run = runs.get(sessionId);
2894
+ if (run?.provider === "opencode") run.receive(event);
2895
+ }
2896
+ function sessionOf(event) {
2897
+ if (event.type === "form.created") return event.data.form.sessionID;
2898
+ const data = "data" in event ? event.data : null;
2899
+ if (isRecord(data) && typeof data.sessionID === "string") return data.sessionID;
2900
+ return null;
2901
+ }
2902
+ function openCodeRuns() {
2903
+ return [...runs.values()].flatMap((run) => run.provider === "opencode" ? [run] : []);
2612
2904
  }
2613
2905
  //#endregion
2614
2906
  //#region src/services/accounts.ts
@@ -2622,10 +2914,36 @@ function providerName(provider) {
2622
2914
  }
2623
2915
  function storedAccounts() {
2624
2916
  return (readJson("accounts.json") ?? []).map((item) => ({
2625
- ...item,
2626
- provider: item.provider ?? "claude"
2917
+ id: item.id,
2918
+ provider: item.provider,
2919
+ name: item.name,
2920
+ configDir: item.configDir,
2921
+ enabled: item.enabled ?? true,
2922
+ hiddenModels: item.hiddenModels ?? {}
2627
2923
  }));
2628
2924
  }
2925
+ function assertEnabled(provider, accountId) {
2926
+ if (!readConfig().enabledProviders[provider]) throw new Error(`${providerName(provider)} is disabled in Settings`);
2927
+ if (provider === "opencode") return;
2928
+ if (!storedAccount(provider, accountId).enabled) throw new Error("That account is disabled in Settings");
2929
+ }
2930
+ async function stopProvider(provider) {
2931
+ const stopping = [...runs.values()].filter((run) => run.provider === provider);
2932
+ for (const run of stopping) run.stop();
2933
+ await Promise.all(stopping.map((run) => run.closed));
2934
+ if (provider === "codex") stopAppServers();
2935
+ if (provider === "opencode") disconnectOpenCode();
2936
+ if (provider === "claude") {
2937
+ for (const account of storedAccounts()) if (account.provider === "claude") forgetClaude(account.id);
2938
+ }
2939
+ }
2940
+ async function stopAccount(account) {
2941
+ const stopping = [...runs.values()].filter((run) => run.settings.accountId === account.id);
2942
+ for (const run of stopping) run.stop();
2943
+ await Promise.all(stopping.map((run) => run.closed));
2944
+ if (account.provider === "codex") stopAppServer(account.configDir);
2945
+ if (account.provider === "claude") forgetClaude(account.id);
2946
+ }
2629
2947
  function storedAccount(provider, accountId) {
2630
2948
  if (accountId === null) throw new Error(`Add a ${providerName(provider)} account in Settings → Accounts`);
2631
2949
  const account = storedAccounts().find((item) => item.id === accountId);
@@ -2635,49 +2953,92 @@ function storedAccount(provider, accountId) {
2635
2953
  }
2636
2954
  async function accounts() {
2637
2955
  const stored = await Promise.all(storedAccounts().map((account) => {
2956
+ if (!account.enabled || !readConfig().enabledProviders[account.provider]) return disabledAccount(account);
2638
2957
  switch (account.provider) {
2639
2958
  case "claude": return claudeAccount(account);
2640
2959
  case "codex": return codexAccount(account);
2641
- case "opencode": throw new Error("OpenCode has no stored accounts");
2642
2960
  }
2643
2961
  }));
2644
- return openCodeInstalled() ? [...stored, openCodeAccount()] : stored;
2962
+ return {
2963
+ claudeAccounts: stored.filter((item) => item.provider === "claude"),
2964
+ codexAccounts: stored.filter((item) => item.provider === "codex"),
2965
+ openCode: { available: openCodeInstalled() && readConfig().enabledProviders.opencode }
2966
+ };
2967
+ }
2968
+ function disabledAccount(account) {
2969
+ return {
2970
+ ...account,
2971
+ email: null,
2972
+ plan: null,
2973
+ error: null,
2974
+ limits: [],
2975
+ linked: true,
2976
+ valid: false
2977
+ };
2645
2978
  }
2646
2979
  async function publishAccounts() {
2647
2980
  try {
2648
2981
  broadcast({
2649
2982
  type: "accounts",
2650
- accounts: await accounts()
2983
+ ...await accounts()
2651
2984
  });
2652
2985
  } catch (error) {
2653
2986
  console.warn(`could not list accounts: ${error}`);
2654
2987
  }
2655
2988
  }
2656
- function capabilities(accountId, cwd) {
2657
- if (accountId === openCodeAccount().id) return openCodeCapabilities(cwd);
2658
- const account = storedAccounts().find((item) => item.id === accountId);
2659
- if (account === void 0) throw new Error("That account was removed");
2660
- switch (account.provider) {
2661
- case "claude": return claudeCapabilities(account, cwd);
2662
- case "codex": return codexCapabilities(account, cwd);
2663
- case "opencode": throw new Error("OpenCode has no stored accounts");
2989
+ async function capabilities(provider, accountId, cwd) {
2990
+ assertEnabled(provider, accountId);
2991
+ const result = await providerCapabilities(provider, accountId, cwd);
2992
+ const settings = readConfig().models[provider];
2993
+ const overrides = provider === "opencode" ? {} : storedAccount(provider, accountId).hiddenModels;
2994
+ return {
2995
+ ...result,
2996
+ defaultModel: defaultModels[provider],
2997
+ models: result.models.map((model) => {
2998
+ const deprecated = deprecatedModels[provider].includes(model.value);
2999
+ const setting = settings[model.value] ?? {
3000
+ hidden: deprecated,
3001
+ effort: null
3002
+ };
3003
+ return {
3004
+ ...model,
3005
+ deprecated,
3006
+ hidden: overrides[model.value] ?? setting.hidden,
3007
+ effort: setting.effort
3008
+ };
3009
+ })
3010
+ };
3011
+ }
3012
+ function providerCapabilities(provider, accountId, cwd) {
3013
+ switch (provider) {
3014
+ case "claude": return claudeCapabilities(storedAccount(provider, accountId), cwd);
3015
+ case "codex": return codexCapabilities(storedAccount(provider, accountId), cwd);
3016
+ case "opencode":
3017
+ if (accountId !== null) throw new Error("OpenCode has no accounts. Connect providers in OpenCode");
3018
+ return openCodeCapabilities(cwd);
2664
3019
  }
2665
3020
  }
2666
3021
  function addAccount(provider, name, configDir) {
2667
- if (provider === "opencode") throw new Error("OpenCode has no accounts. Connect providers in OpenCode");
2668
3022
  saveAccounts([...storedAccounts(), {
2669
3023
  id: randomUUID(),
2670
3024
  provider,
2671
- ...validAccount(name, configDir)
3025
+ ...validAccount(name, configDir),
3026
+ enabled: true,
3027
+ hiddenModels: {}
2672
3028
  }]);
2673
3029
  }
2674
- function updateAccount(id, name, configDir) {
2675
- const account = validAccount(name, configDir);
2676
- saveAccounts(storedAccounts().map((item) => item.id === id ? {
3030
+ async function updateAccount(update) {
3031
+ const before = storedAccounts().find((item) => item.id === update.id);
3032
+ if (before === void 0) throw new Error("That account was removed");
3033
+ const account = validAccount(update.name, update.configDir);
3034
+ saveAccounts(storedAccounts().map((item) => item.id === update.id ? {
2677
3035
  ...item,
2678
- ...account
3036
+ ...account,
3037
+ enabled: update.enabled,
3038
+ hiddenModels: update.hiddenModels
2679
3039
  } : item));
2680
- forgetClaude(id);
3040
+ forgetClaude(update.id);
3041
+ if (before.enabled && !update.enabled) await stopAccount(before);
2681
3042
  }
2682
3043
  function removeAccount(id) {
2683
3044
  saveAccounts(storedAccounts().filter((item) => item.id !== id));
@@ -2700,81 +3061,6 @@ function saveAccounts(items) {
2700
3061
  writeJson("accounts.json", items);
2701
3062
  }
2702
3063
  //#endregion
2703
- //#region src/lib/claude/content.ts
2704
- function toMessage(type, item, time) {
2705
- return {
2706
- type,
2707
- uuid: item.uuid,
2708
- time,
2709
- content: isRecord(item.message) ? toBlocks(item.message.content, answersOf(item.tool_use_result)) : [],
2710
- parent: item.parent_tool_use_id
2711
- };
2712
- }
2713
- function contextTokens(message) {
2714
- if (!isRecord(message) || !isRecord(message.usage)) return null;
2715
- const usage = message.usage;
2716
- return [
2717
- usage.input_tokens,
2718
- usage.cache_creation_input_tokens,
2719
- usage.cache_read_input_tokens,
2720
- usage.output_tokens
2721
- ].reduce((total, value) => total + (typeof value === "number" ? value : 0), 0);
2722
- }
2723
- function answersOf(output) {
2724
- if (!isRecord(output) || !isRecord(output.answers)) return null;
2725
- const answers = {};
2726
- for (const [question, answer] of Object.entries(output.answers)) {
2727
- if (typeof answer !== "string") return null;
2728
- answers[question] = answer;
2729
- }
2730
- return answers;
2731
- }
2732
- function toBlocks(content, answers) {
2733
- if (typeof content === "string") return [{
2734
- type: "text",
2735
- text: content
2736
- }];
2737
- if (!Array.isArray(content)) return [];
2738
- return content.flatMap((block) => {
2739
- if (!isRecord(block)) return [];
2740
- switch (block.type) {
2741
- case "text": return typeof block.text === "string" && !block.text.startsWith("<system-reminder>") && !block.text.startsWith("<local-command-caveat>") ? [{
2742
- type: "text",
2743
- text: block.text
2744
- }] : [];
2745
- case "thinking": return typeof block.thinking === "string" && block.thinking !== "" ? [{
2746
- type: "thinking",
2747
- thinking: block.thinking
2748
- }] : [];
2749
- case "image": {
2750
- const source = block.source;
2751
- return isRecord(source) && source.type === "base64" && typeof source.media_type === "string" && typeof source.data === "string" ? [{
2752
- type: "image",
2753
- mediaType: source.media_type,
2754
- data: source.data
2755
- }] : [];
2756
- }
2757
- case "tool_use": return typeof block.id === "string" && typeof block.name === "string" && isRecord(block.input) ? [{
2758
- type: "tool_use",
2759
- id: block.id,
2760
- name: block.name,
2761
- input: block.input
2762
- }] : [];
2763
- case "tool_result": return typeof block.tool_use_id === "string" ? [{
2764
- type: "tool_result",
2765
- toolUseId: block.tool_use_id,
2766
- content: toBlocks(block.content, null).filter((item) => item.type === "text"),
2767
- isError: block.is_error === true,
2768
- answers
2769
- }] : [];
2770
- default: return [];
2771
- }
2772
- });
2773
- }
2774
- function isRecord(value) {
2775
- return typeof value === "object" && value !== null && !Array.isArray(value);
2776
- }
2777
- //#endregion
2778
3064
  //#region src/lib/claude/claude.ts
2779
3065
  const efforts = [
2780
3066
  "low",
@@ -2817,13 +3103,15 @@ function claudeMode(value) {
2817
3103
  }
2818
3104
  function claudeOptions(accountId, cwd) {
2819
3105
  const account = storedAccount("claude", accountId);
3106
+ const env = {
3107
+ ...process.env,
3108
+ CLAUDE_CODE_ENTRYPOINT: "offsite",
3109
+ CLAUDE_CONFIG_DIR: account.configDir
3110
+ };
3111
+ if (path.resolve(account.configDir) === path.join(os.homedir(), ".claude")) delete env.CLAUDE_CONFIG_DIR;
2820
3112
  return {
2821
3113
  cwd,
2822
- env: {
2823
- ...process.env,
2824
- CLAUDE_CODE_ENTRYPOINT: "offsite",
2825
- CLAUDE_CONFIG_DIR: account.configDir
2826
- },
3114
+ env,
2827
3115
  pathToClaudeCodeExecutable: executable$1,
2828
3116
  systemPrompt: {
2829
3117
  type: "preset",
@@ -2832,7 +3120,7 @@ function claudeOptions(accountId, cwd) {
2832
3120
  hooks: { PostToolUse: [{
2833
3121
  matcher: "PushNotification",
2834
3122
  hooks: [async (input) => {
2835
- if (input.hook_event_name !== "PostToolUse" || typeof input.tool_input !== "object" || input.tool_input === null || !("message" in input.tool_input) || typeof input.tool_input.message !== "string") throw new Error("Unexpected PushNotification hook input");
3123
+ if (input.hook_event_name !== "PostToolUse" || !isRecord(input.tool_input) || typeof input.tool_input.message !== "string") throw new Error("Unexpected PushNotification hook input");
2836
3124
  broadcast({
2837
3125
  type: "notification",
2838
3126
  sessionId: input.session_id,
@@ -2853,12 +3141,15 @@ function claudeOptions(accountId, cwd) {
2853
3141
  async function claudeCapabilities(account, cwd) {
2854
3142
  const result = await initialization(account.id, cwd);
2855
3143
  return {
2856
- models: result.models.map((model) => ({
2857
- value: model.value,
2858
- name: model.displayName,
2859
- description: model.description,
2860
- efforts: model.supportedEffortLevels ?? []
2861
- })),
3144
+ models: result.models.filter((model) => model.value !== "default").map((model) => {
3145
+ if (model.resolvedModel === void 0) throw new Error(`Claude did not resolve the ${model.value} model`);
3146
+ return {
3147
+ value: model.resolvedModel,
3148
+ name: model.displayName,
3149
+ description: model.description,
3150
+ efforts: model.supportedEffortLevels ?? []
3151
+ };
3152
+ }),
2862
3153
  commands: result.commands.map((command) => ({
2863
3154
  name: command.name,
2864
3155
  description: command.description,
@@ -2919,6 +3210,28 @@ function recordLimit(accountId, info) {
2919
3210
  limits.set(accountId, current);
2920
3211
  return accountLimits(accountId);
2921
3212
  }
3213
+ async function recordUsage(accountId, probe) {
3214
+ try {
3215
+ const usage = await probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
3216
+ if (usage.rate_limits === null) return;
3217
+ const current = limits.get(accountId) ?? /* @__PURE__ */ new Map();
3218
+ const windows = {
3219
+ five_hour: usage.rate_limits.five_hour,
3220
+ seven_day: usage.rate_limits.seven_day,
3221
+ seven_day_opus: usage.rate_limits.seven_day_opus,
3222
+ seven_day_sonnet: usage.rate_limits.seven_day_sonnet
3223
+ };
3224
+ for (const [name, window] of Object.entries(windows)) if (window !== void 0 && window !== null) current.set(name, {
3225
+ window: name,
3226
+ utilization: window.utilization === null ? null : window.utilization / 100,
3227
+ resetsAt: window.resets_at === null ? null : Date.parse(window.resets_at) / 1e3,
3228
+ rejected: window.utilization !== null && window.utilization >= 100
3229
+ });
3230
+ limits.set(accountId, current);
3231
+ } catch (error) {
3232
+ console.warn(`could not read Claude usage limits: ${error}`);
3233
+ }
3234
+ }
2922
3235
  function isLinked(configDir) {
2923
3236
  const base = readConfig().claudeBaseDir;
2924
3237
  try {
@@ -2948,7 +3261,9 @@ async function runProbe(accountId, cwd) {
2948
3261
  options: claudeOptions(accountId, cwd)
2949
3262
  });
2950
3263
  try {
2951
- return await probe.initializationResult();
3264
+ const result = await probe.initializationResult();
3265
+ await recordUsage(accountId, probe);
3266
+ return result;
2952
3267
  } finally {
2953
3268
  closed.resolve();
2954
3269
  probe.close();
@@ -3007,14 +3322,15 @@ function toItems(messages) {
3007
3322
  }
3008
3323
  if (message.type === "assistant") {
3009
3324
  message.content.forEach((block, index) => {
3010
- const id = `${message.uuid}:${index}`;
3011
- const parent = message.parent;
3325
+ const shared = {
3326
+ id: `${message.uuid}:${index}`,
3327
+ parent: message.parent,
3328
+ time: message.time
3329
+ };
3012
3330
  switch (block.type) {
3013
3331
  case "text":
3014
3332
  if (block.text.trim() !== "") items.push({
3015
- id,
3016
- parent,
3017
- time: message.time,
3333
+ ...shared,
3018
3334
  kind: "assistant",
3019
3335
  text: block.text,
3020
3336
  message: message.uuid
@@ -3022,21 +3338,19 @@ function toItems(messages) {
3022
3338
  break;
3023
3339
  case "thinking":
3024
3340
  if (block.thinking.trim() !== "") items.push({
3025
- id,
3026
- parent,
3027
- time: message.time,
3341
+ ...shared,
3028
3342
  kind: "reasoning",
3029
3343
  text: block.thinking
3030
3344
  });
3031
3345
  break;
3032
3346
  case "tool_use":
3033
3347
  if (block.name === "SendMessage") {
3034
- const owner = owners.get(text(block.input.to));
3348
+ const owner = owners.get(text$1(block.input.to));
3035
3349
  if (owner !== void 0) change(owner, { status: "running" });
3036
3350
  }
3037
- if (block.name === "TaskStop") stops.set(block.id, text(block.input.task_id));
3351
+ if (block.name === "TaskStop") stops.set(block.id, text$1(block.input.task_id));
3038
3352
  tools.set(block.id, items.length);
3039
- items.push(toolItem(block, parent, message.time, (to) => {
3353
+ items.push(toolItem(block, message.parent, message.time, (to) => {
3040
3354
  const owner = owners.get(to);
3041
3355
  return owner === void 0 ? null : items[owner].id;
3042
3356
  }));
@@ -3125,7 +3439,7 @@ function toRequest(options) {
3125
3439
  case "ExitPlanMode": return {
3126
3440
  id: options.id,
3127
3441
  kind: "plan",
3128
- plan: text(options.input.plan)
3442
+ plan: text$1(options.input.plan)
3129
3443
  };
3130
3444
  default: return {
3131
3445
  id: options.id,
@@ -3152,8 +3466,8 @@ function toolItem(tool, parent, time, ownerOf) {
3152
3466
  case "Bash": return {
3153
3467
  ...base,
3154
3468
  kind: "command",
3155
- command: text(input.command),
3156
- description: text(input.description),
3469
+ command: text$1(input.command),
3470
+ description: text$1(input.description),
3157
3471
  output: "",
3158
3472
  status: "running",
3159
3473
  background: input.run_in_background === true ? {
@@ -3165,8 +3479,8 @@ function toolItem(tool, parent, time, ownerOf) {
3165
3479
  case "Monitor": return {
3166
3480
  ...base,
3167
3481
  kind: "command",
3168
- command: text(input.command) || (isRecord(input.ws) ? text(input.ws.url) : ""),
3169
- description: text(input.description),
3482
+ command: text$1(input.command) || (isRecord(input.ws) ? text$1(input.ws.url) : ""),
3483
+ description: text$1(input.description),
3170
3484
  output: "",
3171
3485
  status: "running",
3172
3486
  background: {
@@ -3178,10 +3492,10 @@ function toolItem(tool, parent, time, ownerOf) {
3178
3492
  case "Edit": return {
3179
3493
  ...base,
3180
3494
  kind: "file_change",
3181
- path: text(input.file_path),
3495
+ path: text$1(input.file_path),
3182
3496
  diffs: [{
3183
- before: text(input.old_string),
3184
- after: text(input.new_string)
3497
+ before: text$1(input.old_string),
3498
+ after: text$1(input.new_string)
3185
3499
  }],
3186
3500
  output: "",
3187
3501
  status: "running"
@@ -3189,7 +3503,7 @@ function toolItem(tool, parent, time, ownerOf) {
3189
3503
  case "MultiEdit": return {
3190
3504
  ...base,
3191
3505
  kind: "file_change",
3192
- path: text(input.file_path),
3506
+ path: text$1(input.file_path),
3193
3507
  diffs: input.edits.map((edit) => ({
3194
3508
  before: edit.old_string,
3195
3509
  after: edit.new_string
@@ -3200,10 +3514,10 @@ function toolItem(tool, parent, time, ownerOf) {
3200
3514
  case "Write": return {
3201
3515
  ...base,
3202
3516
  kind: "file_change",
3203
- path: text(input.file_path),
3517
+ path: text$1(input.file_path),
3204
3518
  diffs: [{
3205
3519
  before: "",
3206
- after: text(input.content)
3520
+ after: text$1(input.content)
3207
3521
  }],
3208
3522
  output: "",
3209
3523
  status: "running"
@@ -3211,7 +3525,7 @@ function toolItem(tool, parent, time, ownerOf) {
3211
3525
  case "NotebookEdit": return {
3212
3526
  ...base,
3213
3527
  kind: "file_change",
3214
- path: text(input.notebook_path),
3528
+ path: text$1(input.notebook_path),
3215
3529
  diffs: [],
3216
3530
  output: "",
3217
3531
  status: "running"
@@ -3220,7 +3534,7 @@ function toolItem(tool, parent, time, ownerOf) {
3220
3534
  ...base,
3221
3535
  kind: "web_search",
3222
3536
  query: null,
3223
- url: text(input.url),
3537
+ url: text$1(input.url),
3224
3538
  results: [],
3225
3539
  output: "",
3226
3540
  status: "running"
@@ -3228,7 +3542,7 @@ function toolItem(tool, parent, time, ownerOf) {
3228
3542
  case "WebSearch": return {
3229
3543
  ...base,
3230
3544
  kind: "web_search",
3231
- query: text(input.query),
3545
+ query: text$1(input.query),
3232
3546
  url: null,
3233
3547
  results: [],
3234
3548
  output: "",
@@ -3237,7 +3551,7 @@ function toolItem(tool, parent, time, ownerOf) {
3237
3551
  case "Skill": return {
3238
3552
  ...base,
3239
3553
  kind: "skill",
3240
- skill: text(input.skill),
3554
+ skill: text$1(input.skill),
3241
3555
  output: "",
3242
3556
  status: "running"
3243
3557
  };
@@ -3252,7 +3566,7 @@ function toolItem(tool, parent, time, ownerOf) {
3252
3566
  case "ExitPlanMode": return {
3253
3567
  ...base,
3254
3568
  kind: "plan",
3255
- plan: text(input.plan)
3569
+ plan: text$1(input.plan)
3256
3570
  };
3257
3571
  case "AskUserQuestion": return {
3258
3572
  ...base,
@@ -3263,23 +3577,23 @@ function toolItem(tool, parent, time, ownerOf) {
3263
3577
  };
3264
3578
  case "Agent":
3265
3579
  case "Task": {
3266
- const agent = text(input.subagent_type);
3580
+ const agent = text$1(input.subagent_type);
3267
3581
  return {
3268
3582
  ...base,
3269
3583
  kind: "subagent",
3270
3584
  agent: agent === "" ? "general-purpose" : agent,
3271
- description: text(input.description),
3585
+ description: text$1(input.description),
3272
3586
  agentId: null,
3273
3587
  status: "running"
3274
3588
  };
3275
3589
  }
3276
3590
  case "SendMessage": {
3277
- const summary = text(input.summary);
3591
+ const summary = text$1(input.summary);
3278
3592
  return {
3279
3593
  ...base,
3280
3594
  kind: "agent_message",
3281
- target: ownerOf(text(input.to)),
3282
- summary: summary === "" ? text(input.message).split("\n")[0] : summary,
3595
+ target: ownerOf(text$1(input.to)),
3596
+ summary: summary === "" ? text$1(input.message).split("\n")[0] : summary,
3283
3597
  status: "running"
3284
3598
  };
3285
3599
  }
@@ -3369,7 +3683,7 @@ function userText(value) {
3369
3683
  text: value
3370
3684
  };
3371
3685
  }
3372
- function text(value) {
3686
+ function text$1(value) {
3373
3687
  return typeof value === "string" ? value : "";
3374
3688
  }
3375
3689
  //#endregion
@@ -3498,11 +3812,13 @@ async function history(sessionId, cwd) {
3498
3812
  const seen = new Set(messages.map((item) => item.uuid));
3499
3813
  const last = kept.findLast((item) => item.type === "assistant" && item.parent_tool_use_id === null);
3500
3814
  const tokens = last === void 0 ? null : contextTokens(last.message);
3815
+ const live = run?.messages ?? [];
3816
+ const compacted = live.findLastIndex((item) => item.type === "compaction");
3501
3817
  return {
3502
3818
  type: "history",
3503
3819
  sessionId,
3504
3820
  provider: "claude",
3505
- items: toItems([...messages, ...run?.messages.filter((item) => !seen.has(item.uuid)) ?? []]),
3821
+ items: toItems([...messages, ...live.slice(compacted + 1).filter((item) => !seen.has(item.uuid))]),
3506
3822
  requests: [...run?.pending.values() ?? []].map((item) => item.request),
3507
3823
  queue: run?.queue ?? [],
3508
3824
  settings: run?.settings ?? null,
@@ -3538,12 +3854,6 @@ async function agentMessages(sessionId, cwd, agentId, parent) {
3538
3854
  //#endregion
3539
3855
  //#region src/lib/claude/run.ts
3540
3856
  const IDLE_LIMIT = 6e5;
3541
- const imageTypes = [
3542
- "image/png",
3543
- "image/jpeg",
3544
- "image/gif",
3545
- "image/webp"
3546
- ];
3547
3857
  var Run = class {
3548
3858
  provider = "claude";
3549
3859
  sessionId;
@@ -3673,42 +3983,10 @@ var Run = class {
3673
3983
  }
3674
3984
  push(prompt, priority) {
3675
3985
  clearTimeout(this.idleTimer);
3676
- for (const image of prompt.images) if (!imageTypes.includes(image.mediaType)) throw new Error(`${image.mediaType} images are not supported`);
3677
3986
  const uuid = randomUUID();
3678
- this.inbox.push({
3679
- type: "user",
3680
- message: {
3681
- role: "user",
3682
- content: [...prompt.images.map((image) => ({
3683
- type: "image",
3684
- source: {
3685
- type: "base64",
3686
- media_type: image.mediaType,
3687
- data: image.data
3688
- }
3689
- })), ...prompt.text === "" ? [] : [{
3690
- type: "text",
3691
- text: prompt.text
3692
- }]]
3693
- },
3694
- parent_tool_use_id: null,
3695
- uuid,
3696
- ...priority === void 0 ? {} : { priority }
3697
- });
3987
+ this.inbox.push(toUserInput(prompt, uuid, priority));
3698
3988
  this.wake?.();
3699
- this.add({
3700
- type: "user",
3701
- uuid,
3702
- time: Date.now(),
3703
- content: [...prompt.images.map((image) => ({
3704
- type: "image",
3705
- ...image
3706
- })), ...prompt.text === "" ? [] : [{
3707
- type: "text",
3708
- text: prompt.text
3709
- }]],
3710
- parent: null
3711
- });
3989
+ this.add(promptMessage(prompt, uuid));
3712
3990
  this.running = true;
3713
3991
  this.prompted = true;
3714
3992
  broadcastActivity();
@@ -3902,6 +4180,7 @@ var Run = class {
3902
4180
  request
3903
4181
  });
3904
4182
  broadcastActivity();
4183
+ scheduleAutoResolve(this.sessionId, request);
3905
4184
  options.signal.addEventListener("abort", () => this.settle(request.id, {
3906
4185
  behavior: "deny",
3907
4186
  message: "Cancelled"
@@ -3952,21 +4231,13 @@ var Run = class {
3952
4231
  });
3953
4232
  }
3954
4233
  };
3955
- function notice(text) {
3956
- return {
3957
- type: "notice",
3958
- uuid: randomUUID(),
3959
- time: Date.now(),
3960
- text,
3961
- isError: true
3962
- };
3963
- }
3964
4234
  //#endregion
3965
4235
  //#region src/services/git.ts
3966
4236
  const DIFF_LIMIT = 8e4;
3967
4237
  const UNTRACKED_LIMIT = 100;
3968
4238
  const NEW_FILE_LIMIT = 262144;
3969
4239
  const REPO_DEPTH = 3;
4240
+ const run$1 = promisify(execFile);
3970
4241
  async function gitStatus(cwd) {
3971
4242
  let output;
3972
4243
  try {
@@ -4020,20 +4291,24 @@ async function push$1(cwd) {
4020
4291
  return gitStatus(cwd);
4021
4292
  }
4022
4293
  async function createPullRequest(cwd) {
4023
- const { stdout } = await promisify(execFile)("gh", [
4294
+ const url = (await run$1("gh", [
4024
4295
  "pr",
4025
4296
  "create",
4026
4297
  "--fill"
4027
- ], { cwd });
4028
- const url = stdout.trim().split("\n").at(-1);
4298
+ ], { cwd })).stdout.trim().split("\n").at(-1);
4029
4299
  if (url === void 0) throw new Error("gh did not return a pull request URL");
4030
4300
  return { url };
4031
4301
  }
4032
- async function suggestCommitMessage(provider, accountId, cwd) {
4033
- const diff = (await workingDiff(cwd)).slice(0, DIFF_LIMIT);
4302
+ async function suggestCommitMessage(cwd) {
4303
+ const settings = readConfig().commitMessage;
4304
+ const provider = settings.provider;
4305
+ const accountId = settings.accountId;
4306
+ const model = settings.model;
4307
+ if (provider === null || model === null) throw new Error("Choose a commit message model in Settings → Git");
4308
+ assertEnabled(provider, accountId);
4309
+ if (!await isRepo(cwd)) throw new Error(`${cwd} is not a git repository`);
4310
+ const diff = (await repoDiff(cwd, "")).slice(0, DIFF_LIMIT);
4034
4311
  if (diff === "") throw new Error("There is nothing to commit");
4035
- const model = readConfig().commitModels[provider];
4036
- if (model === null) throw new Error(`Choose a ${providerName(provider)} commit model in Settings → Accounts`);
4037
4312
  const prompt = `Write a git commit message for this diff. Reply with only the message: a short summary line, optionally followed by a blank line and a brief body.\n\n${diff}`;
4038
4313
  if (provider === "codex") return codexCommitMessage(accountId, cwd, model, prompt);
4039
4314
  if (provider === "opencode") return openCodeCommitMessage(cwd, model, prompt);
@@ -4056,10 +4331,6 @@ async function suggestCommitMessage(provider, accountId, cwd) {
4056
4331
  }
4057
4332
  throw new Error("Claude did not reply");
4058
4333
  }
4059
- async function workingDiff(cwd) {
4060
- if (!await isRepo(cwd)) throw new Error(`${cwd} is not a git repository`);
4061
- return repoDiff(cwd, "");
4062
- }
4063
4334
  async function projectDiff(cwd) {
4064
4335
  if (await isRepo(cwd)) return repoDiff(cwd, "");
4065
4336
  const repos = childRepos(cwd, "", REPO_DEPTH);
@@ -4067,7 +4338,7 @@ async function projectDiff(cwd) {
4067
4338
  return (await Promise.all(repos.map((repo) => repoDiff(path.join(cwd, repo), `${repo}/`)))).join("");
4068
4339
  }
4069
4340
  function git(cwd, args) {
4070
- return promisify(execFile)("git", args, {
4341
+ return run$1("git", args, {
4071
4342
  cwd,
4072
4343
  maxBuffer: 67108864
4073
4344
  });
@@ -4216,7 +4487,7 @@ async function taskOutput(sessionId, file) {
4216
4487
  try {
4217
4488
  stats = await lstat(file);
4218
4489
  } catch (error) {
4219
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
4490
+ if (hasCode(error, "ENOENT")) return null;
4220
4491
  throw error;
4221
4492
  }
4222
4493
  if (!stats.isFile()) throw new Error("Background task output is not a regular file");
@@ -4254,73 +4525,80 @@ function walk(root) {
4254
4525
  }
4255
4526
  //#endregion
4256
4527
  //#region src/services/push.ts
4257
- const pushFile = path.join(home, "push.json");
4258
4528
  const TITLE_LIMIT = 100;
4259
4529
  const BODY_LIMIT = 500;
4260
4530
  const TIME_TO_LIVE = 900;
4261
4531
  const SEND_TIMEOUT = 1e4;
4262
4532
  const SUBJECT = "https://github.com/NazmusSayad/offsite";
4263
4533
  let server$1 = "";
4264
- let active = false;
4534
+ let notifier = null;
4265
4535
  function startPush(address) {
4266
4536
  server$1 = address;
4267
- try {
4268
- const store = readJson("push.json");
4269
- if (store !== null) track(store);
4270
- } catch (error) {
4271
- console.warn(`push notifications are off until a browser turns them on again, could not read ${pushFile}: ${error}`);
4272
- }
4273
4537
  }
4274
4538
  function pushActive() {
4275
- return active;
4276
- }
4277
- function pushKey() {
4278
- return { publicKey: read().publicKey };
4279
- }
4280
- function subscribe(subscription) {
4281
- if (new URL(subscription.endpoint).protocol !== "https:") throw new Error("the endpoint must be an https URL");
4282
- update((store) => {
4283
- store.subscriptions = store.subscriptions.filter((existing) => existing.endpoint !== subscription.endpoint);
4284
- store.subscriptions.push({
4285
- endpoint: subscription.endpoint,
4286
- keys: {
4287
- p256dh: subscription.keys.p256dh,
4288
- auth: subscription.keys.auth
4289
- },
4290
- server: server$1
4291
- });
4539
+ return targets().length > 0;
4540
+ }
4541
+ function notifierClient() {
4542
+ return notifier === null ? null : notifier.client;
4543
+ }
4544
+ function claimNotifier(client, endpoint) {
4545
+ notifier = {
4546
+ client,
4547
+ endpoint
4548
+ };
4549
+ for (const item of clients) item.send({
4550
+ type: "notifier",
4551
+ active: item === client
4292
4552
  });
4553
+ }
4554
+ function subscribe(registration, client) {
4555
+ if (new URL(registration.subscription.endpoint).protocol !== "https:") throw new Error("the endpoint must be an https URL");
4556
+ const values = {
4557
+ endpoint: registration.subscription.endpoint,
4558
+ p256dh: registration.subscription.keys.p256dh,
4559
+ auth: registration.subscription.keys.auth,
4560
+ publicKey: registration.publicKey,
4561
+ privateKey: registration.privateKey,
4562
+ machine: registration.machine,
4563
+ server: server$1
4564
+ };
4565
+ db.insert(pushSubscriptions).values(values).onConflictDoUpdate({
4566
+ target: pushSubscriptions.endpoint,
4567
+ set: values
4568
+ }).run();
4569
+ if (notifier !== null && notifier.client === client) notifier.endpoint = registration.subscription.endpoint;
4293
4570
  console.log("a browser subscribed to push notifications");
4294
4571
  }
4295
4572
  function unsubscribe(endpoint) {
4296
- update((store) => {
4297
- store.subscriptions = store.subscriptions.filter((existing) => existing.endpoint !== endpoint);
4298
- });
4573
+ db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)).run();
4574
+ if (notifier !== null && notifier.endpoint === endpoint) notifier.endpoint = null;
4299
4575
  console.log("a browser unsubscribed from push notifications");
4300
4576
  }
4301
4577
  async function sendPush(message) {
4302
- let store;
4303
- try {
4304
- store = read();
4305
- } catch (error) {
4306
- console.error(`could not read push subscriptions: ${error}`);
4307
- return;
4308
- }
4309
- const subscriptions = store.subscriptions.filter(ours);
4578
+ const subscriptions = targets();
4310
4579
  if (subscriptions.length === 0) return;
4311
- const payload = JSON.stringify({
4580
+ const trimmed = {
4312
4581
  ...message,
4313
4582
  title: [...message.title].slice(0, TITLE_LIMIT).join(""),
4314
4583
  body: [...message.body].slice(0, BODY_LIMIT).join("")
4315
- });
4584
+ };
4316
4585
  const gone = [];
4317
4586
  await Promise.all(subscriptions.map(async (subscription) => {
4318
4587
  try {
4319
- await webPush.sendNotification(subscription, payload, {
4588
+ await webPush.sendNotification({
4589
+ endpoint: subscription.endpoint,
4590
+ keys: {
4591
+ p256dh: subscription.p256dh,
4592
+ auth: subscription.auth
4593
+ }
4594
+ }, JSON.stringify({
4595
+ ...trimmed,
4596
+ machineId: subscription.machine
4597
+ }), {
4320
4598
  vapidDetails: {
4321
4599
  subject: SUBJECT,
4322
- publicKey: store.publicKey,
4323
- privateKey: store.privateKey
4600
+ publicKey: subscription.publicKey,
4601
+ privateKey: subscription.privateKey
4324
4602
  },
4325
4603
  TTL: TIME_TO_LIVE,
4326
4604
  urgency: "high",
@@ -4342,59 +4620,11 @@ async function sendPush(message) {
4342
4620
  }
4343
4621
  }));
4344
4622
  if (gone.length === 0) return;
4345
- try {
4346
- update((current) => {
4347
- current.subscriptions = current.subscriptions.filter((subscription) => !gone.includes(subscription.endpoint));
4348
- });
4349
- } catch (error) {
4350
- console.error(`could not remove gone push subscriptions: ${error}`);
4351
- }
4352
- }
4353
- function ours(subscription) {
4354
- return subscription.server === server$1;
4623
+ db.delete(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), inArray(pushSubscriptions.endpoint, gone))).run();
4355
4624
  }
4356
- function track(store) {
4357
- active = store.subscriptions.some(ours);
4358
- }
4359
- function load() {
4360
- let store;
4361
- try {
4362
- store = readJson("push.json");
4363
- } catch (error) {
4364
- if (!(error instanceof SyntaxError)) throw error;
4365
- return replaceCorrupt(error);
4366
- }
4367
- if (store === null) return create();
4368
- 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"));
4369
- return store;
4370
- }
4371
- function replaceCorrupt(error) {
4372
- const aside = `${pushFile}.corrupt-${randomUUID()}`;
4373
- renameSync(pushFile, aside);
4374
- console.warn(`${pushFile} is corrupt, moved it to ${aside} and created a new one: ${error}`);
4375
- return create();
4376
- }
4377
- function create() {
4378
- const store = {
4379
- ...webPush.generateVAPIDKeys(),
4380
- subscriptions: []
4381
- };
4382
- save(store);
4383
- return store;
4384
- }
4385
- function save(store) {
4386
- writeJson("push.json", store);
4387
- }
4388
- function read() {
4389
- const store = load();
4390
- track(store);
4391
- return store;
4392
- }
4393
- function update(change) {
4394
- const store = load();
4395
- change(store);
4396
- save(store);
4397
- track(store);
4625
+ function targets() {
4626
+ if (notifier === null || notifier.endpoint === null) return [];
4627
+ return db.select().from(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), eq(pushSubscriptions.endpoint, notifier.endpoint))).all();
4398
4628
  }
4399
4629
  //#endregion
4400
4630
  //#region src/services/runs.ts
@@ -4415,9 +4645,10 @@ function rewind(request) {
4415
4645
  }
4416
4646
  async function rewindNow(request) {
4417
4647
  const sessionId = request.sessionId;
4418
- if (runs.get(sessionId)?.running === true) throw new Error("Stop Claude before rewinding");
4648
+ const cwd = projectPath(request.projectId);
4649
+ if (runs.get(sessionId)?.running === true) throw new Error(`Stop ${providerName(providerOf(sessionId))} before rewinding`);
4419
4650
  if ((runs.get(sessionId)?.backgroundTasks ?? 0) > 0) throw new Error("Wait for background tasks to finish before rewinding");
4420
- const run = await runFor(sessionId, request.cwd, request.settings);
4651
+ const run = await runFor(sessionId, cwd, request.settings);
4421
4652
  if (run.provider === "codex") {
4422
4653
  if (request.code) throw new Error("Codex cannot restore files");
4423
4654
  if (request.dryRun || !request.conversation) return null;
@@ -4430,7 +4661,7 @@ async function rewindNow(request) {
4430
4661
  const result = await rewindOpenCode(request);
4431
4662
  if (request.dryRun) return result;
4432
4663
  run.items.clear();
4433
- publish$2(sessionId, await openCodeHistory(sessionId, request.cwd));
4664
+ publish$2(sessionId, await openCodeHistory(sessionId, cwd));
4434
4665
  return result;
4435
4666
  }
4436
4667
  let result = null;
@@ -4444,58 +4675,60 @@ async function rewindNow(request) {
4444
4675
  };
4445
4676
  }
4446
4677
  if (request.dryRun || !request.conversation) return result;
4447
- const disk = await getSessionMessages(sessionId, { dir: request.cwd });
4678
+ const disk = await getSessionMessages(sessionId, { dir: cwd });
4448
4679
  const index = disk.findIndex((item) => item.uuid === request.messageId);
4449
4680
  if (index < 1) throw new Error("Cannot rewind the conversation to that point");
4450
4681
  run.stop();
4451
4682
  await run.closed;
4452
4683
  new Run({
4453
4684
  sessionId,
4454
- cwd: request.cwd,
4685
+ cwd,
4455
4686
  resume: true,
4456
4687
  resumeAt: disk[index - 1].uuid,
4457
4688
  settings: request.settings
4458
4689
  });
4459
- publish$2(sessionId, await history(sessionId, request.cwd));
4690
+ publish$2(sessionId, await history(sessionId, cwd));
4460
4691
  return result;
4461
4692
  }
4462
- async function open$1(client, sessionId, cwd) {
4693
+ async function open$1(client, sessionId, projectId) {
4463
4694
  client.opening = sessionId;
4464
- const event = await sessionHistory(sessionId, cwd);
4695
+ const event = await sessionHistory(sessionId, projectPath(projectId));
4465
4696
  if (client.opening !== sessionId) return;
4466
4697
  client.sessionId = sessionId;
4467
4698
  client.send(event);
4468
4699
  }
4469
4700
  async function send$1(client, request) {
4470
- if (!statSync(request.cwd, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${request.cwd} is not a folder`);
4701
+ assertEnabled(request.settings.provider, request.settings.accountId);
4702
+ const cwd = projectPath(request.projectId);
4703
+ if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${cwd} is not a folder`);
4471
4704
  if (request.sessionId === null) {
4472
- const run = await startRun(request.cwd, request.settings);
4705
+ const run = await startRun(cwd, request.settings);
4473
4706
  const sessionId = run.sessionId;
4474
4707
  client.opening = sessionId;
4475
4708
  client.sessionId = sessionId;
4476
4709
  client.send({
4477
4710
  type: "started",
4478
4711
  sessionId,
4479
- cwd: request.cwd
4712
+ projectId: request.projectId
4480
4713
  });
4481
4714
  await run.submit(request.prompt, request.delivery);
4482
4715
  recordInteraction(sessionId);
4483
4716
  return;
4484
4717
  }
4485
4718
  const sessionId = request.sessionId;
4486
- await locked(sessionId, () => sendExisting(client, sessionId, request));
4719
+ await locked(sessionId, () => sendExisting(client, sessionId, cwd, request));
4487
4720
  }
4488
- async function sendExisting(client, sessionId, request) {
4721
+ async function sendExisting(client, sessionId, cwd, request) {
4489
4722
  const provider = providerOf(sessionId);
4490
4723
  if (request.settings.provider !== provider) throw new Error(`This session belongs to ${providerName(provider)}`);
4491
4724
  const existing = runs.get(sessionId);
4492
- if (existing?.provider === "claude" && !existing.running && existing.backgroundTasks === 0 && await changedElsewhere(existing, request.cwd)) {
4725
+ if (existing?.provider === "claude" && !existing.running && existing.backgroundTasks === 0 && await changedElsewhere(existing, cwd) || existing?.provider === "codex" && !existing.running && existing.pending.size === 0 && (listedUpdatedAt("codex", sessionId) ?? 0) > existing.activeAt) {
4493
4726
  existing.stop();
4494
4727
  await existing.closed;
4495
4728
  }
4496
- const run = await runFor(sessionId, request.cwd, request.settings);
4729
+ const run = await runFor(sessionId, cwd, request.settings);
4497
4730
  if (run === existing) await run.configure(request.settings);
4498
- else await open$1(client, sessionId, request.cwd);
4731
+ else await open$1(client, sessionId, request.projectId);
4499
4732
  await run.submit(request.prompt, request.delivery);
4500
4733
  recordInteraction(sessionId);
4501
4734
  }
@@ -4639,7 +4872,7 @@ function shellIntegrationIssue(command, platform, defaultShell) {
4639
4872
  }
4640
4873
  //#endregion
4641
4874
  //#region src/services/terminal/shell-integration.ts
4642
- const scripts = path.join(serverDir, "src", "services", "terminal", "shell-integration");
4875
+ const scripts = path.join(serverDir, "src/services/terminal/shell-integration");
4643
4876
  const folder = path.join(home, "shell-integration");
4644
4877
  function prepare(env, command, shell) {
4645
4878
  if (shellIntegrationIssue(command, platform(), shell) !== null) return null;
@@ -4856,7 +5089,7 @@ function launch() {
4856
5089
  offsite: [
4857
5090
  "offsite",
4858
5091
  "offsite",
4859
- version
5092
+ "0.0.0"
4860
5093
  ],
4861
5094
  ghostty: [
4862
5095
  "ghostty",
@@ -5139,7 +5372,7 @@ async function request(connection, message) {
5139
5372
  return;
5140
5373
  }
5141
5374
  case "createTab":
5142
- await createTab(message.cwd);
5375
+ await createTab(message.projectId);
5143
5376
  break;
5144
5377
  case "closeTab":
5145
5378
  closeTab(message.id);
@@ -5163,17 +5396,17 @@ async function request(connection, message) {
5163
5396
  break;
5164
5397
  }
5165
5398
  case "select": {
5166
- const project = projects.get(message.cwd) ?? {
5399
+ const project = projects.get(message.projectId) ?? {
5167
5400
  tabs: [],
5168
5401
  activeTab: null
5169
5402
  };
5170
- projects.set(message.cwd, project);
5403
+ projects.set(message.projectId, project);
5171
5404
  const tab = project.tabs.find((item) => item.id === message.tabId);
5172
5405
  if (tab !== void 0) {
5173
5406
  tab.minimized = false;
5174
5407
  project.activeTab = tab.id;
5175
5408
  }
5176
- if (project.tabs.length === 0) await createTab(message.cwd);
5409
+ if (project.tabs.length === 0) await createTab(message.projectId);
5177
5410
  break;
5178
5411
  }
5179
5412
  }
@@ -5206,16 +5439,17 @@ function input(connection, bytes) {
5206
5439
  }
5207
5440
  terminal.pty.write(Buffer.from(frame.data));
5208
5441
  }
5209
- async function createTab(cwd) {
5442
+ async function createTab(projectId) {
5443
+ const cwd = projectPath(projectId);
5210
5444
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
5211
5445
  console.error(`${cwd} is not a folder, terminal not created`);
5212
5446
  return;
5213
5447
  }
5214
- const project = projects.get(cwd) ?? {
5448
+ const project = projects.get(projectId) ?? {
5215
5449
  tabs: [],
5216
5450
  activeTab: null
5217
5451
  };
5218
- projects.set(cwd, project);
5452
+ projects.set(projectId, project);
5219
5453
  const active = project.activeTab === null ? void 0 : terminals.get(project.activeTab);
5220
5454
  let directory = cwd;
5221
5455
  if (active !== void 0) try {
@@ -5323,7 +5557,7 @@ function scan(id, terminal, data) {
5323
5557
  return;
5324
5558
  }
5325
5559
  const alert = terminal.scanner.scan(data);
5326
- if (alert === null || browserOpen()) return;
5560
+ if (alert === null || notifierWatching()) return;
5327
5561
  if (!(alert.type === "bell" ? terminal.alerts.startBell() : terminal.alerts.startNotification())) return;
5328
5562
  const windowTitle = terminal.scanner.title;
5329
5563
  setTimeout(() => push(id, alert, windowTitle), 500);
@@ -5340,7 +5574,7 @@ function push(id, alert, windowTitle) {
5340
5574
  title = alert.title;
5341
5575
  body = alert.body;
5342
5576
  }
5343
- if (browserOpen()) return;
5577
+ if (notifierWatching()) return;
5344
5578
  const entry = [...projects].find(([, project]) => project.tabs.some((tab) => tab.id === id));
5345
5579
  if (entry === void 0) return;
5346
5580
  const tab = findTab(id);
@@ -5348,18 +5582,17 @@ function push(id, alert, windowTitle) {
5348
5582
  sendPush({
5349
5583
  title,
5350
5584
  body,
5351
- cwd: entry[0],
5585
+ projectId: entry[0],
5352
5586
  tabId: id
5353
5587
  });
5354
5588
  }
5355
- function browserOpen() {
5356
- return [...connections.values()].some((connection) => Date.now() - connection.client.heardAt < SILENCE_LIMIT);
5589
+ function notifierWatching() {
5590
+ const client = notifierClient();
5591
+ if (client === null || !connections.has(client)) return false;
5592
+ return Date.now() - client.heardAt < SILENCE_LIMIT;
5357
5593
  }
5358
5594
  function findTab(id) {
5359
- for (const project of projects.values()) {
5360
- const tab = project.tabs.find((item) => item.id === id);
5361
- if (tab !== void 0) return tab;
5362
- }
5595
+ return [...projects.values()].flatMap((project) => project.tabs).find((tab) => tab.id === id);
5363
5596
  }
5364
5597
  function nearestVisible(tabs, index) {
5365
5598
  for (let position = index; position < tabs.length; position++) if (!tabs[position].minimized) return position;
@@ -5388,33 +5621,41 @@ function send(connection, event) {
5388
5621
  //#region src/api/socket.ts
5389
5622
  const handlers = {
5390
5623
  ping: () => null,
5391
- sync: async () => ({
5392
- ...configInfo(),
5393
- protocol: 2,
5394
- hostname: os.hostname(),
5395
- projects: projects$1(),
5396
- sessions: await sessions(),
5397
- activity: activity()
5398
- }),
5624
+ sync: () => {
5625
+ refreshAll();
5626
+ return {
5627
+ ...configInfo(),
5628
+ hostname: os.hostname(),
5629
+ projects: projects$1(),
5630
+ sessions: sessions(),
5631
+ providers: providerStatuses(),
5632
+ activity: activity()
5633
+ };
5634
+ },
5399
5635
  "config.get": () => configInfo(),
5400
- "config.save": (params) => {
5401
- const before = readConfig().claudeBaseDir;
5636
+ "config.save": async (params) => {
5637
+ const before = readConfig();
5402
5638
  const saved = saveConfig(params);
5403
- if (saved.claudeBaseDir === before) return saved;
5639
+ for (const provider of [
5640
+ "claude",
5641
+ "codex",
5642
+ "opencode"
5643
+ ]) if (before.enabledProviders[provider] && !saved.enabledProviders[provider]) await stopProvider(provider);
5644
+ if (saved.claudeBaseDir === before.claudeBaseDir && JSON.stringify(saved.enabledProviders) === JSON.stringify(before.enabledProviders)) return saved;
5404
5645
  watchSessions();
5405
- publishSessions();
5646
+ refreshAll();
5406
5647
  publishAccounts();
5407
5648
  return saved;
5408
5649
  },
5409
5650
  "projects.add": async (params) => {
5410
5651
  const project = await addProject(params.path);
5411
- publishSessions();
5652
+ refreshAll();
5412
5653
  return project;
5413
5654
  },
5414
- "projects.update": (params) => updateProject(params.path, params.patch),
5655
+ "projects.update": (params) => updateProject(params.id, params.patch),
5415
5656
  "projects.remove": (params) => {
5416
- removeProject(params.path);
5417
- publishSessions();
5657
+ removeProject(params.id);
5658
+ refreshAll();
5418
5659
  return null;
5419
5660
  },
5420
5661
  "directories.list": (params) => directories(params.path),
@@ -5435,14 +5676,14 @@ const handlers = {
5435
5676
  return null;
5436
5677
  },
5437
5678
  "sessions.open": async (params, client) => {
5438
- await open$1(client, params.sessionId, params.cwd);
5679
+ await open$1(client, params.sessionId, params.projectId);
5439
5680
  return null;
5440
5681
  },
5441
5682
  "sessions.earlier": (params) => earlier(params.sessionId, params.before),
5442
5683
  "sessions.image": (params) => image(params.sessionId, params.itemId, params.index),
5443
5684
  "sessions.agent": (params) => {
5444
5685
  switch (providerOf(params.sessionId)) {
5445
- case "claude": return agentMessages(params.sessionId, params.cwd, params.agentId, params.parent);
5686
+ case "claude": return agentMessages(params.sessionId, projectPath(params.projectId), params.agentId, params.parent);
5446
5687
  case "codex": return codexAgent(params.agentId, params.parent);
5447
5688
  case "opencode": return openCodeAgent(params.agentId, params.parent);
5448
5689
  }
@@ -5480,7 +5721,7 @@ const handlers = {
5480
5721
  },
5481
5722
  "sessions.rewind": (params) => rewind(params),
5482
5723
  "sessions.fork": async (params) => {
5483
- const result = await fork(params.sessionId, params.cwd, params.messageId);
5724
+ const result = await fork(params.sessionId, params.projectId, params.messageId);
5484
5725
  publishSessions();
5485
5726
  return result;
5486
5727
  },
@@ -5490,8 +5731,8 @@ const handlers = {
5490
5731
  publishAccounts();
5491
5732
  return null;
5492
5733
  },
5493
- "accounts.update": (params) => {
5494
- updateAccount(params.id, params.name, params.configDir);
5734
+ "accounts.update": async (params) => {
5735
+ await updateAccount(params);
5495
5736
  publishAccounts();
5496
5737
  return null;
5497
5738
  },
@@ -5500,26 +5741,29 @@ const handlers = {
5500
5741
  publishAccounts();
5501
5742
  return null;
5502
5743
  },
5503
- capabilities: (params) => capabilities(params.accountId, params.cwd),
5504
- "files.list": (params) => listFiles(params.cwd),
5505
- "files.read": (params) => readProjectFile(params.cwd, params.path),
5744
+ capabilities: (params) => capabilities(params.provider, params.accountId, params.projectId === null ? os.homedir() : projectPath(params.projectId)),
5745
+ "files.list": (params) => listFiles(projectPath(params.projectId)),
5746
+ "files.read": (params) => readProjectFile(projectPath(params.projectId), params.path),
5506
5747
  "files.upload": (params) => saveUpload(params.name, Buffer.from(params.data, "base64")),
5507
5748
  "tasks.output": (params) => taskOutput(params.sessionId, params.path),
5508
- "git.status": (params) => gitStatus(params.cwd),
5509
- "git.diff": (params) => projectDiff(params.cwd),
5510
- "git.commit": (params) => commit(params.cwd, params.message),
5511
- "git.push": (params) => push$1(params.cwd),
5512
- "git.pullRequest": (params) => createPullRequest(params.cwd),
5513
- "git.message": (params) => suggestCommitMessage(params.provider, params.accountId, params.cwd),
5514
- "push.key": () => pushKey(),
5515
- "push.subscribe": (params) => {
5516
- subscribe(params);
5749
+ "git.status": (params) => gitStatus(projectPath(params.projectId)),
5750
+ "git.diff": (params) => projectDiff(projectPath(params.projectId)),
5751
+ "git.commit": (params) => commit(projectPath(params.projectId), params.message),
5752
+ "git.push": (params) => push$1(projectPath(params.projectId)),
5753
+ "git.pullRequest": (params) => createPullRequest(projectPath(params.projectId)),
5754
+ "git.message": (params) => suggestCommitMessage(projectPath(params.projectId)),
5755
+ "push.subscribe": (params, client) => {
5756
+ subscribe(params, client);
5517
5757
  return null;
5518
5758
  },
5519
5759
  "push.unsubscribe": (params) => {
5520
5760
  unsubscribe(params.endpoint);
5521
5761
  return null;
5522
5762
  },
5763
+ "notifications.claim": (params, client) => {
5764
+ claimNotifier(client, params.endpoint);
5765
+ return null;
5766
+ },
5523
5767
  terminal: async (params, client) => {
5524
5768
  await terminalRequest(client, params);
5525
5769
  return null;
@@ -5579,40 +5823,32 @@ async function answer(client, text) {
5579
5823
  //#endregion
5580
5824
  //#region src/storage/token.ts
5581
5825
  function readTokens() {
5582
- const stored = readJson("tokens.json");
5583
- return stored === null ? [] : stored.tokens;
5826
+ return db.select().from(tokens).orderBy(tokens.createdAt).all();
5584
5827
  }
5585
5828
  function addToken(name) {
5586
5829
  const token = randomBytes(32).toString("base64url");
5587
- writeJson("tokens.json", { tokens: [...readTokens(), {
5588
- token,
5830
+ db.insert(tokens).values({
5831
+ hash: hashToken(token),
5589
5832
  name,
5590
5833
  createdAt: Date.now()
5591
- }] });
5834
+ }).run();
5592
5835
  return token;
5593
5836
  }
5594
- function removeTokens(tokens) {
5595
- writeJson("tokens.json", { tokens: readTokens().filter((item) => !tokens.includes(item.token)) });
5837
+ function removeTokens(hashes) {
5838
+ db.delete(tokens).where(inArray(tokens.hash, hashes)).run();
5596
5839
  }
5597
- function firstToken() {
5598
- return readTokens().at(0)?.token ?? addToken(null);
5840
+ function hasTokens() {
5841
+ return db.select().from(tokens).limit(1).get() !== void 0;
5599
5842
  }
5600
5843
  function matchesToken(given) {
5601
5844
  if (given === null) return false;
5602
- const actual = Buffer.from(given);
5603
- return readTokens().some((item) => {
5604
- const expected = Buffer.from(item.token);
5605
- return expected.length === actual.length && timingSafeEqual(expected, actual);
5606
- });
5607
- }
5608
- function readUsage() {
5609
- return readJson("token-usage.json") ?? {};
5845
+ return db.select().from(tokens).where(eq(tokens.hash, hashToken(given))).get() !== void 0;
5610
5846
  }
5611
5847
  function recordUse(token) {
5612
- writeJson("token-usage.json", {
5613
- ...readUsage(),
5614
- [token]: Date.now()
5615
- });
5848
+ db.update(tokens).set({ lastUsedAt: Date.now() }).where(eq(tokens.hash, hashToken(token))).run();
5849
+ }
5850
+ function hashToken(token) {
5851
+ return createHash("sha256").update(token).digest("hex");
5616
5852
  }
5617
5853
  //#endregion
5618
5854
  //#region src/main.ts
@@ -5637,12 +5873,7 @@ const address = `http://${args.host}:${port}`;
5637
5873
  const fallback = args.fallback === void 0 ? null : readFallback(args.fallback);
5638
5874
  if (args.command === "pair") {
5639
5875
  const links = pairingLinks(addToken(args.name));
5640
- console.log(`Scan this to pair a phone with ${HOSTED_APP}:`);
5641
- console.log("");
5642
- console.log(renderUnicodeCompact(links.hosted, { border: 2 }));
5643
- console.log("");
5644
- console.log("Or open one of these links in the browser to pair:");
5645
- console.log(` ${links.hosted}`);
5876
+ console.log("Open this link in the browser to pair:");
5646
5877
  console.log(` ${links.local}`);
5647
5878
  if (fallback !== null) console.log(`Fallback: ${fallback}`);
5648
5879
  process.exit(0);
@@ -5654,27 +5885,18 @@ if (args.command === "unpair") {
5654
5885
  lockHome();
5655
5886
  process.env.CLAUDE_CONFIG_DIR = readConfig().claudeBaseDir;
5656
5887
  watchSessions();
5888
+ refreshAll();
5657
5889
  if (findClaude() === null) {
5658
5890
  console.error("could not find claude on PATH; install Claude Code first");
5659
5891
  process.exit(1);
5660
5892
  }
5661
5893
  const publicDir = path.join(import.meta.dirname, "..", "..", "web", "dist");
5662
- const token = args.auth && args.pair ? firstToken() : null;
5894
+ const token = args.auth && args.pair && !hasTokens() ? addToken(null) : null;
5663
5895
  const server = createServer((request, response) => {
5664
5896
  const url = new URL(request.url ?? "/", "http://localhost");
5665
- if (url.pathname === "/version") {
5666
- response.setHeader("access-control-allow-origin", "*");
5667
- response.setHeader("content-type", "application/json");
5668
- response.end(JSON.stringify({
5669
- version,
5670
- protocol: 2,
5671
- authorized: authorized(url)
5672
- }));
5673
- return;
5674
- }
5675
5897
  if (args.command === "serve") {
5676
5898
  response.statusCode = 404;
5677
- response.end(`offsite serve has no web app; open ${HOSTED_APP}`);
5899
+ response.end("offsite serve has no web app");
5678
5900
  return;
5679
5901
  }
5680
5902
  serveFile(url.pathname, response);
@@ -5684,7 +5906,7 @@ const paired = /* @__PURE__ */ new Map();
5684
5906
  server.on("upgrade", (request, socket, head) => {
5685
5907
  const url = new URL(request.url ?? "/", "http://localhost");
5686
5908
  const given = url.searchParams.get("token");
5687
- if (url.pathname !== "/ws" || url.searchParams.get("protocol") !== String(2) || !authorized(url)) {
5909
+ if (url.pathname !== "/ws" || !authorized(url)) {
5688
5910
  socket.destroy();
5689
5911
  return;
5690
5912
  }
@@ -5701,13 +5923,12 @@ if (args.auth) setInterval(() => {
5701
5923
  }, UNPAIR_CHECK);
5702
5924
  server.listen(port, args.host, () => {
5703
5925
  startPush(`${args.host}:${port}`);
5704
- if (args.pair) {
5926
+ if (args.pair && (token !== null || !args.auth)) {
5705
5927
  const links = pairingLinks(token);
5706
5928
  if (args.command === "web") console.log(`offsite is running at ${links.local}`);
5707
- console.log(`Open it on ${HOSTED_APP}: ${links.hosted}`);
5708
5929
  } else {
5709
5930
  console.log(`offsite is running at ${address}`);
5710
- console.log(`Open it on ${HOSTED_APP}`);
5931
+ if (args.pair) console.log("Run offsite pair to pair another browser");
5711
5932
  }
5712
5933
  if (!args.auth) console.warn("auth is off; anyone who can reach this port can control this machine");
5713
5934
  });
@@ -5730,23 +5951,18 @@ async function unpair() {
5730
5951
  console.error("offsite unpair needs an interactive terminal");
5731
5952
  process.exit(1);
5732
5953
  }
5733
- const usage = readUsage();
5734
5954
  const picked = await consola.prompt("Which browsers should be unpaired?", {
5735
5955
  type: "multiselect",
5736
5956
  required: false,
5737
5957
  cancel: "null",
5738
- options: tokens.map((item) => {
5739
- const used = usage[item.token];
5740
- return {
5741
- value: item.token,
5742
- label: [
5743
- item.name ?? "Unnamed",
5744
- `${item.token.slice(0, 6)}…`,
5745
- `paired ${new Date(item.createdAt).toLocaleString()}`,
5746
- used === void 0 ? "never used" : `last used ${new Date(used).toLocaleString()}`
5747
- ].join(" · ")
5748
- };
5749
- })
5958
+ options: tokens.map((item) => ({
5959
+ value: item.hash,
5960
+ label: [
5961
+ item.name ?? "Unnamed",
5962
+ `paired ${new Date(item.createdAt).toLocaleString()}`,
5963
+ item.lastUsedAt === null ? "never used" : `last used ${new Date(item.lastUsedAt).toLocaleString()}`
5964
+ ].join(" · ")
5965
+ }))
5750
5966
  });
5751
5967
  if (picked === null || picked.length === 0) {
5752
5968
  console.log("Nothing unpaired");
@@ -5770,15 +5986,10 @@ function readFallback(link) {
5770
5986
  return url.origin;
5771
5987
  }
5772
5988
  function pairingLinks(token) {
5773
- const hosted = new URLSearchParams({
5989
+ return { local: `${address}/pair#${new URLSearchParams({
5774
5990
  machine: address,
5775
- ...fallback !== null && { fallback },
5776
5991
  ...token !== null && { token }
5777
- });
5778
- return {
5779
- local: token === null ? address : `${address}/pair#${new URLSearchParams({ token })}`,
5780
- hosted: `${HOSTED_APP}/pair#${hosted}`
5781
- };
5992
+ })}` };
5782
5993
  }
5783
5994
  function serveFile(pathname, response) {
5784
5995
  const requested = path.join(publicDir, path.normalize(pathname));