Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ Rule of thumb: reach for Omnigent when you need OS-level isolation and the broad
- **In-daemon API backends** (`openai`, `gemini`) register only when their **API key** is set in `~/.codeoid/.env`. These bill against the key, not a subscription.

Pick a backend per session with `codeoid new <name> --provider <id>`, or switch a live session with `/provider <id>`.
To make another backend the default for new sessions, set `session.defaultProvider` in `config.json` (or `CODEOID_DEFAULT_PROVIDER`), e.g. `{"session": {"defaultProvider": "pi"}}`.
The daemon refuses to start if that backend is misspelled, disabled, or not installed, rather than silently falling back to Claude.
Existing sessions keep the backend they were created on, and the conductor stays on `conductor.provider`.
Set keys from the Settings screen (⚙ / `/settings`) or by editing `~/.codeoid/.env` — see [Configuration](docs/CONFIGURATION.md) for every variable.

> **Gemini needs an API key (or Vertex) — a consumer Google (AI Pro/Ultra) subscription can't be used with Codeoid.**
Expand Down
8 changes: 8 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ CODEOID_RESUME_MAX_SESSIONS=200 # sessions restored from disk at startu
# Resume is also time-boxed, so raising this costs
# startup time; the remainder stays on disk and loads
# on a later restart.
CODEOID_DEFAULT_PROVIDER=claude # backend for sessions created without --provider
# (claude | codex | pi | qwen | gemini-cli | openai | gemini).
# An unknown, disabled or uninstalled backend stops the
# daemon at startup. Resumed sessions and the conductor
# keep their own backend.

