Navigace
ELOWEN · Dokumentace

Poslední aktualizace: 20. října 2026

Procházet dokumentaci · Infrastructure and process topology

Tento článek zatím není přeložen. Je dostupný v angličtině.

Developer reference

Infrastructure and process topology

Owning code and consumers

Owning code, by area:

  • Ports and CLI endpoint resolution: src/shared/servicePorts.ts, src/cli/launcher.ts (localDaemonUrl, daemonUrl, start lock).
  • Process locks: scripts/kernelLock.mjs (descriptor lock), scripts/privilegedLocks.mjs (lock table, ranks, wait classes).
  • Root helper operations: scripts/elowen-site-gateway.mjs (operation table), scripts/siteGatewayContract.mjs (installed contract).
  • Privileged client and installation: src/privileged/workerClient.ts, src/cli/install/rootFiles.ts, src/cli/install/privilegedCutover.ts, src/privileged/privilegedFiles.ts.
  • Deployment records: src/cli/provision/deployment.ts.
  • Atomic JSON writes and the one directory fsync (syncDirectory, a no-op on Windows, also used by the core swap and the local file executor): packages/plugin-shared/atomicJson.mjs.
  • Process topology, sub-agents and managed branch reads: src/daemon/brainCore.ts, src/subagent/pool.ts, src/subagent/sizing.ts, src/brain/service/sessionProcesses.ts, src/brain/processRegistry.ts, src/integrations/managedProjectBranch.ts.
  • Host alerts: src/daemon/resourceAlerts.ts, src/update/report.ts, plugins/sandbox/lib/environmentAlerts.mjs.

Minimal use of the CLI endpoint and a kernel lock:

import { daemonUrl } from '../cli/launcher.js';
import { acquireKernelLock } from '../../scripts/kernelLock.mjs';

const base = daemonUrl(process.env); // Public /api, explicit direct daemon, then local daemon
const release = await acquireKernelLock('<dataDir>/example.lock', { timeoutMs: 5_000 });
try { /* exclusive section */ } finally { await release(); }

Limits: lock waits are bounded by a named wait class and by the request deadline, and a lock is never released by a stale PID file. Without an installation record, local lifecycle commands select the port from the environment or the tracked run state. Remote ELOWEN_URL targets are for HTTP clients only.

Privileged worker observation and request outcomes

PrivilegedWorkerClient.connectionState is immutable canonical observation for service target privileged-worker, with this client's authority/generation/sequence. It progresses unknown -> connecting/pending -> socket-connected/pending -> validated hello connected/ready. Disconnect is disconnected/unknown. retry is ALWAYS null: this client has no background retry loop or invented attempt counters. An actually NEW call may reconnect after transient failure through existing singleflight #connecting; pending application requests are rejected, never replayed.

The existing 5000 ms handshake budget covers connection plus validated hello. Close/pause can destroy a still-connecting socket; intentional close/pause keeps code closed. Existing request/preparation/attachment/execution budgets still bound their operations, not retry timing. Cancel/detach frames remain allowed during pause. Refused/malformed hello, protocol/digest drift and actual permission refusal are sticky for this client lifetime; reuse of its socket or a new request cannot bypass them. Explicit client lifecycle replacement is required.

The exported native errors retain messages and Error.cause, not a prose parser:

Error code / requestOutcome reason and meaning
PrivilegedWorkerConnectionError connection_failed / not-dispatched unavailable or timeout; only this class permits a consumer to consider a NEW-call retry under its own existing policy.
PrivilegedWorkerHandshakeError handshake_failed / not-dispatched forbidden, protocol, digest or refused; sticky terminal. Forbidden comes only from actual EACCES/EPERM socket errno, never operation error text.
PrivilegedWorkerOutcomeUnknownError outcome_unknown / unknown connection_lost, timeout, refused, write_failed or protocol; an application write began and may already have mutated state.
PrivilegedWorkerClosedError closed / not-dispatched or unknown Intentional client close/pause, preserving the individual application's dispatch phase.
PrivilegedWorkerAbortedError aborted / not-dispatched or unknown Caller cancellation, preserving its cause and application dispatch phase.

A hello with typed protocol/digest fields that differ from the expected release preserves an InstanceConditionError cause, including a refused hello. Publication and preview consumers can keep this operator-maintenance condition local through isInstanceCondition. Missing/malformed handshake fields and application refusals remain software failures; error prose never establishes release drift. The refusal stays terminal and does not change dispatch or retry authority.

