@frod.io/bridge 0.7.5 → 0.7.6

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 (3) hide show
  1. package/README.md +52 -6
  2. package/dist/cli.js +418 -102
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -145,9 +145,10 @@ says the same). When the desk is clear, it swaps `current` atomically, logs
145
145
  `bridge.updated {from, to}`, and restarts through the supervisor: it exits
146
146
  with code 75, a non-zero exit that launchd's `KeepAlive SuccessfulExit=false`
147
147
  and systemd's `Restart=on-failure` both bring straight back up — a clean exit
148
- (code 0) is deliberately a final goodbye, not a restart signal. Windows'
149
- scheduled task has no restart-on-failure, so there the bridge relaunches the
150
- new version itself, detached; if that fails, the next logon starts it.
148
+ (code 0) is deliberately a final goodbye, not a restart signal. On Windows the
149
+ logon task carries the same rule (`RestartOnFailure`, three tries a minute
150
+ apart), and the bridge also relaunches the new version itself, detached and
151
+ with no console window; if that fails, the next logon starts it.
151
152
 
152
153
  The keychain item is never touched by an update. You'll see `Updated to <v>`
153
154
  in `~/.frod/bridge.log` and the new version on the computer card. Two
@@ -258,12 +259,57 @@ Never-ask policy as the others. Each adapter is one file.
258
259
  unit is written correctly, but `systemctl --user` doesn't exist in a
259
260
  container, so the manual command is printed instead. Not yet proven on a
260
261
  real Linux machine.
261
- - **Windows** — a scheduled task *Frod.io bridge* at logon
262
- (`schtasks /Create`), also pointing at `current`. If it refuses, the exact
263
- command is printed. Not yet proven on a real Windows machine.
262
+ - **Windows** — a logon task *Frod.io bridge*, registered from a task
263
+ definition (`schtasks /Create /XML`, written to `~/.frod/bridge-task.xml`)
264
+ and pointing at `current` through `~/.frod/bridge-hidden.vbs`, a launcher
265
+ that starts the bridge with **no window** and appends its output to
266
+ `~/.frod/bridge.log`. The definition also says what the command line cannot:
267
+ hidden, no 72-hour execution limit, indifferent to battery, restart after a
268
+ crash. A real Windows *service* would run as SYSTEM while the runtime CLIs
269
+ are signed in as the person, so a logon task — the equivalent of the macOS
270
+ user agent — is deliberate. If the definition is refused, the plain
271
+ `schtasks /Create /TR` form is still tried (it works, and it shows a console
272
+ window), and the exact commands are printed. Not yet proven on a real
273
+ Windows machine.
274
+
275
+ Finding the runtimes on Windows needs no hand configuration either: `claude`,
276
+ `codex` and `grok` are looked for in npm's global folder (`%APPDATA%\npm`),
277
+ `~/.local/bin`, the per-tool folders and `%LOCALAPPDATA%\Programs`, with
278
+ `.exe`, `.cmd` and `.bat` tried in that order, then `where.exe`. An npm
279
+ `.cmd` shim cannot be spawned without a shell (CVE-2024-27980), so the shim is
280
+ read and the JavaScript entry it names is started with the bridge's own node —
281
+ which also keeps Claude Code's system prompt out of a cmd.exe command line and
282
+ its ~8 KB limit. `FROD_CLAUDE_PATH` / `FROD_CODEX_PATH` / `FROD_GROK_PATH`
283
+ still override everything, on every OS.
264
284
 
265
285
  ## Changelog
266
286
 
287
+ ### 0.7.6
288
+
289
+ - **Windows, without hand configuration.** The runtimes are found where Windows
290
+ actually puts them (npm's global folder, `~/.local/bin`, the per-tool folders,
291
+ then `where.exe`) instead of only Unix paths and a `/usr/bin/which` that does
292
+ not exist there — no `FROD_*_PATH` to set by hand. An npm `.cmd` shim is read
293
+ and its JavaScript entry started with our own node, because Node refuses to
294
+ spawn a `.cmd` without a shell and a cmd.exe command line cannot carry an
295
+ agent's system prompt.
296
+ - **Windows, without a window.** The logon task is registered from a task
297
+ definition and runs a launcher that starts the bridge hidden and logs to
298
+ `~/.frod/bridge.log` — so there is no console window a person can close and
299
+ kill the bridge with. The definition also turns off the 72-hour execution
300
+ limit and the stop-on-battery default, and turns on restart-after-crash. The
301
+ old command-line form remains the fallback. Not yet proven on real Windows
302
+ hardware.
303
+ - **A computer's desk is sized by its memory**, not fixed at two sessions: the
304
+ ceiling is what memory allows after a 4 GB reserve at 200 MB a session, and
305
+ the default is 30 or that ceiling, whichever is smaller. A computer joined
306
+ before this is raised once, and a number a person chose is never overruled.
307
+ - **Capacity reads Claude Code's Keychain item by account**, as Claude Code
308
+ writes it, so a Mac that is signed in is no longer reported signed out
309
+ because of a stray item; a failed read now says why.
310
+ - Per-turn usage on Codex and Grok Build is billed as the increment, not the
311
+ running session total.
312
+
267
313
  ### 0.7.4 — unreleased
268
314
 
269
315
  - `join` is runtime-neutral: it admits a computer when at least one of Claude
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @frod.io/bridge 0.7.5. One file; do not edit: the source is apps/bridge/src in the platform repository.
2
+ // @frod.io/bridge 0.7.6. One file; do not edit: the source is apps/bridge/src in the platform repository.
3
3
  import { createRequire as __createRequire } from 'node:module';
4
4
  const require = __createRequire(import.meta.url);
5
5
  var __create = Object.create;
@@ -3705,10 +3705,10 @@ var require_websocket_server = __commonJS({
3705
3705
  });
3706
3706
 
3707
3707
  // src/cli.ts
3708
- import { execFileSync as execFileSync7 } from "node:child_process";
3709
- import { existsSync as existsSync12, mkdirSync as mkdirSync9, realpathSync as realpathSync4 } from "node:fs";
3710
- import { homedir as homedir7, hostname as hostname2, platform as platform6 } from "node:os";
3711
- import { dirname as dirname5, join as join11, resolve as resolve6 } from "node:path";
3708
+ import { execFileSync as execFileSync8 } from "node:child_process";
3709
+ import { existsSync as existsSync14, mkdirSync as mkdirSync9, realpathSync as realpathSync4 } from "node:fs";
3710
+ import { homedir as homedir8, hostname as hostname2, platform as platform6 } from "node:os";
3711
+ import { dirname as dirname5, join as join12, resolve as resolve6 } from "node:path";
3712
3712
  import { fileURLToPath as fileURLToPath2 } from "node:url";
3713
3713
  import { spawn as spawn6 } from "node:child_process";
3714
3714
 
