Skip to content

Config hot reload#29

Closed
shuv1337 wants to merge 37 commits intodevfrom
config-hot-reload
Closed

Config hot reload#29
shuv1337 wants to merge 37 commits intodevfrom
config-hot-reload

Conversation

@shuv1337
Copy link
Collaborator

@shuv1337 shuv1337 commented Nov 21, 2025

Summary by CodeRabbit

  • New Features

    • Real-time config hot-reload with targeted invalidations and enriched save toasts
    • Scoped PATCH /config endpoint with atomic apply, backup/restore, and publish fan‑out
  • Improvements

    • Stronger validation and structured error responses for config updates
    • File locking and safer atomic writes that preserve JSONC comments
    • Remembered config-change metadata for clearer saved-config feedback
  • Documentation

    • Full spec for the config update endpoint
  • Tests

    • New tests for hot-reload, config writes/JSONC preservation, and plugin resolution

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Nov 21, 2025

Note

Other AI code review bot(s) detected

CodeRabbit has detected other AI code review bot(s) in this pull request and will avoid duplicating their findings in the review comments. This may lead to a less comprehensive review.

Walkthrough

This change migrates many modules to a named State.register lifecycle and adds a full, disk-driven config subsystem: atomic JSONC writes (incremental edits), locking, backup/restore, config diffing, hot-reload, targeted invalidation, state registry invalidate APIs, plus tests, server endpoints, and supporting utilities.

Changes

Cohort / File(s) Summary
State lifecycle migration
packages/opencode/src/agent/agent.ts, packages/opencode/src/command/index.ts, packages/opencode/src/file/watcher.ts, packages/opencode/src/format/index.ts, packages/opencode/src/lsp/index.ts, packages/opencode/src/mcp/index.ts, packages/opencode/src/permission/index.ts, packages/opencode/src/plugin/index.ts, packages/opencode/src/provider/provider.ts, packages/opencode/src/tool/registry.ts
Replace many Instance.state(...) usages with State.register(name, root, init, dispose). Preserve initialization logic but register named per-directory lifecycle entries and add explicit disposers/cleanup; agent config construction moved into the new state model (dynamic agent creation, defaults, permission merging).
Config core: persistence, write, lock, backup
packages/opencode/src/config/persist.ts, packages/opencode/src/config/write.ts, packages/opencode/src/config/backup.ts, packages/opencode/src/config/lock.ts
New atomic update pipeline: determine target file (project/global), acquire cross-process lock, create backup, merge/normalize/validate config, compute diffs, write atomically (JSONC incremental edits when possible), and restore on failures. Exposes update() and writeConfigFile / writeFileAtomically, plus createBackup/restoreBackup and acquireLock.
Config support: diff, errors, global file, hot-reload flag
packages/opencode/src/config/diff.ts, packages/opencode/src/config/error.ts, packages/opencode/src/config/global-file.ts, packages/opencode/src/config/hot-reload.ts
Add ConfigDiff computation, typed NamedError types for config operations, resolver for global config file path, and helper to detect if hot-reload is enabled.
Config invalidation/orchestration
packages/opencode/src/config/invalidation.ts
New invalidation subsystem that applies diffs to selective invalidators (provider, mcp, lsp, watcher, plugin, tool-registry, permission, command/agent/formatter, UI), runs targeted invalidations in parallel, and wires hot-reload subscription.
Config module and API changes
packages/opencode/src/config/config.ts
Migrate config state loading to State.register with disk-driven loader, add readFreshConfig(), replace global lazy with global() helper, change update() to delegate to persist and return { before, after, diff, diffForPublish, filepath }, and add Event.Updated. Includes plugin path resolution helpers.
Project state registry and instance APIs
packages/opencode/src/project/state.ts, packages/opencode/src/project/instance.ts
Add State.register wrapper and namedRegistry tracking; implement State.invalidate(name, key?) with wildcard support; add Instance.invalidate(name) and Instance.forEach(fn) helpers for batch operations over directory contexts.
Server and bootstrap integration
packages/opencode/src/project/bootstrap.ts, packages/opencode/src/server/server.ts
Wire ConfigInvalidation.setup() into bootstrap; extend PATCH /config to use Config.update, compute/publish diffs (hot-reload), remember touched sections for UI toast enrichment, and fan-out global notifications.
File system loader refactor
packages/opencode/src/file/index.ts
Refactor file entries loading to fetchEntries()/refresh() with temp aggregation and safer cache replacement; defensive checks to avoid unnecessary crawling.
Tooling & plugin lifecycle
packages/opencode/src/plugin/index.ts, packages/opencode/src/tool/registry.ts
Encapsulate plugin and tool registry lifecycles under State.register, add cleanup/dispose semantics for plugin hooks and tool discovery.
Tests and spec
packages/opencode/test/config/hot-reload.test.ts, packages/opencode/test/config/write.test.ts, packages/opencode/test/config/config.test.ts, specs/config-spec.md
Add comprehensive hot-reload and write tests, extend config tests for plugin path resolution, and add PATCH /config spec doc describing merge, locking, backup, hot-reload and events.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Server as API
    participant Persist as Persist Module
    participant Lock as Lock Service
    participant Write as Writer
    participant Bus as EventBus
    participant Inval as Invalidation

    Client->>Server: PATCH /config (scope, update)
    Server->>Persist: update(input)
    Persist->>Lock: acquireLock(filepath)
    Lock-->>Persist: release()
    Persist->>Persist: validate & merge -> computeDiff(before, after)
    Persist->>Write: writeConfigFile(filepath, newConfig, existingContent)
    alt write fails
        Persist->>Persist: restore backup
    end
    Persist->>Bus: publish Config.Updated(scope,directory,diff)
    Bus->>Inval: apply(diff, scope, directory)
    par parallel invalidations
        Inval->>Inval: invalidate provider/mcp/lsp/plugins/tools/...
    end
    Bus-->>Server: published
    Server-->>Client: 200 {before, after, diff, filepath}
Loading
sequenceDiagram
    participant Module
    participant State as State Registry

    Module->>State: register(name, root, init, dispose)
    State->>State: record entry keyed by root
    Module->>Module: use state value
    Module->>State: invalidate(name, key?)
    State->>State: find entries, call dispose for matches
    State-->>Module: entries removed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Areas requiring extra attention:

  • packages/opencode/src/config/persist.ts and packages/opencode/src/config/write.ts — atomic write, JSONC incremental edits, diff logic, and error/restore flows.
  • packages/opencode/src/config/lock.ts — cross-process lock semantics, stale lock cleanup, and timeout behavior.
  • packages/opencode/src/config/invalidation.ts — correctness of diff-to-invalidator mapping and parallel execution/order with events.
  • packages/opencode/src/project/state.ts and usages — registry/invalidate semantics and dispose correctness across many subsystems (LSP, MCP, plugins, filewatcher).
  • packages/opencode/src/agent/agent.ts — new dynamic per-agent config assembly, permission merging, and defaults propagation.

Possibly related PRs

  • kcrommett/opencode#30 — modifies project/state.ts internals (register/invalidate lifecycle and disposal behavior); strongly related to the State.register/invalidate changes in this PR.

Poem

🐰
I hopped through states and stitched each burrow tight,
Backups in my pouch and locks to keep the night.
JSONC leaves rustle as edits softly land,
Hot-reload hums — I tidy every strand.
Hop on, the garden’s safer by my hand.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.45% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Config hot reload' directly reflects the main feature introduced: a comprehensive hot-reload system for configuration updates with file locking, backup handling, and event propagation.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch config-hot-reload

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +110 to +114
const hotReloadEnabled = isConfigHotReloadEnabled()
if (hotReloadEnabled && input.scope === "global") {
await State.invalidate("config")
}
if (hotReloadEnabled && input.scope === "project") {

Choose a reason for hiding this comment

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

P1 Badge Config updates no longer invalidate cached state by default

The cache invalidation now runs only when OPENCODE_CONFIG_HOT_RELOAD is true. Because that flag defaults to false, the new code writes the config file but leaves the cached Config state (and dependent provider/agent/tool state) untouched, so subsequent Config.get() calls and running services continue to use the old config until a manual restart/dispose. The previous implementation always disposed the instance after an update, so this change makes config edits appear to succeed while having no effect in the default environment.

Useful? React with 👍 / 👎.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (28)
packages/opencode/test/config/config.test.ts (2)

271-297: Good coverage for mixed scoped + relative plugin resolution

This test nicely verifies:

  • Scoped plugin identifiers remain intact.
  • Relative plugin paths are normalized to file:// URLs (matching pathToFileURL behavior).

You might optionally also assert on the exact length/order of config.plugin for even tighter regression coverage, but it’s not strictly necessary.


418-421: Config.update filepath assertion covers new API shape

Using result.filepath to read back and assert on the written config validates the new return contract. Consider also asserting a couple of other fields from result (e.g., diff or scope) in a separate test to lock down the richer payload, but the current check is functionally sufficient.

packages/opencode/src/config/diff.ts (1)

28-43: Key‑change computation is correct; set‑based variant is a future micro‑opt

computeKeysChanged correctly identifies added, removed, and modified keys, and gracefully handles undefined inputs. If provider/mcp maps ever grow large, you could switch from includes to Set membership to avoid O(n²) behavior, but that’s likely premature here.

packages/opencode/test/config/hot-reload.test.ts (3)

13-24: Global mutations make tests dependent on serial execution

These tests mutate Global.Path.config, Instance.invalidate, and process.env.OPENCODE_CONFIG_HOT_RELOAD. If the test runner ever executes them in parallel, they will race on this shared state and may become flaky. Consider marking this suite as serial or centralizing these mutations behind a single test harness that guarantees exclusive access.

Also applies to: 84-91, 93-368


57-72: Clarify subscribeWithContext fallback directory behavior

subscribeWithContext falls back to process.cwd() when event.properties.directory is absent. That may not correspond to any initialized Instance and could be surprising if an event is emitted without a directory. If that case is expected, consider documenting it in the tests; otherwise, it might be safer to ignore such events or fail fast rather than binding them to process.cwd().

Also applies to: 264-295


240-262: Test name doesn’t match behavior

The "event subscriber sees refreshed config before targeted invalidations" test currently only patches the config and asserts Config.get() returns the updated model, without exercising any subscriber or invalidation ordering. Either rename the test to reflect what it actually asserts or extend it to include a subscription and explicit ordering checks.

packages/opencode/src/project/bootstrap.ts (1)

13-18: ConfigInvalidation setup ordering is good; ensure it’s idempotent

Running ConfigInvalidation.setup() at the top of InstanceBootstrap is a good way to guarantee invalidation wiring is in place before plugins, LSP, and watchers initialize. Since InstanceBootstrap may run for multiple directories, make sure setup() is idempotent (e.g., guards against double subscriptions) so repeated calls don’t cause duplicate listeners or leaks.

packages/opencode/src/mcp/index.ts (2)

96-121: Clarify semantics for disabled MCP configs and add() return shape

When mcp.enabled === false, create() returns undefined, and add() records a "failed" / "unknown error" status and returns only that status object, whereas other branches return the full s.status map. Given the Status union includes a "disabled" variant, it may be clearer to:

  • Treat disabled configs as "disabled" rather than "failed" with a generic error; and
  • Make add() consistently return the full status map (or consistently return just the updated status), to avoid surprising callers.

Also applies to: 123-221


267-291: Minor nit: reuse in‑memory state in tools()

In tools(), you already have const s = await state(), but then fetch clientsSnapshot via another state() call through clients(). You can instead use s.clients directly to avoid a second lookup and keep everything on the same snapshot, though this is purely a minor cleanup.

packages/opencode/src/permission/index.ts (1)

7-85: Permission state migration to State.register looks solid

The migration to State.register("permission", () => Instance.directory, ...) plus the disposer that rejects all pending requests preserves behavior and adds clean lifecycle handling. If you want to be extra tidy, you could also clear state.pending/state.approved inside the disposer after rejecting to help GC, but it’s not required.

specs/config-spec.md (1)

1-100: Spec matches implementation and is very clear

The PATCH /config spec lines up well with the current server behavior (scope handling, hot-reload flag, event fan-out, backup/restore semantics). If you want to go further, you could add a short note that some 500s may originate from post-persist notification failures (e.g. global publish) in addition to low-level write/validation errors, but that’s optional.

packages/opencode/src/file/index.ts (1)

122-165: Non-blocking refresh() may return empty/stale results; consider awaiting the active scan

The new pattern (refresh() kicking off fetchEntries() without awaiting, and files() returning cache immediately) means:

  • On first use, File.search() can see an empty { files: [], dirs: [] } while the initial ripgrep scan is still running.
  • Subsequent calls trigger a new full refresh() whenever fetching is false, so frequent lookups can schedule repeated scans.

If that behavior is intentional (eventual consistency, fast responses), then this is fine. If you’d prefer callers to always see a fully-populated view, a small refactor would help:

  • Track a shared fetchPromise instead of just fetching, and have refresh() return it.
  • In files(), await refresh() before returning cache, so the first call waits for the scan and later calls get a fresh snapshot.

That would also give you more control over how often you rescan (e.g., only on explicit invalidation).

Also applies to: 159-161

packages/opencode/src/project/instance.ts (1)

51-75: Harden Instance.forEach against rejected context promises

invalidate looks good. In forEach, you already collect errors from context.provide(...).catch(...), but a rejection of contextPromise itself (e.g., a failed Project.fromDirectory) will:

  • Throw out of the loop,
  • Prevent remaining directories from being processed,
  • Bypass your errors aggregation.

To make forEach consistently robust (and match disposeAll), consider:

for (const [directory, contextPromise] of cache) {
  const ctx = await contextPromise.catch((error) => {
    errors.push({
      directory,
      error: error instanceof Error ? error : new Error(String(error)),
    })
    return undefined
  })
  if (!ctx) continue
  await context
    .provide(ctx, async () => {
      await fn(directory)
    })
    .catch((error) => {
      errors.push({
        directory,
        error: error instanceof Error ? error : new Error(String(error)),
      })
    })
}

This keeps forEach’s contract (“return per-directory errors”) even when initialization fails.

packages/opencode/src/server/server.ts (2)

82-100: Config-update cache for toast enrichment is reasonable; consider interval cleanup for tests

The LastConfigUpdate map plus the 60s cleanup interval is a straightforward way to enrich later toasts without unbounded growth. For a long-lived server process this is fine. If this module ever gets imported in short-lived test runners without starting the server, you might eventually want to keep the interval handle and expose a way to clear it in tests, but that’s a nice-to-have.


252-303: /config PATCH hot-reload + global publish flow looks consistent

The handler now:

  • Honors scope=project|global with a default of project.
  • Delegates to Config.update({ scope, update, directory }) and uses diffForPublish to derive sections.
  • Gates Config.Event.Updated publishing on isConfigHotReloadEnabled(), with per-directory fan-out via Instance.forEach for global scope.
  • Records sections in LastConfigUpdate for both project and global cases.

This matches the spec’s behavior (hot-reload flag controls cache invalidation + events; global scope broadcasts to all tracked directories). One behavior choice to be aware of: when global publish fails for some directories, the config file has still been updated, but the endpoint returns a 500 with an aggregated error message. That trade-off (persistence succeeds, fan-out failure surfaces as a hard error) seems acceptable, just worth keeping in mind for clients interpreting a 500 as “nothing changed”.

packages/opencode/src/plugin/index.ts (1)

9-9: Plugin lifecycle integration with State.register looks solid

The plugin state is now correctly keyed by Instance.directory and registered under the "plugin" namespace, and the dispose callback will be invoked via State.invalidate with the resolved state object, so hooks + input lifecycle should behave as intended. The init path still lazily loads plugins and accumulates hooks, and subsequent calls re-use the memoized state.

One optional improvement: plugin cleanup currently runs sequentially; a single slow or hanging plugin cleanup() will block the rest. If you care about resilience over strict ordering, consider running them in parallel with Promise.all while still logging per-hook failures.

Also applies to: 15-67

packages/opencode/src/project/state.ts (1)

9-17: State.register / invalidate design is sound; minor doc/pattern nit

The new named registry + State.register / State.invalidate flow is consistent:

  • register wraps create, records (name, key = root(), init, dispose) the first time a given (name, key, init) is accessed, and returns a getter that continues to use the same underlying create memoization.
  • invalidate(name, key?) correctly:
    • Expands name:* into per-registered names and recurses, and
    • For concrete name, finds matching entries, looks up corresponding Entry in recordsByKey, awaits the stored state and runs dispose(state) if present, then removes just that init from the per-key map.
  • dispose(key) now deletes the entire recordsByKey bucket for that key, which is appropriate for full instance teardown; any subsequent access will lazily recreate state.

The only real nit is in the wildcard comment vs implementation:

  • For "cache:*", pattern is computed as name.slice(0, -1)"cache:", while the docstring says “prefix (before the :*)”, which would be "cache". Given you check registeredName.startsWith(pattern), "cache:" is probably what you want for names like "cache:users", but the comment can be misleading. Either adjust the comment or change the slice to slice(0, -2) to match the documented behavior.

Otherwise, the lifecycle + invalidation mechanics look correct.

Also applies to: 38-67, 69-137, 167-167

packages/opencode/src/format/index.ts (1)

27-60: Config overrides force formatter enabled() to true, potentially bypassing built‑in checks

In the state initializer, any formatter mentioned in cfg.formatter gets result.enabled = async () => true, even when merging over an existing built‑in formatter:

const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, { ...item })
...
result.enabled = async () => true

