offsite 0.0.85 → 0.0.87

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.
@@ -1,21 +1,22 @@
1
1
  import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import { accessSync, appendFileSync, chmodSync, closeSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync } from "node:fs";
2
+ import { accessSync, appendFileSync, chmodSync, closeSync, constants, createReadStream, existsSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
3
3
  import http, { createServer } from "node:http";
4
4
  import path from "node:path";
5
5
  import zlib, { createGzip } from "node:zlib";
6
- import { consola } from "consola";
6
+ import { LogLevels, consola, createConsola } from "consola";
7
7
  import { WebSocketServer } from "ws";
8
8
  import { z } from "zod";
9
9
  import os, { tmpdir } from "node:os";
10
10
  import { deleteSession, forkSession, getSessionInfo, getSessionMessages, getSubagentMessages, listSessions, query, renameSession } from "@anthropic-ai/claude-agent-sdk";
11
+ import { formatWithOptions, promisify } from "node:util";
12
+ import { Command } from "@commander-js/extra-typings";
11
13
  import { execFile, spawn, spawnSync } from "node:child_process";
12
14
  import readline from "node:readline";
13
- import { Command } from "@commander-js/extra-typings";
14
15
  import { lstat, mkdir, mkdtemp, open, readFile, readdir, readlink, rm, rmdir, stat, writeFile } from "node:fs/promises";
15
16
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
16
17
  import { OpenCode, isSessionNotFoundError } from "@opencode/client";
17
18
  import { ensure, headers } from "@opencode/client/service";
18
- import { and, eq, gt, inArray, lte, max, sql } from "drizzle-orm";
19
+ import { and, eq, gt, inArray, isNull, lte, max, sql } from "drizzle-orm";
19
20
  import { pathToFileURL } from "node:url";
20
21
  import { sumBy } from "es-toolkit";
21
22
  import { DatabaseSync } from "node:sqlite";
@@ -23,7 +24,6 @@ import { drizzle } from "drizzle-orm/node-sqlite";
23
24
  import { migrate } from "drizzle-orm/node-sqlite/migrator";
24
25
  import { integer, real, snakeCase, text } from "drizzle-orm/sqlite-core";
25
26
  import sharp from "sharp";
26
- import { promisify } from "node:util";
27
27
  import webPush from "web-push";
28
28
  import dns from "node:dns";
29
29
  import { BlockList, isIP } from "node:net";
@@ -88,9 +88,73 @@ function publish$2(sessionId, event) {
88
88
  function broadcast(event) {
89
89
  for (const client of clients) client.send(event);
90
90
  }
91
+ // src/args.ts
92
+ let command = "web";
93
+ let name = null;
94
+ 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").option("--connect <addresses>", "Add this machine to the web app with these comma separated addresses; needs --no-auth").configureHelp({ showGlobalOptions: true });
95
+ program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
96
+ program.command("serve").description("Starts only the server, without the web app").action(() => {
97
+ command = "serve";
98
+ });
99
+ 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) => {
100
+ command = "pair";
101
+ name = options.name ?? null;
102
+ });
103
+ program.command("unpair").description("Picks paired browsers to unpair").action(() => {
104
+ command = "unpair";
105
+ });
106
+ program.parse();
107
+ const options = program.opts();
108
+ if (!options.auth && !options.pair) program.error("error: --no-pair can't be used with --no-auth");
109
+ if (options.connect !== void 0 && options.auth) program.error("error: --connect needs --no-auth");
110
+ if (options.connect !== void 0 && command === "serve") program.error("error: --connect can't be used with offsite serve");
111
+ const connect$1 = options.connect === void 0 ? null : options.connect.split(",").map((address) => {
112
+ const url = URL.parse(address);
113
+ if (url === null || url.protocol !== "http:" && url.protocol !== "https:") return program.error(`error: --connect address ${address} needs http:// or https://, like https://example.com`);
114
+ return url.origin;
115
+ });
116
+ const args = {
117
+ ...options,
118
+ dir: path.resolve(options.dir),
119
+ command,
120
+ name,
121
+ connect: connect$1,
122
+ portGiven: program.getOptionValueSource("port") === "cli"
123
+ };
124
+ const serverDir = path.join(import.meta.dirname, "..");
125
+ // src/lib/log.ts
126
+ const folder$1 = path.join(args.dir, "logs", `${Date.now()}-${process.pid}`);
127
+ const files$1 = /* @__PURE__ */ new Map();
128
+ function write(file, entry) {
129
+ let descriptor = files$1.get(file);
130
+ if (descriptor === void 0) {
131
+ mkdirSync(folder$1, {
132
+ recursive: true,
133
+ mode: 448
134
+ });
135
+ descriptor = openSync(path.join(folder$1, file), "a", 384);
136
+ files$1.set(file, descriptor);
137
+ }
138
+ const tag = entry.tag === "" ? "" : ` [${entry.tag}]`;
139
+ const message = formatWithOptions({
140
+ colors: false,
141
+ breakLength: Infinity
142
+ }, ...entry.args);
143
+ writeSync(descriptor, `${entry.date.toISOString()} ${entry.type.toUpperCase()}${tag} ${message}\n`);
144
+ }
145
+ function fileLogger(file) {
146
+ return createConsola({
147
+ level: LogLevels.verbose,
148
+ throttle: 0,
149
+ reporters: [{ log: (entry) => write(file, entry) }]
150
+ });
151
+ }
152
+ const logger = fileLogger("server.log");
153
+ const browserLogger = fileLogger("browser.log");
91
154
  // src/services/usage.ts
92
155
  const freshFor = 6e4;
93
156
  const forcedFreshFor = 1e4;
157
+ const log$20 = logger.withTag("usage");
94
158
  const entries = /* @__PURE__ */ new Map();
95
159
  const fetching = /* @__PURE__ */ new Map();
96
160
  function cachedUsage(accountId) {
@@ -108,6 +172,7 @@ function recordLimits(accountId, limits) {
108
172
  limits: [...current.limits.map((limit) => limits.find((item) => item.window === limit.window) ?? limit), ...limits.filter((item) => !current.limits.some((limit) => limit.window === item.window))],
109
173
  updatedAt: Date.now()
110
174
  });