Dispatch is recorded immediately BEFORE actual Socket.write. A missing/closed socket before that boundary is not dispatched; a synchronous write throw after entering it is conservatively unknown. Pending control calls and preparation/ exec-start sessions preserve their individual phase on the same loss. A negative operation response is unknown/refused regardless of words such as busy, permission denied or version drift; it does not attest to authentication failure or an unapplied mutation. Generic/handshake/closed/aborted/unknown errors grant no retry permission. In particular an accepted OR unknown root restart cannot be resent; only a separate authorized readiness GET may follow. The consuming service owner must implement that policy; typed transport errors alone do not establish it.

A started execution's explicit session.cancel() retains the unknown-outcome wrapper with an InstanceConditionError cause for local reporting. Its rejected closed promise, interruption callback and cancellation frame are unchanged. Malformed terminal status and unsolicited worker failures do not acquire that cause; cancellation of an already-failed session cannot overwrite its original failure.

Existing Sandbox, lifecycle and Sites gateway callers consume this same client; gateway status retains the typed closed distinction. Reading observation does no I/O; calls retain existing socket/deadline resources, disposed by their original request/client lifetimes. OS worker systemd crash supervision remains separate: no worker wire/scripts/units, service inspection or reconnect controller is added. Focused transport tests use fake sockets and never connect to the installed worker. See finite runner startup for the independent fork/IPC owner.

Process-attributed diagnostics

src/shared/logger.ts owns the isolate-local diagnostic identity. Entry points call setLogProcessRole(role) before starting work; PID always comes from process.pid, and worker threads also carry Node's threadId. The role vocabulary also lists store-read-worker and wal-checkpoint-worker, but no process in this tree sets either: the entry points that do are daemon, runner and background-worker, and PostgreSQL owns database reads and checkpointing without separate worker processes. An unmarked test or standalone helper stays helper; importing daemon modules never declares a process to be the daemon.

The runner is one consumer:

import { logger, setLogProcessRole } from '../shared/logger.js';

setLogProcessRole('runner');
logger('daemon').info('brain core ready');

Text output carries [runner:1234 daemon]; a worker-thread emitter additionally includes its thread ID. The subsystem scope remains daemon in LogEvent.scope; the separate required LogEvent.process contains { role, pid, threadId? }. Existing logger instances resolve the identity when emitting, so setting the role also attributes module-level loggers. The single sink receives the same identity on invisible warnings and errors, without changing plugin ownership, problem codes, reporting classification or print thresholds. Without a sink the console and daily file still carry it. Identity reads allocate nothing; changing the role publishes a frozen snapshot rather than mutating already-recorded events. Synthetic update-journal handoff events also obtain LogEvent.process through logProcessIdentity() before the problem service projects them; the bounded external problem report still carries only its existing codes and redacted samples.

SlowSyncEntry.process uses the same identity for both slow and silent fine steps. slowStep and watchLoopLag reach the existing logger and retain their thresholds and warning cadence; watchdog descriptions also identify the recorded synchronous steps. startStallProfiler captures that identity at startup for each attribution and saved profile. A profile name has the form stall-<UTC end time>-<role>-<pid>[-thread-<threadId>].cpuprofile; the JSON retains the standard CPU profile fields and adds elowen: { process, endedAt }, with endedAt in epoch milliseconds for the first stall saved from that segment. Multiple stalls covered by one segment still share its file. Names sort by time and the existing directory-wide retention count stays unchanged.

Filter comparable daemon measurements by role and the PID of one run. Report runner, background and worker-thread activity separately. This seam adds attribution, not a performance optimization: inspector rotation, attribution and JSON serialization still run on the observed thread. Production comparisons need equivalent workloads and sampling windows after the attributed build starts.

Bound computation jobs