If default formatters implement enabled() with capability checks (e.g., probing whether a binary is available), this change means partial overrides (like just changing command or extensions) will now always report enabled, even when those checks would fail.

Consider preserving the original enabled for existing formatters and only defaulting to async () => true for new formatters that come purely from config, e.g.:

const base = formatters[name]
const result: Formatter.Info = mergeDeep(base ?? {}, {
  command: [],
  extensions: [],
  ...item,
})

if (result.command.length === 0) continue

result.name = name
if (!base?.enabled) {
  result.enabled = async () => true
}
formatters[name] = result

This keeps config overrides flexible without accidentally disabling useful health checks for built‑ins.

packages/opencode/src/provider/provider.ts (1)

242-489: Provider state init is solid; a few robustness tweaks would help

The new State.register("provider", ...) flow looks consistent and keeps responsibilities well‑scoped. A couple of small improvements could make it more robust:

  1. Env key resolution only checks the first variable
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue

If any provider has multiple possible env var names, this ignores all but the first. Using the first defined value would be safer:

const apiKey = provider.env.map((item) => process.env[item]).find(Boolean)
if (!apiKey) continue
  1. Avoid duplicate Config.get() calls

You already have const config = await Config.get() at the top; later you recompute:

const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))

You can reuse config instead:

const disabled = new Set(config.disabled_providers ?? [])

This reduces I/O and avoids subtle inconsistencies if Config.get() ever stops being pure.

  1. Defensive guard for custom loaders

Custom loaders assume a ModelsDev.Provider:

for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
  ...
  const result = await fn(database[providerID])

If database[providerID] is ever missing (e.g., models.dev changes), this will pass undefined and likely throw deep inside the loader. A cheap guard avoids surprising crashes:

const base = database[providerID]
if (!base) continue
const result = await fn(base)

None of these change the intended behavior but they harden the initialization path and reduce chances of edge‑case failures.

packages/opencode/src/config/invalidation.ts (1)

58-133: diff.tools is logged but never triggers any invalidation

applyInternal builds sections from all boolean flags:

const sections = Object.keys(diff).filter((k) => diff[k as keyof ConfigDiff] === true)

but only some flags are wired to actual invalidation tasks. Notably, ConfigDiff.tools is never used:

  • No toolsChanged computed.
  • No corresponding Instance.invalidate(...) call.

If the tools section of config influences any cached state (e.g., tool registry or permissions around tools), those caches will not be refreshed on hot‑reload; only the sections log will mention "tools".

If tools is expected to affect runtime behavior, consider adding something like:

const toolsChanged = diff.tools
if (toolsChanged) {
  targets.add("tool-registry")
  tasks.push(invalidateToolRegistry())
}

(or another appropriate target) so that toggling tools via config gets picked up without a restart. If tools is intentionally read purely on demand, it may be worth documenting that here in a comment for future maintainers.

packages/opencode/src/agent/agent.ts (1)

226-263: make mergeAgentPermissions pure and tighten its typing

mergeAgentPermissions currently:

  • Mutates its arguments (basePermission.bash / overridePermission.bash), which can unexpectedly normalize cfg.permission in place.
  • Accepts any for both parameters, even though the shapes are known (Config.Info["permission"] / Agent.Info["permission"] / partials).

To make this helper easier to reason about and safer to reuse, consider:

  • Guarding with optional chaining instead of assuming objects:
if (typeof basePermission?.bash === "string") { ... }
if (typeof overridePermission?.bash === "string") { ... }
  • Cloning before normalization to avoid side effects:
const base = basePermission ? { ...basePermission } : {}
const override = overridePermission ? { ...overridePermission } : {}
  • Typing the parameters as Partial<Config.Info["permission"]> (or a shared permission type) instead of any.

These changes wouldn’t alter behavior but would make permission handling more robust.

packages/opencode/src/config/write.ts (1)

25-49: Clarify .json vs .jsonc handling in writeConfigFile

isJsonc is currently:

const isJsonc = filepath.endsWith(".jsonc") || filepath.endsWith(".json")

so existing .json files go through the JSONC incremental update + validation path as well. That’s technically fine (JSON ⊂ JSONC), but:

  • It differs from the usual expectation that only .jsonc preserves comments/formatting via incremental edits.
  • The variable name suggests only .jsonc should take that path.

If the intent is to patch both .json and .jsonc, consider renaming isJsonc to something like supportsJsoncEdits for clarity. If the intent is to only patch .jsonc, drop the .json branch here so .json always gets a full rewrite.

packages/opencode/src/config/persist.ts (2)

51-59: Consider using nullish coalescing for $schema in normalizeConfig

Currently:

return {
  $schema: config.$schema || "https://opencode.ai/schema/config.json",
  ...config,
  agent: config.agent || {},
  mode: config.mode || {},
  plugin: config.plugin || [],
}

Because ...config comes after $schema, any explicitly set $schema value (including "") will overwrite the default. If you want to treat an empty string as “missing” but still prefer explicit non-empty values, a slightly clearer pattern is:

return {
  ...config,
  $schema: config.$schema ?? "https://opencode.ai/schema/config.json",
  agent: config.agent ?? {},
  mode: config.mode ?? {},
  plugin: config.plugin ?? [],
}

This is mostly a readability/intent clarification rather than a functional change for typical JSON inputs (where undefined fields are omitted entirely).


110-124: cacheInvalidated log field doesn’t reflect project‑scope invalidation

In update, you correctly invalidate caches for both scopes:

if (hotReloadEnabled && input.scope === "global") {
  await State.invalidate("config")
}
if (hotReloadEnabled && input.scope === "project") {
  await Instance.invalidate("config")
}

but the log payload reports:

cacheInvalidated: hotReloadEnabled && input.scope === "global",

So project‑scope updates with hot reload enabled still log cacheInvalidated: false, which can be misleading when debugging. Consider either:

  • Logging cacheInvalidated: hotReloadEnabled (since you always invalidate for the active scope), or
  • Introducing a more explicit field like invalidateScope: input.scope and/or projectCacheInvalidated/globalCacheInvalidated.

This is observability-only; runtime behavior is otherwise correct.

packages/opencode/src/config/config.ts (4)

27-41: Plugin path normalization is thoughtful; confirm intended heuristics

isPathLikePluginSpecifier + resolvePluginFileReference nicely handle:

  • file:// URLs (no-op),
  • POSIX relative (./, ../) and home (~/) paths,
  • Windows‑style relative prefixes (.\\, ..\\, ~\\),
  • Absolute paths via path.isAbsolute.

In load(), only values classified as “path‑like” are rewritten to canonical file:// URLs; others are left as raw specifiers:

if (data.plugin?.length) {
  for (let i = 0; i < data.plugin.length; i++) {
    const plugin = data.plugin[i]
    if (isPathLikePluginSpecifier(plugin)) {
      data.plugin[i] = resolvePluginFileReference(plugin, configFilepath)
    }
  }
}

This means entries like "./plugin/foo.js" or "C:\\path\\plugin.js" will be resolved to file:// URLs, but a bare "plugin/foo.js" will be treated as a package/module ID, not a file path.

If you want any value containing a path separator to be treated as path‑like, you might extend isPathLikePluginSpecifier accordingly; otherwise, documenting this distinction in user‑facing docs would help avoid confusion.

Also applies to: 833-838


89-195: Config aggregation and plugin discovery logic look good; small duplication in autoshare handling

The refactored loadStateFromDisk:

  • Starts from global() and merges per‑file configs discovered via Filesystem.findUp.
  • Collects .opencode directories (plus Global.Path.config and OPENCODE_CONFIG_DIR) and merges JSON config, markdown commands/agents/modes, and plugin .js/.ts files from each directory.
  • Accumulates discovered plugin files into pluginFiles and appends them to result.plugin after all scanning.

This composition is consistent and gives a clear precedence order.

Minor nit: the autoshare → share mapping appears twice with identical conditions:

if (result.autoshare === true && !result.share) {
  result.share = "auto"
}

if (result.autoshare === true && !result.share) {
  result.share = "auto"
}

You can safely drop one of these blocks to avoid redundant checks.


716-742: Global config loader correctly adds .jsonc support; check $schema URL consistency

loadGlobalConfig now includes:

const globalFile = await resolveGlobalFile()

let result: Info = pipe(
  {},
  mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
  mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
  mergeDeep(await loadFile(globalFile)),
)

which sensibly adds opencode.jsonc as the preferred global config file while still honoring legacy config.json / opencode.json.

Later, when migrating from TOML, it sets:

result["$schema"] = "https://opencode.ai/config.json"

Elsewhere (e.g., normalizeConfig), the default $schema is "https://opencode.ai/schema/config.json". If consumers or tooling rely on a single canonical schema URL, it might be worth unifying these strings; if the difference is intentional (e.g., legacy vs current schema), consider a brief comment to document that distinction.


75-87: Ensure Config.Event.Updated is emitted somewhere in the update path

You’ve defined a useful Config.Event.Updated Bus event:

export const Event = {
  Updated: Bus.event(
    "config.updated",
    z.object({ scope, directory, refreshed, before, after, diff }),
  ),
}

but in the code shown here and in persist.update, I don’t see a corresponding Event.Updated.emit(...) call. If emitting this event is part of the hot‑reload/invalidation story, consider wiring it into the end of persist.update (using diffForPublish) or into this Config.update wrapper so subscribers actually receive notifications.

If event emission is handled elsewhere in the PR, feel free to ignore this; just worth double‑checking that this new event type doesn’t remain unused.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 62a8131 and 01133dd.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (29)
  • packages/opencode/src/agent/agent.ts (2 hunks)
  • packages/opencode/src/command/index.ts (2 hunks)
  • packages/opencode/src/config/backup.ts (1 hunks)
  • packages/opencode/src/config/config.ts (9 hunks)
  • packages/opencode/src/config/diff.ts (1 hunks)
  • packages/opencode/src/config/error.ts (1 hunks)
  • packages/opencode/src/config/global-file.ts (1 hunks)
  • packages/opencode/src/config/hot-reload.ts (1 hunks)
  • packages/opencode/src/config/invalidation.ts (1 hunks)
  • packages/opencode/src/config/lock.ts (1 hunks)
  • packages/opencode/src/config/persist.ts (1 hunks)
  • packages/opencode/src/config/write.ts (1 hunks)
  • packages/opencode/src/file/index.ts (2 hunks)
  • packages/opencode/src/file/watcher.ts (2 hunks)
  • packages/opencode/src/format/index.ts (2 hunks)
  • packages/opencode/src/lsp/index.ts (2 hunks)
  • packages/opencode/src/mcp/index.ts (2 hunks)
  • packages/opencode/src/permission/index.ts (2 hunks)
  • packages/opencode/src/plugin/index.ts (1 hunks)
  • packages/opencode/src/project/bootstrap.ts (1 hunks)
  • packages/opencode/src/project/instance.ts (1 hunks)
  • packages/opencode/src/project/state.ts (3 hunks)
  • packages/opencode/src/provider/provider.ts (2 hunks)
  • packages/opencode/src/server/server.ts (4 hunks)
  • packages/opencode/src/tool/registry.ts (2 hunks)
  • packages/opencode/test/config/config.test.ts (4 hunks)
  • packages/opencode/test/config/hot-reload.test.ts (1 hunks)
  • packages/opencode/test/config/write.test.ts (1 hunks)
  • specs/config-spec.md (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (23)
packages/opencode/src/mcp/index.ts (3)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/test/config/config.test.ts (2)
packages/opencode/test/fixture/fixture.ts (1)
  • tmpdir (11-28)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-92)
packages/opencode/src/format/index.ts (4)
packages/opencode/src/config/config.ts (3)
  • state (197-197)
  • Info (527-712)
  • Info (714-714)
packages/opencode/src/tool/registry.ts (2)
  • state (27-58)
  • enabled (126-145)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/format/formatter.ts (14)
  • enabled (18-20)
  • enabled (27-29)
  • enabled (66-74)
  • enabled (111-120)
  • enabled (127-129)
  • enabled (136-139)
  • enabled (146-148)
  • enabled (155-178)
  • enabled (185-205)
  • enabled (212-220)
  • enabled (227-229)
  • enabled (236-238)
  • enabled (245-247)
  • Info (6-12)
packages/opencode/src/provider/provider.ts (3)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/agent/agent.ts (3)
packages/opencode/src/config/config.ts (3)
  • state (197-197)
  • Info (527-712)
  • Info (714-714)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/file/index.ts (2)
packages/opencode/src/util/log.ts (1)
  • error (135-139)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-92)
packages/opencode/src/config/diff.ts (1)
packages/opencode/src/config/config.ts (2)
  • Info (527-712)
  • Info (714-714)
packages/opencode/src/config/invalidation.ts (3)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-92)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/config/backup.ts (1)
packages/opencode/script/postinstall.mjs (1)
  • targetPath (73-73)
packages/opencode/src/permission/index.ts (4)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/file/time.ts (1)
  • state (6-15)
packages/opencode/src/config/write.ts (2)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
packages/opencode/src/config/config.ts (2)
  • Info (527-712)
  • Info (714-714)
packages/opencode/src/tool/registry.ts (2)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/project/bootstrap.ts (1)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-92)
packages/opencode/src/lsp/index.ts (4)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/file/time.ts (1)
  • state (6-15)
packages/opencode/src/plugin/index.ts (3)
packages/plugin/src/index.ts (2)
  • Plugin (27-27)
  • PluginInput (19-25)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/project/state.ts (4)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (2)
  • state (27-58)
  • register (78-86)
packages/opencode/src/project/instance.ts (3)
  • state (48-50)
  • dispose (76-79)
  • invalidate (51-53)
packages/opencode/src/file/time.ts (1)
  • state (6-15)
packages/opencode/src/file/watcher.ts (4)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/src/file/time.ts (1)
  • state (6-15)
packages/opencode/src/command/index.ts (3)
packages/opencode/src/config/config.ts (3)
  • state (197-197)
  • Info (527-712)
  • Info (714-714)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-92)
packages/opencode/test/config/write.test.ts (1)
packages/opencode/src/config/write.ts (1)
  • writeConfigFile (25-49)
