offsite 0.0.10 → 0.0.12

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 (97) hide show
  1. package/README.md +24 -24
  2. package/apps/server/dist/main.mjs +2035 -1875
  3. package/apps/server/dist/main.mjs.map +1 -1
  4. package/apps/server/drizzle/20261017103559_browser_push_keys/migration.sql +11 -0
  5. package/apps/server/drizzle/20261017103559_browser_push_keys/snapshot.json +265 -0
  6. package/apps/server/drizzle/20261017110336_browser_machine_ids/migration.sql +3 -0
  7. package/apps/server/drizzle/20261017110336_browser_machine_ids/snapshot.json +252 -0
  8. package/apps/server/drizzle/20261017120143_device_tokens/migration.sql +15 -0
  9. package/apps/server/drizzle/20261017120143_device_tokens/snapshot.json +305 -0
  10. package/apps/web/dist/apple-touch-icon.png +0 -0
  11. package/apps/web/dist/assets/{angular-html-BEUtPJ31.js → angular-html-DNGrmdhg.js} +1 -1
  12. package/apps/web/dist/assets/{angular-ts-Bnr4rzFr.js → angular-ts-D4hBqsOh.js} +1 -1
  13. package/apps/web/dist/assets/{apl-B-JNBTdP.js → apl-CYvdPOPE.js} +1 -1
  14. package/apps/web/dist/assets/{astro-DLjK95IW.js → astro-BqtVnRyx.js} +1 -1
  15. package/apps/web/dist/assets/{blade-PLMCMBeb.js → blade-BPJV2sdT.js} +1 -1
  16. package/apps/web/dist/assets/{c-D5z8xNe6.js → c-CBQDE6Uf.js} +1 -1
  17. package/apps/web/dist/assets/{chapel-B9y2HxI_.js → chapel-BHOESU2p.js} +1 -1
  18. package/apps/web/dist/assets/{cobol-BSwRu2X7.js → cobol-CMpvzrel.js} +1 -1
  19. package/apps/web/dist/assets/{coffee-BAWFR6iF.js → coffee-Bv27C06e.js} +1 -1
  20. package/apps/web/dist/assets/{cpp-CO-vZI4m.js → cpp-MTwxWDTP.js} +1 -1
  21. package/apps/web/dist/assets/{crystal-BSfyPt6R.js → crystal-CI5fSUCy.js} +1 -1
  22. package/apps/web/dist/assets/{css-BOzKs1dt.js → css-BW8DHcmj.js} +1 -1
  23. package/apps/web/dist/assets/{edge-OBqkt95y.js → edge-C2HOl06F.js} +1 -1
  24. package/apps/web/dist/assets/{elixir-B14D2jMM.js → elixir-OV4G1_r7.js} +1 -1
  25. package/apps/web/dist/assets/{elm-DgfIayFq.js → elm-BF0pK-yg.js} +1 -1
  26. package/apps/web/dist/assets/{erb-w-PUv7ic.js → erb-DFQCtOmK.js} +1 -1
  27. package/apps/web/dist/assets/{git-rebase-DnzmyV_3.js → git-rebase-C9nAAu5A.js} +1 -1
  28. package/apps/web/dist/assets/{glimmer-js-CLephpyX.js → glimmer-js-B9gnYdT1.js} +1 -1
  29. package/apps/web/dist/assets/{glimmer-ts-CvphZOog.js → glimmer-ts-BkG38Inm.js} +1 -1
  30. package/apps/web/dist/assets/{glsl-Ddj2GHKO.js → glsl-wwo6dKBr.js} +1 -1
  31. package/apps/web/dist/assets/{graphql-CeGXMmoK.js → graphql-tQJRbYuy.js} +1 -1
  32. package/apps/web/dist/assets/{hack-CtBKZ5VW.js → hack-C9e9oyUG.js} +1 -1
  33. package/apps/web/dist/assets/{haml-DVvVd-Mp.js → haml-CTolMb7J.js} +1 -1
  34. package/apps/web/dist/assets/{handlebars-Blx6Nluh.js → handlebars-CrJTM5vD.js} +1 -1
  35. package/apps/web/dist/assets/{html-O-gZ5M1-.js → html-Be6zOrn3.js} +1 -1
  36. package/apps/web/dist/assets/{html-derivative-BsDe9c3P.js → html-derivative-10Bvy_Et.js} +1 -1
  37. package/apps/web/dist/assets/{http-CAUZeWmG.js → http-GZlGWwa-.js} +1 -1
  38. package/apps/web/dist/assets/{hurl-CYnRsJrb.js → hurl-DEYfhn1R.js} +1 -1
  39. package/apps/web/dist/assets/index-6ofGnhX4.css +2 -0
  40. package/apps/web/dist/assets/index-CiQ326rG.js +1203 -0
  41. package/apps/web/dist/assets/{java-DjjpMT1c.js → java-BsFb26JS.js} +1 -1
  42. package/apps/web/dist/assets/{javascript-BtiswSWS.js → javascript-DDLm5bzW.js} +1 -1
  43. package/apps/web/dist/assets/{jinja-ZQGJY58d.js → jinja-NBeNjUXf.js} +1 -1
  44. package/apps/web/dist/assets/{jison-VPkdeMFr.js → jison-DKz4RK3A.js} +1 -1
  45. package/apps/web/dist/assets/{json-D5BAJIHo.js → json-Bg4r8xCd.js} +1 -1
  46. package/apps/web/dist/assets/{jsx-DMEKVcfT.js → jsx-BMnPLKsa.js} +1 -1
  47. package/apps/web/dist/assets/{julia-BmOLQFch.js → julia-Wll5K4al.js} +1 -1
  48. package/apps/web/dist/assets/{just-YZGutsxo.js → just-BLn1iI69.js} +1 -1
  49. package/apps/web/dist/assets/{latex-CvrF79xl.js → latex-D15HRF2E.js} +1 -1
  50. package/apps/web/dist/assets/{liquid-B--WbaSh.js → liquid-COHkGI02.js} +1 -1
  51. package/apps/web/dist/assets/{lua-BNEqLlv-.js → lua-CkbAAxOH.js} +1 -1
  52. package/apps/web/dist/assets/{marko-jEzbzXvj.js → marko-Dmgo6IU5.js} +1 -1
  53. package/apps/web/dist/assets/{mdc-yfiYVuXh.js → mdc-D_OV_cCt.js} +1 -1
  54. package/apps/web/dist/assets/{nginx-j7vnr3lB.js → nginx-ADn2lIMW.js} +1 -1
  55. package/apps/web/dist/assets/{nim-CV2OP2RC.js → nim-C6zTq4FI.js} +1 -1
  56. package/apps/web/dist/assets/{org-DTTS5Tv_.js → org-D9XGvcFV.js} +1 -1
  57. package/apps/web/dist/assets/{perl-DuF7yMdF.js → perl-B7-K-xLH.js} +1 -1
  58. package/apps/web/dist/assets/{php-8ufnwSAo.js → php-CO9ZzQRp.js} +1 -1
  59. package/apps/web/dist/assets/{pug-D-_t9dIn.js → pug-BaAyPwTO.js} +1 -1
  60. package/apps/web/dist/assets/{qml-DFxD5uAC.js → qml-Cp3pyCa-.js} +1 -1
  61. package/apps/web/dist/assets/{r-D8-Yi2KS.js → r-CT05_vfW.js} +1 -1
  62. package/apps/web/dist/assets/{razor-DhBq7-pb.js → razor-D5rHsTSp.js} +1 -1
  63. package/apps/web/dist/assets/{regexp-C20CUsFr.js → regexp-D6kzd_X5.js} +1 -1
  64. package/apps/web/dist/assets/{rst-DsIaukoD.js → rst-AkFwrhnR.js} +1 -1
  65. package/apps/web/dist/assets/{ruby-C__EgK1w.js → ruby-LNJDVfMG.js} +1 -1
  66. package/apps/web/dist/assets/{sas-BdDDRZUF.js → sas-C8o3xi__.js} +1 -1
  67. package/apps/web/dist/assets/{scss-nPFPh8nF.js → scss-Ch7Tact0.js} +1 -1
  68. package/apps/web/dist/assets/{shellscript-Cfn0DgiQ.js → shellscript-CN-vf6fZ.js} +1 -1
  69. package/apps/web/dist/assets/{shellsession-DFGAcvZ4.js → shellsession-CxId-awN.js} +1 -1
  70. package/apps/web/dist/assets/{soy-8DrmW46F.js → soy-PXtFJtGw.js} +1 -1
  71. package/apps/web/dist/assets/{sql-BeU26hky.js → sql-B7js2_Lu.js} +1 -1
  72. package/apps/web/dist/assets/{stata-OFfE-Kh9.js → stata-C0Zwsf36.js} +1 -1
  73. package/apps/web/dist/assets/{surrealql-Bwt4eVgP.js → surrealql-DawzxLLe.js} +1 -1
  74. package/apps/web/dist/assets/{svelte-DF7N9uBp.js → svelte-CbCoXemF.js} +1 -1
  75. package/apps/web/dist/assets/{templ-CEirsWl9.js → templ-CQyn0W4C.js} +1 -1
  76. package/apps/web/dist/assets/{tex-BvBhDkV4.js → tex-FeZKQwyK.js} +1 -1
  77. package/apps/web/dist/assets/{ts-tags-Dx8bn0ts.js → ts-tags-KsJvoinT.js} +1 -1
  78. package/apps/web/dist/assets/{tsx-vXk5Q55O.js → tsx-Cw6pGPeZ.js} +1 -1
  79. package/apps/web/dist/assets/{twig-CIgLDHIW.js → twig-Cj9MLPms.js} +1 -1
  80. package/apps/web/dist/assets/{typescript-CCW6p4Fc.js → typescript-DwZR66Da.js} +1 -1
  81. package/apps/web/dist/assets/{typst-CQL-qDz3.js → typst--K-bHYFW.js} +1 -1
  82. package/apps/web/dist/assets/{vue-gFAUYOUp.js → vue-BrB0TrD_.js} +1 -1
  83. package/apps/web/dist/assets/{vue-html-BHuiCeAo.js → vue-html-BgNSGBvl.js} +1 -1
  84. package/apps/web/dist/assets/{vue-vine-DxeD8nHa.js → vue-vine-C2qLo1pd.js} +1 -1
  85. package/apps/web/dist/assets/{xml-B43ZEHE5.js → xml-DzdlZG_g.js} +1 -1
  86. package/apps/web/dist/assets/{xsl-icvYLDXv.js → xsl-D3HxAJEY.js} +1 -1
  87. package/apps/web/dist/assets/{yaml-Bj0dlmrV.js → yaml-SX7TQG-a.js} +1 -1
  88. package/apps/web/dist/icon-192.png +0 -0
  89. package/apps/web/dist/icon-512.png +0 -0
  90. package/apps/web/dist/icon-maskable-512.png +0 -0
  91. package/apps/web/dist/icon.svg +4 -2
  92. package/apps/web/dist/index.html +4 -4
  93. package/apps/web/dist/manifest.webmanifest +1 -0
  94. package/apps/web/dist/sw.js +1 -1
  95. package/package.json +4 -3
  96. package/apps/web/dist/assets/index-5vPpgW8A.css +0 -2
  97. package/apps/web/dist/assets/index-CGSSO_em.js +0 -1152
@@ -1,29 +1,29 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
1
2
  import { chmodSync, closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
3
  import { createServer } from "node:http";
3
4
  import path from "node:path";
4
5
  import { consola } from "consola";
5
- import { renderUnicodeCompact } from "uqr";
6
6
  import { WebSocketServer } from "ws";
7
7
  import os from "node:os";
8
8
  import { deleteSession, forkSession, getSessionMessages, getSubagentMessages, listSessions, query, renameSession } from "@anthropic-ai/claude-agent-sdk";
9
- 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";
16
+ import { and, eq, gt, inArray, lte, sql } from "drizzle-orm";
17
+ import { sumBy } from "es-toolkit";
17
18
  import { DatabaseSync } from "node:sqlite";
18
19
  import { drizzle } from "drizzle-orm/node-sqlite";
19
20
  import { migrate } from "drizzle-orm/node-sqlite/migrator";
20
- import webPush from "web-push";
21
21
  import { integer, snakeCase, text } from "drizzle-orm/sqlite-core";
22
22
  import { lstat, open, readFile, readdir, readlink, stat } from "node:fs/promises";
23
23
  import { promisify } from "node:util";
24
+ import webPush from "web-push";
24
25
  import { spawn as spawn$1 } from "@lydell/node-pty";
25
26
  //#region ../core/src/protocol.ts
26
- const HOSTED_APP = "https://offsite.sayad.dev";
27
27
  function encodeFrame(kind, id, data) {
28
28
  const frame = new Uint8Array(5 + data.length);
29
29
  frame[0] = kind;
@@ -40,6 +40,27 @@ function decodeFrame(frame) {
40
40
  };
41
41
  }
42
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
43
64
  //#region src/lib/hub.ts
44
65
  const clients = /* @__PURE__ */ new Set();
45
66
  function publish$2(sessionId, event) {
@@ -49,281 +70,6 @@ function broadcast(event) {
49
70
  for (const client of clients) client.send(event);
50
71
  }
51
72
  //#endregion
52
- //#region src/args.ts
53
- let command = "web";
54
- let name = null;
55
- const program = new Command("offsite").description("Starts the server and the web app").option("--host <host>", "Address to listen on", "127.0.0.1").option("--port <port>", "Port to listen on", "47841").option("--dir <dir>", "Folder for settings and accounts", path.join(os.homedir(), ".offsite")).option("--no-auth", "Accept connections without the pairing token").option("--no-pair", "Don't create a pairing token or print pairing links").option("--fallback <url>", "Address to try when this one is unreachable, e.g. a Cloudflare tunnel");
56
- program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
57
- program.command("serve").description("Starts only the server, without the web app").action(() => {
58
- command = "serve";
59
- });
60
- 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) => {
61
- command = "pair";
62
- name = options.name ?? null;
63
- });
64
- program.command("unpair").description("Picks paired browsers to unpair").action(() => {
65
- command = "unpair";
66
- });
67
- program.parse();
68
- const options = program.opts();
69
- const args = {
70
- ...options,
71
- dir: path.resolve(options.dir),
72
- command,
73
- name
74
- };
75
- const serverDir = path.join(import.meta.dirname, "..");
76
- //#endregion
77
- //#region src/storage/storage.ts
78
- const home = args.dir;
79
- const lockFile = path.join(home, "server.pid");
80
- mkdirSync(home, { recursive: true });
81
- function lockHome() {
82
- lock();
83
- process.on("exit", () => unlinkSync(lockFile));
84
- }
85
- function lock() {
86
- try {
87
- writeFileSync(lockFile, `${process.pid}\n`, { flag: "wx" });
88
- return;
89
- } catch (error) {
90
- if (!hasCode(error, "EEXIST")) throw error;
91
- }
92
- let content;
93
- let modified;
94
- try {
95
- content = readFileSync(lockFile, "utf8");
96
- modified = statSync(lockFile).mtimeMs;
97
- } catch (error) {
98
- if (hasCode(error, "ENOENT")) return lock();
99
- throw error;
100
- }
101
- const pid = Number(content);
102
- const validPid = Number.isInteger(pid) && pid > 0;
103
- const writtenThisBoot = modified > Date.now() - os.uptime() * 1e3;
104
- if (validPid && writtenThisBoot && pid !== process.pid && isRunning(pid)) {
105
- consola.error(`offsite is already running for ${home} (pid ${pid})`);
106
- process.exit(1);
107
- }
108
- unlinkSync(lockFile);
109
- lock();
110
- }
111
- function isRunning(pid) {
112
- try {
113
- process.kill(pid, 0);
114
- return true;
115
- } catch (error) {
116
- if (hasCode(error, "ESRCH")) return false;
117
- if (hasCode(error, "EPERM")) return true;
118
- throw error;
119
- }
120
- }
121
- function hasCode(error, code) {
122
- return error instanceof Error && "code" in error && error.code === code;
123
- }
124
- function readJson(name) {
125
- try {
126
- return JSON.parse(readFileSync(path.join(home, name), "utf8"));
127
- } catch (error) {
128
- if (error.code === "ENOENT") return null;
129
- throw error;
130
- }
131
- }
132
- function writeJson(name, value) {
133
- const file = path.join(home, name);
134
- mkdirSync(home, { recursive: true });
135
- writeFileSync(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 384 });
136
- renameSync(`${file}.tmp`, file);
137
- }
138
- //#endregion
139
- //#region src/storage/config.ts
140
- const version = "0.0.0";
141
- const configFile = path.join(home, "config.json");
142
- const defaults = {
143
- claudeBaseDir: path.join(os.homedir(), ".claude"),
144
- composer: {
145
- provider: "claude",
146
- claude: {
147
- accountId: null,
148
- model: "haiku",
149
- effort: "low",
150
- mode: "bypassPermissions",
151
- plan: false
152
- },
153
- codex: {
154
- accountId: null,
155
- model: null,
156
- effort: null,
157
- mode: "auto",
158
- plan: false
159
- },
160
- opencode: {
161
- accountId: "opencode",
162
- model: null,
163
- effort: null,
164
- mode: null,
165
- plan: false
166
- }
167
- },
168
- commitModels: {
169
- claude: "haiku",
170
- codex: "gpt-6-luna",
171
- opencode: null
172
- },
173
- terminal: {
174
- shell: {
175
- macos: null,
176
- linux: null,
177
- windows: null
178
- },
179
- behavior: {
180
- terminalIdentity: "offsite",
181
- shellIntegration: true
182
- }
183
- }
184
- };
185
- const choices = {
186
- "composer.provider": [
187
- "claude",
188
- "codex",
189
- "opencode"
190
- ],
191
- "composer.claude.effort": [
192
- "low",
193
- "medium",
194
- "high",
195
- "xhigh",
196
- "max"
197
- ],
198
- "composer.claude.mode": [
199
- "default",
200
- "acceptEdits",
201
- "bypassPermissions",
202
- "plan",
203
- "dontAsk",
204
- "auto"
205
- ],
206
- "composer.codex.mode": [
207
- "read-only",
208
- "auto",
209
- "full-access"
210
- ],
211
- "terminal.behavior.terminalIdentity": [
212
- "offsite",
213
- "ghostty",
214
- "iterm2"
215
- ]
216
- };
217
- const nullable = [
218
- "composer.claude.accountId",
219
- "composer.claude.model",
220
- "composer.claude.effort",
221
- "composer.claude.mode",
222
- "composer.codex.accountId",
223
- "composer.codex.model",
224
- "composer.codex.effort",
225
- "composer.codex.mode",
226
- "composer.opencode.accountId",
227
- "composer.opencode.model",
228
- "composer.opencode.effort",
229
- "composer.opencode.mode",
230
- "commitModels.claude",
231
- "commitModels.codex",
232
- "commitModels.opencode",
233
- "terminal.shell.macos",
234
- "terminal.shell.linux",
235
- "terminal.shell.windows"
236
- ];
237
- function platform() {
238
- if (process.platform === "darwin") return "macos";
239
- if (process.platform === "linux") return "linux";
240
- if (process.platform === "win32") return "windows";
241
- throw new Error(`unsupported platform ${process.platform}`);
242
- }
243
- function defaultShell() {
244
- if (process.platform === "win32") return process.env.COMSPEC ?? "cmd.exe";
245
- return os.userInfo().shell ?? "/bin/sh";
246
- }
247
- function configInfo() {
248
- return {
249
- config: readConfig(),
250
- defaults,
251
- platform: platform(),
252
- version,
253
- defaultShell: defaultShell()
254
- };
255
- }
256
- function readConfig() {
257
- let user;
258
- try {
259
- user = readJson("config.json");
260
- } catch (error) {
261
- console.warn(`invalid ${configFile}, using the default config: ${error}`);
262
- return structuredClone(defaults);
263
- }
264
- if (user === null) return structuredClone(defaults);
265
- if (!isObject(user)) {
266
- console.warn(`${configFile} is not an object, using the default config`);
267
- return structuredClone(defaults);
268
- }
269
- let config = structuredClone(defaults);
270
- for (const [key, value] of Object.entries(user)) config = merge(config, [key], value);
271
- return config;
272
- }
273
- function saveConfig(value) {
274
- const error = invalid(value, defaults, []);
275
- if (error !== null) throw new Error(error);
276
- writeJson("config.json", value);
277
- process.env.CLAUDE_CONFIG_DIR = value.claudeBaseDir;
278
- broadcast({
279
- type: "config",
280
- config: value
281
- });
282
- return value;
283
- }
284
- function merge(config, keys, user) {
285
- if (isObject(keys.reduce((value, key) => isObject(value) ? value[key] : void 0, config)) && isObject(user)) {
286
- let next = config;
287
- for (const [key, value] of Object.entries(user)) next = merge(next, [...keys, key], value);
288
- return next;
289
- }
290
- const candidate = structuredClone(config);
291
- const parent = keys.slice(0, -1).reduce((value, key) => value[key], candidate);
292
- parent[keys[keys.length - 1]] = user;
293
- const error = invalid(candidate, defaults, []);
294
- if (error === null) return candidate;
295
- console.warn(`invalid ${keys.join(".")} in ${configFile}, using its default: ${error}`);
296
- return config;
297
- }
298
- function invalid(value, template, keys) {
299
- const name = keys.join(".");
300
- if (isObject(template)) {
301
- if (!isObject(value)) return `${name || "config"} must be an object`;
302
- for (const key of Object.keys(value)) if (!(key in template)) return `unknown setting ${[...keys, key].join(".")}`;
303
- for (const key of Object.keys(template)) {
304
- if (!(key in value)) return `missing setting ${[...keys, key].join(".")}`;
305
- const error = invalid(value[key], template[key], [...keys, key]);
306
- if (error !== null) return error;
307
- }
308
- return null;
309
- }
310
- if (nullable.includes(name) && value === null) return null;
311
- if (name === "claudeBaseDir") return typeof value === "string" && path.isAbsolute(value) ? null : "claudeBaseDir must be an absolute path";
312
- if (name in choices) {
313
- if (choices[name].includes(value)) return null;
314
- return `${name} must be one of ${choices[name].join(", ")}`;
315
- }
316
- if (template === null) {
317
- if (typeof value === "string") return null;
318
- return `${name} must be text or null`;
319
- }
320
- if (typeof value !== typeof template) return `${name} must be a ${typeof template}`;
321
- return null;
322
- }
323
- function isObject(value) {
324
- return typeof value === "object" && value !== null && !Array.isArray(value);
325
- }
326
- //#endregion
327
73
  //#region src/lib/codex/app-server.ts
