Skip to content

Instantly share code, notes, and snippets.

@matthewmorek
Created September 4, 2026 10:12
Show Gist options
  • Select an option

  • Save matthewmorek/44fcc55fe35f4d5c6fdcd7c9ba0ae2d7 to your computer and use it in GitHub Desktop.

Select an option

Save matthewmorek/44fcc55fe35f4d5c6fdcd7c9ba0ae2d7 to your computer and use it in GitHub Desktop.
OCX profile startup dependency reinstall investigation

Profile launches reinstall OpenCode dependencies on every startup

Summary

ocx oc -p <profile> creates a new writable ocx-oc-merged-* directory for every launch, copies the profile into it, points OpenCode at it with OPENCODE_CONFIG_DIR, passes merged JSON through OPENCODE_CONFIG_CONTENT, sets OPENCODE_DISABLE_PROJECT_CONFIG=true, and removes the merged directory after OpenCode exits.

OpenCode treats every writable config directory as a dependency-install root. In OpenCode 1.18.27, config loading starts an install in that directory, adding @opencode-ai/plugin@1.18.27. If an external/local plugin is present, plugin initialization waits for all config dependency installs to finish. Because OCX supplies a fresh directory on every invocation and deletes the completed install afterward, the same dependency resolution and installation can recur on every profile launch.

This creates startup delays ranging from seconds to minutes even with warm external caches. The issue is not specific to a particular profile or plugin; a generic profile containing one no-op local plugin is sufficient to exercise the path.

The most robust fix would be to give profile launches a stable or shared dependency location and reuse it across launches. Copying a completed dependency tree into each temporary directory is a useful proof/workaround, but the observed tree was about 75 MB, so copying it on every launch should not be the preferred design.

Environment

Observed on:

  • Apple Silicon (arm64) macOS 15.8
  • Bun 1.3.5
  • OCX 2.0.15
  • OpenCode 1.18.27
  • npm registry reachable over HTTPS

The installed versions and architecture were rechecked directly. The launcher lifecycle was also verified against:

  • OCX 2.0.15 source at commit 62106fb
  • current OCX main at commit 636dc2d as of 2026-09-04
  • OpenCode v1.18.27 at commit 4b7e19e

This does not appear to be an OCX 2.0.15-only regression: the behavior was also present with OCX 2.0.14, and the relevant profile-overlay lifecycle introduced by PR #143 remains in current source. It also does not appear to be new in OpenCode 1.18.27: related reports describe the same install/wait mechanism in OpenCode 1.14.x and 1.18.20.

Reproduction

Use a generic isolated profile rather than any published profile:

rm -rf /tmp/ocx-startup-repro
XDG_CONFIG_HOME=/tmp/ocx-startup-repro ocx init --global
XDG_CONFIG_HOME=/tmp/ocx-startup-repro ocx profile add fixture --global

mkdir -p /tmp/ocx-startup-repro/opencode/profiles/fixture/plugins

cat > /tmp/ocx-startup-repro/opencode/profiles/fixture/ocx.jsonc <<'JSON'
{
  "$schema": "https://ocx.kdco.dev/schemas/profile.json",
  "registries": {}
}
JSON

cat > /tmp/ocx-startup-repro/opencode/profiles/fixture/opencode.jsonc <<'JSON'
{
  "$schema": "https://opencode.ai/config.json"
}
JSON

cat > /tmp/ocx-startup-repro/opencode/profiles/fixture/plugins/noop.ts <<'TS'
export const FixturePlugin = async () => ({})
TS

Run a command that initializes config and plugins, twice:

XDG_CONFIG_HOME=/tmp/ocx-startup-repro time ocx oc -p fixture -- debug config
XDG_CONFIG_HOME=/tmp/ocx-startup-repro time ocx oc -p fixture -- debug config

While a slow launch is in progress, inspect the OpenCode process and the OS temporary directory. On macOS, $TMPDIR may resolve outside the literal /tmp path:

find "${TMPDIR:-/tmp}" -maxdepth 1 -name 'ocx-oc-merged-*' -print
lsof -p <opencode-pid>

The important reproducer property is the local file under plugins/; no npm plugin entry is required in opencode.jsonc. Registry/cache state affects the elapsed time, so a maintainer regression test should use a controlled registry or instrumentation rather than relying on a public-network delay.

Do not use --help as the reproduction command. OpenCode help exits before instance/plugin initialization, so it bypasses the affected path.

Expected behavior

  • A profile's dependencies are prepared once and reused until the profile, its project overlay, dependency manifest, or relevant OpenCode version changes.
  • The second launch should not resolve and materialize a new config-scoped dependency tree.
  • Startup should remain bounded and near the no-local-plugin baseline when dependencies are already available.

Actual behavior

  • Every profile launch receives a different ocx-oc-merged-* config directory.
  • OpenCode sees a writable config directory without node_modules and starts config dependency installation there.
  • The presence of any tested local plugin causes plugin initialization to await that install.
  • OCX removes the merged directory, including generated package.json, package-lock.json, and node_modules, after the child exits.
  • The next launch begins from another fresh directory and repeats the work.

An isolated launcher check with a fake OpenCode executable confirmed two distinct ocx-oc-merged-* paths across consecutive runs, confirmed that the local plugin was copied into each path, confirmed OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_CONTENT, and OPENCODE_DISABLE_PROJECT_CONFIG=true, and confirmed that both merged directories were gone after child exit.

During real OpenCode runs, lsof showed TLS connections to the npm registry and writes beneath the merged directory's config-scoped node_modules. A completed dependency directory occupied approximately 75 MB.

Measurements

Elapsed wall-clock measurements from the investigation:

Case Elapsed
Standalone OpenCode debug config control 0.607 s
Copied Workcell config with local plugins removed 0.619 s
Warm copied config with local plugins and prepared dependencies 0.773 s
Fresh copied config with local plugins; npm plugin array removed 148.609 s
Fresh full profile with warm external package cache >60 s
Actual ocx oc -p workcell -- debug config after stable profile dependencies were preinstalled 1.188 s

Additional minimization results:

  • Every local plugin tested by itself exceeded an 8-second timeout.
  • The same copied config with no local plugin completed in 0.619 s.
  • Removing the npm plugin array did not remove the delay because auto-discovered local file plugins still trigger the wait.
  • Testing plugins independently rules out session-metrics and Plannotator as necessary root causes.
  • The same lifecycle was reproduced after minimizing away Workcell v0.2.4-specific content, so this is not a Workcell-specific reproduction.

These values are observations from one machine, not proposed universal thresholds. The useful signal is the controlled split between a fresh config directory with a local plugin and either no local plugin or an already-prepared dependency tree.

Root cause

This is an interaction between two individually understandable lifecycle choices:

  1. OCX makes the effective profile ephemeral. OCX creates a fresh temporary directory with the ocx-oc-merged- prefix, copies every profile entry into it, optionally overlays project files, and returns a cleanup function that recursively removes it. The launcher supplies that path as OPENCODE_CONFIG_DIR and calls cleanup after OpenCode exits.
  2. OpenCode treats that effective profile as a writable install root. For each config directory, OpenCode 1.18.27 calls Npm.install(dir, { add: [{ name: "@opencode-ai/plugin", version: InstallationVersion }] }). If node_modules is absent, Npm.install immediately runs Arborist's reify in that directory.
  3. A local/external plugin makes startup wait. OpenCode forks those installs during config loading. When plugin origins are non-empty, plugin initialization calls Config.waitForDependencies(), which joins the dependency-install fibers before loading the plugins. Instance bootstrap initializes plugins before the remaining services.

Primary source references:

Why it repeats

OpenCode's install checks are scoped to the directory supplied as OPENCODE_CONFIG_DIR. A completed install would normally make later launches cheap because that same directory retains node_modules and its lockfile.