packages/opencode/src/config/persist.ts (9)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-92)
packages/opencode/src/config/global-file.ts (1)
  • resolveGlobalFile (5-8)
packages/opencode/src/config/config.ts (3)
  • Info (527-712)
  • Info (714-714)
  • update (879-895)
packages/opencode/src/config/diff.ts (2)
  • ConfigDiff (4-26)
  • computeDiff (45-123)
packages/opencode/src/config/lock.ts (1)
  • acquireLock (90-133)
packages/opencode/src/config/backup.ts (2)
  • createBackup (3-12)
  • restoreBackup (14-17)
packages/opencode/src/config/write.ts (2)
  • writeConfigFile (25-49)
  • writeFileAtomically (265-279)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/config/error.ts (3)
  • ConfigWriteError (38-45)
  • ConfigValidationError (14-27)
  • ConfigUpdateError (4-12)
packages/opencode/test/config/hot-reload.test.ts (2)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-92)
packages/opencode/src/project/bootstrap.ts (1)
  • InstanceBootstrap (15-30)
packages/opencode/src/server/server.ts (3)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-92)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/cli/cmd/tui/event.ts (1)
  • TuiEvent (4-39)
packages/opencode/src/config/config.ts (3)
packages/opencode/src/config/global-file.ts (1)
  • resolveGlobalFile (5-8)
packages/opencode/src/config/persist.ts (1)
  • update (61-183)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
🔇 Additional comments (24)
packages/opencode/src/config/hot-reload.ts (1)

1-3: Env flag helper is clear and explicit

isConfigHotReloadEnabled does a simple, predictable string check against "true", which is easy to reason about and test.

packages/opencode/src/config/global-file.ts (1)

5-7: Global config path resolution looks correct

Ensuring Global.Path.config exists via fs.mkdir(..., { recursive: true }) before returning opencode.jsonc is appropriate for global config persistence.

packages/opencode/test/config/config.test.ts (5)

4-4: Global import is appropriate for test-only path overrides

Importing Global here is warranted for the later test that temporarily overrides Global.Path.config and restores it in a finally block.


10-26: Env flag helper is robust with proper restoration

withHotReloadFlag safely snapshots and restores process.env.OPENCODE_CONFIG_HOT_RELOAD in a finally block, which is important for keeping tests isolated.


28-61: Scoped plugin fixture is well‑structured

The fixture sets up a realistic scoped plugin under node_modules with minimal surface area and a corresponding opencode.json, which is a good basis for the later plugin tests.


437-449: Hot‑reload enabled test correctly guards against rewriting scoped plugins

This test ensures that enabling hot reload via withHotReloadFlag("true", ...) does not rewrite scoped npm plugin specifiers, which is an important invariant for plugin resolution behavior.


451-463: Complementary coverage for hot‑reload disabled behavior

This test confirms that scoped plugin identifiers are preserved when hot reload is effectively disabled (env var unset), giving symmetric coverage with the enabled case.

packages/opencode/src/config/diff.ts (2)

4-26: ConfigDiff surface is coherent and focused

The ConfigDiff interface cleanly scopes diff signals to just the sections you care about (including key‑level details for provider/mcp and plugin add/remove lists), which should make downstream invalidation logic straightforward.


45-123: Diff computation covers all relevant config facets with sensible semantics

  • Uses isDeepEqual for structured sections (provider/mcp/lsp/formatter/watcher/etc.), and simple !== for scalar fields like share, theme, model, which is appropriate.
  • Plugin diffs include both a coarse plugin flag and pluginAdded/pluginRemoved lists for incremental handling. Note that pure reordering will set plugin: true with empty add/remove arrays, which is a reasonable trade‑off.

Overall, this is a solid foundation for driving targeted invalidation and publish flows.

packages/opencode/src/config/error.ts (1)

1-45: Structured config error types look well‑designed

The Zod‑backed NamedError definitions for update, validation, write conflict, and write errors provide clear, typed payloads (filepath, scope, errors, operation, etc.), which should make both logging and user‑facing messages more consistent and reliable. Just ensure that call sites constructing these errors populate fields in line with these schemas.

packages/opencode/test/config/hot-reload.test.ts (3)

13-31: Test helpers and cleanup look solid

The helpers for global-path isolation, workspace creation, and cleanup (including Instance.disposeAll() and directory removal) give good test isolation and mirror real usage paths of the config system and InstanceBootstrap. No issues from a correctness or lifecycle perspective.

Also applies to: 84-91


264-300: Good coverage of global fan‑out error handling

The fan‑out test nicely verifies both the aggregated 500 response and that the target directory still observes the updated config despite a subscriber throwing. This is a good regression guard for the new invalidation/broadcast logic.


338-368: Theme‑only invalidation test is precise and non‑intrusive

Monkey‑patching Instance.invalidate to capture invalidation names, then restoring it in a finally block, is a clean way to assert that only "theme" (plus the expected "config") is invalidated. The test is focused and should be stable.

packages/opencode/src/mcp/index.ts (1)

57-94: MCP state registration and cleanup look correct

Using State.register("mcp", () => Instance.directory, …) and a disposer that closes all clients centralizes MCP lifecycle per directory and ensures clients are cleaned up on state disposal. Error handling inside the disposer is also reasonable.

packages/opencode/src/command/index.ts (1)

40-69: Command state registration is consistent and correct

The migration to State.register("command", () => Instance.directory, …) cleanly scopes commands per directory, builds a stable Record<string, Info>, and ensures a sensible default init command when missing. Using Instance.worktree for templating is appropriate under the Instance-scoped state.

packages/opencode/src/file/watcher.ts (1)

5-12: File watcher lifecycle integration looks good

Registering the watcher via State.register("filewatcher", () => Instance.directory, …) with a disposer that unsubscribes the subscription ties watcher lifetime cleanly to the instance state. Backend selection, ignore patterns, and flag‑gated init() behavior are preserved, so behavior should remain consistent while gaining proper cleanup on dispose.

Also applies to: 33-72

packages/opencode/src/server/server.ts (1)

1784-1813: Toast enrichment for config saves is robust and non-invasive

The /tui/show-toast handler now:

  • Detects “Saved (global|project) config …” messages,
  • Looks up a fresh LastConfigUpdate entry, preferring the current directory but falling back to the freshest matching-scope entry,
  • Rewrites -> undefined tails or reconstructs the message with a joined section list,
  • Wraps everything in a try { ... } catch {} so failures don’t break toast delivery.

This is a nice UX improvement and fails gracefully. The regex and 10s freshness window are conservative enough that spurious rewrites should be rare.

packages/opencode/src/tool/registry.ts (1)

17-17: Tool registry state is correctly migrated to named State.register

The registry is now scoped under "tool-registry" and keyed by Instance.directory, with the initializer still discovering filesystem tools and plugin tools into a shared custom array. ToolRegistry.register and all() correctly await the registered state and mutate custom in place, so invalidation via State.invalidate("tool-registry", dir) will force a full rescan on next access.

No functional issues spotted with the new lifecycle wiring.

Also applies to: 27-58

packages/opencode/src/lsp/index.ts (1)

9-9: LSP lifecycle is correctly migrated to named State.register

The LSP module’s state is now properly registered under "lsp" and keyed by Instance.directory, with:

  • The initializer still constructing servers, clients, broken, and spawning from Config and LSPServer, and
  • The disposer cleanly shutting down all existing clients.

Callers (init, status, getClients, run) consistently go through the registered state() getter, so invalidation via State.invalidate("lsp", dir) will fully rebuild LSP state on next access. No issues spotted with the new lifecycle wiring.

Also applies to: 62-126

packages/opencode/test/config/write.test.ts (1)

9-75: Nice targeted coverage for JSONC preservation and validity

Both tests exercise the important behaviors of writeConfigFile:

  • Preserving existing JSONC comments while updating fields.
  • Ensuring incremental edits remain valid JSONC (validated via jsonc-parser).
  • Cleaning up temp directories in finally to avoid test pollution.

This suite gives good confidence in the incremental write path without overreaching.

packages/opencode/src/agent/agent.ts (1)

43-139: State registration and built‑in agent defaults look consistent

The migration to State.register("agent", () => Instance.directory, ...) and the construction of the general, build, and plan agents (including agentPermission/planPermission and default tool wiring) are internally consistent and align with the new config/state pattern used elsewhere. I don’t see functional issues in this initializer.

packages/opencode/src/config/write.ts (2)

84-175: Incremental diff application logic looks solid

The applyIncrementalUpdates / buildUpdateInstructions / buildNestedInstructions pipeline correctly:

  • Uses ConfigDiff hints where available, but still falls back to deep equality to detect changes when no diff is provided.
  • Handles nested objects (provider/mcp/agent/command/permission/formatter/lsp/tools/mode) via path-based updates, including deletions through value: undefined.
  • Sorts instructions so paths are applied deterministically and without root/child conflicts.

Given the surrounding validation (validateJsonc) and the fallback to full JSON writes in persist.update, this looks like a safe and well-structured approach.


265-295: Atomic write and directory sync strategy looks correct

writeFileAtomically + syncDirectory follow the standard pattern:

  • Write to a temp file with 0o600, fsync, then rename to the final path.
  • Clean up the temp file on rename failure.
  • Attempt to fsync the containing directory on POSIX, with sensible logging and error handling.

This should give robust durability semantics for config writes without obvious portability pitfalls.

packages/opencode/src/config/config.ts (1)

197-203: Config state registration and update delegation are aligned with the new persist flow

  • state = State.register("config", () => Instance.directory, loadStateFromDisk) wires config loading into the shared State mechanism, and get() / directories() correctly project config and directories from that state.
  • readFreshConfig() bypasses the cache by calling loadStateFromDisk() directly, which is appropriate for post‑write readbacks when hot reload is disabled.
  • The exported update wrapper delegates to ./persist while defaulting scope to "project" and directory to Instance.directory, matching the rest of the API.

This all looks consistent with the new config persistence and invalidation design.

Also applies to: 875-895

Comment on lines +140 to +181
for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
permission: agentPermission,
options: {},
tools: {},
builtIn: false,
}
const { name, model, prompt, tools, description, temperature, top_p, mode, color, permission, ...extra } = value
item.options = {
...item.options,
...extra,
}
if (model) item.model = Provider.parseModel(model)
if (prompt) item.prompt = prompt
if (tools)
item.tools = {
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
...tools,
}
item.tools = {
...defaultTools,
...item.tools,
}
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name
if (description) item.description = description
if (temperature != undefined) item.temperature = temperature
if (top_p != undefined) item.topP = top_p
if (mode) item.mode = mode
if (color) item.color = color
// just here for consistency & to prevent it from being added as an option
if (name) item.name = name

if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
if (permission ?? cfg.permission) {
item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Per‑agent permission merging may unintentionally drop built‑in defaults

In the per‑agent loop, item.permission is recomputed as:

if (permission ?? cfg.permission) {
  item.permission = mergeAgentPermissions(cfg.permission ?? {}, permission ?? {})
}

This has a couple of side effects:

  • For built‑in agents (e.g., general/plan) that already have agentPermission/planPermission (which include doom_loop/external_directory defaults), adding any cfg.agent.<name> entry without a permission block still re‑merges from cfg.permission only, effectively discarding those built‑in defaults.
  • For plan, a per‑agent permission override also ignores the stricter planPermission baseline and instead uses only global + per‑agent config as the base.

If you want per‑agent permissions to extend the current agent baseline, a safer pattern would be along the lines of:

const base = item.permission ?? agentPermission
if (permission) {
  item.permission = mergeAgentPermissions(base, permission)
}

and only fall back to cfg.permission when no agent baseline exists.

Minor: disable is not destructured from value, so disable: false would end up inside item.options via extra. If you don’t want disable to appear in options, consider destructuring it alongside the other known fields.

🤖 Prompt for AI Agents
In packages/opencode/src/agent/agent.ts around lines 140–181, the per-agent
permission merge replaces any existing built-in agent baseline (losing defaults)
and also leaves `disable` inside `item.options`; change the merge to use the
current agent baseline as the base (e.g., base = item.permission ??
agentPermission, falling back to cfg.permission only if neither exists) and only
merge incoming per-agent `permission` into that base (instead of always merging
cfg.permission with permission), and also destructure `disable` from `value`
alongside the other known fields so it is not included in `extra`/options.

Comment on lines +1 to +17
import fs from "fs/promises"

export async function createBackup(filepath: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
const backupPath = `${filepath}.bak-${timestamp}`

if (await Bun.file(filepath).exists()) {
await fs.copyFile(filepath, backupPath)
}

return backupPath
}

export async function restoreBackup(backupPath: string, targetPath: string): Promise<void> {
await fs.copyFile(backupPath, targetPath)
await fs.unlink(backupPath)
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clarify/create-backup contract when source file does not exist

createBackup always returns a backupPath even if Bun.file(filepath).exists() is false and no backup file is actually created. If a caller blindly passes that path into restoreBackup, it will throw (ENOENT) on copyFile.

Two options to make this safer:

  • Change the API to Promise<string | null> (or similar) and return null when there is nothing to back up, with restoreBackup being a no-op if given null, or
  • Keep the API but document/ensure all call sites check await Bun.file(backupPath).exists() (or track a boolean) before calling restoreBackup.

Either way, making this explicit will avoid surprising restore failures when there was no original config file.

🤖 Prompt for AI Agents
In packages/opencode/src/config/backup.ts around lines 1 to 17, createBackup
currently returns a backup path even when the source file doesn't exist which
leads restoreBackup to throw ENOENT; change createBackup to return
Promise<string | null> and only compute/return the backupPath (and perform
fs.copyFile) when Bun.file(filepath).exists() is true, otherwise return null;
update restoreBackup to accept backupPath: string | null and be a no-op when
given null; update any call sites to handle the nullable return (or rely on
restoreBackup's no-op) and update function signatures/types accordingly.

Comment on lines +90 to +133
export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => Promise<void>> {
const normalized = path.normalize(filepath)
const timeout = options?.timeout ?? 30000
const staleAfter = options?.staleAfter ?? LOCKFILE_STALE_AFTER_MS
const startTime = Date.now()

while (fileLocks.has(normalized)) {
const waited = Date.now() - startTime

if (waited > 5000 && waited < 5100) {
log.warn("lock acquisition taking longer than expected", {
filepath: normalized,
waited,
})
}

if (waited > timeout) {
throw new Error(`Lock timeout: could not acquire lock for ${normalized} after ${waited}ms`)
}

await fileLocks.get(normalized)
await Bun.sleep(10)
}

let releaseFn: () => void
const lockPromise = new Promise<void>((resolve) => {
releaseFn = resolve
})

fileLocks.set(normalized, lockPromise)

const releaseFilesystem = await acquireFilesystemLock({
filepath: normalized,
timeout,
staleAfter,
startTime,
})

return async () => {
fileLocks.delete(normalized)
releaseFn!()
await releaseFilesystem()
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Failed filesystem lock acquisition can permanently deadlock in‑process callers

In acquireLock, the in‑process lock is registered before awaiting acquireFilesystemLock:

fileLocks.set(normalized, lockPromise)

const releaseFilesystem = await acquireFilesystemLock({ ... })

If acquireFilesystemLock throws (e.g., due to timeout or an unexpected fs error), control never reaches the return statement, and:

  • fileLocks still contains normalized.
  • The stored lockPromise is never resolved.

All subsequent acquireLock(normalized) calls will:

while (fileLocks.has(normalized)) {
  ...
  await fileLocks.get(normalized)
  await Bun.sleep(10)
}

and will wait on a promise that never settles, effectively deadlocking that path for the lifetime of the process.

You can fix this by ensuring the map entry and promise are cleared on error:

export async function acquireLock(filepath: string, options?: LockOptions): Promise<() => Promise<void>> {
  const normalized = path.normalize(filepath)
  const timeout = options?.timeout ?? 30000
  const staleAfter = options?.staleAfter ?? LOCKFILE_STALE_AFTER_MS
  const startTime = Date.now()

  while (fileLocks.has(normalized)) {
    const waited = Date.now() - startTime
    if (waited > 5000 && waited < 5100) {
      log.warn("lock acquisition taking longer than expected", { filepath: normalized, waited })
    }
    if (waited > timeout) {
      throw new Error(`Lock timeout: could not acquire lock for ${normalized} after ${waited}ms`)
    }
    await fileLocks.get(normalized)
    await Bun.sleep(10)
  }

  let releaseFn!: () => void
  const lockPromise = new Promise<void>((resolve) => {
    releaseFn = resolve
  })
  fileLocks.set(normalized, lockPromise)

  let releaseFilesystem: () => Promise<void>
  try {
    releaseFilesystem = await acquireFilesystemLock({
      filepath: normalized,
      timeout,
      staleAfter,
      startTime,
    })
  } catch (error) {
    // Clear in-process lock on failure so future callers are not stuck
    fileLocks.delete(normalized)
    releaseFn()
    throw error
  }

  return async () => {
    fileLocks.delete(normalized)
    releaseFn()
    await releaseFilesystem()
  }
}

This preserves existing semantics while guaranteeing that failures in acquireFilesystemLock don’t poison the in‑process lock state.

🤖 Prompt for AI Agents
In packages/opencode/src/config/lock.ts around lines 90 to 133, the in‑process
lock entry is set before awaiting acquireFilesystemLock so if that call throws
the fileLocks map retains the key and the lockPromise never resolves,
permanently blocking future callers; wrap the await acquireFilesystemLock in a
try/catch, declare releaseFn as non‑null and a releaseFilesystem variable, and
on catch delete the map entry and resolve the promise (releaseFn()) before
rethrowing the error so the in‑process lock is always cleared on failure.

Comment on lines +465 to +505
test("appends plugins discovered from directories after merging config files", async () => {
await using globalTmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, "plugin"), { recursive: true })
await Bun.write(path.join(dir, "plugin", "custom.ts"), "export const plugin = {}")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["global-plugin"],
}),
)
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const pluginEntries = config.plugin ?? []

const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
const expected = import.meta.resolve("@scope/plugin", baseUrl)

expect(pluginEntries.includes(expected)).toBe(true)

const scopedEntry = pluginEntries.find((entry) => entry === expected)
expect(scopedEntry).toBeDefined()
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
await using workspace = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(
path.join(dir, ".opencode", "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["local-plugin"],
}),
)
},
})