328
74
  const IDLE_LIMIT$3 = 18e5;
329
75
  const START_TIMEOUT = 15e3;
@@ -340,6 +86,9 @@ function appServer(home) {
340
86
  function stopAppServers() {
341
87
  for (const server of servers.values()) server.stop();
342
88
  }
89
+ function stopAppServer(home) {
90
+ servers.get(home)?.stop();
91
+ }
343
92
  function findCodex() {
344
93
  const name = process.platform === "win32" ? "codex.exe" : "codex";
345
94
  for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
@@ -378,8 +127,8 @@ var AppServer = class {
378
127
  this.ready = this.call("initialize", {
379
128
  clientInfo: {
380
129
  name: "offsite",
381
- title: "Offsite",
382
- version
130
+ title: "OffSite",
131
+ version: "0.0.0"
383
132
  },
384
133
  capabilities: {
385
134
  experimentalApi: true,
@@ -616,82 +365,210 @@ function isMirror(dir) {
616
365
  }
617
366
  }
618
367
  //#endregion
619
- //#region src/lib/codex/items.ts
620
- const statuses$1 = {
621
- inProgress: "running",
622
- completed: "completed",
623
- failed: "failed",
624
- declined: "stopped",
625
- interrupted: "stopped"
626
- };
627
- function turnItems(turns, parent) {
628
- 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)] : []]);
629
- }
630
- function itemId(turnId, id) {
631
- 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
+ };
632
383
  }
633
- function turnOf(itemId) {
634
- const index = itemId.indexOf(":");
635
- if (index < 0) throw new Error(`${itemId} is not a Codex item`);
636
- 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
+ };
637
406
  }
638
- function errorItem(id, text) {
407
+ function promptMessage(prompt, uuid) {
639
408
  return {
640
- id,
641
- parent: null,
409
+ type: "user",
410
+ uuid,
642
411
  time: Date.now(),
643
- kind: "error",
644
- 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
645
420
  };
646
421
  }
647
- function toItems$1(turnId, item, parent, time) {
648
- const base = {
649
- id: itemId(turnId, item.id),
650
- parent,
651
- time
422
+ function notice(text) {
423
+ return {
424
+ type: "notice",
425
+ uuid: randomUUID(),
426
+ time: Date.now(),
427
+ text,
428
+ isError: true
652
429
  };
653
- switch (item.type) {
654
- case "userMessage": return [{
655
- ...base,
656
- kind: "user",
657
- text: item.content.flatMap((input) => userText$1(input)).join("\n").trim(),
658
- images: item.content.flatMap((input) => userImages(input)),
659
- format: "text"
660
- }];
661
- case "agentMessage": return item.text.trim() === "" ? [] : [{
662
- ...base,
663
- kind: "assistant",
664
- text: item.text,
665
- message: base.id
666
- }];
667
- case "reasoning": {
668
- const text = [...item.summary, ...item.content].join("\n\n").trim();
669
- return text === "" ? [] : [{
670
- ...base,
671
- kind: "reasoning",
672
- text
673
- }];
674
- }
675
- case "plan": return [{
676
- ...base,
677
- kind: "plan",
678
- plan: item.text
679
- }];
680
- case "commandExecution": return [{
681
- ...base,
682
- kind: "command",
683
- command: item.command,
684
- description: "",
685
- output: item.aggregatedOutput ?? "",
686
- status: statuses$1[item.status],
687
- background: null
688
- }];
689
- case "fileChange": return item.changes.map((change, index) => ({
690
- ...base,
691
- id: `${base.id}:${index}`,
692
- kind: "file_change",
693
- path: change.path,
694
- diffs: change.kind.type === "add" ? [{
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}`,
569
+ kind: "file_change",
570
+ path: change.path,
571
+ diffs: change.kind.type === "add" ? [{
695
572
  before: "",
696
573
  after: change.diff
697
574
  }] : change.kind.type === "delete" ? [{
@@ -812,50 +689,293 @@ function hunks(diff) {
812
689
  return result;
813
690
  }
814
691
  function contentText$1(content) {
815
- 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");
816
693
  }
817
694
  function record$1(value) {
818
- if (typeof value === "object" && value !== null && !Array.isArray(value)) return value;
819
- return { value };
695
+ return isRecord(value) ? value : { value };
820
696
  }
821
697
  //#endregion
822
- //#region src/lib/live.ts
823
- const runs = /* @__PURE__ */ new Map();
824
- function activity() {
825
- const result = {};
826
- for (const run of runs.values()) result[run.sessionId] = {
827
- running: run.running,
828
- waiting: run.pending.size > 0,
829
- prompted: run.prompted,
830
- tasks: run.tasks
831
- };
832
- 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 pairing").option("--no-pair", "Don't create a pairing code or print pairing links").option("--pwa", "Serve the web app as an installable app that works offline");
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 code for another browser and prints its link").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));
833
730
  }
834
- function broadcastActivity() {
835
- broadcast({
836
- type: "activity",
837
- activity: activity()
838
- });
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);
839
783
  }
840
784
  //#endregion
841
- //#region src/lib/codex/requests.ts
842
- function toRequest$1(requestId, method, params, items) {
843
- const turnId = params.turnId;
844
- switch (method) {
845
- case "item/commandExecution/requestApproval": return {
846
- id: requestId,
847
- kind: "approval",
848
- title: params.reason ?? "Run this command?",
849
- canAlwaysAllow: true,
850
- item: items.get(itemId(turnId, params.itemId)) ?? {
851
- id: itemId(turnId, params.itemId),
852
- parent: null,
853
- time: Date.now(),
854
- kind: "command",
855
- command: params.command ?? "",
856
- description: "",
857
- output: "",
858
- status: "running",
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",
859
979
  background: null
860
980
  }
861
981
  };
@@ -1182,6 +1302,7 @@ var CodexRun = class CodexRun {
1182
1302
  request
1183
1303
  });
1184
1304
  broadcastActivity();
1305
+ scheduleAutoResolve(this.sessionId, request);
1185
1306
  }
1186
1307
  async startTurn(prompt) {
1187
1308
  clearTimeout(this.idleTimer);
@@ -1439,1058 +1560,530 @@ async function readThread(threadId) {
1439
1560
  })).thread;
1440
1561
  }
1441
1562
  //#endregion
1442
- //#region src/storage/schema.ts
1443
- const machine = snakeCase.table("machine", {
1444
- id: text().primaryKey(),
1445
- pushPublicKey: text().notNull(),
1446
- pushPrivateKey: text().notNull()
1447
- });
1448
- const projects$2 = snakeCase.table("projects", {
1449
- id: text().primaryKey(),
1450
- path: text().notNull().unique(),
1451
- name: text().notNull(),
1452
- logo: text()
1453
- });
1454
- const sessions$1 = snakeCase.table("sessions", {
1455
- sessionId: text().primaryKey(),
1456
- interactedAt: integer(),
1457
- status: text({ enum: [
1458
- "active",
1459
- "pinned",
1460
- "minimized"
1461
- ] }).notNull()
1462
- });
1463
- const tokens = snakeCase.table("tokens", {
1464
- hash: text().primaryKey(),
1465
- name: text(),
1466
- createdAt: integer().notNull(),
1467
- lastUsedAt: integer()
1468
- });
1469
- const pushSubscriptions = snakeCase.table("push_subscriptions", {
1470
- endpoint: text().primaryKey(),
1471
- p256dh: text().notNull(),
1472
- auth: text().notNull(),
1473
- server: text().notNull()
1474
- });
1475
- //#endregion
1476
- //#region src/storage/db.ts
1477
- const file = path.join(home, "offsite.db");
1478
- closeSync(openSync(file, "a", 384));
1479
- chmodSync(file, 384);
1480
- const client = new DatabaseSync(file);
1481
- client.exec(`
1482
- PRAGMA journal_mode = WAL;
1483
- PRAGMA busy_timeout = 5000;
1484
- PRAGMA synchronous = NORMAL;
1485
- PRAGMA foreign_keys = ON;
1486
- `);
1487
- const db = drizzle({ client });
1488
- migrate(db, { migrationsFolder: path.join(serverDir, "drizzle") });
1489
- db.transaction((tx) => {
1490
- if (tx.select().from(machine).get() !== void 0) return;
1491
- const keys = webPush.generateVAPIDKeys();
1492
- tx.insert(machine).values({
1493
- id: randomUUID(),
1494
- pushPublicKey: keys.publicKey,
1495
- pushPrivateKey: keys.privateKey
1496
- }).run();
1497
- }, { behavior: "immediate" });
1498
- function machineInfo() {
1499
- const row = db.select().from(machine).get();
1500
- if (row === void 0) throw new Error("the machine row is missing");
1501
- return row;
1502
- }
1503
- //#endregion
1504
- //#region src/services/projects.ts
1505
- const LOGO_TYPES = [
1506
- "image/png",
1507
- "image/jpeg",
1508
- "image/gif",
1509
- "image/webp",
1510
- "image/svg+xml",
1511
- "image/x-icon",
1512
- "image/vnd.microsoft.icon"
1513
- ];
1514
- const LOGO_LIMIT = 524288;
1515
- function projects$1() {
1516
- return db.select().from(projects$2).all();
1517
- }
1518
- function projectPath(id) {
1519
- const project = db.select().from(projects$2).where(eq(projects$2.id, id)).get();
1520
- if (project === void 0) throw new Error("That project was removed");
1521
- return project.path;
1522
- }
1523
- async function addProject(input) {
1524
- const folder = path.resolve(expandHome(input.trim()));
1525
- if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
1526
- db.insert(projects$2).values({
1527
- id: randomUUID(),
1528
- path: folder,
1529
- name: path.basename(folder) || folder,
1530
- logo: null
1531
- }).onConflictDoNothing({ target: projects$2.path }).run();
1532
- publish$1();
1533
- const project = projects$1().find((item) => item.path === folder);
1534
- if (project === void 0) throw new Error(`Could not add ${folder}`);
1535
- return project;
1536
- }
1537
- function updateProject(id, patch) {
1538
- if (patch.name !== void 0) {
1539
- const name = patch.name.trim();
1540
- if (name === "") throw new Error("Project name cannot be empty");
1541
- db.update(projects$2).set({ name }).where(eq(projects$2.id, id)).run();
1542
- }
1543
- if (patch.logo !== void 0) {
1544
- if (patch.logo !== null) checkLogo(patch.logo);
1545
- db.update(projects$2).set({ logo: patch.logo }).where(eq(projects$2.id, id)).run();
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 [];
1546
1626
  }
1547
- publish$1();
1548
- return null;
1549
1627
  }
1550
- function removeProject(id) {
1551
- db.delete(projects$2).where(eq(projects$2.id, id)).run();
1552
- publish$1();
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
+ }]);
1553
1635
  }
1554
- async function directories(input) {
1555
- const folder = path.resolve(expandHome(input.trim()));
1556
- const entries = await readdir(folder, { withFileTypes: true });
1557
- const parent = path.dirname(folder);
1558
- return {
1559
- path: folder,
1560
- parent: parent === folder ? null : parent,
1561
- directories: entries.filter((entry) => entry.isDirectory()).map((entry) => ({
1562
- name: entry.name,
1563
- path: path.join(folder, entry.name)
1564
- })).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
1565
- };
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
+ }));
1566
1652
  }
1567
- function publish$1() {
1568
- broadcast({
1569
- type: "projects",
1570
- projects: projects$1()
1571
- });
1653
+ function partItems(message, part, index, parent) {
1654
+ const base = {
1655
+ id: `${message.id}:${index}`,
1656
+ parent,
1657
+ time: message.time.created
1658
+ };
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
+ }
1572
1677
  }
1573
- function checkLogo(logo) {
1574
- const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
1575
- if (type === void 0 || !LOGO_TYPES.includes(type)) throw new Error("Use a PNG, JPEG, GIF, WebP, SVG or ICO image");
1576
- if (logo.length > LOGO_LIMIT) throw new Error("Use an image smaller than 384 KB");
1678
+ function toolItems(base, tool) {
1679
+ const state = tool.state;
1680
+ const status = toolStatuses[state.status];
1681
+ const input = state.status === "streaming" ? {} : state.input;
1682
+ const metadata = state.status === "streaming" ? {} : state.metadata ?? {};
1683
+ const output = state.status === "error" ? state.error.message : state.status === "completed" ? contentText(state.content) : "";
1684
+ switch (tool.name) {
1685
+ case "shell": return [{
1686
+ ...base,
1687
+ kind: "command",
1688
+ command: text$2(input.command),
1689
+ description: text$2(input.description),
1690
+ output,
1691
+ status,
1692
+ background: null
1693
+ }];
1694
+ case "edit": return [{
1695
+ ...base,
1696
+ kind: "file_change",
1697
+ path: text$2(input.path ?? input.filePath),
1698
+ diffs: [{
1699
+ before: text$2(input.oldString),
1700
+ after: text$2(input.newString)
1701
+ }],
1702
+ output,
1703
+ status
1704
+ }];
1705
+ case "write": return [{
1706
+ ...base,
1707
+ kind: "file_change",
1708
+ path: text$2(input.path ?? input.filePath),
1709
+ diffs: [{
1710
+ before: "",
1711
+ after: text$2(input.content)
1712
+ }],
1713
+ output,
1714
+ status
1715
+ }];
1716
+ case "patch":
1717
+ case "apply_patch":
1718
+ if (!Array.isArray(metadata.files)) break;
1719
+ return metadata.files.map((file, index) => ({
1720
+ ...base,
1721
+ id: `${base.id}:${index}`,
1722
+ kind: "file_change",
1723
+ path: text$2(record(file).file),
1724
+ diffs: hunks(text$2(record(file).patch)),
1725
+ output,
1726
+ status
1727
+ }));
1728
+ case "todowrite": return [{
1729
+ ...base,
1730
+ kind: "todo_list",
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"
1734
+ }))
1735
+ }];
1736
+ case "subagent":
1737
+ case "task": return [{
1738
+ ...base,
1739
+ kind: "subagent",
1740
+ agent: text$2(input.agent ?? input.subagent_type) || "Agent",
1741
+ description: text$2(input.description),
1742
+ agentId: typeof metadata.sessionID === "string" ? metadata.sessionID : null,
1743
+ status
1744
+ }];
1745
+ case "webfetch":
1746
+ case "websearch": return [{
1747
+ ...base,
1748
+ kind: "web_search",
1749
+ query: tool.name === "websearch" ? text$2(input.query) : null,
1750
+ url: tool.name === "webfetch" ? text$2(input.url) : null,
1751
+ results: [],
1752
+ output,
1753
+ status
1754
+ }];
1755
+ case "question": {
1756
+ const questions = list$1(input.questions).map((item) => ({
1757
+ question: text$2(record(item).question),
1758
+ header: text$2(record(item).header),
1759
+ multiSelect: record(item).multiple === true,
1760
+ options: list$1(record(item).options).map((option) => ({
1761
+ label: text$2(record(option).label),
1762
+ description: text$2(record(option).description)
1763
+ }))
1764
+ }));
1765
+ const answers = metadata.answers;
1766
+ return [{
1767
+ ...base,
1768
+ kind: "question",
1769
+ questions,
1770
+ answers: Array.isArray(answers) ? Object.fromEntries(questions.map((question, index) => [question.question, list$1(answers[index]).map(text$2).join(", ")])) : null,
1771
+ status
1772
+ }];
1773
+ }
1774
+ case "skill": return [{
1775
+ ...base,
1776
+ kind: "skill",
1777
+ skill: text$2(input.name),
1778
+ output,
1779
+ status
1780
+ }];
1781
+ }
1782
+ return [{
1783
+ ...base,
1784
+ kind: "tool",
1785
+ name: tool.name,
1786
+ input,
1787
+ output,
1788
+ status
1789
+ }];
1577
1790
  }
1578
- function expandHome(input) {
1579
- if (input === "~") return os.homedir();
1580
- if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
1581
- return input;
1791
+ function questionText(field) {
1792
+ return field.description ?? field.title ?? field.key;
1582
1793
  }
1583
- //#endregion
1584
- //#region src/services/sessions.ts
1585
- const day = 864e5;
1586
- const LIST_TIMEOUT = 1e4;
1587
- const REFRESH_DELAY = 1e3;
1588
- const allProviders = [
1589
- "claude",
1590
- "codex",
1591
- "opencode"
1592
- ];
1593
- const listed = {
1594
- claude: [],
1595
- codex: [],
1596
- opencode: []
1597
- };
1598
- const statuses = {
1599
- claude: {
1600
- provider: "claude",
1601
- state: "loading",
1602
- error: null
1603
- },
1604
- codex: {
1605
- provider: "codex",
1606
- state: "loading",
1607
- error: null
1608
- },
1609
- opencode: {
1610
- provider: "opencode",
1611
- state: "loading",
1612
- error: null
1794
+ function fieldOptions(field) {
1795
+ switch (field.type) {
1796
+ case "string":
1797
+ case "multiselect": return (field.options ?? []).map((option) => ({
1798
+ label: option.label,
1799
+ description: option.description ?? ""
1800
+ }));
1801
+ case "boolean": return [{
1802
+ label: "Yes",
1803
+ description: ""
1804
+ }, {
1805
+ label: "No",
1806
+ description: ""
1807
+ }];
1808
+ case "number":
1809
+ case "integer": return [];
1613
1810
  }
1614
- };
1615
- const refreshing = /* @__PURE__ */ new Map();
1616
- const timers = /* @__PURE__ */ new Map();
1617
- const providers = /* @__PURE__ */ new Map();
1618
- let watchers = [];
1619
- function sessions() {
1620
- const records = new Map(db.select().from(sessions$1).all().map((record) => [record.sessionId, record]));
1621
- const known = new Set(projects$1().map((project) => project.id));
1622
- return allProviders.flatMap((provider) => listed[provider].filter((item) => known.has(item.projectId)).map((item) => {
1623
- const record = records.get(item.id);
1624
- const updatedAt = record?.interactedAt ?? item.updatedAt;
1625
- return {
1626
- ...item,
1627
- updatedAt,
1628
- status: record === void 0 || record.status === "active" && Date.now() - updatedAt > day ? "inactive" : record.status
1629
- };
1630
- }));
1631
1811
  }
1632
- function providerStatuses() {
1633
- return allProviders.map((provider) => statuses[provider]);
1812
+ function optionValue(options, label) {
1813
+ return options.find((option) => option.label === label)?.value ?? label;
1634
1814
  }
1635
- function listedUpdatedAt(provider, sessionId) {
1636
- return listed[provider].find((item) => item.id === sessionId)?.updatedAt ?? null;
1815
+ function contentText(content) {
1816
+ return content.map((block) => block.type === "text" ? block.text : block.uri).join("\n");
1637
1817
  }
1638
- function providerOf(sessionId) {
1639
- const provider = runs.get(sessionId)?.provider ?? providers.get(sessionId);
1640
- if (provider === void 0) throw new Error("offsite does not know this session yet, reload the page");
1641
- return provider;
1818
+ function text$2(value) {
1819
+ return typeof value === "string" ? value : "";
1642
1820
  }
1643
- function recordInteraction(id) {
1644
- db.insert(sessions$1).values({
1645
- sessionId: id,
1646
- interactedAt: Date.now(),
1647
- status: "active"
1648
- }).onConflictDoUpdate({
1649
- target: sessions$1.sessionId,
1650
- set: {
1651
- interactedAt: Date.now(),
1652
- status: sql`CASE ${sessions$1.status} WHEN 'pinned' THEN 'pinned' WHEN 'minimized' THEN 'minimized' ELSE 'active' END`
1653
- }
1654
- }).run();
1821
+ function list$1(value) {
1822
+ return Array.isArray(value) ? value : [];
1655
1823
  }
1656
- function publishSessions() {
1657
- broadcast({
1658
- type: "sessions",
1659
- sessions: sessions()
1824
+ function record(value) {
1825
+ return isRecord(value) ? value : {};
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();
1832
+ function openCodeInstalled() {
1833
+ return findOpenCode() !== null;
1834
+ }
1835
+ async function openCodeCapabilities(cwd) {
1836
+ const client = await openCode();
1837
+ const location = { directory: cwd };
1838
+ const [models, commands, skills, agents] = await Promise.all([
1839
+ client.model.list({ location }),
1840
+ client.command.list({ location }),
1841
+ client.skill.list({ location }),
1842
+ client.agent.list({ location })
1843
+ ]);
1844
+ return {
1845
+ models: models.data.filter((model) => model.enabled).map((model) => ({
1846
+ value: `${model.providerID}/${model.id}`,
1847
+ name: model.name,
1848
+ description: model.providerID,
1849
+ efforts: model.variants.map((variant) => variant.id)
1850
+ })),
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
+ }))],
1860
+ agents: agents.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden).map((agent) => ({
1861
+ id: agent.id,
1862
+ name: agent.name,
1863
+ description: agent.description ?? ""
1864
+ }))
1865
+ };
1866
+ }
1867
+ async function openCodeSessions(paths) {
1868
+ if (paths.length === 0 || !openCodeInstalled()) return [];
1869
+ const client = await openCode();
1870
+ return (await Promise.all(paths.map(async (directory) => {
1871
+ const found = [];
1872
+ let cursor = null;
1873
+ do {
1874
+ const page = await client.session.list(cursor === null ? {
1875
+ directory,
1876
+ parentID: null,
1877
+ limit: PAGE
1878
+ } : { cursor });
1879
+ found.push(...page.data);
1880
+ cursor = page.data.length === PAGE ? page.cursor.next ?? null : null;
1881
+ } while (cursor !== null);
1882
+ return found;
1883
+ }))).flat().filter((session) => !temporary.has(session.id));
1884
+ }
1885
+ async function openCodeHistory(sessionId, cwd) {
1886
+ const run = openCodeRun(sessionId);
1887
+ const client = await openCode();
1888
+ const messages = await allMessages(client, sessionId);
1889
+ return {
1890
+ type: "history",
1891
+ sessionId,
1892
+ provider: "opencode",
1893
+ items: messages.flatMap((message) => messageItems(message, null)),
1894
+ requests: [...run?.pending.values() ?? []].map((item) => item.request),
1895
+ queue: run?.queue ?? [],
1896
+ settings: run?.settings ?? null,
1897
+ context: run?.context ?? await contextOf(client, cwd, messages),
1898
+ before: null,
1899
+ partial: run?.partial ?? ""
1900
+ };
1901
+ }
1902
+ async function openCodeAgent(agentId, parent) {
1903
+ return (await allMessages(await openCode(), agentId)).flatMap((message) => messageItems(message, parent));
1904
+ }
1905
+ async function renameOpenCode(sessionId, title) {
1906
+ await (await openCode()).session.update({
1907
+ sessionID: sessionId,
1908
+ title
1660
1909
  });
1661
1910
  }
1662
- function refreshAll() {
1663
- for (const provider of allProviders) refresh(provider);
1911
+ async function removeOpenCode(sessionId) {
1912
+ await (await openCode()).session.remove({ sessionID: sessionId });
1664
1913
  }
1665
- function refreshSessions(provider) {
1666
- if (timers.has(provider)) return;
1667
- timers.set(provider, setTimeout(() => {
1668
- timers.delete(provider);
1669
- refresh(provider);
1670
- }, REFRESH_DELAY));
1914
+ async function forkOpenCode(sessionId, messageId) {
1915
+ const client = await openCode();
1916
+ const messages = await allMessages(client, sessionId);
1917
+ const index = messages.findIndex((message) => message.id === messageId);
1918
+ if (index < 0) throw new Error("Cannot fork the session at that point");
1919
+ const next = messages.at(index + 1);
1920
+ return { sessionId: (await client.session.fork({
1921
+ sessionID: sessionId,
1922
+ ...next === void 0 ? {} : { before: next.id }
1923
+ })).id };
1671
1924
  }
1672
- async function refresh(provider) {
1673
- const current = refreshing.get(provider);
1674
- if (current !== void 0) {
1675
- current.again = true;
1676
- return;
1677
- }
1678
- const entry = { again: true };
1679
- refreshing.set(provider, entry);
1680
- while (entry.again) {
1681
- entry.again = false;
1682
- await list$1(provider);
1683
- }
1684
- refreshing.delete(provider);
1925
+ async function rewindOpenCode(request) {
1926
+ if (request.code && !request.conversation && !request.dryRun) throw new Error("OpenCode restores files only with the conversation");
1927
+ const client = await openCode();
1928
+ const sessionID = request.sessionId;
1929
+ const files = (await client.session.revert.stage({
1930
+ sessionID,
1931
+ messageID: request.messageId,
1932
+ files: request.code
1933
+ })).files ?? [];
1934
+ const result = request.code ? {
1935
+ files: files.map((file) => file.file),
1936
+ insertions: sumBy(files, (file) => file.additions),
1937
+ deletions: sumBy(files, (file) => file.deletions)
1938
+ } : null;
1939
+ if (request.dryRun) await client.session.revert.clear({ sessionID });
1940
+ else await client.session.revert.commit({ sessionID });
1941
+ return result;
1685
1942
  }
1686
- async function list$1(provider) {
1687
- const paths = new Map(projects$1().map((project) => [project.path, project.id]));
1688
- const before = statuses[provider];
1689
- let timer;
1943
+ async function openCodeCommitMessage(cwd, model, prompt) {
1944
+ const client = await openCode();
1945
+ const session = await client.session.create({
1946
+ location: { directory: cwd },
1947
+ agent: "plan",
1948
+ model: modelRef(model, null)
1949
+ });
1950
+ temporary.add(session.id);
1690
1951
  try {
1691
- const found = await Promise.race([find(provider, paths), new Promise((_, reject) => {
1692
- timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${providerName(provider)} did not list its sessions within ${LIST_TIMEOUT / 1e3} seconds`)), LIST_TIMEOUT);
1693
- })]);
1694
- if (found === null) {
1695
- listed[provider] = [];
1696
- statuses[provider] = {
1697
- provider,
1698
- state: "missing",
1699
- error: null
1700
- };
1701
- } else {
1702
- listed[provider] = found;
1703
- statuses[provider] = {
1704
- provider,
1705
- state: "ready",
1706
- error: null
1707
- };
1708
- for (const item of found) providers.set(item.id, provider);
1952
+ await client.session.prompt({
1953
+ sessionID: session.id,
1954
+ text: prompt
1955
+ });
1956
+ await client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(COMMIT_LIMIT) });
1957
+ const messages = await allMessages(client, session.id);
1958
+ for (const message of messages.toReversed()) {
1959
+ if (message.type === "idle" && message.outcome === "failed") throw new Error(message.error?.message ?? "OpenCode failed");
1960
+ if (message.type !== "assistant") continue;
1961
+ const text = message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("").trim();
1962
+ if (text !== "") return { message: text };
1709
1963
  }
1710
- } catch (error) {
1711
- const message = error instanceof Error ? error.message : String(error);
1712
- console.warn(`could not list ${providerName(provider)} sessions: ${message}`);
1713
- statuses[provider] = {
1714
- provider,
1715
- state: "error",
1716
- error: message
1717
- };
1964
+ throw new Error("OpenCode did not reply");
1718
1965
  } finally {
1719
- clearTimeout(timer);
1966
+ await client.session.remove({ sessionID: session.id });
1967
+ temporary.delete(session.id);
1720
1968
  }