175
+ log$20.debug(`account ${accountId} reported ${limits.map((limit) => limit.window).join(", ")} limits from a running session`);
111
176
  broadcast({
112
177
  type: "limits",
113
178
  accountId,
@@ -116,9 +181,15 @@ function recordLimits(accountId, limits) {
116
181
  }
117
182
  async function readUsage(accountId, force) {
118
183
  const current = cachedUsage(accountId);
119
- if (current.fetchedAt !== null && Date.now() - current.fetchedAt < (force ? forcedFreshFor : freshFor)) return current;
184
+ if (current.fetchedAt !== null && Date.now() - current.fetchedAt < (force ? forcedFreshFor : freshFor)) {
185
+ log$20.debug(`account ${accountId} served from cache, ${Math.round((Date.now() - current.fetchedAt) / 1e3)}s old${force ? " (forced)" : ""}`);
186
+ return current;
187
+ }
120
188
  const pending = fetching.get(accountId);
121
- if (pending !== void 0) return pending;
189
+ if (pending !== void 0) {
190
+ log$20.debug(`account ${accountId} joined the fetch in progress`);
191
+ return pending;
192
+ }
122
193
  const next = fetchUsage(accountId);
123
194
  fetching.set(accountId, next);
124
195
  try {
@@ -134,15 +205,19 @@ async function fetchUsage(accountId) {
134
205
  const account = storedAccounts().find((item) => item.id === accountId);
135
206
  if (account === void 0) throw new Error("That account was removed");
136
207
  let usage;
208
+ const started = performance.now();
209
+ log$20.debug(`fetching limits for ${account.name} (${account.provider})`);
137
210
  try {
211
+ const limits = await providerLimits(account);
212
+ log$20.debug(`fetched ${limits.length} limits for ${account.name} in ${Math.round(performance.now() - started)}ms`);
138
213
  usage = {
139
- limits: await providerLimits(account),
214
+ limits,
140
215
  fetchedAt: Date.now(),
141
216
  updatedAt: Date.now(),
142
217
  error: null
143
218
  };
144
219
  } catch (error) {
145
- console.warn(`could not read usage limits for ${account.name}: ${error}`);
220
+ log$20.warn(`could not read usage limits for ${account.name}:`, error);
146
221
  usage = {
147
222
  ...cachedUsage(accountId),
148
223
  fetchedAt: Date.now(),
@@ -290,44 +365,11 @@ function toBlocks(content, answers) {
290
365
  function isRecord(value) {
291
366
  return typeof value === "object" && value !== null && !Array.isArray(value);
292
367
  }
293
- // src/args.ts
294
- let command = "web";
295
- let name = null;
296
- 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").option("--connect <addresses>", "Add this machine to the web app with these comma separated addresses; needs --no-auth").configureHelp({ showGlobalOptions: true });
297
- program.command("web", { isDefault: true }).description("Starts the server and the web app, same as offsite");
298
- program.command("serve").description("Starts only the server, without the web app").action(() => {
299
- command = "serve";
300
- });
301
- 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) => {
302
- command = "pair";
303
- name = options.name ?? null;
304
- });
305
- program.command("unpair").description("Picks paired browsers to unpair").action(() => {
306
- command = "unpair";
307
- });
308
- program.parse();
309
- const options = program.opts();
310
- if (!options.auth && !options.pair) program.error("error: --no-pair can't be used with --no-auth");
311
- if (options.connect !== void 0 && options.auth) program.error("error: --connect needs --no-auth");
312
- if (options.connect !== void 0 && command === "serve") program.error("error: --connect can't be used with offsite serve");
313
- const connect$1 = options.connect === void 0 ? null : options.connect.split(",").map((address) => {
314
- const url = URL.parse(address);
315
- if (url === null || url.protocol !== "http:" && url.protocol !== "https:") return program.error(`error: --connect address ${address} needs http:// or https://, like https://example.com`);
316
- return url.origin;
317
- });
318
- const args = {
319
- ...options,
320
- dir: path.resolve(options.dir),
321
- command,
322
- name,
323
- connect: connect$1,
324
- portGiven: program.getOptionValueSource("port") === "cli"
325
- };
326
- const serverDir = path.join(import.meta.dirname, "..");
327
368
  // src/storage/storage.ts
328
369
  const home = args.dir;
329
370
  const lockFile = path.join(home, "server.pid");
330
371
  const savedServer = z.object({ port: z.number() }).nullable();
372
+ const log$19 = logger.withTag("storage");
331
373
  mkdirSync(home, { recursive: true });
332
374
  const scratchAvailable = spawnSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: home }).status !== 0;
333
375
  function listenPort() {
@@ -359,7 +401,8 @@ function lock() {
359
401
  const validPid = Number.isInteger(pid) && pid > 0;
360
402
  const writtenThisBoot = modified > Date.now() - os.uptime() * 1e3;
361
403
  if (validPid && writtenThisBoot && pid !== process.pid && isRunning(pid)) {
362
- consola.error(`offsite is already running for ${home} (pid ${pid})`);
404
+ console.error(`offsite is already running for ${home} (pid ${pid})`);
405
+ log$19.error(`offsite is already running for ${home} (pid ${pid})`);
363
406
  process.exit(1);
364
407
  }
365
408
  unlinkSync(lockFile);
@@ -392,7 +435,7 @@ function readSettings(name, schema, defaults) {
392
435
  try {
393
436
  value = readJson(name);
394
437
  } catch (error) {
395
- console.warn(`invalid ${file}, using the defaults: ${error}`);
438
+ log$19.warn(`invalid ${file}, using the defaults:`, error);
396
439
  return structuredClone(defaults);
397
440
  }
398
441
  if (value === null) return structuredClone(defaults);
@@ -404,10 +447,10 @@ function readSettings(name, schema, defaults) {
404
447
  for (const target of targets) {
405
448
  const discarded = discard(value, target);
406
449
  if (discarded === null) {
407
- console.warn(`invalid ${file}, using the defaults:\n${z.prettifyError(result.error)}`);
450
+ log$19.warn(`invalid ${file}, using the defaults:\n${z.prettifyError(result.error)}`);
408
451
  return structuredClone(defaults);
409
452
  }
410
- console.warn(`${file}: discarded ${discarded.map(String).join(".")} (${issue.message} at ${target.map(String).join(".")})`);
453
+ log$19.warn(`${file}: discarded ${discarded.map(String).join(".")} (${issue.message} at ${target.map(String).join(".")})`);
411
454
  }
412
455
  result = schema.safeParse(value);
413
456
  }
@@ -475,6 +518,7 @@ function quote(value) {
475
518
  return `'${value.replaceAll("'", "'\\''")}'`;
476
519
  }
477
520
  // src/lib/codex/app-server.ts
521
+ const log$18 = logger.withTag("codex");
478
522
  const IDLE_LIMIT$3 = 18e5;
479
523
  const START_TIMEOUT = 15e3;
480
524
  const REQUEST_TIMEOUT = 3e4;
@@ -515,6 +559,7 @@ var AppServer = class {
515
559
  const executable = findCodex();
516
560
  if (executable === null) throw new Error("Codex is not installed or not on the PATH");
517
561
  this.home = home;
562
+ log$18.info(`starting the Codex app server for ${home}`);
518
563
  this.child = spawn(executable, ["app-server"], {
519
564
  env: {
520
565
  ...process.env,
@@ -541,6 +586,7 @@ var AppServer = class {
541
586
  requestAttestation: false
542
587
  }
543
588
  }, START_TIMEOUT).then(() => this.write({ method: "initialized" }), (error) => {
589
+ log$18.warn(`the Codex app server for ${home} did not start:`, error);
544
590
  this.child.kill();
545
591
  throw error;
546
592
  });
@@ -549,7 +595,15 @@ var AppServer = class {
549
595
  async request(method, params) {
550
596
  await this.ready;
551
597
  this.armIdleTimer();
552
- return await this.call(method, params, REQUEST_TIMEOUT);
598
+ const started = performance.now();
599
+ try {
600
+ const result = await this.call(method, params, REQUEST_TIMEOUT);
601
+ log$18.debug(`${method} took ${Math.round(performance.now() - started)}ms`);
602
+ return result;
603
+ } catch (error) {
604
+ log$18.debug(`${method} failed after ${Math.round(performance.now() - started)}ms: ${error}`);
605
+ throw error;
606
+ }
553
607
  }
554
608
  reply(id, result) {
555
609
  this.write({
@@ -567,6 +621,7 @@ var AppServer = class {
567
621
  });
568
622
  }
569
623
  stop() {
624
+ log$18.info(`stopping the Codex app server for ${this.home}`);
570
625
  clearTimeout(this.idleTimer);
571
626
  if (servers.get(this.home) === this) servers.delete(this.home);
572
627
  this.child.stdin.end();
@@ -607,7 +662,7 @@ var AppServer = class {
607
662
  try {
608
663
  message = JSON.parse(line);
609
664
  } catch {
610
- console.warn(`ignoring a Codex line that is not JSON: ${line}`);
665
+ log$18.warn(`ignoring a Codex line that is not JSON: ${line}`);
611
666
  return;
612
667
  }
613
668
  if (message.method === void 0) {
@@ -657,6 +712,7 @@ var AppServer = class {
657
712
  exit(error) {
658
713
  if (this.exited !== null) return;
659
714
  this.exited = error;
715
+ log$18.info(`the Codex app server for ${this.home} exited:`, error.message);
660
716
  clearTimeout(this.idleTimer);
661
717
  if (servers.get(this.home) === this) servers.delete(this.home);
662
718
  for (const waiter of this.waiting.values()) waiter.reject(error);
@@ -666,6 +722,7 @@ var AppServer = class {
666
722
  }
667
723
  };
668
724
  // src/lib/codex/accounts.ts
725
+ const log$17 = logger.withTag("codex");
669
726
  const privateEntries = [
670
727
  "auth.json",
671
728
  "models_cache.json",
@@ -682,6 +739,7 @@ async function codexAccount(account) {
682
739
  email: null,
683
740
  displayName: null,
684
741
  organization: null,
742
+ identity: null,
685
743
  plan: null,
686
744
  linked
687
745
  };
@@ -696,7 +754,7 @@ async function codexAccount(account) {
696
754
  case "chatgpt": return {
697
755
  ...base,
698
756
  email: result.account.email,
699
- displayName: codexDisplayName(configDir),
757
+ ...codexLogin(configDir),
700
758
  organization: result.account.workspaceName,
701
759
  plan: result.account.planType,
702
760
  error: null,
@@ -768,21 +826,40 @@ function toLimits(snapshot) {
768
826
  rejected: window.usedPercent >= 100
769
827
  }]);
770
828
  }
771
- function codexDisplayName(home) {
829
+ function codexLogin(home) {
772
830
  let raw;
773
831
  try {
774
832
  raw = JSON.parse(readFileSync(path.join(home, "auth.json"), "utf8"));
775
833
  } catch (error) {
776
- if (error.code !== "ENOENT") console.warn(`could not read the Codex account name: ${error}`);
777
- return null;
834
+ if (error.code !== "ENOENT") log$17.warn(`could not read the Codex account:`, error);
835
+ return {
836
+ displayName: null,
837
+ identity: null
838
+ };
778
839
  }
779
- if (!isRecord(raw) || !isRecord(raw.tokens) || typeof raw.tokens.id_token !== "string") return null;
840
+ if (!isRecord(raw) || !isRecord(raw.tokens) || typeof raw.tokens.id_token !== "string") return {
841
+ displayName: null,
842
+ identity: null
843
+ };
780
844
  const payload = raw.tokens.id_token.split(".").at(1);
781
- if (payload === void 0) return null;
845
+ if (payload === void 0) return {
846
+ displayName: null,
847
+ identity: null
848
+ };
782
849
  const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
783
- if (!isRecord(claims) || typeof claims.name !== "string") return null;
784
- const name = claims.name.trim();
785
- return name === "" ? null : name;
850
+ if (!isRecord(claims)) return {
851
+ displayName: null,
852
+ identity: null
853
+ };
854
+ const name = typeof claims.name === "string" ? claims.name.trim() : "";
855
+ const auth = claims["https://api.openai.com/auth"];
856
+ return {
857
+ displayName: name === "" ? null : name,
858
+ identity: isRecord(auth) && typeof auth.chatgpt_user_id === "string" && typeof auth.chatgpt_account_id === "string" ? {
859
+ accountId: auth.chatgpt_user_id,
860
+ organizationId: auth.chatgpt_account_id
861
+ } : null
862
+ };
786
863
  }
787
864
  function isMirror(dir) {
788
865
  try {
@@ -1069,19 +1146,6 @@ const configSchema = z.strictObject({
1069
1146
  codex: z.boolean().default(true),
1070
1147
  opencode: z.boolean().default(true)
1071
1148
  }).prefault({}),
1072
- newThreads: z.strictObject({
1073
- provider: z.enum([
1074
- "claude",
1075
- "codex",
1076
- "opencode"
1077
- ]).default("claude"),
1078
- accountId: z.string().nullable().default(null),
1079
- models: z.strictObject({
1080
- claude: z.string().nullable().default(null),
1081
- codex: z.string().nullable().default(null),
1082
- opencode: z.string().nullable().default(null)
1083
- }).prefault({})
1084
- }).prefault({}),
1085
1149
  commitMessage: generationModel,
1086
1150
  titleGeneration: generationModel,
1087
1151
  models: z.strictObject({
@@ -1091,7 +1155,11 @@ const configSchema = z.strictObject({
1091
1155
  }).prefault({}),
1092
1156
  delivery: z.enum(["queue", "steer"]).default("steer"),
1093
1157
  autoSkipQuestionTimeout: z.number().positive().nullable().default(180),
1094
- autoPermissionMode: z.enum(["allow", "deny"]).default("allow"),
1158
+ autoPermissionMode: z.enum([
1159
+ "bypass",
1160
+ "allow",
1161
+ "deny"
1162
+ ]).default("bypass"),
1095
1163
  autoPermissionTimeout: z.number().positive().nullable().default(15),
1096
1164
  terminal: z.strictObject({
1097
1165
  shell: z.strictObject({
@@ -1153,8 +1221,10 @@ function saveConfig(value) {
1153
1221
  return result.data;
1154
1222
  }
1155
1223
  // src/lib/live.ts
1224
+ const log$16 = logger.withTag("runs");
1156
1225
  const runs = /* @__PURE__ */ new Map();
1157
1226
  const titleJobs = /* @__PURE__ */ new Set();
1227
+ let states = /* @__PURE__ */ new Map();
1158
1228
  function activity() {
1159
1229
  const result = {};
1160
1230
  for (const run of runs.values()) result[run.sessionId] = {
@@ -1170,6 +1240,19 @@ function activity() {
1170
1240
  return result;
1171
1241
  }
1172
1242
  function broadcastActivity() {
1243
+ const next = /* @__PURE__ */ new Map();
1244
+ for (const run of runs.values()) {
1245
+ const state = [
1246
+ run.running ? "running" : "idle",
1247
+ ...run.pending.size > 0 ? ["waiting for an answer"] : [],
1248
+ ...run.compacting ? ["compacting"] : []
1249
+ ].join(", ");
1250
+ next.set(run.sessionId, state);
1251
+ if (!states.has(run.sessionId)) log$16.info(`${run.provider} session ${run.sessionId} opened in ${run.cwd} with account ${run.settings.accountId}`);
1252
+ if (states.get(run.sessionId) !== state) log$16.debug(`session ${run.sessionId} is ${state}`);
1253
+ }
1254
+ for (const sessionId of states.keys()) if (!next.has(sessionId)) log$16.info(`session ${sessionId} closed`);
1255
+ states = next;
1173
1256
  broadcast({
1174
1257
  type: "activity",
1175
1258
  activity: activity()
@@ -1178,16 +1261,18 @@ function broadcastActivity() {
1178
1261
  function scheduleAutoResolve(sessionId, request) {
1179
1262
  if (request.kind === "plan") return request;
1180
1263
  const config = readConfig();
1181
- const seconds = request.kind === "question" ? config.autoSkipQuestionTimeout : config.autoPermissionTimeout;
1264
+ const run = runs.get(sessionId);
1265
+ const immediate = config.autoPermissionMode === "bypass" && (run?.provider === "claude" && run.settings.mode === "bypassPermissions" || run?.provider === "codex" && run.settings.mode === "full-access");
1266
+ const seconds = request.kind === "question" ? config.autoSkipQuestionTimeout : immediate ? 0 : config.autoPermissionTimeout;
1182
1267
  if (seconds === null) return request;
1183
- const decision = request.kind === "question" ? "allow" : config.autoPermissionMode;
1268
+ const decision = request.kind === "question" || config.autoPermissionMode === "bypass" ? "allow" : config.autoPermissionMode;
1184
1269
  setTimeout(async () => {
1185
1270
  const run = runs.get(sessionId);
1186
1271
  if (run === void 0) return;
1187
1272
  try {
1188
1273
  await run.respond(request.id, decision, request.kind === "question" ? {} : null);
1189
1274
  } catch (error) {
1190
- console.warn(`could not auto-resolve ${request.id}: ${error}`);
1275
+ log$16.warn(`could not auto-resolve ${request.id}:`, error);
1191
1276
  }
1192
1277
  }, seconds * 1e3);
1193
1278
  return {
@@ -1328,6 +1413,7 @@ function reply(method, params, decision, answers) {
1328
1413
  throw new Error(`offsite cannot answer ${method}`);
1329
1414
  }
1330
1415
  // src/lib/codex/run.ts
1416
+ const log$15 = logger.withTag("codex");
1331
1417
  const IDLE_LIMIT$2 = 18e5;
1332
1418
  function codexServer(settings) {
1333
1419
  return appServer(storedAccount("codex", settings.accountId).configDir ?? codexBaseDir);
@@ -1452,9 +1538,10 @@ var CodexRun = class CodexRun {
1452
1538
  if (runs.get(this.sessionId) === this) runs.delete(this.sessionId);
1453
1539
  this.server.threads.delete(this.sessionId);
1454
1540
  clearTimeout(this.idleTimer);
1455
- this.server.request("thread/unsubscribe", { threadId: this.sessionId }).catch((error) => console.warn(`could not unload Codex thread: ${error}`)).finally(() => this.end());
1541
+ this.server.request("thread/unsubscribe", { threadId: this.sessionId }).catch((error) => log$15.warn(`could not unload Codex thread:`, error)).finally(() => this.end());
1456
1542
  }
1457
1543
  exited() {
1544
+ log$15.warn(`session ${this.sessionId} lost its Codex app server`);
1458
1545
  this.put(errorItem(randomUUID(), this.pending.size === 0 ? "Codex stopped" : "Codex stopped, its open requests expired"));
1459
1546
  if (runs.get(this.sessionId) === this) runs.delete(this.sessionId);
1460
1547
  this.end();
@@ -1658,6 +1745,7 @@ var CodexRun = class CodexRun {
1658
1745
  });
1659
1746
  }
1660
1747
  fail(error) {
1748
+ log$15.warn(`session ${this.sessionId} failed:`, error);
1661
1749
  this.put(errorItem(randomUUID(), error instanceof Error ? error.message : String(error)));
1662
1750
  }
1663
1751
  armIdleTimer() {
@@ -2524,6 +2612,7 @@ function sessionSettings(session, settings) {
2524
2612
  };
2525
2613
  }
2526
2614
  // src/lib/opencode/run.ts
2615
+ const log$14 = logger.withTag("opencode");
2527
2616
  const IDLE_LIMIT$1 = 18e5;
2528
2617
  const RECENT = 30;
2529
2618
  const replies = {
@@ -2651,6 +2740,7 @@ var OpenCodeRun = class OpenCodeRun {
2651
2740
  this.end();
2652
2741
  }
2653
2742
  exited() {
2743
+ log$14.warn(`session ${this.sessionId} lost the connection to OpenCode`);
2654
2744
  this.put([errorItem(randomUUID(), this.pending.size === 0 ? "Lost the connection to OpenCode" : "Lost the connection to OpenCode, its open requests expired")]);
2655
2745
  this.stop();
2656
2746
  }
@@ -2718,7 +2808,7 @@ var OpenCodeRun = class OpenCodeRun {
2718
2808
  }
2719
2809
  refresh(client) {
2720
2810
  clearTimeout(this.refreshTimer);
2721
- this.loading = this.loading.then(async () => this.load(client ?? await openCode())).catch((error) => console.warn(`could not load OpenCode messages: ${error}`));
2811
+ this.loading = this.loading.then(async () => this.load(client ?? await openCode())).catch((error) => log$14.warn(`could not load OpenCode messages:`, error));
2722
2812
  }
2723
2813
  async load(client) {
2724
2814
  const messages = (await client.message.list({
@@ -2835,6 +2925,7 @@ var OpenCodeRun = class OpenCodeRun {
2835
2925
  });
2836
2926
  }
2837
2927
  fail(error) {
2928
+ log$14.warn(`session ${this.sessionId} failed:`, error);
2838
2929
  this.put([errorItem(randomUUID(), error instanceof Error ? error.message : String(error))]);
2839
2930
  }
2840
2931
  armIdleTimer() {
@@ -2885,6 +2976,11 @@ const projects$1 = snakeCase.table("projects", {
2885
2976
  });
2886
2977
  const sessions$1 = snakeCase.table("sessions", {
2887
2978
  sessionId: text().primaryKey(),
2979
+ provider: text({ enum: [
2980
+ "claude",
2981
+ "codex",
2982
+ "opencode"
2983
+ ] }),
2888
2984
  interactedAt: integer(),
2889
2985
  status: text({ enum: [
2890
2986
  "active",
@@ -2920,6 +3016,16 @@ const pushSubscriptions = snakeCase.table("push_subscriptions", {
2920
3016
  machine: text().notNull(),
2921
3017
  server: text().notNull()
2922
3018
  });
3019
+ const lastUsedModels = snakeCase.table("last_used_models", {
3020
+ provider: text({ enum: [
3021
+ "claude",
3022
+ "codex",
3023
+ "opencode"
3024
+ ] }).primaryKey(),
3025
+ accountId: text(),
3026
+ model: text().notNull(),
3027
+ usedAt: integer().notNull()
3028
+ });
2923
3029
  // src/storage/auth.ts
2924
3030
  const CODE_LIFETIME = 3e5;
2925
3031
  const DEVICE_LIFETIME = 2592e6;
@@ -3969,10 +4075,145 @@ async function find(provider, paths) {
3969
4075
  });
3970
4076
  }
3971
4077
  }
4078
+ // src/services/threads.ts
4079
+ const log$13 = logger.withTag("threads");
4080
+ const allProviders$2 = [
4081
+ "claude",
4082
+ "codex",
4083
+ "opencode"
4084
+ ];
4085
+ const threads = {
4086
+ claude: /* @__PURE__ */ new Map(),
4087
+ codex: /* @__PURE__ */ new Map(),
4088
+ opencode: /* @__PURE__ */ new Map()
4089
+ };
4090
+ const changed = {
4091
+ claude: /* @__PURE__ */ new Set(),
4092
+ codex: /* @__PURE__ */ new Set(),
4093
+ opencode: /* @__PURE__ */ new Set()
4094
+ };
4095
+ const failures = /* @__PURE__ */ new Set();
4096
+ let built = Promise.resolve();
4097
+ let builtFor = "";
4098
+ function rebuildThreads() {
4099
+ const config = readConfig();
4100
+ const key = JSON.stringify([
4101
+ projectPaths$1(),
4102
+ config.enabledProviders,
4103
+ config.claudeBaseDir
4104
+ ]);
4105
+ if (key === builtFor) return;
4106
+ builtFor = key;
4107
+ built = relistAll();
4108
+ }
4109
+ function threadsReady() {
4110
+ return built;
4111
+ }
4112
+ function threadFailed(provider) {
4113
+ return failures.has(provider);
4114
+ }
4115
+ function knownThreads() {
4116
+ const known = new Set(projects().map((project) => project.id));
4117
+ return allProviders$2.filter((provider) => readConfig().enabledProviders[provider]).flatMap((provider) => [...threads[provider].values()]).filter((item) => known.has(item.projectId));
4118
+ }
4119
+ async function providerThreads(provider) {
4120
+ await built;
4121
+ if (failures.has(provider)) throw new Error(`could not list ${providerName(provider)} sessions`);
4122
+ return knownThreads().filter((item) => item.provider === provider);
4123
+ }
4124
+ function threadOf(provider, id) {
4125
+ return threads[provider].get(id);
4126
+ }
4127
+ function threadProvider(id) {
4128
+ return allProviders$2.find((provider) => threads[provider].has(id));
4129
+ }
4130
+ function markChanged(provider, id) {
4131
+ changed[provider].add(id);
4132
+ }
4133
+ function fileChanged(provider, filename) {
4134
+ if (filename === null) return;
4135
+ if (provider === "claude") {
4136
+ const parts = filename.split(path.sep);
4137
+ if (parts.length === 2 && parts[1].endsWith(".jsonl")) changed.claude.add(path.basename(parts[1], ".jsonl"));
4138
+ return;
4139
+ }
4140
+ const id = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/.exec(filename)?.[1];
4141
+ if (id !== void 0) changed.codex.add(id);
4142
+ }
4143
+ function forgetThread(provider, id) {
4144
+ threads[provider].delete(id);
4145
+ broadcast({ type: "threads" });
4146
+ }
4147
+ async function updateThreads(provider) {
4148
+ if (provider === "opencode") {
4149
+ await relist(provider);
4150
+ return;
4151
+ }
4152
+ const ids = [...changed[provider]];
4153
+ changed[provider].clear();
4154
+ if (ids.length === 0) return;
4155
+ await built;
4156
+ const paths = new Map(projectPaths$1());
4157
+ const results = await Promise.all(ids.map(async (id) => {
4158
+ try {
4159
+ return {
4160
+ id,
4161
+ item: await timeLimit(lookup$1(provider, id, paths), `${providerName(provider)} did not find session ${id} in time`)
4162
+ };
4163
+ } catch (error) {
4164
+ log$13.warn(`could not update ${providerName(provider)} session:`, error);
4165
+ return null;
4166
+ }
4167
+ }));
4168
+ let updated = false;
4169
+ for (const result of results) {
4170
+ if (result === null) continue;
4171
+ const before = threads[provider].get(result.id);
4172
+ if (result.item === null) {
4173
+ updated ||= before !== void 0;
4174
+ threads[provider].delete(result.id);
4175
+ continue;
4176
+ }
4177
+ updated ||= JSON.stringify(before) !== JSON.stringify(result.item);
4178
+ threads[provider].set(result.id, result.item);
4179
+ }
4180
+ if (updated) broadcast({ type: "threads" });
4181
+ }
4182
+ async function lookup$1(provider, id, paths) {
4183
+ const known = threads[provider].get(id);
4184
+ const knownPath = known === void 0 ? void 0 : [...paths].find((entry) => entry[1] === known.projectId);
4185
+ if (knownPath === void 0) return info(provider, id, paths);
4186
+ return await info(provider, id, new Map([knownPath])) ?? info(provider, id, paths);
4187
+ }
4188
+ async function relistAll() {
4189
+ await Promise.all(allProviders$2.map((provider) => relist(provider)));
4190
+ }
4191
+ async function relist(provider) {
4192
+ if (!readConfig().enabledProviders[provider]) {
4193
+ threads[provider] = /* @__PURE__ */ new Map();
4194
+ failures.delete(provider);
4195
+ broadcast({ type: "threads" });
4196
+ return;
4197
+ }
4198
+ try {
4199
+ const found = await timeLimit(find(provider, new Map(projectPaths$1())), `${providerName(provider)} did not list its sessions in time`) ?? [];
4200
+ threads[provider] = new Map(found.map((item) => [item.id, item]));
4201
+ failures.delete(provider);
4202
+ } catch (error) {
4203
+ log$13.warn(`could not list all ${providerName(provider)} sessions:`, error);
4204
+ failures.add(provider);
4205
+ builtFor = "";
4206
+ }
4207
+ broadcast({ type: "threads" });
4208
+ }
4209
+ function projectPaths$1() {
4210
+ return projects().map((project) => [project.path, project.id]);
4211
+ }
3972
4212
  // src/services/sessions.ts
4213
+ const log$12 = logger.withTag("sessions");
3973
4214
  const day = 864e5;
3974
4215
  const REFRESH_DELAY = 1e3;
3975
- const allProviders = [
4216
+ const allProviders$1 = [
3976
4217
  "claude",
3977
4218
  "codex",
3978
4219
  "opencode"
@@ -4005,61 +4246,35 @@ const providers = /* @__PURE__ */ new Map();
4005
4246
  let watchers = [];
4006
4247
  function sessions() {
4007
4248
  const known = new Set(projects().map((project) => project.id));
4008
- return withStatus(allProviders.flatMap((provider) => listed[provider]).filter((item) => known.has(item.projectId))).filter((session) => session.status !== "inactive");
4249
+ return withStatus(allProviders$1.flatMap((provider) => listed[provider]).filter((item) => known.has(item.projectId))).filter((session) => session.status !== "inactive");
4009
4250
  }
4010
- async function inactiveSessions(projectId) {
4011
- return (await everything(projects().filter((project) => projectId === null || project.id === projectId || projectId === "no-project" && project.scratch).map((project) => [project.path, project.id]))).filter((session) => session.status === "inactive").sort((a, b) => b.updatedAt - a.updatedAt);
4251
+ async function inactiveSessions(params) {
4252
+ await threadsReady();
4253
+ const scope = new Set(projects().filter((project) => params.projectId === null || project.id === params.projectId || params.projectId === "no-project" && project.scratch).map((project) => project.id));
4254
+ const found = withStatus(knownThreads().filter((item) => scope.has(item.projectId))).filter((session) => session.status === "inactive").sort((a, b) => b.updatedAt - a.updatedAt);
4255
+ return {
4256
+ sessions: found.slice(0, params.limit),
4257
+ total: found.length
4258
+ };
4012
4259
  }
4013
4260
  async function searchableSessions() {
4014
- const paths = projectPaths();
4015
- const lists = await Promise.all(enabledProviders().map(async (provider) => {
4016
- try {
4017
- return {
4018
- provider,
4019
- items: await timeLimit(find(provider, new Map(paths)), `${providerName(provider)} did not list its sessions in time`) ?? [],
4020
- failed: false
4021
- };
4022
- } catch (error) {
4023
- console.warn(`could not search ${providerName(provider)}: ${error}`);
4024
- return {
4025
- provider,
4026
- items: [],
4027
- failed: true
4028
- };
4029
- }
4030
- }));
4031
- const found = lists.flatMap((list) => list.items);
4032
- for (const item of found) providers.set(item.id, item.provider);
4261
+ await threadsReady();
4262
+ const found = knownThreads();
4033
4263
  const opens = new Map(db.select().from(sessionOpens).all().map((record) => [record.sessionId, record]));
4034
4264
  return {
4035
4265
  sessions: withStatus(found).map((session) => ({
4036
4266
  ...session,
4037
4267
  opens: opens.get(session.id) ?? null
4038
4268
  })),
4039
- failed: lists.filter((list) => list.failed).map((list) => list.provider)
4269
+ failed: enabledProviders().filter((provider) => threadFailed(provider))
4040
4270
  };
4041
4271
  }
4042
4272
  async function findSession(id) {
4043
- const paths = new Map(projectPaths());
4273
+ if (ownerOf(id) === void 0) await threadsReady();
4044
4274
  const owner = ownerOf(id);
4045
- if (owner !== void 0) {
4046
- const item = await info(owner, id, paths);
4047
- return item === null ? null : withStatus([item])[0];
4048
- }
4049
- for (const provider of allProviders) {
4050
- if (!readConfig().enabledProviders[provider]) continue;
4051
- const item = await info(provider, id, paths).catch(() => null);
4052
- if (item !== null) {
4053
- providers.set(id, provider);
4054
- return withStatus([item])[0];
4055
- }
4056
- }
4057
- return null;
4058
- }
4059
- async function everything(paths) {
4060
- const found = (await Promise.all(enabledProviders().map((provider) => find(provider, new Map(paths))))).flatMap((items) => items ?? []);
4061
- for (const item of found) providers.set(item.id, item.provider);
4062
- return withStatus(found);
4275
+ if (owner === void 0) return null;
4276
+ const item = await info(owner, id, new Map(projectPaths()));
4277
+ return item === null ? null : withStatus([item])[0];
4063
4278
  }
4064
4279
  function withStatus(items) {
4065
4280
  const records = new Map(db.select().from(sessions$1).all().map((record) => [record.sessionId, record]));
@@ -4074,16 +4289,16 @@ function withStatus(items) {
4074
4289
  });
4075
4290
  }
4076
4291
  function enabledProviders() {
4077
- return allProviders.filter((provider) => readConfig().enabledProviders[provider]);
4292
+ return allProviders$1.filter((provider) => readConfig().enabledProviders[provider]);
4078
4293
  }
4079
4294
  function projectPaths() {
4080
4295
  return projects().map((project) => [project.path, project.id]);
4081
4296
  }
4082
4297
  function ownerOf(sessionId) {
4083
- return runs.get(sessionId)?.provider ?? providers.get(sessionId);
4298
+ return runs.get(sessionId)?.provider ?? db.select({ provider: sessions$1.provider }).from(sessions$1).where(eq(sessions$1.sessionId, sessionId)).get()?.provider ?? providers.get(sessionId) ?? threadProvider(sessionId);
4084
4299
  }
4085
4300
  function providerStatuses() {
4086
- return allProviders.map((provider) => statuses[provider]);
4301
+ return allProviders$1.map((provider) => statuses[provider]);
4087
4302
  }
4088
4303
  function listedUpdatedAt(provider, sessionId) {
4089
4304
  return listed[provider].find((item) => item.id === sessionId)?.updatedAt ?? null;
@@ -4093,14 +4308,16 @@ function providerOf(sessionId) {
4093
4308
  if (provider === void 0) throw new Error("offsite does not know this session yet, reload the page");
4094
4309
  return provider;
4095
4310
  }
4096
- function recordInteraction(id) {
4311
+ function recordInteraction(id, provider) {
4097
4312
  db.insert(sessions$1).values({
4098
4313
  sessionId: id,
4314
+ provider,
4099
4315
  interactedAt: Date.now(),
4100
4316
  status: "active"
4101
4317
  }).onConflictDoUpdate({
4102
4318
  target: sessions$1.sessionId,
4103
4319
  set: {
4320
+ provider,
4104
4321
  interactedAt: Date.now(),
4105
4322
  status: sql`CASE ${sessions$1.status} WHEN 'pinned' THEN 'pinned' WHEN 'snoozed' THEN 'snoozed' WHEN 'minimized' THEN 'minimized' ELSE 'active' END`
4106
4323
  }
@@ -4123,13 +4340,15 @@ function publishSessions() {
4123
4340
  });
4124
4341
  }
4125
4342
  function refreshAll() {
4126
- for (const provider of allProviders) refresh(provider);
4343
+ rebuildThreads();
4344
+ for (const provider of allProviders$1) refresh(provider);
4127
4345
  }
4128
4346
  function refreshSessions(provider) {
4129
4347
  if (timers.has(provider)) return;
4130
4348
  timers.set(provider, setTimeout(() => {
4131
4349
  timers.delete(provider);
4132
4350
  refresh(provider);
4351
+ updateThreads(provider);
4133
4352
  }, REFRESH_DELAY));
4134
4353
  }
4135
4354
  async function refresh(provider) {
@@ -4178,7 +4397,7 @@ async function list(provider) {
4178
4397
  }
4179
4398
  } catch (error) {
4180
4399
  const message = error instanceof Error ? error.message : String(error);
4181
- console.warn(`could not list ${providerName(provider)} sessions: ${message}`);
4400
+ log$12.warn(`could not list ${providerName(provider)} sessions: ${message}`);
4182
4401
  statuses[provider] = {
4183
4402
  provider,
4184
4403
  state: "error",
@@ -4194,15 +4413,10 @@ async function list(provider) {
4194
4413
  }
4195
4414
  async function tracked(provider, paths) {
4196
4415
  if (provider === "codex" && !codexInstalled() || provider === "opencode" && !openCodeInstalled()) return null;
4197
- return (await Promise.all(db.select().from(sessions$1).all().map((record) => {
4198
- const owner = ownerOf(record.sessionId);
4199
- if (owner === provider) return lookup(provider, record.sessionId, paths);
4200
- if (owner === void 0) return lookup(provider, record.sessionId, paths).catch(() => null);
4201
- return null;
4202
- }))).filter((item) => item !== null);
4416
+ return (await Promise.all(db.select().from(sessions$1).all().filter((record) => record.provider === provider).map((record) => lookup(provider, record.sessionId, paths)))).filter((item) => item !== null);
4203
4417
  }
4204
4418
  async function lookup(provider, sessionId, paths) {
4205
- const known = listed[provider].find((item) => item.id === sessionId);
4419
+ const known = threadOf(provider, sessionId) ?? listed[provider].find((item) => item.id === sessionId);
4206
4420
  const knownPath = known === void 0 ? void 0 : [...paths].find((entry) => entry[1] === known.projectId);
4207
4421
  if (knownPath === void 0) return info(provider, sessionId, paths);
4208
4422
  return await info(provider, sessionId, new Map([knownPath])) ?? info(provider, sessionId, paths);
@@ -4212,11 +4426,16 @@ function watchSessions() {
4212
4426
  watchers = [["claude", path.join(claudeBaseDir(), "projects")], ["codex", path.join(codexBaseDir, "sessions")]].flatMap(([provider, dir]) => {
4213
4427
  if (!readConfig().enabledProviders[provider]) return [];
4214
4428
  try {
4215
- const watcher = watch(dir, { recursive: true }, () => refreshSessions(provider));
4216
- watcher.on("error", (error) => console.warn(`stopped watching ${dir}: ${error}`));
4429
+ const watcher = watch(dir, { recursive: true }, (_, filename) => {
4430
+ fileChanged(provider, filename);
4431
+ refreshSessions(provider);
4432
+ });
4433
+ watcher.on("error", (error) => log$12.warn(`stopped watching ${dir}:`, error));
4434
+ log$12.debug(`watching ${dir}`);
4217
4435
  return [watcher];
4218
4436
  } catch (error) {
4219
4437
  if (error.code !== "ENOENT") throw error;
4438
+ log$12.debug(`not watching ${dir}, it does not exist`);
4220
4439
  return [];
4221
4440
  }
4222
4441
  });
@@ -4250,16 +4469,24 @@ async function updateSession(id, patch) {
4250
4469
  status: patch.status,
4251
4470
  interactedAt: Date.now()
4252
4471
  } : { status: patch.status };
4472
+ const provider = providerOf(id);
4253
4473
  db.insert(sessions$1).values({
4254
4474
  sessionId: id,
4475
+ provider,
4255
4476
  ...set
4256
4477
  }).onConflictDoUpdate({
4257
4478
  target: sessions$1.sessionId,
4258
- set
4479
+ set: {
4480
+ provider,
4481
+ ...set
4482
+ }
4259
4483
  }).run();
4260
- await refresh(providerOf(id));
4484
+ await refresh(provider);
4485
+ }
4486
+ if (patch.title !== void 0) {
4487
+ markChanged(providerOf(id), id);
4488
+ refreshSessions(providerOf(id));
4261
4489
  }
4262
- if (patch.title !== void 0) refreshSessions(providerOf(id));
4263
4490
  }
4264
4491
  async function removeSession(id, projectId) {
4265
4492
  const provider = providerOf(id);
@@ -4280,19 +4507,19 @@ async function removeSession(id, projectId) {
4280
4507
  db.delete(sessions$1).where(eq(sessions$1.sessionId, id)).run();
4281
4508
  db.delete(sessionOpens).where(eq(sessionOpens.sessionId, id)).run();
4282
4509
  listed[provider] = listed[provider].filter((item) => item.id !== id);
4510
+ forgetThread(provider, id);
4283
4511
  const project = projects().find((item) => item.id === projectId);
4284
4512
  if (project === void 0 || !project.scratch) return;
4285
- try {
4286
- if ((await everything([[project.path, project.id]])).filter((item) => item.id !== id).length === 0) removeProject(project.id);
4287
- } catch (error) {
4288
- console.warn(`could not check other chats in ${project.path}: ${error}`);
4289
- }
4513
+ await threadsReady();
4514
+ if (enabledProviders().some((owner) => threadFailed(owner))) return;
4515
+ if (!knownThreads().some((item) => item.projectId === project.id)) removeProject(project.id);
4290
4516
  }
4291
4517
  async function fork(sessionId, projectId, messageId) {
4292
4518
  const provider = providerOf(sessionId);
4293
4519
  const result = await forkWith(provider, sessionId, projectPath(projectId), messageId);
4294
4520
  providers.set(result.sessionId, provider);
4295
- recordInteraction(result.sessionId);
4521
+ recordInteraction(result.sessionId, provider);
4522
+ markChanged(provider, result.sessionId);
4296
4523
  refreshSessions(provider);
4297
4524
  return result;
4298
4525
  }
@@ -4306,6 +4533,7 @@ async function forkWith(provider, sessionId, cwd, messageId) {
4306
4533
  })).sessionId };
4307
4534
  }
4308
4535
  // src/lib/opencode/client.ts
4536
+ const log$11 = logger.withTag("opencode");
4309
4537
  const listChanges = /* @__PURE__ */ new Set([
4310
4538
  "session.created",
4311
4539
  "session.renamed",
@@ -4351,6 +4579,7 @@ async function start() {
4351
4579
  }
4352
4580
  async function connect() {
4353
4581
  const endpoint = await ensure();
4582
+ log$11.info(`connected to OpenCode at ${endpoint.url}`);
4354
4583
  return OpenCode.make({
4355
4584
  baseUrl: endpoint.url,
4356
4585
  headers: headers(endpoint)
@@ -4365,10 +4594,11 @@ async function listen(client) {
4365
4594
  signal: controller.signal,
4366
4595
  onActivity: () => silence.refresh()
4367
4596
  })) route(event);
4368
- console.warn("the OpenCode event stream ended");
4597
+ log$11.warn("the OpenCode event stream ended");
4369
4598
  } catch (error) {
4370
4599
  if (listener !== controller) return;
4371
- console.warn(controller.signal.aborted ? `the OpenCode event stream was silent for ${SILENCE_LIMIT$2 / 1e3} seconds` : `lost the OpenCode event stream: ${error}`);
4600
+ if (controller.signal.aborted) log$11.warn(`the OpenCode event stream was silent for ${SILENCE_LIMIT$2 / 1e3} seconds`);
4601
+ else log$11.warn("lost the OpenCode event stream:", error);
4372
4602
  } finally {
4373
4603
  clearTimeout(silence);
4374
4604
  }
@@ -4384,14 +4614,15 @@ async function reconnect() {
4384
4614
  try {
4385
4615
  client = await connect();
4386
4616
  } catch (error) {
4617
+ log$11.debug(`reconnect attempt ${attempt + 1} to OpenCode failed:`, error);
4387
4618
  failure = error;
4388
4619
  continue;
4389
4620
  }
4390
4621
  listen(client);
4391
- await catchUp(client).catch((error) => console.warn(`could not catch up with OpenCode: ${error}`));
4622
+ await catchUp(client).catch((error) => log$11.warn(`could not catch up with OpenCode:`, error));
4392
4623
  return client;
4393
4624
  }
4394
- console.warn(`could not reconnect to OpenCode: ${failure}`);
4625
+ log$11.warn(`could not reconnect to OpenCode:`, failure);
4395
4626
  connection = void 0;
4396
4627
  for (const run of openCodeRuns()) run.exited();
4397
4628
  refreshSessions("opencode");
@@ -4422,6 +4653,7 @@ function openCodeRuns() {
4422
4653
  return [...runs.values()].flatMap((run) => run.provider === "opencode" ? [run] : []);
4423
4654
  }
4424
4655
  // src/services/accounts.ts
4656
+ const log$10 = logger.withTag("accounts");
4425
4657
  const providerNames = {
4426
4658
  claude: "Claude",
4427
4659
  codex: "Codex",
@@ -4489,6 +4721,7 @@ function disabledAccount(account) {
4489
4721
  email: null,
4490
4722
  displayName: null,
4491
4723
  organization: null,
4724
+ identity: null,
4492
4725
  plan: null,
4493
4726
  error: null,
4494
4727
  linked: true,
@@ -4502,7 +4735,7 @@ async function publishAccounts() {
4502
4735
  ...await accounts()
4503
4736
  });
4504
4737
  } catch (error) {
4505
- console.warn(`could not list accounts: ${error}`);
4738
+ log$10.warn(`could not list accounts:`, error);
4506
4739
  }
4507
4740
  }
4508
4741
  async function capabilities(provider, accountId, cwd) {
@@ -4609,6 +4842,7 @@ function validAccount(name, configDir) {
4609
4842
  throw new Error("Use an absolute config directory or one starting with ~/");
4610
4843
  }
4611
4844
  // src/services/push.ts
4845
+ const log$9 = logger.withTag("push");
4612
4846
  const TITLE_LIMIT = 100;
4613
4847
  const BODY_LIMIT = 500;
4614
4848
  const TIME_TO_LIVE = 900;
@@ -4656,12 +4890,12 @@ function subscribe(registration, client) {
4656
4890
  set: values
4657
4891
  }).run();
4658
4892
  if (notifier !== null && notifier.client === client) notifier.endpoint = registration.subscription.endpoint;
4659
- console.log("a browser subscribed to push notifications");
4893
+ log$9.info("a browser subscribed to push notifications");
4660
4894
  }
4661
4895
  function unsubscribe(endpoint) {
4662
4896
  db.delete(pushSubscriptions).where(eq(pushSubscriptions.endpoint, endpoint)).run();
4663
4897
  if (notifier !== null && notifier.endpoint === endpoint) notifier.endpoint = null;
4664
- console.log("a browser unsubscribed from push notifications");
4898
+ log$9.info("a browser unsubscribed from push notifications");
4665
4899
  }
4666
4900
  async function sendPush(message) {
4667
4901
  const subscriptions = targets();
@@ -4672,7 +4906,7 @@ async function sendPush(message) {
4672
4906
  body: [...message.body].slice(0, BODY_LIMIT).join("")
4673
4907
  };
4674
4908
  const gone = [];
4675
- let sent = false;
4909
+ let delivered = 0;
4676
4910
  await Promise.all(subscriptions.map(async (subscription) => {
4677
4911
  try {
4678
4912
  await webPush.sendNotification({
@@ -4694,7 +4928,7 @@ async function sendPush(message) {
4694
4928
  urgency: "high",
4695
4929
  timeout: SEND_TIMEOUT
4696
4930
  });
4697
- sent = true;
4931
+ delivered += 1;
4698
4932
  } catch (error) {
4699
4933
  const status = error.statusCode;
4700
4934
  if (status !== void 0 && [
@@ -4703,22 +4937,24 @@ async function sendPush(message) {
4703
4937
  404,
4704
4938
  410
4705
4939
  ].includes(status)) {
4706
- console.log(`push subscription ${subscription.endpoint} is gone or was made for another key, removing it`);
4940
+ log$9.info(`push subscription ${subscription.endpoint} is gone or was made for another key, removing it`);
4707
4941
  gone.push(subscription.endpoint);
4708
4942
  return;
4709
4943
  }
4710
- console.warn(`could not send a push to ${subscription.endpoint}: ${error}`);
4944
+ log$9.warn(`could not send a push to ${subscription.endpoint}:`, error);
4711
4945
  }
4712
4946
  }));
4713
- if (gone.length === 0) return sent;
4947
+ log$9.debug(`sent a push to ${delivered} of ${subscriptions.length} browsers`);
4948
+ if (gone.length === 0) return delivered > 0;
4714
4949
  db.delete(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), inArray(pushSubscriptions.endpoint, gone))).run();
4715
- return sent;
4950
+ return delivered > 0;
4716
4951
  }
4717
4952
  function targets() {
4718
4953
  if (notifier === null || notifier.endpoint === null) return [];
4719
4954
  return db.select().from(pushSubscriptions).where(and(eq(pushSubscriptions.server, server$1), eq(pushSubscriptions.endpoint, notifier.endpoint))).all();
4720
4955
  }
4721
4956
  // src/lib/claude/claude.ts
4957
+ const log$8 = logger.withTag("claude");
4722
4958
  const efforts = [
4723
4959
  "low",
4724
4960
  "medium",
@@ -4857,7 +5093,7 @@ async function claudeAccount(account) {
4857
5093
  return {
4858
5094
  ...account,
4859
5095
  email: info.email ?? null,
4860
- displayName: claudeDisplayName(configDir),
5096
+ ...claudeLogin(configDir),
4861
5097
  organization: info.organization ?? null,
4862
5098
  plan: info.subscriptionType ?? null,
4863
5099
  error: null,
@@ -4870,6 +5106,7 @@ async function claudeAccount(account) {
4870
5106
  email: null,
4871
5107
  displayName: null,
4872
5108
  organization: null,
5109
+ identity: null,
4873
5110
  plan: null,
4874
5111
  error: error instanceof Error ? error.message : String(error),
4875
5112
  linked,
@@ -4948,18 +5185,31 @@ async function claudeLimits(account) {
4948
5185
  probe.close();
4949
5186
  }
4950
5187
  }
4951
- function claudeDisplayName(configDir) {
5188
+ function claudeLogin(configDir) {
4952
5189
  const file = path.resolve(configDir) === path.join(os.homedir(), ".claude") ? path.join(os.homedir(), ".claude.json") : path.join(configDir, ".claude.json");
4953
5190
  let raw;
4954
5191
  try {
4955
5192
  raw = JSON.parse(readFileSync(file, "utf8"));
4956
5193
  } catch (error) {
4957
- if (error.code !== "ENOENT") console.warn(`could not read the Claude account name: ${error}`);
4958
- return null;
5194
+ if (error.code !== "ENOENT") log$8.warn(`could not read the Claude account:`, error);
5195
+ return {
5196
+ displayName: null,
5197
+ identity: null
5198
+ };
4959
5199
  }
4960
- if (!isRecord(raw) || !isRecord(raw.oauthAccount) || typeof raw.oauthAccount.displayName !== "string") return null;
4961
- const name = raw.oauthAccount.displayName.trim();
4962
- return name === "" ? null : name;
5200
+ if (!isRecord(raw) || !isRecord(raw.oauthAccount)) return {
5201
+ displayName: null,
5202
+ identity: null
5203
+ };
5204
+ const login = raw.oauthAccount;
5205
+ const name = typeof login.displayName === "string" ? login.displayName.trim() : "";
5206
+ return {
5207
+ displayName: name === "" ? null : name,
5208
+ identity: typeof login.accountUuid === "string" && typeof login.organizationUuid === "string" ? {
5209
+ accountId: login.accountUuid,
5210
+ organizationId: login.organizationUuid
5211
+ } : null
5212
+ };
4963
5213
  }
4964
5214
  function isLinked(configDir) {
4965
5215
  const base = claudeBaseDir();
@@ -5735,7 +5985,10 @@ var Run = class {
5735
5985
  try {
5736
5986
  for await (const message of this.query) this.receive(message);
5737
5987
  } catch (error) {
5738
- if (!this.stopping) this.add(notice(error instanceof Error ? error.message : String(error)));
5988
+ if (!this.stopping) {
5989
+ logger.withTag("claude").warn(`${this.sessionId} failed:`, error);
5990
+ this.add(notice(error instanceof Error ? error.message : String(error)));
5991
+ }
5739
5992
  }
5740
5993
  clearTimeout(this.idleTimer);
5741
5994
  for (const id of this.pending.keys()) this.settle(id, {
@@ -6166,7 +6419,7 @@ async function submitFirst(client, run, request, projectId) {
6166
6419
  from: request.projectId
6167
6420
  });
6168
6421
  await run.submit(request.prompt, request.delivery);
6169
- recordInteraction(sessionId);
6422
+ recordInteraction(sessionId, run.provider);
6170
6423
  }
6171
6424
  async function sendExisting(client, sessionId, cwd, request) {
6172
6425
  const provider = providerOf(sessionId);
@@ -6180,7 +6433,7 @@ async function sendExisting(client, sessionId, cwd, request) {
6180
6433
  if (run === existing) await run.configure(request.settings);
6181
6434
  else await open$1(client, sessionId, request.projectId);
6182
6435
  await run.submit(request.prompt, request.delivery);
6183
- recordInteraction(sessionId);
6436
+ recordInteraction(sessionId, provider);
6184
6437
  if (emptied.has(sessionId) && request.prompt.text.trim() !== "") {
6185
6438
  emptied.delete(sessionId);
6186
6439
  await updateSession(sessionId, { title: request.prompt.text.trim() });
@@ -6353,6 +6606,7 @@ function fenced(language, text) {
6353
6606
  return `${fence}${language}\n${text}\n${fence}`;
6354
6607
  }
6355
6608
  // src/services/message-search.ts
6609
+ const log$7 = logger.withTag("search");
6356
6610
  const READ_LIMIT = 30;
6357
6611
  const CODEX_READ_LIMIT = 10;
6358
6612
  const RG_TIME_LIMIT = 5e3;
@@ -6363,7 +6617,6 @@ async function searchMessages(query) {
6363
6617
  matches: [],
6364
6618
  failed: []
6365
6619
  };
6366
- const paths = new Map(projects().map((project) => [project.path, project.id]));
6367
6620
  const folders = new Map(projects().map((project) => [project.id, project.path]));
6368
6621
  const results = await Promise.all([
6369
6622
  "claude",
@@ -6373,11 +6626,11 @@ async function searchMessages(query) {
6373
6626
  try {
6374
6627
  return {
6375
6628
  provider,
6376
- matches: await timeLimit(search(provider, query, paths, folders), `${providerName(provider)} did not search in time`),
6629
+ matches: await timeLimit(search(provider, query, folders), `${providerName(provider)} did not search in time`),
6377
6630
  failed: false
6378
6631
  };
6379
6632
  } catch (error) {
6380
- console.warn(`could not search ${providerName(provider)} messages: ${error}`);
6633
+ log$7.warn(`could not search ${providerName(provider)} messages:`, error);
6381
6634
  return {
6382
6635
  provider,
6383
6636
  matches: [],
@@ -6390,10 +6643,8 @@ async function searchMessages(query) {
6390
6643
  failed: results.filter((result) => result.failed).map((result) => result.provider)
6391
6644
  };
6392
6645
  }
6393
- async function search(provider, query, paths, folders) {
6394
- const found = await find(provider, paths);
6395
- if (found === null) return [];
6396
- const sessions = newestFirst(found);
6646
+ async function search(provider, query, folders) {
6647
+ const sessions = newestFirst(await providerThreads(provider));
6397
6648
  switch (provider) {
6398
6649
  case "claude": return searchClaude(query, sessions, folders);
6399
6650
  case "codex": return searchCodex(query, sessions);
@@ -6427,7 +6678,7 @@ async function claudeCandidates(query) {
6427
6678
  const lists = await Promise.all(words.map((word) => sessionsWith(word)));
6428
6679
  return new Set(lists[0].filter((id) => lists.every((list) => list.includes(id))));
6429
6680
  } catch (error) {
6430
- console.warn(`could not narrow Claude sessions with rg: ${error}`);
6681
+ log$7.warn(`could not narrow Claude sessions with rg:`, error);
6431
6682
  return null;
6432
6683
  }
6433
6684
  }
@@ -6989,6 +7240,7 @@ function shellIntegrationIssue(command, platform, defaultShell) {
6989
7240
  return "Available for bash, zsh and fish";
6990
7241
  }
6991
7242
  // src/services/terminal/shell-integration.ts
7243
+ const log$6 = logger.withTag("terminal");
6992
7244
  const scripts = path.join(serverDir, "src/services/terminal/shell-integration");
6993
7245
  const folder = path.join(home, "shell-integration");
6994
7246
  function prepare(env, command, shell) {
@@ -7031,7 +7283,7 @@ function zshGlobalsSupported() {
7031
7283
  contents = readFileSync(global, "utf8");
7032
7284
  } catch (error) {
7033
7285
  if (error.code === "ENOENT") continue;
7034
- console.warn(`could not read ${global}, zsh starts without shell integration: ${error}`);
7286
+ log$6.warn(`could not read ${global}, zsh starts without shell integration:`, error);
7035
7287
  return false;
7036
7288
  }
7037
7289
  for (const rawLine of contents.split("\n")) {
@@ -7076,11 +7328,12 @@ function writeScript(target, source) {
7076
7328
  renameSync(`${target}.tmp`, target);
7077
7329
  return true;
7078
7330
  } catch (error) {
7079
- console.warn(`could not write ${target}, the shell starts without shell integration: ${error}`);
7331
+ log$6.warn(`could not write ${target}, the shell starts without shell integration:`, error);
7080
7332
  return false;
7081
7333
  }
7082
7334
  }
7083
7335
  // src/services/terminal/process.ts
7336
+ const log$5 = logger.withTag("terminal");
7084
7337
  const run = promisify(execFile);
7085
7338
  const iterm2Version = "3.6.8";
7086
7339
  const ghosttyVersion = "1.3.1";
@@ -7176,7 +7429,7 @@ async function busyProgram(pid) {
7176
7429
  try {
7177
7430
  return await programName(group);
7178
7431
  } catch (error) {
7179
- console.warn(`could not read the name of process ${group}: ${error}`);
7432
+ log$5.warn(`could not read the name of process ${group}:`, error);
7180
7433
  return `process ${group}`;
7181
7434
  }
7182
7435
  }
@@ -7418,6 +7671,7 @@ var Alerts = class {
7418
7671
  }
7419
7672
  };
7420
7673
  // src/services/terminal/terminal.ts
7674
+ const log$4 = logger.withTag("terminal");
7421
7675
  const HISTORY_LIMIT = 2097152;
7422
7676
  const OUTPUT_LIMIT = 1048576;
7423
7677
  const STALL_LIMIT = 1e4;
@@ -7452,7 +7706,7 @@ async function terminalRequest(client, message) {
7452
7706
  function terminalInput(client, frame) {
7453
7707
  const connection = connections.get(client);
7454
7708
  if (connection === void 0) {
7455
- console.warn("terminal input from a browser that is not attached");
7709
+ log$4.warn("terminal input from a browser that is not attached");
7456
7710
  return;
7457
7711
  }
7458
7712
  input(connection, frame);
@@ -7469,7 +7723,7 @@ async function busyTerminal(id) {
7469
7723
  try {
7470
7724
  return { program: await busyProgram(terminal.pty.pid) };
7471
7725
  } catch (error) {
7472
- console.warn(`could not tell if terminal ${id} is busy: ${error}`);
7726
+ log$4.warn(`could not tell if terminal ${id} is busy:`, error);
7473
7727
  return { error: String(error) };
7474
7728
  }
7475
7729
  }
@@ -7538,12 +7792,12 @@ function input(connection, bytes) {
7538
7792
  try {
7539
7793
  frame = decodeFrame(bytes);
7540
7794
  } catch (error) {
7541
- console.warn(`ignoring terminal input: ${error}`);
7795
+ log$4.warn(`ignoring terminal input:`, error);
7542
7796
  return;
7543
7797
  }
7544
7798
  const terminal = terminals.get(frame.id);
7545
7799
  if (terminal === void 0) {
7546
- console.warn(`input for terminal ${frame.id}, which does not exist`);
7800
+ log$4.warn(`input for terminal ${frame.id}, which does not exist`);
7547
7801
  return;
7548
7802
  }
7549
7803
  if (frame.kind === 1) {
@@ -7555,7 +7809,7 @@ function input(connection, bytes) {
7555
7809
  terminal.pty.resize(size.cols, size.rows);
7556
7810
  }
7557
7811
  } else {
7558
- console.warn(`ignoring terminal input of unknown kind ${frame.kind}`);
7812
+ log$4.warn(`ignoring terminal input of unknown kind ${frame.kind}`);
7559
7813
  return;
7560
7814
  }
7561
7815
  terminal.pty.write(Buffer.from(frame.data));
@@ -7563,7 +7817,7 @@ function input(connection, bytes) {
7563
7817
  async function createTab(projectId) {
7564
7818
  const cwd = projectId === null ? workingDirectory() : projectPath(projectId);
7565
7819
  if (!statSync(cwd, { throwIfNoEntry: false })?.isDirectory()) {
7566
- console.error(`${cwd} is not a folder, terminal not created`);
7820
+ log$4.error(`${cwd} is not a folder, terminal not created`);
7567
7821
  return;
7568
7822
  }
7569
7823
  const activeId = active.get(projectId ?? "global");
@@ -7572,7 +7826,7 @@ async function createTab(projectId) {
7572
7826
  if (current !== void 0) try {
7573
7827
  directory = await currentDirectory(current.pty.pid, current.scanner.directory);
7574
7828
  } catch (error) {
7575
- console.warn(`starting in the project folder, could not read the folder of the active terminal: ${error}`);
7829
+ log$4.warn(`starting in the project folder, could not read the folder of the active terminal:`, error);
7576
7830
  }
7577
7831
  nextTab += 1;
7578
7832
  const id = nextTab;
@@ -7580,7 +7834,7 @@ async function createTab(projectId) {
7580
7834
  try {
7581
7835
  pty = startShell(directory);
7582
7836
  } catch (error) {
7583
- console.error(`could not start terminal ${id}: ${error}`);
7837
+ log$4.error(`could not start terminal ${id}:`, error);
7584
7838
  return;
7585
7839
  }
7586
7840
  const terminal = {
@@ -7591,6 +7845,7 @@ async function createTab(projectId) {
7591
7845
  alerts: new Alerts(),
7592
7846
  scanner: new Scanner()
7593
7847
  };
7848
+ log$4.info(`started terminal ${id} with pid ${pty.pid} in ${directory}`);
7594
7849
  terminals.set(id, terminal);
7595
7850
  tabs.push({
7596
7851
  id,
@@ -7603,7 +7858,8 @@ async function createTab(projectId) {
7603
7858
  pty.pause();
7604
7859
  held.add(pty);
7605
7860
  }
7606
- pty.onExit(() => {
7861
+ pty.onExit((event) => {
7862
+ log$4.info(`terminal ${id} exited with code ${event.exitCode}`);
7607
7863
  terminals.delete(id);
7608
7864
  paused.delete(pty);
7609
7865
  held.delete(pty);
@@ -7613,6 +7869,7 @@ async function createTab(projectId) {
7613
7869
  function closeTab(id) {
7614
7870
  const tab = findTab(id);
7615
7871
  if (tab === void 0) return false;
7872
+ log$4.debug(`closing terminal ${id}`);
7616
7873
  release(tab);
7617
7874
  tabs.splice(tabs.indexOf(tab), 1);
7618
7875
  const terminal = terminals.get(id);
@@ -7654,7 +7911,7 @@ function drain() {
7654
7911
  last.queued = queued;
7655
7912
  progress.set(connection, last);
7656
7913
  if (now - last.at > STALL_LIMIT) {
7657
- console.warn("a browser is not reading fast enough, disconnecting it");
7914
+ log$4.warn("a browser is not reading fast enough, disconnecting it");
7658
7915
  connection.client.socket.terminate();
7659
7916
  continue;
7660
7917
  }
@@ -7744,6 +8001,36 @@ function send(connection, event) {
7744
8001
  event
7745
8002
  });
7746
8003
  }
8004
+ // src/storage/last-used.ts
8005
+ const lastUsedSchema = z.discriminatedUnion("provider", [z.strictObject({
8006
+ provider: z.enum(["claude", "codex"]),
8007
+ accountId: z.string(),
8008
+ model: z.string()
8009
+ }), z.strictObject({
8010
+ provider: z.literal("opencode"),
8011
+ accountId: z.null(),
8012
+ model: z.string()
8013
+ })]);
8014
+ function readLastUsed() {
8015
+ return db.select().from(lastUsedModels).all();
8016
+ }
8017
+ function saveLastUsed(value) {
8018
+ const result = lastUsedSchema.safeParse(value);
8019
+ if (!result.success) throw new Error(z.prettifyError(result.error));
8020
+ const row = {
8021
+ ...result.data,
8022
+ usedAt: Date.now()
8023
+ };
8024
+ db.insert(lastUsedModels).values(row).onConflictDoUpdate({
8025
+ target: lastUsedModels.provider,
8026
+ set: row
8027
+ }).run();
8028
+ broadcast({
8029
+ type: "lastUsed",
8030
+ lastUsed: readLastUsed()
8031
+ });
8032
+ return null;
8033
+ }
7747
8034
  // ../core/src/app-icons.ts
7748
8035
  const appIcons = [
7749
8036
  {
@@ -7814,77 +8101,14 @@ const appIcons = [
7814
8101
  }
7815
8102
  ];
7816
8103
  // src/storage/state.ts
7817
- const stateSchema = z.strictObject({
7818
- composer: z.strictObject({
7819
- provider: z.enum([
7820
- "claude",
7821
- "codex",
7822
- "opencode"
7823
- ]).default("claude"),
7824
- claude: z.strictObject({
7825
- accountId: z.string().nullable().default(null),
7826
- model: z.string().nullable().default(null),
7827
- effort: z.enum([
7828
- "low",
7829
- "medium",
7830
- "high",
7831
- "xhigh",
7832
- "max"
7833
- ]).nullable().default(null),
7834
- mode: z.enum([
7835
- "default",
7836
- "acceptEdits",
7837
- "bypassPermissions",
7838
- "plan",
7839
- "dontAsk",
7840
- "auto"
7841
- ]).nullable().default("bypassPermissions"),
7842
- plan: z.boolean().default(false)
7843
- }).prefault({}),
7844
- codex: z.strictObject({
7845
- accountId: z.string().nullable().default(null),
7846
- model: z.string().nullable().default(null),
7847
- effort: z.string().nullable().default(null),
7848
- mode: z.enum([
7849
- "read-only",
7850
- "auto",
7851
- "full-access"
7852
- ]).nullable().default("auto"),
7853
- plan: z.boolean().default(false)
7854
- }).prefault({}),
7855
- opencode: z.strictObject({
7856
- accountId: z.null().default(null),
7857
- model: z.string().nullable().default(null),
7858
- effort: z.string().nullable().default(null),
7859
- mode: z.string().nullable().default(null),
7860
- plan: z.boolean().default(false)
7861
- }).prefault({})
7862
- }).prefault({}),
7863
- appIcon: z.custom((value) => appIcons.some((icon) => icon.id === value)).default("original")
7864
- });
8104
+ const stateSchema = z.strictObject({ appIcon: z.custom((value) => appIcons.some((icon) => icon.id === value)).default("original") });
7865
8105
  const defaults = stateSchema.parse({});
7866
8106
  function readState() {
7867
8107
  return readSettings("state.json", stateSchema, defaults);
7868
8108
  }
7869
- function readComposer() {
7870
- return readState().composer;
7871
- }
7872
8109
  function readAppIcon() {
7873
8110
  return readState().appIcon;
7874
8111
  }
7875
- function saveComposer(value) {
7876
- const result = stateSchema.safeParse({
7877
- ...readState(),
7878
- composer: value
7879
- });
7880
- if (!result.success) throw new Error(z.prettifyError(result.error));
7881
- writeJson("state.json", result.data);
7882
- broadcast({
7883
- type: "composer",
7884
- composer: result.data.composer
7885
- });
7886
- return result.data.composer;
7887
- }
7888
8112
  function saveAppIcon(value) {
7889
8113
  const result = stateSchema.safeParse({
7890
8114
  ...readState(),
@@ -7899,13 +8123,14 @@ function saveAppIcon(value) {
7899
8123
  return result.data.appIcon;
7900
8124
  }
7901
8125
  // src/api/socket.ts
8126
+ const log$3 = logger.withTag("socket");
7902
8127
  const handlers = {
7903
8128
  ping: () => null,
7904
8129
  sync: () => {
7905
8130
  refreshAll();
7906
8131
  return {
7907
8132
  ...configInfo(),
7908
- composer: readComposer(),
8133
+ lastUsed: readLastUsed(),
7909
8134
  hostname: os.hostname(),
7910
8135
  projects: projects(),
7911
8136
  sessions: sessions(),
@@ -7916,8 +8141,7 @@ const handlers = {
7916
8141
  };
7917
8142
  },
7918
8143
  "config.get": () => configInfo(),
7919
- "composer.get": () => readComposer(),
7920
- "composer.save": (params) => saveComposer(params),
8144
+ "lastUsed.save": (params) => saveLastUsed(params),
7921
8145
  "appIcon.save": (params) => saveAppIcon(params),
7922
8146
  "config.save": async (params) => {
7923
8147
  const before = readConfig();
@@ -7973,7 +8197,7 @@ const handlers = {
7973
8197
  if (params.opened && session !== null) recordOpen(params.sessionId, params.query);
7974
8198
  return session;
7975
8199
  },
7976
- "sessions.inactive": (params) => inactiveSessions(params.projectId),
8200
+ "sessions.inactive": (params) => inactiveSessions(params),
7977
8201
  "sessions.searchable": () => searchableSessions(),
7978
8202
  "sessions.searchMessages": (params) => searchMessages(params.query),
7979
8203
  "sessions.earlier": (params) => earlier(params.sessionId, params.before),
@@ -8014,7 +8238,7 @@ const handlers = {
8014
8238
  const run = runs.get(params.sessionId);
8015
8239
  if (run === void 0) return null;
8016
8240
  await run.respond(params.requestId, params.decision, params.answers);
8017
- recordInteraction(params.sessionId);
8241
+ recordInteraction(params.sessionId, run.provider);
8018
8242
  return null;
8019
8243
  },
8020
8244
  "sessions.rewind": (params) => rewind(params),
@@ -8128,6 +8352,7 @@ function connectClient(socket, device, address) {
8128
8352
  send: (message) => socket.send(JSON.stringify(message))
8129
8353
  };
8130
8354
  clients.add(client);
8355
+ log$3.info(`a browser connected from ${address}`);
8131
8356
  socket.on("message", (data, isBinary) => {
8132
8357
  client.heardAt = Date.now();
8133
8358
  if (isBinary) {
@@ -8136,8 +8361,9 @@ function connectClient(socket, device, address) {
8136
8361
  }
8137
8362
  answer(client, String(data));
8138
8363
  });
8139
- socket.on("error", (error) => console.warn(`a browser connection failed: ${error}`));
8140
- socket.on("close", () => {
8364
+ socket.on("error", (error) => log$3.warn(`a browser connection from ${address} failed:`, error));
8365
+ socket.on("close", (code) => {
8366
+ log$3.info(`a browser from ${address} disconnected (code ${code})`);
8141
8367
  clients.delete(client);
8142
8368
  detachTerminal(client);
8143
8369
  });
@@ -8147,9 +8373,10 @@ async function answer(client, text) {
8147
8373
  try {
8148
8374
  call = JSON.parse(text);
8149
8375
  } catch (error) {
8150
- console.warn(`ignoring a message that is not JSON: ${error}`);
8376
+ log$3.warn(`ignoring a message from ${client.address} that is not JSON:`, error);
8151
8377
  return;
8152
8378
  }
8379
+ const started = performance.now();
8153
8380
  try {
8154
8381
  if (!Object.hasOwn(handlers, call.method)) throw new Error(`unknown method ${call.method}`);
8155
8382
  const handler = handlers[call.method];
@@ -8160,11 +8387,15 @@ async function answer(client, text) {
8160
8387
  result
8161
8388
  });
8162
8389
  } catch (error) {
8390
+ log$3.warn(`${call.method} from ${client.address} failed:`, error);
8163
8391
  client.send({
8164
8392
  type: "reply",
8165
8393
  id: call.id,
8166
8394
  error: error instanceof Error ? error.message : String(error)
8167
8395
  });
8396
+ } finally {
8397
+ const took = Math.round(performance.now() - started);
8398
+ if (call.method !== "ping") log$3.debug(`${call.method} from ${client.address} took ${took}ms`);
8168
8399
  }
8169
8400
  }
8170
8401
  // src/lib/proxy/css.ts
@@ -9337,6 +9568,7 @@ function fail(response, status, message) {
9337
9568
  response.end(message);
9338
9569
  }
9339
9570
  // src/services/app-icon.ts
9571
+ const log$2 = logger.withTag("app-icon");
9340
9572
  const pngs = /* @__PURE__ */ new Map([
9341
9573
  ["icon-192.png", {
9342
9574
  size: 192,
@@ -9369,7 +9601,7 @@ async function serveAppIcon(publicDir, pathname, response) {
9369
9601
  response.setHeader("cache-control", "no-cache");
9370
9602
  response.end(icon.body);
9371
9603
  } catch (error) {
9372
- console.warn(`could not serve the app icon: ${error}`);
9604
+ log$2.warn(`could not serve the app icon:`, error);
9373
9605
  response.statusCode = 500;
9374
9606
  response.end("could not render the app icon");
9375
9607
  }
@@ -9401,10 +9633,83 @@ function tile(icon, maskable) {
9401
9633
  if (icon.tile.shape === "circle") return `<circle cx="256" cy="256" r="256" fill="${icon.tile.color}"/>`;
9402
9634
  throw new Error(`unknown tile shape for ${icon.id}`);
9403
9635
  }
9636
+ // src/services/backfill-session-providers.ts
9637
+ const log$1 = logger.withTag("backfill");
9638
+ const allProviders = [
9639
+ "claude",
9640
+ "codex",
9641
+ "opencode"
9642
+ ];
9643
+ async function backfillSessionProviders() {
9644
+ const rows = db.select().from(sessions$1).where(isNull(sessions$1.provider)).all();
9645
+ if (rows.length === 0) return;
9646
+ const paths = new Map(projects().map((project) => [project.path, project.id]));
9647
+ const enabled = allProviders.filter((provider) => readConfig().enabledProviders[provider]);
9648
+ for (const row of rows) {
9649
+ const answers = await Promise.all(enabled.map(async (provider) => {
9650
+ try {
9651
+ return await timeLimit(info(provider, row.sessionId, paths), `${providerName(provider)} did not answer in time`) !== null;
9652
+ } catch (error) {
9653
+ log$1.debug(`${providerName(provider)} does not know session ${row.sessionId}:`, error);
9654
+ return false;
9655
+ }
9656
+ }));
9657
+ const owners = enabled.filter((_, index) => answers[index]);
9658
+ if (owners.length !== 1) {
9659
+ log$1.warn(`${owners.length} providers know session ${row.sessionId}, it stays without a provider until the next start`);
9660
+ continue;
9661
+ }
9662
+ db.update(sessions$1).set({ provider: owners[0] }).where(eq(sessions$1.sessionId, row.sessionId)).run();
9663
+ log$1.info(`saved ${providerName(owners[0])} as the provider of session ${row.sessionId}`);
9664
+ }
9665
+ }
9666
+ // src/services/browser-logs.ts
9667
+ const logsBody = z.array(z.object({
9668
+ time: z.number(),
9669
+ type: z.enum([
9670
+ "fatal",
9671
+ "error",
9672
+ "warn",
9673
+ "log",
9674
+ "info",
9675
+ "success",
9676
+ "fail",
9677
+ "ready",
9678
+ "start",
9679
+ "box",
9680
+ "debug",
9681
+ "trace",
9682
+ "verbose"
9683
+ ]),
9684
+ tag: z.string(),
9685
+ message: z.string()
9686
+ }));
9687
+ async function saveBrowserLogs(device, request, response) {
9688
+ let body = "";
9689
+ for await (const chunk of request) body += chunk;
9690
+ let entries;
9691
+ try {
9692
+ entries = logsBody.parse(JSON.parse(body));
9693
+ } catch {
9694
+ response.statusCode = 400;
9695
+ response.end("expected [{ time, type, tag, message }]");
9696
+ return;
9697
+ }
9698
+ const source = device === null ? request.socket.remoteAddress ?? "unknown address" : readDevices().find((item) => item.hash === device)?.name ?? "Unnamed";
9699
+ for (const entry of entries) browserLogger.log({
9700
+ date: new Date(entry.time),
9701
+ type: entry.type,
9702
+ tag: entry.tag,
9703
+ args: [`${source}: ${entry.message}`]
9704
+ });
9705
+ response.statusCode = 204;
9706
+ response.end();
9707
+ }
9404
9708
  // src/main.ts
9405
9709
  const UNPAIR_CHECK = 5e3;
9406
9710
  const TICKET_LIFETIME = 6e4;
9407
9711
  const openBody = z.object({ path: z.string() });
9712
+ const log = logger.withTag("server");
9408
9713
  const contentTypes = {
9409
9714
  ".html": "text/html; charset=utf-8",
9410
9715
  ".js": "text/javascript; charset=utf-8",
@@ -9440,6 +9745,7 @@ const browseTypes = new Map([
9440
9745
  const port = Number(listenPort());
9441
9746
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
9442
9747
  console.error(`invalid port ${args.port}`);
9748
+ log.error(`invalid port ${args.port}`);
9443
9749
  process.exit(1);
9444
9750
  }
9445
9751
  const address = `http://${args.host}:${port}`;
@@ -9454,12 +9760,15 @@ if (args.command === "unpair") {
9454
9760
  process.exit(0);
9455
9761
  }
9456
9762
  lockHome();
9763
+ log.info(`starting offsite ${args.command} with pid ${process.pid} on ${address}, dir ${args.dir}, auth ${args.auth ? "on" : "off"}`);
9457
9764
  process.env.CLAUDE_CONFIG_DIR = claudeBaseDir();
9458
9765
  installOpen(args.host, port);
9766
+ await backfillSessionProviders();
9459
9767
  watchSessions();
9460
9768
  refreshAll();
9461
9769
  if (findClaude() === null) {
9462
9770
  console.error("could not find claude on PATH; install Claude Code first");
9771
+ log.error("could not find claude on PATH; install Claude Code first");
9463
9772
  process.exit(1);
9464
9773
  }
9465
9774
  const publicDir = path.join(import.meta.dirname, "..", "..", "client", "dist");
@@ -9469,7 +9778,7 @@ const server = createServer((request, response) => {
9469
9778
  try {
9470
9779
  answerRequest(request, response);
9471
9780
  } catch (error) {
9472
- console.warn(`a request failed: ${error}`);
9781
+ log.warn("a request failed:", error);
9473
9782
  if (response.headersSent) {
9474
9783
  response.destroy();
9475
9784
  return;
@@ -9559,11 +9868,16 @@ server.listen(port, args.host, () => {
9559
9868
  console.log(`offsite is running at ${address}`);
9560
9869
  if (args.auth && args.pair) console.log("Run offsite pair to pair another browser");
9561
9870
  }
9562
- if (!args.auth) console.warn("auth is off; anyone who can reach this port can control this machine");
9871
+ log.info(`listening on ${address}`);
9872
+ if (!args.auth) {
9873
+ console.warn("auth is off; anyone who can reach this port can control this machine");
9874
+ log.warn("auth is off; anyone who can reach this port can control this machine");
9875
+ }
9563
9876
  });
9564
9877
  process.on("SIGINT", stop);
9565
9878
  process.on("SIGTERM", stop);
9566
- function stop() {
9879
+ function stop(signal) {
9880
+ log.info(`stopping on ${signal}`);
9567
9881
  stopAll();
9568
9882
  process.exit(0);
9569
9883
  }
@@ -9592,6 +9906,16 @@ function answerApi(pathname, request, response) {
9592
9906
  answerJson(response, { token });
9593
9907
  return;
9594
9908
  }
9909
+ if (pathname === "/api/logs") {
9910
+ const device = given === null ? null : findDevice(given);
9911
+ if (args.auth && device === null) {
9912
+ response.statusCode = 401;
9913
+ response.end("This browser needs pairing");
9914
+ return;
9915
+ }
9916
+ saveBrowserLogs(device, request, response);
9917
+ return;
9918
+ }
9595
9919
  if (pathname === "/api/ticket") {
9596
9920
  if (!args.auth) {
9597
9921
  answerJson(response, { ticket: null });