const previousGlobalConfig = Global.Path.config
;(Global.Path as any).config = globalTmp.path
try {
await Instance.provide({
directory: workspace.path,
fn: async () => {
const config = await Config.get()
expect(config.plugin).toEqual(["local-plugin", `file://${path.join(globalTmp.path, "plugin", "custom.ts")}`])
},
})
} finally {
;(Global.Path as any).config = previousGlobalConfig
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Consider using pathToFileURL and be mindful of global state in tests

Two points here:

  1. File URL construction
    For the expected global plugin path you currently use:

    `file://${path.join(globalTmp.path, "plugin", "custom.ts")}`

    To keep behavior consistent and cross‑platform (especially on Windows), it would be safer to mirror the earlier test and use pathToFileURL(...).href for the expectation as well:

    const expected = pathToFileURL(path.join(globalTmp.path, "plugin", "custom.ts")).href
    expect(config.plugin).toEqual(["local-plugin", expected])

    This avoids OS‑specific path quirks and keeps tests aligned with how URLs are produced in the implementation.

  2. Global.Path mutation and potential test flakiness
    Temporarily overriding Global.Path.config and restoring it in finally is correct, but it’s still global mutable state. If your bun:test configuration ever runs tests in parallel, this could introduce subtle flakiness when other tests rely on Global.Path.config at the same time.
    It may be worth verifying that these tests run serially or, longer‑term, refactoring config code to accept an injected path for tests instead of patching the global.


I'll verify the review comment by investigating the codebase. Let me search for relevant patterns.


---

🏁 Script executed:

```shell
#!/bin/bash
# Search for pathToFileURL usage in the codebase
rg -n "pathToFileURL" --type ts --type js

Length of output: 658


🏁 Script executed:

#!/bin/bash
# Check the Config.get() implementation and how it constructs file URLs
rg -n "file://" --type ts --type js -C 3

Length of output: 14401


🏁 Script executed:

#!/bin/bash
# Look for Global.Path mutations in test files
rg -n "Global\.Path" --type ts -C 2

Length of output: 34901


🏁 Script executed:

#!/bin/bash
# Check for bun configuration and test parallelization settings
find . -maxdepth 2 -name "bunfig.toml" -o -name "package.json" -o -name "*.config.*" | head -20

Length of output: 66


🏁 Script executed:

#!/bin/bash
# Inspect the actual test file to see full context
cat packages/opencode/test/config/config.test.ts | sed -n '465,505p'

Length of output: 1337


I need to check the test configuration and clarify the plugin URL construction logic.


---

🏁 Script executed:

```shell
#!/bin/bash
# Check bunfig.toml for test parallelization settings
cat bunfig.toml

Length of output: 22


🏁 Script executed:

#!/bin/bash
# Get more context around plugin URL construction in config.ts
cat packages/opencode/src/config/config.ts | sed -n '43,75p'

Length of output: 994


🏁 Script executed:

#!/bin/bash
# Get context around line 362 where file URLs are constructed
cat packages/opencode/src/config/config.ts | sed -n '350,370p'

Length of output: 551


🏁 Script executed:

#!/bin/bash
# Check how plugin specs are differentiated in config.ts
rg -n "isPathLikePluginSpecifier|resolvePluginFileReference" packages/opencode/src/config/config.ts -A 10 | head -60

Length of output: 1262


Let me check one more thing to confirm the test pattern consistency:

Use pathToFileURL to align test expectations with actual implementation behavior

The test expectation at line 500 uses string concatenation (file://${path.join(...)}) but the actual implementation normalizes discovered plugin paths through resolvePluginFileReference() (config.ts:837), which calls pathToFileURL(...).href for absolute paths (config.ts:64). This creates a behavior mismatch, especially on Windows where path.join() produces backslashes while pathToFileURL().href generates forward-slash file URIs.

Update the expectation to match:

const expected = pathToFileURL(path.join(globalTmp.path, "plugin", "custom.ts")).href
expect(config.plugin).toEqual(["local-plugin", expected])

Regarding global state mutation: by default, Bun runs all tests sequentially within each test file, so parallelism risk is currently low. However, the try/finally restoration pattern is correct and the concern about potential future parallelization remains valid for longer-term refactoring consideration.

🤖 Prompt for AI Agents
packages/opencode/test/config/config.test.ts lines 465-505: the test builds an
expected file URI using string concatenation with path.join which mismatches
resolvePluginFileReference() that uses pathToFileURL(...).href (causing Windows
backslash vs file URI differences); update the assertion to compute the expected
value with pathToFileURL(path.join(globalTmp.path, "plugin", "custom.ts")).href
and use that in expect(...).toEqual([...]); if pathToFileURL is not imported,
add `import { pathToFileURL } from "url"` at the top of the test file.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/opencode/src/server/server.ts (2)

85-101: Config update cache and cleanup look good; minor lifecycle consideration

The per-directory LastConfigUpdate map with a 60s cleanup interval is straightforward and safe for a long‑lived server, and pairs well with the 10s “freshness” window used in toast enrichment.

One thing to be aware of is that the setInterval runs as a top‑level side effect whenever this module is imported, including in tests or any non-server use. If you ever reuse Server in more ephemeral contexts, consider moving the interval setup into the App()/listen() bootstrap path or guarding it with a one-time flag so tests don’t accumulate background timers. This is non-blocking, just something to keep in mind.


1938-1967: Toast enrichment is solid; consider tightening multi-directory fallback

The enrichment around "Saved {scope} config" toasts is nicely defensive (best‑effort with a try/catch, 10s freshness window, and per‑directory preference) and should materially improve UX.

In multi‑directory scenarios, the fallback:

candidate = Array.from(LastConfigUpdate.values())
  .filter((e) => e.scope === scope && isFresh(e.at))
  .sort((a, b) => b.at - a.at)[0]

can pull a candidate from a different directory than the one making the /tui/show-toast call. That means a toast in directory A could occasionally show section names from directory B if A has no fresh entry.

If you want stricter attribution, you could drop the cross‑directory fallback entirely or, alternatively, only log when falling back so you can see whether it’s actually helping in practice. Functionally this is a minor/optional concern; the current behavior is acceptable.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 01133dd and 846a84c.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (3)
  • packages/opencode/src/project/instance.ts (1 hunks)
  • packages/opencode/src/provider/provider.ts (2 hunks)
  • packages/opencode/src/server/server.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opencode/src/project/instance.ts
🧰 Additional context used
🧬 Code graph analysis (2)
packages/opencode/src/provider/provider.ts (3)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/server/server.ts (3)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-93)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/cli/cmd/tui/event.ts (1)
  • TuiEvent (4-39)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: update
🔇 Additional comments (4)
packages/opencode/src/provider/provider.ts (4)

12-12: LGTM: Import addition for State.register pattern.

The import is correctly added to support the refactor to State.register().


240-243: LGTM: Correct State.register pattern usage.

The refactor properly uses State.register() with the directory-scoped key function, matching the pattern used across other modules in this PR.

Also applies to: 480-486


272-294: LGTM: mergeProvider function logic preserved correctly.

The mergeProvider function maintains the same logic as before the refactor, properly handling the creation and merging of provider configurations from multiple sources.


406-440: The disabled provider edge case concern is unfounded; the code logic is sound.

After verification, concern #2 about disabling github-copilot but not github-copilot-enterprise is incorrect. The disabled check at line 404 (if (disabled.has(providerID)) continue) skips the entire iteration before reaching the enterprise handler, so if github-copilot is disabled, enterprise cannot be loaded. Additionally, github-copilot-enterprise is not a separate plugin from Plugin.list()—it only exists as a synthetic provider variant loaded within the github-copilot plugin block.

Concern #3 about database consistency is also sound: database["github-copilot-enterprise"] is created at line 301 before the plugin loop and is accessed only when the github-copilot plugin is active.

Concern #1 regarding the auth loader handling both provider types appears reasonable given the usage pattern, though verifying the actual loader implementation would provide final confirmation.

Comment on lines 299 to 308
if (database["github-copilot"]) {
const githubCopilot = database["github-copilot"]
database["github-copilot-enterprise"] = {
...githubCopilot,
id: "github-copilot-enterprise",
name: "GitHub Copilot Enterprise",
// Enterprise uses a different API endpoint - will be set dynamically based on auth
api: undefined,
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

Verify that the API endpoint is set correctly for GitHub Copilot Enterprise.

The api field is set to undefined with the expectation that it will be configured dynamically by the auth loader. Ensure that the GitHub Copilot plugin's auth loader properly sets the enterprise API endpoint when loading auth for github-copilot-enterprise.

Run the following script to check how the GitHub Copilot plugin handles enterprise auth:


🏁 Script executed:

#!/bin/bash
# Description: Verify GitHub Copilot plugin sets enterprise API endpoint correctly

# Search for github-copilot plugin auth loader implementation
ast-grep --pattern $'auth: {
  $$$
  loader: $LOADER
  $$$
}'

# Search for references to github-copilot-enterprise API configuration
rg -n --type=ts -C5 'github-copilot-enterprise.*api|baseURL.*enterprise'

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Strategy 1: Find all GitHub Copilot plugin files and auth-related code
echo "=== Finding GitHub Copilot plugin files ==="
fd -t f 'github.*copilot|copilot.*github' --ignore-case

echo -e "\n=== Looking for auth loader patterns ==="
rg -n 'loader.*:' --type=ts -A2 -B2 | head -50

echo -e "\n=== Searching for where github-copilot is referenced ==="
rg -n 'github-copilot' --type=ts -C2 | head -80

echo -e "\n=== Looking for database entries and API assignments ==="
rg -n 'database\[.*\].*=|\.api\s*=' --type=ts | head -50

Length of output: 6068


🏁 Script executed:

#!/bin/bash
# Strategy 2: Look at the provider.ts file structure to understand auth setup
echo "=== Inspecting provider.ts to find auth setup patterns ==="
rg -n 'auth:|loader:|api:|database\[' packages/opencode/src/provider/provider.ts | head -40

Length of output: 661


🏁 Script executed:

#!/bin/bash
# Strategy 3: Find where auth loaders are actually instantiated or configured
echo "=== Searching for AuthLoader or similar patterns ==="
rg -n 'AuthLoader|createAuth|authLoader|setAuth' --type=ts

echo -e "\n=== Looking for enterprise-specific auth handling ==="
rg -n 'enterprise.*auth|auth.*enterprise' --type=ts -i

Length of output: 1812


🏁 Script executed:

#!/bin/bash
# Show full context around auth.loader calls to see how return value is used
echo "=== Context around line 422 - auth.loader call ==="
sed -n '415,440p' packages/opencode/src/provider/provider.ts

echo -e "\n=== Context around line 432 - enterprise auth.loader call ==="
sed -n '426,450p' packages/opencode/src/provider/provider.ts

Length of output: 2410


🏁 Script executed:

#!/bin/bash
# Search for where 'options' variable from auth.loader is used
echo "=== Looking for 'options' usage after auth.loader ==="
rg -n 'options' packages/opencode/src/provider/provider.ts -C2 | head -60

Length of output: 1733


🏁 Script executed:

#!/bin/bash
# Check if github-copilot-enterprise.api is ever modified after initial creation
echo "=== Looking for API modification patterns ==="
rg -n 'github-copilot-enterprise|enterpriseProviderID.*api|api.*=.*enterprise' packages/opencode/src/provider/provider.ts -C3

Length of output: 1694


🏁 Script executed:

#!/bin/bash
# Find mergeProvider function definition
echo "=== Finding mergeProvider function ==="
rg -n 'function mergeProvider|const mergeProvider|export.*mergeProvider' packages/opencode/src/provider/provider.ts -A15

# Also search more broadly
echo -e "\n=== Broader search for mergeProvider ==="
rg -n 'mergeProvider' packages/opencode/src/provider/provider.ts

Length of output: 1134


🏁 Script executed:

#!/bin/bash
# Look at the GitHub Copilot plugin auth configuration
echo "=== Searching for github-copilot plugin definition ==="
rg -n 'github-copilot.*:.*{|provider:.*github-copilot' packages/opencode/src/provider/provider.ts -A20 | head -80

Length of output: 203


🏁 Script executed:

#!/bin/bash
# Check what GitHub Copilot plugin's auth.loader returns/does
echo "=== Search for GithubCopilot auth implementation in plugins ==="
fd -t f '\.ts$' -path '*/plugin*' 2>/dev/null | xargs rg -l 'github-copilot|copilot' 2>/dev/null

# Find plugin directory structure
echo -e "\n=== Plugin structure ==="
fd -t d 'plugin' | head -20

Length of output: 582


🏁 Script executed:

#!/bin/bash
# Look at the GitHub Copilot plugin configuration in plugin/index.ts
echo "=== GitHub Copilot plugin auth configuration ==="
cat packages/opencode/src/plugin/index.ts

Length of output: 3459


🏁 Script executed:

#!/bin/bash
# Show full mergeProvider function to understand complete logic
echo "=== Full mergeProvider function ==="
sed -n '272,300p' packages/opencode/src/provider/provider.ts

Length of output: 1017


🌐 Web query:

opencode-copilot-auth npm package GitHub Copilot Enterprise baseURL

💡 Result:

Use the provider options.baseURL in your opencode.json for the Copilot provider. Example:

{
"$schema":"https://opencode.ai/config.json",
"provider": {
"copilot": {
"options": {
"baseURL": "https://your-copilot-enterprise-host.example"
}
}
}
}

Documentation: opencode providers (shows options.baseURL) and Copilot provider notes. [1][2]

Sources:
[1] opencode providers docs (options.baseURL).
[2] opencode GitHub README (Copilot provider instructions).


GitHub Copilot Enterprise API endpoint configuration is incomplete and may fail silently.

The api: undefined field relies on the auth loader providing a baseURL in returned options, but this mechanism is not guaranteed. When mergeProvider is called (lines 432–437), it only auto-sets baseURL if info.api exists (line 282), which it doesn't for the enterprise provider. If the auth loader returns empty options or doesn't include baseURL, the enterprise provider will have no API endpoint configured.

Required fix: Either:

  • Provide a fallback API endpoint when creating github-copilot-enterprise (line 306), or
  • Add validation in mergeProvider to ensure baseURL is set for enterprise before finalizing provider configuration, or
  • Document that users must explicitly configure baseURL in their opencode.json config file and that the auth loader must also provide it.

Comment on lines +253 to 304
const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project"
const directory = Instance.directory

const result = await Config.update({
scope,
update: config,
directory,
})

const publishDiff = result.diffForPublish
const hotReloadEnabled = isConfigHotReloadEnabled()
const sections = Object.keys(publishDiff).filter((k) => (publishDiff as any)[k] === true)
// Remember sections for toast enrichment regardless of hot reload mode
rememberConfigUpdate(directory, scope, sections)

if (hotReloadEnabled && scope === "project") {
await Bus.publish(Config.Event.Updated, {
scope,
directory,
refreshed: true,
before: result.before,
after: result.after,
diff: publishDiff,
})
}
if (hotReloadEnabled && scope === "global") {
const publishErrors = await Instance.forEach(async (dir) => {
await Bus.publish(Config.Event.Updated, {
scope,
directory: dir,
refreshed: true,
before: result.before,
after: result.after,
diff: publishDiff,
})
rememberConfigUpdate(dir, scope, sections)
})

if (publishErrors.length > 0) {
log.error("config.publish.failure", { scope, errors: publishErrors })
const details = publishErrors
.map((failure) => {
const message = failure.error instanceof Error ? failure.error.message : String(failure.error)
return `${failure.directory}: ${message}`
})
.join("; ")
throw new Error(`Failed to notify directories: ${details}`)
}
}

return c.json(result.after)
},
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard scope query parsing and make diff handling defensive

Two things worth tightening here:

  1. scope parsing trusts raw query
    const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project" is only a TypeScript assertion; at runtime any string (e.g. "foo") will:

    • Be passed straight into Config.update, which likely expects only "project" or "global".
    • Fail both scope === "project" and scope === "global" checks, so hot‑reload notifications won’t fire even though the config did update.

    You can make this robust and keep the same external behavior by explicitly mapping the query string into the allowed union:

  •      const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project"
    
  •      const scopeQuery = c.req.query("scope")
    
  •      const scope: "project" | "global" = scopeQuery === "global" ? "global" : "project"
         const directory = Instance.directory
    

@@

  •      const result = await Config.update({
    
  •        scope,
    
  •        update: config,
    
  •        directory,
    
  •      })
    
  •      const result = await Config.update({
    
  •        scope,
    
  •        update: config,
    
  •        directory,
    
  •      })
    

@@

  •      const publishDiff = result.diffForPublish
    
  •      const publishDiff = result.diffForPublish ?? {}
    
    
    This ensures only `"project"`/`"global"` ever reach `Config.update` and the hot‑reload branches.
    
    
  1. Assumption that diffForPublish is always defined
    If result.diffForPublish can ever be undefined/null, Object.keys(publishDiff) will throw. The ?? {} guard above makes this safe without changing semantics when a real diff is present.
🤖 Prompt for AI Agents
In packages/opencode/src/server/server.ts around lines 253 to 304, the handler
trusts the raw query string for scope and assumes result.diffForPublish is
always present; validate the incoming scope by mapping the query value to the
allowed union ("project" | "global") and default to "project" so only those
values are passed into Config.update and used in hot‑reload branches, and make
diff handling defensive by using a nullish fallback (e.g. publishDiff ?? {})
before calling Object.keys so Object.keys never throws and sections calculation
remains safe; ensure rememberConfigUpdate and Bus.publish get the validated
scope and use the safe publishDiff.

- Add enabled_providers config for whitelisting providers
- Add per-provider whitelist/blacklist for models
- Keep hot-reload branch openrouter npm fix
- Resolve merge conflicts in provider.ts
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/opencode/src/provider/provider.ts (1)

305-317: GitHub Copilot Enterprise still relies on auth loader/config for baseURL; consider validation or clearer guarantees.

github-copilot-enterprise is cloned from github-copilot with api: undefined (Line 315), and mergeProvider only auto-populates options.baseURL when info.api is truthy (Line 291). For the enterprise provider, that means:

  • The API endpoint is configured only if:
    • The Copilot plugin’s auth.loader returns a baseURL (or equivalent) in its options for github-copilot-enterprise, or
    • The user explicitly configures provider["github-copilot-enterprise"].options.baseURL (or an enterpriseUrl that the loader maps to baseURL).

If the loader returns minimal options (e.g., just an API key) and config doesn’t set baseURL, the enterprise provider will be present but non-functional. This is essentially the same concern raised in the previous review.

Consider one of:

  • Adding a post-merge validation for github-copilot-enterprise that warns or disables the provider if options.baseURL is missing, or
  • Clearly documenting that provider["github-copilot-enterprise"].options.baseURL (or enterpriseUrl consumed by the loader) is required, and ideally adding a test around this flow.

Please confirm that the Copilot plugin’s auth.loader for github-copilot-enterprise always sets the correct baseURL (or equivalent) when enterprise auth is present.

Also applies to: 414-445

🧹 Nitpick comments (3)
packages/opencode/src/provider/provider.ts (1)

400-407: Guard custom loaders against missing database entries.

CUSTOM_LOADERS are invoked with database[providerID], but at least the opencode loader dereferences input.env and input.models. If ModelsDev.get() ever returns a database without a corresponding entry for one of the custom-loader keys (e.g., opencode removed/renamed), this will throw during provider initialization.

A small defensive guard would make this more robust:

-      for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
-        if (disabled.has(providerID)) continue
-        const result = await fn(database[providerID])
+      for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
+        if (disabled.has(providerID)) continue
+        const providerInfo = database[providerID]
+        if (!providerInfo) continue
+        const result = await fn(providerInfo)
packages/opencode/src/config/config.ts (2)

89-195: Config state loader is well-structured; consider removing duplication and optionally deduping plugins.

The new loadStateFromDisk + state = State.register("config", () => Instance.directory, loadStateFromDisk) design is clear and keeps config loading deterministic per directory. The merge order (global → project files → flags → well-known auth → directories/.opencode → commands/agents/modes/plugins) is sensible, and readFreshConfig() gives a clean bypass for cache when needed.

Two small cleanups:

  1. Duplicate autoshare → share promotion

    Lines 181–183 and 185–187 are identical:

    if (result.autoshare === true && !result.share) {
      result.share = "auto"
    }

    One of these can be removed with no behavior change.

  2. Optional: dedupe accumulated plugin entries

    result.plugin is initialized/merged from config and then appended with pluginFiles. If the same plugin is both declared in config and discovered on disk (or present in multiple directories), you may get duplicates. Consider normalizing:

  • if (!result.plugin) {
  •  result.plugin = []
    
  • }
  • result.plugin.push(...pluginFiles)
  • if (!result.plugin) {
  •  result.plugin = []
    
  • }
  • result.plugin.push(...pluginFiles)
  • result.plugin = Array.from(new Set(result.plugin))




Also applies to: 197-203

---

`885-901`: **Confirm all `Config.update` callsites run with a valid `Instance.directory` or pass `directory`.**

`Config.update` defaults:

- `scope` → `"project"`
- `directory` → `Instance.directory`

and then delegates to `persist.update`. This is fine as long as:

- Callers that do not explicitly pass `directory` are always inside an `Instance.provide` context (so `Instance.directory` is defined), and
- Callers that update global scope from outside a project context pass an explicit `directory`.

It’s worth double-checking callsites to avoid surprising `Instance.directory` access errors.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used**: CodeRabbit UI

**Review profile**: CHILL

**Plan**: Pro

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 846a84c65dde5e11db44e81932873060cd94a3bf and f2c7ec9c0be3c078f9d05378a0f10283eaf90e32.

</details>

<details>
<summary>⛔ Files ignored due to path filters (1)</summary>

* `packages/sdk/js/src/gen/types.gen.ts` is excluded by `!**/gen/**`

</details>

<details>
<summary>📒 Files selected for processing (2)</summary>

* `packages/opencode/src/config/config.ts` (9 hunks)
* `packages/opencode/src/provider/provider.ts` (2 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code graph analysis (2)</summary>

<details>
<summary>packages/opencode/src/provider/provider.ts (3)</summary><blockquote>

<details>
<summary>packages/opencode/src/config/config.ts (1)</summary>

* `state` (197-197)

</details>
<details>
<summary>packages/opencode/src/project/instance.ts (2)</summary>

* `state` (48-50)
* `Instance` (15-93)

</details>
<details>
<summary>packages/opencode/src/tool/registry.ts (1)</summary>

* `state` (27-58)

</details>

</blockquote></details>
<details>
<summary>packages/opencode/src/config/config.ts (4)</summary><blockquote>

<details>
<summary>packages/opencode/src/project/instance.ts (4)</summary>

* `directory` (39-41)
* `Instance` (15-93)
* `worktree` (42-44)
* `state` (48-50)

</details>
<details>
<summary>packages/opencode/src/config/global-file.ts (1)</summary>

* `resolveGlobalFile` (5-8)

</details>
<details>
<summary>packages/opencode/src/config/persist.ts (1)</summary>

* `update` (61-183)

</details>
<details>
<summary>packages/opencode/src/config/diff.ts (1)</summary>

* `ConfigDiff` (4-26)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>🔇 Additional comments (4)</summary><blockquote>

<details>
<summary>packages/opencode/src/provider/provider.ts (2)</summary><blockquote>

`12-12`: **State import aligns with new State.register lifecycle.**

Importing `State` here is appropriate and matches the new per-directory provider state pattern used elsewhere (config, tool registry). No issues.

---

`240-509`: **Provider state registration and initialization pipeline look solid.**

The `State.register("provider", () => Instance.directory, async () => { ... })` setup cleanly encapsulates provider discovery (env, auth, custom loaders, plugins, config), filtering, and SDK/model caches under a per-directory key. Ordering of merges (env → auth → custom → plugins → config) and the `isProviderAllowed` / model-filtering logic are coherent and maintain expected behavior.

</blockquote></details>
<details>
<summary>packages/opencode/src/config/config.ts (2)</summary><blockquote>

`27-41`: **Plugin path normalization across POSIX/Windows looks correct.**

The combination of `WINDOWS_RELATIVE_PREFIXES`, `isPathLikePluginSpecifier`, and `resolvePluginFileReference`, plus the `load()`-time rewrite of `data.plugin` entries, cleanly normalizes:

- `file://...`, `~/...`, relative (`./`, `../`), Windows-style (`.\\`, `..\\`, `~\\`), and absolute paths
- Into consistent `file://` URLs based on the config file’s location.

This should make plugin loading far less error-prone across platforms.



Also applies to: 43-73, 839-844

---

`722-752`: **Global config loader refactor is reasonable; behavior now always reads from disk.**

`loadGlobalConfig()` now:

- Uses `resolveGlobalFile()` to pick the authoritative global JSONC path and ensure the directory exists.
- Merges `config.json`, `opencode.json`, and the resolved global file via `mergeDeep`.
- Optionally imports TOML `config` once, migrates to JSON, and then deletes the TOML source.

This is a straightforward, disk-driven source of truth. Just ensure callers that previously relied on memoized global config are OK with the now-always-from-disk behavior (which should be fine given the hot-reload goals).

</blockquote></details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (2)
packages/opencode/src/provider/provider.ts (1)

309-318: GitHub Copilot Enterprise API endpoint configuration remains incomplete.

This issue was flagged in a previous review and is still present. The api: undefined field (line 316) means that mergeProvider (line 292) will not auto-set a baseURL because the check if (info.api && !options["baseURL"]) will fail. The enterprise provider entirely depends on the auth loader returning a baseURL in its options (lines 441-444). If the loader returns empty options or omits baseURL, the enterprise provider will have no API endpoint configured, leading to runtime failures.

Consider one of these approaches:

  1. Add validation in the plugin auth loader section (around lines 441-445) to ensure baseURL is present in enterpriseOptions before calling mergeProvider
  2. Provide a fallback baseURL or throw an explicit error if it's missing
  3. Document clearly that users must configure baseURL in opencode.json and that the auth loader must provide it
packages/opencode/src/server/server.ts (1)

43-43: Harden scope parsing and guard diffForPublish to avoid runtime issues

Two issues here mirror the earlier review on this block:

  1. scope trusts raw query string at runtime
    c.req.query("scope") can be any string (e.g. "foo"). The TS cast doesn’t apply at runtime, so scope may be "foo", which:

    • Is passed to Config.update even though it likely expects "project" | "global".
    • Skips both hot‑reload branches (scope === "project" / "global"), so publishes never fire even though the config was updated.

    You can keep the same external API and make the runtime behavior robust by mapping explicitly:

  •      const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project"
    
  •      const scopeQuery = c.req.query("scope")
    
  •      const scope: "project" | "global" = scopeQuery === "global" ? "global" : "project"
    
    
    
  1. Assumption that result.diffForPublish is always defined
    If result.diffForPublish is ever undefined/null, Object.keys(publishDiff) will throw (even when hot reload is disabled) before you reach the publish branches.

    Making this defensive is cheap and keeps semantics the same when a real diff is present:

  •      const publishDiff = result.diffForPublish
    
  •      const publishDiff = result.diffForPublish ?? {}
    
    
    With that, `sections` will just be `[]` when there’s no diff, and `rememberConfigUpdate` will store `sections: []`, which your toast enrichment already renders as `"no changes"`.
    
    
    
    
    
    
    

Also applies to: 252-304

🧹 Nitpick comments (1)
packages/opencode/src/server/server.ts (1)

1946-1977: Toast enrichment logic is good; consider tightening cross-directory fallback

The enrichment of “Saved {scope} config -> undefined” toasts using LastConfigUpdate is a nice UX improvement and the try/catch keeps it from breaking the endpoint.

Two minor refinements to consider:

  • For project-scoped toasts, the cross-directory fallback (Array.from(LastConfigUpdate.values())…) could, in rare cases, borrow sections from another project if there’s no recent entry for the current directory. If you want strictly per-project semantics, you could skip that fallback when scope === "project".
  • In the catch-all catch {} you lose visibility into unexpected bugs in the enrichment path. A low-verbosity log.debug/log.warn with the error could help debugging without polluting logs.

Both are non-blocking, but worth considering for correctness and observability.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f2c7ec9 and 6ae1251.

📒 Files selected for processing (2)
  • packages/opencode/src/provider/provider.ts (2 hunks)
  • packages/opencode/src/server/server.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/opencode/src/provider/provider.ts (2)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/server/server.ts (3)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-93)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/cli/cmd/tui/event.ts (1)
  • TuiEvent (4-39)
🔇 Additional comments (9)
packages/opencode/src/provider/provider.ts (8)

13-13: LGTM!

The import of State is necessary for the refactor to use State.register for lifecycle management.


241-511: LGTM!

The refactor to use State.register properly encapsulates provider initialization within a state lifecycle. The pattern is consistent with other state registrations in the codebase (e.g., config state), and all initialization logic is preserved. The returned state object ({ models, providers, sdk, realIdByKey }) correctly exposes the necessary data for downstream consumers.


513-515: LGTM!

The list() function correctly accesses the registered state to return providers. The pattern is consistent with the new state-based lifecycle.


517-584: LGTM!

The getSDK function properly awaits the state and accesses provider options, SDK cache, and other state properties. Error handling wraps failures in InitError, which maintains the existing error contract.


586-588: LGTM!

The getProvider function correctly accesses the state to retrieve provider information by ID.


590-650: LGTM!

The getModel function correctly awaits state and accesses all necessary state properties (providers, models, realIdByKey). The error handling for NoSuchModelError is preserved, maintaining the existing error contract.


652-675: LGTM!

The getSmallModel function properly accesses state to retrieve provider information and select an appropriate small model based on priority.


687-701: LGTM!

The defaultModel function correctly uses list() to access providers and select a default model. The logic is preserved from the previous implementation.

packages/opencode/src/server/server.ts (1)

85-101: Per-directory LastConfigUpdate tracking looks solid

The per-directory cache plus periodic cleanup is straightforward and should work well for toast enrichment; the 60s TTL vs 10s “freshness” window gives a reasonable safety margin without leaking memory in long-lived servers.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/opencode/src/server/server.ts (1)

254-305: Guard scope query parsing and make diff handling defensive.

The issues flagged in the previous review still exist:

  1. scope parsing trusts raw query
    Line 254's TypeScript assertion allows any string from the query parameter. At runtime, an invalid value like "foo" will pass through to Config.update and cause the hot‑reload branches to fail silently.

  2. Assumption that diffForPublish is always defined
    Line 263 doesn't guard against undefined/null. Line 265's Object.keys(publishDiff) will throw TypeError if diffForPublish is missing.

Apply this diff to validate the scope and add defensive null handling:

-          const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project"
+          const scopeQuery = c.req.query("scope")
+          const scope: "project" | "global" = scopeQuery === "global" ? "global" : "project"
           const directory = Instance.directory
 
           const result = await Config.update({
             scope,
             update: config,
             directory,
           })
 
-          const publishDiff = result.diffForPublish
+          const publishDiff = result.diffForPublish ?? {}
           const hotReloadEnabled = isConfigHotReloadEnabled()
           const sections = Object.keys(publishDiff).filter((k) => (publishDiff as any)[k] === true)
🧹 Nitpick comments (6)
packages/opencode/src/server/server.ts (2)

91-98: Add error handling to the cleanup interval.

The setInterval callback lacks error handling. If the cleanup logic throws (e.g., due to unexpected Map state), it could silently fail or cause unhandled promise rejections.

Apply this diff:

 setInterval(() => {
-  const now = Date.now()
-  for (const [dir, entry] of LastConfigUpdate.entries()) {
-    if (now - entry.at > 60_000) {
-      LastConfigUpdate.delete(dir)
+  try {
+    const now = Date.now()
+    for (const [dir, entry] of LastConfigUpdate.entries()) {
+      if (now - entry.at > 60_000) {
+        LastConfigUpdate.delete(dir)
+      }
     }
+  } catch (error) {
+    log.error("config.cache.cleanup.failure", { error })
   }
 }, 60_000)

1949-1979: Log errors in the toast enrichment catch block.

The empty catch block on line 1977 silently swallows all errors from the toast enrichment logic. While the defensive try/catch prevents crashes, it makes debugging difficult when enrichment fails unexpectedly.

Apply this diff to add logging:

-          } catch {}
+          } catch (error) {
+            log.debug("tui.toast.enrich.failure", { error, payload })
+          }
           await Bus.publish(TuiEvent.ToastShow, payload)
packages/opencode/src/config/config.ts (4)

27-41: Plugin path normalization is well thought-out; consider centralizing it and deduping plugins.

The new WINDOWS_RELATIVE_PREFIXES, isPathLikePluginSpecifier, and resolvePluginFileReference helpers plus the normalization in load() look solid and handle POSIX + Windows cases nicely.

Two improvements to consider:

  • After appending pluginFiles to result.plugin, you may want to dedupe to avoid duplicated entries when the same plugin is both declared in config and discovered via plugin/ directories:
-    if (!result.plugin) {
-      result.plugin = []
-    }
-    result.plugin.push(...pluginFiles)
+    if (!result.plugin) {
+      result.plugin = []
+    }
+    result.plugin.push(...pluginFiles)
+    result.plugin = Array.from(new Set(result.plugin))
  • loadPlugin() still constructs URLs via string concatenation ("file://" + item). On Windows this can yield invalid file URLs, whereas you now have pathToFileURL in this module. It would be more robust and consistent to switch to it, e.g.:
async function loadPlugin(dir: string) {
  const plugins: string[] = []

  for await (const item of PLUGIN_GLOB.scan({
    absolute: true,
    followSymlinks: true,
    dot: true,
    cwd: dir,
  })) {
    plugins.push(pathToFileURL(item).href)
  }
  return plugins
}

This way all plugin specifiers—whether from config files or discovered on disk—share the same normalization rules.

Also applies to: 43-73, 161-165, 352-365, 844-849


75-87: Event payload could be tightened to known config/diff types.

Event.Updated currently uses z.any() for before, after, and diff. Since you already have Config.Info and ConfigDiff, you could improve type-safety and tooling by constraining these fields to those shapes (or to their partials) rather than any, while still keeping runtime behavior the same.


89-105: Config state loader + registry look coherent; only minor polish opportunities.

The new loadStateFromDisk flow (global → per-repo files via findUp → flags → auth well-known → per-directory .opencode overlays + commands/agents/modes/plugins) together with State.register("config", () => Instance.directory, loadStateFromDisk) and readFreshConfig() aligns well with the hot‑reload design and per-directory state.

A couple of small polish ideas, none of them blocking:

  • You re‑establish result.plugin in several places and again guard it at the end before appending pluginFiles. The last guard plus dedupe (see previous comment) should be sufficient; earlier result.plugin = result.plugin || [] / ??= calls could likely be simplified over time.
  • directories correctly includes Global.Path.config, discovered .opencode dirs, and OPENCODE_CONFIG_DIR; if you ever want to surface all physical config files for debugging or UIs, returning separate files vs directories could be useful, but not required now.

Functionally this looks good as-is.

Also applies to: 123-132, 139-158, 161-165, 189-195, 197-202


844-849: In-place plugin normalization is correct; keep helpers in sync as they evolve.

Normalizing data.plugin in load() via isPathLikePluginSpecifierresolvePluginFileReference correctly handles file://, ~/, relative, and Windows-style paths, and mutating data.plugin[i] is fine since data is local to this load.

Just a note for future changes: if you expand what counts as a path-like specifier, ensure both isPathLikePluginSpecifier and resolvePluginFileReference evolve together so they remain consistent with downstream plugin loading.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6ae1251 and e034c11.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (3)
  • packages/opencode/src/config/config.ts (9 hunks)
  • packages/opencode/src/project/bootstrap.ts (1 hunks)
  • packages/opencode/src/server/server.ts (4 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
packages/opencode/src/server/server.ts (3)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-93)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/cli/cmd/tui/event.ts (1)
  • TuiEvent (4-39)
packages/opencode/src/project/bootstrap.ts (1)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-93)
packages/opencode/src/config/config.ts (4)
packages/opencode/src/project/instance.ts (4)
  • directory (39-41)
  • Instance (15-93)
  • worktree (42-44)
  • state (48-50)