src/brain/backgroundWorkerFork.ts and backgroundWorkerProtocol.ts own the fork, IPC, cancel/grace and typed job priority. pluginComputation.ts extends this boundary for plugin-owned modules, bound by the loader's exact plugin root through ctx.host.computation. The daemon and runner both wire it through createPluginHost; CPU work never goes back to the daemon by RPC. Files diff and Web conversion own their algorithms. Each binding lazily owns one reusable child through the existing fork/entry/dispatch seam. The imported module stays loaded across calls. Invocations queue before input preparation and every IPC descriptor/reply carries its request ID. Queued cancellation never kills an active caller; active cancellation kills its child immediately, waits for exit and rejects without retry. A later independent call can lazily create a replacement. Stop rejects queued and active work, awaits settlement/input cleanup and child exit. An idle child exits on lost IPC; there is no global pool, idle timer or second worker entry. Catalog/probe/embeddings/attachments/export remain one-shot. Each binding contributes a stop service to the existing registry lifecycle. Shutdown closes admission and aborts pending work. The existing fork seam immediately SIGKILLs pure computation and read-only export workers: synchronous CPU and dependency-installed JS signal handlers cannot defer that signal beyond the parent's three-second shutdown budget. Computation modules may read and compute but hold no shared write locks. Catalog/PI maintenance retains its existing SIGTERM abort and five-second kill grace; neither budget changes. Async private files carry exact text inputs/outputs; IPC carries only their descriptor. Consumption, errors and explicit abort remove the directory. Names directly under the OS temporary directory include the owner PID. Each binding asynchronously reclaims only matching real directories with mode 0700 and the current UID whose PID fails signal 0 with ESRCH. It preserves live/reused PIDs, including signal-0 EPERM from a foreign live PID, symlinks and other ownership/modes without age deadlines. EPERM preserves inputs and permits binding; other probe/I/O failures propagate, except an entry already removed concurrently. Recovery after parent SIGKILL waits for the next binding initialization; it does not add a periodic service or sweep unrelated temporary files. Files omits caller cancellation after a completed write/read-state update so the baseline success, hash, diff and patch survive tool abort; lifecycle stop still cancels the binding. Calls have no computation deadline or output truncation. An input-bounded consumer can opt into the same module locally up to 512 bytes. Interactive jobs inherit priority, while maintenance retains nice 5. Catalog, credential and embedding imports are lazy within their corresponding branches. Probe-reply validation stays with the catalog caller, so the generic worker protocol does not transitively bootstrap catalog runtime code. Source-worker tests enforce this with an import-denying loader.

Infrastructure lifecycle and deployment facts

src/shared/servicePorts.ts owns validated daemon/web bind ports. CLI HTTP clients resolve through daemonUrl (src/cli/launcher.ts): ELOWEN_URL selects the public instance's /api base, while ELOWEN_DAEMON_URL alone selects a direct daemon root. With both variables, HTTP retains the public API and hooks/WSS use the explicit daemon. Without either, the installed daemon port wins even when an operator shell exports a different ELOWEN_PORT, because installed systemd units still bind their recorded port. Without an installation record, only the supplied environment selects the port, followed by its tracked port and shared default; an explicit environment object never borrows missing endpoint values from process.env. Tests model installation facts and run state in fixtures and clear inherited endpoint variables rather than reading host installation state. localDaemonUrl ignores remote overrides for updater health and offline lifecycle checks. Auto-start refuses unreachable remote targets and preserves the systemd guard. It calls the launcher's daemon-only mode, which starts and tracks the existing daemonSupervisor without requiring the web bundle; a later full start adopts that supervisor and adds the web.

scripts/kernelLock.mjs is the shared process-lock seam for launcher start, update/recovery, explicit deployment reconfiguration and privileged mutations. It opens an owned regular file without following symlinks and passes its open-file description to /usr/bin/flock for acquisition. The caller keeps its own descriptor after that subprocess exits; release or caller death closes it. There is no separate holder process whose death could unlock a still-running mutation. The lock pathname is never unlinked and PID text is never authority. Namespaces remain separate; startup waits five seconds and updates fail fast. A lock may be taken shared or exclusive; the privileged operation locks below use both. A held lock throws KernelLockBusy with the caller's busyMessage on every platform, so a caller that must tell "held" from a failure checks the class, never the text; the standalone client's Windows service stop is that caller. The installed root helper imports a root-owned copy beside it; privileged installation and the worker/client digest include that copy. Refresh the privileged files before restarting an upgraded daemon. Gateway state JSON and publication identities are not migrated or rewritten by this convergence.

On Windows, where only the standalone client runs, the same two functions open the lock file with exclusive sharing (libuv UV_FS_O_EXLOCK, 0x10000000) instead of asking flock: another open fails with EBUSY while the handle exists and the kernel closes it when its process dies, so there is still no helper and no stale PID. timeoutMs is honoured by retrying every 50 ms until the deadline (a timer for acquireKernelLock, Atomics.wait for withKernelLockSync); the uid, mode and hard-link checks become a regular-file check, and shared is refused because no Windows caller takes one.

