@frod.io/bridge 0.7.4 → 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 +586 -121
  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.4. 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
@@ -4090,6 +4090,25 @@ var LineBuffer = class {
4090
4090
  // src/session-store.ts
4091
4091
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "node:fs";
4092
4092
  import { join as join4 } from "node:path";
4093
+
4094
+ // src/adapters/usage-delta.ts
4095
+ var KEYS = ["inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens"];
4096
+ function usageDelta(current, previous) {
4097
+ if (!previous || KEYS.some((key) => current[key] < previous[key])) return { ...current };
4098
+ return {
4099
+ inputTokens: current.inputTokens - previous.inputTokens,
4100
+ outputTokens: current.outputTokens - previous.outputTokens,
4101
+ cacheCreationTokens: current.cacheCreationTokens - previous.cacheCreationTokens,
4102
+ cacheReadTokens: current.cacheReadTokens - previous.cacheReadTokens
4103
+ };
4104
+ }
4105
+ function isUsageCounters(value) {
4106
+ if (!value || typeof value !== "object") return false;
4107
+ const row = value;
4108
+ return KEYS.every((key) => typeof row[key] === "number" && Number.isFinite(row[key]) && row[key] >= 0);
4109
+ }
4110
+
4111
+ // src/session-store.ts
4093
4112
  var PATH = join4(FROD_DIR, "sessions.json");
4094
4113
  function read() {
4095
4114
  if (!existsSync3(PATH)) return {};
@@ -4107,6 +4126,9 @@ function write(store) {
4107
4126
  function getStored(platformSessionId) {
4108
4127
  return read()[platformSessionId] ?? null;
4109
4128
  }
4129
+ function getStoredByHostSessionId(hostSessionId) {
4130
+ return Object.values(read()).filter((session) => session.hostSessionId === hostSessionId).sort((a, b) => Date.parse(b.lastPromptAt ?? b.startedAt) - Date.parse(a.lastPromptAt ?? a.startedAt))[0] ?? null;
4131
+ }
4110
4132
  function putStored(platformSessionId, s) {
4111
4133
  const store = read();
4112
4134
  store[platformSessionId] = s;
@@ -4121,6 +4143,14 @@ function touchStored(platformSessionId) {
4121
4143
  write(store);
4122
4144
  }
4123
4145
  }
4146
+ function putStoredUsage(platformSessionId, adapter, counters) {
4147
+ const store = read();
4148
+ const session = store[platformSessionId];
4149
+ if (!session || !isUsageCounters(counters)) return;
4150
+ session.cumulativeUsage = { adapter, counters: { ...counters } };
4151
+ session.lastPromptAt = (/* @__PURE__ */ new Date()).toISOString();
4152
+ write(store);
4153
+ }
4124
4154
 
4125
4155
  // src/log.ts
4126
4156
  var REDACT = [
@@ -4154,6 +4184,44 @@ var log = {
4154
4184
  }
4155
4185
  };
4156
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
+
4157
4225
  // src/permission-policy.ts
4158
4226
  import { resolve, isAbsolute, sep, dirname } from "node:path";
4159
4227
  import { realpathSync } from "node:fs";
@@ -4432,7 +4500,7 @@ function cleanEnv(base, extra = {}) {
4432
4500
  var CHILDREN_PATH = join5(FROD_DIR, "children.json");
4433
4501
  function readChildren() {
4434
4502
  try {
4435
- return existsSync4(CHILDREN_PATH) ? JSON.parse(readFileSync4(CHILDREN_PATH, "utf8")) : [];
4503
+ return existsSync5(CHILDREN_PATH) ? JSON.parse(readFileSync5(CHILDREN_PATH, "utf8")) : [];
4436
4504
  } catch {
4437
4505
  return [];
4438
4506
  }
@@ -4484,8 +4552,12 @@ var ClaudeAdapter = class {
4484
4552
  }
4485
4553
  /** `claude --version`, and whether the CLI says it is signed in. Never throws. */
4486
4554
  static check(claudePath, commands = {}) {
4487
- const versionCommand = commands.version ?? ((path) => execFileSync2(path, ["--version"], { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString());
4488
- 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"]));
4489
4561
  let version;
4490
4562
  try {
4491
4563
  version = versionCommand(claudePath).trim();
@@ -4502,7 +4574,7 @@ var ClaudeAdapter = class {
4502
4574
  }
4503
4575
  }
4504
4576
  async start(spec, o) {
4505
- if (!existsSync4(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
4577
+ if (!existsSync5(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
4506
4578
  const stored = spec.resume_session_id ? getStored(spec.session_id) : null;
4507
4579
  const resumeId = spec.resume_session_id && stored?.hostSessionId === spec.resume_session_id ? spec.resume_session_id : null;
4508
4580
  const live = {
@@ -4599,7 +4671,9 @@ var ClaudeAdapter = class {
4599
4671
  if (bytes > ARGV_LIMIT_BYTES) {
4600
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.`);
4601
4673
  }
4602
- 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, {
4603
4677
  cwd: live.sessionDir,
4604
4678
  env: cleanEnv(process.env, { ...this.opts.env, ...live.secretEnv }),
4605
4679
  stdio: ["pipe", "pipe", "pipe"]
@@ -4860,6 +4934,8 @@ var CodexTranslator = class {
4860
4934
  }
4861
4935
  /** The newest `thread/tokenUsage/updated` of this turn, or null if none arrived. */
4862
4936
  usage = null;
4937
+ /** The running thread counters immediately before this turn's first model call. */
4938
+ billingBase = null;
4863
4939
  /** Item ids whose text was already streamed as deltas — not repeated on completion. */
4864
4940
  streamed = /* @__PURE__ */ new Set();
4865
4941
  /** Tool items announced with `item/started`, so a completion without a start still opens a card. */
@@ -4949,19 +5025,42 @@ var CodexTranslator = class {
4949
5025
  }
4950
5026
  onTokenUsage(p) {
4951
5027
  const u = p.tokenUsage;
4952
- if (u && typeof u === "object" && u.last && u.total) this.usage = u;
5028
+ if (u && typeof u === "object" && u.last && u.total) {
5029
+ if (!this.billingBase) {
5030
+ const total = countersOf(u.total);
5031
+ const last = countersOf(u.last);
5032
+ if (total && last && countersCover(total, last)) this.billingBase = usageDelta(total, last);
5033
+ }
5034
+ this.usage = u;
5035
+ }
4953
5036
  return true;
4954
5037
  }
4955
5038
  };
4956
- function toUsage(usage, model) {
5039
+ function countersOf(breakdown) {
5040
+ const inputTokens = num(breakdown.inputTokens);
5041
+ const outputTokens = num(breakdown.outputTokens);
5042
+ if (inputTokens === null || outputTokens === null) return null;
5043
+ return {
5044
+ inputTokens,
5045
+ outputTokens,
5046
+ cacheCreationTokens: num(breakdown.cacheWriteInputTokens) ?? 0,
5047
+ cacheReadTokens: num(breakdown.cachedInputTokens) ?? 0
5048
+ };
5049
+ }
5050
+ function countersCover(total, part) {
5051
+ return total.inputTokens >= part.inputTokens && total.outputTokens >= part.outputTokens && total.cacheCreationTokens >= part.cacheCreationTokens && total.cacheReadTokens >= part.cacheReadTokens;
5052
+ }
5053
+ function toUsage(usage, model, billingBase = null) {
4957
5054
  if (!usage || !model) return null;
4958
5055
  const { last, total } = usage;
4959
- if (num(total.inputTokens) === null || num(total.outputTokens) === null) return null;
5056
+ const cumulative = countersOf(total);
5057
+ if (!cumulative) return null;
5058
+ const turn = usageDelta(cumulative, billingBase);
4960
5059
  return {
4961
- input_tokens: Math.max(0, total.inputTokens - (num(total.cachedInputTokens) ?? 0)),
4962
- output_tokens: total.outputTokens,
4963
- cache_creation_tokens: num(total.cacheWriteInputTokens) ?? 0,
4964
- cache_read_tokens: num(total.cachedInputTokens) ?? 0,
5060
+ input_tokens: Math.max(0, turn.inputTokens - turn.cacheReadTokens),
5061
+ output_tokens: turn.outputTokens,
5062
+ cache_creation_tokens: turn.cacheCreationTokens,
5063
+ cache_read_tokens: turn.cacheReadTokens,
4965
5064
  model,
4966
5065
  context_tokens: num(last.inputTokens),
4967
5066
  context_window: num(usage.modelContextWindow)
@@ -5046,9 +5145,13 @@ var CodexAdapter = class _CodexAdapter {
5046
5145
  }
5047
5146
  /** The version and whether somebody is signed in — the same shape ClaudeAdapter.check returns. */
5048
5147
  static check(codexPath, commands = {}) {
5049
- 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
+ });
5050
5152
  const loginStatusCommand = commands.loginStatus ?? ((path) => {
5051
- 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 });
5052
5155
  if (res.error) throw res.error;
5053
5156
  return `${res.stdout ?? ""}${res.stderr ?? ""}`;
5054
5157
  });
@@ -5177,7 +5280,9 @@ var CodexAdapter = class _CodexAdapter {
5177
5280
  // The process and the wire
5178
5281
  // -------------------------------------------------------------------------
5179
5282
  spawnChild(live) {
5180
- 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, {
5181
5286
  cwd: live.sessionDir,
5182
5287
  stdio: ["pipe", "pipe", "pipe"],
5183
5288
  env: { ...process.env }
@@ -5232,7 +5337,8 @@ var CodexAdapter = class _CodexAdapter {
5232
5337
  const status2 = typeof turn.status === "string" ? turn.status : "completed";
5233
5338
  const err = turn.error;
5234
5339
  const stopReason = status2 === "interrupted" ? "cancelled" : status2 === "failed" ? "error" : "end_turn";
5235
- const usage = toUsage(live.turn?.translator.usage ?? null, live.model);
5340
+ const translator = live.turn?.translator;
5341
+ const usage = toUsage(translator?.usage ?? null, live.model, translator?.billingBase ?? null);
5236
5342
  live.turnId = null;
5237
5343
  this.settleTurn(live, {
5238
5344
  stopReason,
@@ -5450,7 +5556,7 @@ function describeError(err) {
5450
5556
 
5451
5557
  // src/adapters/grok.ts
5452
5558
  import { spawn as spawn3, spawnSync as spawnSync2, execFileSync as execFileSync4 } from "node:child_process";
5453
- import { existsSync as existsSync5, mkdirSync as mkdirSync4 } from "node:fs";
5559
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "node:fs";
5454
5560
 
5455
5561
  // src/adapters/grok-spec.ts
5456
5562
  import { isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2, join as join6 } from "node:path";
@@ -5571,19 +5677,29 @@ function stopReasonFor(acp) {
5571
5677
  }
5572
5678
  }
5573
5679
  var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
5574
- function toUsage2(meta, fallbackModel, contextWindow) {
5680
+ function cumulativeUsageOf(meta) {
5575
5681
  const total = meta?.usage;
5682
+ if (!total) return null;
5683
+ const inputTokens = num2(total.inputTokens);
5684
+ const outputTokens = num2(total.outputTokens);
5685
+ if (inputTokens === null || outputTokens === null) return null;
5686
+ return {
5687
+ inputTokens,
5688
+ outputTokens,
5689
+ cacheCreationTokens: num2(total.cacheCreationTokens) ?? 0,
5690
+ cacheReadTokens: num2(total.cachedReadTokens) ?? 0
5691
+ };
5692
+ }
5693
+ function toUsage2(meta, fallbackModel, contextWindow, previous = null) {
5576
5694
  const model = str2(meta?.modelId) ?? fallbackModel;
5577
- if (!total || !model) return null;
5578
- const input = num2(total.inputTokens);
5579
- const output = num2(total.outputTokens);
5580
- if (input === null || output === null) return null;
5581
- const cacheRead = num2(total.cachedReadTokens) ?? 0;
5695
+ const cumulative = cumulativeUsageOf(meta);
5696
+ if (!cumulative || !model) return null;
5697
+ const turn = usageDelta(cumulative, previous);
5582
5698
  return {
5583
- input_tokens: Math.max(0, input - cacheRead),
5584
- output_tokens: output,
5585
- cache_creation_tokens: num2(total.cacheCreationTokens) ?? 0,
5586
- cache_read_tokens: cacheRead,
5699
+ input_tokens: Math.max(0, turn.inputTokens - turn.cacheReadTokens),
5700
+ output_tokens: turn.outputTokens,
5701
+ cache_creation_tokens: turn.cacheCreationTokens,
5702
+ cache_read_tokens: turn.cacheReadTokens,
5587
5703
  model,
5588
5704
  context_tokens: num2(meta?.inputTokens),
5589
5705
  context_window: contextWindow
@@ -5767,9 +5883,13 @@ var GrokAdapter = class _GrokAdapter {
5767
5883
  */
5768
5884
  static check(grokPath, commands = {}) {
5769
5885
  const env = { ...process.env, GROK_DISABLE_AUTOUPDATER: "1" };
5770
- 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
+ });
5771
5890
  const modelsCommand = commands.models ?? ((path) => {
5772
- 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"] });
5773
5893
  if (res.error) throw res.error;
5774
5894
  return `${res.stdout ?? ""}
5775
5895
  ${res.stderr ?? ""}`;
@@ -5809,6 +5929,8 @@ ${res.stderr ?? ""}`;
5809
5929
  turn: null,
5810
5930
  model: spec.model?.name ?? null,
5811
5931
  contextWindow: null,
5932
+ cumulativeUsage: null,
5933
+ needsUsageBaseline: false,
5812
5934
  onExit: o.onExit,
5813
5935
  askPermission: o.askPermission,
5814
5936
  reportAutoAllowed: o.reportAutoAllowed,
@@ -5818,7 +5940,7 @@ ${res.stderr ?? ""}`;
5818
5940
  stderrTail: []
5819
5941
  };
5820
5942
  this.live.set(spec.session_id, live);
5821
- if (!existsSync5(o.sessionDir)) mkdirSync4(o.sessionDir, { recursive: true, mode: 448 });
5943
+ if (!existsSync6(o.sessionDir)) mkdirSync4(o.sessionDir, { recursive: true, mode: 448 });
5822
5944
  this.spawnChild(live);
5823
5945
  let init;
5824
5946
  try {
@@ -5839,6 +5961,10 @@ ${res.stderr ?? ""}`;
5839
5961
  const mcpServers = mcpServersFor(spec);
5840
5962
  const params = { cwd: o.sessionDir, mcpServers, _meta: sessionMeta(spec) };
5841
5963
  const resumeId = spec.resume_session_id ?? null;
5964
+ const resumedStore = resumeId ? getStoredByHostSessionId(resumeId) : null;
5965
+ if (resumedStore?.cumulativeUsage?.adapter === "grok" && isUsageCounters(resumedStore.cumulativeUsage.counters)) {
5966
+ live.cumulativeUsage = { ...resumedStore.cumulativeUsage.counters };
5967
+ }
5842
5968
  let resumed = false;
5843
5969
  let started;
5844
5970
  if (resumeId) {
@@ -5850,12 +5976,25 @@ ${res.stderr ?? ""}`;
5850
5976
  }
5851
5977
  }
5852
5978
  if (!started) started = await this.request(live, "session/new", params);
5979
+ if (!resumed) live.cumulativeUsage = null;
5980
+ live.needsUsageBaseline = resumed && !live.cumulativeUsage;
5853
5981
  const sessionId = typeof started?.sessionId === "string" ? started.sessionId : null;
5854
5982
  if (!sessionId) throw new Error("Grok Build started but did not name the session \u2014 it cannot be addressed.");
5855
5983
  live.acpSessionId = sessionId;
5856
5984
  const models = started?.models ?? init?._meta?.modelState ?? null;
5857
5985
  live.model = live.model ?? (typeof models?.currentModelId === "string" ? models.currentModelId : null);
5858
5986
  live.contextWindow = contextWindowOf(models, live.model);
5987
+ try {
5988
+ putStored(spec.session_id, {
5989
+ hostSessionId: sessionId,
5990
+ cwd: o.sessionDir,
5991
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5992
+ lastPromptAt: null,
5993
+ ...resumed && live.cumulativeUsage ? { cumulativeUsage: { adapter: "grok", counters: { ...live.cumulativeUsage } } } : {}
5994
+ });
5995
+ } catch (err) {
5996
+ log.warn(`[${spec.session_id.slice(0, 8)}] grok: could not open the usage-counter record (${err instanceof Error ? err.message : String(err)})`);
5997
+ }
5859
5998
  log.event("grok.started", {
5860
5999
  session: spec.session_id,
5861
6000
  acp_session: sessionId,
@@ -5880,7 +6019,21 @@ ${res.stderr ?? ""}`;
5880
6019
  const meta = r._meta ?? null;
5881
6020
  if (typeof meta?.modelId === "string" && meta.modelId) live.model = meta.modelId;
5882
6021
  const { stopReason, error } = stopReasonFor(r.stopReason);
5883
- this.settleTurn(live, { stopReason, usage: toUsage2(meta, live.model, live.contextWindow), ...error ? { error } : {} });
6022
+ const cumulative = cumulativeUsageOf(meta);
6023
+ const usage = live.needsUsageBaseline ? null : toUsage2(meta, live.model, live.contextWindow, live.cumulativeUsage);
6024
+ if (cumulative) {
6025
+ if (live.needsUsageBaseline) {
6026
+ log.warn(`[${live.spec.session_id.slice(0, 8)}] grok: resumed without an earlier usage snapshot \u2014 this turn is unpriced and seeds the next increment`);
6027
+ live.needsUsageBaseline = false;
6028
+ }
6029
+ live.cumulativeUsage = cumulative;
6030
+ try {
6031
+ putStoredUsage(live.spec.session_id, "grok", cumulative);
6032
+ } catch (err) {
6033
+ log.warn(`[${live.spec.session_id.slice(0, 8)}] grok: could not remember usage counters (${err instanceof Error ? err.message : String(err)})`);
6034
+ }
6035
+ }
6036
+ this.settleTurn(live, { stopReason, usage, ...error ? { error } : {} });
5884
6037
  }).catch((err) => {
5885
6038
  this.settleTurn(live, { stopReason: "error", usage: null, error: err.message });
5886
6039
  });
@@ -5920,7 +6073,9 @@ ${res.stderr ?? ""}`;
5920
6073
  // The process and the wire
5921
6074
  // -------------------------------------------------------------------------
5922
6075
  spawnChild(live) {
5923
- 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, {
5924
6079
  cwd: live.sessionDir,
5925
6080
  stdio: ["pipe", "pipe", "pipe"],
5926
6081
  env: grokEnv(process.env)
@@ -6129,7 +6284,7 @@ import { hostname, platform as platform2 } from "node:os";
6129
6284
 
6130
6285
  // src/repo.ts
6131
6286
  import { spawn as spawn4 } from "node:child_process";
6132
- 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";
6133
6288
  import { join as join7, resolve as resolve3 } from "node:path";
6134
6289
  var PLATFORM_COMMIT_ENV = {
6135
6290
  GIT_AUTHOR_NAME: "AgentOrchestrator",
@@ -6230,7 +6385,7 @@ async function canReach(gitUrl, timeoutMs = 3e4) {
6230
6385
  return { ok: false, detail: firstLine(r.stderr) || `git exited ${r.code}`, ms: r.ms };
6231
6386
  }
6232
6387
  async function originOf(dir) {
6233
- if (!existsSync6(join7(dir, ".git"))) return null;
6388
+ if (!existsSync7(join7(dir, ".git"))) return null;
6234
6389
  const r = await git(["-C", dir, "remote", "get-url", "origin"], void 0, { timeoutMs: 1e4 });
6235
6390
  return r.code === 0 ? r.stdout.trim() : null;
6236
6391
  }
@@ -6255,7 +6410,7 @@ async function prepareCheckout(repo, workdir, computerName, log2, opts = {}) {
6255
6410
  throw new Error(`${dir} already holds a different repository (${existing}); not touching it`);
6256
6411
  }
6257
6412
  if (!existing) {
6258
- if (existsSync6(dir)) rmSync(dir, { recursive: true, force: true });
6413
+ if (existsSync7(dir)) rmSync(dir, { recursive: true, force: true });
6259
6414
  mkdirSync5(resolve3(dir, ".."), { recursive: true, mode: 448 });
6260
6415
  const branchToClone = onOrigin ? repo.branch : repo.default_branch;
6261
6416
  const clone = await git(["clone", "--quiet", "--branch", branchToClone, "--single-branch", "--", repo.git_url, dir], void 0, { timeoutMs: 10 * 6e4 });
@@ -6331,7 +6486,7 @@ function pruneRunDirs(workdir, maxAgeMs = 7 * 24 * 60 * 6e4) {
6331
6486
  let removed = 0;
6332
6487
  for (const sub of ["runs"]) {
6333
6488
  const root = join7(workdir, sub);
6334
- if (!existsSync6(root)) continue;
6489
+ if (!existsSync7(root)) continue;
6335
6490
  for (const name of readdirSync(root)) {
6336
6491
  const p = resolve3(root, name);
6337
6492
  try {
@@ -6347,7 +6502,7 @@ function pruneRunDirs(workdir, maxAgeMs = 7 * 24 * 60 * 6e4) {
6347
6502
  }
6348
6503
 
6349
6504
  // src/repo-door.ts
6350
- import { existsSync as existsSync7 } from "node:fs";
6505
+ import { existsSync as existsSync8 } from "node:fs";
6351
6506
  var DoorCheckouts = class {
6352
6507
  bySession = /* @__PURE__ */ new Map();
6353
6508
  get(sessionId, repositoryId) {
@@ -6426,7 +6581,7 @@ async function handleRepoRequest(req, sessionId, ctx, checkouts) {
6426
6581
  }
6427
6582
  const known = checkouts.get(sessionId, req.repository.id);
6428
6583
  const at = known?.dir ?? dir;
6429
- 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`);
6430
6585
  if (req.action === "status") {
6431
6586
  const [branch, head, changed] = await Promise.all([
6432
6587
  git(["-C", at, "symbolic-ref", "--short", "-q", "HEAD"], void 0, { timeoutMs: 1e4 }),
@@ -6444,7 +6599,7 @@ async function handleRepoRequest(req, sessionId, ctx, checkouts) {
6444
6599
  }
6445
6600
 
6446
6601
  // src/workdir.ts
6447
- 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";
6448
6603
  import { homedir as homedir4 } from "node:os";
6449
6604
  import { dirname as dirname2, isAbsolute as isAbsolute3, relative, resolve as resolve4, sep as sep3 } from "node:path";
6450
6605
  function expandHome(p, home = homedir4()) {
@@ -6482,12 +6637,12 @@ function resolveWorkdir(requested, rule, opts = {}) {
6482
6637
  const realAnchor = realpathSync2(anchor);
6483
6638
  const realTarget = resolve4(realAnchor, relative(anchor, abs));
6484
6639
  if (!realRoots.some((root) => under(realTarget, root))) throw new Error(`${refusalWords(rule, home)} (the folder links to a place outside them)`);
6485
- if (opts.create !== false && !existsSync8(abs)) mkdirSync6(abs, { recursive: true, mode: 448 });
6486
- 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;
6487
6642
  }
6488
6643
  function deepestExisting(abs) {
6489
6644
  let cur = abs;
6490
- while (!existsSync8(cur)) {
6645
+ while (!existsSync9(cur)) {
6491
6646
  const parent = dirname2(cur);
6492
6647
  if (parent === cur) return cur;
6493
6648
  cur = parent;
@@ -6977,12 +7132,12 @@ function plainWords(message) {
6977
7132
 
6978
7133
  // src/install.ts
6979
7134
  import { execFileSync as execFileSync6 } from "node:child_process";
6980
- 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";
6981
7136
  import { homedir as homedir5, platform as platform4, userInfo } from "node:os";
6982
- import { dirname as dirname4, join as join9 } from "node:path";
7137
+ import { dirname as dirname4, join as join9, win32 as winPath2 } from "node:path";
6983
7138
 
6984
7139
  // src/updater.ts
6985
- 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";
6986
7141
  import { join as join8, dirname as dirname3, basename, resolve as resolve5, sep as sep4 } from "node:path";
6987
7142
  import { platform as platform3 } from "node:os";
6988
7143
  import { execFileSync as execFileSync5 } from "node:child_process";
@@ -7093,7 +7248,7 @@ function currentVersion(currentLink = CURRENT_LINK) {
7093
7248
  }
7094
7249
  function packageVersionAt(root) {
7095
7250
  try {
7096
- const pkg = JSON.parse(readFileSync5(join8(root, "package.json"), "utf8"));
7251
+ const pkg = JSON.parse(readFileSync6(join8(root, "package.json"), "utf8"));
7097
7252
  return pkg.name && pkg.version ? { name: pkg.name, version: pkg.version } : null;
7098
7253
  } catch {
7099
7254
  return null;
@@ -7105,14 +7260,14 @@ function stagePackage(loc, expectedVersion, versionsDir = VERSIONS_DIR) {
7105
7260
  if (pkg.version !== expectedVersion) return { ok: false, reason: `the package at ${loc.packageRoot} is ${pkg.version}, this bridge is ${expectedVersion}` };
7106
7261
  const dest = join8(versionsDir, pkg.version);
7107
7262
  if (loc.installedVersion === pkg.version && safeReal(loc.packageRoot) === safeReal(dest)) return { ok: true, dir: dest, copied: false };
7108
- if (!existsSync9(join8(loc.packageRoot, "dist", "cli.js"))) {
7263
+ if (!existsSync10(join8(loc.packageRoot, "dist", "cli.js"))) {
7109
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` };
7110
7265
  }
7111
- 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 };
7112
7267
  const partial = `${dest}.partial`;
7113
7268
  rmSync2(partial, { recursive: true, force: true });
7114
7269
  mkdirSync7(partial, { recursive: true, mode: 493 });
7115
- 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));
7116
7271
  cpSync(join8(loc.packageRoot, "dist"), join8(partial, "dist"), { recursive: true });
7117
7272
  rmSync2(dest, { recursive: true, force: true });
7118
7273
  renameSync(partial, dest);
@@ -7172,7 +7327,7 @@ async function stageRelease(release, opts = {}) {
7172
7327
  const pkg = packageVersionAt(root);
7173
7328
  if (!pkg || pkg.name !== "@frod.io/bridge") return refuse("the tarball is not the @frod.io/bridge package");
7174
7329
  if (pkg.version !== v) return refuse(`the tarball says it is ${pkg.version}, not ${v}`);
7175
- 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");
7176
7331
  try {
7177
7332
  rmSync2(dest, { recursive: true, force: true });
7178
7333
  renameSync(root, dest);
@@ -7184,7 +7339,7 @@ async function stageRelease(release, opts = {}) {
7184
7339
  }
7185
7340
  function swapCurrent(version, versionsDir = VERSIONS_DIR, currentLink = CURRENT_LINK) {
7186
7341
  const target = join8(versionsDir, version);
7187
- 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`);
7188
7343
  mkdirSync7(dirname3(currentLink), { recursive: true });
7189
7344
  const tmp = `${currentLink}.tmp-${process.pid}`;
7190
7345
  rmSync2(tmp, { recursive: true, force: true });
@@ -7199,7 +7354,7 @@ function swapCurrent(version, versionsDir = VERSIONS_DIR, currentLink = CURRENT_
7199
7354
  }
7200
7355
  function pruneVersions(opts = {}) {
7201
7356
  const versionsDir = opts.versionsDir ?? VERSIONS_DIR;
7202
- if (!existsSync9(versionsDir)) return [];
7357
+ if (!existsSync10(versionsDir)) return [];
7203
7358
  const keepSet = new Set([opts.running ?? "", currentVersion(opts.currentLink ?? CURRENT_LINK) ?? ""].filter(Boolean));
7204
7359
  const entries = readdirSync2(versionsDir);
7205
7360
  const versions = entries.filter((e) => SEMVER.test(e)).sort((a, b) => -(compareSemver(a, b) ?? 0));
@@ -7361,17 +7516,22 @@ function xml(s) {
7361
7516
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
7362
7517
  }
7363
7518
  function defaultTarget() {
7364
- 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;
7365
7525
  }
7366
7526
  var realIo = {
7367
7527
  exec: (cmd, args) => {
7368
7528
  execFileSync6(cmd, args, { stdio: "pipe" });
7369
7529
  },
7370
- writeFile: (path, content, mode) => {
7371
- writeFileSync6(path, content, { encoding: "utf8", mode });
7530
+ writeFile: (path, content, mode, encoding = "utf8") => {
7531
+ writeFileSync6(path, content, { encoding, mode });
7372
7532
  },
7373
7533
  mkdir: (dir) => {
7374
- if (!existsSync10(dir)) mkdirSync8(dir, { recursive: true });
7534
+ if (!existsSync11(dir)) mkdirSync8(dir, { recursive: true });
7375
7535
  }
7376
7536
  };
7377
7537
  function renderLaunchdPlist(t) {
@@ -7445,7 +7605,7 @@ function uninstallMacOS() {
7445
7605
  execFileSync6("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { stdio: "ignore" });
7446
7606
  } catch {
7447
7607
  }
7448
- if (existsSync10(plistPath())) {
7608
+ if (existsSync11(plistPath())) {
7449
7609
  unlinkSync2(plistPath());
7450
7610
  return { removed: true };
7451
7611
  }
@@ -7522,12 +7682,96 @@ function uninstallLinux(t = defaultTarget(), io = realIo) {
7522
7682
  } catch (err) {
7523
7683
  detail = `systemctl: ${err instanceof Error ? err.message : String(err)}`;
7524
7684
  }
7525
- if (existsSync10(path)) {
7685
+ if (existsSync11(path)) {
7526
7686
  unlinkSync2(path);
7527
7687
  return { removed: true, detail };
7528
7688
  }
7529
7689
  return { removed: false, detail };
7530
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
+ }
7531
7775
  function renderSchtasksArgs(t) {
7532
7776
  const tr = `"${t.node}" "${t.cli}" run`;
7533
7777
  return ["/Create", "/F", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", tr];
@@ -7535,26 +7779,60 @@ function renderSchtasksArgs(t) {
7535
7779
  function windowsManualPath(t) {
7536
7780
  return [
7537
7781
  "To run it at logon, in a command prompt:",
7538
- ` schtasks ${renderSchtasksArgs(t).map((a) => /\s/.test(a) && !a.startsWith('"') ? `"${a}"` : a).join(" ")}`,
7782
+ ` schtasks /Create /F /TN "${WINDOWS_TASK}" /XML "${windowsTaskXmlPath(t)}"`,
7539
7783
  ` schtasks /Run /TN "${WINDOWS_TASK}"`,
7540
- `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.`,
7541
- `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.`
7542
7787
  ].join("\n");
7543
7788
  }
7544
- function installWindows(t = defaultTarget(), io = realIo) {
7789
+ function installWindows(t = defaultTarget(), io = realIo, env = process.env) {
7545
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
+ }
7546
7810
  try {
7547
7811
  io.exec("schtasks", renderSchtasksArgs(t));
7548
7812
  try {
7549
7813
  io.exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
7550
7814
  } catch {
7551
7815
  }
7552
- 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 };
7553
7817
  } catch (err) {
7554
- 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;
7555
7827
  }
7556
7828
  }
7557
- 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
+ }
7558
7836
  try {
7559
7837
  io.exec("schtasks", ["/Delete", "/F", "/TN", WINDOWS_TASK]);
7560
7838
  return { removed: true, detail: "task deleted" };
@@ -7566,11 +7844,84 @@ function installStub(t = defaultTarget()) {
7566
7844
  return `Automatic install is not available on ${platform4()}. Run: node "${t.cli}" run`;
7567
7845
  }
7568
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
+
7569
7920
  // src/capacity.ts
7570
7921
  import { execFile, spawn as spawn5 } from "node:child_process";
7571
- import { existsSync as existsSync11, mkdtempSync, readFileSync as readFileSync6 } from "node:fs";
7572
- import { homedir as homedir6, platform as platform5, tmpdir as tmpdir2 } from "node:os";
7573
- 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";
7574
7925
  import { createInterface } from "node:readline";
7575
7926
  var SAMPLE_INTERVAL_MS = 30 * 6e4;
7576
7927
  var READER_TIMEOUT_MS = 25e3;
@@ -7580,9 +7931,27 @@ var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
7580
7931
  var CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
7581
7932
  var CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
7582
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
+ }
7583
7952
  function readClaudeCredential(deps = {}) {
7584
7953
  const os = deps.platform ?? platform5();
7585
- const home = deps.home ?? homedir6();
7954
+ const home = deps.home ?? homedir7();
7586
7955
  const parse = (raw) => {
7587
7956
  try {
7588
7957
  const j = JSON.parse(raw);
@@ -7594,25 +7963,34 @@ function readClaudeCredential(deps = {}) {
7594
7963
  }
7595
7964
  };
7596
7965
  if (os === "darwin") {
7597
- return new Promise((resolve7) => {
7598
- execFile("security", ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], { timeout: deps.timeoutMs ?? KEYCHAIN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout) => {
7599
- if (err) {
7600
- const killed = err.killed || err.signal === "SIGTERM";
7601
- resolve7({ problem: killed ? APPROVAL_WORDS : "Claude Code is not signed in on this computer" });
7602
- return;
7603
- }
7604
- resolve7(parse(String(stdout).trim()));
7605
- });
7606
- });
7966
+ return readClaudeKeychain(deps.security ?? securityRunner(deps.timeoutMs ?? KEYCHAIN_TIMEOUT_MS), deps.account ?? userInfo2().username, parse);
7607
7967
  }
7608
- const file = join10(home, ".claude", ".credentials.json");
7609
- 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" });
7610
7970
  try {
7611
- return Promise.resolve(parse(readFileSync6(file, "utf8")));
7971
+ return Promise.resolve(parse(readFileSync7(file, "utf8")));
7612
7972
  } catch {
7613
7973
  return Promise.resolve({ problem: "Claude Code's sign-in could not be read on this computer" });
7614
7974
  }
7615
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
+ }
7616
7994
  function claudeTierWords(profile) {
7617
7995
  const org = profile?.organization;
7618
7996
  const tier = org?.rate_limit_tier ?? "";
@@ -7747,7 +8125,7 @@ function parseGrokBilling(result) {
7747
8125
  }
7748
8126
  async function readGrokCapacity(grokPath, deps = {}) {
7749
8127
  const ask = deps.ask ?? askChild;
7750
- const cwd = deps.cwd ?? mkdtempSync(join10(tmpdir2(), "frod-capacity-"));
8128
+ const cwd = deps.cwd ?? mkdtempSync(join11(tmpdir2(), "frod-capacity-"));
7751
8129
  const r = await ask({
7752
8130
  command: grokPath,
7753
8131
  args: ["agent", "--no-leader", "stdio"],
@@ -7790,6 +8168,8 @@ function askChild(input) {
7790
8168
  } catch {
7791
8169
  }
7792
8170
  };
8171
+ child.stdin?.on("error", () => {
8172
+ });
7793
8173
  child.on("error", (err) => finish({ problem: `could not start: ${err.message}` }));
7794
8174
  child.on("exit", (code) => {
7795
8175
  if (!done) finish({ problem: `ended (exit ${code ?? "signal"}) before answering` });
@@ -7849,40 +8229,114 @@ function samplesInWords(samples) {
7849
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";
7850
8230
  }
7851
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
+
8278
+ // package.json
8279
+ var package_default = {
8280
+ name: "@frod.io/bridge",
8281
+ version: "0.7.6",
8282
+ type: "module",
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.",
8284
+ license: "UNLICENSED",
8285
+ homepage: "https://frod.io",
8286
+ repository: {
8287
+ type: "git",
8288
+ url: "https://github.com/awafeek/agentic-engineering.git",
8289
+ directory: "apps/bridge"
8290
+ },
8291
+ bin: {
8292
+ "frod-bridge": "dist/cli.js"
8293
+ },
8294
+ files: [
8295
+ "dist",
8296
+ "README.md"
8297
+ ],
8298
+ engines: {
8299
+ node: ">=20"
8300
+ },
8301
+ publishConfig: {
8302
+ access: "public"
8303
+ },
8304
+ scripts: {
8305
+ build: "node scripts/bundle.mjs && tsc --noEmit",
8306
+ bundle: "node scripts/bundle.mjs",
8307
+ lint: "eslint src",
8308
+ test: "vitest run --passWithNoTests",
8309
+ "type-check": "tsc --noEmit",
8310
+ "pack:check": "npm pack --dry-run"
8311
+ },
8312
+ devDependencies: {
8313
+ "@eslint/js": "^9.39.3",
8314
+ "@types/node": "^22.0.0",
8315
+ "@types/ws": "^8.18.1",
8316
+ esbuild: "^0.27.3",
8317
+ eslint: "^9.0.0",
8318
+ tsx: "^4.19.0",
8319
+ typescript: "^5.7.0",
8320
+ "typescript-eslint": "^8.56.1",
8321
+ vitest: "^4.0.18",
8322
+ ws: "^8.21.3"
8323
+ }
8324
+ };
8325
+
7852
8326
  // src/cli.ts
7853
- var VERSION = "0.7.4";
8327
+ var VERSION = package_default.version;
7854
8328
  function arg(flag, argv) {
7855
8329
  const i = argv.indexOf(flag);
7856
8330
  return i >= 0 ? argv[i + 1] : void 0;
7857
8331
  }
7858
8332
  function findClaude() {
7859
- const candidates = [process.env.FROD_CLAUDE_PATH, join11(homedir7(), ".local", "bin", "claude"), "/opt/homebrew/bin/claude", "/usr/local/bin/claude"].filter((c) => !!c);
7860
- for (const c of candidates) if (existsSync12(c)) return c;
7861
- try {
7862
- return execFileSync7("/usr/bin/which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7863
- } catch {
7864
- }
7865
- return null;
8333
+ return findRuntime("claude");
7866
8334
  }
7867
8335
  function findCodex() {
7868
- const candidates = [process.env.FROD_CODEX_PATH, join11(homedir7(), ".local", "bin", "codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex"].filter((c) => !!c);
7869
- for (const c of candidates) if (existsSync12(c)) return c;
7870
- try {
7871
- const found = execFileSync7("/usr/bin/which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7872
- return found || null;
7873
- } catch {
7874
- return null;
7875
- }
8336
+ return findRuntime("codex");
7876
8337
  }
7877
8338
  function findGrok() {
7878
- 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);
7879
- for (const c of candidates) if (existsSync12(c)) return c;
7880
- try {
7881
- const found = execFileSync7("/usr/bin/which", ["grok"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7882
- return found || null;
7883
- } catch {
7884
- return null;
7885
- }
8339
+ return findRuntime("grok");
7886
8340
  }
7887
8341
  var RUNTIME_ORDER = ["claude", "codex", "grok"];
7888
8342
  var RUNTIME_LABELS = { claude: "Claude Code", codex: "Codex", grok: "Grok Build" };
@@ -7952,7 +8406,7 @@ async function cmdJoin(argv, overrides = {}) {
7952
8406
  join: join3,
7953
8407
  storeToken,
7954
8408
  ensureWorkdir: (path) => {
7955
- if (!existsSync12(path)) mkdirSync9(path, { recursive: true, mode: 448 });
8409
+ if (!existsSync14(path)) mkdirSync9(path, { recursive: true, mode: 448 });
7956
8410
  },
7957
8411
  writeConfig,
7958
8412
  reportGitAccess,
@@ -7968,8 +8422,10 @@ async function cmdJoin(argv, overrides = {}) {
7968
8422
  if (refusal) throw new Error(refusal);
7969
8423
  const safeApi = assertSafeEndpoint(apiUrl);
7970
8424
  const name = arg("--name", argv) ?? defaultName();
7971
- const workdir = resolve6(arg("--workdir", argv) ?? join11(homedir7(), "frod-work"));
7972
- 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;
7973
8429
  const probes = probeAllRuntimes(deps.probes);
7974
8430
  for (const runtime of RUNTIME_ORDER) {
7975
8431
  const probe = probes[runtime];
@@ -7990,6 +8446,7 @@ async function cmdJoin(argv, overrides = {}) {
7990
8446
  name,
7991
8447
  workdir,
7992
8448
  maxSessions,
8449
+ maxSessionsChosen: askedSessions !== void 0,
7993
8450
  adapters,
7994
8451
  claudePath: isReady(probes.claude) ? probes.claude.path : null,
7995
8452
  codexPath: isReady(probes.codex) ? probes.codex.path : void 0,
@@ -8004,7 +8461,9 @@ async function cmdJoin(argv, overrides = {}) {
8004
8461
  log.info(`Joined. This computer is now "${name}" \u2014 ${joined.person?.name ?? "your"} computer in ${joined.workspaceName ?? joined.workspaceId} \u2014 hosting ${hostedAgentsWords(cfg)}.`);
8005
8462
  for (const b of cfg.bindings ?? []) log.info(` ${b.agentName}: ${bindingWords(b)}`);
8006
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)}.`);
8007
- 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.`);
8008
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.`);
8009
8468
  await deps.reportGitAccess(arg("--repo", argv));
8010
8469
  if (argv.includes("--no-install")) {
@@ -8016,12 +8475,12 @@ async function cmdJoin(argv, overrides = {}) {
8016
8475
  async function reportGitAccess(repoUrl) {
8017
8476
  let version = "not found";
8018
8477
  try {
8019
- 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();
8020
8479
  } catch {
8021
8480
  }
8022
8481
  let helper = "none configured";
8023
8482
  try {
8024
- 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;
8025
8484
  } catch {
8026
8485
  }
8027
8486
  const url = repoUrl ?? "";
@@ -8044,6 +8503,9 @@ async function cmdRun() {
8044
8503
  }
8045
8504
  const token = tokenFor(cfg);
8046
8505
  ensureFrodDir();
8506
+ const sizing = applySessionCap(cfg);
8507
+ if (sizing.write) writeConfig(cfg);
8508
+ for (const words of sizing.words) log.info(words);
8047
8509
  ClaudeAdapter.reapOrphans();
8048
8510
  const pruned = pruneRunDirs(cfg.workdir);
8049
8511
  if (pruned > 0) log.event("repo.runs_pruned", { count: pruned });
@@ -8101,7 +8563,7 @@ async function cmdRun() {
8101
8563
  });
8102
8564
  if (platform6() === "win32") {
8103
8565
  try {
8104
- 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 });
8105
8567
  child.unref();
8106
8568
  log.info(`Windows: started ${to} detached (pid ${child.pid}).`);
8107
8569
  } catch (err) {
@@ -8170,7 +8632,8 @@ async function cmdStatus(argv) {
8170
8632
  }
8171
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}`);
8172
8634
  if (cfg.dormant) log.warn(dormantWords(cfg.dormant, hostedAgentsWords(cfg)));
8173
- 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` : ""}).`);
8174
8637
  log.info(`Working folders an agent may be given here: ${allowedRootsOf(cfg).join(", ")} (add more under "allowedRoots" in ${CONFIG_PATH}).`);
8175
8638
  const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
8176
8639
  for (const runtime of RUNTIME_ORDER) {
@@ -8180,6 +8643,7 @@ async function cmdStatus(argv) {
8180
8643
  const loc = runLocation();
8181
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"}.`);
8182
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"}`);
8183
8647
  log.info("Which agent runs on which runtime is the platform's to say \u2014 attach or detach agents in Mission Control.");
8184
8648
  await reportGitAccess(arg("--repo", argv));
8185
8649
  const token = readToken(cfg.keychainAccount, cfg.tokenStore);
@@ -8263,6 +8727,7 @@ async function cmdInstall() {
8263
8727
  if (platform6() === "win32") {
8264
8728
  const r = installWindows(target);
8265
8729
  log.info(r.loaded ? r.detail : `NOT scheduled (${r.detail}).`);
8730
+ if (r.loaded && !r.hidden) log.info(r.manual);
8266
8731
  if (!r.loaded) {
8267
8732
  log.info(r.manual);
8268
8733
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frod.io/bridge",
3
- "version": "0.7.4",
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",