Files
BeeandSaoud Rizwan 12a4c6bfe2 feat(core): load plugins in the hub process and surface plugin errors (#14957)
* feat(core): load plugins in the hub process and surface plugin errors

Plugins now load in-process through one registry per process instead of a
node/bun subprocess. The compiled CLI and the hub it starts no longer need a
runtime on PATH, and plugins can use the Bun global under the Bun-built hub.
The subprocess sandbox stays available with CLINE_PLUGIN_MODE=sandbox.

The registry wraps import, setup, every hook, tool, and command call with
timeouts and error capture, and tracks loading/ready/degraded/failed/disabled
per plugin with the last error's phase, message, stack, and counts. A failing
plugin no longer silently drops its tools:

- session.create takes a `plugins` policy shaped like toolPolicies, and its
  reply and session.created event carry `pluginIssues`
- failures after start (setup and hooks run on the first turn) reach each
  affected session as a status notice
- the hub answers plugins.list/status/reload and broadcasts
  plugin.status_changed; `cline hub plugins [--reload]` wraps them
- the daemon marks a plugin failed instead of shutting down when an uncaught
  error's stack points into it
- the CLI prints a warning naming the plugin and error, and
  `cline config tools --json` lists failed plugins as plugin_failure entries

Setup and tool contexts gain cwd and emitEvent; __clinePluginHost keeps
working as a shim. Compiled binaries fall back to jiti/static when babel is
not on disk next to them.

* fix(core): give each session its own copy of every plugin module

Plugins written for the per-session sandbox keep state at module level. The
cline-monitor plugin, for example, builds one plugin object per import and
throws "Create a separate monitor plugin handle for each session" on a second
setup(). Sharing one imported copy across hub sessions broke it on the
second session.

The registry now hands each session its own copy: the first session claims
the copy imported at startup, and later ones re-evaluate the module while
reusing jiti's transform cache (about 180ms for cline-monitor instead of
2.8s with no cache).

A setup() failure now costs only the session it happened in: other sessions
keep their copies, the next session tries again, and a successful setup
returns the status to ready. Import failures, attributed uncaught errors, and
repeated hook/tool failures still turn the plugin off for every session.

* docs(sdk): document the plugin hooks that exist and how hook failures behave

The Hook Stages list (snake_case stages such as tool_call_before) and the
Hook Policies table (mode, retries, queueLimit, maxConcurrency) described no
code: plugin hooks are the seven runtime hooks, and there is no per-hook
policy object. Replace both with the real hooks, what each can return, and
how hookErrorMode and the 3-second hook timeout apply.

* fix(core): report failed plugins as failed in settings

Settings marked a plugin "failed" only when the plugin tool report had no
entry for it. The report now lists failed plugins with their state, so they
showed as "available" (desktop Extensions page and other core settings
hosts). Use the reported state, and fill loadError with the failing phase
and message so the reason is visible.

Also tell only the calling session about a per-session setup failure;
other sessions' copies still work. Keep the hub log line's own message
instead of letting the plugin error text overwrite it.

* fix(core): address plugin registry review findings

Reload and generations: each session copy records the generation (import)
it came from. A reload starts a new generation without invalidating older
copies, so running sessions keep working. Failures and blocking are counted
per generation, so a broken reload cannot turn off copies that still work,
and an old copy's failures cannot change the current status. A stray error
attributed by stack turns off every live generation, since a stack cannot
tell copies of one file apart.

Sandbox mode: the hub daemon no longer preloads plugins in sandbox mode,
and tool listing and MCP settings sync inspect plugins through the sandbox.
The session plugin policy now applies in sandbox mode: paths whose file
name the policy turns off never reach the sandbox, and plugins matched only
by their exported name are dropped after import.

Calls: commands, rule content, and message builders go through the same
guarded path as hooks and tools (timeout, error capture, failure count), and
calls into a copy that is turned off, failed setup, or whose session ended
are refused. A failing rule contributes no text and a failing message
builder leaves messages unchanged.

Cleanup: ctx.onDispose registers per-session cleanup. Timers a copy creates
while its own code runs, including at import, are tracked and cleared when
the session releases the copy; host callbacks given to plugins run outside
that scope so host timers are never cleared with them. Release runs on
session stop and dispose, and now also when building the session runtime
fails. The automation-events example uses ctx.onDispose.

