@frod.io/bridge 0.7.2 → 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 +235 -124
  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.2. 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() {
@@ -6481,6 +6501,9 @@ function workdirRequestFor(spec) {
6481
6501
  }
6482
6502
 
6483
6503
  // src/connection.ts
6504
+ var RECONCILE_INTERVAL_MS = 6e4;
6505
+ var RECONCILE_WAIT_MS = 1500;
6506
+ var STOP_BOUND_MS = 1e4;
6484
6507
  var HostConnection = class {
6485
6508
  constructor(o) {
6486
6509
  this.o = o;
@@ -6498,6 +6521,7 @@ var HostConnection = class {
6498
6521
  /** Which runtime started each session — a turn must reach the one holding it. */
6499
6522
  sessionAdapter = /* @__PURE__ */ new Map();
6500
6523
  attempt = 0;
6524
+ reconcileTimer = null;
6501
6525
  connectedAt = null;
6502
6526
  /** Requests this computer sent to the pod (night 2: permissions), by id. */
6503
6527
  nextRequestId = 1;
@@ -6593,6 +6617,7 @@ var HostConnection = class {
6593
6617
  const reason = reasonBuf.toString();
6594
6618
  this.connectedAt = null;
6595
6619
  this.ws = null;
6620
+ this.stopReconciling();
6596
6621
  this.failPending(`the connection closed (${code})`);
6597
6622
  void this.endAllSessions(`connection closed (${code}${reason ? ` ${reason}` : ""})`);
6598
6623
  if (final && (code === 4403 || code === 4404 || final.reason.startsWith("Another computer") || final.reason.startsWith("This computer left") || final.reason.startsWith("This computer was disconnected") || /access was revoked/.test(final.reason))) {
@@ -6634,6 +6659,8 @@ var HostConnection = class {
6634
6659
  const hosted = hostedAgentsWords({ bindings, agentName: this.o.config.agentName });
6635
6660
  log.event("host.welcome", { host: params.hostName ?? this.o.config.name, host_id: params.hostId ?? null, person: params.person?.name ?? null, workspace: params.workspaceId, agents: bindings.map((b) => `${b.agentName}: ${bindingWords(b)}`), ping_ms: params.pingIntervalMs });
6636
6661
  log.info(`Connected \u2014 ${this.o.config.name} hosts ${hosted}`);
6662
+ this.reconcile("welcome");
6663
+ this.startReconciling();
6637
6664
  if (typeof params.hostId === "string") {
6638
6665
  this.o.onWelcome?.({ hostId: params.hostId, hostName: String(params.hostName ?? this.o.config.name), person: params.person ?? null, bindings, bridge: params.bridge });
6639
6666
  }
@@ -6673,7 +6700,13 @@ var HostConnection = class {
6673
6700
  const spec = params;
6674
6701
  if (this.draining || this.stopping) return fail(`${this.o.config.name} is restarting${this.draining ? " to update itself" : ""} \u2014 try again in a moment`);
6675
6702
  if (this.sessions.size >= this.o.config.maxSessions && !this.sessions.has(sid)) {
6676
- return fail(`${this.o.config.name} is busy (${this.sessions.size} of ${this.o.config.maxSessions} sessions)`);
6703
+ const before = this.sessions.size;
6704
+ this.reconcile("desk full");
6705
+ await sleep(RECONCILE_WAIT_MS);
6706
+ log.event("session.reconciled", { session: sid, before, after: this.sessions.size, max: this.o.config.maxSessions });
6707
+ if (this.sessions.size >= this.o.config.maxSessions) {
6708
+ return fail(`${this.o.config.name} is busy (${this.sessions.size} of ${this.o.config.maxSessions} sessions)`);
6709
+ }
6677
6710
  }
6678
6711
  this.sessions.add(sid);
6679
6712
  this.sessionsChanged();
@@ -6774,7 +6807,10 @@ var HostConnection = class {
6774
6807
  return;
6775
6808
  }
6776
6809
  if (method === "session/stop") {
6777
- await this.adapterOf(sid).stop(sid);
6810
+ const reason = typeof params.reason === "string" ? params.reason : "stopped";
6811
+ if (reason === "unknown_session") log.event("session.forgotten_by_pod", { session: sid });
6812
+ await this.adapterOf(sid).stop(sid, reason);
6813
+ this.forget(sid);
6778
6814
  return;
6779
6815
  }
6780
6816
  if (method === "session/permission_decided") {
@@ -6869,15 +6905,50 @@ var HostConnection = class {
6869
6905
  }
6870
6906
  async endAllSessions(reason) {
6871
6907
  if (this.sessions.size === 0) return;
6872
- log.warn(`Stopping ${this.sessions.size} session(s): ${reason}`);
6873
- for (const sid of [...this.sessions]) {
6874
- await this.adapterOf(sid).stop(sid, "host_disconnected").catch(() => {
6875
- });
6876
- this.sessions.delete(sid);
6877
- this.checkouts.delete(sid);
6908
+ const sids = [...this.sessions];
6909
+ log.warn(`Stopping ${sids.length} session(s): ${reason}`);
6910
+ for (const sid of sids) this.forget(sid, { keepAdapter: true });
6911
+ await Promise.all(sids.map(async (sid) => {
6912
+ const adapter = this.sessionAdapter.get(sid) ?? this.adapterOf(sid);
6878
6913
  this.sessionAdapter.delete(sid);
6914
+ await Promise.race([
6915
+ adapter.stop(sid, "host_disconnected").catch(() => {
6916
+ }),
6917
+ sleep(STOP_BOUND_MS).then(() => log.warn(`[${sid.slice(0, 8)}] did not stop within ${STOP_BOUND_MS / 1e3} s \u2014 slot freed anyway`))
6918
+ ]);
6919
+ }));
6920
+ }
6921
+ /** Free a session's slot and everything this computer remembered about it. Idempotent. */
6922
+ forget(sid, opts = {}) {
6923
+ const had = this.sessions.delete(sid);
6924
+ this.checkouts.delete(sid);
6925
+ this.doorCheckouts.forget(sid);
6926
+ this.sessionDirs.delete(sid);
6927
+ if (!opts.keepAdapter) this.sessionAdapter.delete(sid);
6928
+ if (had) this.sessionsChanged();
6929
+ }
6930
+ /**
6931
+ * No tails: ask the pod about every lane this computer holds. A known
6932
+ * session gets silence; a forgotten one gets `session/stop` back, which
6933
+ * frees its slot through the ordinary path. Sent only while connected.
6934
+ */
6935
+ reconcile(why) {
6936
+ if (this.sessions.size === 0 || !this.ws || this.ws.readyState !== wrapper_default.OPEN) return;
6937
+ log.event("sessions.reconcile", { why, held: this.sessions.size, max: this.o.config.maxSessions });
6938
+ for (const sid of this.sessions) {
6939
+ this.send({ sid, msg: { jsonrpc: "2.0", method: "session/heartbeat", params: { sessionId: sid } } });
6940
+ }
6941
+ }
6942
+ startReconciling() {
6943
+ this.stopReconciling();
6944
+ this.reconcileTimer = setInterval(() => this.reconcile("interval"), RECONCILE_INTERVAL_MS);
6945
+ this.reconcileTimer.unref?.();
6946
+ }
6947
+ stopReconciling() {
6948
+ if (this.reconcileTimer) {
6949
+ clearInterval(this.reconcileTimer);
6950
+ this.reconcileTimer = null;
6879
6951
  }
6880
- this.sessionsChanged();
6881
6952
  }
6882
6953
  send(frame) {
6883
6954
  const ws = this.ws;
@@ -7779,7 +7850,7 @@ function samplesInWords(samples) {
7779
7850
  }
7780
7851
 
7781
7852
  // src/cli.ts
7782
- var VERSION = "0.7.2";
7853
+ var VERSION = "0.7.4";
7783
7854
  function arg(flag, argv) {
7784
7855
  const i = argv.indexOf(flag);
7785
7856
  return i >= 0 ? argv[i + 1] : void 0;
@@ -7791,7 +7862,7 @@ function findClaude() {
7791
7862
  return execFileSync7("/usr/bin/which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
7792
7863
  } catch {
7793
7864
  }
7794
- throw new Error("claude was not found. Install Claude Code and sign in (claude auth login), then try again.");
7865
+ return null;
7795
7866
  }
7796
7867
  function findCodex() {
7797
7868
  const candidates = [process.env.FROD_CODEX_PATH, join11(homedir7(), ".local", "bin", "codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex"].filter((c) => !!c);
@@ -7813,33 +7884,54 @@ function findGrok() {
7813
7884
  return null;
7814
7885
  }
7815
7886
  }
7816
- function buildAdapters(cfg, permissions) {
7817
- const adapters = {
7818
- 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)
7819
7910
  };
7820
- const codexPath = cfg.codexPath ?? findCodex();
7821
- let codexDetail = "not installed on this computer";
7822
- let codexVersion = null;
7823
- if (codexPath) {
7824
- const c = CodexAdapter.check(codexPath);
7825
- codexDetail = `${c.version ?? "unknown version"} \u2014 ${c.detail}`;
7826
- if (c.loggedIn) {
7827
- adapters.codex = new CodexAdapter({ codexPath });
7828
- codexVersion = c.version;
7829
- }
7830
- }
7831
- const grokPath = cfg.grokPath ?? findGrok();
7832
- let grokDetail = "not installed on this computer";
7833
- let grokVersion = null;
7834
- if (grokPath) {
7835
- const g = GrokAdapter.check(grokPath);
7836
- grokDetail = `${g.version ?? "unknown version"} \u2014 ${g.detail}`;
7837
- if (g.loggedIn) {
7838
- adapters.grok = new GrokAdapter({ grokPath });
7839
- grokVersion = g.version;
7840
- }
7841
- }
7842
- 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) };
7843
7935
  }
7844
7936
  function runtimeVersionString2(claudeVersion, codexVersion, grokVersion = null) {
7845
7937
  return runtimeVersionString(VERSION, claudeVersion, codexVersion, grokVersion);
@@ -7853,37 +7945,42 @@ function tokenFor(cfg) {
7853
7945
  if (!token) throw new Error("The host token is missing from the keychain. Ask for a new join code and join again.");
7854
7946
  return token;
7855
7947
  }
7856
- 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
+ };
7857
7963
  const code = argv[0];
7858
7964
  const apiUrl = arg("--api", argv) ?? process.env.FROD_API_URL;
7859
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]`);
7860
- const existing = readConfig();
7966
+ const existing = deps.readConfig();
7861
7967
  const refusal = existing ? secondJoinWords(existing) : null;
7862
7968
  if (refusal) throw new Error(refusal);
7863
7969
  const safeApi = assertSafeEndpoint(apiUrl);
7864
7970
  const name = arg("--name", argv) ?? defaultName();
7865
7971
  const workdir = resolve6(arg("--workdir", argv) ?? join11(homedir7(), "frod-work"));
7866
7972
  const maxSessions = Math.max(1, Math.min(20, Number(arg("--max-sessions", argv) ?? 2)));
7867
- const claudePath = findClaude();
7868
- const check = ClaudeAdapter.check(claudePath);
7869
- log.info(`Claude Code: ${check.version ?? "unknown version"} \u2014 ${check.detail}`);
7870
- 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));
7871
7980
  log.info(`Joining ${safeApi} as "${name}"\u2026`);
7872
- const codexAtJoin = (() => {
7873
- const cp = findCodex();
7874
- if (!cp) return null;
7875
- const c = CodexAdapter.check(cp);
7876
- return c.loggedIn ? c.version : null;
7877
- })();
7878
- const grokAtJoin = (() => {
7879
- const gp = findGrok();
7880
- if (!gp) return null;
7881
- const g = GrokAdapter.check(gp);
7882
- return g.loggedIn ? g.version : null;
7883
- })();
7884
- const joined = await join3(safeApi, code, name, { os: platform6(), version: runtimeVersionString2(check.version, codexAtJoin, grokAtJoin) });
7885
- const store = storeToken(joined.hostId, joined.token);
7886
- 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);
7887
7984
  const cfg = {
7888
7985
  apiUrl: safeApi,
7889
7986
  hostId: joined.hostId,
@@ -7893,26 +7990,28 @@ async function cmdJoin(argv) {
7893
7990
  name,
7894
7991
  workdir,
7895
7992
  maxSessions,
7896
- adapters: ["claude"],
7897
- 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,
7898
7997
  tokenStore: store,
7899
7998
  keychainAccount: joined.hostId,
7900
- joinedAt: (/* @__PURE__ */ new Date()).toISOString(),
7999
+ joinedAt: deps.now(),
7901
8000
  dormant: null,
7902
8001
  bindings: joined.bindings ?? []
7903
8002
  };
7904
- writeConfig(cfg);
8003
+ deps.writeConfig(cfg);
7905
8004
  log.info(`Joined. This computer is now "${name}" \u2014 ${joined.person?.name ?? "your"} computer in ${joined.workspaceName ?? joined.workspaceId} \u2014 hosting ${hostedAgentsWords(cfg)}.`);
7906
8005
  for (const b of cfg.bindings ?? []) log.info(` ${b.agentName}: ${bindingWords(b)}`);
7907
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)}.`);
7908
8007
  log.info(`Settings: ${CONFIG_PATH}. Sessions work under ${workdir}/sessions. Up to ${maxSessions} at a time, across every agent this computer hosts.`);
7909
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.`);
7910
- await reportGitAccess(arg("--repo", argv));
8009
+ await deps.reportGitAccess(arg("--repo", argv));
7911
8010
  if (argv.includes("--no-install")) {
7912
8011
  log.info("Next: `install` to run at login, or `run` to run now.");
7913
8012
  return;
7914
8013
  }
7915
- await cmdInstall();
8014
+ await deps.install();
7916
8015
  }
7917
8016
  async function reportGitAccess(repoUrl) {
7918
8017
  let version = "not found";
@@ -7948,24 +8047,30 @@ async function cmdRun() {
7948
8047
  ClaudeAdapter.reapOrphans();
7949
8048
  const pruned = pruneRunDirs(cfg.workdir);
7950
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));
7951
8053
  const permissions = new PermissionServer();
7952
8054
  await permissions.start();
7953
- const { adapters, names, codexDetail, codexVersion, grokDetail, grokVersion } = buildAdapters(cfg, permissions);
7954
- const check = ClaudeAdapter.check(cfg.claudePath);
7955
- log.info(`Runtimes this computer hosts: ${names.join(", ")}. Codex: ${codexDetail}. Grok Build: ${grokDetail}.`);
7956
- if (JSON.stringify(cfg.adapters ?? []) !== JSON.stringify(names)) {
7957
- 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 })) {
7958
8063
  writeConfig(cfg);
7959
8064
  }
7960
- 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 });
7961
- const codexPath = adapters.codex ? cfg.codexPath ?? findCodex() : null;
7962
- 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;
7963
8068
  let sampling = false;
7964
8069
  const sampleAndSend = async () => {
7965
8070
  if (sampling) return;
7966
8071
  sampling = true;
7967
8072
  try {
7968
- const { at, samples } = await sampleCapacity({ claude: check.loggedIn !== false, codexPath, grokPath });
8073
+ const { at, samples } = await sampleCapacity({ claude: isReady(probes.claude), codexPath, grokPath });
7969
8074
  conn.sendHost("host/capacity", { at, samples });
7970
8075
  log.event("capacity.sampled", { at, words: samplesInWords(samples) });
7971
8076
  } catch (err) {
@@ -8010,7 +8115,7 @@ async function cmdRun() {
8010
8115
  config: cfg,
8011
8116
  token,
8012
8117
  adapters,
8013
- version: runtimeVersionString2(check.version, codexVersion, grokVersion),
8118
+ version: versionForReadySet(probes),
8014
8119
  onFinal: (reason2) => {
8015
8120
  writeConfig({ ...cfg, dormant: { reason: reason2, at: (/* @__PURE__ */ new Date()).toISOString() } });
8016
8121
  log.warn(dormantWords({ reason: reason2, at: (/* @__PURE__ */ new Date()).toISOString() }, hostedAgentsWords(cfg)));
@@ -8067,17 +8172,14 @@ async function cmdStatus(argv) {
8067
8172
  if (cfg.dormant) log.warn(dormantWords(cfg.dormant, hostedAgentsWords(cfg)));
8068
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.`);
8069
8174
  log.info(`Working folders an agent may be given here: ${allowedRootsOf(cfg).join(", ")} (add more under "allowedRoots" in ${CONFIG_PATH}).`);
8070
- const check = ClaudeAdapter.check(cfg.claudePath);
8071
- 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
+ }
8072
8180
  const loc = runLocation();
8073
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"}.`);
8074
8182
  if (platform6() === "darwin") log.info(`Runs at login: ${launchdLoaded() ? "yes (launchd)" : "no \u2014 run install"}`);
8075
- const codexPath = cfg.codexPath ?? findCodex();
8076
- const codexCheck = codexPath ? CodexAdapter.check(codexPath) : null;
8077
- log.info(`Codex: ${codexCheck ? `${codexCheck.version ?? "unknown"} \u2014 ${codexCheck.detail}` : CodexAdapter.notYet}`);
8078
- const grokPath = cfg.grokPath ?? findGrok();
8079
- const grokCheck = grokPath ? GrokAdapter.check(grokPath) : null;
8080
- log.info(`Grok Build: ${grokCheck ? `${grokCheck.version ?? "unknown"} \u2014 ${grokCheck.detail}` : GrokAdapter.notYet}`);
8081
8183
  log.info("Which agent runs on which runtime is the platform's to say \u2014 attach or detach agents in Mission Control.");
8082
8184
  await reportGitAccess(arg("--repo", argv));
8083
8185
  const token = readToken(cfg.keychainAccount, cfg.tokenStore);
@@ -8212,10 +8314,19 @@ async function main() {
8212
8314
  process.exit(cmd ? 2 : 0);
8213
8315
  }
8214
8316
  }
8215
- main().catch((err) => {
8216
- log.error(err instanceof Error ? err.message : String(err));
8217
- process.exit(1);
8218
- });
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
+ }
8219
8328
  export {
8329
+ RUNTIME_PREFLIGHT_REFUSAL,
8330
+ cmdJoin,
8220
8331
  runtimeVersionString2 as runtimeVersionString
8221
8332
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frod.io/bridge",
3
- "version": "0.7.2",
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
  }