Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
51c9262e3This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Which grid is a running agent ACTUALLY on?
*
* The desktop app can point an agent at a grid when it is created (`gridLaunch.ts`), and can move an
* existing one (`agent_retarget`). Both work by changing how the engine was launched, which means the
* answer is not in any record this daemon keeps — it is in the process. So it is read from the
* process, the same way `gatewayRuntime.ts` reads which endpoint a pane is talking to, and for the
* same reason: a bookkeeping map drifts the moment anything happens that the bookkeeper did not do,
* and plenty does. A daemon restart forgets nothing. An agent the user started themselves with
* `ANTHROPIC_BASE_URL=… claude` in their own shell — which this product discovers and adopts —
* reports the truth rather than "none".
*
* Each engine is read through the same knob it was written through, so this table and
* `GRID_ENGINE_CONTRACTS` must stay in step: an engine written by environment is read from the
* environment, and Codex, which is written on the command line, is read from argv.
*
* **The key is never read.** The pane's credential is the pane's business; what leaves here is the
* endpoint and the model, both of which the app already knows because it chose them.
*/
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import type { AgentEngine } from '../engines/types.js'
import { GRID_ROUTER_MODEL } from './gridLaunch.js'
import { readProcessEnv } from './processEnv.js'
import type { ProcessIdentity } from './registry.js'
/** Where an agent's inference goes, as far as anyone outside the pane needs to know. */
export interface GridAssignment {
/** The relay root the engine was handed. The grid id is a path segment inside it. */
baseUrl: string
/** The model the launch pinned. Null = the engine's own choice. */
model: string | null
}
/** The environment variable each engine's endpoint was written to, where it is one. */
const BASE_URL_VAR: Partial<Record<AgentEngine, string>> = {
claude: 'ANTHROPIC_BASE_URL',
// opencode is deliberately absent: its endpoint moved into a config file (see
// `readOpencodeGridAssignment`), and leaving it here would read a stray OPENAI_BASE_URL from the
// user's own shell as proof this agent is on a grid.
hermes: 'OPENAI_BASE_URL',
grok: 'GROK_MODELS_BASE_URL',
copilot: 'COPILOT_PROVIDER_BASE_URL',
}
/** The environment variable each engine's model was written to, where it is one. */
const MODEL_VAR: Partial<Record<AgentEngine, string>> = {
claude: 'ANTHROPIC_MODEL',
hermes: 'HERMES_INFERENCE_MODEL',
copilot: 'COPILOT_MODEL',
}
/**
* Engines whose model was written into argv as `-m <model>` rather than an environment variable.
*
* Codex omits the flag when no model was picked; grok always carries one, because its grid credential
* rides on a declared model block and "let the grid route" is therefore spelled `-m Auto` rather than
* by leaving the flag off. [classifyGridAssignment] maps that id back to null.
*/
const MODEL_IN_ARGV = new Set<AgentEngine>(['codex', 'grok'])
/**
* Pi's endpoint is in neither its environment nor its argv — it is in the `models.json` inside the
* config directory `PI_CODING_AGENT_DIR` points at. So that is where it is read from, because the
* alternative is to trust a note we wrote ourselves about what we did.
*/
const PI_CONFIG_DIR_VAR = 'PI_CODING_AGENT_DIR'
/** `--model grid/<model>` — how Pi is told which provider and model to use. */
const PI_ARGV_MODEL = /(?:^|\s)--model\s+([A-Za-z0-9_-]+)\/(\S+)/
/** `-c model_providers.<name>.base_url="…"` — how Codex's endpoint is written, so how it is read. */
const CODEX_BASE_URL = /model_providers\.[A-Za-z0-9_-]+\.base_url=(?:"([^"]+)"|(\S+))/
const ARGV_MODEL = /(?:^|\s)-m\s+(\S+)/
/**
* A base URL that is not a grid is not an assignment.
*
* An agent can be pointed somewhere else entirely — OpenRouter through `ori`, a corporate proxy, a
* local llama server — and reporting those as "on a grid" would make the app offer to move an agent
* away from a place the user deliberately sent it.
*/
function isGridUrl(value: string): boolean {
try {
// The relay always lives under `/relay`, whatever host serves it — that is the one part of the
// shape the control plane and both CLIs agree on (`<grid>/relay` for Messages, `/relay/v1` for
// OpenAI clients). Matching on the host would pin this to one deployment.
return new URL(value).pathname.split('/').includes('relay')
} catch {
return false
}
}
/** Classify an already-read environment and argv. Exported so a spec can pin the rules with no I/O. */
export function classifyGridAssignment(
engine: AgentEngine,
processEnv: Record<string, string>,
args = '',
): GridAssignment | null {
const urlVar = BASE_URL_VAR[engine]
const fromArgv = urlVar ? null : CODEX_BASE_URL.exec(args)
const baseUrl = (urlVar ? processEnv[urlVar] : (fromArgv?.[1] ?? fromArgv?.[2]))?.trim()
if (!baseUrl || !isGridUrl(baseUrl)) return null
const modelVar = MODEL_VAR[engine]
const model = modelVar
? processEnv[modelVar]?.trim()
: MODEL_IN_ARGV.has(engine)
? ARGV_MODEL.exec(args)?.[1]
: undefined
// GROK ONLY, and deliberately not a rule for every engine here.
//
// Grok's launch always passes `-m`, unlike every other argv engine: its grid credential rides on a
// declared `[model.<id>]` block, so "let the grid route" is spelled by declaring the ROUTER rather
// than by omitting the flag. Reporting that id verbatim would print `Auto` where the app prints its
// own Auto row from null, and `assignmentMatches` would compare 'Auto' against null and call every
// routed grok agent misplaced, forever — the trap `readOpencodeGridAssignment` documents below.
//
// Scoped to grok because for anyone else a model literally named `Auto` is a model, and blanking it
// would be this function inventing a meaning the launch never gave it. Opencode reaches the same
// conclusion in its own reader, for its own reason; codex simply omits the flag.
if (engine === 'grok' && model && model.toLowerCase() === GRID_ROUTER_MODEL.toLowerCase()) {
return { baseUrl, model: null }
}
return { baseUrl, model: model || null }
}
/**
* Pi's provider block, read out of the config directory its process was pointed at.
*
* Exported so a spec can drive the real read against real files: the process half of the probe
* cannot be faked (an environment is fixed at exec), and a round trip that re-implemented this
* would prove nothing about the code that runs.
*
* Every failure lands on null — an unreadable or rewritten directory is "we cannot tell", which the
* caller already treats as "not on the grid you picked".
*/
export async function readPiGridAssignment(
processEnv: Record<string, string>,
args: string,
): Promise<GridAssignment | null> {
const dir = processEnv[PI_CONFIG_DIR_VAR]?.trim()
if (!dir) return null
const model = PI_ARGV_MODEL.exec(args)
if (!model) return null
try {
const raw = JSON.parse(await readFile(join(dir, 'models.json'), 'utf8')) as {
providers?: Record<string, { baseUrl?: unknown }>
}
const baseUrl = raw.providers?.[model[1]]?.baseUrl
if (typeof baseUrl !== 'string' || !isGridUrl(baseUrl)) return null
return { baseUrl, model: model[2] }
} catch {
return null
}
}
/** The config file OpenCode was pointed at. Fixed at exec; nothing the engine does can change it. */
const OPENCODE_CONFIG_VAR = 'OPENCODE_CONFIG'
/**
* OpenCode's endpoint and model, read out of the config file its process was pointed at.
*
* Same shape as Pi's, and for the same reason: neither engine carries its endpoint in an environment
* variable, so the only honest answer comes from the file the process is actually reading. Reading
* our own record of what we launched would answer a different question — what we intended — and that
* is precisely the disagreement a probe exists to catch.
*
* **It deliberately does not read argv.** The first version matched `-m <provider>/<model>` there,
* and the app's grid pill flickered between the model and "own login" while an agent was answering:
* every re-probe during a turn had to re-parse a live process's command line, and a command line is
* that process's to rewrite. The file is written once by the launch and is exactly one provider on
* one grid with one model, so those are read straight out of it — a fact that cannot move.
*
* Every failure lands on null. "We cannot tell" and "not on a grid" are treated alike by the caller;
* the opposite error, claiming a grid nobody verified, would leave an agent quietly somewhere else.
*/
export async function readOpencodeGridAssignment(
processEnv: Record<string, string>,
): Promise<GridAssignment | null> {
const configPath = processEnv[OPENCODE_CONFIG_VAR]?.trim()
if (!configPath) return null
try {
const raw = JSON.parse(await readFile(configPath, 'utf8')) as {
provider?: Record<string, { options?: { baseURL?: unknown }; models?: Record<string, unknown> }>
}
const providers = Object.values(raw.provider ?? {})
// More than one provider is not a file this launch wrote. Rather than guess which one the engine
// would pick, say nothing — the caller reads that as "not on the grid you picked", which is the
// safe direction to be wrong in.
if (providers.length !== 1) return null
const baseUrl = providers[0].options?.baseURL
if (typeof baseUrl !== 'string' || !isGridUrl(baseUrl)) return null
const models = Object.keys(providers[0].models ?? {})
if (models.length !== 1) return null
// The router reports as NO model, the same as every other engine launched without one.
//
// OpenCode is the only engine whose provider block has to name something, so a launch with no
// model picked writes the relay's router id there. That is an OpenCode implementation detail and
// must not leak: the app's own "Auto" is `model: null`, and a raw `Auto` coming back would be the
// same state under a different name — the header would print the id instead of "Auto", and
// `assignmentMatches` would compare 'Auto' against null and report every such agent as being on
// the wrong target, forever. See the desktop's `kAutoModelId`, and the commit that stopped the
// model menu offering the router beside its own Auto row.
const model = models[0]
return { baseUrl, model: model.toLowerCase() === GRID_ROUTER_MODEL.toLowerCase() ? null : model }
} catch {
return null
}
}
/**
* Read one live engine process's grid.
*
* **Three answers, not two.** `null` is a finding — this process was read and is on no grid.
* `undefined` is the absence of one: the environment could not be read at all, so there is nothing to
* report. They were the same value until the app's grid pill was seen flickering between an agent's
* model and "own login" for the length of every turn: each failed read overwrote a known-good
* assignment with "on no grid", and the next successful one put it back.
*
* The reads that fail are ordinary — `ps eww` carries a 2s timeout and a discovery sweep runs one per
* agent while the machine is busy answering. Which of them fails does not matter, and chasing that
* was the wrong instinct: what matters is that failing to look must not erase what was already seen.
* A process's environment cannot change under it (see `processEnv.ts`), so an earlier successful read
* of THIS process stays true.
*
* `gateway` in the same registry has worked this way all along — `undefined` there means "the probe
* failed" and leaves the stored value alone. Both `openProcessAgent` and `updateProcessIdentity`
* already guard on `!== undefined`; the grid simply never used it.
*/
export async function probeGridAssignment(
identity: ProcessIdentity,
engine: AgentEngine,
args = '',
): Promise<GridAssignment | null | undefined> {
const processEnv = await readProcessEnv(identity)
if (!processEnv) return undefined
return await gridAssignmentFromEnv(engine, processEnv, args)
}
/**
* The same classification, over an environment the caller already holds.
*
* Split from the read so the RULES live in one place: which variable each engine's endpoint is in,
* and the two engines whose provider is in a file rather than a variable at all. That last part is
* what a second caller would get wrong on its own, which is the whole reason this is a function and
* not a comment.
*
* There is no `undefined` here: the caller decided it could look, and did. Only [probeGridAssignment]
* distinguishes "could not read the process".
*/
export async function gridAssignmentFromEnv(
engine: AgentEngine,
env: Record<string, string>,
args = '',
): Promise<GridAssignment | null> {
if (engine === 'pi') return await readPiGridAssignment(env, args)
if (engine === 'opencode') return await readOpencodeGridAssignment(env)
return classifyGridAssignment(engine, env, args)
}
/**
* Do these two describe the same assignment?
*
* `undefined` is not compared here and must not reach this: it means the probe could not look, which
* is never evidence that anything moved. Callers guard on it before asking.
*/
export function sameGridAssignment(a: GridAssignment | null, b: GridAssignment | null): boolean {
if (a === null || b === null) return a === b
return a.baseUrl === b.baseUrl && (a.model ?? null) === (b.model ?? null)
}
/** Is this agent already where `networkId` is served, on `model`? */
export function assignmentMatches(
assignment: GridAssignment | null | undefined,
networkId: string,
model: string | null,
): boolean {
if (!assignment) return false
// Containment rather than a parsed id: the grid id IS a path segment of the relay URL today, and
// asking "does this endpoint name the grid I picked" survives a control plane that rearranges the
// rest of the path. A false negative costs one needless move; a false positive would leave an agent
// somewhere the user did not choose and say it was fine.
if (!assignment.baseUrl.includes(networkId)) return false
return (assignment.model ?? null) === (model ?? null)
}