Status: shared Core implemented; the macOS settings pages run both routes
read-only. Core validates data and resolves captured facts; it does not launch
providers, probe programs, parse manager files, watch paths, write pins, or
download tools. The macOS settings host expands discoveryPlan scans, starts or
reuses each enabled provider's plugin backend, sends toolchains/query through
lsp.toolchainRequest, passes every raw reply with native facts to decodeReply,
runs the definition's version probe on executables Core returns from
candidates, and shows the result. Any change to a watched input reloads the
whole snapshot. Nothing is saved by these pages. A separate fresh-resolution
adapter serves macOS Build and Provider Run; settings detections are never used
as launch permits. Language-server consumers resolve their tools with their
own adapters. Windows has no
toolchain adapter and does not advertise toolDefinitions or
toolchainProviders, so Core refuses packages that require them there.
Consumer migration, the native query lifecycle and cache, pin saving and
version-manager plugins are not specified yet. The manifest shape is
plugin-manifest.schema.json.
toolDefinitions[] needs requires.toolDefinitions: 0. toolchainProviders[]
needs requires.toolchainProviders: 0, backend: 0, and permissions
backend.launch / toolchains.provide. Capability lists are exact: missing or
unused capabilities reject the manifest. A definition/provider alone is a valid
contribution. Executable candidate sets keep their strict source types; the
toolchain candidate of a build consumer needs no definition, and a consumer
does not gain definition-owned candidate fields (see
Executable resolution).
Definitions own IDs, labels/icons, executableLayouts, ordered executables,
version, homeLayouts, discovery, homeVariable, commands, wrappers,
exports, pathPrepend and argv launch. Counts, relative paths, bounded globs,
platforms, templates and version expressions are validated recursively; unknown
fields fail closed. Candidate graph ordering reuses the existing shared graph
validator and cannot cross layer barriers. This slice does not support a
definition candidate of kind: runtime, or toolchainConsumers.
Provider entries contain {id, displayName, tools, watchFiles?}; each watch entry
is {pattern, reader: "native" | "compatible"}. A present watchFiles lists 1-32
unique patterns; an absent one makes an installation-only provider, whose
replies may not contain statements. Providers share the plugin's
backend; admission permits a host-query trigger without a page or startup event,
and the macOS host starts the backend on the first query. Bootstrap must be packaged, or
runtime with packagedRuntime, without discovered runtime dependencies. A
launch names a tool definition by ID, and requiresTools lists it (see
Tool references); see
instantiateLaunch.
toolchains.provide may influence which program a defined tool launches. It does
not grant arbitrary command replacement, installation, network or workspace-code
execution. Any plugin may define a short ID such as compiler or a qualified
<own-plugin-id>:<local-id>; the prefix of a qualified ID must be the defining
plugin's own ID, so qualified IDs of different plugins never collide. A short ID
defined by more than one enabled plugin belongs to none of them (see the registry
rules below). Providers may answer short IDs. No host-owned tool table, name
alias or manager parser is added.
A native Build consumer that directly executes a tool currently accepts only
["${executable}", "${arguments}"]: its tool context carries a program and
separate Build arguments. Extra launch flags or ${home} tokens are refused
before probing, rather than dropped. An installation dependency instead consumes
checked version evidence and declared exports; it does not instantiate the tool's
entry-launch recipe. For example a runtime can declare a JAR recipe while a build
engine consumes only that runtime's installation and environment. Such a dependency
is not published as a direct executable binding. Entry processes continue to use
their own declared process recipe; supporting more direct templates remains pending.
A definition receives installations through either route, or both. Neither is required: a tool may rely only on pins, project files, global defaults and login-shell commands.
| Route | Declared by | Fits | Runs |
|---|---|---|---|
| Declarative discovery | The definition's discovery, homeLayouts, homeVariable, commands and version |
Installations with a fixed directory shape: a single-component glob under the user home, an environment variable or an absolute system directory, an optional home suffix, and a version expression over a probe | The host expands discoveryPlan scans; no plugin code runs |
| Provider query | toolchainProviders[] answering toolchains/query |
Locations those primitives cannot express: registries, property lists, manager databases, command output, manager-specific version files | The provider's packaged backend |
The declarative primitives are frozen: root kinds stay home, environment
and absolute, and patterns stay single-component * globs. A new installation
shape is answered by a provider, not by a new root kind or a host parser. The
plugin that defines a tool may also declare a provider for it; omit
watchFiles when it only reports installations. A provider backend never runs
on a tool it discovers: the bootstrap rules above apply to a defining plugin's own
provider as well.
discoveryPlan lists both routes per tool. The host expands scans into
systemInstallations, recorded with provenance host:system, and queries every
qualified provider in providers. In the automatic layer both routes form one
candidate set. One canonical home counts once: a provider answer labels it
before the host scan, and among providers the smaller qualified ID labels it.
This label is display provenance and never chooses between different homes;
equal highest versions in different homes remain an automatic conflict that
names every route.
All commands accept UTF-8 JSON values and have a 4 MiB input budget. Success is
{status:"ok", <key>:value}; refusal is {status:"rejected",error:{code,message, field?}}. Errors are structured policy outcomes, not host-localized UI text.
Future hosts render stable codes through shared i18n; English diagnostic messages
are not a substitute for that adapter. These commands perform no native I/O.
| Command | Input | Success key |
|---|---|---|
toolchains.registry |
{plugins:[{manifest}]} |
registry |
toolchains.decodeReply |
Query/response and native evidence described below | snapshot |
toolchains.failureReply |
{params,code} described below |
reply |
toolchains.resolvePins |
{document,directory,tool} |
resolution |
toolchains.resolve |
Captured resolver input described below | resolution |
toolchains.checkVersion |
{registry,tool,output,minimumVersion?} |
version |
toolchains.instantiateLaunch |
{registry,tool,executable,home?,entry?,runtimeArguments,arguments} |
launch |
toolchains.discoveryPlan |
{registry,platform} |
plan |
toolchains.candidates |
Captured discovery input described below | candidates |
Every registry input is the raw {plugins:[{manifest}]} request of the enabled
plugins, not a previously normalized result. Core revalidates complete production
manifests; the host supplies no proof of a plugin's origin, and a plugin's own
claim of one is not read. Output is {definitions:[{pluginID,definition}], providers:[{pluginID,provider}],conflicts:[{tool,pluginIDs}]} in stable order.
A short ID defined by more than one enabled plugin belongs to none of them. Core
registers none of those definitions and registers every other definition as
usual. conflicts lists each contested tool once, sorted by tool, with the IDs
of all plugins that defined it, sorted. Ownership is never decided by arrival
order or by plugin ID order, because a hostile plugin could take a tool by being
loaded first or by choosing a smaller ID. A contested tool is absent from the
registry: discoveryPlan lists no scans or providers for it, and a consumer that
resolves it treats it as declared by nobody. A host should tell the user which
plugins contest the tool; the macOS settings page shows one diagnostic per
conflict and advises keeping only one of those plugins enabled. Qualified IDs
cannot be contested, since their prefix is the defining plugin's ID. A repeated
plugin ID still rejects the whole batch without replacing earlier owners. Limits
are 128 plugins, 512 aggregate definitions and 256 aggregate providers.
A provider answers over its plugin backend's JSON-RPC connection, the same transport other backend requests use (Process start). The backend need not declare a language, language server, Build provider or callable function.
The host starts the backend with lsp.startServer and passes the manifest's
provider IDs as toolchainProviders. Only a plugin-owned session that declares
at least one provider is offered the protocol, in the client capabilities of
initialize:
{"experimental": {"litheToolchains": {"versions": [0], "providers": ["first", "second"]}}}providers lists the declared IDs in sorted order. The backend selects in the
server capabilities of its initialize result:
{"experimental": {"litheToolchains": {"version": 0, "providers": ["first"]}}}The protocol has no version of its own: its version is that of the
toolchainProviders capability, 0. version must be an offered version and
providers a non-empty list of
offered IDs without repeats; other fields are ignored. Any other answer, including
an unoffered version, an undeclared, repeated or non-string ID, or an empty list,
enables no provider at all rather than a partial set, and no query is sent. On
success Core emits a toolchainProviders runtime event with result: {providers}.
The host sends each query with lsp.toolchainRequest
{sessionId, operationId?, params, timeoutMilliseconds?}, where params is the
toolchains/query params described below; Core writes the request with that
method and its own JSON-RPC ID. A params.providerID the backend did not select
is refused with not_supported and details plugin.toolchain.providerUnavailable,
and nothing is written. timeoutMilliseconds is clamped to 1-60,000; without it
the session's request timeout applies. Completion carries result.value: {id, response}: the wire ID and the complete, unvalidated response, error replies
included. The host passes it with the same params to decodeReply.
Cancellation and late results follow
lsp.toolchainRequest.
decodeReply takes {registry,pluginID,request,response,currentGeneration, filesystem,platform,filesystemCaseSensitive?,workspaceRoots?}. pluginID binds
the host-owned session, not a provider claim. platform is macos, windows or
linux; native filesystem case policy is explicit where platform defaults differ.
request is {jsonrpc:"2.0",id,method:"toolchains/query",params:{operationID, generation,providerID,authority:{kind:"local"},context,workspace,tools}}.
context is {contextID,generation} or null; workspace queries require it.
workspace is {rootID,root,directory} or null for global-only queries.
The JSON-RPC id is a positive integer or a nonempty string of at most 128
UTF-8 bytes without control characters; the reply preserves its exact type and
value. Core's LSP transport uses string IDs. Numeric 1 and string "1" are
distinct identities and cannot acknowledge each other's request.
directory is normalized workspace-relative, with . for the opened root.
Tools are unique and sorted. This envelope is validated as inert data; no query
is dispatched and no protocol capability is negotiated by this command.
Successful response echoes JSON-RPC id and has result:{operationID,generation, providerID,installations,statements,watch}. Installations are {tool,version,home}.
Statements are {tool,scope,source,request,installation,kind,reader}: scope is
project|global, kind is environment|declaration, reader matches the provider's
declared watch pattern; installation:null records an unmet requirement. Request
syntax is opaque. Non-null installations must reference the same tool in the
reply. watch contains absolute native inputs, including missing paths.
Each native filesystem fact is {path,canonicalPath,kind,executable?} with kind
file|directory|missing. These facts come from the authority-local host, never
the provider. Core checks unique paths, actual-kind evidence, definition-owned
executable layouts, canonical home containment and exclusion of all workspace
roots. Provider homes are final tool homes; homeLayouts is not reapplied.
A non-missing fact also attests its canonical target with the same kind and mode,
so canonical results found through a link are checked against the same snapshot;
facts that disagree about one canonical path reject it.
Inactive/unknown tools lose installation authority; bounded requirement facts
remain diagnostic only. Lists normalize deterministically; sort order never
chooses between conflicting requirements. A reply is bounded to 1 MiB, 512
installations, 256 statements and 256 watch paths; mismatched IDs/generations,
remote authority, unsafe paths and malformed error mappings reject it.
The snapshot contains {installations,statements,watch,diagnostics}; normalized
entries carry qualified <pluginID>:<providerID> provenance and project source
directory. Cancellation, deadlines, cache invalidation and whole-generation
requery remain native-client implementation work, not behavior performed by this
decoder.
A provider that cannot answer returns a JSON-RPC error instead of result.
error has exactly {code,message,data}, and data has {code,operationID, providerID} plus an optional details:
codeis a number from the table below anddata.codeis the stable code on its row. Each number has exactly one stable code.operationIDandproviderIDequal those of the requestparams.messageis non-empty diagnostic text of at most 1,024 UTF-8 bytes without control characters. It is shown as provider text and never selects behavior.details, when present, is an object of at most 4,096 bytes serialized. It must not carry credentials.
decodeReply then refuses with the stable code and the provider's message.
An error reply that breaks any of these rules, including an unknown number or a
number that does not match data.code, is refused as
plugin.toolchain.invalidReply.
| JSON-RPC | Stable code | Meaning |
|---|---|---|
-32601 |
plugin.toolchain.methodUnavailable |
The backend does not serve the query method |
-32602 |
plugin.toolchain.invalidParams |
Malformed request or invalid declared input |
-32800 |
plugin.toolchain.cancelled |
The caller cancelled the request |
-32070 |
plugin.toolchain.permissionDenied |
A permission, review or enabled-state prerequisite is missing |
-32071 |
plugin.toolchain.authorityUnsupported |
The provider cannot serve the requested authority |
-32072 |
plugin.toolchain.timeout |
The local deadline passed |
-32073 |
plugin.toolchain.providerUnavailable |
The backend failed to start, disconnected, or its upstream query failed |
-32074 |
plugin.toolchain.invalidReply |
Malformed reply, or an installation reference to another tool |
-32075 |
plugin.toolchain.limitExceeded |
A reply, scan or watch budget was exceeded |
-32076 |
plugin.toolchain.unsafePath |
A path is not absolute, escapes its containment or fails executable checks |
-32077 |
plugin.toolchain.operationExpired |
The context or generation was already stale when the query was sent |
A missing installation, an incompatible version or conflicting requirements are
not errors: report them in result (an unmet requirement has
installation:null) and let the resolver give the reason.
When a query gets no reply at all, the host asks failureReply for one. params
is the query's own params and code is plugin.toolchain.timeout (the local
deadline passed) or plugin.toolchain.providerUnavailable (the backend failed to
start, disconnected or did not offer the provider). The result is
{id,response}, the same shape lsp.toolchainRequest completes with, holding the
mapped JSON-RPC error for that operation and provider. The host decodes it with
the same request like any provider error. Cancellation and every other code are
rejected, so a cancelled or refused query never becomes a missing provider.
resolvePins validates an inert local document {version:1,toolchains,pins?}.
toolchains holds per-tool entries; pins maps directory keys to tool maps of
{home,version,provider}. It returns {pin,sourceDirectory}, both null if absent.
Nearest exact ancestor wins; sibling prefixes do not match. Dormant pins survive
missing definitions. No lookup grants execution authority or writes a file.
Saving, conflict protection and .lithe/.gitignore updates are not implemented.
resolve requires {registry,tool,directory,operationID,generation,context, platform,workspaceRoot,filesystem} and accepts workspaceRoots,
filesystemCaseSensitive, local, minimumVersion, probes, queries,
systemInstallations, globalDefault, environment, commandHomes,
projectCodeAllowed, providerChoices and fixed. local defaults to an empty
document. Probes are host observations {executable,output,exitCode} keyed by the
canonical executable a selection reports; system installations are {tool,home}.
Shell environment home variables and commandHomes come from the native login-shell
adapter, not command-name guessing. An environment candidate selects only through
its declared path layout.
Each query is a complete decodeReply input, bound to the resolver's registry,
generation/context, root/directory, case policy, roots and filesystem snapshot.
The resolver accepts local workspace queries here; global-only decoder inputs
are not a substitute for the workspace-bound query round. providerChoices
records explicit project/global provider or automatic-installation choices; it
does not activate providers or bypass their evidence checks.
Its shape is {project?:"pluginID:providerID",global?:"pluginID:providerID", automatic?:"absolute-installation-home"}; choices not present in the current
validated candidate set confer no authority.
Unavailable/timed-out optional providers remain visible as providerUnavailable
diagnostics while lower sources may resolve. Each is {code,tool,provider,cause},
recorded only for a query that asked about this tool; provider is qualified and
cause is the decoded error code, keeping a missed deadline apart from a backend
that could not answer. Safety, identity and cancellation
failures are not converted to missing tools. A valid explicit pin does not need
lower-layer provider answers at all.
A consumer may supply fixed:{home} or fixed:{executable} for one operation.
Each object has exactly one field, an absolute native path bounded to 4,096 UTF-8
bytes, without traversal or control characters. It contains no version, environment,
provider or execution grant. This explicit choice precedes directory pins and fails
terminally if invalid; lower scans and providers cannot replace it. Installation
choices use the definition's layouts and exports. An executable choice preserves
its lexical launcher path, has no inferred home, and omits home exports and PATH
entries. It requires a fresh successful version probe even without a minimum.
A definition whose launch requires ${home} cannot use that choice. A lexical or
canonical executable inside the workspace requires the current explicit action
before it becomes a probe target. Directory pins and packages stay unchanged.
Four layers apply: fixed nearest pin; nearest project source (wrapper > environment
declaration, native reader before compatible reader of the same source); application global default > unambiguous/chosen provider global > login shell; highest compatible installed release. Nearest project requirements mask parent requirements. Invalid fixed values are terminal, even if a definition omits a pin candidate. Uninstalled requirements retain reasons while resolution falls back. Conflicts require explicit choices, never plugin-ID or home-path precedence. Manual pin-only installations are not automatic candidates.
Outcome is resolved with {home,executable,version,provider,layer,projectCode, environment,pathPrepend}, skip with a reason, or terminalFailure with a reason.
Output also carries operation/tool/directory, trace, unmet and conflicts.
Wrapper homes may be null; home exports/PATH are then omitted. Project code
requires an explicit consumer authorization snapshot and is never passively
probed. This foundation rejects project programs or environment homes whose
canonical identity differs from their declared lexical identity;
complete native link-chain validation, including common venv interpreter links,
is deferred rather than silently treated as safe. Each candidate builds a fresh
environment, so rejected exports cannot leak into its successor.
checkVersion returns {satisfied,version} from the definition's first regex
capture. Numeric dotted components (at most eight unsigned 32-bit parts, 64 bytes)
compare with trailing zeros ignored; aliases and tool-specific version syntax are
not interpreted. No match gives version:null,satisfied:false. discoveryPlan
returns {tools,limits}. Each tool carries platform-filtered scans, the sorted
qualified providers that declare it, and the definition's homeLayouts,
executableLayouts, version probe, pathPrepend templates and environment
login-shell home variables ({variable,path} from its platform-applicable
environment candidates). It neither scans, queries nor executes them; limits
are 256 roots, 4096 entries and 64 ancestors for the host adapter.
A plugin that starts a tool by definition ID (a launch.kind: "tool" plan, or a
run/start with {kind: "tool"}) never writes the tool's argv. The definition's
launch template says how the tool takes an entry file, runtime arguments and
program arguments, so no host and no plugin branches on a tool name.
toolchains.instantiateLaunch expands it for the executable the host selected:
registryandtoolare as incheckVersion;executableis the selected executable (required).homeis the resolved tool home,entryan absolute file path,runtimeArgumentsandargumentsstring lists. A text is at most 4096 bytes, a list at most 1024 items of 64 KiB each, and none holds a NUL.${executable},${home},${entry},${runtimeArguments}and${arguments}are replaced by the matching value, a list expanding in place. Any other template token is passed as written.- The result is
{executable, arguments, versionArguments}: the program, the rest of the argv, and the definition'sversion.argumentsfor the probe. - A value the caller supplies that the template cannot carry (an
entryfor a tool that launches none, or arguments for one that takes none), or a${home}or${entry}the caller left out, isplugin.toolchain.launchMismatch. An unknown or disabledtoolisplugin.toolchain.invalidParamswithfield: "tool".
The command performs no native I/O. The host selects the executable with the
four resolution layers and the launch's minimumVersion, probes
versionArguments, checks the output with checkVersion, and then starts the
expanded argv.
candidates reports what a settings page shows for one tool's global and
automatic layers without resolving a directory. It requires {registry,tool, platform,workspaceRoot,filesystem} and accepts workspaceRoots,
filesystemCaseSensitive, minimumVersion, probes, systemInstallations,
providerInstallations, environment, fixed and projectCodeAllowed, with the same meaning and checks as in
resolve. providerInstallations (at most 512) are {tool,home,provider} rows a
host copies from decodeReply snapshots; provider must name a registry provider
pluginID:providerID declaring the tool, and the row keeps that attribution when
a scan reports the same canonical home. The output is
{tool,shell,installations,skipped,selected,conflicts}:
A checked fixed choice without a version contributes a probeFailed skipped row
with its executable for the native probe. An unauthorized workspace program never
contributes such a target. This candidate report itself grants no authority.
shelllists each declared home variable present inenvironmentas{candidateID,variable,home,executable?,version?|reason?}, checked through the candidate's ownpathlayout.installationsare eligible host-scanned homes{home,executable,version, provider}, highest version first, with the canonical home Core checked.skippedlists homes that failed a check as{home,provider,reason, executable?}; reasons are stable codes such asunsafePath,probeFailedandversionUnsatisfied.selectedis the automatic-layer home, ornull; equal highest versions in different homes leave itnulland add anautomaticentry toconflicts.
A first call without probes returns every checked executable as probeFailed.
The host runs only those executables with the definition's probe arguments,
then calls again with their {executable,output,exitCode}. Homes inside a
workspace root are refused before any probe, so a project program is never run
to read its version. The macOS host bounds probes by count, concurrency and
total time, and reports skipped work as diagnostics rather than a missing tool.
Production-shape examples are tool-definition.plugin.json and toolchain-provider.plugin.json. Core acceptance vectors are in toolchain_definitions.rs, with sibling pins/query/resolution suites. The pin document, query exchange and one error reply per stable code that those suites share are inline in toolchain_vectors.rs. Passing them is not native product verification. Contract changes land in the same pull request as the Core code that enforces them; there is no other copy to update.