Skip to content

Integration#31

Closed
shuv1337 wants to merge 47 commits intodevfrom
integration
Closed

Integration#31
shuv1337 wants to merge 47 commits intodevfrom
integration

Conversation

@shuv1337
Copy link
Collaborator

@shuv1337 shuv1337 commented Nov 21, 2025

Summary by CodeRabbit

  • New Features
    • Model favoriting with cycle-forward and cycle-reverse keybinds and dialog support for favorites and recents.
    • Runtime config updates (PATCH /config) with optional hot-reload, atomic writes, backups and restore support.
  • Bug Fixes
    • Improved config validation, safer persistence, and preserved JSONC comments on updates.
    • Read tool now returns multi-part outputs when attachments exist.
  • Documentation
    • Added config update endpoint spec and keybind documentation.

✏️ 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

Refactors many modules to use a named State.register lifecycle, adds a robust config persistence layer with locking, backups, atomic JSONC writes and diffs, implements config hot-reload and invalidation orchestration, introduces model favorites in the CLI, and expands keybinds and tests to cover the new flows.

Changes

Cohort / File(s) Summary
State lifecycle refactor
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
Replaced ad-hoc Instance.state usage with State.register(name, root, init, dispose?) across modules, moving lifecycle init/teardown into the State registry and binding state to directory context.
State registry core
packages/opencode/src/project/state.ts
Added named registration, internal namedRegistry, register(...) API, and State.invalidate(name[, key]) with wildcard support and cleanup of recordsByKey.
Config persistence & update flow
packages/opencode/src/config/persist.ts, packages/opencode/src/config/write.ts, packages/opencode/src/config/lock.ts, packages/opencode/src/config/backup.ts, packages/opencode/src/config/error.ts, packages/opencode/src/config/diff.ts
New end-to-end config update pipeline: target resolution, file locking, timestamped backups, JSONC-aware atomic writes (incremental edits), diff computation, validation, restore-on-error and structured error types.
Config core & hot-reload
packages/opencode/src/config/config.ts, packages/opencode/src/config/global-file.ts, packages/opencode/src/config/hot-reload.ts, packages/opencode/src/config/invalidation.ts
Added disk-first loadStateFromDisk/readFreshConfig, plugin path resolution, Event.Updated emission, global config file resolver, hot-reload flag helper, and invalidation namespace with apply() and setup() to orchestrate per-subsystem invalidations on config changes.
Config-related server integration
packages/opencode/src/server/server.ts, packages/opencode/src/project/bootstrap.ts
PATCH /config handling enhanced to persist diffs, publish updates conditionally based on hot-reload, track per-directory LastConfigUpdate for toast enrichment, and call ConfigInvalidation.setup() during bootstrap.
CLI model favorites & dialog
packages/opencode/src/cli/cmd/tui/context/local.tsx, packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx, packages/opencode/src/cli/cmd/tui/app.tsx
Added persisted favorites list, APIs toggleFavorite/cycleFavorite/favorite accessor, save() helper, favorites-aware model dialog options, Ctrl+F keybind and new commands for cycling favorites.
Agent aggregation & permissions
packages/opencode/src/agent/agent.ts
Rewrote agent construction to use State.register, dynamic accumulation from cfg.agent, merged default tools/permissions, per-item disable support, and top-level plan/build/general entries with updated defaults.
File cache & tool registry
packages/opencode/src/file/index.ts, packages/opencode/src/tool/registry.ts
File cache uses temporary structure with explicit refresh(); tool registry moved to State.register pattern.
Session & read tool changes
packages/opencode/src/session/prompt.ts
Read tool now emits synthetic parts for attachments, supporting multi-part outputs.
UI keyboard event hook
packages/ui/src/components/select-dialog.tsx
Added optional onKeyEvent prop to SelectDialogProps to forward keyboard events with the currently selected item.
SDK keybinds
packages/sdk/go/config.go, packages/sdk/python/src/opencode_ai/models/keybinds_config.py, packages/web/src/content/docs/keybinds.mdx
Added model_cycle_favorite and model_cycle_favorite_reverse keybinds (Go, Python SDKs and docs) and corresponding default entries.
Tests & specs
packages/opencode/test/config/*.test.ts, packages/opencode/test/config/hot-reload.test.ts, packages/opencode/test/config/write.test.ts, specs/config-spec.md
New/updated tests for config hot-reload, plugin resolution, JSONC incremental writes, and a new config-spec doc describing PATCH/GET /config semantics.
Instance extensions
packages/opencode/src/project/instance.ts
Added Instance.invalidate(name) and Instance.forEach(fn) for registry invalidation and iterating cached contexts.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant CLI as TUI
    participant Dialog as Model Dialog
    participant Local as Local Store
    participant Disk as Disk
    participant Config as Config Service
    participant State as State Registry
    participant Invalidate as ConfigInvalidation

    User->>CLI: Open model picker / use favorite key
    CLI->>Dialog: request options
    Dialog->>Local: get favorites & recents
    Local-->>Dialog: enriched options
    User->>Dialog: toggle favorite (Ctrl+F)
    Dialog->>Local: toggleFavorite(model)
    Local->>Disk: save() -> persist favorites
    Disk-->>Local: persisted

    User->>Config: PATCH /config (update)
    Config->>Disk: acquireLock(filepath)
    Config->>Disk: createBackup && read existing
    Config->>Config: merge & validate
    Config->>Disk: writeConfigFile (atomic JSONC)
    Config->>Disk: releaseLock && remove backup
    Config->>State: State.invalidate(matched names)
    State->>Invalidate: emits Config.Updated
    Invalidate->>State: apply(diff) -> trigger per-subsystem refresh
    State-->>User: return { before, after, diff, filepath }
Loading
sequenceDiagram
    participant Bootstrap
    participant State as State Registry
    participant Plugin as Plugin State
    participant Provider as Provider State
    participant Invalidate as ConfigInvalidation

    Bootstrap->>Invalidate: ConfigInvalidation.setup()
    Bootstrap->>State: register("plugin", root, init, dispose)
    Bootstrap->>State: register("provider", root, init, dispose)
    Note right of State: registrations tracked in namedRegistry

    User->>Config: update(...)
    Config->>State: invalidate("provider:*")
    State->>Provider: dispose + re-init on next access
    State->>Invalidate: notify Config.Updated -> apply()
    Invalidate->>Provider: request provider refresh
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

  • Focus review on:
    • packages/opencode/src/config/persist.ts, write.ts — locking, backup/restore, incremental JSONC edits and error paths
    • packages/opencode/src/config/lock.ts — timeouts, stale-lock removal, retry logic
    • packages/opencode/src/project/state.ts — registry, invalidate semantics and cleanup
    • packages/opencode/src/config/invalidation.ts — orchestration and error aggregation
    • packages/opencode/src/agent/agent.ts — permission/tool merging and agent override rules
    • packages/opencode/src/server/server.ts — publish/hot-reload logic and toast enrichment

Poem

🐰
I hopped through state and saved a file,
Made backups, locks, and JSONC smile.
Favorites twinkle, keys now dance,
Invalidations give change a chance.
A little rabbit cheered: “Persist in style!”

Pre-merge checks and finishing touches

❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 1.35% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check ❓ Inconclusive The PR title 'Integration' is vague and generic, using a non-descriptive term that does not convey meaningful information about the substantial changeset. Use a more specific title that captures the primary change (e.g., 'Add model favorites feature and config hot-reload system' or 'Implement config persistence and invalidation system').
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ 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 integration

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

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: 7

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/project/state.ts (1)

9-137: Tighten invalidate semantics: doc mismatch and potential namedRegistry leakage

Two small issues worth addressing:

  1. Wildcard comment vs implementation

The docstring says the wildcard prefix is “before the :*” (e.g. "cache:*""cache"), but the implementation uses name.slice(0, -1), which yields "cache:". That’s still a usable prefix for names like "cache:foo", but the comment is misleading.

If you want the documented behavior, change the slice to remove both characters:

-  export async function invalidate(name: string, key?: string) {
-    const pattern = name.endsWith(":*") ? name.slice(0, -1) : null
+  export async function invalidate(name: string, key?: string) {
+    const pattern = name.endsWith(":*") ? name.slice(0, -2) : null

If the current "cache:"-style prefix is intentional, I’d instead update the comment to say so.

  1. namedRegistry entries never removed

Neither invalidate nor dispose prunes namedRegistry, so over time you can accumulate (name, key, init) tuples for directories and states that no longer exist. Functionally it’s harmless (subsequent invalidations just find no recordsByKey entry), but in a long‑lived process hopping across many directories this will leak some memory.

You can clean this up with minimal changes, for example:

  • In invalidate, track and remove the NamedEntry objects you actually invalidated, and delete the name entirely when empty:
-    const tasks: Promise<void>[] = []
-    for (const entry of entries) {
+    const tasks: Promise<void>[] = []
+    const toRemove: NamedEntry[] = []
+    for (const entry of entries) {
       if (key && entry.key !== key) continue
@@
       if (stateEntry.dispose) {
@@
         tasks.push(task)
       }
-
-      keyRecords.delete(entry.init)
+      keyRecords.delete(entry.init)
+      toRemove.push(entry)
+      if (keyRecords.size === 0) {
+        recordsByKey.delete(entry.key)
+      }
     }
+
+    for (const entry of toRemove) {
+      entries.delete(entry)
+    }
+    if (entries.size === 0) {
+      namedRegistry.delete(name)
+    }
  • In dispose(key), also strip out any NamedEntry for that key:
-    entries.clear()
-    recordsByKey.delete(key)
+    entries.clear()
+    recordsByKey.delete(key)
+
+    for (const [name, namedEntries] of namedRegistry) {
+      for (const entry of Array.from(namedEntries)) {
+        if (entry.key === key) {
+          namedEntries.delete(entry)
+        }
+      }
+      if (namedEntries.size === 0) {
+        namedRegistry.delete(name)
+      }
+    }

These changes keep behavior the same while preventing stale registry entries from building up.

Also applies to: 168-168

🧹 Nitpick comments (15)
packages/opencode/src/session/prompt.ts (1)

903-928: Read tool multi-part handling looks solid; a couple of minor robustness nits

The new flow (always emitting a synthetic text part from result.output and then either mapped attachments or the original part) matches the described behavior and should integrate well with existing message handling. A couple of small, optional improvements:

  • When mapping result.attachments, consider ensuring each attachment has an id to avoid any downstream assumptions about presence/uniqueness of id:
    ...result.attachments.map((attachment) => ({
      ...attachment,
      id: attachment.id ?? Identifier.ascending("part"),
      synthetic: true,
      filename: attachment.filename ?? part.filename,
      messageID: info.id,
      sessionID: input.sessionID,
    }))
  • If there are cases where result.output can be an empty string while attachments carry the real payload, you may want to skip pushing the synthetic text part when !result.output to avoid extra empty bubbles.

These are polish-level suggestions; the core behavior change looks correct.

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

144-153: Tighten ENOENT guard for TypeScript type-safety

The ENOENT handling is fine logically, but the current narrowing:

.catch((error) => {
  if (error instanceof Error && "code" in error && error.code === "ENOENT") return
  log.error("failed to refresh files", { error })
})

may be brittle under useUnknownInCatchVariables and strict mode, since Error doesn’t declare code. A small type guard avoids potential TS errors:

-      fetchEntries()
-        .catch((error) => {
-          if (error instanceof Error && "code" in error && error.code === "ENOENT") return
-          log.error("failed to refresh files", { error })
-        })
+      fetchEntries()
+        .catch((error) => {
+          const code = typeof error === "object" && error !== null && "code" in error ? (error as any).code : undefined
+          if (code === "ENOENT") return
+          log.error("failed to refresh files", { error })
+        })

This keeps runtime behavior the same while staying friendly to stricter TS configs.


155-162: First files() call likely returns empty cache; confirm non-blocking semantics are intended

With the current flow:

  • refresh() is fired once in the initializer (Line 155) but not awaited.
  • fetchEntries() begins the ripgrep scan asynchronously and eventually sets cache = temp.
  • files() (Lines 158-162) triggers another refresh() only when !fetching, but always returns the current cache immediately without waiting.

This means the very first files() call after state creation will almost certainly return the initial { files: [], dirs: [] } snapshot while the first scan is still running, and subsequent calls will always see the previous snapshot (never block for the latest scan).

If that “eventually consistent, never-blocking” behavior is intentional, this is fine; if callers expect the first search() to see a warmed list, you may want a one-time awaited warm-up, e.g.:

-    let fetching = false
+    let fetching = false
+    let initialized = false
@@
-    const refresh = () => {
-      fetching = true
-      fetchEntries()
+    const refresh = async () => {
+      fetching = true
+      await fetchEntries()
         .catch((error) => {
           // ENOENT handling as above
         })
         .finally(() => {
           fetching = false
         })
     }
-    refresh()
+    // prime the cache once
+    const initPromise = refresh().finally(() => {
+      initialized = true
+    })
@@
-      async files() {
-        if (!fetching) {
-          refresh()
-        }
-        return cache
-      },
+      async files() {
+        if (!initialized) {
+          await initPromise
+        } else if (!fetching) {
+          void refresh()
+        }
+        return cache
+      },

This preserves non-blocking refreshes after the first call but guarantees the initial files() result is based on a completed scan. Please confirm which behavior you want.

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

9-75: JSONC write tests look solid; consider a couple of extra assertions

These tests nicely cover comment preservation and JSONC validity, including trailing commas. To tighten coverage further, you might also assert that the top‑level model and nested agent.build/plan models are updated as expected in the incremental case (not just theme), to guard against partial-update regressions.

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

83-101: LastConfigUpdate cache and timer are fine, but consider lifecycle/cleanup

The per‑directory LastConfigUpdate cache with a 60s TTL and a 60s setInterval sweeper works for a long‑running server, but the interval is never cleared. In embedded/test scenarios that import Server without actually listen‑ing, this can keep the process alive longer than intended and makes the timer effectively global.

If that’s a concern for you, consider either:

  • Clearing the interval as part of a server teardown, or
  • Dropping the periodic timer and doing opportunistic cleanup on reads (since you already check freshness with isFresh).

Functionally this is fine; this is more about process lifecycle hygiene.


1938-1967: Toast enrichment logic works; cross‑directory fallback may surprise slightly

The enrichment of “Saved (global|project) config …” toasts based on LastConfigUpdate is robust (directory‑scoped first, 10s freshness window, safe try/catch, and only altering messages that match the pattern). That should get rid of the -> undefined UX without risking hard failures.

One behavioral detail: when there’s no fresh entry for the current directory, you fall back to the freshest entry for the same scope across all directories. That means a toast in one project can occasionally show sections from another project if they save around the same time. If you’d prefer stricter correctness over “best available” enrichment, you could skip the cross‑directory fallback and only enrich when there’s a fresh entry for the current directory.

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

15-67: Plugin state registration is good; watch for duplicate/default plugins and export shape

The State‑backed lifecycle for plugins (init + cleanup) looks solid and the teardown path with per‑hook cleanup() is a nice improvement.

Two minor things to consider here:

  1. Duplicate default plugins
    If a user explicitly lists "opencode-copilot-auth@0.0.5" or "opencode-anthropic-auth@0.0.2" in config.plugin, the current logic will load that plugin twice (once from config, once from the default pushes). A simple dedupe avoids surprising double registration, e.g.:

  •  const plugins = [...(config.plugin ?? [])]
    
  •  if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
    
  •    plugins.push("opencode-copilot-auth@0.0.5")
    
  •    plugins.push("opencode-anthropic-auth@0.0.2")
    
  •  }
    
  •  for (let plugin of plugins) {
    
  •  const plugins = new Set(config.plugin ?? [])
    
  •  if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
    
  •    plugins.add("opencode-copilot-auth@0.0.5")
    
  •    plugins.add("opencode-anthropic-auth@0.0.2")
    
  •  }
    
  •  for (let plugin of plugins) {
    
    
    
  1. Assuming every export is a plugin function
    Object.entries(mod) treats every export as a PluginInstance and calls it. If a plugin module ever exposes additional values (e.g. constants or helpers), this will throw at runtime. If you anticipate that, you could defensively filter exports by typeof fn === "function" before calling.

Neither is a blocker, but tightening these would make plugin loading more robust.

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

1-133: Filesystem locking looks solid; consider two small robustness tweaks

The combination of an in‑memory mutex (fileLocks) and an O_CREAT|O_EXCL lockfile is clean and should work well. A couple of things to consider:

  • The stale-lock removal is purely mtimeMs-based with a hard default of 60s. If any config operation can legitimately hold the lock for longer (e.g., slow I/O on network filesystems), this will aggressively delete a still-valid lock. You already expose staleAfter; it may be worth:
    • Bumping the default for safety, and/or
    • Using the stored { pid, createdAt } to optionally check whether the process is still alive before forcibly removing the lock (even a best-effort process.kill(pid, 0) check when on the same host).
  • acquireLock normalizes but does not resolve the path. If callers ever pass a mix of relative and absolute paths for the same underlying file, you could end up with two independent in‑process locks. If that’s a realistic risk, switching to path.resolve(filepath) instead of normalize would harden this.

None of these are correctness blockers, but tightening them would make the lock more resilient in edge cases.

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

465-505: Cross‑platform nit: build expected file URL via pathToFileURL instead of string concatenation

The "appends plugins discovered from directories after merging config files" test expectation currently builds the plugin URL as:

expect(config.plugin).toEqual([
  "local-plugin",
  `file://${path.join(globalTmp.path, "plugin", "custom.ts")}`,
])

On POSIX this happens to match file:///tmp/..., but on Windows this would yield something like file://C:\path\plugin\custom.ts, which does not match a canonical file URL and may differ from whatever the implementation produces.

For portability and consistency with the earlier test, consider:

-        expect(config.plugin).toEqual(["local-plugin", `file://${path.join(globalTmp.path, "plugin", "custom.ts")}`])
+        const globalPluginUrl = pathToFileURL(
+          path.join(globalTmp.path, "plugin", "custom.ts"),
+        ).href
+        expect(config.plugin).toEqual(["local-plugin", globalPluginUrl])

This keeps the intent while avoiding OS-specific path quirks.

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

93-148: Test names vs asserted behavior (full dispose vs hot-reload)

The first three tests exercise config update semantics correctly, but the “without full dispose” / “uses full dispose” behavior isn’t actually asserted (e.g., by checking instance lifecycle or cache reuse vs re-init). If distinguishing full dispose vs hot-reload behavior is important, consider adding explicit assertions (e.g., on invalidation calls or instance state) or renaming tests to reflect what’s concretely verified.

Also applies to: 150-172


174-201: Strengthen assertions around local overrides vs global updates

The “global updates propagate despite local overrides” and “project updates remain scoped to the initiator” tests correctly verify observer/global behavior, but they don’t assert that the writer’s local project config remains effective in the presence of a global model. Adding explicit checks that the writer still sees the project model (and not the global one) would better lock in the intended precedence rules.

Also applies to: 302-336

packages/opencode/src/cli/cmd/tui/context/local.tsx (2)

102-125: Persistence shape change for model.json—consider backward compatibility

The switch to persisting { recent, favorite } via save() and then reading via x.recent / x.favorite assumes model.json is an object with those properties. If earlier versions ever wrote a bare array or a different shape, existing users could silently lose their recent history on upgrade. It may be worth tolerating both formats (e.g., if (Array.isArray(x)) setModelStore("recent", x) as a migration path) to avoid discarding prior recents.

Also applies to: 129-137, 141-148


254-272: toggleFavorite and set semantics are reasonable, but persistence is best-effort

set() only persists when options?.recent is truthy, and toggleFavorite always persists after updating the favorites list. Given Bun.write is not awaited, persistence is intentionally best-effort and asynchronous—fine for a non-critical MRU/favorites file. If you ever need stronger guarantees (e.g., for debugging flaky state), consider awaiting save() in these paths.

Also applies to: 273-291

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

392-399: Defensive check around database[providerID] in custom loader loop

In the custom loader block you assume every CUSTOM_LOADERS key has a corresponding entry in database:

for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) {
  if (disabled.has(providerID)) continue
  const result = await fn(database[providerID])
  ...
}

If a loader is added for a provider that doesn’t exist in database (or is removed from models.dev), loaders like opencode that dereference input.env / input.models will throw.

A small guard makes this more robust without changing behavior when the database is complete:

-      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)
         if (result && (result.autoload || providers[providerID])) {
           mergeProvider(providerID, result.options ?? {}, "custom", result.getModel)
         }
       }
packages/opencode/src/config/write.ts (1)

25-49: Incremental JSONC updates + atomic writes look solid; consider minor no-op optimization

The overall flow in writeConfigFile + applyIncrementalUpdates is well-structured:

  • Existing JSON/JSONC content is patched via jsonc-parser.modify based on ConfigDiff and deep equality, then validated with validateJsonc.
  • Non-JSONC paths fall back to a full JSON rewrite.
  • writeFileAtomically correctly uses a temp file in the same directory, fsyncs the file and then the directory (on non-Windows), and cleans up temp files on failure.

A couple of non-blocking tweaks you might consider:

  1. Avoid no-op writes: if applyIncrementalUpdates returns a string identical to existingContent, you could skip writeFileAtomically to reduce churn on disk and avoid triggering unnecessary watchers.

  2. Clarify .json handling: isJsonc currently treats both .jsonc and .json as JSONC for incremental edits. That’s perfectly valid (JSON ⊂ JSONC) but is a subtle behavior change; a short code comment explaining the intention (preserve formatting/comments for both) would make this clearer to future readers.

Also applies to: 84-175

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e34df15 and 4423bc5.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (37)
  • packages/opencode/src/agent/agent.ts (2 hunks)
  • packages/opencode/src/cli/cmd/tui/app.tsx (1 hunks)
  • packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx (4 hunks)
  • packages/opencode/src/cli/cmd/tui/context/local.tsx (4 hunks)
  • packages/opencode/src/command/index.ts (2 hunks)
  • packages/opencode/src/config/backup.ts (1 hunks)
  • packages/opencode/src/config/config.ts (10 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/session/prompt.ts (1 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)
  • packages/sdk/go/config.go (2 hunks)
  • packages/sdk/python/src/opencode_ai/models/keybinds_config.py (6 hunks)
  • packages/ui/src/components/select-dialog.tsx (2 hunks)
  • packages/web/src/content/docs/keybinds.mdx (1 hunks)
  • specs/config-spec.md (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (25)
packages/opencode/src/tool/registry.ts (2)
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/config/invalidation.ts (3)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-93)
packages/opencode/src/config/hot-reload.ts (1)
  • isConfigHotReloadEnabled (1-3)
packages/opencode/test/config/write.test.ts (1)
packages/opencode/src/config/write.ts (1)
  • writeConfigFile (25-49)
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/diff.ts (1)
packages/opencode/src/config/config.ts (2)
  • Info (529-714)
  • Info (716-716)
packages/opencode/src/project/bootstrap.ts (1)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-93)
packages/opencode/src/config/backup.ts (1)
packages/opencode/script/postinstall.mjs (1)
  • targetPath (73-73)
packages/opencode/src/permission/index.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/command/index.ts (3)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/config/config.ts (3)
  • state (197-197)
  • Info (529-714)
  • Info (716-716)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
packages/opencode/src/file/watcher.ts (4)
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/file/time.ts (1)
  • state (6-15)
packages/opencode/src/lsp/index.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/project/instance.ts (2)
packages/opencode/src/cli/cmd/agent.ts (2)
  • fn (17-132)
  • fn (143-155)
packages/opencode/src/cli/cmd/github.ts (1)
  • fn (142-358)
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-93)
packages/opencode/src/plugin/index.ts (3)
packages/plugin/src/index.ts (2)
  • Plugin (27-27)
  • PluginInput (19-25)
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/test/config/hot-reload.test.ts (2)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-93)
packages/opencode/src/project/bootstrap.ts (1)
  • InstanceBootstrap (15-30)
packages/opencode/src/config/persist.ts (9)
packages/opencode/src/project/instance.ts (2)
  • directory (39-41)
  • Instance (15-93)
packages/opencode/src/config/global-file.ts (1)
  • resolveGlobalFile (5-8)
packages/opencode/src/config/config.ts (3)
  • Info (529-714)
  • Info (716-716)
  • update (881-897)
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/src/config/write.ts (2)
packages/opencode/src/config/diff.ts (1)
  • ConfigDiff (4-26)
packages/opencode/src/config/config.ts (2)
  • Info (529-714)
  • Info (716-716)
packages/opencode/src/mcp/index.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/file/index.ts (2)
packages/opencode/src/util/log.ts (2)
  • file (50-52)
  • error (135-139)
packages/opencode/src/project/instance.ts (1)
  • Instance (15-93)
packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx (1)
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx (1)
  • filter (149-151)
packages/opencode/src/format/index.ts (3)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/config/config.ts (3)
  • state (197-197)
  • Info (529-714)
  • Info (716-716)
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/agent/agent.ts (3)
packages/opencode/src/project/instance.ts (2)
  • state (48-50)
  • Instance (15-93)
packages/opencode/src/config/config.ts (3)
  • state (197-197)
  • Info (529-714)
  • Info (716-716)
packages/opencode/src/tool/registry.ts (1)
  • state (27-58)
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)
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/project/state.ts (3)
packages/opencode/src/project/instance.ts (3)
  • state (48-50)
  • dispose (76-80)
  • invalidate (51-53)
packages/opencode/src/config/config.ts (1)
  • state (197-197)
packages/opencode/src/tool/registry.ts (2)
  • state (27-58)
  • register (78-86)
🔇 Additional comments (43)
packages/ui/src/components/select-dialog.tsx (2)

14-14: Good addition for extensibility.

The new optional onKeyEvent prop enables custom keyboard event handling while maintaining backward compatibility.


66-72: LGTM! Clean implementation with good reuse.

The selected item is computed once and reused, which is more efficient and consistent. Note that onKeyEvent receives the item state before the key is processed—for navigation keys, this means it gets the current selection before the active item updates. This ordering is appropriate for enabling custom keybind handling that can intercept default behavior.

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

126-143: Atomic cache rebuild via temp looks correct

Building into a local temp and swapping cache = temp at the end avoids partially-populated snapshots leaking to callers, and the directory walk guards (dir === "." / dir === current) plus the Set for de-duping look sound across platforms. No functional issues spotted here.

packages/sdk/go/config.go (2)

1938-1941: LGTM: Keybind fields added correctly.

The new ModelCycleFavorite and ModelCycleFavoriteReverse fields are properly declared and follow the existing pattern for keybind configuration fields.


2015-2016: LGTM: JSON metadata fields added correctly.

The corresponding apijson.Field entries for the new keybind fields are properly included in the JSON metadata struct, maintaining consistency with the struct definition.

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

1-123: LGTM: Well-structured config diff implementation.

The module provides a clean API for computing configuration differences:

  • ConfigDiff interface clearly defines tracked fields
  • computeKeysChanged helper efficiently identifies added/removed/modified keys
  • computeDiff systematically compares all relevant config fields using deep equality
  • Plugin diffing correctly handles array differences

The implementation is comprehensive and follows good patterns for change detection.

packages/sdk/python/src/opencode_ai/models/keybinds_config.py (1)

46-47: LGTM: Complete keybind field integration.

The new model_cycle_favorite and model_cycle_favorite_reverse fields are correctly integrated throughout the class:

  • Properly declared with default value "none"
  • Included in docstring documentation
  • Serialization in to_dict() is complete
  • Deserialization in from_dict() is complete
  • Constructor properly handles both fields

The implementation follows the established pattern for all other keybind fields.

Also applies to: 100-101, 183-185, 288-291, 396-398, 469-470

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

1-45: LGTM: Well-designed error types.

The new error types provide comprehensive coverage for config operations:

  • ConfigUpdateError captures update context (filepath, scope, directory)
  • ConfigValidationError provides detailed validation errors with field-level information
  • ConfigWriteConflictError includes timeout tracking for conflict scenarios
  • ConfigWriteError covers all write operations (create, write, backup, restore)

All schemas are properly structured with appropriate field types and constraints.

packages/web/src/content/docs/keybinds.mdx (1)

41-42: LGTM: Documentation updated correctly.

The new keybind entries are properly documented with default value "none", consistent with the SDK definitions in Go and Python.

packages/opencode/src/cli/cmd/tui/app.tsx (1)

251-268: LGTM: Favorite cycling commands added correctly.

The new command entries follow the established pattern:

  • Properly categorized under "Agent"
  • Keybind names match the config field names (model_cycle_favorite, model_cycle_favorite_reverse)
  • Direction parameters (1, -1) are consistent with existing cycle commands
  • Integration with local.model.cycleFavorite() is clean
packages/opencode/src/config/global-file.ts (1)

1-8: LGTM: Clean global config path resolver.

The implementation is straightforward and correct:

  • Creates the config directory recursively (safe if already exists)
  • Returns the full path to the global config file
  • Simple and focused functionality
packages/opencode/src/lsp/index.ts (1)

9-9: LGTM: State registration refactor applied correctly.

The migration from Instance.state() to State.register() maintains the existing logic while adopting the new state management pattern:

  • Named registration ("lsp") enables targeted invalidation
  • Root resolver remains the same
  • Initialization and cleanup logic are unchanged

This aligns with the broader state lifecycle restructuring across the codebase.

Also applies to: 62-64

packages/opencode/src/config/hot-reload.ts (1)

1-3: LGTM—clean feature flag implementation.

The explicit string comparison === "true" is clear and unambiguous, preventing accidental truthy values from enabling hot-reload.

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

40-69: LGTM—consistent state lifecycle migration.

The migration from Instance.state() to State.register() aligns with the broader refactoring across the codebase. The initialization logic is preserved, and the default command fallback remains intact.

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

57-94: LGTM—proper resource lifecycle with teardown.

The migration to State.register() correctly pairs client initialization with teardown. The teardown function properly closes all MCP clients and handles errors gracefully, preventing resource leaks.

specs/config-spec.md (1)

1-100: LGTM—comprehensive API specification.

The spec clearly documents the PATCH /config endpoint behavior, including:

  • Scope selection and file targeting
  • Locking and backup/restore flow
  • Hot-reload vs. legacy behavior toggle
  • Error cases and client workflow examples

The backward-compatibility guidance (lines 97-100) is particularly valuable for integrators.

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

27-58: LGTM—consistent state lifecycle migration.

The migration from Instance.state() to State.register() maintains the existing tool discovery logic while aligning with the centralized state management pattern.

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

53-85: LGTM—proper cleanup of pending permissions.

The migration to State.register() includes a teardown function that rejects all pending permission requests, preventing hanging promises when the state is disposed. This is crucial for graceful shutdown.

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

51-53: LGTM—clean delegation to State API.

The invalidate() method provides a convenient Instance-level wrapper for state invalidation, maintaining API consistency with the existing Instance.state() pattern.


54-75: LGTM—robust per-directory iteration with error isolation.

The forEach() implementation correctly:

  • Awaits each context before invoking the callback
  • Isolates errors per directory using .catch() on each iteration
  • Normalizes non-Error exceptions to Error instances
  • Logs a warning summary when any errors occur
  • Returns detailed error information for caller inspection

This pattern enables safe broadcast operations (e.g., config invalidation across all tracked directories) without one failure blocking others.

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

13-17: ConfigInvalidation.setup() is correctly idempotent. No action needed.

The implementation uses a promise-caching pattern that guarantees idempotency: setupPromise is declared as a module-level variable initialized to undefined (line 18). The setup function guards against re-execution with an early return if setupPromise already exists (lines 173-174). The event subscription to Bus.subscribe() is registered inside the IIFE (line 179), which only executes on the first invocation. Subsequent calls return the cached promise immediately without re-running setup logic, preventing duplicate event listeners and resource leaks.

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

253-303: Config PATCH hot‑reload gating and error aggregation look consistent

The new PATCH /config flow with:

  • explicit scope (project/global),
  • a single Config.update call,
  • diffForPublish‑based section extraction,
  • hot‑reload gating via isConfigHotReloadEnabled, and
  • aggregated publish errors for global scope

is coherent and lines up with the hot‑reload toggle semantics. The per‑directory publish in the global branch plus detailed error message construction (dir: message) should make partial failures debuggable.

The only thing to double‑check is that diffForPublish’s contract really is “flat record of sections → boolean”, since sections is built from keys with value === true; if nested or non‑boolean shapes ever appear there, toast enrichment will become misleading.

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

5-72: State.register integration for the file watcher looks correct

Using State.register("filewatcher", () => Instance.directory, …) aligns with the new invalidate API and keeps per-directory watcher lifecycle nicely scoped. The initializer handles unsupported platforms and non‑git projects gracefully, and the disposer safely no‑ops when no subscription exists.

No changes needed here.

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

4-26: Helpers for hot-reload flag and scoped plugin fixture look good

withHotReloadFlag correctly snapshots and restores OPENCODE_CONFIG_HOT_RELOAD even when fn throws, and scopedPluginFixture uses tmpdir + await using to keep filesystem state isolated. The minimal package.json/plugin setup is enough to exercise plugin resolution without pulling in real dependencies.

No changes needed here; this is a nice pattern for future env-dependent tests.

Also applies to: 28-61


271-297: Scoped + relative plugin path test aligns with expected resolution

The "preserves scoped plugin specifiers and resolves relative plugin paths" test is well-structured:

  • It covers both a scoped npm plugin and a relative path plugin in the same config.
  • It asserts the resolved config uses a proper file:// URL (pathToFileURL(...).href), which is portable across platforms.

This gives good regression coverage for the new plugin path handling logic.


418-421: Using Config.update’s returned filepath strengthens this test

Switching to:

const result = await Config.update({ update: newConfig as any })
const writtenConfig = JSON.parse(await Bun.file(result.filepath).text())

is a nice improvement: the test no longer has to guess where the config ended up and instead relies on the API contract. This should make it more robust against future changes in persistence layout.


437-463: Hot-reload on/off behavior for scoped plugins is well-covered

The two tests around scoped plugins and OPENCODE_CONFIG_HOT_RELOAD exercise:

  • Enabled hot reload ("true") with withHotReloadFlag, verifying the plugin specifier remains "@scope/plugin".
  • Explicitly disabled hot reload (env var unset), verifying the same outcome.

Together with scopedPluginFixture, this should catch regressions where scoped npm plugin identifiers are rewritten differently under hot reload vs non‑hot‑reload modes.

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

13-24: Helper setup for temp globals and workspaces looks solid

The helper utilities (withFreshGlobalPath, createWorkspace, patchConfig, getConfig, subscribeWithContext, ensureInstance, cleanup) are well-structured, restore global state in finally, and keep tests relatively self-contained. No functional issues stand out here.

Also applies to: 26-89


13-24: Global env/config mutation may be fragile under parallel test execution

These tests mutate process.env.OPENCODE_CONFIG_HOT_RELOAD and Global.Path.config in multiple places, restored with finally, which is good. If Bun’s test runner ever executes tests in this file in parallel, though, this shared global mutation could introduce flakiness. Consider enforcing serial execution for this suite or centralizing env/global overrides in a single, serialized harness.

Also applies to: 93-368


264-300: Fan-out error handling test is nicely targeted

The fan-out test that subscribes on a target instance, forces a publish failure, and then still asserts that the target’s config was updated (despite a 500 response) is a good coverage point for “partial success + aggregated error” semantics. No issues here.


338-368: Theme-only invalidation test clearly pins desired invalidation set

Monkey-patching Instance.invalidate to assert only ["config", "theme"] are hit for a theme-only diff is a clean way to guard against future changes accidentally broadening invalidation scope. The test logic and restoration of the original invalidate function look correct.

packages/opencode/src/cli/cmd/tui/context/local.tsx (1)

202-205: Favorite list APIs and cycleFavorite behavior look coherent

The new favorite() getter plus cycleFavorite behavior (valid-model filtering, wraparound, and auto-seeding recents with a cap of 5) form a consistent UX and keep persistence in sync via save(). The guard that shows an informational toast when no favorites exist is a nice touch.

Also applies to: 227-253

packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx (1)

34-75: Options orchestration for recent/favorite/core models is well structured

The composition of favoriteOptions, recentOptions, and core provider model options (including favorite stars, “Free” footer, and category labels) is clear and keeps duplicates out of the unfiltered list. Using connected() to gate provider descriptions/categories and leaving Free models visible even when not connected is a sensible UX tradeoff.

Also applies to: 76-99, 100-159

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

20-41: Target file resolution and normalization behavior look correct

determineTargetFile’s precedence for project files plus creation of a default .opencode/opencode.jsonc under .opencode/ is sensible, and loadFileContent/normalizeConfig give you a clean base ($schema, agent, mode, plugin) for subsequent diffing and validation. No issues here.

Also applies to: 43-59


143-179: Error handling and Zod validation mapping look appropriate

The backup-restore logic plus ConfigValidationError and ConfigUpdateError wrapping provide good diagnostics without leaking raw Zod errors. Mapping ZodIssue entries into { field, message, expected, received } is helpful for surfacing schema issues to callers. Overall error handling here looks correct.

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

43-105: Agent state registration and default agents are consistent and clear

Using State.register("agent", () => Instance.directory, async () => { ... }) to derive a map of agents from cfg.agent plus built-ins (general, build, plan) is in line with the broader State-based lifecycle. The default agents’ tools and modes, and the way you layer user-defined agents on top (including disable support and defaultTools merging), all look coherent.

Also applies to: 106-139

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

240-487: State.register integration for provider state looks coherent

The migration to State.register("provider", () => Instance.directory, async () => { ... }) keeps provider/model data scoped per directory and lines up with the new config/tool state patterns. The internal shape { models, providers, sdk, realIdByKey } is consistent with downstream uses (list, getProvider, getModel, getSDK), and the GitHub Copilot Enterprise augmentation plus post-filtering of models appear well-contained.

No blocking issues here beyond the more specific concerns called out in other comments.

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

117-221: Nested update handling for provider/mcp keys is a good balance of precision and simplicity

buildUpdateInstructions + buildNestedInstructions do a nice job of:

  • Respecting ConfigDiff flags while still falling back to deep equality when no diff is provided.
  • Minimizing edits for nested records like provider and mcp by emitting per-child updates or deletions ([key, childKey]), while still replacing whole sub-objects for deeper changes.

The use of isDeepEqual, hasOwn, and sortInstructions keeps the behavior predictable and deterministic. No issues from a correctness standpoint here.


247-295: Atomic writer and directory sync behavior are appropriately cautious

writeFileAtomically and syncDirectory are implemented in a robust, POSIX-friendly way:

  • Temp file in the target directory, mode 0o600, fsync + close before rename.
  • Rename wrapped in a try/catch that cleans up the temp file on failure.
  • Directory fsync is attempted on non-Windows platforms with reasonable error logging and graceful degradation when EISDIR/ENOENT occurs.

This is suitable for config persistence and should behave well across platforms.

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

27-73: Plugin path-like spec detection and resolution look correct and cross-platform aware

The combination of WINDOWS_RELATIVE_PREFIXES, isPathLikePluginSpecifier, and resolvePluginFileReference gives you:

  • Support for file://, ~/, ./, ../, absolute paths, and Windows-style relative/absolute paths.
  • Resolution of relative paths against the config file location.
  • Normalization via pathToFileURL (and normalizeWindowsPath for URL-style separators).

Applying this in load() only when data.plugin?.length and leaving non-path-like specifiers (e.g., package names) untouched is a good balance. The logic should behave well on both POSIX and Windows.

One small note: since plugin URLs are only normalized in-memory and not written back (unlike $schema), it’s intentional that user config on disk stays as originally authored.

Also applies to: 835-841


89-165: Config state loader and State.register wiring are consistent with the new state model

loadStateFromDisk now centralizes config aggregation:

  • Starts from global() and merges project/up-stack opencode.jsonc/opencode.json.
  • Honors OPENCODE_CONFIG, OPENCODE_CONFIG_CONTENT, and well-known auth providers.
  • Collects a directories array including Global.Path.config, discovered .opencode dirs, and OPENCODE_CONFIG_DIR.
  • Loads commands/agents/modes/plugins per directory and appends discovered plugin files.

Registering this via:

export const state = State.register("config", () => Instance.directory, loadStateFromDisk)

and then exposing get()/directories() through state() gives you per-directory caching and aligns nicely with other modules that use State.register.

readFreshConfig() delegating directly to loadStateFromDisk() is also a clear escape hatch for bypassing the cache when needed.

Also applies to: 191-203


718-748: Global config loading now uses resolveGlobalFile, keeping all sources consistent

Switching loadGlobalConfig to:

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)),
)

ensures the canonical global JSONC path is used consistently, while still honoring legacy config.json / opencode.json files and TOML migration. This looks correct and keeps the behavior centralized in resolveGlobalFile.


881-897: Config.update delegation to persist module matches the new persistence flow

The new Config.update signature and implementation:

export async function update(input: { scope?: "project" | "global"; update: Info; directory?: string }) {
  const scope = input.scope ?? "project"
  const directory = input.directory ?? Instance.directory

  const { update: persistUpdate } = await import("./persist")
  return persistUpdate({ scope, update: input.update, directory })
}

correctly:

  • Defaults scope to "project" and directory to Instance.directory.
  • Forwards the update to config/persist.ts, which now owns diff computation, writing, backup, and cache invalidation.
  • Returns the enriched { before, after, diff, diffForPublish, filepath } structure expected by callers.

This looks consistent with the rest of the refactor.

Comment on lines +140 to 182
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 | 🟠 Major

Permission merging can drop safe defaults and mutates config objects

Two subtle issues in the permission path:

  1. Loss of doom_loop / external_directory defaults

    • agentPermission and planPermission correctly start from defaultPermission / planPermission (with doom_loop/external_directory defaulting to "ask"), merged with cfg.permission.
    • Inside the for (const [key, value] of Object.entries(cfg.agent ?? {})) loop, the final if (permission ?? cfg.permission) branch overwrites item.permission with mergeAgentPermissions(cfg.permission ?? {}, permission ?? {}), which ignores defaultPermission.
    • For agents that have an entry in cfg.agent (including overrides of built-ins) and a global cfg.permission that doesn’t mention doom_loop / external_directory, the merged result can change those fields from "ask" to undefined. This weakens the default safety posture for those overrides.

    Consider basing this merge on the agent’s existing permission (item.permission) instead, e.g. mergeAgentPermissions(item.permission, permission ?? {}), so that per-agent overrides extend the default rather than replacing it.

  2. mergeAgentPermissions mutates its arguments

    • The normalization logic rewrites basePermission.bash and overridePermission.bash when they’re strings. If cfg.permission is passed in, this mutates the shared config object and may have surprising side effects elsewhere that read cfg.permission.
    • A shallow clone of the inputs before normalization (or working on local temporaries) would avoid this kind of in-place mutation.

Addressing both would make the permission model safer and easier to reason about.

Also applies to: 226-263

Comment on lines +172 to +178
{
keybind: Keybind.parse("ctrl+f")[0],
title: "favorite",
onTrigger: (option) => {
local.model.toggleFavorite(option.value as ModelValue)
},
},
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 ctrl+f favorite keybind against non-model options / undefined selection

onTrigger and onSelect both cast option.value to ModelValue. Two edge cases to consider:

  • If DialogSelect ever calls onTrigger with option undefined (e.g., empty list or no current selection), this will throw.
  • When !connected(), you append provider options from providers(), whose value shape may not match { providerID; modelID }. Pressing ctrl+f on such an option will flow a malformed object into toggleFavorite, leading to confusing “Model undefined/undefined is not valid” toasts.

A small defensive check like if (!option || !("providerID" in option.value && "modelID" in option.value)) return; before calling toggleFavorite / set would avoid these edge cases without changing the happy path.

Also applies to: 184-187

🤖 Prompt for AI Agents
In packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx around lines
172-178 (and similarly for 184-187), the callback casts option.value to
ModelValue unguarded which can throw if option is undefined or when provider
options (added when disconnected) have a different shape; add a defensive
early-return that verifies option is truthy and that option.value is an object
containing both providerID and modelID (e.g., check option && typeof
option.value === "object" && "providerID" in option.value && "modelID" in
option.value) before calling local.model.toggleFavorite or local.model.set so
malformed or empty selections are ignored.

Comment on lines +3 to +17
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 | 🟠 Major

Clarify behavior when the source file doesn’t exist to avoid failing restore paths

createBackup always returns a backup path, even when the source file is missing and no backup was actually written. If callers unconditionally call restoreBackup with that path, fs.copyFile(backupPath, targetPath) will throw when backupPath doesn’t exist, which is likely to happen on first‑run or “no config yet” scenarios.

You may want to make the “no backup created” case explicit, e.g.:

  • Return Promise<string | null> from createBackup and only call restoreBackup when non‑null, or
  • Keep the current return type but make restoreBackup a no‑op when the backup file is missing (optionally logging).

For example, one option on the backup side:

-export async function createBackup(filepath: string): Promise<string> {
+export async function createBackup(filepath: string): Promise<string | null> {
   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
+  if (await Bun.file(filepath).exists()) {
+    await fs.copyFile(filepath, backupPath)
+    return backupPath
+  }
+
+  return null
 }

(With corresponding call‑site updates.)

📝 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 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)
}
export async function createBackup(filepath: string): Promise<string | null> {
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
}
return null
}
export async function restoreBackup(backupPath: string, targetPath: string): Promise<void> {
await fs.copyFile(backupPath, targetPath)
await fs.unlink(backupPath)
}

Comment on lines +19 to +57
async function invalidateProvider(diff: ConfigDiff): Promise<void> {
await Instance.invalidate("provider")
}

async function invalidateMCP(diff: ConfigDiff): Promise<void> {
await Instance.invalidate("mcp")
}

async function invalidateLSP(diff: ConfigDiff): Promise<void> {
await Instance.invalidate("lsp")
}

async function invalidateFileWatcher(): Promise<void> {
await Instance.invalidate("filewatcher")
}

async function invalidatePlugin(diff: ConfigDiff): Promise<void> {
await Instance.invalidate("plugin")
}

async function invalidateToolRegistry(): Promise<void> {
await Instance.invalidate("tool-registry")
}

async function invalidatePermission(): Promise<void> {
await Instance.invalidate("permission")
}

async function invalidateCommandAgentFormat(diff: ConfigDiff): Promise<void> {
if (diff.command) await Instance.invalidate("command")
if (diff.agent) await Instance.invalidate("agent")
if (diff.formatter) await Instance.invalidate("format")
}

async function invalidateUIAndPrompts(diff: ConfigDiff): Promise<void> {
if (diff.instructions) await Instance.invalidate("instructions")
if (diff.theme) await Instance.invalidate("theme")
}

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

Ensure share/autoshare changes actually invalidate their state

The invalidation orchestration looks good overall (nice use of targets + tasks and guarded diff checks), but there’s a small inconsistency around share/autoshare:

  • shareSettingsChanged is computed and, when true, "share-settings" is added to targets:
const shareSettingsChanged = diff.share || diff.autoshare
const uiChanged = diff.theme || diff.instructions || shareSettingsChanged
if (uiChanged) {
  if (diff.theme) targets.add("theme")
  if (diff.instructions) targets.add("instructions")
  if (shareSettingsChanged) targets.add("share-settings")
  tasks.push(invalidateUIAndPrompts(diff))
}
  • However, invalidateUIAndPrompts only invalidates "instructions" and "theme":
async function invalidateUIAndPrompts(diff: ConfigDiff): Promise<void> {
  if (diff.instructions) await Instance.invalidate("instructions")
  if (diff.theme) await Instance.invalidate("theme")
}

So when only share/autoshare flips, you log "share-settings" as a target but never call Instance.invalidate("share-settings"). If there’s state registered under that name, it won’t be refreshed.

Two options:

  • If you intend a real share-settings state, add a dedicated invalidation:
-async function invalidateUIAndPrompts(diff: ConfigDiff): Promise<void> {
-  if (diff.instructions) await Instance.invalidate("instructions")
-  if (diff.theme) await Instance.invalidate("theme")
-}
+async function invalidateUIAndPrompts(diff: ConfigDiff): Promise<void> {
+  if (diff.instructions) await Instance.invalidate("instructions")
+  if (diff.theme) await Instance.invalidate("theme")
+  if (diff.share || diff.autoshare) await Instance.invalidate("share-settings")
+}
  • If there is no such state (and you only log it for now), consider dropping "share-settings" from targets to avoid misleading logs.

Also, several helper functions (invalidateProvider, invalidateMCP, invalidatePlugin, etc.) accept diff but don’t use it. Trimming those unused parameters would simplify the signatures a bit.

Also applies to: 118-133

🤖 Prompt for AI Agents
In packages/opencode/src/config/invalidation.ts around lines 19 to 57 (and also
118 to 133), the code adds "share-settings" to targets when diff.share or
diff.autoshare changes but invalidateUIAndPrompts never invalidates
"share-settings", and several invalidateX functions accept an unused diff
parameter; fix by either adding a call to await
Instance.invalidate("share-settings") inside invalidateUIAndPrompts when
diff.share || diff.autoshare is true, or remove "share-settings" from the
targets generation if no state exists, and remove the unused diff parameters
from helper functions (invalidateProvider, invalidateMCP, invalidatePlugin,
etc.) to simplify signatures and update any call sites accordingly.

Comment on lines +61 to +142
export async function update(input: { scope: "project" | "global"; update: Config.Info; directory: string }): Promise<{
before: Config.Info
after: Config.Info
diff: ConfigDiff
diffForPublish: ConfigDiff
filepath: string
}> {
const filepath = await determineTargetFile(input.scope, input.directory)
const release = await acquireLock(filepath)

log.info("config.update.start", {
scope: input.scope,
directory: input.directory,
filepath,
})

const beforeGlobal = input.scope === "global" ? await Config.global() : undefined

try {
const backupPath = await createBackup(filepath)

try {
const before = await Config.get()

const existingContent = await loadFileContent(filepath)
const fileContent = existingContent ? parseJsonc(existingContent) : {}
const previousParsed = existingContent ? Config.Info.safeParse(fileContent) : undefined
const previousNormalized = previousParsed?.success ? normalizeConfig(previousParsed.data) : undefined

const merged = mergeDeep(fileContent, input.update)

const validated = Config.Info.parse(merged)

const normalized = normalizeConfig(validated)
const writerDiff = previousNormalized ? computeDiff(previousNormalized, normalized) : undefined

await writeConfigFile(filepath, normalized, existingContent, {
diff: writerDiff,
previous: previousNormalized,
}).catch((error) => {
log.error("JSONC write failed, attempting fallback", {
filepath,
error: String(error),
})

const content = JSON.stringify(normalized, null, 2) + "\n"
return writeFileAtomically(filepath, content)
})

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

log.info("config.update.cacheInvalidated", {
scope: input.scope,
directory: input.directory,
filepath,
cacheInvalidated: hotReloadEnabled && input.scope === "global",
hotReloadEnabled,
})

const after = hotReloadEnabled ? await Config.get() : await Config.readFreshConfig()
const afterGlobal = input.scope === "global" ? await Config.global() : undefined

const diff = computeDiff(before, after)
const diffForPublish = input.scope === "global" ? computeDiff(beforeGlobal!, afterGlobal!) : diff

if (await Bun.file(backupPath).exists()) {
await fs.unlink(backupPath)
}

log.info("config.update.persisted", {
scope: input.scope,
directory: input.directory,
filepath,
})

return { before, after, diff, diffForPublish, filepath }
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

Update flow is robust; small logging inconsistency around cache invalidation

The update pipeline—lock, backup, JSONC-aware merge+validate, atomic write with JSON fallback, hot-reload-aware invalidation, then diff computation—looks solid and handles both project and global scopes cleanly, including diffForPublish based on global-only diffs. One minor nit: in the cache-invalidated log, cacheInvalidated is set to hotReloadEnabled && input.scope === "global", even though you also invalidate Instance for project scope when hot reload is enabled. If that field is consumed downstream, consider either renaming it (e.g., globalCacheInvalidated) or including project-scope invalidation in the flag.

🤖 Prompt for AI Agents
In packages/opencode/src/config/persist.ts around lines 61-142, the log entry
"config.update.cacheInvalidated" sets cacheInvalidated to hotReloadEnabled &&
input.scope === "global" which is inconsistent because
Instance.invalidate("config") is called for project scope; update the log to
accurately reflect what was invalidated — either replace cacheInvalidated with
two booleans (globalCacheInvalidated: hotReloadEnabled && input.scope ===
"global", projectCacheInvalidated: hotReloadEnabled && input.scope ===
"project") or rename to globalCacheInvalidated and add a separate
projectCacheInvalidated flag; ensure the values are computed from
hotReloadEnabled and input.scope and update any downstream consumers if
necessary.

Comment on lines +27 to +67
const state = State.register(
"format",
() => Instance.directory,
async () => {
const enabled: Record<string, boolean> = {}
const cfg = await Config.get()

const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
const formatters: Record<string, Formatter.Info> = {}
if (cfg.formatter === false) {
log.info("all formatters are disabled")
return {
enabled,
formatters,
}
}
}

for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
for (const item of Object.values(Formatter)) {
formatters[item.name] = item
}
for (const [name, item] of Object.entries(cfg.formatter ?? {})) {
if (item.disabled) {
delete formatters[name]
continue
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})
if (result.command.length === 0) continue
result.enabled = async () => true
result.name = name
formatters[name] = result
}
const result: Formatter.Info = mergeDeep(formatters[name] ?? {}, {
command: [],
extensions: [],
...item,
})

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

result.enabled = async () => true
result.name = name
formatters[name] = result
}

return {
enabled,
formatters,
}
})
return {
enabled,
formatters,
}
},
)
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 against non‑formatter exports when iterating Object.values(Formatter)

The new state initializer assumes every runtime export from ./formatter is a Formatter.Info:

for (const item of Object.values(Formatter)) {
  formatters[item.name] = item
}

If formatter.ts ever exports anything that isn’t an Info object (e.g. a helper function or constant), this will:

  • Insert it into formatters with item.name (e.g. function name), and
  • Later cause runtime failures when isEnabled calls item.enabled() or when code expects an extensions array.

To make this more future‑proof, add a small runtime guard so only Info‑shaped values are picked up, e.g.:

-      for (const item of Object.values(Formatter)) {
-        formatters[item.name] = item
-      }
+      for (const value of Object.values(Formatter)) {
+        if (
+          value &&
+          typeof value === "object" &&
+          typeof (value as Formatter.Info).name === "string" &&
+          Array.isArray((value as Formatter.Info).extensions) &&
+          typeof (value as Formatter.Info).enabled === "function"
+        ) {
+          const item = value as Formatter.Info
+          formatters[item.name] = item
+        }
+      }

That keeps the auto‑discovery benefit while preventing accidental registration of non‑formatter exports. The rest of the merge/override logic (honoring disabled, requiring non‑empty command, and forcing enabled = async () => true for config‑defined formatters) looks good.

Comment on lines 370 to 381
const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
// 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",
)
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

Env API key resolution ignores all but the first env var and recomputes config unnecessarily

The env-loading block has two problems:

  1. It only inspects the first env var in provider.env, so if the first is unset but a later one is set, the provider will be skipped.
  2. It calls Config.get() again even though config is already available earlier in the initializer.

You can fix both by reusing config and properly searching for the first set env var:

-      const disabled = await Config.get().then((cfg) => new Set(cfg.disabled_providers ?? []))
+      const disabled = new Set(config.disabled_providers ?? [])
       // 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)
+        const envVars = provider.env ?? []
+        const apiKey = envVars
+          .map((name) => process.env[name])
+          .find((value) => value !== undefined && value !== "")
         if (!apiKey) continue
         mergeProvider(
           providerID,
           // only include apiKey if there's only one potential option
-          provider.env.length === 1 ? { apiKey } : {},
+          envVars.length === 1 ? { apiKey } : {},
           "env",
         )
       }
🤖 Prompt for AI Agents
In packages/opencode/src/provider/provider.ts around lines 370 to 381, the
env-loading loop incorrectly only reads the first declared env var and
re-queries Config.get(); update it to reuse the existing config variable for
disabled providers and to find the first set environment variable by iterating
provider.env (or using find) and checking process.env for each entry, assign
that value to apiKey if found, and keep the existing behavior of passing {
apiKey } to mergeProvider only when provider.env.length === 1.

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 +85 to +87
const existingContent = await loadFileContent(filepath)
const fileContent = existingContent ? parseJsonc(existingContent) : {}
const previousParsed = existingContent ? Config.Info.safeParse(fileContent) : undefined

Choose a reason for hiding this comment

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

P1 Badge Permit trailing commas when parsing config before writes

The persistence path parses the existing config with parseJsonc(existingContent) but does not enable allowTrailingComma like the reader (load in config.ts) does. Valid JSONC files that contain trailing commas (e.g., { "model": "foo", }) are successfully loaded by Config.get(), but a PATCH /config will hit this parser and throw before writing or emitting events, making config updates fail for otherwise valid files.

Useful? React with 👍 / 👎.

@shuv1337 shuv1337 closed this Nov 21, 2025
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 logic.

Lines 181-183 and 185-187 contain identical code that checks autoshare === true and sets share = "auto". This is a copy-paste error.

Apply this diff to remove the duplication:

   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)

386-396: Env API key resolution still only inspects the first env var.

The env-loading block still has the issue previously flagged: it only inspects the first env var in provider.env using .at(0), so if the first is unset but a later one is set, the provider will be skipped.

Apply this diff to properly search for the first set env var:

       // 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)
+        const envVars = provider.env ?? []
+        const apiKey = envVars
+          .map((name) => process.env[name])
+          .find((value) => value !== undefined && value !== "")
         if (!apiKey) continue
         mergeProvider(
           providerID,
           // only include apiKey if there's only one potential option
-          provider.env.length === 1 ? { apiKey } : {},
+          envVars.length === 1 ? { apiKey } : {},
           "env",
         )
       }
🧹 Nitpick comments (2)
packages/opencode/src/server/server.ts (2)

85-101: Config update cache is fine; consider extracting TTL constants and unref-ing the cleanup timer

The LastConfigUpdate map and cleanup loop look correct for the enrichment use case, but there are a couple of small lifecycle nits:

  • The 60 s expiry and sweep interval are hard-coded; extracting them into named constants would make it easier to keep them in sync and tune later.
  • The setInterval at Line 89 will keep the process alive in short‑lived tools/tests that import this module (e.g., just to run openapi()), because the timer is never unref’d or cleared.

You could wrap the interval in a variable, pull the TTL into a constant, and unref the timer when supported:

-  const LastConfigUpdate: Map<string, { scope: "project" | "global"; sections: string[]; at: number }> = new Map()
-
-  // Periodically clean up stale entries from LastConfigUpdate
-  setInterval(() => {
-    const now = Date.now()
-    for (const [dir, entry] of LastConfigUpdate.entries()) {
-      if (now - entry.at > 60_000) {
-        LastConfigUpdate.delete(dir)
-      }
-    }
-  }, 60_000)
+  const LastConfigUpdate: Map<string, { scope: "project" | "global"; sections: string[]; at: number }> = new Map()
+  const LAST_CONFIG_UPDATE_TTL_MS = 60_000
+  const LAST_CONFIG_UPDATE_SWEEP_MS = 60_000
+
+  // Periodically clean up stale entries from LastConfigUpdate
+  const lastConfigUpdateTimer = setInterval(() => {
+    const now = Date.now()
+    for (const [dir, entry] of LastConfigUpdate.entries()) {
+      if (now - entry.at > LAST_CONFIG_UPDATE_TTL_MS) {
+        LastConfigUpdate.delete(dir)
+      }
+    }
+  }, LAST_CONFIG_UPDATE_SWEEP_MS)
+  if (typeof (lastConfigUpdateTimer as any).unref === "function") {
+    ;(lastConfigUpdateTimer as any).unref()
+  }

43-43: Config PATCH + hot‑reload fan‑out looks solid; consider tightening scope handling and partial‑failure semantics

The new flow to:

  • take scope + directory,
  • call Config.update and use result.before/after/diffForPublish,
  • gate Config.Event.Updated publishing on isConfigHotReloadEnabled(), and
  • remember sections via rememberConfigUpdate

is coherent and matches the intended hot‑reload behavior.

Two refinements you might want to consider:

  1. Scope validation
    scope is currently taken from c.req.query("scope") and cast to "project" | "global" (Line 253). Any unexpected value (e.g. ?scope=foo) will silently flow into Config.update and the subsequent conditionals. If the public API is meant to be strict, a small Zod enum validator on the query (or an explicit runtime guard that falls back to "project" and/or returns 400 on invalid values) would make this safer and self‑documenting.

  2. Global fan‑out error semantics
    In the global branch, any failure in Instance.forEach results in aggregating publishErrors and throwing a new Error (Lines 291‑300). By that point, the config has already been persisted, so the client will see a 500 even though the write succeeded and only some listeners failed to be notified. If you expect transient notification issues, you may want to:

    • keep the logging you have,
    • but either return 200 with an errors field in the response, or downgrade to a non‑fatal warning, depending on how critical immediate hot‑reload is for callers.

No changes are strictly required, but tightening these two behaviors would make this endpoint more predictable from an API consumer’s perspective.

Also applies to: 253-304

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4423bc5 and 6d6be71.

⛔ Files ignored due to path filters (1)
  • packages/sdk/js/src/gen/types.gen.ts is excluded by !**/gen/**
📒 Files selected for processing (4)
  • packages/opencode/src/config/config.ts (10 hunks)
  • packages/opencode/src/provider/provider.ts (2 hunks)
  • packages/opencode/src/server/server.ts (4 hunks)
  • packages/opencode/src/session/prompt.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/opencode/src/session/prompt.ts
🧰 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/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 (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)
🔇 Additional comments (16)
packages/opencode/src/provider/provider.ts (4)

241-515: State.register pattern correctly applied.

The refactoring properly migrates provider initialization to a State.register lifecycle, matching the pattern used in other modules. The scoped timer, async initialization, and returned state structure are all correct.


415-454: Complex GitHub Copilot Enterprise handling is correct.

The plugin auth loading properly handles both github-copilot and github-copilot-enterprise providers separately. The logic correctly:

  • Checks for auth on both variants
  • Loads main provider only if main auth exists
  • Loads enterprise provider only if enterprise auth exists
  • Calls the plugin loader with the appropriate Auth.get function for each

461-506: Model filtering and provider cleanup logic is comprehensive.

The filtering correctly:

  • Removes disallowed providers
  • Blacklists specific problematic models
  • Filters experimental/alpha/deprecated models (with flag override)
  • Applies provider-specific whitelist/blacklist from config
  • Removes providers with no models after filtering
  • Handles openrouter npm package special case

308-318: Verify the github-copilot auth plugin provides API endpoint for enterprise provider.

The code structure appears sound—database["github-copilot-enterprise"] is created with api: undefined and relies on the external opencode-copilot-auth plugin's auth.loader function to return the API endpoint via options at line 446-450. However, verification of the external plugin implementation is required to confirm it properly handles the enterprise provider. Ensure the plugin's loader returns the appropriate API endpoint in the returned options for github-copilot-enterprise.

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

1948-1977: Toast enrichment around config saves is defensive and matches the intended UX

The enrichment for /tui/show-toast looks good:

  • It’s gated on a clear regex (Saved (global|project) config), so unrelated toasts are untouched.
  • It prefers the current directory’s fresh entry, with a bounded 10 s window, and has a sane “freshest by scope” fallback.
  • All of it is wrapped in a best‑effort try { … } catch {} and falls back to the original payload on any error, so it can’t break toast delivery.
  • The "no changes" fallback when there are no sections is a nice UX improvement over -> undefined.

I don’t see any functional issues here; behavior is consistent with the new LastConfigUpdate tracking.

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

27-41: LGTM! Comprehensive path detection.

The isPathLikePluginSpecifier function correctly identifies various path formats including Windows-specific prefixes, relative paths, absolute paths, and file:// URLs.


43-73: LGTM! Robust plugin reference resolution.

The function handles multiple path formats correctly including Unix and Windows paths, relative and absolute paths, and home directory expansion. The try-catch block provides a safe fallback for edge cases.


75-87: LGTM! Well-structured config update event.

The event definition appropriately captures the scope, directory, and state changes (before/after/diff) needed for config update notifications.


140-164: LGTM! Plugin accumulation logic is sound.

The code correctly accumulates plugin files from multiple directories and defensively ensures result.plugin is an array before pushing accumulated plugin files.


197-197: LGTM! Proper State lifecycle registration.

The state export correctly uses the new State.register lifecycle pattern, binding config state to the instance directory with the loadStateFromDisk loader function.


199-202: LGTM! Clean fresh config accessor.

The readFreshConfig function provides a straightforward way to bypass caching and load configuration directly from disk.


724-754: LGTM! Improved global config loading.

The refactoring to use resolveGlobalFile() improves modularity and the global() function correctly delegates to loadGlobalConfig().


841-846: LGTM! Plugin resolution integrated correctly.

The code properly detects and resolves path-like plugin references while preserving npm package names, allowing both types of plugin specifications to coexist.


493-494: LGTM! New favorite model keybinds.

The new keybinds for cycling favorite models are properly defined with "none" as defaults, allowing users to opt-in by configuring them.


874-881: The code correctly references z.core.$ZodIssue[], which is the documented way to type an array of Zod issue objects in Zod v4. The syntax used in line 878 is valid and compatible with Zod 4.

Likely an incorrect or invalid review comment.


887-903: LGTM! Clean separation of concerns.

The update function correctly delegates to the persist module, maintaining a clean separation between the config API surface and persistence implementation. Verification confirms that Config.Updated events are properly emitted in the server layer after config updates complete, with all relevant context (before, after, diff) included in the event payload.

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.

4 participants