diff --git a/.gitignore b/.gitignore index 5935d05..b0567d7 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ dist/ /tools/db-v3.ts /tools/db-v3.spec.ts /tools/README.md + +# ZCode writes its own ignore file into the folders it opens. +.zcodeignore diff --git a/GUIDE.md b/GUIDE.md index 2df0ee2..ca8c916 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -33,7 +33,7 @@ through a `zcode` executable on PATH. A custom `providers.zcode.dir` or Then run `bitfrost setup zcode` once. Without it, GLM can't ask you for permission and is refused instead. -### opencode, Oh My Pi, Gemini CLI, Crush +### OpenCode, Oh My Pi, Gemini CLI, Crush Sign in to the app first (`opencode auth login`, `omp`, `gemini`, or `crush login` and Crush's own provider setup). Then open the config screen (`bitfrost config`), turn the app on and pick its models. Or do the same by hand in `~/.config/bitfrost/config.json`: @@ -48,9 +48,37 @@ Sign in to the app first (`opencode auth login`, `omp`, `gemini`, or `crush logi } ``` -opencode, Oh My Pi and Crush need `models`: only the models listed there are offered. `*` works as a wildcard. Gemini CLI offers all its models unless you name ones to skip. +OpenCode, Oh My Pi and Crush need `models`: only the models listed there are offered. `*` works as a wildcard. Gemini CLI offers all its models unless you name ones to skip. -Anthropic models are never offered through these apps. Any other model they list can be, GPT, GLM and Gemini included. If Codex, ZCode or Gemini CLI also offers that model, theirs keeps the short name, like glm, and the copy is named after its model id, like opencode-go-glm-5-3. +Any model they list can be offered, Claude, GPT, GLM and Gemini included. A Claude model needs a pattern that names it, like anthropic/* or its full id. A bare * doesn't turn Claude models on. Claude models get no short names, so names like sonnet keep meaning Claude Code's own models. If Codex, ZCode or Gemini CLI also offers that model, theirs keeps the short name, like glm, and the copy is named after its model id, like opencode-go-glm-5-3. + +### Grok Build + +Sign in to the Grok CLI (`grok login`). Then open the config screen (`bitfrost config`) and turn Grok Build on. Or do the same by hand in `~/.config/bitfrost/config.json`: + +```json +{ "providers": { "grok": { "enabled": true } } } +``` + +It offers all its Grok models without a `models` list. No setup step. BitFrost runs it in always-approve mode and gates every action itself, so reads and edits inside the task folder run without asking; other commands and actions ask you. Its own subagents and scheduled runs are refused. + +### Antigravity + +Sign Antigravity in with an API key or a business account (an enterprise, Workspace or Google Cloud account). A personal Google account, or any sign-in BitFrost doesn't recognize as an API key or business account, won't work with BitFrost, because Antigravity's terms don't allow using it through another tool while it is signed in to a personal account. Then open the config screen (`bitfrost config`) and turn Antigravity on. Or do the same by hand in `~/.config/bitfrost/config.json`: + +```json +{ "providers": { "agy": { "enabled": true } } } +``` + +Turning it on installs a small BitFrost bridge plugin into `~/.gemini/config/plugins/bitfrost-bridge`, so there is no setup step. If BitFrost ever says the bridge is missing, run `bitfrost setup agy`. The plugin does nothing in your own `agy` sessions and is removed by `bitfrost uninstall`. Reads and edits inside the task folder run without asking; commands and other actions ask you. Its subagents, agent messaging, workflows and schedules are refused. + +It offers Gemini models, for example Gemini 3.8 Flash with effort low, medium and high, and gpt-oss. Antigravity's separate per-effort model ids show as one model with efforts, and its Claude models stay off until you turn them on in the config screen, or name them in `"anthropic": [...]` under `providers.agy`. Without a current bridge, BitFrost keeps Antigravity's permission prompts on, so actions that need permission are refused. + +### Why did my Antigravity models disappear? + +BitFrost checks how each Antigravity run signed in, before the first prompt and again before each model request. If that run used a personal Google account, a sign-in BitFrost doesn't recognize, or BitFrost can't tell how it signed in, BitFrost stops it and the task fails with a message saying why. The model listing does the same check, so with a personal sign-in the Antigravity models drop out of Claude's list of subagents and the Models page shows the reason. There is no setting to override this. + +Switch Antigravity to an API key or a business account and it works again from the next run. The models come back within about 10 minutes, or right away after a refresh on the BitFrost Models page. Note that an API key set only in a shell rc file may not reach the BitFrost helper if it was started from the desktop app; the key must be in the environment the helper runs in. ### Custom Claude profiles @@ -90,7 +118,7 @@ On the Models page, pick an app, then pick a model. Apps serving their own compa { "providers": { "codex": { "disabled": ["gpt-5-mini"] } } } ``` -Apps serving many companies' models, like opencode, Oh My Pi and Crush, only offer the models you list, so turning one on adds it: +Apps serving many companies' models, like OpenCode, Oh My Pi and Crush, only offer the models you list, so turning one on adds it: ```json { "providers": { "opencode": { "models": ["deepseek/*"] } } } @@ -124,7 +152,7 @@ curl -fsSL https://github.com/SavaSoftworks/BitFrost/releases/download/v0.8.1/in Run `bitfrost uninstall`. It lists what it will remove and asks whether to remove BitFrost, your configuration, and your subagent history and usage stats. Keeping history preserves it for a reinstall. -`--yes` removes everything without prompting; add `--keep-config` or `--keep-history` to preserve either. If a subagent is running, uninstall changes nothing and asks you to wait. +`--yes` removes everything without prompting; add `--keep-config` or `--keep-history` to preserve either. It also removes the Antigravity bridge plugin, if installed. If a subagent is running, uninstall changes nothing and asks you to wait. ## Troubleshooting @@ -167,7 +195,7 @@ BitFrost reloads configuration and detects app updates automatically. Changes th ### How do I turn an app off? -On the config screen, pick the app on the Settings page and turn it off. Or set `"enabled": false` under its name in `providers` in `~/.config/bitfrost/config.json`. +On the config screen, pick the app on the Settings page and turn it off. Or set `"enabled": false` under its name in `providers` in `~/.config/bitfrost/config.json`. Grok Build and Antigravity start off, so turn them on this way first. ## Using it @@ -181,11 +209,11 @@ Yes. Ask "what is glm doing?". Claude checks BitFrost for the current step, rece ### Can I message a subagent while it works? -Yes, through Claude. Codex models take the message inside their current turn. GLM and opencode models get it when their turn ends. If it can't wait, ask Claude to interrupt: the subagent stops its current step and carries on in the same conversation with your message. Claude is told which of these happened. +Yes, through Claude. Codex models take the message inside their current turn. Antigravity steers the running turn, like Codex. Grok Build has no steering, so a message waits for its next turn. GLM and OpenCode models get it when their turn ends. If it can't wait, ask Claude to interrupt: the subagent stops its current step and carries on in the same conversation with your message. Claude is told which of these happened. ### Can I resume a subagent after restarting Claude? -Yes. Reopen the conversation and ask Claude to continue it. It picks up in the same conversation inside its app. For opencode this only works if your opencode version can reopen sessions. +Yes. Reopen the conversation and ask Claude to continue it. It picks up in the same conversation inside its app. For OpenCode this only works if your OpenCode version can reopen sessions. ### Where does BitFrost keep subagent history? @@ -205,7 +233,7 @@ From the subagent runs BitFrost has recorded. The Models page shows usage for To ### Who answers a subagent's permission requests? -In normal mode, you decide; no answer means Deny. In auto mode, a safety review decides instead. +In normal mode, you decide; no answer means Deny. In auto mode, a safety review decides instead. Grok Build and Antigravity run in approve mode, so BitFrost gates their actions itself: reads and edits inside the task folder don't ask, other commands and actions ask you, and their own subagents and schedules are refused. ### What is official handback? diff --git a/README.md b/README.md index eea0d6d..0a40ef1 100644 --- a/README.md +++ b/README.md @@ -51,13 +51,13 @@ Node.js 23.6+, Claude Code, and at least one [supported coding app](#supported-h ### Installation -1. Add this to the `env` block of `~/.claude/settings.json`. This step is required: +Add this to the `env` block of `~/.claude/settings.json`. This step is required: ```json "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" ``` -2. Run the installer: +Run the installer: ```sh curl -fsSL https://github.com/SavaSoftworks/BitFrost/releases/latest/download/install.sh | sh @@ -65,7 +65,7 @@ Node.js 23.6+, Claude Code, and at least one [supported coding app](#supported-h It installs the plugin into Claude Code and adds a `bitfrost` command to `~/.local/bin`. Existing databases are backed up and migrated during installation, including any required history processing. -3. For GLM, sign in to ZCode's command-line tool ([command in the guide](GUIDE.md#zcode-glm)), then run once: +For GLM, sign in to ZCode's command-line tool ([command in the guide](GUIDE.md#zcode-glm)), then run once: ```sh bitfrost setup zcode @@ -73,7 +73,7 @@ Node.js 23.6+, Claude Code, and at least one [supported coding app](#supported-h Without it, GLM can't ask you for permission and is refused instead. -4. Start a new Claude session. New models also only show up in a new session. +Start a new Claude session. New models also only show up in a new session. If you use a Claude profile other than `~/.claude` (`CLAUDE_CONFIG_DIR`), see [Custom Claude profiles](GUIDE.md#custom-claude-profiles). @@ -95,7 +95,7 @@ If the new release has settings you haven't chosen, the CLI asks about them at t bitfrost uninstall ``` -This removes the Claude plugin, daemon, ZCode plugin (if installed) and BitFrost's installed files. It asks separately whether to delete your configuration and history, including usage stats, so you can keep them for a reinstall. +This removes the Claude plugin, daemon, ZCode plugin (if installed), Antigravity bridge plugin (if installed) and BitFrost's installed files. It asks separately whether to delete your configuration and history, including usage stats, so you can keep them for a reinstall. `--yes` removes everything without prompting; use `--keep-config` or `--keep-history` to preserve either. @@ -112,7 +112,7 @@ bitfrost

BitFrost terminal interface with Models, Settings and Status menu options -BitFrost Models page showing opencode models and usage statistics +BitFrost Models page showing OpenCode models and usage statistics

