Loading files
b14c882eeThis file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Pointing an agent at an Autonomous Grid instead of the engine's own login.
*
* The desktop app lets a user pick a grid (and optionally a model), mints a short-lived relay key for
* it, and sends the result as `payload.grid` on `agent_create` / `agent_retarget`. Nothing about that
* reaches an engine by itself: this CLI is what spawns the engine, so this module turns that payload
* into the launch — environment, and where the vendor demands it, argv.
*
* The one place that payload is kept is the registry row (`RegisteredSession.gridLaunch`, in a 0600
* file this daemon owns) — so a restart, or a pane recreated after a reboot, relaunches onto the SAME
* grid with the SAME key rather than silently coming back on the engine's own login. Nothing THIS
* module writes (argv, a config directory) ever carries the key; that rule is unchanged.
*
* ## Every entry here is the vendor's own documented contract
*
* The relay speaks two dialects — Anthropic Messages at `<grid>/relay`, and OpenAI
* chat/completions + responses at `<grid>/relay/v1` — so an engine can be pointed at it only if the
* engine itself offers a way to change its endpoint. Those ways differ, and none of them is
* guessable: `ANTHROPIC_BASE_URL` for Claude Code, `-c model_providers.*` argv for Codex,
* `GROK_MODELS_BASE_URL` for Grok, `COPILOT_PROVIDER_BASE_URL` for Copilot. Each entry below cites
* where it was read from.
*
* An engine with no entry is REFUSED, and the refusal names why for that engine specifically. Three
* shapes of "no" appear, and they are worth telling apart because only one of them could ever change
* on our side:
*
* * **Wrong protocol.** The engine CAN be re-pointed, but at its own vendor's API rather than an
* OpenAI- or Anthropic-shaped one. Cursor Agent reads `CURSOR_API_ENDPOINT` (default
* `https://api2.cursor.sh`, read out of the shipped bundle) and Antigravity reads
* `GOOGLE_GEMINI_BASE_URL` — handing either the relay would send it a dialect the relay does not
* serve (grid ADR 0012 lists Gemini as a future data edit, not a served endpoint). A knob
* existing is not the same as a knob that helps, and pointing one of these at a grid would fail
* inside the app with an error naming neither.
* * **Config-file only** (pi, kilo): the provider block has to be written into the user's own
* dotfile. Editing another tool's configuration on someone's behalf is a side effect that
* outlives the agent, so this module does not do it.
* * **Nothing documented** (muse, commandcode, amp, devin): no vendor documentation describes an
* endpoint override. These are the entries that could gain a contract tomorrow — with a cited
* source, not a plausible-looking variable name.
*
* Refusing is the point. Silently launching against the engine's own login would put the agent
* somewhere other than where the user said, spend the wrong account, and look like it worked.
*/
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { AgentEngine } from '../engines/types.js'
import {
CLAUDE_ALLOW_WEB_TOOLS_ARG,
CLAUDE_DISALLOW_WEB_TOOLS_ARG,
claudeGridPromptArgs,
CODEX_DISABLE_WEB_SEARCH_ARGS,
mcpServersConfig,
codexMcpArgs,
GRID_MCP_AUTH_VAR,
GROK_CONFIG_FILE,
GROK_GRID_HOME_LINKS,
GROK_HOME_VAR,
grokGridConfig,
HERMES_MANAGED_CONFIG_FILE,
HERMES_MANAGED_DIR_VAR,
HERMES_SYSTEM_MANAGED_DIR,
hermesManagedConfig,
mcpAuthorizationHeader,
} from './gridWebMcp.js'
import { HARNESS_MCP_SERVER_NAME } from './harnessWebTools.js'
/** Where Pi keeps the skills the user manages, handed back through our own settings.json. */
function userPiSkillsDir(): string {
return join(homedir(), '.pi', 'agent', 'skills')
}
/**
* Grok's real state directory — the one a private [GROK_HOME_VAR] borrows `sessions/` from.
*
* `homedir()` rather than the daemon's own `env.GROK_HOME`, deliberately and for the reason stated
* beside [HERMES_SYSTEM_MANAGED_DIR]: a contract that reads process configuration answers
* differently depending on who launched it. The two agree in every deployment that matters — the
* daemon's `GROK_HOME` defaults to exactly this — and a machine that has moved it has moved the
* transcripts the daemon reads too, which is a question for the caller, not for a pure contract.
*/
function userGrokHome(): string {
return join(homedir(), '.grok')
}
/** What the desktop sends, once validated. Mirrors `GridAgentOverride` in the desktop app. */
export interface GridLaunchOverride {
networkId: string
/** The grid's display name — for log lines and error text, never for routing. */
networkName: string
/**
* The grid's OpenAI-compatible relay root, as the control plane reports it: `<grid>/relay/v1`.
* Per-engine forms are derived from this; see [relayBaseUrl] and [anthropicBaseUrl].
*/
baseUrl: string
/** Short-lived, minted per launch. Never logged, never placed in argv; persisted only in the registry
* row (see the module header), so a relaunch can repeat this launch. */
apiKey: string
/** Absent means "whatever the engine asks for" — the relay's own default. */
model?: string
/**
* The grid's web-tools MCP endpoint, on the CONTROL PLANE — `…/v1/grid/web-mcp/`, trailing slash
* included, because without it the mount answers 307 and not every client follows one.
*
* Deliberately not derived here from [baseUrl]. That is the RELAY, and grid ADR 0041 D-a takes the
* relay out of this path on purpose: a relay is per-grid, can be asleep, and for a self-hosted grid
* is a LAN address a harness may not reach. The control plane's address is also not ours to guess —
* it is whichever one the user's Grid CLI is signed into, and this machine may have no Grid session
* at all. The desktop knows; this does not.
*
* Absent means no web tools, which is exactly what an older desktop sends.
*/
mcpUrl?: string
/**
* The model's context window in tokens, as the grid's relay reports it (`context_window` on its
* `/models` row) — the size the engine serving it was actually started with.
*
* ⚠️ Every coding agent here assumes a window for a model it does not recognise, and a grid model
* is never one it recognises: Claude Code assumes 200K, Codex and OpenCode know nothing at all. An
* agent that does not know the real window never compacts before it — the server rejects the
* request as too long first, and the session dies where it should have summarised. So each engine
* is TOLD, in its own dialect ([contextWindowHint] and the contracts below).
*
* Absent means the relay did not say, and each engine keeps its own assumption, as before.
*/
contextWindow?: number
}
/**
* The `networkId` of a launch onto a saved API (`apiModels.ts`) rather than a grid: `api:<connection id>`.
* The same contracts below run it — an OpenAI-compatible API such as OpenRouter is reached exactly the
* way a grid's relay is — but nothing grid-specific (waking, the grid's picture) applies to it.
*/
export const API_NETWORK_PREFIX = 'api:'
export function isApiLaunch(launch: Pick<GridLaunchOverride, 'networkId'> | null | undefined): boolean {
return !!launch?.networkId.startsWith(API_NETWORK_PREFIX)
}
/** The smallest window believed. Anything below it is not a model a coding agent can run on, and a
* value that small is likelier a misreport than a real engine — better to say nothing than to have
* an agent compact every turn. */
const MIN_CONTEXT_WINDOW = 4096
/** The largest believed: no engine serves more, and a bigger number is a unit error. */
const MAX_CONTEXT_WINDOW = 16 * 1024 * 1024
/** A context window worth handing an engine, or undefined. Lenient on purpose, unlike every other
* field here: this is a hint, and a malformed one must cost only the hint — refusing the whole
* override would strand a persisted launch that is otherwise fine. */
export function contextWindowHint(value: unknown): number | undefined {
return typeof value === 'number' && Number.isSafeInteger(value) && value >= MIN_CONTEXT_WINDOW && value <= MAX_CONTEXT_WINDOW
? value : undefined
}
export type GridOverrideParse =
| { state: 'absent' }
| { state: 'ok'; override: GridLaunchOverride }
| { state: 'invalid'; reason: string }
/**
* Control characters have no place in a URL, a token or a model id, and every one of these values
* ends up in a process environment or an argv. Rejecting them keeps a malformed frame from producing
* an engine whose launch is subtly not what either side thinks it is.
*/
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
function requiredString(raw: Record<string, unknown>, key: string): string | null {
const value = raw[key]
if (typeof value !== 'string') return null
const trimmed = value.trim()
if (!trimmed || CONTROL_CHARS.test(trimmed)) return null
return trimmed
}
/**
* Why [value] is not an address an engine can be handed, or null when it is one.
*
* Anything else — a `file:` URL, a bare hostname, a path — would fail inside the engine with an
* error naming neither the grid nor this frame. Shared by the two URL fields rather than written
* twice, so they cannot drift into disagreeing about what an address is.
*/
function urlProblem(field: string, value: string): string | null {
let parsed: URL
try {
parsed = new URL(value)
} catch {
return `grid ${field} is not a URL`
}
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return `grid ${field} must be http(s), got ${parsed.protocol}`
}
return null
}
/**
* `payload.grid` as a validated override, or the reason it is not one.
*
* Absent is a first-class answer, not a failure: a build with no grid selected sends no `grid` field
* at all, and must create agents exactly the way it always did.
*/
export function parseGridLaunchOverride(raw: unknown): GridOverrideParse {
if (raw === undefined || raw === null) return { state: 'absent' }
if (typeof raw !== 'object' || Array.isArray(raw)) return { state: 'invalid', reason: 'grid must be an object' }
const source = raw as Record<string, unknown>
const networkId = requiredString(source, 'networkId')
const networkName = requiredString(source, 'networkName')
const baseUrl = requiredString(source, 'baseUrl')
const apiKey = requiredString(source, 'apiKey')
const missing = [
networkId ? null : 'networkId',
networkName ? null : 'networkName',
baseUrl ? null : 'baseUrl',
apiKey ? null : 'apiKey',
].filter((name): name is string => name !== null)
if (missing.length) return { state: 'invalid', reason: `grid is missing ${missing.join(', ')}` }
const badBaseUrl = urlProblem('baseUrl', baseUrl as string)
if (badBaseUrl) return { state: 'invalid', reason: badBaseUrl }
const hasModel = source.model !== undefined && source.model !== null
const model = hasModel ? requiredString(source, 'model') : undefined
if (hasModel && !model) return { state: 'invalid', reason: 'grid model must be a non-empty string' }
const hasMcpUrl = source.mcpUrl !== undefined && source.mcpUrl !== null
const mcpUrl = hasMcpUrl ? requiredString(source, 'mcpUrl') : undefined
if (hasMcpUrl && !mcpUrl) return { state: 'invalid', reason: 'grid mcpUrl must be a non-empty string' }
const badMcpUrl = mcpUrl ? urlProblem('mcpUrl', mcpUrl) : null
if (badMcpUrl) return { state: 'invalid', reason: badMcpUrl }
const contextWindow = contextWindowHint(source.contextWindow)
return {
state: 'ok',
override: {
networkId: networkId as string,
networkName: networkName as string,
baseUrl: baseUrl as string,
apiKey: apiKey as string,
...(model ? { model } : {}),
...(mcpUrl ? { mcpUrl } : {}),
...(contextWindow ? { contextWindow } : {}),
},
}
}
/** The OpenAI-compatible relay root — what every engine here wants except Claude Code. */
export function relayBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, '')
return trimmed.endsWith('/v1') ? trimmed : `${trimmed}/v1`
}
/**
* The relay root Claude Code wants.
*
* The app appends `/v1/messages` itself, so the `/v1` an OpenAI SDK needs would 404 every request
* here — the same one-character difference that makes `grid launch claude --print-env` a separate
* command from `grid info --env` in the grid CLI. Idempotent: a base that already lacks `/v1` comes
* back unchanged.
*/
export function anthropicBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, '')
return trimmed.endsWith('/v1') ? trimmed.slice(0, -'/v1'.length) : trimmed
}
/**
* The relay's routing strategy, used when the user picked no particular model.
*
* Not a model: send a request naming it and the grid chooses one. It is `owned_by: grid-router` in
* the relay's `/models`, and the relay answers it like any other id — which is what lets a provider
* block that must name SOMETHING name this.
*/
export const GRID_ROUTER_MODEL = 'Auto'
/** The file OpenCode's `OPENCODE_CONFIG` is pointed at. */
const OPENCODE_CONFIG_FILE = 'opencode.json'
/**
* A grid's name as an OpenCode provider id.
*
* The id is what a person types as `<id>/<model>`, so it has to survive being typed: lowercase, no
* spaces, no dots. Grid names carry all three — `autonomous.ai`, `private autonomous`, `macOS` — so
* this is a real transformation rather than a formality.
*
* autonomous.ai -> autonomous-ai
* private autonomous -> private-autonomous
* macOS -> macos
*
* A name that leaves nothing behind (punctuation only) falls back to `grid`, because an empty
* provider key would make the config unparseable rather than merely odd.
*/
export function gridProviderId(networkName: string): string {
const slug = networkName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return slug || 'grid'
}
/**
* The provider id OpenCode's generated config declares for [override].
*
* A saved API is `api-<connection id>`, never its bare name: OpenCode ships built-in providers under
* names like `openrouter`, and a config block with the same id is merged into the built-in one rather
* than replacing it.
*/
function opencodeProviderId(override: GridLaunchOverride): string {
return isApiLaunch(override)
? `api-${gridProviderId(override.networkId.slice(API_NETWORK_PREFIX.length))}`
: gridProviderId(override.networkName)
}
/**
* The provider block OpenCode reads, as JSON.
*
* Four things here are load-bearing, each of which breaks the engine differently when got wrong:
*
* 1. **No top-level `model` key.** OpenCode's schema `$ref`s a CLOSED enum of known public models
* with no wildcard branch, so naming a private grid's model there makes OpenCode refuse the
* whole config at startup — a failure that arrives as a dead pane, long after the launch looked
* fine. The model is selected on argv instead, which is not schema-validated.
* 2. **`baseURL` is the relay root verbatim.** It already ends in `/relay/v1`; the SDK appends
* `/chat/completions` itself, so any "normalising" here 404s every request.
* 3. **`apiKey` is `{env:…}`, not the key.** Nothing written to disk by this module may contain a
* credential. This diverges from the advice a person following OpenCode's own docs gets — there
* the literal is recommended, because an unset variable silently becomes an empty string and a
* human-launched OpenCode has no guarantee the variable is set. Here it IS guaranteed: the
* daemon puts it in the pane's environment with `tmux new-session -e` before the engine starts.
* 4. **`limit` only when the window is known, and then BOTH halves.** OpenCode requires `context`
* and `output` together and rejects the config given only one. It is how OpenCode knows how much
* room is left, so without it a session ran on until the relay refused a request as too long,
* never having compacted. Absent when the relay did not report a window: inventing one would be
* worse than OpenCode's own defaults. `output` is a quarter of the window, capped at OpenCode's
* own 32K output ceiling — it is also what OpenCode holds back from the window for the reply, so
* a larger share would compact a small window after every other turn.
*/
/** OpenCode's own ceiling on a reply, which it also reserves out of the window. */
const OPENCODE_OUTPUT_MAX = 32_000
function opencodeOutputLimit(contextWindow: number): number {
return Math.min(OPENCODE_OUTPUT_MAX, Math.floor(contextWindow / 4))
}
function opencodeGridConfig(
provider: string,
override: GridLaunchOverride,
model: string,
): string {
// EXACTLY ONE model, and that is deliberate: it is what the agent was created with.
//
// Offering the router alongside it would let someone switch inside OpenCode, which reads as a
// kindness until you ask what the agent is running on. The probe that answers that
// (`readOpencodeGridAssignment`) reads this file, so a file naming two models can only say which
// one is live by parsing the engine's argv — and an argv is a live process's business, which it
// may rewrite. One model here makes the answer a fact about a file that cannot change under us.
//
// Choosing a different model is what every other engine here does too: per agent, at creation.
const models: Record<string, { name: string; limit?: { context: number; output: number } }> = {
[model]: {
name: model,
...(override.contextWindow
? { limit: { context: override.contextWindow, output: opencodeOutputLimit(override.contextWindow) } }
: {}),
},
}
return `${JSON.stringify({
$schema: 'https://opencode.ai/config.json',
provider: {
[provider]: {
npm: '@ai-sdk/openai-compatible',
// The grid's name as written — this is what a person reads in the model picker, so it keeps
// its dots and its capitals while the id beside it does not.
name: override.networkName,
options: {
baseURL: relayBaseUrl(override.baseUrl),
apiKey: `{env:${GRID_KEY_VAR}}`,
},
models,
},
},
// The grid's web tools, referencing the key exactly the way `apiKey` above does — opencode's own
// `{env:…}`, so this file still contains no credential.
...(override.mcpUrl
? {
mcp: {
[HARNESS_MCP_SERVER_NAME]: {
type: 'remote',
url: override.mcpUrl,
enabled: true,
headers: { Authorization: `Bearer {env:${GRID_KEY_VAR}}` },
},
},
}
: {}),
}, null, 2)}\n`
}
/**
* One directory the per-agent config directory borrows from elsewhere, as a symlink.
*
* For an engine whose variable redirects its WHOLE home rather than just its provider config: the
* private directory has to hand back the parts the daemon still reads. Grok is the only such engine
* — see `GROK_GRID_HOME_LINKS` in `gridWebMcp.ts`.
*/
export interface GridConfigLink {
/** Name inside the per-agent directory. Never a path. */
name: string
/** Absolute path the link points at — the user's real directory. */
target: string
}
/** One file to write into the per-agent config directory a launch is given. */
export interface GridConfigFile {
/** File name inside the directory. Never a path — this writes one flat directory. */
name: string
content: string
}
/**
* Whether the agent this launch produces can search the web, in the words the app shows.
*
* * `on` — the MCP url was present and the engine's contract wired the server in.
* * `unavailable` — no url reached the launch: the grid could not be asked for one (an outdated or
* missing `grid` CLI, no sign-in, an unknown grid — `gridMcpUrl.ts` logs which). Inference still
* goes to the grid; asking again later can fix it.
* * `unsupported` — the engine cannot take the server on this machine at all: Pi has no MCP client,
* and Hermes on a machine whose settings are pinned in [HERMES_SYSTEM_MANAGED_DIR] cannot be handed
* the overlay without replacing them. Nothing about the grid changes this.
*
* Decided HERE, by the same code that decides whether the server is wired, so the two cannot
* disagree — and stored with the launch rather than re-derived, so a reconnect or a restart reports
* what the launch actually did. The app reads it as `grid.webSearch` on every agent frame.
*/
export type GridWebSearchStatus = 'on' | 'unavailable' | 'unsupported'
/**
* A grid launch as the registry keeps it: the override to repeat it with, and what building it
* decided about web search. One object so the two are written and cleared together — a status
* without its launch, or a launch without its status, is a row the app would read wrongly.
*/
export interface GridLaunchRecord {
override: GridLaunchOverride
webSearch: GridWebSearchStatus
}
/**
* Facts about THIS machine a contract cannot read for itself.
*
* A contract is pure — the module comment on [userGrokHome] says why: one that stats the filesystem
* answers differently on two machines, and its spec would follow. So the caller reads the machine
* and hands the answer in, and the contract decides from it. What it decides stays here, where every
* caller (create, retarget, restore) gets the same answer from the same code.
*/
export interface GridLaunchMachine {
/**
* Whether an administrator pinned Hermes settings in [HERMES_SYSTEM_MANAGED_DIR]. Hermes's web
* tools ride a managed-scope overlay that REPLACES that directory rather than adding to it, so on
* such a machine the overlay is dropped and the agent launches without web tools.
*/
hermesSystemManaged: boolean
}
/** How one engine is launched against a grid. */
export interface GridEngineLaunch {
/** Layered over the engine's inherited environment. This is where the key goes, always. */
env: Record<string, string>
/** Appended to the engine's argv. Never carries the key — `ps` is world-readable. */
args: string[]
/** What this launch gives the agent by way of web search. See [GridWebSearchStatus]. */
webSearch: GridWebSearchStatus
/**
* For an engine that reads its provider out of a config directory rather than an environment
* variable: files the daemon writes into a directory IT owns, and the variable that points the
* engine at that directory.
*
* This is not "editing the user's dotfiles" — the point of the indirection is that it never
* touches them. The engine gets a private configuration for this agent, the user's own stays
* exactly as they left it, and deleting the directory undoes everything. No file written here may
* contain the key; Pi's provider block references an environment variable instead.
*/
configDir?: {
envVar: string
files: GridConfigFile[]
/**
* Point [envVar] at ONE of the written files rather than at the directory holding them.
*
* Pi wants the directory (`PI_CODING_AGENT_DIR`); OpenCode's `OPENCODE_CONFIG` wants a config
* file. Same mechanism — a private directory this daemon owns — differing only in what the
* engine is handed, so it is a field rather than a second writer.
*/
pointAt?: string
/**
* Directories the private one borrows back from the engine's real home.
*
* Only for a variable that redirects a whole home: `GROK_HOME` takes `sessions/` with it, and the
* daemon reads transcripts from the real one. Empty for every engine whose variable names only a
* provider config, which is the usual case.
*/
links?: GridConfigLink[]
}
}
interface GridEngineContract {
build: (override: GridLaunchOverride, machine: GridLaunchMachine) => GridEngineLaunch
/** The engine cannot start against a grid without being told which model to ask for. */
requiresModel?: boolean
}
/**
* The variable an engine is told to read the key from, where the engine supports that indirection.
*
* Codex names it in `env_key`; Pi's `models.json` writes it as a `$VAR` reference. Both exist so the
* credential can stay in the environment while the configuration that points at it is not secret.
*/
const GRID_KEY_VAR = 'GRID_API_KEY'
/** The provider id our generated config declares. Pi selects it as `--model <id>/<model>`. */
const GRID_PROVIDER_ID = 'grid'
/**
* The status for an engine whose contract wires the server whenever there is a url to wire — which
* is every contract but Pi's, and Hermes's only when the machine lets it.
*/
const webSearchWhenWired = (override: GridLaunchOverride): GridWebSearchStatus =>
override.mcpUrl ? 'on' : 'unavailable'
/**
* Every variable any contract in this file uses to point an engine somewhere.
*
* A grid launch has to CLEAR the ones it does not itself set, because setting the right variable is
* not enough to decide where an engine goes: an engine picks a provider from whatever credentials it
* can see, and a stray one wins on its own terms. Measured, twice, on one machine:
*
* * OpenCode, handed `OPENAI_BASE_URL` for a grid, found an inherited `ANTHROPIC_API_KEY` and chose
* Claude Sonnet at api.anthropic.com — reporting `invalid x-api-key`, a sentence that names
* neither the grid nor the variable that redirected it.
* * Claude Code, on the same machine, had its `ANTHROPIC_BASE_URL` deleted by a line in `.zshrc` and
* fell back to the same key, with the same unreadable result.
*
* The inherited value arrives from further away than a user can reasonably audit. On that machine it
* came from `.zshrc`, and then — after that was fixed — from a VS Code setting
* (`claudeCode.environmentVariables`) that seeded the terminal the desktop app was launched from,
* whose environment the app passed to the daemon, which passed it to the tmux server, which gave it
* to every pane. Four layers, none visible from the failure.
*
* So the launch is the place to settle it: it is the only point that knows the user asked for a grid.
* Unset here is scoped to the engine's own process and touches nothing on disk — a plain terminal on
* the same machine keeps every variable it had.
*
* The list is deliberately OUR OWN vars rather than a survey of every provider an engine supports.
* Enumerating those is unbounded and would go stale silently; these are the ones this file uses, so
* this file can be right about them.
*/
export const GRID_CONFLICTING_ENV_VARS: readonly string[] = [
'ANTHROPIC_API_KEY',
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_MODEL',
'OPENAI_API_KEY',
'OPENAI_BASE_URL',
'XAI_API_KEY',
'GROK_MODELS_BASE_URL',
'COPILOT_PROVIDER_API_KEY',
'COPILOT_PROVIDER_BASE_URL',
'COPILOT_MODEL',
'HERMES_INFERENCE_MODEL',
GRID_KEY_VAR,
GRID_MCP_AUTH_VAR,
HERMES_MANAGED_DIR_VAR,
GROK_HOME_VAR,
]
/**
* The variables this launch must clear: everything in [GRID_CONFLICTING_ENV_VARS] the launch does not
* itself set.
*
* Set-then-unset would be a bug, so the two sets are computed from one another rather than listed
* twice — a contract that gains a variable stops clearing it in the same edit.
*/
export function gridConflictingEnvToClear(launch: Pick<GridEngineLaunch, 'env' | 'configDir'>): string[] {
const provided = new Set(Object.keys(launch.env))
if (launch.configDir) provided.add(launch.configDir.envVar)
return GRID_CONFLICTING_ENV_VARS.filter((name) => !provided.has(name))
}
/**
* Pi's provider block, as the Grid app shipped and unit-tested it
* (`autonomous-grid-app`, `pi_grid_config.dart` at 36d00c95, before Pi was dropped from that app for
* reasons about ITS chat UI — a fourth agent nobody reached for, and a 180 MB private Node
* toolchain — none of which apply here, where the user installs Pi themselves).
*
* `api: openai-completions` makes Pi post to `<base>/chat/completions`, which is what the relay
* serves. The context/cost numbers are Pi's own bookkeeping for its display; the grid decides what
* the model really takes.
*/
function piModelsJson(baseUrl: string, model: string, contextWindow?: number): string {
return JSON.stringify({
providers: {
[GRID_PROVIDER_ID]: {
name: 'Autonomous Grid',
baseUrl,
api: 'openai-completions',
// An env reference, not the key — nothing secret is written to disk.
apiKey: `$${GRID_KEY_VAR}`,
models: [{
id: model,
name: model,
reasoning: false,
input: ['text'],
// The real window when the relay reported one — the same fault as every engine here:
// told 200K, Pi never compacted before a smaller server refused the request.
contextWindow: contextWindow ?? 200000,
maxTokens: 8192,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
}],
},
},
}, null, 2)
}
/**
* Pi's settings for the directory we own, which exists to hand back the one thing the redirection
* takes away: the skills the user keeps in their real `~/.pi/agent/skills`.
*
* Deliberately NOT `defaultProjectTrust: always`. The Grid app set it because it drove Pi headless,
* one process per turn, with nobody there to answer a prompt. Here the user is sitting in front of
* an interactive pane, and silently pre-trusting every folder they open an agent in would be this
* daemon deciding something it was not asked to decide.
*/
function piSettingsJson(userSkillsDir: string): string {
return JSON.stringify({ skills: [userSkillsDir] }, null, 2)
}
/**
* Every engine that can be pointed at a grid, and how.
*
* `undefined` is a refusal with a reason attached in [GRID_ENGINE_REFUSALS]; see the module comment
* for why a missing entry is never filled in with a plausible-looking guess.
*/
const GRID_ENGINE_CONTRACTS: Partial<Record<AgentEngine, GridEngineContract>> = {
// The grid CLI's own launch target (`autonomous-grid/shared/launch/claude.py`), so these are the
// vendor's names as that team verified them rather than this repo's reading of them.
claude: {
build: (override) => {
const webSearch = webSearchWhenWired(override)
return {
env: {
ANTHROPIC_BASE_URL: anthropicBaseUrl(override.baseUrl),
// The bearer variable, and ONLY it. Claude Code warns when ANTHROPIC_AUTH_TOKEN and
// ANTHROPIC_API_KEY are both set, and the relay prefers the Bearer header anyway — so
// ANTHROPIC_API_KEY would decide nothing, while colliding with the variable a user's own
// Anthropic key lives in.
ANTHROPIC_AUTH_TOKEN: override.apiKey,
// `grid launch claude` deliberately sets no model variable, on the grounds that a launcher
// has no standing to choose a user's model. That reasoning does not carry here: the desktop
// app ASKED, and this is the answer. Left unset when the user picked no model.
...(override.model ? { ANTHROPIC_MODEL: override.model } : {}),
// The window to compact within. A grid model's id is one Claude Code does not recognise, so
// it assumes 200K and compacts only near that — on a smaller server, never, because the
// server refuses the request first. This is the documented variable for exactly that
// case (code.claude.com/docs/en/model-config, "unrecognized model IDs").
...(override.contextWindow ? { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(override.contextWindow) } : {}),
// Only when there are web tools to reach: the variable exists to be referenced by the config
// below, and setting it otherwise would leave a key in the pane that nothing reads.
...(override.mcpUrl ? { [GRID_KEY_VAR]: override.apiKey } : {}),
},
args: [
// On every grid launch, web tools or not: the built-in search is an Anthropic server tool
// that no grid runs, and the built-in fetch summarises through a model the grid does not
// serve. See `CLAUDE_DISALLOW_WEB_TOOLS_ARG`.
CLAUDE_DISALLOW_WEB_TOOLS_ARG,
// With the server comes its approval: a tool the daemon wired in and a permission mode then
// refuses is worse than no tool at all. See `CLAUDE_ALLOW_WEB_TOOLS_ARG`.
...(override.mcpUrl
? ['--mcp-config', mcpServersConfig(override.mcpUrl, GRID_KEY_VAR), CLAUDE_ALLOW_WEB_TOOLS_ARG]
: []),
// And the words. An agent moved here mid-conversation reads `WebSearch` off its own history
// before it reads the list, so the prompt names what is gone and — when one was wired — what
// replaces it, delivered past the recorded prompt a resume would otherwise replay. See
// `claudeGridPromptArgs`.
...claudeGridPromptArgs(webSearch === 'on'),
],
webSearch,
}
},
},
// Codex configures its provider entirely on the command line — `-c key=value` overrides anything
// `~/.codex/config.toml` would have said, which is how `ori codex` points it at OpenRouter without
// touching a dotfile (see `gatewayRuntime.ts`). Key names verified against the grid repo's
// `docs/codex-quickstart.md` and `-c` against codex-cli 0.144.6 on this machine.
//
// The key travels in the environment under `env_key`, never in argv.
codex: {
build: (override) => ({
env: {
[GRID_KEY_VAR]: override.apiKey,
...(override.mcpUrl ? { [GRID_MCP_AUTH_VAR]: mcpAuthorizationHeader(override.apiKey) } : {}),
},
args: [
'-c', 'model_provider="grid"',
'-c', 'model_providers.grid.name="Autonomous Grid"',
'-c', `model_providers.grid.base_url="${relayBaseUrl(override.baseUrl)}"`,
'-c', `model_providers.grid.env_key="${GRID_KEY_VAR}"`,
// Mandatory: Codex speaks the Responses dialect and rejects `wire_api = "chat"`.
'-c', 'model_providers.grid.wire_api="responses"',
// The relay streams HTTP SSE, not WebSocket.
'-c', 'model_providers.grid.supports_websockets=false',
// On every grid launch, web tools or not: the native search is a Responses-API feature no
// grid serves. See `CODEX_DISABLE_WEB_SEARCH_ARGS`.
...CODEX_DISABLE_WEB_SEARCH_ARGS,
...(override.mcpUrl ? codexMcpArgs(override.mcpUrl) : []),
...(override.model ? ['-m', override.model] : []),
// The window, and where to compact inside it. Codex knows neither for a model it does not
// recognise, so it never compacted and the relay refused the request as too long instead.
// 90% leaves the summary request itself room to fit.
...(override.contextWindow
? [
'-c', `model_context_window=${override.contextWindow}`,
'-c', `model_auto_compact_token_limit=${Math.floor(override.contextWindow * 0.9)}`,
]
: []),
],
webSearch: webSearchWhenWired(override),
}),
},
// opencode's simplest documented custom provider: the OpenAI-compatible pair
// (https://opencode.ai/docs/providers). The model is left to the app — its `--model` wants a
// `provider/model` pair whose provider id is not documented for the env-var route, and inventing
// one would send it looking for a model that does not exist.
// OpenCode is the one engine here that an endpoint variable alone cannot steer, and the reason is
// worth stating because `OPENAI_BASE_URL` looks like it should be enough — the grid CLI's own
// `grid info --env` exports exactly that pair, and it is enough for hermes.
//
// OpenCode's `openai` provider carries a COMPILED-IN model catalogue (49 entries: gpt-4o,
// gpt-5.6-terra-pro, …). It sends requests to `OPENAI_BASE_URL` but never asks that endpoint what
// it serves, so pointing it at a grid gives an engine that will only ever name models the grid has
// never heard of. Measured: it selected `gpt-5.6-terra-pro` and the relay answered
//
// 503 · No providers available for this model. This grid serves: DeepSeek-V4-Flash-0731, …
//
// and `-m openai/DeepSeek-V4-Flash-0731` — naming a real grid model under its built-in provider —
// made OpenCode's own server throw instead.
//
// So it takes the shape codex and pi take: DECLARE a provider. `OPENCODE_CONFIG` names a config
// file, this daemon writes one into a directory it owns, and the grid arrives as a provider whose
// models are the grid's own ids. The user's `~/.config/opencode/opencode.json` is never opened.
opencode: {
build: (override) => {
const provider = opencodeProviderId(override)
// No model chosen means the grid routes — `Auto` is the router's own id, and the relay serves
// it (verified: 200). It is a real id to OpenCode either way, which is what matters: the
// provider block has to name something, and leaving the model out entirely puts OpenCode back
// on its own catalogue and the 503 above.
const model = override.model ?? GRID_ROUTER_MODEL
return {
// The key travels in the environment and is REFERENCED from the file, never written into it
// — the rule every config-file engine here follows.
env: { [GRID_KEY_VAR]: override.apiKey },
// Deliberately NOT `OPENAI_BASE_URL`/`OPENAI_API_KEY`. Setting them would re-arm the built-in
// `openai` provider beside ours, and its catalogue is what chose the model the grid refused.
args: ['-m', `${provider}/${model}`],
configDir: {
envVar: 'OPENCODE_CONFIG',
pointAt: OPENCODE_CONFIG_FILE,
files: [{
name: OPENCODE_CONFIG_FILE,
content: opencodeGridConfig(provider, override, model),
}],
},
webSearch: webSearchWhenWired(override),
}
},
},
// Nous Research's documented trio for a custom OpenAI-compatible endpoint
// (hermes-agent/website/docs/reference/environment-variables.md) — plus the model on the command
// line, which is the only place hermes reads it from in the mode this daemon actually launches.
//
// `HERMES_INFERENCE_MODEL` is read by `hermes -z` and by the gateway behind `hermes --tui`. The
// pane opened here is neither: it is the INTERACTIVE CLI, whose model resolution is `-m` then
// config.yaml and no env tier at all (`cli.py:_init_model_and_provider`, "Priority: CLI args >
// env vars > config file" — the env half is about the provider). And a grid move relaunches as
// `hermes --resume <id>`, which restores the model stored on the session row unless argv carried
// an explicit `-m`; that flag is the documented opt-out ("resume must not clobber an explicit -m
// with the session's stored model", `cli.py` / `cli_model_switch_mixin.py`).
//
// Measured on 2026-09-08 before this line existed: the pane's environment said
// `HERMES_INFERENCE_MODEL=GLM-4.7-Flash` — which is what the desktop's model pill read back and
// printed — while hermes itself ran DeepSeek-V4-Flash-0731, the default in the user's own
// ~/.hermes/config.yaml and the model persisted on the resumed session's row.
//
// The variable stays beside the flag: `-z`/`--tui` read it, and it is how `gridAssignment.ts`
// answers "which model is this agent on" for hermes without parsing a live process's argv.
//
// ⚠️ The ENDPOINT has no such flag. Hermes's custom-provider resolver deliberately ignores
// `OPENAI_BASE_URL` ("config.yaml is the single source of truth for endpoint URLs",
// `runtime_provider_backends.py`) and the CLI exposes no `--base-url`, so moving a hermes agent
// between grids moves its model but leaves its requests on whatever relay config.yaml names. The
// only lever left is `HERMES_HOME`, which would take state.db and the user's skills with it and
// break the resume this move depends on.
//
// ⚠️ The overlay is the ONE thing here a machine can refuse. `HERMES_MANAGED_DIR` REPLACES
// `/etc/hermes` rather than adding to it — so on a machine where an administrator pinned Hermes
// settings there, writing ours would take their policy away for as long as the agent runs. The
// agent still launches on the grid; it launches without web tools, which is the smaller loss and
// the one the app can say out loud (`unsupported`). The machine fact arrives from the caller
// (`GridLaunchMachine`); the decision is made here so create, retarget and restore cannot differ.
hermes: {
build: (override, machine) => {
const overlay = !machine.hermesSystemManaged && override.mcpUrl
? hermesManagedConfig(override.mcpUrl, GRID_KEY_VAR)
: undefined
return {
env: {
OPENAI_BASE_URL: relayBaseUrl(override.baseUrl),
OPENAI_API_KEY: override.apiKey,
...(override.model ? { HERMES_INFERENCE_MODEL: override.model } : {}),
// Referenced by the overlay below, so it exists only when there is an overlay to read it.
...(overlay ? { [GRID_KEY_VAR]: override.apiKey } : {}),
},
args: override.model ? ['-m', override.model] : [],
// Hermes reads its MCP servers from one config file and takes no flag for them, so the web
// tools arrive as a managed-scope overlay merged over the user's own — see `gridWebMcp.ts`,
// including why this is `HERMES_MANAGED_DIR` and not `HERMES_HOME`.
...(overlay
? {
configDir: {
envVar: HERMES_MANAGED_DIR_VAR,
files: [{ name: HERMES_MANAGED_CONFIG_FILE, content: overlay }],
},
}
: {}),
// The pin outranks the url: a machine that cannot take the overlay cannot take it whether or
// not the grid offered one, and that is the fact a person can act on.
webSearch: machine.hermesSystemManaged ? 'unsupported' : webSearchWhenWired(override),
}
},
},
// xAI's own Grok CLI (the one this repo discovers under `~/.grok`, whose transcripts carry the
// `_x.ai/session/update` method). Its docs: "Grok fetches the model list from {base_url}/models",
// and "when you set models_base_url, Grok uses API key auth instead of session auth" — the second
// half of which does NOT hold in practice: measured, the variable moves where requests go but not
// which credential is picked, and the session token still wins. See `grokGridConfig`. The model has
// no documented variable, so it goes in argv AND into the config block that carries the credential.
//
// Its web tools cost it a private home. Grok reads `[mcp_servers]` from its config file and takes
// no flag for them, and the only layer that both starts unattended and is not the user's own file
// is `$GROK_HOME/config.toml` — the project layer is gated on a folder-trust prompt nobody is
// there to answer, and `CLAUDE_CONFIG_DIR` does not move MCP discovery at all. See `gridWebMcp.ts`,
// which measured each of them.
//
// ⚠️ `GROK_HOME` moves the WHOLE state directory, `sessions/` included — and for grok that is not
// cosmetic: `cli.ts` drops an observed grok agent entirely when it can find no transcript, so an
// un-borrowed home would launch an agent the harness never registers. The directory is linked back
// rather than moved; [GROK_GRID_HOME_LINKS] says which, and why the list stops where it does.
//
// ⚠️ The private home is NOT optional, and the reason is the credential order rather than the web
// tools. `XAI_API_KEY` is the LAST thing Grok reaches for — behind the OIDC session token — so an
// agent launched with the documented variable pair alone authenticates as the user's own xAI login
// and the relay answers 401. The config block declares the model with `env_key`, which outranks the
// session token; `grokGridConfig` carries the measurement. So every grid launch writes a config,
// web tools or not, and the user's own `~/.grok` is still never opened.
grok: {
// Deliberately NOT `requiresModel`. The credential rides on a declared model block, so a launch
// does need SOME model named — but that is this contract's problem to solve, not a question to
// put back to the user. No model picked means the grid routes, and the relay serves its router id
// like any other model (verified: `{"model":"Auto"}` → 200, answered by DeepSeek-V4-Flash-0731),
// so `Auto` is what the block declares. Verified end to end: Grok launched against `[model."Auto"]`
// with `env_key` answered a prompt through the router with `auth_mode=null` and no probe.
//
// Making this `requiresModel` was the first fix here and it was the wrong one: it turned "Auto",
// the default the New Agent dialog shows, into a refusal at the moment of clicking Create —
// trading a 401 the user could not diagnose for a wall they could not get past. Same trade
// OpenCode faced, same answer (see `GRID_ROUTER_MODEL` in its contract above).
build: (override) => {
// One name, used in three places that must agree: argv, the config block's header, and the
// `model` field inside it. A mismatch would declare a credential for a model Grok never asks for.
const model = override.model ?? GRID_ROUTER_MODEL
return {
env: {
GROK_MODELS_BASE_URL: relayBaseUrl(override.baseUrl),
// Kept even though the config below is what actually wins. It is the documented pair for a
// custom endpoint, it is what `gridAssignment.ts` reads back, and on a build whose config
// layer we have misjudged it is still the credential Grok would reach for last.
XAI_API_KEY: override.apiKey,
// Referenced by `env_key` in the config, which is always written — so this always is too.
[GRID_KEY_VAR]: override.apiKey,
},
args: ['-m', model],
// ⚠️ ALWAYS a config directory now, web tools or not. It used to be written only when there
// were MCP servers to declare, on the reasoning that a launch with nothing to configure should
// leave the user's `~/.grok` alone — but the credential order (see `grokGridConfig`) means the
// config is what makes the grid key win over the OIDC session token. Without it a grid agent
// authenticates as the user's xAI login and the relay answers 401.
configDir: {
envVar: GROK_HOME_VAR,
files: [{
name: GROK_CONFIG_FILE,
content: grokGridConfig(
override.mcpUrl,
GRID_KEY_VAR,
model,
relayBaseUrl(override.baseUrl),
),
}],
links: GROK_GRID_HOME_LINKS.map((name) => ({ name, target: join(userGrokHome(), name) })),
},
webSearch: webSearchWhenWired(override),
}
},
},
// Pi reads its providers out of a config DIRECTORY, and `PI_CODING_AGENT_DIR` moves that
// directory. So it gets a private one per agent: the provider block lands there, the user's
// ~/.pi/agent/models.json is never opened, and their skills are handed back through settings.json.
//
// The provider block has to name the model it serves, and Pi selects it as `grid/<model>` — so
// something must always be named. No model chosen means the grid routes: `Auto` is the router's
// own id and the relay answers it like any other, exactly as OpenCode's block above relies on.
//
// This used to be `requiresModel: true`, which refused the launch outright. That made Pi the one
// grid-capable engine a person could not start from the New agent dialog at all, since the dialog
// always creates on Auto and no longer offers a model field — a dead end, not a prompt to go and
// pick something.
pi: {
build: (override) => {
const model = override.model ?? GRID_ROUTER_MODEL
return {
env: { [GRID_KEY_VAR]: override.apiKey },
args: ['--model', `${GRID_PROVIDER_ID}/${model}`],
configDir: {
envVar: 'PI_CODING_AGENT_DIR',
files: [
{ name: 'models.json', content: piModelsJson(relayBaseUrl(override.baseUrl), model, override.contextWindow) },
{ name: 'settings.json', content: piSettingsJson(userPiSkillsDir()) },
],
},
// Pi has no MCP client, so there is nothing to hand the server to — with or without a url.
webSearch: 'unsupported',
}
},
},
// GitHub's documented BYOK path for Copilot CLI (docs.github.com … /use-byok-models). Copilot
// will not start against a custom provider without being told the model, so that is enforced
// here rather than left to fail inside the app.
//
// Web tools ride the same JSON document Claude Code is handed, under Copilot's own flag. The flag
// is `--additional-mcp-config` rather than a `--mcp-config`: it AUGMENTS `~/.copilot/mcp-config.json`
// for the session, which is the behaviour wanted here and the reason no dotfile is written.
copilot: {
requiresModel: true,
build: (override) => ({
env: {
COPILOT_PROVIDER_BASE_URL: relayBaseUrl(override.baseUrl),
COPILOT_PROVIDER_API_KEY: override.apiKey,
...(override.model ? { COPILOT_MODEL: override.model } : {}),
// Same rule as claude: the variable exists only to be referenced by the config below, so it
// is set only when there is a config to reference it.
...(override.mcpUrl ? { [GRID_KEY_VAR]: override.apiKey } : {}),
},
args: override.mcpUrl
? ['--additional-mcp-config', mcpServersConfig(override.mcpUrl, GRID_KEY_VAR)]
: [],
webSearch: webSearchWhenWired(override),
}),
},
}
/**
* Why an engine cannot be pointed at a grid, in words the person who picked it can act on.
*
* Every engine without a contract has an entry: "unsupported" on its own tells a user nothing about
* whether to wait for a release, change a setting, or pick another engine.
*/
const GRID_ENGINE_REFUSALS: Partial<Record<AgentEngine, string>> = {
// NOTE: pi is NOT here — it is supported through a private config directory. Kilo is, because its
// CLI has no OpenAI-compatible provider to configure at all, in any directory.
cursor: 'Cursor Agent can only be re-pointed at another Cursor API (CURSOR_API_ENDPOINT), '
+ 'not at an OpenAI-compatible relay',
agy: 'Antigravity speaks the Gemini API, which this relay does not serve',
kilo: 'the Kilo CLI has no OpenAI-compatible provider option yet (its own issues #5840, #6315)',
amp: 'Amp documents no way to change where it sends inference',
devin: 'Devin runs on its own hosted service and documents no endpoint override',
muse: 'Muse Code documents no way to change its endpoint',
commandcode: 'Command Code documents no way to change its endpoint',
terminal: 'a terminal runs no engine to point at a grid — start one inside it and it will use its own login',
}
/** Engines that can be pointed at a grid today, for error text that names what to pick instead. */
export function gridCapableEngines(): AgentEngine[] {
return Object.keys(GRID_ENGINE_CONTRACTS) as AgentEngine[]
}
export type GridLaunchResult =
| { ok: true; launch: GridEngineLaunch }
| { ok: false; error: string; detail: string }
/**
* How `engine` must be launched to reach `override` on a machine like `machine`, or why it cannot be.
*
* `machine` is required rather than defaulted on purpose: the one fact in it is a policy an
* administrator set, and a caller that forgot to read it would write the overlay that policy exists
* to refuse — the exact failure moving the decision in here was meant to end.
*/
export function buildGridEngineLaunch(
engine: AgentEngine,
override: GridLaunchOverride,
machine: GridLaunchMachine,
): GridLaunchResult {
const contract = GRID_ENGINE_CONTRACTS[engine]
if (!contract) {
const reason = GRID_ENGINE_REFUSALS[engine] ?? 'it has no known way to change its endpoint'
return {
ok: false,
error: 'GRID_ENGINE_UNSUPPORTED',
detail: `${engine} cannot run on grid ${override.networkName}: ${reason}. `
+ `It would have run on its own login instead. Engines that can: ${gridCapableEngines().join(', ')}.`,
}
}
if (contract.requiresModel && !override.model) {
return {
ok: false,
error: 'GRID_MODEL_REQUIRED',
detail: `${engine} will not start against a grid without a model. `
+ `Pick one for ${override.networkName} and try again.`,
}
}
return { ok: true, launch: contract.build(override, machine) }
}
/**
* Why a launch has no web search, in the daemon log's words — for the two answers this module
* decides itself. `unavailable` is decided elsewhere (`gridMcpUrl.ts`), which logs its own reason
* the moment it gives up, so nothing is repeated here.
*/
function webSearchReason(engine: AgentEngine, webSearch: GridWebSearchStatus): string | null {
if (webSearch !== 'unsupported') return null
if (engine === 'pi') return 'Pi has no MCP client'
if (engine === 'hermes') {
return `${HERMES_SYSTEM_MANAGED_DIR} pins this machine's Hermes settings, and the overlay carrying `
+ 'the web tools would replace it'
}
return null
}
/** One log line naming where an agent was sent — grid, model, engine, and whether it got web
* search (with the reason when this module is the one that took it away). Never the key, never
* the MCP url. */
export function describeGridLaunch(
engine: AgentEngine,
override: GridLaunchOverride,
webSearch: GridWebSearchStatus,
): string {
const reason = webSearchReason(engine, webSearch)
return `[grid] ${engine} -> ${override.networkName} (${override.networkId})`
+ ` · ${override.model ?? 'model chosen by the engine'} · web search ${webSearch}`
+ (reason ? ` (${reason})` : '')
}
/** A placeholder override, used only to ask a contract which variables it sets. Never launched. */
const PROBE_OVERRIDE: GridLaunchOverride = {
networkId: 'probe',
networkName: 'probe',
baseUrl: 'https://example.invalid/probe/relay/v1',
apiKey: 'probe',
model: 'probe',
// Present so the probe reports the variables a launch WITH web tools sets. Retarget clears what
// this answers, and a probe that left them out would move an agent to another grid while its old
// grid's MCP credential stayed in the pane — the exact staleness the doc comment below warns of.
mcpUrl: 'https://example.invalid/v1/grid/web-mcp/',
// Present for the same reason: a launch that knew its window sets a variable (Claude Code's), and
// moving back to the own login must take it out of the pane with the rest.
contextWindow: 131072,
}
/**
* Which environment variables pointing [engine] at a grid sets.
*
* Asked of the contract by building one, rather than kept as a second list beside it. A hand-written
* list would be correct exactly until an engine's contract gained a variable, and the symptom of it
* being stale is the worst kind: an agent moved back to its own login that quietly keeps talking to
* the grid, reporting success the whole way.
*
* Empty for an engine that has no contract — there is nothing to clear because nothing was set.
*/
export function gridEnvVarNames(engine: AgentEngine): string[] {
// No pin, for the same reason the probe carries an MCP url: this asks which variables a launch
// WITH web tools sets, so that clearing them covers the fullest launch this engine can get.
const built = buildGridEngineLaunch(engine, PROBE_OVERRIDE, { hermesSystemManaged: false })
if (!built.ok) return []
const names = Object.keys(built.launch.env)
if (built.launch.configDir) names.push(built.launch.configDir.envVar)
return names
}