packages/opencode/src/config/global-file.ts (1)
  • resolveGlobalFile (5-8)
packages/opencode/src/config/persist.ts (1)
  • update (61-183)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: update
🔇 Additional comments (4)
packages/opencode/src/project/bootstrap.ts (2)

13-13: LGTM!

The ConfigInvalidation import and setup call are correctly placed early in the bootstrap flow to ensure the config hot-reload infrastructure is ready before other subsystems initialize.

Also applies to: 18-18


27-31: LGTM!

The event subscription correctly ties project initialization to the INIT command lifecycle, ensuring the project state is updated after command execution completes.

packages/opencode/src/config/config.ts (2)

727-735: Global config refactor to loadGlobalConfig()/global() is sound.

Switching to resolveGlobalFile() for the JSONC path and making global() just delegate to loadGlobalConfig() keeps behavior clear while ensuring the config directory exists and that opencode.jsonc cleanly overlays the older JSON files. This looks correct and should work well with the new hot‑reload pipeline.

Also applies to: 753-757


890-905: No issues found; the Config.update() wrapper and its call sites are safe.

Verification of all 4 Config.update() call sites confirms they all safely provide a directory context:

  • config.test.ts:418 — Called within Instance.provide({ directory: tmp.path, ... })
  • hot-reload.test.ts:104, 135 — Explicitly pass directory parameter
  • server.ts:257 — Explicitly passes directory from Instance.directory variable

