Controls and plugin dependencies
Local target ownership
The known localExecution control implements the same four local-target methods as SandboxControl, plus prepareExecution returning a local prepared session, the core artifact channel and the three publication methods described below. It is a core-owned host control: the authoritative daemon registers it with registerHostControl over the paired computers' executor sockets (paired computers), and no plugin contributes it. Only Sandbox may consume it and must declare consumesControls:['localExecution'] with control reads. Core and ordinary plugins resolve sandbox, retaining one execution seam. In the daemon a missing owner is a broken host and Sandbox throws; there is never alternative execution.
Sandbox registration fixtures need prepareExecution, every ENVIRONMENT_CONTROL_METHODS method, and resolveLocalTarget, bindLocalTurn, localFiles and localRequest. Core supplies runCommand when resolving the control. In tests/plugins/pluginControls.test.ts, unused local operations throw, the launch method remains a spy, and managed reads override projectFiles with the bounded guest provider. Assert control resolution before checking consumer behavior so an incomplete fixture cannot hide the intended grant, live-replacement or file-boundary assertion. Method-shape validation performs no target I/O.
A plugin resolving ctx.control(key) must declare reads: ['controls'] and include that key in consumesControls. An absent or missing key is refused with a local diagnostic and returns undefined. Privileged keys independently enforce the host's authority allowlist; declaration alone never grants credentials, process launch or artifact access. Resolve the control on each use. A disabled, missing or incomplete owner returns undefined. Sandbox artifacts remain core-only. The Files plugin declares sandbox and skillResources as a real consumer.
Project publication transport
Sites resolves the existing Sandbox control and calls
projectPublicationAuthorize({project,accountUserId}),
projectPublicationBinding({project,accountUserId?,publicationId,port}) and
projectPublicationRelease({project,publicationId,retire?}). The Project ref is
ManagedProjectRef | LocalProjectRef; the result remains {generation,socketPath}.
Use the returned trusted unix socket for HTTP and WebSocket proxying. No new control
registration is needed. Missing Sandbox means publication is unavailable.
For a computer, initial binding requires the publishing member's account, checked
with canExecute; administrator visibility alone grants no execution. Sandbox
persists port and publisher in its existing publication row and restores the binding
without another account argument only for that same port. Core owns the live
listener and tunnels, while Sandbox owns durable retirement and publication
mutation locks. Sandbox derives the canonical socket path and supplies socketPath
to the core host control's binding and release methods; Sites continues passing
only the public arguments above. Each visitor reaches only a loopback port on the
computer. Offline bindings close visitor connections.
A connected executor without the handshake capability rejects publishing with
LOCAL_CLIENT_OUTDATED, status 409, requesting a CLI update. Member removal
closes the publisher's live transport through Sandbox's mutation locks before
process cancellation and after the membership transaction commits, including
membership changes made by account presets. The daemon-only local host control remains
restricted to Sandbox; consumers must not resolve it directly. Runner calls forward
through the existing scoped host RPC and retain Sites tool grants and read-only
refusals. The full lifecycle and protocol are documented in
paired computers.
File transfer admission
ctx.host.projectFiles().read and write support the current local target as well as host and
managed targets. Local paths use the computer's platform and current cwd, with no host fallback.
Reads use the share-source consent boundary outside cwd. Writes publish through the shared CAS
upload; create-only is the default and overwrite first observes the current destination version.
Existing capability, approved-consumer and 250 MiB bounds still apply.
Files alone may call host.projectFiles().transfer({source, destination, expectedVersion, move?}, {signal, onProgress}). Each endpoint is {kind:'project',projectId,path} or
{kind:'computer',path}; the latter means the conversation's selected computer. Distinct named
Projects can transfer between each other; computer transfers require a managed Project.
There is no anonymous host endpoint. Core checks current account membership and turn scope on
both Projects at every operation, preserves managed generation and local socket binding, and
uses guestFiles streaming rather than buffering the file. expectedVersion:null is create-only.
Progress counts destination acknowledgements. Move removes the observed source version only
after confirmed target commit. Unknown write outcomes remain typed LOCAL_EXECUTION_LOST
failures with mutationOutcome:'unknown', without replay or host fallback. Missing stores or
Sandbox throws. The Files tool uses the normal permission gate with default ask; this host
method adds no registration and never schedules background retries.
Guest file client
Import functions from elowen-plugin-shared/guestFiles and bind files to the caller's live,
authorized Sandbox projectFiles control. Keep account, Project, generation, workspace and
startIfNeeded on that binding. The client registers nothing and holds no authority.
Without a provider calls reject. Consumers never construct operation objects or copy
chunk, paging, parent-creation or byte-collision sequences.
import { readGuestFile, writeGuestFile } from 'elowen-plugin-shared/guestFiles';
const files = operation => ctx.control('sandbox').projectFiles({
project, accountUserId, expectedGeneration, workspaceId, operation,
});
const { bytes, version } = await readGuestFile(files, path, { maxBytes: 4 * 1024 * 1024 });
await writeGuestFile(files, path, replacement, { expectedVersion: version });
statGuestFile(files, path, { follow? }) returns an entry or null.
listGuestDir(files, path, { metadata, limit?, cursor? }) follows pages up to the total bound
and returns entries, truncation and the next cursor; metadata entries carry no version.
walkGuest(files, path, { limit, skip?, maxDepth? }) uses one bounded guest traversal. Files uses it for project discovery and OneDrive for tree mirrors. Symlinks are never followed; the guest reports truncation when the entry or depth bound is exhausted, and failures propagate to the caller.
readGuestFile(files, path, { maxBytes, offset?, length?, expectedVersion? }) returns
{ bytes, version, totalBytes }. Without length the entire remaining file must fit the bound;
length selects a bounded range. readGuestStream yields blocks without buffering the whole file.
Reads use 128 KiB blocks and pin one version with fresh content hashes at the first and final blocks.
Only intermediate pages may reuse a cached version. The final read follows symlinks and rehashes;
no closing stat is needed. Files may pass opened to continue the same snapshot from its verified
classification read. If it already covers the range, that opening is the final page and adds no
operation: a tiny managed Read costs one guest read. Otherwise the final page rehashes against
the opening version. A prior stat's expectedTotalBytes validates size before accumulating bytes.
writeGuestFile uses one write up to and including 512 KiB, or a chunked upload above it.
Parents are created only after parent_missing; returned type and size are checked.
Null expected version is create-only; a string replaces exactly that version.
publishGuestFile compares existing bytes only after already_exists; different bytes
remain a coded collision. makeGuestDir creates missing ancestry. renameGuestFile and
copyGuestFile(files, path, destination, { expectedVersion }) require the source version and
attempt one operation; only parent_missing creates destination ancestry before retrying the
same source CAS. Copy returns the destination entry, preserves symlinks and refuses an existing
destination. removeGuestFile(files, path, { expectedVersion, recursive? }) defaults to
nonrecursive and sends recursive only when explicitly supplied. Editor uses copy for duplication
and recursive: true for directory deletion, with the selected entry's existing version rather
than a whole-tree snapshot. The bound transport retains source/destination authorization, allowed
roots and symlink confinement. Other transport errors propagate unchanged.
beginGuestUpload(files, { path, size, expectedVersion, subject }) returns path, received,
broken, append, commit and abort. Append regroups arbitrary pieces into exact transport
chunks with at most one chunk buffered. An interrupted stream drops its unacknowledged
partial chunk and resumes from received; destination failure marks the handle broken.
append(body, signal) checks cancellation between chunks and passes the signal as the
optional second argument of the bound file call. Local executor loss and an unknown mutation
outcome throw unchanged; callers must discard that upload instead of resuming or replaying it.
localFileCall combines the binding and request signals and lets explicit abort cleanup run
after request cancellation, while the pinned transport still refuses a lost connection.
Commit checks declared size. uploadGuestFile(files, input, body) appends/commits and
aborts on failure, reporting cleanup failure alongside the first error.
MCP's ReadMcpResource awaits writeGuestFile for managed binary exports, using
expectedVersion: null so every returned reference retains its original bytes.
The existing managed export limit is 16 MiB. Host exports instead await the MCP
plugin's persistResourceBlob: asynchronous exclusive creation and chunked base64
decoding preserve the bytes and metadata without a host size cap. A failed host
export removes only its own incomplete file; an existing destination survives a
creation collision. Neither path returns a saved reference before persistence finishes.
Bundled Files, MCP exports and core managed output publication use this client. The bundled Files and
MCP manifests ship with the paired host, declaring requiresCore: 0.30.4 and requiresSharedApi: 14.
The old guestUpload module is removed without a compatibility entry.
Controls and plugin dependencies
A control is a live domain interface, not a plugin-name lookup. Publish a key
with ctx.registerControl(name, control) and declare it in
provides.controls. Consume a sibling with ctx.control(name) and declare
reads: ["controls"]. Resolution happens at call time and returns
undefined when the provider is disabled or unavailable. Treat that as a
real state and fail honestly. Never cache the returned object.
Owning code: src/plugins/registry.ts (control, registerControl) and
src/plugins/api.ts (PluginContext). A consumer also needs its key in
consumesControls when the manifest has that field. Without it, or without
reads: ["controls"], ctx.control() returns undefined and logs a warning
(src/plugins/registry.ts, control). A provider whose registration lacks a
method that core calls for that key counts as absent (KNOWN_CONTROL_METHODS in
src/plugins/registry.ts).
The bundled Files plugin declares skillResources in consumesControls for managed Read support files.
Core provides that authority from the live, grant-, owner- and override-filtered skill catalog. Only Files
may consume it, and only canonical directory-form skill roots authorize a read; flat skills do not grant
access to their shared loader folder. A hidden skill or missing provider grants no host resource access.
Real consumer: plugins/files/index.mjs calls ctx.control('skillResources'); the
provider is registered in src/daemon/brainCore.ts.
requiresControls names control contracts needed before enablement. The host
uses the manifest declarations to report missing providers without coupling one
plugin to another plugin name. Declare a key here when the plugin cannot do its
job without its provider; a conditional feature, such as managed-project access
in a plugin that also serves host projects, stays in consumesControls only.
Core-owned keys, such as skillCatalog, machineRuntimeGateway or localExecution, are not plugin
dependencies. A registration may also set
registerControl(name, control, { requires }) for a live control dependency.
Enablement enforces the declaration. When no enabled provider publishes a
required key, the enable request answers 409 with error: "missing plugin dependency" and controls: [{ key, providedBy }] (src/api/routes/plugins/index.ts,
enablePlugin; missingControls in src/api/routes/plugins/marketplace.ts).
An unknown key has no provider and therefore blocks enablement.
The licence server derives optional catalog requiresControls: string[],
provides.controls: string[] and label: string from the manifest archived for
that exact version, alongside its compatibility requirements. Never hand-write
these dependency fields into the registry. Omitted fields make no claim; older
core catalog parsers tolerate unknown fields. Core reads these fields in
src/plugins/marketplace.ts. The derivation runs on the licence server, which is
not part of this repository.
Admin GET /plugins and GET /plugins/marketplace add dependencies: one row per
resolved provider plugin, shaped as { name, label?, i18n?, enabled }, where
i18n carries installed manifest locale labels. Installed manifests override
catalog declarations for the same provider. Catalog-only, disabled and
soft-removed providers have enabled: false. Multiple controls provided by the
same plugin yield one row; self-provided controls yield no pill. Unknown keys
still refuse enablement, but produce no invented plugin identity. Unreadable
installed manifests have no dependency claims to display.
The same resolveControlDependencies helper matches control keys for the enable
gate and both listings. A key is satisfied when any installed enabled provider
publishes it; enabling a candidate also counts its own controls. The gate keeps
its existing 409 controls: [{ key, providedBy }] response. Listing resolves
against the licence-filtered catalog and installed manifests, with one catalog
refresh/cache read and one API-side plugin scan per request and no per-dependency
network work. An unavailable catalog retains known installed providers. The helper
lives in src/plugins/controlDependencies.ts; the listing code is in
src/api/routes/plugins/index.ts and src/api/routes/plugins/marketplace.ts.
Settings/Plugins shows the resulting providers beside each card's version,
including Available and soft-removed bundled cards. OneDrive's microsoftIdentity
requirement resolves to Microsoft Teams. Outline Badge tones are muted for
enabled providers and warning for unavailable ones; shared HelpTip explains
the required display names on hover, focus or tap. No requirements or no known
provider means no pill. The pills render in DependencyPills in
web/modules/settings/PluginsSection.tsx. Both OneDrive (requiresControls) and
Microsoft Teams (provides.controls) are plugins in the plugin registry, not in
this repository.
The microsoftIdentity control gives OneDrive a fresh drive-scoped Graph client through
driveGraphFor(accountId). An absent binding or typed missing sign-in returns null without a warning;
verification and transport failures reject. Consumers keep their current link state on an outage rather
than requesting another sign-in. No provider means no client or request; the existing delegated-session
verification owns tokens and network cost, and the consumer owns failure reporting.
Core publishes docs, implemented by the single DocsSearchService:
search(query, limit, {locale, projection, group?}?, execution?)returns ranked passages withpath, title, heading, text, articleId, sectionId, locale, href. The default is English/all for tools; Guides and Ask AI explicitly use the user projection. All includes developer reference sources; user includes user and administrator guides, not operational permission grants. The service caps control search results at 20 and deduplicates by article identically in keyword and hybrid modes.catalog(locale)returns{revision,categories,articles}, with localized metadata only.article(slug,locale)returns sanitized HTML, TOC, related and previous/next guide metadata. Missing translations return the complete English source withlocale:'en', translated:false.asset(slug,assetId)returns{bytes,contentType,hash}only for a locally referenced, bounded, signature-checked PNG/JPEG/GIF/WebP image. The content-hash ID never becomes a filesystem path.
The server-only execution: DocsSearchExecution carries {userId, signal?}; it is not a browser DTO.
Omission inherits the active tool contribution account, while explicit userId:null is instance work.
Core HTTP consumers supply the authenticated payer and a caller/deadline signal; tools supply their
contribution account and native cancellation signal. Query embeddings use existing spend admission
and account usage recording. Account refusals and cancellation reject rather than becoming lexical
successes; the five-second HTTP retrieval deadline also aborts outstanding embedding transport.
Missing articles/assets return null. Missing or invalid corpus files and read failures reject; an
empty search result means no match. The closed shared category metadata supplies stable group IDs.
Installed third-party documentation receives source-owned plugin-reference / user metadata, or
plugin-development / developer under docs/developer/; existing display-value group strings are not
interpreted. Authored title, slug and order remain required, and the shared parser owns section IDs.
Core and bundled sources require explicit stable category, audience and section metadata.
Metadata edits, locale siblings, plugin removal and image replacement invalidate the catalog revision
and source caches on the next read, without a daemon restart. DTOs live in src/shared/docs.ts.
Core reads the control for Guides and POST /search/ask; declared plugin consumers resolve it at call
time through consumesControls. Catalog/article/asset reads do no model work; a configured search
costs one query embedding, never document embedding. All variants share corpus docs in the existing
vector store and background queue, so partial coverage still answers immediately through BM25/RRF.
See Documentation indexing and
the semantic index.
Code: src/search/docsSearchService.ts, registered with registerHostControl('docs') in
src/daemon/brainCore.ts; consumer
lookupDocs in src/api/routes/search.ts; the constants SEARCH_ASK_PASSAGES (8),
SEARCH_ASK_DOCS_TIMEOUT_MS (5000) and SEARCH_ASK_TIMEOUT_MS (15000) are in
src/search/siteSearchAsk.ts.
The bundled subagent plugin's subagent control also carries plan mode's prompt slot,
planModeWorkflow({ tools, exploreAgents, planAgents }). Core calls it while composing a full
plan-mode directive for an owner or room turn: tools are the tool names that turn may call, and the two
numbers are the operator's RuntimeLimits.planModeExploreAgents and planModePlanAgents (0–5, default 3).
It returns { explore?, design? }, the plugin's own text for Phase 1 and Phase 2, or undefined when
tools has no Delegate. The plugin picks the variant: explore agents when exploreAgents is above 0, one
plan agent at 1, several perspectives above 1, and writes the ceiling in as "up to N"; the model decides how
many it starts. A missing phase keeps core's text (prompts/plan-mode/initial-understanding.md,
prompts/plan-mode/design.md), in which the agent explores or designs by itself, so with the plugin disabled
or Delegate withheld a Plan turn plans without agents. It reads its three shipped templates once at load and does
no I/O per call; the text costs prompt tokens only on full directives (entry and every fifth plan turn).
Code: plugins/subagent/lib/planMode.mjs and plugins/subagent/templates/; consumer turnModeContext in
src/brain/session/modeContext.ts, called from src/brain/service/turnContextBuilder.ts and
src/brain/channels.ts. registerPrompts does not fit this slot because it cannot replace a core template
and core would have to know the plugin's template names.
Controls currently used by the built-in plugin set include delegation, terminal, cron, workflow, MCP, LSP, code mode, sandbox, identity, GitHub, site gateway, browser capture, the skill catalog, and documentation search. Use domain keys, not current plugin names.
The table maps those domains to their keys and to where each provider is registered. Some providers are plugin-registry plugins, not bundled in core, and are marked as such.
| Domain | Key | Provider | Registered in |
|---|---|---|---|
| Delegation | subagent, workflow |
Bundled subagent plugin | plugins/subagent/index.mjs, plugins/subagent/lib/workflow.mjs |
| Terminal | terminal |
Bundled terminal plugin | plugins/terminal/index.mjs |
| Code mode | codeMode |
Bundled code-mode plugin | plugins/code-mode/src/index.ts |
| Sandbox | sandbox, sandboxArtifacts |
Bundled sandbox plugin | plugins/sandbox/index.mjs |
| Documentation search | docs |
Core host | src/daemon/brainCore.ts |
| MCP | mcp |
Bundled MCP plugin | plugins/mcp/index.mjs |
| LSP | lsp |
Plugin registry (lsp) | The LSP plugin in the registry |
| Cron | cron |
Plugin registry (cronjob) | plugins/cronjob/lib/filing.mjs in the registry |
| Browser capture | browserCapture |
Plugin registry (browser) | plugins/browser/src/index.ts in the registry |
| GitHub | github |
Plugin registry (github) | plugins/github/src/index.ts in the registry |
| Identity | microsoftIdentity |
Plugin registry (Microsoft Teams) | Microsoft Teams plugin in the registry |
| Site gateway | publishedSitesGateway |
Core host, when configured | src/daemon/brainCore.ts |
| Skill catalog | skillCatalog, skillManagement, skillResources |
Core host | src/daemon/brainCore.ts |
| Machine runtime | machineRuntimeGateway |
Core host | src/daemon/brainCore.ts |
| Local execution | localExecution |
Core host, authoritative daemon only | src/remoteCli/connections.ts, registered in src/daemon/brainCore.ts |
Core host keys are not plugin dependencies, so they never produce a pill.