Independent plugin processes
Declaration, supervision and removal
A user plugin may declare one manifest.service.entry. Core never imports this entry: the root helper (scripts/elowen-site-gateway.mjs) validates its real path, runs Node --check as the service user (preflight in the plugin-service spec), and owns a hardened elowen-plugin-<name>.socket/.service pair rendered by scripts/pluginServiceUnits.mjs. The ordinary plugin context receives only its own socketPath, the trustProxy projection, status() and restart() (PluginServiceHandle in src/plugins/api.ts); each restart is preflighted by the root helper (ensurePluginService in scripts/elowen-site-gateway.mjs). The authoritative daemon reconciles all installed declarations once its plugin services have started (reconcilePluginServices, called by BrainService in src/brain/brainService.ts and wired in src/daemon/brainCore.ts). Bundled plugins, runners and non-authoritative daemons receive null (authoritativeProcess and serviceFor in src/plugins/loader.ts). Disable, failed imports, and daemon shutdown never stop an independent process: only the plugin uninstall route calls PluginServiceManager.remove (src/api/routes/plugins/index.ts), and the shutdown path stops only the registry-contributed services of PluginServiceRunner (src/plugins/serviceRunner.ts). Uninstall removes it before marketplace files or data are deleted (same route). Root ownership records hashes and pending writes, in the build, units and pending fields of the root-owned plugin-service state, so an interrupted update is repairable without adopting unfamiliar host units.
The same service seam owns Chatbot's public transport. The daemon publishes a bounded private policy and exact active credential-hash projection; the worker serves the existing widget, appearance and durable message intake without database access or brain imports. Actual account, Project and spend admission stays on the daemon's existing public route, reached only over loopback. The intake key is the existing visitor/client-turn pair. A lost admission receipt can be delivered again without a second brain turn; an already-running interrupted turn is never re-executed. The worker keeps the browser stream and resumes the daemon's durable event log after transport loss, without a second transcript.
The main vhost generator, not the Sites gateway renderer, exposes explicitly selected plugin hook prefixes on the service socket (nginxSiteBody in src/cli/install/proxy.ts; the Sites gateway helper has no plugin-service upstream). Core probes readiness before any nginx effect (probePluginServiceHooks in src/cli/install/proxy.ts, called by applyNginxSite in src/cli/provision/deployment.ts). ctx.service.trustProxy projects the security.trustProxy setting that the daemon also passes to its shared clientOrigin (src/api/clientIp.ts). The Chatbot worker passes its projected value to the same function (clientOrigin in the registry's plugins/chatbot/src/service/server.ts), so the daemon and the worker apply one trust rule. No independent plugin owns host nginx fragments or makes its own trust decision.
Owning code
src/plugins/api.ts:PluginServiceHandleandPluginContext.service, the plugin-facing contract.src/plugins/manifest.tsvalidates theservicemanifest field.src/privileged/pluginServices.ts:PluginServiceManager, the daemon-side client. It stamps identity, owns finite application-ready start episodes and reports failures.scripts/elowen-site-gateway.mjs: the root helper'splugin-servicedomain (serviceSpec,ensurePluginService,pluginServiceReconcile).scripts/pluginServiceUnits.mjs: entry validation and the fixed unit templates.src/daemon/brainCore.tsandsrc/brain/brainService.ts: wiring and boot order.src/cli/install/proxy.tsandsrc/cli/provision/deployment.ts: nginx hook prefixes and the readiness probe.src/api/clientIp.ts: the one client-origin rule.
Minimal use
A plugin opts in with one manifest field:
"service": { "entry": "dist/service/main.js" }
The entry is a relative .js or .mjs path. It may not contain whitespace, quotes, %, $, backslashes, control characters or .. segments (validateServiceEntry in scripts/pluginServiceUnits.mjs). Host code treats a missing handle as "no worker":
const socket = ctx.service?.socketPath ?? null; // null: no declaration, runner, or non-authoritative daemon
The worker side is plugin code. Chatbot's worker listens on inherited socket fd 3 when LISTEN_FDS is 1 and LISTEN_PID matches its own pid (registry plugins/chatbot/src/service/main.ts). For the full worker contract, see the plugin author services page.
Finite start and application readiness
PluginServiceManager receives the process-owned retry-policy reader from
src/daemon/brainCore.ts. ctx.service.restart({ buildIdentity, signal, ready })
captures one canonical policy per plugin and immutable executable build; the
GET-only ready(signal) callback returns canonical RetryAttemptResult<void>.
Chatbot's service health and Sites' worker control health are the real consumers.
Concurrent handles share the episode. The original attempt plus maxRetries
uses canonical request timing, not an independent cooldown.
Only a typed PrivilegedWorkerConnectionError proving a transient pre-dispatch
failure permits another root restart request. Handshake refusal, closure,
cancellation and unclassified errors do not. An accepted or unknown/negative root
operation permits only GET readiness probes within the same remaining finite
episode; unknown dispatch remains explicitly unconfirmed until application proof.
No exit-code or diagnostic-text classifier grants replay.
Failed, blocked, cancelled and exhausted build identities remain retained across health ticks, new handles, off/on and root reconciliation. A new executable build cancels the previous episode and waits for attempt cleanup. After success, another explicit check shares a single fresh GET probe, not a new budget or root mutation. Removal cancels work while retaining identity tombstones; reconciliation does not reset an episode.
status() separates OS active from canonical connectionState. Before proof,
readiness is unknown; root acknowledgement or OS activity cannot clear the
standing warning. Matching application readiness clears it. Observed OS inactivity
withdraws prior readiness as stale. Fixed systemd supervision, socket activation
and consumer health intervals remain separate and unchanged.
Absence, limits and cost
- Without a declaration, in a bundled plugin, in a runner process, or in a non-authoritative daemon,
ctx.serviceisnull(src/plugins/loader.ts). - A failed
--checkpreflight leaves the running service untouched (preflightinscripts/elowen-site-gateway.mjs). - A plugin whose runtime is not prepared yet (its
--checkexits with status 75) is deferred during bulk reconcile only, whether it is a first install or an update of an installed service. The updater's refresh runs before the new daemon can prepare the new build, so an installed service keeps its units and running process until the daemon's own reconcile converges it after its plugin services started (Chatbot writes its serving snapshot in thechatbot-servingservice start). A missing data folder is deferred only for a service that was never installed. Explicit restart still fails. - Actual failed start/probe attempts keep local diagnostics and the existing third-consecutive-failure
plugin.service_failedreporting threshold. Shared pending calls do not multiply attempts. Confirmed application success resets that reporting counter; removal clears it without restoring a retained retry budget, and root reconciliation resets neither. Alert writes retain their separate counter, reset only after a successful alert write. Error prose does not control eligibility or counting. - Unit limits:
MemoryMax=512M,TasksMax=64,Restart=alwayswithRestartSec=1, a read-only system with the data folder as the only writable path (pluginServiceUnitsinscripts/pluginServiceUnits.mjs). - Each service build hashes at most 8192 entries and 64 MiB of JavaScript (
serviceSpecinscripts/elowen-site-gateway.mjs). - Cost: each declaring plugin runs one extra Node process. A daemon boot does not restart an unchanged, active service (
ensurePluginService). Because disable never stops the process, a disabled plugin keeps running until it is uninstalled.
Daemon-bound cleanup
registerService({ name, start() {}, stop }) also owns cleanup for resources initialized during plugin
registration. MCP's mcp-clients stop closes every live client and transport, leaving process-group ownership
to the PI MCP layer that spawned them; Sandbox's environments stop disposes runtime environments and
reconciles stale leases while retaining durable rows.
The host stops interval timers and awaits their pending work first, then stops services newest-first.
Registry services therefore stop before MCP and Sandbox. Each stop has a 30-second grace within the
existing bounded daemon pause, and a failed stop is logged and never restarted. Runners do not start
these services. No service registration means no cleanup callback; there is no shutdown hook.
Lifecycle alert identity
Core plugin lifecycle alerts use src/alerts/pluginAlerts.ts as their identity owner. pluginReconcileAlertKey(plugin) returns the existing plugin-reconcile:<plugin> key; pluginServiceAlertKey(plugin, service) returns plugin-service:<plugin>:<service>. PluginServiceRunner uses these keys at boot reconciliation and service start, PluginServiceManager uses the independent service identity, and plugin management routes use the same keys when clearing disabled-plugin alerts. pluginServiceWarning(plugin, service, reason) returns the existing admin-scoped core warning with already-reported metadata, localized service message keys and the encoded plugin settings URL; it performs no I/O or logging. Producers log first and retain their reason preparation policy, including PluginServiceManager's 1024-character slice. Reconciliation retains its distinct title, body and joined reasons. Without an alert sink, lifecycle work proceeds without alert writes. Identity building performs only string assembly; existing AlertService validation and delivery limits still apply.
Real consumers
- Chatbot, a registry plugin: its manifest declares
service.entry, and the worker lives inplugins/chatbot/src/service/. The daemon side publishestrustProxy, the loopback daemon address and a hashed credential projection (plugins/chatbot/src/service/daemon.ts). - Sites, a registry plugin: its manifest also declares
service(plugins/sites/elowen-plugin.json).
Root service operations
The plugin-service domain exposes status, restart, remove, reconcile and remove-all. Explicit restart uses forced convergence; bulk reconcile leaves unchanged active services running. The private convergence function receives plugin, trusted owner, IO and required forcedRestart. There is no ensure wire operation. Missing declarations create no service; only reconcile may defer an unprepared runtime (PluginServiceNotPreparedError.source separates a check status 75 from a missing data-folder). PluginServiceManager.context supplies restart and the updater consumes reconcile. Ownership/preflight/build hashes/backoff/resource limits stay unchanged.