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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
- `ask_operator` no longer pre-authorizes a model-authored shell command when
the operator picks any option, including Reject. Clarification choices
cannot mint shell grants.
- Live `/model` switches refresh inference, permission identity, grant
persistence identity, and advertised tool schemas together. A grant for the
previous model no longer covers the same action, new grants store under the
new pair, and Kimi/Moonshot sessions get non-recursive `present` schemas
immediately (canonical schemas restore when switching away).

## [0.3.10] - 2026-08-30

Expand Down
2 changes: 1 addition & 1 deletion docs/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ Profiles supply per-project or named-profile overrides for `model` and `systemPr

Providers and credentials are read exclusively from settings files: the global `~/.corbits/settings.json` (definitions + credentials) and the per-repo `.corbits/settings.json` (selection only). There are no `OPENAI_COMPATIBLE_*` environment-variable overrides, and `index.ts` does not load `.env` files — a deliberately stale or exported key can no longer shadow the configured provider.

**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG.
**Models-first connect.** There is no standalone `/login` command. `/model` opens on a flat **models-only** list (Recent, Favorites, then connected provider/model rows) built by `buildModelsFirstList` (`src/tui/model-picker.ts`); type-to-filter owns printable keys. Selecting a row runs `applyLiveModelSwitch` (`src/session/live-model-switch.ts`) so inference sources, permission-gate identity, grant persistence identity, and advertised tool schemas cut over together. **Alt+A** opens Connect via `addProviderSelectorChoices` (`src/tui/provider-setup.ts`), which lists every first-class kind including Custom — never bare `c` / Ctrl+A, and never in-list “connect →” rows. First-class API-key rows use a named-instance + auth-only form (instance name, key; catalog base URL is display-only); Custom keeps the full manual form. **Alt+F** toggles favorites; recent/favorite pairs live in global settings (`recentModels` / `favoriteModels`). **Alt+D** sets the default via `setDefaultModel` (global `defaultProvider` + that provider's `defaultModel`) plus `persistConnectedSelection` without switching the live session. First-class providers ship from `packages/first-class-providers` (corbits-agnostic defs) and `packages/opencode-go` (Go catalog, auth validate, multi-protocol endpoints, usage). OAuth providers open the existing browser login modal with a named account step; API-key providers share the same multi-instance naming and pre-seed models on save so selection works without restart. Both OAuth and API-key (including Custom) connects share `persistConnectedSelection` in `provider-setup-submit.ts` so project-local provider/model selection is written alongside global credentials. OpenCode Go forces `OPENCODE_GO_BASE_URL` when `opencodeGo` is set so subscription traffic is not billed as Zen PAYG.

**OpenCode Go multi-protocol.** Each Go model carries protocol metadata (`chat-completions`, `responses`, or `messages`). `buildGoSource` / `resolveGoEndpoint` pick the adapter and base URL per model (not a single provider-wide OpenAI route). When Go is the active provider, subscription usage is fetched for the status bar and omitted on auth/network failure.

Expand Down
2 changes: 1 addition & 1 deletion docs/PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ The TUI has an extensible slash-command framework. Built-ins include `/help` (sh

Providers are **models-first**: there is no standalone `/login` command. `/model` opens a **models-only list** (Recent, Favorites, then connected provider/model rows) — type-to-filter owns printable keys, so Connect is never a bare letter. **Alt+A** opens a dedicated add-provider selector over every first-class kind (OpenAI dual-path ChatGPT OAuth or API key, xAI, OpenCode Zen, Anthropic, Google, OpenCode Go, Z.AI Coding Plan, Custom), each annotated with its live account count and never filtered out for “already connected.” **Alt+F** toggles favorite on the highlighted model. **Alt+D** persists the highlighted pair as the default without switching the live session. Advanced provider drill-down (edit/delete/tiers) stays on the advanced surface, not a bare printable key while the model list is filtering. OAuth providers open their existing browser login with a named account step so multiple accounts per kind coexist (`codex/work`, …). API-key providers use the same named-instance step before the key (auth-only form: instance name + key + fixed catalog base URL), so personal and team keys land as distinct catalog rows (`openai/default`, `anthropic/work`, …); reusing a name re-keys that instance after confirm. Custom remains a free-form single endpoint (full manual form). Successful connect refreshes the catalog and reopens the model list focused on the new account’s default model. OpenCode Go routes each model by its protocol metadata (chat completions, OpenAI responses, or Anthropic messages) and can show subscription usage in the status bar when active (rolling 5h / weekly / monthly windows when the usage API responds; omitted on auth or network failure). When Go returns a quota or rate-limit error — including some HTTP 400 responses that carry limit payloads — Corbits classifies them so quota aborts cleanly and short provider rate limits remain retryable. On a free-tier or subscription quota hit, wait for the window to reset or use OpenCode Zen free models.

`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart), and can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials).
`/model` opens a dedicated full-screen modal — the single place agent configuration lives. The default view is models-only (Recent / Favorites / connected models); add-provider, tiers, and profiles remain reachable from the same surface without in-list “connect →” rows. A switch applies to the running session immediately (no restart): inference, permission identity, grant persistence identity, and advertised tool schemas cut over together, and the choice can be saved as this project's default (written to the per-repo selection file). Recent and favorite model pairs are stored in global settings (no credentials).