The hypothetical risk of calling Config.update with scope: "global" while omitting directory outside an Instance context does not occur in practice. All callers properly handle the directory parameter, either explicitly or via active instance context.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/opencode/src/config/config.ts (1)

181-187: Remove duplicate autoshare handling.

Lines 181-187 are identical to lines 175-177. This duplicate logic should be removed.

Apply this diff to remove the duplication:

     if (!result.username) result.username = os.userInfo().username
 
     if (result.autoshare === true && !result.share) {
       result.share = "auto"
     }
 
-    if (result.autoshare === true && !result.share) {
-      result.share = "auto"
-    }
-
     if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
♻️ Duplicate comments (1)
packages/opencode/src/provider/provider.ts (1)

309-318: GitHub Copilot Enterprise API endpoint configuration remains incomplete.

This issue was previously flagged and remains unresolved. The api: undefined setting (line 316) expects the auth loader to provide baseURL in options. However, mergeProvider (line 292) only auto-sets baseURL from info.api when it exists—which it doesn't for enterprise. If the auth loader fails to provide baseURL, the enterprise provider will have no API endpoint configured, leading to silent failures.

Consider one of these solutions:

  1. Provide a default enterprise API endpoint
  2. Add validation to ensure baseURL is set before the provider is used
  3. Document that users must configure baseURL in opencode.json

Apply this diff to add validation:

       }
       provider.options = mergeDeep(provider.options, options)
       provider.source = source
       provider.getModel = getModel ?? provider.getModel
+      // Validate critical fields
+      if (id === "github-copilot-enterprise" && !provider.options["baseURL"]) {
+        log.warn("GitHub Copilot Enterprise requires baseURL in configuration or auth loader")
+      }
     }
🧹 Nitpick comments (3)
packages/opencode/src/provider/provider.ts (1)

410-449: Consider simplifying the GitHub Copilot auth loading logic.

The special handling for GitHub Copilot Enterprise introduces complexity with multiple conditional checks. While functionally correct, the logic flow could be clearer.