@@ -3904,7 +3904,7 @@ function leave(apiUrl, token) {
3904
3904
 
3905
3905
  // src/adapters/claude.ts
3906
3906
  import { spawn, execFileSync as execFileSync2 } from "node:child_process";
3907
- import { mkdirSync as mkdirSync2, existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
3907
+ import { mkdirSync as mkdirSync2, existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
3908
3908
  import { join as join5 } from "node:path";
3909
3909
 
3910
3910
  // src/adapters/claude-stream.ts
@@ -4184,6 +4184,44 @@ var log = {
4184
4184
  }
4185
4185
  };
4186
4186
 
4187
+ // src/win-launch.ts
4188
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
4189
+ import { win32 as winPath } from "node:path";
4190
+ function defaultLaunchIo() {
4191
+ return {
4192
+ platform: process.platform,
4193
+ env: process.env,
4194
+ node: process.execPath,
4195
+ exists: existsSync4,
4196
+ readFile: (path) => readFileSync4(path, "utf8")
4197
+ };
4198
+ }
4199
+ var BATCH = /\.(cmd|bat)$/i;
4200
+ var SHIM_ENTRY = /%~?dp0%?[\\/]+([^"\s]+\.(?:c|m)?js)/i;
4201
+ function shimEntry(shimPath, io = defaultLaunchIo()) {
4202
+ let text;
4203
+ try {
4204
+ text = io.readFile(shimPath);
4205
+ } catch {
4206
+ return null;
4207
+ }
4208
+ const found = SHIM_ENTRY.exec(text)?.[1];
4209
+ if (!found) return null;
4210
+ const entry = winPath.isAbsolute(found) ? found : winPath.resolve(winPath.dirname(shimPath), found);
4211
+ return io.exists(entry) ? entry : null;
4212
+ }
4213
+ function launchOf(exe, args = [], io = defaultLaunchIo()) {
4214
+ if (io.platform !== "win32" || !BATCH.test(exe)) return { command: exe, args, via: "direct" };
4215
+ const entry = shimEntry(exe, io);
4216
+ if (entry) return { command: io.node, args: [entry, ...args], via: "node-shim" };
4217
+ return {
4218
+ command: io.env.ComSpec ?? "cmd.exe",
4219
+ args: ["/d", "/s", "/c", exe, ...args],
4220
+ via: "cmd",
4221
+ note: `${exe} is a batch shim whose entry could not be read, so it starts through cmd.exe \u2014 where a long system prompt (more than about 8 KB of arguments) fails. Point the FROD_CLAUDE_PATH / FROD_CODEX_PATH / FROD_GROK_PATH override at a .exe if there is one.`
4222
+ };
4223
+ }
4224
+
4187
4225
  // src/permission-policy.ts
4188
4226
  import { resolve, isAbsolute, sep, dirname } from "node:path";
4189
4227
  import { realpathSync } from "node:fs";
@@ -4462,7 +4500,7 @@ function cleanEnv(base, extra = {}) {
4462
4500
  var CHILDREN_PATH = join5(FROD_DIR, "children.json");
4463
4501
  function readChildren() {
4464
4502
  try {
4465
- return existsSync4(CHILDREN_PATH) ? JSON.parse(readFileSync4(CHILDREN_PATH, "utf8")) : [];
4503
+ return existsSync5(CHILDREN_PATH) ? JSON.parse(readFileSync5(CHILDREN_PATH, "utf8")) : [];
4466
4504
  } catch {
4467
4505
  return [];
4468
4506
  }
@@ -4514,8 +4552,12 @@ var ClaudeAdapter = class {
4514
4552
  }
4515
4553
  /** `claude --version`, and whether the CLI says it is signed in. Never throws. */
4516
4554
  static check(claudePath, commands = {}) {
4517
- const versionCommand = commands.version ?? ((path) => execFileSync2(path, ["--version"], { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString());
4518
- const authStatusCommand = commands.authStatus ?? ((path) => execFileSync2(path, ["auth", "status"], { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString());
4555
+ const run = (path, args) => {
4556
+ const l = launchOf(path, args);
4557
+ return execFileSync2(l.command, l.args, { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString();
4558
+ };
4559
+ const versionCommand = commands.version ?? ((path) => run(path, ["--version"]));
4560
+ const authStatusCommand = commands.authStatus ?? ((path) => run(path, ["auth", "status"]));
4519
4561
  let version;
4520
4562
  try {
4521
4563
  version = versionCommand(claudePath).trim();
@@ -4532,7 +4574,7 @@ var ClaudeAdapter = class {
4532
4574
  }
4533
4575
  }
4534
4576
  async start(spec, o) {
4535
- if (!existsSync4(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
4577
+ if (!existsSync5(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
4536
4578
  const stored = spec.resume_session_id ? getStored(spec.session_id) : null;
4537
4579
  const resumeId = spec.resume_session_id && stored?.hostSessionId === spec.resume_session_id ? spec.resume_session_id : null;
4538
4580
  const live = {
@@ -4629,7 +4671,9 @@ var ClaudeAdapter = class {
4629
4671
  if (bytes > ARGV_LIMIT_BYTES) {
4630
4672
  throw new Error(`The agent's instructions are too long to start Claude Code on this computer (${Math.round(bytes / 1024)} KB of arguments; the limit here is about ${Math.round(ARGV_LIMIT_BYTES / 1024)} KB). Shorten its skills or system instructions.`);
4631
4673
  }
4632
- const child = spawn(this.opts.claudePath, args, {
4674
+ const launch = launchOf(this.opts.claudePath, args);
4675
+ if (launch.note) log.warn(launch.note);
4676
+ const child = spawn(launch.command, launch.args, {
4633
4677
  cwd: live.sessionDir,
4634
4678
  env: cleanEnv(process.env, { ...this.opts.env, ...live.secretEnv }),
4635
4679
  stdio: ["pipe", "pipe", "pipe"]
@@ -5101,9 +5145,13 @@ var CodexAdapter = class _CodexAdapter {
5101
5145
  }
5102
5146
  /** The version and whether somebody is signed in — the same shape ClaudeAdapter.check returns. */
5103
5147
  static check(codexPath, commands = {}) {
5104
- const versionCommand = commands.version ?? ((path) => execFileSync3(path, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString());
5148
+ const versionCommand = commands.version ?? ((path) => {
5149
+ const l = launchOf(path, ["--version"]);
5150
+ return execFileSync3(l.command, l.args, { stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString();
5151
+ });
5105
5152
  const loginStatusCommand = commands.loginStatus ?? ((path) => {
5106
- const res = spawnSync(path, ["login", "status"], { encoding: "utf8", timeout: 3e4 });
5153
+ const l = launchOf(path, ["login", "status"]);
5154
+ const res = spawnSync(l.command, l.args, { encoding: "utf8", timeout: 3e4 });
5107
5155
  if (res.error) throw res.error;
5108
5156
  return `${res.stdout ?? ""}${res.stderr ?? ""}`;
5109
5157
  });
@@ -5232,7 +5280,9 @@ var CodexAdapter = class _CodexAdapter {
5232
5280
  // The process and the wire
5233
5281
  // -------------------------------------------------------------------------
5234
5282
  spawnChild(live) {
5235
- const child = spawn2(this.opts.codexPath, ["app-server"], {
5283
+ const launch = launchOf(this.opts.codexPath, ["app-server"]);
5284
+ if (launch.note) log.warn(launch.note);
5285
+ const child = spawn2(launch.command, launch.args, {
5236
5286
  cwd: live.sessionDir,
5237
5287
  stdio: ["pipe", "pipe", "pipe"],
5238
5288
  env: { ...process.env }
@@ -5506,7 +5556,7 @@ function describeError(err) {
5506
5556
 
5507
5557
  // src/adapters/grok.ts
5508
5558
  import { spawn as spawn3, spawnSync as spawnSync2, execFileSync as execFileSync4 } from "node:child_process";
5509
- import { existsSync as existsSync5, mkdirSync as mkdirSync4 } from "node:fs";
5559
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "node:fs";
5510
5560
 
5511
5561
  // src/adapters/grok-spec.ts
5512
5562
  import { isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2, join as join6 } from "node:path";
@@ -5833,9 +5883,13 @@ var GrokAdapter = class _GrokAdapter {
5833
5883
  */
5834
5884
  static check(grokPath, commands = {}) {
5835
5885
  const env = { ...process.env, GROK_DISABLE_AUTOUPDATER: "1" };
5836
- const versionCommand = commands.version ?? ((path) => execFileSync4(path, ["--version"], { env, stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString());
5886
+ const versionCommand = commands.version ?? ((path) => {
5887
+ const l = launchOf(path, ["--version"]);
5888
+ return execFileSync4(l.command, l.args, { env, stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString();
5889
+ });
5837
5890
  const modelsCommand = commands.models ?? ((path) => {
5838
- const res = spawnSync2(path, ["models"], { env, encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
5891
+ const l = launchOf(path, ["models"]);
5892
+ const res = spawnSync2(l.command, l.args, { env, encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
5839
5893
  if (res.error) throw res.error;
5840
5894
  return `${res.stdout ?? ""}
5841
5895
  ${res.stderr ?? ""}`;
@@ -5886,7 +5940,7 @@ ${res.stderr ?? ""}`;
5886
5940
  stderrTail: []
5887
5941
  };
5888
5942
  this.live.set(spec.session_id, live);
5889
- if (!existsSync5(o.sessionDir)) mkdirSync4(o.sessionDir, { recursive: true, mode: 448 });
5943
+ if (!existsSync6(o.sessionDir)) mkdirSync4(o.sessionDir, { recursive: true, mode: 448 });
5890
5944
  this.spawnChild(live);
5891
5945
  let init;
5892
5946
  try {
@@ -6019,7 +6073,9 @@ ${res.stderr ?? ""}`;
6019
6073
  // The process and the wire
6020
6074
  // -------------------------------------------------------------------------
6021
6075
  spawnChild(live) {
6022
- const child = spawn3(this.opts.grokPath, grokArgs(live.spec), {
6076
+ const launch = launchOf(this.opts.grokPath, grokArgs(live.spec));
6077
+ if (launch.note) log.warn(launch.note);
6078
+ const child = spawn3(launch.command, launch.args, {
6023
6079
  cwd: live.sessionDir,
6024
6080
  stdio: ["pipe", "pipe", "pipe"],
6025
6081
  env: grokEnv(process.env)
@@ -6228,7 +6284,7 @@ import { hostname, platform as platform2 } from "node:os";
6228
6284
 
6229
6285
  // src/repo.ts
6230
6286
  import { spawn as spawn4 } from "node:child_process";
6231
- import { existsSync as existsSync6, mkdirSync as mkdirSync5, readdirSync, rmSync, statSync } from "node:fs";
6287
+ import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync, rmSync, statSync } from "node:fs";
6232
6288
  import { join as join7, resolve as resolve3 } from "node:path";
6233
6289
  var PLATFORM_COMMIT_ENV = {
6234
6290
  GIT_AUTHOR_NAME: "AgentOrchestrator",
@@ -6329,7 +6385,7 @@ async function canReach(gitUrl, timeoutMs = 3e4) {
6329
6385
  return { ok: false, detail: firstLine(r.stderr) || `git exited ${r.code}`, ms: r.ms };
6330
6386
  }
6331
6387
  async function originOf(dir) {
6332
- if (!existsSync6(join7(dir, ".git"))) return null;
6388
+ if (!existsSync7(join7(dir, ".git"))) return null;
6333
6389
  const r = await git(["-C", dir, "remote", "get-url", "origin"], void 0, { timeoutMs: 1e4 });
6334
6390
  return r.code === 0 ? r.stdout.trim() : null;
6335
6391
  }
@@ -6354,7 +6410,7 @@ async function prepareCheckout(repo, workdir, computerName, log2, opts = {}) {
6354
6410
  throw new Error(`${dir} already holds a different repository (${existing}); not touching it`);
6355
6411
  }
6356
6412
  if (!existing) {
6357
- if (existsSync6(dir)) rmSync(dir, { recursive: true, force: true });
6413
+ if (existsSync7(dir)) rmSync(dir, { recursive: true, force: true });
6358
6414
  mkdirSync5(resolve3(dir, ".."), { recursive: true, mode: 448 });
6359
6415
  const branchToClone = onOrigin ? repo.branch : repo.default_branch;
6360
6416
  const clone = await git(["clone", "--quiet", "--branch", branchToClone, "--single-branch", "--", repo.git_url, dir], void 0, { timeoutMs: 10 * 6e4 });
@@ -6430,7 +6486,7 @@ function pruneRunDirs(workdir, maxAgeMs = 7 * 24 * 60 * 6e4) {
6430
6486
  let removed = 0;
6431
6487
  for (const sub of ["runs"]) {
6432
6488
  const root = join7(workdir, sub);
6433
- if (!existsSync6(root)) continue;
6489
+ if (!existsSync7(root)) continue;
6434
6490
  for (const name of readdirSync(root)) {
6435
6491
  const p = resolve3(root, name);
6436
6492
  try {
@@ -6446,7 +6502,7 @@ function pruneRunDirs(workdir, maxAgeMs = 7 * 24 * 60 * 6e4) {
6446
6502
  }
6447
6503
 
6448
6504
  // src/repo-door.ts
6449
- import { existsSync as existsSync7 } from "node:fs";
6505
+ import { existsSync as existsSync8 } from "node:fs";
6450
6506
  var DoorCheckouts = class {
6451
6507
  bySession = /* @__PURE__ */ new Map();
6452
6508
  get(sessionId, repositoryId) {
@@ -6525,7 +6581,7 @@ async function handleRepoRequest(req, sessionId, ctx, checkouts) {
6525
6581
  }
6526
6582
  const known = checkouts.get(sessionId, req.repository.id);
6527
6583
  const at = known?.dir ?? dir;
6528
- if (!existsSync7(at)) throw new Error(`${req.repository.name} is not checked out on ${ctx.computerName} for this session \u2014 call checkout_repository first`);
6584
+ if (!existsSync8(at)) throw new Error(`${req.repository.name} is not checked out on ${ctx.computerName} for this session \u2014 call checkout_repository first`);
6529
6585
  if (req.action === "status") {
6530
6586
  const [branch, head, changed] = await Promise.all([
6531
6587
  git(["-C", at, "symbolic-ref", "--short", "-q", "HEAD"], void 0, { timeoutMs: 1e4 }),
@@ -6543,7 +6599,7 @@ async function handleRepoRequest(req, sessionId, ctx, checkouts) {
6543
6599
  }
6544
6600
 
6545
6601
  // src/workdir.ts
6546
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, realpathSync as realpathSync2 } from "node:fs";
6602
+ import { existsSync as existsSync9, mkdirSync as mkdirSync6, realpathSync as realpathSync2 } from "node:fs";
6547
6603
  import { homedir as homedir4 } from "node:os";
6548
6604
  import { dirname as dirname2, isAbsolute as isAbsolute3, relative, resolve as resolve4, sep as sep3 } from "node:path";
6549
6605
  function expandHome(p, home = homedir4()) {
@@ -6581,12 +6637,12 @@ function resolveWorkdir(requested, rule, opts = {}) {
6581
6637
  const realAnchor = realpathSync2(anchor);
6582
6638
  const realTarget = resolve4(realAnchor, relative(anchor, abs));
6583
6639
  if (!realRoots.some((root) => under(realTarget, root))) throw new Error(`${refusalWords(rule, home)} (the folder links to a place outside them)`);
6584
- if (opts.create !== false && !existsSync8(abs)) mkdirSync6(abs, { recursive: true, mode: 448 });
6585
- return existsSync8(abs) ? realpathSync2(abs) : realTarget;
6640
+ if (opts.create !== false && !existsSync9(abs)) mkdirSync6(abs, { recursive: true, mode: 448 });
6641
+ return existsSync9(abs) ? realpathSync2(abs) : realTarget;
6586
6642
  }
6587
6643
  function deepestExisting(abs) {
6588
6644
  let cur = abs;
6589
- while (!existsSync8(cur)) {
6645
+ while (!existsSync9(cur)) {
6590
6646
  const parent = dirname2(cur);
6591
6647
  if (parent === cur) return cur;
6592
6648
  cur = parent;
@@ -7076,12 +7132,12 @@ function plainWords(message) {
7076
7132
 
7077
7133
  // src/install.ts
7078
7134
  import { execFileSync as execFileSync6 } from "node:child_process";
7079
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, writeFileSync as writeFileSync6, unlinkSync as unlinkSync2 } from "node:fs";
7135
+ import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync6, unlinkSync as unlinkSync2 } from "node:fs";
7080
7136
  import { homedir as homedir5, platform as platform4, userInfo } from "node:os";
7081
- import { dirname as dirname4, join as join9 } from "node:path";
7137
+ import { dirname as dirname4, join as join9, win32 as winPath2 } from "node:path";
7082
7138
 
7083
7139
  // src/updater.ts
7084
- import { existsSync as existsSync9, mkdirSync as mkdirSync7, rmSync as rmSync2, readdirSync as readdirSync2, renameSync, symlinkSync, readFileSync as readFileSync5, lstatSync, realpathSync as realpathSync3, readlinkSync, cpSync, writeFileSync as writeFileSync5, statSync as statSync2 } from "node:fs";
7140
+ import { existsSync as existsSync10, mkdirSync as mkdirSync7, rmSync as rmSync2, readdirSync as readdirSync2, renameSync, symlinkSync, readFileSync as readFileSync6, lstatSync, realpathSync as realpathSync3, readlinkSync, cpSync, writeFileSync as writeFileSync5, statSync as statSync2 } from "node:fs";
7085
7141
  import { join as join8, dirname as dirname3, basename, resolve as resolve5, sep as sep4 } from "node:path";
7086
7142
  import { platform as platform3 } from "node:os";
7087
7143
  import { execFileSync as execFileSync5 } from "node:child_process";
@@ -7192,7 +7248,7 @@ function currentVersion(currentLink = CURRENT_LINK) {
7192
7248
  }
7193
7249
  function packageVersionAt(root) {
7194
7250
  try {
7195
- const pkg = JSON.parse(readFileSync5(join8(root, "package.json"), "utf8"));
7251
+ const pkg = JSON.parse(readFileSync6(join8(root, "package.json"), "utf8"));
7196
7252
  return pkg.name && pkg.version ? { name: pkg.name, version: pkg.version } : null;
7197
7253
  } catch {
7198
7254
  return null;
@@ -7204,14 +7260,14 @@ function stagePackage(loc, expectedVersion, versionsDir = VERSIONS_DIR) {
7204
7260
  if (pkg.version !== expectedVersion) return { ok: false, reason: `the package at ${loc.packageRoot} is ${pkg.version}, this bridge is ${expectedVersion}` };
7205
7261
  const dest = join8(versionsDir, pkg.version);
7206
7262
  if (loc.installedVersion === pkg.version && safeReal(loc.packageRoot) === safeReal(dest)) return { ok: true, dir: dest, copied: false };
7207
- if (!existsSync9(join8(loc.packageRoot, "dist", "cli.js"))) {
7263
+ if (!existsSync10(join8(loc.packageRoot, "dist", "cli.js"))) {
7208
7264
  return { ok: false, reason: `${loc.packageRoot} has no dist/cli.js \u2014 run \`npm run build -w @frod.io/bridge\` in the checkout, or run the published package: npx @frod.io/bridge@latest install` };
7209
7265
  }
7210
- if (existsSync9(join8(dest, "dist", "cli.js"))) return { ok: true, dir: dest, copied: false };
7266
+ if (existsSync10(join8(dest, "dist", "cli.js"))) return { ok: true, dir: dest, copied: false };
7211
7267
  const partial = `${dest}.partial`;
7212
7268
  rmSync2(partial, { recursive: true, force: true });
7213
7269
  mkdirSync7(partial, { recursive: true, mode: 493 });
7214
- for (const f of ["package.json", "README.md"]) if (existsSync9(join8(loc.packageRoot, f))) cpSync(join8(loc.packageRoot, f), join8(partial, f));
7270
+ for (const f of ["package.json", "README.md"]) if (existsSync10(join8(loc.packageRoot, f))) cpSync(join8(loc.packageRoot, f), join8(partial, f));
7215
7271
  cpSync(join8(loc.packageRoot, "dist"), join8(partial, "dist"), { recursive: true });
7216
7272
  rmSync2(dest, { recursive: true, force: true });
7217
7273
  renameSync(partial, dest);
@@ -7271,7 +7327,7 @@ async function stageRelease(release, opts = {}) {
7271
7327
  const pkg = packageVersionAt(root);
7272
7328
  if (!pkg || pkg.name !== "@frod.io/bridge") return refuse("the tarball is not the @frod.io/bridge package");
7273
7329
  if (pkg.version !== v) return refuse(`the tarball says it is ${pkg.version}, not ${v}`);
7274
- if (!existsSync9(join8(root, "dist", "cli.js"))) return refuse("the tarball has no dist/cli.js");
7330
+ if (!existsSync10(join8(root, "dist", "cli.js"))) return refuse("the tarball has no dist/cli.js");
7275
7331
  try {
7276
7332
  rmSync2(dest, { recursive: true, force: true });
7277
7333
  renameSync(root, dest);
@@ -7283,7 +7339,7 @@ async function stageRelease(release, opts = {}) {
7283
7339
  }
7284
7340
  function swapCurrent(version, versionsDir = VERSIONS_DIR, currentLink = CURRENT_LINK) {
7285
7341
  const target = join8(versionsDir, version);
7286
- if (!existsSync9(join8(target, "dist", "cli.js"))) throw new Error(`${target} has no dist/cli.js \u2014 not swapping`);
7342
+ if (!existsSync10(join8(target, "dist", "cli.js"))) throw new Error(`${target} has no dist/cli.js \u2014 not swapping`);
7287
7343
  mkdirSync7(dirname3(currentLink), { recursive: true });
7288
7344
  const tmp = `${currentLink}.tmp-${process.pid}`;
7289
7345
  rmSync2(tmp, { recursive: true, force: true });
@@ -7298,7 +7354,7 @@ function swapCurrent(version, versionsDir = VERSIONS_DIR, currentLink = CURRENT_
7298
7354
  }
7299
7355
  function pruneVersions(opts = {}) {
7300
7356
  const versionsDir = opts.versionsDir ?? VERSIONS_DIR;
7301
- if (!existsSync9(versionsDir)) return [];
7357
+ if (!existsSync10(versionsDir)) return [];
7302
7358
  const keepSet = new Set([opts.running ?? "", currentVersion(opts.currentLink ?? CURRENT_LINK) ?? ""].filter(Boolean));
7303
7359
  const entries = readdirSync2(versionsDir);
7304
7360
  const versions = entries.filter((e) => SEMVER.test(e)).sort((a, b) => -(compareSemver(a, b) ?? 0));
@@ -7460,17 +7516,22 @@ function xml(s) {
7460
7516
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7461
7517
  }
7462
7518
  function defaultTarget() {
7463
- return { node: process.execPath, cli: join9(CURRENT_LINK, "dist", "cli.js"), workdir: BRIDGE_DIR, home: homedir5(), frodDir: FROD_DIR, logPath: LOG_PATH };
7519
+ return { node: process.execPath, cli: join9(CURRENT_LINK, "dist", "cli.js"), workdir: BRIDGE_DIR, home: homedir5(), frodDir: FROD_DIR, logPath: LOG_PATH, user: windowsUser() };
7520
+ }
7521
+ function windowsUser(env = process.env) {
7522
+ const name = env.USERNAME ?? userInfo().username;
7523
+ const domain = env.USERDOMAIN;
7524
+ return domain ? `${domain}\\${name}` : name;
7464
7525
  }
7465
7526
  var realIo = {
7466
7527
  exec: (cmd, args) => {
7467
7528
  execFileSync6(cmd, args, { stdio: "pipe" });
7468
7529
  },
7469
- writeFile: (path, content, mode) => {
7470
- writeFileSync6(path, content, { encoding: "utf8", mode });
7530
+ writeFile: (path, content, mode, encoding = "utf8") => {
7531
+ writeFileSync6(path, content, { encoding, mode });
7471
7532
  },
7472
7533
  mkdir: (dir) => {
7473
- if (!existsSync10(dir)) mkdirSync8(dir, { recursive: true });
7534
+ if (!existsSync11(dir)) mkdirSync8(dir, { recursive: true });
7474
7535
  }
7475
7536
  };
7476
7537
  function renderLaunchdPlist(t) {
@@ -7544,7 +7605,7 @@ function uninstallMacOS() {
7544
7605
  execFileSync6("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { stdio: "ignore" });
7545
7606
  } catch {
7546
7607
  }
7547
- if (existsSync10(plistPath())) {
7608
+ if (existsSync11(plistPath())) {
7548
7609
  unlinkSync2(plistPath());
7549
7610
  return { removed: true };
7550
7611
  }
@@ -7621,12 +7682,96 @@ function uninstallLinux(t = defaultTarget(), io = realIo) {
7621
7682
  } catch (err) {
7622
7683
  detail = `systemctl: ${err instanceof Error ? err.message : String(err)}`;
7623
7684
  }
7624
- if (existsSync10(path)) {
7685
+ if (existsSync11(path)) {
7625
7686
  unlinkSync2(path);
7626
7687
  return { removed: true, detail };
7627
7688
  }
7628
7689
  return { removed: false, detail };
7629
7690
  }
7691
+ function hiddenLauncherPath(t) {
7692
+ return winPath2.join(t.frodDir, "bridge-hidden.vbs");
7693
+ }
7694
+ function windowsTaskXmlPath(t) {
7695
+ return winPath2.join(t.frodDir, "bridge-task.xml");
7696
+ }
7697
+ function vbString(s) {
7698
+ return `"${s.replace(/"/g, '""')}"`;
7699
+ }
7700
+ function renderHiddenLauncher(t) {
7701
+ const command = `cmd /c "${'"'}${t.node}${'"'} ${'"'}${t.cli}${'"'} run >> ${'"'}${t.logPath}${'"'} 2>&1"`;
7702
+ return [
7703
+ `' Frod.io bridge \u2014 started at logon by the scheduled task "` + WINDOWS_TASK + '".',
7704
+ "' No window: the bridge is not something a person can close by accident.",
7705
+ "Dim command",
7706
+ `command = ${vbString(command)}`,
7707
+ 'CreateObject("WScript.Shell").Run command, 0, False',
7708
+ ""
7709
+ ].join("\r\n");
7710
+ }
7711
+ function system32(name, env) {
7712
+ return winPath2.join(env.SystemRoot ?? "C:\\Windows", "System32", name);
7713
+ }
7714
+ function windowsAction(t, env = process.env) {
7715
+ return { command: system32("wscript.exe", env), args: `//B //NoLogo "${hiddenLauncherPath(t)}"` };
7716
+ }
7717
+ function renderWindowsTaskXml(t, env = process.env) {
7718
+ const action = windowsAction(t, env);
7719
+ const user = t.user ?? "BUILTIN\\Users";
7720
+ return `\uFEFF<?xml version="1.0" encoding="UTF-16"?>
7721
+ <Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
7722
+ <RegistrationInfo>
7723
+ <Description>${xml("Frod.io bridge \u2014 hosts your agents' sessions on this computer. Starts at logon, runs with no window.")}</Description>
7724
+ <URI>\\${xml(WINDOWS_TASK)}</URI>
7725
+ </RegistrationInfo>
7726
+ <Triggers>
7727
+ <LogonTrigger>
7728
+ <Enabled>true</Enabled>
7729
+ <UserId>${xml(user)}</UserId>
7730
+ </LogonTrigger>
7731
+ </Triggers>
7732
+ <Principals>
7733
+ <Principal id="Author">
7734
+ <UserId>${xml(user)}</UserId>
7735
+ <LogonType>InteractiveToken</LogonType>
7736
+ <RunLevel>LeastPrivilege</RunLevel>
7737
+ </Principal>
7738
+ </Principals>
7739
+ <Settings>
7740
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
7741
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
7742
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
7743
+ <AllowHardTerminate>true</AllowHardTerminate>
7744
+ <StartWhenAvailable>true</StartWhenAvailable>
7745
+ <RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
7746
+ <IdleSettings>
7747
+ <StopOnIdleEnd>false</StopOnIdleEnd>
7748
+ <RestartOnIdle>false</RestartOnIdle>
7749
+ </IdleSettings>
7750
+ <AllowStartOnDemand>true</AllowStartOnDemand>
7751
+ <Enabled>true</Enabled>
7752
+ <Hidden>true</Hidden>
7753
+ <RunOnlyIfIdle>false</RunOnlyIfIdle>
7754
+ <WakeToRun>false</WakeToRun>
7755
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
7756
+ <Priority>7</Priority>
7757
+ <RestartOnFailure>
7758
+ <Interval>PT1M</Interval>
7759
+ <Count>3</Count>
7760
+ </RestartOnFailure>
7761
+ </Settings>
7762
+ <Actions Context="Author">
7763
+ <Exec>
7764
+ <Command>${xml(action.command)}</Command>
7765
+ <Arguments>${xml(action.args)}</Arguments>
7766
+ <WorkingDirectory>${xml(t.workdir)}</WorkingDirectory>
7767
+ </Exec>
7768
+ </Actions>
7769
+ </Task>
7770
+ `;
7771
+ }
7772
+ function renderSchtasksXmlArgs(t) {
7773
+ return ["/Create", "/F", "/TN", WINDOWS_TASK, "/XML", windowsTaskXmlPath(t)];
7774
+ }
7630
7775
  function renderSchtasksArgs(t) {
7631
7776
  const tr = `"${t.node}" "${t.cli}" run`;
7632
7777
  return ["/Create", "/F", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", tr];
@@ -7634,26 +7779,60 @@ function renderSchtasksArgs(t) {
7634
7779
  function windowsManualPath(t) {
7635
7780
  return [
7636
7781
  "To run it at logon, in a command prompt:",
7637
- ` schtasks ${renderSchtasksArgs(t).map((a) => /\s/.test(a) && !a.startsWith('"') ? `"${a}"` : a).join(" ")}`,
7782
+ ` schtasks /Create /F /TN "${WINDOWS_TASK}" /XML "${windowsTaskXmlPath(t)}"`,
7638
7783
  ` schtasks /Run /TN "${WINDOWS_TASK}"`,
7639
- `Restart on failure: schtasks cannot set it from the command line \u2014 open Task Scheduler \u2192 "${WINDOWS_TASK}" \u2192 Settings \u2192 "If the task fails, restart every 1 minute" if you want it. A self-update relaunches the bridge itself; if that fails, the next logon starts it.`,
7640
- `Logs: ${t.logPath} is not written by the Task Scheduler; run \`node "${t.cli}" run\` in a terminal to watch it.`
7784
+ `The task definition and the launcher it runs are written to ${windowsTaskXmlPath(t)} and ${hiddenLauncherPath(t)} whatever happens, so you can register them by hand or open Task Scheduler and import the XML.`,
7785
+ `Without that definition the plain form still works \u2014 schtasks ${renderSchtasksArgs(t).map((a) => /\s/.test(a) && !a.startsWith('"') ? `"${a}"` : a).join(" ")} \u2014 but it shows a console window that ends the bridge when closed, it has no restart on failure, and Windows stops it after 72 hours or when the machine goes on battery.`,
7786
+ `Logs: ${t.logPath}, written by the launcher. A self-update relaunches the bridge itself; if that fails, the next logon starts it.`
7641
7787
  ].join("\n");
7642
7788
  }
7643
- function installWindows(t = defaultTarget(), io = realIo) {
7789
+ function installWindows(t = defaultTarget(), io = realIo, env = process.env) {
7644
7790
  const manual = windowsManualPath(t);
7791
+ io.mkdir(t.frodDir);
7792
+ let wrote = true;
7793
+ try {
7794
+ io.writeFile(hiddenLauncherPath(t), renderHiddenLauncher(t), 420);
7795
+ io.writeFile(windowsTaskXmlPath(t), renderWindowsTaskXml(t, env), 420, "utf16le");
7796
+ } catch {
7797
+ wrote = false;
7798
+ }
7799
+ if (wrote) {
7800
+ try {
7801
+ io.exec("schtasks", renderSchtasksXmlArgs(t));
7802
+ try {
7803
+ io.exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
7804
+ } catch {
7805
+ }
7806
+ return { loaded: true, hidden: true, detail: `scheduled task "${WINDOWS_TASK}" created (at logon, no window, restarts after a crash)`, manual };
7807
+ } catch {
7808
+ }
7809
+ }
7645
7810
  try {
7646
7811
  io.exec("schtasks", renderSchtasksArgs(t));
7647
7812
  try {
7648
7813
  io.exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
7649
7814
  } catch {
7650
7815
  }
7651
- return { loaded: true, detail: `scheduled task "${WINDOWS_TASK}" created (at logon)`, manual };
7816
+ return { loaded: true, hidden: false, detail: `scheduled task "${WINDOWS_TASK}" created (at logon) \u2014 the task definition was refused, so it runs in a console window`, manual };
7652
7817
  } catch (err) {
7653
- return { loaded: false, detail: `schtasks refused: ${err instanceof Error ? err.message : String(err)}`, manual };
7818
+ return { loaded: false, hidden: false, detail: `schtasks refused: ${err instanceof Error ? err.message : String(err)}`, manual };
7819
+ }
7820
+ }
7821
+ function windowsTaskLoaded(io = realIo) {
7822
+ try {
7823
+ io.exec("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
7824
+ return true;
7825
+ } catch {
7826
+ return false;
7654
7827
  }
7655
7828
  }
7656
- function uninstallWindows(io = realIo) {
7829
+ function uninstallWindows(io = realIo, t = defaultTarget()) {
7830
+ for (const path of [hiddenLauncherPath(t), windowsTaskXmlPath(t)]) {
7831
+ try {
7832
+ if (existsSync11(path)) unlinkSync2(path);
7833
+ } catch {
7834
+ }
7835
+ }
7657
7836
  try {
7658
7837
  io.exec("schtasks", ["/Delete", "/F", "/TN", WINDOWS_TASK]);
7659
7838
  return { removed: true, detail: "task deleted" };
@@ -7665,11 +7844,84 @@ function installStub(t = defaultTarget()) {
7665
7844
  return `Automatic install is not available on ${platform4()}. Run: node "${t.cli}" run`;
7666
7845
  }
7667
7846
 
7847
+ // src/runtime-paths.ts
7848
+ import { execFileSync as execFileSync7 } from "node:child_process";
7849
+ import { existsSync as existsSync12 } from "node:fs";
7850
+ import { homedir as homedir6, platform as osPlatform } from "node:os";
7851
+ import { join as join10, win32 as winPath3 } from "node:path";
7852
+ var ENV_OVERRIDE = {
7853
+ claude: "FROD_CLAUDE_PATH",
7854
+ codex: "FROD_CODEX_PATH",
7855
+ grok: "FROD_GROK_PATH"
7856
+ };
7857
+ var WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat"];
7858
+ function posixCandidates(name, home) {
7859
+ const dirs = [join10(home, ".local", "bin"), "/opt/homebrew/bin", "/usr/local/bin"];
7860
+ if (name === "grok") dirs.splice(1, 0, join10(home, ".grok", "bin"));
7861
+ return dirs.map((d) => join10(d, name));
7862
+ }
7863
+ function windowsCandidates(name, home, env) {
7864
+ const appData = env.APPDATA ?? winPath3.join(home, "AppData", "Roaming");
7865
+ const localAppData = env.LOCALAPPDATA ?? winPath3.join(home, "AppData", "Local");
7866
+ const dirs = [
7867
+ winPath3.join(appData, "npm"),
7868
+ winPath3.join(localAppData, "npm"),
7869
+ winPath3.join(home, ".local", "bin"),
7870
+ winPath3.join(home, `.${name}`, "bin"),
7871
+ winPath3.join(localAppData, "Programs", name),
7872
+ winPath3.join(localAppData, name, "bin")
7873
+ ];
7874
+ const out = [];
7875
+ for (const dir of dirs) for (const ext of WINDOWS_EXTENSIONS) out.push(winPath3.join(dir, `${name}${ext}`));
7876
+ return out;
7877
+ }
7878
+ function findOnWindowsPath(command, env = process.env) {
7879
+ const whereExe = winPath3.join(env.SystemRoot ?? "C:\\Windows", "System32", "where.exe");
7880
+ for (const tool of [whereExe, "where.exe"]) {
7881
+ try {
7882
+ const lines = execFileSync7(tool, [command], { stdio: ["ignore", "pipe", "ignore"], timeout: 1e4 }).toString().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
7883
+ if (lines.length === 0) return null;
7884
+ for (const ext of WINDOWS_EXTENSIONS) {
7885
+ const hit = lines.find((l) => l.toLowerCase().endsWith(ext));
7886
+ if (hit) return hit;
7887
+ }
7888
+ return lines[0];
7889
+ } catch {
7890
+ }
7891
+ }
7892
+ return null;
7893
+ }
7894
+ function findOnPosixPath(command) {
7895
+ try {
7896
+ const found = execFileSync7("/usr/bin/which", [command], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7897
+ return found || null;
7898
+ } catch {
7899
+ return null;
7900
+ }
7901
+ }
7902
+ function defaultLookup() {
7903
+ const platform7 = osPlatform();
7904
+ return {
7905
+ platform: platform7,
7906
+ env: process.env,
7907
+ home: homedir6(),
7908
+ exists: existsSync12,
7909
+ onPath: (command) => platform7 === "win32" ? findOnWindowsPath(command, process.env) : findOnPosixPath(command)
7910
+ };
7911
+ }
7912
+ function findRuntime(name, lookup = defaultLookup()) {
7913
+ const override = lookup.env[ENV_OVERRIDE[name]];
7914
+ if (override && lookup.exists(override)) return override;
7915
+ const candidates = lookup.platform === "win32" ? windowsCandidates(name, lookup.home, lookup.env) : posixCandidates(name, lookup.home);
7916
+ for (const c of candidates) if (lookup.exists(c)) return c;
7917
+ return lookup.onPath(name);
7918
+ }
7919
+
7668
7920
  // src/capacity.ts
7669
7921
  import { execFile, spawn as spawn5 } from "node:child_process";
7670
- import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync6 } from "node:fs";
7671
- import { homedir as homedir6, platform as platform5, tmpdir as tmpdir2 } from "node:os";
7672
- import { join as join10 } from "node:path";
7922
+ import { existsSync as existsSync13, mkdtempSync, readFileSync as readFileSync7 } from "node:fs";
7923
+ import { homedir as homedir7, platform as platform5, tmpdir as tmpdir2, userInfo as userInfo2 } from "node:os";
7924
+ import { join as join11 } from "node:path";
7673
7925
  import { createInterface } from "node:readline";
7674
7926
  var SAMPLE_INTERVAL_MS = 30 * 6e4;
7675
7927
  var READER_TIMEOUT_MS = 25e3;
@@ -7679,9 +7931,27 @@ var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
7679
7931
  var CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
7680
7932
  var CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
7681
7933
  var APPROVAL_WORDS = "Needs a one-time approval on this computer: allow the bridge to read Claude Code's sign-in in the Keychain";
7934
+ var SECURITY_ITEM_NOT_FOUND = 44;
7935
+ function securityRunner(timeoutMs) {
7936
+ return (args) => new Promise((resolve7) => {
7937
+ execFile("security", args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout) => {
7938
+ if (err) {
7939
+ const e = err;
7940
+ resolve7({ ok: false, code: typeof e.code === "number" ? e.code : null, killed: e.killed === true || e.signal === "SIGTERM" });
7941
+ return;
7942
+ }
7943
+ resolve7({ ok: true, stdout: String(stdout) });
7944
+ });
7945
+ });
7946
+ }
7947
+ async function keychainAccountOf(run) {
7948
+ const meta = await run(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE]);
7949
+ if (!meta.ok) return null;
7950
+ return /"acct"<blob>="([^"]*)"/.exec(meta.stdout)?.[1] ?? null;
7951
+ }
7682
7952
  function readClaudeCredential(deps = {}) {
7683
7953
  const os = deps.platform ?? platform5();
7684
- const home = deps.home ?? homedir6();
7954
+ const home = deps.home ?? homedir7();
7685
7955
  const parse = (raw) => {
7686
7956
  try {
7687
7957
  const j = JSON.parse(raw);
@@ -7693,25 +7963,34 @@ function readClaudeCredential(deps = {}) {
7693
7963
  }
7694
7964
  };
7695
7965
  if (os === "darwin") {
7696
- return new Promise((resolve7) => {
7697
- execFile("security", ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], { timeout: deps.timeoutMs ?? KEYCHAIN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout) => {
7698
- if (err) {
7699
- const killed = err.killed || err.signal === "SIGTERM";
7700
- resolve7({ problem: killed ? APPROVAL_WORDS : "Claude Code is not signed in on this computer" });
7701
- return;
7702
- }
7703
- resolve7(parse(String(stdout).trim()));
7704
- });
7705
- });
7966
+ return readClaudeKeychain(deps.security ?? securityRunner(deps.timeoutMs ?? KEYCHAIN_TIMEOUT_MS), deps.account ?? userInfo2().username, parse);
7706
7967
  }
7707
- const file = join10(home, ".claude", ".credentials.json");
7708
- if (!existsSync11(file)) return Promise.resolve({ problem: "Claude Code is not signed in on this computer" });
7968
+ const file = join11(home, ".claude", ".credentials.json");
7969
+ if (!existsSync13(file)) return Promise.resolve({ problem: "Claude Code is not signed in on this computer" });
7709
7970
  try {
7710
- return Promise.resolve(parse(readFileSync6(file, "utf8")));
7971
+ return Promise.resolve(parse(readFileSync7(file, "utf8")));
7711
7972
  } catch {
7712
7973
  return Promise.resolve({ problem: "Claude Code's sign-in could not be read on this computer" });
7713
7974
  }
7714
7975
  }
7976
+ async function readClaudeKeychain(run, account, parse) {
7977
+ let answer = await run(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-a", account, "-w"]);
7978
+ let scoped = true;
7979
+ if (!answer.ok && !answer.killed && answer.code === SECURITY_ITEM_NOT_FOUND) {
7980
+ answer = await run(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"]);
7981
+ scoped = false;
7982
+ }
7983
+ if (!answer.ok) {
7984
+ if (answer.killed) return { problem: APPROVAL_WORDS };
7985
+ if (answer.code === SECURITY_ITEM_NOT_FOUND) return { problem: "Claude Code is not signed in on this computer" };
7986
+ return { problem: `Claude Code's sign-in could not be read from the Keychain (security exit ${answer.code ?? "unknown"}) \u2014 it may need a one-time approval on this computer` };
7987
+ }
7988
+ const cred = parse(answer.stdout.trim());
7989
+ if (!("problem" in cred) || scoped) return cred;
7990
+ const other = await keychainAccountOf(run);
7991
+ if (!other || other === account) return cred;
7992
+ return { problem: `The Keychain's Claude Code entry on this computer belongs to "${other}", not to ${account}, and carries no sign-in \u2014 remove that entry if it is not yours` };
7993
+ }
7715
7994
  function claudeTierWords(profile) {
7716
7995
  const org = profile?.organization;
7717
7996
  const tier = org?.rate_limit_tier ?? "";
@@ -7846,7 +8125,7 @@ function parseGrokBilling(result) {
7846
8125
  }
7847
8126
  async function readGrokCapacity(grokPath, deps = {}) {
7848
8127
  const ask = deps.ask ?? askChild;
7849
- const cwd = deps.cwd ?? mkdtempSync(join10(tmpdir2(), "frod-capacity-"));
8128
+ const cwd = deps.cwd ?? mkdtempSync(join11(tmpdir2(), "frod-capacity-"));
7850
8129
  const r = await ask({
7851
8130
  command: grokPath,
7852
8131
  args: ["agent", "--no-leader", "stdio"],
@@ -7950,10 +8229,56 @@ function samplesInWords(samples) {
7950
8229
  return samples.map((s) => `${label[s.provider]} ${s.usedPct === null ? `(${s.words ?? "no number"})` : `${Math.round(s.usedPct)}% of ${win[s.window]}`}`).join(" \xB7 ") || "nothing to report";
7951
8230
  }
7952
8231
 
8232
+ // src/session-cap.ts
8233
+ import { totalmem } from "node:os";
8234
+ var SESSION_RAM_MB = 200;
8235
+ var RESERVED_RAM_MB = 4 * 1024;
8236
+ var HARD_MAX_SESSIONS = 100;
8237
+ var DEFAULT_MAX_SESSIONS = 30;
8238
+ var LEGACY_DEFAULT_MAX_SESSIONS = 2;
8239
+ function sessionCeiling(totalMemBytes = totalmem()) {
8240
+ const usableMb = Math.floor(totalMemBytes / (1024 * 1024)) - RESERVED_RAM_MB;
8241
+ return Math.max(1, Math.min(HARD_MAX_SESSIONS, Math.floor(usableMb / SESSION_RAM_MB)));
8242
+ }
8243
+ function defaultMaxSessions(totalMemBytes) {
8244
+ return Math.min(DEFAULT_MAX_SESSIONS, sessionCeiling(totalMemBytes));
8245
+ }
8246
+ function resolveMaxSessions(requested, totalMemBytes) {
8247
+ const ceiling = sessionCeiling(totalMemBytes);
8248
+ if (requested === null || requested === void 0 || !Number.isFinite(requested)) {
8249
+ return { value: defaultMaxSessions(totalMemBytes), ceiling, clamped: false };
8250
+ }
8251
+ const asked = Math.max(1, Math.floor(requested));
8252
+ return { value: Math.min(asked, ceiling), ceiling, clamped: asked > ceiling };
8253
+ }
8254
+ function clampWords(asked, cap) {
8255
+ if (!cap.clamped) return null;
8256
+ return `${asked} sessions were asked for; this computer's memory carries ${cap.ceiling} at ${SESSION_RAM_MB} MB a session. Using ${cap.value}.`;
8257
+ }
8258
+ function applySessionCap(cfg, totalMemBytes) {
8259
+ const words = [];
8260
+ let write2 = false;
8261
+ if (cfg.maxSessionsChosen === void 0 && cfg.maxSessions <= LEGACY_DEFAULT_MAX_SESSIONS) {
8262
+ const raised = defaultMaxSessions(totalMemBytes);
8263
+ if (raised > cfg.maxSessions) {
8264
+ words.push(`This computer offered ${cfg.maxSessions} sessions at a time \u2014 the old default. Raising it to ${raised}, which its memory carries at ${SESSION_RAM_MB} MB a session. Set "maxSessions" in the settings file to choose your own; this happens once.`);
8265
+ cfg.maxSessions = raised;
8266
+ }
8267
+ cfg.maxSessionsChosen = false;
8268
+ write2 = true;
8269
+ }
8270
+ const cap = resolveMaxSessions(cfg.maxSessions, totalMemBytes);
8271
+ if (cap.clamped) {
8272
+ words.push(`The settings file asks for ${cfg.maxSessions} sessions; this computer's memory carries ${cap.ceiling} at ${SESSION_RAM_MB} MB a session. Using ${cap.value} \u2014 the file is left as it is.`);
8273
+ cfg.maxSessions = cap.value;
8274
+ }
8275
+ return { write: write2, words };
8276
+ }
8277
+
7953
8278
  // package.json
7954
8279
  var package_default = {
7955
8280
  name: "@frod.io/bridge",
7956
- version: "0.7.5",
8281
+ version: "0.7.6",
7957
8282
  type: "module",
7958
8283
  description: "Your computer is a desk: hosts a Frod.io agent's sessions with your own Claude Code, Codex or Grok Build. Installs and updates itself.",
7959
8284
  license: "UNLICENSED",
@@ -8005,33 +8330,13 @@ function arg(flag, argv) {
8005
8330
  return i >= 0 ? argv[i + 1] : void 0;
8006
8331
  }
8007
8332
  function findClaude() {
8008
- const candidates = [process.env.FROD_CLAUDE_PATH, join11(homedir7(), ".local", "bin", "claude"), "/opt/homebrew/bin/claude", "/usr/local/bin/claude"].filter((c) => !!c);
8009
- for (const c of candidates) if (existsSync12(c)) return c;
8010
- try {
8011
- return execFileSync7("/usr/bin/which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
8012
- } catch {
8013
- }
8014
- return null;
8333
+ return findRuntime("claude");
8015
8334
  }
8016
8335
  function findCodex() {
8017
- const candidates = [process.env.FROD_CODEX_PATH, join11(homedir7(), ".local", "bin", "codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex"].filter((c) => !!c);
8018
- for (const c of candidates) if (existsSync12(c)) return c;
8019
- try {
8020
- const found = execFileSync7("/usr/bin/which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
8021
- return found || null;
8022
- } catch {
8023
- return null;
8024
- }
8336
+ return findRuntime("codex");
8025
8337
  }
8026
8338
  function findGrok() {
8027
- const candidates = [process.env.FROD_GROK_PATH, join11(homedir7(), ".local", "bin", "grok"), join11(homedir7(), ".grok", "bin", "grok"), "/opt/homebrew/bin/grok", "/usr/local/bin/grok"].filter((c) => !!c);
8028
- for (const c of candidates) if (existsSync12(c)) return c;
8029
- try {
8030
- const found = execFileSync7("/usr/bin/which", ["grok"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
8031
- return found || null;
8032
- } catch {
8033
- return null;
8034
- }
8339
+ return findRuntime("grok");
8035
8340
  }
8036
8341
  var RUNTIME_ORDER = ["claude", "codex", "grok"];
8037
8342
  var RUNTIME_LABELS = { claude: "Claude Code", codex: "Codex", grok: "Grok Build" };
@@ -8101,7 +8406,7 @@ async function cmdJoin(argv, overrides = {}) {
8101
8406
  join: join3,
8102
8407
  storeToken,
8103
8408
  ensureWorkdir: (path) => {
8104
- if (!existsSync12(path)) mkdirSync9(path, { recursive: true, mode: 448 });
8409
+ if (!existsSync14(path)) mkdirSync9(path, { recursive: true, mode: 448 });
8105
8410
  },
8106
8411
  writeConfig,
8107
8412
  reportGitAccess,
@@ -8117,8 +8422,10 @@ async function cmdJoin(argv, overrides = {}) {
8117
8422
  if (refusal) throw new Error(refusal);
8118
8423
  const safeApi = assertSafeEndpoint(apiUrl);
8119
8424
  const name = arg("--name", argv) ?? defaultName();
8120
- const workdir = resolve6(arg("--workdir", argv) ?? join11(homedir7(), "frod-work"));
8121
- const maxSessions = Math.max(1, Math.min(20, Number(arg("--max-sessions", argv) ?? 2)));
8425
+ const workdir = resolve6(arg("--workdir", argv) ?? join12(homedir8(), "frod-work"));
8426
+ const askedSessions = arg("--max-sessions", argv);
8427
+ const cap = resolveMaxSessions(askedSessions === void 0 ? null : Number(askedSessions));
8428
+ const maxSessions = cap.value;
8122
8429
  const probes = probeAllRuntimes(deps.probes);
8123
8430
  for (const runtime of RUNTIME_ORDER) {
8124
8431
  const probe = probes[runtime];
@@ -8139,6 +8446,7 @@ async function cmdJoin(argv, overrides = {}) {
8139
8446
  name,
8140
8447
  workdir,
8141
8448
  maxSessions,
8449
+ maxSessionsChosen: askedSessions !== void 0,
8142
8450
  adapters,
8143
8451
  claudePath: isReady(probes.claude) ? probes.claude.path : null,
8144
8452
  codexPath: isReady(probes.codex) ? probes.codex.path : void 0,
@@ -8153,7 +8461,9 @@ async function cmdJoin(argv, overrides = {}) {
8153
8461
  log.info(`Joined. This computer is now "${name}" \u2014 ${joined.person?.name ?? "your"} computer in ${joined.workspaceName ?? joined.workspaceId} \u2014 hosting ${hostedAgentsWords(cfg)}.`);
8154
8462
  for (const b of cfg.bindings ?? []) log.info(` ${b.agentName}: ${bindingWords(b)}`);
8155
8463
  log.info(`Token kept in ${store === "keychain" ? "the OS keychain" : `a 0600 file under ${dirname5(CONFIG_PATH)} (no keychain available on this computer \u2014 stated, not hidden)`}; it expires ${joined.expiresAt.slice(0, 10)}.`);
8156
- log.info(`Settings: ${CONFIG_PATH}. Sessions work under ${workdir}/sessions. Up to ${maxSessions} at a time, across every agent this computer hosts.`);
8464
+ const clamped = askedSessions === void 0 ? null : clampWords(Math.max(1, Math.floor(Number(askedSessions))), cap);
8465
+ if (clamped) log.info(clamped);
8466
+ log.info(`Settings: ${CONFIG_PATH}. Sessions work under ${workdir}/sessions. Up to ${maxSessions} at a time, across every agent this computer hosts \u2014 this computer's memory carries ${cap.ceiling} at ${SESSION_RAM_MB} MB a session; change "maxSessions" in the settings file.`);
8157
8467
  log.info(`To host more agents from here: open the agent's page in Mission Control \u2192 "Join from your computer" \u2192 "Attach an existing computer". No second join.`);
8158
8468
  await deps.reportGitAccess(arg("--repo", argv));
8159
8469
  if (argv.includes("--no-install")) {
@@ -8165,12 +8475,12 @@ async function cmdJoin(argv, overrides = {}) {
8165
8475
  async function reportGitAccess(repoUrl) {
8166
8476
  let version = "not found";
8167
8477
  try {
8168
- version = execFileSync7("git", ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim();
8478
+ version = execFileSync8("git", ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim();
8169
8479
  } catch {
8170
8480
  }
8171
8481
  let helper = "none configured";
8172
8482
  try {
8173
- helper = execFileSync7("git", ["config", "--global", "--get-all", "credential.helper"], { stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim().split("\n").filter(Boolean).join(", ") || helper;
8483
+ helper = execFileSync8("git", ["config", "--global", "--get-all", "credential.helper"], { stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim().split("\n").filter(Boolean).join(", ") || helper;
8174
8484
  } catch {
8175
8485
  }
8176
8486
  const url = repoUrl ?? "";
@@ -8193,6 +8503,9 @@ async function cmdRun() {
8193
8503
  }
8194
8504
  const token = tokenFor(cfg);
8195
8505
  ensureFrodDir();
8506
+ const sizing = applySessionCap(cfg);
8507
+ if (sizing.write) writeConfig(cfg);
8508
+ for (const words of sizing.words) log.info(words);
8196
8509
  ClaudeAdapter.reapOrphans();
8197
8510
  const pruned = pruneRunDirs(cfg.workdir);
8198
8511
  if (pruned > 0) log.event("repo.runs_pruned", { count: pruned });
@@ -8250,7 +8563,7 @@ async function cmdRun() {
8250
8563
  });
8251
8564
  if (platform6() === "win32") {
8252
8565
  try {
8253
- const child = spawn6(process.execPath, [join11(CURRENT_LINK, "dist", "cli.js"), "run"], { detached: true, stdio: "ignore", env: process.env });
8566
+ const child = spawn6(process.execPath, [join12(CURRENT_LINK, "dist", "cli.js"), "run"], { detached: true, stdio: "ignore", windowsHide: true, env: process.env });
8254
8567
  child.unref();
8255
8568
  log.info(`Windows: started ${to} detached (pid ${child.pid}).`);
8256
8569
  } catch (err) {
@@ -8319,7 +8632,8 @@ async function cmdStatus(argv) {
8319
8632
  }
8320
8633
  log.info(`This computer: "${cfg.name}" \u2014 ${cfg.person?.name ? `${cfg.person.name}'s computer` : "a computer"} in ${cfg.workspaceName ?? cfg.workspaceId} via ${cfg.apiUrl}`);
8321
8634
  if (cfg.dormant) log.warn(dormantWords(cfg.dormant, hostedAgentsWords(cfg)));
8322
- log.info(`Token: ${cfg.tokenStore}; settings ${CONFIG_PATH}; sessions under ${cfg.workdir}/sessions; up to ${cfg.maxSessions} at a time across every agent here.`);
8635
+ const statusCap = resolveMaxSessions(cfg.maxSessions);
8636
+ log.info(`Token: ${cfg.tokenStore}; settings ${CONFIG_PATH}; sessions under ${cfg.workdir}/sessions; up to ${statusCap.value} at a time across every agent here (this computer's memory carries ${statusCap.ceiling} at ${SESSION_RAM_MB} MB a session${statusCap.clamped ? `, so the ${cfg.maxSessions} in the settings file is not honoured` : ""}).`);
8323
8637
  log.info(`Working folders an agent may be given here: ${allowedRootsOf(cfg).join(", ")} (add more under "allowedRoots" in ${CONFIG_PATH}).`);
8324
8638
  const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
8325
8639
  for (const runtime of RUNTIME_ORDER) {
@@ -8329,6 +8643,7 @@ async function cmdStatus(argv) {
8329
8643
  const loc = runLocation();
8330
8644
  log.info(`Software: ${VERSION}, running from ${loc.installedVersion ? `${VERSIONS_DIR}/${loc.installedVersion}` : `${loc.packageRoot} (not an installed version \u2014 it will not update itself; run \`npx @frod.io/bridge@latest install\`)`}; current \u2192 ${currentVersion() ?? "none"}.`);
8331
8645
  if (platform6() === "darwin") log.info(`Runs at login: ${launchdLoaded() ? "yes (launchd)" : "no \u2014 run install"}`);
8646
+ if (platform6() === "win32") log.info(`Runs at logon: ${windowsTaskLoaded() ? "yes (scheduled task)" : "no \u2014 run install"}`);
8332
8647
  log.info("Which agent runs on which runtime is the platform's to say \u2014 attach or detach agents in Mission Control.");
8333
8648
  await reportGitAccess(arg("--repo", argv));
8334
8649
  const token = readToken(cfg.keychainAccount, cfg.tokenStore);
@@ -8412,6 +8727,7 @@ async function cmdInstall() {
8412
8727
  if (platform6() === "win32") {
8413
8728
  const r = installWindows(target);
8414
8729
  log.info(r.loaded ? r.detail : `NOT scheduled (${r.detail}).`);
8730
+ if (r.loaded && !r.hidden) log.info(r.manual);
8415
8731
  if (!r.loaded) {
8416
8732
  log.info(r.manual);
8417
8733
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frod.io/bridge",
3
- "version": "0.7.5",
3
+ "version": "0.7.6",
4
4
  "type": "module",
5
5
  "description": "Your computer is a desk: hosts a Frod.io agent's sessions with your own Claude Code, Codex or Grok Build. Installs and updates itself.",
6
6
  "license": "UNLICENSED",