1721
- publishSessions();
1722
- if (JSON.stringify(statuses[provider]) !== JSON.stringify(before)) broadcast({
1723
- type: "providers",
1724
- providers: providerStatuses()
1725
- });
1726
1969
  }
1727
- async function find(provider, paths) {
1728
- switch (provider) {
1729
- case "claude": return (await Promise.all([...paths].map(async ([cwd, projectId]) => (await listSessions({
1730
- dir: cwd,
1731
- includeWorktrees: false,
1732
- includeProgrammatic: false
1733
- })).map((item) => ({
1734
- id: item.sessionId,
1735
- provider: "claude",
1736
- title: item.summary,
1737
- projectId,
1738
- branch: item.gitBranch ?? null,
1739
- updatedAt: item.lastModified
1740
- }))))).flat();
1741
- case "codex":
1742
- if (!codexInstalled()) return null;
1743
- return (await codexThreads([...paths.keys()])).flatMap((thread) => {
1744
- const projectId = paths.get(thread.cwd);
1745
- if (projectId === void 0) return [];
1746
- return [{
1747
- id: thread.id,
1748
- provider: "codex",
1749
- title: thread.name ?? thread.preview,
1750
- projectId,
1751
- branch: thread.gitInfo?.branch ?? null,
1752
- updatedAt: thread.updatedAt * 1e3
1753
- }];
1754
- });
1755
- case "opencode":
1756
- if (!openCodeInstalled()) return null;
1757
- return (await openCodeSessions([...paths.keys()])).flatMap((session) => {
1758
- const projectId = paths.get(session.location.directory);
1759
- if (projectId === void 0) return [];
1760
- return [{
1761
- id: session.id,
1762
- provider: "opencode",
1763
- title: session.title ?? "New session",
1764
- projectId,
1765
- branch: null,
1766
- updatedAt: session.time.updated
1767
- }];
1768
- });
1769
- }
1970
+ async function contextOf(client, cwd, messages) {
1971
+ const last = messages.findLast((message) => message.type === "assistant" && message.tokens !== void 0);
1972
+ if (last?.type !== "assistant" || last.tokens === void 0) return null;
1973
+ const tokens = last.tokens;
1974
+ const model = (await client.model.list({ location: { directory: cwd } })).data.find((item) => item.providerID === last.model.providerID && item.id === last.model.id);
1975
+ return {
1976
+ tokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
1977
+ window: model?.limit.context ?? null
1978
+ };
1770
1979
  }
1771
- function watchSessions() {
1772
- for (const watcher of watchers) watcher.close();
1773
- watchers = [["claude", path.join(readConfig().claudeBaseDir, "projects")], ["codex", path.join(codexBaseDir, "sessions")]].flatMap(([provider, dir]) => {
1774
- try {
1775
- const watcher = watch(dir, { recursive: true }, () => refreshSessions(provider));
1776
- watcher.on("error", (error) => console.warn(`stopped watching ${dir}: ${error}`));
1777
- return [watcher];
1778
- } catch (error) {
1779
- if (error.code !== "ENOENT") throw error;
1780
- return [];
1781
- }
1782
- });
1980
+ function modelRef(value, effort) {
1981
+ const index = value.indexOf("/");
1982
+ if (index < 0) throw new Error(`${value} is not an OpenCode model`);
1983
+ return {
1984
+ providerID: value.slice(0, index),
1985
+ id: value.slice(index + 1),
1986
+ ...effort === null ? {} : { variant: effort }
1987
+ };
1783
1988
  }
1784
- async function updateSession(id, patch) {
1785
- if (patch.title !== void 0) switch (providerOf(id)) {
1786
- case "claude":
1787
- await renameSession(id, patch.title);
1788
- break;
1789
- case "codex":
1790
- await renameThread(id, patch.title);
1791
- break;
1792
- case "opencode": await renameOpenCode(id, patch.title);
1793
- }
1794
- if (patch.status === "inactive") db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
1795
- else if (patch.status !== void 0) db.insert(sessions$1).values({
1796
- sessionId: id,
1797
- status: patch.status
1798
- }).onConflictDoUpdate({
1799
- target: sessions$1.sessionId,
1800
- set: { status: patch.status }
1801
- }).run();
1802
- if (patch.title !== void 0) refreshSessions(providerOf(id));
1803
- }
1804
- async function removeSession(id) {
1805
- const provider = providerOf(id);
1806
- const run = runs.get(id);
1807
- if (run !== void 0) {
1808
- run.stop();
1809
- await run.closed;
1810
- }
1811
- switch (provider) {
1812
- case "claude":
1813
- await deleteSession(id);
1814
- break;
1815
- case "codex":
1816
- await deleteThread(id);
1817
- break;
1818
- case "opencode": await removeOpenCode(id);
1819
- }
1820
- db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
1821
- listed[provider] = listed[provider].filter((item) => item.id !== id);
1822
- }
1823
- async function fork(sessionId, projectId, messageId) {
1824
- const provider = providerOf(sessionId);
1825
- const result = await forkWith(provider, sessionId, projectPath(projectId), messageId);
1826
- providers.set(result.sessionId, provider);
1827
- recordInteraction(result.sessionId);
1828
- refreshSessions(provider);
1829
- return result;
1989
+ async function allMessages(client, sessionId) {
1990
+ const messages = [];
1991
+ let cursor = null;
1992
+ do {
1993
+ const page = await client.message.list(cursor === null ? {
1994
+ sessionID: sessionId,
1995
+ limit: PAGE,
1996
+ order: "asc"
1997
+ } : {
1998
+ sessionID: sessionId,
1999
+ cursor
2000
+ });
2001
+ messages.push(...page.data);
2002
+ cursor = page.data.length === PAGE ? page.cursor.next ?? null : null;
2003
+ } while (cursor !== null);
2004
+ return messages;
1830
2005
  }
1831
- async function forkWith(provider, sessionId, cwd, messageId) {
1832
- if (provider === "codex") return forkThread(sessionId, messageId);
1833
- if (provider === "opencode") return forkOpenCode(sessionId, messageId);
1834
- if ((await getSessionMessages(sessionId, { dir: cwd })).findIndex((item) => item.uuid === messageId) < 0) throw new Error("Cannot fork the session at that point");
1835
- return { sessionId: (await forkSession(sessionId, {
1836
- dir: cwd,
1837
- upToMessageId: messageId
1838
- })).sessionId };
2006
+ function openCodeRun(sessionId) {
2007
+ const run = runs.get(sessionId);
2008
+ return run?.provider === "opencode" ? run : void 0;
1839
2009
  }
1840
2010
  //#endregion
1841
- //#region src/lib/opencode/client.ts
1842
- const listChanges = /* @__PURE__ */ new Set([
1843
- "session.created",
1844
- "session.renamed",
1845
- "session.moved",
1846
- "session.forked",
1847
- "session.deleted",
1848
- "session.execution.succeeded",
1849
- "session.execution.failed",
1850
- "session.execution.interrupted"
1851
- ]);
1852
- const SILENCE_LIMIT$1 = 45e3;
1853
- const RECONNECT_ATTEMPTS = 5;
1854
- const RECONNECT_DELAY = 2e3;
1855
- let connection;
1856
- function findOpenCode() {
1857
- const name = process.platform === "win32" ? "opencode.exe" : "opencode";
1858
- for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
1859
- const candidate = path.join(dir, name);
1860
- if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
2011
+ //#region src/lib/opencode/run.ts
2012
+ const IDLE_LIMIT$1 = 18e5;
2013
+ const RECENT = 30;
2014
+ const replies = {
2015
+ allow: "once",
2016
+ always: "always",
2017
+ deny: "reject"
2018
+ };
2019
+ var OpenCodeRun = class OpenCodeRun {
2020
+ provider = "opencode";
2021
+ sessionId;
2022
+ cwd;
2023
+ pending = /* @__PURE__ */ new Map();
2024
+ items = /* @__PURE__ */ new Map();
2025
+ closed;
2026
+ queue = [];
2027
+ settings;
2028
+ context = null;
2029
+ partial = "";
2030
+ running = false;
2031
+ prompted = false;
2032
+ backgroundTasks = 0;
2033
+ tasks = {};
2034
+ close;
2035
+ applied;
2036
+ loading = Promise.resolve();
2037
+ refreshTimer;
2038
+ idleTimer;
2039
+ static async start(cwd, settings) {
2040
+ const session = await (await openCode()).session.create({
2041
+ location: { directory: cwd },
2042
+ ...settings.mode === null ? {} : { agent: settings.mode },
2043
+ ...settings.model === null ? {} : { model: modelRef(settings.model, settings.effort) }
2044
+ });
2045
+ return new OpenCodeRun(session.id, cwd, settings, settings);
1861
2046
  }
1862
- return null;
1863
- }
1864
- function openCode() {
1865
- connection ??= start();
1866
- return connection;
1867
- }
1868
- async function start() {
1869
- try {
1870
- const client = await connect();
1871
- listen(client);
1872
- return client;
1873
- } catch (error) {
1874
- connection = void 0;
1875
- throw error;
2047
+ static async resume(sessionId, cwd, settings) {
2048
+ const session = await (await openCode()).session.get({ sessionID: sessionId });
2049
+ return new OpenCodeRun(sessionId, cwd, settings, sessionSettings(session, settings));
1876
2050
  }
1877
- }
1878
- async function connect() {
1879
- const endpoint = await ensure();
1880
- return OpenCode.make({
1881
- baseUrl: endpoint.url,
1882
- headers: headers(endpoint)
1883
- });
1884
- }
1885
- async function listen(client) {
1886
- const controller = new AbortController();
1887
- const silence = setTimeout(() => controller.abort(), SILENCE_LIMIT$1);
1888
- try {
1889
- for await (const event of client.event.subscribe({
1890
- signal: controller.signal,
1891
- onActivity: () => silence.refresh()
1892
- })) route(event);
1893
- console.warn("the OpenCode event stream ended");
1894
- } catch (error) {
1895
- console.warn(controller.signal.aborted ? `the OpenCode event stream was silent for ${SILENCE_LIMIT$1 / 1e3} seconds` : `lost the OpenCode event stream: ${error}`);
1896
- } finally {
1897
- clearTimeout(silence);
2051
+ constructor(sessionId, cwd, settings, applied) {
2052
+ this.sessionId = sessionId;
2053
+ this.cwd = cwd;
2054
+ this.settings = settings;
2055
+ this.applied = applied;
2056
+ const closed = Promise.withResolvers();
2057
+ this.closed = closed.promise;
2058
+ this.close = closed.resolve;
2059
+ runs.set(sessionId, this);
2060
+ this.armIdleTimer();
1898
2061
  }
1899
- connection = reconnect();
1900
- connection.catch(() => void 0);
1901
- }
1902
- async function reconnect() {
1903
- let failure;
1904
- for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
1905
- await setTimeout$1(RECONNECT_DELAY);
1906
- let client;
1907
- try {
1908
- client = await connect();
1909
- } catch (error) {
1910
- failure = error;
1911
- continue;
2062
+ async submit(prompt, delivery) {
2063
+ if (!this.running) {
2064
+ await this.startTurn(prompt);
2065
+ return;
2066
+ }
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();
1912
2082
  }
1913
- listen(client);
1914
- await catchUp(client).catch((error) => console.warn(`could not catch up with OpenCode: ${error}`));
1915
- return client;
1916
2083
  }
1917
- console.warn(`could not reconnect to OpenCode: ${failure}`);
1918
- connection = void 0;
1919
- for (const run of openCodeRuns()) run.exited();
1920
- refreshSessions("opencode");
1921
- throw failure;
1922
- }
1923
- async function catchUp(client) {
1924
- const active = await client.session.active();
1925
- for (const run of openCodeRuns()) {
1926
- const [permissions, forms] = await Promise.all([client.permission.request.list({ location: { directory: run.cwd } }), client.session.form.list({ sessionID: run.sessionId })]);
1927
- run.reconcile(client, run.sessionId in active, permissions.data.filter((permission) => permission.sessionID === run.sessionId), forms);
1928
- }
1929
- refreshSessions("opencode");
1930
- }
1931
- function route(event) {
1932
- if (listChanges.has(event.type)) refreshSessions("opencode");
1933
- const sessionId = sessionOf(event);
1934
- if (sessionId === null) return;
1935
- const run = runs.get(sessionId);
1936
- if (run?.provider === "opencode") run.receive(event);
1937
- }
1938
- function sessionOf(event) {
1939
- if (event.type === "form.created") return event.data.form.sessionID;
1940
- const data = "data" in event ? event.data : null;
1941
- if (typeof data === "object" && data !== null && "sessionID" in data && typeof data.sessionID === "string") return data.sessionID;
1942
- return null;
1943
- }
1944
- function openCodeRuns() {
1945
- return [...runs.values()].flatMap((run) => run.provider === "opencode" ? [run] : []);
1946
- }
1947
- //#endregion
1948
- //#region src/lib/opencode/items.ts
1949
- const toolStatuses = {
1950
- streaming: "running",
1951
- running: "running",
1952
- completed: "completed",
1953
- error: "failed"
1954
- };
1955
- const todoStatuses$1 = {
1956
- pending: "pending",
1957
- in_progress: "running",
1958
- completed: "completed",
1959
- cancelled: "completed"
1960
- };
1961
- function messageItems(message, parent) {
1962
- const base = {
1963
- id: message.id,
1964
- parent,
1965
- time: message.time.created
1966
- };
1967
- switch (message.type) {
1968
- case "user": return [{
1969
- ...base,
1970
- kind: "user",
1971
- text: message.text,
1972
- images: (message.files ?? []).filter((file) => file.mime.startsWith("image/")).map((file) => ({
1973
- mediaType: file.mime,
1974
- data: file.data
1975
- })),
1976
- format: "text"
1977
- }];
1978
- case "skill": return [{
1979
- ...base,
1980
- kind: "skill",
1981
- skill: message.name,
1982
- output: "",
1983
- status: "completed"
1984
- }];
1985
- case "shell": return [{
1986
- ...base,
1987
- kind: "command",
1988
- command: message.command,
1989
- description: "",
1990
- output: message.output?.output ?? "",
1991
- status: message.status === "running" ? "running" : message.status === "exited" ? message.exit === 0 ? "completed" : "failed" : "stopped",
1992
- background: null
1993
- }];
1994
- case "assistant": return message.content.flatMap((part, index) => partItems(message, part, index, parent));
1995
- case "compaction": return message.status === "failed" ? [{
1996
- ...errorItem(message.id, message.error.message),
1997
- parent
1998
- }] : [{
1999
- ...base,
2000
- kind: "compaction"
2001
- }];
2002
- case "idle": return message.outcome === "failed" && message.error !== void 0 ? [{
2003
- ...errorItem(message.id, message.error.message),
2004
- parent
2005
- }] : [];
2006
- case "agent-switched":
2007
- case "model-switched":
2008
- case "location-switched":
2009
- case "synthetic":
2010
- case "system": return [];
2011
- }
2012
- }
2013
- function formQuestions(form) {
2014
- return form.fields.flatMap((field) => field.type === "external" ? [] : [{
2015
- question: questionText(field),
2016
- header: field.title ?? "",
2017
- multiSelect: field.type === "multiselect",
2018
- options: fieldOptions(field)
2019
- }]);
2020
- }
2021
- function formAnswer(form, answers) {
2022
- return Object.fromEntries(form.fields.flatMap((field) => {
2023
- if (field.type === "external") return [];
2024
- const answer = answers[questionText(field)];
2025
- if (answer === void 0 || answer === "") return [];
2026
- switch (field.type) {
2027
- case "string": return [[field.key, optionValue(field.options ?? [], answer)]];
2028
- case "multiselect": return [[field.key, answer.split(", ").map((label) => optionValue(field.options, label))]];
2029
- case "boolean":
2030
- if (answer === "Yes") return [[field.key, true]];
2031
- if (answer === "No") return [[field.key, false]];
2032
- throw new Error(`${field.key} needs Yes or No`);
2033
- case "number":
2034
- case "integer": return [[field.key, Number(answer)]];
2035
- }
2036
- }));
2037
- }
2038
- function partItems(message, part, index, parent) {
2039
- const base = {
2040
- id: `${message.id}:${index}`,
2041
- parent,
2042
- time: message.time.created
2043
- };
2044
- switch (part.type) {
2045
- case "text": return part.text.trim() === "" ? [] : [{
2046
- ...base,
2047
- kind: "assistant",
2048
- text: part.text,
2049
- message: message.id
2050
- }];
2051
- case "reasoning": return part.text.trim() === "" ? [] : [{
2052
- ...base,
2053
- kind: "reasoning",
2054
- text: part.text.trim()
2055
- }];
2056
- case "tool": return toolItems({
2057
- id: `${message.id}:${part.id}`,
2058
- parent,
2059
- time: part.time.created
2060
- }, part);
2061
- }
2062
- }
2063
- function toolItems(base, tool) {
2064
- const state = tool.state;
2065
- const status = toolStatuses[state.status];
2066
- const input = state.status === "streaming" ? {} : state.input;
2067
- const metadata = state.status === "streaming" ? {} : state.metadata ?? {};
2068
- const output = state.status === "error" ? state.error.message : state.status === "completed" ? contentText(state.content) : "";
2069
- switch (tool.name) {
2070
- case "shell": return [{
2071
- ...base,
2072
- kind: "command",
2073
- command: text$2(input.command),
2074
- description: text$2(input.description),
2075
- output,
2076
- status,
2077
- background: null
2078
- }];
2079
- case "edit": return [{
2080
- ...base,
2081
- kind: "file_change",
2082
- path: text$2(input.path ?? input.filePath),
2083
- diffs: [{
2084
- before: text$2(input.oldString),
2085
- after: text$2(input.newString)
2086
- }],
2087
- output,
2088
- status
2089
- }];
2090
- case "write": return [{
2091
- ...base,
2092
- kind: "file_change",
2093
- path: text$2(input.path ?? input.filePath),
2094
- diffs: [{
2095
- before: "",
2096
- after: text$2(input.content)
2097
- }],
2098
- output,
2099
- status
2100
- }];
2101
- case "patch":
2102
- case "apply_patch":
2103
- if (!Array.isArray(metadata.files)) break;
2104
- return metadata.files.map((file, index) => ({
2105
- ...base,
2106
- id: `${base.id}:${index}`,
2107
- kind: "file_change",
2108
- path: text$2(record(file).file),
2109
- diffs: hunks(text$2(record(file).patch)),
2110
- output,
2111
- status
2112
- }));
2113
- case "todowrite": return [{
2114
- ...base,
2115
- kind: "todo_list",
2116
- steps: list(input.todos).map((todo) => ({
2117
- text: text$2(record(todo).content),
2118
- status: todoStatuses$1[text$2(record(todo).status)] ?? "pending"
2119
- }))
2120
- }];
2121
- case "subagent":
2122
- case "task": return [{
2123
- ...base,
2124
- kind: "subagent",
2125
- agent: text$2(input.agent ?? input.subagent_type) || "Agent",
2126
- description: text$2(input.description),
2127
- agentId: typeof metadata.sessionID === "string" ? metadata.sessionID : null,
2128
- status
2129
- }];
2130
- case "webfetch":
2131
- case "websearch": return [{
2132
- ...base,
2133
- kind: "web_search",
2134
- query: tool.name === "websearch" ? text$2(input.query) : null,
2135
- url: tool.name === "webfetch" ? text$2(input.url) : null,
2136
- results: [],
2137
- output,
2138
- status
2139
- }];
2140
- case "question": {
2141
- const questions = list(input.questions).map((item) => ({
2142
- question: text$2(record(item).question),
2143
- header: text$2(record(item).header),
2144
- multiSelect: record(item).multiple === true,
2145
- options: list(record(item).options).map((option) => ({
2146
- label: text$2(record(option).label),
2147
- description: text$2(record(option).description)
2148
- }))
2149
- }));
2150
- const answers = metadata.answers;
2151
- return [{
2152
- ...base,
2153
- kind: "question",
2154
- questions,
2155
- answers: Array.isArray(answers) ? Object.fromEntries(questions.map((question, index) => [question.question, list(answers[index]).map(text$2).join(", ")])) : null,
2156
- status
2157
- }];
2158
- }
2159
- case "skill": return [{
2160
- ...base,
2161
- kind: "skill",
2162
- skill: text$2(input.name),
2163
- output,
2164
- status
2165
- }];
2166
- }
2167
- return [{
2168
- ...base,
2169
- kind: "tool",
2170
- name: tool.name,
2171
- input,
2172
- output,
2173
- status
2174
- }];
2175
- }
2176
- function questionText(field) {
2177
- return field.description ?? field.title ?? field.key;
2178
- }
2179
- function fieldOptions(field) {
2180
- switch (field.type) {
2181
- case "string":
2182
- case "multiselect": return (field.options ?? []).map((option) => ({
2183
- label: option.label,
2184
- description: option.description ?? ""
2185
- }));
2186
- case "boolean": return [{
2187
- label: "Yes",
2188
- description: ""
2189
- }, {
2190
- label: "No",
2191
- description: ""
2192
- }];
2193
- case "number":
2194
- case "integer": return [];
2195
- }
2196
- }
2197
- function optionValue(options, label) {
2198
- return options.find((option) => option.label === label)?.value ?? label;
2199
- }
2200
- function contentText(content) {
2201
- return content.map((block) => block.type === "text" ? block.text : block.uri).join("\n");
2202
- }
2203
- function text$2(value) {
2204
- return typeof value === "string" ? value : "";
2205
- }
2206
- function list(value) {
2207
- return Array.isArray(value) ? value : [];
2208
- }
2209
- function record(value) {
2210
- return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
2211
- }
2212
- //#endregion
2213
- //#region src/lib/opencode/sessions.ts
2214
- const PAGE = 100;
2215
- const COMMIT_LIMIT = 12e4;
2216
- const temporary = /* @__PURE__ */ new Set();
2217
- function openCodeAccount() {
2218
- return {
2219
- id: "opencode",
2220
- provider: "opencode",
2221
- name: "OpenCode",
2222
- configDir: "",
2223
- email: null,
2224
- plan: null,
2225
- error: null,
2226
- limits: [],
2227
- linked: true,
2228
- valid: true
2229
- };
2230
- }
2231
- function openCodeInstalled() {
2232
- return findOpenCode() !== null;
2233
- }
2234
- async function openCodeCapabilities(cwd) {
2235
- const client = await openCode();
2236
- const location = { directory: cwd };
2237
- const [models, commands, skills, agents] = await Promise.all([
2238
- client.model.list({ location }),
2239
- client.command.list({ location }),
2240
- client.skill.list({ location }),
2241
- client.agent.list({ location })
2242
- ]);
2243
- return {
2244
- models: models.data.filter((model) => model.enabled).map((model) => ({
2245
- value: `${model.providerID}/${model.id}`,
2246
- name: model.name,
2247
- description: model.providerID,
2248
- efforts: model.variants.map((variant) => variant.id)
2249
- })),
2250
- commands: [
2251
- {
2252
- name: "compact",
2253
- description: "Summarize the conversation to free up context",
2254
- hint: ""
2255
- },
2256
- ...commands.data.map((command) => ({
2257
- name: command.name,
2258
- description: command.description ?? "",
2259
- hint: ""
2260
- })),
2261
- ...skills.data.map((skill) => ({
2262
- name: skill.name,
2263
- description: skill.description ?? "",
2264
- hint: ""
2265
- }))
2266
- ],
2267
- agents: agents.data.filter((agent) => agent.mode !== "subagent" && !agent.hidden).map((agent) => ({
2268
- id: agent.id,
2269
- name: agent.name,
2270
- description: agent.description ?? ""
2271
- }))
2272
- };
2273
- }
2274
- async function openCodeSessions(paths) {
2275
- if (paths.length === 0 || !openCodeInstalled()) return [];
2276
- const client = await openCode();
2277
- return (await Promise.all(paths.map(async (directory) => {
2278
- const found = [];
2279
- let cursor = null;
2280
- do {
2281
- const page = await client.session.list(cursor === null ? {
2282
- directory,
2283
- parentID: null,
2284
- limit: PAGE
2285
- } : { cursor });
2286
- found.push(...page.data);
2287
- cursor = page.data.length === PAGE ? page.cursor.next ?? null : null;
2288
- } while (cursor !== null);
2289
- return found;
2290
- }))).flat().filter((session) => !temporary.has(session.id));
2291
- }
2292
- async function openCodeHistory(sessionId, cwd) {
2293
- const run = openCodeRun(sessionId);
2294
- const client = await openCode();
2295
- const messages = await allMessages(client, sessionId);
2296
- return {
2297
- type: "history",
2298
- sessionId,
2299
- provider: "opencode",
2300
- items: messages.flatMap((message) => messageItems(message, null)),
2301
- requests: [...run?.pending.values() ?? []].map((item) => item.request),
2302
- queue: run?.queue ?? [],
2303
- settings: run?.settings ?? null,
2304
- context: run?.context ?? await contextOf(client, cwd, messages),
2305
- before: null,
2306
- partial: run?.partial ?? ""
2307
- };
2308
- }
2309
- async function openCodeAgent(agentId, parent) {
2310
- return (await allMessages(await openCode(), agentId)).flatMap((message) => messageItems(message, parent));
2311
- }
2312
- async function renameOpenCode(sessionId, title) {
2313
- await (await openCode()).session.update({
2314
- sessionID: sessionId,
2315
- title
2316
- });
2317
- }
2318
- async function removeOpenCode(sessionId) {
2319
- await (await openCode()).session.remove({ sessionID: sessionId });
2320
- }
2321
- async function forkOpenCode(sessionId, messageId) {
2322
- const client = await openCode();
2323
- const messages = await allMessages(client, sessionId);
2324
- const index = messages.findIndex((message) => message.id === messageId);
2325
- if (index < 0) throw new Error("Cannot fork the session at that point");
2326
- const next = messages.at(index + 1);
2327
- return { sessionId: (await client.session.fork({
2328
- sessionID: sessionId,
2329
- ...next === void 0 ? {} : { before: next.id }
2330
- })).id };
2331
- }
2332
- async function rewindOpenCode(request) {
2333
- if (request.code && !request.conversation && !request.dryRun) throw new Error("OpenCode restores files only with the conversation");
2334
- const client = await openCode();
2335
- const sessionID = request.sessionId;
2336
- const files = (await client.session.revert.stage({
2337
- sessionID,
2338
- messageID: request.messageId,
2339
- files: request.code
2340
- })).files ?? [];
2341
- const result = request.code ? {
2342
- files: files.map((file) => file.file),
2343
- insertions: files.reduce((sum, file) => sum + file.additions, 0),
2344
- deletions: files.reduce((sum, file) => sum + file.deletions, 0)
2345
- } : null;
2346
- if (request.dryRun) await client.session.revert.clear({ sessionID });
2347
- else await client.session.revert.commit({ sessionID });
2348
- return result;
2349
- }
2350
- async function openCodeCommitMessage(cwd, model, prompt) {
2351
- const client = await openCode();
2352
- const session = await client.session.create({
2353
- location: { directory: cwd },
2354
- agent: "plan",
2355
- model: modelRef(model, null)
2356
- });
2357
- temporary.add(session.id);
2358
- try {
2359
- await client.session.prompt({
2360
- sessionID: session.id,
2361
- text: prompt
2362
- });
2363
- await client.session.wait({ sessionID: session.id }, { signal: AbortSignal.timeout(COMMIT_LIMIT) });
2364
- const messages = await allMessages(client, session.id);
2365
- for (const message of messages.toReversed()) {
2366
- if (message.type === "idle" && message.outcome === "failed") throw new Error(message.error?.message ?? "OpenCode failed");
2367
- if (message.type !== "assistant") continue;
2368
- const text = message.content.flatMap((part) => part.type === "text" ? [part.text] : []).join("").trim();
2369
- if (text !== "") return { message: text };
2370
- }
2371
- throw new Error("OpenCode did not reply");
2372
- } finally {
2373
- await client.session.remove({ sessionID: session.id });
2374
- temporary.delete(session.id);
2375
- }
2376
- }
2377
- async function contextOf(client, cwd, messages) {
2378
- const last = messages.findLast((message) => message.type === "assistant" && message.tokens !== void 0);
2379
- if (last?.type !== "assistant" || last.tokens === void 0) return null;
2380
- const tokens = last.tokens;
2381
- const model = (await client.model.list({ location: { directory: cwd } })).data.find((item) => item.providerID === last.model.providerID && item.id === last.model.id);
2382
- return {
2383
- tokens: tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write,
2384
- window: model?.limit.context ?? null
2385
- };
2386
- }
2387
- function modelRef(value, effort) {
2388
- const index = value.indexOf("/");
2389
- if (index < 0) throw new Error(`${value} is not an OpenCode model`);
2390
- return {
2391
- providerID: value.slice(0, index),
2392
- id: value.slice(index + 1),
2393
- ...effort === null ? {} : { variant: effort }
2394
- };
2395
- }
2396
- async function allMessages(client, sessionId) {
2397
- const messages = [];
2398
- let cursor = null;
2399
- do {
2400
- const page = await client.message.list(cursor === null ? {
2401
- sessionID: sessionId,
2402
- limit: PAGE,
2403
- order: "asc"
2404
- } : {
2405
- sessionID: sessionId,
2406
- cursor
2407
- });
2408
- messages.push(...page.data);
2409
- cursor = page.data.length === PAGE ? page.cursor.next ?? null : null;
2410
- } while (cursor !== null);
2411
- return messages;
2412
- }
2413
- function openCodeRun(sessionId) {
2414
- const run = runs.get(sessionId);
2415
- return run?.provider === "opencode" ? run : void 0;
2416
- }
2417
- //#endregion
2418
- //#region src/lib/opencode/run.ts
2419
- const IDLE_LIMIT$1 = 18e5;
2420
- const RECENT = 30;
2421
- const replies = {
2422
- allow: "once",
2423
- always: "always",
2424
- deny: "reject"
2425
- };
2426
- var OpenCodeRun = class OpenCodeRun {
2427
- provider = "opencode";
2428
- sessionId;
2429
- cwd;
2430
- pending = /* @__PURE__ */ new Map();
2431
- items = /* @__PURE__ */ new Map();
2432
- closed;
2433
- queue = [];
2434
- settings;
2435
- context = null;
2436
- partial = "";
2437
- running = false;
2438
- prompted = false;
2439
- backgroundTasks = 0;
2440
- tasks = {};
2441
- close;
2442
- applied;
2443
- loading = Promise.resolve();
2444
- refreshTimer;
2445
- idleTimer;
2446
- static async start(cwd, settings) {
2447
- const session = await (await openCode()).session.create({
2448
- location: { directory: cwd },
2449
- ...settings.mode === null ? {} : { agent: settings.mode },
2450
- ...settings.model === null ? {} : { model: modelRef(settings.model, settings.effort) }
2451
- });
2452
- return new OpenCodeRun(session.id, cwd, settings, settings);
2453
- }
2454
- static async resume(sessionId, cwd, settings) {
2455
- const session = await (await openCode()).session.get({ sessionID: sessionId });
2456
- return new OpenCodeRun(sessionId, cwd, settings, sessionSettings(session, settings));
2457
- }
2458
- constructor(sessionId, cwd, settings, applied) {
2459
- this.sessionId = sessionId;
2460
- this.cwd = cwd;
2461
- this.settings = settings;
2462
- this.applied = applied;
2463
- const closed = Promise.withResolvers();
2464
- this.closed = closed.promise;
2465
- this.close = closed.resolve;
2466
- runs.set(sessionId, this);
2467
- this.armIdleTimer();
2468
- }
2469
- async submit(prompt, delivery) {
2470
- if (!this.running) {
2471
- await this.startTurn(prompt);
2472
- return;
2473
- }
2474
- switch (delivery) {
2475
- case "steer":
2476
- await (await openCode()).session.prompt({
2477
- sessionID: this.sessionId,
2478
- text: prompt.text,
2479
- files: files(prompt),
2480
- delivery: "steer"
2481
- });
2482
- return;
2483
- case "queue":
2484
- this.queue = [...this.queue, {
2485
- id: randomUUID(),
2486
- prompt
2487
- }];
2488
- this.publishQueue();
2489
- }
2490
- }
2491
- unqueue(id) {
2492
- this.queue = this.queue.filter((item) => item.id !== id);
2493
- this.publishQueue();
2084
+ unqueue(id) {
2085
+ this.queue = this.queue.filter((item) => item.id !== id);
2086
+ this.publishQueue();
2494
2087
  }
2495
2088
  steer(id) {
2496
2089
  const item = this.queue.find((queued) => queued.id === id);
@@ -2616,187 +2209,704 @@ var OpenCodeRun = class OpenCodeRun {
2616
2209
  context
2617
2210
  });
2618
2211
  }
2619
- async startTurn(prompt) {
2620
- clearTimeout(this.idleTimer);
2621
- this.running = true;
2622
- this.prompted = true;
2623
- broadcastActivity();
2624
- try {
2625
- await this.apply();
2626
- await this.send(prompt);
2627
- } catch (error) {
2628
- this.running = false;
2629
- broadcastActivity();
2630
- this.armIdleTimer();
2631
- throw error;
2632
- }
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();
2337
+ }
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();
2346
+ }
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 = "";
2351
+ publish$2(this.sessionId, {
2352
+ type: "items",
2353
+ sessionId: this.sessionId,
2354
+ items
2355
+ });
2356
+ }
2357
+ fail(error) {
2358
+ this.put([errorItem(randomUUID(), error instanceof Error ? error.message : String(error))]);
2359
+ }
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);
2366
+ }
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
2380
+ });
2381
+ }
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 pairingCodes = snakeCase.table("pairing_codes", {
2427
+ hash: text().primaryKey(),
2428
+ name: text(),
2429
+ expiresAt: integer().notNull()
2430
+ });
2431
+ const devices = snakeCase.table("devices", {
2432
+ hash: text().primaryKey(),
2433
+ name: text(),
2434
+ createdAt: integer().notNull(),
2435
+ expiresAt: integer().notNull(),
2436
+ lastUsedAt: integer()
2437
+ });
2438
+ const pushSubscriptions = snakeCase.table("push_subscriptions", {
2439
+ endpoint: text().primaryKey(),
2440
+ p256dh: text().notNull(),
2441
+ auth: text().notNull(),
2442
+ publicKey: text().notNull(),
2443
+ privateKey: text().notNull(),
2444
+ machine: text().notNull(),
2445
+ server: text().notNull()
2446
+ });
2447
+ //#endregion
2448
+ //#region src/services/projects.ts
2449
+ const LOGO_TYPES = [
2450
+ "image/png",
2451
+ "image/jpeg",
2452
+ "image/gif",
2453
+ "image/webp",
2454
+ "image/svg+xml",
2455
+ "image/x-icon",
2456
+ "image/vnd.microsoft.icon"
2457
+ ];
2458
+ const LOGO_LIMIT = 524288;
2459
+ function projects$1() {
2460
+ return db.select().from(projects$2).all();
2461
+ }
2462
+ function projectPath(id) {
2463
+ const project = db.select().from(projects$2).where(eq(projects$2.id, id)).get();
2464
+ if (project === void 0) throw new Error("That project was removed");
2465
+ return project.path;
2466
+ }
2467
+ async function addProject(input) {
2468
+ const folder = path.resolve(expandHome(input.trim()));
2469
+ if (!(await stat(folder)).isDirectory()) throw new Error(`${folder} is not a folder`);
2470
+ db.insert(projects$2).values({
2471
+ id: randomUUID(),
2472
+ path: folder,
2473
+ name: path.basename(folder) || folder,
2474
+ logo: null
2475
+ }).onConflictDoNothing({ target: projects$2.path }).run();
2476
+ publish$1();
2477
+ const project = projects$1().find((item) => item.path === folder);
2478
+ if (project === void 0) throw new Error(`Could not add ${folder}`);
2479
+ return project;
2480
+ }
2481
+ function updateProject(id, patch) {
2482
+ if (patch.name !== void 0) {
2483
+ const name = patch.name.trim();
2484
+ if (name === "") throw new Error("Project name cannot be empty");
2485
+ db.update(projects$2).set({ name }).where(eq(projects$2.id, id)).run();
2486
+ }
2487
+ if (patch.logo !== void 0) {
2488
+ if (patch.logo !== null) checkLogo(patch.logo);
2489
+ db.update(projects$2).set({ logo: patch.logo }).where(eq(projects$2.id, id)).run();
2633
2490
  }
2634
- async send(prompt) {
2635
- const client = await openCode();
2636
- const sessionID = this.sessionId;
2637
- const location = { directory: this.cwd };
2638
- const match = /^\/(\S+)\s*([\s\S]*)$/.exec(prompt.text);
2639
- if (match !== null) {
2640
- if (match[1] === "compact" && prompt.images.length === 0) {
2641
- await client.session.compact({ sessionID });
2642
- return;
2643
- }
2644
- if ((await client.command.list({ location })).data.some((command) => command.name === match[1])) {
2645
- await client.session.command({
2646
- sessionID,
2647
- name: match[1],
2648
- text: match[2],
2649
- files: files(prompt)
2650
- });
2651
- return;
2652
- }
2653
- const skill = (await client.skill.list({ location })).data.find((item) => item.name === match[1]);
2654
- if (skill !== void 0) {
2655
- await client.session.prompt({
2656
- sessionID,
2657
- text: match[2],
2658
- files: files(prompt),
2659
- skills: [{ id: skill.id }]
2660
- });
2661
- return;
2662
- }
2663
- }
2664
- await client.session.prompt({
2665
- sessionID,
2666
- text: prompt.text,
2667
- files: files(prompt)
2668
- });
2491
+ publish$1();
2492
+ return null;
2493
+ }
2494
+ function removeProject(id) {
2495
+ db.delete(projects$2).where(eq(projects$2.id, id)).run();
2496
+ publish$1();
2497
+ }
2498
+ async function directories(input) {
2499
+ const folder = path.resolve(expandHome(input.trim()));
2500
+ const entries = await readdir(folder, { withFileTypes: true });
2501
+ const parent = path.dirname(folder);
2502
+ return {
2503
+ path: folder,
2504
+ parent: parent === folder ? null : parent,
2505
+ directories: entries.filter((entry) => entry.isDirectory()).map((entry) => ({
2506
+ name: entry.name,
2507
+ path: path.join(folder, entry.name)
2508
+ })).sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
2509
+ };
2510
+ }
2511
+ function publish$1() {
2512
+ broadcast({
2513
+ type: "projects",
2514
+ projects: projects$1()
2515
+ });
2516
+ }
2517
+ function checkLogo(logo) {
2518
+ const type = /^data:([^;,]+);base64,/.exec(logo)?.[1];
2519
+ if (type === void 0 || !LOGO_TYPES.includes(type)) throw new Error("Use a PNG, JPEG, GIF, WebP, SVG or ICO image");
2520
+ if (logo.length > LOGO_LIMIT) throw new Error("Use an image smaller than 384 KB");
2521
+ }
2522
+ function expandHome(input) {
2523
+ if (input === "~") return os.homedir();
2524
+ if (input.startsWith("~/")) return path.join(os.homedir(), input.slice(2));
2525
+ return input;
2526
+ }
2527
+ //#endregion
2528
+ //#region src/services/sessions.ts
2529
+ const day = 864e5;
2530
+ const LIST_TIMEOUT = 1e4;
2531
+ const REFRESH_DELAY = 1e3;
2532
+ const allProviders = [
2533
+ "claude",
2534
+ "codex",
2535
+ "opencode"
2536
+ ];
2537
+ const listed = {
2538
+ claude: [],
2539
+ codex: [],
2540
+ opencode: []
2541
+ };
2542
+ const statuses = {
2543
+ claude: {
2544
+ provider: "claude",
2545
+ state: "loading",
2546
+ error: null
2547
+ },
2548
+ codex: {
2549
+ provider: "codex",
2550
+ state: "loading",
2551
+ error: null
2552
+ },
2553
+ opencode: {
2554
+ provider: "opencode",
2555
+ state: "loading",
2556
+ error: null
2669
2557
  }
2670
- async apply() {
2671
- const client = await openCode();
2672
- const settings = this.settings;
2673
- const model = settings.model ?? this.applied.model;
2674
- if (model !== null && (model !== this.applied.model || settings.effort !== this.applied.effort)) await client.session.switchModel({
2675
- sessionID: this.sessionId,
2676
- model: modelRef(model, settings.effort)
2677
- });
2678
- if (settings.mode !== null && settings.mode !== this.applied.mode) await client.session.switchAgent({
2679
- sessionID: this.sessionId,
2680
- agent: settings.mode
2681
- });
2682
- this.applied = {
2683
- ...settings,
2684
- model,
2685
- mode: settings.mode ?? this.applied.mode
2558
+ };
2559
+ const refreshing = /* @__PURE__ */ new Map();
2560
+ const timers = /* @__PURE__ */ new Map();
2561
+ const providers = /* @__PURE__ */ new Map();
2562
+ let watchers = [];
2563
+ function sessions() {
2564
+ const records = new Map(db.select().from(sessions$1).all().map((record) => [record.sessionId, record]));
2565
+ const known = new Set(projects$1().map((project) => project.id));
2566
+ return allProviders.flatMap((provider) => listed[provider].filter((item) => known.has(item.projectId)).map((item) => {
2567
+ const record = records.get(item.id);
2568
+ const updatedAt = record?.interactedAt ?? item.updatedAt;
2569
+ return {
2570
+ ...item,
2571
+ updatedAt,
2572
+ status: record === void 0 || record.status === "active" && Date.now() - updatedAt > day ? "inactive" : record.status
2686
2573
  };
2574
+ }));
2575
+ }
2576
+ function providerStatuses() {
2577
+ return allProviders.map((provider) => statuses[provider]);
2578
+ }
2579
+ function listedUpdatedAt(provider, sessionId) {
2580
+ return listed[provider].find((item) => item.id === sessionId)?.updatedAt ?? null;
2581
+ }
2582
+ function providerOf(sessionId) {
2583
+ const provider = runs.get(sessionId)?.provider ?? providers.get(sessionId);
2584
+ if (provider === void 0) throw new Error("offsite does not know this session yet, reload the page");
2585
+ return provider;
2586
+ }
2587
+ function recordInteraction(id) {
2588
+ db.insert(sessions$1).values({
2589
+ sessionId: id,
2590
+ interactedAt: Date.now(),
2591
+ status: "active"
2592
+ }).onConflictDoUpdate({
2593
+ target: sessions$1.sessionId,
2594
+ set: {
2595
+ interactedAt: Date.now(),
2596
+ status: sql`CASE ${sessions$1.status} WHEN 'pinned' THEN 'pinned' WHEN 'minimized' THEN 'minimized' ELSE 'active' END`
2597
+ }
2598
+ }).run();
2599
+ }
2600
+ function publishSessions() {
2601
+ broadcast({
2602
+ type: "sessions",
2603
+ sessions: sessions()
2604
+ });
2605
+ }
2606
+ function refreshAll() {
2607
+ for (const provider of allProviders) refresh(provider);
2608
+ }
2609
+ function refreshSessions(provider) {
2610
+ if (timers.has(provider)) return;
2611
+ timers.set(provider, setTimeout(() => {
2612
+ timers.delete(provider);
2613
+ refresh(provider);
2614
+ }, REFRESH_DELAY));
2615
+ }
2616
+ async function refresh(provider) {
2617
+ const current = refreshing.get(provider);
2618
+ if (current !== void 0) {
2619
+ current.again = true;
2620
+ return;
2687
2621
  }
2688
- ask(permission) {
2689
- const source = permission.source;
2690
- const item = source === void 0 ? void 0 : this.items.get(`${source.messageID}:${source.id}`);
2691
- this.addPending({
2692
- kind: "permission",
2693
- request: {
2694
- id: permission.id,
2695
- kind: "approval",
2696
- title: permission.message ?? `Allow ${permission.action} ${permission.resources.join(", ")}?`,
2697
- canAlwaysAllow: (permission.save ?? []).length > 0,
2698
- item: item ?? {
2699
- id: permission.id,
2700
- parent: null,
2701
- time: Date.now(),
2702
- kind: "tool",
2703
- name: permission.action,
2704
- input: { resources: permission.resources },
2705
- output: "",
2706
- status: "running"
2707
- }
2708
- }
2709
- });
2622
+ const entry = { again: true };
2623
+ refreshing.set(provider, entry);
2624
+ while (entry.again) {
2625
+ entry.again = false;
2626
+ await list(provider);
2710
2627
  }
2711
- askForm(form) {
2712
- this.addPending({
2713
- kind: "form",
2714
- request: {
2715
- id: form.id,
2716
- kind: "question",
2717
- questions: formQuestions(form)
2718
- },
2719
- form
2628
+ refreshing.delete(provider);
2629
+ }
2630
+ async function list(provider) {
2631
+ const paths = new Map(projects$1().map((project) => [project.path, project.id]));
2632
+ const before = statuses[provider];
2633
+ let timer;
2634
+ try {
2635
+ if (!readConfig().enabledProviders[provider]) {
2636
+ listed[provider] = [];
2637
+ statuses[provider] = {
2638
+ provider,
2639
+ state: "disabled",
2640
+ error: null
2641
+ };
2642
+ return;
2643
+ }
2644
+ const found = await Promise.race([find(provider, paths), new Promise((_, reject) => {
2645
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`${providerName(provider)} did not list its sessions within ${LIST_TIMEOUT / 1e3} seconds`)), LIST_TIMEOUT);
2646
+ })]);
2647
+ if (found === null) {
2648
+ listed[provider] = [];
2649
+ statuses[provider] = {
2650
+ provider,
2651
+ state: "missing",
2652
+ error: null
2653
+ };
2654
+ } else {
2655
+ listed[provider] = found;
2656
+ statuses[provider] = {
2657
+ provider,
2658
+ state: "ready",
2659
+ error: null
2660
+ };
2661
+ for (const item of found) providers.set(item.id, provider);
2662
+ }
2663
+ } catch (error) {
2664
+ const message = error instanceof Error ? error.message : String(error);
2665
+ console.warn(`could not list ${providerName(provider)} sessions: ${message}`);
2666
+ statuses[provider] = {
2667
+ provider,
2668
+ state: "error",
2669
+ error: message
2670
+ };
2671
+ } finally {
2672
+ clearTimeout(timer);
2673
+ publishSessions();
2674
+ if (JSON.stringify(statuses[provider]) !== JSON.stringify(before)) broadcast({
2675
+ type: "providers",
2676
+ providers: providerStatuses()
2720
2677
  });
2721
2678
  }
2722
- addPending(pending) {
2723
- this.pending.set(pending.request.id, pending);
2724
- publish$2(this.sessionId, {
2725
- type: "request",
2726
- sessionId: this.sessionId,
2727
- request: pending.request
2728
- });
2729
- broadcastActivity();
2679
+ }
2680
+ async function find(provider, paths) {
2681
+ switch (provider) {
2682
+ case "claude": return (await Promise.all([...paths].map(async ([cwd, projectId]) => (await listSessions({
2683
+ dir: cwd,
2684
+ includeWorktrees: false,
2685
+ includeProgrammatic: false
2686
+ })).map((item) => ({
2687
+ id: item.sessionId,
2688
+ provider: "claude",
2689
+ title: item.summary,
2690
+ projectId,
2691
+ branch: item.gitBranch ?? null,
2692
+ updatedAt: item.lastModified
2693
+ }))))).flat();
2694
+ case "codex":
2695
+ if (!codexInstalled()) return null;
2696
+ return (await codexThreads([...paths.keys()])).flatMap((thread) => {
2697
+ const projectId = paths.get(thread.cwd);
2698
+ if (projectId === void 0) return [];
2699
+ return [{
2700
+ id: thread.id,
2701
+ provider: "codex",
2702
+ title: thread.name ?? thread.preview,
2703
+ projectId,
2704
+ branch: thread.gitInfo?.branch ?? null,
2705
+ updatedAt: thread.updatedAt * 1e3
2706
+ }];
2707
+ });
2708
+ case "opencode":
2709
+ if (!openCodeInstalled()) return null;
2710
+ return (await openCodeSessions([...paths.keys()])).flatMap((session) => {
2711
+ const projectId = paths.get(session.location.directory);
2712
+ if (projectId === void 0) return [];
2713
+ return [{
2714
+ id: session.id,
2715
+ provider: "opencode",
2716
+ title: session.title ?? "New session",
2717
+ projectId,
2718
+ branch: null,
2719
+ updatedAt: session.time.updated
2720
+ }];
2721
+ });
2730
2722
  }
2731
- finishTurn() {
2732
- this.partial = "";
2733
- const [next, ...rest] = this.queue;
2734
- if (next !== void 0) {
2735
- this.queue = rest;
2736
- this.publishQueue();
2737
- this.startTurn(next.prompt).catch((error) => this.fail(error));
2738
- return;
2723
+ }
2724
+ function watchSessions() {
2725
+ for (const watcher of watchers) watcher.close();
2726
+ watchers = [["claude", path.join(readConfig().claudeBaseDir, "projects")], ["codex", path.join(codexBaseDir, "sessions")]].flatMap(([provider, dir]) => {
2727
+ if (!readConfig().enabledProviders[provider]) return [];
2728
+ try {
2729
+ const watcher = watch(dir, { recursive: true }, () => refreshSessions(provider));
2730
+ watcher.on("error", (error) => console.warn(`stopped watching ${dir}: ${error}`));
2731
+ return [watcher];
2732
+ } catch (error) {
2733
+ if (error.code !== "ENOENT") throw error;
2734
+ return [];
2739
2735
  }
2740
- this.running = false;
2741
- broadcastActivity();
2742
- this.armIdleTimer();
2736
+ });
2737
+ }
2738
+ async function updateSession(id, patch) {
2739
+ if (patch.title !== void 0) switch (providerOf(id)) {
2740
+ case "claude":
2741
+ await renameSession(id, patch.title);
2742
+ break;
2743
+ case "codex":
2744
+ await renameThread(id, patch.title);
2745
+ break;
2746
+ case "opencode": await renameOpenCode(id, patch.title);
2743
2747
  }
2744
- settle(requestId) {
2745
- if (!this.pending.delete(requestId)) return;
2746
- publish$2(this.sessionId, {
2747
- type: "requestDone",
2748
- sessionId: this.sessionId,
2749
- requestId
2750
- });
2751
- broadcastActivity();
2748
+ if (patch.status === "inactive") db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
2749
+ else if (patch.status !== void 0) db.insert(sessions$1).values({
2750
+ sessionId: id,
2751
+ status: patch.status
2752
+ }).onConflictDoUpdate({
2753
+ target: sessions$1.sessionId,
2754
+ set: { status: patch.status }
2755
+ }).run();
2756
+ if (patch.title !== void 0) refreshSessions(providerOf(id));
2757
+ }
2758
+ async function removeSession(id) {
2759
+ const provider = providerOf(id);
2760
+ const run = runs.get(id);
2761
+ if (run !== void 0) {
2762
+ run.stop();
2763
+ await run.closed;
2752
2764
  }
2753
- put(items) {
2754
- if (items.length === 0) return;
2755
- for (const item of items) this.items.set(item.id, item);
2756
- if (items.some((item) => item.kind === "assistant")) this.partial = "";
2757
- publish$2(this.sessionId, {
2758
- type: "items",
2759
- sessionId: this.sessionId,
2760
- items
2761
- });
2765
+ switch (provider) {
2766
+ case "claude":
2767
+ await deleteSession(id);
2768
+ break;
2769
+ case "codex":
2770
+ await deleteThread(id);
2771
+ break;
2772
+ case "opencode": await removeOpenCode(id);
2762
2773
  }
2763
- fail(error) {
2764
- this.put([errorItem(randomUUID(), error instanceof Error ? error.message : String(error))]);
2774
+ db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
2775
+ listed[provider] = listed[provider].filter((item) => item.id !== id);
2776
+ }
2777
+ async function fork(sessionId, projectId, messageId) {
2778
+ const provider = providerOf(sessionId);
2779
+ const result = await forkWith(provider, sessionId, projectPath(projectId), messageId);
2780
+ providers.set(result.sessionId, provider);
2781
+ recordInteraction(result.sessionId);
2782
+ refreshSessions(provider);
2783
+ return result;
2784
+ }
2785
+ async function forkWith(provider, sessionId, cwd, messageId) {
2786
+ if (provider === "codex") return forkThread(sessionId, messageId);
2787
+ if (provider === "opencode") return forkOpenCode(sessionId, messageId);
2788
+ if ((await getSessionMessages(sessionId, { dir: cwd })).findIndex((item) => item.uuid === messageId) < 0) throw new Error("Cannot fork the session at that point");
2789
+ return { sessionId: (await forkSession(sessionId, {
2790
+ dir: cwd,
2791
+ upToMessageId: messageId
2792
+ })).sessionId };
2793
+ }
2794
+ //#endregion
2795
+ //#region src/lib/opencode/client.ts
2796
+ const listChanges = /* @__PURE__ */ new Set([
2797
+ "session.created",
2798
+ "session.renamed",
2799
+ "session.moved",
2800
+ "session.forked",
2801
+ "session.deleted",
2802
+ "session.execution.succeeded",
2803
+ "session.execution.failed",
2804
+ "session.execution.interrupted"
2805
+ ]);
2806
+ const SILENCE_LIMIT$1 = 45e3;
2807
+ const RECONNECT_ATTEMPTS = 5;
2808
+ const RECONNECT_DELAY = 2e3;
2809
+ let connection;
2810
+ let listener;
2811
+ function findOpenCode() {
2812
+ const name = process.platform === "win32" ? "opencode.exe" : "opencode";
2813
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
2814
+ const candidate = path.join(dir, name);
2815
+ if (statSync(candidate, { throwIfNoEntry: false })?.isFile()) return candidate;
2765
2816
  }
2766
- armIdleTimer() {
2767
- clearTimeout(this.idleTimer);
2768
- if (this.running) return;
2769
- this.idleTimer = setTimeout(() => {
2770
- if (!this.running && this.pending.size === 0) this.stop();
2771
- }, IDLE_LIMIT$1);
2817
+ return null;
2818
+ }
2819
+ function openCode() {
2820
+ connection ??= start();
2821
+ return connection;
2822
+ }
2823
+ function disconnectOpenCode() {
2824
+ const controller = listener;
2825
+ listener = void 0;
2826
+ connection = void 0;
2827
+ controller?.abort();
2828
+ }
2829
+ async function start() {
2830
+ try {
2831
+ const client = await connect();
2832
+ listen(client);
2833
+ return client;
2834
+ } catch (error) {
2835
+ connection = void 0;
2836
+ throw error;
2772
2837
  }
2773
- end() {
2774
- clearTimeout(this.idleTimer);
2775
- clearTimeout(this.refreshTimer);
2776
- for (const requestId of [...this.pending.keys()]) this.settle(requestId);
2777
- this.running = false;
2778
- broadcastActivity();
2779
- this.close();
2838
+ }
2839
+ async function connect() {
2840
+ const endpoint = await ensure();
2841
+ return OpenCode.make({
2842
+ baseUrl: endpoint.url,
2843
+ headers: headers(endpoint)
2844
+ });
2845
+ }
2846
+ async function listen(client) {
2847
+ const controller = new AbortController();
2848
+ listener = controller;
2849
+ const silence = setTimeout(() => controller.abort(), SILENCE_LIMIT$1);
2850
+ try {
2851
+ for await (const event of client.event.subscribe({
2852
+ signal: controller.signal,
2853
+ onActivity: () => silence.refresh()
2854
+ })) route(event);
2855
+ console.warn("the OpenCode event stream ended");
2856
+ } catch (error) {
2857
+ if (listener !== controller) return;
2858
+ console.warn(controller.signal.aborted ? `the OpenCode event stream was silent for ${SILENCE_LIMIT$1 / 1e3} seconds` : `lost the OpenCode event stream: ${error}`);
2859
+ } finally {
2860
+ clearTimeout(silence);
2861
+ }
2862
+ if (listener !== controller) return;
2863
+ connection = reconnect();
2864
+ connection.catch(() => void 0);
2865
+ }
2866
+ async function reconnect() {
2867
+ let failure;
2868
+ for (let attempt = 0; attempt < RECONNECT_ATTEMPTS; attempt++) {
2869
+ await setTimeout$1(RECONNECT_DELAY);
2870
+ let client;
2871
+ try {
2872
+ client = await connect();
2873
+ } catch (error) {
2874
+ failure = error;
2875
+ continue;
2876
+ }
2877
+ listen(client);
2878
+ await catchUp(client).catch((error) => console.warn(`could not catch up with OpenCode: ${error}`));
2879
+ return client;
2780
2880
  }
2781
- publishQueue() {
2782
- publish$2(this.sessionId, {
2783
- type: "queue",
2784
- sessionId: this.sessionId,
2785
- queue: this.queue
2786
- });
2881
+ console.warn(`could not reconnect to OpenCode: ${failure}`);
2882
+ connection = void 0;
2883
+ for (const run of openCodeRuns()) run.exited();
2884
+ refreshSessions("opencode");
2885
+ throw failure;
2886
+ }
2887
+ async function catchUp(client) {
2888
+ const active = await client.session.active();
2889
+ for (const run of openCodeRuns()) {
2890
+ const [permissions, forms] = await Promise.all([client.permission.request.list({ location: { directory: run.cwd } }), client.session.form.list({ sessionID: run.sessionId })]);
2891
+ run.reconcile(client, run.sessionId in active, permissions.data.filter((permission) => permission.sessionID === run.sessionId), forms);
2787
2892
  }
2788
- };
2789
- function sessionSettings(session, settings) {
2790
- const variant = session.model?.variant;
2791
- return {
2792
- ...settings,
2793
- model: session.model === void 0 ? null : `${session.model.providerID}/${session.model.id}`,
2794
- effort: variant === void 0 || variant === "default" ? null : variant,
2795
- mode: session.agent ?? null
2796
- };
2893
+ refreshSessions("opencode");
2797
2894
  }
2798
- function files(prompt) {
2799
- return prompt.images.map((image) => ({ uri: `data:${image.mediaType};base64,${image.data}` }));
2895
+ function route(event) {
2896
+ if (listChanges.has(event.type)) refreshSessions("opencode");
2897
+ const sessionId = sessionOf(event);
2898
+ if (sessionId === null) return;
2899
+ const run = runs.get(sessionId);
2900
+ if (run?.provider === "opencode") run.receive(event);
2901
+ }
2902
+ function sessionOf(event) {
2903
+ if (event.type === "form.created") return event.data.form.sessionID;
2904
+ const data = "data" in event ? event.data : null;
2905
+ if (isRecord(data) && typeof data.sessionID === "string") return data.sessionID;
2906
+ return null;
2907
+ }
2908
+ function openCodeRuns() {
2909
+ return [...runs.values()].flatMap((run) => run.provider === "opencode" ? [run] : []);
2800
2910
  }
2801
2911
  //#endregion
2802
2912
  //#region src/services/accounts.ts
@@ -2809,7 +2919,36 @@ function providerName(provider) {
2809
2919
  return providerNames[provider];
2810
2920
  }
2811
2921
  function storedAccounts() {
2812
- return readJson("accounts.json") ?? [];
2922
+ return (readJson("accounts.json") ?? []).map((item) => ({
2923
+ id: item.id,
2924
+ provider: item.provider,
2925
+ name: item.name,
2926
+ configDir: item.configDir,
2927
+ enabled: item.enabled ?? true,
2928
+ hiddenModels: item.hiddenModels ?? {}
2929
+ }));
2930
+ }
2931
+ function assertEnabled(provider, accountId) {
2932
+ if (!readConfig().enabledProviders[provider]) throw new Error(`${providerName(provider)} is disabled in Settings`);
2933
+ if (provider === "opencode") return;
2934
+ if (!storedAccount(provider, accountId).enabled) throw new Error("That account is disabled in Settings");
2935
+ }
2936
+ async function stopProvider(provider) {
2937
+ const stopping = [...runs.values()].filter((run) => run.provider === provider);
2938
+ for (const run of stopping) run.stop();
2939
+ await Promise.all(stopping.map((run) => run.closed));
2940
+ if (provider === "codex") stopAppServers();
2941
+ if (provider === "opencode") disconnectOpenCode();
2942
+ if (provider === "claude") {
2943
+ for (const account of storedAccounts()) if (account.provider === "claude") forgetClaude(account.id);
2944
+ }
2945
+ }
2946
+ async function stopAccount(account) {
2947
+ const stopping = [...runs.values()].filter((run) => run.settings.accountId === account.id);
2948
+ for (const run of stopping) run.stop();
2949
+ await Promise.all(stopping.map((run) => run.closed));
2950
+ if (account.provider === "codex") stopAppServer(account.configDir);
2951
+ if (account.provider === "claude") forgetClaude(account.id);
2813
2952
  }
2814
2953
  function storedAccount(provider, accountId) {
2815
2954
  if (accountId === null) throw new Error(`Add a ${providerName(provider)} account in Settings → Accounts`);
@@ -2820,49 +2959,92 @@ function storedAccount(provider, accountId) {
2820
2959
  }
2821
2960
  async function accounts() {
2822
2961
  const stored = await Promise.all(storedAccounts().map((account) => {
2962
+ if (!account.enabled || !readConfig().enabledProviders[account.provider]) return disabledAccount(account);
2823
2963
  switch (account.provider) {
2824
2964
  case "claude": return claudeAccount(account);
2825
2965
  case "codex": return codexAccount(account);
2826
- case "opencode": throw new Error("OpenCode has no stored accounts");
2827
2966
  }
2828
2967
  }));
2829
- return openCodeInstalled() ? [...stored, openCodeAccount()] : stored;
2968
+ return {
2969
+ claudeAccounts: stored.filter((item) => item.provider === "claude"),
2970
+ codexAccounts: stored.filter((item) => item.provider === "codex"),
2971
+ openCode: { available: openCodeInstalled() && readConfig().enabledProviders.opencode }
2972
+ };
2973
+ }
2974
+ function disabledAccount(account) {
2975
+ return {
2976
+ ...account,
2977
+ email: null,
2978
+ plan: null,
2979
+ error: null,
2980
+ limits: [],
2981
+ linked: true,
2982
+ valid: false
2983
+ };
2830
2984
  }
2831
2985
  async function publishAccounts() {
2832
2986
  try {
2833
2987
  broadcast({
2834
2988
  type: "accounts",
2835
- accounts: await accounts()
2989
+ ...await accounts()
2836
2990
  });
2837
2991
  } catch (error) {
2838
2992
  console.warn(`could not list accounts: ${error}`);
2839
2993
  }
2840
2994
  }
2841
- function capabilities(accountId, cwd) {
2842
- if (accountId === openCodeAccount().id) return openCodeCapabilities(cwd);
2843
- const account = storedAccounts().find((item) => item.id === accountId);
2844
- if (account === void 0) throw new Error("That account was removed");
2845
- switch (account.provider) {
2846
- case "claude": return claudeCapabilities(account, cwd);
2847
- case "codex": return codexCapabilities(account, cwd);
2848
- case "opencode": throw new Error("OpenCode has no stored accounts");
2995
+ async function capabilities(provider, accountId, cwd) {
2996
+ assertEnabled(provider, accountId);
2997
+ const result = await providerCapabilities(provider, accountId, cwd);
2998
+ const settings = readConfig().models[provider];
2999
+ const overrides = provider === "opencode" ? {} : storedAccount(provider, accountId).hiddenModels;
3000
+ return {
3001
+ ...result,
3002
+ defaultModel: defaultModels[provider],
3003
+ models: result.models.map((model) => {
3004
+ const deprecated = deprecatedModels[provider].includes(model.value);
3005
+ const setting = settings[model.value] ?? {
3006
+ hidden: deprecated,
3007
+ effort: null
3008
+ };
3009
+ return {
3010
+ ...model,
3011
+ deprecated,
3012
+ hidden: overrides[model.value] ?? setting.hidden,
3013
+ effort: setting.effort
3014
+ };
3015
+ })
3016
+ };
3017
+ }
3018
+ function providerCapabilities(provider, accountId, cwd) {
3019
+ switch (provider) {
3020
+ case "claude": return claudeCapabilities(storedAccount(provider, accountId), cwd);
3021
+ case "codex": return codexCapabilities(storedAccount(provider, accountId), cwd);
3022
+ case "opencode":
3023
+ if (accountId !== null) throw new Error("OpenCode has no accounts. Connect providers in OpenCode");
3024
+ return openCodeCapabilities(cwd);
2849
3025
  }
2850
3026
  }
2851
3027
  function addAccount(provider, name, configDir) {
2852
- if (provider === "opencode") throw new Error("OpenCode has no accounts. Connect providers in OpenCode");
2853
3028
  saveAccounts([...storedAccounts(), {
2854
3029
  id: randomUUID(),
2855
3030
  provider,
2856
- ...validAccount(name, configDir)
3031
+ ...validAccount(name, configDir),
3032
+ enabled: true,
3033
+ hiddenModels: {}
2857
3034
  }]);
2858
3035
  }
2859
- function updateAccount(id, name, configDir) {
2860
- const account = validAccount(name, configDir);
2861
- saveAccounts(storedAccounts().map((item) => item.id === id ? {
3036
+ async function updateAccount(update) {
3037
+ const before = storedAccounts().find((item) => item.id === update.id);
3038
+ if (before === void 0) throw new Error("That account was removed");
3039
+ const account = validAccount(update.name, update.configDir);
3040
+ saveAccounts(storedAccounts().map((item) => item.id === update.id ? {
2862
3041
  ...item,
2863
- ...account
3042
+ ...account,
3043
+ enabled: update.enabled,
3044
+ hiddenModels: update.hiddenModels
2864
3045
  } : item));
2865
- forgetClaude(id);
3046
+ forgetClaude(update.id);
3047
+ if (before.enabled && !update.enabled) await stopAccount(before);
2866
3048
  }
2867
3049
  function removeAccount(id) {
2868
3050
  saveAccounts(storedAccounts().filter((item) => item.id !== id));
@@ -2885,81 +3067,6 @@ function saveAccounts(items) {
2885
3067
  writeJson("accounts.json", items);
2886
3068
  }
2887
3069
  //#endregion
2888
- //#region src/lib/claude/content.ts
2889
- function toMessage(type, item, time) {
2890
- return {
2891
- type,
2892
- uuid: item.uuid,
2893
- time,
2894
- content: isRecord(item.message) ? toBlocks(item.message.content, answersOf(item.tool_use_result)) : [],
2895
- parent: item.parent_tool_use_id
2896
- };
2897
- }
2898
- function contextTokens(message) {
2899
- if (!isRecord(message) || !isRecord(message.usage)) return null;
2900
- const usage = message.usage;
2901
- return [
2902
- usage.input_tokens,
2903
- usage.cache_creation_input_tokens,
2904
- usage.cache_read_input_tokens,
2905
- usage.output_tokens
2906
- ].reduce((total, value) => total + (typeof value === "number" ? value : 0), 0);
2907
- }
2908
- function answersOf(output) {
2909
- if (!isRecord(output) || !isRecord(output.answers)) return null;
2910
- const answers = {};
2911
- for (const [question, answer] of Object.entries(output.answers)) {
2912
- if (typeof answer !== "string") return null;
2913
- answers[question] = answer;
2914
- }
2915
- return answers;
2916
- }
2917
- function toBlocks(content, answers) {
2918
- if (typeof content === "string") return [{
2919
- type: "text",
2920
- text: content
2921
- }];
2922
- if (!Array.isArray(content)) return [];
2923
- return content.flatMap((block) => {
2924
- if (!isRecord(block)) return [];
2925
- switch (block.type) {
2926
- case "text": return typeof block.text === "string" && !block.text.startsWith("<system-reminder>") && !block.text.startsWith("<local-command-caveat>") ? [{
2927
- type: "text",
2928
- text: block.text
2929
- }] : [];
2930
- case "thinking": return typeof block.thinking === "string" && block.thinking !== "" ? [{
2931
- type: "thinking",
2932
- thinking: block.thinking
2933
- }] : [];
2934
- case "image": {
2935
- const source = block.source;
2936
- return isRecord(source) && source.type === "base64" && typeof source.media_type === "string" && typeof source.data === "string" ? [{
2937
- type: "image",
2938
- mediaType: source.media_type,
2939
- data: source.data
2940
- }] : [];
2941
- }
2942
- case "tool_use": return typeof block.id === "string" && typeof block.name === "string" && isRecord(block.input) ? [{
2943
- type: "tool_use",
2944
- id: block.id,
2945
- name: block.name,
2946
- input: block.input
2947
- }] : [];
2948
- case "tool_result": return typeof block.tool_use_id === "string" ? [{
2949
- type: "tool_result",
2950
- toolUseId: block.tool_use_id,
2951
- content: toBlocks(block.content, null).filter((item) => item.type === "text"),
2952
- isError: block.is_error === true,
2953
- answers
2954
- }] : [];
2955
- default: return [];
2956
- }
2957
- });
2958
- }
2959
- function isRecord(value) {
2960
- return typeof value === "object" && value !== null && !Array.isArray(value);
2961
- }
2962
- //#endregion
2963
3070
  //#region src/lib/claude/claude.ts
2964
3071
  const efforts = [
2965
3072
  "low",
@@ -3002,13 +3109,15 @@ function claudeMode(value) {
3002
3109
  }
3003
3110
  function claudeOptions(accountId, cwd) {
3004
3111
  const account = storedAccount("claude", accountId);
3112
+ const env = {
3113
+ ...process.env,
3114
+ CLAUDE_CODE_ENTRYPOINT: "offsite",
3115
+ CLAUDE_CONFIG_DIR: account.configDir
3116
+ };
3117
+ if (path.resolve(account.configDir) === path.join(os.homedir(), ".claude")) delete env.CLAUDE_CONFIG_DIR;
3005
3118
  return {
3006
3119
  cwd,
3007
- env: {
3008
- ...process.env,
3009
- CLAUDE_CODE_ENTRYPOINT: "offsite",
3010
- CLAUDE_CONFIG_DIR: account.configDir
3011
- },
3120
+ env,
3012
3121
  pathToClaudeCodeExecutable: executable$1,
3013
3122
  systemPrompt: {
3014
3123
  type: "preset",
@@ -3017,7 +3126,7 @@ function claudeOptions(accountId, cwd) {
3017
3126
  hooks: { PostToolUse: [{
3018
3127
  matcher: "PushNotification",
3019
3128
  hooks: [async (input) => {
3020
- 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");
3129
+ if (input.hook_event_name !== "PostToolUse" || !isRecord(input.tool_input) || typeof input.tool_input.message !== "string") throw new Error("Unexpected PushNotification hook input");
3021
3130
  broadcast({
3022
3131
  type: "notification",
3023
3132
  sessionId: input.session_id,
@@ -3038,12 +3147,15 @@ function claudeOptions(accountId, cwd) {
3038
3147
  async function claudeCapabilities(account, cwd) {
3039
3148
  const result = await initialization(account.id, cwd);
3040
3149
  return {
3041
- models: result.models.map((model) => ({
3042
- value: model.value,
3043
- name: model.displayName,
3044
- description: model.description,
3045
- efforts: model.supportedEffortLevels ?? []
3046
- })),
3150
+ models: result.models.filter((model) => model.value !== "default").map((model) => {
3151
+ if (model.resolvedModel === void 0) throw new Error(`Claude did not resolve the ${model.value} model`);
3152
+ return {
3153
+ value: model.resolvedModel,
3154
+ name: model.displayName,
3155
+ description: model.description,
3156
+ efforts: model.supportedEffortLevels ?? []
3157
+ };
3158
+ }),
3047
3159
  commands: result.commands.map((command) => ({
3048
3160
  name: command.name,
3049
3161
  description: command.description,
@@ -3104,6 +3216,28 @@ function recordLimit(accountId, info) {
3104
3216
  limits.set(accountId, current);
3105
3217
  return accountLimits(accountId);
3106
3218
  }
3219
+ async function recordUsage(accountId, probe) {
3220
+ try {
3221
+ const usage = await probe.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true });
3222
+ if (usage.rate_limits === null) return;
3223
+ const current = limits.get(accountId) ?? /* @__PURE__ */ new Map();
3224
+ const windows = {
3225
+ five_hour: usage.rate_limits.five_hour,
3226
+ seven_day: usage.rate_limits.seven_day,
3227
+ seven_day_opus: usage.rate_limits.seven_day_opus,
3228
+ seven_day_sonnet: usage.rate_limits.seven_day_sonnet
3229
+ };
3230
+ for (const [name, window] of Object.entries(windows)) if (window !== void 0 && window !== null) current.set(name, {
3231
+ window: name,
3232
+ utilization: window.utilization === null ? null : window.utilization / 100,
3233
+ resetsAt: window.resets_at === null ? null : Date.parse(window.resets_at) / 1e3,
3234
+ rejected: window.utilization !== null && window.utilization >= 100
3235
+ });
3236
+ limits.set(accountId, current);
3237
+ } catch (error) {
3238
+ console.warn(`could not read Claude usage limits: ${error}`);
3239
+ }
3240
+ }
3107
3241
  function isLinked(configDir) {
3108
3242
  const base = readConfig().claudeBaseDir;
3109
3243
  try {
@@ -3133,7 +3267,9 @@ async function runProbe(accountId, cwd) {
3133
3267
  options: claudeOptions(accountId, cwd)
3134
3268
  });
3135
3269
  try {
3136
- return await probe.initializationResult();
3270
+ const result = await probe.initializationResult();
3271
+ await recordUsage(accountId, probe);
3272
+ return result;
3137
3273
  } finally {
3138
3274
  closed.resolve();
3139
3275
  probe.close();
@@ -3192,14 +3328,15 @@ function toItems(messages) {
3192
3328
  }
3193
3329
  if (message.type === "assistant") {
3194
3330
  message.content.forEach((block, index) => {
3195
- const id = `${message.uuid}:${index}`;
3196
- const parent = message.parent;
3331
+ const shared = {
3332
+ id: `${message.uuid}:${index}`,
3333
+ parent: message.parent,
3334
+ time: message.time
3335
+ };
3197
3336
  switch (block.type) {
3198
3337
  case "text":
3199
3338
  if (block.text.trim() !== "") items.push({
3200
- id,
3201
- parent,
3202
- time: message.time,
3339
+ ...shared,
3203
3340
  kind: "assistant",
3204
3341
  text: block.text,
3205
3342
  message: message.uuid
@@ -3207,9 +3344,7 @@ function toItems(messages) {
3207
3344
  break;
3208
3345
  case "thinking":
3209
3346
  if (block.thinking.trim() !== "") items.push({
3210
- id,
3211
- parent,
3212
- time: message.time,
3347
+ ...shared,
3213
3348
  kind: "reasoning",
3214
3349
  text: block.thinking
3215
3350
  });
@@ -3221,7 +3356,7 @@ function toItems(messages) {
3221
3356
  }
3222
3357
  if (block.name === "TaskStop") stops.set(block.id, text$1(block.input.task_id));
3223
3358
  tools.set(block.id, items.length);
3224
- items.push(toolItem(block, parent, message.time, (to) => {
3359
+ items.push(toolItem(block, message.parent, message.time, (to) => {
3225
3360
  const owner = owners.get(to);
3226
3361
  return owner === void 0 ? null : items[owner].id;
3227
3362
  }));
@@ -3725,12 +3860,6 @@ async function agentMessages(sessionId, cwd, agentId, parent) {
3725
3860
  //#endregion
3726
3861
  //#region src/lib/claude/run.ts
3727
3862
  const IDLE_LIMIT = 6e5;
3728
- const imageTypes = [
3729
- "image/png",
3730
- "image/jpeg",
3731
- "image/gif",
3732
- "image/webp"
3733
- ];
3734
3863
  var Run = class {
3735
3864
  provider = "claude";
3736
3865
  sessionId;
@@ -3860,42 +3989,10 @@ var Run = class {
3860
3989
  }
3861
3990
  push(prompt, priority) {
3862
3991
  clearTimeout(this.idleTimer);
3863
- for (const image of prompt.images) if (!imageTypes.includes(image.mediaType)) throw new Error(`${image.mediaType} images are not supported`);
3864
3992
  const uuid = randomUUID();
3865
- this.inbox.push({
3866
- type: "user",
3867
- message: {
3868
- role: "user",
3869
- content: [...prompt.images.map((image) => ({
3870
- type: "image",
3871
- source: {
3872
- type: "base64",
3873
- media_type: image.mediaType,
3874
- data: image.data
3875
- }
3876
- })), ...prompt.text === "" ? [] : [{
3877
- type: "text",
3878
- text: prompt.text
3879
- }]]
3880
- },
3881
- parent_tool_use_id: null,
3882
- uuid,
3883
- ...priority === void 0 ? {} : { priority }
3884
- });
3993
+ this.inbox.push(toUserInput(prompt, uuid, priority));
3885
3994
  this.wake?.();
3886
- this.add({
3887
- type: "user",
3888
- uuid,
3889
- time: Date.now(),
3890
- content: [...prompt.images.map((image) => ({
3891
- type: "image",
3892
- ...image
3893
- })), ...prompt.text === "" ? [] : [{
3894
- type: "text",
3895
- text: prompt.text
3896
- }]],
3897
- parent: null
3898
- });
3995
+ this.add(promptMessage(prompt, uuid));
3899
3996
  this.running = true;
3900
3997
  this.prompted = true;
3901
3998
  broadcastActivity();
@@ -4089,6 +4186,7 @@ var Run = class {
4089
4186
  request
4090
4187
  });
4091
4188
  broadcastActivity();
4189
+ scheduleAutoResolve(this.sessionId, request);
4092
4190
  options.signal.addEventListener("abort", () => this.settle(request.id, {
4093
4191
  behavior: "deny",
4094
4192
  message: "Cancelled"
@@ -4139,21 +4237,13 @@ var Run = class {
4139
4237
  });
4140
4238
  }
4141
4239
  };
4142
- function notice(text) {
4143
- return {
4144
- type: "notice",
4145
- uuid: randomUUID(),
4146
- time: Date.now(),
4147
- text,
4148
- isError: true
4149
- };
4150
- }
4151
4240
  //#endregion
4152
4241
  //#region src/services/git.ts
4153
4242
  const DIFF_LIMIT = 8e4;
4154
4243
  const UNTRACKED_LIMIT = 100;
4155
4244
  const NEW_FILE_LIMIT = 262144;
4156
4245
  const REPO_DEPTH = 3;
4246
+ const run$1 = promisify(execFile);
4157
4247
  async function gitStatus(cwd) {
4158
4248
  let output;
4159
4249
  try {
@@ -4207,20 +4297,24 @@ async function push$1(cwd) {
4207
4297
  return gitStatus(cwd);
4208
4298
  }
4209
4299
  async function createPullRequest(cwd) {
4210
- const { stdout } = await promisify(execFile)("gh", [
4300
+ const url = (await run$1("gh", [
4211
4301
  "pr",
4212
4302
  "create",
4213
4303
  "--fill"
4214
- ], { cwd });
4215
- const url = stdout.trim().split("\n").at(-1);
4304
+ ], { cwd })).stdout.trim().split("\n").at(-1);
4216
4305
  if (url === void 0) throw new Error("gh did not return a pull request URL");
4217
4306
  return { url };
4218
4307
  }
4219
- async function suggestCommitMessage(provider, accountId, cwd) {
4220
- const diff = (await workingDiff(cwd)).slice(0, DIFF_LIMIT);
4308
+ async function suggestCommitMessage(cwd) {
4309
+ const settings = readConfig().commitMessage;
4310
+ const provider = settings.provider;
4311
+ const accountId = settings.accountId;
4312
+ const model = settings.model;
4313
+ if (provider === null || model === null) throw new Error("Choose a commit message model in Settings → Git");
4314
+ assertEnabled(provider, accountId);
4315
+ if (!await isRepo(cwd)) throw new Error(`${cwd} is not a git repository`);
4316
+ const diff = (await repoDiff(cwd, "")).slice(0, DIFF_LIMIT);
4221
4317
  if (diff === "") throw new Error("There is nothing to commit");
4222
- const model = readConfig().commitModels[provider];
4223
- if (model === null) throw new Error(`Choose a ${providerName(provider)} commit model in Settings → Accounts`);
4224
4318
  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}`;
4225
4319
  if (provider === "codex") return codexCommitMessage(accountId, cwd, model, prompt);
4226
4320
  if (provider === "opencode") return openCodeCommitMessage(cwd, model, prompt);
@@ -4243,10 +4337,6 @@ async function suggestCommitMessage(provider, accountId, cwd) {
4243
4337
  }
4244
4338
  throw new Error("Claude did not reply");
4245
4339
  }
4246
- async function workingDiff(cwd) {
4247
- if (!await isRepo(cwd)) throw new Error(`${cwd} is not a git repository`);
4248
- return repoDiff(cwd, "");
4249
- }
4250
4340
  async function projectDiff(cwd) {
4251
4341
  if (await isRepo(cwd)) return repoDiff(cwd, "");
4252
4342
  const repos = childRepos(cwd, "", REPO_DEPTH);
@@ -4254,7 +4344,7 @@ async function projectDiff(cwd) {
4254
4344
  return (await Promise.all(repos.map((repo) => repoDiff(path.join(cwd, repo), `${repo}/`)))).join("");
4255
4345
  }
4256
4346
  function git(cwd, args) {
4257
- return promisify(execFile)("git", args, {
4347
+ return run$1("git", args, {
4258
4348
  cwd,
4259
4349
  maxBuffer: 67108864
4260
4350
  });
@@ -4403,7 +4493,7 @@ async function taskOutput(sessionId, file) {
4403
4493
  try {
4404
4494
  stats = await lstat(file);
4405
4495
  } catch (error) {
4406
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
4496
+ if (hasCode(error, "ENOENT")) return null;
4407
4497
  throw error;
4408
4498
  }
4409
4499
  if (!stats.isFile()) throw new Error("Background task output is not a regular file");
@@ -4447,42 +4537,57 @@ const TIME_TO_LIVE = 900;
4447
4537
  const SEND_TIMEOUT = 1e4;
4448
4538
  const SUBJECT = "https://github.com/NazmusSayad/offsite";
4449
4539
  let server$1 = "";
4540
+ let notifier = null;
4450
4541
  function startPush(address) {
4451
4542
  server$1 = address;
4452
4543
  }
4453
4544
  function pushActive() {
4454
- return ours().length > 0;
4545
+ return targets().length > 0;
4546
+ }
4547
+ function notifierClient() {
4548
+ return notifier === null ? null : notifier.client;
4455
4549
  }
4456
- function pushKey() {
4457
- return { publicKey: machineInfo().pushPublicKey };
4550
+ function claimNotifier(client, endpoint) {
4551
+ notifier = {
4552
+ client,
4553
+ endpoint
4554
+ };
4555
+ for (const item of clients) item.send({
4556
+ type: "notifier",
4557
+ active: item === client
4558
+ });
4458
4559
  }
4459
- function subscribe(subscription) {
4460
- if (new URL(subscription.endpoint).protocol !== "https:") throw new Error("the endpoint must be an https URL");
4560
+ function subscribe(registration, client) {
4561
+ if (new URL(registration.subscription.endpoint).protocol !== "https:") throw new Error("the endpoint must be an https URL");
4461
4562
  const values = {
4462
- endpoint: subscription.endpoint,
4463
- p256dh: subscription.keys.p256dh,
4464
- auth: subscription.keys.auth,
4563
+ endpoint: registration.subscription.endpoint,
4564
+ p256dh: registration.subscription.keys.p256dh,
4565
+ auth: registration.subscription.keys.auth,
4566
+ publicKey: registration.publicKey,
4567
+ privateKey: registration.privateKey,
4568
+ machine: registration.machine,
4465
4569
  server: server$1
4466
4570
  };
4467
4571
  db.insert(pushSubscriptions).values(values).onConflictDoUpdate({
4468
4572
  target: pushSubscriptions.endpoint,
4469
4573
  set: values
4470
4574
  }).run();
4575
+ if (notifier !== null && notifier.client === client) notifier.endpoint = registration.subscription.endpoint;
4471
4576
  console.log("a browser subscribed to push notifications");
4472
4577
  }
4473
4578
  function unsubscribe(endpoint) {
4474
4579
  db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)).run();
4580
+ if (notifier !== null && notifier.endpoint === endpoint) notifier.endpoint = null;
4475
4581
  console.log("a browser unsubscribed from push notifications");
4476
4582
  }
4477
4583
  async function sendPush(message) {
4478
- const subscriptions = ours();
4584
+ const subscriptions = targets();
4479
4585
  if (subscriptions.length === 0) return;
4480
- const keys = machineInfo();
4481
- const payload = JSON.stringify({
4586
+ const trimmed = {
4482
4587
  ...message,
4483
4588
  title: [...message.title].slice(0, TITLE_LIMIT).join(""),
4484
4589
  body: [...message.body].slice(0, BODY_LIMIT).join("")
4485
- });
4590
+ };
4486
4591
  const gone = [];
4487
4592
  await Promise.all(subscriptions.map(async (subscription) => {
4488
4593
  try {
@@ -4492,11 +4597,14 @@ async function sendPush(message) {
4492
4597
  p256dh: subscription.p256dh,
4493
4598
  auth: subscription.auth
4494
4599
  }
4495
- }, payload, {
4600
+ }, JSON.stringify({
4601
+ ...trimmed,
4602
+ machineId: subscription.machine
4603
+ }), {
4496
4604
  vapidDetails: {
4497
4605
  subject: SUBJECT,
4498
- publicKey: keys.pushPublicKey,
4499
- privateKey: keys.pushPrivateKey
4606
+ publicKey: subscription.publicKey,
4607
+ privateKey: subscription.privateKey
4500
4608
  },
4501
4609
  TTL: TIME_TO_LIVE,
4502
4610
  urgency: "high",
@@ -4520,8 +4628,9 @@ async function sendPush(message) {
4520
4628
  if (gone.length === 0) return;
4521
4629
  db.delete(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), inArray(pushSubscriptions.endpoint, gone))).run();
4522
4630
  }
4523
- function ours() {
4524
- return db.select().from(pushSubscriptions).where(eq(pushSubscriptions.server, server$1)).all();
4631
+ function targets() {
4632
+ if (notifier === null || notifier.endpoint === null) return [];
4633
+ return db.select().from(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), eq(pushSubscriptions.endpoint, notifier.endpoint))).all();
4525
4634
  }
4526
4635
  //#endregion
4527
4636
  //#region src/services/runs.ts
@@ -4595,6 +4704,7 @@ async function open$1(client, sessionId, projectId) {
4595
4704
  client.send(event);
4596
4705
  }
4597
4706
  async function send$1(client, request) {
4707
+ assertEnabled(request.settings.provider, request.settings.accountId);
4598
4708
  const cwd = projectPath(request.projectId);
4599
4709
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`${cwd} is not a folder`);
4600
4710
  if (request.sessionId === null) {
@@ -4618,11 +4728,7 @@ async function sendExisting(client, sessionId, cwd, request) {
4618
4728
  const provider = providerOf(sessionId);
4619
4729
  if (request.settings.provider !== provider) throw new Error(`This session belongs to ${providerName(provider)}`);
4620
4730
  const existing = runs.get(sessionId);
4621
- if (existing?.provider === "claude" && !existing.running && existing.backgroundTasks === 0 && await changedElsewhere(existing, cwd)) {
4622
- existing.stop();
4623
- await existing.closed;
4624
- }
4625
- if (existing?.provider === "codex" && !existing.running && existing.pending.size === 0 && (listedUpdatedAt("codex", sessionId) ?? 0) > existing.activeAt) {
4731
+ 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) {
4626
4732
  existing.stop();
4627
4733
  await existing.closed;
4628
4734
  }
@@ -4772,7 +4878,7 @@ function shellIntegrationIssue(command, platform, defaultShell) {
4772
4878
  }
4773
4879
  //#endregion
4774
4880
  //#region src/services/terminal/shell-integration.ts
4775
- const scripts = path.join(serverDir, "src", "services", "terminal", "shell-integration");
4881
+ const scripts = path.join(serverDir, "src/services/terminal/shell-integration");
4776
4882
  const folder = path.join(home, "shell-integration");
4777
4883
  function prepare(env, command, shell) {
4778
4884
  if (shellIntegrationIssue(command, platform(), shell) !== null) return null;
@@ -4989,7 +5095,7 @@ function launch() {
4989
5095
  offsite: [
4990
5096
  "offsite",
4991
5097
  "offsite",
4992
- version
5098
+ "0.0.0"
4993
5099
  ],
4994
5100
  ghostty: [
4995
5101
  "ghostty",
@@ -5457,7 +5563,7 @@ function scan(id, terminal, data) {
5457
5563
  return;
5458
5564
  }
5459
5565
  const alert = terminal.scanner.scan(data);
5460
- if (alert === null || browserOpen()) return;
5566
+ if (alert === null || notifierWatching()) return;
5461
5567
  if (!(alert.type === "bell" ? terminal.alerts.startBell() : terminal.alerts.startNotification())) return;
5462
5568
  const windowTitle = terminal.scanner.title;
5463
5569
  setTimeout(() => push(id, alert, windowTitle), 500);
@@ -5474,7 +5580,7 @@ function push(id, alert, windowTitle) {
5474
5580
  title = alert.title;
5475
5581
  body = alert.body;
5476
5582
  }
5477
- if (browserOpen()) return;
5583
+ if (notifierWatching()) return;
5478
5584
  const entry = [...projects].find(([, project]) => project.tabs.some((tab) => tab.id === id));
5479
5585
  if (entry === void 0) return;
5480
5586
  const tab = findTab(id);
@@ -5482,19 +5588,17 @@ function push(id, alert, windowTitle) {
5482
5588
  sendPush({
5483
5589
  title,
5484
5590
  body,
5485
- machineId: machineInfo().id,
5486
5591
  projectId: entry[0],
5487
5592
  tabId: id
5488
5593
  });
5489
5594
  }
5490
- function browserOpen() {
5491
- return [...connections.values()].some((connection) => Date.now() - connection.client.heardAt < SILENCE_LIMIT);
5595
+ function notifierWatching() {
5596
+ const client = notifierClient();
5597
+ if (client === null || !connections.has(client)) return false;
5598
+ return Date.now() - client.heardAt < SILENCE_LIMIT;
5492
5599
  }
5493
5600
  function findTab(id) {
5494
- for (const project of projects.values()) {
5495
- const tab = project.tabs.find((item) => item.id === id);
5496
- if (tab !== void 0) return tab;
5497
- }
5601
+ return [...projects.values()].flatMap((project) => project.tabs).find((tab) => tab.id === id);
5498
5602
  }
5499
5603
  function nearestVisible(tabs, index) {
5500
5604
  for (let position = index; position < tabs.length; position++) if (!tabs[position].minimized) return position;
@@ -5527,8 +5631,6 @@ const handlers = {
5527
5631
  refreshAll();
5528
5632
  return {
5529
5633
  ...configInfo(),
5530
- protocol: 3,
5531
- machineId: machineInfo().id,
5532
5634
  hostname: os.hostname(),
5533
5635
  projects: projects$1(),
5534
5636
  sessions: sessions(),
@@ -5537,10 +5639,15 @@ const handlers = {
5537
5639
  };
5538
5640
  },
5539
5641
  "config.get": () => configInfo(),
5540
- "config.save": (params) => {
5541
- const before = readConfig().claudeBaseDir;
5642
+ "config.save": async (params) => {
5643
+ const before = readConfig();
5542
5644
  const saved = saveConfig(params);
5543
- if (saved.claudeBaseDir === before) return saved;
5645
+ for (const provider of [
5646
+ "claude",
5647
+ "codex",
5648
+ "opencode"
5649
+ ]) if (before.enabledProviders[provider] && !saved.enabledProviders[provider]) await stopProvider(provider);
5650
+ if (saved.claudeBaseDir === before.claudeBaseDir && JSON.stringify(saved.enabledProviders) === JSON.stringify(before.enabledProviders)) return saved;
5544
5651
  watchSessions();
5545
5652
  refreshAll();
5546
5653
  publishAccounts();
@@ -5630,8 +5737,8 @@ const handlers = {
5630
5737
  publishAccounts();
5631
5738
  return null;
5632
5739
  },
5633
- "accounts.update": (params) => {
5634
- updateAccount(params.id, params.name, params.configDir);
5740
+ "accounts.update": async (params) => {
5741
+ await updateAccount(params);
5635
5742
  publishAccounts();
5636
5743
  return null;
5637
5744
  },
@@ -5640,7 +5747,7 @@ const handlers = {
5640
5747
  publishAccounts();
5641
5748
  return null;
5642
5749
  },
5643
- capabilities: (params) => capabilities(params.accountId, projectPath(params.projectId)),
5750
+ capabilities: (params) => capabilities(params.provider, params.accountId, params.projectId === null ? os.homedir() : projectPath(params.projectId)),
5644
5751
  "files.list": (params) => listFiles(projectPath(params.projectId)),
5645
5752
  "files.read": (params) => readProjectFile(projectPath(params.projectId), params.path),
5646
5753
  "files.upload": (params) => saveUpload(params.name, Buffer.from(params.data, "base64")),
@@ -5650,16 +5757,19 @@ const handlers = {
5650
5757
  "git.commit": (params) => commit(projectPath(params.projectId), params.message),
5651
5758
  "git.push": (params) => push$1(projectPath(params.projectId)),
5652
5759
  "git.pullRequest": (params) => createPullRequest(projectPath(params.projectId)),
5653
- "git.message": (params) => suggestCommitMessage(params.provider, params.accountId, projectPath(params.projectId)),
5654
- "push.key": () => pushKey(),
5655
- "push.subscribe": (params) => {
5656
- subscribe(params);
5760
+ "git.message": (params) => suggestCommitMessage(projectPath(params.projectId)),
5761
+ "push.subscribe": (params, client) => {
5762
+ subscribe(params, client);
5657
5763
  return null;
5658
5764
  },
5659
5765
  "push.unsubscribe": (params) => {
5660
5766
  unsubscribe(params.endpoint);
5661
5767
  return null;
5662
5768
  },
5769
+ "notifications.claim": (params, client) => {
5770
+ claimNotifier(client, params.endpoint);
5771
+ return null;
5772
+ },
5663
5773
  terminal: async (params, client) => {
5664
5774
  await terminalRequest(client, params);
5665
5775
  return null;
@@ -5717,38 +5827,58 @@ async function answer(client, text) {
5717
5827
  }
5718
5828
  }
5719
5829
  //#endregion
5720
- //#region src/storage/token.ts
5721
- function readTokens() {
5722
- return db.select().from(tokens).orderBy(tokens.createdAt).all();
5830
+ //#region src/storage/auth.ts
5831
+ const CODE_LIFETIME = 3e5;
5832
+ const DEVICE_LIFETIME = 2592e6;
5833
+ function addCode(name) {
5834
+ const code = randomBytes(16).toString("base64url");
5835
+ db.delete(pairingCodes).where(lte(pairingCodes.expiresAt, Date.now())).run();
5836
+ db.insert(pairingCodes).values({
5837
+ hash: hash(code),
5838
+ name,
5839
+ expiresAt: Date.now() + CODE_LIFETIME
5840
+ }).run();
5841
+ return code;
5723
5842
  }
5724
- function addToken(name) {
5843
+ function redeemCode(code) {
5844
+ const found = db.delete(pairingCodes).where(eq(pairingCodes.hash, hash(code))).returning().get();
5845
+ if (found === void 0 || found.expiresAt <= Date.now()) return null;
5725
5846
  const token = randomBytes(32).toString("base64url");
5726
- db.insert(tokens).values({
5727
- hash: hashToken(token),
5728
- name,
5729
- createdAt: Date.now()
5847
+ const now = Date.now();
5848
+ db.insert(devices).values({
5849
+ hash: hash(token),
5850
+ name: found.name,
5851
+ createdAt: now,
5852
+ expiresAt: now + DEVICE_LIFETIME
5730
5853
  }).run();
5731
5854
  return token;
5732
5855
  }
5733
- function removeTokens(hashes) {
5734
- db.delete(tokens).where(inArray(tokens.hash, hashes)).run();
5856
+ function findDevice(token) {
5857
+ const found = db.select().from(devices).where(and(eq(devices.hash, hash(token)), gt(devices.expiresAt, Date.now()))).get();
5858
+ return found === void 0 ? null : found.hash;
5735
5859
  }
5736
- function hasTokens() {
5737
- return db.select().from(tokens).limit(1).get() !== void 0;
5860
+ function isPaired(device) {
5861
+ return db.select().from(devices).where(and(eq(devices.hash, device), gt(devices.expiresAt, Date.now()))).get() !== void 0;
5738
5862
  }
5739
- function matchesToken(given) {
5740
- if (given === null) return false;
5741
- return db.select().from(tokens).where(eq(tokens.hash, hashToken(given))).get() !== void 0;
5863
+ function hasDevices() {
5864
+ return db.select().from(devices).where(gt(devices.expiresAt, Date.now())).limit(1).get() !== void 0;
5742
5865
  }
5743
- function recordUse(token) {
5744
- db.update(tokens).set({ lastUsedAt: Date.now() }).where(eq(tokens.hash, hashToken(token))).run();
5866
+ function readDevices() {
5867
+ return db.select().from(devices).orderBy(devices.createdAt).all();
5745
5868
  }
5746
- function hashToken(token) {
5747
- return createHash("sha256").update(token).digest("hex");
5869
+ function removeDevices(hashes) {
5870
+ db.delete(devices).where(inArray(devices.hash, hashes)).run();
5871
+ }
5872
+ function recordUse(device) {
5873
+ db.update(devices).set({ lastUsedAt: Date.now() }).where(eq(devices.hash, device)).run();
5874
+ }
5875
+ function hash(secret) {
5876
+ return createHash("sha256").update(secret).digest("hex");
5748
5877
  }
5749
5878
  //#endregion
5750
5879
  //#region src/main.ts
5751
5880
  const UNPAIR_CHECK = 5e3;
5881
+ const TICKET_LIFETIME = 6e4;
5752
5882
  const contentTypes = {
5753
5883
  ".html": "text/html; charset=utf-8",
5754
5884
  ".js": "text/javascript; charset=utf-8",
@@ -5766,17 +5896,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65535) {
5766
5896
  process.exit(1);
5767
5897
  }
5768
5898
  const address = `http://${args.host}:${port}`;
5769
- const fallback = args.fallback === void 0 ? null : readFallback(args.fallback);
5770
5899
  if (args.command === "pair") {
5771
- const links = pairingLinks(addToken(args.name));
5772
- console.log(`Scan this to pair a phone with ${HOSTED_APP}:`);
5773
- console.log("");
5774
- console.log(renderUnicodeCompact(links.hosted, { border: 2 }));
5775
- console.log("");
5776
- console.log("Or open one of these links in the browser to pair:");
5777
- console.log(` ${links.hosted}`);
5900
+ const links = pairingLinks(addCode(args.name));
5901
+ console.log("Open this link in a browser within 5 minutes to pair it:");
5778
5902
  console.log(` ${links.local}`);
5779
- if (fallback !== null) console.log(`Fallback: ${fallback}`);
5780
5903
  process.exit(0);
5781
5904
  }
5782
5905
  if (args.command === "unpair") {
@@ -5792,23 +5915,17 @@ if (findClaude() === null) {
5792
5915
  process.exit(1);
5793
5916
  }
5794
5917
  const publicDir = path.join(import.meta.dirname, "..", "..", "web", "dist");
5795
- const token = args.auth && args.pair && !hasTokens() ? addToken(null) : null;
5918
+ const code = args.auth && args.pair && !hasDevices() ? addCode(null) : null;
5919
+ const tickets = /* @__PURE__ */ new Map();
5796
5920
  const server = createServer((request, response) => {
5797
5921
  const url = new URL(request.url ?? "/", "http://localhost");
5798
- if (url.pathname === "/version") {
5799
- response.setHeader("access-control-allow-origin", "*");
5800
- response.setHeader("content-type", "application/json");
5801
- response.end(JSON.stringify({
5802
- version,
5803
- protocol: 3,
5804
- machineId: machineInfo().id,
5805
- authorized: authorized(url)
5806
- }));
5922
+ if (url.pathname.startsWith("/api/")) {
5923
+ answerApi(url.pathname, request, response);
5807
5924
  return;
5808
5925
  }
5809
5926
  if (args.command === "serve") {
5810
5927
  response.statusCode = 404;
5811
- response.end(`offsite serve has no web app; open ${HOSTED_APP}`);
5928
+ response.end("offsite serve has no web app");
5812
5929
  return;
5813
5930
  }
5814
5931
  serveFile(url.pathname, response);
@@ -5817,34 +5934,31 @@ const sockets = new WebSocketServer({ noServer: true });
5817
5934
  const paired = /* @__PURE__ */ new Map();
5818
5935
  server.on("upgrade", (request, socket, head) => {
5819
5936
  const url = new URL(request.url ?? "/", "http://localhost");
5820
- const given = url.searchParams.get("token");
5821
- if (url.pathname !== "/ws" || url.searchParams.get("protocol") !== String(3) || !authorized(url)) {
5937
+ const device = args.auth ? redeemTicket(url.searchParams.get("ticket")) : null;
5938
+ if (url.pathname !== "/ws" || args.auth && device === null) {
5822
5939
  socket.destroy();
5823
5940
  return;
5824
5941
  }
5825
5942
  sockets.handleUpgrade(request, socket, head, (client) => {
5826
5943
  connectClient(client);
5827
- if (!args.auth || given === null) return;
5828
- paired.set(client, given);
5944
+ if (device === null) return;
5945
+ paired.set(client, device);
5829
5946
  client.on("close", () => paired.delete(client));
5830
- recordUse(given);
5947
+ recordUse(device);
5831
5948
  });
5832
5949
  });
5833
5950
  if (args.auth) setInterval(() => {
5834
- for (const [client, given] of paired) if (!matchesToken(given)) client.close();
5951
+ for (const [client, device] of paired) if (!isPaired(device)) client.close();
5952
+ for (const [ticket, item] of tickets) if (item.expiresAt <= Date.now()) tickets.delete(ticket);
5835
5953
  }, UNPAIR_CHECK);
5836
5954
  server.listen(port, args.host, () => {
5837
5955
  startPush(`${args.host}:${port}`);
5838
- if (args.pair && (token !== null || !args.auth)) {
5839
- const links = pairingLinks(token);
5956
+ if (args.pair && (code !== null || !args.auth)) {
5957
+ const links = pairingLinks(code);
5840
5958
  if (args.command === "web") console.log(`offsite is running at ${links.local}`);
5841
- console.log(`Open it on ${HOSTED_APP}: ${links.hosted}`);
5842
- } else if (args.pair) {
5843
- console.log(`offsite is running at ${address}`);
5844
- console.log("Run offsite pair to pair another browser");
5845
5959
  } else {
5846
5960
  console.log(`offsite is running at ${address}`);
5847
- console.log(`Open it on ${HOSTED_APP}`);
5961
+ if (args.pair) console.log("Run offsite pair to pair another browser");
5848
5962
  }
5849
5963
  if (!args.auth) console.warn("auth is off; anyone who can reach this port can control this machine");
5850
5964
  });
@@ -5854,12 +5968,67 @@ function stop() {
5854
5968
  stopAll();
5855
5969
  process.exit(0);
5856
5970
  }
5857
- function authorized(url) {
5858
- return !args.auth || matchesToken(url.searchParams.get("token"));
5971
+ function answerApi(pathname, request, response) {
5972
+ response.setHeader("access-control-allow-origin", "*");
5973
+ if (request.method === "OPTIONS") {
5974
+ response.setHeader("access-control-allow-methods", "POST");
5975
+ response.setHeader("access-control-allow-headers", "authorization");
5976
+ response.statusCode = 204;
5977
+ response.end();
5978
+ return;
5979
+ }
5980
+ if (request.method !== "POST") {
5981
+ response.statusCode = 405;
5982
+ response.end("use POST");
5983
+ return;
5984
+ }
5985
+ const given = request.headers.authorization?.match(/^Bearer (.+)$/)?.[1] ?? null;
5986
+ if (pathname === "/api/pair") {
5987
+ const token = given === null ? null : redeemCode(given);
5988
+ if (token === null) {
5989
+ response.statusCode = 401;
5990
+ response.end("That pairing code expired or was already used");
5991
+ return;
5992
+ }
5993
+ answerJson(response, { token });
5994
+ return;
5995
+ }
5996
+ if (pathname === "/api/ticket") {
5997
+ if (!args.auth) {
5998
+ answerJson(response, { ticket: null });
5999
+ return;
6000
+ }
6001
+ const device = given === null ? null : findDevice(given);
6002
+ if (device === null) {
6003
+ response.statusCode = 401;
6004
+ response.end("This browser needs pairing");
6005
+ return;
6006
+ }
6007
+ const ticket = randomBytes(32).toString("base64url");
6008
+ tickets.set(ticket, {
6009
+ device,
6010
+ expiresAt: Date.now() + TICKET_LIFETIME
6011
+ });
6012
+ answerJson(response, { ticket });
6013
+ return;
6014
+ }
6015
+ response.statusCode = 404;
6016
+ response.end("not found");
6017
+ }
6018
+ function answerJson(response, body) {
6019
+ response.setHeader("content-type", "application/json");
6020
+ response.end(JSON.stringify(body));
6021
+ }
6022
+ function redeemTicket(ticket) {
6023
+ if (ticket === null) return null;
6024
+ const found = tickets.get(ticket);
6025
+ tickets.delete(ticket);
6026
+ if (found === void 0 || found.expiresAt <= Date.now()) return null;
6027
+ return found.device;
5859
6028
  }
5860
6029
  async function unpair() {
5861
- const tokens = readTokens();
5862
- if (tokens.length === 0) {
6030
+ const devices = readDevices();
6031
+ if (devices.length === 0) {
5863
6032
  console.log("No browsers are paired");
5864
6033
  return;
5865
6034
  }
@@ -5871,11 +6040,12 @@ async function unpair() {
5871
6040
  type: "multiselect",
5872
6041
  required: false,
5873
6042
  cancel: "null",
5874
- options: tokens.map((item) => ({
6043
+ options: devices.map((item) => ({
5875
6044
  value: item.hash,
5876
6045
  label: [
5877
6046
  item.name ?? "Unnamed",
5878
6047
  `paired ${new Date(item.createdAt).toLocaleString()}`,
6048
+ `${item.expiresAt <= Date.now() ? "expired" : "expires"} ${new Date(item.expiresAt).toLocaleString()}`,
5879
6049
  item.lastUsedAt === null ? "never used" : `last used ${new Date(item.lastUsedAt).toLocaleString()}`
5880
6050
  ].join(" · ")
5881
6051
  }))
@@ -5884,35 +6054,21 @@ async function unpair() {
5884
6054
  console.log("Nothing unpaired");
5885
6055
  return;
5886
6056
  }
5887
- removeTokens(picked);
6057
+ removeDevices(picked);
5888
6058
  console.log(`Unpaired ${picked.length} ${picked.length === 1 ? "browser" : "browsers"}; a running offsite disconnects them within seconds`);
5889
6059
  }
5890
- function readFallback(link) {
5891
- let url;
5892
- try {
5893
- url = new URL(link);
5894
- } catch {
5895
- console.error(`invalid fallback URL ${link}`);
5896
- process.exit(1);
5897
- }
5898
- if (url.protocol !== "http:" && url.protocol !== "https:") {
5899
- console.error(`the fallback URL ${link} needs http:// or https://`);
5900
- process.exit(1);
5901
- }
5902
- return url.origin;
5903
- }
5904
- function pairingLinks(token) {
5905
- const hosted = new URLSearchParams({
6060
+ function pairingLinks(code) {
6061
+ return { local: `${address}/pair#${new URLSearchParams({
5906
6062
  machine: address,
5907
- ...fallback !== null && { fallback },
5908
- ...token !== null && { token }
5909
- });
5910
- return {
5911
- local: token === null ? address : `${address}/pair#${new URLSearchParams({ token })}`,
5912
- hosted: `${HOSTED_APP}/pair#${hosted}`
5913
- };
6063
+ ...code !== null && { code }
6064
+ })}` };
5914
6065
  }
5915
6066
  function serveFile(pathname, response) {
6067
+ if (!args.pwa && pathname === "/manifest.webmanifest") {
6068
+ response.statusCode = 404;
6069
+ response.end("offsite runs without --pwa");
6070
+ return;
6071
+ }
5916
6072
  const requested = path.join(publicDir, path.normalize(pathname));
5917
6073
  const file = requested.startsWith(publicDir + path.sep) && statSync(requested, { throwIfNoEntry: false })?.isFile() ? requested : path.join(publicDir, "index.html");
5918
6074
  if (!existsSync(file)) {
@@ -5921,6 +6077,10 @@ function serveFile(pathname, response) {
5921
6077
  return;
5922
6078
  }
5923
6079
  response.setHeader("content-type", contentTypes[path.extname(file)] ?? "application/octet-stream");
6080
+ if (!args.pwa && path.basename(file) === "index.html") {
6081
+ response.end(readFileSync(file, "utf8").replace(/<link rel="manifest"[^>]*>/, ""));
6082
+ return;
6083
+ }
5924
6084
  createReadStream(file).pipe(response);
5925
6085
  }
5926
6086
  //#endregion