@frod.io/bridge 0.7.3 → 0.7.5

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 +61 -16
  2. package/dist/cli.js +343 -134
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # Frod.io bridge — your computer is a desk
2
2
 
3
- You already have Claude Code on your computer, with your own sign-in, skills,
4
- memory and tools. The bridge makes that Claude a **session of the platform**:
3
+ You already have at least one supported coding runtime on your computer
4
+ Claude Code, Codex, or Grok Build with its own sign-in, settings and tools.
5
+ The bridge makes those ready runtimes available to **sessions of the platform**:
5
6
  agents you choose run on your computer, take `@mentions` from Teams and
6
7
  Telegram, read their Inbox, sit in the Office, run blueprint steps and file
7
8
  into the map — governed and metered like every other agent — and nothing of
@@ -20,7 +21,11 @@ npx @frod.io/bridge@latest join <code> --api https://api.agentic.frod.io [--name
20
21
 
21
22
  `npx` only bootstraps the command. `join` itself:
22
23
 
23
- - checks that `claude` is installed and signed in (`claude auth login`);
24
+ - checks Claude Code, Codex, and Grok Build once each, and offers exactly the
25
+ **READY set**: a runtime is READY only when it is installed and its sign-in
26
+ check explicitly says it is signed in. The computer may therefore join with
27
+ Codex only, Claude Code only, Grok Build only, or any mixed-ready set; it
28
+ does not need Claude Code when Codex or Grok Build is READY;
24
29
  - exchanges the code for a token and keeps it in your OS keychain — macOS's
25
30
  `security`, set up so the item trusts `/usr/bin/security` and never pops a
26
31
  dialog; Linux's `secret-tool`; anywhere else a 0600 file, and it says so;
@@ -38,6 +43,22 @@ npx @frod.io/bridge@latest join <code> --api https://api.agentic.frod.io [--name
38
43
  Pass `--no-install` to join without any of the install/run-at-login steps —
39
44
  useful if you'd rather run `install` or `run` by hand afterwards.
40
45
 
46
+ An installed runtime whose sign-in result is false is not READY. Neither is a
47
+ runtime whose probe is unreadable, invalid, ambiguous, timed out, or unable to
48
+ run; an unreadable result never becomes READY by assumption. Other READY
49
+ runtimes still let the computer join, and only those READY runtimes are
50
+ offered. If none is READY, `join` stops before exchanging the code and always
51
+ starts its refusal with this exact text:
52
+
53
+ ```text
54
+ No supported runtime is ready. Sign in and try again: Claude Code (`claude auth login`), Codex (`codex login`), and Grok Build (`grok login`).
55
+ ```
56
+
57
+ That core always names all three runtimes and all three sign-in commands. It
58
+ may then add `Install Claude Code.`, `Install Codex.`, or `Install Grok Build.`
59
+ in that order, but only for a runtime the probe established is absent. An
60
+ installed-but-signed-out or unreadable runtime receives no install hint.
61
+
41
62
  **You join once.** A computer is *yours* on the platform, not an agent's.
42
63
  Every further agent is attached in Mission Control — on that agent's page,
43
64
  **Join from your computer → Attach an existing computer** — and the change
@@ -82,15 +103,15 @@ For every agent on your computer the workspace owner chooses:
82
103
  from the token counts Claude Code reports (the ledger says *billed to
83
104
  subscription (list rate)*), and its **working folder** (`<workdir>/sessions/<id>`,
84
105
  or the run checkout for a repository step).
85
- - That this computer is **connected or offline**, its name, OS and Claude Code
86
- version, and how many sessions it is running (up to `--max-sessions`, across
87
- all its agents).
106
+ - That this computer is **connected or offline**, its name, OS, the versions of
107
+ its READY runtimes, and how many sessions it is running (up to
108
+ `--max-sessions`, across all its agents).
88
109
  - Every permission decision, and — under *Never ask* — every call that would
89
110
  have asked.
90
111
 
91
112
  ## What it never sees
92
113
 
93
- - Your Claude sign-in, your `~/.claude` settings, skills, plugins and MCP
114
+ - Your runtime sign-ins and local runtime settings, skills, plugins and MCP
94
115
  servers — they load as they always do; the platform only *adds* its own
95
116
  system prompt and its own tools on top.
96
117
  - Any file outside the session folder unless the agent asks and a person
@@ -177,7 +198,7 @@ Disconnect**), which takes the computer off the floor at once.
177
198
  | the OS keychain, item `io.frod.bridge` | the host token |
178
199
  | `~/.frod/bridge/versions/<version>/` | one installed copy of the package |
179
200
  | `~/.frod/bridge/current` | a symlink (junction on Windows) to the version that's running |
180
- | `~/.frod/sessions.json` | which Claude Code session each platform session ran as (for resume) |
201
+ | `~/.frod/sessions.json` | which local runtime session each platform session ran as (for resume) |
181
202
  | `~/.frod/bridge.log` | the log — tokens are redacted before anything is written |
182
203
  | `<workdir>/sessions/<id>` | a session's folder (the default) |
183
204
  | the folder the owner chose | a session's folder when the agent's binding names a **working folder on this computer** (see below) |
@@ -216,14 +237,13 @@ is spelled.
216
237
 
217
238
  The bridge talks to a tool through one small interface — `start`, `prompt`,
218
239
  `cancel`, `stop` (`src/adapters/types.ts`). **`claude`**
219
- (`src/adapters/claude.ts`) is required — `join` checks it's installed and
220
- signed in, and it drives Claude Code's headless CLI with your subscription.
221
- **`codex`** (`src/adapters/codex.ts`) and **`grok`** (`src/adapters/grok.ts`)
222
- are optional: each is offered only when it's installed *and* signed in on
223
- this computer. Which agent runs on which adapter is decided in Mission
224
- Control, not here. Grok Build runs in its `workspace` sandbox, and the bridge
225
- answers its permission questions by the same Ask / Never-ask policy as the
226
- others. Each adapter is one file.
240
+ (`src/adapters/claude.ts`), **`codex`** (`src/adapters/codex.ts`), and
241
+ **`grok`** (`src/adapters/grok.ts`) are peers: each is offered only when its
242
+ runtime is installed and explicitly signed in. At least one must be READY;
243
+ none is mandatory by itself. Which agent runs on which offered adapter is
244
+ decided in Mission Control, not here. Grok Build runs in its `workspace`
245
+ sandbox, and the bridge answers its permission questions by the same Ask /
246
+ Never-ask policy as the others. Each adapter is one file.
227
247
 
228
248
  ## Running at login
229
249
 
@@ -242,6 +262,31 @@ others. Each adapter is one file.
242
262
  (`schtasks /Create`), also pointing at `current`. If it refuses, the exact
243
263
  command is printed. Not yet proven on a real Windows machine.
244
264
 
265
+ ## Changelog
266
+
267
+ ### 0.7.4 — unreleased
268
+
269
+ - `join` is runtime-neutral: it admits a computer when at least one of Claude
270
+ Code, Codex, or Grok Build is installed and explicitly signed in, and offers
271
+ exactly that READY set.
272
+ - All three runtime probes use the same tri-state sign-in contract: `true` is
273
+ ready, explicit signed-out is `false`, and unreadable or unestablished state
274
+ is unknown rather than ready.
275
+ - Configuration, announced adapters, version-bearing runtime segments, and
276
+ capacity sampling come from that READY set. The version string keeps the
277
+ compatibility-only `claude ?` sentinel when Claude Code is not READY; the
278
+ sentinel never offers Claude Code.
279
+ - This release is waiting for owner review at the merge queue. It has not been
280
+ merged, published, signed, tagged, announced, deployed, or rolled out; only
281
+ the owner may perform those release and rollout actions in the owner-present
282
+ rollout window.
283
+
284
+ ### 0.7.3 — historical
285
+
286
+ - The previous bridge release retained the Claude-required join preflight.
287
+ Its behavior is historical and is superseded by the unreleased 0.7.4 rule
288
+ above.
289
+
245
290
  ## Publishing
246
291
 
