Environment
@cortexkit/aft-opencode 0.55.1
- macOS, OpenCode Desktop
- Multiple directory-scoped plugin instances in the same host process
Summary
Disposing one directory's AFT plugin instance runs a process-wide cleanup registry. This shuts down bridge pools belonging to other, still-active plugin instances and causes outstanding shell calls to fail with:
[aft-plugin] Bridge shutting down
The host uses directory-keyed plugin state and invokes each instance's dispose() when that directory is unloaded. AFT creates a separate transport pool for each factory invocation, but its returned dispose() invokes all registered cleanups rather than only that invocation's cleanup.
Observed sequence
On September 10, 2026, the host disposed one project while shell calls were running in another. Project names below are replaced with A and B; timestamps are UTC.
| Timestamp |
Event |
15:36:02.097 |
OpenCode logs disposing instance for project A. |
15:36:02.099 |
AFT logs Shutdown triggered by dispose — running 8 cleanup(s). |
15:36:02.103 |
Two outstanding Bash calls in project B return, coinciding with the shutdown. |
15:36:02.129 |
AFT logs three Process exited during shutdown messages. |
15:36:05.237 |
New tool demand triggers transport was shut down but new demand arrived — reviving (host quit hook fired without process exit?). |
15:36:05.240 |
AFT spawns a replacement bridge. |
The process-exit messages describe AFT bridge children, not proof that the OpenCode host exited. OpenCode continued logging under the same run ID. The parenthetical in the revival warning is speculative; the explicit shutdown reason was dispose.
Installed-source evidence
References below are to the installed package's dist/index.js in version 0.55.1.
Each factory invocation owns a separate pool
At lines 40413–40414, the plugin factory delegates to initializePluginForDirectory(input). Each invocation creates its transport pool at lines 40651–40678. The standalone transport factory constructs a new BridgePool at lines 16430–16448.
Cleanup registration is process-wide
Lines 36868–36874:
var GLOBAL_KEY = "__aftShutdownHooks__";
function getState() {
const g = globalThis;
if (!g[GLOBAL_KEY]) {
g[GLOBAL_KEY] = { cleanups: new Set, installed: false };
}
return g[GLOBAL_KEY];
}
At lines 40747–40761, each factory invocation registers a cleanup closure capturing its own pool. That cleanup also stops its RPC server, clears subscriptions, and ultimately executes:
Individual disposal invokes all cleanups
Lines 41056–41058:
dispose: async () => {
await runCleanups("dispose");
}
At lines 36877–36894, runCleanups obtains the global registry, copies every callback, clears the set, and awaits all callbacks with Promise.allSettled.
The resulting ownership mismatch is:
factory A -> pool A -> cleanup A registered globally
factory B -> pool B -> cleanup B registered globally
dispose(A)
-> runCleanups("dispose")
-> cleanup A -> pool A.shutdown()
-> cleanup B -> pool B.shutdown()
Host lifecycle
The inspected OpenCode host uses directory-keyed InstanceState scopes. Directory disposal invalidates that directory's ScopedCache entry, closes its scope, and invokes the corresponding plugin hooks' dispose() methods.
Relevant host source:
packages/opencode/src/project/instance-store.ts: instance disposal and registered disposers.
packages/opencode/src/effect/instance-state.ts:26–40: directory-keyed cache and invalidation registration.
packages/opencode/src/plugin/index.ts:306–319: plugin-state finalizer invoking hook.hooks.dispose?.().
This lifecycle does not require unrelated directories or the entire host process to be disposed. The report comes from a custom OpenCode build; stock-host reproduction has not been performed.
Expected behavior
Disposing one plugin instance should clean up only that instance's owned resources. Other directories' bridges and outstanding calls should remain unaffected.
Global cleanup is appropriate for actual process shutdown, but should not be used as an individual factory instance's disposal operation. Instance disposal should also remove its own registration from the global shutdown registry and remain safe when called repeatedly.
Suggested regression test
This isolated test has not yet been executed. The evidence above consists of the observed runtime failure and direct installed-source inspection.
- Invoke the plugin factory twice in the same JavaScript realm with distinct directories, producing hooks A and B.
- Start a bridge for each instance and hold a tool call open in B.
- Call only
await hooksA.dispose().
- Assert that A's resources are cleaned up, while B's bridge and held call remain active.
- Complete B's call and verify normal result delivery.
- Dispose B and verify its resources are cleaned up without duplicate cleanup of A.
Scope
This report concerns cross-instance shutdown and interrupted tool calls. Separately observed memory-pressure spikes have not been attributed to this defect and are not part of this report.
Environment
@cortexkit/aft-opencode0.55.1Summary
Disposing one directory's AFT plugin instance runs a process-wide cleanup registry. This shuts down bridge pools belonging to other, still-active plugin instances and causes outstanding shell calls to fail with:
The host uses directory-keyed plugin state and invokes each instance's
dispose()when that directory is unloaded. AFT creates a separate transport pool for each factory invocation, but its returneddispose()invokes all registered cleanups rather than only that invocation's cleanup.Observed sequence
On September 10, 2026, the host disposed one project while shell calls were running in another. Project names below are replaced with A and B; timestamps are UTC.
15:36:02.097disposing instancefor project A.15:36:02.099Shutdown triggered by dispose — running 8 cleanup(s).15:36:02.10315:36:02.129Process exited during shutdownmessages.15:36:05.237transport was shut down but new demand arrived — reviving (host quit hook fired without process exit?).15:36:05.240The process-exit messages describe AFT bridge children, not proof that the OpenCode host exited. OpenCode continued logging under the same run ID. The parenthetical in the revival warning is speculative; the explicit shutdown reason was
dispose.Installed-source evidence
References below are to the installed package's
dist/index.jsin version 0.55.1.Each factory invocation owns a separate pool
At lines 40413–40414, the plugin factory delegates to
initializePluginForDirectory(input). Each invocation creates its transport pool at lines 40651–40678. The standalone transport factory constructs a newBridgePoolat lines 16430–16448.Cleanup registration is process-wide
Lines 36868–36874:
At lines 40747–40761, each factory invocation registers a cleanup closure capturing its own pool. That cleanup also stops its RPC server, clears subscriptions, and ultimately executes:
Individual disposal invokes all cleanups
Lines 41056–41058:
At lines 36877–36894,
runCleanupsobtains the global registry, copies every callback, clears the set, and awaits all callbacks withPromise.allSettled.The resulting ownership mismatch is:
Host lifecycle
The inspected OpenCode host uses directory-keyed
InstanceStatescopes. Directory disposal invalidates that directory'sScopedCacheentry, closes its scope, and invokes the corresponding plugin hooks'dispose()methods.Relevant host source:
packages/opencode/src/project/instance-store.ts: instance disposal and registered disposers.packages/opencode/src/effect/instance-state.ts:26–40: directory-keyed cache and invalidation registration.packages/opencode/src/plugin/index.ts:306–319: plugin-state finalizer invokinghook.hooks.dispose?.().This lifecycle does not require unrelated directories or the entire host process to be disposed. The report comes from a custom OpenCode build; stock-host reproduction has not been performed.
Expected behavior
Disposing one plugin instance should clean up only that instance's owned resources. Other directories' bridges and outstanding calls should remain unaffected.
Global cleanup is appropriate for actual process shutdown, but should not be used as an individual factory instance's disposal operation. Instance disposal should also remove its own registration from the global shutdown registry and remain safe when called repeatedly.
Suggested regression test
This isolated test has not yet been executed. The evidence above consists of the observed runtime failure and direct installed-source inspection.
await hooksA.dispose().Scope
This report concerns cross-instance shutdown and interrupted tool calls. Separately observed memory-pressure spikes have not been attributed to this defect and are not part of this report.