## Lifecycle Hooks

Expand Down
3 changes: 1 addition & 2 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,7 +357,6 @@ export async function runExec(config: Config): Promise<ExecResult> {
const sessionMode: SessionMode =
resolveSessionMode(config.settings, localSettingsForMode) ?? "orchestrator";

const activeProviderModel = `${config.providerName}:${config.model}`;
const seededApprovals = await loadSeededApprovals(config.cwd, sessionId);

const interactive = input.isTTY === true && output.isTTY === true;
Expand All @@ -377,7 +376,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
model: config.model,
requestApproval: (request: PermissionRequest): Promise<ApprovalOutcome> =>
promptPermission(request, interactive),
persist: createApprovalPersist(config.cwd, activeProviderModel),
persist: createApprovalPersist(config.cwd, () => `${config.providerName}:${config.model}`),
approvalLog: createApprovalLog(sessionDir(config.cwd, sessionId)),
interactive,
skipPermissions: config.dangerouslySkipPermissions,
Expand Down
9 changes: 8 additions & 1 deletion src/permission/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,10 @@ export interface PermissionGate {
// TUI wires the toggle here so a switch takes effect on the next tool call —
// including pre-gate sandboxes that read getSkipPermissions live.
setSkipPermissions: (value: boolean) => void;
// Point matching and newly minted provider-model grants at a different
// providerName:model. A live `/model` switch calls this so a grant scoped to
// the previous pair no longer auto-allows, and new grants tag the new pair.
setProviderIdentity: (providerName: string, model: string) => void;
registerMcpClient: (client: MCPClient) => void;
unregisterMcpServer: (serverName: string) => void;
}
Expand Down Expand Up @@ -359,7 +363,7 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
let skipPermissions = options.skipPermissions;
// Own a private copy so evaluating a grant never mutates the caller's array.
const approvals: Approval[] = [...options.approvals];
const activeProviderModel =
let activeProviderModel =
providerName !== undefined && model !== undefined ? `${providerName}:${model}` : undefined;
// Session grants live only in this array; persisted grants are seeded in via
// options.approvals and re-routed to a store by the persist callback.
Expand Down Expand Up @@ -734,6 +738,9 @@ export function createPermissionGate(options: PermissionGateOptions): Permission
setSkipPermissions: (value: boolean) => {
skipPermissions = value;
},
setProviderIdentity: (nextProviderName: string, nextModel: string) => {
activeProviderModel = `${nextProviderName}:${nextModel}`;
},
registerMcpClient,
unregisterMcpServer,
};
Expand Down
161 changes: 161 additions & 0 deletions src/session/live-model-switch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { ToolCall, ToolDefinition } from "@intx/types/runtime";

