mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Problem: selecting a custom preset captures unused skill templates and extension packages and waits for optional MCP startup. Root cause: resource snapshot creation also served as the runtime permission model, making selection cost scale with installed resource payload. Fix: add live capability-grant bindings while preserving legacy snapshots. Resolve current skill/MCP/extension/hook/command authority at use, defer optional startup, retain stable provider contracts, and fence new event and archive formats from older readers. Use lightweight status summaries and per-instance extension call leases for safe concurrent replacement. Verification: root and Desktop suites, full boot/builtin suites, affected storage/controller/frontend tests, lifecycle race checks, frontend interaction tests and typecheck, lint/vet/check, actionlint, and Windows cross-compilation. Twenty-sample real switch measurements include build, persist and publication; 64 MiB unused templates remain outside the binding and stay below the local 500 ms P95 target. Native Windows execution is delegated to hosted CI.
629 lines
25 KiB
Go
629 lines
25 KiB
Go
package boot
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
"reasonix/internal/contract/agentcomposition"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/contract/tool"
|
|
"reasonix/internal/ext/command"
|
|
"reasonix/internal/ext/extension"
|
|
"reasonix/internal/ext/extension/dispatch"
|
|
"reasonix/internal/ext/extension/protocol"
|
|
"reasonix/internal/ext/extension/sidecar"
|
|
"reasonix/internal/ext/extension/uihub"
|
|
"reasonix/internal/ext/hook"
|
|
"reasonix/internal/ext/modhost"
|
|
"reasonix/internal/ext/plugin"
|
|
"reasonix/internal/ext/pluginpkg"
|
|
"reasonix/internal/ext/skill"
|
|
"reasonix/internal/platform/computer"
|
|
"reasonix/internal/session/control"
|
|
)
|
|
|
|
// BuildResult is the full product of one runtime build: the ready-to-drive
|
|
// controller plus the extension kernel's frozen view of the same assembly.
|
|
// Snapshot is nil when kernel assembly failed — boot behavior is never
|
|
// allowed to depend on it (see build). Runtime is the snapshot's bound
|
|
// closable set; it holds the extension sidecar Manager when any v2 runtime
|
|
// package is installed, and its Close is chained into the controller's
|
|
// cleanup so sidecars die with their controller generation.
|
|
type BuildResult struct {
|
|
ComputerDriver *computer.Driver
|
|
Controller *control.Controller
|
|
Snapshot *extension.RuntimeSnapshot
|
|
Runtime *extension.RuntimeSet
|
|
// Owner is the session-lineage lifecycle owner. Independent builds receive
|
|
// independent owners; RebuildFrom reuses the previous owner so only that
|
|
// lineage's old generation drains.
|
|
Owner *extension.RuntimeOwner
|
|
// Extensions is the started extension sidecar Manager (nil when no v2
|
|
// runtime package is installed). Its lifecycle belongs to Runtime; the
|
|
// field exists so later stages (and tests) can reach the live clients.
|
|
Extensions *sidecar.Manager
|
|
// Dispatcher is the frozen interceptor dispatcher for this runtime
|
|
// generation (nil when no sidecar started, or when snapshot assembly
|
|
// degraded). It is immutable and safe for concurrent turns; the
|
|
// controller receives it through SetExtensions right after assembly.
|
|
Dispatcher *dispatch.Dispatcher
|
|
// ExtensionUI is the host extension UI hub for this runtime generation
|
|
// (nil when no sidecar started). It is bound to the build's
|
|
// session ID and generation; the controller receives it through
|
|
// SetExtensionUI right after assembly, and a Rebuild creates a fresh hub
|
|
// on the new generation.
|
|
ExtensionUI *uihub.Hub
|
|
// ProviderResolver is the build's effective provider resolver: the
|
|
// caller-owned broker when Options.ProviderResolver is set, the local
|
|
// config-backed resolver otherwise, merged with any extension-hosted
|
|
// sidecar providers. Plugin-namespaced refs route to the owning
|
|
// sidecar; every other ref resolves through the base resolver.
|
|
ProviderResolver provider.Resolver
|
|
// BaseProviderResolver is the pre-sidecar catalog used to re-merge after
|
|
// a narrow rebuild replaces the Manager (must not re-merge already-merged).
|
|
BaseProviderResolver provider.Resolver
|
|
// Plan is the RuntimePlan for this generation (from the previous graph
|
|
// when Rebuild supplies one; cold start when nil previous).
|
|
Plan *extension.RuntimePlan
|
|
// Status is the diagnostic component status snapshot for doctor/UI.
|
|
Status *extension.RuntimeStatus
|
|
// Lifecycle tracks component state transitions for this generation.
|
|
Lifecycle *extension.LifecycleRegistry
|
|
// Assembly is retained so a subsequent RebuildFrom can skip rediscovery
|
|
// when the RuntimePlan is no-op or interceptor/UI-only.
|
|
Assembly *ReusedAssembly
|
|
// ReusedController is true when a true subgraph rebuild kept the previous
|
|
// controller pointer (no control.New / BuildRuntime). Callers must not
|
|
// Close the "old" controller when it is the same pointer as Controller.
|
|
ReusedController bool
|
|
// Phases is how long each stretch of this assembly took, in run order. A
|
|
// model or effort switch rebuilds everything, so this is the account of
|
|
// what the user waited for.
|
|
Phases []Phase
|
|
}
|
|
|
|
// runtimeGeneration is the process-wide build generation counter. The first
|
|
// build gets generation 1 so 0 can mean "no snapshot" on a RuntimeSet built
|
|
// outside the kernel pipeline.
|
|
var runtimeGeneration atomic.Uint64
|
|
|
|
// nextRuntimeGeneration returns the next build generation. Generations pair
|
|
// with RuntimeSet.CloseIfGeneration so stale cleanup can never close a newer
|
|
// runtime's resources.
|
|
func nextRuntimeGeneration() uint64 { return runtimeGeneration.Add(1) }
|
|
|
|
// BuildRuntime runs the full boot assembly and returns the controller
|
|
// together with the extension kernel's frozen snapshot of the exact resources
|
|
// the build wired — tools, skills, commands, hooks, MCP servers, providers,
|
|
// and the composed system prompt. The snapshot is assembled from the in-hand
|
|
// objects the build itself produced (discovery never re-runs), so it cannot
|
|
// drift from what the controller actually uses, and it never makes an
|
|
// otherwise-successful build fail: an assembly error degrades to a nil
|
|
// Snapshot with a logged warning.
|
|
func BuildRuntime(ctx context.Context, opts Options) (*BuildResult, error) {
|
|
res, err := build(ctx, opts)
|
|
if err == nil {
|
|
noteHealthyConfig(opts.Version)
|
|
}
|
|
return res, err
|
|
}
|
|
|
|
// Build loads config, resolves the model(s), and returns a Controller wrapping a
|
|
// single Agent, or a two-model Coordinator when agent.planner_model is set. The
|
|
// returned controller owns plugin subprocesses; call Close (via Controller.Close)
|
|
// to release them.
|
|
//
|
|
// Build is the compatibility wrapper over BuildRuntime: frontends keep their
|
|
// existing signature. The runtime set is NOT closed here — it is chained into
|
|
// the controller's cleanup (the way LSP cleanup is chained), so extension
|
|
// sidecars live exactly as long as their controller.
|
|
func Build(ctx context.Context, opts Options) (*control.Controller, error) {
|
|
res, err := BuildRuntime(ctx, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.Controller, nil
|
|
}
|
|
|
|
// legacyAssembly carries the already-assembled runtime resources the kernel
|
|
// snapshot is built from. Every field is the exact object the rest of the
|
|
// build wired into the controller — the snapshot never re-derives anything.
|
|
type legacyAssembly struct {
|
|
systemPrompt string
|
|
registry *tool.Registry
|
|
skills []skill.Skill
|
|
commands []command.Command
|
|
hooks []hook.ResolvedHook
|
|
mcpSpecs []plugin.Spec
|
|
providers []provider.Descriptor
|
|
}
|
|
|
|
// extensionBoot carries the extension sidecar launch inputs into snapshot
|
|
// assembly: the session the sidecars serve, where non-fatal warnings go, and
|
|
// the UI hub the sidecars' host/ui/* calls bind to.
|
|
type extensionBoot struct {
|
|
session protocol.SessionContext
|
|
onWarning func(string)
|
|
// onSidecarDown reports an optional extension skipped for want of a running sidecar.
|
|
onSidecarDown func(string, extension.InterceptorPoint)
|
|
ui *uihub.Hub
|
|
// skipPromptStrategy skips system_prompt.build strategy when the RuntimePlan
|
|
// is a no-op (or does not affect cache), preserving the previous prompt.
|
|
skipPromptStrategy bool
|
|
}
|
|
|
|
func (p extensionBoot) warn(msg string) {
|
|
if p.onWarning != nil {
|
|
p.onWarning(msg)
|
|
}
|
|
}
|
|
|
|
// startExtensionPackages is the sidecar launch seam (tests override it).
|
|
// previous+plan adopt Unchanged packages without respawn.
|
|
var startExtensionPackages = sidecar.StartPackagesWithPlan
|
|
|
|
// preflightExtensionRuntimes starts enabled runtime packages before model
|
|
// resolution. Required failures are fatal; optional failures warn. With
|
|
// prev+plan, only Added/Reloaded start and Unchanged are adopted.
|
|
func preflightExtensionRuntimes(ctx context.Context, home string, ext extensionBoot, prev *sidecar.Manager, plan *extension.RuntimePlan, bindings ...*agentcomposition.Binding) (*sidecar.Manager, error) {
|
|
if strings.TrimSpace(home) == "" {
|
|
return nil, nil
|
|
}
|
|
ensureModHost()
|
|
packages, loadWarnings := sidecar.LoadRuntimePackagesFor(home, ext.session.WorkspaceRoot)
|
|
var binding *agentcomposition.Binding
|
|
if len(bindings) > 0 {
|
|
binding = bindings[0]
|
|
}
|
|
var selectErr error
|
|
packages, selectErr = compositionPackages(home, packages, binding)
|
|
if selectErr != nil {
|
|
return nil, selectErr
|
|
}
|
|
if len(packages) == 0 && !binding.Live() {
|
|
// No v2 runtime packages: surface the installed-state warnings exactly
|
|
// as the in-assembly StartPackages call did, and take the untouched
|
|
// pre-sidecar path (no processes, no Manager).
|
|
for _, warning := range loadWarnings {
|
|
ext.warn(warning)
|
|
}
|
|
return nil, nil
|
|
}
|
|
// The stage-8a UI hub installs as each sidecar's host/ui/* handler; a nil
|
|
// hub keeps the "ui not available" default. Guard against the typed-nil
|
|
// trap: a nil *uihub.Hub must stay a nil UIHandler.
|
|
var ui sidecar.UIHandler
|
|
if ext.ui != nil {
|
|
ui = ext.ui
|
|
}
|
|
var mgr *sidecar.Manager
|
|
var warnings []string
|
|
var err error
|
|
if binding.Live() {
|
|
mgr, warnings, err = sidecar.StartLivePackages(ctx, home, packages, ext.session, ui, func(current []pluginpkg.InstalledPackage) ([]pluginpkg.InstalledPackage, error) {
|
|
return liveCompositionPackages(current, binding)
|
|
})
|
|
} else if binding != nil {
|
|
mgr, warnings, err = sidecar.StartResolvedPackages(ctx, packages, ext.session, ui)
|
|
} else {
|
|
mgr, warnings, err = startExtensionPackages(ctx, home, ext.session, ui, prev, plan)
|
|
}
|
|
for _, warning := range warnings {
|
|
ext.warn(warning)
|
|
}
|
|
reconcileMods(mgr)
|
|
if err != nil {
|
|
// A required runtime failed: the Manager already shut down whatever
|
|
// started. The call site treats *RequiredStartError as fatal.
|
|
return nil, err
|
|
}
|
|
if len(mgr.Clients()) == 0 && len(mgr.DeferredPackages()) == 0 && !binding.Live() {
|
|
// Every package was optional and failed: retire the empty Manager and
|
|
// take the pre-sidecar path with warnings already surfaced.
|
|
_ = mgr.Close()
|
|
return nil, nil
|
|
}
|
|
return mgr, nil
|
|
}
|
|
|
|
// assembleLegacySnapshot freezes one boot's resources into an extension
|
|
// kernel snapshot. ConflictCollect is deliberate: the legacy sources already
|
|
// resolved their clashes inside their own discovery passes, and any residual
|
|
// dispute must land on the snapshot's Diagnostics without changing whether
|
|
// the session boots.
|
|
//
|
|
// Extension sidecars: mgr is the Manager preflightExtensionRuntimes
|
|
// started before model resolution; this function takes OVER its ownership —
|
|
// on every error path it closes mgr, and on success the Manager is registered
|
|
// into the RuntimeSet at activation, so it dies with its controller
|
|
// generation. Starting before the builder freezes is load-bearing: the
|
|
// handshake's declared providers and UI actions can only enter the catalog
|
|
// from a live handshake, and required-runtime failures must fail the build,
|
|
// which the activator seam (post-freeze) could only do after the catalog
|
|
// already settled.
|
|
//
|
|
// Dispatch wiring: with live sidecars the boot also runs the
|
|
// system_prompt.build strategy BEFORE the freeze, so the frozen snapshot's
|
|
// SystemPrompt and CacheHash cover the final (possibly replaced) prompt —
|
|
// the hash honestly attributes the prompt the session was built with rather
|
|
// than a pre-strategy draft. A strategy failure fails the build because the
|
|
// slot owner is required-class by definition. After the freeze the boot
|
|
// builds the generation's Dispatcher from the snapshot's frozen chain and
|
|
// replacements, and broadcasts the system_prompt.build event with the final
|
|
// prompt to every observer. The replaced prompt lands in the snapshot; the
|
|
// build's tail swaps the live executor session to the same final
|
|
// prompt when it differs from the host-composed one the session was built
|
|
// with, so snapshot and session describe the same session before any turn.
|
|
// With a nil Manager the path is byte-identical to the pre-sidecar one: no
|
|
// processes, no contributions, no dispatcher, an empty runtime set.
|
|
func assembleLegacySnapshot(ctx context.Context, in legacyAssembly, generation uint64, ext extensionBoot, mgr *sidecar.Manager) (*extension.RuntimeSnapshot, *extension.RuntimeSet, *dispatch.Dispatcher, error) {
|
|
legacy := legacyContributions(in)
|
|
b := extension.NewBuilder().
|
|
WithGeneration(generation).
|
|
WithConflictPolicy(extension.ConflictCollect).
|
|
AddContributor(extension.ContributorFunc{
|
|
ContributorName: "boot-legacy",
|
|
Fn: func(context.Context) ([]extension.Contribution, error) {
|
|
return legacy, nil
|
|
},
|
|
})
|
|
|
|
prompt := in.systemPrompt
|
|
var dispatcher *dispatch.Dispatcher
|
|
// postFreeze runs after a successful b.Build when sidecars are live: it
|
|
// builds the generation's dispatcher from the snapshot's own frozen chain
|
|
// and replacements, then broadcasts system_prompt.build with the final
|
|
// prompt so observers see exactly what froze.
|
|
var postFreeze func(snap *extension.RuntimeSnapshot)
|
|
if mgr != nil && (len(mgr.Clients()) > 0 || mgr.OnDemand()) {
|
|
managed := mgr
|
|
bindExtensionUI(ext.ui, managed, ext.warn)
|
|
sidecarContribs := managed.Contributions()
|
|
b.AddContributor(extension.ContributorFunc{
|
|
ContributorName: "boot-extension-runtimes",
|
|
Fn: func(context.Context) ([]extension.Contribution, error) {
|
|
return sidecarContribs, nil
|
|
},
|
|
})
|
|
b.WithActivator(func(actx context.Context, snap *extension.RuntimeSnapshot) (*extension.RuntimeSet, error) {
|
|
rs := extension.NewRuntimeSet(snap.Generation())
|
|
// Track the sidecar manager as a cancelable generation effect so
|
|
// mid-activation failure and drain share one EffectScope owner.
|
|
if err := rs.Track(extension.Effect{
|
|
ID: "sidecar-manager",
|
|
Owner: "boot",
|
|
Component: "extension-runtimes",
|
|
Class: extension.Cancelable,
|
|
Dispose: func(ctx context.Context) error {
|
|
_ = ctx
|
|
return managed.Close()
|
|
},
|
|
}); err != nil {
|
|
_ = managed.Close()
|
|
return nil, err
|
|
}
|
|
// UI hub binding is a reversible generation effect (rebind is free).
|
|
if err := extension.TrackUIHub(rs.Scope(), snap.Generation()); err != nil {
|
|
_ = rs.Close()
|
|
return nil, err
|
|
}
|
|
// Per-client MCP/process handles stay under the manager dispose above;
|
|
// track an event-subscription style teardown for each live client so
|
|
// EffectScope inventory matches the live process set for doctor.
|
|
for _, client := range managed.Clients() {
|
|
pluginID := client.PluginID()
|
|
_ = extension.TrackEventSubscription(rs.Scope(), "sidecar:"+pluginID, func() error {
|
|
// Manager.Close already tears down clients; this is inventory.
|
|
return nil
|
|
})
|
|
}
|
|
_ = actx
|
|
return rs, nil
|
|
})
|
|
|
|
// Resolve replacement-slot claims over the exact contribution set
|
|
// the builder will see, mirroring the kernel's claim pass, so a
|
|
// conflict fails BEFORE any strategy runs and the winning claims
|
|
// drive the pre-freeze strategy below.
|
|
claims, err := resolveReplacementClaims(legacy, sidecarContribs)
|
|
if err != nil {
|
|
_ = managed.Close()
|
|
return nil, nil, nil, err
|
|
}
|
|
required := requiredRuntimeSet(managed)
|
|
clients := sidecarClientResolver(managed)
|
|
dispatchOpts := dispatch.Options{Warn: ext.warn, SidecarDown: ext.onSidecarDown, TightenOnly: tightenOnlyRuntimeSet(managed), Mods: modRuntimeSet(managed)}
|
|
// system_prompt.build strategy: the slot's owner rules on the
|
|
// composed prompt before the snapshot freezes. Skipped on no-op plans
|
|
// so CacheHash stays stable across rebuilds.
|
|
if !ext.skipPromptStrategy {
|
|
if _, owned := claims[extension.SlotSystemPrompt]; owned {
|
|
strategyDispatcher := dispatch.New(nil, claims, clients, required, dispatchOpts)
|
|
payload := dispatch.SystemPromptPayload{Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot}
|
|
if err := strategyDispatcher.RunStrategy(ctx, extension.SlotSystemPrompt, extension.PointSystemPromptBuild, &payload); err != nil {
|
|
_ = managed.Close()
|
|
return nil, nil, nil, err
|
|
}
|
|
prompt = payload.Prompt
|
|
}
|
|
}
|
|
|
|
postFreeze = func(snap *extension.RuntimeSnapshot) {
|
|
dispatcher = dispatch.New(snap.InterceptorChain(), snap.Replacements(), clients, required, dispatchOpts)
|
|
dispatcher.Event(extension.PointSystemPromptBuild, dispatch.SystemPromptPayload{
|
|
Prompt: prompt, WorkspaceRoot: ext.session.WorkspaceRoot,
|
|
})
|
|
}
|
|
} else if mgr != nil {
|
|
// Defensive: preflight already retires client-less managers, but a
|
|
// caller-owned Manager must never leak through assembly either way.
|
|
_ = mgr.Close()
|
|
mgr = nil
|
|
}
|
|
|
|
b.WithSystemPrompt(prompt)
|
|
snap, runtimeSet, err := b.Build(ctx)
|
|
if err != nil {
|
|
if mgr != nil {
|
|
_ = mgr.Close()
|
|
}
|
|
return nil, nil, nil, err
|
|
}
|
|
if postFreeze != nil {
|
|
postFreeze(snap)
|
|
}
|
|
return snap, runtimeSet, dispatcher, nil
|
|
}
|
|
|
|
// resolveReplacementClaims replays the kernel's slot-claim pass (see
|
|
// resolveContributions) over the contribution set before the builder freezes:
|
|
// claims come from every contribution's SlotClaimer payload, winners and
|
|
// losers alike, and a second claimant is a *SlotConflictError.
|
|
func resolveReplacementClaims(groups ...[]extension.Contribution) (map[extension.Slot]extension.ContributionSource, error) {
|
|
claims := extension.NewReplaceClaims()
|
|
for _, group := range groups {
|
|
for _, ct := range group {
|
|
claimer, ok := ct.Payload.(extension.SlotClaimer)
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, slot := range claimer.ReplacementSlots() {
|
|
if err := claims.Claim(slot, ct.Source); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return claims.Claims(), nil
|
|
}
|
|
|
|
// requiredRuntimeSet marks every started sidecar whose manifest declared the
|
|
// runtime required:true, the dispatcher's required-class input.
|
|
func tightenOnlyRuntimeSet(mgr *sidecar.Manager) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, client := range mgr.Clients() {
|
|
if client.TightenOnly() {
|
|
out[client.PluginID()] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// modRuntimeSet names the live runtimes that are Claude Code mods, by the mod
|
|
// registry's own record rather than by anything the runtime says.
|
|
func modRuntimeSet(mgr *sidecar.Manager) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, client := range mgr.Clients() {
|
|
if modhost.Default.Known(client.PluginID()) {
|
|
out[client.PluginID()] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func requiredRuntimeSet(mgr *sidecar.Manager) map[string]bool {
|
|
clients := mgr.Clients()
|
|
out := make(map[string]bool, len(clients))
|
|
for _, client := range clients {
|
|
out[client.PluginID()] = client.Required()
|
|
}
|
|
return out
|
|
}
|
|
|
|
// sidecarClientResolver adapts the Manager to the dispatcher's client lookup.
|
|
// The dispatcher requires an untyped nil for missing clients; Manager.Client
|
|
// returns a typed *sidecar.Client.
|
|
func sidecarClientResolver(mgr *sidecar.Manager) func(pluginID string) dispatch.Client {
|
|
return func(pluginID string) dispatch.Client {
|
|
if client := mgr.Client(pluginID); client != nil {
|
|
return client
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// bindExtensionUI finishes the stage-8a hub binding once sidecars are live:
|
|
// the client resolver routes later /<plugin>:<action> invocations and form
|
|
// submissions, and each client's handshake-declared UI actions enter the
|
|
// registry. An invalid declaration degrades to a warning (the plugin's other
|
|
// contributions are unaffected) rather than failing the build.
|
|
func bindExtensionUI(hub *uihub.Hub, mgr *sidecar.Manager, warn func(string)) {
|
|
if hub == nil || mgr == nil {
|
|
return
|
|
}
|
|
hub.SetResolver(func(pluginID string) uihub.ActionClient {
|
|
if client := mgr.Client(pluginID); client != nil {
|
|
return client
|
|
}
|
|
return nil
|
|
})
|
|
for _, client := range mgr.Clients() {
|
|
actions := client.Handshake().UIActions
|
|
if len(actions) == 0 {
|
|
continue
|
|
}
|
|
if err := hub.RegisterActions(client.PluginID(), actions); err != nil {
|
|
warn(fmt.Sprintf("%s: extension UI actions not registered: %v", client.PluginID(), err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// legacyContributions maps the assembled runtime resources to kernel
|
|
// contributions, reusing the extension package's per-kind mappers so scope
|
|
// attribution stays in one place. Contributions whose IDs predate or violate
|
|
// the kernel's ID contract are skipped: one pathological legacy name must not
|
|
// take down the whole snapshot, and the live runtime is untouched either way.
|
|
func legacyContributions(in legacyAssembly) []extension.Contribution {
|
|
specByName := map[string]plugin.Spec{}
|
|
for _, spec := range in.mcpSpecs {
|
|
specByName[mcpNormalizedServerName(spec.Name)] = spec
|
|
}
|
|
var out []extension.Contribution
|
|
for _, entry := range in.registry.ContractEntries() {
|
|
if !extension.ValidToolID(entry.Name) {
|
|
// Legacy MCP name normalization preserves uppercase characters,
|
|
// which predates the kernel's lowercase tool-ID contract. Such a
|
|
// tool keeps working in the live registry; it is skipped here
|
|
// rather than failing the whole snapshot.
|
|
continue
|
|
}
|
|
out = append(out, extension.Contribution{
|
|
Kind: extension.KindTool,
|
|
ID: entry.Name,
|
|
Source: legacyToolSource(entry.Name, specByName),
|
|
Payload: entry,
|
|
})
|
|
}
|
|
for _, sk := range in.skills {
|
|
if !kernelID(sk.SlashName()) {
|
|
continue
|
|
}
|
|
out = append(out, extension.SkillContribution(sk))
|
|
}
|
|
for _, cmd := range in.commands {
|
|
if !kernelID(cmd.Name) {
|
|
continue
|
|
}
|
|
out = append(out, extension.CommandContribution(cmd))
|
|
}
|
|
perEvent := map[hook.Event]int{}
|
|
for _, h := range in.hooks {
|
|
seq := perEvent[h.Event]
|
|
perEvent[h.Event]++
|
|
out = append(out, extension.HookContribution(h, seq))
|
|
}
|
|
for _, spec := range in.mcpSpecs {
|
|
if !kernelID(spec.Name) {
|
|
// A server name outside the kernel ID contract cannot be a
|
|
// catalog entry; the server's tools still attribute through
|
|
// specByName above.
|
|
continue
|
|
}
|
|
out = append(out, extension.MCPServerContribution(spec))
|
|
}
|
|
for _, desc := range in.providers {
|
|
if !extension.IsProviderRef(desc.Ref) {
|
|
// The kernel keys providers on <name>/<model> refs; legacy
|
|
// catalogs can carry a bare provider name or a model that itself
|
|
// contains a slash. Those entries stay resolvable through the
|
|
// ordinary provider path but cannot be catalogued in a v2
|
|
// snapshot.
|
|
continue
|
|
}
|
|
out = append(out, extension.ProviderContribution(desc))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// kernelID mirrors the generic ID hygiene the kernel validates for every
|
|
// kind (non-empty, no whitespace). The kernel's parse step trims first, so
|
|
// only interior whitespace or emptiness disqualifies.
|
|
func kernelID(id string) bool {
|
|
id = strings.TrimSpace(id)
|
|
return id != "" && !strings.ContainsAny(id, " \t\n")
|
|
}
|
|
|
|
// legacyToolSource attributes a registry tool to its origin. MCP-backed tools
|
|
// (the mcp__<server>__<tool> namespace) belong to the server's spec: a
|
|
// package-owned server attributes to the plugin package, otherwise to the
|
|
// server itself, both at the plugin tier. Everything else — and any MCP tool
|
|
// whose server can no longer be matched to a spec — stays at the builtin
|
|
// tier: the registry is the compile-time default surface, and an
|
|
// unattributable entry must not masquerade as a higher one. Tool IDs are
|
|
// unique inside one registry, so the scope is provenance only and
|
|
// never decides a shadow race.
|
|
func legacyToolSource(name string, specByName map[string]plugin.Spec) extension.ContributionSource {
|
|
if server, _, ok := tool.SplitMCPName(name); ok {
|
|
if spec, found := specByName[server]; found {
|
|
pluginID := strings.TrimSpace(spec.Package)
|
|
if pluginID == "" {
|
|
pluginID = spec.Name
|
|
}
|
|
origin := strings.TrimSpace(spec.ConfigSource)
|
|
if origin == "" {
|
|
origin = "mcp"
|
|
}
|
|
return extension.ContributionSource{Scope: extension.ScopePlugin, PluginID: pluginID, Origin: origin}
|
|
}
|
|
}
|
|
return extension.ContributionSource{Scope: extension.ScopeBuiltin, Origin: "builtin"}
|
|
}
|
|
|
|
// mcpNormalizedServerName returns the server name as it appears inside
|
|
// mcp__<server>__<tool> registry names, i.e. after the plugin package's name
|
|
// normalization (invalid characters replaced, collision hash appended).
|
|
func mcpNormalizedServerName(name string) string {
|
|
return strings.TrimSuffix(strings.TrimPrefix(plugin.ToolPrefix(name), tool.MCPNamePrefix), "__")
|
|
}
|
|
|
|
// enabledMCPSpecs returns the deduplicated set of MCP server specs a build
|
|
// enabled: the configured eager/background tiers plus host-session extras.
|
|
// Names are deduplicated so the kernel catalog holds one entry per server.
|
|
func enabledMCPSpecs(configSpecs, extraSpecs []plugin.Spec) []plugin.Spec {
|
|
var out []plugin.Spec
|
|
seen := map[string]bool{}
|
|
add := func(spec plugin.Spec) {
|
|
name := strings.TrimSpace(spec.Name)
|
|
if name == "" || seen[name] {
|
|
return
|
|
}
|
|
seen[name] = true
|
|
out = append(out, spec)
|
|
}
|
|
for _, spec := range configSpecs {
|
|
add(spec)
|
|
}
|
|
for _, spec := range extraSpecs {
|
|
add(spec)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// gateExtensionUIRequest serves a sidecar's blocking host/ui/request once the
|
|
// session controller exists. A sidecar may legally ask right after
|
|
// extension/initialized — while the build is still assembling the controller —
|
|
// so the preflight hub cannot answer immediately. Rather than failing the
|
|
// prompt (which would deadlock an extension waiting on its own startup
|
|
// request), the gate waits for the controller to become ready, for the build
|
|
// to fail, or for the request context to cancel. serve is only invoked after
|
|
// load reports a controller.
|
|
func gateExtensionUIRequest(reqCtx context.Context, load func() *control.Controller, ready <-chan struct{}, failed <-chan struct{}, serve func(*control.Controller) (map[string]any, bool, error)) (map[string]any, bool, error) {
|
|
if c := load(); c != nil {
|
|
return serve(c)
|
|
}
|
|
select {
|
|
case <-ready:
|
|
c := load()
|
|
if c == nil {
|
|
return nil, false, fmt.Errorf("extension UI request: controller readiness signalled without a controller")
|
|
}
|
|
return serve(c)
|
|
case <-failed:
|
|
return nil, false, fmt.Errorf("extension UI request arrived but the session build failed")
|
|
case <-reqCtx.Done():
|
|
return nil, false, reqCtx.Err()
|
|
}
|
|
}
|