With an OCX profile launch, that state cannot become warm across invocations:

  1. launch A creates merged directory A;
  2. OpenCode installs dependencies into A;
  3. OCX removes A after OpenCode exits;
  4. launch B creates unrelated merged directory B;
  5. B has no retained install state, so OpenCode installs again.

A warm global/external package cache can reduce downloads, but it does not supply the missing config-scoped node_modules tree or lock state. That is consistent with the >60-second fresh-full-profile result despite a warm external cache.

Workaround and proof

Preinstalling the matching OpenCode plugin package and all profile runtime dependencies into the stable profile directory, including a consistent package manifest/lock and node_modules, reduced the actual OCX path to 1.188 seconds. OCX then copied the already-prepared tree into its temporary merged directory, and OpenCode did not have to materialize it from scratch before plugin load.

That is both a workaround and a causal check, but it has poor steady-state I/O characteristics: the prepared dependency tree was approximately 75 MB and is copied to a new temporary directory on every launch. It may also complicate profile updates, OpenCode-version changes, and stale dependency cleanup. A persistent/shared dependency design is preferable.

A second diagnostic workaround is to run OpenCode against a stable copied config directory and reuse that directory. The warm copied-config measurement was 0.773 seconds. This bypasses OCX's ephemeral overlay lifecycle and is therefore not a complete user-facing replacement for profile mode.

Potential fixes

In preferred order:

  1. Use a persistent prepared directory keyed by effective profile identity. Key it by inputs that can change the effective config, for example profile identity/revision, project-overlay inputs and policy, dependency manifest, and OpenCode version. Build or refresh it atomically, then reuse it across launches.
  2. Use a shared persistent dependency directory/cache. Keep merged config files ephemeral if required for overlay safety, but arrange for OpenCode's config install root to reuse a stable dependency tree. The exact mechanism should preserve module resolution and avoid cross-profile dependency contamination.
  3. Use the stable profile directory directly when no overlay is needed. If there are no project files to merge and no other transformation requiring a copy, OPENCODE_CONFIG_DIR could point at the stable profile path. This needs an explicit decision about allowing OpenCode-generated package and lock files in that directory.
  4. Retain prepared node_modules across merged-directory rebuilds. This is less attractive than a shared location but still avoids registry resolution and full reification each time.
  5. Install once as part of profile installation/update. OCX could prepare dependencies when a profile is installed or changed, then launch from retained state.

Please prefer a persistent/shared design over recursively copying a roughly 75 MB dependency tree for each invocation. Any cache key should account for profile updates, project overlay changes, dependency declarations, OpenCode version changes, and concurrent launches. Atomic preparation plus stale-entry cleanup would avoid exposing partially installed trees.

OpenCode could independently improve this path with progress, cancellation, or a timeout, as discussed in its related issues, but that would only bound the symptom. OCX is the component that intentionally replaces a normally persistent config directory with a disposable one, so retaining/reusing the install state in profile mode addresses the repeat-work root cause.

Regression test

Add an integration test with an isolated XDG config and a generic fixture profile containing exactly one no-op local file plugin.

The test must invoke the real bootstrap path twice:

ocx oc -p fixture -- debug config
ocx oc -p fixture -- debug config

Assertions should cover both bounded startup and reuse:

  • both invocations complete within a deterministic upper bound under the controlled test setup;
  • the second invocation reuses prepared dependency state rather than creating/reifying another fresh dependency tree;
  • registry requests or install calls do not repeat on the second invocation when the effective profile identity is unchanged;
  • changing a cache-key input invalidates/rebuilds the prepared state;
  • concurrent launches cannot observe a partial install;
  • cleanup does not remove the persistent/shared dependency state.

Use a controlled local registry, a counted install seam, or filesystem instrumentation so the test does not depend on public npm latency. A timing-only assertion is insufficient by itself; combine the bound with an explicit reuse assertion.

Do not substitute --help for debug config. Help bypasses plugin initialization and therefore cannot catch this regression.

Related issues and pull requests

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