import { presentDefinition } from "../agent/director.js";
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
import { createPermissionGate } from "../permission/gate.js";
import * as permissionStore from "../permission/store.js";
import type { Approval } from "../permission/types.js";
import { createApprovalPersist } from "./runtime-assembly.js";
import { applyLiveModelSwitch, providerModelKey, type LiveModelRef } from "./live-model-switch.js";

const MODEL_A: LiveModelRef = { providerName: "openai", model: "gpt-5" };
const MODEL_B: LiveModelRef = { providerName: "anthropic", model: "claude-opus" };
const MODEL_KIMI: LiveModelRef = { providerName: "moonshot", model: "kimi-k2" };

const canonicalDefs: readonly ToolDefinition[] = [presentDefinition];

function schemaHasRef(value: unknown): boolean {
if (value === null || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(schemaHasRef);
const obj = value as Record<string, unknown>;
if ("$ref" in obj) return true;
return Object.values(obj).some(schemaHasRef);
}

const shellCall = (command: string): ToolCall => ({
id: "c",
name: "run_shell",
arguments: { command },
});

const providerModelScope = {
id: "provider-model",
label: "",
pattern: "npm *",
grant: "provider-model" as const,
};

function presentSchema(defs: readonly ToolDefinition[]): unknown {
return defs.find((d) => d.name === "present")?.inputSchema;
}

/**
* Same collaborators the TUI `/model` handler wires through
* `applyLiveModelSwitch`: live identity (persist reads it), permission gate,
* inference rebuild, and canonical-then-family-gate advertise.
*/
function createProductionSwitch() {
let identity: LiveModelRef = MODEL_A;
let inference: LiveModelRef = MODEL_A;
let advertised = normalizeToolDefinitionsForProvider(canonicalDefs, MODEL_A);

const persist = createApprovalPersist("/tmp/proj", () => providerModelKey(identity));
const gate = createPermissionGate({
approvals: [],
requestApproval: async () => ({ allow: true, persist: providerModelScope }),
persist,
interactive: true,
skipPermissions: false,
auto: false,
providerName: identity.providerName,
model: identity.model,
});

const switchTo = (next: LiveModelRef): void => {
applyLiveModelSwitch(next, {
applyIdentity: (ref) => {
identity = ref;
},
setPermissionIdentity: (providerName, model) => {
gate.setProviderIdentity(providerName, model);
},
rebuildInference: (ref) => {
inference = ref;
},
refreshAdvertisedSchemas: (ref) => {
advertised = normalizeToolDefinitionsForProvider(canonicalDefs, ref);
},
});
};

return {
gate,
switchTo,
identity: () => identity,
inference: () => inference,
advertised: () => advertised,
};
}

describe("applyLiveModelSwitch", () => {
afterEach(() => {
mock.restore();
});

test("refreshes identity, permission, inference, and schemas as one operation", () => {
const order: string[] = [];
applyLiveModelSwitch(MODEL_B, {
applyIdentity: () => {
order.push("identity");
},
setPermissionIdentity: () => {
order.push("permission");
},
rebuildInference: () => {
order.push("inference");
},
refreshAdvertisedSchemas: () => {
order.push("schemas");
},
});
expect(order).toEqual(["identity", "permission", "inference", "schemas"]);
});

test("a grant scoped to A does not cover the action after switching to B; new grants store under B", async () => {
const saved: string[] = [];
spyOn(permissionStore, "saveProviderModelApproval").mockImplementation(async (key: string) => {
saved.push(key);
});
spyOn(permissionStore, "saveProjectApproval").mockResolvedValue(undefined);
spyOn(permissionStore, "saveGlobalApproval").mockResolvedValue(undefined);

const session = createProductionSwitch();

expect((await session.gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
expect(saved).toEqual([providerModelKey(MODEL_A)]);
const grantA = session.gate
.getApprovals()
.find((a: Approval) => a.providerModel === providerModelKey(MODEL_A));
expect(grantA).toBeDefined();

session.switchTo(MODEL_B);

expect(session.identity()).toEqual(MODEL_B);
expect(session.inference()).toEqual(MODEL_B);

expect((await session.gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
expect(saved).toEqual([providerModelKey(MODEL_A), providerModelKey(MODEL_B)]);
expect(
session.gate.getApprovals().some((a) => a.providerModel === providerModelKey(MODEL_B)),
).toBe(true);
});

test("non-kimi to kimi rewrites advertised present; switching away restores canonical", () => {
const session = createProductionSwitch();
expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true);
expect(presentSchema(session.advertised())).toBe(presentDefinition.inputSchema);

session.switchTo(MODEL_KIMI);

expect(session.inference()).toEqual(MODEL_KIMI);
expect(schemaHasRef(presentSchema(session.advertised()))).toBe(false);
expect(presentSchema(session.advertised())).not.toBe(presentDefinition.inputSchema);

session.switchTo(MODEL_A);

expect(session.inference()).toEqual(MODEL_A);
expect(schemaHasRef(presentSchema(session.advertised()))).toBe(true);
expect(presentSchema(session.advertised())).toBe(presentDefinition.inputSchema);
});
});
37 changes: 37 additions & 0 deletions src/session/live-model-switch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* One cutover for every model-scoped runtime fact a live session reads.
* `/model` (and tests) must go through this rather than refreshing inference
* alone — a missed step is how provider-model grants and kimi wire schemas
* went stale after a switch.
*/

export interface LiveModelRef {
providerName: string;
model: string;
}

export function providerModelKey(ref: LiveModelRef): string {
return `${ref.providerName}:${ref.model}`;
}

export interface LiveModelSwitchHandles {
/** Session config / live identity that persist getters read. */
applyIdentity: (next: LiveModelRef) => void;
/** Permission-gate matching and mint identity. */
setPermissionIdentity: (providerName: string, model: string) => void;
/** Rebuild inference sources for the next turn. */
rebuildInference: (next: LiveModelRef) => void;
/**
* Re-advertise family-gated tool schemas from canonical definitions.
* Must not re-normalize an already-rewritten advertise set — switching
* away from kimi would then keep the non-recursive present schema.
*/
refreshAdvertisedSchemas: (next: LiveModelRef) => void;
}

export function applyLiveModelSwitch(next: LiveModelRef, handles: LiveModelSwitchHandles): void {
handles.applyIdentity(next);
handles.setPermissionIdentity(next.providerName, next.model);
handles.rebuildInference(next);
handles.refreshAdvertisedSchemas(next);
}
18 changes: 17 additions & 1 deletion src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ describe("createApprovalPersist", () => {
undefined,
);

const persist = createApprovalPersist("/tmp/proj", "openai:gpt-5");
const persist = createApprovalPersist("/tmp/proj", () => "openai:gpt-5");
const approval = { tool: "run_shell", pattern: "npm *" };

persist(approval, "project");
Expand All @@ -187,6 +187,22 @@ describe("createApprovalPersist", () => {
expect(global).toHaveBeenCalledTimes(1);
expect(providerModel).toHaveBeenCalledTimes(1);
});

test("a live identity change stores the next provider-model grant under the new key", () => {
const providerModel = spyOn(permissionStore, "saveProviderModelApproval").mockResolvedValue(
undefined,
);
let identity = "openai:gpt-5";
const persist = createApprovalPersist("/tmp/proj", () => identity);
const approval = { tool: "run_shell", pattern: "npm *" };

persist(approval, "provider-model");
identity = "anthropic:claude-opus";
persist(approval, "provider-model");

expect(providerModel).toHaveBeenNthCalledWith(1, "openai:gpt-5", approval);
expect(providerModel).toHaveBeenNthCalledWith(2, "anthropic:claude-opus", approval);
});
});

describe("skillDirsFromEnabledPlugins", () => {
Expand Down
Loading
Loading