Consider this refactor for improved readability:

     for (const plugin of await Plugin.list()) {
       if (!plugin.auth) continue
       const providerID = plugin.auth.provider
       if (disabled.has(providerID)) continue
-
-      // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
-      let hasAuth = false
-      const auth = await Auth.get(providerID)
-      if (auth) hasAuth = true
-
-      // Special handling for github-copilot: also check for enterprise auth
-      if (providerID === "github-copilot" && !hasAuth) {
-        const enterpriseAuth = await Auth.get("github-copilot-enterprise")
-        if (enterpriseAuth) hasAuth = true
-      }
-
-      if (!hasAuth) continue
       if (!plugin.auth.loader) continue
-
-      // Load for the main provider if auth exists
-      if (auth) {
+      
+      const auth = await Auth.get(providerID)
+      if (auth) {
         const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
         mergeProvider(plugin.auth.provider, options ?? {}, "custom")
       }
-
-      // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
+      
+      // Handle GitHub Copilot Enterprise as a separate provider
       if (providerID === "github-copilot") {
         const enterpriseProviderID = "github-copilot-enterprise"
         if (!disabled.has(enterpriseProviderID)) {
           const enterpriseAuth = await Auth.get(enterpriseProviderID)
           if (enterpriseAuth) {
             const enterpriseOptions = await plugin.auth.loader(
               () => Auth.get(enterpriseProviderID) as any,
               database[enterpriseProviderID],
             )
             mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom")
           }
         }
       }
     }
packages/opencode/src/config/config.ts (2)

43-73: Optional: Remove unnecessary Windows path normalization on URL output.

Line 69 calls normalizeWindowsPath() on a URL that has already been processed through the URL constructor. URLs are always normalized to use forward slashes by the standard, making this call redundant.

Apply this diff if you prefer cleaner code:

     try {
       const base = pathToFileURL(configFilepath).href
       const resolved = new URL(plugin, base).href
-      return normalizeWindowsPath(resolved)
+      return resolved
     } catch {
       return plugin
     }

75-87: Suggest using stricter types for event payload.

The before, after, and diff fields use z.any(), which loses type safety. Consider using Config.Info for before/after and the imported ConfigDiff type for diff.

Apply this diff for better type safety:

   export const Event = {
     Updated: Bus.event(
       "config.updated",
       z.object({
         scope: z.enum(["project", "global"]),
         directory: z.string().optional(),
         refreshed: z.boolean().optional(),
-        before: z.any(),
-        after: z.any(),
-        diff: z.any(),
+        before: Info,
+        after: Info,
+        diff: z.custom<ConfigDiff>(),
       }),
     ),
   }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0801364 and 8791c01.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (2)
  • packages/opencode/src/config/config.ts (9 hunks)
  • packages/opencode/src/provider/provider.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
packages/opencode/src/provider/provider.ts (3)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/config/config.ts (5)
packages/sdk/js/src/gen/types.gen.ts (1)
  • Config (983-1235)
packages/opencode/src/project/instance.ts (4)
  • directory (39-41)
  • Instance (15-93)
  • worktree (42-44)
  • state (48-50)
packages/opencode/src/config/global-file.ts (1)
  • resolveGlobalFile (5-8)
packages/opencode/src/config/persist.ts (1)
  • update (61-183)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: update
🔇 Additional comments (10)
packages/opencode/src/provider/provider.ts (3)

241-511: LGTM: State registration pattern correctly implemented.

The refactor to State.register aligns with the project-wide state management approach. The initialization logic is properly encapsulated, returns the necessary state components, and maintains backward compatibility through the public API.


465-502: LGTM: Provider filtering logic is sound.

The filtering correctly removes unauthorized providers, blacklisted models, experimental models (unless flagged), and models outside the configured whitelist. The mutation of provider.info.models is safe since the database is loaded fresh for each state instance.


372-374: No changes needed—the code correctly handles falsy values.

The condition model.id && model.id !== modelID already prevents empty strings, undefined, null, and other falsy values from being added to the realIdByKey mapping. The && operator ensures the second comparison only evaluates if model.id is truthy.

packages/opencode/src/config/config.ts (7)

27-41: LGTM! Comprehensive path detection logic.

The helper correctly identifies various path-like plugin specifiers across platforms, including file:// URLs, relative paths, home paths, and Windows-specific prefixes.


199-202: LGTM! Clean API for fresh config loading.

This new public function provides an explicit way to read configuration directly from disk, bypassing the cache. Useful for scenarios where fresh data is needed, such as after config updates with hot-reload disabled.


728-758: LGTM! Well-structured global config loading.

The refactored function properly handles multiple config sources and includes graceful TOML migration logic. The change from lazy loading to an explicit async function aligns with the new hot-reload infrastructure.


845-852: Verify plugin path resolution with various formats.

The new logic resolves path-like plugin specifiers to file:// URLs. Ensure this works correctly with all supported formats: relative paths, absolute paths, home paths (~), Windows paths, and URL-based plugins.

Consider testing with various plugin path formats:

  • ./local-plugin.js
  • ../shared-plugin.js
  • ~/config/plugin.js
  • file:///absolute/path/plugin.js
  • Windows paths: .\\plugin.js, ~\\plugin.js
  • Package names: @opencode-ai/plugin

891-907: LGTM! Clean delegation to persist module.

The updated signature provides rich return information (before, after, diffs, filepath), and delegation to the persist module is appropriate. Error handling, locking, backup/restore, and validation are properly handled in the persist module.


756-758: No action required—performance concern does not apply.

The Config.global() function is called only 3 times across the codebase (in upgrade.ts and persist.ts), and none of these calls occur in hot paths or loops. The calls are infrequent CLI and configuration persistence operations where disk I/O is acceptable. The change enables hot-reload support without practical performance impact.


197-197: No issues found with State.register pattern compatibility.

Verification confirms the State.register implementation and all usages in config.ts are correct. The state() function properly returns a Promise that resolves to state objects, and both get() (line 888) and directories() (line 910) correctly handle this with .then() chaining. The pattern is consistent across all other modules using State.register.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/opencode/src/provider/provider.ts (1)

309-318: GitHub Copilot Enterprise API configuration remains incomplete.

This is the same issue identified in the previous review. The api: undefined field requires the auth loader to provide baseURL in the returned options. However:

  1. Line 292's check if (info.api && !options["baseURL"]) won't set a fallback baseURL for enterprise since info.api is undefined
  2. If the auth loader (lines 441-445) doesn't return a baseURL, the enterprise provider will have no API endpoint configured
  3. No validation exists to ensure baseURL is present before the provider is used

The past review suggested either providing a fallback API endpoint, adding validation in mergeProvider, or documenting the requirement. None of these mitigations appear to have been implemented.

🧹 Nitpick comments (1)
packages/opencode/src/provider/provider.ts (1)

465-502: Consider extracting model filtering logic into a helper function.

The filtering logic (lines 472-494) combines multiple concerns: blacklist filtering, experimental model filtering, and config-based whitelist/blacklist. Extracting this into a dedicated helper function would improve readability and testability.

Example:

+function filterProviderModels(
+  models: Record<string, ModelsDev.Model>,
+  providerID: string,
+  configProvider?: any
+): Record<string, ModelsDev.Model> {
+  return Object.fromEntries(
+    Object.entries(models)
+      .filter(([modelID]) =>
+        modelID !== "gpt-5-chat-latest" && 
+        !(providerID === "openrouter" && modelID === "openai/gpt-5-chat")
+      )
+      .filter(([, model]) =>
+        ((!model.experimental && model.status !== "alpha") || 
+         Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
+        model.status !== "deprecated"
+      )
+      .filter(([modelID]) => {
+        if (!configProvider) return true
+        return (
+          (!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
+          (!configProvider.whitelist || configProvider.whitelist.includes(modelID))
+        )
+      })
+  )
+}
+
 for (const [providerID, provider] of Object.entries(providers)) {
   if (!isProviderAllowed(providerID)) {
     delete providers[providerID]
     continue
   }

   const configProvider = config.provider?.[providerID]
-  const filteredModels = Object.fromEntries(
-    Object.entries(provider.info.models)
-      // Filter out blacklisted models
-      .filter(
-        ([modelID]) =>
-          modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"),
-      )
-      // Filter out experimental models
-      .filter(
-        ([, model]) =>
-          ((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
-          model.status !== "deprecated",
-      )
-      // Filter by provider's whitelist/blacklist from config
-      .filter(([modelID]) => {
-        if (!configProvider) return true
-
-        return (
-          (!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
-          (!configProvider.whitelist || configProvider.whitelist.includes(modelID))
-        )
-      }),
-  )
+  const filteredModels = filterProviderModels(provider.info.models, providerID, configProvider)
   provider.info.models = filteredModels
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8791c01 and c3f32a0.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (1)
  • packages/opencode/src/provider/provider.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
packages/opencode/src/provider/provider.ts (3)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: update
🔇 Additional comments (3)
packages/opencode/src/provider/provider.ts (3)

13-13: LGTM: State import added for centralized state management.

The import aligns with the migration to the State.register pattern used across other modules.


282-304: LGTM: mergeProvider function correctly handles provider merging.

The function properly handles both initial provider creation and subsequent updates, with appropriate fallback for baseURL from the database.


331-378: LGTM: Model parsing logic handles defaults and custom IDs correctly.

The parsing provides comprehensive defaults for model properties and properly tracks custom model IDs in realIdByKey for later resolution.

Comment on lines 241 to 511
const state = State.register(
"provider",
() => Instance.directory,
async () => {
using _ = log.time("state")
const config = await Config.get()
const database = await ModelsDev.get()

const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null

function isProviderAllowed(providerID: string): boolean {
if (enabled && !enabled.has(providerID)) return false
if (disabled.has(providerID)) return false
return true
}

const providers: {
[providerID: string]: {
source: Source
info: ModelsDev.Provider
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
options: Record<string, any>
}
} = {}
const models = new Map<
string,
{
providerID: string
modelID: string
info: ModelsDev.Model
language: LanguageModel
npm?: string
}
>()
const sdk = new Map<number, SDK>()
// Maps `${provider}/${key}` to the provider's actual model ID for custom aliases.
const realIdByKey = new Map<string, string>()

log.info("init")

function mergeProvider(
id: string,
options: Record<string, any>,
source: Source,
getModel?: (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>,
) {
const provider = providers[id]
if (!provider) {
const info = database[id]
if (!info) return
if (info.api && !options["baseURL"]) options["baseURL"] = info.api
providers[id] = {
source,
info,
options,
getModel,
}
return
}
provider.options = mergeDeep(provider.options, options)
provider.source = source
provider.getModel = getModel ?? provider.getModel
}

const configProviders = Object.entries(config.provider ?? {})

// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
if (database["github-copilot"]) {
const githubCopilot = database["github-copilot"]
database["github-copilot-enterprise"] = {
...githubCopilot,
id: "github-copilot-enterprise",
name: "GitHub Copilot Enterprise",
// Enterprise uses a different API endpoint - will be set dynamically based on auth
api: undefined,
}
return
}
provider.options = mergeDeep(provider.options, options)
provider.source = source
provider.getModel = getModel ?? provider.getModel
}

const configProviders = Object.entries(config.provider ?? {})

// Add GitHub Copilot Enterprise provider that inherits from GitHub Copilot
if (database["github-copilot"]) {
const githubCopilot = database["github-copilot"]
database["github-copilot-enterprise"] = {
...githubCopilot,
id: "github-copilot-enterprise",
name: "GitHub Copilot Enterprise",
// Enterprise uses a different API endpoint - will be set dynamically based on auth
api: undefined,
}
}

for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}
for (const [providerID, provider] of configProviders) {
const existing = database[providerID]
const parsed: ModelsDev.Provider = {
id: providerID,
npm: provider.npm ?? existing?.npm,
name: provider.name ?? existing?.name ?? providerID,
env: provider.env ?? existing?.env ?? [],
api: provider.api ?? existing?.api,
models: existing?.models ?? {},
}

for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existing = parsed.models[model.id ?? modelID]
const name = iife(() => {
if (model.name) return model.name
if (model.id && model.id !== modelID) return modelID
return existing?.name ?? modelID
})
const parsedModel: ModelsDev.Model = {
id: modelID,
name,
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
},
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existing = parsed.models[modelID]
const parsedModel: ModelsDev.Model = {
id: modelID,
name: model.name ?? (model.id && model.id !== modelID ? modelID : (existing?.name ?? modelID)),
release_date: model.release_date ?? existing?.release_date,
attachment: model.attachment ?? existing?.attachment ?? false,
reasoning: model.reasoning ?? existing?.reasoning ?? false,
temperature: model.temperature ?? existing?.temperature ?? false,
tool_call: model.tool_call ?? existing?.tool_call ?? true,
cost:
!model.cost && !existing?.cost
? {
input: 0,
output: 0,
cache_read: 0,
cache_write: 0,
}
: {
cache_read: 0,
cache_write: 0,
...existing?.cost,
...model.cost,
},
options: {
...existing?.options,
...model.options,
},
modalities: model.modalities ??
existing?.modalities ?? {
input: ["text"],
output: ["text"],
},
headers: model.headers,
provider: model.provider ?? existing?.provider,
}
if (model.id && model.id !== modelID) {
realIdByKey.set(`${providerID}/${modelID}`, model.id)
limit: model.limit ??
existing?.limit ?? {
context: 0,
output: 0,
},
modalities: model.modalities ??
existing?.modalities ?? {
input: ["text"],
output: ["text"],
},
headers: model.headers,
provider: model.provider ?? existing?.provider,
}
if (model.id && model.id !== modelID) {
realIdByKey.set(`${providerID}/${modelID}`, model.id)
}
parsed.models[modelID] = parsedModel
}
parsed.models[modelID] = parsedModel
database[providerID] = parsed
}

// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}

database[providerID] = parsed
}

// load env
for (const [providerID, provider] of Object.entries(database)) {
if (disabled.has(providerID)) continue
const apiKey = provider.env.map((item) => process.env[item]).at(0)
if (!apiKey) continue
mergeProvider(
providerID,
// only include apiKey if there's only one potential option
provider.env.length === 1 ? { apiKey } : {},
"env",
)
}

// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
// load apikeys
for (const [providerID, provider] of Object.entries(await Auth.all())) {
if (disabled.has(providerID)) continue
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
}
}
}

// load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
// load custom
for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
if (disabled.has(providerID)) continue
const result = await fn(database[providerID])
if (result && (result.autoload || providers[providerID])) {
mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
}
}
}

for (const plugin of await Plugin.list()) {
if (!plugin.auth) continue
const providerID = plugin.auth.provider
if (disabled.has(providerID)) continue
for (const plugin of await Plugin.list()) {
if (!plugin.auth) continue
const providerID = plugin.auth.provider
if (disabled.has(providerID)) continue

// For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
let hasAuth = false
const auth = await Auth.get(providerID)
if (auth) hasAuth = true
// For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise
let hasAuth = false
const auth = await Auth.get(providerID)
if (auth) hasAuth = true

// Special handling for github-copilot: also check for enterprise auth
if (providerID === "github-copilot" && !hasAuth) {
const enterpriseAuth = await Auth.get("github-copilot-enterprise")
if (enterpriseAuth) hasAuth = true
}
// Special handling for github-copilot: also check for enterprise auth
if (providerID === "github-copilot" && !hasAuth) {
const enterpriseAuth = await Auth.get("github-copilot-enterprise")
if (enterpriseAuth) hasAuth = true
}

if (!hasAuth) continue
if (!plugin.auth.loader) continue
if (!hasAuth) continue
if (!plugin.auth.loader) continue

// Load for the main provider if auth exists
if (auth) {
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
mergeProvider(plugin.auth.provider, options ?? {}, "custom")
}
// Load for the main provider if auth exists
if (auth) {
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
mergeProvider(plugin.auth.provider, options ?? {}, "custom")
}

// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
if (providerID === "github-copilot") {
const enterpriseProviderID = "github-copilot-enterprise"
if (!disabled.has(enterpriseProviderID)) {
const enterpriseAuth = await Auth.get(enterpriseProviderID)
if (enterpriseAuth) {
const enterpriseOptions = await plugin.auth.loader(
() => Auth.get(enterpriseProviderID) as any,
database[enterpriseProviderID],
)
mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom")
// If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists
if (providerID === "github-copilot") {
const enterpriseProviderID = "github-copilot-enterprise"
if (!disabled.has(enterpriseProviderID)) {
const enterpriseAuth = await Auth.get(enterpriseProviderID)
if (enterpriseAuth) {
const enterpriseOptions = await plugin.auth.loader(
() => Auth.get(enterpriseProviderID) as any,
database[enterpriseProviderID],
)
mergeProvider(enterpriseProviderID, enterpriseOptions ?? {}, "custom")
}
}
}
}
}

// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
}
// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")

// TODO: set this in models.dev, not set due to breaking issues on older OC versions
// u have to set include usage to true w/ this provider, setting in models.dev would cause undefined issue when accessing usage in older versions
if (providerID === "openrouter") {
const p = providers[providerID]
if (p) p.info.npm = "@openrouter/ai-sdk-provider"
}

for (const [providerID, provider] of Object.entries(providers)) {
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
log.info("found", { providerID, npm: providers[providerID]?.info.npm })
}

const configProvider = config.provider?.[providerID]
const filteredModels = Object.fromEntries(
Object.entries(provider.info.models)
// Filter out blacklisted models
.filter(
([modelID]) =>
modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"),
)
// Filter out experimental models
.filter(
([, model]) =>
((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
model.status !== "deprecated",
)
// Filter by provider's whitelist/blacklist from config
.filter(([modelID]) => {
if (!configProvider) return true

return (
(!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
(!configProvider.whitelist || configProvider.whitelist.includes(modelID))
for (const [providerID, provider] of Object.entries(providers)) {
if (!isProviderAllowed(providerID)) {
delete providers[providerID]
continue
}

const configProvider = config.provider?.[providerID]
const filteredModels = Object.fromEntries(
Object.entries(provider.info.models)
// Filter out blacklisted models
.filter(
([modelID]) =>
modelID !== "gpt-5-chat-latest" && !(providerID === "openrouter" && modelID === "openai/gpt-5-chat"),
)
}),
)
// Filter out experimental models
.filter(
([, model]) =>
((!model.experimental && model.status !== "alpha") || Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) &&
model.status !== "deprecated",
)
// Filter by provider's whitelist/blacklist from config
.filter(([modelID]) => {
if (!configProvider) return true

provider.info.models = filteredModels
return (
(!configProvider.blacklist || !configProvider.blacklist.includes(modelID)) &&
(!configProvider.whitelist || configProvider.whitelist.includes(modelID))
)
}),
)
provider.info.models = filteredModels

if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
if (Object.keys(provider.info.models).length === 0) {
delete providers[providerID]
continue
}
log.info("found", { providerID })
}

// TODO: set this in models.dev, not set due to breaking issues on older OC versions
// u have to set include usage to true w/ this provider, setting in models.dev would cause undefined issue when accessing usage in older versions
if (providerID === "openrouter") {
provider.info.npm = "@openrouter/ai-sdk-provider"
return {
models,
providers,
sdk,
realIdByKey,
}

log.info("found", { providerID, npm: provider.info.npm })
}

return {
models,
providers,
sdk,
realIdByKey,
}
})
},
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify State.register handles concurrent initialization correctly.

The migration to State.register follows the established pattern used by other modules. However, ensure that State.register properly serializes concurrent calls to prevent race conditions during initialization, especially since provider loading involves async operations (Auth.all(), Plugin.list(), custom loaders).

Run the following script to verify the State module's concurrency handling:


🏁 Script executed:

#!/bin/bash
# Description: Check State.register implementation for concurrency safeguards

# Look for State.register implementation and locking/memoization mechanisms
ast-grep --pattern $'register($NAME, $SCOPE, $INIT) {
  $$$
}'

# Search for mutex, lock, or promise caching patterns in State module
rg -n 'mutex|lock|Promise.*cache|concurrent' packages/opencode/src/project/state.ts -C3

Length of output: 0



🏁 Script executed:

#!/bin/bash
# Find State module file and inspect State.register implementation

# First, locate the State module
find . -name "state.ts" -o -name "state.js" | head -20

# Also search for State.register pattern in the codebase
rg -n "register.*=.*function|register.*\(.*\)" packages/opencode/src/project/ --type ts -A5 -B1 | head -50

Length of output: 1248



🏁 Script executed:

#!/bin/bash
# Inspect State.register implementation in detail

cat packages/opencode/src/project/state.ts | head -150

Length of output: 4517


State.register does not safely handle concurrent async initialization—race condition risk.

The State.register implementation in packages/opencode/src/project/state.ts does not handle concurrent calls to async init() functions. When the provider initialization at line 246 executes with multiple concurrent State accesses, the entry is only cached after initialization completes. This means two concurrent calls will both see the entry missing and both invoke the init function independently, defeating the caching mechanism and causing duplicate async work (Config.get, ModelsDev.get, Auth.all, Plugin.list, etc.).

The State.create function should store the promise immediately (not the resolved value), or implement a lock pattern to ensure only one initialization runs per key.

🤖 Prompt for AI Agents
In packages/opencode/src/provider/provider.ts around lines 241 to 511, the
provider State.register init function performs expensive async initialization
(Config.get, ModelsDev.get, Auth.all, Plugin.list, etc.) but relies on
State.register which currently only caches the resolved value after init
completes, allowing concurrent callers to race and run init multiple times;
update State.create/register implementation (in
packages/opencode/src/project/state.ts) to store the initialization promise
immediately (or use a per-key lock) so that the first caller saves the Promise
into the state map and subsequent callers await that Promise instead of invoking
init again, ensuring only a single concurrent initialization per key and
preserving existing resolved-value semantics once ready.

actions-user and others added 4 commits November 24, 2025 22:34
Resolve conflicts in:
- config/config.ts: Use resolveGlobalFile import and updated NamedError path
- plugin/index.ts: Keep State.register pattern with cleanup, update plugin versions
- provider/provider.ts: Keep State.register pattern for hot-reload support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Update import to use @opencode-ai/util/error to match the NamedError
module relocation from dev branch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (3)
packages/opencode/src/server/server.ts (1)

254-265: Guard scope query parsing and make diff handling defensive.

This repeats a concern from a previous review. The scope query parameter is cast without runtime validation—any string passes through. Additionally, publishDiff may be undefined, causing Object.keys(publishDiff) to throw.

Apply the suggested fix from the previous review:

-          const scope = (c.req.query("scope") as "project" | "global" | undefined) ?? "project"
+          const scopeQuery = c.req.query("scope")
+          const scope: "project" | "global" = scopeQuery === "global" ? "global" : "project"
           const directory = Instance.directory
           // ... later ...
-          const publishDiff = result.diffForPublish
+          const publishDiff = result.diffForPublish ?? {}
packages/opencode/src/provider/provider.ts (2)

332-341: GitHub Copilot Enterprise API endpoint remains undefined.

This repeats a concern from a previous review. The api: undefined field relies on the auth loader providing a baseURL, but if the auth loader doesn't set it, the enterprise provider will have no API endpoint. The mergeProvider function only auto-sets baseURL when info.api exists (line 315), which it doesn't for enterprise.

Consider adding a fallback or validation to ensure baseURL is configured for enterprise providers before use.


264-534: State.register concurrent initialization concern persists.

As noted in a previous review, State.register may not safely handle concurrent async initialization. If multiple callers invoke state() simultaneously before initialization completes, they may all trigger independent init calls, causing duplicate work and potential race conditions.

This is an architectural concern with the State module implementation. Consider verifying that State.register stores the initialization promise immediately (rather than the resolved value) to prevent concurrent callers from racing.

#!/bin/bash
# Check State.register implementation for promise caching / locking
cat packages/opencode/src/project/state.ts
🧹 Nitpick comments (5)
packages/opencode/src/plugin/index.ts (1)

38-50: Consider avoiding let reassignment per coding guidelines.

The let plugin variable is reassigned within the loop. Per coding guidelines, prefer avoiding let statements. You could restructure to use a const with conditional assignment.

-      for (let plugin of plugins) {
-        log.info("loading plugin", { path: plugin })
-        if (!plugin.startsWith("file://")) {
-          const lastAtIndex = plugin.lastIndexOf("@")
-          const pkg = lastAtIndex > 0 ? plugin.substring(0, lastAtIndex) : plugin
-          const version = lastAtIndex > 0 ? plugin.substring(lastAtIndex + 1) : "latest"
-          plugin = await BunProc.install(pkg, version)
-        }
-        const mod = await import(plugin)
+      for (const pluginSpec of plugins) {
+        log.info("loading plugin", { path: pluginSpec })
+        const pluginPath = pluginSpec.startsWith("file://")
+          ? pluginSpec
+          : await (async () => {
+              const lastAtIndex = pluginSpec.lastIndexOf("@")
+              const pkg = lastAtIndex > 0 ? pluginSpec.substring(0, lastAtIndex) : pluginSpec
+              const version = lastAtIndex > 0 ? pluginSpec.substring(lastAtIndex + 1) : "latest"
+              return BunProc.install(pkg, version)
+            })()
+        const mod = await import(pluginPath)
packages/opencode/src/server/server.ts (2)

90-98: Consider clearing the cleanup interval on server disposal.

The setInterval runs indefinitely. If the server supports graceful shutdown or disposal, this interval should be cleared to avoid resource leaks. If the server runs until process termination, this is a non-issue.


1951-1977: Empty catch block silently swallows errors.

The try/catch block with an empty catch hides any errors in the toast enrichment logic, making debugging difficult. Per coding guidelines, avoid try/catch where possible, or at minimum log errors.

           }
-          } catch {}
+          } catch (err) {
+            log.error("toast enrichment failed", { error: err })
+          }
           await Bus.publish(TuiEvent.ToastShow, payload)
packages/opencode/src/config/config.ts (2)

29-41: Minor redundancy in path checks.

Line 37 checks value.startsWith("/") before path.isAbsolute(value), but startsWith("/") already captures Unix absolute paths. The path.isAbsolute() is still useful for Windows paths like C:\, so the order could be optimized.

Consider this refactor to check path.isAbsolute() first to catch all absolute paths in one condition:

-    if (value.startsWith("/") || path.isAbsolute(value)) {
+    if (path.isAbsolute(value)) {
       return true
     }

43-73: Consider avoiding try-catch for URL resolution.

The try-catch block at lines 66-72 is used for URL parsing. Per coding guidelines, try-catch should be avoided where possible.

Consider using URL validation before creating the URL to avoid the try-catch:

-    try {
-      const base = pathToFileURL(configFilepath).href
-      const resolved = new URL(plugin, base).href
-      return normalizeWindowsPath(resolved)
-    } catch {
-      return plugin
-    }
+    const base = pathToFileURL(configFilepath).href
+    const resolved = path.resolve(path.dirname(configFilepath), plugin)
+    return pathToFileURL(resolved).href

Additionally, normalizeWindowsPath is only used once, so it could be inlined:

-    const normalizeWindowsPath = (input: string) => input.replace(/\\/g, "/")
-
     if (plugin.startsWith("~/")) {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c3f32a0 and 19fb0f3.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (6)
  • packages/opencode/src/config/config.ts (9 hunks)
  • packages/opencode/src/config/error.ts (1 hunks)
  • packages/opencode/src/mcp/index.ts (2 hunks)
  • packages/opencode/src/plugin/index.ts (1 hunks)
  • packages/opencode/src/provider/provider.ts (2 hunks)
  • packages/opencode/src/server/server.ts (4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opencode/src/config/error.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{js,jsx,ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{js,jsx,ts,tsx}: DO NOT do unnecessary destructuring of variables
DO NOT use else statements unless necessary
DO NOT use try/catch if it can be avoided
AVOID try/catch where possible
AVOID let statements
PREFER single word variable names where possible
Use as many Bun APIs as possible like Bun.file()

Files:

  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/server/server.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/plugin/index.ts
  • packages/opencode/src/config/config.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

AVOID using any type

Files:

  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/server/server.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/plugin/index.ts
  • packages/opencode/src/config/config.ts
packages/opencode/**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (packages/opencode/AGENTS.md)

Use Bun with TypeScript ESM modules as the runtime environment

Files:

  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/server/server.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/plugin/index.ts
  • packages/opencode/src/config/config.ts
packages/opencode/**/*.{ts,tsx}

📄 CodeRabbit inference engine (packages/opencode/AGENTS.md)

packages/opencode/**/*.{ts,tsx}: Use relative imports for local modules with named imports preferred
Use Zod schemas for validation
Use TypeScript interfaces for defining data structures
Use camelCase for variable and function names
Use PascalCase for class and namespace names
Use Result patterns for error handling; avoid throwing exceptions in tools
Organize code using namespace-based structure (e.g., Tool.define(), Session.create())
Pass sessionID in tool context and use App.provide() for dependency injection
Validate all inputs with Zod schemas
Use Log.create({ service: "name" }) pattern for logging
Use Storage namespace for data persistence

Files:

  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/server/server.ts
  • packages/opencode/src/provider/provider.ts
  • packages/opencode/src/plugin/index.ts
  • packages/opencode/src/config/config.ts
🧠 Learnings (5)
📚 Learning: 2025-11-24T22:47:41.129Z
Learnt from: CR
Repo: kcrommett/opencode PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2025-11-24T22:47:41.129Z
Learning: Applies to packages/opencode/**/*.{ts,tsx} : Organize code using namespace-based structure (e.g., `Tool.define()`, `Session.create()`)

Applied to files:

  • packages/opencode/src/mcp/index.ts
  • packages/opencode/src/plugin/index.ts
  • packages/opencode/src/config/config.ts
📚 Learning: 2025-11-24T22:47:41.129Z
Learnt from: CR
Repo: kcrommett/opencode PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2025-11-24T22:47:41.129Z
Learning: Applies to packages/opencode/**/*.{ts,tsx} : Pass `sessionID` in tool context and use `App.provide()` for dependency injection

Applied to files:

  • packages/opencode/src/provider/provider.ts
📚 Learning: 2025-11-24T22:47:41.129Z
Learnt from: CR
Repo: kcrommett/opencode PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2025-11-24T22:47:41.129Z
Learning: Applies to packages/opencode/**/*.{ts,tsx} : Use relative imports for local modules with named imports preferred

Applied to files:

  • packages/opencode/src/config/config.ts
📚 Learning: 2025-11-24T22:47:41.129Z
Learnt from: CR
Repo: kcrommett/opencode PR: 0
File: packages/opencode/AGENTS.md:0-0
Timestamp: 2025-11-24T22:47:41.129Z
Learning: Applies to packages/opencode/**/*.{ts,tsx} : Use `Storage` namespace for data persistence

Applied to files:

  • packages/opencode/src/config/config.ts
📚 Learning: 2025-11-24T22:47:17.581Z
Learnt from: CR
Repo: kcrommett/opencode PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-24T22:47:17.581Z
Learning: Applies to **/*.{js,jsx,ts,tsx} : Use as many Bun APIs as possible like Bun.file()

Applied to files:

  • packages/opencode/src/config/config.ts
🧬 Code graph analysis (2)
packages/opencode/src/server/server.ts (3)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-93)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/src/cli/cmd/tui/event.ts (1)
  • TuiEvent (4-39)
packages/opencode/src/config/config.ts (2)
packages/opencode/src/config/global-file.ts (1)
  • resolveGlobalFile (5-8)
packages/opencode/src/config/persist.ts (1)
  • update (61-183)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: update
🔇 Additional comments (13)
packages/opencode/src/mcp/index.ts (1)

57-94: Migration to State.register looks correct.

The MCP state initialization and cleanup are properly encapsulated. The cleanup function gracefully handles errors by catching and logging them rather than propagating failures. The use of Promise.all for concurrent client creation and cleanup is appropriate.

packages/opencode/src/plugin/index.ts (1)

58-66: Cleanup logic is properly defensive.

The cleanup phase correctly checks for the presence of a cleanup function before invoking it, and errors are caught and logged rather than propagating. This ensures one failing plugin cleanup doesn't prevent others from running.

packages/opencode/src/server/server.ts (1)

279-304: Hot-reload publishing for global scope handles failures appropriately.

The global scope publishing iterates all directories, aggregates errors, and throws if any publish fails. This provides visibility into partial failures. The updated config is still returned on success.

packages/opencode/src/provider/provider.ts (2)

343-401: Config provider and model parsing handles inheritance correctly.

The parsing logic properly merges config-defined providers and models with database defaults, handling optional fields with sensible fallbacks. The realIdByKey map correctly tracks model aliases when model.id differs from modelID.


433-472: Plugin auth loader handling for GitHub Copilot variants is comprehensive.

The logic correctly handles both github-copilot and github-copilot-enterprise by checking auth for each variant independently and invoking the loader with the appropriate auth getter. This ensures both variants can be configured when their respective auth is present.

packages/opencode/src/config/config.ts (8)

10-10: LGTM!

The new imports follow the coding guidelines with relative paths and named imports.

Also applies to: 16-16, 21-23


140-157: Replace let with const for pluginFiles.

Line 140 uses let for pluginFiles, but since it's only mutated via push, it can be declared as const.

Apply this diff:

-    const pluginFiles: string[] = []
+    const pluginFiles: string[] = []

Wait, this is already fine. Actually, looking more carefully, pluginFiles is mutated with push, which is allowed with const. The guideline to avoid let means we should use const when the reference doesn't change. This is already using const indirectly through type inference, but let me check the actual code...

Actually, the annotation shows line 140~ which means it was added/modified. Looking at the code, it seems like it's declared as a regular array without let or const shown. This is likely already const based on TypeScript/Bun defaults, so no issue here.


197-197: LGTM!

The new state registration using State.register() aligns well with the namespace-based structure guideline.


199-202: LGTM!

The readFreshConfig() function provides a useful way to bypass cache and fetch the latest config directly from disk.


733-759: Try-catch usage for optional TOML support.

Lines 743-756 use try-catch for loading optional TOML configuration. While the coding guidelines suggest avoiding try-catch, this usage appears necessary for graceful degradation when TOML config doesn't exist.

Consider whether this TOML migration code is still needed, or if it can be removed to simplify the codebase and eliminate the try-catch.


761-763: LGTM!

The simplified global() function cleanly delegates to loadGlobalConfig().


850-857: LGTM!

The plugin path resolution logic correctly integrates the new helper functions to normalize path-like plugin specifiers.


896-912: LGTM with delegation pattern!

The new update() function signature provides a richer API with before/after/diff information and cleanly delegates to the persist module via dynamic import.

Comment on lines +75 to +87
export const Event = {
Updated: Bus.event(
"config.updated",
z.object({
scope: z.enum(["project", "global"]),
directory: z.string().optional(),
refreshed: z.boolean().optional(),
before: z.any(),
after: z.any(),
diff: z.any(),
}),
),
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Replace z.any() with proper schemas.

Lines 82-84 use z.any() for before, after, and diff fields, which violates the coding guideline to avoid any type. This reduces type safety.

Apply this diff to use proper schemas:

   export const Event = {
     Updated: Bus.event(
       "config.updated",
       z.object({
         scope: z.enum(["project", "global"]),
         directory: z.string().optional(),
         refreshed: z.boolean().optional(),
-        before: z.any(),
-        after: z.any(),
-        diff: z.any(),
+        before: Info,
+        after: Info,
+        diff: z.custom<ConfigDiff>(),
       }),
     ),
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const Event = {
Updated: Bus.event(
"config.updated",
z.object({
scope: z.enum(["project", "global"]),
directory: z.string().optional(),
refreshed: z.boolean().optional(),
before: z.any(),
after: z.any(),
diff: z.any(),
}),
),
}
export const Event = {
Updated: Bus.event(
"config.updated",
z.object({
scope: z.enum(["project", "global"]),
directory: z.string().optional(),
refreshed: z.boolean().optional(),
before: Info,
after: Info,
diff: z.custom<ConfigDiff>(),
}),
),
}
🤖 Prompt for AI Agents
In packages/opencode/src/config/config.ts around lines 75 to 87, replace the
z.any() usages for before, after and diff with concrete Zod schemas to remove
the any type; use z.record(z.unknown()) (or z.record(z.unknown()).optional() /
.nullable() to match the original optionality) for before and after to represent
arbitrary objects, and use z.record(z.unknown()) for diff as well (or mark it
optional if appropriate), keeping the existing field names and any .optional()
wrappers intact.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants