@frod.io/bridge 0.7.3 → 0.7.4

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 +175 -115
  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.4. 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
@@ -4482,24 +4483,23 @@ var ClaudeAdapter = class {
4482
4483
  writeChildren([...this.live.values()].map((l) => l.child?.pid).filter((p) => typeof p === "number"));
4483
4484
  }
4484
4485
  /** `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 = "";
4486
+ 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());
4489
+ let version;
4489
4490
  try {
4490
- version = execFileSync2(claudePath, ["--version"], { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim();
4491
+ version = versionCommand(claudePath).trim();
4492
+ if (!/\d+\.\d+\.\d+/.test(version)) throw new Error("answered without a valid version");
4491
4493
  } catch (err) {
4492
- detail += `--version failed: ${err instanceof Error ? err.message : String(err)}. `;
4494
+ return { version: null, loggedIn: null, detail: `--version failed: ${err instanceof Error ? err.message : String(err)}` };
4493
4495
  }
4494
4496
  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})` : ""}. `;
4497
+ const parsed = JSON.parse(authStatusCommand(claudePath));
4498
+ const loggedIn = parsed.loggedIn === true ? true : parsed.loggedIn === false ? false : null;
4499
+ return { version, loggedIn, detail: `auth: ${parsed.authMethod ?? "?"}${parsed.subscriptionType ? ` (${parsed.subscriptionType})` : ""}` };
4499
4500
  } catch (err) {
4500
- detail += `auth status failed: ${err instanceof Error ? err.message : String(err)}. `;
4501
+ return { version, loggedIn: null, detail: `auth status failed: ${err instanceof Error ? err.message : String(err)}` };
4501
4502
  }
4502
- return { version, loggedIn, detail: detail.trim() };
4503
4503
  }
4504
4504
  async start(spec, o) {
4505
4505
  if (!existsSync4(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
@@ -5045,19 +5045,30 @@ var CodexAdapter = class _CodexAdapter {
5045
5045
  return "The Codex adapter needs the `codex` CLI on this computer and a signed-in account (`codex login`).";
5046
5046
  }
5047
5047
  /** The version and whether somebody is signed in — the same shape ClaudeAdapter.check returns. */
