Summary
A plugin can register an opaque Tool created by a different loaded copy of @opencode-ai/plugin. Registration and plugin activation appear to succeed, but Core later throws TypeError: Invalid Tool value while materializing the Location's tool catalog.
The broken tool does not need to be called. Its presence prevents every ordinary tool-enabled model step in that Location, affecting existing and newly created sessions.
Hot reload makes the impact broader:
- Any plugin/config change rebuilds the entire ordered plugin set for the Location.
- Core closes the previous plugin scopes before proving the replacements are valid.
- Tool registrations are removed and added immediately rather than committed atomically.
- A failed replacement cannot restore the previous working plugin version.
- An invalid opaque tool is not detected during registration, so the broken plugin remains listed as active.
Environment
- Channel:
next
- Initially observed on
0.0.0-next-15144
- Reproduced after updating to
0.0.0-next-15145
- macOS arm64
- Observed
origin/v2 revision: 212c9f99ee2d082082a00f7074df950974747a2a
Actual incident timeline
Times are UTC and rounded to the nearest relevant event.
21:15:36 An active session edits a project plugin to import Tool from a source checkout.
21:15:36 The watched configuration directory emits config.updated.
21:15:37 PluginSupervisor imports the changed plugin using its new mtime.
21:15:40 The active session begins its next model step.
21:15:42 An advertised edit call fails: Stale tool call: edit.
21:15:42 The continuation fails: Invalid Tool value.
21:21-23 Five later prompts in the same session fail before starting a model step.
21:23-24 Three newly created sessions in the same Location also fail immediately.
21:25:45 The project plugin is changed back to canonical package imports.
21:25:47 The watcher imports it with a new mtime.
21:25-26 Four more prompts still fail with Invalid Tool value.
21:26-35 Managed-service restarts do not recover while the project plugin remains present.
21:35:12 The project-local plugin is removed.
21:35:13 Reload omits that plugin and removes its registration.
21:36:10 The next prompt starts a real model step.
Afterward No further Invalid Tool value failures are observed.
Four sessions in the same Location produced 22 durable Invalid Tool value failures. Other Locations were not implicated.
The restart activity also exposed a separate managed-service election problem tracked in #35964. That restart race was not the cause of the invalid tool: failures persisted on the stable service until the project plugin was removed.
What caused Invalid Tool value
packages/plugin/src/v2/effect/tool.ts represents a tool as an opaque frozen object. Its actual runtime is held in a module-local WeakMap:
const runtimes = new WeakMap<AnyTool, Runtime>()
Core can inspect a tool only when that exact object was constructed by the same loaded module instance:
function runtimeOf(tool: AnyTool) {
const runtime = runtimes.get(tool)
if (!runtime) throw new TypeError("Invalid Tool value")
return runtime
}
In the incident, the project plugin constructed its tool through the source checkout while the compiled service used its bundled plugin runtime. The two module instances had separate WeakMaps:
Plugin/source runtime WeakMap: contains the custom tool
Compiled Core runtime WeakMap: does not contain the custom tool
Returning to canonical imports did not recover because package resolution from inside the source checkout still produced a module instance distinct from the one bundled into the running service. Restarting reconstructed the same incompatible registration.
Why one broken tool breaks every session
ToolRegistry.register currently stores opaque tool values without validating runtime ownership:
Plugin activation
-> ctx.tool.transform
-> ToolRegistry.register
-> raw tool object stored successfully
-> plugin reported as active
Before each non-final model step, the runner materializes every permission-eligible registered tool:
SessionRunner.attemptStep
-> ToolRegistry.materialize
-> Tool.permission / Tool.definition
-> runtimeOf(invalidTool)
-> throws Invalid Tool value
-> LLM request never starts
No invocation of the broken custom tool is required. One invalid registration poisons the entire tool catalog shared by all sessions in that Location.
The durable sequence for subsequent prompts is therefore:
session.prompt.admitted
session.execution.started
session.prompt.promoted
session.execution.failed unknown: Invalid Tool value
There is no session.step.started, because failure occurs before llm.stream().
Current reload semantics
Today a plugin generation is the complete ordered set of plugin IDs and optional versions for one Location.
T0 Active generation: [A v1, B v1, C v1]
T1 Any watched plugin/config change occurs
T2 Supervisor rescans and imports the complete candidate set
T3 If all IDs/versions are unchanged and active, activation is skipped
T4 Otherwise Core clears the active map
T5 Core closes C, B, then A
T6 Core loads candidate A, B, and C sequentially
T7 Successfully loaded candidates become active
There is no rollback:
- An import failure is silently omitted from the candidate set.
- A plugin effect failure is logged and skipped after its previous scope is already gone.
- An opaque invalid tool is accepted, so the plugin is considered successfully active and fails sessions later.
- Unchanged plugins are unnecessarily closed and recreated when any generation entry changes.
State.batch delays materialization for state-backed plugin domains, but scoped tool registration mutates the live registry immediately. Session tool materialization does not share the plugin activation lock. Sessions can therefore observe old registrations, no registrations, a partial replacement set, or the completed set.
Stale in-flight tool call
The first failure included Stale tool call: edit. Tool materialization snapshots each advertised registration's identity. Settlement rejects the provider's call if reload has replaced that identity:
Session snapshots edit from generation A
Plugin reload replaces registrations with generation B
Provider returns the edit call advertised from A
Registry compares A's identity with active B
Registry returns Stale tool call: edit
This ordering is strongly supported by the behavior and existing registry semantics, but the exact overlap is not provable from persisted events because activation completion and registration identities are not logged.
The later Invalid Tool value failures are not timing-dependent. Once the invalid tool is active, materialization fails deterministically.
Minimal reproduction: invalid registration
The module-identity failure can be reproduced without filesystem/package setup by cloning a valid opaque tool. The clone has the same apparent shape but is absent from the module's private WeakMap:
const valid = Tool.make({
description: "Example",
input: Schema.Struct({}),
output: Schema.Struct({ ok: Schema.Boolean }),
execute: () => Effect.succeed({ ok: true }),
})
const invalid = structuredClone(valid)
yield* registry.register({ invalid }) // currently succeeds
yield* registry.materialize({ model }) // throws Invalid Tool value
Expected timeline:
T0 A healthy tool is registered
T1 Registration receives an invalid opaque tool
T2 Registration fails with Tool.RegistrationError naming the tool
T3 Registry remains unchanged
T4 Healthy tools still materialize
Minimal reproduction: adding a broken plugin
T0 Healthy plugin A v1 is active
T1 New plugin B v1 is discovered
T2 B attempts to register an invalid opaque tool
T3 B is rejected
T4 A remains active without being closed or reloaded
T5 Sessions can still materialize A's tools
Current behavior instead closes and reloads A, accepts B, and then breaks materialization for the Location.
Minimal reproduction: broken replacement
T0 Plugin A v1 is active with a valid tool
T1 The file changes and candidate A v2 is loaded
T2 A v2 attempts to register an invalid opaque tool
T3 A v2 is rejected
T4 A v1 remains active and its scope remains open
T5 Sessions continue using A v1's tool
Current behavior closes A v1 first, accepts the invalid A v2 registration, and has no previous version to restore.
Desired semantics
Plugin replacement should be isolated per plugin:
Successful update
T0 A v1 is active
T1 A v2 is staged and validated
T2 A v2 becomes active atomically
T3 A v1 is closed
Failed update
T0 A v1 is active
T1 A v2 is staged and validation fails
T2 A v2 is discarded
T3 A v1 remains active
Failed addition
T0 A and B are active
T1 New plugin C fails validation
T2 C is absent
T3 A and B remain untouched
Removal
T0 A and B are active
T1 A is removed from configuration
T2 A is closed
T3 B remains untouched
Regression tests prepared locally
A local branch based on origin/v2 currently adds three failing tests:
ToolRegistry rejects invalid tool values before mutating registrations.
- Adding an invalid plugin does not close or reload an unchanged healthy plugin.
- An invalid replacement leaves the previous plugin scope and valid tool active.
Focused verification currently produces 26 passing existing tests and exactly these three expected failures. Typecheck and file-scoped oxlint pass.
Implementation considerations
Small, immediate safety fix
Validate every opaque tool at ToolRegistry.register before mutating the registry. Return a typed RegistrationError containing the registered name. This prevents one invalid object from poisoning later session materialization.
Broader isolation fix
Change plugin activation from whole-generation teardown to differential, per-plugin replacement. Unchanged plugin scopes should be retained.
Atomicity complication
Simply loading a candidate while the previous scope remains open is insufficient. Plugin effects currently write directly into live state, tools, hooks, and other scoped contribution points. A candidate can therefore become partially visible before its effect finishes.
True last-valid-plugin behavior needs either:
- staged contribution registries that commit only after the candidate effect succeeds, or
- an activation transaction shared by every plugin contribution domain and by session materialization.
Tool validation is localized and straightforward. Atomic per-plugin replacement across all plugin contribution types is the larger architectural change.
Relevant code
packages/plugin/src/v2/effect/tool.ts: opaque tool runtime and runtimeOf
packages/core/src/tool/registry.ts: registration, materialization, and stale identity checks
packages/core/src/plugin/host.ts: plugin tool registration boundary
packages/core/src/plugin.ts: whole-generation activation and scope lifecycle
packages/core/src/plugin/supervisor.ts: filesystem/config-driven scan, import, and activation
packages/core/src/session/runner/llm.ts: tool materialization before the LLM request
packages/core/src/state.ts: batching used by state-backed plugin contributions
Acceptance criteria
- Invalid opaque tools fail during registration with plugin/tool context.
- A bad new plugin cannot break built-ins, healthy plugins, or sessions in the Location.
- A failed plugin update preserves that plugin's previous valid version.
- Unchanged plugins are not closed or reloaded because another plugin changed.
- Candidate contributions are not visible to sessions before successful activation.
- A plugin reload cannot write
Invalid Tool value as an unscoped session execution failure.
Summary
A plugin can register an opaque
Toolcreated by a different loaded copy of@opencode-ai/plugin. Registration and plugin activation appear to succeed, but Core later throwsTypeError: Invalid Tool valuewhile materializing the Location's tool catalog.The broken tool does not need to be called. Its presence prevents every ordinary tool-enabled model step in that Location, affecting existing and newly created sessions.
Hot reload makes the impact broader:
Environment
next0.0.0-next-151440.0.0-next-15145origin/v2revision:212c9f99ee2d082082a00f7074df950974747a2aActual incident timeline
Times are UTC and rounded to the nearest relevant event.
Four sessions in the same Location produced 22 durable
Invalid Tool valuefailures. Other Locations were not implicated.The restart activity also exposed a separate managed-service election problem tracked in #35964. That restart race was not the cause of the invalid tool: failures persisted on the stable service until the project plugin was removed.
What caused
Invalid Tool valuepackages/plugin/src/v2/effect/tool.tsrepresents a tool as an opaque frozen object. Its actual runtime is held in a module-localWeakMap:Core can inspect a tool only when that exact object was constructed by the same loaded module instance:
In the incident, the project plugin constructed its tool through the source checkout while the compiled service used its bundled plugin runtime. The two module instances had separate
WeakMaps:Returning to canonical imports did not recover because package resolution from inside the source checkout still produced a module instance distinct from the one bundled into the running service. Restarting reconstructed the same incompatible registration.
Why one broken tool breaks every session
ToolRegistry.registercurrently stores opaque tool values without validating runtime ownership:Before each non-final model step, the runner materializes every permission-eligible registered tool:
No invocation of the broken custom tool is required. One invalid registration poisons the entire tool catalog shared by all sessions in that Location.
The durable sequence for subsequent prompts is therefore:
There is no
session.step.started, because failure occurs beforellm.stream().Current reload semantics
Today a plugin generation is the complete ordered set of plugin IDs and optional versions for one Location.
There is no rollback:
State.batchdelays materialization for state-backed plugin domains, but scoped tool registration mutates the live registry immediately. Session tool materialization does not share the plugin activation lock. Sessions can therefore observe old registrations, no registrations, a partial replacement set, or the completed set.Stale in-flight tool call
The first failure included
Stale tool call: edit. Tool materialization snapshots each advertised registration's identity. Settlement rejects the provider's call if reload has replaced that identity:This ordering is strongly supported by the behavior and existing registry semantics, but the exact overlap is not provable from persisted events because activation completion and registration identities are not logged.
The later
Invalid Tool valuefailures are not timing-dependent. Once the invalid tool is active, materialization fails deterministically.Minimal reproduction: invalid registration
The module-identity failure can be reproduced without filesystem/package setup by cloning a valid opaque tool. The clone has the same apparent shape but is absent from the module's private
WeakMap:Expected timeline:
Minimal reproduction: adding a broken plugin
Current behavior instead closes and reloads A, accepts B, and then breaks materialization for the Location.
Minimal reproduction: broken replacement
Current behavior closes A v1 first, accepts the invalid A v2 registration, and has no previous version to restore.
Desired semantics
Plugin replacement should be isolated per plugin:
Successful update
Failed update
Failed addition
Removal
Regression tests prepared locally
A local branch based on
origin/v2currently adds three failing tests:ToolRegistryrejects invalid tool values before mutating registrations.Focused verification currently produces 26 passing existing tests and exactly these three expected failures. Typecheck and file-scoped oxlint pass.
Implementation considerations
Small, immediate safety fix
Validate every opaque tool at
ToolRegistry.registerbefore mutating the registry. Return a typedRegistrationErrorcontaining the registered name. This prevents one invalid object from poisoning later session materialization.Broader isolation fix
Change plugin activation from whole-generation teardown to differential, per-plugin replacement. Unchanged plugin scopes should be retained.
Atomicity complication
Simply loading a candidate while the previous scope remains open is insufficient. Plugin effects currently write directly into live state, tools, hooks, and other scoped contribution points. A candidate can therefore become partially visible before its effect finishes.
True last-valid-plugin behavior needs either:
Tool validation is localized and straightforward. Atomic per-plugin replacement across all plugin contribution types is the larger architectural change.
Relevant code
packages/plugin/src/v2/effect/tool.ts: opaque tool runtime andruntimeOfpackages/core/src/tool/registry.ts: registration, materialization, and stale identity checkspackages/core/src/plugin/host.ts: plugin tool registration boundarypackages/core/src/plugin.ts: whole-generation activation and scope lifecyclepackages/core/src/plugin/supervisor.ts: filesystem/config-driven scan, import, and activationpackages/core/src/session/runner/llm.ts: tool materialization before the LLM requestpackages/core/src/state.ts: batching used by state-backed plugin contributionsAcceptance criteria
Invalid Tool valueas an unscoped session execution failure.