Registrations: automation event types are validated while setup runs. If
the host still rejects a registration on commit, the copy's setup is
recorded as failed and the error is rethrown so the partial registrations
are discarded.

Also: forward importTimeoutMs, hookTimeoutMs, and contributionTimeoutMs to
the in-process registry per session; key the plugin tool inspection cache on
registry status; keep the module copy imported by preload for the first
session; report settings-disabled plugins a session would use as issues
with reason "settings".

* fix(core): keep this bindings and route import-time plugin events

- Timer tracking forwards the runtime's this (the timer handle in Node and
  Bun) to plugin callbacks.
- Plugin methods keep their receiver: rule content, message builders,
  command handlers, and setup() are called on their own objects, including
  setup() in the existing in-process loader.
- Each plugin copy owns one async scope from import onward. Claiming the
  copy for a session fills in that scope's session id and event callback,
  so events emitted by work the module started at import reach the session.
- Each generation tracks its own health, so an older copy's first failure
  after a reload is reported to its sessions even though the status shows
  the reloaded generation.

* fix(core): bind plugin changes to sessions started afterwards

Plugins are bound to a session when it starts; added, edited, or reloaded
plugins reach only sessions started afterwards. Change detection used the
entry file's mtime and size, which missed edits to files the entry imports
(common for package plugins) and same-size edits within one clock tick, so
the next new session could claim a stale preloaded copy.

Detect changes with a content fingerprint of the entry file and every file
it reaches through relative static imports, for the registry, the plugin
tool inspection cache, and the plugin command service. Add a bootstrap test
that a session started before a plugin is added or changed keeps its
original tools even when its setup runs afterwards, and a registry test for
a change outside the entry file.

* fix(core): include side-effect imports in plugin change fingerprints

The fingerprint only followed `from`, `import()`, and `require()`
specifiers, so a fix to a file the entry imports only for side effects
(`import "./setup.js"`) left the fingerprint unchanged and new sessions kept
getting the failed entry.

Scan statement-level side-effect imports for the fingerprint only. They are
kept out of the dependency preflight, where a loose match on text inside a
string (`'run import "x" first'`) would reject a valid plugin.

* fix(core): retry failed plugin imports on a backoff

Change detection follows static and side-effect relative imports, but a
broken import can also be fixed where no fingerprint sees it: a dynamically
imported file, a file behind a block comment, or a dependency installed in
node_modules. Such plugins stayed failed until an explicit reload.

Retry a plugin whose import failed when a session starts, at most every 30
seconds, even if no change was detected. A broken plugin costs at most one
extra import attempt per 30 seconds, bounded by the import timeout.

Replace the SHA-1 fingerprint with a non-cryptographic checksum; it only has
to notice edits to plugin source.

* feat(desktop): show plugin failures in the chat

Plugin failures reached the desktop webview only as core logs written to the
devtools console. Show them as status lines in the chat:

- session start returns `pluginWarnings` for plugins the session asked for
  but could not load, and the webview adds one status line per warning
- plugin notices that arrive later on the session stream (setup and hooks
  run on the first turn) carry `metadata.pluginIssue`; the webview adds
  those as status lines too, while other core logs stay out of the
  transcript

* fix(desktop): keep plugin warnings when saved history reloads

Finishing a turn replaces the live transcript with saved history, which
never contains plugin warnings (they come from the start reply and stream
notices), so the explanation of a missing plugin disappeared after the first
turn. Carry the session's live plugin-warning lines into canonical history
at their original position by time.

