Independent services and site gateway
Plugin computations
At registration, bind a plugin-owned module with
const diff = await ctx.host.computation('lib/diff.mjs', { inlineSmallInputs: true }).
The returned function accepts an array of exact strings and optional { signal }, and returns
an array of exact strings. The module exports compute(inputs, signal); it owns the algorithm
and output shape. Files uses one job for its display diff and optional unified patch.
The host imports compute by name from the bound module, so static dead-code checks must model
Files' lib/diff.mjs and Web's lib/convert.mjs as public computation exports.
Module paths are relative to the loader's plugin directory, including real-path containment.
Never take a module path from a tool argument.
Core uses its existing background worker entry and IPC in both daemon and runner, without daemon RPC.
Each binding lazily forks one computation child and reuses it, including its imported module, until
cancellation, child failure or lifecycle stop. Calls on that binding queue in invocation order before
input preparation; descriptor requests and replies carry a unique request ID. Independent bindings
own independent children. Only work whose CPU cost is bounded by input size should opt into
inlineSmallInputs: true: the same module then runs locally up to 512 bytes of combined UTF-8 input.
Larger inputs use the bound child. The threshold is unchanged.
There is no output cap, computation timeout, worker pool or synchronous failure fallback.
Large strings use async private temporary files with UTF-16 encoding to preserve every JS code unit.
IPC carries the module and validated file descriptor, never the text bodies.
A worker error or death rejects the caller. Files are removed after consumption, failure or abort.
Workdirs are mode 0700 and text files mode 0600. Each workdir is named
elowen-computation-<owner PID>-<mkdtemp suffix> directly inside the OS temporary directory.
Binding initialization asynchronously scans only that location and reclaims a matching real directory
only when it has mode 0700, the current UID, and signal 0 proves its owner absent with ESRCH.
Symlinks, other owners/modes and live or reused PIDs remain untouched, regardless of age.
Signal-0 EPERM proves a live foreign PID and preserves its inputs while initialization continues.
Other unexpected filesystem and liveness failures reject initialization; concurrent disappearance is harmless.
This also reclaims full-text inputs left by a parent SIGKILL, at the next binding initialization.
The binding automatically contributes lifecycle cleanup through registerService's existing
registry service list. Shutdown refuses new jobs, rejects queued and active calls, kills the child,
and waits for its exit and input cleanup. A queued caller's abort rejects only that call; an active
abort immediately kills its child and waits for its exit. The next independent call may lazily fork
a replacement, but failed or aborted work is never retried. An idle child exits on IPC loss. Computation modules must
only read and compute, without shared writes or locks: cancellation kills their worker immediately
with SIGKILL so synchronous CPU cannot outlive the owner's bounded shutdown. The same rule applies
to the core read-only export job. Catalog/PI maintenance keeps its existing SIGTERM and five-second
grace. No shutdown budget is extended. Explicit caller cancellation rejects the waiting computation.
Files deliberately omits the tool signal after writing and updating read state, so completed mutations
return their original success, hash and diff even after tool abort. WebFetch omits its transport signal
once the body is received so conversion can complete and populate its cache. Lifecycle cancellation
still applies to both. No binding means no child or temporary files.
Interactive computations inherit process priority; catalog and maintenance jobs retain their
background priority. Credentials, catalog runtimes and embedding services load only in their jobs.
Independent plugin service
Declare exactly "service": { "entry": "dist/worker/main.js" }. The relative POSIX entry must be .js or .mjs, with no absolute/traversal path, empty segments, whitespace, quotes, backslash, percent/dollar expansion or control characters. Unknown service keys are rejected. Core validates the installed real path and invokes it as the recorded nonroot service user, never as root. The worker entry supports --check: import its complete graph, validate current persisted serving state, exit zero without listening or mutating state. A failing preflight leaves the running service untouched. The process protocol reserves Unix EX_TEMPFAIL exit 75 only when --check can explicitly prove that the runtime for this build has not been prepared yet: a first install, or an update whose new build needs state that only its daemon side writes (Chatbot exits 75 on a serving snapshot older than its schema). Root bulk reconcile reports such a plugin in deferred without touching units, ownership state or a running process, because the updater's refresh runs before the new daemon could prepare anything. A plugin data folder that does not exist yet (the plugin never ran on this instance) is an unprepared first runtime; root classifies it after validating the declaration, units and build, without running --check, and defers it only if no prior ownership record, unit or active process exists. Independent workers remain core-free; the root consumer uses the shared unit-policy constant. Deferral does not claim service readiness. Malformed runtime state must fail with any other exit status; explicit restart never defers. The authoritative plugin registration must then prepare its data and call the normal service restart before publishing.
Application-ready service handle contract
The public PluginServiceHandle refines OS activity and application readiness
without adding a duplicate retry/result type:
interface PluginServiceHandle {
readonly trustProxy: boolean;
readonly socketPath: string;
status(): Promise<{ active: boolean; connectionState: ConnectionState }>;
restart(input: {
readonly buildIdentity: string;
readonly signal: AbortSignal;
readonly ready: (signal: AbortSignal) => Promise<RetryAttemptResult<void>>;
}): Promise<void>;
}
ConnectionState and RetryAttemptResult<void> come from canonical
elowen-plugin-shared/retry. A caller captures the expected executable build
identity once and supplies its native abort signal plus a GET-only verifier of
that build's real health/readiness. OS/root acknowledgement and active:true are
not application-ready proof. No zero-argument overload, optional verifier, manual
reset flag or second readiness union exists. Health ticks, reconciliation and
off/on alone cannot renew an exhausted finite start episode.
Sites worker and Chatbot daemon are the actual consumers of the core
PluginServiceManager. An existing reload POST stays one mutation OUTSIDE the
readiness verifier; neither accepted nor unknown restart grants replay permission.
Only real GET readiness probes may follow. Typed privileged transport failure
owns dispatch evidence, not body/prose parsing or an OS status guess.
The daemon's PluginServiceManager owns the finite start episode for a plugin and
immutable executable build. Its required policy reader is the process-owned
retry-policy source, not a plugin default or a new database reader. See
Independent services
for dispatch, retained exhaustion and GET-only proof after success. Creating a
handle starts no process, probe, controller or timer; absent ctx.service remains
null. Fixed OS service policy and supervision are unchanged.
In the authoritative daemon, ctx.service is null or the PluginServiceHandle described above. trustProxy is a getter that reads the live core security.trustProxy setting on each access (src/privileged/pluginServices.ts, PluginServiceManager.context; wired in src/daemon/brainCore.ts). Nothing in this path writes a snapshot. Independent HTTP services resolve addresses through the existing exported elowen/dist/api/clientIp.js module, using clientOrigin(c, trustProxy) from src/api/clientIp.ts; they do not invent another header-trust rule. It accepts no plugin name or path input. Read LISTEN_FDS and LISTEN_PID and listen on inherited fd 3. Use ELOWEN_PLUGIN_DATA for the plugin's existing data directory, not daemon configuration, host node_modules or a second database. The socket has mode 0660 and the root-recorded proxy group. All service policy is fixed: no extra executable arguments, one environment variable, no capabilities, read-only system and home except this plugin's data directory, private temporary devices, bounded memory and tasks. Core stamps identity and owner proof through the plugin-service privileged domain. Finite start/retry and standing-failure behavior belong to this manager; do not infer application readiness from a root restart or OS activity.
During executable updates the coordinator stops affected plugin sockets and services before swapping or restoring folders; proof waits for their settled startup results. Ordinary daemon restarts retain the independent-service behaviour below.
Core ensures all installed user declarations on boot after daemon-side background writers start, during install and privileged refresh, and removes proven orphans on refresh. A changed JavaScript build or unit is preflighted and restarts only the service, never its socket. Plugin disable, failed registration, daemon stop and crash leave it alone. Uninstall orders external offline, service removal, then marketplace deletion. Core never imports the independent entry. Bundled plugins and runner contexts receive null. Sites must persist its snapshot and secrets before its first restart; adding the declaration alone does not complete cutover.
Each web.settings entry accepts id, label, optional icon and optional placement. Placement defaults to page; pluginDetail offers the section in the selected plugin's settings. Unknown section fields are rejected, including the retired layout field. The independent web.layout document/workbench contract remains available.
brain.turn.contextBuilt and HookPatch.appendContext are removed. Plugins contributing turn context use registerText('turn-context', fn). Existing retained context records remain readable. Lifecycle hooks are observational, bounded and fail open; tool and route permissions remain authoritative.
Shared runtime helpers
The additive /ask, /projectExecution, /subagentName and /imageRuntime package entries and
adjacent declarations ship with shared API 11 and remain exported by the current shared API 14
package. Registry consumers adopt them with the paired core artifact; no contract number changes.
Their input, absent behavior, limits and costs are documented in
packages/plugin-shared/README.md.
/ask supplies parseQuestionReply(text, question) and collectQuestionAnswers(questions, selected, other) to Discord/Telegram/WhatsApp without owning presentation or question authorization.
/display adds frozen DISPLAY_AXES for config validation and display menus.
/modelIdentity adds resolveActiveModel(models, chosen), modelReasoningLevels(active) and
supportsReasoningChoice(active, value); use a fresh active descriptor at reasoning confirmation.
An absent descriptor advertises no choices. Teams translates its empty reset to 'default' at this boundary.
/projectExecution owns RESERVED_GUEST_ROOTS, isReservedProjectSlug, managedGuestRoot and
ELOWEN_ARTIFACT_ROOT. Sandbox mount validation and runtime paths share them with core's typed
Project parser facade. They derive names only and do not authorize host or guest operations.
/subagentName is the shared owner for derived and explicit delegated-child labels in the coordinated
store/migration/Subagent cutover, preserving stored names and empty-task behavior.
Image-gen and Image-edit call createImageRuntime(ctx) from /imageRuntime and pass their own
generate/edit request. The helper uses the existing authorized ctx.images and
ctx.host.projectFiles() seams (both image manifests declare reads: ["project-files"]), validates path/overwrite before rendering, then writes the provider's
actual format and returns the unchanged result text. A provider this installation does not configure
returns null after one info log, and the runtime holds no credential: it names the provider and
calls the host seam, which authorizes the render against that provider's accounts. Failures propagate.
It registers no tool, adds no retry and performs one render/write.
The browser runtime also exposes the existing hooks.useNow(periodMs?, enabled?) function itself.
Todo's live cards and rail share the host clock, visibility behavior and timer teardown. Unreleased
consumers must require this member explicitly, without a local timer fallback. See docs/WEB.md, Shared clock.
elowen-plugin-shared/processTokens exports DIRECT_PROCESS_TOKEN_ENV and tokenPids(tokens).
Terminal stamps a fresh token into each direct-host launch; descendants inherit it even after setsid.
Use tokenPids([token]) for cleanup, or pass all dead-runner tokens together. It synchronously scans
Linux /proc once per call, skips the scanning process and unreadable or exited processes, and returns
matching pids in descending order. Empty tokens, no readable match or unavailable /proc return [].
The helper registers nothing and sends no signals. Work scales with readable process environments and
the token set, without per-token rescans, provider calls or persisted state. Terminal uses it for confirmed
lease cleanup and retains its ESRCH/error distinction; core's killTokenProcesses uses it for best-effort
runner-exit cleanup. The adjacent .d.mts is shipped with the additive subpath; the shared API
level of the package that carries it is unchanged by the addition.
Shared helpers are opt-in imports; importing them registers no service or hook. They ship inside the
paired core artifact, and the plugin's requiresSharedApi must name the exact level that artifact
provides — see Release.
elowen-plugin-shared/errors exports errorText(error), preserving Error.message or String(error),
and FinalTextDeliveryError, the marker used by chat bindings when final-answer transport fails.
For example, Discord wraps a rejected final REST send with its diagnostic and original cause;
Telegram and Teams mark a null send result, and WhatsApp marks rejected text sends. Each adapter's
existing /turnRunner errorText callback maps this marker to /messages
SHARED_MESSAGES[lang].deliveryFailed in en/cs/sk, while other errors retain their detail.
The marker allocates one Error and the message table does no I/O. Without a marked send failure,
normal turn handling is unchanged. The four registry adapters require core 0.29.64 or newer, the paired artifact shipping this marker.
The same module exports InstanceConditionError and isInstanceCondition(error), plus
HostConditionError and isHostCondition(error) for a condition of the host itself; the reporting
rule those predicates drive is in Typed instance conditions.
elowen-plugin-shared/toolResult exports textResult(text, details = {}) and
errorResult(error, details = {}). Both return one text content block and caller-owned details; the latter
prefixes Error: . They never infer ok/isError, serialize objects, catch conversion failures or classify
transport errors. Code Mode, core memory tools, ExitPlanMode and local ToolSearch use the same envelope;
ElowenApi serializes its response before passing the text to it. Files retains tool/status metadata above
the primitive; Web rethrows transport faults before rendering domain errors. These helpers perform no I/O
and allocate only the result envelope and converted text. With no importer, nothing happens.
elowen-plugin-shared/runtimeContext exports runtimeClock(timezone),
runtimeIdentity(identity, displayName?) and createTimezoneResolver(readTimezone, warn).
The bundled Runtime Context turn providers and core voice instructions use the same clock and identity
formatters; the plugin host and voice use the same live operator timezone resolver. For example,
runtimeClock(ctx.timezone()) renders the current local date, time and daypart.
Identity accepts the display-only fields of a host-minted turn identity, returns an empty string for
null, and bounds/sanitizes names; the optional account display name supplements the login name.
These helpers do not grant authority, read storage, execute tools or register hooks. Formatting is
bounded by the supplied identity fields. The resolver calls its supplied getter when used, falls back
to the host timezone for absent settings and warns once per invalid legacy value or getter failure.
With no importer, nothing runs. The new package entry ships with the paired core artifact.
elowen-plugin-shared/zonedTime exports zonedParts(ms, timezone),
zonedTimeToMs(timezone, year, month, day, hour, minute) and Sunday-first WEEKDAYS.
Cronjob and Meeting Confirm share local wall-clock conversion, for example
zonedTimeToMs('Europe/Prague', 2026, 10, 8, 9, 0). Cached formatters and two offset corrections
cost constant work per conversion. Invalid zones retain cronjob's machine-zone fallback; validate new
settings at their boundary. The converter does not reject DST gaps or select repeated-hour variants.
It performs no I/O or registration, and without a caller nothing runs. The exported entry and adjacent
.d.mts declarations ship with the paired core artifact; this is additive to shared API 11.
elowen-plugin-shared/contentHash exports hashText(text) for SHA-256 of exact UTF-8 text.
elowen-plugin-shared/vectors exports packVector(vector), unpackVector(buffer) and cosine(a, b).
MemoryStore and the shared semantic index (SemanticVectorStore and semanticRank) share these bytes and scores. Packing respects a Float32 view's
byte offset/length and passes an existing Buffer through unchanged; unpacking copies the selected bytes.
No endian conversion or stored-data migration occurs. Empty, mismatched and zero-norm vectors score zero;
callers still validate embedding-space identity. Hashing and scoring are linear, unpacking allocates one
copy, and the helpers perform no I/O or registration. They do not normalize text or repair bad vectors.
elowen-plugin-shared/subagentProgress exports foldProgressEvent(state, event) plus tool-detail,
usage and nested-wait helpers. Delegate, workflow nodes and daemon recovery share sticky authored notes,
valid token aggregates and the turn/model fence for effective speed. The boolean says when to rewrite the
progress row; callers own session lifecycle signals and persistence. Each fold costs constant work.
elowen-plugin-shared/toolLists exports listCovers(iterable, name) for exact or trailing-star prefix
matching. Core grants and Subagent policies consume it over arrays or sets; callers own absent lists.
It scans entries once and an empty iterable covers nothing.
elowen-plugin-shared/modelIdentity exports splitModelIdentity(spec) for one complete provider/model
pair, splitting on the first slash. The helper neither trims nor decodes; core exec parsing owns legacy
syntax and program prefixes, while Subagent validates and trims stored pins. The existing /lifecycle
entry owns both the daemon's English fallback table and adapter translations. /chatImageNames owns the
lowercase generated UUID/SHA-256 filename pattern and validator used by stored image reads and /format
reference parsing. It is browser-safe, accepts only png/jpg/gif/webp, and does not grant file access.
elowen-plugin-shared/imageSniff exports sniffImageMime(bytes), the one magic-number check for raw
image bytes (png, jpeg, gif, webp, otherwise null; never the file name). ShareImage, visitor conversation uploads, the CLI clipboard
paste and code-mode's image() all use it, so no reader can accept a signature another rejects.
These opt-in pure helpers perform no I/O or registration; they leave storage and authorization unchanged.
The shared elowen-plugin-shared/record module owns isRecord(value) and optional record(value) traversal. The guard accepts non-null, non-array objects without requiring a particular prototype; traversal returns the original object or undefined. Core imports the same implementation through src/shared/record.ts for nested Code Mode arguments, Project and ElowenApi input checks, site-search replies and configuration/voice settings. Subagent, Code Mode's schema renderer, MCP OAuth credential validation and Sandbox Desktop argument messages use the package entry directly. It performs no I/O and registers nothing when unused.
The existing shared elowen-plugin-shared/atomicJson helper accepts writeJsonAtomic(file, value, { durable: true, mode: 0o600, maxBytes: 16777216 }). Durable mode syncs staged file bytes before atomic rename and the parent directory afterwards, throwing on either failure. Mode applies from the staging file's creation; maxBytes bounds the exact formatted bytes before any filesystem effect. Chatbot uses this for its private policy and intake files; ordinary callers retain atomic replacement without the extra sync cost. compact:true omits indentation, newline:true adds the existing licence-record trailing newline, and forceMode:true applies the requested mode before syncing and rename regardless of umask. These options default to false. readJsonStrict(file, parse, { maxBytes }) throws for missing, unreadable or corrupt JSON; an optional domain parser validates the shape and returns its typed value without catches or defaults. With maxBytes, reads allocate at most maxBytes + 1 bytes, accept the exact boundary, and parse only after EOF. UpdateJournal uses strict transport reads with its domain validator and durable compact writes; update report and schedule retain writes without fsync. The licence record, boot report and core swap/inventory also use the same writer. writeTextAtomic(file, text, { durable, mode, stagingMode, forceMode, maxBytes }) accepts strings or Uint8Array bytes. stagingMode defaults to mode; exclusive no-follow staging prevents reuse of an existing path, and forceMode applies mode on the same descriptor after writing and before fsync and rename. A staging or final-mode failure preserves the destination, removes only the owned staging file and propagates the error. PluginSecretVault prepares its directory with mode 0700 and writes with mode 0600 and forceMode. This is the same writer for text that is not JSON: writeJsonAtomic only serializes and hands its bytes to it, so both share the exclusive no-follow staging file, the optional fsync, the mode handling and the removal of a staging file a failed write left behind. The marketplace catalog cache (registry.json and catalog.etag) and the plugin install receipts, pending-install records and parked-uninstall markers are written through it, with the bytes and mode they had before. These additive exports and options ship in the coordinated shared API 10 release and require the paired core artifact that ships them; writeTextAtomic is additive and needs a core that ships it.
Shared helper subpaths are shipped inside the core artifact together with their
adjacent .d.mts declarations. Import each helper from its explicit
elowen-plugin-shared subpath; the package root does not re-export helper bodies.
NodeNext resolves the adjacent declaration for the .mjs export. Each declaration
has an exact analysis entry. These additions retain the shared API level of the
package that ships them, but consumers must require the first verified core
release containing the helper and its export metadata. An older
requiresSharedApi pin alone does not establish availability. Unused helpers
perform no work; a missing target or unsupported subpath fails module loading.
Validate the packed artifact as well as the source tree.
elowen-plugin-shared/xml exports sanitizeXmlText(value), xmlEscapeText(value)
and xmlEscape(value). Each first converts its input with String. The sanitizer
replaces XML 1.0-forbidden C0 controls with U+FFFD while retaining tab, LF and CR.
The text helper additionally escapes ampersand and angle brackets; the attribute
helper also escapes both quote characters. Non-ASCII characters are preserved
and literal entities are escaped as data.
Use import { xmlEscape } from 'elowen-plugin-shared/xml'; and
xmlEscape(proc.command) when rendering a Subagent collect reminder.
Core prompt producers import the same implementation through src/shared/xml.ts.
The helpers run synchronously only when called, perform no I/O, register no hooks
and incur linear string-processing work. Without an importer nothing runs.
They do not parse XML, preserve pre-escaped entities, validate element names,
bound input length or perform Unicode validation beyond the stated C0 replacement.
Callers own framing and size limits. The public subpath and adjacent xml.d.mts
ship with the paired core artifact; the subpath is additive and does not change
the shared API level.
/configTokenList exports parseConfigTokenList(value) for messaging configuration. Arrays are already tokenized: stringify and trim elements, then remove empty entries while preserving embedded commas and newlines. String values split on commas and newlines. Missing values produce an empty list; order and duplicates remain intact. Discord threadIds, Telegram allowedChatIds and WhatsApp groupIds use it when reading adapter configuration. For example, parseConfigTokenList(['123', '456,789']) preserves two tokens. Work scales with input size. It does not validate configuration writes.
/operationInitiator exports operationInitiatorFromCredentialScope(scope). Call it with the host-verified req.auth.credentialScope at authenticated HTTP operation creation. full and impersonation map to ui, agent maps to agent, and api, advisor or an absent origin map to system. Sandbox environment operations, core Project routes and Cronjob manual runs share this classifier. It performs constant-time classification without authorization or persistence. Request bodies and account roles do not determine origin; non-HTTP callers retain their existing defaults.
/withTimeout exports withTimeout(work, ms, failure), where work is a Promise or a callback returning one and failure is a string or Error. For example, await withTimeout(() => cdp.send('Tracing.end'), 1000, new BrowserDeadlineError('Trace end timed out')). Callback work starts once after the timer is armed. Work results and failures, including synchronous throws, preserve identity. Expiry creates an Error from a string or rejects with the supplied Error unchanged. The timer is unref'd and cleared on settlement. This helper never cancels work: callers own late effects and cancellation. Each call uses one timer with Node event-loop timing; without a call it schedules nothing. Browser tracing after migration and core platform startup through src/shared/withTimeout.ts are real consumers. Resolve-on-expiry waits keep their own semantics.
/ask also supplies formatQuestionOptionLabel(option, messages, maxLabelLength?) and formatQuestionPrompt(question, messages). Pass the adapter's existing localized message table, which includes SHARED_MESSAGES for en/cs/sk. For example, Discord buttons call formatQuestionOptionLabel(option, adapter.msg, option.recommended ? 60 : 80). The cap applies to the display label before the localized recommendation suffix; the original option label remains the answer identity. Without recommended, no suffix is added. Numbered prompts retain option order and include a custom-answer hint unless custom is false. Callers own markup, description placement, selection prefixes and platform clipping. Formatting is pure, performs no delivery or authorization, and allocates in proportion to the supplied labels/options. Discord and Telegram text-reply flows consume the same prompt formatter.
The supported /imageRuntime entry is createImageRuntime(ctx), called once during Image-gen or Image-edit registration. It returns null and logs the existing info message before acquiring file authority when this installation does not configure the chosen provider id; otherwise its provider property carries that provider's public identity from ctx.provider(id), and its model property contains the trimmed configured id or the existing provider default. Rendering, credentials and file authorization remain host-owned. resolveModel is a private implementation function, not a package export.
The supported /subagentName entry is resolveSubagentName(explicit, task). Pass an empty explicit value to derive a label from the first five task words, clipped to forty characters with whitespace and trailing punctuation normalized. Explicit names retain the same forty-character budget. Empty tasks stay empty. The Subagent plugin and durable store readers use this pure helper; it performs no I/O and does not rewrite persisted names. deriveSubagentName is private.
/imageSniff owns sniffImageMime(bytes) and the ordered INLINE_IMAGE_TYPES vocabulary: PNG, JPEG, GIF, WebP. The sniffer classifies Buffer signatures, including bounded probes, and returns null for other signatures. It does not validate a complete image or promise decoder eligibility. ShareImage uses signature classification at the storage boundary; Files first probes with this classifier, then retains its full-byte PNG/IHDR and APNG/JPEG-LS eligibility checks plus BMP validation. BMP is not an inline MIME and must be converted. With no caller, these helpers perform no work or I/O.
The /inlineImage entry owns prepareInlineImage(raw, mime, policy) for Read, rendered PDF pages and MCP image results. For example, ordinary Read passes { maxRawBytes: IMAGE_MAX_BYTES, fallbackOnUnsupportedResize: false }. The helper calls PI resizeImage with maxWidth/maxHeight 2000 and returns { image, resized, mimeType }. A supported resized result becomes the image block; a null or failed resize uses original supported bytes only within maxRawBytes. PDF sets fallbackOnUnsupportedResize true with the same 3_750_000-byte raw cap. MCP sets it true with its own 10 MiB MAX_MCP_INLINE_IMAGE_BYTES cap and rawData: part.data, and refuses a larger part with McpResultTooLargeError before decoding, preserving its existing original encoded fallback. Without an eligible resized or fallback block, image is null. resized retains coordinate metadata even when its MIME cannot be embedded, and mimeType describes the emitted or omitted result. Decoding and encoding cost scale with the input; this does not add an input allocation limit. Filesystem access, signature validation, model support, text, successful-read hashes and authorization stay with each caller. Importing the module performs no resize or registration.
Delegated child progress and admission
Capture subagentEmitter in the parent turn before spawning and handle its typed durable outcome. Accepted publishes; already-terminal is consumed silently; rejected prevents admission of an orphan background handle and distinguishes validation, ownership and persistence failure. Boolean and void progress sinks are unsupported. Capture the single delegationCompletionEmitter() alongside progress for background sub-agents and whole-workflow summaries. Child unwind owns detached completion; completion acceptance is distinct from journal-committed delivery. See delegation completion and delivery for the typed sink, recovery and acknowledgement contract.
A delegated run(source, text, onEvent) reports session after core persists the child's identity
and scope, before runner capacity can block execution. Persist the run or workflow node in that
synchronous callback. The bundled Delegate and workflow engine use this to protect queued background
children through core's single sparedChildSessionIds rule. Pool session progress carries
status: 'queued' | 'running' and optional detail; fold it through
elowen-plugin-shared/subagentProgress and retain the ordinary running lifecycle while queued.
The existing detail renders in CLI/web rails and is returned by the delegated-children listing.
Background Delegate returns a queued job receipt, with automatic result delivery unchanged.
No callback means no plugin progress row, not a second admission mechanism. No pool means ordinary
in-process execution. Stop and boot recovery own terminalization; never infer completion from a
session event or from the length of a queue wait.
Hook prefixes on the instance vhost
The existing core nginx generator accepts explicit pluginServiceHooks such as ['chatbot/v2']. Operators use elowen proxy print|apply --domain example.com --plugin-service-hook chatbot/v2. The selected exact mount and slash-prefixed subtree retain the original URI and proxy directly to the fixed /run/elowen-plugin-services/chatbot.sock. Root rendering overwrites X-Real-IP, removes the forwarded-address and private control headers, disables request/response buffering and retains long stream reads. No plugin-provided upstream path, directive or executable is accepted. Other hooks retain daemon routing. Apache has no independent-hook cutover.
Before any vhost write, apply checks the protected socket parent, nonroot socket ownership and closed world mode, then performs a bounded Unix HTTP probe of GET /_elowen/service/health. The JSON response must name the selected plugin, contain ready: true and include the relative mount in hooks. This endpoint must check actual service state and assets; a declaration alone is not readiness. It is outside every public hook prefix and nginx does not forward it. Failed proof leaves nginx unchanged. The regular nginx validation/backup/reload path remains the only writer. Omit the option to roll routing back to the retained daemon endpoint, after draining durable intake; root service removal is a separate lifecycle.
Published Sites gateway control
The daemon-only publishedSitesGateway control publishes a bounded set of canonical hostname/slug/class bindings. syncBindings, ensureBinding, and removeBinding accept optional typed global proxySettings. Every desired binding selects the fixed Sites service socket; omitted worker normalizes to true and explicit false is refused. There is no daemon public Sites handler. No caller can provide an upstream path, header fragment, command, or nginx directive. The root helper independently validates every setting before effects. Installed binding status reports the actual stored worker boolean alongside servedGeneration, so a plugin can refuse a one-use access exchange until the worker is actually selected. Persisted legacy false or omitted routing must never be reported as a successful cutover. Desired bindings or cached activity do not establish that routing authority.
Core, not plugin code, stamps a private per-instance ownership proof on mutations. An absent or different proof is refused before any lock file or disk is touched. status() remains read-only and needs no proof. Without an injected privileged transport, the control does not throw: each call resolves to an unavailable status whose detail is no privileged transport in this process (src/daemon/brainCore.ts, src/privileged/publishedSitesGateway.ts).
offline() reads persisted bindings and renders HTTP ACME challenges plus HTTPS maintenance using existing certificates, with 503, no-store, and Retry-After. No certificate is issued or deleted. Missing or malformed persisted state refuses without replacing the active configuration. A gateway that never published a Site, with neither persisted state nor a gateway config, has nothing to take offline: offline() answers inactive with no bindings and writes nothing, so uninstalling a fresh instance does not stop at this step. Plugin removal invokes this host control before scheduling file/data deletion, even when the consuming plugin is disabled or failed to load. Absence, disable, daemon shutdown, and registry load failures never invoke offline. Sites is the only approved plugin consumer.
removeBinding is explicit removal authority: Sites first persists its deleting/hostname-removal marker, and the authenticated root helper durably records a per-hostname removal intent before probes or effects. It withdraws that hostname's active/maintenance blocks and deletes only certificates proven in its issued/adopted ledger. syncBindings and ensureBinding retry outstanding explicit intents, never infer deletion from omission, and preserve omitted stored bindings as maintenance with their existing certificates. Historical owned orphans without intent are retained. Completed removal tombstones reject stale binding lists; only explicit, probed ensureBinding recreates a removed hostname after cleanup completes. The root state bounds ownership and removal ledgers to 4096 entries each; exhaustion refuses. Unchanged configuration avoids reloading nginx. The minute recovery sweep is the periodic caller, and explicit sync uses the same path. No plugin-supplied ownership claim or certificate path is accepted. Legacy adoption requires both exact renewal-configuration proof and non-redirected physical root-owned paths as documented in Security. Host-owned certificates and uninstall maintenance inventory are never deleted by offline. Failed explicit deletion remains retryable through the root intent, ownership ledger and Sites' durable cleanup markers; worker/proxy proof failure postpones physical effects, not persistence of removal intent.
The worker-only renderer is not automatic cutover. The Sites plugin must persist and validate its serving state and directly probe every binding through the worker before supplying worker flags. Root repeats signed, application-backed proofs through /run/elowen-plugin-services/sites.sock before any gateway effect; local HTTPS through nginx is not a cutover prerequisite. Direct transport errors identify the hostname, socket and underlying network failure; invalid replies report the validation reason without response bodies or authentication material. The operation worker's bounded error is preserved as the control's unavailable detail, rather than replaced by a generic refusal. Project previews use durable projectPublicationBinding transports, keyed by preview id, without file copying or execution leases. Initial creation needs the current authorized writable account; reattachment of an existing unchanged record is account-independent. Before recording a new preview, Sites invokes the same writable-account preflight as a conversion. Background migration may use a currently eligible account only for preview rows marked by the upgrade migration and without an ambient actor; Sandbox still performs its full account, grant, scope and Project checks. Preview removal first persists a deletion marker and withdraws access, then retries gateway/certificate cleanup and projectPublicationRelease. No preview row is deleted before transport cleanup succeeds. Retired file Sites have no endpoint and return signed worker maintenance. projectPreviewBinding is removed; bounded worker controls own all one-use exchanges.
Publication identities are globally unique in Sandbox. Mutation locks and the database owner check use that same identity, never a Project-local key. projectPublicationAuthorize({project, accountUserId}) checks writable account/grant/turn/Project authority without runtime or transport mutation. Sites invokes it before recording a conversion intent. Each conversion has a fresh publication id in a durable Sites journal; committing that exact uncancelled attempt atomically adopts its Project/port/id. Superseded or crashed attempts remain cleanup-owned until projectPublicationRelease({project, publicationId, retire: true}) succeeds. Retirement is persisted before asynchronous cleanup, uses the recorded Project owner, and retains a minimal non-serving fence against late creation across daemon replacement. Ordinary release without retirement permits deliberate recreation. Boot cancels outstanding conversion attempts; normal sweeps cancel expired attempts after five minutes and retry cancelled cleanup. Sandbox reconciliation retries persisted retirement independently, and every forwarder start rechecks retirement after its asynchronous privileged operation before returning a usable socket. Missing authorization support refuses a conversion or preview, never bypasses permissions. Publication bindings inspect an already-running Project environment and never provision or implicitly start it; the manager must start the Project and its HTTP service explicitly. Each intent costs one journal row and one retained retirement fence after cancellation; no execution lease or file copy is involved.
Root convergence contract
Plugin-service root operations are status, restart, remove, reconcile and remove-all. ctx.service.restart forces convergence; updater reconcile does not force unchanged active services to restart and alone may defer missing first-runtime data. Without a declaration, no service is created; explicit restart of an unprepared service fails. There is no ensure wire operation. Existing owner proof, preflight, build hash and resource policy remain authoritative.