247
292
  `.github/workflows/deploy-bridge.yml` runs on every push to `main` that
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // @frod.io/bridge 0.7.3. One file; do not edit: the source is apps/bridge/src in the platform repository.
2
+ // @frod.io/bridge 0.7.5. 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;
@@ -3706,9 +3706,10 @@ var require_websocket_server = __commonJS({
3706
3706
 
3707
3707
  // src/cli.ts
3708
3708
  import { execFileSync as execFileSync7 } from "node:child_process";
3709
- import { existsSync as existsSync12, mkdirSync as mkdirSync9 } from "node:fs";
3709
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, realpathSync as realpathSync4 } from "node:fs";
3710
3710
  import { homedir as homedir7, hostname as hostname2, platform as platform6 } from "node:os";
3711
3711
  import { dirname as dirname5, join as join11, resolve as resolve6 } from "node:path";
3712
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
3712
3713
  import { spawn as spawn6 } from "node:child_process";
3713
3714
 
3714
3715
  // src/config.ts
@@ -4089,6 +4090,25 @@ var LineBuffer = class {
4089
4090
  // src/session-store.ts
4090
4091
  import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "node:fs";
4091
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
4092
4112
  var PATH = join4(FROD_DIR, "sessions.json");
4093
4113
  function read() {
4094
4114
  if (!existsSync3(PATH)) return {};
@@ -4106,6 +4126,9 @@ function write(store) {
4106
4126
  function getStored(platformSessionId) {
4107
4127
  return read()[platformSessionId] ?? null;
4108
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
+ }
4109
4132
  function putStored(platformSessionId, s) {
4110
4133
  const store = read();
4111
4134
  store[platformSessionId] = s;
@@ -4120,6 +4143,14 @@ function touchStored(platformSessionId) {
4120
4143
  write(store);
4121
4144
  }
4122
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
+ }
4123
4154
 
4124
4155
  // src/log.ts
4125
4156
  var REDACT = [
@@ -4482,24 +4513,23 @@ var ClaudeAdapter = class {
4482
4513
  writeChildren([...this.live.values()].map((l) => l.child?.pid).filter((p) => typeof p === "number"));
4483
4514
  }
4484
4515
  /** `claude --version`, and whether the CLI says it is signed in. Never throws. */
4485
- static check(claudePath) {
4486
- let version = null;
4487
- let loggedIn = null;
4488
- let detail = "";
4516
+ 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());
4519
+ let version;
4489
4520
  try {
4490
- version = execFileSync2(claudePath, ["--version"], { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim();
4521
+ version = versionCommand(claudePath).trim();
4522
+ if (!/\d+\.\d+\.\d+/.test(version)) throw new Error("answered without a valid version");
4491
4523
  } catch (err) {
4492
- detail += `--version failed: ${err instanceof Error ? err.message : String(err)}. `;
4524
+ return { version: null, loggedIn: null, detail: `--version failed: ${err instanceof Error ? err.message : String(err)}` };
4493
4525
  }
4494
4526
  try {
4495
- const raw = execFileSync2(claudePath, ["auth", "status"], { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString();
4496
- const parsed = JSON.parse(raw);
4497
- loggedIn = parsed.loggedIn === true;
4498
- detail += `auth: ${parsed.authMethod ?? "?"}${parsed.subscriptionType ? ` (${parsed.subscriptionType})` : ""}. `;
4527
+ const parsed = JSON.parse(authStatusCommand(claudePath));
4528
+ const loggedIn = parsed.loggedIn === true ? true : parsed.loggedIn === false ? false : null;
4529
+ return { version, loggedIn, detail: `auth: ${parsed.authMethod ?? "?"}${parsed.subscriptionType ? ` (${parsed.subscriptionType})` : ""}` };
4499
4530
  } catch (err) {
4500
- detail += `auth status failed: ${err instanceof Error ? err.message : String(err)}. `;
4531
+ return { version, loggedIn: null, detail: `auth status failed: ${err instanceof Error ? err.message : String(err)}` };
4501
4532
  }
4502
- return { version, loggedIn, detail: detail.trim() };
4503
4533
  }
4504
4534
  async start(spec, o) {
4505
4535
  if (!existsSync4(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
@@ -4860,6 +4890,8 @@ var CodexTranslator = class {
4860
4890
  }
4861
4891
  /** The newest `thread/tokenUsage/updated` of this turn, or null if none arrived. */
4862
4892
  usage = null;
4893
+ /** The running thread counters immediately before this turn's first model call. */
4894
+ billingBase = null;
4863
4895
  /** Item ids whose text was already streamed as deltas — not repeated on completion. */
4864
4896
  streamed = /* @__PURE__ */ new Set();
4865
4897
  /** Tool items announced with `item/started`, so a completion without a start still opens a card. */
@@ -4949,19 +4981,42 @@ var CodexTranslator = class {
4949
4981
  }
4950
4982
  onTokenUsage(p) {
4951
4983
  const u = p.tokenUsage;
4952
- if (u && typeof u === "object" && u.last && u.total) this.usage = u;
4984
+ if (u && typeof u === "object" && u.last && u.total) {
4985
+ if (!this.billingBase) {
4986
+ const total = countersOf(u.total);
4987
+ const last = countersOf(u.last);
4988
+ if (total && last && countersCover(total, last)) this.billingBase = usageDelta(total, last);
4989
+ }
4990
+ this.usage = u;
4991
+ }
4953
4992
  return true;
4954
4993
  }
4955
4994
  };
4956
- function toUsage(usage, model) {
4995
+ function countersOf(breakdown) {
4996
+ const inputTokens = num(breakdown.inputTokens);
4997
+ const outputTokens = num(breakdown.outputTokens);
4998
+ if (inputTokens === null || outputTokens === null) return null;
4999
+ return {
5000
+ inputTokens,
5001
+ outputTokens,
5002
+ cacheCreationTokens: num(breakdown.cacheWriteInputTokens) ?? 0,
5003
+ cacheReadTokens: num(breakdown.cachedInputTokens) ?? 0
5004
+ };
5005
+ }
5006
+ function countersCover(total, part) {
5007
+ return total.inputTokens >= part.inputTokens && total.outputTokens >= part.outputTokens && total.cacheCreationTokens >= part.cacheCreationTokens && total.cacheReadTokens >= part.cacheReadTokens;
5008
+ }
5009
+ function toUsage(usage, model, billingBase = null) {
4957
5010
  if (!usage || !model) return null;
4958
5011
  const { last, total } = usage;
4959
- if (num(total.inputTokens) === null || num(total.outputTokens) === null) return null;
5012
+ const cumulative = countersOf(total);
5013
+ if (!cumulative) return null;
5014
+ const turn = usageDelta(cumulative, billingBase);
4960
5015
  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,
5016
+ input_tokens: Math.max(0, turn.inputTokens - turn.cacheReadTokens),
5017
+ output_tokens: turn.outputTokens,
5018
+ cache_creation_tokens: turn.cacheCreationTokens,
5019
+ cache_read_tokens: turn.cacheReadTokens,
4965
5020
  model,
4966
5021
  context_tokens: num(last.inputTokens),
4967
5022
  context_window: num(usage.modelContextWindow)
@@ -5045,19 +5100,30 @@ var CodexAdapter = class _CodexAdapter {
5045
5100
  return "The Codex adapter needs the `codex` CLI on this computer and a signed-in account (`codex login`).";
5046
5101
  }
5047
5102
  /** The version and whether somebody is signed in — the same shape ClaudeAdapter.check returns. */
5048
- static check(codexPath) {
5049
- let version = null;
5103
+ static check(codexPath, commands = {}) {
5104
+ const versionCommand = commands.version ?? ((path) => execFileSync3(path, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString());
5105
+ const loginStatusCommand = commands.loginStatus ?? ((path) => {
5106
+ const res = spawnSync(path, ["login", "status"], { encoding: "utf8", timeout: 3e4 });
5107
+ if (res.error) throw res.error;
5108
+ return `${res.stdout ?? ""}${res.stderr ?? ""}`;
5109
+ });
5110
+ let version;
5050
5111
  try {
5051
- version = execFileSync3(codexPath, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString().trim().split(/\s+/).pop() ?? null;
5052
- } catch {
5053
- return { version: null, loggedIn: null, detail: `${codexPath} did not run \u2014 Codex is not installed here` };
5112
+ const raw = versionCommand(codexPath).trim();
5113
+ version = /(\d+\.\d+\.\d+[0-9A-Za-z.-]*)/.exec(raw)?.[1] ?? "";
5114
+ if (!version) throw new Error("answered without a valid version");
5115
+ } catch (err) {
5116
+ return { version: null, loggedIn: null, detail: `${codexPath} did not run \u2014 ${err instanceof Error ? err.message : String(err)}` };
5054
5117
  }
5055
- const res = spawnSync(codexPath, ["login", "status"], { encoding: "utf8", timeout: 3e4 });
5056
- const out = `${res.stdout ?? ""}${res.stderr ?? ""}`.trim();
5057
- if (res.error || !out) {
5058
- return { version, loggedIn: false, detail: out || "could not read the sign-in state \u2014 run `codex login`" };
5118
+ try {
5119
+ const out = loginStatusCommand(codexPath).trim();
5120
+ const detail = out.split("\n").find((line) => line.trim().length > 0)?.trim() || "could not read the sign-in state \u2014 run `codex login`";
5121
+ if (/\b(?:not logged in|not signed in|not authenticated|unauthenticated)\b/i.test(out)) return { version, loggedIn: false, detail };
5122
+ if (/\blogged in\b/i.test(out)) return { version, loggedIn: true, detail };
5123
+ return { version, loggedIn: null, detail };
5124
+ } catch (err) {
5125
+ return { version, loggedIn: null, detail: `could not read the sign-in state: ${err instanceof Error ? err.message : String(err)}` };
5059
5126
  }
5060
- return { version, loggedIn: /logged in/i.test(out), detail: out.split("\n")[0] ?? "" };
5061
5127
  }
5062
5128
  /**
5063
5129
  * A session's credential tier. `codex login status` says how the person
@@ -5221,7 +5287,8 @@ var CodexAdapter = class _CodexAdapter {
5221
5287
  const status2 = typeof turn.status === "string" ? turn.status : "completed";
5222
5288
  const err = turn.error;
5223
5289
  const stopReason = status2 === "interrupted" ? "cancelled" : status2 === "failed" ? "error" : "end_turn";
5224
- const usage = toUsage(live.turn?.translator.usage ?? null, live.model);
5290
+ const translator = live.turn?.translator;
5291
+ const usage = toUsage(translator?.usage ?? null, live.model, translator?.billingBase ?? null);
5225
5292
  live.turnId = null;
5226
5293
  this.settleTurn(live, {
5227
5294
  stopReason,
@@ -5560,19 +5627,29 @@ function stopReasonFor(acp) {
5560
5627
  }
5561
5628
  }
5562
5629
  var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
5563
- function toUsage2(meta, fallbackModel, contextWindow) {
5630
+ function cumulativeUsageOf(meta) {
5564
5631
  const total = meta?.usage;
5632
+ if (!total) return null;
5633
+ const inputTokens = num2(total.inputTokens);
5634
+ const outputTokens = num2(total.outputTokens);
5635
+ if (inputTokens === null || outputTokens === null) return null;
5636
+ return {
5637
+ inputTokens,
5638
+ outputTokens,
5639
+ cacheCreationTokens: num2(total.cacheCreationTokens) ?? 0,
5640
+ cacheReadTokens: num2(total.cachedReadTokens) ?? 0
5641
+ };
5642
+ }
5643
+ function toUsage2(meta, fallbackModel, contextWindow, previous = null) {
5565
5644
  const model = str2(meta?.modelId) ?? fallbackModel;
5566
- if (!total || !model) return null;
5567
- const input = num2(total.inputTokens);
5568
- const output = num2(total.outputTokens);
5569
- if (input === null || output === null) return null;
5570
- const cacheRead = num2(total.cachedReadTokens) ?? 0;
5645
+ const cumulative = cumulativeUsageOf(meta);
5646
+ if (!cumulative || !model) return null;
5647
+ const turn = usageDelta(cumulative, previous);
5571
5648
  return {
5572
- input_tokens: Math.max(0, input - cacheRead),
5573
- output_tokens: output,
5574
- cache_creation_tokens: num2(total.cacheCreationTokens) ?? 0,
5575
- cache_read_tokens: cacheRead,
5649
+ input_tokens: Math.max(0, turn.inputTokens - turn.cacheReadTokens),
5650
+ output_tokens: turn.outputTokens,
5651
+ cache_creation_tokens: turn.cacheCreationTokens,
5652
+ cache_read_tokens: turn.cacheReadTokens,
5576
5653
  model,
5577
5654
  context_tokens: num2(meta?.inputTokens),
5578
5655
  context_window: contextWindow
@@ -5754,23 +5831,32 @@ var GrokAdapter = class _GrokAdapter {
5754
5831
  * says "You are not authenticated." and opens no browser, so this is safe
5755
5832
  * to run unattended at every start.
5756
5833
  */
5757
- static check(grokPath) {
5834
+ static check(grokPath, commands = {}) {
5758
5835
  const env = { ...process.env, GROK_DISABLE_AUTOUPDATER: "1" };
5759
- let version = null;
5836
+ const versionCommand = commands.version ?? ((path) => execFileSync4(path, ["--version"], { env, stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString());
5837
+ const modelsCommand = commands.models ?? ((path) => {
5838
+ const res = spawnSync2(path, ["models"], { env, encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
5839
+ if (res.error) throw res.error;
5840
+ return `${res.stdout ?? ""}
5841
+ ${res.stderr ?? ""}`;
5842
+ });
5843
+ let version;
5760
5844
  try {
5761
- const out2 = execFileSync4(grokPath, ["--version"], { env, stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString();
5762
- version = /(\d+\.\d+\.\d+[0-9A-Za-z.-]*)/.exec(out2)?.[1] ?? null;
5763
- } catch {
5764
- return { version: null, loggedIn: null, detail: `${grokPath} did not run \u2014 Grok Build is not installed here` };
5845
+ const out = versionCommand(grokPath);
5846
+ version = /(\d+\.\d+\.\d+[0-9A-Za-z.-]*)/.exec(out)?.[1] ?? "";
5847
+ if (!version) throw new Error("answered without a valid version");
5848
+ } catch (err) {
5849
+ return { version: null, loggedIn: null, detail: `${grokPath} did not run \u2014 ${err instanceof Error ? err.message : String(err)}` };
5850
+ }
5851
+ try {
5852
+ const out = modelsCommand(grokPath).trim();
5853
+ const first = out.split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? "";
5854
+ if (/\b(?:not logged in|not signed in|not authenticated|unauthenticated)\b/i.test(out)) return { version, loggedIn: false, detail: "not signed in \u2014 run `grok login`" };
5855
+ if (/\blogged in\b/i.test(out)) return { version, loggedIn: true, detail: first };
5856
+ return { version, loggedIn: null, detail: first || "could not read the sign-in state \u2014 run `grok login`" };
5857
+ } catch (err) {
5858
+ return { version, loggedIn: null, detail: `could not read the sign-in state: ${err instanceof Error ? err.message : String(err)}` };
5765
5859
  }
5766
- const res = spawnSync2(grokPath, ["models"], { env, encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
5767
- const out = `${res.stdout ?? ""}
5768
- ${res.stderr ?? ""}`.trim();
5769
- const first = out.split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
5770
- if (res.error || !out) return { version, loggedIn: false, detail: first || "could not read the sign-in state \u2014 run `grok login`" };
5771
- if (/logged in/i.test(out)) return { version, loggedIn: true, detail: first };
5772
- if (/not authenticated/i.test(out)) return { version, loggedIn: false, detail: "not signed in \u2014 run `grok login`" };
5773
- return { version, loggedIn: false, detail: first };
5774
5860
  }
5775
5861
  /** A grok.com sign-in is the subscription; anything else that runs is a key (`XAI_API_KEY`). */
5776
5862
  credentialTier() {
@@ -5789,6 +5875,8 @@ ${res.stderr ?? ""}`.trim();
5789
5875
  turn: null,
5790
5876
  model: spec.model?.name ?? null,
5791
5877
  contextWindow: null,
5878
+ cumulativeUsage: null,
5879
+ needsUsageBaseline: false,
5792
5880
  onExit: o.onExit,
5793
5881
  askPermission: o.askPermission,
5794
5882
  reportAutoAllowed: o.reportAutoAllowed,
@@ -5819,6 +5907,10 @@ ${res.stderr ?? ""}`.trim();
5819
5907
  const mcpServers = mcpServersFor(spec);
5820
5908
  const params = { cwd: o.sessionDir, mcpServers, _meta: sessionMeta(spec) };
5821
5909
  const resumeId = spec.resume_session_id ?? null;
5910
+ const resumedStore = resumeId ? getStoredByHostSessionId(resumeId) : null;
5911
+ if (resumedStore?.cumulativeUsage?.adapter === "grok" && isUsageCounters(resumedStore.cumulativeUsage.counters)) {
5912
+ live.cumulativeUsage = { ...resumedStore.cumulativeUsage.counters };
5913
+ }
5822
5914
  let resumed = false;
5823
5915
  let started;
5824
5916
  if (resumeId) {
@@ -5830,12 +5922,25 @@ ${res.stderr ?? ""}`.trim();
5830
5922
  }
5831
5923
  }
5832
5924
  if (!started) started = await this.request(live, "session/new", params);
5925
+ if (!resumed) live.cumulativeUsage = null;
5926
+ live.needsUsageBaseline = resumed && !live.cumulativeUsage;
5833
5927
  const sessionId = typeof started?.sessionId === "string" ? started.sessionId : null;
5834
5928
  if (!sessionId) throw new Error("Grok Build started but did not name the session \u2014 it cannot be addressed.");
5835
5929
  live.acpSessionId = sessionId;
5836
5930
  const models = started?.models ?? init?._meta?.modelState ?? null;
5837
5931
  live.model = live.model ?? (typeof models?.currentModelId === "string" ? models.currentModelId : null);
5838
5932
  live.contextWindow = contextWindowOf(models, live.model);
5933
+ try {
5934
+ putStored(spec.session_id, {
5935
+ hostSessionId: sessionId,
5936
+ cwd: o.sessionDir,
5937
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5938
+ lastPromptAt: null,
5939
+ ...resumed && live.cumulativeUsage ? { cumulativeUsage: { adapter: "grok", counters: { ...live.cumulativeUsage } } } : {}
5940
+ });
5941
+ } catch (err) {
5942
+ log.warn(`[${spec.session_id.slice(0, 8)}] grok: could not open the usage-counter record (${err instanceof Error ? err.message : String(err)})`);
5943
+ }
5839
5944
  log.event("grok.started", {
5840
5945
  session: spec.session_id,
5841
5946
  acp_session: sessionId,
@@ -5860,7 +5965,21 @@ ${res.stderr ?? ""}`.trim();
5860
5965
  const meta = r._meta ?? null;
5861
5966
  if (typeof meta?.modelId === "string" && meta.modelId) live.model = meta.modelId;
5862
5967
  const { stopReason, error } = stopReasonFor(r.stopReason);
5863
- this.settleTurn(live, { stopReason, usage: toUsage2(meta, live.model, live.contextWindow), ...error ? { error } : {} });
5968
+ const cumulative = cumulativeUsageOf(meta);
5969
+ const usage = live.needsUsageBaseline ? null : toUsage2(meta, live.model, live.contextWindow, live.cumulativeUsage);
5970
+ if (cumulative) {
5971
+ if (live.needsUsageBaseline) {
5972
+ 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`);
5973
+ live.needsUsageBaseline = false;
5974
+ }
5975
+ live.cumulativeUsage = cumulative;
5976
+ try {
5977
+ putStoredUsage(live.spec.session_id, "grok", cumulative);
5978
+ } catch (err) {
5979
+ log.warn(`[${live.spec.session_id.slice(0, 8)}] grok: could not remember usage counters (${err instanceof Error ? err.message : String(err)})`);
5980
+ }
5981
+ }
5982
+ this.settleTurn(live, { stopReason, usage, ...error ? { error } : {} });
5864
5983
  }).catch((err) => {
5865
5984
  this.settleTurn(live, { stopReason: "error", usage: null, error: err.message });
5866
5985
  });
@@ -7770,6 +7889,8 @@ function askChild(input) {
7770
7889
  } catch {
7771
7890
  }
7772
7891
  };
7892
+ child.stdin?.on("error", () => {
7893
+ });
7773
7894
  child.on("error", (err) => finish({ problem: `could not start: ${err.message}` }));
7774
7895
  child.on("exit", (code) => {
7775
7896
  if (!done) finish({ problem: `ended (exit ${code ?? "signal"}) before answering` });
@@ -7829,8 +7950,56 @@ function samplesInWords(samples) {
7829
7950
  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";
7830
7951
  }
7831
7952
 
7953
+ // package.json
7954
+ var package_default = {
7955
+ name: "@frod.io/bridge",
7956
+ version: "0.7.5",
7957
+ type: "module",
7958
+ 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
+ license: "UNLICENSED",
7960
+ homepage: "https://frod.io",
7961
+ repository: {
7962
+ type: "git",
7963
+ url: "https://github.com/awafeek/agentic-engineering.git",
7964
+ directory: "apps/bridge"
7965
+ },
7966
+ bin: {
7967
+ "frod-bridge": "dist/cli.js"
7968
+ },
7969
+ files: [
7970
+ "dist",
7971
+ "README.md"
7972
+ ],
7973
+ engines: {
7974
+ node: ">=20"
7975
+ },
7976
+ publishConfig: {
7977
+ access: "public"
7978
+ },
7979
+ scripts: {
7980
+ build: "node scripts/bundle.mjs && tsc --noEmit",
7981
+ bundle: "node scripts/bundle.mjs",
7982
+ lint: "eslint src",
7983
+ test: "vitest run --passWithNoTests",
7984
+ "type-check": "tsc --noEmit",
7985
+ "pack:check": "npm pack --dry-run"
7986
+ },
7987
+ devDependencies: {
7988
+ "@eslint/js": "^9.39.3",
7989
+ "@types/node": "^22.0.0",
7990
+ "@types/ws": "^8.18.1",
7991
+ esbuild: "^0.27.3",
7992
+ eslint: "^9.0.0",
7993
+ tsx: "^4.19.0",
7994
+ typescript: "^5.7.0",
7995
+ "typescript-eslint": "^8.56.1",
7996
+ vitest: "^4.0.18",
7997
+ ws: "^8.21.3"
7998
+ }
7999
+ };
8000
+
7832
8001
  // src/cli.ts
7833
- var VERSION = "0.7.3";
8002
+ var VERSION = package_default.version;
7834
8003
  function arg(flag, argv) {
7835
8004
  const i = argv.indexOf(flag);
7836
8005
  return i >= 0 ? argv[i + 1] : void 0;
@@ -7842,7 +8011,7 @@ function findClaude() {
7842
8011
  return execFileSync7("/usr/bin/which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7843
8012
  } catch {
7844
8013
  }
7845
- throw new Error("claude was not found. Install Claude Code and sign in (claude auth login), then try again.");
8014
+ return null;
7846
8015
  }
7847
8016
  function findCodex() {
7848
8017
  const candidates = [process.env.FROD_CODEX_PATH, join11(homedir7(), ".local", "bin", "codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex"].filter((c) => !!c);
@@ -7864,33 +8033,54 @@ function findGrok() {
7864
8033
  return null;
7865
8034
  }
7866
8035
  }
7867
- function buildAdapters(cfg, permissions) {
7868
- const adapters = {
7869
- claude: new ClaudeAdapter({ claudePath: cfg.claudePath, permissions })
8036
+ var RUNTIME_ORDER = ["claude", "codex", "grok"];
8037
+ var RUNTIME_LABELS = { claude: "Claude Code", codex: "Codex", grok: "Grok Build" };
8038
+ var INSTALL_HINTS = { claude: "Install Claude Code.", codex: "Install Codex.", grok: "Install Grok Build." };
8039
+ var RUNTIME_PREFLIGHT_REFUSAL = "No supported runtime is ready. Sign in and try again: Claude Code (`claude auth login`), Codex (`codex login`), and Grok Build (`grok login`).";
8040
+ function checkedRuntime(name, path) {
8041
+ if (!path) return { path: null, version: null, loggedIn: null, detail: `not installed on this computer` };
8042
+ try {
8043
+ const check = name === "claude" ? ClaudeAdapter.check(path) : name === "codex" ? CodexAdapter.check(path) : GrokAdapter.check(path);
8044
+ return { path, ...check };
8045
+ } catch (err) {
8046
+ return { path, version: null, loggedIn: null, detail: `could not run: ${err instanceof Error ? err.message : String(err)}` };
8047
+ }
8048
+ }
8049
+ var DEFAULT_RUNTIME_PROBES = {
8050
+ claude: (preferredPath) => checkedRuntime("claude", preferredPath ?? findClaude()),
8051
+ codex: (preferredPath) => checkedRuntime("codex", preferredPath ?? findCodex()),
8052
+ grok: (preferredPath) => checkedRuntime("grok", preferredPath ?? findGrok())
8053
+ };
8054
+ function probeAllRuntimes(probes, preferred) {
8055
+ return {
8056
+ claude: probes.claude(preferred?.claudePath),
8057
+ codex: probes.codex(preferred?.codexPath),
8058
+ grok: probes.grok(preferred?.grokPath)
7870
8059
  };
7871
- const codexPath = cfg.codexPath ?? findCodex();
7872
- let codexDetail = "not installed on this computer";
7873
- let codexVersion = null;
7874
- if (codexPath) {
7875
- const c = CodexAdapter.check(codexPath);
7876
- codexDetail = `${c.version ?? "unknown version"} \u2014 ${c.detail}`;
7877
- if (c.loggedIn) {
7878
- adapters.codex = new CodexAdapter({ codexPath });
7879
- codexVersion = c.version;
7880
- }
7881
- }
7882
- const grokPath = cfg.grokPath ?? findGrok();
7883
- let grokDetail = "not installed on this computer";
7884
- let grokVersion = null;
7885
- if (grokPath) {
7886
- const g = GrokAdapter.check(grokPath);
7887
- grokDetail = `${g.version ?? "unknown version"} \u2014 ${g.detail}`;
7888
- if (g.loggedIn) {
7889
- adapters.grok = new GrokAdapter({ grokPath });
7890
- grokVersion = g.version;
7891
- }
7892
- }
7893
- return { adapters, names: Object.keys(adapters), codexDetail, codexVersion, grokDetail, grokVersion };
8060
+ }
8061
+ function isReady(probe) {
8062
+ return probe.path !== null && probe.version !== null && probe.loggedIn === true;
8063
+ }
8064
+ function readyNames(probes) {
8065
+ return RUNTIME_ORDER.filter((name) => isReady(probes[name]));
8066
+ }
8067
+ function runtimePreflightRefusal(probes) {
8068
+ const hints = RUNTIME_ORDER.filter((name) => probes[name].path === null).map((name) => INSTALL_HINTS[name]);
8069
+ return `${RUNTIME_PREFLIGHT_REFUSAL}${hints.length > 0 ? ` ${hints.join(" ")}` : ""}`;
8070
+ }
8071
+ function versionForReadySet(probes) {
8072
+ return runtimeVersionString2(
8073
+ isReady(probes.claude) ? probes.claude.version : null,
8074
+ isReady(probes.codex) ? probes.codex.version : null,
8075
+ isReady(probes.grok) ? probes.grok.version : null
8076
+ );
8077
+ }
8078
+ function buildAdapters(probes, permissions) {
8079
+ const adapters = {};
8080
+ if (isReady(probes.claude)) adapters.claude = new ClaudeAdapter({ claudePath: probes.claude.path, permissions });
8081
+ if (isReady(probes.codex)) adapters.codex = new CodexAdapter({ codexPath: probes.codex.path });
8082
+ if (isReady(probes.grok)) adapters.grok = new GrokAdapter({ grokPath: probes.grok.path });
8083
+ return { adapters, names: readyNames(probes) };
7894
8084
  }
7895
8085
  function runtimeVersionString2(claudeVersion, codexVersion, grokVersion = null) {
7896
8086
  return runtimeVersionString(VERSION, claudeVersion, codexVersion, grokVersion);
@@ -7904,37 +8094,42 @@ function tokenFor(cfg) {
7904
8094
  if (!token) throw new Error("The host token is missing from the keychain. Ask for a new join code and join again.");
7905
8095
  return token;
7906
8096
  }
7907
- async function cmdJoin(argv) {
8097
+ async function cmdJoin(argv, overrides = {}) {
8098
+ const deps = {
8099
+ readConfig,
8100
+ probes: DEFAULT_RUNTIME_PROBES,
8101
+ join: join3,
8102
+ storeToken,
8103
+ ensureWorkdir: (path) => {
8104
+ if (!existsSync12(path)) mkdirSync9(path, { recursive: true, mode: 448 });
8105
+ },
8106
+ writeConfig,
8107
+ reportGitAccess,
8108
+ install: cmdInstall,
8109
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
8110
+ ...overrides
8111
+ };
7908
8112
  const code = argv[0];
7909
8113
  const apiUrl = arg("--api", argv) ?? process.env.FROD_API_URL;
7910
8114
  if (!code || !apiUrl) throw new Error(`Usage: join <code> --api https://api.agentic.frod.io [--name "Wafeek's Mac"] [--workdir <dir>] [--max-sessions N]`);
7911
- const existing = readConfig();
8115
+ const existing = deps.readConfig();
7912
8116
  const refusal = existing ? secondJoinWords(existing) : null;
7913
8117
  if (refusal) throw new Error(refusal);
7914
8118
  const safeApi = assertSafeEndpoint(apiUrl);
7915
8119
  const name = arg("--name", argv) ?? defaultName();
7916
8120
  const workdir = resolve6(arg("--workdir", argv) ?? join11(homedir7(), "frod-work"));
7917
8121
  const maxSessions = Math.max(1, Math.min(20, Number(arg("--max-sessions", argv) ?? 2)));
7918
- const claudePath = findClaude();
7919
- const check = ClaudeAdapter.check(claudePath);
7920
- log.info(`Claude Code: ${check.version ?? "unknown version"} \u2014 ${check.detail}`);
7921
- if (check.loggedIn === false) throw new Error("Claude Code is not signed in on this computer. Run `claude auth login` first.");
8122
+ const probes = probeAllRuntimes(deps.probes);
8123
+ for (const runtime of RUNTIME_ORDER) {
8124
+ const probe = probes[runtime];
8125
+ log.info(`${RUNTIME_LABELS[runtime]}: ${probe.version ?? (probe.path ? "unknown version" : "not installed")} \u2014 ${probe.detail}`);
8126
+ }
8127
+ const adapters = readyNames(probes);
8128
+ if (adapters.length === 0) throw new Error(runtimePreflightRefusal(probes));
7922
8129
  log.info(`Joining ${safeApi} as "${name}"\u2026`);
7923
- const codexAtJoin = (() => {
7924
- const cp = findCodex();
7925
- if (!cp) return null;
7926
- const c = CodexAdapter.check(cp);
7927
- return c.loggedIn ? c.version : null;
7928
- })();
7929
- const grokAtJoin = (() => {
7930
- const gp = findGrok();
7931
- if (!gp) return null;
7932
- const g = GrokAdapter.check(gp);
7933
- return g.loggedIn ? g.version : null;
7934
- })();
7935
- const joined = await join3(safeApi, code, name, { os: platform6(), version: runtimeVersionString2(check.version, codexAtJoin, grokAtJoin) });
7936
- const store = storeToken(joined.hostId, joined.token);
7937
- if (!existsSync12(workdir)) mkdirSync9(workdir, { recursive: true, mode: 448 });
8130
+ const joined = await deps.join(safeApi, code, name, { os: platform6(), version: versionForReadySet(probes) });
8131
+ const store = deps.storeToken(joined.hostId, joined.token);
8132
+ deps.ensureWorkdir(workdir);
7938
8133
  const cfg = {
7939
8134
  apiUrl: safeApi,
7940
8135
  hostId: joined.hostId,
@@ -7944,26 +8139,28 @@ async function cmdJoin(argv) {
7944
8139
  name,
7945
8140
  workdir,
7946
8141
  maxSessions,
7947
- adapters: ["claude"],
7948
- claudePath,
8142
+ adapters,
8143
+ claudePath: isReady(probes.claude) ? probes.claude.path : null,
8144
+ codexPath: isReady(probes.codex) ? probes.codex.path : void 0,
8145
+ grokPath: isReady(probes.grok) ? probes.grok.path : void 0,
7949
8146
  tokenStore: store,
7950
8147
  keychainAccount: joined.hostId,
7951
- joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
8148
+ joinedAt: deps.now(),
7952
8149
  dormant: null,
7953
8150
  bindings: joined.bindings ?? []
7954
8151
  };
7955
- writeConfig(cfg);
8152
+ deps.writeConfig(cfg);
7956
8153
  log.info(`Joined. This computer is now "${name}" \u2014 ${joined.person?.name ?? "your"} computer in ${joined.workspaceName ?? joined.workspaceId} \u2014 hosting ${hostedAgentsWords(cfg)}.`);
7957
8154
  for (const b of cfg.bindings ?? []) log.info(` ${b.agentName}: ${bindingWords(b)}`);
7958
8155
  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)}.`);
7959
8156
  log.info(`Settings: ${CONFIG_PATH}. Sessions work under ${workdir}/sessions. Up to ${maxSessions} at a time, across every agent this computer hosts.`);
7960
8157
  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.`);
7961
- await reportGitAccess(arg("--repo", argv));
8158
+ await deps.reportGitAccess(arg("--repo", argv));
7962
8159
  if (argv.includes("--no-install")) {
7963
8160
  log.info("Next: `install` to run at login, or `run` to run now.");
7964
8161
  return;
7965
8162
  }
7966
- await cmdInstall();
8163
+ await deps.install();
7967
8164
  }
7968
8165
  async function reportGitAccess(repoUrl) {
7969
8166
  let version = "not found";
@@ -7999,24 +8196,30 @@ async function cmdRun() {
7999
8196
  ClaudeAdapter.reapOrphans();
8000
8197
  const pruned = pruneRunDirs(cfg.workdir);
8001
8198
  if (pruned > 0) log.event("repo.runs_pruned", { count: pruned });
8199
+ const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
8200
+ const ready = readyNames(probes);
8201
+ if (ready.length === 0) throw new Error(runtimePreflightRefusal(probes));
8002
8202
  const permissions = new PermissionServer();
8003
8203
  await permissions.start();
8004
- const { adapters, names, codexDetail, codexVersion, grokDetail, grokVersion } = buildAdapters(cfg, permissions);
8005
- const check = ClaudeAdapter.check(cfg.claudePath);
8006
- log.info(`Runtimes this computer hosts: ${names.join(", ")}. Codex: ${codexDetail}. Grok Build: ${grokDetail}.`);
8007
- if (JSON.stringify(cfg.adapters ?? []) !== JSON.stringify(names)) {
8008
- cfg.adapters = names;
8204
+ const { adapters, names } = buildAdapters(probes, permissions);
8205
+ log.info(`Runtimes this computer hosts: ${names.join(", ")}. ${RUNTIME_ORDER.map((name) => `${RUNTIME_LABELS[name]}: ${probes[name].version ?? (probes[name].path ? "unknown version" : "not installed")} \u2014 ${probes[name].detail}`).join(". ")}.`);
8206
+ const runtimeConfigBefore = JSON.stringify({ adapters: cfg.adapters ?? [], claudePath: cfg.claudePath, codexPath: cfg.codexPath, grokPath: cfg.grokPath });
8207
+ cfg.adapters = names;
8208
+ cfg.claudePath = isReady(probes.claude) ? probes.claude.path : null;
8209
+ cfg.codexPath = isReady(probes.codex) ? probes.codex.path : void 0;
8210
+ cfg.grokPath = isReady(probes.grok) ? probes.grok.path : void 0;
8211
+ if (runtimeConfigBefore !== JSON.stringify({ adapters: cfg.adapters, claudePath: cfg.claudePath, codexPath: cfg.codexPath, grokPath: cfg.grokPath })) {
8009
8212
  writeConfig(cfg);
8010
8213
  }
8011
- log.event("bridge.start", { version: VERSION, name: cfg.name, host_id: cfg.hostId, agents: (cfg.bindings ?? []).map((b) => b.agentName), api: cfg.apiUrl, claude: check.version, logged_in: check.loggedIn, max_sessions: cfg.maxSessions, os: platform6(), runs_from: runLocation().installedVersion ? `versions/${runLocation().installedVersion}` : runLocation().packageRoot });
8012
- const codexPath = adapters.codex ? cfg.codexPath ?? findCodex() : null;
8013
- const grokPath = adapters.grok ? cfg.grokPath ?? findGrok() : null;
8214
+ log.event("bridge.start", { version: VERSION, name: cfg.name, host_id: cfg.hostId, agents: (cfg.bindings ?? []).map((b) => b.agentName), api: cfg.apiUrl, claude: probes.claude.version, logged_in: probes.claude.loggedIn, max_sessions: cfg.maxSessions, os: platform6(), runs_from: runLocation().installedVersion ? `versions/${runLocation().installedVersion}` : runLocation().packageRoot });
8215
+ const codexPath = isReady(probes.codex) ? probes.codex.path : null;
8216
+ const grokPath = isReady(probes.grok) ? probes.grok.path : null;
8014
8217
  let sampling = false;
8015
8218
  const sampleAndSend = async () => {
8016
8219
  if (sampling) return;
8017
8220
  sampling = true;
8018
8221
  try {
8019
- const { at, samples } = await sampleCapacity({ claude: check.loggedIn !== false, codexPath, grokPath });
8222
+ const { at, samples } = await sampleCapacity({ claude: isReady(probes.claude), codexPath, grokPath });
8020
8223
  conn.sendHost("host/capacity", { at, samples });
8021
8224
  log.event("capacity.sampled", { at, words: samplesInWords(samples) });
8022
8225
  } catch (err) {
@@ -8061,7 +8264,7 @@ async function cmdRun() {
8061
8264
  config: cfg,
8062
8265
  token,
8063
8266
  adapters,
8064
- version: runtimeVersionString2(check.version, codexVersion, grokVersion),
8267
+ version: versionForReadySet(probes),
8065
8268
  onFinal: (reason2) => {
8066
8269
  writeConfig({ ...cfg, dormant: { reason: reason2, at: (/* @__PURE__ */ new Date()).toISOString() } });
8067
8270
  log.warn(dormantWords({ reason: reason2, at: (/* @__PURE__ */ new Date()).toISOString() }, hostedAgentsWords(cfg)));
@@ -8118,17 +8321,14 @@ async function cmdStatus(argv) {
8118
8321
  if (cfg.dormant) log.warn(dormantWords(cfg.dormant, hostedAgentsWords(cfg)));
8119
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.`);
8120
8323
  log.info(`Working folders an agent may be given here: ${allowedRootsOf(cfg).join(", ")} (add more under "allowedRoots" in ${CONFIG_PATH}).`);
8121
- const check = ClaudeAdapter.check(cfg.claudePath);
8122
- log.info(`Claude Code: ${check.version ?? "unknown"} \u2014 ${check.detail}`);
8324
+ const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
8325
+ for (const runtime of RUNTIME_ORDER) {
8326
+ const probe = probes[runtime];
8327
+ log.info(`${RUNTIME_LABELS[runtime]}: ${probe.version ?? (probe.path ? "unknown version" : "not installed")} \u2014 ${probe.detail}`);
8328
+ }
8123
8329
  const loc = runLocation();
8124
8330
  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"}.`);
8125
8331
  if (platform6() === "darwin") log.info(`Runs at login: ${launchdLoaded() ? "yes (launchd)" : "no \u2014 run install"}`);
8126
- const codexPath = cfg.codexPath ?? findCodex();
8127
- const codexCheck = codexPath ? CodexAdapter.check(codexPath) : null;
8128
- log.info(`Codex: ${codexCheck ? `${codexCheck.version ?? "unknown"} \u2014 ${codexCheck.detail}` : CodexAdapter.notYet}`);
8129
- const grokPath = cfg.grokPath ?? findGrok();
8130
- const grokCheck = grokPath ? GrokAdapter.check(grokPath) : null;
8131
- log.info(`Grok Build: ${grokCheck ? `${grokCheck.version ?? "unknown"} \u2014 ${grokCheck.detail}` : GrokAdapter.notYet}`);
8132
8332
  log.info("Which agent runs on which runtime is the platform's to say \u2014 attach or detach agents in Mission Control.");
8133
8333
  await reportGitAccess(arg("--repo", argv));
8134
8334
  const token = readToken(cfg.keychainAccount, cfg.tokenStore);
@@ -8263,10 +8463,19 @@ async function main() {
8263
8463
  process.exit(cmd ? 2 : 0);
8264
8464
  }
8265
8465
  }
8266
- main().catch((err) => {
8267
- log.error(err instanceof Error ? err.message : String(err));
8268
- process.exit(1);
8269
- });
8466
+ var isEntrypoint = false;
8467
+ try {
8468
+ isEntrypoint = !!process.argv[1] && realpathSync4(process.argv[1]) === realpathSync4(fileURLToPath2(import.meta.url));
8469
+ } catch {
8470
+ }
8471
+ if (isEntrypoint) {
8472
+ main().catch((err) => {
8473
+ log.error(err instanceof Error ? err.message : String(err));
8474
+ process.exit(1);
8475
+ });
8476
+ }
8270
8477
  export {
8478
+ RUNTIME_PREFLIGHT_REFUSAL,
8479
+ cmdJoin,
8271
8480
  runtimeVersionString2 as runtimeVersionString
8272
8481
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frod.io/bridge",
3
- "version": "0.7.3",
3
+ "version": "0.7.5",
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",
@@ -32,11 +32,14 @@
32
32
  "pack:check": "npm pack --dry-run"
33
33
  },
34
34
  "devDependencies": {
35
+ "@eslint/js": "^9.39.3",
35
36
  "@types/node": "^22.0.0",
36
37
  "@types/ws": "^8.18.1",
37
38
  "esbuild": "^0.27.3",
39
+ "eslint": "^9.0.0",
38
40
  "tsx": "^4.19.0",
39
41
  "typescript": "^5.7.0",
42
+ "typescript-eslint": "^8.56.1",
40
43
  "vitest": "^4.0.18",
41
44
  "ws": "^8.21.3"
42
45
  }