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.
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
mainat commit636dc2das 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.
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 () => ({})
TSRun 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 configWhile 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.
- 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.
- Every profile launch receives a different
ocx-oc-merged-*config directory. - OpenCode sees a writable config directory without
node_modulesand 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, andnode_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.
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-metricsand 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.
This is an interaction between two individually understandable lifecycle choices:
- 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 asOPENCODE_CONFIG_DIRand calls cleanup after OpenCode exits. - 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 }] }). Ifnode_modulesis absent,Npm.installimmediately runs Arborist'sreifyin that directory. - 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:
- OCX merged-directory prefix, create/copy/cleanup lifecycle:
opencode-overlay.tsat v2.0.15, copy and recursive cleanup, and freshmkdtemppreparation - OCX environment construction:
opencode.ts - OCX child spawn and post-exit cleanup:
opencode.ts - OpenCode starts config-directory installs and adds the matching plugin package: v1.18.27
config.ts(raw) - OpenCode's config install writes through Arborist and installs when
node_modulesis absent: v1.18.27npm.tsand install checks - OpenCode joins dependency fibers in
Config.waitForDependencies(): v1.18.27config.ts - OpenCode waits when local/external plugins exist: v1.18.27
plugin/index.ts(raw) - Plugin initialization is on the bootstrap path before other services: v1.18.27
project/bootstrap.ts
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:
- launch A creates merged directory A;
- OpenCode installs dependencies into A;
- OCX removes A after OpenCode exits;
- launch B creates unrelated merged directory B;
- 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.
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.
In preferred order:
- 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.
- 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.
- 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_DIRcould point at the stable profile path. This needs an explicit decision about allowing OpenCode-generated package and lock files in that directory. - Retain prepared
node_modulesacross merged-directory rebuilds. This is less attractive than a shared location but still avoids registry resolution and full reification each time. - 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.
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 configAssertions 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.
- anomalyco/opencode#47212 — First launch can stall on project
.opencodedependency install: reports the same config-directory install plus plugin wait behavior in OpenCode 1.18.20, including a fast second launch when the directory is retained. - anomalyco/opencode#33905 — Instance bootstrap can hang indefinitely during plugin dependency installation: documents the same unbounded wait mechanism in OpenCode 1.14.x; it was closed as a duplicate, with later discussion noting likely mis-triage.
- kdcokenny/ocx#131 — local plugin installed without dependencies: adjacent dependency-lifecycle failure for local plugins.
- kdcokenny/ocx#132 — run Bun install after writing npm dependencies: unmerged attempt to address #131; discussion confirms the expectation that OpenCode installs config dependencies automatically, which is precisely what becomes costly when the config directory is disposable.
- kdcokenny/ocx#143 — harden profile overlay mode: introduced the temporary merged-directory overlay and cleanup lifecycle used by profile launches.