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
106 changes: 82 additions & 24 deletions src/daemon/session-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ function normalizeWorkdir(input: string): string | null {
const RESUME_MAX_SESSIONS = 50;
const RESUME_DEADLINE_MS = 20_000;

/** Provider assumed when a client doesn't say which catalog it wants.
* Sessions are Claude-backed today; when the provider registry is wired
* into session creation this becomes the configured default provider. */
export const DEFAULT_PROVIDER_ID = "claude";

/** Sort key for resume ordering: most-recently-active first. Falls back to
* createdAt, then 0, so a malformed timestamp never throws. */
function resumeSortKey(m: { lastActivityAt?: string; createdAt?: string }): number {
Expand All @@ -85,9 +90,10 @@ export class SessionManager {
#identityManager?: AgentIdentityManager;
#rateLimiter: RateLimiter;
#memory?: MemoryEngine;
/** Live model catalog from the backend (via SDK supportedModels), cached
* daemon-wide once any session initializes. Null until then. */
#modelsCache: ModelInfo[] | null = null;
/** Live model catalogs by provider id (via each backend's supportedModels
* equivalent), cached daemon-wide once any session of that provider
* initializes. Empty until then. */
#modelsCache = new Map<string, ModelInfo[]>();
#config?: CodeoidConfig;
#compressionRegistry?: CompressionRegistry;

Expand Down Expand Up @@ -154,7 +160,7 @@ export class SessionManager {
memory: this.#memory,
config: this.#config,
compressionRegistry: this.#compressionRegistry,
onModels: (m) => this.#cacheModels(m),
onModels: (providerId, m) => this._cacheModels(providerId, m),
});

// Restore scrollback from transcript, seeding the seq counter past
Expand Down Expand Up @@ -487,7 +493,7 @@ export class SessionManager {
...(this.#memory ? { memory: this.#memory } : {}),
config: this.#config,
compressionRegistry: this.#compressionRegistry,
onModels: (m) => this.#cacheModels(m),
onModels: (providerId, m) => this._cacheModels(providerId, m),
});
this.#sessions.set(session.id, session);
this.#rateLimiter.recordCreation(auth.sub);
Expand Down Expand Up @@ -578,34 +584,85 @@ export class SessionManager {
}

/**
* Cache the live model catalog reported by a session's SDK query. The list
* is version-static across sessions, so the first one to report wins and we
* stop overwriting (cheap idempotence; avoids churn from every new session).
* Cache the live model catalog a provider reported. The list is
* version-static per provider within a daemon lifetime, so the first
* report per provider wins and we stop overwriting (cheap idempotence;
* avoids churn from every new session).
*
* The first report of each daemon lifetime is also persisted to SQLite
* (keyed by provider id), so subsequent boots serve current model names
* before any turn runs (see `#currentModels`) instead of the baked-in
* fallback that goes stale between codeoid releases.
*
* TypeScript-private (not `#`) so unit tests can exercise the persistence
* path directly without a live backend query — same convention as
* `Session._applyInterruptedStateToTool`. Do NOT call from production code
* outside the `onModels` wiring.
*/
#cacheModels(
private _cacheModels(
providerId: string,
raw: ReadonlyArray<{ value: string; displayName: string; description?: string }>,
): void {
if (this.#modelsCache || raw.length === 0) return;
this.#modelsCache = raw.map((m) => ({
if (this.#modelsCache.has(providerId) || raw.length === 0) return;
const models = raw.map((m) => ({
value: m.value,
displayName: m.displayName,
...(m.description ? { description: m.description } : {}),
isDefault: m.value === "default",
}));
this.#modelsCache.set(providerId, models);
try {
this.#store.saveModelCatalog(providerId, models);
} catch (err) {
// Persistence is best-effort — the in-memory cache still serves this
// lifetime; next boot just falls back one tier further.
console.error(
`[codeoid/models] failed to persist ${providerId} model catalog: ${err instanceof Error ? err.message : String(err)}`,
);
}
}

/** The live model catalog if cached, else the built-in fallback. */
#currentModels(): { models: ModelInfo[]; live: boolean } {
return this.#modelsCache
? { models: this.#modelsCache, live: true }
: { models: fallbackModelInfos(), live: false };
/**
* The model catalog to serve for a provider, best source first:
* 1. live — reported by that provider's backend this daemon lifetime
* 2. cached — the last live list persisted by a previous lifetime
* 3. fallback — the baked-in catalog (claude only; other providers have
* no baked-in list and serve empty until they report)
* `live` is true only for tier 1, so clients keep refetching until the
* backend has actually been asked this lifetime.
*/
#currentModels(providerId: string): { models: ModelInfo[]; live: boolean } {
const liveModels = this.#modelsCache.get(providerId);
if (liveModels) return { models: liveModels, live: true };
const persisted = this.#persistedModels(providerId);
if (persisted) return { models: persisted, live: false };
return {
models: providerId === DEFAULT_PROVIDER_ID ? fallbackModelInfos() : [],
live: false,
};
}

/** Lazily-loaded persisted catalogs (null = never reported / unreadable). */
#persistedModelsCache = new Map<string, ModelInfo[] | null>();
#persistedModels(providerId: string): ModelInfo[] | null {
if (!this.#persistedModelsCache.has(providerId)) {
let value: ModelInfo[] | null = null;
try {
value = this.#store.getModelCatalog(providerId);
} catch {
value = null;
}
this.#persistedModelsCache.set(providerId, value);
}
return this.#persistedModelsCache.get(providerId) ?? null;
}

#modelsList(
msg: Extract<ClientMessage, { type: "models.list" }>,
): DaemonMessage {
const { models, live } = this.#currentModels();
return { type: "models.list.result", requestId: msg.id, models, live };
const provider = msg.provider ?? DEFAULT_PROVIDER_ID;
const { models, live } = this.#currentModels(provider);
return { type: "models.list.result", requestId: msg.id, models, live, provider };
}

async #fsBrowseDir(
Expand Down Expand Up @@ -796,7 +853,7 @@ export class SessionManager {
memory: this.#memory,
config: this.#config,
compressionRegistry: this.#compressionRegistry,
onModels: (m) => this.#cacheModels(m),
onModels: (providerId, m) => this._cacheModels(providerId, m),
});

this.#sessions.set(session.id, session);
Expand Down Expand Up @@ -1093,11 +1150,12 @@ export class SessionManager {
code: "not_found",
};
}
// Validate against the live backend catalog (or the fallback). Accepts a
// canonical value, a case-insensitive display name (`opus` → "Opus"), or
// a full claude-* id. An unknown value is rejected here with the set of
// valid choices, so `/model o` gets actionable feedback.
const { models } = this.#currentModels();
// Validate against the session's provider catalog (live, persisted, or
// fallback). Accepts a canonical value, a case-insensitive display name
// (`opus` → "Opus"), or a full claude-* id. An unknown value is rejected
// here with the set of valid choices, so `/model o` gets actionable
// feedback.
const { models } = this.#currentModels(session.providerId);
const resolvedModel = resolveAgainstList(msg.model, models);
if (!resolvedModel) {
return {
Expand Down
19 changes: 15 additions & 4 deletions src/daemon/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,16 @@ export interface SessionCreateOptions {
existingId?: string;
/**
* Called once per session with the live model catalog the backend
* supports (from the SDK's `supportedModels()`). The manager caches it
* daemon-wide so `/model` validation + the picker use the real list.
* supports (e.g. the Claude Code SDK's `supportedModels()`), tagged with
* the reporting provider's id so the manager can cache catalogs
* per-provider — codeoid is provider-agnostic and each backend serves a
* different model list. The manager caches it daemon-wide so `/model`
* validation + the picker use the real list.
*/
onModels?: (models: ReadonlyArray<{ value: string; displayName: string; description?: string }>) => void;
onModels?: (
providerId: string,
models: ReadonlyArray<{ value: string; displayName: string; description?: string }>,
) => void;
/** Optional memory engine — when provided, episodes are chunked and stored for recall. */
memory?: MemoryEngine;
/**
Expand Down Expand Up @@ -356,7 +362,10 @@ export class Session {
memory: opts.memory,
config: opts.config,
compressionRegistry: opts.compressionRegistry,
onModels: opts.onModels,
// Tag model reports with the provider's own id — the arrow runs only
// after construction (models arrive async on first query), so
// this.#provider is set by then. Works unchanged for any provider.
onModels: (m) => opts.onModels?.(this.#provider.id, m),
});

// Restore any pinned files the user had on this session before.
Expand Down Expand Up @@ -436,6 +445,8 @@ export class Session {
}

get status(): SessionStatus { return this.#status; }
/** Id of the provider backing this session (e.g. "claude"). */
get providerId(): string { return this.#provider.id; }
get attachedClientCount(): number { return this.#clients.size; }

/**
Expand Down
63 changes: 62 additions & 1 deletion src/daemon/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { Database } from "bun:sqlite";
import type { SessionInfo, SessionStatus } from "../protocol/types.js";
import type { ModelInfo, SessionInfo, SessionStatus } from "../protocol/types.js";

export class Store {
#db: Database;
Expand Down Expand Up @@ -73,7 +73,21 @@ export class Store {
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_pins_session ON session_pins(session_id);

-- Last live model catalog per provider (claude, gemini, openai, ...),
-- as reported by that provider's backend. Served as the models.list
-- fallback on boots where no session has run a turn yet, so the picker
-- shows current model names instead of a baked-in list that goes stale
-- between codeoid releases.
CREATE TABLE IF NOT EXISTS provider_model_catalogs (
provider_id TEXT PRIMARY KEY,
models_json TEXT NOT NULL,
cached_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
// Pre-release single-row predecessor of provider_model_catalogs — never
// shipped in a tagged version; drop from dev databases that ran the branch.
this.#db.exec("DROP TABLE IF EXISTS cached_model_catalog");
}

/**
Expand Down Expand Up @@ -275,6 +289,53 @@ export class Store {
this.#db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
}

// ── Model catalog cache ───────────────────────────────────────────────

/**
* Persist the live model catalog a provider's backend reported. One row
* per provider id — the latest report wins across daemon lifetimes.
*/
saveModelCatalog(providerId: string, models: readonly ModelInfo[]): void {
this.#db
.prepare(
`INSERT INTO provider_model_catalogs (provider_id, models_json, cached_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(provider_id) DO UPDATE SET
models_json = excluded.models_json,
cached_at = excluded.cached_at`,
)
.run(providerId, JSON.stringify(models));
}

/**
* The last persisted live model catalog for a provider, or null when that
* provider has never reported one (first-ever boot) or the stored JSON is
* unreadable.
*/
getModelCatalog(providerId: string): ModelInfo[] | null {
const row = this.#db
.prepare("SELECT models_json FROM provider_model_catalogs WHERE provider_id = ?")
.get(providerId) as { models_json: string } | null;
if (!row) return null;
try {
const parsed: unknown = JSON.parse(row.models_json);
if (!Array.isArray(parsed)) return null;
// Structural validation — a row written by a future/older version with
// a different shape degrades to the next fallback tier instead of
// serving malformed entries to pickers.
const valid = parsed.filter(
(m): m is ModelInfo =>
!!m &&
typeof m === "object" &&
typeof (m as ModelInfo).value === "string" &&
typeof (m as ModelInfo).displayName === "string",
);
return valid.length > 0 ? valid : null;
} catch {
return null;
}
}

// ── Audit ─────────────────────────────────────────────────────────────

audit(subject: string, action: string, sessionId?: string, detail?: string): void {
Expand Down
12 changes: 11 additions & 1 deletion src/protocol/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -859,6 +859,11 @@ export interface ClaudeConfigMsg extends BaseClientMsg {
*/
export interface ModelsListMsg extends BaseClientMsg {
type: "models.list";
/**
* Which provider's catalog to return. Optional and additive — omitted by
* older clients, in which case the daemon's default provider is assumed.
*/
provider?: string;
}

/** One selectable model as reported by the Claude Code backend. */
Expand Down Expand Up @@ -1058,8 +1063,13 @@ export interface ModelsListResultMsg {
type: "models.list.result";
requestId: string;
models: ModelInfo[];
/** True when these came from the live backend; false = built-in fallback. */
/**
* True when these came from the live backend this daemon lifetime;
* false = persisted last-known list or built-in fallback.
*/
live: boolean;
/** Provider whose catalog this is (e.g. "claude", "gemini"). */
provider: string;
}

export interface SessionExportResultMsg {
Expand Down
Loading
Loading