5048
- static check(codexPath) {
5049
- let version = null;
5048
+ static check(codexPath, commands = {}) {
5049
+ const versionCommand = commands.version ?? ((path) => execFileSync3(path, ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString());
5050
+ const loginStatusCommand = commands.loginStatus ?? ((path) => {
5051
+ const res = spawnSync(path, ["login", "status"], { encoding: "utf8", timeout: 3e4 });
5052
+ if (res.error) throw res.error;
5053
+ return `${res.stdout ?? ""}${res.stderr ?? ""}`;
5054
+ });
5055
+ let version;
5050
5056
  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` };
5057
+ const raw = versionCommand(codexPath).trim();
5058
+ version = /(\d+\.\d+\.\d+[0-9A-Za-z.-]*)/.exec(raw)?.[1] ?? "";
5059
+ if (!version) throw new Error("answered without a valid version");
5060
+ } catch (err) {
5061
+ return { version: null, loggedIn: null, detail: `${codexPath} did not run \u2014 ${err instanceof Error ? err.message : String(err)}` };
5054
5062
  }
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`" };
5063
+ try {
5064
+ const out = loginStatusCommand(codexPath).trim();
5065
+ const detail = out.split("\n").find((line) => line.trim().length > 0)?.trim() || "could not read the sign-in state \u2014 run `codex login`";
5066
+ if (/\b(?:not logged in|not signed in|not authenticated|unauthenticated)\b/i.test(out)) return { version, loggedIn: false, detail };
5067
+ if (/\blogged in\b/i.test(out)) return { version, loggedIn: true, detail };
5068
+ return { version, loggedIn: null, detail };
5069
+ } catch (err) {
5070
+ return { version, loggedIn: null, detail: `could not read the sign-in state: ${err instanceof Error ? err.message : String(err)}` };
5059
5071
  }
5060
- return { version, loggedIn: /logged in/i.test(out), detail: out.split("\n")[0] ?? "" };
5061
5072
  }
5062
5073
  /**
5063
5074
  * A session's credential tier. `codex login status` says how the person
@@ -5754,23 +5765,32 @@ var GrokAdapter = class _GrokAdapter {
5754
5765
  * says "You are not authenticated." and opens no browser, so this is safe
5755
5766
  * to run unattended at every start.
5756
5767
  */
5757
- static check(grokPath) {
5768
+ static check(grokPath, commands = {}) {
5758
5769
  const env = { ...process.env, GROK_DISABLE_AUTOUPDATER: "1" };
5759
- let version = null;
5770
+ const versionCommand = commands.version ?? ((path) => execFileSync4(path, ["--version"], { env, stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString());
5771
+ const modelsCommand = commands.models ?? ((path) => {
5772
+ const res = spawnSync2(path, ["models"], { env, encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
5773
+ if (res.error) throw res.error;
5774
+ return `${res.stdout ?? ""}
5775
+ ${res.stderr ?? ""}`;
5776
+ });
5777
+ let version;
5760
5778
  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` };
5779
+ const out = versionCommand(grokPath);
5780
+ version = /(\d+\.\d+\.\d+[0-9A-Za-z.-]*)/.exec(out)?.[1] ?? "";
5781
+ if (!version) throw new Error("answered without a valid version");
5782
+ } catch (err) {
5783
+ return { version: null, loggedIn: null, detail: `${grokPath} did not run \u2014 ${err instanceof Error ? err.message : String(err)}` };
5784
+ }
5785
+ try {
5786
+ const out = modelsCommand(grokPath).trim();
5787
+ const first = out.split("\n").map((line) => line.trim()).find((line) => line.length > 0) ?? "";
5788
+ 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`" };
5789
+ if (/\blogged in\b/i.test(out)) return { version, loggedIn: true, detail: first };
5790
+ return { version, loggedIn: null, detail: first || "could not read the sign-in state \u2014 run `grok login`" };
5791
+ } catch (err) {
5792
+ return { version, loggedIn: null, detail: `could not read the sign-in state: ${err instanceof Error ? err.message : String(err)}` };
5765
5793
  }
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
5794
  }
5775
5795
  /** A grok.com sign-in is the subscription; anything else that runs is a key (`XAI_API_KEY`). */
5776
5796
  credentialTier() {
@@ -7830,7 +7850,7 @@ function samplesInWords(samples) {
7830
7850
  }
7831
7851
 
7832
7852
  // src/cli.ts
7833
- var VERSION = "0.7.3";
7853
+ var VERSION = "0.7.4";
7834
7854
  function arg(flag, argv) {
7835
7855
  const i = argv.indexOf(flag);
7836
7856
  return i >= 0 ? argv[i + 1] : void 0;
@@ -7842,7 +7862,7 @@ function findClaude() {
7842
7862
  return execFileSync7("/usr/bin/which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7843
7863
  } catch {
7844
7864
  }
7845
- throw new Error("claude was not found. Install Claude Code and sign in (claude auth login), then try again.");
7865
+ return null;
7846
7866
  }
7847
7867
  function findCodex() {
7848
7868
  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 +7884,54 @@ function findGrok() {
7864
7884
  return null;
7865
7885
  }
7866
7886
  }
7867
- function buildAdapters(cfg, permissions) {
7868
- const adapters = {
7869
- claude: new ClaudeAdapter({ claudePath: cfg.claudePath, permissions })
7887
+ var RUNTIME_ORDER = ["claude", "codex", "grok"];
7888
+ var RUNTIME_LABELS = { claude: "Claude Code", codex: "Codex", grok: "Grok Build" };
7889
+ var INSTALL_HINTS = { claude: "Install Claude Code.", codex: "Install Codex.", grok: "Install Grok Build." };
7890
+ 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`).";
7891
+ function checkedRuntime(name, path) {
7892
+ if (!path) return { path: null, version: null, loggedIn: null, detail: `not installed on this computer` };
7893
+ try {
7894
+ const check = name === "claude" ? ClaudeAdapter.check(path) : name === "codex" ? CodexAdapter.check(path) : GrokAdapter.check(path);
7895
+ return { path, ...check };
7896
+ } catch (err) {
7897
+ return { path, version: null, loggedIn: null, detail: `could not run: ${err instanceof Error ? err.message : String(err)}` };
7898
+ }
7899
+ }
7900
+ var DEFAULT_RUNTIME_PROBES = {
7901
+ claude: (preferredPath) => checkedRuntime("claude", preferredPath ?? findClaude()),
7902
+ codex: (preferredPath) => checkedRuntime("codex", preferredPath ?? findCodex()),
7903
+ grok: (preferredPath) => checkedRuntime("grok", preferredPath ?? findGrok())
7904
+ };
7905
+ function probeAllRuntimes(probes, preferred) {
7906
+ return {
7907
+ claude: probes.claude(preferred?.claudePath),
7908
+ codex: probes.codex(preferred?.codexPath),
7909
+ grok: probes.grok(preferred?.grokPath)
7870
7910
  };
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 };
7911
+ }
7912
+ function isReady(probe) {
7913
+ return probe.path !== null && probe.version !== null && probe.loggedIn === true;
7914
+ }
7915
+ function readyNames(probes) {
7916
+ return RUNTIME_ORDER.filter((name) => isReady(probes[name]));
7917
+ }
7918
+ function runtimePreflightRefusal(probes) {
7919
+ const hints = RUNTIME_ORDER.filter((name) => probes[name].path === null).map((name) => INSTALL_HINTS[name]);
7920
+ return `${RUNTIME_PREFLIGHT_REFUSAL}${hints.length > 0 ? ` ${hints.join(" ")}` : ""}`;
7921
+ }
7922
+ function versionForReadySet(probes) {
7923
+ return runtimeVersionString2(
7924
+ isReady(probes.claude) ? probes.claude.version : null,
7925
+ isReady(probes.codex) ? probes.codex.version : null,
7926
+ isReady(probes.grok) ? probes.grok.version : null
7927
+ );
7928
+ }
7929
+ function buildAdapters(probes, permissions) {
7930
+ const adapters = {};
7931
+ if (isReady(probes.claude)) adapters.claude = new ClaudeAdapter({ claudePath: probes.claude.path, permissions });
7932
+ if (isReady(probes.codex)) adapters.codex = new CodexAdapter({ codexPath: probes.codex.path });
7933
+ if (isReady(probes.grok)) adapters.grok = new GrokAdapter({ grokPath: probes.grok.path });
7934
+ return { adapters, names: readyNames(probes) };
7894
7935
  }
7895
7936
  function runtimeVersionString2(claudeVersion, codexVersion, grokVersion = null) {
7896
7937
  return runtimeVersionString(VERSION, claudeVersion, codexVersion, grokVersion);
@@ -7904,37 +7945,42 @@ function tokenFor(cfg) {
7904
7945
  if (!token) throw new Error("The host token is missing from the keychain. Ask for a new join code and join again.");
7905
7946
  return token;
7906
7947
  }
7907
- async function cmdJoin(argv) {
7948
+ async function cmdJoin(argv, overrides = {}) {
7949
+ const deps = {
7950
+ readConfig,
7951
+ probes: DEFAULT_RUNTIME_PROBES,
7952
+ join: join3,
7953
+ storeToken,
7954
+ ensureWorkdir: (path) => {
7955
+ if (!existsSync12(path)) mkdirSync9(path, { recursive: true, mode: 448 });
7956
+ },
7957
+ writeConfig,
7958
+ reportGitAccess,
7959
+ install: cmdInstall,
7960
+ now: () => (/* @__PURE__ */ new Date()).toISOString(),
7961
+ ...overrides
7962
+ };
7908
7963
  const code = argv[0];
7909
7964
  const apiUrl = arg("--api", argv) ?? process.env.FROD_API_URL;
7910
7965
  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();
7966
+ const existing = deps.readConfig();
7912
7967
  const refusal = existing ? secondJoinWords(existing) : null;
7913
7968
  if (refusal) throw new Error(refusal);
7914
7969
  const safeApi = assertSafeEndpoint(apiUrl);
7915
7970
  const name = arg("--name", argv) ?? defaultName();
7916
7971
  const workdir = resolve6(arg("--workdir", argv) ?? join11(homedir7(), "frod-work"));
7917
7972
  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.");
7973
+ const probes = probeAllRuntimes(deps.probes);
7974
+ for (const runtime of RUNTIME_ORDER) {
7975
+ const probe = probes[runtime];
7976
+ log.info(`${RUNTIME_LABELS[runtime]}: ${probe.version ?? (probe.path ? "unknown version" : "not installed")} \u2014 ${probe.detail}`);
7977
+ }
7978
+ const adapters = readyNames(probes);
7979
+ if (adapters.length === 0) throw new Error(runtimePreflightRefusal(probes));
7922
7980
  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 });
7981
+ const joined = await deps.join(safeApi, code, name, { os: platform6(), version: versionForReadySet(probes) });
7982
+ const store = deps.storeToken(joined.hostId, joined.token);
7983
+ deps.ensureWorkdir(workdir);
7938
7984
  const cfg = {
7939
7985
  apiUrl: safeApi,
7940
7986
  hostId: joined.hostId,
@@ -7944,26 +7990,28 @@ async function cmdJoin(argv) {
7944
7990
  name,
7945
7991
  workdir,
7946
7992
  maxSessions,
7947
- adapters: ["claude"],
7948
- claudePath,
7993
+ adapters,
7994
+ claudePath: isReady(probes.claude) ? probes.claude.path : null,
7995
+ codexPath: isReady(probes.codex) ? probes.codex.path : void 0,
7996
+ grokPath: isReady(probes.grok) ? probes.grok.path : void 0,
7949
7997
  tokenStore: store,
7950
7998
  keychainAccount: joined.hostId,
7951
- joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
7999
+ joinedAt: deps.now(),
7952
8000
  dormant: null,
7953
8001
  bindings: joined.bindings ?? []
7954
8002
  };
7955
- writeConfig(cfg);
8003
+ deps.writeConfig(cfg);
7956
8004
  log.info(`Joined. This computer is now "${name}" \u2014 ${joined.person?.name ?? "your"} computer in ${joined.workspaceName ?? joined.workspaceId} \u2014 hosting ${hostedAgentsWords(cfg)}.`);
7957
8005
  for (const b of cfg.bindings ?? []) log.info(` ${b.agentName}: ${bindingWords(b)}`);
7958
8006
  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
8007
  log.info(`Settings: ${CONFIG_PATH}. Sessions work under ${workdir}/sessions. Up to ${maxSessions} at a time, across every agent this computer hosts.`);
7960
8008
  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));
8009
+ await deps.reportGitAccess(arg("--repo", argv));
7962
8010
  if (argv.includes("--no-install")) {
7963
8011
  log.info("Next: `install` to run at login, or `run` to run now.");
7964
8012
  return;
7965
8013
  }
7966
- await cmdInstall();
8014
+ await deps.install();
7967
8015
  }
7968
8016
  async function reportGitAccess(repoUrl) {
7969
8017
  let version = "not found";
@@ -7999,24 +8047,30 @@ async function cmdRun() {
7999
8047
  ClaudeAdapter.reapOrphans();
8000
8048
  const pruned = pruneRunDirs(cfg.workdir);
8001
8049
  if (pruned > 0) log.event("repo.runs_pruned", { count: pruned });
8050
+ const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
8051
+ const ready = readyNames(probes);
8052
+ if (ready.length === 0) throw new Error(runtimePreflightRefusal(probes));
8002
8053
  const permissions = new PermissionServer();
8003
8054
  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;
8055
+ const { adapters, names } = buildAdapters(probes, permissions);
8056
+ 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(". ")}.`);
8057
+ const runtimeConfigBefore = JSON.stringify({ adapters: cfg.adapters ?? [], claudePath: cfg.claudePath, codexPath: cfg.codexPath, grokPath: cfg.grokPath });
8058
+ cfg.adapters = names;
8059
+ cfg.claudePath = isReady(probes.claude) ? probes.claude.path : null;
8060
+ cfg.codexPath = isReady(probes.codex) ? probes.codex.path : void 0;
8061
+ cfg.grokPath = isReady(probes.grok) ? probes.grok.path : void 0;
8062
+ if (runtimeConfigBefore !== JSON.stringify({ adapters: cfg.adapters, claudePath: cfg.claudePath, codexPath: cfg.codexPath, grokPath: cfg.grokPath })) {
8009
8063
  writeConfig(cfg);
8010
8064
  }
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;
8065
+ 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 });
8066
+ const codexPath = isReady(probes.codex) ? probes.codex.path : null;
8067
+ const grokPath = isReady(probes.grok) ? probes.grok.path : null;
8014
8068
  let sampling = false;
8015
8069
  const sampleAndSend = async () => {
8016
8070
  if (sampling) return;
8017
8071
  sampling = true;
8018
8072
  try {
8019
- const { at, samples } = await sampleCapacity({ claude: check.loggedIn !== false, codexPath, grokPath });
8073
+ const { at, samples } = await sampleCapacity({ claude: isReady(probes.claude), codexPath, grokPath });
8020
8074
  conn.sendHost("host/capacity", { at, samples });
8021
8075
  log.event("capacity.sampled", { at, words: samplesInWords(samples) });
8022
8076
  } catch (err) {
@@ -8061,7 +8115,7 @@ async function cmdRun() {
8061
8115
  config: cfg,
8062
8116
  token,
8063
8117
  adapters,
8064
- version: runtimeVersionString2(check.version, codexVersion, grokVersion),
8118
+ version: versionForReadySet(probes),
8065
8119
  onFinal: (reason2) => {
8066
8120
  writeConfig({ ...cfg, dormant: { reason: reason2, at: (/* @__PURE__ */ new Date()).toISOString() } });
8067
8121
  log.warn(dormantWords({ reason: reason2, at: (/* @__PURE__ */ new Date()).toISOString() }, hostedAgentsWords(cfg)));
@@ -8118,17 +8172,14 @@ async function cmdStatus(argv) {
8118
8172
  if (cfg.dormant) log.warn(dormantWords(cfg.dormant, hostedAgentsWords(cfg)));
8119
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.`);
8120
8174
  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}`);
8175
+ const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
8176
+ for (const runtime of RUNTIME_ORDER) {
8177
+ const probe = probes[runtime];
8178
+ log.info(`${RUNTIME_LABELS[runtime]}: ${probe.version ?? (probe.path ? "unknown version" : "not installed")} \u2014 ${probe.detail}`);
8179
+ }
8123
8180
  const loc = runLocation();
8124
8181
  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
8182
  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
8183
  log.info("Which agent runs on which runtime is the platform's to say \u2014 attach or detach agents in Mission Control.");
8133
8184
  await reportGitAccess(arg("--repo", argv));
8134
8185
  const token = readToken(cfg.keychainAccount, cfg.tokenStore);
@@ -8263,10 +8314,19 @@ async function main() {
8263
8314
  process.exit(cmd ? 2 : 0);
8264
8315
  }
8265
8316
  }
8266
- main().catch((err) => {
8267
- log.error(err instanceof Error ? err.message : String(err));
8268
- process.exit(1);
8269
- });
8317
+ var isEntrypoint = false;
8318
+ try {
8319
+ isEntrypoint = !!process.argv[1] && realpathSync4(process.argv[1]) === realpathSync4(fileURLToPath2(import.meta.url));
8320
+ } catch {
8321
+ }
8322
+ if (isEntrypoint) {
8323
+ main().catch((err) => {
8324
+ log.error(err instanceof Error ? err.message : String(err));
8325
+ process.exit(1);
8326
+ });
8327
+ }
8270
8328
  export {
8329
+ RUNTIME_PREFLIGHT_REFUSAL,
8330
+ cmdJoin,
8271
8331
  runtimeVersionString2 as runtimeVersionString
8272
8332
  };
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.4",
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
  }