Every operation of the root helper (sites, nspawn and plugin-service) is one entry of a single definition table in scripts/elowen-site-gateway.mjs. An entry declares its effect (read, mutation or execution), its lock scope, the wait class that bounds acquiring that scope, its worker admission class (control or bulk), what it needs read after the locks are held (owner proof, deployment, storage roots), any phases and its handler. The scope is mandatory and explicit: lockScope.none says so deliberately, lockScope.host(name, mode) names a host resource, lockScope.environments(resolver) names the environments a request touches, and lockScope.all(...) combines them. A definition or phase with no declared scope, a scope that contradicts its wait class, or a phase that breaks the lock order makes the module fail to load, and tests/contract/privilegedOperationScopes.test.ts pins the declared scope of every operation. The worker handoff policy (only mutations are handed to a later caller), the capability list the broker advertises, whether a deployment record is read and the admission class are all derived from that table; there is no second list of lock-free or record-free operations.

scripts/privilegedLocks.mjs owns the lock table, scope identities, claim resolution, operation-definition validation and the descriptor executor built on kernelLock.mjs. The root operation table and all request-to-environment validators stay in elowen-site-gateway.mjs. Trusted maintenance callers import lockScope and runWithDeclaredLocks directly from the lock module; handleRequest uses its claim executor, and so do the two trusted maintenance callers (explicit deployment reconfiguration, which holds the gateway lock, and the proof-host teardown, which holds the uid-registry lock). No handler takes a lock itself. Locks have a fixed rank and are acquired in rank order, then in key order within a rank, and released in reverse: the provisioning barrier (10, shared by work that needs a provisioned host and exclusive for provisioning), environments (20), the Sites gateway and plugin-service ledger (30) and the machine uid registry (40). A nested phase may only take a lock ranked above everything already held, which is what makes a cycle between two requests impossible; there is no upgrade from shared to exclusive. Every wait is bounded by a named class (none, uid-registry 5 s, environment-control 30 s, environment-bulk 15 min, plugin-services 120 s, host-transaction 9 min) and by the request's own deadline, and a request logs how long it waited for and held its locks. The maintenance-only cutover lock ranks below all of them (rank 5); no operation may declare it.

An environment lock is named by a validated identity, never by a request string. A Project request names its resource by kind and resource (and its machine, which must agree), a tree request names the owner of each tree it touches (owner, or sourceOwner and targetOwner, each exactly {kind, resource}), and the helper requires every tree path to lie inside that owner's storage root. A copy between two environments takes both locks in canonical order. Lock files live under /var/lib/elowen/locks (the gateway lock keeps its historical path), are never unlinked, and are refused unless their directories are not symlinks, are owned by the helper and are closed to group and world (the gateway lock keeps the instance state directory it always had, so only its file checks apply). A bind source of a machine envelope must lie in the storage of the environment the envelope belongs to. The machine uid range is reserved in a declared uid phase under the short registry lock, which is released before extraction or an ownership pass starts. Command execution, freezing and thawing a machine (which touch no helper data, so a recovery thaw never waits behind a long tree operation), status and the other reads take no lock, and the worker keeps one operation slot for control work so bulk operations cannot occupy every slot. Replacing the installed helper is a cutover, not an in-place swap, and installPrivilegedFiles is the only writer of those files: elowen privileged refresh and elowen install both go through src/cli/install/privilegedCutover.ts, which serializes against another replacement (the maintenance-only cutover lock), stops admission and drains the worker, holds every host lock while the files are written, and only then reopens what was open, so an old and a new lock policy never mutate the same host at once.

scripts/siteGatewayContract.mjs is the pure installed contract for canonical hostnames, binding count and byte limits, token/email/slug grammars, fixed owner/deployment/storage receipt paths and plugin-service socket derivation. The root helper, daemon gateway and service-unit renderer import it directly; CLI proxy readiness checks use the same socket function. Boundary-specific binding diagnostics and installed routing semantics stay with their callers. None of the installed modules reads host configuration on import: undeclared scopes and invalid socket names fail explicitly, and an unlocked scope touches no lock files. The installed modules and their declarations ship in the package; the JavaScript files are installed root-owned before the helper and worker through PRIVILEGED_FILES (elowen-site-gateway.mjs, cloudflareIpRanges.mjs, pluginServiceUnits.mjs, kernelLock.mjs, privilegedLocks.mjs, siteGatewayContract.mjs). The original inventory order is retained with the dependencies inserted before the helper. Worker and client SHA-256 digests hash the same files in a fixed order — worker, helper, Cloudflare ranges, plugin service units, kernel lock, lock table, gateway contract — separated by NUL bytes, so missing, extra or mismatched bytes refuse the handshake. Cutover, owner checks and historical lock paths are unchanged. The proof-host inventory copies the installed modules and rewrites only the lock table's paths into its isolated lock namespace.

