import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' import { MAX_CLAIM_LEASE_MS, type AgentModel } from '@radial/core' import { selectHarness } from './harness.js' import type { AgentInitInput } from './init.js' export const CONFIG_FILENAME = 'radial.json' export type Environment = Record export interface AgentConfig { artifactTypes: string[] identifier?: string | undefined pds?: string | undefined name?: string | undefined harness?: string | undefined models?: Array | undefined } export interface RadialConfig { identifier?: string | undefined /** * Where this instance's sessions and (by default) its run state live, resolved relative to the * DIRECTORY OF THIS FILE — see `resolveDataDir` in `instance.ts`, which owns the precedence. * Naming it here is what lets `--config` alone select one of several daemons on a machine, with * no `$RADIAL_DATA_DIR` discipline on every invocation. */ dataDir?: string | undefined pds?: string | undefined harness?: string | undefined models?: Array | undefined agents: Record run?: DaemonRunConfig } /** * The daemon's run-path settings: which spaces to serve turns for, and the sandboxing knobs for * the containers it spawns. Only parsed and validated here — `run.concurrency` and friends * get their runtime defaults applied by the daemon (D2), not by config parsing. */ export interface DaemonRunConfig { spaces: string[] stateDir?: string image?: string concurrency?: number /** Whole-run wall clock cap on a turn container; outliving it is a retryable crash. Runtime * default 60 * 60_000. */ timeoutMs?: number cooldownMs?: number retryBound?: number /** Base per-PR interval for merge observation polling (design §10). Runtime default 60_000. */ mergePollIntervalMs?: number /** Exponential backoff cap for merge polling. Runtime default 30 * 60_000. */ mergePollBackoffMaxMs?: number network?: string /** * Extra environment variable NAMES forwarded from the daemon's own environment into every turn * container, on top of what the profiles' harnesses already declare (`Harness.credentialEnv`). * This is how a provider — or a provider's non-secret configuration, e.g. `CLOUDFLARE_ACCOUNT_ID` * — that no harness lists reaches a turn. Names only: the daemon forwards a name when it is set * in its own environment and never invents a value. An explicit list, never a prefix glob, since * every entry is a credential the turn then possesses (design §13). */ modelEnv?: string[] /** * ChatGPT-managed authentication for Codex turns. Unlike an API key or a Codex workspace access * token, this is a refreshable login stored under the instance data directory. The daemon gives * each turn a scratch copy and serializes users of that login so Codex can rotate it safely. */ codexAuth?: CodexAuthConfig /** Turn-socket transport. `auto` (the default) selects the production unix-socket path on Linux * and the TCP bridge on macOS/Windows, where VM-backed Docker cannot connect to a bind-mounted * host AF_UNIX socket. Explicit `unix`/`tcp` remain available as operator overrides. */ turnTransport?: 'auto' | 'unix' | 'tcp' gitSchemes?: string[] /** Container memory limit (docker `--memory` syntax, e.g. `4g`/`512m`). Unset means "use the * daemon's default floor" (see `turn.ts`, which floors at `4g` — a turn must never run * unbounded); this only overrides that default, it never widens it above what an operator sets. */ memory?: string /** Check-runner knobs (design §10). Checks run in their own dispatcher with their own budgets so * they never contend with turns. `checkImage` defaults to `image` when unset (the check image * must already contain the project toolchain); the rest fall back to check-specific daemon * defaults in `resolveRunConfig`. */ checkConcurrency?: number /** Whole-run cap for a check, covering the host-side checkout as well as the container itself * (see `check-runner.ts`). Runtime default 15 * 60_000. */ checkTimeoutMs?: number checkImage?: string checkCooldownMs?: number checkRetryBound?: number /** Forge integration (design §10), as a single adapter. Predates `forges` and still parses: * operator configs in the wild carry it, and `buildDefaultConfig` still writes it. Equivalent to * a one-element `forges`. */ forge?: ForgeConfig /** Every forge this daemon speaks, selected per project by the host of its `gitUrl`. A space may * hold a GitHub project and a tangled project at once, so this is a list rather than a switch — * and it is why a missing GitHub token no longer disables implementations for every project. */ forges?: ForgeConfig[] /** Claim/lease timing for OPEN (unassigned) requests — design §11. Every field has a runtime * default (`resolveRunConfig`); the whole block is optional and only matters to an operator whose * space uses open requests. See `ClaimsConfig` for what each knob buys. */ claims?: ClaimsConfig /** Jetstream ingestion (design §5). Opt-in and a LATENCY optimisation only: polling stays * authoritative and remains the backfill, so an unreachable endpoint costs latency and nothing * else. Absent means "poll only", which is what every deployment before Phase 7 did. */ jetstream?: JetstreamConfig } export interface CodexAuthConfig { mode: 'chatgpt-session' } /** Claim/lease timing. Defaults live in `resolveRunConfig`; the relationships between them are * checked at parse time, because an operator who sets a lease shorter than the renewal interval * gets a daemon that drops every claim it makes and no error to explain it. */ export interface ClaimsConfig { leaseMs?: number renewIntervalMs?: number confirmCycles?: number maxOutstanding?: number } export interface JetstreamConfig { /** A Jetstream `wss://…/subscribe` endpoint, e.g. `wss://jetstream2.us-east.bsky.network/subscribe`. */ endpoint: string /** How often to poll while the stream is healthy. Polling never stops — it is the authority and * the backfill for DIDs the stream's filter has not caught up with — but it can slow right down. * Runtime default 60_000. */ backfillIntervalMs?: number } /** One configured forge. `kind` is the only required field; the rest are per-forge settings. */ export type ForgeConfig = | { kind: 'github' } | { kind: 'tangled' /** Hosts this adapter speaks for. Default `["tangled.org"]`; a self-hosted knot adds its own. */ hosts?: string[] /** The Bobbin instance pull state is read from — tangled's read-only XRPC appview. Default * `https://api.tangled.org`, the one tangled runs; a self-hosted appview points here instead. * `false` turns it off, and the adapter folds `sh.tangled.repo.pull.status` records off PDSes * itself (correct, but it can only scan the DIDs it can guess). */ api?: string | false /** Verify direct pull-page links through the web appview. Default true; false uses at-URIs. */ pageLinks?: boolean /** `ssh-keyscan` lines for those hosts, pinned. Required before an implementation turn can * push: Radial will not fall back to `StrictHostKeyChecking=no`, which would make every push * MITM-able (design §13). */ knownHosts?: string[] } /** The configured forges, however the operator spelled them: `forges` when present, else the legacy * single `forge`, else none. One place so every caller agrees. */ export function configuredForges(run: DaemonRunConfig | undefined): ForgeConfig[] { if (!run) return [] if (run.forges) return run.forges return run.forge ? [run.forge] : [] } const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) export function parseModel(entry: string | AgentModel): AgentModel { if (typeof entry !== 'string') return entry const separator = entry.indexOf('=') return separator === -1 ? { name: entry, costHint: '' } : { name: entry.slice(0, separator), costHint: entry.slice(separator + 1) } } /** * Each profile is published as an agent record keyed by its own name, so a profile name has to * be a legal atproto record key. */ export function assertProfileName(profile: string): void { if (profile.length === 0 || profile.length > 512 || !/^[A-Za-z0-9._~:-]+$/.test(profile)) { throw new TypeError( `Profile name "${profile}" is not a valid record key (letters, digits, and ".-_~:" only)`, ) } if (profile === '.' || profile === '..') throw new TypeError(`Profile name "${profile}" is reserved`) } function strings(value: unknown, where: string): string[] | undefined { if (value === undefined) return undefined if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { throw new TypeError(`${where} must be an array of strings`) } return value as string[] } function models(value: unknown, where: string): Array | undefined { if (value === undefined) return undefined if (!Array.isArray(value)) throw new TypeError(`${where} must be an array`) return value.map((entry) => { if (typeof entry === 'string') return entry if (object(entry) && typeof entry.name === 'string' && typeof entry.costHint === 'string') { return { name: entry.name, costHint: entry.costHint } } throw new TypeError(`${where} entries must be "name=costHint" or { name, costHint }`) }) } /** Environment variable names, validated at parse time: a name carrying an `=`, a space or a * leading digit is a configuration mistake (usually `NAME=value` written where a name goes), and * catching it here beats silently forwarding nothing at run time. */ function envNames(value: unknown, where: string): string[] | undefined { const names = strings(value, where) if (names === undefined) return undefined for (const name of names) { if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) { throw new TypeError(`${where} entries must be environment variable names (A-Z, 0-9, _), got "${name}"`) } } return names } function text(value: unknown, where: string): string | undefined { if (value === undefined) return undefined if (typeof value !== 'string' || value === '') throw new TypeError(`${where} must be a non-empty string`) return value } function num(value: unknown, where: string): number | undefined { if (value === undefined) return undefined if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`${where} must be a number`) return value } function turnTransportValue(value: unknown, where: string): 'auto' | 'unix' | 'tcp' | undefined { if (value === undefined) return undefined if (value !== 'auto' && value !== 'unix' && value !== 'tcp') { throw new TypeError(`${where} must be "auto", "unix", or "tcp"`) } return value } function bool(value: unknown, where: string): boolean | undefined { if (value === undefined) return undefined if (typeof value !== 'boolean') throw new TypeError(`${where} must be a boolean`) return value } /** An appview base URL, or `false` for "do not use one". Validated as an absolute http(s) URL here * rather than at first use, so a typo is a startup error and not a silent per-poll fallback. */ function appviewValue(value: unknown, where: string): string | false | undefined { if (value === undefined) return undefined if (value === false) return false const url = text(value, `${where}`) if (url === undefined) return undefined let parsed: URL try { parsed = new URL(url) } catch { throw new TypeError(`${where} must be an absolute http(s) URL, or false`) } if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { throw new TypeError(`${where} must be an absolute http(s) URL, or false`) } return url } function forgeValue(value: unknown, where: string): ForgeConfig | undefined { if (value === undefined) return undefined if (!object(value)) throw new TypeError(`${where} must be an object`) if (value.kind === 'github') return { kind: 'github' } if (value.kind === 'tangled') { const hosts = strings(value.hosts, `${where}.hosts`) const knownHosts = strings(value.knownHosts, `${where}.knownHosts`) const api = appviewValue(value.api, `${where}.api`) const pageLinks = bool(value.pageLinks, `${where}.pageLinks`) return { kind: 'tangled', ...(hosts !== undefined ? { hosts } : {}), ...(api !== undefined ? { api } : {}), ...(pageLinks !== undefined ? { pageLinks } : {}), ...(knownHosts !== undefined ? { knownHosts } : {}), } } throw new TypeError(`${where}.kind must be "github" or "tangled"`) } function forgesValue(value: unknown, where: string): ForgeConfig[] | undefined { if (value === undefined) return undefined if (!Array.isArray(value)) throw new TypeError(`${where} must be an array`) const forges = value.map((entry, index) => forgeValue(entry, `${where}[${index}]`) as ForgeConfig) const kinds = new Set() for (const forge of forges) { if (kinds.has(forge.kind)) throw new TypeError(`${where} lists "${forge.kind}" more than once`) kinds.add(forge.kind) } return forges } /** * Validates `run.claims` and the relationships between its knobs. Fails LOUDLY at parse time rather * than defaulting quietly: the three ways to get this wrong all produce a daemon that behaves oddly * and says nothing — a lease shorter than the renewal interval drops every claim it makes, fewer * than three renewal attempts inside a lease means two failed writes lose the work, and a * non-positive `maxOutstanding` claims nothing at all while the requests sit open. */ function claimsValue(value: unknown, where: string): ClaimsConfig | undefined { if (value === undefined) return undefined if (!object(value)) throw new TypeError(`${where} must be an object`) const leaseMs = num(value.leaseMs, `${where}.leaseMs`) const renewIntervalMs = num(value.renewIntervalMs, `${where}.renewIntervalMs`) const confirmCycles = num(value.confirmCycles, `${where}.confirmCycles`) const maxOutstanding = num(value.maxOutstanding, `${where}.maxOutstanding`) if (leaseMs !== undefined && leaseMs <= 0) throw new TypeError(`${where}.leaseMs must be positive`) // The wire contract bounds a declared lease (`MAX_CLAIM_LEASE_MS`), and every observer — including // this daemon's own fold — ignores a claim that declares more. A lease configured above the // ceiling would produce claims nobody counts, so it fails here rather than at the first write. if (leaseMs !== undefined && leaseMs > MAX_CLAIM_LEASE_MS) { throw new TypeError( `${where}.leaseMs must be at most ${MAX_CLAIM_LEASE_MS}ms — the protocol bound on a declared claim lease, ` + 'above which every materializer ignores the claim', ) } if (renewIntervalMs !== undefined && renewIntervalMs <= 0) { throw new TypeError(`${where}.renewIntervalMs must be positive`) } if (confirmCycles !== undefined && (!Number.isInteger(confirmCycles) || confirmCycles < 0)) { throw new TypeError(`${where}.confirmCycles must be a non-negative integer`) } if (maxOutstanding !== undefined && (!Number.isInteger(maxOutstanding) || maxOutstanding < 1)) { throw new TypeError(`${where}.maxOutstanding must be an integer of at least 1`) } // Only checkable when the operator set both; a lone override is measured against the runtime // default in `resolveRunConfig`, which applies this same rule. if (leaseMs !== undefined && renewIntervalMs !== undefined && renewIntervalMs * 3 > leaseMs) { throw new TypeError( `${where}.renewIntervalMs must be at most a third of ${where}.leaseMs, so a lease survives two failed renewals`, ) } return { ...(leaseMs !== undefined ? { leaseMs } : {}), ...(renewIntervalMs !== undefined ? { renewIntervalMs } : {}), ...(confirmCycles !== undefined ? { confirmCycles } : {}), ...(maxOutstanding !== undefined ? { maxOutstanding } : {}), } } function jetstreamValue(value: unknown, where: string): JetstreamConfig | undefined { if (value === undefined) return undefined if (!object(value)) throw new TypeError(`${where} must be an object`) const endpoint = text(value.endpoint, `${where}.endpoint`) if (!endpoint) throw new TypeError(`${where}.endpoint is required`) let parsed: URL try { parsed = new URL(endpoint) } catch { throw new TypeError(`${where}.endpoint must be an absolute ws(s):// URL`) } if (parsed.protocol !== 'wss:' && parsed.protocol !== 'ws:') { throw new TypeError(`${where}.endpoint must be an absolute ws(s):// URL`) } const backfillIntervalMs = num(value.backfillIntervalMs, `${where}.backfillIntervalMs`) if (backfillIntervalMs !== undefined && backfillIntervalMs <= 0) { throw new TypeError(`${where}.backfillIntervalMs must be positive`) } return { endpoint, ...(backfillIntervalMs !== undefined ? { backfillIntervalMs } : {}) } } function codexAuthValue(value: unknown, where: string): CodexAuthConfig | undefined { if (value === undefined) return undefined if (!object(value)) throw new TypeError(`${where} must be an object`) const mode = text(value.mode, `${where}.mode`) if (mode !== 'chatgpt-session') { throw new TypeError(`${where}.mode must be "chatgpt-session"`) } return { mode } } /** Validates the raw `run` block; applies no runtime defaults (the daemon's run path applies those). */ export function parseRunConfig(value: unknown): DaemonRunConfig { if (!object(value)) throw new TypeError('run config must be an object') // An EMPTY list parses: the `radiald init` scaffold writes `spaces: []` so the block it emits // (and the `forge` inside it) is loadable before an operator has pasted their space URI in. The // non-empty requirement belongs to `radiald run`, which is the only command that needs one, and // it is enforced there rather than making every other command refuse a fresh scaffold. const spaces = strings(value.spaces, 'run.spaces') if (!spaces) throw new TypeError('run.spaces must be an array of "at://" URIs') for (const space of spaces) { if (!space.startsWith('at://')) throw new TypeError(`run.spaces entries must be "at://" URIs, got "${space}"`) } const stateDir = text(value.stateDir, 'run.stateDir') const image = text(value.image, 'run.image') const concurrency = num(value.concurrency, 'run.concurrency') const timeoutMs = num(value.timeoutMs, 'run.timeoutMs') const cooldownMs = num(value.cooldownMs, 'run.cooldownMs') const retryBound = num(value.retryBound, 'run.retryBound') const mergePollIntervalMs = num(value.mergePollIntervalMs, 'run.mergePollIntervalMs') const mergePollBackoffMaxMs = num(value.mergePollBackoffMaxMs, 'run.mergePollBackoffMaxMs') const network = text(value.network, 'run.network') const modelEnv = envNames(value.modelEnv, 'run.modelEnv') const codexAuth = codexAuthValue(value.codexAuth, 'run.codexAuth') const turnTransport = turnTransportValue(value.turnTransport, 'run.turnTransport') const gitSchemes = strings(value.gitSchemes, 'run.gitSchemes') const memory = text(value.memory, 'run.memory') const checkConcurrency = num(value.checkConcurrency, 'run.checkConcurrency') const checkTimeoutMs = num(value.checkTimeoutMs, 'run.checkTimeoutMs') const checkImage = text(value.checkImage, 'run.checkImage') const checkCooldownMs = num(value.checkCooldownMs, 'run.checkCooldownMs') const checkRetryBound = num(value.checkRetryBound, 'run.checkRetryBound') const forge = forgeValue(value.forge, 'run.forge') const forges = forgesValue(value.forges, 'run.forges') const claims = claimsValue(value.claims, 'run.claims') const jetstream = jetstreamValue(value.jetstream, 'run.jetstream') if (forge && forges) { throw new TypeError('run.forge and run.forges are two spellings of the same setting; keep one') } return { spaces, ...(stateDir !== undefined ? { stateDir } : {}), ...(image !== undefined ? { image } : {}), ...(concurrency !== undefined ? { concurrency } : {}), ...(timeoutMs !== undefined ? { timeoutMs } : {}), ...(cooldownMs !== undefined ? { cooldownMs } : {}), ...(retryBound !== undefined ? { retryBound } : {}), ...(mergePollIntervalMs !== undefined ? { mergePollIntervalMs } : {}), ...(mergePollBackoffMaxMs !== undefined ? { mergePollBackoffMaxMs } : {}), ...(network !== undefined ? { network } : {}), ...(modelEnv !== undefined ? { modelEnv } : {}), ...(codexAuth !== undefined ? { codexAuth } : {}), ...(turnTransport !== undefined ? { turnTransport } : {}), ...(gitSchemes !== undefined ? { gitSchemes } : {}), ...(memory !== undefined ? { memory } : {}), ...(checkConcurrency !== undefined ? { checkConcurrency } : {}), ...(checkTimeoutMs !== undefined ? { checkTimeoutMs } : {}), ...(checkImage !== undefined ? { checkImage } : {}), ...(checkCooldownMs !== undefined ? { checkCooldownMs } : {}), ...(checkRetryBound !== undefined ? { checkRetryBound } : {}), ...(forge !== undefined ? { forge } : {}), ...(forges !== undefined ? { forges } : {}), ...(claims !== undefined ? { claims } : {}), ...(jetstream !== undefined ? { jetstream } : {}), } } export function parseConfig(value: unknown): RadialConfig { if (!object(value)) throw new TypeError('Radial config must be a JSON object') if (!object(value.agents)) throw new TypeError('Radial config must define an "agents" object') const agents: Record = {} for (const [profile, raw] of Object.entries(value.agents)) { assertProfileName(profile) if (!object(raw)) throw new TypeError(`agents.${profile} must be an object`) const artifactTypes = strings(raw.artifactTypes, `agents.${profile}.artifactTypes`) if (!artifactTypes || artifactTypes.length === 0) { throw new TypeError(`agents.${profile} must declare at least one artifactType`) } agents[profile] = { artifactTypes, identifier: text(raw.identifier, `agents.${profile}.identifier`), pds: text(raw.pds, `agents.${profile}.pds`), name: text(raw.name, `agents.${profile}.name`), harness: text(raw.harness, `agents.${profile}.harness`), models: models(raw.models, `agents.${profile}.models`), } } return { identifier: text(value.identifier, 'identifier'), dataDir: text(value.dataDir, 'dataDir'), pds: text(value.pds, 'pds'), harness: text(value.harness, 'harness'), models: models(value.models, 'models'), agents, ...(value.run !== undefined ? { run: parseRunConfig(value.run) } : {}), } } /** * The operator's own config, alongside the XDG state directory that holds sessions.json. * Agent identities belong to the operator running the daemon, not to any one checkout. */ export function defaultConfigPath(env: Environment = process.env): string { if (env.RADIAL_CONFIG_DIR) return join(env.RADIAL_CONFIG_DIR, CONFIG_FILENAME) if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, 'radial', CONFIG_FILENAME) if (!env.HOME) throw new Error('Set RADIAL_CONFIG, RADIAL_CONFIG_DIR, or HOME') return join(env.HOME, '.config', 'radial', CONFIG_FILENAME) } async function exists(path: string): Promise { try { await readFile(path, 'utf8') return true } catch (error) { if (object(error) && error.code === 'ENOENT') return false throw error } } /** * Explicit path, then $RADIAL_CONFIG, then the nearest radial.json walking up from `from`, then * the operator's config. A daemon started from an arbitrary working directory still finds its * identities; a checkout may override them with a radial.json of its own. */ export async function findConfigPath( explicit?: string, from: string = process.cwd(), env: Environment = process.env, ): Promise { if (explicit) return resolve(explicit) if (env.RADIAL_CONFIG) return resolve(env.RADIAL_CONFIG) let directory = isAbsolute(from) ? from : resolve(from) for (;;) { const candidate = join(directory, CONFIG_FILENAME) if (await exists(candidate)) return candidate const parent = dirname(directory) if (parent === directory) break directory = parent } let fallback: string try { fallback = defaultConfigPath(env) } catch { return undefined // no HOME to resolve: report "not found" rather than crashing discovery } return (await exists(fallback)) ? fallback : undefined } export async function loadConfig(path: string): Promise { let raw: string try { raw = await readFile(path, 'utf8') } catch (error) { if (object(error) && error.code === 'ENOENT') throw new Error(`No Radial config at ${path}`) throw error } try { return parseConfig(JSON.parse(raw) as unknown) } catch (error) { throw new Error(`Invalid Radial config at ${path}: ${error instanceof Error ? error.message : error}`) } } /** * Merge top-level defaults into each requested profile. Profiles normally share one agent DID, so * one password covers them all; `passwords` may name additional identities for the uncommon case * of a profile overriding `identifier`. Requesting no profiles resolves every profile. */ export function resolveAgentInits( config: RadialConfig, profiles: string[], passwords: { default: string; byIdentifier?: Record }, ): AgentInitInput[] { const names = profiles.length > 0 ? profiles : Object.keys(config.agents) if (names.length === 0) throw new Error('Radial config defines no agents') return names.map((profile) => { const agent = config.agents[profile] if (!agent) { const known = Object.keys(config.agents).join(', ') || 'none' throw new Error(`Unknown profile "${profile}" (config defines: ${known})`) } const identifier = agent.identifier ?? config.identifier if (!identifier) throw new Error(`Profile "${profile}" has no "identifier" and the config sets no default`) const harness = agent.harness ?? config.harness if (!harness) throw new Error(`Profile "${profile}" has no "harness" and the config sets no default`) // Refuse to PUBLISH a harness the daemon cannot run: the agent record is what a space's // assignee menu offers, and a record advertising a harness no turn could launch is a promise // the daemon would then break silently. Same fail-closed selector `radiald run` uses. selectHarness(harness) const shared = identifier === config.identifier const password = shared ? passwords.default : passwords.byIdentifier?.[identifier] if (!password) { throw new Error( shared ? `No app password for ${identifier}; pipe it in with --password-stdin or set $RADIAL_PASSWORD` : `Profile "${profile}" overrides identity to ${identifier}; set its app password in $${passwordEnvName(identifier)}`, ) } const service = agent.pds ?? config.pds return { profile, // Omitted: initializeAgent resolves the PDS from the identity's DID document. ...(service ? { service } : {}), identifier, password, handleName: agent.name ?? profile, harness, models: (agent.models ?? config.models ?? []).map(parseModel), artifactTypes: agent.artifactTypes, } }) } /** Environment variable holding an overriding identity's app password. */ export function passwordEnvName(identifier: string): string { return `RADIAL_${identifier.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PASSWORD` } /** Identities used by the requested profiles that are not the config's default identity. */ export function extraIdentities(config: RadialConfig, profiles: string[]): string[] { const names = profiles.length > 0 ? profiles : Object.keys(config.agents) const found = new Set() for (const profile of names) { const identifier = config.agents[profile]?.identifier if (identifier && identifier !== config.identifier) found.add(identifier) } return [...found] } /** * A starting config for a new operator: one identity, one profile per built-in artifact type * (`plan` and `implementation`, §11) plus a reviewer — which also carries `answer`, the built-in that * replies to a message in a goal's thread — each inheriting the shared harness and models. Separate * profiles are what let an operator route a different model to reviews. * * The planner also carries the system types `space create` seeds (§8) — writing an architecture doc, * keeping the inventory and glossary current, or distilling an ADR is the same register of work as * planning, and a seeded type no profile publishes is a ⊕ entry whose assignee list is empty. * * The `run` block is emitted with the forge already wired. It is the one setting an operator has * no way to guess is load bearing: without it the daemon cannot confirm a predecessor's pull * request, so a second version of an implementation opens a SECOND pull request instead of * updating the one under review. `spaces` is left empty for the operator to fill in — every other * `run` default is computed at runtime and deliberately not frozen into the file. */ export function buildDefaultConfig( identifier: string, options: { dataDir?: string } = {}, ): RadialConfig { return { identifier, // Emitted only when asked for. A scaffold that names its own data directory is what makes a // SECOND instance on this machine one `--config` away: see docs/radial-json.md, "More than one // daemon on one machine". ...(options.dataDir ? { dataDir: options.dataDir } : {}), // `claude` stays the scaffold default: it is the tested path, and changing what a new operator // gets is a separate decision from making a second harness available. Switching a profile (or // the whole config) to `pi` — and picking a non-Anthropic provider — is one field plus one // `radiald init --update`; see docs/radial-json.md, "Choosing a harness". harness: 'claude', models: ['claude-opus-4-8=high'], run: { spaces: [], forge: { kind: 'github' } }, agents: { planner: { artifactTypes: [ 'plan', 'architecture', 'architecture-inventory', 'glossary', 'conventions', 'adr', ], }, implementer: { artifactTypes: ['implementation'] }, // `answer` rides with the reviewer for the same reason `review` does: neither is registry data, // and both are the same register of work — reading what is already there and saying something // about it. A profile that publishes it is what makes the agent selectable when somebody presses // "Ask an agent to reply"; without one, that picker is empty. reviewer: { artifactTypes: ['review', 'answer'] }, }, } } /** Serialize a config, creating its directory. Refuses to clobber an existing file. */ export async function writeConfig( path: string, config: RadialConfig, options: { force?: boolean } = {}, ): Promise { if (!options.force && (await exists(path))) { throw new Error(`${path} already exists; pass --force to overwrite it`) } await mkdir(dirname(path), { recursive: true, mode: 0o700 }) // Defaults first, then `run`, agents last: the file reads top-down from identity through the // daemon's own settings to per-profile overrides. `run` is serialized rather than dropped — // dropping it meant the scaffold could not carry a `forge`, and an operator who never noticed // that got a fresh pull request for every implementation v2. const body = { identifier: config.identifier, ...(config.dataDir ? { dataDir: config.dataDir } : {}), ...(config.pds ? { pds: config.pds } : {}), ...(config.harness ? { harness: config.harness } : {}), ...(config.models ? { models: config.models } : {}), ...(config.run ? { run: config.run } : {}), agents: config.agents, } await writeFile(path, `${JSON.stringify(body, null, 2)}\n`) }