* refactor(core): simplify the in-process plugin registry (#14999)

* refactor(core): simplify the plugin registry

Drop the pieces added in review rounds that cost more than they buy:

- Global timer tracking. It monkeypatched setTimeout/setInterval/
  setImmediate and their clear* counterparts on globalThis for the whole
  host process (the hub, the CLI, and the shared VS Code extension host),
  and attributed timers by async scope, so a host callback invoked from
  inside a plugin call had its timers cancelled when the session released
  its plugin copy. Plugins stop what they start in ctx.onDispose, which
  stays.
- Plugin generations and per-generation health. Blocking and consecutive
  failures are counted per plugin again; a re-import (reload or source
  change) resets them. Running sessions keep their copies callable unless
  the plugin is turned off, so a broken reload fails closed for them.
- Automatic retry of failed imports on a 30 s backoff, and the side-effect
  import scan in the change fingerprint. plugins.reload covers changes the
  fingerprint cannot see.
- The duplicated registerAutomationEventType validation; the commit
  try/catch already records a host-rejected registration as a setup
  failure.

* refactor(core): do not apply the session plugin policy in sandbox mode

The sandbox is an opt-in escape hatch and the per-session policy is new;
filtering sandbox results by file-derived and exported names added two
passes nobody needs yet. The policy is documented as in-process only.

* revert(desktop): drop the live plugin-warning merge into saved history

Reverts 90df99574. The merge re-inserted unpersisted warning lines by
timestamp after every history reload and still lost them on reopen.

* fix(core): keep plugin status consistent across a reload and a late block

- A re-import now resets `blocked` and the failure count only after it
  succeeds, so calls that hit the failure threshold while the import was in
  flight cannot leave the plugin blocked under a `ready` status.
- Sessions are notified when a plugin is turned off even if its status
  already read `failed` from another session's setup failure.
- Docs: say which relative imports the change fingerprint follows, that a
  failed reload turns the plugin off for running sessions too, and that the
  sandbox ignores the session plugin policy.

* fix(core): attribute stray plugin errors by the plugin's real path too

Module resolution follows symlinks and, on Windows, expands 8.3 short names
(the GitHub runner's temp dir is C:\Users\RUNNER~1\...), so a stack frame can
spell the plugin path differently from the configured one and the uncaught
error was never attributed. Match the realpath as well, and compare with
normalized separators and case on win32. Fixes the Windows-only failure of
"attributes a stray error to the plugin whose file is in its stack", which
also fails on the base branch.

* fix(core): match both realpath spellings when attributing plugin errors

On Windows fs.realpathSync keeps 8.3 short names while realpathSync.native
expands them, and module resolvers differ in which they use. Accept either
so the stack frame matches regardless.

* fix(core): do not count a stale plugin copy's failures against the reloaded module

Each copy is stamped with the entry's import counter. After a reload (or a
source change), failures from copies of the previous import are reported to
their own session but no longer bump the error counts, block the plugin, or
change its status, so a session still running a broken copy cannot turn off
the fixed plugin for everyone else. The pre-review registry had this guard
via its generation number; the simplification dropped it.

* fix(core): don't let a stale session import turn off a reloaded plugin

A session's copy import that failed after plugins.reload (or a source change)
had already re-imported the plugin was recorded against the current module:
the catch around claimInstance had no import identity, so recordFailure
blocked the plugin and dropped the freshly loaded copy for every session.

Capture the entry's importId before claiming. If it changed by the time the
import fails, report the failure to that session only and leave the current
module's status alone, matching how stale copies' call failures are handled.

* fix(desktop): don't duplicate a queued prompt behind a plugin start warning

Session start appends plugin warnings after the optimistic user bubble. The
chat_queued_prompt_start handler searched backward for that bubble and
stopped at the first non-user row, so it hit the warning, missed the bubble,
and appended the prompt a second time until saved history replaced it.

Skip plugin warning rows in that search.

* feat(core): default a worker-thread transport for isolated plugins (#15034)

* feat(core): add a worker-thread transport for isolated plugins

CLINE_PLUGIN_MODE=worker runs each session's plugins in a worker thread
inside the host runtime. It reuses the subprocess sandbox protocol and
bootstrap, so it needs no node/bun executable on PATH and works from the
compiled CLI and hub. terminate() stops a plugin stuck in a synchronous
loop, and an uncaught error or process.exit() ends only that worker.

- WorkerSandbox mirrors SubprocessSandbox (timeouts, idle reclaim,
  per-worker pending calls) over the worker message port.
- The bootstrap talks to parentPort in a worker and posts ready once it
  listens; the host holds calls until then because Bun drops earlier
  messages.
- Workers override process.cwd() instead of chdir, which Bun would apply
  to the whole host process.
- Uncloneable values fall back to JSON, matching the subprocess IPC.
- The sandbox test suite runs against both transports, plus worker-only
  tests for loops, stray throws, process.exit, cwd, and no runtime.

* fix(core): ship a self-contained plugin bootstrap and snapshot queued worker calls

The CLI copies plugin-sandbox-bootstrap.js beside the compiled binary,
where nothing can resolve @cline/shared or jiti, so a worker (or
subprocess) exited before initializing any plugin. Bundle every package
into the bootstrap and fail the core build if it imports anything but
Node builtins.

Calls made before a worker reports ready kept the caller's args object,
so later changes leaked into the call and an uncloneable value added in
that window threw from the message listener. Queue a structured clone
instead, skip calls that already timed out, and reject a call whose
flush fails.

* test(core): give the worker loop-recovery test room for a cold worker on CI

The recovery call boots a fresh worker and re-imports the plugin within
the same tool timeout, which 200ms did not cover on CI runners.

* fix(core): keep worker plugin stdout off the host's stdout under Bun

Bun workers write console.log and process.stdout straight to the host's
stdout (Bun ignores the Worker stdout option), so a plugin's logging
corrupted --json output and JSON-RPC streams. Discard stdout-bound output
inside the worker, matching the subprocess sandbox's stdio "ignore".

* fix(core): complete discarded stdout write callbacks in plugin workers

The worker's process.stdout.write replacement dropped the completion
callback, so a plugin awaiting a write hung until the call timed out.
Call it on the next tick for both write(chunk, cb) and
write(chunk, encoding, cb).

* feat(core): run plugins in workers by default, through the plugin registry

Worker mode now goes through PluginRegistry instead of around it, so it
keeps everything in-process mode has: plugin status and plugins.list/
status/reload, plugin.status_changed, degraded/failed tracking with the
failure threshold, session notices, the per-session plugins policy,
stale-copy protection, and per-session timeouts. Only where a copy runs
changes: each plugin gets its own worker per session, and the copy the
registry wraps is a proxy to it.

One worker per plugin means a worker that dies on its own (uncaught
error, unhandled rejection, process.exit) is that plugin's failure with
no stack attribution: the plugin is turned off and the host and other
plugins keep running. The hub preloads status in throwaway workers.

- CLINE_PLUGIN_MODE defaults to worker; in_process and sandbox are
  opt-ins. Hosts that ship no bootstrap fall back to in_process.
- WorkerSandbox reports unexpected exits; sandbox tool proxies honor a
  tool's own timeoutMs.
- The bootstrap is resolved lazily and can be set with
  CLINE_PLUGIN_BOOTSTRAP_PATH. The desktop app ships it as a Tauri
  resource and points the sidecar at it; the VS Code build copies it
  next to the bundle as .mjs.

* fix(core): carry setup context, tool context, and tool definitions into plugin workers

Worker plugins lost what in-process plugins get, now that workers are the
default:

- Setup context: ctx.cwd, ctx.emitEvent, and ctx.onDispose. Cleanup runs
  in the worker before it stops (session release, shutdown, idle
  reclaim); calls arriving during an idle stop wait for a fresh worker.
- Tool context: the abort signal (the host forwards aborts by call id),
  emitUpdate (routed back to the calling tool), and emitEvent.
- Tool definitions: lifecycle, executionMode, resultPolicy, maxRetries,
  and any other plain field now reach the runtime.
- Setup gets its own timeout inside the worker. A setup that throws,
  times out, or takes the worker down fails only that session's copy,
  instead of being reported as an import failure that turns the plugin
  off for every session.

* fix(core): don't treat an exit during plugin cleanup as a crash

stopGracefully() ran the dispose method before marking the worker as
stopping, so a disposer that called process.exit() or threw from a timer
looked like an unexpected exit and turned the plugin off for every
session. Mark the worker as stopping before cleanup starts.

* fix(core): apply the session plugin policy by a worker plugin's own name

The host checked the policy against the file-derived name before the
worker had imported the plugin, so a policy keyed by the plugin's
exported name misfired: "*" off plus the plugin turned on by name
skipped it for good, and turning it off by name still ran its setup().

The worker now applies the policy after import, against both names, and
before setup. The host skips the worker up front only once it has seen
the plugin's own name for its current code.

* fix(core): keep setup prompts and cleanup for worker plugins

Worker plugins run setup() while the session bootstraps, before it is
registered, unlike in-process plugins whose setup runs on the first turn.

- Prompts a plugin sends to its session during setup (steer_message,
  queue_message, pending_prompt) found no session and were dropped. The
  host now holds them until the session is registered, then delivers them.
- A setup that registered ctx.onDispose and then threw or timed out lost
  its cleanup, since only plugins that loaded are disposed later. The
  worker now runs that cleanup right away, so a child process started
  before the failure does not outlive it.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-10-12 22:29:54 -07:00
..
2026-02-14 22:02:22 -08:00