On the client, PhaseDeadlines in src/privileged/workerClient.ts is the one lifecycle for a request: handshake, preparation, attachment and the overall deadline are phases with budgets defined together. The five-second attachment timer covers only the wait from writing exec-start until the worker reports exec-started (admission, the three stream attachments and the launcher handoff). Time spent connecting or preparing (which may wait for an environment lock) never counts against it, and a missing attachment still times out. The caller's overall deadline is preserved. The phase budgets are handshake 5 s, preparation 120 s and attachment 5 s.

NspawnClient.prepareFileAgent(spec, executionId, { signal, onInterrupted }) is the Sandbox file transport's prepared-session seam. It publishes the bundled guestFiles.py by SHA-256 digest and verifies the owned running container, then returns { start(), settle({ cancel }) }. start may be called once and returns the existing worker session's stdin, stdout, stderr, closed and cancel. The signal and interruption callback pass to that session unchanged. settle uses the same persistent guest-unit release or cancellation tombstone as prepareExecution; the owning file transport closes or cancels the session before settling its lease. Without a worker transport it fails explicitly and opens no session.

File agents carry op: 'exec', lifetime: 'connection', the helper digest, the exact two-element Python argv, cwd: '/' and an empty environment. Preparation verifies the digest-addressed read-only helper bind under the existing Project lock. The root argument builder also validates the connection shape during preparation and launch, rejects a detached launch and omits RuntimeMaxSec. Only this fixed helper and the fixed desktop connector may use connection lifetime. The client keeps preparation and attachment budgets but arms no execution deadline; the file transport owns its operation deadlines and idle cleanup. The worker separately admits 16 pending or live file agents and 32 general sessions, including terminals and desktop connectors. These are capacity limits, not measured throughput. Both use the same attachment, owner-loss cancellation and verified settlement state machine, with no extra socket or relay. Refresh installed privileged files with sudo elowen privileged refresh before restarting the matching daemon; the complete helper digest rejects stale installed workers.

Fresh install and explicit setup use recordGatewayDeployment for the gateway's independently validated host/port facts. reconfigureDeployment holds the declared gateway lock, updates those facts and the canonical install URL together, preserves the install inventory, and restores both records and the app proxy on a failed operation. A hostname change with existing Site bindings or pending removals is refused before any proxy mutation: those identities require a separately coordinated DNS/publication migration. Existing bindings also forbid an HTTPS downgrade; failed TLS provisioning restores the prior deployment. Neither upgrade nor privileged refresh invokes this explicit reconfiguration; its only caller is the setup step (src/cli/setup/steps/deployment.ts).

Core catalog caches use packages/plugin-shared/atomicJson.mjs with its TypeScript declaration, private mode (the callers pass mode: 0o600) and non-durable replacement (durable defaults to false). Shared staging uses exclusive creation and removes only a staging file it created, including when a colliding name prevents acquisition.

Process topology

The normal topology has one daemon process and one web process. Each data-owning process opens one PostgreSQL pool through elowen-db; runners and background workers use their own pools without running migrations. Only daemon startup migrates. The protected database URL and explicit instance directory remain separate configuration values. See database persistence.

browser
  via web application
    daemon HTTP/SSE
CLI
  daemon HTTP/SSE
platform adapter
  daemon
    brain and typed asynchronous repositories
      PostgreSQL through the process-owned pool
    plugin services and platform gateways
    optional delegated runner processes