Models manages providers, models and usage statistics. Settings configures Claude profiles, official handback, history retention and app locations. Status shows sessions and daemon activity. @@ -128,16 +128,18 @@ If the interface can't open on your platform, BitFrost prints help and tells you Use any mix of these. -| Harness | Models | Turned on | -|-----------------------------------------|---------|---| -| Codex (CLI, or the ChatGPT desktop app) | GPT | automatically, when installed and signed in | -| ZCode | GLM | automatically, when installed and signed in, plus one setup step | -| opencode, Oh My Pi, Crush | Various | only once you turn them on in the config screen or `~/.config/bitfrost/config.json` | -| Gemini CLI | Gemini | only once you turn it on, the same way | +| Harness | Models | Turned on | +|-----------------------------------------|-----------------|---| +| Codex (CLI, or the ChatGPT desktop app) | GPT | automatically, when installed and signed in | +| ZCode | GLM | automatically, when installed and signed in, plus one setup step | +| OpenCode, Oh My Pi, Crush | Various | only once you turn them on in the config screen or `~/.config/bitfrost/config.json` | +| Gemini CLI | Gemini | only once you turn it on, the same way | +| Grok Build | Grok | only once you turn it on | +| Antigravity | Gemini, gpt-oss | only once you turn it on, with an API key or a business account | Codex and ZCode are fully supported. The others are experimental. -opencode, Oh My Pi and Crush also need a `models` list naming the models you want. Setup for each app is in [the guide](GUIDE.md#setup). The config screen sets this up too, so you don't need to edit the file by hand. +OpenCode, Oh My Pi and Crush also need a `models` list naming the models you want. Setup for each app is in [the guide](GUIDE.md#setup). The config screen sets this up too, so you don't need to edit the file by hand.
@@ -180,19 +182,20 @@ Once Claude understands the assignment, ask it to save that as a rule in your CL - GLM needs a Z.ai individual coding plan. ZCode only offers Teams, Start plans, free usage, bigmodel and its other providers inside its desktop app, not to outside tools, so BitFrost can't use them. - Windows is not a supported platform. It may work via WSL, but it also may not. -- Gemini CLI requires Gemini Code Assist Standard/Enterprise, Google Cloud access or a paid API key. [Google ended free and Google AI Pro/Ultra access in June 2026](https://github.com/google-gemini/gemini-cli/discussions/27274). Antigravity CLI (`agy`) isn't supported yet. +- Gemini CLI requires Gemini Code Assist Standard/Enterprise, Google Cloud access or a paid API key. [Google ended free and Google AI Pro/Ultra access in June 2026](https://github.com/google-gemini/gemini-cli/discussions/27274). - ZCode's own auto mode doesn't work. When Claude Code is in auto mode, BitFrost reviews GLM's permission requests with a short Claude (Sonnet) check instead, which counts toward your Claude usage. - Oh My Pi may only ask permission for commands, deletes and moves, not ordinary file edits. -- In opencode, denying a permission request ends that subagent's turn. -- BitFrost disables opencode's subagents and Oh My Pi's advisor, subagents and prewalk. Crush uses your selected model for helper work such as titles and summaries too. -- Claude Code is the only app that can hand out work. Codex, ZCode and the rest can take tasks but can't start BitFrost subagents of their own. +- Grok Build has no mid-turn steering. A message you send while it works waits for its next turn. +- Antigravity keeps BitFrost conversations in its own history (`~/.gemini/antigravity-cli/conversations`). +- In OpenCode, denying a permission request ends that subagent's turn. +- BitFrost disables OpenCode's subagents and Oh My Pi's advisor, subagents and prewalk. Crush uses your selected model for helper work such as titles and summaries too. - BitFrost relies on Claude Code's function hooks, which are still experimental. A Claude Code update, or Anthropic switching the feature off remotely, can turn BitFrost off until it's fixed.
## More ---- + - [Guide](GUIDE.md): Configuration, troubleshooting, etc. - [Contributing](CONTRIBUTING.md) @@ -205,6 +208,8 @@ Once Claude understands the assignment, ask it to save that as a rule in your CL BitFrost runs supported coding apps on your machine. Those apps handle authentication and model requests using your accounts. You need valid access to each provider you use; usage counts toward that provider's limits and charges. +BitFrost refuses to run Antigravity when it is signed in to a personal Google account, because Antigravity's terms don't allow using it through another tool that way. The [guide's Antigravity section](GUIDE.md#antigravity) explains how to sign in, and [why the models may disappear](GUIDE.md#why-did-my-antigravity-models-disappear). + BitFrost does not resell or pool account access, or bypass quotas or access restrictions. You are responsible for following the applicable provider and app terms, including any plan or workplace restrictions. BitFrost is an independent open-source project, not affiliated with, endorsed by or sponsored by the providers or app developers named here. Product names and trademarks belong to their respective owners and identify supported integrations. diff --git a/plugins/bitfrost/.claude-plugin/plugin.json b/plugins/bitfrost/.claude-plugin/plugin.json index c0e1a35..6a438bb 100644 --- a/plugins/bitfrost/.claude-plugin/plugin.json +++ b/plugins/bitfrost/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "bitfrost", - "version": "0.9.2", - "description": "Use Codex, ZCode and ACP apps (opencode, Oh My Pi, Gemini CLI) as Claude Code subagents", + "version": "0.9.3", + "description": "Use Codex, ZCode, ACP apps (opencode, Oh My Pi, Gemini CLI), Crush, Grok Build and Antigravity as Claude Code subagents", "author": { "name": "Nick Germaine" }, diff --git a/plugins/bitfrost/agy-plugin/hook b/plugins/bitfrost/agy-plugin/hook new file mode 100755 index 0000000..2405099 --- /dev/null +++ b/plugins/bitfrost/agy-plugin/hook @@ -0,0 +1,10 @@ +#!/bin/sh +# Copyright (C) 2026 Nick Germaine +# SPDX-License-Identifier: GPL-3.0-only +# +# BitFrost is free software: you can share and change it under the GNU General +# Public License, version 3 only. It comes with no warranty. See LICENSE. + +# Acts only in runs BitFrost started, which carry BITFROST_AGY_TOKEN. No output means no opinion. +[ -n "$BITFROST_AGY_TOKEN" ] || exit 0 +exec "$BITFROST_NODE" "$(dirname "$0")/hook.mjs" "$@" diff --git a/plugins/bitfrost/agy-plugin/hook.mjs b/plugins/bitfrost/agy-plugin/hook.mjs new file mode 100644 index 0000000..48d7a99 --- /dev/null +++ b/plugins/bitfrost/agy-plugin/hook.mjs @@ -0,0 +1,48 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +// Sends one Antigravity hook call to the BitFrost helper and prints its answer. +// A tool call the helper doesn't explicitly allow or deny is blocked (exit 2), never +// allowed; other hooks fall back to no opinion. +import http from 'node:http' + +const hook = process.argv[2] ?? '' +let input = '' +process.stdin.on('data', (c) => (input += c)) +process.stdin.on('end', () => { + let payload + try { payload = JSON.parse(input || '{}') } catch { return fail('Antigravity sent a hook call BitFrost could not read') } + const req = http.request( + { + socketPath: process.env.BITFROST_SOCKET, + path: '/providers/agy/bridge', + method: 'POST', + headers: { 'content-type': 'application/json', 'x-bitfrost-token': process.env.BITFROST_AGY_TOKEN }, + }, + (res) => { + let body = '' + res.on('data', (c) => (body += c)) + res.on('end', () => { + if (res.statusCode !== 200) return fail(`BitFrost refused the call (${res.statusCode})`) + if (hook === 'PreToolUse') { + let reply + try { reply = JSON.parse(body) } catch { return fail('BitFrost did not answer the tool call') } + if (!['allow', 'deny'].includes(reply?.decision)) return fail('BitFrost did not authorize the tool call') + } + if (body.trim()) process.stdout.write(body) + process.exit(0) + }) + }, + ) + req.on('error', () => fail('BitFrost is not reachable')) + req.end(JSON.stringify({ hook, input: payload })) +}) + +function fail(reason) { + if (hook !== 'PreToolUse') process.exit(0) + process.stderr.write(reason) + process.exit(2) +} diff --git a/plugins/bitfrost/agy-plugin/hooks.json b/plugins/bitfrost/agy-plugin/hooks.json new file mode 100644 index 0000000..7a30f46 --- /dev/null +++ b/plugins/bitfrost/agy-plugin/hooks.json @@ -0,0 +1,6 @@ +{ + "bitfrost-bridge": { + "PreToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "./hook PreToolUse", "timeout": 604800 }] }], + "PreInvocation": [{ "type": "command", "command": "./hook PreInvocation", "timeout": 60 }] + } +} diff --git a/plugins/bitfrost/agy-plugin/plugin.json b/plugins/bitfrost/agy-plugin/plugin.json new file mode 100644 index 0000000..4d7912a --- /dev/null +++ b/plugins/bitfrost/agy-plugin/plugin.json @@ -0,0 +1 @@ +{ "name": "bitfrost-bridge", "version": "1.0.0", "description": "Passes Antigravity's tool calls and questions to BitFrost, in runs BitFrost starts. Does nothing otherwise." } diff --git a/plugins/bitfrost/daemon/bitfrostd.ts b/plugins/bitfrost/daemon/bitfrostd.ts index 6c6db7c..2f59c69 100644 --- a/plugins/bitfrost/daemon/bitfrostd.ts +++ b/plugins/bitfrost/daemon/bitfrostd.ts @@ -98,7 +98,7 @@ function appStamp(p: Provider): string { // Allow and deny lists by app, from providers..models and providers..disabled. function lists(config: Config) { const pick = (key: string) => new Map(Object.entries(config.providers).filter(([, c]) => Array.isArray(c?.[key])).map(([id, c]) => [id, (c[key] as unknown[]).map(String)])) - return { allow: pick('models'), deny: pick('disabled') } + return { allow: pick('models'), deny: pick('disabled'), anthropic: pick('anthropic') } } const or = (names: string[]) => (names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} or ${names.at(-1)}`) @@ -322,6 +322,11 @@ function serve() { .catch((e) => { log(`discovery: ${p.id} failed: ${(e as Error).message}`) lastErrors.set(p.id, (e as Error).message) + // A refused app loses its models at once instead of keeping the last good list. + if ((e as any).blocked) { + lastModels.delete(p.id) + lastModelsAt.delete(p.id) + } }) .finally(() => listing.delete(p.id)) listing.set(p.id, job) @@ -340,7 +345,7 @@ function serve() { await Promise.all(late) const found = [...providers.values()].flatMap((p) => lastModels.get(p.id) ?? []) const ownVendors = new Map([...providers.values()].filter((p) => p.vendor).map((p) => [p.id, p.vendor!])) - const { allow, deny } = lists(config) + const { allow, deny, anthropic } = lists(config) const unlisted: string[] = [] for (const p of providers.values()) { if (p.vendor || allow.has(p.id)) continue @@ -348,7 +353,7 @@ function serve() { log(`discovery: ${p.id} offers ${n} models from many companies; none are used until config.json lists the wanted ones in providers.${p.id}.models`) unlisted.push(`${p.displayName} offers ${n} models; list the ones you want in providers.${p.id}.models.`) } - const agents = buildAgents(selectModels(found, ownVendors, allow, deny), ownVendors) + const agents = buildAgents(selectModels(found, ownVendors, allow, deny, anthropic), ownVendors) const hint = agents.length ? undefined : !providers.size @@ -368,7 +373,7 @@ function serve() { const config = await syncProviders() const locations = await Promise.all(PROVIDERS.map((f) => detect(f, config))) return PROVIDERS.map((f, i) => ({ id: f.id, displayName: f.displayName, detected: !!locations[i], location: locations[i], - enabled: config.providers[f.id]?.enabled ?? !f.optIn, optIn: !!f.optIn, running: providers.has(f.id), mode: f.vendor ? 'deny' : 'allow' })) + enabled: config.providers[f.id]?.enabled ?? !f.optIn, optIn: !!f.optIn, firstClass: !!f.firstClass, running: providers.has(f.id), mode: f.vendor ? 'deny' : 'allow' })) } // Every model an app lists, with whether it's offered and why not. @@ -388,11 +393,11 @@ function serve() { // A discovery already under way may have started before this app was on. const job = listing.get(id) ?? (lastModels.has(id) ? null : listProvider(p)) if (job) await Promise.race([job, new Promise((r) => setTimeout(r, MODELS_WAIT_MS))]) - const { allow, deny } = lists(config) + const { allow, deny, anthropic } = lists(config) const ownVendors = ownVendorsNow() const agents = registry?.agents ?? [] const models = (lastModels.get(id) ?? []).map((m) => { - const reason = whyNot(m, ownVendors, allow, deny) + const reason = whyNot(m, ownVendors, allow, deny, anthropic) const blocked = reason === 'disabled' || reason === 'not_listed' return { model: m.model, displayName: m.displayName, company: m.provider, description: m.description, efforts: m.efforts, defaultEffort: m.defaultEffort, isDefault: m.isDefault, enabled: !blocked, offered: reason === null, reason, agent: agents.find((a) => a.harness === id && a.model === m.model)?.name ?? null } @@ -825,7 +830,7 @@ usage: bitfrost restart restart the helper, once no subagent is running migrate stop an idle helper and migrate the database now update install the latest release, if it's newer - uninstall [--yes] remove BitFrost from Claude Code, ZCode and this machine + uninstall [--yes] remove BitFrost from Claude Code, ZCode, Antigravity and this machine [--keep-config] with --yes, keep your config [--keep-history] with --yes, keep subagent history and usage stats config open the config screen (also a bare bitfrost on a terminal) diff --git a/plugins/bitfrost/daemon/config.ts b/plugins/bitfrost/daemon/config.ts index 6553142..5d07f55 100644 --- a/plugins/bitfrost/daemon/config.ts +++ b/plugins/bitfrost/daemon/config.ts @@ -125,7 +125,7 @@ export function validateConfig(cfg: unknown, known: string[] = PROVIDERS.map((p) continue } if ('enabled' in entry && typeof entry.enabled !== 'boolean') errors.push(`providers.${id}.enabled must be true or false`) - for (const key of ['models', 'disabled']) if (key in entry && !isStrings(entry[key])) errors.push(`providers.${id}.${key} must be a list of strings`) + for (const key of ['models', 'disabled', 'anthropic']) if (key in entry && !isStrings(entry[key])) errors.push(`providers.${id}.${key} must be a list of strings`) for (const key of ['bin', 'dir']) if (key in entry && typeof entry[key] !== 'string') errors.push(`providers.${id}.${key} must be a string`) } } diff --git a/plugins/bitfrost/daemon/configedit.ts b/plugins/bitfrost/daemon/configedit.ts index fb73db8..48dd034 100644 --- a/plugins/bitfrost/daemon/configedit.ts +++ b/plugins/bitfrost/daemon/configedit.ts @@ -9,7 +9,7 @@ import fs from 'node:fs' import path from 'node:path' import { createHash } from 'node:crypto' import { validateConfig } from './config.ts' -import { globMatch } from './registry.ts' +import { globMatch, turnsOn } from './registry.ts' export type Raw = Record @@ -98,12 +98,28 @@ const entryOf = (raw: Raw, id: string) => { } const listOf = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : []) -// deny: apps serving their own company offer every model unless providers..disabled names it. +// deny: apps serving their own company offer every model unless providers..disabled names it, +// except Claude models, which they offer once providers..anthropic names them. // allow: apps serving many companies offer only what providers..models names. -export function setModel(raw: Raw, id: string, model: string, enabled: boolean, mode: 'deny' | 'allow', known: string[]): string[] { +export function setModel(raw: Raw, id: string, model: string, enabled: boolean, mode: 'deny' | 'allow', known: string[], claude = false): string[] { const entry = entryOf(raw, id) const notes: string[] = [] const hasList = Array.isArray(entry.models) + const covers = (p: string) => (claude ? turnsOn(p, { provider: 'Anthropic', model }) : globMatch(p, model)) + if (claude && mode === 'deny' && !hasList) { + const rest = listOf(entry.anthropic).filter((p) => p !== model) + const still = !enabled && rest.find((p) => globMatch(p, model)) + if (still) notes.push(`${model} is still turned on by "${still}" in providers.${id}.anthropic`) + const list = enabled ? [...rest, model] : rest + if (list.length) entry.anthropic = list + else delete entry.anthropic + if (enabled) { + const disabled = listOf(entry.disabled).filter((p) => p !== model) + if (disabled.length) entry.disabled = disabled + else delete entry.disabled + } + return notes + } if (enabled) { const disabled = listOf(entry.disabled).filter((p) => p !== model) const still = disabled.find((p) => globMatch(p, model)) @@ -112,7 +128,7 @@ export function setModel(raw: Raw, id: string, model: string, enabled: boolean, else delete entry.disabled if (mode === 'allow' || hasList) { const models = listOf(entry.models) - if (!models.some((p) => globMatch(p, model))) models.push(model) + if (!models.some(covers)) models.push(model) entry.models = models } } else if (mode === 'deny' && !hasList) { @@ -123,7 +139,7 @@ export function setModel(raw: Raw, id: string, model: string, enabled: boolean, // A pattern that covers this model turns into the models it covers now, minus this one. const out: string[] = [] for (const p of listOf(entry.models)) { - if (!globMatch(p, model)) out.push(p) + if (!covers(p)) out.push(p) else if (p !== model && p.includes('*')) out.push(...known.filter((k) => k !== model && globMatch(p, k))) } entry.models = [...new Set(out)] diff --git a/plugins/bitfrost/daemon/process.ts b/plugins/bitfrost/daemon/process.ts index d78c458..72eff3b 100644 --- a/plugins/bitfrost/daemon/process.ts +++ b/plugins/bitfrost/daemon/process.ts @@ -9,7 +9,10 @@ import { execFileSync } from 'node:child_process' // owner is the helper that started the group: its store, and its own pid and start time. type Owner = { store: string; pid: number; startTime: string | null } -export type ProcessIdentity = { pgid: number; startTime: string | null; token: string; owner?: Owner } +// marker names the environment variable that carries token; older records are ZCode's. +export type ProcessIdentity = { pgid: number; startTime: string | null; token: string; marker?: string; owner?: Owner } + +const APPS: Record = { BITFROST_ZCODE_TURN: 'ZCode', BITFROST_AGY_PROC: 'Antigravity' } let self: Owner | null = null @@ -32,8 +35,8 @@ export function ownProcesses(store: string) { self = { store: real, pid: process.pid, startTime: startTimeOf(process.pid) } } -export function processIdentity(pgid: number, token: string): ProcessIdentity { - return { pgid, startTime: startTimeOf(pgid), token, ...(self ? { owner: self } : {}) } +export function processIdentity(pgid: number, token: string, marker = 'BITFROST_ZCODE_TURN'): ProcessIdentity { + return { pgid, startTime: startTimeOf(pgid), token, ...(marker !== 'BITFROST_ZCODE_TURN' ? { marker } : {}), ...(self ? { owner: self } : {}) } } // A group is this helper's to stop when it started it, or a helper on the same store that is gone did. @@ -47,7 +50,9 @@ function mine(owner: Owner | undefined) { } export function killOwnedGroup(identity: ProcessIdentity, log: (text: string) => void = () => {}): boolean { - if (!Number.isSafeInteger(identity?.pgid) || identity.pgid <= 1 || !identity.token || !mine(identity.owner)) return false + const marker = identity?.marker ?? 'BITFROST_ZCODE_TURN' + if (!Number.isSafeInteger(identity?.pgid) || identity.pgid <= 1 || !identity.token || !APPS[marker] || !mine(identity.owner)) return false + const tag = `${marker}=${identity.token}` let ours = false try { if (process.platform === 'linux') { @@ -57,7 +62,7 @@ export function killOwnedGroup(identity: ProcessIdentity, log: (text: string) => const pid = Number(name), info = stat(pid) if (info.pgid !== identity.pgid || (pid === identity.pgid && identity.startTime && info.startTime !== identity.startTime)) continue const env = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0') - if (env.includes(`BITFROST_ZCODE_TURN=${identity.token}`)) { ours = true; break } + if (env.includes(tag)) { ours = true; break } } catch {} } } else if (process.platform === 'darwin') { @@ -65,11 +70,11 @@ export function killOwnedGroup(identity: ProcessIdentity, log: (text: string) => ours = rows.split('\n').some((row) => { const match = row.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/) if (!match || Number(match[2]) !== identity.pgid) return false - if (Number(match[1]) === identity.pgid && identity.startTime && processIdentity(identity.pgid, identity.token).startTime !== identity.startTime) return false - return match[3].split(/\s+/).includes(`BITFROST_ZCODE_TURN=${identity.token}`) + if (Number(match[1]) === identity.pgid && identity.startTime && startTimeOf(identity.pgid) !== identity.startTime) return false + return match[3].split(/\s+/).includes(tag) }) } - if (ours) { process.kill(-identity.pgid, 'SIGKILL'); log(`stopped leftover ZCode process group ${identity.pgid}`); return true } + if (ours) { process.kill(-identity.pgid, 'SIGKILL'); log(`stopped leftover ${APPS[marker]} process group ${identity.pgid}`); return true } } catch (e) { if ((e as NodeJS.ErrnoException).code !== 'ESRCH') log(`could not stop process group ${identity.pgid}: ${(e as Error).message}`) } return false } diff --git a/plugins/bitfrost/daemon/provider.ts b/plugins/bitfrost/daemon/provider.ts index 00a6183..0f46818 100644 --- a/plugins/bitfrost/daemon/provider.ts +++ b/plugins/bitfrost/daemon/provider.ts @@ -98,6 +98,8 @@ export type ProviderFactory = { vendor?: string // Enable only when config.json explicitly turns this provider on. optIn?: boolean + // A dedicated harness, listed before apps that wrap many companies' models. + firstClass?: boolean create(env: ProviderEnv): Provider | null // Where the app is, without starting it; null when it isn't installed. detect?(env: ProviderEnv): string | null diff --git a/plugins/bitfrost/daemon/providers/acp-agents.ts b/plugins/bitfrost/daemon/providers/acp-agents.ts index 006d8c4..b8ee664 100644 --- a/plugins/bitfrost/daemon/providers/acp-agents.ts +++ b/plugins/bitfrost/daemon/providers/acp-agents.ts @@ -23,7 +23,7 @@ const OPENCODE_PERMISSION = { export const OPENCODE: AcpAgentSpec = { id: 'opencode', - displayName: 'opencode', + displayName: 'OpenCode', vendor: null, binary: 'opencode', args: ['acp'], diff --git a/plugins/bitfrost/daemon/providers/acp-shapes.ts b/plugins/bitfrost/daemon/providers/acp-shapes.ts index 45ae48c..3e5f89c 100644 --- a/plugins/bitfrost/daemon/providers/acp-shapes.ts +++ b/plugins/bitfrost/daemon/providers/acp-shapes.ts @@ -4,6 +4,8 @@ // BitFrost is free software: you can share and change it under the GNU General // Public License, version 3 only. It comes with no warranty. See LICENSE. +import fs from 'node:fs' +import path from 'node:path' import type { Decision, FileChange, Question } from '../events.ts' export { globMatch } from '../registry.ts' @@ -29,6 +31,28 @@ export const objectOf = (v: Json): Json => (v && typeof v === 'object' && !Array const str = (v: Json): string | undefined => (typeof v === 'string' && v ? v : undefined) export const isSelect = (category: string) => (o: Json) => o?.category === category && o.type === 'select' +// Compare real paths so a link inside the folder cannot point an edit outside it. +// A missing path resolves through its parent; a dangling link resolves to nothing. +function realPath(p: string): string | null { + try { return fs.realpathSync(p) } catch (e) { + if (!['ENOENT', 'ENOTDIR'].includes((e as NodeJS.ErrnoException).code ?? '')) return null + try { if (fs.lstatSync(p).isSymbolicLink()) return null } catch {} + const parent = path.dirname(p) + if (parent === p) return null + const real = realPath(parent) + return real ? path.join(real, path.basename(p)) : null + } +} + +// Whether file, relative to dir or absolute, is inside dir. +export function inside(file: string, dir: string): boolean { + const f = realPath(path.resolve(dir, file)) + const d = realPath(path.resolve(dir)) + if (!f || !d) return false + const rel = path.relative(d, f) + return rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel) +} + export function choicesOf(option: Json): Json[] { return (option.options ?? []).flatMap((o: Json) => (Array.isArray(o.options) ? o.options : [o])) } @@ -56,10 +80,11 @@ export function outputOf(t: ToolCall): string { const texts = t.content.filter((c) => c?.type === 'content' && c.content?.type === 'text').map((c) => String(c.content.text)) // Oh My Pi repeats the command in its output; remove that block. const kept = t.kind === 'execute' ? texts.filter((x) => x !== `$ ${commandOf(t)}`) : texts - if (kept.length) return kept.join('\n') + if (kept.some((x) => x.length)) return kept.join('\n') const o = t.rawOutput if (o == null) return '' if (typeof o === 'string') return o + if (typeof o.output_for_prompt === 'string') return o.output_for_prompt if (typeof o.output === 'string') return o.output if (typeof o.stdout === 'string') return o.stdout + (o.stderr ? `\n${o.stderr}` : '') if (typeof o.error === 'string') return o.error diff --git a/plugins/bitfrost/daemon/providers/acp.ts b/plugins/bitfrost/daemon/providers/acp.ts index 69deff9..e19f7d3 100644 --- a/plugins/bitfrost/daemon/providers/acp.ts +++ b/plugins/bitfrost/daemon/providers/acp.ts @@ -54,7 +54,7 @@ export type AcpOptions = { // Cache for a day because listing can switch models and leave history entries. const MODELS_TTL_MS = 24 * 60 * 60_000 -type Turn = { +export type AcpTurn = { id: string cancelled: boolean wrongModel: string | null @@ -66,7 +66,7 @@ type Turn = { denied: { at: number; reason: string } | null } -type State = { +export type AcpState = { session: Session acpId: string cwd: string @@ -76,10 +76,13 @@ type State = { intro: string | null drifted: boolean dead: boolean - turn: Turn | null + turn: AcpTurn | null grants: Set } +type Turn = AcpTurn +type State = AcpState + type Pending = | { kind: 'permission'; session: Session; rpcId: number | string; options: Json[]; grantKey: string } | { kind: 'confirm'; session: Session; rpcId: number | string; field: string; yes: Json; no: Json; grantKey: string } @@ -93,18 +96,19 @@ export class AcpProvider implements Provider { readonly location: string private spec: AcpAgentSpec private launch: AcpLaunch - private log: (msg: string) => void + protected log: (msg: string) => void private record: ((line: string) => void) | null private cacheFile: string | null private allow: string[] | null private models: { at: number; allow: string[] | null; list: HarnessModel[] } | null = null - private proc: ChildProcess | null = null + protected proc: ChildProcess | null = null private ready: Promise | null = null private agentCaps: Json = {} + protected initialized: Json = null private nextId = 1 private requests = new Map void; reject: (e: Error) => void }>() - private byAcpId = new Map() - private states = new Map() + protected byAcpId = new Map() + protected states = new Map() private pending = new Map() private opening = new Set() private discovering = 0 @@ -129,7 +133,7 @@ export class AcpProvider implements Provider { } - private start(): Promise { + protected start(): Promise { if (this.ready) return this.ready const proc = spawn(this.launch.bin, this.launch.args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, ...this.launch.env } }) this.proc = proc @@ -147,6 +151,7 @@ export class AcpProvider implements Provider { }).then((r) => { if (r?.protocolVersion !== PROTOCOL_VERSION) throw new Error(`${this.displayName} speaks ACP version ${r?.protocolVersion}, not ${PROTOCOL_VERSION}`) this.agentCaps = r.agentCapabilities ?? {} + this.initialized = r }) this.ready.catch((e) => { this.log(`${this.id}: handshake failed: ${e.message}`) @@ -155,7 +160,7 @@ export class AcpProvider implements Provider { return this.ready } - private onExit(proc: ChildProcess, why: string) { + protected onExit(proc: ChildProcess, why: string) { if (this.proc !== proc) return this.proc = null this.ready = null @@ -184,7 +189,7 @@ export class AcpProvider implements Provider { this.proc?.stdin!.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n') } - private request(method: string, params: Json, session?: Session): Promise { + protected request(method: string, params: Json, session?: Session): Promise { if (!this.proc) return Promise.reject(new Error(`${this.displayName} is not running`)) const id = this.nextId++ return new Promise((resolve, reject) => { @@ -193,7 +198,7 @@ export class AcpProvider implements Provider { }) } - private reply(rpcId: number | string, result: Json) { + protected reply(rpcId: number | string, result: Json) { this.send({ id: rpcId, result }) } @@ -209,35 +214,39 @@ export class AcpProvider implements Provider { const r = this.requests.get(msg.id) if (!r) return this.requests.delete(msg.id) - if (msg.error) r.reject(Object.assign(new Error(msg.error.message ?? 'error'), { code: msg.error.code })) + if (msg.error) r.reject(Object.assign(new Error(msg.error.message ?? 'error'), { code: msg.error.code, data: msg.error.data })) else r.resolve(msg.result) } else if (msg.id !== undefined) { this.onRequest(msg) } else if (msg.method === 'session/update') { this.onUpdate(msg.params ?? {}) - } + } else this.onNotification(msg.method, msg.params ?? {}) } - private onRequest(msg: Json) { + protected onNotification(method: string, params: Json) {} + + protected onRequest(msg: Json) { if (msg.method === 'session/request_permission') return this.onPermission(msg.id, msg.params ?? {}) if (msg.method === 'elicitation/create') return this.onElicitation(msg.id, msg.params ?? {}) this.log(`${this.id} asked ${msg.method}; not offered, refusing`) this.send({ id: msg.id, error: { code: -32601, message: `bitfrost does not handle ${msg.method}` } }) } - private explain(e: Json): Error { + protected explain(e: Json): Error { if (e?.code !== AUTH_REQUIRED) return e return new Error(`${this.displayName} could not start a session: ${e.message}. If it needs signing in, run \`${this.spec.loginCommand}\` in a terminal, then try again.`) } private async newSession(cwd: string, session?: Session): Promise { try { - return await this.request('session/new', { cwd, mcpServers: [] }, session) + return await this.request('session/new', this.sessionParams(cwd), session) } catch (e) { throw this.explain(e) } } + protected sessionParams(cwd: string): Json { return { cwd, mcpServers: [] } } + // Close task sessions to keep their history; delete throwaway sessions when supported. private dropSession(acpId: string, keep = false) { const caps = this.agentCaps.sessionCapabilities ?? {} @@ -406,7 +415,7 @@ export class AcpProvider implements Provider { await this.start() if (!this.agentCaps.loadSession) throw new Error(`${this.displayName} can't reopen this session: the agent does not advertise loadSession`) if (!ref?.acpSessionId) throw new Error(`${this.displayName} can't reopen this session: no native session id was saved`) - const r = await this.request('session/load', { sessionId: ref.acpSessionId, cwd: session.info.cwd, mcpServers: [] }, session) + const r = await this.request('session/load', { sessionId: ref.acpSessionId, ...this.sessionParams(session.info.cwd) }, session) const modelOption = await this.chooseModel(ref.acpSessionId, r, session.info.model, session.info.effort ?? undefined, session) const st: State = { session, acpId: ref.acpSessionId, cwd: session.info.cwd, model: session.info.model, modelOption, canAsk: !!ref.canAsk, intro: null, drifted: false, dead: false, turn: null, grants: new Set() } this.states.set(session.info.id, st) @@ -450,7 +459,7 @@ export class AcpProvider implements Provider { } } - private stateOf(session: Session): State | undefined { + protected stateOf(session: Session): State | undefined { return this.states.get(session.info.id) } @@ -476,7 +485,7 @@ export class AcpProvider implements Provider { } // ACP requires cancelling pending requests before stopping the turn. - private cancelTurn(st: State) { + protected cancelTurn(st: State) { const turn = st.turn if (!turn || turn.cancelled) return turn.cancelled = true @@ -488,7 +497,7 @@ export class AcpProvider implements Provider { timer.unref() } - private dropPending(session: Session) { + protected dropPending(session: Session) { for (const [id, p] of this.pending) { if (p.session !== session) continue this.pending.delete(id) @@ -497,13 +506,10 @@ export class AcpProvider implements Provider { } } - private endTurn(st: State, turn: Turn, r: Json, error: string | null) { + protected endTurn(st: State, turn: Turn, r: Json, error: string | null) { if (st.turn !== turn) return this.flush(st, turn) - // Keep context limits on the last snapshot, and bill the prompt-wide totals once. - const out = r.usage?.outputTokens - if (turn.lastUsed !== null) st.session.push({ type: 'usage', requestId: turn.id, inputTokens: turn.lastUsed, outputTokens: Number(out) || 0, cachedInputTokens: 0, - billing: { inputTokens: r.usage?.inputTokens ?? turn.lastUsed, outputTokens: Number(out) || 0, cachedInputTokens: Number(r.usage?.cachedInputTokens) || 0, requests: Number(r.usage?.modelRequestCount) || 1 } }) + this.endUsage(st, turn, r) const stop = r.stopReason const status = turn.wrongModel || (error && !turn.cancelled) ? 'failed' @@ -511,7 +517,7 @@ export class AcpProvider implements Provider { : stop === 'end_turn' ? 'completed' : 'failed' const denied = stop !== 'end_turn' && !turn.cancelled && !turn.wrongModel && turn.denied && Date.now() - turn.denied.at <= 2000 ? turn.denied : null - const reason: EndReason = turn.wrongModel ? 'wrong_model' : turn.cancelled || stop === 'cancelled' ? 'interrupted' : denied ? 'permission_denied' : error ? 'error' : ({ end_turn: 'end_turn', max_tokens: 'max_tokens', max_turn_requests: 'max_requests', refusal: 'refusal' } as Record)[stop] ?? 'error' + const reason: EndReason = turn.wrongModel ? 'wrong_model' : turn.cancelled || stop === 'cancelled' ? 'interrupted' : denied ? 'permission_denied' : this.endReason(stop, error) const why = turn.wrongModel ? `${this.displayName} switched to ${turn.wrongModel} instead of ${st.model}; stopped it` : (denied?.reason ?? error ?? (status === 'failed' ? (STOPS[stop] ?? `it stopped (${stop})`) : null)) @@ -519,8 +525,19 @@ export class AcpProvider implements Provider { st.session.push({ type: 'turn_completed', turnId: turn.id, status, reason, finalText: turn.finalText, ...(why ? { error: why } : {}) }) } + protected endUsage(st: State, turn: Turn, r: Json) { + // Keep context limits on the last snapshot, and bill the prompt-wide totals once. + const out = r.usage?.outputTokens + if (turn.lastUsed !== null) st.session.push({ type: 'usage', requestId: turn.id, inputTokens: turn.lastUsed, outputTokens: Number(out) || 0, cachedInputTokens: 0, + billing: { inputTokens: r.usage?.inputTokens ?? turn.lastUsed, outputTokens: Number(out) || 0, cachedInputTokens: Number(r.usage?.cachedInputTokens) || 0, requests: Number(r.usage?.modelRequestCount) || 1 } }) + } + + protected endReason(stop: string, error: string | null): EndReason { + return error ? 'error' : ({ end_turn: 'end_turn', max_tokens: 'max_tokens', max_turn_requests: 'max_requests', refusal: 'refusal' } as Record)[stop] ?? 'error' + } + - private onUpdate(p: Json) { + protected onUpdate(p: Json) { const st = this.byAcpId.get(p.sessionId) const u = p.update ?? {} if (!st) return @@ -547,7 +564,7 @@ export class AcpProvider implements Provider { } // Stop model switches so the task uses only the model the user chose. - private checkModel(st: State, options: Json[] | undefined) { + protected checkModel(st: State, options: Json[] | undefined) { const now = options?.find(isSelect('model'))?.currentValue if (now === undefined || now === st.model) return st.drifted = true diff --git a/plugins/bitfrost/daemon/providers/agy.ts b/plugins/bitfrost/daemon/providers/agy.ts new file mode 100644 index 0000000..428a1e4 --- /dev/null +++ b/plugins/bitfrost/daemon/providers/agy.ts @@ -0,0 +1,928 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +// Run one Antigravity CLI process per task, in print mode with stream-json in and out. +// Print mode refuses whatever needs permission, so Antigravity runs with permission +// prompts off and BitFrost's bridge plugin gates every tool call through a PreToolUse hook. +import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { createInterface } from 'node:readline' +import { fileURLToPath } from 'node:url' +import type { AgentEventBody, Decision, EndReason, Question } from '../events.ts' +import type { HarnessModel, Provider, ProviderCapabilities, ProviderEnv, ProviderFactory, SpawnRequest } from '../provider.ts' +import type { Session } from '../session.ts' +import { killOwnedGroup, processIdentity, type ProcessIdentity } from '../process.ts' +import { blankTool, claudeTool, describeAction, grantKeyOf, inside, objectOf, unifiedDiff, vendorOf, type ToolCall } from './acp-shapes.ts' +import { killGroup, readOnlyCommand } from './crush.ts' + +type Json = any + +const PLUGIN_NAME = 'bitfrost-bridge' +// Listing also rechecks the sign-in, so a list is kept only briefly. +const MODELS_TTL_MS = 5 * 60_000 +const IDLE_MS = 10 * 60_000 +const STOP_GRACE_MS = 5_000 +const AUTH_WAIT_MS = 15_000 +const EFFORTS = ['minimal', 'low', 'medium', 'high', 'xhigh', 'max'] + +const READ_ONLY = new Set(['view_file', 'list_dir', 'grep_search', 'find_by_name', 'codebase_search', 'view_file_outline', 'view_code_item', 'view_content_chunk', 'command_status', 'list_permissions', 'list_resources', 'list_plugin_accounts', 'wait', 'wait_5_seconds', 'finish']) +const FILE_TOOLS = new Set(['write_to_file', 'replace_file_content', 'multi_replace_file_content', 'sed_file', 'notebook_edit']) +// No nested subagents, agent messaging, workflows, or runs scheduled outside BitFrost. +const DISALLOWED = new Set(['invoke_subagent', 'define_subagent', 'manage_subagents', 'browser_subagent', 'send_message', 'manage_inbox', 'run_workflow', 'schedule']) +const PERMISSION_TOOLS = new Set(['ask_permission', 'ask_custom_permission']) +const PATH_KEYS = ['TargetFile', 'AbsolutePath', 'FilePath', 'NotebookPath', 'File', 'Path', 'DirectoryPath', 'SearchPath', 'SearchDirectory'] + +export type AgyOptions = { + bin: string + args?: string[] + log: (msg: string) => void + record?: ((line: string) => void) | null + runDir: string + socket: string + // Whether Antigravity will load the bridge plugin; without it permission prompts stay on. + bridgeLoaded?: () => Promise + node?: string + idleMs?: number + authWaitMs?: number +} + +type Proc = { child: ChildProcess; identity: ProcessIdentity | null; skip: boolean; verified: boolean; stopping: boolean; stderr: string; exited: boolean; gone: Promise; done: () => void; idle: NodeJS.Timeout | null; denied: number; auth: { file: string; read: number } } + +type Turn = { + id: string + proc: Proc | null + lines: number + interrupted: boolean + wrongModel: string | null + failure: { reason: EndReason; error: string } | null + texts: Map + finalText: string + started: Set +} + +type Call = { name: string; args: Json; file?: string; old?: string | null } + +type State = { + session: Session + token: string + cwd: string + model: string + canAsk: boolean + intro: string | null + conversationId: string | null + proc: Proc | null + launching: Promise | null + dying: Promise | null + turn: Turn | null + steer: string[] + carry: string[] + calls: Map + grants: Set + dead: boolean +} + +type Pending = + | { kind: 'approval'; session: Session; grantKey: string; resolve: (reply: Json) => void } + | { kind: 'question'; session: Session; questions: Question[]; resolve: (reply: Json) => void } + +type Listing = { at: number; auth?: string; models: HarnessModel[]; variants: Record> } + +const allow = () => ({ decision: 'allow' }) +const deny = (reason: string) => ({ decision: 'deny', reason }) +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) + +const SIGN_IN = "Sign Antigravity in with an API key or a business account; BitFrost can't use it with a personal Google account." + +// Antigravity's terms don't allow driving it from another tool while it's signed in to a +// personal Google account; API keys and business accounts are fine. Each run says how it +// signed in only in its own log, so anything BitFrost can't read counts as personal. +// Every sign-in a log shows, null for one BitFrost can't read. +function authMethods(log: string): (string | null)[] { + return log.split('\n').filter((l) => l.includes('applyAuthResult:')).map((l) => l.match(/\bauthMethod=([A-Za-z_]*)[,\s]/)?.[1].toLowerCase() ?? null) +} + +// A run counts as refused when any of its sign-ins is. +export function authMethodOf(log: string): string | null { + const all = authMethods(log) + const bad = all.findIndex((m) => authRefusal(m)) + return bad === -1 ? all.at(-1) ?? null : all[bad] +} + +// Only sign-ins known to be API keys or business accounts run; anything else is refused. +const ALLOWED_AUTH = /^(?:gemini_api_key|vertex\w*|business_\w+|enterprise\w*)$/ + +export function authRefusal(method: string | null | undefined): string | null { + if (!method) return `BitFrost couldn't tell how Antigravity is signed in, so it won't run it. ${SIGN_IN}` + if (/consumer|personal/.test(method)) return "Antigravity is signed in with a personal Google account. Its terms don't allow using it through another tool that way, so BitFrost won't run it. Sign it in with an API key or a business account to use it here." + if (!ALLOWED_AUTH.test(method)) return `Antigravity is signed in a way BitFrost doesn't recognize (${method}), so it won't run it. ${SIGN_IN}` + return null +} + +// What a run logged since the last check; a partial last line waits for the next one. +function readNew(file: string, from: number): { text: string; next: number } { + let fd: number + try { fd = fs.openSync(file, 'r') } catch { return { text: '', next: from } } + try { + const size = fs.fstatSync(fd).size + if (size <= from) return { text: '', next: from } + const buf = Buffer.alloc(size - from) + fs.readSync(fd, buf, 0, buf.length, from) + const end = buf.lastIndexOf(10) + 1 + return { text: buf.subarray(0, end).toString('utf8'), next: from + end } + } finally { fs.closeSync(fd) } +} + +const refused = (why: string) => Object.assign(new Error(why), { blocked: true }) + +function drop(file: string) { + try { fs.rmSync(file, { force: true }) } catch {} +} + +// Antigravity signs in within a second of starting and logs how. +function waitForAuth(file: string, ms: number, gone: () => boolean): Promise { + const until = Date.now() + ms + return new Promise((resolve) => { + const tick = () => { + let text = '' + try { text = fs.readFileSync(file, 'utf8') } catch {} + const method = authMethodOf(text) + if (method !== null || gone() || Date.now() >= until) return resolve(method) + setTimeout(tick, 100) + } + tick() + }) +} + +function groupAlive(pgid: number): boolean { + try { process.kill(-pgid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' } +} + +function killLeftovers(child: ChildProcess) { + try { if (child.pid) process.kill(-child.pid, 'SIGKILL') } catch {} +} + +// The next turn starts a new process, once this one has gone, and resumes the conversation. +function retire(st: State, proc: Proc, signal: NodeJS.Signals) { + proc.stopping = true + if (proc.idle) clearTimeout(proc.idle) + if (st.proc === proc) st.proc = null + st.dying = proc.gone + killGroup(proc.child, signal) + if (signal !== 'SIGKILL') setTimeout(() => { if (!proc.exited) killGroup(proc.child, 'SIGKILL') }, STOP_GRACE_MS).unref() +} + +export function foldModels(listing: string): Omit { + const groups = new Map }>() + for (const line of listing.split('\n')) { + const [id, name] = line.split('\t').map((s) => s.trim()) + if (!id || name === undefined || /\s/.test(id)) continue + const m = id.match(/^(.+)-(minimal|low|medium|high|xhigh|max)$/) + const base = m ? m[1] : id + const g = groups.get(base) ?? { name: m ? name.replace(/\s*\([^)]*\)\s*$/, '') : name, ids: new Map() } + g.ids.set(m ? m[2] : '', id) + groups.set(base, g) + } + const models: HarnessModel[] = [] + const variants: Listing['variants'] = {} + for (const [base, g] of groups) { + const efforts = EFFORTS.filter((e) => g.ids.has(e)) + variants[base] = Object.fromEntries(g.ids) + models.push({ + harness: 'agy', + provider: vendorOf(base), + model: base, + displayName: g.name || base, + description: 'Through your Antigravity API key or business account.', + efforts, + defaultEffort: g.ids.has('') || !efforts.length ? null : efforts.includes('medium') ? 'medium' : efforts[0], + isDefault: !models.length, + }) + } + return { models, variants } +} + +export function agyEndReason(text: string): EndReason { + if (/\bcredits?\b|quota/i.test(text)) return 'quota_exhausted' + if (/rate.?limit|RESOURCE_EXHAUSTED|\b429\b|too many requests/i.test(text)) return 'rate_limited' + if (/sign(?:ed)?[ -]?in|log[ -]?in|authenticat|unauthori[sz]ed|UNAUTHENTICATED|\b401\b|\b403\b/i.test(text)) return 'auth' + if (/context (?:window|length)|token limit|max(?:imum)? (?:output )?tokens|prompt is too long/i.test(text)) return 'max_tokens' + return 'error' +} + +function stderrError(stderr: string): string | null { + const lines = stderr.split('\n').map((l) => l.trim()).filter(Boolean) + for (const line of lines.reverse()) { + const m = line.match(/^AGY_ERROR:\s*(\{.*\})$/) + if (m) { + try { + const e = JSON.parse(m[1]) + return String(e.short_error ?? e.message ?? m[1]) + } catch { return m[1] } + } + } + return lines.find((l) => /^error:/i.test(l))?.replace(/^error:\s*/i, '') ?? (lines.length ? lines.slice(0, 3).reverse().join(' ') : null) +} + +function pathArg(args: Json, cwd: string): string | undefined { + for (const key of PATH_KEYS) if (str(args[key])) return path.resolve(cwd, args[key]) + return undefined +} + +function readOld(file: string): string | null | undefined { + try { return fs.statSync(file).size <= 1 << 20 ? fs.readFileSync(file, 'utf8') : undefined } catch { return null } +} + +function toolCallOf(name: string, args: Json, cwd: string, old?: string | null): ToolCall { + const a = Object.fromEntries(Object.entries(objectOf(args)).filter(([k]) => k !== 'toolAction' && k !== 'toolSummary')) + const file = pathArg(a, cwd) + const t = (kind: string, rawInput: Json, content: Json[] = []) => ({ ...blankTool(), kind, name, title: kind === 'other' ? name : '', rawInput, content }) + switch (name) { + case 'run_command': + return t('execute', { command: String(a.CommandLine ?? ''), ...(str(args.toolSummary) ? { description: args.toolSummary } : {}) }) + case 'view_file': + return t('read', { file_path: file }) + case 'write_to_file': + return t('edit', { file_path: file, content: String(a.CodeContent ?? '') }, file && old !== undefined ? [{ type: 'diff', path: file, oldText: old, newText: String(a.CodeContent ?? '') }] : []) + case 'replace_file_content': + return typeof a.TargetContent === 'string' && typeof a.ReplacementContent === 'string' + ? t('edit', { file_path: file, old_string: a.TargetContent, new_string: a.ReplacementContent }) + : t('edit', { file_path: file, ...a }) + case 'read_url_content': + return t('fetch', { url: str(a.Url) ?? str(a.url) ?? '' }) + case 'search_web': + return t('fetch', { query: str(a.query) ?? str(a.Query) ?? '' }) + } + if (FILE_TOOLS.has(name)) return t('edit', { ...a, ...(file ? { file_path: file } : {}) }) + return t('other', a) +} + +function shapeOf(name: string, args: Json, cwd: string): { name: string; input: Json } { + if (name === 'list_dir') { + const where = pathArg(args, cwd) + return { name: 'Bash', input: { command: where ? `ls '${where.replace(/'/g, `'\\''`)}'` : 'ls', description: 'List files' } } + } + const t = toolCallOf(name, args, cwd) + return claudeTool(t.kind, name, t.rawInput) ?? { name, input: t.rawInput } +} + +function batch(texts: string[]): string { + return texts.length === 1 ? texts[0] : `The lead sent these messages while you were working, oldest first:\n${texts.map((t, i) => `${i + 1}. ${t}`).join('\n\n')}` +} + +export class AgyProvider implements Provider { + readonly id = 'agy' + readonly displayName = 'Antigravity' + readonly vendor = 'Google' + readonly capabilities: ProviderCapabilities = { steer: true, autoReview: false, questions: true, gates: 'all' } + readonly location: string + readonly watch: string[] + private opts: AgyOptions + private log: (msg: string) => void + private cacheFile: string + private listing: Listing | null = null + private fetching: Promise | null = null + private states = new Map() + private byToken = new Map() + private pending = new Map() + // Set once the bridge failed to load, so later runs keep permission prompts on. + private unsafe = false + // Why the last check refused how Antigravity is signed in. + private refusal: string | null = null + + constructor(opts: AgyOptions) { + this.opts = opts + this.log = opts.log + this.location = opts.bin + this.watch = [opts.bin] + this.cacheFile = path.join(opts.runDir, 'agy-models.json') + } + + // A private file for one run's log, which holds the account's email address. + private authLog(): string { + const dir = path.join(this.opts.runDir, 'agy-auth') + fs.mkdirSync(dir, { recursive: true, mode: 0o700 }) + return path.join(dir, `${randomUUID()}.log`) + } + + private checked(method: string | null): string | null { + this.refusal = authRefusal(method) + if (this.refusal) { + this.listing = null + drop(this.cacheFile) + } + return this.refusal + } + + async listModels(force = false): Promise { + return (await this.models(force)).models + } + + private async models(force = false): Promise { + if (!this.listing) { + try { this.listing = JSON.parse(fs.readFileSync(this.cacheFile, 'utf8')) } catch {} + } + // A list from before the sign-in check, or after a refusal, is fetched again. + if (!force && !this.refusal && this.listing && !authRefusal(this.listing.auth) && Date.now() - this.listing.at < MODELS_TTL_MS) return this.listing + this.fetching ??= this.fetchModels().finally(() => (this.fetching = null)) + return this.fetching + } + + private fetchModels(): Promise { + return new Promise((resolve, reject) => { + const logFile = this.authLog() + execFile(this.opts.bin, [...(this.opts.args ?? []), '--log-file', logFile, 'models'], { timeout: 60_000, maxBuffer: 1 << 20 }, (err, stdout, stderr) => { + let method: string | null = null + try { method = authMethodOf(fs.readFileSync(logFile, 'utf8')) } catch {} + drop(logFile) + // Check first, so a failed listing on a refused sign-in still drops the old list. + const refusal = this.checked(method) + const folded = foldModels(String(stdout ?? '')) + if (!folded.models.length && (!refusal || method === null)) { + const said = `${stdout ?? ''}\n${stderr ?? ''}\n${err?.message ?? ''}` + const why = agyEndReason(said) === 'auth' ? `Antigravity is not signed in. ${SIGN_IN}` : `Antigravity listed no models: ${said.trim().split('\n').slice(-2).join(' ').slice(0, 300)}` + return reject(refusal ? refused(why) : new Error(why)) + } + if (refusal) return reject(refused(refusal)) + this.listing = { at: Date.now(), auth: method!, ...folded } + try { + fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true, mode: 0o700 }) + fs.writeFileSync(this.cacheFile, JSON.stringify(this.listing), { mode: 0o600 }) + } catch (e) { this.log(`agy: could not cache the model list: ${(e as Error).message}`) } + resolve(this.listing) + }) + }) + } + + private async variantOf(model: string, effort: string | undefined): Promise { + let v: Record | undefined + try { v = (await this.models()).variants[model] } catch {} + if (!v) return effort && EFFORTS.includes(effort) && !model.endsWith(`-${effort}`) ? `${model}-${effort}` : model + if (effort && v[effort]) return v[effort] + if (effort) this.log(`agy: ${model} has no effort ${effort}; using its default`) + const listed = this.listing?.models.find((m) => m.model === model) + return v[''] ?? v[listed?.defaultEffort ?? ''] ?? Object.values(v)[0] + } + + async spawnSession(session: Session, req: SpawnRequest): Promise { + const model = await this.variantOf(req.model, req.effort) + session.info.id = `agy_${randomUUID()}` + session.info.effort = req.effort ?? null + const st = this.track(session, req.cwd, model, !!req.canAskUser, req.developerInstructions ?? null, null) + session.setNativeRef({ conversationId: null, model, canAsk: st.canAsk }) + session.acceptInput(req.prompt, 'started') + this.runTurn(st, req.prompt) + return session.info.id + } + + private track(session: Session, cwd: string, model: string, canAsk: boolean, intro: string | null, conversationId: string | null): State { + const st: State = { session, token: randomUUID(), cwd, model, canAsk, intro, conversationId, proc: null, launching: null, dying: null, turn: null, steer: [], carry: [], calls: new Map(), grants: new Set(), dead: false } + this.states.set(session.info.id, st) + this.byToken.set(st.token, st) + return st + } + + async sendInput(session: Session, text: string): Promise<'started' | 'steered' | 'queued'> { + const st = this.states.get(session.info.id) + if (!st || st.dead) throw new Error("Antigravity can't reopen this session") + if (st.turn) { + if (st.turn.interrupted || st.turn.wrongModel) { session.queueInput(this, text); return 'queued' } + // The next model request picks it up through the PreInvocation hook. + st.steer.push(text) + return 'steered' + } + this.runTurn(st, text) + return 'started' + } + + async attach(session: Session, ref: Json) { + if (!ref?.conversationId) throw new Error("Antigravity can't reopen this session: no conversation id was saved") + const model = str(ref.model) ?? await this.variantOf(session.info.model, session.info.effort ?? undefined) + if (ref.process) killOwnedGroup(ref.process, this.log) + this.track(session, session.info.cwd, model, !!ref.canAsk, null, String(ref.conversationId)) + } + + kill(session: Session) { + const st = this.states.get(session.info.id) + const proc = st?.turn?.proc ?? st?.proc + return proc ? killGroup(proc.child, 'SIGKILL') : false + } + + isBusy(session: Session) { return !!this.states.get(session.info.id)?.turn } + + async interrupt(session: Session): Promise { + this.dropPending(session, 'Stopped.') + const st = this.states.get(session.info.id) + const turn = st?.turn + if (!st || !turn || turn.interrupted) return + turn.interrupted = true + const proc = turn.proc ?? st.proc + // SIGINT ends the turn cleanly and the process with it. + if (proc) retire(st, proc, 'SIGINT') + } + + dispose() { + for (const st of [...this.states.values()]) this.close(st, 'BitFrost stopped.') + } + + disposeSession(session: Session) { + const st = this.states.get(session.info.id) + if (st) this.close(st, 'The task was stopped.') + else session.dropInputs() + } + + private close(st: State, why: string) { + st.dead = true + st.session.dropInputs() + this.dropPending(st.session, why) + if (st.turn) st.turn.interrupted = true + const proc = st.proc + if (!proc) killOwnedGroup(st.session.nativeRef?.process, this.log) + if (proc && st.turn) retire(st, proc, 'SIGTERM') + else if (proc) { + proc.child.stdin?.end() + setTimeout(() => { if (!proc.exited) killGroup(proc.child, 'SIGKILL') }, STOP_GRACE_MS).unref() + } + if (this.states.get(st.session.info.id) === st) this.states.delete(st.session.info.id) + this.byToken.delete(st.token) + } + + private runTurn(st: State, text: string) { + const turn: Turn = { id: `agy_${randomUUID()}`, proc: null, lines: 0, interrupted: false, wrongModel: null, failure: null, texts: new Map(), finalText: '', started: new Set() } + st.turn = turn + if (st.proc?.idle) { clearTimeout(st.proc.idle); st.proc.idle = null } + st.session.push({ type: 'turn_started', turnId: turn.id }) + // Antigravity has no system prompt option, so the instructions lead the first message. + const prompt = [st.intro, ...st.carry.splice(0), text].filter(Boolean).join('\n\n') + st.intro = null + void this.write(st, turn, prompt) + } + + private async write(st: State, turn: Turn, text: string) { + try { + const proc = st.proc ?? await (st.launching ??= this.launch(st).finally(() => (st.launching = null))) + if (st.turn !== turn) return + if (turn.interrupted) return this.finish(st, turn, 'interrupted', 'interrupted') + if (proc.exited) throw new Error(stderrError(proc.stderr) ?? 'it exited at once') + if (this.signedOut(st, proc)) throw refused(this.refusal!) + turn.proc = proc + turn.lines++ + proc.child.stdin!.write(JSON.stringify({ event: 'user', message: { content: text } }) + '\n') + } catch (e) { + if (turn.interrupted) this.finish(st, turn, 'interrupted', 'interrupted') + else if ((e as Json).blocked) this.finish(st, turn, 'failed', 'auth', (e as Error).message) + else this.finish(st, turn, 'failed', 'crashed', `Antigravity could not run: ${(e as Error).message}`) + } + } + + private async launch(st: State): Promise { + const check = this.opts.bridgeLoaded ?? (() => Promise.resolve(false)) + const skip = !this.unsafe && await check().catch(() => false) + if (!skip) this.log('agy: the BitFrost bridge plugin is not loaded in Antigravity, so it will refuse every action that needs permission (run: bitfrostd setup agy)') + await st.dying + if (st.dead) throw new Error('the task was stopped') + const logFile = this.authLog() + const args = ['--log-file', logFile, '-p=', '--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--model', st.model] + if (skip) args.push('--dangerously-skip-permissions') + if (st.conversationId) args.push('--conversation', st.conversationId) + const mark = randomUUID() + const child = spawn(this.opts.bin, [...(this.opts.args ?? []), ...args], { + cwd: st.cwd, + detached: true, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, BITFROST_SOCKET: this.opts.socket, BITFROST_AGY_TOKEN: st.token, BITFROST_AGY_PROC: mark, BITFROST_NODE: this.opts.node ?? process.execPath }, + }) + let done = () => {} + const gone = new Promise((resolve) => (done = resolve)) + // A restarted helper finds the group through the saved session and stops what is left of it. + const identity = child.pid ? processIdentity(child.pid, mark, 'BITFROST_AGY_PROC') : null + if (identity) st.session.setNativeRef({ ...st.session.nativeRef, process: identity }) + const proc: Proc = { child, identity, skip, verified: false, stopping: false, stderr: '', exited: false, gone, done, idle: null, denied: 0, auth: { file: logFile, read: 0 } } + st.proc = proc + child.stdin!.on('error', (e) => this.log(`agy stdin: ${e.message}`)) + child.stderr!.on('data', (d) => { + proc.stderr = (proc.stderr + String(d)).slice(-4000) + this.log(`agy stderr: ${String(d).trimEnd().slice(0, 1000)}`) + }) + createInterface({ input: child.stdout! }).on('line', (line) => { + this.opts.record?.(line) + let ev: Json + try { ev = JSON.parse(line) } catch { return } + try { this.onLine(st, proc, ev) } catch (e) { this.log(`agy: could not handle ${ev?.event}: ${(e as Error).stack}`) } + }) + let exitTimer: NodeJS.Timeout | null = null + const ended = (code: number | null, signal: NodeJS.Signals | null) => { + if (exitTimer) clearTimeout(exitTimer) + this.onExit(st, proc, code, signal) + } + child.on('error', (e) => { + proc.stderr += `\n${e.message}` + ended(null, null) + }) + // A command left running can hold the pipes open, so wait only briefly for them after exit. + child.on('exit', (code, signal) => { + exitTimer = setTimeout(() => { + child.stdout?.destroy() + child.stderr?.destroy() + ended(code, signal) + }, 1000) + exitTimer.unref() + }) + child.on('close', ended) + // No prompt goes in until the run has shown how it signed in. The log stays until the + // process ends, so later sign-ins are checked too. + const method = await waitForAuth(logFile, this.opts.authWaitMs ?? AUTH_WAIT_MS, () => proc.exited || st.dead) + if (proc.exited || st.dead) return proc + const refusal = this.checked(method) + if (refusal) { + this.log(`agy: refused the run: ${method === null ? 'no sign-in found in its log' : `signed in as ${method}`}`) + drop(logFile) + retire(st, proc, 'SIGKILL') + throw refused(refusal) + } + return proc + } + + // True when the run has signed in again in a way that is refused; it is then stopped. + private signedOut(st: State, proc: Proc): boolean { + const { text, next } = readNew(proc.auth.file, proc.auth.read) + proc.auth.read = next + if (!text.includes('applyAuthResult:')) return false + const method = authMethodOf(text) + const refusal = this.checked(method) + if (!refusal) return false + this.log(`agy: refused the run: it signed in again ${method === null ? 'in a way BitFrost could not read' : `as ${method}`}`) + if (st.turn?.proc === proc && !st.turn.failure) st.turn.failure = { reason: 'auth', error: refusal } + retire(st, proc, 'SIGKILL') + return true + } + + private onExit(st: State, proc: Proc, code: number | null, signal: NodeJS.Signals | null) { + if (proc.exited) return + proc.exited = true + proc.done() + drop(proc.auth.file) + if (proc.idle) clearTimeout(proc.idle) + if (st.proc === proc) st.proc = null + if (proc.stopping) killLeftovers(proc.child) + if (proc.identity && st.session.nativeRef?.process?.token === proc.identity.token && !groupAlive(proc.identity.pgid)) { + const { process: _, ...ref } = st.session.nativeRef + st.session.setNativeRef(ref) + } + const turn = st.turn + if (!turn || turn.proc !== proc) return + this.dropPending(st.session, 'Antigravity stopped.') + if (turn.interrupted && !turn.wrongModel && !turn.failure) return this.finish(st, turn, 'interrupted', 'interrupted') + if (turn.wrongModel || turn.failure) return this.finish(st, turn, 'failed') + const said = stderrError(proc.stderr) ?? `exit ${code ?? signal}` + const reason = agyEndReason(said) + this.log(`agy exited mid-turn code=${code} signal=${signal}: ${proc.stderr.trim().slice(-500)}`) + this.finish(st, turn, 'failed', reason === 'error' ? 'crashed' : reason, `Antigravity stopped: ${said}${reason === 'auth' ? ` ${SIGN_IN}` : ''}`) + } + + private onLine(st: State, proc: Proc, ev: Json) { + const conv = str(ev.conversation_id) ?? str(ev.step_update?.conversation_id) ?? str(ev.result?.conversation_id) + if (conv && st.conversationId !== conv && st.proc === proc) { + st.conversationId = conv + st.session.setNativeRef({ ...st.session.nativeRef, conversationId: conv }) + } + const turn = st.turn + if (!turn || turn.proc !== proc) return + if (ev.event === 'init') { + const ran = str(ev.init?.model) + if (ran && ran !== st.model) this.wrongModel(st, turn, ran) + } else if (ev.event === 'step_update') this.onStep(st, proc, turn, ev.step_update ?? {}) + else if (ev.event === 'result') this.onResult(st, proc, turn, ev.result ?? {}) + } + + private onStep(st: State, proc: Proc, turn: Turn, s: Json) { + if (turn.wrongModel || turn.failure) return + // The PreInvocation hook runs before every model request, so a request without it means no gate. + if (proc.skip && !proc.verified && (s.step_type === 'agent_response' || s.step_type === 'tool')) { + this.unsafe = true + turn.failure = { reason: 'error', error: 'Antigravity did not load the BitFrost bridge plugin, so BitFrost stopped it before it could act unchecked. Run: bitfrostd setup agy' } + retire(st, proc, 'SIGKILL') + return + } + const id = `${s.conversation_id ?? st.conversationId}:${s.step_index}` + const emit = (body: AgentEventBody) => st.session.push(body) + if (s.step_type === 'agent_response') { + const text = (turn.texts.get(id) ?? '') + (typeof s.text_delta === 'string' ? s.text_delta : '') + turn.texts.set(id, text) + st.session.live = { activity: 'responding', partialText: text || turn.finalText || null, updatedAt: Date.now() } + if (s.state === 'ACTIVE') return + turn.texts.delete(id) + if (text.trim()) { + turn.finalText = text.trim() + emit({ type: 'text', itemId: id, text: text.trim() }) + } + const u = s.usage + // Antigravity reports uncached input; BitFrost counts cached input inside inputTokens. + if (u) emit({ type: 'usage', requestId: id, inputTokens: (Number(u.input_tokens) || 0) + (Number(u.cache_read_tokens) || 0), outputTokens: Number(u.output_tokens) || 0, cachedInputTokens: Number(u.cache_read_tokens) || 0 }) + return + } + if (s.step_type === 'tool') this.onTool(st, turn, id, s, emit) + } + + private onTool(st: State, turn: Turn, id: string, s: Json, emit: (b: AgentEventBody) => void) { + const name = String(s.tool_name ?? s.tool_info?.name ?? 'tool') + const call = st.calls.get(id) + const args = objectOf(call?.args ?? s.tool_info?.parameters) + const cwd = str(args.Cwd) ?? st.cwd + st.session.live = { activity: 'working', partialText: turn.finalText || null, updatedAt: Date.now() } + const start = () => { + if (turn.started.has(id)) return + turn.started.add(id) + if (name === 'run_command') { + const command = String(args.CommandLine ?? '') + emit({ type: 'command_started', itemId: id, command, summary: str(args.toolSummary) ?? command.split('\n')[0].slice(0, 70), cwd }) + } else emit({ type: 'tool_started', itemId: id, ...shapeOf(name, args, st.cwd) }) + } + // Edits are announced when they finish, with their diff. + if (s.state === 'ACTIVE') return FILE_TOOLS.has(name) ? undefined : start() + st.calls.delete(id) + const ok = s.state === 'DONE' + const output = str(s.tool_info?.output) ?? str(s.tool_info?.error?.message) ?? '' + if (name === 'run_command') { + start() + const command = String(args.CommandLine ?? '') + const ms = Number(s.duration_seconds) * 1000 + emit({ type: 'command_completed', itemId: id, command, summary: str(args.toolSummary) ?? command.split('\n')[0].slice(0, 70), cwd, output, exitCode: null, durationMs: Number.isFinite(ms) ? Math.round(ms) : null, status: ok ? 'completed' : 'failed' }) + return + } + if (ok && call?.file && call.old !== undefined) { + const now = readOld(call.file) + if (typeof now === 'string') { + emit({ type: 'file_change', itemId: id, changes: [{ path: call.file, kind: call.old === null ? 'add' : 'update', movePath: null, diff: unifiedDiff(call.old, now) }], status: 'completed' }) + return + } + } + start() + emit({ type: 'tool_completed', itemId: id, ...shapeOf(name, args, st.cwd), ok, output }) + } + + private onResult(st: State, proc: Proc, turn: Turn, r: Json) { + turn.lines = Math.max(0, turn.lines - 1) + if (str(r.response)?.trim()) turn.finalText = r.response.trim() + if (turn.lines > 0) return + if (turn.wrongModel || turn.failure) return this.finish(st, turn, 'failed') + if (turn.interrupted) return this.finish(st, turn, 'interrupted', 'interrupted') + if (r.status === 'ERROR') { + const said = str(r.error) ?? stderrError(proc.stderr) ?? 'Antigravity reported an error' + const reason = agyEndReason(said) + return this.finish(st, turn, 'failed', reason, `${said}${reason === 'auth' ? ` ${SIGN_IN}` : ''}`) + } + // The lead's message came after the model's last request, so it runs next, in this same turn. + if (st.steer.length) { + if (this.signedOut(st, proc)) return this.finish(st, turn, 'failed') + turn.lines++ + proc.child.stdin!.write(JSON.stringify({ event: 'user', message: { content: batch(st.steer.splice(0)) } }) + '\n') + return + } + // The list covers the whole process, so only its new entries belong to this turn. + const denied = Array.isArray(r.denied_actions) ? r.denied_actions.slice(proc.denied) : [] + proc.denied += denied.length + if (!proc.skip && denied.length) { + const what = denied.map((d: Json) => d?.display_name ?? d?.action).filter(Boolean).join(', ') + return this.finish(st, turn, 'failed', 'permission_denied', `Antigravity refused ${what || 'an action'} because the BitFrost bridge plugin is not loaded. Run: bitfrostd setup agy`) + } + this.finish(st, turn, 'completed', 'end_turn') + } + + private finish(st: State, turn: Turn, status: 'completed' | 'interrupted' | 'failed', reason?: EndReason, error?: string) { + if (st.turn !== turn) return + st.turn = null + st.carry.push(...st.steer.splice(0)) + this.dropPending(st.session, 'The turn ended.') + if (turn.wrongModel) [reason, error] = ['wrong_model', `Antigravity started ${turn.wrongModel} instead of ${st.model}; stopped it`] + else if (turn.failure) [reason, error] = [turn.failure.reason, turn.failure.error] + const partial = [...turn.texts.values()].join('').trim() + st.session.push({ type: 'turn_completed', turnId: turn.id, status, reason: reason ?? 'error', finalText: turn.finalText || partial, ...(error ? { error } : {}) }) + const proc = st.proc + // An idle process ends when its input closes; the next turn resumes the conversation. + if (proc && !st.turn && !st.dead) { + if (proc.idle) clearTimeout(proc.idle) + proc.idle = setTimeout(() => { + if (st.turn || st.proc !== proc) return + proc.stopping = true + st.proc = null + st.dying = proc.gone + proc.child.stdin?.end() + setTimeout(() => { if (!proc.exited) killGroup(proc.child, 'SIGKILL') }, STOP_GRACE_MS).unref() + }, this.opts.idleMs ?? IDLE_MS) + proc.idle.unref() + } + } + + private wrongModel(st: State, turn: Turn, ran: string) { + if (turn.wrongModel) return + turn.wrongModel = ran + this.dropPending(st.session, 'Stopped: Antigravity runs the wrong model.') + const proc = turn.proc ?? st.proc + if (proc) retire(st, proc, 'SIGINT') + } + + async bridge(token: string, body: Json): Promise { + const hook = String(body?.hook ?? '') + const input = objectOf(body?.input) + const st = this.byToken.get(token) + if (!st || st.dead) return hook === 'PreToolUse' ? deny('Unknown BitFrost task.') : null + if (st.proc) st.proc.verified = true + // Hooks run before every model request and tool call, so each one rechecks the sign-in. + const live = st.turn?.proc ?? st.proc + if (live && this.signedOut(st, live)) return hook === 'PreToolUse' ? deny(this.refusal!) : null + const turn = st.turn + if (turn && str(input.modelName) && input.modelName !== 'auto' && input.modelName !== st.model) this.wrongModel(st, turn, input.modelName) + if (hook === 'PreInvocation') { + if (!turn || turn.interrupted || turn.wrongModel || turn.failure || !st.steer.length) return null + return { injectSteps: [{ userMessage: batch(st.steer.splice(0)) }] } + } + if (hook !== 'PreToolUse') return null + if (!turn || turn.interrupted || turn.wrongModel || turn.failure) return deny('The task was stopped.') + return this.gate(st, input) + } + + private gate(st: State, input: Json): Json | Promise { + const name = String(input.toolCall?.name ?? '') + const args = objectOf(input.toolCall?.args) + if (DISALLOWED.has(name)) return deny(`BitFrost subagents cannot use ${name}: nested subagents, multi-agent workflows, and runs scheduled outside BitFrost are disabled.`) + if (name === 'ask_question') return this.ask(st, args) + if (PERMISSION_TOOLS.has(name)) return deny('BitFrost asks the user itself when an action runs. Call the tool you need directly.') + const itemId = input.stepIdx != null ? `${str(input.conversationId) ?? st.conversationId}:${input.stepIdx}` : null + const file = pathArg(args, st.cwd) + const old = FILE_TOOLS.has(name) && file ? readOld(file) : undefined + if (itemId) st.calls.set(itemId, { name, args, ...(FILE_TOOLS.has(name) && file ? { file, old } : {}) }) + if (READ_ONLY.has(name)) return allow() + if (name === 'manage_task' && /^(status|list)$/i.test(String(args.Action ?? args.action ?? ''))) return allow() + if (name === 'run_command' && readOnlyCommand(String(args.CommandLine ?? ''))) return allow() + if (FILE_TOOLS.has(name) && file && inside(file, st.cwd)) return allow() + if (!st.canAsk) return deny('This BitFrost task runs without asking, so actions that need approval are refused.') + if (st.proc && !st.proc.skip) return deny('Antigravity refuses this here because the BitFrost bridge plugin is out of date or turned off. Run: bitfrostd setup agy') + const t = toolCallOf(name, args, st.cwd, old) + const runIn = path.resolve(st.cwd, str(args.Cwd) ?? '.') + const grantKey = t.kind === 'execute' ? `${grantKeyOf(t)}\0${runIn}` : grantKeyOf(t) + if (st.grants.has(grantKey)) return allow() + const action = describeAction(t, runIn) + const said = str(args.toolSummary) + const approvalId = randomUUID() + return new Promise((resolve) => { + this.pending.set(approvalId, { kind: 'approval', session: st.session, grantKey, resolve }) + st.session.push({ type: 'approval_requested', approvalId, itemId, ...action, detail: said && !action.detail.includes(said) ? `It says: ${said}\n${action.detail}` : action.detail }) + }) + } + + // Print mode skips ask_question, so the hook asks BitFrost and denies with the answer, which the model reads. + private ask(st: State, args: Json): Json | Promise { + if (!st.canAsk) return deny('No one can answer questions in this task; use your best judgment.') + const questions: Question[] = (Array.isArray(args.questions) ? args.questions : []).map((q: Json, i: number) => ({ + id: `q${i}`, + header: String(q?.header ?? ''), + question: `${q?.question ?? ''}${q?.is_multi_select ? ' (pick one or more)' : ''}`, + options: (Array.isArray(q?.options) ? q.options : []).map((o: Json) => (typeof o === 'string' ? { label: o, description: '' } : { label: String(o?.label ?? ''), description: String(o?.description ?? '') })), + allowOther: true, + secret: false, + })) + if (!questions.length) return deny('The question was empty.') + const questionId = randomUUID() + return new Promise((resolve) => { + this.pending.set(questionId, { kind: 'question', session: st.session, questions, resolve }) + st.session.push({ type: 'question_asked', questionId, questions }) + }) + } + + private dropPending(session: Session, why: string) { + for (const [id, p] of this.pending) { + if (p.session !== session) continue + this.pending.delete(id) + p.resolve(deny(why)) + if (p.kind === 'approval') session.push({ type: 'approval_resolved', approvalId: id, decision: 'deny' }) + } + } + + pendingApprovals(session: Session): string[] { + return [...this.pending].filter(([, p]) => p.kind === 'approval' && p.session === session).map(([id]) => id) + } + + resolveApproval(session: Session, approvalId: string, decision: Decision, reason?: string): boolean { + const p = this.pending.get(approvalId) + if (!p || p.kind !== 'approval' || p.session !== session) return false + this.pending.delete(approvalId) + if (decision === 'allow_session') this.states.get(session.info.id)?.grants.add(p.grantKey) + p.resolve(decision === 'deny' ? deny(reason || 'The user denied this.') : allow()) + session.push({ type: 'approval_resolved', approvalId, decision }) + return true + } + + pendingQuestions(session: Session): string[] { + return [...this.pending].filter(([, p]) => p.kind === 'question' && p.session === session).map(([id]) => id) + } + + async answerQuestion(session: Session, questionId: string, answers: Record | null, defer: boolean): Promise { + const p = this.pending.get(questionId) + if (!p || p.kind !== 'question' || p.session !== session) return false + this.pending.delete(questionId) + session.push({ type: 'question_answered', questionId, how: defer ? 'deferred' : 'answered' }) + if (defer) { + p.resolve(deny('The lead agent will answer this. Stop now; the answer will arrive as the next message.')) + void session.stop(this, 'host', { preserveQueue: true }).catch(() => {}) + return true + } + const given = p.questions.flatMap((q) => (answers?.[q.id]?.length ? [`${q.question}\n${answers[q.id].join(', ')}`] : [])) + p.resolve(deny(given.length ? `The user answered your question.\n\n${given.join('\n\n')}` : 'No answer from the user; use your best judgment.')) + return true + } +} + + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +export const BRIDGE_SOURCE = path.join(HERE, '..', '..', 'agy-plugin') +const BRIDGE_FILES = ['plugin.json', 'hook', 'hook.mjs'] + +export const bridgeDir = (home = os.homedir()) => path.join(home, '.gemini', 'config', 'plugins', PLUGIN_NAME) + +// Hooks run from the plugin folder; a full path keeps them working wherever Antigravity runs them. +function bridgeHooks(dir: string, source: string): string { + const hooks = JSON.parse(fs.readFileSync(path.join(source, 'hooks.json'), 'utf8')) + const hook = `'${path.join(dir, 'hook').replace(/'/g, `'\\''`)}'` + return JSON.stringify(hooks, (key, value) => (key === 'command' && typeof value === 'string' ? value.replace(/^\.\/hook\b/, hook) : value), 2) + '\n' +} + +export function bridgeCurrent(dir = bridgeDir(), source = BRIDGE_SOURCE): boolean { + try { + return BRIDGE_FILES.every((f) => fs.readFileSync(path.join(dir, f), 'utf8') === fs.readFileSync(path.join(source, f), 'utf8')) + && fs.readFileSync(path.join(dir, 'hooks.json'), 'utf8') === bridgeHooks(dir, source) + } catch { + return false + } +} + +// Build the copy outside the plugins folder and move it in, so Antigravity never loads half a plugin. +export function installBridge(dir = bridgeDir(), source = BRIDGE_SOURCE) { + const next = path.join(path.dirname(path.dirname(dir)), `.${PLUGIN_NAME}.new`) + fs.rmSync(next, { recursive: true, force: true }) + fs.mkdirSync(next, { recursive: true }) + for (const f of BRIDGE_FILES) fs.copyFileSync(path.join(source, f), path.join(next, f)) + fs.chmodSync(path.join(next, 'hook'), 0o755) + fs.writeFileSync(path.join(next, 'hooks.json'), bridgeHooks(dir, source)) + fs.mkdirSync(path.dirname(dir), { recursive: true }) + fs.rmSync(dir, { recursive: true, force: true }) + fs.renameSync(next, dir) +} + +export function uninstallBridge(dir = bridgeDir(), dryRun = false): { label: string; path: string }[] { + const found = fs.existsSync(dir) ? [{ label: 'BitFrost bridge plugin in Antigravity', path: dir }] : [] + if (!dryRun) fs.rmSync(dir, { recursive: true, force: true }) + return found +} + +export function bridgeLoaded(bin: string, dir = bridgeDir(), source = BRIDGE_SOURCE, prefix: string[] = []): Promise { + if (!bridgeCurrent(dir, source)) return Promise.resolve(false) + return new Promise((resolve) => { + execFile(bin, [...prefix, 'plugin', 'list', '--json'], { timeout: 20_000, maxBuffer: 1 << 20 }, (err, stdout) => { + if (err) return resolve(false) + try { + const real = (p: string) => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } } + resolve((JSON.parse(String(stdout)) as Json[]).some((p) => p?.name === PLUGIN_NAME && p.enabled !== false && real(String(p.path ?? '')) === real(dir) && (p.hooks ?? []).includes(PLUGIN_NAME))) + } catch { + resolve(false) + } + }) + }) +} + +export function resolveAgy(env: ProviderEnv, fallback = path.join(os.homedir(), '.local', 'bin', 'agy')): string | null { + const chosen = process.env.BITFROST_AGY_BIN ?? env.config?.bin ?? env.resolveBinary('agy') + if (chosen) return chosen + try { + fs.accessSync(fallback, fs.constants.X_OK) + return fallback + } catch { + return null + } +} + +export const agyProvider: ProviderFactory = { + id: 'agy', + displayName: 'Antigravity', + vendor: 'Google', + firstClass: true, + optIn: true, + detect: (env) => resolveAgy(env), + create(env) { + const bin = resolveAgy(env) + if (!bin) return null + // The user turned Antigravity on, so keep the bridge copy current. + try { if (!bridgeCurrent()) installBridge() } catch (e) { env.log(`agy: could not install the bridge plugin: ${(e as Error).message}`) } + return new AgyProvider({ bin, log: env.log, record: env.recorder('agy'), runDir: env.runDir, socket: env.socket, bridgeLoaded: () => bridgeLoaded(bin) }) + }, + setup() { + installBridge() + console.log(`Installed the BitFrost bridge plugin in ${bridgeDir()}`) + }, + uninstall: (dryRun = false) => uninstallBridge(bridgeDir(), dryRun), +} diff --git a/plugins/bitfrost/daemon/providers/codex.ts b/plugins/bitfrost/daemon/providers/codex.ts index b395c48..07a0605 100644 --- a/plugins/bitfrost/daemon/providers/codex.ts +++ b/plugins/bitfrost/daemon/providers/codex.ts @@ -673,6 +673,7 @@ export const codexProvider: ProviderFactory = { id: 'codex', displayName: 'Codex', vendor: 'OpenAI', + firstClass: true, create(env: ProviderEnv) { const chosen = process.env.BITFROST_CODEX_BIN ?? env.config.bin ?? env.resolveBinary('codex') if (chosen) return new CodexAdapter(chosen, env.log, env.recorder('codex')) diff --git a/plugins/bitfrost/daemon/providers/crush.ts b/plugins/bitfrost/daemon/providers/crush.ts index b83349c..15cc5cc 100644 --- a/plugins/bitfrost/daemon/providers/crush.ts +++ b/plugins/bitfrost/daemon/providers/crush.ts @@ -150,21 +150,19 @@ function request(sock: string, method: string, url: string, body?: Json, timeout }) } -function killGroup(proc: ChildProcess, signal: NodeJS.Signals) { +export function killGroup(proc: ChildProcess, signal: NodeJS.Signals) { if (!proc.pid || proc.exitCode !== null || proc.signalCode !== null) return false try { process.kill(-proc.pid, signal) } catch { try { proc.kill(signal) } catch { return false } } return true } // Allow only commands that cannot change anything; Crush runs some others unasked. +// Git is left out: it runs programs named in repository config, which edits in the folder can set. const READ_ONLY = new Set(['pwd', 'ls', 'date', 'cal', 'df', 'du', 'free', 'groups', 'id', 'uname', 'uptime', 'whatis', 'whereis', 'which', 'whoami', 'type', 'echo']) -const GIT_READ_ONLY = new Set(['status', 'diff', 'log', 'show', 'blame', 'ls-files', 'rev-parse', 'describe', 'shortlog']) export function readOnlyCommand(command: string): boolean { if (!command.trim() || /[><&|;`$\\\n\r'"]/.test(command)) return false const words = command.trim().split(/\s+/) - // Git can also read any file (--no-index, or a plain diff outside a repo), so paths stay inside the folder. - if (words[0] === 'git') return GIT_READ_ONLY.has(words[1]) && !words.slice(2).some((w) => /^(-c|-O|--output|--ext-diff|--textconv|--open-files-in-pager|--exec|--no-index|--contents)/.test(w) || /^[~/]|(^|[/=:])\.\.(\/|$)/.test(w)) if (words[0] === 'date') return !words.some((w) => /^(-s|--set)/.test(w)) return READ_ONLY.has(words[0]) } diff --git a/plugins/bitfrost/daemon/providers/grok-hook.mjs b/plugins/bitfrost/daemon/providers/grok-hook.mjs new file mode 100644 index 0000000..30655e5 --- /dev/null +++ b/plugins/bitfrost/daemon/providers/grok-hook.mjs @@ -0,0 +1,38 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +import http from 'node:http' + +let input = '' +let finished = false +function deny(reason) { + if (finished) return + finished = true + process.stdout.write(JSON.stringify({ decision: 'deny', reason })) + process.exitCode = 2 +} + +process.stdin.on('error', () => deny('BitFrost could not read the tool call.')) +process.stdin.on('data', (c) => { input += c }) +process.stdin.on('end', () => { + if (!process.env.BITFROST_SOCKET || !process.env.BITFROST_GROK_TOKEN) return deny('This Grok process was not started by BitFrost.') + const req = http.request({ socketPath: process.env.BITFROST_SOCKET, path: '/providers/grok/bridge', method: 'POST', headers: { 'content-type': 'application/json', 'x-bitfrost-token': process.env.BITFROST_GROK_TOKEN } }, (res) => { + let body = '' + res.on('data', (c) => { body += c }) + res.on('error', () => deny('BitFrost did not finish answering the tool call.')) + res.on('end', () => { + if (res.statusCode !== 200) return deny(`BitFrost refused the tool call (${res.statusCode}).`) + let reply + try { reply = JSON.parse(body) } catch { return deny('BitFrost returned an unreadable answer.') } + if (!['allow', 'deny'].includes(reply?.decision)) return deny('BitFrost did not authorize the tool call.') + if (finished) return + finished = true + process.stdout.write(JSON.stringify(reply)) + }) + }) + req.on('error', () => deny('BitFrost is not reachable.')) + req.end(input) +}) diff --git a/plugins/bitfrost/daemon/providers/grok.ts b/plugins/bitfrost/daemon/providers/grok.ts new file mode 100644 index 0000000..668bd5f --- /dev/null +++ b/plugins/bitfrost/daemon/providers/grok.ts @@ -0,0 +1,274 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +// Run Grok Build over ACP in always-approve mode; a BitFrost hook gates each action. +import type { ChildProcess } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Decision, EndReason, Question } from '../events.ts' +import type { HarnessModel, ProviderEnv, ProviderFactory } from '../provider.ts' +import type { Session } from '../session.ts' +import { AcpProvider, type AcpLaunch, type AcpOptions, type AcpState, type AcpTurn } from './acp.ts' +import { blankTool, describeAction, grantKeyOf, inside, objectOf } from './acp-shapes.ts' + +type Json = any + +const READ_TOOLS = new Set(['readfile', 'listdir', 'grep', 'glob', 'websearch', 'todowrite', 'skill', 'skills', 'getcommandorsubagentoutput', 'killcommandorsubagent', 'getterminalcommandoutput', 'killterminalcommand', 'gettaskoutput', 'killtask', 'wait', 'waittasks', 'taskoutput', 'bashoutput', 'killshell']) +const FILE_TOOLS = new Set(['write', 'writefile', 'searchreplace', 'edit', 'multiedit', 'hashlineedit']) +const READ_COMMANDS = new Set(['ls', 'cat', 'pwd', 'whoami', 'uptime', 'ps', 'head', 'tail', 'wc', 'tr', 'cut', 'grep']) +const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '') +const deny = (reason: string) => ({ decision: 'deny', reason }) +const allow = () => ({ decision: 'allow' }) +const count = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0 + +type HookPending = { session: Session; grantKey: string; resolve: (reply: Json) => void } +type NativeQuestion = { session: Session; rpcId: string | number; questions: Question[] } +type Usage = { seen: Set; total?: Json; stop?: string } + +export function grokReadOnlyCommand(command: string): boolean { + if (!command.trim() || /[><`$\\\n\r'"(){}!#]/.test(command)) return false + return command.split(/&&|\|\||[;|]/).every((segment) => { + if (!segment.trim() || segment.includes('&')) return false + const [cmd, ...args] = segment.trim().split(/\s+/) + if (cmd === 'date' || cmd === 'hostname') return args.length === 0 + if (cmd === 'sort') return args.every((a) => !a.startsWith('-') || /^-[bdfghMnrsuVz]+$/.test(a)) + if (cmd === 'rg') return !args.some((a) => /^--(?:pre|hostname-bin)/.test(a)) + // A second file operand is where uniq writes. + if (cmd === 'uniq') return !args.includes('--') && args.filter((a) => a === '-' || !a.startsWith('-')).length <= 1 + // Git and kubectl can execute programs from local configuration. + return READ_COMMANDS.has(cmd) + }) +} + +function canonical(value: Json): Json { + if (Array.isArray(value)) return value.map(canonical) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) +} + +export function grokEndReason(stop: string, error: string | null): EndReason { + const text = `${stop} ${error ?? ''}`.toLowerCase() + if (/not authenticated|unauth|authentication|\bauth(?:_required|_failed)?\b|\b401\b|\b403\b|login required/.test(text)) return 'auth' + if (/quota|credit|billing|payment|usage.limit|budget.exceeded/.test(text)) return 'quota_exhausted' + if (/rate.limit|\b429\b/.test(text)) return 'rate_limited' + if (/max.tokens|token.limit|context.(?:window|length|limit)/.test(text)) return 'max_tokens' + if (/max.(?:turn.)?requests|request.limit/.test(text)) return 'max_requests' + if (/refus|content.filter|safety/.test(text)) return 'refusal' + return !error && stop === 'end_turn' ? 'end_turn' : 'error' +} + +export class GrokProvider extends AcpProvider { + readonly watch: string[] + private token: string + private relocate?: () => string | null + private hooks = new Map() + private questions = new Map() + private usage = new WeakMap() + + constructor(launch: AcpLaunch, opts: AcpOptions & { token: string; watch?: string[]; relocate?: () => string | null }) { + super({ id: 'grok', displayName: 'Grok Build', vendor: 'xAI', binary: 'grok', args: [], loginCommand: 'grok login', optIn: true, gates: 'all', note: '' }, launch, opts) + this.token = opts.token + this.watch = opts.watch ?? [launch.bin] + this.relocate = opts.relocate + } + + moved(): boolean { return !!this.relocate && this.relocate() !== this.location } + + async listModels(): Promise { + await this.start() + const state = this.initialized?._meta?.modelState + return (state?.availableModels ?? []).filter((m: Json) => /^grok-[\w.-]+$/.test(m.modelId)).map((m: Json) => { + const meta = m._meta ?? {} + const context = count(meta.totalContextTokens) + const levels = meta.supportsReasoningEffort ? meta.reasoningEfforts ?? [] : [] + return { + harness: this.id, provider: 'xAI', model: m.modelId, displayName: m.name ?? m.modelId, + description: [m.description, context ? `${context >= 1e6 ? `${context / 1e6}M` : `${Math.round(context / 1000)}k`} context.` : ''].filter(Boolean).join(' '), + efforts: levels.map((e: Json) => String(e.value)), + defaultEffort: levels.find((e: Json) => e.default)?.value ?? meta.reasoningEffort ?? null, + isDefault: m.modelId === state.currentModelId, + } + }) + } + + protected sessionParams(cwd: string): Json { return { cwd, mcpServers: [], _meta: { yoloMode: true } } } + + protected explain(e: Json): Error { + const data = e?.data + const detail = typeof data === 'string' ? data : [data?.detail, data?.message, data?.status, data?.code, data?.error?.message, data?.error?.code].filter((v) => typeof v === 'string' || typeof v === 'number').join(' ') + const message = [e?.message, detail, [401, 403, 429].includes(e?.code) ? e.code : null].filter(Boolean).join(' ').slice(0, 2000) + if (e?.code === -32000 || grokEndReason('', message) === 'auth') return new Error(`Grok Build could not authenticate: ${message}. Run grok login in a terminal, then try again.`) + return message !== e?.message ? new Error(message) : e + } + + protected endReason(stop: string, error: string | null): EndReason { return grokEndReason(stop, error) } + + protected onNotification(method: string, p: Json) { + if (method !== '_x.ai/session_notification') return + const st = this.byAcpId.get(p.sessionId) + const u = p.update ?? {} + if (!st) return + if (u.sessionUpdate === 'model_changed') return this.checkModel(st, [{ category: 'model', type: 'select', currentValue: u.model_id }]) + const turn = st.turn + if (!turn) return + const usage = this.usageOf(turn) + if (u.sessionUpdate === 'turn_completed') { + usage.total = u.usage + usage.stop = u.stop_reason + } + if (u.sessionUpdate !== 'response_completed' || !u.usage) return + const id = String(u.request_id ?? u.response_id ?? u.signature ?? p._meta?.eventId ?? usage.seen.size) + if (usage.seen.has(id)) return + usage.seen.add(id) + const cached = count(u.usage.cache_read_input_tokens) + st.session.push({ type: 'usage', requestId: `${turn.id}:${createHash('sha256').update(id).digest('hex')}`, inputTokens: count(u.usage.input_tokens) + cached + count(u.usage.cache_creation_input_tokens), outputTokens: count(u.usage.output_tokens), cachedInputTokens: cached }) + } + + protected onUpdate(p: Json) { + if (p.update?.sessionUpdate === 'usage_update') return + const u = p.update + const meta = u?._meta?.['x.ai/tool'] + if (u && meta) p = { ...p, update: { ...u, name: u.name ?? meta.name, kind: u.kind ?? (meta.kind === 'write' ? 'edit' : meta.kind) } } + super.onUpdate(p) + } + + private usageOf(turn: AcpTurn): Usage { + let usage = this.usage.get(turn) + if (!usage) this.usage.set(turn, usage = { seen: new Set() }) + return usage + } + + protected endUsage(st: AcpState, turn: AcpTurn, r: Json) { + const usage = this.usageOf(turn) + const total = r._meta?.usage ?? usage.total + if (usage.seen.size || !total) return + st.session.push({ type: 'usage', requestId: `${turn.id}:total`, inputTokens: count(total.inputTokens), outputTokens: count(total.outputTokens), cachedInputTokens: count(total.cachedReadTokens), + billing: { inputTokens: count(total.inputTokens), outputTokens: count(total.outputTokens), cachedInputTokens: count(total.cachedReadTokens), requests: count(total.modelCalls) || 1 } }) + } + + protected endTurn(st: AcpState, turn: AcpTurn, r: Json, error: string | null) { + if (st.turn !== turn) return + if (r._meta?.modelId) this.checkModel(st, [{ category: 'model', type: 'select', currentValue: r._meta.modelId }]) + this.dropPending(st.session) + const stop = r.stopReason ?? this.usageOf(turn).stop + if (grokEndReason(stop, error) === 'auth' && !error?.includes('grok login')) error = `Grok Build could not authenticate. Run grok login in a terminal, then try again. ${error ?? ''}`.trim() + super.endTurn(st, turn, { ...r, stopReason: stop }, error) + } + + protected onRequest(msg: Json) { + if (msg.method !== '_x.ai/ask_user_question') return super.onRequest(msg) + const st = this.byAcpId.get(msg.params?.sessionId) + const input = msg.params?.questions + if (!st?.turn || st.turn.cancelled || !st.canAsk || !Array.isArray(input) || !input.length) return this.reply(msg.id, { outcome: 'skip_interview' }) + const questions: Question[] = input.map((q: Json, i: number) => ({ id: `q${i}`, header: q.header ?? `Question ${i + 1}`, question: String(q.question ?? ''), options: (q.options ?? []).map((o: Json) => ({ label: String(o.label), description: String(o.description ?? '') })), allowOther: true, secret: false })) + const questionId = randomUUID() + this.questions.set(questionId, { session: st.session, rpcId: msg.id, questions }) + st.session.push({ type: 'question_asked', questionId, questions }) + } + + async bridge(token: string, payload: Json): Promise { + if (token !== this.token) return deny('Unknown BitFrost process.') + const st = this.byAcpId.get(payload?.sessionId) + if (!st?.turn || st.dead || st.turn.cancelled) return deny('This BitFrost task is stopped or unknown.') + if (payload.hookEventName !== 'pre_tool_use' || payload.toolInputTruncated) return deny('BitFrost could not verify the complete tool call.') + const name = String(payload.toolName ?? '') + const tool = normalize(name) + const input = objectOf(payload.toolInput) + if (READ_TOOLS.has(tool)) return allow() + if (/agent|workflow|schedul|cron|loop|goal|task/.test(tool)) return deny(`BitFrost subagents cannot use ${name}: nested subagents and scheduled runs are disabled.`) + if (tool === 'askuserquestion') return st.canAsk ? allow() : deny('This BitFrost task cannot ask questions.') + const file = input.file_path ?? input.path ?? input.filePath + const cwd = typeof payload.cwd === 'string' ? path.resolve(st.cwd, payload.cwd) : st.cwd + if (FILE_TOOLS.has(tool) && typeof file === 'string' && inside(path.resolve(cwd, file), st.cwd) && (!input.edits || Array.isArray(input.edits) && input.edits.every((e: Json) => !e.file_path && !e.path && !e.filePath))) return allow() + const command = input.command ?? input.cmd + const kind = tool === 'runterminalcommand' || tool === 'bash' ? 'execute' : FILE_TOOLS.has(tool) ? 'edit' : tool === 'webfetch' ? 'fetch' : 'other' + if (kind === 'execute' && typeof command === 'string' && grokReadOnlyCommand(command)) return allow() + if (!st.canAsk) return deny('This BitFrost task runs without asking, so actions that need approval are refused.') + const t = { ...blankTool(), kind, name, title: name, rawInput: canonical({ ...input, ...(typeof file === 'string' ? { file_path: path.resolve(cwd, file) } : {}) }) } + const grantKey = kind === 'execute' ? `${grantKeyOf(t)}\0${cwd}` : grantKeyOf(t) + if (st.grants.has(grantKey)) return allow() + const approvalId = randomUUID() + const action = describeAction(t, cwd) + return new Promise((resolve) => { + this.hooks.set(approvalId, { session: st.session, resolve, grantKey }) + st.session.push({ type: 'approval_requested', approvalId, itemId: payload.toolUseId ?? null, ...action, title: action.title.replaceAll('`', '') }) + }) + } + + pendingApprovals(session: Session): string[] { return [...super.pendingApprovals(session), ...[...this.hooks].filter(([, p]) => p.session === session).map(([id]) => id)] } + + resolveApproval(session: Session, id: string, decision: Decision, reason?: string): boolean { + const p = this.hooks.get(id) + if (!p) return super.resolveApproval(session, id, decision, reason) + if (p.session !== session) return false + this.hooks.delete(id) + if (decision === 'allow_session') this.stateOf(session)?.grants.add(p.grantKey) + p.resolve(decision === 'deny' ? deny(reason || 'The user denied permission.') : allow()) + session.push({ type: 'approval_resolved', approvalId: id, decision }) + return true + } + + pendingQuestions(session: Session): string[] { return [...super.pendingQuestions(session), ...[...this.questions].filter(([, p]) => p.session === session).map(([id]) => id)] } + + async answerQuestion(session: Session, id: string, answers: Record | null, defer: boolean): Promise { + const p = this.questions.get(id) + if (!p) return super.answerQuestion(session, id, answers, defer) + if (p.session !== session) return false + this.questions.delete(id) + const given = Object.fromEntries(p.questions.map((q) => [q.question, answers?.[q.id]?.length ? answers[q.id] : ['No answer from the user; use your best judgment.']])) + this.reply(p.rpcId, defer ? { outcome: 'skip_interview' } : { outcome: 'accepted', answers: given }) + session.push({ type: 'question_answered', questionId: id, how: defer ? 'deferred' : 'answered' }) + if (defer) void session.stop(this, 'host', { preserveQueue: true }).catch(() => {}) + return true + } + + protected dropPending(session: Session) { + for (const [id, p] of this.hooks) if (p.session === session) this.resolveApproval(session, id, 'deny', 'The task was stopped.') + for (const [id, p] of this.questions) if (p.session === session) { + this.questions.delete(id) + this.reply(p.rpcId, { outcome: 'skip_interview' }) + } + super.dropPending(session) + } + + protected onExit(proc: ChildProcess, why: string) { + if (this.proc === proc) for (const st of this.states.values()) this.dropPending(st.session) + super.onExit(proc, why) + } + + dispose() { + for (const st of this.states.values()) this.dropPending(st.session) + super.dispose() + } +} + +function binary(env: ProviderEnv): string | null { + const bin = process.env.BITFROST_GROK_BIN ?? env.config.bin ?? env.resolveBinary('grok') ?? path.join(os.homedir(), '.grok', 'bin', 'grok') + try { + fs.accessSync(bin, fs.constants.X_OK) + return fs.realpathSync(bin) + } catch { return null } +} + +export const grokProvider: ProviderFactory = { + id: 'grok', displayName: 'Grok Build', vendor: 'xAI', optIn: true, firstClass: true, + detect: binary, + create(env) { + const bin = binary(env) + if (!bin) return null + const token = randomUUID() + const dir = path.join(env.runDir, 'grok-plugin') + fs.mkdirSync(path.join(dir, 'hooks'), { recursive: true, mode: 0o700 }) + // A process plugin leaves the user's Grok and Claude settings alone. + fs.writeFileSync(path.join(dir, 'hooks', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'exec "$BITFROST_NODE" "$BITFROST_GROK_HOOK"', timeout: 7 * 24 * 3600 }] }] } }), { mode: 0o600 }) + const hook = fileURLToPath(new URL('./grok-hook.mjs', import.meta.url)) + return new GrokProvider({ bin, args: ['agent', '--no-leader', '--plugin-dir', dir, 'stdio'], env: { GROK_DISABLE_AUTOUPDATER: '1', BITFROST_NODE: process.execPath, BITFROST_GROK_HOOK: hook, BITFROST_SOCKET: env.socket, BITFROST_GROK_TOKEN: token } }, + { log: env.log, record: env.recorder('grok'), token, watch: [bin, path.join(os.homedir(), '.grok', 'bin')], relocate: () => binary(env) }) + }, +} diff --git a/plugins/bitfrost/daemon/providers/index.ts b/plugins/bitfrost/daemon/providers/index.ts index e2729bb..d7e0a6c 100644 --- a/plugins/bitfrost/daemon/providers/index.ts +++ b/plugins/bitfrost/daemon/providers/index.ts @@ -9,5 +9,8 @@ import { codexProvider } from './codex.ts' import { zcodeProvider } from './zcode.ts' import { geminiProvider, ompProvider, opencodeProvider } from './acp-agents.ts' import { crushProvider } from './crush.ts' +import { grokProvider } from './grok.ts' +import { agyProvider } from './agy.ts' -export const PROVIDERS: ProviderFactory[] = [codexProvider, zcodeProvider, opencodeProvider, ompProvider, geminiProvider, crushProvider] +// Dedicated harnesses first, then apps serving many companies, each group alphabetical. +export const PROVIDERS: ProviderFactory[] = [agyProvider, codexProvider, grokProvider, zcodeProvider, crushProvider, geminiProvider, ompProvider, opencodeProvider] diff --git a/plugins/bitfrost/daemon/providers/zcode.ts b/plugins/bitfrost/daemon/providers/zcode.ts index e355d3f..f99f066 100644 --- a/plugins/bitfrost/daemon/providers/zcode.ts +++ b/plugins/bitfrost/daemon/providers/zcode.ts @@ -624,6 +624,7 @@ export const zcodeProvider: ProviderFactory = { id: 'zcode', displayName: 'ZCode', vendor: 'Z.ai', + firstClass: true, detect: (env) => resolveZCode(env)?.location ?? null, create(env) { const installation = resolveZCode(env) diff --git a/plugins/bitfrost/daemon/registry.ts b/plugins/bitfrost/daemon/registry.ts index 8cadaae..2a88dc9 100644 --- a/plugins/bitfrost/daemon/registry.ts +++ b/plugins/bitfrost/daemon/registry.ts @@ -17,20 +17,28 @@ export type AgentDef = HarnessModel & { const vendorKey = (v: string) => v.toLowerCase().replace(/[^a-z0-9]/g, '') -export type Offer = null | 'anthropic' | 'disabled' | 'not_listed' +export type Offer = null | 'disabled' | 'not_listed' -// Exclude Anthropic only. Apps serving many companies need a model list to keep host context small. +export const isAnthropic = (m: { provider: string }) => vendorKey(m.provider).startsWith('anthropic') + +// A Claude model in a models list needs a pattern that names it; a bare wildcard is not enough. +export const turnsOn = (pattern: string, m: { provider: string; model: string }) => + globMatch(pattern, m.model) && (!isAnthropic(m) || pattern === m.model || /claude|anthropic/i.test(pattern)) + +// Apps serving many companies need a model list to keep host context small. Anthropic models +// stay off until named too: in models for those apps, in anthropic for an app's own list. // Returns why a model isn't offered, or null when it is. -export function whyNot(m: HarnessModel, ownVendors: Map, allow: Map, deny: Map = new Map()): Offer { - if (vendorKey(m.provider).startsWith('anthropic')) return 'anthropic' - if (deny.get(m.harness)?.some((pattern) => globMatch(pattern, m.model))) return 'disabled' +export function whyNot(m: HarnessModel, ownVendors: Map, allow: Map, deny: Map = new Map(), anthropic: Map = new Map()): Offer { + const named = (list: string[] | undefined) => !!list?.some((pattern) => globMatch(pattern, m.model)) + if (named(deny.get(m.harness))) return 'disabled' const list = allow.get(m.harness) - if (!list) return ownVendors.has(m.harness) ? null : 'not_listed' - return list.some((pattern) => globMatch(pattern, m.model)) ? null : 'not_listed' + if (list) return list.some((pattern) => turnsOn(pattern, m)) ? null : 'not_listed' + if (!ownVendors.has(m.harness)) return 'not_listed' + return !isAnthropic(m) || named(anthropic.get(m.harness)) ? null : 'not_listed' } -export function selectModels(found: HarnessModel[], ownVendors: Map, allow: Map, deny?: Map): HarnessModel[] { - return found.filter((m) => whyNot(m, ownVendors, allow, deny) === null) +export function selectModels(found: HarnessModel[], ownVendors: Map, allow: Map, deny?: Map, anthropic?: Map): HarnessModel[] { + return found.filter((m) => whyNot(m, ownVendors, allow, deny, anthropic) === null) } export function globMatch(pattern: string, value: string): boolean { @@ -63,6 +71,7 @@ function newer(a: string | null, b: string | null): boolean { // Build versioned aliases; bare names select the newest model in each family. A model run by its // company's own app claims its name and aliases before the same model through another app. +// Claude models get no aliases, since names like sonnet already mean Claude Code's own models. export function buildAgents(models: HarnessModel[], ownVendors: Map = new Map()): AgentDef[] { const facts = new Map(models.map((m) => [m, familyOf(m.displayName)])) const own = (m: HarnessModel) => { @@ -76,6 +85,7 @@ export function buildAgents(models: HarnessModel[], ownVendors: Map() for (const m of ranked) { + if (isAnthropic(m)) continue for (const bare of bareNames(m)) { const cur = newest.get(bare) if (!cur || newer(facts.get(m)!.version, facts.get(cur)!.version)) newest.set(bare, m) @@ -88,7 +98,7 @@ export function buildAgents(models: HarnessModel[], ownVendors: Map() const claim = (alias: string) => { if (!claimed.has(alias)) { claimed.add(alias); aliases.add(alias) } } - if (version) { + if (version && !isAnthropic(m)) { claim(`${family.replace(/\s+/g, '')}${version}`) claim(`${family} ${version}`) if (vendor) claim(`${vendor}${version} ${family}`) diff --git a/plugins/bitfrost/daemon/selftest.ts b/plugins/bitfrost/daemon/selftest.ts index 03142d7..440948c 100644 --- a/plugins/bitfrost/daemon/selftest.ts +++ b/plugins/bitfrost/daemon/selftest.ts @@ -159,7 +159,8 @@ async function runOne(factory: ProviderFactory, config: Config, agentName?: stri if (Array.isArray(listed)) allow.set(provider.id, listed) // Treat an explicit model choice as the required model list for a multi-company app. else if (agentName && !provider.vendor) allow.set(provider.id, [agentName]) - const agents = buildAgents(selectModels(models, ownVendors, allow), ownVendors) + const anthropic = new Map([[provider.id, [...(config.providers[provider.id]?.anthropic ?? []), ...(agentName ? [agentName] : [])].map(String)]]) + const agents = buildAgents(selectModels(models, ownVendors, allow, undefined, anthropic), ownVendors) const agent = pickAgent(agents, agentName) if (!agent) { if (agentName) { diff --git a/plugins/bitfrost/daemon/store.ts b/plugins/bitfrost/daemon/store.ts index 74a2ef6..e61b650 100644 --- a/plugins/bitfrost/daemon/store.ts +++ b/plugins/bitfrost/daemon/store.ts @@ -431,11 +431,19 @@ export class Store { }) } - processes(): Row[] { return this.read(() => this.stmt('SELECT pgid,process_identity FROM turns WHERE pgid IS NOT NULL AND process_identity IS NOT NULL').all()) } + // Sessions also name a process that outlives its turns. Only owned ones count, as old ones may come from a copied store. + processes(): Row[] { + return this.read(() => this.stmt(`SELECT pgid,process_identity FROM turns WHERE pgid IS NOT NULL AND process_identity IS NOT NULL + UNION ALL SELECT json_extract(native_ref,'$.process.pgid'),json_extract(native_ref,'$.process') FROM sessions + WHERE json_extract(native_ref,'$.process.owner') IS NOT NULL`).all()) + } - // Ended turns were swept once at startup, so later startups skip them. + // Ended turns and closed sessions were swept once at startup, so later startups skip them. forgetProcesses() { - try { this.stmt('UPDATE turns SET pgid=NULL,process_identity=NULL WHERE ended_at IS NOT NULL AND pgid IS NOT NULL').run() } catch (e) { this.degrade(e) } + try { + this.stmt('UPDATE turns SET pgid=NULL,process_identity=NULL WHERE ended_at IS NOT NULL AND pgid IS NOT NULL').run() + this.stmt(`UPDATE sessions SET native_ref=json_remove(native_ref,'$.process') WHERE closed_at IS NOT NULL AND json_extract(native_ref,'$.process') IS NOT NULL`).run() + } catch (e) { this.degrade(e) } } openSessions(): Row[] { return this.read(() => this.stmt('SELECT id FROM sessions WHERE closed_at IS NULL').all()) } diff --git a/plugins/bitfrost/daemon/test/agy.spec.ts b/plugins/bitfrost/daemon/test/agy.spec.ts new file mode 100644 index 0000000..518cdba --- /dev/null +++ b/plugins/bitfrost/daemon/test/agy.spec.ts @@ -0,0 +1,663 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +// Tests for AgyProvider. fake-agy.mjs stands in for the Antigravity CLI, playing either +// a real agy 1.3.5 run from fixtures/agy/ or scripted steps. The bridge plugin is +// installed into a temporary home and its hook runs for real against a small bridge. +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn as nodeSpawn, spawnSync } from 'node:child_process' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { AgyProvider, agyEndReason, agyProvider, authMethodOf, authRefusal, bridgeCurrent, bridgeDir, bridgeLoaded, foldModels, installBridge, resolveAgy, uninstallBridge } from '../providers/agy.ts' +import { Session } from '../session.ts' + +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const FAKE = path.join(HERE, 'fake-agy.mjs') +const FIXTURES = path.join(HERE, 'fixtures', 'agy') +const MODEL = 'gemini-3.8-flash' + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +async function until(desc: string, ready: () => boolean) { + const deadline = Date.now() + 10000 + while (!ready()) { + if (Date.now() > deadline) throw new Error(`timed out waiting for ${desc}`) + await sleep(10) + } +} + +type Run = { provider: AgyProvider; dir: string; cwd: string; home: string; log: () => any[]; stop: () => Promise; logs: string[] } + +async function fake(t: any, scenario: any, { bridge = true, loaded, authWaitMs }: { bridge?: boolean; loaded?: boolean; authWaitMs?: number } = {}): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bfagy-')) + const home = path.join(dir, 'home') + const cwd = path.join(dir, 'work') + fs.mkdirSync(cwd, { recursive: true }) + const pluginDir = bridgeDir(home) + if (bridge) installBridge(pluginDir) + const scenarioFile = path.join(dir, 'scenario.json') + const logFile = path.join(dir, 'log.jsonl') + fs.writeFileSync(scenarioFile, JSON.stringify({ models: path.join(FIXTURES, 'models.txt'), ...scenario })) + process.env.BITFROST_AGY_SCENARIO = scenarioFile + process.env.BITFROST_AGY_LOG = logFile + process.env.BITFROST_AGY_PLUGIN_DIR = pluginDir + const socket = path.join(dir, 'bridge.sock') + const logs: string[] = [] + const provider = new AgyProvider({ bin: process.execPath, args: [FAKE], log: (m) => logs.push(m), runDir: path.join(dir, 'run'), socket, bridgeLoaded: async () => loaded ?? bridge, authWaitMs }) + const server = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => (body += c)) + req.on('end', async () => { + const reply = await provider.bridge(String(req.headers['x-bitfrost-token'] ?? ''), JSON.parse(body)) + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(reply ? JSON.stringify(reply) : '') + }) + }) + await new Promise((resolve) => server.listen(socket, resolve)) + const log = () => (fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l)) : []) + const stop = async () => { + provider.dispose() + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + await sleep(50) + fs.rmSync(dir, { recursive: true, force: true }) + } + t.after(stop) + return { provider, dir, cwd, home, log, stop, logs } +} + +async function spawn(run: Run, { effort = 'low', canAskUser = true, prompt = 'Do the task.', developerInstructions }: { effort?: string; canAskUser?: boolean; prompt?: string; developerInstructions?: string } = {}): Promise { + const session = new Session({ id: '', harness: 'agy', agent: 'test', model: MODEL, cwd: run.cwd, state: 'idle' }) + await run.provider.spawnSession(session, { model: MODEL, effort, cwd: run.cwd, prompt, canAskUser, developerInstructions }) + return session +} + +const types = (s: Session) => s.events.filter((e) => !['user_input', 'input_consumed'].includes(e.type)).map((e) => e.type) +const all = (s: Session, type: string): any[] => s.events.filter((e) => e.type === type) +const find = (s: Session, type: string): any => s.events.find((e) => e.type === type) +const done = (s: Session, n = 1) => until(`${n} finished turn(s)`, () => all(s, 'turn_completed').length >= n) +const asked = (s: Session, type: string, n = 1) => until(`${type} ${n}`, () => all(s, type).length >= n).then(() => all(s, type)[n - 1]) +const spawned = (run: Run) => run.log().filter((e) => e.args) +const hookOut = (run: Run, hook: string) => run.log().filter((e) => e.hook === hook).map((e) => (e.out ? JSON.parse(e.out) : null)) + +test('effort variants fold into one model, with each maker named and the list kept for a few minutes', async (t) => { + const run = await fake(t, {}) + const models = await run.provider.listModels() + assert.deepStrictEqual(models.map((m) => [m.model, m.provider, m.displayName, m.efforts, m.defaultEffort, m.isDefault]), [ + ['gemini-3.8-flash', 'Google', 'Gemini 3.8 Flash', ['low', 'medium', 'high'], 'medium', true], + ['claude-sonnet-4-6', 'Anthropic', 'Claude Sonnet 4.6 (Thinking)', [], null, false], + ['claude-opus-4-6-thinking', 'Anthropic', 'Claude Opus 4.6 (Thinking)', [], null, false], + ['gpt-oss-120b', 'OpenAI', 'GPT-OSS 120B', ['medium'], 'medium', false], + ]) + await run.provider.listModels() + const again = new AgyProvider({ bin: process.execPath, args: [FAKE], log: () => {}, runDir: path.join(run.dir, 'run'), socket: '' }) + assert.strictEqual((await again.listModels()).length, 4) + assert.strictEqual(run.log().filter((e) => e.models).length, 1) + await again.listModels(true) + assert.strictEqual(run.log().filter((e) => e.models).length, 2) + + // The chosen effort picks the model id Antigravity runs. + const session = await spawn(run, { effort: 'high' }) + await done(session) + assert.strictEqual(spawned(run)[0].args[spawned(run)[0].args.indexOf('--model') + 1], 'gemini-3.8-flash-high') + assert.strictEqual(session.nativeRef.model, 'gemini-3.8-flash-high') +}) + +test('a listing that fails asks the user to sign in', async (t) => { + const run = await fake(t, { modelsError: 'Please sign in to view available models. Launch the CLI without arguments to sign in.' }) + await assert.rejects(run.provider.listModels(), /not signed in\. Sign Antigravity in with an API key or a business account/) + assert.deepStrictEqual(foldModels('Fetching available models...\n').models, []) +}) + +test('the sign-in method comes from the log lines, and only known API key and business sign-ins run', () => { + const line = (method: string) => `I1016 13:35:22.977622 1 server_oauth.go:204] applyAuthResult: email=test@example.com, authMethod=${method}, quotaProject=` + assert.strictEqual(authMethodOf(line('Consumer')), 'consumer') + assert.strictEqual(authMethodOf(line('gemini_api_key')), 'gemini_api_key') + assert.strictEqual(authMethodOf(line('business_licensed_enterprise')), 'business_licensed_enterprise') + assert.strictEqual(authMethodOf(line('')), '') + assert.strictEqual(authMethodOf('I1016 13:35:22.977622 1 server_oauth.go:204] applyAuthResult: email=test@example.com, authMethod=cons'), null) + assert.strictEqual(authMethodOf('the log never said'), null) + assert.match(authRefusal('consumer')!, /personal Google account/) + assert.match(authRefusal('personal_workspace')!, /personal Google account/) + assert.match(authRefusal('')!, /couldn't tell how Antigravity is signed in/) + assert.match(authRefusal(null)!, /couldn't tell how Antigravity is signed in/) + assert.strictEqual(authRefusal('gemini_api_key'), null) + assert.strictEqual(authRefusal('business_licensed_enterprise'), null) + for (const method of ['google_one', 'free_tier', 'individual', 'gca_free']) assert.match(authRefusal(method)!, /doesn't recognize/, method) + // A later refused sign-in wins over an earlier good one. + assert.strictEqual(authMethodOf(`${line('gemini_api_key')}\n${line('consumer')}\n`), 'consumer') + assert.strictEqual(authMethodOf(`${line('consumer')}\n${line('gemini_api_key')}\n`), 'consumer') +}) + +test('a personal Google account is refused before any prompt is sent', async (t) => { + const run = await fake(t, { authMethod: 'consumer', authDelayMs: 100, turns: [{ steps: [{ text: 'Should not run.' }] }] }) + const session = await spawn(run) + await done(session) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['failed', 'auth']) + assert.match(end.error, /personal Google account/) + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined), []) + const pid = spawned(run)[0].pid + await until('the process to be killed', () => { + try { process.kill(pid, 0); return false } catch { return true } + }) + assert.deepStrictEqual(fs.readdirSync(path.join(run.dir, 'run', 'agy-auth')), []) +}) + +test('a run that never says how it signed in is refused', async (t) => { + const run = await fake(t, { authMethod: null, turns: [{ steps: [{ text: 'Should not run.' }] }] }, { authWaitMs: 500 }) + const session = await spawn(run) + await done(session) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['failed', 'auth']) + assert.match(end.error, /couldn't tell how Antigravity is signed in/) + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined), []) +}) + +test('api key and business sign-ins run as normal', async (t) => { + for (const authMethod of ['gemini_api_key', 'business_licensed_enterprise']) { + const run = await fake(t, { authMethod, turns: [{ steps: [{ text: 'Done.' }] }] }) + const session = await spawn(run) + await done(session) + assert.deepStrictEqual([find(session, 'turn_completed').status, find(session, 'turn_completed').finalText], ['completed', 'Done.']) + } +}) + +test('a consumer sign-in blocks the model list and clears the cache', async (t) => { + const run = await fake(t, { authMethod: 'consumer' }) + const cache = path.join(run.dir, 'run', 'agy-models.json') + fs.mkdirSync(path.dirname(cache), { recursive: true }) + // Fresh, but from before the sign-in check, so it is fetched again. + fs.writeFileSync(cache, JSON.stringify({ at: Date.now(), models: [], variants: {} })) + await assert.rejects(run.provider.listModels(), (e: any) => e.blocked === true && /personal Google account/.test(e.message)) + assert.ok(!fs.existsSync(cache)) + assert.strictEqual(run.log().filter((e) => e.models).length, 1) +}) + +test('a failed listing on a personal sign-in still drops the cached models', async (t) => { + const run = await fake(t, { authMethod: 'consumer', modelsError: 'error: network unreachable' }) + const cache = path.join(run.dir, 'run', 'agy-models.json') + fs.mkdirSync(path.dirname(cache), { recursive: true }) + fs.writeFileSync(cache, JSON.stringify({ at: Date.now(), auth: 'gemini_api_key', models: [{ model: 'gemini-3.8-flash' }], variants: {} })) + await assert.rejects(run.provider.listModels(true), (e: any) => e.blocked === true && /personal Google account/.test(e.message)) + assert.ok(!fs.existsSync(cache)) + await assert.rejects(run.provider.listModels(), (e: any) => e.blocked === true) +}) + +test('the run log with the account email is removed once the run ends', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ text: 'Done.' }] }] }) + const session = await spawn(run) + await done(session) + run.provider.disposeSession(session) + await until('the log to go', () => fs.readdirSync(path.join(run.dir, 'run', 'agy-auth')).length === 0) +}) + +test('a run that signs in again with a personal account is stopped before its next prompt', async (t) => { + const run = await fake(t, { reauth: 'consumer', turns: [{ steps: [{ text: 'First.' }] }, { steps: [{ text: 'Should not run.' }] }] }) + const session = await spawn(run) + await done(session) + assert.strictEqual(find(session, 'turn_completed').status, 'completed') + await until('the second sign-in', () => run.log().some((e) => e.reauth)) + await run.provider.sendInput(session, 'Next.') + await done(session, 2) + const end = all(session, 'turn_completed')[1] + assert.deepStrictEqual([end.status, end.reason], ['failed', 'auth']) + assert.match(end.error, /personal Google account/) + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined).map((e) => e.input), ['Do the task.']) +}) + +test('a real run replays with approvals, a denial and its reason, diffs, and usage once per request', async (t) => { + const fixture = path.join(FIXTURES, 'real-basic.jsonl') + const run = await fake(t, { turns: [{ fixture, part: 0, writes: { 8: ['notes.txt', 'hi\n'], 10: ['notes.txt', 'bye\n'] } }, { fixture, part: 1 }] }) + fs.writeFileSync(path.join(run.cwd, 'f.txt'), 'a\nb\n') + const session = await spawn(run, { developerInstructions: 'You are a delegated subagent.' }) + for (let n = 1; n <= 3; n++) { + const a = await asked(session, 'approval_requested', n) + if (a.title.includes('b.txt')) run.provider.resolveApproval(session, a.approvalId, 'deny', 'Not b.txt; use c.txt instead') + else run.provider.resolveApproval(session, a.approvalId, 'allow') + } + await done(session) + assert.deepStrictEqual(types(session), [ + 'turn_started', + 'usage', 'command_started', 'approval_requested', 'approval_resolved', 'command_completed', + 'usage', 'command_started', 'approval_requested', 'approval_resolved', 'command_completed', + 'usage', 'command_started', 'approval_requested', 'approval_resolved', 'command_completed', + 'usage', 'file_change', + 'usage', 'file_change', + 'usage', 'tool_started', 'tool_completed', + 'text', 'usage', + 'turn_completed', + ]) + const approvals = all(session, 'approval_requested') + assert.deepStrictEqual(approvals.map((a) => [a.kind, a.title, a.tool, a.input.command]), [ + ['command', 'run `touch a.txt`', 'Bash', 'touch a.txt'], + ['command', 'run `touch b.txt`', 'Bash', 'touch b.txt'], + ['command', 'run `touch c.txt`', 'Bash', 'touch c.txt'], + ]) + assert.strictEqual(approvals[0].itemId, 'b4f239ef-7d82-4f89-b06e-a0eb354e0b70:2') + // The denial reason reaches the model as the tool's error. + const commands = all(session, 'command_completed') + assert.deepStrictEqual(commands.map((c) => c.status), ['completed', 'failed', 'completed']) + assert.match(commands[1].output, /Not b\.txt; use c\.txt instead/) + assert.deepStrictEqual(hookOut(run, 'PreToolUse')[1], { decision: 'deny', reason: 'Not b.txt; use c.txt instead' }) + assert.deepStrictEqual(all(session, 'file_change').map((f) => [path.basename(f.changes[0].path), f.changes[0].kind, f.changes[0].diff]), [ + ['notes.txt', 'add', '@@ -0,0 +1,1 @@\n+hi'], + ['notes.txt', 'update', '@@ -1,1 +1,1 @@\n-hi\n+bye'], + ]) + const read = find(session, 'tool_completed') + assert.deepStrictEqual([read.name, read.input.file_path, read.ok, read.output], ['Read', path.join(run.cwd, 'f.txt'), true, '3 lines, 4 bytes']) + // Cached input counts inside input tokens, and each request is reported once. + const usage = all(session, 'usage') + assert.deepStrictEqual(usage.map((u) => u.inputTokens), [11903, 12108, 12325, 12530, 12825, 13237, 13581]) + assert.deepStrictEqual(usage.map((u) => u.cachedInputTokens), [0, 0, 0, 10059, 0, 0, 0]) + assert.strictEqual(new Set(usage.map((u) => u.requestId)).size, usage.length) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['completed', 'end_turn']) + assert.match(end.finalText, /^DONE\n\nFiles:/) + assert.ok(end.finalText.includes(run.cwd)) + // The instructions lead the first message only, and the run is gated by the hook. + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined).map((e) => e.input), ['You are a delegated subagent.\n\nDo the task.']) + const args = spawned(run)[0].args + assert.deepStrictEqual(args.slice(0, 8), ['-p=', '--input-format', 'stream-json', '--output-format', 'stream-json', '--disable-slash-commands', '--model', 'gemini-3.8-flash-low']) + assert.ok(args.includes('--dangerously-skip-permissions')) + assert.strictEqual(spawned(run)[0].env.token, (run.provider as any).states.get(session.info.id).token) + assert.strictEqual(session.nativeRef.conversationId, 'b4f239ef-7d82-4f89-b06e-a0eb354e0b70') + + // A second message runs on the same process. + await session.deliver(run.provider, 'Now reply with just the word SECOND.') + await done(session, 2) + assert.strictEqual(all(session, 'turn_completed')[1].finalText, 'SECOND') + assert.strictEqual(spawned(run).length, 1) + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined).map((e) => e.input).slice(1), ['Now reply with just the word SECOND.']) +}) + +test('allow_session lets the same command through for the rest of the task', async (t) => { + const cmd = { CommandLine: 'npm test', Cwd: '/elsewhere' } + const run = await fake(t, { turns: [{ steps: [{ tool: 'run_command', args: cmd }, { tool: 'run_command', args: cmd }, { tool: 'run_command', args: { CommandLine: 'npm run build' } }, { text: 'Done.' }] }] }) + const session = await spawn(run) + const first = await asked(session, 'approval_requested') + assert.strictEqual(first.detail.split('\n').at(-1), 'In /elsewhere') + run.provider.resolveApproval(session, first.approvalId, 'allow_session') + const third = await asked(session, 'approval_requested', 2) + assert.strictEqual(third.input.command, 'npm run build') + run.provider.resolveApproval(session, third.approvalId, 'deny') + await done(session) + assert.deepStrictEqual(all(session, 'command_completed').map((c) => c.status), ['completed', 'completed', 'failed']) + assert.deepStrictEqual(hookOut(run, 'PreToolUse'), [{ decision: 'allow' }, { decision: 'allow' }, { decision: 'deny', reason: 'The user denied this.' }]) + assert.deepStrictEqual(all(session, 'approval_resolved').map((a) => a.decision), ['allow_session', 'deny']) +}) + +test('subagent and scheduling tools are refused, reads and edits in the folder pass, and the rest is asked', async (t) => { + const outside = path.join(os.tmpdir(), 'bfagy-outside.txt') + const steps = [ + ...['invoke_subagent', 'define_subagent', 'manage_subagents', 'browser_subagent', 'send_message', 'run_workflow', 'schedule'].map((tool) => ({ tool, args: { Prompt: 'x' } })), + { tool: 'ask_permission', args: { Reason: 'x' } }, + { tool: 'view_file', args: { AbsolutePath: '/etc/hostname' } }, + { tool: 'grep_search', args: { Query: 'x' } }, + { tool: 'run_command', args: { CommandLine: 'ls -la' } }, + { tool: 'manage_task', args: { Action: 'status', TaskId: 't1' } }, + { tool: 'write_to_file', args: { TargetFile: 'new.txt', CodeContent: 'hi' }, write: ['new.txt', 'hi'] }, + { tool: 'write_to_file', args: { TargetFile: outside, CodeContent: 'hi' } }, + { tool: 'read_url_content', args: { Url: 'https://example.com' } }, + { text: 'Done.' }, + ] + const run = await fake(t, { turns: [{ steps }] }) + const session = await spawn(run) + const write = await asked(session, 'approval_requested') + assert.deepStrictEqual([write.kind, write.title, write.tool], ['file_change', `create ${outside}`, 'Write']) + run.provider.resolveApproval(session, write.approvalId, 'deny', 'Stay in the folder.') + const fetch = await asked(session, 'approval_requested', 2) + assert.deepStrictEqual([fetch.kind, fetch.title, fetch.tool, fetch.input.url], ['permissions', 'fetch https://example.com', 'WebFetch', 'https://example.com']) + run.provider.resolveApproval(session, fetch.approvalId, 'allow') + await done(session) + const replies = hookOut(run, 'PreToolUse') + for (const r of replies.slice(0, 7)) assert.match(r.reason, /^BitFrost subagents cannot use \w+: nested subagents/) + assert.match(replies[7].reason, /Call the tool you need directly/) + assert.deepStrictEqual(replies.slice(8, 13), Array(5).fill({ decision: 'allow' })) + assert.deepStrictEqual(replies.slice(13), [{ decision: 'deny', reason: 'Stay in the folder.' }, { decision: 'allow' }]) + assert.strictEqual(all(session, 'approval_requested').length, 2) + const change = find(session, 'file_change') + assert.deepStrictEqual([change.changes[0].path, change.changes[0].kind], [path.join(run.cwd, 'new.txt'), 'add']) +}) + +test('dangling links, git, and a command grant in another folder are all asked about', async (t) => { + const steps = [ + { tool: 'write_to_file', args: { TargetFile: 'link.txt', CodeContent: 'hi' } }, + { tool: 'run_command', args: { CommandLine: 'git status' } }, + { tool: 'run_command', args: { CommandLine: 'rm -rf build' } }, + { tool: 'run_command', args: { CommandLine: 'rm -rf build', Cwd: '/elsewhere' } }, + { text: 'Done.' }, + ] + const run = await fake(t, { turns: [{ steps }] }) + fs.symlinkSync(path.join(run.dir, 'missing', 'new.txt'), path.join(run.cwd, 'link.txt')) + const session = await spawn(run) + const titles: string[] = [] + for (let n = 1; n <= 4; n++) { + const a = await asked(session, 'approval_requested', n) + titles.push(a.title) + run.provider.resolveApproval(session, a.approvalId, n === 3 ? 'allow_session' : 'deny') + } + await done(session) + assert.match(titles[0], /link\.txt/) + assert.deepStrictEqual(titles.slice(1), ['run `git status`', 'run `rm -rf build`', 'run `rm -rf build`']) +}) + +test('a task that cannot ask refuses what needs approval and every question', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ tool: 'run_command', args: { CommandLine: 'rm -rf build' } }, { tool: 'ask_question', args: { questions: [{ question: 'Which?', options: ['a', 'b'] }] } }, { tool: 'run_command', args: { CommandLine: 'pwd' } }, { text: 'Done.' }] }] }) + const session = await spawn(run, { canAskUser: false }) + await done(session) + assert.deepStrictEqual(hookOut(run, 'PreToolUse'), [ + { decision: 'deny', reason: 'This BitFrost task runs without asking, so actions that need approval are refused.' }, + { decision: 'deny', reason: 'No one can answer questions in this task; use your best judgment.' }, + { decision: 'allow' }, + ]) + assert.strictEqual(all(session, 'approval_requested').length + all(session, 'question_asked').length, 0) +}) + +test('ask_question goes to the user and the answer comes back as the hook reason', async (t) => { + const q = { questions: [{ is_multi_select: false, options: ['red', 'green', 'blue'], question: 'Which color do you want?' }] } + const run = await fake(t, { turns: [{ steps: [{ tool: 'ask_question', args: q }, { text: 'green' }] }] }) + const session = await spawn(run) + const asking = await asked(session, 'question_asked') + assert.deepStrictEqual(asking.questions, [{ id: 'q0', header: '', question: 'Which color do you want?', options: ['red', 'green', 'blue'].map((label) => ({ label, description: '' })), allowOther: true, secret: false }]) + assert.deepStrictEqual(run.provider.pendingQuestions(session), [asking.questionId]) + assert.strictEqual(await run.provider.answerQuestion(session, asking.questionId, { q0: ['green'] }, false), true) + await done(session) + assert.deepStrictEqual(hookOut(run, 'PreToolUse'), [{ decision: 'deny', reason: 'The user answered your question.\n\nWhich color do you want?\ngreen' }]) + assert.strictEqual(find(session, 'question_answered').how, 'answered') + assert.strictEqual(find(session, 'turn_completed').finalText, 'green') +}) + +test('a deferred question stops the turn, and the answer resumes the conversation', async (t) => { + const q = { questions: [{ options: [], question: 'Which branch?' }] } + const run = await fake(t, { turns: [{ steps: [{ tool: 'ask_question', args: q }, { block: true }] }], resumed: [{ steps: [{ text: 'On main.' }] }] }) + const session = await spawn(run) + const asking = await asked(session, 'question_asked') + await run.provider.answerQuestion(session, asking.questionId, null, true) + await done(session) + assert.deepStrictEqual([find(session, 'turn_completed').status, find(session, 'question_answered').how], ['interrupted', 'deferred']) + await session.deliver(run.provider, 'Use main.') + await done(session, 2) + assert.strictEqual(all(session, 'turn_completed')[1].finalText, 'On main.') + assert.deepStrictEqual(spawned(run)[1].args.slice(-2), ['--conversation', 'conv-1']) +}) + +test('a message sent during a turn is injected before the next model request', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ tool: 'run_command', args: { CommandLine: 'make' } }, { text: 'DONE1 PINEAPPLE' }] }] }) + const session = await spawn(run) + const a = await asked(session, 'approval_requested') + const receipt = await session.deliver(run.provider, 'Also end your reply with the word PINEAPPLE.') + assert.strictEqual(receipt.delivery, 'steered') + run.provider.resolveApproval(session, a.approvalId, 'allow') + await done(session) + assert.deepStrictEqual(run.log().filter((e) => e.injected).map((e) => e.injected), ['Also end your reply with the word PINEAPPLE.']) + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined).length, 1) + assert.strictEqual(all(session, 'turn_completed').length, 1) + const steered = session.events.find((e) => e.type === 'user_input' && e.delivery === 'steered') as any + assert.ok(session.events.some((e) => e.type === 'input_consumed' && e.inputId === steered.inputId)) +}) + +test('a message that arrives after the last model request runs next, in the same turn', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ text: 'ONE' }, { wait: 300 }] }, { steps: [{ text: 'TWO' }] }] }) + const session = await spawn(run) + await until('the first reply', () => all(session, 'text').length === 1) + assert.strictEqual((await session.deliver(run.provider, 'Now say TWO.')).delivery, 'steered') + await done(session) + await sleep(100) + assert.strictEqual(all(session, 'turn_completed').length, 1) + assert.strictEqual(find(session, 'turn_completed').finalText, 'TWO') + assert.deepStrictEqual(run.log().filter((e) => e.input !== undefined).map((e) => e.input), ['Do the task.', 'Now say TWO.']) + assert.strictEqual(run.log().filter((e) => e.injected).length, 0) +}) + +test('an interrupt ends the turn and the next message resumes the conversation in a new process', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ tool: 'run_command', args: { CommandLine: 'sleep 30' }, block: true }] }], resumed: [{ steps: [{ text: 'sleep 30' }] }] }) + const session = await spawn(run) + const a = await asked(session, 'approval_requested') + run.provider.resolveApproval(session, a.approvalId, 'allow') + await until('the command to run', () => run.log().some((e) => e.hook === 'PreToolUse')) + assert.strictEqual(await session.stop(run.provider, 'host'), 'graceful') + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['interrupted', 'interrupted']) + assert.ok(run.log().some((e) => e.signal === 'SIGINT')) + await session.deliver(run.provider, 'What did I ask?') + await done(session, 2) + assert.deepStrictEqual([all(session, 'turn_completed')[1].status, all(session, 'turn_completed')[1].finalText], ['completed', 'sleep 30']) + const [first, second] = spawned(run) + assert.ok(!first.args.includes('--conversation')) + assert.deepStrictEqual(second.args.slice(-2), ['--conversation', 'conv-1']) + assert.throws(() => process.kill(first.pid, 0)) +}) + +test('an app that ignores the interrupt is killed', async (t) => { + const run = await fake(t, { ignoreInt: true, turns: [{ steps: [{ block: true }] }] }) + const session = await spawn(run) + // Wait for the prompt, which goes in only after the sign-in check. + await until('the prompt', () => run.log().some((e) => e.input !== undefined)) + assert.strictEqual(await session.stop(run.provider, 'host', { graceMs: 200 }), 'forced') + assert.strictEqual(find(session, 'turn_completed').reason, 'interrupted') +}) + +test('a different model than the one asked for stops the turn', async (t) => { + const run = await fake(t, { ranModel: 'gemini-3.8-flash-high', turns: [{ steps: [{ tool: 'run_command', args: { CommandLine: 'touch x' }, block: true }] }] }) + const session = await spawn(run) + await done(session) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['failed', 'wrong_model']) + assert.strictEqual(end.error, 'Antigravity started gemini-3.8-flash-high instead of gemini-3.8-flash-low; stopped it') + assert.strictEqual(all(session, 'approval_requested').length, 0) +}) + +test('a hook naming a different model is refused and stops the turn', async (t) => { + const run = await fake(t, { hookModel: 'claude-opus-4-6-thinking', turns: [{ steps: [{ tool: 'run_command', args: { CommandLine: 'touch x' } }, { block: true }] }] }) + const session = await spawn(run) + await done(session) + assert.strictEqual(find(session, 'turn_completed').reason, 'wrong_model') + assert.ok(hookOut(run, 'PreToolUse').every((r) => r?.decision === 'deny')) + assert.strictEqual(all(session, 'approval_requested').length, 0) +}) + +test('attach reopens a saved conversation after a helper restart', async (t) => { + const run = await fake(t, { resumed: [{ steps: [{ text: 'Still here.' }] }] }) + const session = new Session({ id: 'agy_saved', harness: 'agy', agent: 'test', model: MODEL, effort: 'low', cwd: run.cwd, state: 'detached' }) + session.nativeRef = { conversationId: 'conv-saved', model: 'gemini-3.8-flash-low', canAsk: true } + await session.attach(run.provider) + await session.deliver(run.provider, 'Are you there?') + await done(session) + assert.strictEqual(find(session, 'turn_completed').finalText, 'Still here.') + const args = spawned(run)[0].args + assert.deepStrictEqual([args[args.indexOf('--model') + 1], ...args.slice(-2)], ['gemini-3.8-flash-low', '--conversation', 'conv-saved']) + await assert.rejects(run.provider.attach!(new Session({ id: 'x', harness: 'agy', agent: 't', model: MODEL, cwd: run.cwd, state: 'detached' }), {}), /no conversation id/) +}) + +test('the process group is saved with the session, swept by its mark, and forgotten once it is gone', async (t) => { + const { killOwnedGroup } = await import('../process.ts') + const run = await fake(t, { turns: [{ steps: [{ text: 'Done.' }] }] }) + const session = await spawn(run) + await done(session) + const started = spawned(run)[0] + const identity = session.nativeRef.process + assert.deepStrictEqual([identity.pgid, identity.marker, identity.token], [started.pid, 'BITFROST_AGY_PROC', started.env.mark]) + // An unknown mark or token never matches. + assert.equal(killOwnedGroup({ ...identity, marker: 'PATH' }), false) + assert.equal(killOwnedGroup({ ...identity, token: 'another process' }), false) + assert.equal(killOwnedGroup(identity), true) + await until('the saved group is forgotten', () => !session.nativeRef.process) +}) + +test('a crash fails the turn with the reason Antigravity gave, and the next turn starts a new process', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ text: 'Working' }, { exit: 3, stderr: 'AGY_ERROR: {"short_error":"Your AI credits balance is too low to continue","retryable":false}' }] }], resumed: [{ steps: [{ text: 'Back.' }] }] }) + const session = await spawn(run) + await done(session) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason, end.finalText], ['failed', 'quota_exhausted', 'Working']) + assert.match(end.error, /AI credits balance is too low/) + await session.deliver(run.provider, 'Again.') + await done(session, 2) + assert.strictEqual(all(session, 'turn_completed')[1].finalText, 'Back.') +}) + +test('an error result fails the turn but keeps the process for the next one', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ error: 'RESOURCE_EXHAUSTED: 429 Too Many Requests' }] }, { steps: [{ text: 'Fine now.' }] }] }) + const session = await spawn(run) + await done(session) + assert.deepStrictEqual([find(session, 'turn_completed').status, find(session, 'turn_completed').reason], ['failed', 'rate_limited']) + await session.deliver(run.provider, 'Retry.') + await done(session, 2) + assert.strictEqual(all(session, 'turn_completed')[1].finalText, 'Fine now.') + assert.strictEqual(spawned(run).length, 1) +}) + +test('failures are sorted into end reasons', () => { + assert.strictEqual(agyEndReason('Your AI credits balance is too low to continue'), 'quota_exhausted') + assert.strictEqual(agyEndReason('RESOURCE_EXHAUSTED: 429'), 'rate_limited') + assert.strictEqual(agyEndReason('Please sign in to continue'), 'auth') + assert.strictEqual(agyEndReason('No valid authentication found (expired). Starting login...'), 'auth') + assert.strictEqual(agyEndReason('the prompt exceeds the context window'), 'max_tokens') + assert.strictEqual(agyEndReason('something odd'), 'error') +}) + +test('without the bridge plugin Antigravity keeps its permission prompts and says why it refused', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ tool: 'run_command', args: { CommandLine: 'touch x' } }, { text: 'Could not.' }] }] }, { bridge: false }) + const session = await spawn(run) + await done(session) + assert.ok(!spawned(run)[0].args.includes('--dangerously-skip-permissions')) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['failed', 'permission_denied']) + assert.match(end.error, /bitfrostd setup agy/) + assert.ok(run.logs.some((m) => /bridge plugin is not loaded/.test(m))) +}) + +test('a run whose hooks never call BitFrost is stopped before it can act', async (t) => { + const run = await fake(t, { turns: [{ steps: [{ text: 'Thinking.' }, { wait: 500 }, { tool: 'run_command', args: { CommandLine: 'touch x' }, write: ['x', ''] }, { text: 'Done.' }] }] }, { bridge: false, loaded: true }) + const session = await spawn(run) + await done(session) + assert.ok(spawned(run)[0].args.includes('--dangerously-skip-permissions')) + const end = find(session, 'turn_completed') + assert.deepStrictEqual([end.status, end.reason], ['failed', 'error']) + assert.match(end.error, /did not load the BitFrost bridge plugin/) + assert.ok(!fs.existsSync(path.join(run.cwd, 'x'))) + // Later runs keep the permission prompts on. + await session.deliver(run.provider, 'Again.') + await done(session, 2) + assert.ok(!spawned(run)[1].args.includes('--dangerously-skip-permissions')) +}) + +test('the hook has no opinion outside BitFrost runs and blocks tool calls BitFrost cannot answer', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bfagy-hook-')) + try { + const plugin = bridgeDir(dir) + installBridge(plugin) + const hook = (event: string, env: Record) => spawnSync('sh', ['-c', `'${plugin}/hook' ${event}`], { cwd: plugin, input: '{"toolCall":{"name":"run_command","args":{}}}', encoding: 'utf8', env: { PATH: process.env.PATH ?? '', ...env } }) + for (const event of ['PreToolUse', 'PreInvocation']) { + const r = hook(event, {}) + assert.deepStrictEqual([r.status, r.stdout, r.stderr], [0, '', '']) + } + const away = { BITFROST_AGY_TOKEN: 't', BITFROST_NODE: process.execPath, BITFROST_SOCKET: path.join(dir, 'missing.sock') } + const blocked = hook('PreToolUse', away) + assert.deepStrictEqual([blocked.status, blocked.stdout, blocked.stderr], [2, '', 'BitFrost is not reachable']) + const quiet = hook('PreInvocation', away) + assert.deepStrictEqual([quiet.status, quiet.stdout], [0, '']) + } finally { + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('the hook blocks a tool call that BitFrost answers without a decision', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bfagy-hook-')) + const socket = path.join(dir, 'bridge.sock') + const server = http.createServer((req, res) => { + req.resume() + req.on('end', () => { res.writeHead(200); res.end('') }) + }) + await new Promise((resolve) => server.listen(socket, resolve)) + try { + const plugin = bridgeDir(dir) + installBridge(plugin) + const env = { PATH: process.env.PATH ?? '', BITFROST_AGY_TOKEN: 't', BITFROST_NODE: process.execPath, BITFROST_SOCKET: socket } + const hook = (event: string) => new Promise<{ code: number | null; err: string }>((resolve) => { + const child = nodeSpawn('sh', ['-c', `'${plugin}/hook' ${event}`], { cwd: plugin, env }) + let err = '' + child.stderr.on('data', (d) => (err += d)) + child.on('close', (code) => resolve({ code, err })) + child.stdin.end('{"toolCall":{"name":"run_command","args":{}}}') + }) + assert.deepStrictEqual(await hook('PreToolUse'), { code: 2, err: 'BitFrost did not answer the tool call' }) + assert.deepStrictEqual(await hook('PreInvocation'), { code: 0, err: '' }) + } finally { + server.closeAllConnections() + await new Promise((resolve) => server.close(resolve)) + fs.rmSync(dir, { recursive: true, force: true }) + } +}) + +test('setup installs the bridge into the plugins folder, and uninstall removes it', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'bfagy-home-')) + const saved = process.env.HOME + process.env.HOME = home + try { + const dir = bridgeDir(home) + assert.strictEqual(bridgeDir(), dir) + assert.deepStrictEqual(agyProvider.uninstall!(true), []) + assert.strictEqual(bridgeCurrent(dir), false) + const quiet = console.log + console.log = () => {} + try { agyProvider.setup!({} as any) } finally { console.log = quiet } + assert.deepStrictEqual(fs.readdirSync(dir).sort(), ['hook', 'hook.mjs', 'hooks.json', 'plugin.json']) + assert.strictEqual(fs.statSync(path.join(dir, 'hook')).mode & 0o777, 0o755) + assert.strictEqual(JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), 'utf8')).name, 'bitfrost-bridge') + const hooks = JSON.parse(fs.readFileSync(path.join(dir, 'hooks.json'), 'utf8'))['bitfrost-bridge'] + assert.strictEqual(hooks.PreToolUse[0].matcher, '*') + assert.strictEqual(hooks.PreToolUse[0].hooks[0].command, `'${dir}/hook' PreToolUse`) + assert.strictEqual(hooks.PreInvocation[0].command, `'${dir}/hook' PreInvocation`) + assert.ok(hooks.PreToolUse[0].hooks[0].timeout >= 24 * 3600) + assert.strictEqual(bridgeCurrent(dir), true) + assert.ok(!fs.existsSync(path.join(home, '.gemini', 'config', '.bitfrost-bridge.new'))) + fs.appendFileSync(path.join(dir, 'hook.mjs'), '\n') + assert.strictEqual(bridgeCurrent(dir), false) + installBridge(dir) + assert.strictEqual(bridgeCurrent(dir), true) + + // Antigravity's own plugin list must show it loaded with its hooks. + const scenarioFile = path.join(home, 'scenario.json') + process.env.BITFROST_AGY_SCENARIO = scenarioFile + fs.writeFileSync(scenarioFile, JSON.stringify({ plugins: [{ name: 'bitfrost-bridge', enabled: true, path: dir, hooks: ['bitfrost-bridge'] }] })) + assert.strictEqual(await bridgeLoaded(process.execPath, dir, undefined, [FAKE]), true) + fs.writeFileSync(scenarioFile, JSON.stringify({ plugins: [{ name: 'bitfrost-bridge', enabled: false, path: dir, hooks: ['bitfrost-bridge'] }] })) + assert.strictEqual(await bridgeLoaded(process.execPath, dir, undefined, [FAKE]), false) + + assert.deepStrictEqual(agyProvider.uninstall!(true), [{ label: 'BitFrost bridge plugin in Antigravity', path: dir }]) + assert.ok(fs.existsSync(dir)) + assert.deepStrictEqual(agyProvider.uninstall!(), [{ label: 'BitFrost bridge plugin in Antigravity', path: dir }]) + assert.ok(!fs.existsSync(dir)) + assert.deepStrictEqual(uninstallBridge(dir), []) + } finally { + process.env.HOME = saved + delete process.env.BITFROST_AGY_SCENARIO + fs.rmSync(home, { recursive: true, force: true }) + } +}) + +test('the app is found through the environment, the config, the PATH, then its install folder', () => { + const env = (bin: string | null, config: any = {}) => ({ config, resolveBinary: () => bin }) as any + const saved = process.env.BITFROST_AGY_BIN + delete process.env.BITFROST_AGY_BIN + try { + assert.strictEqual(resolveAgy(env('/usr/bin/agy', { bin: '/opt/agy' })), '/opt/agy') + assert.strictEqual(resolveAgy(env('/usr/bin/agy')), '/usr/bin/agy') + assert.strictEqual(resolveAgy(env(null), process.execPath), process.execPath) + assert.strictEqual(resolveAgy(env(null), '/nonexistent/agy'), null) + process.env.BITFROST_AGY_BIN = '/env/agy' + assert.strictEqual(resolveAgy(env('/usr/bin/agy')), '/env/agy') + } finally { + if (saved === undefined) delete process.env.BITFROST_AGY_BIN + else process.env.BITFROST_AGY_BIN = saved + } +}) diff --git a/plugins/bitfrost/daemon/test/config.spec.ts b/plugins/bitfrost/daemon/test/config.spec.ts index 7002473..22b19c7 100644 --- a/plugins/bitfrost/daemon/test/config.spec.ts +++ b/plugins/bitfrost/daemon/test/config.spec.ts @@ -150,6 +150,14 @@ test('a model through another app is offered, but its own app keeps the name and assert.strictEqual(new Set(all).size, all.length, 'no alias names two models') }) +test('Claude models through other apps get no short names, so sonnet and opus keep meaning Claude Code models', () => { + const m = (harness: string, provider: string, model: string, displayName: string) => ({ harness, harnessName: harness, provider, model, displayName, description: '', efforts: [], defaultEffort: null, isDefault: false }) + const agents = buildAgents([m('agy', 'Anthropic', 'claude-sonnet-4-6', 'Claude Sonnet 4.6 (Thinking)'), m('agy', 'Google', 'gemini-3.8-flash', 'Gemini 3.8 Flash')], new Map([['agy', 'Google']])) + assert.deepStrictEqual([agents[0].name, agents[0].aliases], ['claude-sonnet-4-6', []]) + assert.ok(agents[1].aliases.length > 0) + assert.match(describe(agents[0], agents), /^Claude Sonnet 4\.6 \(Thinking\), Anthropic's model claude-sonnet-4-6, running in the real agy app/) +}) + test('retention defaults, overrides and invalid shapes are checked', (t) => { const file = path.join(tempDir(t),'config.json') assert.deepEqual(loadConfig(file).retention,{ eventsDays:30,messagesDays:180 }) diff --git a/plugins/bitfrost/daemon/test/crush.spec.ts b/plugins/bitfrost/daemon/test/crush.spec.ts index c9b32d8..a2c38a9 100644 --- a/plugins/bitfrost/daemon/test/crush.spec.ts +++ b/plugins/bitfrost/daemon/test/crush.spec.ts @@ -192,7 +192,7 @@ test('a turn streams text, asks before a command and an edit, reports usage and const [status, touch] = all(session, 'command_completed') assert.deepStrictEqual( { command: status.command, output: status.output, exitCode: status.exitCode, durationMs: status.durationMs, status: status.status, cwd: status.cwd }, - { command: 'git status', output: 'nothing to commit', exitCode: 0, durationMs: 40, status: 'completed', cwd: run.cwd }, + { command: 'ls', output: 'a.txt', exitCode: 0, durationMs: 40, status: 'completed', cwd: run.cwd }, ) assert.deepStrictEqual([touch.command, touch.summary, touch.status, touch.durationMs], ['touch a.txt', 'Create a.txt', 'completed', 250]) assert.deepStrictEqual(find(session, 'file_change').changes, [{ path: file, kind: 'update', movePath: null, diff: '@@ -1,1 +1,1 @@\n-one\n+two' }]) @@ -439,13 +439,16 @@ test('a real Crush run replays: text, usage, a blocked command and a denied perm }) test('only commands that cannot change anything skip asking', () => { - for (const c of ['ls', 'ls -la src', 'pwd', 'git status', 'git diff HEAD~1', 'git log --oneline -5', 'git diff src/a..b', 'git log main..HEAD', 'git show HEAD:src/x.ts', 'which node', 'echo hello', 'date']) assert.ok(readOnlyCommand(c), c) + for (const c of ['ls', 'ls -la src', 'pwd', 'which node', 'echo hello', 'date']) assert.ok(readOnlyCommand(c), c) for (const c of [ 'echo hi > file', 'timeout 5 rm -rf build', 'env rm -rf x', 'nice make', 'kill 1', + 'git status', + 'git diff HEAD~1', + 'git log --oneline -5', 'git branch -D main', 'git diff --output=x', 'git diff --no-index /etc/passwd /dev/null', diff --git a/plugins/bitfrost/daemon/test/daemon.spec.ts b/plugins/bitfrost/daemon/test/daemon.spec.ts index f2bb1ed..de47174 100644 --- a/plugins/bitfrost/daemon/test/daemon.spec.ts +++ b/plugins/bitfrost/daemon/test/daemon.spec.ts @@ -424,7 +424,8 @@ test('/providers lists every app, with its mode and whether it runs', async () = const r = await call('GET', '/providers') assert.strictEqual(r.status, 200) const byId = Object.fromEntries(r.body.providers.map((p: any) => [p.id, p])) - assert.deepStrictEqual(r.body.providers.slice(0, 2).map((p: any) => p.id), ['codex', 'zcode']) + // Dedicated harnesses come first, then apps serving many companies, each alphabetical. + assert.deepStrictEqual(r.body.providers.map((p: any) => [p.id, p.firstClass]), [['agy', true], ['codex', true], ['grok', true], ['zcode', true], ['crush', false], ['gemini', false], ['omp', false], ['opencode', false]]) assert.deepStrictEqual([byId.codex.running, byId.codex.enabled, byId.codex.detected, byId.codex.mode, byId.codex.location], [true, true, true, 'deny', FAKE]) assert.deepStrictEqual([byId.zcode.running, byId.zcode.enabled, byId.zcode.mode], [false, false, 'deny']) assert.deepStrictEqual([byId.opencode.running, byId.opencode.enabled, byId.opencode.optIn, byId.opencode.mode], [false, false, true, 'allow']) diff --git a/plugins/bitfrost/daemon/test/fake-agy.mjs b/plugins/bitfrost/daemon/test/fake-agy.mjs new file mode 100644 index 0000000..ae450e9 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fake-agy.mjs @@ -0,0 +1,207 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +// Stands in for the Antigravity CLI in tests. It reads one stream-json user line per +// turn and plays the next turn of the scenario in BITFROST_AGY_SCENARIO: either a +// recorded run from fixtures/agy/, or scripted steps. Hooks run for real, from the +// bridge plugin installed in BITFROST_AGY_PLUGIN_DIR, the way Antigravity runs them. +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { createInterface } from 'node:readline' + +const argv = process.argv.slice(2) +// BitFrost points every run at a log file to learn how it signed in; take it out of argv +// so the command checks below keep seeing the arguments they expect. +const logAt = argv.indexOf('--log-file') +const authLog = logAt === -1 ? null : argv.splice(logAt, 2)[1] +const scenario = process.env.BITFROST_AGY_SCENARIO ? JSON.parse(fs.readFileSync(process.env.BITFROST_AGY_SCENARIO, 'utf8')) : {} +const log = (entry) => process.env.BITFROST_AGY_LOG && fs.appendFileSync(process.env.BITFROST_AGY_LOG, JSON.stringify(entry) + '\n') +const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n') +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// The real app says how it signed in within a second of starting, before it reads any input. +const authMethod = scenario.authMethod === undefined ? 'gemini_api_key' : scenario.authMethod +const writeAuth = (method = authMethod) => { + if (!authLog || method === null) return + const d = new Date() + const p = (n, w) => String(n).padStart(w, '0') + fs.appendFileSync(authLog, `I${p(d.getMonth() + 1, 2)}${p(d.getDate(), 2)} ${p(d.getHours(), 2)}:${p(d.getMinutes(), 2)}:${p(d.getSeconds(), 2)}.${p(d.getMilliseconds(), 6)} 1 server_oauth.go:204] applyAuthResult: email=test@example.com, authMethod=${method}, quotaProject=\n`) +} +if (scenario.authDelayMs) setTimeout(() => writeAuth(), scenario.authDelayMs) +else writeAuth() + +if (argv[0] === 'models') { + if (scenario.modelsError) { + process.stderr.write(scenario.modelsError + '\n') + process.exit(1) + } + log({ models: true }) + process.stdout.write(fs.readFileSync(scenario.models, 'utf8')) + process.exit(0) +} +if (argv[0] === 'plugin') { + process.stdout.write(JSON.stringify(scenario.plugins ?? [])) + process.exit(0) +} + +const flag = (name) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : undefined) +const resumed = flag('--conversation') +let conversation = resumed ?? scenario.conversation ?? 'conv-1' +const model = scenario.ranModel ?? flag('--model') +const skip = argv.includes('--dangerously-skip-permissions') +const turns = [...((resumed ? scenario.resumed : scenario.turns) ?? [])] +let step = resumed ? 100 : 0 +let invocation = 0 +let denied = [] +log({ args: argv, authLog, pid: process.pid, cwd: process.cwd(), env: { socket: process.env.BITFROST_SOCKET, token: process.env.BITFROST_AGY_TOKEN, mark: process.env.BITFROST_AGY_PROC, node: process.env.BITFROST_NODE } }) + +process.on('SIGINT', () => { + log({ signal: 'SIGINT' }) + if (scenario.ignoreInt) return + process.stderr.write('error: interrupted\n') + out({ event: 'result', result: { conversation_id: conversation, status: 'ERROR', response: '', error: 'interrupted' } }) + process.exit(1) +}) + +const hooks = (() => { + const dir = process.env.BITFROST_AGY_PLUGIN_DIR + try { return { dir, spec: Object.values(JSON.parse(fs.readFileSync(path.join(dir, 'hooks.json'), 'utf8')))[0] } } catch { return null } +})() + +// Run the plugin's hook command as Antigravity does: sh -c, from the plugin folder, JSON in and out. +function runHook(event, input) { + const entry = hooks?.spec?.[event]?.[0] + const command = entry?.hooks?.[0]?.command ?? entry?.command + if (!command) return Promise.resolve({ code: 0, out: '' }) + return new Promise((resolve) => { + const child = spawn('sh', ['-c', command], { cwd: hooks.dir, stdio: ['pipe', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.on('data', (d) => (stdout += d)) + child.stderr.on('data', (d) => (stderr += d)) + child.on('close', (code) => { + log({ hook: event, code, out: stdout, err: stderr }) + resolve({ code, out: stdout, err: stderr }) + }) + child.stdin.end(JSON.stringify({ conversationId: conversation, modelName: scenario.hookModel ?? model, workspacePaths: [process.cwd()], ...input })) + }) +} + +async function preInvocation() { + const r = await runHook('PreInvocation', { invocationNum: invocation++ }) + if (r.code !== 0 || !r.out.trim()) return + for (const s of JSON.parse(r.out).injectSteps ?? []) { + log({ injected: s.userMessage }) + out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: step++, state: 'DONE', step_type: 'user_input' } }) + } +} + +// Returns the error text that blocks the call, or null when it may run. +async function preToolUse(index, name, args) { + const r = await runHook('PreToolUse', { stepIdx: index, toolCall: { name, args } }) + if (r.code !== 0) return `JSON hook failed: exit status ${r.code}, stderr: ${r.err}` + const reply = r.out.trim() ? JSON.parse(r.out) : null + if (reply && reply.decision !== 'allow') return `tool call denied by pre-tool hook: ${reply.reason ?? ''}` + if (!reply && !skip && !['view_file', 'list_dir'].includes(name)) { + denied.push({ action: 'command', display_name: 'RunCommand' }) + return 'auto-denied' + } + return null +} + +const usage = (n) => ({ input_tokens: 1000 + n, output_tokens: 10, thinking_tokens: 0, cache_read_tokens: n % 2 ? 500 : 0, total_tokens: 1010 + n }) + +async function scripted(steps, text) { + let response = '' + for (const s of steps) { + if (s.wait) await sleep(s.wait) + if (s.text !== undefined) { + await preInvocation() + const index = step++ + const parts = s.text.match(/.{1,8}/gs) ?? [''] + for (const p of parts.slice(0, -1)) out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: index, state: 'ACTIVE', step_type: 'agent_response', text_delta: p } }) + out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: index, state: 'DONE', step_type: 'agent_response', text_delta: parts.at(-1), usage: usage(index) } }) + response = s.text + } + if (s.tool) { + await preInvocation() + const index = step++ + out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: index, state: 'DONE', step_type: 'agent_response', usage: usage(index) } }) + const tool = step++ + const shown = s.shown ?? s.args + out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: tool, state: 'ACTIVE', step_type: 'tool', tool_name: s.tool, tool_info: { name: s.tool, parameters: shown } } }) + const blocked = await preToolUse(tool, s.tool, s.args) + if (!blocked && s.write) fs.writeFileSync(path.resolve(process.cwd(), s.write[0]), s.write[1]) + if (!blocked && s.block) await new Promise(() => {}) + const error = blocked && blocked !== 'auto-denied' ? { type: 'TOOL_ERROR', message: blocked } : undefined + out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: tool, state: error ? 'ERROR' : 'DONE', step_type: 'tool', tool_name: s.tool, duration_seconds: 0.25, tool_info: { name: s.tool, parameters: shown, ...(s.output ? { output: s.output } : {}), ...(error ? { error } : {}) } } }) + } + if (s.block) await new Promise(() => {}) + if (s.exit !== undefined) { + if (s.stderr) process.stderr.write(s.stderr + '\n') + process.exit(s.exit) + } + if (s.error) { + out({ event: 'result', result: { conversation_id: conversation, status: 'ERROR', response: '', error: s.error } }) + return + } + } + out({ event: 'result', result: { conversation_id: conversation, status: 'SUCCESS', response: response ? `${response}\n` : '', ...(denied.length ? { denied_actions: denied } : {}) } }) +} + +// Play a recorded run, calling the hooks where Antigravity would. +async function recorded(lines, turn) { + const seen = new Set() + for (const line of lines) { + const ev = JSON.parse(line.replaceAll('$CWD', process.cwd())) + if (ev.event === 'init') conversation = ev.conversation_id + const s = ev.step_update + if (s?.step_type === 'agent_response' && !seen.has(s.step_index)) { + seen.add(s.step_index) + await preInvocation() + } + if (s?.step_type === 'tool' && s.state === 'ACTIVE') { + out(ev) + await preToolUse(s.step_index, s.tool_name, { ...s.tool_info.parameters, ...(turn.args?.[s.step_index] ?? {}) }) + const write = turn.writes?.[s.step_index] + if (write) fs.writeFileSync(path.resolve(process.cwd(), write[0]), write[1]) + continue + } + out(ev) + } +} + +let busy = Promise.resolve() +let reauthed = false +let initDone = false +createInterface({ input: process.stdin }).on('line', (line) => { + const msg = JSON.parse(line) + log({ input: msg.message?.content }) + busy = busy.then(async () => { + const turn = turns.shift() ?? { steps: [{ text: 'OK' }] } + if (!initDone && !turn.fixture) out({ event: 'init', conversation_id: conversation, init: { model, cwd: process.cwd(), permission_mode: skip ? 'always-proceed' : 'request-review' } }) + initDone = true + if (turn.fixture) { + // A recording holds one chunk per turn, each ending with its result. + const chunks = [[]] + for (const l of fs.readFileSync(turn.fixture, 'utf8').split('\n').filter(Boolean)) { + chunks.at(-1).push(l) + if (l.startsWith('{"event":"result"')) chunks.push([]) + } + return recorded(chunks[turn.part ?? 0], turn) + } + out({ event: 'step_update', step_update: { conversation_id: conversation, step_index: step++, state: 'DONE', step_type: 'user_input' } }) + await scripted(turn.steps ?? [], msg.message?.content) + // A scenario can sign in again after the first turn, as a switched account would. + if (scenario.reauth && !reauthed) { + reauthed = true + writeAuth(scenario.reauth) + log({ reauth: scenario.reauth }) + } + }) +}) +process.stdin.on('end', () => busy.then(() => process.exit(0))) diff --git a/plugins/bitfrost/daemon/test/fake-grok.mjs b/plugins/bitfrost/daemon/test/fake-grok.mjs new file mode 100644 index 0000000..9d0c3e7 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fake-grok.mjs @@ -0,0 +1,67 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +import fs from 'node:fs' +import { createInterface } from 'node:readline' + +const scenario = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) +const waiting = new Map() +const cancels = new Map() +const cancelled = new Set() +let turn = 0 +let next = 0 +const options = structuredClone(scenario.newSession.result?.configOptions ?? []) +const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...msg }) + '\n') +const fill = (value, sid, cwd) => JSON.parse(JSON.stringify(value).replaceAll('$SESSION', sid).replaceAll('$CWD', cwd)) + +async function prompt(msg) { + cancelled.delete(msg.params.sessionId) + for (const step of scenario.turns[turn++] ?? []) { + const value = fill(step, msg.params.sessionId, process.env.BITFROST_GROK_CWD) + if (value.method) { + if (value.request) { + const id = next++ + const answer = new Promise((resolve) => waiting.set(id, resolve)) + send({ id, method: value.method, params: value.params }) + await answer + } else send(value) + } else if (value.waitCancel) { + if (!cancelled.has(msg.params.sessionId)) await new Promise((resolve) => cancels.set(msg.params.sessionId, resolve)) + cancels.delete(msg.params.sessionId) + return send({ id: msg.id, result: { stopReason: 'cancelled' } }) + } else if (value.crash) process.exit(value.crash) + else if (value.delay) await new Promise((resolve) => setTimeout(resolve, value.delay)) + else if (value.error || value.result) return send({ id: msg.id, ...value }) + } + send({ id: msg.id, result: { stopReason: 'end_turn' } }) +} + +createInterface({ input: process.stdin }).on('line', (line) => { + fs.appendFileSync(process.env.BITFROST_GROK_LOG, line + '\n') + const msg = JSON.parse(line) + const p = msg.params ?? {} + if (!msg.method) { + waiting.get(msg.id)?.(msg.result ?? msg.error) + waiting.delete(msg.id) + return + } + switch (msg.method) { + case 'initialize': return send({ id: msg.id, result: scenario.initialize }) + case 'session/new': + case 'session/load': return send({ id: msg.id, ...scenario.newSession }) + case 'session/set_config_option': { + const option = options.find((o) => o.id === p.configId) + if (option) option.currentValue = scenario.reportModel && option.category === 'model' ? scenario.reportModel : p.value + return send({ id: msg.id, result: { configOptions: options } }) + } + case 'session/prompt': return void prompt(msg) + case 'session/cancel': + cancelled.add(p.sessionId) + return void cancels.get(p.sessionId)?.() + case 'session/close': return send({ id: msg.id, result: {} }) + default: return send({ id: msg.id, error: { code: -32601, message: 'Method not found' } }) + } +}) diff --git a/plugins/bitfrost/daemon/test/fixtures/agy/models.txt b/plugins/bitfrost/daemon/test/fixtures/agy/models.txt new file mode 100644 index 0000000..e912625 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/agy/models.txt @@ -0,0 +1,6 @@ +gemini-3.8-flash-high Gemini 3.8 Flash (High) +gemini-3.8-flash-medium Gemini 3.8 Flash (Medium) +gemini-3.8-flash-low Gemini 3.8 Flash (Low) +claude-sonnet-4-6 Claude Sonnet 4.6 (Thinking) +claude-opus-4-6-thinking Claude Opus 4.6 (Thinking) +gpt-oss-120b-medium GPT-OSS 120B (Medium) diff --git a/plugins/bitfrost/daemon/test/fixtures/agy/real-basic.jsonl b/plugins/bitfrost/daemon/test/fixtures/agy/real-basic.jsonl new file mode 100644 index 0000000..581fd21 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/agy/real-basic.jsonl @@ -0,0 +1,29 @@ +{"event":"init","conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","init":{"model":"gemini-3.8-flash-low","cwd":"$CWD","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_plugin_accounts","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","run_workflow","schedule","search_marketplace","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"always-proceed"}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":0,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":1,"state":"DONE","step_type":"agent_response","duration_seconds":2.605730089,"usage":{"input_tokens":11903,"output_tokens":117,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12020}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{"name":"run_command","parameters":{"CommandLine":"touch a.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":2,"state":"DONE","step_type":"tool","tool_name":"run_command","duration_seconds":0.374868979,"tool_info":{"name":"run_command","parameters":{"CommandLine":"touch a.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":3,"state":"DONE","step_type":"agent_response","duration_seconds":3.016966255,"usage":{"input_tokens":12108,"output_tokens":117,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12225}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":4,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{"name":"run_command","parameters":{"CommandLine":"touch b.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":4,"state":"ERROR","step_type":"tool","tool_name":"run_command","duration_seconds":0.143720783,"tool_info":{"name":"run_command","parameters":{"CommandLine":"touch b.txt"},"error":{"type":"TOOL_ERROR","message":"tool call denied by pre-tool hook: Not b.txt; use c.txt instead"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":5,"state":"DONE","step_type":"agent_response","duration_seconds":2.157042937,"usage":{"input_tokens":12325,"output_tokens":117,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12442}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":6,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{"name":"run_command","parameters":{"CommandLine":"touch c.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":6,"state":"DONE","step_type":"tool","tool_name":"run_command","duration_seconds":0.360106803,"tool_info":{"name":"run_command","parameters":{"CommandLine":"touch c.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":7,"state":"DONE","step_type":"agent_response","duration_seconds":3.496277629,"usage":{"input_tokens":2471,"output_tokens":125,"thinking_tokens":0,"cache_read_tokens":10059,"total_tokens":2596}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":8,"state":"ACTIVE","step_type":"tool","tool_name":"write_to_file","tool_info":{"name":"write_to_file","parameters":{"TargetFile":"$CWD/notes.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":8,"state":"DONE","step_type":"tool","tool_name":"write_to_file","duration_seconds":0.120309215,"tool_info":{"name":"write_to_file","parameters":{"TargetFile":"$CWD/notes.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":9,"state":"DONE","step_type":"agent_response","duration_seconds":4.38671742,"usage":{"input_tokens":12825,"output_tokens":152,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12977}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":10,"state":"ACTIVE","step_type":"tool","tool_name":"replace_file_content","tool_info":{"name":"replace_file_content","parameters":{"TargetFile":"$CWD/notes.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":10,"state":"DONE","step_type":"tool","tool_name":"replace_file_content","duration_seconds":0.12040613,"tool_info":{"name":"replace_file_content","parameters":{"TargetFile":"$CWD/notes.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":11,"state":"DONE","step_type":"agent_response","duration_seconds":1.973610256,"usage":{"input_tokens":13237,"output_tokens":100,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":13337}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":12,"state":"ACTIVE","step_type":"tool","tool_name":"view_file","tool_info":{"name":"view_file","parameters":{"AbsolutePath":"$CWD/f.txt"}}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":12,"state":"DONE","step_type":"tool","tool_name":"view_file","duration_seconds":0.144656907,"tool_info":{"name":"view_file","parameters":{"AbsolutePath":"$CWD/f.txt"},"output":"3 lines, 4 bytes"}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":13,"state":"ACTIVE","step_type":"agent_response","text_delta":"DONE\n\nFiles:\n- [a.txt](file://$CWD/a.txt"}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":13,"state":"ACTIVE","step_type":"agent_response","text_delta":")\n- [c.txt](file://$CWD/c.txt)\n- [notes."}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":13,"state":"ACTIVE","step_type":"agent_response","text_delta":"txt](file://$CWD/notes.txt)\n- [f.txt](fi"}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":13,"state":"DONE","step_type":"agent_response","text_delta":"le://$CWD/f.txt)\n","duration_seconds":3.546467334,"usage":{"input_tokens":13581,"output_tokens":300,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":13881}}} +{"event":"result","result":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","status":"SUCCESS","response":"DONE\n\nFiles:\n- [a.txt](file://$CWD/a.txt)\n- [c.txt](file://$CWD/c.txt)\n- [notes.txt](file://$CWD/notes.txt)\n- [f.txt](file://$CWD/f.txt)\n","duration_seconds":22.207806067,"num_turns":1,"usage":{"input_tokens":78450,"output_tokens":1028,"thinking_tokens":0,"cache_read_tokens":10059,"total_tokens":79478}}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":14,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":15,"state":"ACTIVE","step_type":"agent_response","text_delta":"SECOND"}} +{"event":"step_update","step_update":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","step_index":15,"state":"DONE","step_type":"agent_response","text_delta":"\n","duration_seconds":2.681458504,"usage":{"input_tokens":13955,"output_tokens":1,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":13956}}} +{"event":"result","result":{"conversation_id":"b4f239ef-7d82-4f89-b06e-a0eb354e0b70","status":"SUCCESS","response":"SECOND\n","duration_seconds":25.004028828,"num_turns":2,"usage":{"input_tokens":92405,"output_tokens":1029,"thinking_tokens":0,"cache_read_tokens":10059,"total_tokens":93434}}} diff --git a/plugins/bitfrost/daemon/test/fixtures/agy/real-interrupt.jsonl b/plugins/bitfrost/daemon/test/fixtures/agy/real-interrupt.jsonl new file mode 100644 index 0000000..100425e --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/agy/real-interrupt.jsonl @@ -0,0 +1,11 @@ +{"event":"init","conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","init":{"model":"gemini-3.8-flash-low","cwd":"$CWD","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_plugin_accounts","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","run_workflow","schedule","search_marketplace","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"always-proceed"}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":0,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":1,"state":"DONE","step_type":"agent_response","duration_seconds":2.707071872,"usage":{"input_tokens":11831,"output_tokens":115,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":11946}}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{"name":"run_command","parameters":{"CommandLine":"sleep 30"}}}} +{"event":"result","result":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","status":"ERROR","response":"","error":"interrupted","duration_seconds":4.345672359,"num_turns":1,"usage":{"input_tokens":11831,"output_tokens":115,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":11946}}} +{"event":"init","conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","init":{"model":"gemini-3.8-flash-low","cwd":"$CWD","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_plugin_accounts","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","run_workflow","schedule","search_marketplace","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"always-proceed"}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":3,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":4,"state":"DONE","step_type":"system_message","duration_seconds":0.000056339}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":5,"state":"ACTIVE","step_type":"agent_response","text_delta":"sleep 30"}} +{"event":"step_update","step_update":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","step_index":5,"state":"DONE","step_type":"agent_response","text_delta":"\n","duration_seconds":1.7666919189999999,"usage":{"input_tokens":12238,"output_tokens":4,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12242}}} +{"event":"result","result":{"conversation_id":"2b90d410-5cb0-48c1-b161-e35531121f16","status":"SUCCESS","response":"sleep 30\n","duration_seconds":11.410177986,"num_turns":2,"usage":{"input_tokens":24069,"output_tokens":119,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":24188}}} diff --git a/plugins/bitfrost/daemon/test/fixtures/agy/real-question.jsonl b/plugins/bitfrost/daemon/test/fixtures/agy/real-question.jsonl new file mode 100644 index 0000000..53e1256 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/agy/real-question.jsonl @@ -0,0 +1,6 @@ +{"event":"init","conversation_id":"62182477-44c5-478e-bd9c-00de42e3d9c2","init":{"model":"gemini-3.8-flash-low","cwd":"$CWD","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_plugin_accounts","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","run_workflow","schedule","search_marketplace","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"always-proceed"}} +{"event":"step_update","step_update":{"conversation_id":"62182477-44c5-478e-bd9c-00de42e3d9c2","step_index":0,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"62182477-44c5-478e-bd9c-00de42e3d9c2","step_index":1,"state":"DONE","step_type":"agent_response","duration_seconds":2.579985604,"usage":{"input_tokens":11841,"output_tokens":64,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":11905}}} +{"event":"step_update","step_update":{"conversation_id":"62182477-44c5-478e-bd9c-00de42e3d9c2","step_index":2,"state":"ERROR","step_type":"unknown","duration_seconds":0.093735354}} +{"event":"step_update","step_update":{"conversation_id":"62182477-44c5-478e-bd9c-00de42e3d9c2","step_index":3,"state":"DONE","step_type":"agent_response","text_delta":"green\n","duration_seconds":2.002191077,"usage":{"input_tokens":12010,"output_tokens":1,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12011}}} +{"event":"result","result":{"conversation_id":"62182477-44c5-478e-bd9c-00de42e3d9c2","status":"SUCCESS","response":"green\n","duration_seconds":4.836325037,"num_turns":1,"usage":{"input_tokens":23851,"output_tokens":65,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":23916}}} diff --git a/plugins/bitfrost/daemon/test/fixtures/agy/real-steer.jsonl b/plugins/bitfrost/daemon/test/fixtures/agy/real-steer.jsonl new file mode 100644 index 0000000..9c5d566 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/agy/real-steer.jsonl @@ -0,0 +1,9 @@ +{"event":"init","conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","init":{"model":"gemini-3.8-flash-low","cwd":"$CWD","tools":["ask_custom_permission","ask_permission","ask_question","browser_click_element","browser_drag_pixel_to_pixel","browser_get_dom","browser_get_network_request","browser_input","browser_list_network_requests","browser_mouse_down","browser_mouse_up","browser_move_mouse","browser_press_key","browser_refresh_page","browser_resize_window","browser_scroll","browser_scroll_dom","browser_select_option","browser_subagent","call_mcp_tool","capture_browser_console_logs","capture_browser_screenshot","click_browser_pixel","command_status","define_subagent","delete_knowledge","execute_browser_javascript","find_by_name","finish","generate_image","grep_search","invoke_subagent","list_browser_pages","list_dir","list_permissions","list_plugin_accounts","list_resources","manage_inbox","manage_subagents","manage_task","multi_replace_file_content","notebook_edit","notebook_execution","open_browser_url","read_browser_page","read_resource","read_url_content","replace_file_content","run_command","run_workflow","schedule","search_marketplace","search_web","sed_file","send_command_input","send_message","view_file","wait","wait_5_seconds","write_to_file"],"permission_mode":"always-proceed"}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":0,"state":"DONE","step_type":"user_input"}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":1,"state":"DONE","step_type":"agent_response","duration_seconds":2.02631947,"usage":{"input_tokens":11832,"output_tokens":116,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":11948}}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":2,"state":"ACTIVE","step_type":"tool","tool_name":"run_command","tool_info":{"name":"run_command","parameters":{"CommandLine":"sleep 4"}}}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":2,"state":"DONE","step_type":"tool","tool_name":"run_command","duration_seconds":4.194549869,"tool_info":{"name":"run_command","parameters":{"CommandLine":"sleep 4"}}}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":3,"state":"DONE","step_type":"user_input","duration_seconds":0.00002922}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":4,"state":"ACTIVE","step_type":"agent_response","text_delta":"DONE1 PINEAPPLE"}} +{"event":"step_update","step_update":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","step_index":4,"state":"DONE","step_type":"agent_response","text_delta":"\n","duration_seconds":2.6547708180000003,"usage":{"input_tokens":12112,"output_tokens":5,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":12117}}} +{"event":"result","result":{"conversation_id":"dca4db0c-e770-442d-a49d-e152171a5591","status":"SUCCESS","response":"DONE1 PINEAPPLE\n","duration_seconds":8.967138921,"num_turns":2,"usage":{"input_tokens":23944,"output_tokens":121,"thinking_tokens":0,"cache_read_tokens":0,"total_tokens":24065}}} diff --git a/plugins/bitfrost/daemon/test/fixtures/crush/turn.json b/plugins/bitfrost/daemon/test/fixtures/crush/turn.json index dee7c46..ab3ee8c 100644 --- a/plugins/bitfrost/daemon/test/fixtures/crush/turn.json +++ b/plugins/bitfrost/daemon/test/fixtures/crush/turn.json @@ -220,7 +220,7 @@ "data": { "id": "call_0", "name": "bash", - "input": "{\"command\":\"git status\"}", + "input": "{\"command\":\"ls\"}", "finished": true } }, @@ -270,7 +270,7 @@ "hook": { "tool_name": "bash", "tool_input": { - "command": "git status" + "command": "ls" } }, "none": [ @@ -289,8 +289,8 @@ "data": { "tool_call_id": "call_0", "name": "bash", - "content": "nothing to commit", - "metadata": "{\"start_time\":1000,\"end_time\":1040,\"output\":\"nothing to commit\",\"working_directory\":\"$CWD\"}", + "content": "a.txt", + "metadata": "{\"start_time\":1000,\"end_time\":1040,\"output\":\"a.txt\",\"working_directory\":\"$CWD\"}", "is_error": false } }, @@ -425,7 +425,7 @@ "data": { "id": "call_0", "name": "bash", - "input": "{\"command\":\"git status\"}", + "input": "{\"command\":\"ls\"}", "finished": true } }, diff --git a/plugins/bitfrost/daemon/test/fixtures/grok/hooks.json b/plugins/bitfrost/daemon/test/fixtures/grok/hooks.json new file mode 100644 index 0000000..f38d332 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/grok/hooks.json @@ -0,0 +1,83 @@ +{ + "_note": "Recorded through the process plugin in Grok Build 1.0.52, grok-4.5 low. The command was denied by the bridge; the subsequent native question was answered Blue.", + "events": [ + { + "hookEventName": "pre_tool_use", + "sessionId": "$SESSION", + "cwd": "$CWD", + "workspaceRoot": "$CWD", + "timestamp": "2026-10-15T18:09:34.900382963+00:00", + "transcriptPath": "$CWD/updates.jsonl", + "permissionMode": "bypassPermissions", + "toolName": "run_terminal_command", + "toolUseId": "call-505b27b2-db56-4faa-98cd-c2376f76c3b1-0", + "toolInput": { + "command": "printf bitfrost-hook", + "description": "Print bitfrost-hook exactly once" + }, + "toolInputTruncated": false, + "hook_event_name": "PreToolUse", + "session_id": "$SESSION", + "transcript_path": "$CWD/updates.jsonl", + "permission_mode": "bypassPermissions", + "tool_name": "run_terminal_command", + "tool_input": { + "command": "printf bitfrost-hook", + "description": "Print bitfrost-hook exactly once" + }, + "tool_use_id": "call-505b27b2-db56-4faa-98cd-c2376f76c3b1-0" + }, + { + "hookEventName": "pre_tool_use", + "sessionId": "$SESSION", + "cwd": "$CWD", + "workspaceRoot": "$CWD", + "timestamp": "2026-10-15T18:09:37.114670580+00:00", + "transcriptPath": "$CWD/updates.jsonl", + "permissionMode": "bypassPermissions", + "toolName": "ask_user_question", + "toolUseId": "call-91a2d068-ef11-430a-8cd0-4b50cf48732a-1", + "toolInput": { + "questions": [ + { + "question": "Blue or Red?", + "options": [ + { + "label": "Blue", + "description": "Choose Blue" + }, + { + "label": "Red", + "description": "Choose Red" + } + ] + } + ] + }, + "toolInputTruncated": false, + "hook_event_name": "PreToolUse", + "session_id": "$SESSION", + "transcript_path": "$CWD/updates.jsonl", + "permission_mode": "bypassPermissions", + "tool_name": "ask_user_question", + "tool_input": { + "questions": [ + { + "question": "Blue or Red?", + "options": [ + { + "label": "Blue", + "description": "Choose Blue" + }, + { + "label": "Red", + "description": "Choose Red" + } + ] + } + ] + }, + "tool_use_id": "call-91a2d068-ef11-430a-8cd0-4b50cf48732a-1" + } + ] +} diff --git a/plugins/bitfrost/daemon/test/fixtures/grok/question.json b/plugins/bitfrost/daemon/test/fixtures/grok/question.json new file mode 100644 index 0000000..936bf22 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/grok/question.json @@ -0,0 +1,25 @@ +{ + "_note": "Recorded from Grok Build 1.0.52, grok-4.5 low. Native question response verified live as outcome accepted with answers keyed by question text.", + "method": "_x.ai/ask_user_question", + "params": { + "sessionId": "$SESSION", + "toolCallId": "call-282b1e44-7700-4e73-a20b-fc7d679bb61b-0", + "questions": [ + { + "question": "Which color do you choose?", + "options": [ + { + "label": "Blue", + "description": "Choose Blue" + }, + { + "label": "Red", + "description": "Choose Red" + } + ], + "multiSelect": null + } + ], + "mode": "default" + } +} diff --git a/plugins/bitfrost/daemon/test/fixtures/grok/turn.json b/plugins/bitfrost/daemon/test/fixtures/grok/turn.json new file mode 100644 index 0000000..13e2f49 --- /dev/null +++ b/plugins/bitfrost/daemon/test/fixtures/grok/turn.json @@ -0,0 +1,1190 @@ +{ + "_note": "Recorded from Grok Build 1.0.52, grok-4.5 low. Paths, session ids and response signatures sanitized; unrelated command catalog notifications omitted.", + "initialize": { + "protocolVersion": 1, + "agentCapabilities": { + "loadSession": true, + "promptCapabilities": { + "image": false, + "audio": false, + "embeddedContext": true + }, + "mcpCapabilities": { + "http": true, + "sse": true + }, + "sessionCapabilities": { + "list": {}, + "resume": {}, + "close": {} + }, + "auth": {}, + "_meta": { + "x.ai/fs_notify": true, + "x.ai/hooks": { + "blockingEvents": [ + "pre_tool_use", + "stop", + "subagent_stop" + ], + "decisions": [ + "deny", + "block" + ], + "stopSignals": [ + "continue", + "stopReason", + "additionalContext" + ] + }, + "x.ai/capabilities": { + "toolOverrides": { + "x_keyword_search": true, + "x_semantic_search": true, + "x_user_search": false, + "x_thread_fetch": false + } + } + } + }, + "_meta": { + "modelState": { + "currentModelId": "grok-4.7", + "availableModels": [ + { + "modelId": "grok-4.7", + "name": "Grok 4.7", + "description": "SpaceXAI's latest frontier model", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High", + "description": "Maximum reasoning for the hardest tasks.", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High", + "description": "Thorough reasoning and quality. Recommended.", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Strong quality with a faster turnaround.", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Fastest responses. Best for simple tasks.", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + }, + { + "modelId": "grok-4.7-build-fast", + "name": "Grok 4.7 Fast", + "description": "Fast variant. 2x the price.", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High", + "description": "Maximum reasoning for the hardest tasks.", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High", + "description": "Thorough reasoning and quality. Recommended.", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Strong quality with a faster turnaround.", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Fastest responses. Best for simple tasks.", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + }, + { + "modelId": "grok-4.6", + "name": "Grok 4.6", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High", + "description": "Highest effort and reasoning level", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High", + "description": "Higher implementation quality with extensive reasoning", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Balanced effort with standard implementation and testing", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Quick, fast implementations", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + }, + { + "modelId": "grok-4.5", + "name": "Grok 4.5", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "high", + "value": "high", + "label": "High", + "description": "Highest implementation quality with extensive reasoning", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Balanced effort with standard implementation and testing", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Quick, fast implementations", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + } + ] + } + } + }, + "newSession": { + "result": { + "sessionId": "grok_session", + "models": { + "currentModelId": "grok-4.7", + "availableModels": [ + { + "modelId": "grok-4.7", + "name": "Grok 4.7", + "description": "SpaceXAI's latest frontier model", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High", + "description": "Maximum reasoning for the hardest tasks.", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High", + "description": "Thorough reasoning and quality. Recommended.", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Strong quality with a faster turnaround.", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Fastest responses. Best for simple tasks.", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + }, + { + "modelId": "grok-4.7-build-fast", + "name": "Grok 4.7 Fast", + "description": "Fast variant. 2x the price.", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High", + "description": "Maximum reasoning for the hardest tasks.", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High", + "description": "Thorough reasoning and quality. Recommended.", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Strong quality with a faster turnaround.", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Fastest responses. Best for simple tasks.", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + }, + { + "modelId": "grok-4.6", + "name": "Grok 4.6", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "xhigh", + "value": "xhigh", + "label": "Extra High", + "description": "Highest effort and reasoning level", + "default": false + }, + { + "id": "high", + "value": "high", + "label": "High", + "description": "Higher implementation quality with extensive reasoning", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Balanced effort with standard implementation and testing", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Quick, fast implementations", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + }, + { + "modelId": "grok-4.5", + "name": "Grok 4.5", + "_meta": { + "totalContextTokens": 256000, + "agentType": "grok-build-plan", + "supportsReasoningEffort": true, + "reasoningEffort": "high", + "reasoningEfforts": [ + { + "id": "high", + "value": "high", + "label": "High", + "description": "Highest implementation quality with extensive reasoning", + "default": true + }, + { + "id": "medium", + "value": "medium", + "label": "Medium", + "description": "Balanced effort with standard implementation and testing", + "default": false + }, + { + "id": "low", + "value": "low", + "label": "Low", + "description": "Quick, fast implementations", + "default": false + } + ], + "contextWindows": [ + 256000, + 500000 + ] + } + } + ] + }, + "configOptions": [ + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "grok-4.7", + "options": [ + { + "value": "grok-4.7", + "name": "Grok 4.7" + }, + { + "value": "grok-4.7-build-fast", + "name": "Grok 4.7 Fast" + }, + { + "value": "grok-4.6", + "name": "Grok 4.6" + }, + { + "value": "grok-4.5", + "name": "Grok 4.5" + } + ] + }, + { + "id": "reasoning_effort", + "name": "Reasoning Effort", + "category": "thought_level", + "type": "select", + "currentValue": "high", + "options": [ + { + "value": "xhigh", + "name": "Extra High", + "description": "Maximum reasoning for the hardest tasks." + }, + { + "value": "high", + "name": "High", + "description": "Thorough reasoning and quality. Recommended." + }, + { + "value": "medium", + "name": "Medium", + "description": "Strong quality with a faster turnaround." + }, + { + "value": "low", + "name": "Low", + "description": "Fastest responses. Best for simple tasks." + } + ] + } + ] + } + }, + "turns": [ + [ + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "I'll" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " run" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " the" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " shell" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " command" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " and" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " create" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " `" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "notes" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": ".txt" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "`" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": " now" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "." + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_delta_chunk", + "tool_call_id": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0", + "tool_index": 0, + "name": "run_terminal_command" + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_delta_chunk", + "tool_index": 0, + "arguments_delta": "{\"command\":\"echo hi > /tmp/grokprobe.txt\",\"description\":\"Write hi to /tmp/grokprobe.txt\"}" + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_delta_chunk", + "tool_call_id": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-1", + "tool_index": 1, + "name": "write" + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_delta_chunk", + "tool_index": 1, + "arguments_delta": "{\"file_path\":\"$CWD/notes.txt\",\"content\":\"apple\\n\"}" + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "response_completed", + "usage": { + "input_tokens": 17292, + "output_tokens": 163, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "reasoning_tokens": 50 + }, + "signature": "recorded-response-19" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0", + "title": "run_terminal_command", + "rawInput": { + "command": "echo hi > /tmp/grokprobe.txt", + "description": "Write hi to /tmp/grokprobe.txt" + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "run_terminal_command", + "kind": "execute", + "namespace": "grok_build", + "label": "Run Command", + "read_only": false, + "input": { + "command": "echo hi > /tmp/grokprobe.txt", + "description": "Write hi to /tmp/grokprobe.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "pending_interaction", + "tool_call_id": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0", + "kind": "permission" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0", + "kind": "execute", + "title": "Execute `echo hi > /tmp/grokprobe.txt`", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Write hi to /tmp/grokprobe.txt" + } + } + ], + "locations": [], + "rawInput": { + "variant": "Bash", + "command": "echo hi > /tmp/grokprobe.txt", + "description": "Write hi to /tmp/grokprobe.txt", + "is_background": false + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "run_terminal_command", + "kind": "execute", + "namespace": "grok_build", + "label": "Run Command", + "read_only": false, + "input": { + "command": "echo hi > /tmp/grokprobe.txt", + "description": "Write hi to /tmp/grokprobe.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "interaction_resolved", + "tool_call_id": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-1", + "title": "write", + "rawInput": { + "file_path": "$CWD/notes.txt", + "content": "apple\n" + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "write", + "kind": "write", + "namespace": "opencode", + "label": "Write", + "read_only": false, + "input": { + "path": "$CWD/notes.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "pending_interaction", + "tool_call_id": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-1", + "kind": "permission" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-1", + "kind": "edit", + "title": "Write `$CWD/notes.txt`", + "content": [ + { + "type": "diff", + "path": "$CWD/notes.txt", + "oldText": "", + "newText": "apple\n" + } + ], + "locations": [ + { + "path": "$CWD/notes.txt" + } + ], + "rawInput": { + "variant": "Write", + "file_path": "$CWD/notes.txt", + "content": "apple\n" + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "write", + "kind": "write", + "namespace": "opencode", + "label": "Write", + "read_only": false, + "input": { + "path": "$CWD/notes.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "interaction_resolved", + "tool_call_id": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-1" + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0", + "status": "in_progress", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "" + } + } + ], + "rawOutput": { + "type": "Bash", + "output": [], + "output_for_prompt": "", + "exit_code": 0, + "command": "echo hi > /tmp/grokprobe.txt", + "truncated": false, + "signal": null, + "timed_out": false, + "description": null, + "current_dir": "$CWD", + "output_file": "", + "total_bytes": 0 + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "run_terminal_command", + "kind": "execute", + "namespace": "grok_build", + "label": "Run Command", + "read_only": false, + "input": { + "command": "echo hi > /tmp/grokprobe.txt", + "description": "Write hi to /tmp/grokprobe.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-1", + "status": "completed", + "content": [ + { + "type": "diff", + "path": "$CWD/notes.txt", + "oldText": "", + "newText": "apple\n", + "_meta": { + "details": [ + { + "old_string": "", + "old_line": 1, + "new_string": "apple\n", + "new_line": 1, + "context_before": "", + "context_after": "", + "line_prefix": "" + } + ] + } + } + ], + "rawOutput": { + "type": "SearchReplace", + "EditsApplied": { + "old_string": "", + "new_string": "apple\n", + "tool_output_for_prompt": "The file $CWD/notes.txt has been created.", + "tool_output_for_prompt_concise": "The file $CWD/notes.txt has been created.", + "absolute_path": "$CWD/notes.txt", + "edits": { + "details": [ + { + "old_string": "", + "old_line": 1, + "new_string": "apple\n", + "new_line": 1, + "context_before": "", + "context_after": "", + "line_prefix": "" + } + ] + } + } + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "write", + "kind": "write", + "namespace": "opencode", + "label": "Write", + "read_only": false, + "input": { + "path": "$CWD/notes.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-20cc7319-72dc-429f-9841-5e5a7fc19871-0", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "" + } + } + ], + "rawOutput": { + "type": "Bash", + "output": [], + "output_for_prompt": "exit: 0\n", + "exit_code": 0, + "command": "echo hi > /tmp/grokprobe.txt", + "truncated": false, + "signal": null, + "timed_out": false, + "description": "Write hi to /tmp/grokprobe.txt", + "current_dir": "$CWD", + "output_file": "/recorded/sessions/%2Ftmp%2Fclaude-1000%2F-home-nick-Projects-bitfrost%2F05ea69d9-e945-48ca-8af8-5313d85b9dd5%2Fscratchpad%2Fprobe-cwd/$SESSION/terminal/call-20cc7319-72dc-429f-9841-5e5a7fc19871-0.log", + "total_bytes": 0 + }, + "_meta": { + "x.ai/tool": { + "version": 1, + "name": "run_terminal_command", + "kind": "execute", + "namespace": "grok_build", + "label": "Run Command", + "read_only": false, + "input": { + "command": "echo hi > /tmp/grokprobe.txt", + "description": "Write hi to /tmp/grokprobe.txt" + } + } + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "DONE" + } + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "response_completed", + "usage": { + "input_tokens": 252, + "output_tokens": 11, + "cache_read_input_tokens": 17280, + "cache_creation_input_tokens": 0, + "reasoning_tokens": 10 + }, + "signature": "recorded-response-32" + } + } + }, + { + "jsonrpc": "2.0", + "method": "_x.ai/session_notification", + "params": { + "sessionId": "$SESSION", + "update": { + "sessionUpdate": "turn_completed", + "prompt_id": "e307fcc5-a780-4524-8b53-9cfcb64817a7", + "stop_reason": "end_turn", + "usage": { + "inputTokens": 34824, + "outputTokens": 174, + "totalTokens": 34998, + "cachedReadTokens": 17280, + "cacheCreationTokens": 0, + "reasoningTokens": 60, + "modelCalls": 2, + "apiDurationMs": 4240, + "costUsdTicks": 140474400, + "modelUsage": { + "grok-4.5-build": { + "inputTokens": 34824, + "outputTokens": 174, + "totalTokens": 34998, + "cachedReadTokens": 17280, + "cacheCreationTokens": 0, + "reasoningTokens": 60, + "modelCalls": 2, + "apiDurationMs": 4240, + "costUsdTicks": 140474400 + } + }, + "numTurns": 2 + }, + "elapsed_ms": 9859 + } + } + }, + { + "result": { + "stopReason": "end_turn", + "_meta": { + "sessionId": "$SESSION", + "requestId": "e307fcc5-a780-4524-8b53-9cfcb64817a7", + "promptId": "e307fcc5-a780-4524-8b53-9cfcb64817a7", + "totalTokens": 17552, + "modelId": "grok-4.5", + "inputTokens": 17532, + "outputTokens": 11, + "cachedReadTokens": 17280, + "reasoningTokens": 10, + "usage": { + "inputTokens": 34824, + "outputTokens": 174, + "totalTokens": 34998, + "cachedReadTokens": 17280, + "cacheCreationTokens": 0, + "reasoningTokens": 60, + "modelCalls": 2, + "apiDurationMs": 4240, + "costUsdTicks": 140474400, + "modelUsage": { + "grok-4.5-build": { + "inputTokens": 34824, + "outputTokens": 174, + "totalTokens": 34998, + "cachedReadTokens": 17280, + "cacheCreationTokens": 0, + "reasoningTokens": 60, + "modelCalls": 2, + "apiDurationMs": 4240, + "costUsdTicks": 140474400 + } + }, + "numTurns": 2 + } + } + } + } + ] + ] +} diff --git a/plugins/bitfrost/daemon/test/grok.spec.ts b/plugins/bitfrost/daemon/test/grok.spec.ts new file mode 100644 index 0000000..a52f35d --- /dev/null +++ b/plugins/bitfrost/daemon/test/grok.spec.ts @@ -0,0 +1,376 @@ +// Copyright (C) 2026 Nick Germaine +// SPDX-License-Identifier: GPL-3.0-only +// +// BitFrost is free software: you can share and change it under the GNU General +// Public License, version 3 only. It comes with no warranty. See LICENSE. + +import test from 'node:test' +import assert from 'node:assert/strict' +import { spawn as child } from 'node:child_process' +import fs from 'node:fs' +import http from 'node:http' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { GrokProvider, grokProvider, grokReadOnlyCommand, grokEndReason } from '../providers/grok.ts' +import { Session } from '../session.ts' + +type Json = any +const HERE = path.dirname(fileURLToPath(import.meta.url)) +const fixture = (name: string) => JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'grok', name), 'utf8')) +const events = (s: Session, type: string): Json[] => s.events.filter((e) => e.type === type) +const last = (s: Session): Json => events(s, 'turn_completed').at(-1) +const token = 'test-grok-token' +const update = (u: Json, method = 'session/update') => ({ method, params: { sessionId: '$SESSION', update: u } }) +const notification = (u: Json) => update(u, '_x.ai/session_notification') + +async function until(ready: () => boolean) { + const deadline = Date.now() + 8000 + while (!ready()) { + if (Date.now() > deadline) throw new Error('Timed out waiting for Grok') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function fake(t: Json, turns?: Json[][], extra: Json = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfrost-grok-test-')) + const file = path.join(dir, 'scenario.json') + const log = path.join(dir, 'sent.jsonl') + const scenario = { ...fixture('turn.json'), ...(turns ? { turns } : {}), ...extra } + fs.writeFileSync(file, JSON.stringify(scenario)) + const messages: string[] = [] + const provider = new GrokProvider({ bin: process.execPath, args: [path.join(HERE, 'fake-grok.mjs'), file], env: { BITFROST_GROK_LOG: log, BITFROST_GROK_CWD: dir } }, { log: (s) => messages.push(s), token }) + t.after(() => { + provider.dispose() + setTimeout(() => fs.rmSync(dir, { recursive: true, force: true }), 100).unref() + }) + const sent = (): Json[] => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map((s) => JSON.parse(s)) : [] + const session = new Session({ id: '', harness: 'grok', agent: 'test', model: 'grok-4.5', cwd: dir, state: 'running' }) + const start = (canAskUser = true) => provider.spawnSession(session, { cwd: dir, model: 'grok-4.5', effort: 'low', prompt: 'A tiny test.', canAskUser }) + const hook = (toolName: string, toolInput: Json = {}, extra: Json = {}) => provider.bridge(token, { sessionId: 'grok_session', hookEventName: 'pre_tool_use', toolName, toolInput, cwd: dir, toolUseId: 'tool-1', ...extra }) + return { provider, session, start, hook, sent, dir, messages } +} + +test('Grok lists initialization models and efforts without creating a session or exposing other vendors', async (t) => { + const base = fixture('turn.json') + base.initialize._meta.modelState.availableModels.push({ modelId: 'anthropic/claude-opus', name: 'Claude' }) + const r = fake(t, [], { initialize: base.initialize }) + const models = await r.provider.listModels() + assert.equal(models.length, 4) + assert.ok(models.every((m) => m.provider === 'xAI' && m.harness === 'grok')) + assert.deepEqual(models.find((m) => m.model === 'grok-4.5')?.efforts, ['high', 'medium', 'low']) + assert.deepEqual(models[0].efforts, ['xhigh', 'high', 'medium', 'low']) + assert.equal(models[0].defaultEffort, 'high') + assert.equal(models[0].isDefault, true) + assert.match(models[0].description, /256k context/) + assert.deepEqual(r.sent().map((m) => m.method), ['initialize']) + assert.equal(r.provider.capabilities.steer, false) +}) + +test('a recorded turn reports text, command output, file diffs and each request usage once', async (t) => { + const base = fixture('turn.json') + const steps = base.turns[0] + const usage = steps.find((s: Json) => s.params?.update?.sessionUpdate === 'response_completed') + steps.splice(steps.indexOf(usage) + 1, 0, structuredClone(usage)) + steps.unshift(update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'Thinking.' } })) + const r = fake(t, [steps]) + await r.start() + await until(() => !!last(r.session)) + assert.equal(last(r.session).status, 'completed') + assert.equal(last(r.session).reason, 'end_turn') + assert.ok(last(r.session).finalText) + assert.equal(events(r.session, 'reasoning')[0].text, 'Thinking.') + assert.equal(events(r.session, 'command_started').length, 1) + assert.equal(events(r.session, 'command_completed')[0].exitCode, 0) + assert.equal(events(r.session, 'command_completed')[0].output, 'exit: 0\n') + assert.equal(events(r.session, 'file_change').length, 1) + assert.match(events(r.session, 'file_change')[0].changes[0].diff, /\+apple/) + const used = events(r.session, 'usage') + assert.equal(used.length, 2) + assert.equal(new Set(used.map((u) => u.requestId)).size, 2) + assert.deepEqual(used.map((u) => [u.inputTokens, u.outputTokens, u.cachedInputTokens]), [[17292, 163, 0], [17532, 11, 17280]]) + assert.deepEqual(r.sent().find((m) => m.method === 'session/new').params._meta, { yoloMode: true }) + assert.deepEqual(r.sent().filter((m) => m.method === 'session/set_config_option').map((m) => [m.params.configId, m.params.value]), [['model', 'grok-4.5'], ['reasoning_effort', 'low']]) +}) + +test('the bridge allows reads and workspace writes and gates commands with exact session grants and denial reasons', async (t) => { + const r = fake(t, [[{ waitCancel: true }]]) + await r.start() + await until(() => r.sent().some((m) => m.method === 'session/prompt')) + assert.deepEqual(await r.hook('read_file', { file_path: '/etc/hosts' }), { decision: 'allow' }) + assert.deepEqual(await r.hook('write', { file_path: 'new/file.txt', content: 'ok' }), { decision: 'allow' }) + assert.deepEqual(await r.hook('run_terminal_command', { command: 'ls | wc -l' }), { decision: 'allow' }) + const command = { command: 'npm test' } + const first = r.hook('run_terminal_command', command) + let id = r.provider.pendingApprovals(r.session)[0] + assert.ok(id) + assert.equal(r.provider.resolveApproval(r.session, id, 'allow_session'), true) + assert.deepEqual(await first, { decision: 'allow' }) + assert.deepEqual(await r.hook('run_terminal_command', command), { decision: 'allow' }) + const other = r.hook('run_terminal_command', { command: 'npm run deploy' }) + id = r.provider.pendingApprovals(r.session)[0] + assert.equal(events(r.session, 'approval_requested').at(-1).tool, 'Bash') + r.provider.resolveApproval(r.session, id, 'deny', 'Do not deploy this branch.') + assert.deepEqual(await other, { decision: 'deny', reason: 'Do not deploy this branch.' }) + assert.equal(r.provider.resolveApproval(r.session, id, 'allow'), false) + const outside = r.hook('write', { file_path: '../outside.txt', content: 'no' }) + id = r.provider.pendingApprovals(r.session)[0] + r.provider.resolveApproval(r.session, id, 'allow') + assert.deepEqual(await outside, { decision: 'allow' }) + const recorded = fixture('hooks.json').events[0] + const replay = r.provider.bridge(token, { ...recorded, sessionId: 'grok_session', cwd: r.dir }) + id = r.provider.pendingApprovals(r.session)[0] + r.provider.resolveApproval(r.session, id, 'deny', 'Recorded denial.') + assert.deepEqual(await replay, { decision: 'deny', reason: 'Recorded denial.' }) + await r.provider.interrupt(r.session) + await until(() => !!last(r.session)) +}) + +test('the bridge refuses nested agents, scheduled work, forged calls, symlink escapes and noninteractive actions', async (t) => { + const r = fake(t, [[{ waitCancel: true }]]) + await r.start(false) + for (const name of ['task', 'spawn_subagent', 'Agent', 'workflow', 'scheduler_create', 'loop', 'update_goal', 'CronCreate']) assert.equal((await r.hook(name)).decision, 'deny', name) + for (const name of ['get_command_or_subagent_output', 'kill_command_or_subagent', 'wait_tasks', 'todo_write', 'skills']) assert.equal((await r.hook(name)).decision, 'allow', name) + assert.match((await r.hook('run_terminal_command', { command: 'rm x' })).reason, /without asking/) + fs.symlinkSync(os.tmpdir(), path.join(r.dir, 'escape')) + assert.equal((await r.hook('write', { file_path: 'escape/elsewhere.txt' })).decision, 'deny') + assert.equal((await r.hook('write', { file_path: 'local' }, { cwd: '/tmp' })).decision, 'deny') + assert.equal((await r.provider.bridge('forged', {})).decision, 'deny') + assert.equal((await r.hook('read_file', {}, { sessionId: 'other' })).decision, 'deny') + assert.equal((await r.hook('write', {}, { toolInputTruncated: true })).decision, 'deny') + assert.equal((await r.hook('ask_user_question')).decision, 'deny') + assert.equal(r.provider.pendingApprovals(r.session).length, 0) +}) + +test('native Grok questions use BitFrost question ids and return answers keyed by question text', async (t) => { + const q = fixture('question.json') + const r = fake(t, [[{ ...q, request: true }]]) + await r.start() + await until(() => r.provider.pendingQuestions(r.session).length > 0) + const asked = events(r.session, 'question_asked')[0] + assert.deepEqual(asked.questions[0].options.map((o: Json) => o.label), ['Blue', 'Red']) + assert.equal(await r.provider.answerQuestion(r.session, asked.questionId, { q0: ['Blue'] }, false), true) + await until(() => !!last(r.session)) + assert.deepEqual(r.sent().find((m) => m.id === 0 && !m.method).result, { outcome: 'accepted', answers: { 'Which color do you choose?': ['Blue'] } }) + assert.equal(events(r.session, 'question_answered')[0].how, 'answered') +}) + +test('deferring a question stops the turn and keeps queued lead input', async (t) => { + const r = fake(t, [[{ ...fixture('question.json'), request: true }, { waitCancel: true }]]) + await r.start() + await until(() => r.provider.pendingQuestions(r.session).length > 0) + const receipt = await r.session.deliver(r.provider, 'The lead will answer.') + const id = r.provider.pendingQuestions(r.session)[0] + await r.provider.answerQuestion(r.session, id, null, true) + await until(() => events(r.session, 'turn_completed').length >= 2) + assert.equal(events(r.session, 'turn_completed')[0].reason, 'interrupted') + assert.equal(r.session.events.filter((e) => e.type === 'input_consumed' && e.inputId === receipt.inputId).length, 1) + assert.equal(r.session.events.filter((e) => e.type === 'input_dropped' && e.inputId === receipt.inputId).length, 0) + assert.equal(r.sent().find((m) => m.id === 0 && !m.method).result.outcome, 'skip_interview') +}) + +test('questions without a user are declined and ACP permission requests still work', async (t) => { + const q = fake(t, [[{ ...fixture('question.json'), request: true }]]) + await q.start(false) + await until(() => !!last(q.session)) + assert.equal(q.sent().find((m) => m.id === 0 && !m.method).result.outcome, 'skip_interview') + assert.equal(events(q.session, 'question_asked').length, 0) + const r = fake(t, [[{ request: true, method: 'session/request_permission', params: { sessionId: '$SESSION', toolCall: { toolCallId: 'cmd', kind: 'execute', rawInput: { command: 'npm test' } }, options: [{ kind: 'allow_once', optionId: 'yes' }, { kind: 'reject_once', optionId: 'no' }] } }]]) + await r.start() + await until(() => r.provider.pendingApprovals(r.session).length > 0) + r.provider.resolveApproval(r.session, r.provider.pendingApprovals(r.session)[0], 'allow') + await until(() => !!last(r.session)) + assert.equal(r.sent().find((m) => m.id === 0 && !m.method).result.outcome.optionId, 'yes') +}) + +test('interrupt cancels the turn and releases a waiting hook; input while busy is queued', async (t) => { + const r = fake(t, [[{ waitCancel: true }]]) + await r.start() + await until(() => r.sent().some((m) => m.method === 'session/prompt')) + assert.equal(await r.provider.sendInput(r.session, 'Later'), 'queued') + const pending = r.hook('run_terminal_command', { command: 'npm test' }) + await r.provider.interrupt(r.session) + assert.equal((await pending).decision, 'deny') + await until(() => !!last(r.session)) + assert.equal(events(r.session, 'turn_completed')[0].reason, 'interrupted') + assert.equal(r.provider.pendingApprovals(r.session).length, 0) + assert.ok(r.sent().some((m) => m.method === 'session/cancel')) +}) + +test('model drift through both Grok and ACP notifications stops the turn', async (t) => { + for (const step of [notification({ sessionUpdate: 'model_changed', model_id: 'grok-4.6' }), update({ sessionUpdate: 'config_option_update', configOptions: [{ category: 'model', type: 'select', currentValue: 'grok-4.6' }] })]) { + const r = fake(t, [[step, { waitCancel: true }]]) + await r.start() + await until(() => !!last(r.session)) + assert.equal(last(r.session).reason, 'wrong_model') + assert.match(last(r.session).error, /instead of grok-4.5/) + } +}) + +test('resume loads the native session, reapplies model and effort, and closes disposed sessions', async (t) => { + const r = fake(t, [[]]) + await r.start() + await until(() => !!last(r.session)) + const ref = r.session.nativeRef + assert.equal(ref.acpSessionId, 'grok_session') + const s = new Session({ ...r.session.info, state: 'running' }) + const resumed = fake(t, [[]]) + await resumed.provider.attach(s, ref) + assert.equal(await resumed.provider.sendInput(s, 'Continue.'), 'started') + await until(() => !!last(s)) + assert.deepEqual(resumed.sent().find((m) => m.method === 'session/load').params, { sessionId: 'grok_session', cwd: r.dir, mcpServers: [], _meta: { yoloMode: true } }) + assert.deepEqual(resumed.sent().filter((m) => m.method === 'session/set_config_option').map((m) => m.params.value), ['grok-4.5', 'low']) + resumed.provider.disposeSession(s) + await until(() => resumed.sent().some((m) => m.method === 'session/close')) +}) + +test('authentication failures name grok login and prompt errors have useful end reasons', async (t) => { + const r = fake(t, [], { newSession: { error: { code: -32000, message: 'Not authenticated' } } }) + await assert.rejects(r.start(), /Run grok login/) + for (const [message, expected] of [['401 unauthorized', 'auth'], ['Rate limited 429', 'rate_limited'], ['Insufficient quota', 'quota_exhausted'], ['context window limit', 'max_tokens'], ['max_turn_requests', 'max_requests'], ['refusal', 'refusal']]) { + const r = fake(t, [[{ error: { code: -32603, message } }]]) + await r.start() + await until(() => !!last(r.session)) + assert.equal(last(r.session).reason, expected) + if (expected === 'auth') assert.match(last(r.session).error, /grok login/) + } + for (const reason of ['max_tokens', 'max_turn_requests', 'refusal', 'rate_limited', 'quota_exhausted']) assert.notEqual(grokEndReason(reason, null), 'error') +}) + +test('a process crash fails the turn and releases pending bridge calls', async (t) => { + const r = fake(t, [[{ delay: 80 }, { crash: 7 }]]) + await r.start() + const hook = r.hook('run_terminal_command', { command: 'npm test' }) + await until(() => !!last(r.session)) + assert.equal(last(r.session).reason, 'crashed') + assert.equal((await hook).decision, 'deny') + assert.equal(r.provider.pendingApprovals(r.session).length, 0) +}) + +test('equal-sized requests retain distinct identities and cache creation counts toward inclusive input', async (t) => { + const usage = { input_tokens: 10, output_tokens: 3, cache_read_input_tokens: 20, cache_creation_input_tokens: 5, reasoning_tokens: 2 } + const r = fake(t, [[ + notification({ sessionUpdate: 'response_completed', signature: 'one', usage }), + notification({ sessionUpdate: 'response_completed', signature: 'two', usage }), + notification({ sessionUpdate: 'response_completed', signature: 'two', usage }), + update({ sessionUpdate: 'usage_update', used: 99999 }), + { method: '_x.ai/queue/changed', params: {} }, + { method: '_x.ai/models/update', params: {} }, + { result: { stopReason: 'end_turn', _meta: { usage: { inputTokens: 70, outputTokens: 6, modelCalls: 2 } } } }, + ]]) + await r.start() + await until(() => !!last(r.session)) + const used = events(r.session, 'usage') + assert.equal(used.length, 2) + assert.deepEqual(used.map((u) => [u.inputTokens, u.cachedInputTokens, u.outputTokens]), [[35, 20, 3], [35, 20, 3]]) + assert.notEqual(used[0].requestId, used[1].requestId) + assert.deepEqual(r.messages, []) +}) + +test('turn usage falls back to reported totals once and Grok stop reasons classify failures', async (t) => { + const total = { inputTokens: 42, cachedReadTokens: 20, outputTokens: 9, modelCalls: 1, modelUsage: { 'grok-4.5-build': { inputTokens: 42 } } } + const r = fake(t, [[notification({ sessionUpdate: 'turn_completed', stop_reason: 'end_turn', usage: total }), { result: { stopReason: 'end_turn', _meta: { usage: total } } }]]) + await r.start() + await until(() => !!last(r.session)) + assert.equal(last(r.session).reason, 'end_turn') + assert.equal(events(r.session, 'usage').length, 1) + assert.deepEqual(events(r.session, 'usage')[0].billing, { inputTokens: 42, cachedInputTokens: 20, outputTokens: 9, requests: 1 }) + for (const [stop, reason] of [['max_tokens', 'max_tokens'], ['max_turn_requests', 'max_requests'], ['refusal', 'refusal'], ['rate_limited', 'rate_limited'], ['quota_exhausted', 'quota_exhausted'], ['auth', 'auth']]) { + const r = fake(t, [[{ result: { stopReason: stop } }]]) + await r.start() + await until(() => !!last(r.session)) + assert.equal(last(r.session).status, 'failed') + assert.equal(last(r.session).reason, reason) + } +}) + +test('wrong model selection is refused before prompting and final model metadata is checked', async (t) => { + const r = fake(t, [[]], { reportModel: 'grok-4.6' }) + await assert.rejects(r.start(), /instead of grok-4.5/) + assert.ok(!r.sent().some((m) => m.method === 'session/prompt')) + const done = fake(t, [[{ result: { stopReason: 'end_turn', _meta: { modelId: 'grok-4.6' } } }]]) + await done.start() + await until(() => !!last(done.session)) + assert.equal(last(done.session).reason, 'wrong_model') +}) + +test('structured RPC error details and status codes retain failure classification', async (t) => { + for (const [error, reason] of [[{ code: -32603, message: 'Request failed', data: { detail: 'Not authenticated' } }, 'auth'], [{ code: 429, message: 'Request failed' }, 'rate_limited'], [{ code: -32603, message: 'Request failed', data: { code: 'insufficient_quota' } }, 'quota_exhausted']]) { + const r = fake(t, [[{ error }]]) + await r.start() + await until(() => !!last(r.session)) + assert.equal(last(r.session).reason, reason) + } +}) + +test('read-only shell recognition rejects mutations, substitutions and executable options', () => { + for (const command of ['ls', 'cat file | head -10', 'pwd && whoami; rg hello .', 'sort -n file | uniq', 'uniq -c file']) assert.equal(grokReadOnlyCommand(command), true, command) + for (const command of ['ls > x', 'ls && rm x', 'ls &', 'cat $(touch x)', 'rg --pre=sh x', 'rg --hostname-bin=sh x', 'sort --compress-program=sh x', 'sort -o x file', 'uniq file ../out', 'uniq - out', 'uniq -- -a -b', 'date -s now', 'hostname newname', 'git -c core.fsmonitor=sh status', 'git diff', 'kubectl get pods', 'ls\nrm x']) assert.equal(grokReadOnlyCommand(command), false, command) +}) + +test('the factory installs a process hook, respects binary precedence and notices symlink updates', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfrost-grok-factory-')) + t.after(() => fs.rmSync(dir, { recursive: true, force: true })) + const bin = path.join(dir, 'grok') + fs.symlinkSync(process.execPath, bin) + const old = process.env.BITFROST_GROK_BIN + process.env.BITFROST_GROK_BIN = bin + t.after(() => { + if (old === undefined) delete process.env.BITFROST_GROK_BIN + else process.env.BITFROST_GROK_BIN = old + }) + const env = { runDir: dir, socket: path.join(dir, 'daemon.sock'), log: () => {}, config: { bin: '/missing' }, resolveBinary: () => '/missing', recorder: () => null } + const p = grokProvider.create(env)! + assert.equal(p.location, fs.realpathSync(process.execPath)) + assert.equal(grokProvider.optIn, true) + assert.equal(p.moved!(), false) + const launch = (p as Json).launch + assert.deepEqual(launch.args, ['agent', '--no-leader', '--plugin-dir', path.join(dir, 'grok-plugin'), 'stdio']) + assert.equal(launch.env.GROK_DISABLE_AUTOUPDATER, '1') + assert.equal(launch.env.BITFROST_NODE, process.execPath) + const hook = JSON.parse(fs.readFileSync(path.join(dir, 'grok-plugin', 'hooks', 'hooks.json'), 'utf8')).hooks.PreToolUse[0].hooks[0] + assert.match(hook.command, /BITFROST_NODE/) + assert.ok(hook.timeout >= 86400) + fs.unlinkSync(bin) + fs.symlinkSync('/bin/sh', bin) + assert.equal(p.moved!(), true) + delete process.env.BITFROST_GROK_BIN + assert.equal(grokProvider.detect!({ ...env, config: { bin: process.execPath } }), fs.realpathSync(process.execPath)) + assert.equal(grokProvider.detect!({ ...env, config: {}, resolveBinary: () => process.execPath }), fs.realpathSync(process.execPath)) +}) + +test('the hook posts the token and payload and fails closed on unreachable or invalid replies', async (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitfrost-grok-hook-')) + const socket = path.join(dir, 'hook.sock') + let reply = JSON.stringify({ decision: 'deny', reason: 'No deployment.' }) + let received: Json + const server = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => { body += c }) + req.on('end', () => { + received = { url: req.url, token: req.headers['x-bitfrost-token'], body: JSON.parse(body) } + res.end(reply) + }) + }) + await new Promise((resolve) => server.listen(socket, resolve)) + t.after(() => { + server.close() + server.closeAllConnections() + fs.rmSync(dir, { recursive: true, force: true }) + }) + const call = (sock: string) => new Promise((resolve, reject) => { + const p = child(process.execPath, [path.join(HERE, '..', 'providers', 'grok-hook.mjs')], { env: { ...process.env, BITFROST_SOCKET: sock, BITFROST_GROK_TOKEN: token } }) + let out = '' + p.stdout.on('data', (c) => { out += c }) + p.on('error', reject) + p.on('exit', (code) => resolve({ code, reply: JSON.parse(out) })) + p.stdin.end(JSON.stringify({ toolName: 'write', sessionId: 'native-id' })) + }) + assert.deepEqual((await call(socket)).reply, JSON.parse(reply)) + assert.deepEqual(received, { url: '/providers/grok/bridge', token, body: { toolName: 'write', sessionId: 'native-id' } }) + reply = '' + assert.equal((await call(socket)).reply.decision, 'deny') + const missing = await call(path.join(dir, 'missing.sock')) + assert.equal(missing.code, 2) + assert.match(missing.reply.reason, /not reachable/) +}) diff --git a/plugins/bitfrost/daemon/test/store.spec.ts b/plugins/bitfrost/daemon/test/store.spec.ts index 912bc8d..6277355 100644 --- a/plugins/bitfrost/daemon/test/store.spec.ts +++ b/plugins/bitfrost/daemon/test/store.spec.ts @@ -71,6 +71,24 @@ test('push writes sessions, native refs, turns, events, messages and usage throu } finally { reader.close() } }) +test('the startup sweep lists each owned session process once, then forgets closed ones', (t) => { + const { store, session } = setup(t) + const owner = { store: '/data/bitfrost.db', pid: 1234, startTime: '1' } + const open = session('open') + open.setNativeRef({ conversationId: 'c1', process: { pgid: 501, startTime: '9', token: 'a', marker: 'BITFROST_AGY_PROC', owner } }) + // Records without an owner may come from a copied store, so they are skipped. + session('old').setNativeRef({ process: { pgid: 502, startTime: '9', token: 'b' } }) + const closed = session('closed') + closed.info.closedAt = Date.now() + closed.setNativeRef({ conversationId: 'c3', process: { pgid: 503, startTime: '9', token: 'c', owner } }) + const tokens = () => store.processes().map((r) => [r.pgid, JSON.parse(r.process_identity).token]) + assert.deepStrictEqual(tokens().sort(), [[501, 'a'], [503, 'c']]) + store.forgetProcesses() + assert.deepStrictEqual(tokens(), [[501, 'a']]) + const saved = store.db.prepare("SELECT native_ref FROM sessions WHERE id='closed'").get() as any + assert.deepStrictEqual(JSON.parse(saved.native_ref), { conversationId: 'c3' }) +}) + test('details are capped with a truncation marker and completion-only tools are retained', (t) => { const { store, session } = setup(t) const s = session() diff --git a/plugins/bitfrost/daemon/test/tui-rpc.spec.ts b/plugins/bitfrost/daemon/test/tui-rpc.spec.ts index 03ba25d..d83429d 100644 --- a/plugins/bitfrost/daemon/test/tui-rpc.spec.ts +++ b/plugins/bitfrost/daemon/test/tui-rpc.spec.ts @@ -18,12 +18,23 @@ import { useTestSettings } from './fixtures/settings.ts' const model = (harness: string, provider: string, id: string) => ({ harness, provider, model: id, displayName: id, description: '', efforts: [], defaultEffort: null, isDefault: false }) -test('whyNot: Anthropic, deny lists, then allow lists', () => { - const own = new Map([['codex', 'OpenAI']]) - const allow = new Map([['opencode', ['opencode-go/*']]]) +test('whyNot: deny lists, allow lists, and Claude models only once named', () => { + const own = new Map([['codex', 'OpenAI'], ['agy', 'Google']]) + const allow = new Map([['opencode', ['opencode-go/*', 'anthropic/claude-opus-5']]]) const deny = new Map([['codex', ['gpt-old*']]]) - assert.equal(whyNot(model('opencode', 'Anthropic', 'claude'), own, allow, deny), 'anthropic') - // Only Anthropic is held back; another company's model runs through any app that lists it. + const anthropic = new Map([['agy', ['claude-sonnet-4-6']]]) + // A Claude model runs through an app that lists it, like any other company's model. + assert.equal(whyNot(model('opencode', 'Anthropic', 'anthropic/claude-opus-5'), own, allow, deny), null) + assert.equal(whyNot(model('opencode', 'Anthropic', 'anthropic/claude-haiku-5'), own, allow, deny), 'not_listed') + // A wildcard alone never turns a Claude model on; a pattern naming Claude does. + assert.equal(whyNot(model('crush', 'Anthropic', 'anthropic/claude-haiku-5'), own, new Map([['crush', ['*']]]), deny), 'not_listed') + assert.equal(whyNot(model('crush', 'OpenAI', 'openai/gpt-x'), own, new Map([['crush', ['*']]]), deny), null) + assert.equal(whyNot(model('crush', 'Anthropic', 'anthropic/claude-haiku-5'), own, new Map([['crush', ['anthropic/*']]]), deny), null) + // An app's own list offers everything but Claude models, which wait for the anthropic list. + assert.equal(whyNot(model('agy', 'Google', 'gemini-3.8-flash'), own, allow, deny, anthropic), null) + assert.equal(whyNot(model('agy', 'Anthropic', 'claude-sonnet-4-6'), own, allow, deny, anthropic), null) + assert.equal(whyNot(model('agy', 'Anthropic', 'claude-opus-4-6-thinking'), own, allow, deny, anthropic), 'not_listed') + assert.equal(whyNot(model('agy', 'Anthropic', 'claude-sonnet-4-6'), own, allow, new Map([['agy', ['claude-*']]]), anthropic), 'disabled') assert.equal(whyNot(model('opencode', 'OpenAI', 'opencode-go/gpt-x'), own, allow, deny), null) assert.equal(whyNot(model('opencode', 'OpenAI', 'gpt-x'), own, allow, deny), 'not_listed') assert.equal(whyNot(model('codex', 'OpenAI', 'gpt-old-1'), own, allow, deny), 'disabled') @@ -33,6 +44,24 @@ test('whyNot: Anthropic, deny lists, then allow lists', () => { assert.equal(whyNot(model('omp', 'Moonshot', 'kimi'), own, allow, deny), 'not_listed') }) +test('setModel: a Claude model in an own-company app goes in and out of the anthropic list', () => { + const raw: any = { providers: { agy: { disabled: ['claude-sonnet-4-6', 'gemini-old'] } } } + setModel(raw, 'agy', 'claude-sonnet-4-6', true, 'deny', [], true) + assert.deepEqual(raw.providers.agy, { disabled: ['gemini-old'], anthropic: ['claude-sonnet-4-6'] }) + setModel(raw, 'agy', 'claude-sonnet-4-6', false, 'deny', [], true) + assert.deepEqual(raw.providers.agy, { disabled: ['gemini-old'] }) + // An app with a models list keeps using it, Claude models included. + const listed: any = { providers: { agy: { models: ['gemini-*'] } } } + setModel(listed, 'agy', 'claude-sonnet-4-6', true, 'deny', [], true) + assert.deepEqual(listed.providers.agy, { models: ['gemini-*', 'claude-sonnet-4-6'] }) + // A wildcard doesn't cover a Claude model, so turning one on names it and turning it off keeps the wildcard. + const wild: any = { providers: { crush: { models: ['*'] } } } + setModel(wild, 'crush', 'anthropic/claude-haiku-5', true, 'allow', [], true) + assert.deepEqual(wild.providers.crush, { models: ['*', 'anthropic/claude-haiku-5'] }) + setModel(wild, 'crush', 'anthropic/claude-haiku-5', false, 'allow', [], true) + assert.deepEqual(wild.providers.crush, { models: ['*'] }) +}) + test('setModel: deny mode adds and removes exact ids in disabled', () => { const raw: any = {} setModel(raw, 'codex', 'gpt-a', false, 'deny', []) diff --git a/plugins/bitfrost/daemon/tui-rpc.ts b/plugins/bitfrost/daemon/tui-rpc.ts index 1c8b694..2b67d2f 100644 --- a/plugins/bitfrost/daemon/tui-rpc.ts +++ b/plugins/bitfrost/daemon/tui-rpc.ts @@ -177,6 +177,7 @@ export async function serveRpc(opts: { input?: Readable; output?: Writable; file const notify = (method: string, params: unknown) => send({ method, params }) let known = revision(file) const lastModels = new Map() + const claudeModels = new Map>() const polling = new Set() let renew: NodeJS.Timeout | null = null let starting: Promise | null = null @@ -304,6 +305,7 @@ export async function serveRpc(opts: { input?: Readable; output?: Writable; file const id = factory(p?.provider).id const r = await ask(`/providers/${encodeURIComponent(id)}/models${p?.refresh ? '?refresh=1' : ''}`, 20_000) lastModels.set(id, r.models.map((m: any) => m.model)) + claudeModels.set(id, new Set(r.models.filter((m: any) => /^anthropic/i.test(m.company ?? '')).map((m: any) => m.model))) if (r.pending) poll(id) return r }, @@ -365,11 +367,15 @@ export async function serveRpc(opts: { input?: Readable; output?: Writable; file const f = factory(p?.provider) if (typeof p.model !== 'string' || !p.model || typeof p.enabled !== 'boolean') throw new RpcError('bad_request', 'models.set needs model and enabled') // A pattern may be turned into the models it covers, so use the app's list as it is now. - if (!f.vendor && !p.enabled) { + if ((!f.vendor && !p.enabled) || (f.vendor && (!claudeModels.has(f.id) || !lastModels.get(f.id)?.includes(p.model)))) { const now = await ask(`/providers/${encodeURIComponent(f.id)}/models`).catch(() => null) - if (now?.models?.length) lastModels.set(f.id, now.models.map((m: any) => m.model)) + if (now?.models?.length) { + lastModels.set(f.id, now.models.map((m: any) => m.model)) + claudeModels.set(f.id, new Set(now.models.filter((m: any) => /^anthropic/i.test(m.company ?? '')).map((m: any) => m.model))) + } } - return change(p.revision, (raw) => setModel(raw, f.id, p.model, p.enabled, f.vendor ? 'deny' : 'allow', lastModels.get(f.id) ?? [])) + const claude = !!claudeModels.get(f.id)?.has(p.model) + return change(p.revision, (raw) => setModel(raw, f.id, p.model, p.enabled, f.vendor ? 'deny' : 'allow', lastModels.get(f.id) ?? [], claude)) }, 'providers.set': (p) => { const f = factory(p?.provider) diff --git a/plugins/bitfrost/hooks/register.js b/plugins/bitfrost/hooks/register.js index e4f3704..b4194d3 100644 --- a/plugins/bitfrost/hooks/register.js +++ b/plugins/bitfrost/hooks/register.js @@ -270,7 +270,8 @@ const CONTEXT_CAP = 150_000 // Claude Code gives an unknown model a 200k window and blocks every step past it. // A Claude handback reports its real usage, which can pass 200k, so ask for 1M. -const modelLabel = (def) => `${def.model}[1m]` +// A Claude model through another app gets its app's name in front, so its label is never a real Claude model id. +const modelLabel = (def) => `${/^anthropic/i.test(def.provider ?? '') ? `${def.harness}-` : ''}${def.model}[1m]` function capUsage(u) { const output = Math.min(u.output_tokens, CONTEXT_CAP / 4) diff --git a/plugins/bitfrost/hooks/register.test.ts b/plugins/bitfrost/hooks/register.test.ts index 398295d..29f637d 100644 --- a/plugins/bitfrost/hooks/register.test.ts +++ b/plugins/bitfrost/hooks/register.test.ts @@ -208,7 +208,8 @@ const world = (on: any, opts: { store?: Record } = {}) => { w.modelCalls.push(e) return { value: fake.reviewReply } }) - on('tool.check', () => ({ value: { decision: 'ask' } })) + // A query from BitFrost's review gets no verdict; real calls run, as no dialog exists here. + on('tool.check', ($: any, e: any) => ({ decision: e.tool_use_id ? 'allow' : 'ask' })) on('tool.register', ($: any, e: any) => { w.tools.push(e) return { value: { tool: `mcp__bitfrost__${e.name}` } } @@ -383,6 +384,14 @@ test('with no models to offer, the helper\'s hint reaches the user', { timeoutMs expect(w.registered).toEqual([]) }) +test('a Claude model from another app registers under a label that is never a real Claude model id', { timeoutMs: 20000 }, async ($, on) => { + const w = world(on) + const sonnet = { ...SOL, name: 'claude-sonnet-4-6', model: 'claude-sonnet-4-6', displayName: 'Claude Sonnet 4.6', provider: 'Anthropic', aliases: [], family: 'sonnet', harness: 'agy' } + w.fake.agentsReply = { agents: [SOL, sonnet], nameTable: NAME_TABLE, at: 1 } + await start($) + expect(w.registered.map((r: any) => r.model)).toEqual(['gpt-6-sol[1m]', 'agy-claude-sonnet-4-6[1m]']) +}) + // Delivers the handback in a step's chunks, then runs the closing step after it. async function closeOut($: any, w: any, agentId: string, s: any, index: number) { const id = toolChunk(s.chunks).id diff --git a/release/install.sh b/release/install.sh index 3083bb5..b2ffdb4 100755 --- a/release/install.sh +++ b/release/install.sh @@ -252,7 +252,7 @@ main() { if [ -z "$previous" ]; then todo "Codex and ZCode work once installed and signed in. For GLM, also run once:" cmd "bitfrost setup zcode" - todo "opencode, Oh My Pi, Gemini CLI and Crush stay off until you turn them on with bitfrost config (or in $(short "$CONFIG"))" + todo "opencode, Oh My Pi, Gemini CLI, Crush, Grok Build and Antigravity stay off until you turn them on with bitfrost config (or in $(short "$CONFIG"))" todo "Setup for each app: https://github.com/$REPO/blob/main/GUIDE.md#setup" fi } diff --git a/tui/src/app/app.cpp b/tui/src/app/app.cpp index 403737f..6656d17 100644 --- a/tui/src/app/app.cpp +++ b/tui/src/app/app.cpp @@ -6,9 +6,9 @@ #include "app.hpp" #include "metadata.hpp" -#include "rpc/protocol.hpp" #include "ui/list.hpp" #include +#include #include #include @@ -269,6 +269,25 @@ namespace bf { } else if (request_info.method == "providers.list") { std::string previous = provider()["id"].str(); providers = result["providers"].array(); + std::stable_sort(providers.begin(), providers.end(), [](const Json &left, const Json &right) { + bool first = left["firstClass"].boolean(), other = right["firstClass"].boolean(); + if (first != other) { + return first; + } + auto name = [](const Json &entry) { + std::string value = entry["displayName"].str(); + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) { + return char(std::tolower(ch)); + }); + return value; + }; + return name(left) < name(right); + }); + std::vector groups; + for (const Json &entry : providers) { + groups.push_back(entry["firstClass"].boolean() ? "first" : "other"); + } + provider_view.reset(groups, false); move(provider_selection, 0, providers.size()); for (size_t index = 0; index < providers.size(); ++index) { if (providers[index]["id"].str() == previous) { @@ -615,7 +634,7 @@ namespace bf { if (focus.back() == Focus::Sidebar) { if (current == Page::Models) { int previous_selection = provider_selection; - provider_view.input(provider_selection, event.key, int(providers.size()), sidebar_visible); + provider_view.input(provider_selection, event.key, sidebar_visible); if (previous_selection != provider_selection) { models.clear(); model_rows.reset(models, false); @@ -645,7 +664,7 @@ namespace bf { } if (current == Page::Models) { if (focus.back() == Focus::Popup) { - if (event.key == Key::Enter && !model().null() && model_toggle_allowed(model())) { + if (event.key == Key::Enter && !model().null()) { saving = true; message = "Saving..."; request( diff --git a/tui/src/app/app.hpp b/tui/src/app/app.hpp index 6bfe4c1..21c06a9 100644 --- a/tui/src/app/app.hpp +++ b/tui/src/app/app.hpp @@ -85,8 +85,8 @@ namespace bf { void draw_configuration(Grid &); ModelList model_rows; int section_selection = 0, item_selection = 0, popup_selection = 0; - GroupedList setting_rows, helper_rows; - ListView provider_view, section_view, status_view, popup_view; + GroupedList provider_view, setting_rows, helper_rows; + ListView section_view, status_view, popup_view; int stats_tab = 0, sidebar_visible = 1, list_visible = 1, popup_visible = 1; static constexpr int STATS_TABS = 4; bool loading_models = false, loading_settings = false, saving = false, running = true; diff --git a/tui/src/app/mouse.cpp b/tui/src/app/mouse.cpp index c63b508..cd58821 100644 --- a/tui/src/app/mouse.cpp +++ b/tui/src/app/mouse.cpp @@ -82,7 +82,7 @@ namespace bf { int delta = 3 * event.delta; switch (target->kind) { case HitKind::Provider: - provider_view.scroll(delta, int(providers.size()), sidebar_visible); + provider_view.scroll(delta, sidebar_visible); break; case HitKind::Section: section_view.scroll(delta, int(sections.size()), sidebar_visible); diff --git a/tui/src/app/view.cpp b/tui/src/app/view.cpp index 35bad7a..5c22af3 100644 --- a/tui/src/app/view.cpp +++ b/tui/src/app/view.cpp @@ -6,7 +6,6 @@ #include "app.hpp" #include "fx/border.hpp" -#include "rpc/protocol.hpp" #include "ui/sidebar.hpp" #include diff --git a/tui/src/pages/model_stats.cpp b/tui/src/pages/model_stats.cpp index 36c75ce..bc8f48a 100644 --- a/tui/src/pages/model_stats.cpp +++ b/tui/src/pages/model_stats.cpp @@ -5,7 +5,6 @@ // Public License, version 3 only. It comes with no warranty. See LICENSE. #include "app/app.hpp" -#include "rpc/protocol.hpp" #include "ui/format.hpp" #include "ui/list.hpp" #include "ui/usage_chart.hpp" @@ -79,10 +78,7 @@ namespace bf { grid.text(x, y + 2, "Select a model", dim, black, facts_width - 2); return; } - std::string state = std::string(model_unavailable_reason(model())); - if (state.empty()) { - state = model()["enabled"].boolean() ? "Enabled" : "Disabled"; - } + std::string state = model()["enabled"].boolean() ? "Enabled" : "Disabled"; std::string efforts; for (const Json &effort : model()["efforts"].array()) { efforts += (efforts.empty() ? "" : ", ") + effort.str(); diff --git a/tui/src/pages/models.cpp b/tui/src/pages/models.cpp index 931ec71..918351d 100644 --- a/tui/src/pages/models.cpp +++ b/tui/src/pages/models.cpp @@ -5,7 +5,6 @@ // Public License, version 3 only. It comes with no warranty. See LICENSE. #include "app/app.hpp" -#include "rpc/protocol.hpp" #include "ui/list.hpp" #include "ui/popup.hpp" #include "ui/sidebar.hpp" @@ -23,7 +22,8 @@ namespace bf { stats_visible = panel_height > 0; load_stats(); list_visible = std::max(1, panel_y - 6); - int first_provider = provider_view.first_row(provider_selection, int(providers.size()), sidebar_visible); + int first_provider = provider_view.first_row(provider_selection, sidebar_visible); + const auto &provider_rows = provider_view.rows(); int sidebar_width = draw_scrollbar( grid, "providers", @@ -31,21 +31,25 @@ namespace bf { layout.sidebar_y, layout.sidebar_width - 3, sidebar_visible, - int(providers.size()), + int(provider_rows.size()), first_provider, sidebar_visible ); hits.scroll({3, layout.sidebar_y, layout.sidebar_width - 3, sidebar_visible}, HitKind::Provider); - for (int index = first_provider; index < int(providers.size()) && index < first_provider + sidebar_visible; + for (int index = first_provider; index < int(provider_rows.size()) && index < first_provider + sidebar_visible; ++index) { - hits.add({3, layout.sidebar_y + index - first_provider, sidebar_width, 1}, HitKind::Provider, index); - const Json &entry = providers[size_t(index)]; + int item = provider_rows[size_t(index)].item; + if (item < 0) { + continue; + } + hits.add({3, layout.sidebar_y + index - first_provider, sidebar_width, 1}, HitKind::Provider, item); + const Json &entry = providers[size_t(item)]; list_item( grid, 3, layout.sidebar_y + index - first_provider, sidebar_width, - {entry["displayName"].str(), index == provider_selection, !entry["detected"].boolean()}, + {entry["displayName"].str(), item == provider_selection, !entry["detected"].boolean()}, clock ); } @@ -89,7 +93,6 @@ namespace bf { const Json &entry = models[size_t(row.model)]; hits.add({layout.main_x, 5 + index - first_model, width, 1}, HitKind::Model, index); std::string label = row.label.empty() ? entry["model"].str() : row.label; - std::string_view reason = model_unavailable_reason(entry); bool offered = entry["enabled"].boolean() && entry["offered"].boolean() && entry["reason"].null(); list_item( grid, @@ -99,7 +102,7 @@ namespace bf { {label, index == model_rows.selected_row() && focus.back() != Focus::Sidebar, !offered, - reason, + {}, offered ? "on" : "off"}, clock ); @@ -131,20 +134,15 @@ namespace bf { Popup popup{x, y, width, height}; hits.modal({x, y, width, height}); popup_frame(grid, popup, model()["displayName"].str(model()["model"].str())); - bool can_toggle = model_toggle_allowed(model()); - if (can_toggle) { - list_item( - grid, - x + 1, - y + 3, - width - 2, - {model()["enabled"].boolean() ? "> Disable" : "> Enable", true}, - clock - ); - hits.add({x + 1, y + 3, width - 2, 1}, HitKind::ModelAction); - } else { - grid.text(x + 2, y + 3, model_unavailable_reason(model()), dim, black, width - 4); - } + list_item( + grid, + x + 1, + y + 3, + width - 2, + {model()["enabled"].boolean() ? "> Disable" : "> Enable", true}, + clock + ); + hits.add({x + 1, y + 3, width - 2, 1}, HitKind::ModelAction); std::string hint = controls_hint(); popup_footer(grid, popup, hint); hits.add({x + 2, y + height - 2, text_width(hint), 1}, HitKind::Controls); diff --git a/tui/src/rpc/protocol.hpp b/tui/src/rpc/protocol.hpp deleted file mode 100644 index 1d8cab8..0000000 --- a/tui/src/rpc/protocol.hpp +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (C) 2026 Nick Germaine -// SPDX-License-Identifier: GPL-3.0-only -// -// BitFrost is free software: you can share and change it under the GNU General -// Public License, version 3 only. It comes with no warranty. See LICENSE. - -#pragma once -#include "json.hpp" - -namespace bf { - inline std::string_view model_unavailable_reason(const Json &model) { - std::string reason = model["reason"].str(); - if (reason == "anthropic") { - return "Anthropic, never offered"; - } - return {}; - } - - inline bool model_toggle_allowed(const Json &model) { - return model_unavailable_reason(model).empty(); - } -} diff --git a/tui/src/ui/format.cpp b/tui/src/ui/format.cpp index 0f93a50..c67a55c 100644 --- a/tui/src/ui/format.cpp +++ b/tui/src/ui/format.cpp @@ -17,11 +17,13 @@ namespace bf { constexpr std::pair names[]{ {"codex", "Codex"}, {"zcode", "ZCode"}, - {"opencode", "opencode"}, + {"opencode", "OpenCode"}, {"oh-my-pi", "Oh My Pi"}, {"omp", "Oh My Pi"}, {"gemini", "Gemini CLI"}, - {"crush", "Crush"} + {"crush", "Crush"}, + {"grok", "Grok Build"}, + {"agy", "Antigravity"} }; for (auto [key, name] : names) { if (id == key) { diff --git a/tui/src/ui/grouped_list.cpp b/tui/src/ui/grouped_list.cpp index c764ec2..5b3f220 100644 --- a/tui/src/ui/grouped_list.cpp +++ b/tui/src/ui/grouped_list.cpp @@ -10,7 +10,7 @@ #include namespace bf { - void GroupedList::reset(const std::vector &groups) { + void GroupedList::reset(const std::vector &groups, bool headings) { entries.clear(); selectable.clear(); std::vector names; @@ -23,7 +23,7 @@ namespace bf { if (!entries.empty()) { entries.push_back({-1, {}}); } - if (!name.empty()) { + if (headings && !name.empty()) { entries.push_back({-1, name}); } for (int index = 0; index < int(groups.size()); ++index) { diff --git a/tui/src/ui/grouped_list.hpp b/tui/src/ui/grouped_list.hpp index 36a3212..4fd0c2f 100644 --- a/tui/src/ui/grouped_list.hpp +++ b/tui/src/ui/grouped_list.hpp @@ -21,7 +21,7 @@ namespace bf { bool following = true; public: - void reset(const std::vector &groups); + void reset(const std::vector &groups, bool headings = true); void input(int &item, Key key, int visible_rows); void scroll(int delta, int visible_rows); void clear_view() { diff --git a/tui/src/ui/model_list.cpp b/tui/src/ui/model_list.cpp index a290219..687d442 100644 --- a/tui/src/ui/model_list.cpp +++ b/tui/src/ui/model_list.cpp @@ -5,7 +5,6 @@ // Public License, version 3 only. It comes with no warranty. See LICENSE. #include "model_list.hpp" -#include "rpc/protocol.hpp" #include "ui/list.hpp" #include #include @@ -84,7 +83,7 @@ namespace bf { std::vector enabled, everything; for (size_t index = 0; index < models.size(); ++index) { everything.push_back(int(index)); - if (models[index]["enabled"].boolean() && model_toggle_allowed(models[index])) { + if (models[index]["enabled"].boolean()) { enabled.push_back(int(index)); } } diff --git a/tui/tests/fake-broker.mjs b/tui/tests/fake-broker.mjs index 8cb0aa1..04ea7c5 100644 --- a/tui/tests/fake-broker.mjs +++ b/tui/tests/fake-broker.mjs @@ -139,15 +139,17 @@ function watchSubagents(params) } const today = new Date(); today.setUTCHours(0, 0, 0, 0); -const providers = [ 'codex', 'zcode', 'opencode', 'omp', 'gemini', 'crush' ].map( +const providers = [ 'agy', 'codex', 'grok', 'zcode', 'crush', 'gemini', 'omp', 'opencode' ].map( (id, index) => ({ id, - displayName : [ 'Codex', 'ZCode', 'OpenCode', 'OhMyPi', 'Gemini', 'Crush' ][index], - detected : index !== 1, - location : index === 1 ? null : '/usr/bin/' + id, - enabled : index !== 5, - optIn : index === 5, - running : index !== 1 && index !== 5, + displayName : [ 'Antigravity', 'Codex', 'Grok Build', 'ZCode', 'Crush', 'Gemini CLI', + 'Oh My Pi', 'OpenCode' ][index], + firstClass : index < 4, + detected : id !== 'zcode', + location : id === 'zcode' ? null : '/usr/bin/' + id, + enabled : ![ 'crush', 'grok', 'agy' ].includes(id), + optIn : [ 'crush', 'grok', 'agy' ].includes(id), + running : ![ 'zcode', 'crush', 'grok', 'agy' ].includes(id), mode : [ 'opencode', 'omp', 'crush' ].includes(id) ? 'allow' : 'deny', })); const modelIds = [ @@ -179,18 +181,18 @@ modelMap.get('opencode') ...modelMap.get('opencode')[0], model : 'opencode/claude-haiku-5-5', displayName : 'Claude Haiku', - enabled : true, + enabled : false, offered : false, - reason : 'anthropic', + reason : 'not_listed', agent : null }, { ...modelMap.get('opencode')[0], model : 'opencode/claude-test', displayName : 'Claude Test', - enabled : true, + enabled : false, offered : false, - reason : 'anthropic', + reason : 'not_listed', agent : null }, ); @@ -211,7 +213,7 @@ const sections = await settingsOf({ handback : {enabled : true, model : 'sonnet', effort : 'low'}, providers : {opencode : {enabled : true}}, }, {codex : '/usr/local/bin/codex', zcode : '/opt/ZCode', opencode : null, - omp : '/usr/local/bin/pi', gemini : '/usr/local/bin/gemini', crush : null}, + omp : '/usr/local/bin/pi', gemini : '/usr/local/bin/gemini', crush : null, grok : null, agy : null}, newSettings ? '0.9.2' : '0.9.1'); function totals(sessions, turns, tokens) { diff --git a/tui/tests/pty_smoke.py b/tui/tests/pty_smoke.py index 034b89c..638e8ef 100644 --- a/tui/tests/pty_smoke.py +++ b/tui/tests/pty_smoke.py @@ -511,6 +511,11 @@ def no_subpage_particles(): key(b"\x1b[H") key(b"\r", 0.7) assert "Providers" in screen.text(), "Models page missing" + sidebar = ["".join(c[0] for c in row[4:24] if c[0] not in ("▀", "▄")).strip() + for row in screen.cells[9:18]] + assert sidebar == ["Antigravity", "Codex", "Grok Build", "ZCode", "", "Crush", + "Gemini CLI", "Oh My Pi", "OpenCode"], "Provider grouping or order is wrong: " + repr(sidebar) + key(b"s", 0.3) assert "All models" in "".join( c[0] for c in screen.cells[16] ), "All models header moved unexpectedly" @@ -541,19 +546,27 @@ def no_subpage_particles(): ) assert "codex/model-1" not in screen.text(), "Grouped model label retains its category prefix" screen.status_bar(sidebar=True) - key(b"w") - assert "Crush" in screen.selected(right=25), "Provider sidebar does not wrap upward" + key(b"\x1b[Hw") + assert "OpenCode" in screen.selected(right=25), "Provider sidebar does not wrap upward" key(b"s") - assert "Codex" in screen.selected(right=25), "Provider sidebar does not wrap downward" + assert "Antigravity" in screen.selected(right=25), "Provider sidebar does not wrap downward" key(b"\x1b[F") - assert "Crush" in screen.selected(right=25), "Sidebar End failed" + assert "OpenCode" in screen.selected(right=25), "Sidebar End failed" key(b"\x1b[H") - assert "Codex" in screen.selected(right=25), "Sidebar Home failed" + assert "Antigravity" in screen.selected(right=25), "Sidebar Home failed" key(b"\x1b[6~") - assert "Crush" in screen.selected(right=25), "Sidebar Page Down failed" + assert "OpenCode" in screen.selected(right=25), "Sidebar Page Down failed" key(b"\x1b[5~") - assert "Codex" in screen.selected(right=25), "Sidebar Page Up failed" - key(b"\x1b[Fw", 0.3) + assert "Antigravity" in screen.selected(right=25), "Sidebar Page Up failed" + key(b"s", 0.3) + assert "Codex" in screen.selected(right=25), "Returning to Codex failed" + key(b"ss") + assert "ZCode" in screen.selected(right=25), "Last first-class provider is wrong" + key(b"s") + assert "Crush" in screen.selected(right=25), "Down did not skip provider gap" + key(b"w") + assert "ZCode" in screen.selected(right=25), "Up did not skip provider gap" + key(b"\x1b[Fww", 0.3) key(b"\r") assert "None yet" in screen.text(), "Empty Enabled section placeholder missing" assert not "".join( @@ -561,12 +574,11 @@ def no_subpage_particles(): ).strip(), "None yet has no blank separator" key(b"\x1b[Z") assert "model-1" in screen.selected(left=27, bottom=22), "Tab selected an empty section" - key(b"q\x1b[H") - key(b"ss", 0.3) + key(b"q\x1b[F", 0.3) key(b"\r") assert ( - "Enabled" in screen.text() and "Anthropic, never offered" not in screen.text() - ), "Enabled section includes blocked models" + "Enabled" in screen.text() and "claude-haiku-5-5" not in screen.text() + ), "Enabled section includes off Anthropic models" key(b"\x1b[Z") assert "All models" in screen.text(), "Tab did not jump to All models" key(b"w") @@ -581,7 +593,7 @@ def no_subpage_particles(): assert "Enabled" in screen.text(), "Shift-Tab did not jump to Enabled" key(b"\x1b[Z") assert "All models" in screen.text(), "Shift-Tab did not return to All models" - assert "Anthropic, never offered" in screen.text(), "Anthropic row reason missing" + assert "claude-haiku-5-5" in screen.text(), "Anthropic row missing from All models" assert "off" in screen.text() and "on" in screen.text(), "Model state columns missing" assert ( "Avg. session time" in screen.text() and "62m" in screen.text() @@ -779,12 +791,9 @@ def no_subpage_particles(): key(b"q") key(b"\x1b[H\x1b[Zsssssss\r", 0.2) assert ( - "Anthropic, never offered" in screen.text() and "Avg. session time" in screen.text() - ), "Blocked model reason or bottom stats missing" - assert ( - "> Enable" not in screen.text() and "> Disable" not in screen.text() - ), "Blocked popup exposes a toggle" - screen.save(snapshots, "anthropic-popup") + "> Enable" in screen.text() and "Avg. session time" in screen.text() + ), "Anthropic enable action or bottom stats missing" + screen.save(snapshots, "anthropic-toggle") writes_before = sum( json.loads(line)["method"] == "models.set" for line in log.read_text().splitlines() ) @@ -792,15 +801,17 @@ def no_subpage_particles(): writes_after = sum( json.loads(line)["method"] == "models.set" for line in log.read_text().splitlines() ) - assert writes_before == writes_after, "Blocked model wrote config" + assert writes_after == writes_before + 1, "Anthropic model did not write config" + requests = [json.loads(line) for line in log.read_text().splitlines()] + assert any(r["method"] == "models.set" and r["params"]["model"] == "opencode/claude-haiku-5-5" + and r["params"]["enabled"] is True for r in requests), "Anthropic enable write is wrong" + key(b"\r") + assert "> Disable" in screen.text(), "Anthropic row did not become enabled" key(b"q") key(b"s\r", 0.2) assert ( - "Anthropic, never offered" in screen.text() and "Avg. session time" in screen.text() - ), "Blocked row reason or bottom stats missing" - assert ( - "> Enable" not in screen.text() and "> Disable" not in screen.text() - ), "Blocked model popup exposes a toggle" + "> Enable" in screen.text() and "Avg. session time" in screen.text() + ), "Second Anthropic row is not toggleable" key(b"q") key(b"q") key(b"q", 0.3) @@ -818,7 +829,7 @@ def no_subpage_particles(): assert "General" in screen.selected(right=25), "Settings sidebar End and Page Up failed" key(b"\x1b[6~") assert "Harnesses" in screen.selected(right=25), "Settings sidebar Page Down failed" - assert "".join(c[0] for c in screen.cells[5][28:33]) == "Codex", "Harness heading moved unexpectedly" + assert "".join(c[0] for c in screen.cells[5][28:39]) == "Antigravity", "Harness heading moved unexpectedly" wait_for(lambda: screen.cells[5][28][1] == (182, 197, 210), "Harness heading did not finish fading in") assert screen.cells[5][28][1] == (182, 197, 210) and screen.cells[6][28][1] == (78, 173, 245), ( "Harness headings and inactive labels have the wrong colors" @@ -840,16 +851,16 @@ def no_subpage_particles(): assert screen.selected(left=27) == before, "Harness heading click changed selection" key(b"w") assert "Location: not found" in screen.selected(left=27) and ( - "".join(c[0] for c in screen.cells[25][28:33]) == "Crush" - ), "Settings items do not wrap upward to Crush Location" + "".join(c[0] for c in screen.cells[31][28:36]) == "OpenCode" + ), "Settings items do not wrap upward to OpenCode Location" key(b"s") - assert "Status: Enabled" in screen.selected(left=27) and ( - "".join(c[0] for c in screen.cells[5][28:33]) == "Codex" - ), "Settings items do not wrap downward to Codex Status" + assert "Status: Disabled" in screen.selected(left=27) and ( + "".join(c[0] for c in screen.cells[5][28:39]) == "Antigravity" + ), "Settings items do not wrap downward to Antigravity Status" key(b"\x1b[F") assert "Location: not found" in screen.selected(left=27), "Settings items End failed" key(b"\x1b[H") - assert "Codex" in "".join(c[0] for c in screen.cells[5][27:116]), "Settings Home hid the first heading" + assert "Antigravity" in "".join(c[0] for c in screen.cells[5][27:116]), "Settings Home hid the first heading" fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 20, 80, 0, 0)) screen = Screen(80, 20) os.kill(process.pid, signal.SIGWINCH) @@ -857,12 +868,12 @@ def no_subpage_particles(): key(b"\x1b[6~") assert ( "Location: not found" in screen.selected(left=19, bottom=14) - and "opencode" in "".join(c[0] for c in screen.cells[11][19:76]) - ), "Settings Page Down did not jump nine grouped rows to opencode Location" + and "Grok Build" in "".join(c[0] for c in screen.cells[11][19:76]) + ), "Settings Page Down did not jump nine grouped rows to Grok Build Location" key(b"\x1b[5~") - assert "Status: Enabled" in screen.selected(left=19, bottom=14) and ( - "Codex" in "".join(c[0] for c in screen.cells[5][19:76]) - ), "Settings Page Up failed to restore Codex and its first item" + assert "Status: Disabled" in screen.selected(left=19, bottom=14) and ( + "Antigravity" in "".join(c[0] for c in screen.cells[5][19:76]) + ), "Settings Page Up failed to restore Antigravity and its first item" fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 40, 120, 0, 0)) screen = Screen() os.kill(process.pid, signal.SIGWINCH) @@ -1146,7 +1157,12 @@ def back(delay=0.4): drain(0.35) report(0, 56, 24, 0.7) assert "Providers" in screen.text(), "Mouse home click did not open Models" - report(0, 5, 11, 0.4) + before = screen.selected(right=25) + requests_before = sum(json.loads(line)["method"] == "models.list" for line in log.read_text().splitlines()) + report(0, 5, 13, 0.4) + assert screen.selected(right=25) == before, "Gap click changed provider selection" + assert sum(json.loads(line)["method"] == "models.list" for line in log.read_text().splitlines()) == requests_before, "Gap click requested models" + report(0, 5, 17, 0.4) assert "OpenCode" in screen.selected(right=25), "Mouse did not select provider" report(0, 32, 6) assert "> Disable" not in screen.text(), "Category heading click opened a model popup" diff --git a/tui/tests/scroll_selftest.cpp b/tui/tests/scroll_selftest.cpp index 25cf830..ef85606 100644 --- a/tui/tests/scroll_selftest.cpp +++ b/tui/tests/scroll_selftest.cpp @@ -176,7 +176,8 @@ namespace bf { providers.push_back( Json::object( {{"id", "provider-" + std::to_string(index)}, - {"displayName", "Provider " + std::to_string(index)}, + {"displayName", + "Provider " + std::string(index < 10 ? "0" : "") + std::to_string(index)}, {"enabled", true}, {"detected", true}} ) @@ -302,7 +303,7 @@ namespace bf { auto model_requests = count("models.list"); wheel(5, 1); require( - line(grid, 4, 5, 15).starts_with("Provider 3") && count("models.list") == model_requests && + line(grid, 4, 5, 15).starts_with("Provider 03") && count("models.list") == model_requests && app.focused() == Focus::Sidebar, "Sidebar wheel moves three rows without changing provider selection or loading models" ); @@ -318,7 +319,7 @@ namespace bf { ); wheel(5, -1000); wheel(5, -1); - require(line(grid, 4, 5, 15).starts_with("Provider 0"), "Sidebar clamps at the top"); + require(line(grid, 4, 5, 15).starts_with("Provider 00"), "Sidebar clamps at the top"); key(Key::Up); require( last("models.list")["params"]["provider"].str() == "provider-0", diff --git a/tui/tests/selftest.cpp b/tui/tests/selftest.cpp index c0a7e3a..cb9b1f0 100644 --- a/tui/tests/selftest.cpp +++ b/tui/tests/selftest.cpp @@ -7,7 +7,6 @@ #include "app/app.hpp" #include "fx/glyphs.hpp" #include "fx/mosaic.hpp" -#include "rpc/protocol.hpp" #include "term/output.hpp" #include "ui/format.hpp" #include "ui/list.hpp" @@ -54,6 +53,138 @@ namespace bf { check(rejected, "JSON accepts malformed input"); } + void providers_selftest() { + Json::Array providers = Json::parse(R"([ + {"id":"opencode", "displayName":"OpenCode", "firstClass":false}, + {"id":"zcode", "displayName":"ZCode", "firstClass":true}, + {"id":"grok", "displayName":"Grok Build", "firstClass":true}, + {"id":"omp", "displayName":"Oh My Pi", "firstClass":false}, + {"id":"crush", "displayName":"crush", "firstClass":false}, + {"id":"codex", "displayName":"Codex", "firstClass":true}, + {"id":"gemini", "displayName":"Gemini CLI", "firstClass":false}, + {"id":"agy", "displayName":"antigravity", "firstClass":true} + ])") + .array(); + std::deque pending; + std::vector sent; + App app( + [&](const Json &request) { + pending.push_back(request); + sent.push_back(request); + }, + false + ); + Grid grid(120, 20); + auto pump = [&] { + while (!pending.empty()) { + Json request = pending.front(); + pending.pop_front(); + Json result = Json::object({{"protocol", 1}, {"revision", "test"}}); + if (request["method"].str() == "providers.list") { + result["providers"] = providers; + } + app.receive(Json::object({{"id", request["id"]}, {"result", result}})); + } + app.render(grid); + }; + auto key = [&](Key key) { + app.input({key, {}}); + pump(); + }; + auto line = [&](int y, bool selected = false) { + std::string text; + for (int x = 4; x < 23; ++x) { + const Cell &cell = grid.cells[size_t(y * grid.width + x)]; + text += !selected || cell.fg == black ? cell.glyph : " "; + } + return text; + }; + auto selected = [&](std::string_view name) { + for (int y = 5; y < status_row(grid); ++y) { + if (line(y, true).starts_with(name)) { + return true; + } + } + return false; + }; + app.start(); + pump(); + key(Key::Enter); + std::vector expected{ + "antigravity", + "Codex", + "Grok Build", + "ZCode", + "", + "crush", + "Gemini CLI", + "Oh My Pi", + "OpenCode" + }; + for (size_t row = 0; row < expected.size(); ++row) { + check(line(5 + int(row)).starts_with(expected[row]), "Provider groups sort names without case"); + } + check(line(9) == std::string(19, ' '), "Provider groups have one empty row"); + key(Key::Down); + key(Key::Down); + key(Key::Down); + check(selected("ZCode"), "Sidebar reaches the last first-class provider"); + auto requests = sent.size(); + check(!app.mouse_input({{5, 9}, MouseKind::Click, 0}), "Provider gap has no click target"); + pump(); + check(selected("ZCode") && sent.size() == requests, "Gap click leaves selection and requests alone"); + key(Key::Down); + check(selected("crush"), "Sidebar Down skips the gap"); + key(Key::Up); + check(selected("ZCode"), "Sidebar Up skips the gap"); + key(Key::End); + check(selected("OpenCode"), "Sidebar End selects the last provider"); + key(Key::Down); + check(selected("antigravity"), "Sidebar wraps from the end to the top"); + key(Key::Up); + check(selected("OpenCode"), "Sidebar wraps from the top to the end"); + key(Key::PageUp); + check(selected("antigravity"), "Sidebar Page Up skips the gap"); + key(Key::PageDown); + check(selected("OpenCode"), "Sidebar Page Down skips the gap"); + check(app.mouse_input({{5, 10}, MouseKind::Click, 0}), "Second provider group accepts clicks"); + pump(); + check(selected("crush"), "Mouse rows map past the gap to provider indices"); + key(Key::Home); + key(Key::Down); + providers[5]["displayName"] = "zzCodex"; + std::reverse(providers.begin(), providers.end()); + app.receive(Json::object({{"method", "config.changed"}})); + pump(); + check(selected("zzCodex"), "Provider refresh preserves the selected id after sorting changes its index"); + grid = Grid(120, 16); + key(Key::Home); + check(app.mouse_input({{5, 7}, MouseKind::Wheel, 100}), "Provider sidebar scrolls"); + pump(); + check(line(5).starts_with("zzCodex"), "Provider wheel scrolls without moving selection"); + key(Key::Down); + check(selected("zzCodex"), "Navigation after scrolling selects the visible provider"); + GroupedList view; + view.reset({"first", "first", "first", "first", "other", "other", "other", "other"}, false); + view.scroll(4, 5); + int item = 0; + view.input(item, Key::Down, 5); + check(item == 4 && view.first_row(item, 5) == 4, "Navigation skips a gap at the scrolled viewport top"); + grid = Grid(120, 20); + for (bool first : {true, false}) { + for (Json &entry : providers) { + entry["firstClass"] = first; + } + app.receive(Json::object({{"method", "config.changed"}})); + pump(); + key(Key::Home); + for (int y = 5; y < 13; ++y) { + check(line(y) != std::string(19, ' '), "A single provider group has no gap or headings"); + } + check(line(13) == std::string(19, ' '), "A single provider group has exactly eight rows"); + } + } + void json_numbers_selftest() { struct CommaNumbers : std::numpunct { char do_decimal_point() const override { @@ -98,6 +229,7 @@ namespace bf { int selftest() { try { + providers_selftest(); status_selftest(); protocol_selftest(); settings_selftest(); @@ -262,7 +394,7 @@ namespace bf { Json::Array model_fixture = Json::parse(R"([ {"model":"a", "enabled":true, "offered":false, "reason":"disabled"}, {"model":"b", "enabled":false, "offered":false, "reason":"disabled"}, - {"model":"c", "enabled":true, "offered":false, "reason":"anthropic"} + {"model":"c", "enabled":false, "offered":false, "reason":"not_listed"} ])") .array(); ModelList combined; @@ -270,7 +402,7 @@ namespace bf { check( combined.rows().size() == 7 && combined.selected_row() == 1 && combined.model_index() == 0 && combined.rows()[2].model < 0 && combined.rows()[2].title.empty(), - "Enabled section excludes blocked models and selects past its header" + "Enabled section excludes off models and selects past its header" ); combined.input(Key::Up, 3); check(combined.selected_row() == 6 && combined.model_index() == 2, "Combined model wheel wraps up"); @@ -546,6 +678,7 @@ namespace bf { { "id": "codex", "displayName": "Codex", + "firstClass": true, "detected": true, "enabled": true, "mode": "deny" @@ -553,6 +686,7 @@ namespace bf { { "id": "crush", "displayName": "Crush", + "firstClass": false, "detected": true, "enabled": false, "mode": "allow" @@ -580,16 +714,16 @@ namespace bf { { "model": "test/claude", "displayName": "Claude", - "enabled": true, + "enabled": false, "offered": false, - "reason": "anthropic" + "reason": "not_listed" }, { "model": "test/claude-other", "displayName": "Claude other", - "enabled": true, + "enabled": false, "offered": false, - "reason": "anthropic" + "reason": "not_listed" } ] })" @@ -1086,17 +1220,21 @@ namespace bf { faults.render(grid); pump(); check( - faults.focused() == Focus::Popup && !model_toggle_allowed(Json::object({{"reason", "anthropic"}})), - "Anthropic model has no toggle action" + faults.focused() == Focus::Popup && frame_text(grid).find("> Enable") != std::string::npos, + "Anthropic model exposes an enable action" ); fault_key(Key::Enter); check( - std::none_of( + std::any_of( queue.begin(), queue.end(), - [](const Json &request) { return request["method"].str() == "models.set"; } + [](const Json &request) { + return request["method"].str() == "models.set" && + request["params"]["model"].str() == "test/claude" && + request["params"]["enabled"].boolean(); + } ), - "Blocked model Enter sends no write" + "Anthropic model Enter sends an enable write" ); Diff optimized; Grid styles(10, 4); diff --git a/tui/tests/status_selftest.cpp b/tui/tests/status_selftest.cpp index dc479d0..122ea68 100644 --- a/tui/tests/status_selftest.cpp +++ b/tui/tests/status_selftest.cpp @@ -232,7 +232,9 @@ namespace bf { Json::object({{"id", "opencode"}, {"location", "/opt/opencode"}, {"models", 9}}), Json::object({{"id", "omp"}, {"location", "/opt/omp"}, {"models", 10}}), Json::object({{"id", "gemini"}, {"location", "/opt/gemini"}, {"models", 11}}), - Json::object({{"id", "crush"}, {"location", "/opt/crush"}, {"models", 12}}) + Json::object({{"id", "crush"}, {"location", "/opt/crush"}, {"models", 12}}), + Json::object({{"id", "grok"}, {"location", "/opt/grok"}, {"models", 13}}), + Json::object({{"id", "agy"}, {"location", "/opt/agy"}, {"models", 14}}) }}, {"leases", Json::Array{ @@ -354,7 +356,8 @@ namespace bf { text(grid, true).find("workstation") == std::string::npos, "Helper scrolls to leases without selectable highlights" ); - for (std::string_view name : {"Codex", "ZCode", "opencode", "Oh My Pi", "Gemini CLI", "Crush"}) { + for (std::string_view name : + {"Codex", "ZCode", "OpenCode", "Oh My Pi", "Gemini CLI", "Crush", "Grok Build", "Antigravity"}) { require(helper_text.find(name) != std::string::npos, "Helper uses app display names as group headings"); } require(