# Memory
CODEOID_MEMORY=1 # default: on; set to 0 to disable
Expand Down Expand Up @@ -147,6 +152,9 @@ Optional `~/.codeoid/config.json` (env vars take precedence):
"enabled": true,
"dbPath": "~/.codeoid/memory.db",
"model": "Xenova/bge-small-en-v1.5"
},
"session": {
"defaultProvider": "claude"
}
}
```
Expand Down
4 changes: 2 additions & 2 deletions docs/conductor-frontends-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ It is a real turn (§3.C), not a hidden side channel.
If you are watching that session in another pane or another client, you will see the conductor-issued turn arrive there.

**Caveat (honest scope).**
Fleet tools are surfaced only by the Claude provider today, and spawned workers currently default to Claude.
Fleet tools are surfaced only by the Claude provider today, and spawned workers default to the daemon's default backend (`session.defaultProvider`, Claude unless configured).
So `send`-to-an-existing-session (the Spark case, where the target already runs its own backend) works now.
True cross-backend *spawn* is spec-not-shipped; the UI must not over-promise it (§7, §13).

Expand Down Expand Up @@ -377,7 +377,7 @@ Primary view is the state-grouped list; tree/graph is the co-primary map.
Resolution is visible and correctable before dispatch; dispatched instructions land in the target session's own transcript.

**Open.**
Cross-backend `spawn`: today spawns default to Claude and fleet tools are Claude-only — sequencing the daemon work to let the conductor spawn a codex/gemini/pi worker (via the anyagent adapter) is out of P5 scope but gates the full §7 story; when does it land?
Cross-backend `spawn`: today spawns default to `session.defaultProvider` (Claude unless configured) and fleet tools are Claude-only — sequencing the daemon work to let the conductor spawn a codex/gemini/pi worker (via the anyagent adapter) is out of P5 scope but gates the full §7 story; when does it land?
Default home: does a user with an active conductor default to the Conductor home or the Sessions home?
Review-queue merge: how much of the sequenced-merge / conflict-pre-detection lands in P5.4 vs a later slice?
Normalized "conductor credit": exact conversion model across token / credit / quota / GPU-second wallets.
Expand Down
56 changes: 33 additions & 23 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { program } from "commander";
// string to drift). release-smoke asserts these two stay equal.
import pkg from "../package.json" with { type: "json" };
import { DaemonServer } from "./daemon/server.js";
import { DefaultProviderError } from "./daemon/providers/registry.js";
import { parseRoleSpec } from "./daemon/collaboration.js";
import { type ModelBinding, roleBindingsFromSpecs } from "./daemon/pipeline/binding.js";
import {
Expand Down Expand Up @@ -99,29 +100,38 @@ program
};
}

const daemon = new DaemonServer({
port: bindPort,
host: bindHost,
dbPath: config.dbPath,
transcriptDir: config.transcriptDir,
auth: config.auth,
localMode,
// ZeroID-dependent subsystems are simply not passed in local mode. (The
// daemon guards these too — belt and suspenders — so an embedder that
// builds its own DaemonConfig gets the same offline guarantee.)
oauth: localMode ? undefined : config.oauth,
agentIdentity: localMode ? undefined : config.agentIdentity,
memory: config.memory?.enabled
? {
dbPath: config.memory.dbPath,
model: config.memory.model,
modelCacheDir: config.memory.modelCacheDir,
}
: undefined,
// Forward the full config so session-level features (compress, etc.)
// get the parsed shape rather than re-reading env/file.
fullConfig: config,
});
let daemon: DaemonServer;
try {
daemon = new DaemonServer({
port: bindPort,
host: bindHost,
dbPath: config.dbPath,
transcriptDir: config.transcriptDir,
auth: config.auth,
localMode,
// ZeroID-dependent subsystems are simply not passed in local mode. (The
// daemon guards these too — belt and suspenders — so an embedder that
// builds its own DaemonConfig gets the same offline guarantee.)
oauth: localMode ? undefined : config.oauth,
agentIdentity: localMode ? undefined : config.agentIdentity,
memory: config.memory?.enabled
? {
dbPath: config.memory.dbPath,
model: config.memory.model,
modelCacheDir: config.memory.modelCacheDir,
}
: undefined,
// Forward the full config so session-level features (compress, etc.)
// get the parsed shape rather than re-reading env/file.
fullConfig: config,
});
} catch (err) {
// A misconfigured default backend is operator error: say what to fix
// and stop. Anything else is a bug and keeps its stack trace.
if (!(err instanceof DefaultProviderError)) throw err;
console.error(`\n[codeoid] ${err.message}\n`);
process.exit(1);
}

// ── Register frontends ────────────────────────────────────────

Expand Down
33 changes: 30 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,24 @@ const TelemetrySchema = z
* sane defaults; users can tune via config or env.
*/
/**
* Per-session model defaults. `defaultModel` is used on session creation;
* Per-session defaults. `defaultProvider` picks the backend a new session runs
* on when the caller names none (unset = "claude"); `defaultModel` is used on
* session creation;
* `fallbackModel` is handed to the SDK's `fallbackModel` option so a 429
* or 529 transparently retries with a cheaper/less-loaded model instead of
* failing the turn. Both accept aliases (`opus`/`sonnet`/`haiku`) or full
* Anthropic model ids.
*/
const SessionSchema = z
.object({
/**
* Backend for a session created without a provider. Must name a backend
* this daemon registers — a typo, a disabled backend, or one whose binary
* is missing fails daemon startup rather than silently reverting to
* "claude". Resumed sessions keep the backend they were created on; the
* conductor keeps `conductor.provider`.
*/
defaultProvider: z.string().trim().min(1).optional(),
defaultModel: z.string().optional(),
fallbackModel: z.string().optional(),
/**
Expand Down Expand Up @@ -1013,6 +1023,8 @@ export interface CodeoidConfig {
};
/** Model selection defaults applied when a session is created. */
session: {
/** Backend for a session created without a provider (unset = "claude"). Validated at startup. */
defaultProvider?: string;
defaultModel?: string;
fallbackModel?: string;
/** Stall watchdog: ms of event-stream silence while the model should be generating before a turn is force-recovered (0 = off; paused during tool execution and pending approvals). Defaults to 300000 when omitted. */
Expand Down Expand Up @@ -1184,6 +1196,10 @@ interface EnvOverride {
kind: OverrideKind;
}

/** Env override for `session.defaultProvider` — named so the settings check
* can look past it at the value config.json would carry on its own. */
export const DEFAULT_PROVIDER_ENV = "CODEOID_DEFAULT_PROVIDER";

const ENV_OVERRIDES: readonly EnvOverride[] = [
{ env: "CODEOID_DAEMON_URL", path: "daemonUrl", kind: "string" },
{ env: "CODEOID_DB_PATH", path: "dbPath", kind: "string" },
Expand Down Expand Up @@ -1220,6 +1236,7 @@ const ENV_OVERRIDES: readonly EnvOverride[] = [
{ env: "CODEOID_AUTO_ROTATE_PCT", path: "autoRotate.rotatePct", kind: "float" },
{ env: "CODEOID_AUTO_ROTATE_HARD_PCT", path: "autoRotate.hardRotatePct", kind: "float" },
{ env: "CODEOID_AUTO_ROTATE_MIN_TURNS", path: "autoRotate.minTurnsBeforeRotate", kind: "int" },
{ env: DEFAULT_PROVIDER_ENV, path: "session.defaultProvider", kind: "string" },
{ env: "CODEOID_DEFAULT_MODEL", path: "session.defaultModel", kind: "string" },
// Dispatch kill switch — disable send-class fleet dispatch per-invocation
// without touching config.json. Other dispatch knobs are file-config only,
Expand Down Expand Up @@ -1261,6 +1278,15 @@ export interface LoadOptions {
configPath?: string;
/** Env source (default process.env). Tests inject a controlled object. */
env?: Record<string, string | undefined>;
/**
* An in-memory config.json object to load INSTEAD of reading the file —
* used to preview the config a settings write would leave for the next
* boot, through exactly the path that boot takes.
*/
raw?: unknown;
/** Skip the operator-facing startup warnings — for previews that run on
* every settings write, where repeating them is noise. */
quiet?: boolean;
}

/**
Expand All @@ -1282,8 +1308,8 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
const env = opts.env ?? process.env;

// 1. File defaults.
let fileConfig: unknown = {};
if (existsSync(configPath)) {
let fileConfig: unknown = opts.raw ?? {};
if (opts.raw === undefined && existsSync(configPath)) {
try {
const raw = readFileSync(configPath, "utf8");
fileConfig = JSON.parse(raw);
Expand Down Expand Up @@ -1394,6 +1420,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
// daemon's local identity store would be keyed personal/dev while the minted
// identities live in the badge's actual tenant — a silent split. Surface it.
if (
!opts.quiet &&
parsed.agentIdentity.registrarKey !== undefined &&
parsed.agentIdentity.accountId === "personal" &&
parsed.agentIdentity.projectId === "dev"
Expand Down
12 changes: 7 additions & 5 deletions src/daemon/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,13 @@ export function resolveModelId(identifier: string): string | null {
export const DEFAULT_MODEL_ALIAS = "opus";

/**
* The provider id whose model space this catalog describes. Must match the
* default id the provider registry is built with
* (`createDefaultProviderRegistry` → `new ProviderRegistry("claude")`);
* `DEFAULT_PROVIDER_ID` in session-manager re-exports this so there is one
* source of truth.
* The provider id whose model space this catalog (and `DEFAULT_MODEL_ALIAS`)
* describes. It is also the registry's default when `session.defaultProvider`
* is unset — but only that: the configured default can be any backend, so
* code asking "which backend will this session run on?" reads
* `ProviderRegistry.defaultId`, and code asking "is this Claude?" compares
* against this constant. Conflating the two is how a non-Claude default ends
* up validated against Claude's catalog.
*/
export const CLAUDE_PROVIDER_ID = "claude";

Expand Down
69 changes: 61 additions & 8 deletions src/daemon/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { McpHub } from "../mcp/hub.js";
import type { CompressionRegistry } from "../compress/index.js";
import type { CodeoidConfig } from "../../config.js";
import type { SessionProvider, CatalogEntry } from "./interface.js";
import { CLAUDE_PROVIDER_ID } from "../models.js";
import { ClaudeProvider } from "./claude/index.js";
import { GeminiProvider } from "./gemini/index.js";
import { OpenAIProvider } from "./openai/index.js";
Expand Down Expand Up @@ -88,7 +89,7 @@ export class ProviderRegistry {
/** Id used when a session doesn't carry a provider selection. */
readonly defaultId: string;

constructor(defaultId = "claude") {
constructor(defaultId = CLAUDE_PROVIDER_ID) {
this.defaultId = defaultId;
}

Expand Down Expand Up @@ -156,13 +157,63 @@ export class ProviderRegistry {
}
}

/**
* A `session.defaultProvider` the daemon cannot honour. Its own class so the
* CLI can print it as the operator's config mistake it is — one line, exit 1
* — without also swallowing the stack trace of a genuine startup bug.
*/
export class DefaultProviderError extends Error {
override name = "DefaultProviderError";
}

/**
* Why `id` cannot be this daemon's default backend, or undefined when it can.
*
* Shared by startup (`createDefaultProviderRegistry`) and the settings write
* path, so a value the settings UI accepts is exactly a value the next boot
* accepts. A backend codeoid supports but could not activate (binary missing,
* no API key) gets its actionable hint; anything else is a typo or a backend
* disabled under `providers.<id>.enabled`.
*/
export function defaultProviderProblem(registry: ProviderRegistry, id: string): string | undefined {
if (registry.has(id)) return undefined;
// JSON-quoted: the value is caller-supplied on the settings path, and a
// quoted string can't smuggle a newline into a log line.
const quoted = JSON.stringify(id);
const hint = registry.unavailableHint(id);
if (hint) return `session.defaultProvider ${quoted} is not available on this daemon: ${hint}`;
const toggle = ENABLE_KEY[id];
const fix = toggle ? `It is disabled — set providers.${toggle}.enabled to true.` : "Check the spelling.";
return `session.defaultProvider ${quoted} is not a registered backend (registered: ${registry.ids().join(", ")}). ${fix}`;
}

/** Backend id → its `providers.<key>.enabled` switch, for the ones that have one. */
const ENABLE_KEY: Record<string, string> = {
pi: "pi",
codex: "codex",
"gemini-cli": "geminiCli",
qwen: "qwen",
};

/**
* The built-in backends. Daemon startup builds exactly one of these.
* `config` gates optional backends (pi can be disabled) and carries their
* settings (binary path); absent = every backend with defaults.
*
* `config.session.defaultProvider` picks the default backend and is checked
* once every backend has registered. It THROWS rather than warns: unlike
* `resolve()`'s fallback — which exists so resume survives a session written
* by a newer codeoid — a misspelled default is operator error, and falling
* back would silently put every new session on a backend they opted out of.
*/
export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderRegistry {
const registry = new ProviderRegistry("claude");
export function createDefaultProviderRegistry(
config?: CodeoidConfig,
/** Where backend keys and binary PATH lookups are read. A parameter so the
* settings path can dry-run the NEXT boot's registry without touching the
* live process env. */
env: Record<string, string | undefined> = process.env,
): ProviderRegistry {
const registry = new ProviderRegistry(config?.session?.defaultProvider ?? CLAUDE_PROVIDER_ID);
registry.register({
id: "claude",
displayName: "Claude (Anthropic)",
Expand All @@ -189,7 +240,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
// binary checks below. Without this, forking onto e.g. openai created a
// session that failed cryptically ("401 Incorrect API key: missing")
// instead of the option simply not appearing.
if (process.env.GOOGLE_API_KEY) {
if (env.GOOGLE_API_KEY) {
registry.register({
id: "gemini",
displayName: "Gemini (Google)",
Expand All @@ -212,7 +263,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
"GOOGLE_API_KEY is not set — add it to ~/.codeoid/.env to use the Gemini backend",
);
}
if (process.env.OPENAI_API_KEY) {
if (env.OPENAI_API_KEY) {
registry.register({
id: "openai",
displayName: "OpenAI",
Expand Down Expand Up @@ -241,7 +292,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
// resolution means picking pi can't fail on a missing binary; no
// resolution means the catalog says "not installed" with the fix.
const configured = config?.providers?.pi?.command;
const resolution = resolvePiCommand(configured === "pi" ? undefined : configured);
const resolution = resolvePiCommand(configured === "pi" ? undefined : configured, env);
if (resolution) {
registry.register({
id: "pi",
Expand Down Expand Up @@ -271,7 +322,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
}
if (config?.providers?.codex?.enabled !== false) {
const configured = config?.providers?.codex?.command;
const resolution = resolveCodexCommand(configured === "codex" ? undefined : configured);
const resolution = resolveCodexCommand(configured === "codex" ? undefined : configured, env);
if (resolution) {
registry.register({
id: "codex",
Expand Down Expand Up @@ -301,7 +352,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
}
if (config?.providers?.geminiCli?.enabled !== false) {
const configured = config?.providers?.geminiCli?.command;
const resolution = resolveGeminiCliCommand(configured === "gemini" ? undefined : configured);
const resolution = resolveGeminiCliCommand(configured === "gemini" ? undefined : configured, env);
if (resolution) {
registry.register({
id: "gemini-cli",
Expand Down Expand Up @@ -352,5 +403,7 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR
}),
});
}
const problem = defaultProviderProblem(registry, registry.defaultId);
if (problem) throw new DefaultProviderError(problem);
return registry;
}
5 changes: 4 additions & 1 deletion src/daemon/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,10 @@ export class DaemonServer {
},
);

console.log(`[codeoid] providers: ${this.#manager.providerIds().join(", ")}`);
// Default first and labelled: with session.defaultProvider set, which
// backend unqualified sessions land on is worth seeing at boot.
const [defaultProvider, ...otherProviders] = this.#manager.providerIds();
console.log(`[codeoid] providers: ${[`${defaultProvider} (default)`, ...otherProviders].join(", ")}`);
for (const { id, hint } of this.#manager.unavailableProviders()) {
console.warn(`[codeoid] provider ${id} unavailable: ${hint}`);
}
Expand Down
Loading
Loading