The daemon creates the Hono HTTP server, registers core and plugin routes, and dispatches authenticated plugin root mounts without core domain fallbacks. Unknown mounts return 404, while a known unavailable plugin returns 503. It attaches plugin WebSocket upgrades to that same server. Ticketed routes retain live account/grant resolution; public integration routes require a registered authorize({params,headers}) function before upgrade and expose no Elowen identity or ticket payload. False answers HTTP 401 without a 101, verifier failures answer 500, and the same-host Origin gate remains first. Registry Twilio consumes this generic transport. The registry preserves each declared route pattern for refusal, transport and handler logs so path credentials are not logged; the existing bounded-frame, paused-read, backpressure and generation teardown paths are shared. Without registration there is no public verifier work. The daemon starts platform adapters and plugin services, and owns maintenance, shutdown, recovery, and process registries. Plugin load, boot-reconciliation, and service-start failures produce warning alerts for administrators and clear when the producer recovers. Maintenance produces core alerts for provider usage at 80% warning and 95% critical, for host disk space (a warning when 20% or less is free, critical when at most 2 GiB and at most 5% are free), and for updates: a rolled-back or failed run raises update-failed (critical when the previous set could not even be restored), a run that deliberately left plugins behind raises update-skipped, and a clean commit clears both. The update producer reads the updater's durable last-run report (report.json in InstancePaths.updateDir, written by every decided elowen update path), because the CLI updater runs in its own process without an AlertService; one stable key per condition makes a repeated hourly failure upsert instead of spam. Provider alerts clear below 75% in every usage window. A critical host-disk alert drops back to a warning once 3 GiB or 7.5% is free, and the host-disk alert clears only when less than 75% of the disk is used. The disk dial in Settings → System (GET /system diagnostics) reads the same figure through hostDiskUsage in src/api/systemDiagnostics.ts, the filesystem holding the instance directory, so the dial and the alert always agree. PostgreSQL storage capacity is monitored separately by the database operator. The Sandbox plugin raises project-scoped alerts: a warning at 80% of a managed environment's disk ceiling, a critical alert when usage exceeds the ceiling, a warning when a start is refused for disk, and a critical alert when automatic recovery is exhausted.

startDaemon constructs the process-owned PrivilegedWorkerClient() with the installed canonical socket and source digest, then passes it through buildApp. Startup accepts the licence key and server, with no worker socket override. Connection, digest validation, protocol, domain authorization and pause behavior stay with the existing client and gateways.

The shared buildBrainCore factory never creates a privileged connection. Process entry points explicitly supply privilegedTransport: new PrivilegedWorkerClient(): the daemon passes it through buildApp, and the delegated runner supplies it directly. Embedded and test cores omit it by default. Machine gateway calls then reject locally with no privileged transport in this process; daemon-mode Sites gateway calls report unavailable with the same detail. The runner still has no Sites gateway. A supplied client is reused for both domain-stamped gateways and the daemon's pause hook; omission creates no connection or pause work. Sandbox is the machine-runtime consumer, and Sites is the published-site consumer. Loading a registry without Sites, including a disabled or failed plugin, sends no gateway request and leaves the existing deployment untouched.

Background shell telemetry has one daemon-owned conversation projection in SessionProcessService.snapshot. GET /brain/status, the first SSE snapshot and live process events carry its processes field. It contains the focused conversation and its direct durable delegated children, filtered by the existing operator/account ownership rule. processHandleOwnedByAccount is the canonical identity predicate for local handles, accountless runner snapshots and teardown sweeps: a non-null explicit account wins; otherwise the originating durable session owner decides. Snapshots expose no explicit account field. Foreground controls describe the focused session only. Terminal registers both explicit background launches and foreground runs in ProcessRegistry, and re-registers the same handle as a job after manual or deadline detach. Runner IPC supplies metadata to this read model and clears it when the runner exits; output and kill still reach the owning registry through their existing gates. No process cards or client account-wide polling remain. Empty registries produce empty telemetry; this adds no persisted state, changes no restart lifetime, and costs only a scan of registered handles. CLI and web rails render running non-foreground entries from this field.

Direct-host descendant discovery has one scanner in packages/plugin-shared/processTokens.mjs. Terminal stamps DIRECT_PROCESS_TOKEN_ENV into each run and calls tokenPids([token]) while confirming cleanup; src/brain/processTokens.ts retains only core's best-effort killTokenProcesses(tokens) wrapper for runner-exit cleanup. One synchronous Linux /proc pass handles the whole token set, including setsid descendants; the scanner excludes itself and unreadable or exited processes and returns descending pids. Empty tokens or no readable matches return no pids and send no signals. Scan cost scales with process environments and token comparisons; no hook, persistence or provider request is added. Terminal keeps its kill-error diagnostics and waits for confirmed exit before releasing a lease. ProcessRegistry reads output and kills by process id, lists processes per session, and lists them per session and account (listForSessionAccount). These list methods return serializable ProcessInfo snapshots, never live callbacks. Terminal's ListProcesses filters that session/account list to background entries and passes each id through its existing scopedHandle resolver before returning the metadata. That resolver fetches the live handle from the same registry, checks the current session/account, and authorizes its local lease or managed environment generation, exactly as ProcessOutput and KillProcess do. A missing inherited local target or a failed socket lease refuses the list; host handles keep their existing behavior. An empty filtered list returns without an authorization call. Each listed entry adds one in-memory handle lookup and its existing authorization, with no callback fields added to API, SSE or runner snapshots. The account-wide kill is killAccountProcesses in src/daemon/bootstrap.ts: it stops the account's local handles through ProcessRegistry.killWhere with processHandleOwnedByAccount, awaits the runner sweep, and throws unless every stop is confirmed. The account delete route (src/api/routes/users.ts) calls it first and refuses the delete when it fails.

