Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
05e54d2
fix: offer Altimate Base to every user on an implicit free Zen default
anandgupta42 Sep 10, 2026
1f0e359
fix: wrap two remaining hunks in altimate_change block markers
anandgupta42 Sep 10, 2026
d49a248
chore: drop the upstream product name from five #1301 comments
anandgupta42 Sep 10, 2026
0ec6bcd
fix: address review feedback on the Altimate Base default migration (…
anandgupta42 Sep 10, 2026
e7736af
fix: queue prompt-history writes until the startup read settles; comp…
anandgupta42 Sep 10, 2026
9af9ab4
fix: address remaining bot findings on PR #1302 (prompt-history write…
anandgupta42 Sep 11, 2026
21a5998
fix: close the altimate_change marker after append()'s closing brace
anandgupta42 Sep 11, 2026
0370cfa
fix: only flip prompt-history loaded() after the startup flush settle…
anandgupta42 Sep 11, 2026
93ea10a
fix: round 6 bot review findings on PR #1302 (history race, cross-sur…
anandgupta42 Sep 11, 2026
96375f6
fix: keep the app.tsx local.tsx import single-line for the marker guard
anandgupta42 Sep 11, 2026
b01a139
fix: Codex HOLD findings on PR #1302 (kv-ready pending direction, cyc…
anandgupta42 Sep 11, 2026
ce0d3af
fix: scope the shouldSkipOnboardingAtStartup discriminator to a genui…
anandgupta42 Sep 11, 2026
cb9e4d7
fix: Codex re-review round 8 (cycleOrder staleness, ready-pending tes…
anandgupta42 Sep 11, 2026
170f8af
fix: wrap createDeferredRetry in altimate_change markers
anandgupta42 Sep 11, 2026
9315dd5
fix: latch first-run only after a successful prompt-gate dialog.repla…
anandgupta42 Sep 11, 2026
d4dfb8a
fix: Codex re-review round 9 (onboardingReady() latch, deferred-submi…
anandgupta42 Sep 11, 2026
2fc58ac
fix: don't discard the typed prompt when the prompt-gate picker fails…
anandgupta42 Sep 11, 2026
fdae60d
fix: fold cycle-stability tests into one, isolate state dir from real…
anandgupta42 Sep 11, 2026
55c2705
fix: snapshot the deferred-submit revision serialized; share the onbo…
anandgupta42 Sep 11, 2026
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
1 change: 1 addition & 0 deletions docs/docs/configure/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ registration request; **No** is selected by default. After registration, the mod
explicit model is selected. Big Pickle is retired as a new selection — it no longer appears in the
picker or the full model catalog for users choosing a model for the first time. Users already on
Big Pickle are still detected on launch and offered Altimate Base through the same consent gate.
If you decline the default switch, `declinedManagedBaseDefault: true` in the state directory's `model.json` keeps public Zen ahead of registered Base for headless and ACP defaults, with Base used only as a last resort; accepting migration or explicitly selecting Base clears the flag.

Official release binaries embed the current gateway endpoint at build time. Operators and local
development can override it without changing code:
Expand Down
4 changes: 2 additions & 2 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,8 @@ We collect the following categories of events:
| `onboarding_started` | The first-run setup gate opened (fresh launch with no usable model). |
| `model_picker_shown` | The provider picker was displayed. `trigger` distinguishes the first run from `/connect`, from declining Altimate Base, and from the prompt gate. |
| `provider_selected` | A provider row was chosen — `altimate_gateway`, `altimate_base`, `anthropic`, `openai`, `google`, `search_all`, or `other` for anything outside the curated five. `provider_id` carries the raw id only for publicly-known providers, so a provider you named yourself in config is reported as `other` with no name attached. `via_search` marks a pick made inside the full catalogue after choosing "Search all providers…". **Choosing search emits this event twice for one user** — once as `search_all`, then again with the provider actually chosen — so count distinct users or filter on `via_search`, not raw event count. Recorded at the moment of choice, so a sign-in that is then cancelled still counts. |
| `altimate_base_confirm_shown` / `altimate_base_choice` | The Altimate Base disclosure was shown (`welcome` or `model` origin), and what the user decided (`accept`/`cancel`). |
| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. No credential or gateway response body is included. |
| `altimate_base_confirm_shown` / `altimate_base_choice` | The Altimate Base disclosure was shown, and what the user decided (`accept`/`cancel`). `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base on launch); required for the disclosure event and optional for the choice event. |
| `altimate_base_register_result` | The consented registration outcome: `success`, `rate_limited`, `unavailable`, `network`, or `error`. Optional `origin` is `welcome`, `model`, or `migration` (returning free-default users offered Altimate Base on launch). No credential or gateway response body is included. |
| `gateway_device_code_issued` | The Altimate Gateway authorize URL was built and the browser open attempted. **Name note:** the flow is a browser loopback OAuth — there is no device code. The name follows the original event spec. |
| `gateway_auth_completed` / `gateway_auth_failed` | Gateway sign-in outcome. `reason` is `timeout`, `denied`, or `error` — never the underlying message, which can contain the instance name. An unrecognised callback state does not reject the pending attempt, so a CSRF mismatch surfaces as `timeout`. |
| `instance_connected` | Credentials received and saved. `time_to_connect_ms` runs from the start of the authorize call, so it includes the browser launch. No instance or tenant name is sent. |
Expand Down
25 changes: 23 additions & 2 deletions packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,34 @@ const paths = {
repos: path.join(data, "repos"),
cache,
config,
state,
// altimate_change start — cubic review (3986917361): unlike `home` above, `state` was a plain
// const with no test-isolation override, so any consumer reading `Global.Path.state` — or
// `Flock`'s lock directory, which is derived from it (see the `Flock.setGlobal` call below) —
// silently touched the REAL, current developer's state directory in tests. Mirror `home`'s
// pattern: a getter honoring `OPENCODE_TEST_STATE_HOME`, read fresh on every access.
get state() {
return process.env.OPENCODE_TEST_STATE_HOME ?? state

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When OPENCODE_TEST_STATE_HOME is set to an empty string, this getter returns an invalid empty path and startup fails in fs.mkdir(Path.state). Use the same truthy fallback as the opencode global implementation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/src/global.ts, line 39:

<comment>When `OPENCODE_TEST_STATE_HOME` is set to an empty string, this getter returns an invalid empty path and startup fails in `fs.mkdir(Path.state)`. Use the same truthy fallback as the opencode global implementation.</comment>

<file context>
@@ -30,13 +30,34 @@ const paths = {
+  // silently touched the REAL, current developer's state directory in tests. Mirror `home`'s
+  // pattern: a getter honoring `OPENCODE_TEST_STATE_HOME`, read fresh on every access.
+  get state() {
+    return process.env.OPENCODE_TEST_STATE_HOME ?? state
+  },
+  // altimate_change end
</file context>
Suggested change
return process.env.OPENCODE_TEST_STATE_HOME ?? state
return process.env.OPENCODE_TEST_STATE_HOME || state

},
// altimate_change end
tmp,
}

export const Path = paths

Flock.setGlobal({ state })
// altimate_change start — cubic review (3986917361): `Flock.setGlobal` used to be given the
// frozen `state` const directly, snapshotted once at this module's import time — even after
// adding the `OPENCODE_TEST_STATE_HOME` override to `Path.state` above, `Flock`'s own internal
// lock-directory resolution would still have kept using whatever `state` was BEFORE any test set
// that env var (module imports happen once, before a test's own `beforeAll`/mount code runs). A
// getter-backed property here means `Flock`'s `root()` — which just reads `global.state` as a
// plain property — re-evaluates `Path.state` fresh on every lock acquisition instead, so setting
// `OPENCODE_TEST_STATE_HOME` redirects BOTH `Global.Path.state` reads and `Flock`'s lock root.
Flock.setGlobal({
get state() {
return Path.state
},
})
// altimate_change end

await Promise.all([
fs.mkdir(Path.data, { recursive: true }),
Expand Down
14 changes: 14 additions & 0 deletions packages/opencode/src/acp/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export type Snapshot = {
readonly defaultModeID: string
readonly availableCommands: readonly Command.Info[]
readonly defaultModel?: DefaultModel
// altimate_change start — cache the project config that drives default-model selection, so the
// mutable model.json state (recents, decline flag) can be re-read at each selection instead of
// being frozen into the snapshot
readonly defaultModelConfig?: {
readonly model?: string
readonly provider?: Record<string, unknown>
}
// altimate_change end
}

export interface LoaderInterface {
Expand Down Expand Up @@ -61,6 +69,9 @@ export const build = (input: {
readonly defaultModeID: string
readonly commands: readonly Command.Info[]
readonly defaultModel?: DefaultModel
// altimate_change start — see `Snapshot.defaultModelConfig`
readonly defaultModelConfig?: Snapshot["defaultModelConfig"]
// altimate_change end
}): Snapshot => {
const modelOptions = Provider.sort(
Object.values(input.providers).flatMap((provider) =>
Expand Down Expand Up @@ -110,6 +121,9 @@ export const build = (input: {
: (input.modes[0]?.id ?? input.defaultModeID),
availableCommands: input.commands,
...(input.defaultModel ? { defaultModel: input.defaultModel } : {}),
// altimate_change start — see `Snapshot.defaultModelConfig`
...(input.defaultModelConfig ? { defaultModelConfig: input.defaultModelConfig } : {}),
// altimate_change end
}
}

Expand Down
108 changes: 70 additions & 38 deletions packages/opencode/src/acp/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -805,19 +805,6 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
// provider either, so it fails closed the same way an explicit allowlist without it does.
const snapshotProviders = configLoaded && !hasProviderAllowlist ? providers : withoutManagedBase()
// altimate_change end
const defaultModelStarted = performance.now()
// altimate_change start — resolve the default against the SAME filtered snapshot advertised to
// the client. Resolving against the unfiltered `providers` map let a project that sets
// `model: "altimate-free/altimate-base"` alongside any `provider` allowlist end up with a
// `defaultModel` pointing at a provider this snapshot had just excluded — ACP would still
// select and route the managed model even though it was hidden from `modelOptions`.
const defaultModel = defaultModelFromConfig(
config?.model,
snapshotProviders,
config?.provider as Record<string, unknown> | undefined,
)
// altimate_change end
ACPProfile.duration("acp.directory.defaultModel.resolve", defaultModelStarted, { configured: !!defaultModel })
const modes = agents
.filter((agent) => agent.mode !== "subagent" && agent.hidden !== true)
.map((agent) => ({
Expand Down Expand Up @@ -846,7 +833,10 @@ async function loadDirectorySnapshot(sdk: OpencodeClient, directory: string) {
modes,
defaultModeID: agents.find((agent) => agent.mode === "primary" && agent.hidden !== true)?.name ?? "build",
commands: commands.toSorted((a, b) => a.name.localeCompare(b.name)),
...(defaultModel ? { defaultModel } : {}),
// altimate_change start — cache project config, but resolve mutable model.json state at each
// default selection
defaultModelConfig: { model: config?.model, provider: config?.provider },
// altimate_change end
})
})
}
Expand All @@ -856,6 +846,10 @@ export function defaultModelFromConfig(
configuredModel: string | undefined,
providers: Record<ProviderV2.ID, Provider.Info>,
providerFilter?: Record<string, unknown>,
// altimate_change start — persisted recents and default-switch consent, normalized by the shared state reader
declinedManagedBaseDefault = false,
recent: Awaited<ReturnType<typeof Provider.readDefaultModelState>>["recent"] = [],
// altimate_change end
): Directory.DefaultModel | undefined {
// altimate_change start — fork Provider ids are branded ProviderID/ModelID; re-brand to core ProviderV2.ID/ModelV2.ID (identity at runtime)
const configured = configuredModel
Expand All @@ -869,6 +863,16 @@ export function defaultModelFromConfig(
const configuredProviderEntries = Object.keys(providerFilter ?? {})
const hasProviderAllowlist = configuredProviderEntries.length > 0

for (const entry of recent) {
const providerID = ProviderV2.ID.make(entry.providerID)
const modelID = ModelV2.ID.make(entry.modelID)
if (!Object.hasOwn(providers, providerID)) continue
if (!Object.hasOwn(providers[providerID].models, modelID)) continue
// Match Provider.defaultModel(): only managed Base recents are restricted by an allowlist.
if (entry.providerID === "altimate-free" && hasProviderAllowlist) continue
return { providerID, modelID }
}

// Prefer altimate-backend/altimate-default when the fork's backend is available and the user
// hasn't pinned a model — restores dropped fork behavior (the merge fell straight through to the
// opencode provider, routing ACP clients away from altimate's backend). Honors an explicit
Expand All @@ -882,11 +886,22 @@ export function defaultModelFromConfig(
return { providerID: ProviderV2.ID.make("altimate-backend"), modelID: ModelV2.ID.make("altimate-default") }
}

// First-session ACP startup must not scan historical sessions just to infer
// a default. Configured model, opencode provider, then sorted best model keep
// the protocol response deterministic without extra session/message reads.
// First-session ACP startup must not scan historical sessions just to infer a default.
// Recents above come from model.json, not session storage. After configured/recent choices
// and the backend preference, use the opencode provider, then the sorted best model,
// without extra session/message reads.
const baseProvider = providers[ProviderV2.ID.make("altimate-free")]
const registeredBaseAvailable = Boolean(baseProvider?.models[ModelV2.ID.make("altimate-base")]) && !hasProviderAllowlist
const providerAllowed = (id: string) =>
id !== "altimate-free" && (!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id))
id !== "altimate-free" &&
(!hasProviderAllowlist || Object.prototype.hasOwnProperty.call(providerFilter, id)) &&
!(
registeredBaseAvailable &&
!declinedManagedBaseDefault &&
id === "opencode" &&
providers[ProviderV2.ID.make(id)]?.options.apiKey === "public" &&
!providers[ProviderV2.ID.make(id)]?.key
)
const opencodeProvider = providerAllowed("opencode") ? providers[ProviderV2.ID.make("opencode")] : undefined
const opencodeModel = opencodeProvider
? Provider.sort(Object.values(opencodeProvider.models)).find((model) => model.id !== "big-pickle")
Expand All @@ -901,21 +916,41 @@ export function defaultModelFromConfig(
).find((model) => !(model.providerID === "opencode" && model.id === "big-pickle"))
if (best) return { providerID: ProviderV2.ID.make(best.providerID), modelID: ModelV2.ID.make(best.id) }

// Altimate Base replaces Big Pickle as the free fallback, but only as a LAST resort and only
// after the user consented and registered (which is why it is present in `providers`). Anything
// else connected outranks the request-logging tier. A project provider block cannot force the
// managed model; an explicit configured model above remains authoritative.
const baseProvider = providers[ProviderV2.ID.make("altimate-free")]
if (!hasProviderAllowlist && baseProvider?.models[ModelV2.ID.make("altimate-base")]) {
// Altimate Base replaces Big Pickle as the free fallback only after the user consented and
// registered (which is why it is present in `providers`). Anything the user actually connected
// outranks the request-logging tier, except the keyless public Zen tier, which ranks below
// registered Base unless the user declined the default switch in model.json. After a decline,
// public Zen stays in both scans and Base is only the last resort. A keyed Zen account still
// wins. A project provider block cannot force the managed model; an explicit configured model
// above remains authoritative.
if (registeredBaseAvailable) {
return { providerID: ProviderV2.ID.make("altimate-free"), modelID: ModelV2.ID.make("altimate-base") }
}
return undefined
// altimate_change end
}

// altimate_change start — keep Big Pickle explicitly selectable but never choose it implicitly
export function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModel) return snapshot.defaultModel
// altimate_change start — Big Pickle is never chosen by the implicit provider/model SCANS below
// (the `opencodeModel`/`best` fallbacks both exclude it) — but a persisted `recent` entry is the
// user's own past pick, so it is honored verbatim, including a legacy Big Pickle one (kilo review
// round 6, 3986171219: mirrors `Provider.defaultModel()`'s identical recents-loop rationale in
// provider.ts — the TUI owns the migration because it owns the disclosure, so rewriting it here
// would move a declining user to the request-logging tier with no prompt).
export async function selectDefaultModel(snapshot: Directory.Snapshot) {
if (snapshot.defaultModelConfig) {
const started = performance.now()
const { recent, declinedManagedBaseDefault } = await Provider.readDefaultModelState()
// Resolve against the filtered catalogue so an excluded managed provider cannot be selected.
const selected = defaultModelFromConfig(
snapshot.defaultModelConfig.model,
snapshot.providers,
snapshot.defaultModelConfig.provider,
declinedManagedBaseDefault,
recent,
)
ACPProfile.duration("acp.directory.defaultModel.resolve", started, { configured: !!selected })
if (selected) return selected
} else if (snapshot.defaultModel) return snapshot.defaultModel
// Big Pickle remains explicitly selectable for existing users, but Altimate Base replaces it as
// the free implicit choice. Do not silently route a new ACP session back to Big Pickle when it is
// the first (or only) sorted catalogue entry and no usable default was resolved above.
Expand All @@ -935,17 +970,14 @@ function availableModel(snapshot: Directory.Snapshot, model: Directory.DefaultMo
: undefined
}

function requireDefaultModel(snapshot: Directory.Snapshot) {
const selected = selectDefaultModel(snapshot)
return selected
? Effect.succeed(selected)
: Effect.fail(
new ACPError.ServiceFailureError({
safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.",
service: "model",
}),
)
}
const requireDefaultModel = Effect.fn("ACP.requireDefaultModel")(function* (snapshot: Directory.Snapshot) {
const selected = yield* request(() => selectDefaultModel(snapshot), "model")
if (selected) return selected
return yield* new ACPError.ServiceFailureError({
safeMessage: "No supported model is configured. Register Altimate Base or configure another provider.",
service: "model",
})
})
// altimate_change end

function detectSlashCommand(parts: ReturnType<typeof promptContentToParts>) {
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1027,18 +1027,20 @@ export namespace Telemetry {
type: "altimate_base_confirm_shown"
timestamp: number
session_id: string
origin: "welcome" | "model"
origin: "welcome" | "model" | "migration"
}
| {
type: "altimate_base_choice"
timestamp: number
session_id: string
origin?: "welcome" | "model" | "migration"
choice: "accept" | "cancel"
}
| {
type: "altimate_base_register_result"
timestamp: number
session_id: string
origin?: "welcome" | "model" | "migration"
result: "success" | "rate_limited" | "unavailable" | "network" | "error"
}
| {
Expand Down
14 changes: 13 additions & 1 deletion packages/opencode/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,19 @@ export namespace Global {
log: path.join(data, "log"),
cache,
config,
state,
// altimate_change start — cubic review round 5, P2: unlike `home` above, `state` was a
// plain module-load-time const with no test-isolation override, so any test reading or
// writing through `Global.Path.state` (recent-model / migration-decline persistence in
// `model.json`) was silently touching the REAL, current developer's state directory —
// racing any other test file doing the same thing in parallel, and risking clobbering real
// state if a test run were killed mid-write. Mirror `home`'s pattern with its own getter and
// env var so `Global.Path.state` can be redirected to a throwaway temp dir per test (see
// `test/fixture/fixture.ts`'s `withTestStateHome`), without changing production behavior —
// the getter is evaluated fresh on every access, and the env var is unset outside tests.
get state() {
return process.env.OPENCODE_TEST_STATE_HOME || state
},
// altimate_change end
}
}

Expand Down
Loading
Loading