@frod.io/bridge 0.7.4 → 0.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -6
- package/dist/cli.js +586 -121
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,9 +145,10 @@ says the same). When the desk is clear, it swaps `current` atomically, logs
|
|
|
145
145
|
`bridge.updated {from, to}`, and restarts through the supervisor: it exits
|
|
146
146
|
with code 75, a non-zero exit that launchd's `KeepAlive SuccessfulExit=false`
|
|
147
147
|
and systemd's `Restart=on-failure` both bring straight back up — a clean exit
|
|
148
|
-
(code 0) is deliberately a final goodbye, not a restart signal. Windows
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
(code 0) is deliberately a final goodbye, not a restart signal. On Windows the
|
|
149
|
+
logon task carries the same rule (`RestartOnFailure`, three tries a minute
|
|
150
|
+
apart), and the bridge also relaunches the new version itself, detached and
|
|
151
|
+
with no console window; if that fails, the next logon starts it.
|
|
151
152
|
|
|
152
153
|
The keychain item is never touched by an update. You'll see `Updated to <v>`
|
|
153
154
|
in `~/.frod/bridge.log` and the new version on the computer card. Two
|
|
@@ -258,12 +259,57 @@ Never-ask policy as the others. Each adapter is one file.
|
|
|
258
259
|
unit is written correctly, but `systemctl --user` doesn't exist in a
|
|
259
260
|
container, so the manual command is printed instead. Not yet proven on a
|
|
260
261
|
real Linux machine.
|
|
261
|
-
- **Windows** — a
|
|
262
|
-
(`schtasks /Create
|
|
263
|
-
|
|
262
|
+
- **Windows** — a logon task *Frod.io bridge*, registered from a task
|
|
263
|
+
definition (`schtasks /Create /XML`, written to `~/.frod/bridge-task.xml`)
|
|
264
|
+
and pointing at `current` through `~/.frod/bridge-hidden.vbs`, a launcher
|
|
265
|
+
that starts the bridge with **no window** and appends its output to
|
|
266
|
+
`~/.frod/bridge.log`. The definition also says what the command line cannot:
|
|
267
|
+
hidden, no 72-hour execution limit, indifferent to battery, restart after a
|
|
268
|
+
crash. A real Windows *service* would run as SYSTEM while the runtime CLIs
|
|
269
|
+
are signed in as the person, so a logon task — the equivalent of the macOS
|
|
270
|
+
user agent — is deliberate. If the definition is refused, the plain
|
|
271
|
+
`schtasks /Create /TR` form is still tried (it works, and it shows a console
|
|
272
|
+
window), and the exact commands are printed. Not yet proven on a real
|
|
273
|
+
Windows machine.
|
|
274
|
+
|
|
275
|
+
Finding the runtimes on Windows needs no hand configuration either: `claude`,
|
|
276
|
+
`codex` and `grok` are looked for in npm's global folder (`%APPDATA%\npm`),
|
|
277
|
+
`~/.local/bin`, the per-tool folders and `%LOCALAPPDATA%\Programs`, with
|
|
278
|
+
`.exe`, `.cmd` and `.bat` tried in that order, then `where.exe`. An npm
|
|
279
|
+
`.cmd` shim cannot be spawned without a shell (CVE-2024-27980), so the shim is
|
|
280
|
+
read and the JavaScript entry it names is started with the bridge's own node —
|
|
281
|
+
which also keeps Claude Code's system prompt out of a cmd.exe command line and
|
|
282
|
+
its ~8 KB limit. `FROD_CLAUDE_PATH` / `FROD_CODEX_PATH` / `FROD_GROK_PATH`
|
|
283
|
+
still override everything, on every OS.
|
|
264
284
|
|
|
265
285
|
## Changelog
|
|
266
286
|
|
|
287
|
+
### 0.7.6
|
|
288
|
+
|
|
289
|
+
- **Windows, without hand configuration.** The runtimes are found where Windows
|
|
290
|
+
actually puts them (npm's global folder, `~/.local/bin`, the per-tool folders,
|
|
291
|
+
then `where.exe`) instead of only Unix paths and a `/usr/bin/which` that does
|
|
292
|
+
not exist there — no `FROD_*_PATH` to set by hand. An npm `.cmd` shim is read
|
|
293
|
+
and its JavaScript entry started with our own node, because Node refuses to
|
|
294
|
+
spawn a `.cmd` without a shell and a cmd.exe command line cannot carry an
|
|
295
|
+
agent's system prompt.
|
|
296
|
+
- **Windows, without a window.** The logon task is registered from a task
|
|
297
|
+
definition and runs a launcher that starts the bridge hidden and logs to
|
|
298
|
+
`~/.frod/bridge.log` — so there is no console window a person can close and
|
|
299
|
+
kill the bridge with. The definition also turns off the 72-hour execution
|
|
300
|
+
limit and the stop-on-battery default, and turns on restart-after-crash. The
|
|
301
|
+
old command-line form remains the fallback. Not yet proven on real Windows
|
|
302
|
+
hardware.
|
|
303
|
+
- **A computer's desk is sized by its memory**, not fixed at two sessions: the
|
|
304
|
+
ceiling is what memory allows after a 4 GB reserve at 200 MB a session, and
|
|
305
|
+
the default is 30 or that ceiling, whichever is smaller. A computer joined
|
|
306
|
+
before this is raised once, and a number a person chose is never overruled.
|
|
307
|
+
- **Capacity reads Claude Code's Keychain item by account**, as Claude Code
|
|
308
|
+
writes it, so a Mac that is signed in is no longer reported signed out
|
|
309
|
+
because of a stray item; a failed read now says why.
|
|
310
|
+
- Per-turn usage on Codex and Grok Build is billed as the increment, not the
|
|
311
|
+
running session total.
|
|
312
|
+
|
|
267
313
|
### 0.7.4 — unreleased
|
|
268
314
|
|
|
269
315
|
- `join` is runtime-neutral: it admits a computer when at least one of Claude
|
package/dist/cli.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// @frod.io/bridge 0.7.
|
|
2
|
+
// @frod.io/bridge 0.7.6. One file; do not edit: the source is apps/bridge/src in the platform repository.
|
|
3
3
|
import { createRequire as __createRequire } from 'node:module';
|
|
4
4
|
const require = __createRequire(import.meta.url);
|
|
5
5
|
var __create = Object.create;
|
|
@@ -3705,10 +3705,10 @@ var require_websocket_server = __commonJS({
|
|
|
3705
3705
|
});
|
|
3706
3706
|
|
|
3707
3707
|
// src/cli.ts
|
|
3708
|
-
import { execFileSync as
|
|
3709
|
-
import { existsSync as
|
|
3710
|
-
import { homedir as
|
|
3711
|
-
import { dirname as dirname5, join as
|
|
3708
|
+
import { execFileSync as execFileSync8 } from "node:child_process";
|
|
3709
|
+
import { existsSync as existsSync14, mkdirSync as mkdirSync9, realpathSync as realpathSync4 } from "node:fs";
|
|
3710
|
+
import { homedir as homedir8, hostname as hostname2, platform as platform6 } from "node:os";
|
|
3711
|
+
import { dirname as dirname5, join as join12, resolve as resolve6 } from "node:path";
|
|
3712
3712
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
3713
3713
|
import { spawn as spawn6 } from "node:child_process";
|
|
3714
3714
|
|
|
@@ -3904,7 +3904,7 @@ function leave(apiUrl, token) {
|
|
|
3904
3904
|
|
|
3905
3905
|
// src/adapters/claude.ts
|
|
3906
3906
|
import { spawn, execFileSync as execFileSync2 } from "node:child_process";
|
|
3907
|
-
import { mkdirSync as mkdirSync2, existsSync as
|
|
3907
|
+
import { mkdirSync as mkdirSync2, existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
|
|
3908
3908
|
import { join as join5 } from "node:path";
|
|
3909
3909
|
|
|
3910
3910
|
// src/adapters/claude-stream.ts
|
|
@@ -4090,6 +4090,25 @@ var LineBuffer = class {
|
|
|
4090
4090
|
// src/session-store.ts
|
|
4091
4091
|
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, chmodSync as chmodSync3 } from "node:fs";
|
|
4092
4092
|
import { join as join4 } from "node:path";
|
|
4093
|
+
|
|
4094
|
+
// src/adapters/usage-delta.ts
|
|
4095
|
+
var KEYS = ["inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens"];
|
|
4096
|
+
function usageDelta(current, previous) {
|
|
4097
|
+
if (!previous || KEYS.some((key) => current[key] < previous[key])) return { ...current };
|
|
4098
|
+
return {
|
|
4099
|
+
inputTokens: current.inputTokens - previous.inputTokens,
|
|
4100
|
+
outputTokens: current.outputTokens - previous.outputTokens,
|
|
4101
|
+
cacheCreationTokens: current.cacheCreationTokens - previous.cacheCreationTokens,
|
|
4102
|
+
cacheReadTokens: current.cacheReadTokens - previous.cacheReadTokens
|
|
4103
|
+
};
|
|
4104
|
+
}
|
|
4105
|
+
function isUsageCounters(value) {
|
|
4106
|
+
if (!value || typeof value !== "object") return false;
|
|
4107
|
+
const row = value;
|
|
4108
|
+
return KEYS.every((key) => typeof row[key] === "number" && Number.isFinite(row[key]) && row[key] >= 0);
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
// src/session-store.ts
|
|
4093
4112
|
var PATH = join4(FROD_DIR, "sessions.json");
|
|
4094
4113
|
function read() {
|
|
4095
4114
|
if (!existsSync3(PATH)) return {};
|
|
@@ -4107,6 +4126,9 @@ function write(store) {
|
|
|
4107
4126
|
function getStored(platformSessionId) {
|
|
4108
4127
|
return read()[platformSessionId] ?? null;
|
|
4109
4128
|
}
|
|
4129
|
+
function getStoredByHostSessionId(hostSessionId) {
|
|
4130
|
+
return Object.values(read()).filter((session) => session.hostSessionId === hostSessionId).sort((a, b) => Date.parse(b.lastPromptAt ?? b.startedAt) - Date.parse(a.lastPromptAt ?? a.startedAt))[0] ?? null;
|
|
4131
|
+
}
|
|
4110
4132
|
function putStored(platformSessionId, s) {
|
|
4111
4133
|
const store = read();
|
|
4112
4134
|
store[platformSessionId] = s;
|
|
@@ -4121,6 +4143,14 @@ function touchStored(platformSessionId) {
|
|
|
4121
4143
|
write(store);
|
|
4122
4144
|
}
|
|
4123
4145
|
}
|
|
4146
|
+
function putStoredUsage(platformSessionId, adapter, counters) {
|
|
4147
|
+
const store = read();
|
|
4148
|
+
const session = store[platformSessionId];
|
|
4149
|
+
if (!session || !isUsageCounters(counters)) return;
|
|
4150
|
+
session.cumulativeUsage = { adapter, counters: { ...counters } };
|
|
4151
|
+
session.lastPromptAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4152
|
+
write(store);
|
|
4153
|
+
}
|
|
4124
4154
|
|
|
4125
4155
|
// src/log.ts
|
|
4126
4156
|
var REDACT = [
|
|
@@ -4154,6 +4184,44 @@ var log = {
|
|
|
4154
4184
|
}
|
|
4155
4185
|
};
|
|
4156
4186
|
|
|
4187
|
+
// src/win-launch.ts
|
|
4188
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
|
|
4189
|
+
import { win32 as winPath } from "node:path";
|
|
4190
|
+
function defaultLaunchIo() {
|
|
4191
|
+
return {
|
|
4192
|
+
platform: process.platform,
|
|
4193
|
+
env: process.env,
|
|
4194
|
+
node: process.execPath,
|
|
4195
|
+
exists: existsSync4,
|
|
4196
|
+
readFile: (path) => readFileSync4(path, "utf8")
|
|
4197
|
+
};
|
|
4198
|
+
}
|
|
4199
|
+
var BATCH = /\.(cmd|bat)$/i;
|
|
4200
|
+
var SHIM_ENTRY = /%~?dp0%?[\\/]+([^"\s]+\.(?:c|m)?js)/i;
|
|
4201
|
+
function shimEntry(shimPath, io = defaultLaunchIo()) {
|
|
4202
|
+
let text;
|
|
4203
|
+
try {
|
|
4204
|
+
text = io.readFile(shimPath);
|
|
4205
|
+
} catch {
|
|
4206
|
+
return null;
|
|
4207
|
+
}
|
|
4208
|
+
const found = SHIM_ENTRY.exec(text)?.[1];
|
|
4209
|
+
if (!found) return null;
|
|
4210
|
+
const entry = winPath.isAbsolute(found) ? found : winPath.resolve(winPath.dirname(shimPath), found);
|
|
4211
|
+
return io.exists(entry) ? entry : null;
|
|
4212
|
+
}
|
|
4213
|
+
function launchOf(exe, args = [], io = defaultLaunchIo()) {
|
|
4214
|
+
if (io.platform !== "win32" || !BATCH.test(exe)) return { command: exe, args, via: "direct" };
|
|
4215
|
+
const entry = shimEntry(exe, io);
|
|
4216
|
+
if (entry) return { command: io.node, args: [entry, ...args], via: "node-shim" };
|
|
4217
|
+
return {
|
|
4218
|
+
command: io.env.ComSpec ?? "cmd.exe",
|
|
4219
|
+
args: ["/d", "/s", "/c", exe, ...args],
|
|
4220
|
+
via: "cmd",
|
|
4221
|
+
note: `${exe} is a batch shim whose entry could not be read, so it starts through cmd.exe \u2014 where a long system prompt (more than about 8 KB of arguments) fails. Point the FROD_CLAUDE_PATH / FROD_CODEX_PATH / FROD_GROK_PATH override at a .exe if there is one.`
|
|
4222
|
+
};
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4157
4225
|
// src/permission-policy.ts
|
|
4158
4226
|
import { resolve, isAbsolute, sep, dirname } from "node:path";
|
|
4159
4227
|
import { realpathSync } from "node:fs";
|
|
@@ -4432,7 +4500,7 @@ function cleanEnv(base, extra = {}) {
|
|
|
4432
4500
|
var CHILDREN_PATH = join5(FROD_DIR, "children.json");
|
|
4433
4501
|
function readChildren() {
|
|
4434
4502
|
try {
|
|
4435
|
-
return
|
|
4503
|
+
return existsSync5(CHILDREN_PATH) ? JSON.parse(readFileSync5(CHILDREN_PATH, "utf8")) : [];
|
|
4436
4504
|
} catch {
|
|
4437
4505
|
return [];
|
|
4438
4506
|
}
|
|
@@ -4484,8 +4552,12 @@ var ClaudeAdapter = class {
|
|
|
4484
4552
|
}
|
|
4485
4553
|
/** `claude --version`, and whether the CLI says it is signed in. Never throws. */
|
|
4486
4554
|
static check(claudePath, commands = {}) {
|
|
4487
|
-
const
|
|
4488
|
-
|
|
4555
|
+
const run = (path, args) => {
|
|
4556
|
+
const l = launchOf(path, args);
|
|
4557
|
+
return execFileSync2(l.command, l.args, { env: cleanEnv(process.env), stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString();
|
|
4558
|
+
};
|
|
4559
|
+
const versionCommand = commands.version ?? ((path) => run(path, ["--version"]));
|
|
4560
|
+
const authStatusCommand = commands.authStatus ?? ((path) => run(path, ["auth", "status"]));
|
|
4489
4561
|
let version;
|
|
4490
4562
|
try {
|
|
4491
4563
|
version = versionCommand(claudePath).trim();
|
|
@@ -4502,7 +4574,7 @@ var ClaudeAdapter = class {
|
|
|
4502
4574
|
}
|
|
4503
4575
|
}
|
|
4504
4576
|
async start(spec, o) {
|
|
4505
|
-
if (!
|
|
4577
|
+
if (!existsSync5(o.sessionDir)) mkdirSync2(o.sessionDir, { recursive: true, mode: 448 });
|
|
4506
4578
|
const stored = spec.resume_session_id ? getStored(spec.session_id) : null;
|
|
4507
4579
|
const resumeId = spec.resume_session_id && stored?.hostSessionId === spec.resume_session_id ? spec.resume_session_id : null;
|
|
4508
4580
|
const live = {
|
|
@@ -4599,7 +4671,9 @@ var ClaudeAdapter = class {
|
|
|
4599
4671
|
if (bytes > ARGV_LIMIT_BYTES) {
|
|
4600
4672
|
throw new Error(`The agent's instructions are too long to start Claude Code on this computer (${Math.round(bytes / 1024)} KB of arguments; the limit here is about ${Math.round(ARGV_LIMIT_BYTES / 1024)} KB). Shorten its skills or system instructions.`);
|
|
4601
4673
|
}
|
|
4602
|
-
const
|
|
4674
|
+
const launch = launchOf(this.opts.claudePath, args);
|
|
4675
|
+
if (launch.note) log.warn(launch.note);
|
|
4676
|
+
const child = spawn(launch.command, launch.args, {
|
|
4603
4677
|
cwd: live.sessionDir,
|
|
4604
4678
|
env: cleanEnv(process.env, { ...this.opts.env, ...live.secretEnv }),
|
|
4605
4679
|
stdio: ["pipe", "pipe", "pipe"]
|
|
@@ -4860,6 +4934,8 @@ var CodexTranslator = class {
|
|
|
4860
4934
|
}
|
|
4861
4935
|
/** The newest `thread/tokenUsage/updated` of this turn, or null if none arrived. */
|
|
4862
4936
|
usage = null;
|
|
4937
|
+
/** The running thread counters immediately before this turn's first model call. */
|
|
4938
|
+
billingBase = null;
|
|
4863
4939
|
/** Item ids whose text was already streamed as deltas — not repeated on completion. */
|
|
4864
4940
|
streamed = /* @__PURE__ */ new Set();
|
|
4865
4941
|
/** Tool items announced with `item/started`, so a completion without a start still opens a card. */
|
|
@@ -4949,19 +5025,42 @@ var CodexTranslator = class {
|
|
|
4949
5025
|
}
|
|
4950
5026
|
onTokenUsage(p) {
|
|
4951
5027
|
const u = p.tokenUsage;
|
|
4952
|
-
if (u && typeof u === "object" && u.last && u.total)
|
|
5028
|
+
if (u && typeof u === "object" && u.last && u.total) {
|
|
5029
|
+
if (!this.billingBase) {
|
|
5030
|
+
const total = countersOf(u.total);
|
|
5031
|
+
const last = countersOf(u.last);
|
|
5032
|
+
if (total && last && countersCover(total, last)) this.billingBase = usageDelta(total, last);
|
|
5033
|
+
}
|
|
5034
|
+
this.usage = u;
|
|
5035
|
+
}
|
|
4953
5036
|
return true;
|
|
4954
5037
|
}
|
|
4955
5038
|
};
|
|
4956
|
-
function
|
|
5039
|
+
function countersOf(breakdown) {
|
|
5040
|
+
const inputTokens = num(breakdown.inputTokens);
|
|
5041
|
+
const outputTokens = num(breakdown.outputTokens);
|
|
5042
|
+
if (inputTokens === null || outputTokens === null) return null;
|
|
5043
|
+
return {
|
|
5044
|
+
inputTokens,
|
|
5045
|
+
outputTokens,
|
|
5046
|
+
cacheCreationTokens: num(breakdown.cacheWriteInputTokens) ?? 0,
|
|
5047
|
+
cacheReadTokens: num(breakdown.cachedInputTokens) ?? 0
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5050
|
+
function countersCover(total, part) {
|
|
5051
|
+
return total.inputTokens >= part.inputTokens && total.outputTokens >= part.outputTokens && total.cacheCreationTokens >= part.cacheCreationTokens && total.cacheReadTokens >= part.cacheReadTokens;
|
|
5052
|
+
}
|
|
5053
|
+
function toUsage(usage, model, billingBase = null) {
|
|
4957
5054
|
if (!usage || !model) return null;
|
|
4958
5055
|
const { last, total } = usage;
|
|
4959
|
-
|
|
5056
|
+
const cumulative = countersOf(total);
|
|
5057
|
+
if (!cumulative) return null;
|
|
5058
|
+
const turn = usageDelta(cumulative, billingBase);
|
|
4960
5059
|
return {
|
|
4961
|
-
input_tokens: Math.max(0,
|
|
4962
|
-
output_tokens:
|
|
4963
|
-
cache_creation_tokens:
|
|
4964
|
-
cache_read_tokens:
|
|
5060
|
+
input_tokens: Math.max(0, turn.inputTokens - turn.cacheReadTokens),
|
|
5061
|
+
output_tokens: turn.outputTokens,
|
|
5062
|
+
cache_creation_tokens: turn.cacheCreationTokens,
|
|
5063
|
+
cache_read_tokens: turn.cacheReadTokens,
|
|
4965
5064
|
model,
|
|
4966
5065
|
context_tokens: num(last.inputTokens),
|
|
4967
5066
|
context_window: num(usage.modelContextWindow)
|
|
@@ -5046,9 +5145,13 @@ var CodexAdapter = class _CodexAdapter {
|
|
|
5046
5145
|
}
|
|
5047
5146
|
/** The version and whether somebody is signed in — the same shape ClaudeAdapter.check returns. */
|
|
5048
5147
|
static check(codexPath, commands = {}) {
|
|
5049
|
-
const versionCommand = commands.version ?? ((path) =>
|
|
5148
|
+
const versionCommand = commands.version ?? ((path) => {
|
|
5149
|
+
const l = launchOf(path, ["--version"]);
|
|
5150
|
+
return execFileSync3(l.command, l.args, { stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString();
|
|
5151
|
+
});
|
|
5050
5152
|
const loginStatusCommand = commands.loginStatus ?? ((path) => {
|
|
5051
|
-
const
|
|
5153
|
+
const l = launchOf(path, ["login", "status"]);
|
|
5154
|
+
const res = spawnSync(l.command, l.args, { encoding: "utf8", timeout: 3e4 });
|
|
5052
5155
|
if (res.error) throw res.error;
|
|
5053
5156
|
return `${res.stdout ?? ""}${res.stderr ?? ""}`;
|
|
5054
5157
|
});
|
|
@@ -5177,7 +5280,9 @@ var CodexAdapter = class _CodexAdapter {
|
|
|
5177
5280
|
// The process and the wire
|
|
5178
5281
|
// -------------------------------------------------------------------------
|
|
5179
5282
|
spawnChild(live) {
|
|
5180
|
-
const
|
|
5283
|
+
const launch = launchOf(this.opts.codexPath, ["app-server"]);
|
|
5284
|
+
if (launch.note) log.warn(launch.note);
|
|
5285
|
+
const child = spawn2(launch.command, launch.args, {
|
|
5181
5286
|
cwd: live.sessionDir,
|
|
5182
5287
|
stdio: ["pipe", "pipe", "pipe"],
|
|
5183
5288
|
env: { ...process.env }
|
|
@@ -5232,7 +5337,8 @@ var CodexAdapter = class _CodexAdapter {
|
|
|
5232
5337
|
const status2 = typeof turn.status === "string" ? turn.status : "completed";
|
|
5233
5338
|
const err = turn.error;
|
|
5234
5339
|
const stopReason = status2 === "interrupted" ? "cancelled" : status2 === "failed" ? "error" : "end_turn";
|
|
5235
|
-
const
|
|
5340
|
+
const translator = live.turn?.translator;
|
|
5341
|
+
const usage = toUsage(translator?.usage ?? null, live.model, translator?.billingBase ?? null);
|
|
5236
5342
|
live.turnId = null;
|
|
5237
5343
|
this.settleTurn(live, {
|
|
5238
5344
|
stopReason,
|
|
@@ -5450,7 +5556,7 @@ function describeError(err) {
|
|
|
5450
5556
|
|
|
5451
5557
|
// src/adapters/grok.ts
|
|
5452
5558
|
import { spawn as spawn3, spawnSync as spawnSync2, execFileSync as execFileSync4 } from "node:child_process";
|
|
5453
|
-
import { existsSync as
|
|
5559
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "node:fs";
|
|
5454
5560
|
|
|
5455
5561
|
// src/adapters/grok-spec.ts
|
|
5456
5562
|
import { isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2, join as join6 } from "node:path";
|
|
@@ -5571,19 +5677,29 @@ function stopReasonFor(acp) {
|
|
|
5571
5677
|
}
|
|
5572
5678
|
}
|
|
5573
5679
|
var num2 = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
5574
|
-
function
|
|
5680
|
+
function cumulativeUsageOf(meta) {
|
|
5575
5681
|
const total = meta?.usage;
|
|
5682
|
+
if (!total) return null;
|
|
5683
|
+
const inputTokens = num2(total.inputTokens);
|
|
5684
|
+
const outputTokens = num2(total.outputTokens);
|
|
5685
|
+
if (inputTokens === null || outputTokens === null) return null;
|
|
5686
|
+
return {
|
|
5687
|
+
inputTokens,
|
|
5688
|
+
outputTokens,
|
|
5689
|
+
cacheCreationTokens: num2(total.cacheCreationTokens) ?? 0,
|
|
5690
|
+
cacheReadTokens: num2(total.cachedReadTokens) ?? 0
|
|
5691
|
+
};
|
|
5692
|
+
}
|
|
5693
|
+
function toUsage2(meta, fallbackModel, contextWindow, previous = null) {
|
|
5576
5694
|
const model = str2(meta?.modelId) ?? fallbackModel;
|
|
5577
|
-
|
|
5578
|
-
|
|
5579
|
-
const
|
|
5580
|
-
if (input === null || output === null) return null;
|
|
5581
|
-
const cacheRead = num2(total.cachedReadTokens) ?? 0;
|
|
5695
|
+
const cumulative = cumulativeUsageOf(meta);
|
|
5696
|
+
if (!cumulative || !model) return null;
|
|
5697
|
+
const turn = usageDelta(cumulative, previous);
|
|
5582
5698
|
return {
|
|
5583
|
-
input_tokens: Math.max(0,
|
|
5584
|
-
output_tokens:
|
|
5585
|
-
cache_creation_tokens:
|
|
5586
|
-
cache_read_tokens:
|
|
5699
|
+
input_tokens: Math.max(0, turn.inputTokens - turn.cacheReadTokens),
|
|
5700
|
+
output_tokens: turn.outputTokens,
|
|
5701
|
+
cache_creation_tokens: turn.cacheCreationTokens,
|
|
5702
|
+
cache_read_tokens: turn.cacheReadTokens,
|
|
5587
5703
|
model,
|
|
5588
5704
|
context_tokens: num2(meta?.inputTokens),
|
|
5589
5705
|
context_window: contextWindow
|
|
@@ -5767,9 +5883,13 @@ var GrokAdapter = class _GrokAdapter {
|
|
|
5767
5883
|
*/
|
|
5768
5884
|
static check(grokPath, commands = {}) {
|
|
5769
5885
|
const env = { ...process.env, GROK_DISABLE_AUTOUPDATER: "1" };
|
|
5770
|
-
const versionCommand = commands.version ?? ((path) =>
|
|
5886
|
+
const versionCommand = commands.version ?? ((path) => {
|
|
5887
|
+
const l = launchOf(path, ["--version"]);
|
|
5888
|
+
return execFileSync4(l.command, l.args, { env, stdio: ["ignore", "pipe", "ignore"], timeout: 2e4 }).toString();
|
|
5889
|
+
});
|
|
5771
5890
|
const modelsCommand = commands.models ?? ((path) => {
|
|
5772
|
-
const
|
|
5891
|
+
const l = launchOf(path, ["models"]);
|
|
5892
|
+
const res = spawnSync2(l.command, l.args, { env, encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
5773
5893
|
if (res.error) throw res.error;
|
|
5774
5894
|
return `${res.stdout ?? ""}
|
|
5775
5895
|
${res.stderr ?? ""}`;
|
|
@@ -5809,6 +5929,8 @@ ${res.stderr ?? ""}`;
|
|
|
5809
5929
|
turn: null,
|
|
5810
5930
|
model: spec.model?.name ?? null,
|
|
5811
5931
|
contextWindow: null,
|
|
5932
|
+
cumulativeUsage: null,
|
|
5933
|
+
needsUsageBaseline: false,
|
|
5812
5934
|
onExit: o.onExit,
|
|
5813
5935
|
askPermission: o.askPermission,
|
|
5814
5936
|
reportAutoAllowed: o.reportAutoAllowed,
|
|
@@ -5818,7 +5940,7 @@ ${res.stderr ?? ""}`;
|
|
|
5818
5940
|
stderrTail: []
|
|
5819
5941
|
};
|
|
5820
5942
|
this.live.set(spec.session_id, live);
|
|
5821
|
-
if (!
|
|
5943
|
+
if (!existsSync6(o.sessionDir)) mkdirSync4(o.sessionDir, { recursive: true, mode: 448 });
|
|
5822
5944
|
this.spawnChild(live);
|
|
5823
5945
|
let init;
|
|
5824
5946
|
try {
|
|
@@ -5839,6 +5961,10 @@ ${res.stderr ?? ""}`;
|
|
|
5839
5961
|
const mcpServers = mcpServersFor(spec);
|
|
5840
5962
|
const params = { cwd: o.sessionDir, mcpServers, _meta: sessionMeta(spec) };
|
|
5841
5963
|
const resumeId = spec.resume_session_id ?? null;
|
|
5964
|
+
const resumedStore = resumeId ? getStoredByHostSessionId(resumeId) : null;
|
|
5965
|
+
if (resumedStore?.cumulativeUsage?.adapter === "grok" && isUsageCounters(resumedStore.cumulativeUsage.counters)) {
|
|
5966
|
+
live.cumulativeUsage = { ...resumedStore.cumulativeUsage.counters };
|
|
5967
|
+
}
|
|
5842
5968
|
let resumed = false;
|
|
5843
5969
|
let started;
|
|
5844
5970
|
if (resumeId) {
|
|
@@ -5850,12 +5976,25 @@ ${res.stderr ?? ""}`;
|
|
|
5850
5976
|
}
|
|
5851
5977
|
}
|
|
5852
5978
|
if (!started) started = await this.request(live, "session/new", params);
|
|
5979
|
+
if (!resumed) live.cumulativeUsage = null;
|
|
5980
|
+
live.needsUsageBaseline = resumed && !live.cumulativeUsage;
|
|
5853
5981
|
const sessionId = typeof started?.sessionId === "string" ? started.sessionId : null;
|
|
5854
5982
|
if (!sessionId) throw new Error("Grok Build started but did not name the session \u2014 it cannot be addressed.");
|
|
5855
5983
|
live.acpSessionId = sessionId;
|
|
5856
5984
|
const models = started?.models ?? init?._meta?.modelState ?? null;
|
|
5857
5985
|
live.model = live.model ?? (typeof models?.currentModelId === "string" ? models.currentModelId : null);
|
|
5858
5986
|
live.contextWindow = contextWindowOf(models, live.model);
|
|
5987
|
+
try {
|
|
5988
|
+
putStored(spec.session_id, {
|
|
5989
|
+
hostSessionId: sessionId,
|
|
5990
|
+
cwd: o.sessionDir,
|
|
5991
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5992
|
+
lastPromptAt: null,
|
|
5993
|
+
...resumed && live.cumulativeUsage ? { cumulativeUsage: { adapter: "grok", counters: { ...live.cumulativeUsage } } } : {}
|
|
5994
|
+
});
|
|
5995
|
+
} catch (err) {
|
|
5996
|
+
log.warn(`[${spec.session_id.slice(0, 8)}] grok: could not open the usage-counter record (${err instanceof Error ? err.message : String(err)})`);
|
|
5997
|
+
}
|
|
5859
5998
|
log.event("grok.started", {
|
|
5860
5999
|
session: spec.session_id,
|
|
5861
6000
|
acp_session: sessionId,
|
|
@@ -5880,7 +6019,21 @@ ${res.stderr ?? ""}`;
|
|
|
5880
6019
|
const meta = r._meta ?? null;
|
|
5881
6020
|
if (typeof meta?.modelId === "string" && meta.modelId) live.model = meta.modelId;
|
|
5882
6021
|
const { stopReason, error } = stopReasonFor(r.stopReason);
|
|
5883
|
-
|
|
6022
|
+
const cumulative = cumulativeUsageOf(meta);
|
|
6023
|
+
const usage = live.needsUsageBaseline ? null : toUsage2(meta, live.model, live.contextWindow, live.cumulativeUsage);
|
|
6024
|
+
if (cumulative) {
|
|
6025
|
+
if (live.needsUsageBaseline) {
|
|
6026
|
+
log.warn(`[${live.spec.session_id.slice(0, 8)}] grok: resumed without an earlier usage snapshot \u2014 this turn is unpriced and seeds the next increment`);
|
|
6027
|
+
live.needsUsageBaseline = false;
|
|
6028
|
+
}
|
|
6029
|
+
live.cumulativeUsage = cumulative;
|
|
6030
|
+
try {
|
|
6031
|
+
putStoredUsage(live.spec.session_id, "grok", cumulative);
|
|
6032
|
+
} catch (err) {
|
|
6033
|
+
log.warn(`[${live.spec.session_id.slice(0, 8)}] grok: could not remember usage counters (${err instanceof Error ? err.message : String(err)})`);
|
|
6034
|
+
}
|
|
6035
|
+
}
|
|
6036
|
+
this.settleTurn(live, { stopReason, usage, ...error ? { error } : {} });
|
|
5884
6037
|
}).catch((err) => {
|
|
5885
6038
|
this.settleTurn(live, { stopReason: "error", usage: null, error: err.message });
|
|
5886
6039
|
});
|
|
@@ -5920,7 +6073,9 @@ ${res.stderr ?? ""}`;
|
|
|
5920
6073
|
// The process and the wire
|
|
5921
6074
|
// -------------------------------------------------------------------------
|
|
5922
6075
|
spawnChild(live) {
|
|
5923
|
-
const
|
|
6076
|
+
const launch = launchOf(this.opts.grokPath, grokArgs(live.spec));
|
|
6077
|
+
if (launch.note) log.warn(launch.note);
|
|
6078
|
+
const child = spawn3(launch.command, launch.args, {
|
|
5924
6079
|
cwd: live.sessionDir,
|
|
5925
6080
|
stdio: ["pipe", "pipe", "pipe"],
|
|
5926
6081
|
env: grokEnv(process.env)
|
|
@@ -6129,7 +6284,7 @@ import { hostname, platform as platform2 } from "node:os";
|
|
|
6129
6284
|
|
|
6130
6285
|
// src/repo.ts
|
|
6131
6286
|
import { spawn as spawn4 } from "node:child_process";
|
|
6132
|
-
import { existsSync as
|
|
6287
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync5, readdirSync, rmSync, statSync } from "node:fs";
|
|
6133
6288
|
import { join as join7, resolve as resolve3 } from "node:path";
|
|
6134
6289
|
var PLATFORM_COMMIT_ENV = {
|
|
6135
6290
|
GIT_AUTHOR_NAME: "AgentOrchestrator",
|
|
@@ -6230,7 +6385,7 @@ async function canReach(gitUrl, timeoutMs = 3e4) {
|
|
|
6230
6385
|
return { ok: false, detail: firstLine(r.stderr) || `git exited ${r.code}`, ms: r.ms };
|
|
6231
6386
|
}
|
|
6232
6387
|
async function originOf(dir) {
|
|
6233
|
-
if (!
|
|
6388
|
+
if (!existsSync7(join7(dir, ".git"))) return null;
|
|
6234
6389
|
const r = await git(["-C", dir, "remote", "get-url", "origin"], void 0, { timeoutMs: 1e4 });
|
|
6235
6390
|
return r.code === 0 ? r.stdout.trim() : null;
|
|
6236
6391
|
}
|
|
@@ -6255,7 +6410,7 @@ async function prepareCheckout(repo, workdir, computerName, log2, opts = {}) {
|
|
|
6255
6410
|
throw new Error(`${dir} already holds a different repository (${existing}); not touching it`);
|
|
6256
6411
|
}
|
|
6257
6412
|
if (!existing) {
|
|
6258
|
-
if (
|
|
6413
|
+
if (existsSync7(dir)) rmSync(dir, { recursive: true, force: true });
|
|
6259
6414
|
mkdirSync5(resolve3(dir, ".."), { recursive: true, mode: 448 });
|
|
6260
6415
|
const branchToClone = onOrigin ? repo.branch : repo.default_branch;
|
|
6261
6416
|
const clone = await git(["clone", "--quiet", "--branch", branchToClone, "--single-branch", "--", repo.git_url, dir], void 0, { timeoutMs: 10 * 6e4 });
|
|
@@ -6331,7 +6486,7 @@ function pruneRunDirs(workdir, maxAgeMs = 7 * 24 * 60 * 6e4) {
|
|
|
6331
6486
|
let removed = 0;
|
|
6332
6487
|
for (const sub of ["runs"]) {
|
|
6333
6488
|
const root = join7(workdir, sub);
|
|
6334
|
-
if (!
|
|
6489
|
+
if (!existsSync7(root)) continue;
|
|
6335
6490
|
for (const name of readdirSync(root)) {
|
|
6336
6491
|
const p = resolve3(root, name);
|
|
6337
6492
|
try {
|
|
@@ -6347,7 +6502,7 @@ function pruneRunDirs(workdir, maxAgeMs = 7 * 24 * 60 * 6e4) {
|
|
|
6347
6502
|
}
|
|
6348
6503
|
|
|
6349
6504
|
// src/repo-door.ts
|
|
6350
|
-
import { existsSync as
|
|
6505
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
6351
6506
|
var DoorCheckouts = class {
|
|
6352
6507
|
bySession = /* @__PURE__ */ new Map();
|
|
6353
6508
|
get(sessionId, repositoryId) {
|
|
@@ -6426,7 +6581,7 @@ async function handleRepoRequest(req, sessionId, ctx, checkouts) {
|
|
|
6426
6581
|
}
|
|
6427
6582
|
const known = checkouts.get(sessionId, req.repository.id);
|
|
6428
6583
|
const at = known?.dir ?? dir;
|
|
6429
|
-
if (!
|
|
6584
|
+
if (!existsSync8(at)) throw new Error(`${req.repository.name} is not checked out on ${ctx.computerName} for this session \u2014 call checkout_repository first`);
|
|
6430
6585
|
if (req.action === "status") {
|
|
6431
6586
|
const [branch, head, changed] = await Promise.all([
|
|
6432
6587
|
git(["-C", at, "symbolic-ref", "--short", "-q", "HEAD"], void 0, { timeoutMs: 1e4 }),
|
|
@@ -6444,7 +6599,7 @@ async function handleRepoRequest(req, sessionId, ctx, checkouts) {
|
|
|
6444
6599
|
}
|
|
6445
6600
|
|
|
6446
6601
|
// src/workdir.ts
|
|
6447
|
-
import { existsSync as
|
|
6602
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync6, realpathSync as realpathSync2 } from "node:fs";
|
|
6448
6603
|
import { homedir as homedir4 } from "node:os";
|
|
6449
6604
|
import { dirname as dirname2, isAbsolute as isAbsolute3, relative, resolve as resolve4, sep as sep3 } from "node:path";
|
|
6450
6605
|
function expandHome(p, home = homedir4()) {
|
|
@@ -6482,12 +6637,12 @@ function resolveWorkdir(requested, rule, opts = {}) {
|
|
|
6482
6637
|
const realAnchor = realpathSync2(anchor);
|
|
6483
6638
|
const realTarget = resolve4(realAnchor, relative(anchor, abs));
|
|
6484
6639
|
if (!realRoots.some((root) => under(realTarget, root))) throw new Error(`${refusalWords(rule, home)} (the folder links to a place outside them)`);
|
|
6485
|
-
if (opts.create !== false && !
|
|
6486
|
-
return
|
|
6640
|
+
if (opts.create !== false && !existsSync9(abs)) mkdirSync6(abs, { recursive: true, mode: 448 });
|
|
6641
|
+
return existsSync9(abs) ? realpathSync2(abs) : realTarget;
|
|
6487
6642
|
}
|
|
6488
6643
|
function deepestExisting(abs) {
|
|
6489
6644
|
let cur = abs;
|
|
6490
|
-
while (!
|
|
6645
|
+
while (!existsSync9(cur)) {
|
|
6491
6646
|
const parent = dirname2(cur);
|
|
6492
6647
|
if (parent === cur) return cur;
|
|
6493
6648
|
cur = parent;
|
|
@@ -6977,12 +7132,12 @@ function plainWords(message) {
|
|
|
6977
7132
|
|
|
6978
7133
|
// src/install.ts
|
|
6979
7134
|
import { execFileSync as execFileSync6 } from "node:child_process";
|
|
6980
|
-
import { existsSync as
|
|
7135
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync8, writeFileSync as writeFileSync6, unlinkSync as unlinkSync2 } from "node:fs";
|
|
6981
7136
|
import { homedir as homedir5, platform as platform4, userInfo } from "node:os";
|
|
6982
|
-
import { dirname as dirname4, join as join9 } from "node:path";
|
|
7137
|
+
import { dirname as dirname4, join as join9, win32 as winPath2 } from "node:path";
|
|
6983
7138
|
|
|
6984
7139
|
// src/updater.ts
|
|
6985
|
-
import { existsSync as
|
|
7140
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync7, rmSync as rmSync2, readdirSync as readdirSync2, renameSync, symlinkSync, readFileSync as readFileSync6, lstatSync, realpathSync as realpathSync3, readlinkSync, cpSync, writeFileSync as writeFileSync5, statSync as statSync2 } from "node:fs";
|
|
6986
7141
|
import { join as join8, dirname as dirname3, basename, resolve as resolve5, sep as sep4 } from "node:path";
|
|
6987
7142
|
import { platform as platform3 } from "node:os";
|
|
6988
7143
|
import { execFileSync as execFileSync5 } from "node:child_process";
|
|
@@ -7093,7 +7248,7 @@ function currentVersion(currentLink = CURRENT_LINK) {
|
|
|
7093
7248
|
}
|
|
7094
7249
|
function packageVersionAt(root) {
|
|
7095
7250
|
try {
|
|
7096
|
-
const pkg = JSON.parse(
|
|
7251
|
+
const pkg = JSON.parse(readFileSync6(join8(root, "package.json"), "utf8"));
|
|
7097
7252
|
return pkg.name && pkg.version ? { name: pkg.name, version: pkg.version } : null;
|
|
7098
7253
|
} catch {
|
|
7099
7254
|
return null;
|
|
@@ -7105,14 +7260,14 @@ function stagePackage(loc, expectedVersion, versionsDir = VERSIONS_DIR) {
|
|
|
7105
7260
|
if (pkg.version !== expectedVersion) return { ok: false, reason: `the package at ${loc.packageRoot} is ${pkg.version}, this bridge is ${expectedVersion}` };
|
|
7106
7261
|
const dest = join8(versionsDir, pkg.version);
|
|
7107
7262
|
if (loc.installedVersion === pkg.version && safeReal(loc.packageRoot) === safeReal(dest)) return { ok: true, dir: dest, copied: false };
|
|
7108
|
-
if (!
|
|
7263
|
+
if (!existsSync10(join8(loc.packageRoot, "dist", "cli.js"))) {
|
|
7109
7264
|
return { ok: false, reason: `${loc.packageRoot} has no dist/cli.js \u2014 run \`npm run build -w @frod.io/bridge\` in the checkout, or run the published package: npx @frod.io/bridge@latest install` };
|
|
7110
7265
|
}
|
|
7111
|
-
if (
|
|
7266
|
+
if (existsSync10(join8(dest, "dist", "cli.js"))) return { ok: true, dir: dest, copied: false };
|
|
7112
7267
|
const partial = `${dest}.partial`;
|
|
7113
7268
|
rmSync2(partial, { recursive: true, force: true });
|
|
7114
7269
|
mkdirSync7(partial, { recursive: true, mode: 493 });
|
|
7115
|
-
for (const f of ["package.json", "README.md"]) if (
|
|
7270
|
+
for (const f of ["package.json", "README.md"]) if (existsSync10(join8(loc.packageRoot, f))) cpSync(join8(loc.packageRoot, f), join8(partial, f));
|
|
7116
7271
|
cpSync(join8(loc.packageRoot, "dist"), join8(partial, "dist"), { recursive: true });
|
|
7117
7272
|
rmSync2(dest, { recursive: true, force: true });
|
|
7118
7273
|
renameSync(partial, dest);
|
|
@@ -7172,7 +7327,7 @@ async function stageRelease(release, opts = {}) {
|
|
|
7172
7327
|
const pkg = packageVersionAt(root);
|
|
7173
7328
|
if (!pkg || pkg.name !== "@frod.io/bridge") return refuse("the tarball is not the @frod.io/bridge package");
|
|
7174
7329
|
if (pkg.version !== v) return refuse(`the tarball says it is ${pkg.version}, not ${v}`);
|
|
7175
|
-
if (!
|
|
7330
|
+
if (!existsSync10(join8(root, "dist", "cli.js"))) return refuse("the tarball has no dist/cli.js");
|
|
7176
7331
|
try {
|
|
7177
7332
|
rmSync2(dest, { recursive: true, force: true });
|
|
7178
7333
|
renameSync(root, dest);
|
|
@@ -7184,7 +7339,7 @@ async function stageRelease(release, opts = {}) {
|
|
|
7184
7339
|
}
|
|
7185
7340
|
function swapCurrent(version, versionsDir = VERSIONS_DIR, currentLink = CURRENT_LINK) {
|
|
7186
7341
|
const target = join8(versionsDir, version);
|
|
7187
|
-
if (!
|
|
7342
|
+
if (!existsSync10(join8(target, "dist", "cli.js"))) throw new Error(`${target} has no dist/cli.js \u2014 not swapping`);
|
|
7188
7343
|
mkdirSync7(dirname3(currentLink), { recursive: true });
|
|
7189
7344
|
const tmp = `${currentLink}.tmp-${process.pid}`;
|
|
7190
7345
|
rmSync2(tmp, { recursive: true, force: true });
|
|
@@ -7199,7 +7354,7 @@ function swapCurrent(version, versionsDir = VERSIONS_DIR, currentLink = CURRENT_
|
|
|
7199
7354
|
}
|
|
7200
7355
|
function pruneVersions(opts = {}) {
|
|
7201
7356
|
const versionsDir = opts.versionsDir ?? VERSIONS_DIR;
|
|
7202
|
-
if (!
|
|
7357
|
+
if (!existsSync10(versionsDir)) return [];
|
|
7203
7358
|
const keepSet = new Set([opts.running ?? "", currentVersion(opts.currentLink ?? CURRENT_LINK) ?? ""].filter(Boolean));
|
|
7204
7359
|
const entries = readdirSync2(versionsDir);
|
|
7205
7360
|
const versions = entries.filter((e) => SEMVER.test(e)).sort((a, b) => -(compareSemver(a, b) ?? 0));
|
|
@@ -7361,17 +7516,22 @@ function xml(s) {
|
|
|
7361
7516
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
7362
7517
|
}
|
|
7363
7518
|
function defaultTarget() {
|
|
7364
|
-
return { node: process.execPath, cli: join9(CURRENT_LINK, "dist", "cli.js"), workdir: BRIDGE_DIR, home: homedir5(), frodDir: FROD_DIR, logPath: LOG_PATH };
|
|
7519
|
+
return { node: process.execPath, cli: join9(CURRENT_LINK, "dist", "cli.js"), workdir: BRIDGE_DIR, home: homedir5(), frodDir: FROD_DIR, logPath: LOG_PATH, user: windowsUser() };
|
|
7520
|
+
}
|
|
7521
|
+
function windowsUser(env = process.env) {
|
|
7522
|
+
const name = env.USERNAME ?? userInfo().username;
|
|
7523
|
+
const domain = env.USERDOMAIN;
|
|
7524
|
+
return domain ? `${domain}\\${name}` : name;
|
|
7365
7525
|
}
|
|
7366
7526
|
var realIo = {
|
|
7367
7527
|
exec: (cmd, args) => {
|
|
7368
7528
|
execFileSync6(cmd, args, { stdio: "pipe" });
|
|
7369
7529
|
},
|
|
7370
|
-
writeFile: (path, content, mode) => {
|
|
7371
|
-
writeFileSync6(path, content, { encoding
|
|
7530
|
+
writeFile: (path, content, mode, encoding = "utf8") => {
|
|
7531
|
+
writeFileSync6(path, content, { encoding, mode });
|
|
7372
7532
|
},
|
|
7373
7533
|
mkdir: (dir) => {
|
|
7374
|
-
if (!
|
|
7534
|
+
if (!existsSync11(dir)) mkdirSync8(dir, { recursive: true });
|
|
7375
7535
|
}
|
|
7376
7536
|
};
|
|
7377
7537
|
function renderLaunchdPlist(t) {
|
|
@@ -7445,7 +7605,7 @@ function uninstallMacOS() {
|
|
|
7445
7605
|
execFileSync6("launchctl", ["bootout", `gui/${uid}/${LAUNCHD_LABEL}`], { stdio: "ignore" });
|
|
7446
7606
|
} catch {
|
|
7447
7607
|
}
|
|
7448
|
-
if (
|
|
7608
|
+
if (existsSync11(plistPath())) {
|
|
7449
7609
|
unlinkSync2(plistPath());
|
|
7450
7610
|
return { removed: true };
|
|
7451
7611
|
}
|
|
@@ -7522,12 +7682,96 @@ function uninstallLinux(t = defaultTarget(), io = realIo) {
|
|
|
7522
7682
|
} catch (err) {
|
|
7523
7683
|
detail = `systemctl: ${err instanceof Error ? err.message : String(err)}`;
|
|
7524
7684
|
}
|
|
7525
|
-
if (
|
|
7685
|
+
if (existsSync11(path)) {
|
|
7526
7686
|
unlinkSync2(path);
|
|
7527
7687
|
return { removed: true, detail };
|
|
7528
7688
|
}
|
|
7529
7689
|
return { removed: false, detail };
|
|
7530
7690
|
}
|
|
7691
|
+
function hiddenLauncherPath(t) {
|
|
7692
|
+
return winPath2.join(t.frodDir, "bridge-hidden.vbs");
|
|
7693
|
+
}
|
|
7694
|
+
function windowsTaskXmlPath(t) {
|
|
7695
|
+
return winPath2.join(t.frodDir, "bridge-task.xml");
|
|
7696
|
+
}
|
|
7697
|
+
function vbString(s) {
|
|
7698
|
+
return `"${s.replace(/"/g, '""')}"`;
|
|
7699
|
+
}
|
|
7700
|
+
function renderHiddenLauncher(t) {
|
|
7701
|
+
const command = `cmd /c "${'"'}${t.node}${'"'} ${'"'}${t.cli}${'"'} run >> ${'"'}${t.logPath}${'"'} 2>&1"`;
|
|
7702
|
+
return [
|
|
7703
|
+
`' Frod.io bridge \u2014 started at logon by the scheduled task "` + WINDOWS_TASK + '".',
|
|
7704
|
+
"' No window: the bridge is not something a person can close by accident.",
|
|
7705
|
+
"Dim command",
|
|
7706
|
+
`command = ${vbString(command)}`,
|
|
7707
|
+
'CreateObject("WScript.Shell").Run command, 0, False',
|
|
7708
|
+
""
|
|
7709
|
+
].join("\r\n");
|
|
7710
|
+
}
|
|
7711
|
+
function system32(name, env) {
|
|
7712
|
+
return winPath2.join(env.SystemRoot ?? "C:\\Windows", "System32", name);
|
|
7713
|
+
}
|
|
7714
|
+
function windowsAction(t, env = process.env) {
|
|
7715
|
+
return { command: system32("wscript.exe", env), args: `//B //NoLogo "${hiddenLauncherPath(t)}"` };
|
|
7716
|
+
}
|
|
7717
|
+
function renderWindowsTaskXml(t, env = process.env) {
|
|
7718
|
+
const action = windowsAction(t, env);
|
|
7719
|
+
const user = t.user ?? "BUILTIN\\Users";
|
|
7720
|
+
return `\uFEFF<?xml version="1.0" encoding="UTF-16"?>
|
|
7721
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
7722
|
+
<RegistrationInfo>
|
|
7723
|
+
<Description>${xml("Frod.io bridge \u2014 hosts your agents' sessions on this computer. Starts at logon, runs with no window.")}</Description>
|
|
7724
|
+
<URI>\\${xml(WINDOWS_TASK)}</URI>
|
|
7725
|
+
</RegistrationInfo>
|
|
7726
|
+
<Triggers>
|
|
7727
|
+
<LogonTrigger>
|
|
7728
|
+
<Enabled>true</Enabled>
|
|
7729
|
+
<UserId>${xml(user)}</UserId>
|
|
7730
|
+
</LogonTrigger>
|
|
7731
|
+
</Triggers>
|
|
7732
|
+
<Principals>
|
|
7733
|
+
<Principal id="Author">
|
|
7734
|
+
<UserId>${xml(user)}</UserId>
|
|
7735
|
+
<LogonType>InteractiveToken</LogonType>
|
|
7736
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
7737
|
+
</Principal>
|
|
7738
|
+
</Principals>
|
|
7739
|
+
<Settings>
|
|
7740
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
7741
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
7742
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
7743
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
7744
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
7745
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
7746
|
+
<IdleSettings>
|
|
7747
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
7748
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
7749
|
+
</IdleSettings>
|
|
7750
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
7751
|
+
<Enabled>true</Enabled>
|
|
7752
|
+
<Hidden>true</Hidden>
|
|
7753
|
+
<RunOnlyIfIdle>false</RunOnlyIfIdle>
|
|
7754
|
+
<WakeToRun>false</WakeToRun>
|
|
7755
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
7756
|
+
<Priority>7</Priority>
|
|
7757
|
+
<RestartOnFailure>
|
|
7758
|
+
<Interval>PT1M</Interval>
|
|
7759
|
+
<Count>3</Count>
|
|
7760
|
+
</RestartOnFailure>
|
|
7761
|
+
</Settings>
|
|
7762
|
+
<Actions Context="Author">
|
|
7763
|
+
<Exec>
|
|
7764
|
+
<Command>${xml(action.command)}</Command>
|
|
7765
|
+
<Arguments>${xml(action.args)}</Arguments>
|
|
7766
|
+
<WorkingDirectory>${xml(t.workdir)}</WorkingDirectory>
|
|
7767
|
+
</Exec>
|
|
7768
|
+
</Actions>
|
|
7769
|
+
</Task>
|
|
7770
|
+
`;
|
|
7771
|
+
}
|
|
7772
|
+
function renderSchtasksXmlArgs(t) {
|
|
7773
|
+
return ["/Create", "/F", "/TN", WINDOWS_TASK, "/XML", windowsTaskXmlPath(t)];
|
|
7774
|
+
}
|
|
7531
7775
|
function renderSchtasksArgs(t) {
|
|
7532
7776
|
const tr = `"${t.node}" "${t.cli}" run`;
|
|
7533
7777
|
return ["/Create", "/F", "/TN", WINDOWS_TASK, "/SC", "ONLOGON", "/RL", "LIMITED", "/TR", tr];
|
|
@@ -7535,26 +7779,60 @@ function renderSchtasksArgs(t) {
|
|
|
7535
7779
|
function windowsManualPath(t) {
|
|
7536
7780
|
return [
|
|
7537
7781
|
"To run it at logon, in a command prompt:",
|
|
7538
|
-
` schtasks
|
|
7782
|
+
` schtasks /Create /F /TN "${WINDOWS_TASK}" /XML "${windowsTaskXmlPath(t)}"`,
|
|
7539
7783
|
` schtasks /Run /TN "${WINDOWS_TASK}"`,
|
|
7540
|
-
`
|
|
7541
|
-
`
|
|
7784
|
+
`The task definition and the launcher it runs are written to ${windowsTaskXmlPath(t)} and ${hiddenLauncherPath(t)} whatever happens, so you can register them by hand or open Task Scheduler and import the XML.`,
|
|
7785
|
+
`Without that definition the plain form still works \u2014 schtasks ${renderSchtasksArgs(t).map((a) => /\s/.test(a) && !a.startsWith('"') ? `"${a}"` : a).join(" ")} \u2014 but it shows a console window that ends the bridge when closed, it has no restart on failure, and Windows stops it after 72 hours or when the machine goes on battery.`,
|
|
7786
|
+
`Logs: ${t.logPath}, written by the launcher. A self-update relaunches the bridge itself; if that fails, the next logon starts it.`
|
|
7542
7787
|
].join("\n");
|
|
7543
7788
|
}
|
|
7544
|
-
function installWindows(t = defaultTarget(), io = realIo) {
|
|
7789
|
+
function installWindows(t = defaultTarget(), io = realIo, env = process.env) {
|
|
7545
7790
|
const manual = windowsManualPath(t);
|
|
7791
|
+
io.mkdir(t.frodDir);
|
|
7792
|
+
let wrote = true;
|
|
7793
|
+
try {
|
|
7794
|
+
io.writeFile(hiddenLauncherPath(t), renderHiddenLauncher(t), 420);
|
|
7795
|
+
io.writeFile(windowsTaskXmlPath(t), renderWindowsTaskXml(t, env), 420, "utf16le");
|
|
7796
|
+
} catch {
|
|
7797
|
+
wrote = false;
|
|
7798
|
+
}
|
|
7799
|
+
if (wrote) {
|
|
7800
|
+
try {
|
|
7801
|
+
io.exec("schtasks", renderSchtasksXmlArgs(t));
|
|
7802
|
+
try {
|
|
7803
|
+
io.exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
7804
|
+
} catch {
|
|
7805
|
+
}
|
|
7806
|
+
return { loaded: true, hidden: true, detail: `scheduled task "${WINDOWS_TASK}" created (at logon, no window, restarts after a crash)`, manual };
|
|
7807
|
+
} catch {
|
|
7808
|
+
}
|
|
7809
|
+
}
|
|
7546
7810
|
try {
|
|
7547
7811
|
io.exec("schtasks", renderSchtasksArgs(t));
|
|
7548
7812
|
try {
|
|
7549
7813
|
io.exec("schtasks", ["/Run", "/TN", WINDOWS_TASK]);
|
|
7550
7814
|
} catch {
|
|
7551
7815
|
}
|
|
7552
|
-
return { loaded: true, detail: `scheduled task "${WINDOWS_TASK}" created (at logon)`, manual };
|
|
7816
|
+
return { loaded: true, hidden: false, detail: `scheduled task "${WINDOWS_TASK}" created (at logon) \u2014 the task definition was refused, so it runs in a console window`, manual };
|
|
7553
7817
|
} catch (err) {
|
|
7554
|
-
return { loaded: false, detail: `schtasks refused: ${err instanceof Error ? err.message : String(err)}`, manual };
|
|
7818
|
+
return { loaded: false, hidden: false, detail: `schtasks refused: ${err instanceof Error ? err.message : String(err)}`, manual };
|
|
7819
|
+
}
|
|
7820
|
+
}
|
|
7821
|
+
function windowsTaskLoaded(io = realIo) {
|
|
7822
|
+
try {
|
|
7823
|
+
io.exec("schtasks", ["/Query", "/TN", WINDOWS_TASK]);
|
|
7824
|
+
return true;
|
|
7825
|
+
} catch {
|
|
7826
|
+
return false;
|
|
7555
7827
|
}
|
|
7556
7828
|
}
|
|
7557
|
-
function uninstallWindows(io = realIo) {
|
|
7829
|
+
function uninstallWindows(io = realIo, t = defaultTarget()) {
|
|
7830
|
+
for (const path of [hiddenLauncherPath(t), windowsTaskXmlPath(t)]) {
|
|
7831
|
+
try {
|
|
7832
|
+
if (existsSync11(path)) unlinkSync2(path);
|
|
7833
|
+
} catch {
|
|
7834
|
+
}
|
|
7835
|
+
}
|
|
7558
7836
|
try {
|
|
7559
7837
|
io.exec("schtasks", ["/Delete", "/F", "/TN", WINDOWS_TASK]);
|
|
7560
7838
|
return { removed: true, detail: "task deleted" };
|
|
@@ -7566,11 +7844,84 @@ function installStub(t = defaultTarget()) {
|
|
|
7566
7844
|
return `Automatic install is not available on ${platform4()}. Run: node "${t.cli}" run`;
|
|
7567
7845
|
}
|
|
7568
7846
|
|
|
7847
|
+
// src/runtime-paths.ts
|
|
7848
|
+
import { execFileSync as execFileSync7 } from "node:child_process";
|
|
7849
|
+
import { existsSync as existsSync12 } from "node:fs";
|
|
7850
|
+
import { homedir as homedir6, platform as osPlatform } from "node:os";
|
|
7851
|
+
import { join as join10, win32 as winPath3 } from "node:path";
|
|
7852
|
+
var ENV_OVERRIDE = {
|
|
7853
|
+
claude: "FROD_CLAUDE_PATH",
|
|
7854
|
+
codex: "FROD_CODEX_PATH",
|
|
7855
|
+
grok: "FROD_GROK_PATH"
|
|
7856
|
+
};
|
|
7857
|
+
var WINDOWS_EXTENSIONS = [".exe", ".cmd", ".bat"];
|
|
7858
|
+
function posixCandidates(name, home) {
|
|
7859
|
+
const dirs = [join10(home, ".local", "bin"), "/opt/homebrew/bin", "/usr/local/bin"];
|
|
7860
|
+
if (name === "grok") dirs.splice(1, 0, join10(home, ".grok", "bin"));
|
|
7861
|
+
return dirs.map((d) => join10(d, name));
|
|
7862
|
+
}
|
|
7863
|
+
function windowsCandidates(name, home, env) {
|
|
7864
|
+
const appData = env.APPDATA ?? winPath3.join(home, "AppData", "Roaming");
|
|
7865
|
+
const localAppData = env.LOCALAPPDATA ?? winPath3.join(home, "AppData", "Local");
|
|
7866
|
+
const dirs = [
|
|
7867
|
+
winPath3.join(appData, "npm"),
|
|
7868
|
+
winPath3.join(localAppData, "npm"),
|
|
7869
|
+
winPath3.join(home, ".local", "bin"),
|
|
7870
|
+
winPath3.join(home, `.${name}`, "bin"),
|
|
7871
|
+
winPath3.join(localAppData, "Programs", name),
|
|
7872
|
+
winPath3.join(localAppData, name, "bin")
|
|
7873
|
+
];
|
|
7874
|
+
const out = [];
|
|
7875
|
+
for (const dir of dirs) for (const ext of WINDOWS_EXTENSIONS) out.push(winPath3.join(dir, `${name}${ext}`));
|
|
7876
|
+
return out;
|
|
7877
|
+
}
|
|
7878
|
+
function findOnWindowsPath(command, env = process.env) {
|
|
7879
|
+
const whereExe = winPath3.join(env.SystemRoot ?? "C:\\Windows", "System32", "where.exe");
|
|
7880
|
+
for (const tool of [whereExe, "where.exe"]) {
|
|
7881
|
+
try {
|
|
7882
|
+
const lines = execFileSync7(tool, [command], { stdio: ["ignore", "pipe", "ignore"], timeout: 1e4 }).toString().split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
7883
|
+
if (lines.length === 0) return null;
|
|
7884
|
+
for (const ext of WINDOWS_EXTENSIONS) {
|
|
7885
|
+
const hit = lines.find((l) => l.toLowerCase().endsWith(ext));
|
|
7886
|
+
if (hit) return hit;
|
|
7887
|
+
}
|
|
7888
|
+
return lines[0];
|
|
7889
|
+
} catch {
|
|
7890
|
+
}
|
|
7891
|
+
}
|
|
7892
|
+
return null;
|
|
7893
|
+
}
|
|
7894
|
+
function findOnPosixPath(command) {
|
|
7895
|
+
try {
|
|
7896
|
+
const found = execFileSync7("/usr/bin/which", [command], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
7897
|
+
return found || null;
|
|
7898
|
+
} catch {
|
|
7899
|
+
return null;
|
|
7900
|
+
}
|
|
7901
|
+
}
|
|
7902
|
+
function defaultLookup() {
|
|
7903
|
+
const platform7 = osPlatform();
|
|
7904
|
+
return {
|
|
7905
|
+
platform: platform7,
|
|
7906
|
+
env: process.env,
|
|
7907
|
+
home: homedir6(),
|
|
7908
|
+
exists: existsSync12,
|
|
7909
|
+
onPath: (command) => platform7 === "win32" ? findOnWindowsPath(command, process.env) : findOnPosixPath(command)
|
|
7910
|
+
};
|
|
7911
|
+
}
|
|
7912
|
+
function findRuntime(name, lookup = defaultLookup()) {
|
|
7913
|
+
const override = lookup.env[ENV_OVERRIDE[name]];
|
|
7914
|
+
if (override && lookup.exists(override)) return override;
|
|
7915
|
+
const candidates = lookup.platform === "win32" ? windowsCandidates(name, lookup.home, lookup.env) : posixCandidates(name, lookup.home);
|
|
7916
|
+
for (const c of candidates) if (lookup.exists(c)) return c;
|
|
7917
|
+
return lookup.onPath(name);
|
|
7918
|
+
}
|
|
7919
|
+
|
|
7569
7920
|
// src/capacity.ts
|
|
7570
7921
|
import { execFile, spawn as spawn5 } from "node:child_process";
|
|
7571
|
-
import { existsSync as
|
|
7572
|
-
import { homedir as
|
|
7573
|
-
import { join as
|
|
7922
|
+
import { existsSync as existsSync13, mkdtempSync, readFileSync as readFileSync7 } from "node:fs";
|
|
7923
|
+
import { homedir as homedir7, platform as platform5, tmpdir as tmpdir2, userInfo as userInfo2 } from "node:os";
|
|
7924
|
+
import { join as join11 } from "node:path";
|
|
7574
7925
|
import { createInterface } from "node:readline";
|
|
7575
7926
|
var SAMPLE_INTERVAL_MS = 30 * 6e4;
|
|
7576
7927
|
var READER_TIMEOUT_MS = 25e3;
|
|
@@ -7580,9 +7931,27 @@ var CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
|
7580
7931
|
var CLAUDE_PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
|
|
7581
7932
|
var CLAUDE_OAUTH_BETA = "oauth-2025-04-20";
|
|
7582
7933
|
var APPROVAL_WORDS = "Needs a one-time approval on this computer: allow the bridge to read Claude Code's sign-in in the Keychain";
|
|
7934
|
+
var SECURITY_ITEM_NOT_FOUND = 44;
|
|
7935
|
+
function securityRunner(timeoutMs) {
|
|
7936
|
+
return (args) => new Promise((resolve7) => {
|
|
7937
|
+
execFile("security", args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout) => {
|
|
7938
|
+
if (err) {
|
|
7939
|
+
const e = err;
|
|
7940
|
+
resolve7({ ok: false, code: typeof e.code === "number" ? e.code : null, killed: e.killed === true || e.signal === "SIGTERM" });
|
|
7941
|
+
return;
|
|
7942
|
+
}
|
|
7943
|
+
resolve7({ ok: true, stdout: String(stdout) });
|
|
7944
|
+
});
|
|
7945
|
+
});
|
|
7946
|
+
}
|
|
7947
|
+
async function keychainAccountOf(run) {
|
|
7948
|
+
const meta = await run(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE]);
|
|
7949
|
+
if (!meta.ok) return null;
|
|
7950
|
+
return /"acct"<blob>="([^"]*)"/.exec(meta.stdout)?.[1] ?? null;
|
|
7951
|
+
}
|
|
7583
7952
|
function readClaudeCredential(deps = {}) {
|
|
7584
7953
|
const os = deps.platform ?? platform5();
|
|
7585
|
-
const home = deps.home ??
|
|
7954
|
+
const home = deps.home ?? homedir7();
|
|
7586
7955
|
const parse = (raw) => {
|
|
7587
7956
|
try {
|
|
7588
7957
|
const j = JSON.parse(raw);
|
|
@@ -7594,25 +7963,34 @@ function readClaudeCredential(deps = {}) {
|
|
|
7594
7963
|
}
|
|
7595
7964
|
};
|
|
7596
7965
|
if (os === "darwin") {
|
|
7597
|
-
return
|
|
7598
|
-
execFile("security", ["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"], { timeout: deps.timeoutMs ?? KEYCHAIN_TIMEOUT_MS, maxBuffer: 1 << 20 }, (err, stdout) => {
|
|
7599
|
-
if (err) {
|
|
7600
|
-
const killed = err.killed || err.signal === "SIGTERM";
|
|
7601
|
-
resolve7({ problem: killed ? APPROVAL_WORDS : "Claude Code is not signed in on this computer" });
|
|
7602
|
-
return;
|
|
7603
|
-
}
|
|
7604
|
-
resolve7(parse(String(stdout).trim()));
|
|
7605
|
-
});
|
|
7606
|
-
});
|
|
7966
|
+
return readClaudeKeychain(deps.security ?? securityRunner(deps.timeoutMs ?? KEYCHAIN_TIMEOUT_MS), deps.account ?? userInfo2().username, parse);
|
|
7607
7967
|
}
|
|
7608
|
-
const file =
|
|
7609
|
-
if (!
|
|
7968
|
+
const file = join11(home, ".claude", ".credentials.json");
|
|
7969
|
+
if (!existsSync13(file)) return Promise.resolve({ problem: "Claude Code is not signed in on this computer" });
|
|
7610
7970
|
try {
|
|
7611
|
-
return Promise.resolve(parse(
|
|
7971
|
+
return Promise.resolve(parse(readFileSync7(file, "utf8")));
|
|
7612
7972
|
} catch {
|
|
7613
7973
|
return Promise.resolve({ problem: "Claude Code's sign-in could not be read on this computer" });
|
|
7614
7974
|
}
|
|
7615
7975
|
}
|
|
7976
|
+
async function readClaudeKeychain(run, account, parse) {
|
|
7977
|
+
let answer = await run(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-a", account, "-w"]);
|
|
7978
|
+
let scoped = true;
|
|
7979
|
+
if (!answer.ok && !answer.killed && answer.code === SECURITY_ITEM_NOT_FOUND) {
|
|
7980
|
+
answer = await run(["find-generic-password", "-s", CLAUDE_KEYCHAIN_SERVICE, "-w"]);
|
|
7981
|
+
scoped = false;
|
|
7982
|
+
}
|
|
7983
|
+
if (!answer.ok) {
|
|
7984
|
+
if (answer.killed) return { problem: APPROVAL_WORDS };
|
|
7985
|
+
if (answer.code === SECURITY_ITEM_NOT_FOUND) return { problem: "Claude Code is not signed in on this computer" };
|
|
7986
|
+
return { problem: `Claude Code's sign-in could not be read from the Keychain (security exit ${answer.code ?? "unknown"}) \u2014 it may need a one-time approval on this computer` };
|
|
7987
|
+
}
|
|
7988
|
+
const cred = parse(answer.stdout.trim());
|
|
7989
|
+
if (!("problem" in cred) || scoped) return cred;
|
|
7990
|
+
const other = await keychainAccountOf(run);
|
|
7991
|
+
if (!other || other === account) return cred;
|
|
7992
|
+
return { problem: `The Keychain's Claude Code entry on this computer belongs to "${other}", not to ${account}, and carries no sign-in \u2014 remove that entry if it is not yours` };
|
|
7993
|
+
}
|
|
7616
7994
|
function claudeTierWords(profile) {
|
|
7617
7995
|
const org = profile?.organization;
|
|
7618
7996
|
const tier = org?.rate_limit_tier ?? "";
|
|
@@ -7747,7 +8125,7 @@ function parseGrokBilling(result) {
|
|
|
7747
8125
|
}
|
|
7748
8126
|
async function readGrokCapacity(grokPath, deps = {}) {
|
|
7749
8127
|
const ask = deps.ask ?? askChild;
|
|
7750
|
-
const cwd = deps.cwd ?? mkdtempSync(
|
|
8128
|
+
const cwd = deps.cwd ?? mkdtempSync(join11(tmpdir2(), "frod-capacity-"));
|
|
7751
8129
|
const r = await ask({
|
|
7752
8130
|
command: grokPath,
|
|
7753
8131
|
args: ["agent", "--no-leader", "stdio"],
|
|
@@ -7790,6 +8168,8 @@ function askChild(input) {
|
|
|
7790
8168
|
} catch {
|
|
7791
8169
|
}
|
|
7792
8170
|
};
|
|
8171
|
+
child.stdin?.on("error", () => {
|
|
8172
|
+
});
|
|
7793
8173
|
child.on("error", (err) => finish({ problem: `could not start: ${err.message}` }));
|
|
7794
8174
|
child.on("exit", (code) => {
|
|
7795
8175
|
if (!done) finish({ problem: `ended (exit ${code ?? "signal"}) before answering` });
|
|
@@ -7849,40 +8229,114 @@ function samplesInWords(samples) {
|
|
|
7849
8229
|
return samples.map((s) => `${label[s.provider]} ${s.usedPct === null ? `(${s.words ?? "no number"})` : `${Math.round(s.usedPct)}% of ${win[s.window]}`}`).join(" \xB7 ") || "nothing to report";
|
|
7850
8230
|
}
|
|
7851
8231
|
|
|
8232
|
+
// src/session-cap.ts
|
|
8233
|
+
import { totalmem } from "node:os";
|
|
8234
|
+
var SESSION_RAM_MB = 200;
|
|
8235
|
+
var RESERVED_RAM_MB = 4 * 1024;
|
|
8236
|
+
var HARD_MAX_SESSIONS = 100;
|
|
8237
|
+
var DEFAULT_MAX_SESSIONS = 30;
|
|
8238
|
+
var LEGACY_DEFAULT_MAX_SESSIONS = 2;
|
|
8239
|
+
function sessionCeiling(totalMemBytes = totalmem()) {
|
|
8240
|
+
const usableMb = Math.floor(totalMemBytes / (1024 * 1024)) - RESERVED_RAM_MB;
|
|
8241
|
+
return Math.max(1, Math.min(HARD_MAX_SESSIONS, Math.floor(usableMb / SESSION_RAM_MB)));
|
|
8242
|
+
}
|
|
8243
|
+
function defaultMaxSessions(totalMemBytes) {
|
|
8244
|
+
return Math.min(DEFAULT_MAX_SESSIONS, sessionCeiling(totalMemBytes));
|
|
8245
|
+
}
|
|
8246
|
+
function resolveMaxSessions(requested, totalMemBytes) {
|
|
8247
|
+
const ceiling = sessionCeiling(totalMemBytes);
|
|
8248
|
+
if (requested === null || requested === void 0 || !Number.isFinite(requested)) {
|
|
8249
|
+
return { value: defaultMaxSessions(totalMemBytes), ceiling, clamped: false };
|
|
8250
|
+
}
|
|
8251
|
+
const asked = Math.max(1, Math.floor(requested));
|
|
8252
|
+
return { value: Math.min(asked, ceiling), ceiling, clamped: asked > ceiling };
|
|
8253
|
+
}
|
|
8254
|
+
function clampWords(asked, cap) {
|
|
8255
|
+
if (!cap.clamped) return null;
|
|
8256
|
+
return `${asked} sessions were asked for; this computer's memory carries ${cap.ceiling} at ${SESSION_RAM_MB} MB a session. Using ${cap.value}.`;
|
|
8257
|
+
}
|
|
8258
|
+
function applySessionCap(cfg, totalMemBytes) {
|
|
8259
|
+
const words = [];
|
|
8260
|
+
let write2 = false;
|
|
8261
|
+
if (cfg.maxSessionsChosen === void 0 && cfg.maxSessions <= LEGACY_DEFAULT_MAX_SESSIONS) {
|
|
8262
|
+
const raised = defaultMaxSessions(totalMemBytes);
|
|
8263
|
+
if (raised > cfg.maxSessions) {
|
|
8264
|
+
words.push(`This computer offered ${cfg.maxSessions} sessions at a time \u2014 the old default. Raising it to ${raised}, which its memory carries at ${SESSION_RAM_MB} MB a session. Set "maxSessions" in the settings file to choose your own; this happens once.`);
|
|
8265
|
+
cfg.maxSessions = raised;
|
|
8266
|
+
}
|
|
8267
|
+
cfg.maxSessionsChosen = false;
|
|
8268
|
+
write2 = true;
|
|
8269
|
+
}
|
|
8270
|
+
const cap = resolveMaxSessions(cfg.maxSessions, totalMemBytes);
|
|
8271
|
+
if (cap.clamped) {
|
|
8272
|
+
words.push(`The settings file asks for ${cfg.maxSessions} sessions; this computer's memory carries ${cap.ceiling} at ${SESSION_RAM_MB} MB a session. Using ${cap.value} \u2014 the file is left as it is.`);
|
|
8273
|
+
cfg.maxSessions = cap.value;
|
|
8274
|
+
}
|
|
8275
|
+
return { write: write2, words };
|
|
8276
|
+
}
|
|
8277
|
+
|
|
8278
|
+
// package.json
|
|
8279
|
+
var package_default = {
|
|
8280
|
+
name: "@frod.io/bridge",
|
|
8281
|
+
version: "0.7.6",
|
|
8282
|
+
type: "module",
|
|
8283
|
+
description: "Your computer is a desk: hosts a Frod.io agent's sessions with your own Claude Code, Codex or Grok Build. Installs and updates itself.",
|
|
8284
|
+
license: "UNLICENSED",
|
|
8285
|
+
homepage: "https://frod.io",
|
|
8286
|
+
repository: {
|
|
8287
|
+
type: "git",
|
|
8288
|
+
url: "https://github.com/awafeek/agentic-engineering.git",
|
|
8289
|
+
directory: "apps/bridge"
|
|
8290
|
+
},
|
|
8291
|
+
bin: {
|
|
8292
|
+
"frod-bridge": "dist/cli.js"
|
|
8293
|
+
},
|
|
8294
|
+
files: [
|
|
8295
|
+
"dist",
|
|
8296
|
+
"README.md"
|
|
8297
|
+
],
|
|
8298
|
+
engines: {
|
|
8299
|
+
node: ">=20"
|
|
8300
|
+
},
|
|
8301
|
+
publishConfig: {
|
|
8302
|
+
access: "public"
|
|
8303
|
+
},
|
|
8304
|
+
scripts: {
|
|
8305
|
+
build: "node scripts/bundle.mjs && tsc --noEmit",
|
|
8306
|
+
bundle: "node scripts/bundle.mjs",
|
|
8307
|
+
lint: "eslint src",
|
|
8308
|
+
test: "vitest run --passWithNoTests",
|
|
8309
|
+
"type-check": "tsc --noEmit",
|
|
8310
|
+
"pack:check": "npm pack --dry-run"
|
|
8311
|
+
},
|
|
8312
|
+
devDependencies: {
|
|
8313
|
+
"@eslint/js": "^9.39.3",
|
|
8314
|
+
"@types/node": "^22.0.0",
|
|
8315
|
+
"@types/ws": "^8.18.1",
|
|
8316
|
+
esbuild: "^0.27.3",
|
|
8317
|
+
eslint: "^9.0.0",
|
|
8318
|
+
tsx: "^4.19.0",
|
|
8319
|
+
typescript: "^5.7.0",
|
|
8320
|
+
"typescript-eslint": "^8.56.1",
|
|
8321
|
+
vitest: "^4.0.18",
|
|
8322
|
+
ws: "^8.21.3"
|
|
8323
|
+
}
|
|
8324
|
+
};
|
|
8325
|
+
|
|
7852
8326
|
// src/cli.ts
|
|
7853
|
-
var VERSION =
|
|
8327
|
+
var VERSION = package_default.version;
|
|
7854
8328
|
function arg(flag, argv) {
|
|
7855
8329
|
const i = argv.indexOf(flag);
|
|
7856
8330
|
return i >= 0 ? argv[i + 1] : void 0;
|
|
7857
8331
|
}
|
|
7858
8332
|
function findClaude() {
|
|
7859
|
-
|
|
7860
|
-
for (const c of candidates) if (existsSync12(c)) return c;
|
|
7861
|
-
try {
|
|
7862
|
-
return execFileSync7("/usr/bin/which", ["claude"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
7863
|
-
} catch {
|
|
7864
|
-
}
|
|
7865
|
-
return null;
|
|
8333
|
+
return findRuntime("claude");
|
|
7866
8334
|
}
|
|
7867
8335
|
function findCodex() {
|
|
7868
|
-
|
|
7869
|
-
for (const c of candidates) if (existsSync12(c)) return c;
|
|
7870
|
-
try {
|
|
7871
|
-
const found = execFileSync7("/usr/bin/which", ["codex"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
7872
|
-
return found || null;
|
|
7873
|
-
} catch {
|
|
7874
|
-
return null;
|
|
7875
|
-
}
|
|
8336
|
+
return findRuntime("codex");
|
|
7876
8337
|
}
|
|
7877
8338
|
function findGrok() {
|
|
7878
|
-
|
|
7879
|
-
for (const c of candidates) if (existsSync12(c)) return c;
|
|
7880
|
-
try {
|
|
7881
|
-
const found = execFileSync7("/usr/bin/which", ["grok"], { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
|
|
7882
|
-
return found || null;
|
|
7883
|
-
} catch {
|
|
7884
|
-
return null;
|
|
7885
|
-
}
|
|
8339
|
+
return findRuntime("grok");
|
|
7886
8340
|
}
|
|
7887
8341
|
var RUNTIME_ORDER = ["claude", "codex", "grok"];
|
|
7888
8342
|
var RUNTIME_LABELS = { claude: "Claude Code", codex: "Codex", grok: "Grok Build" };
|
|
@@ -7952,7 +8406,7 @@ async function cmdJoin(argv, overrides = {}) {
|
|
|
7952
8406
|
join: join3,
|
|
7953
8407
|
storeToken,
|
|
7954
8408
|
ensureWorkdir: (path) => {
|
|
7955
|
-
if (!
|
|
8409
|
+
if (!existsSync14(path)) mkdirSync9(path, { recursive: true, mode: 448 });
|
|
7956
8410
|
},
|
|
7957
8411
|
writeConfig,
|
|
7958
8412
|
reportGitAccess,
|
|
@@ -7968,8 +8422,10 @@ async function cmdJoin(argv, overrides = {}) {
|
|
|
7968
8422
|
if (refusal) throw new Error(refusal);
|
|
7969
8423
|
const safeApi = assertSafeEndpoint(apiUrl);
|
|
7970
8424
|
const name = arg("--name", argv) ?? defaultName();
|
|
7971
|
-
const workdir = resolve6(arg("--workdir", argv) ??
|
|
7972
|
-
const
|
|
8425
|
+
const workdir = resolve6(arg("--workdir", argv) ?? join12(homedir8(), "frod-work"));
|
|
8426
|
+
const askedSessions = arg("--max-sessions", argv);
|
|
8427
|
+
const cap = resolveMaxSessions(askedSessions === void 0 ? null : Number(askedSessions));
|
|
8428
|
+
const maxSessions = cap.value;
|
|
7973
8429
|
const probes = probeAllRuntimes(deps.probes);
|
|
7974
8430
|
for (const runtime of RUNTIME_ORDER) {
|
|
7975
8431
|
const probe = probes[runtime];
|
|
@@ -7990,6 +8446,7 @@ async function cmdJoin(argv, overrides = {}) {
|
|
|
7990
8446
|
name,
|
|
7991
8447
|
workdir,
|
|
7992
8448
|
maxSessions,
|
|
8449
|
+
maxSessionsChosen: askedSessions !== void 0,
|
|
7993
8450
|
adapters,
|
|
7994
8451
|
claudePath: isReady(probes.claude) ? probes.claude.path : null,
|
|
7995
8452
|
codexPath: isReady(probes.codex) ? probes.codex.path : void 0,
|
|
@@ -8004,7 +8461,9 @@ async function cmdJoin(argv, overrides = {}) {
|
|
|
8004
8461
|
log.info(`Joined. This computer is now "${name}" \u2014 ${joined.person?.name ?? "your"} computer in ${joined.workspaceName ?? joined.workspaceId} \u2014 hosting ${hostedAgentsWords(cfg)}.`);
|
|
8005
8462
|
for (const b of cfg.bindings ?? []) log.info(` ${b.agentName}: ${bindingWords(b)}`);
|
|
8006
8463
|
log.info(`Token kept in ${store === "keychain" ? "the OS keychain" : `a 0600 file under ${dirname5(CONFIG_PATH)} (no keychain available on this computer \u2014 stated, not hidden)`}; it expires ${joined.expiresAt.slice(0, 10)}.`);
|
|
8007
|
-
|
|
8464
|
+
const clamped = askedSessions === void 0 ? null : clampWords(Math.max(1, Math.floor(Number(askedSessions))), cap);
|
|
8465
|
+
if (clamped) log.info(clamped);
|
|
8466
|
+
log.info(`Settings: ${CONFIG_PATH}. Sessions work under ${workdir}/sessions. Up to ${maxSessions} at a time, across every agent this computer hosts \u2014 this computer's memory carries ${cap.ceiling} at ${SESSION_RAM_MB} MB a session; change "maxSessions" in the settings file.`);
|
|
8008
8467
|
log.info(`To host more agents from here: open the agent's page in Mission Control \u2192 "Join from your computer" \u2192 "Attach an existing computer". No second join.`);
|
|
8009
8468
|
await deps.reportGitAccess(arg("--repo", argv));
|
|
8010
8469
|
if (argv.includes("--no-install")) {
|
|
@@ -8016,12 +8475,12 @@ async function cmdJoin(argv, overrides = {}) {
|
|
|
8016
8475
|
async function reportGitAccess(repoUrl) {
|
|
8017
8476
|
let version = "not found";
|
|
8018
8477
|
try {
|
|
8019
|
-
version =
|
|
8478
|
+
version = execFileSync8("git", ["--version"], { stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim();
|
|
8020
8479
|
} catch {
|
|
8021
8480
|
}
|
|
8022
8481
|
let helper = "none configured";
|
|
8023
8482
|
try {
|
|
8024
|
-
helper =
|
|
8483
|
+
helper = execFileSync8("git", ["config", "--global", "--get-all", "credential.helper"], { stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 }).toString().trim().split("\n").filter(Boolean).join(", ") || helper;
|
|
8025
8484
|
} catch {
|
|
8026
8485
|
}
|
|
8027
8486
|
const url = repoUrl ?? "";
|
|
@@ -8044,6 +8503,9 @@ async function cmdRun() {
|
|
|
8044
8503
|
}
|
|
8045
8504
|
const token = tokenFor(cfg);
|
|
8046
8505
|
ensureFrodDir();
|
|
8506
|
+
const sizing = applySessionCap(cfg);
|
|
8507
|
+
if (sizing.write) writeConfig(cfg);
|
|
8508
|
+
for (const words of sizing.words) log.info(words);
|
|
8047
8509
|
ClaudeAdapter.reapOrphans();
|
|
8048
8510
|
const pruned = pruneRunDirs(cfg.workdir);
|
|
8049
8511
|
if (pruned > 0) log.event("repo.runs_pruned", { count: pruned });
|
|
@@ -8101,7 +8563,7 @@ async function cmdRun() {
|
|
|
8101
8563
|
});
|
|
8102
8564
|
if (platform6() === "win32") {
|
|
8103
8565
|
try {
|
|
8104
|
-
const child = spawn6(process.execPath, [
|
|
8566
|
+
const child = spawn6(process.execPath, [join12(CURRENT_LINK, "dist", "cli.js"), "run"], { detached: true, stdio: "ignore", windowsHide: true, env: process.env });
|
|
8105
8567
|
child.unref();
|
|
8106
8568
|
log.info(`Windows: started ${to} detached (pid ${child.pid}).`);
|
|
8107
8569
|
} catch (err) {
|
|
@@ -8170,7 +8632,8 @@ async function cmdStatus(argv) {
|
|
|
8170
8632
|
}
|
|
8171
8633
|
log.info(`This computer: "${cfg.name}" \u2014 ${cfg.person?.name ? `${cfg.person.name}'s computer` : "a computer"} in ${cfg.workspaceName ?? cfg.workspaceId} via ${cfg.apiUrl}`);
|
|
8172
8634
|
if (cfg.dormant) log.warn(dormantWords(cfg.dormant, hostedAgentsWords(cfg)));
|
|
8173
|
-
|
|
8635
|
+
const statusCap = resolveMaxSessions(cfg.maxSessions);
|
|
8636
|
+
log.info(`Token: ${cfg.tokenStore}; settings ${CONFIG_PATH}; sessions under ${cfg.workdir}/sessions; up to ${statusCap.value} at a time across every agent here (this computer's memory carries ${statusCap.ceiling} at ${SESSION_RAM_MB} MB a session${statusCap.clamped ? `, so the ${cfg.maxSessions} in the settings file is not honoured` : ""}).`);
|
|
8174
8637
|
log.info(`Working folders an agent may be given here: ${allowedRootsOf(cfg).join(", ")} (add more under "allowedRoots" in ${CONFIG_PATH}).`);
|
|
8175
8638
|
const probes = probeAllRuntimes(DEFAULT_RUNTIME_PROBES, cfg);
|
|
8176
8639
|
for (const runtime of RUNTIME_ORDER) {
|
|
@@ -8180,6 +8643,7 @@ async function cmdStatus(argv) {
|
|
|
8180
8643
|
const loc = runLocation();
|
|
8181
8644
|
log.info(`Software: ${VERSION}, running from ${loc.installedVersion ? `${VERSIONS_DIR}/${loc.installedVersion}` : `${loc.packageRoot} (not an installed version \u2014 it will not update itself; run \`npx @frod.io/bridge@latest install\`)`}; current \u2192 ${currentVersion() ?? "none"}.`);
|
|
8182
8645
|
if (platform6() === "darwin") log.info(`Runs at login: ${launchdLoaded() ? "yes (launchd)" : "no \u2014 run install"}`);
|
|
8646
|
+
if (platform6() === "win32") log.info(`Runs at logon: ${windowsTaskLoaded() ? "yes (scheduled task)" : "no \u2014 run install"}`);
|
|
8183
8647
|
log.info("Which agent runs on which runtime is the platform's to say \u2014 attach or detach agents in Mission Control.");
|
|
8184
8648
|
await reportGitAccess(arg("--repo", argv));
|
|
8185
8649
|
const token = readToken(cfg.keychainAccount, cfg.tokenStore);
|
|
@@ -8263,6 +8727,7 @@ async function cmdInstall() {
|
|
|
8263
8727
|
if (platform6() === "win32") {
|
|
8264
8728
|
const r = installWindows(target);
|
|
8265
8729
|
log.info(r.loaded ? r.detail : `NOT scheduled (${r.detail}).`);
|
|
8730
|
+
if (r.loaded && !r.hidden) log.info(r.manual);
|
|
8266
8731
|
if (!r.loaded) {
|
|
8267
8732
|
log.info(r.manual);
|
|
8268
8733
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frod.io/bridge",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Your computer is a desk: hosts a Frod.io agent's sessions with your own Claude Code, Codex or Grok Build. Installs and updates itself.",
|
|
6
6
|
"license": "UNLICENSED",
|