The Next.js web process owns browser rendering, route composition, and the same-origin browser-to-daemon proxy. The catch-all proxy forwards REST and streaming responses to the daemon and keeps browser authentication at the web boundary. Web modules display daemon state but do not make authorization or persistence decisions.

When the delegated runner pool is enabled and a delegated turn is dispatched, the daemon can fork runner processes. A runner rebuilds the same brain core and plugin registry needed for its child session, but does not start an HTTP server, ordinary platform gateways, scheduler, migrations, or maintenance loops. The daemon remains responsible for dispatch, recovery, abort fencing, reverse workflow calls, and delivery.

SubagentRunnerPool in src/subagent/pool.ts owns one channel route map whose values hold both the runner entry and its touchedAt activity time. Dispatch and accepted nested edges establish or refresh that record; tapSessionSnapshot, used by delegated-child drill-in, refreshes the same time before asking the owning runner for history and live events. Missing routes send no remote request and let ordinary dispatch choose a runner. The maintenance sweep retires routes idle for 30 minutes only after confirmed release. Busy or failed release keeps ownership and refreshes activity to the sweep's start; each candidate reads current activity because an earlier release can await while another route is used. Successful release removes a route only while that runner still owns it. Runner exit removes all of its routes, and reset clears the map; terminal nested-edge retractions after reset notify the sink without recreating routes. Stats and reap decisions count this map directly, with no separate activity index, persistence, or added IPC. Route lookup is constant time; sweep and session counts are linear in the current routes.

Runner memory admission uses a bounded asynchronous Linux process-tree sample in src/subagent/sizing.ts, triggered by the existing two-second heartbeat. It follows every thread's children file, sums VmRSS once per PID, and stops at 256 processes or 1,024 threads. This includes language-server wrappers, semantic/syntax servers, and typings installers without scanning unrelated host processes. At most one sample per runner is in flight. An unreadable or truncated sample blocks growth until a complete sample arrives; it never becomes a success-shaped zero. A discovered descendant that exits before its status read contributes no resident memory.

The sizing denominator R is the high-water maximum of reported runner RSS and sampled whole-tree RSS, or the existing 512 MiB estimate before a measurement. The memory cap is floor(0.5 * totalHostMemory / R). Automatic capacity is min(max(1, CPUs - 1), memoryCap); an explicit operator maximum replaces the CPU ceiling but still gives min(operatorMax, memoryCap). Spawning also needs available host memory of at least 1.5 * R, the existing growth cooldown and saturation rules. Pending or incomplete samples prevent growth, while admitted work continues. For example, on an 8 GiB host a 256 MiB runner with 1,792 MiB of descendants has a 2 GiB denominator and memory cap 2, rather than the root-only cap 16. This is a conservative RSS budget, not a hard process or cgroup limit: shared resident pages can be counted more than once, swap is not RSS, and descendants reparented after runner death belong to the existing containment cleanup.

/health.subagentPool.runners keeps rssBytes for the runner itself and adds treeRssBytes, treeProcesses, and treeMemoryComplete for the latest sample. Those fields are null before sampling. The pool-wide runnerRssBytes exposes the sizing denominator, which deliberately retains the high-water mark even after descendants exit. nestedActiveTurns projects the runner's existing live child edges, validated against the owning route and deduplicated by child session. activeTurns remains the outer dispatch count. Both cover call settlement as well as execution. The effective load for placement, least-loaded selection, saturation and growth is activeTurns + nestedActiveTurns, derived from that same validated edge projection without another counter. A new directly dispatched turn fits only below load eight; saturation starts at load four or sustained loop lag. Nested work is also a growth demand signal, reconsidered on edge changes and existing heartbeats after the fifteen-second cooldown.

Nested children stay in their parent's runner and start without waiting for a pool slot. Their parent keeps its outer count, including while waiting for children. No parent/child slot dependency is created; a locally nested fan-out may therefore exceed eight, at which point new outer placements wait or use another runner. Memory and runner-count ceilings still apply to growth, and existing work is never killed to meet a reduced memory cap. This changes load accounting only: dispatcher predictions, provider prompt bytes, workflow DAG ownership and abort/recovery routing keep their existing behavior. Spreading work means placing new outer calls on other runners; existing nested trees are never moved.

Managed Project execution is another boundary, not another daemon. The Sandbox plugin and the Project execution contracts prepare and control a persistent managed environment. Core integrations reach it through the typed environment control rather than switching to host paths implicitly. prepareExecution() returns either a local or confined launch shape or a ManagedPreparedExecution; managed consumers call start() for a ManagedExecutionSession with guest stdin, stdout, stderr, and a settled closed promise. The persistent privileged worker carries those sessions over protocol 2 and direct socket-stdio attachments, with interruption recorded before cancellation settles.

The checked-out branch of where a conversation works has one source per execution kind, and every client reads it from one field, project.branch of GET /brain/status. A host directory is a memoized read of .git/HEAD (src/brain/service/gitBranch.ts). A managed Project has no host path, so its branch is read inside its environment by src/integrations/managedProjectBranch.ts: the environment state read, then git rev-parse in the guest through runManagedProjectCommand, the same seam as /projects/:id/git. A conversation on a paired computer ({ kind: 'local' }) works in a directory on that computer, the one effectiveTurnWorkDir resolves, so the same module runs git rev-parse there (readLocalBranch, src/git/localGitReader.ts): through the daemon-owned executor socket file and shell tools use, under a binding of the conversation it takes for the read (bindLocalTurn), and through CliConnections.prepareDaemonRead, which dispatches like a turn's process start but does not count it as a command in the device's activity. GET /projects/summary (the register cards) uses the same module, so the CLI rail, the web telemetry foot and the project card cannot disagree. Every reader answers no branch for a directory that is not a repository, an unborn HEAD and a detached HEAD. The read never starts an environment and never asks an offline computer: a stopped or unprovisioned environment, an offline computer, a refused state read, a failed command and a timeout (2 s) all yield no branch, and project.branch is then absent, which every client draws as no branch line at all. A local read answers within those 2 s whatever the computer does; a computer slow to answer the cancellation finishes it behind the answer, and the next poll joins that read instead of starting another. Answers are remembered per project id, or per account, computer and directory for a local conversation, 5 s for the status (so a git checkout made during a turn shows when the turn ends) and 30 s for the register, a failed read, including an environment-state failure, for 5 s so a wedged guest or computer cannot stall every poll. Pending execution cleanup and the 2 s telemetry timeout are local INFO diagnostics; other failures retain project.branch_read_failed. BrainStatusService.status() awaits repository reads and only peeks the branch cache; the /brain/status route first awaits BrainStatusService.refreshProjectBranch(), which does the (at most one) guest or computer read when the memory is stale and re-checks the caller's access to the Project. Example: a CLI started in a repository on a paired laptop on main: the first status poll costs one short git process on the laptop, the polls of the next 5 s cost none, and the laptop's command count does not move. With no Sandbox plugin, no running environment or no live computer connection the field is absent.

Registry and privileged ownership

PluginRegistryProvider lazily shares one load promise per process. await provider.get() shares in-flight and successful loads; a rejected initial load clears the memo and propagates, so the next call retries. peek() is undefined before successful loading and otherwise reads the loaded registry without I/O. Plugin code/enabled changes take effect through a new provider on daemon restart; skill-only reloads update the existing registry. There is no invalidation/reload fallback or polling. Brain sessions and platform adapters consume this provider.

The root operation table alone owns dispatch, locks, dependencies, mutation handoff and worker admission. helperOperations is an internal contract-test catalog, not a wire advertisement. Machine gateway activation sends request({ op: 'status' }); worker hello returns protocol and installed-file digest. Missing/mismatched values refuse the connection. Retired nspawn capabilities and plugin-service ensure operations produce unsupported-operation errors before ownership reads or locks. Sandbox is the consumer; admission budgets are unchanged.

Sites wrappers select injected site IO or owner-bound defaultSiteIO once; handlers require that IO. Certificate lineage validation is lineageFor(hostname). commandOptionsFor(file, timeoutMs, pins) retains command budgets and descriptor inheritance; nginxCloudflareTrustLines emits four-space-indented directives for installer and gateway. These changes add no host reads or fallback owner.