Navigation
ELOWEN · Documentation

Last updated: October 20, 2026

Browse documentation · Shared CLI and local execution client
Developer reference

Shared CLI and local execution client

Terminal label presentation

terminalInlineText in src/cli/ui/text.ts remains the printable one-line boundary for untrusted labels: it decodes terminal controls with terminalPlainText before calling collapseWhitespace from src/shared/text.ts. Callers such as tool headings and rail rows keep that wrapper rather than applying whitespace normalization to raw terminal content. There is no preview cap; empty printable content remains empty.

AskChoiceDock formats its sanitized display label with formatQuestionOptionLabel from elowen-plugin-shared/ask and the selected locale in elowen-plugin-shared/messages. The shared messages have adjacent TypeScript declarations; the existing runtime exports and wording are unchanged. The dock passes no label-length limit: terminal wrapping, descriptions and geometry remain local. The original op.label stays the selected/submitted identity, never the sanitized label or the recommendation suffix. Both projections are synchronous, allocate with the input length and schedule no work when unused.

Name allocation

uniqueSlug(base, taken) in src/shared/nameGrammar.ts owns first-free allocation for setup and the chat provider picker: return the unchanged base when free, otherwise probe base-2, base-3, and so on, filling the first gap. It only reads the supplied set; it does not reserve the result, validate it against NAME_RE, normalize it, or perform I/O. Work is linear in the occupied suffix prefix. The browser uses the existing byte-identical web/lib/nameGrammar.ts mirror, pinned by webMirrorContracts.test.ts.

Callers still choose their namespaces: setup Project slugs use registered Project slugs, provider ids use provider ids, and setup connection ids use all connection ids across providers. The TUI provider picker deliberately checks provider ids only and retains the same id for its first account. Setup's deriveSlug remains in cli/setup/slug.ts with its basename, NFKD and project fallback rules; web provider-name normalization remains distinct.

Conversation picker pins

BrainClient.sessions() reads the same owned daemon listing as the web, including pinnedAt: string | null. sessionItems places pinned rows first, newest pin first with conversation id as the tie-break, and adds a ◆ marker beside their titles. Unpinned rows keep the listing order. This changes no request count or daemon ordering and does not add a CLI pin mutation.

CLI conversation and instance stream retry

BrainClient.stream(callback, signal, options) and instanceEvents(callback, signal, options) return Promise<RetryCompletion<void>> from one shared finite connection controller per invocation. Import BrainStreamAttempt, DaemonStreamOptions and BrainStreamOptions from chat/brainStream.ts; canonical retry types/runtime come from the shared package. Options carry onOpen, onState and an absolute monotonic deadlineAt; conversation options also select session and snapshot. The old positional callback/options contract is replaced, not overloaded for compatibility. Interactive and headless caller ownership is described below; transport regression tests alone do not prove a completed terminal flow.

A cold connectionPolicy(signal?) performs one authenticated GET /connection-policy bounded to 3000 ms, without retry, config access, disk cache or defaults. Invalid/unavailable/aborted reads reject before opening SSE. The frozen per-client cache and validated instance retry-policy frames retain newer revisions against late reads. Parallel cold callers may each make one read; no singleflight guarantee is implied. Active controllers keep their captured policy; a later explicit stream episode adopts the newer revision.

Targets are isolated: cli:<clientId>:parent, cli:<clientId>:child:<session> and cli:<clientId>:events. onState reports local-owner authority, numeric generation/sequence and fresh observations. The parent request URL resolves the current binding on each attempt; a child does not replace parent identity. Replay cursors are retained. First nonempty bytes report connected/pending and invoke onOpen(attempt), not ready. Only the consumer calls attempt.ready() after an accepted snapshot/hydration or required instance-cache refresh. attempt.reconnect() cancels/cleans an overflowing hydration attempt as not_ready within its existing budget; it does not recursively create a new stream. Normal request/connection limits and the 60-second readiness stability reset apply, not the always-on paired-computer/platform exception.

HTTP 401/403 block as auth_required/forbidden; 408, 429 and 5xx are typed timeout/rate_limited/unavailable retry outcomes with the canonical Retry-After minimum. Other refused/bodyless responses block as not_ready. Fetch/read loss and EOF retain network/closed outcomes. A 75000 ms body-byte silence watchdog reports timeout and refreshes only on nonempty bytes, not headers/readiness. Before retry or completion, reader cancellation/lock release, timers and abort listeners finish cleanup. An independent cleanup fault or consumer exception rejects rather than silently becoming a retry; the non-JSON sentinel catch does not swallow consumer failures. Ordinary HTTP mutations remain untouched and are never resent by this stream helper.

Outer application/session generations must fence separate invocations. Manual reconnect aborts and awaits old cleanup before creating a new episode; exhaustion never authorizes an automatic outer restart. Initial-connect and total deadlines, once-only send dispatch and independent goal polling remain caller-owned. The existing CLI SSE parser remains unbounded; history/hydration limits are separate and unchanged.

Headless connection lifecycle

runHeadless uses BrainClient.stream((frame, attempt) => ..., signal, { snapshot: true, onState, deadlineAt }) with the canonical finite connection controller; it adds no outer retry loop or fallback policy. The synchronous HeadlessSnapshotReconciler.snapshot must accept the snapshot before attempt.ready() and the once-only dispatch guard. Keepalive bytes never start a prompt. Reconnect snapshots can restore readiness but never resend a send or setGoal mutation. Settled or aborted-attempt callbacks are ignored.

The absolute monotonic deadline is established before the first request as performance.now() + timeoutMs and passed unchanged to the stream. Initial connection must produce an accepted snapshot within 20 seconds. The total timeout exits 124; retry exhaustion, authentication/permission refusal and other blocked stream outcomes retain distinct diagnostics and exit 1. Neither a wall-clock jump nor reconnect renews the total deadline. Goal observation keeps its separate 1500 ms cadence; it does not refresh the retry budget or dispatch work again.

With --json, connection transitions are JSONL on stderr only: { type: 'connection', state: ConnectionState }. Stdout keeps its existing session/event/result JSON and answer contract. Existing human session diagnostics on stderr remain, so stderr is not promised to contain JSON records exclusively. Plain mode prints scheduled retry count, delay and typed reason, plus terminal diagnostics, on stderr.

HeadlessSnapshotReconciler remains the append-only replay owner. Durable IDs and monotonic cursors prevent reconnect from repeating output already printed. When snapshot.run changes, overlap with the live tail's cursors preserves that prefix; a disjoint run resets the old tail. Equal text in a later answer is not itself a duplicate. History pagination and compaction retain their existing ownership. The caller aborts its stream and releases initial/total timers on completion; stream cancellation still awaits reader cleanup. Without a headless invocation there is no connection or polling work. Focused tests exercise the actual shared transport with fake fetch, accepted snapshots, one mutation, HTTP refusal and initial/total deadlines; they do not establish a complete CLI-process/server flow or interactive terminal migration.

Interactive chat connection lifecycle

ChatApplication and StreamCoordinator consume the shared controller through three independent finite lanes: parent conversation, focused child and instance events. ChatState.connections retains each lane's canonical ConnectionState, typed RetryCompletion<void> and unexpected error outside the expiring notice slot. An outer UI generation fences separate controller invocations; publication also rejects stale authority, target, controller generation and sequence. Model retry sidecars for parent/child and durable waiting_for_computer controls remain separate from transport state. Parent model progress advances controlRevision so an older status GET cannot overwrite it.

For a conversation, stream(frame, attempt, signal, { snapshot: true, onState }) acknowledges attempt.ready() only after SnapshotHydrator accepts the complete snapshot. A focused child can also become ready after its committed history read. Current application, session, hydration and attempt lifetimes fence all callbacks. Resync, compaction and hydration overflow call the same attempt's reconnect(), not a new outer episode or budget. History bounds and watchdogs are unchanged; failed streams are retained as lane state rather than swallowed.

The instance lane is ready only after the required rate-limits map read commits through ApplicationLifetime. A newer provider-usage/status read may win that publication; a superseded initial read is not a connection failure. A winning read failure reconnects the same attempt only while readiness is pending. Later refresh failure on an already-ready stream retains the existing stale-cache behavior instead of restarting transport. Late reads after shutdown cannot publish or acknowledge readiness.

ChatApplication.reconnect(): Promise<void> is the explicit recovery action, forwarded through ShellInputDeps.reconnect, existing input routing and the application lifetime. Concurrent requests join one recovery. It aborts and waits for old instance and conversation task/reader cleanup before replacing the corresponding streams, unless stopped or superseded by a session switch. StreamCoordinator.reconnect() preserves focused child transcript/navigation until authoritative replacement data; restartStream() remains the narrower parent model-rebuild operation. Neither path repeats start, send or tool mutations. There is no automatic outer restart after exhaustion.

The configurable reconnect action defaults to leader r (Ctrl+X, then r); Ctrl+R still cycles reasoning. /keybinds rebinding and none use the existing keymap. One visible recovery hint derives from keymap.chordLabel('reconnect') when a stream lane is scheduled or terminal/failed; an unbound action has no hint. No second key listener or application-actions layer is introduced.

connectionStatusLines is pure, width-fitted presentation using the canonical formatRetryProgress and already resolved ChatState.locale. Core source resolves that SAME formatter directly from packages/plugin-shared/retryPresentation.mjs, not an installed package artifact or a copied implementation. The parent, focused child and instance streams share ONE status line with the web wording, for example "Reconnecting 2/5 · next attempt in 2 s · ctrl+x r Reconnect": a lane that gave up or failed (it needs the Reconnect key) outranks one still retrying, and among retrying lanes the one with the highest retry number speaks, as the closest to giving up; a terminal phase the controller never announced reads "Connection failed". The reconnect hint ends that line. As on the web, a lane on the first attempt of an outage stays silent and gives no hint: ChatState.publishConnection records the retry number at which the episode last reported succeeded (connectedAt), and attempts count from there, so only the scheduled second attempt and later, terminal phases and failures appear. Model retry and computer waiting keep their own lines, including before any transcript text. Native PI delays remain static announced durations. A visual countdown (formatRetryCountdown) uses a known scheduled nextAttemptAt only for fresh local-owner/shared progress, replacing the announced delay; expiry says "next attempt now", not that dispatch occurred. The existing AnimationController refreshes that visual once per second and owns pause/stop cleanup. Terminal count/reason information outlives transient notices.

These lanes use ordinary finite SSE policy, not the unlimited paired-computer or platform exception. No new poller, socket class, persistence or retry owner is introduced. There is one existing visual-scheduler entry only while a local retry is scheduled; normal stream, cache and history work retains its existing cost. Focused regressions use the actual shared transport and formatter with fake fetch for readiness, cleanup, terminal state and keymap behavior. They do not establish integrated wire/provider compatibility, a real terminal flow or headless adoption.

Interactive terminal executor

packages/plugin-shared/localPtyWatchdog.mjs runs behind the existing process watchdog and its IPC lifetime boundary. On Linux it launches the shared ptyHelper.py as /usr/bin/python3 -I -S -c <source> /bin/bash -l. A missing or non-executable interpreter returns LOCAL_TARGET_UNSUPPORTED. The helper frames keystrokes and sizes; stdout remains terminal bytes. A Linux client archive copies the canonical helper next to its bundled watchdog; Windows archives ship none.

Windows dynamically loads node-pty, requests ConPTY and starts the existing windowsPowerShell() selection with -NoLogo. Profiles remain enabled. Windows builds below 18309 are refused as LOCAL_TARGET_UNSUPPORTED, so node-pty cannot silently choose winpty. UTF-8 input decoding retains split code points. Output pauses the PTY when the watchdog pipe fills. Cancel and executor IPC loss kill the PTY; natural exit retains PowerShell's exit code.

The executor strips ELOWEN_TOKEN case-insensitively and removes any environment value containing the device credential before starting the watchdog. It adds only the existing ELOWEN_AGENT_SHELL policy marker. Git and gh use the computer's own configuration and login. Linux and Windows computer terminals require a client advertising ptyV1; macOS terminals return LOCAL_TARGET_UNSUPPORTED.

scripts/build-client.mjs keeps node-pty external to the bundle and copies it only into Windows client archives: runtime JavaScript, package metadata, licence and the target architecture's conpty.node and conpty_console_list.node prebuilds. No native build tree, winpty binaries or PDBs ship. Native PE headers must match the target and every archive path must fit the existing 122-character budget. Core keeps node-pty as a development dependency; production Linux installs need no native PTY addon.

Shared command and installation owners

src/cli/index.ts owns the shared command dispatcher. The full bin.ts adds serverCommands.ts, lifecycleCommands.ts and launcherLifecycle.ts; the standalone clientBin.ts adds the startup updater and clientCommands.ts (client-install, connect, uninstall). Both launch the same ChatApplication, TranscriptModel and headless client. Pure transcript ordering and preview defaults live under src/shared/ so a client import never initializes server persistence. The server updater starts the selected installed release through its public bin.js up entry, so swap and rollback do not depend on another release's internal launcher imports. index.ts does not execute on import: server callers pass serverCli to main, while retained-release recovery launches the full bin.ts entry with that package's Node engine metadata. Prompt usage belongs to runHeadless; its injected I/O exposes the actual usage before credential resolution when no prompt is supplied. Slash examples use the existing CLI command catalog, including /stats, /goal and /plan.

launcher.ts::instanceAddress is the one URL seam. ELOWEN_URL means the public web origin or its explicit /api base and normalizes HTTP to /api exactly once. ELOWEN_DAEMON_URL means an explicit direct daemon origin. On its own it serves HTTP and WSS without a web prefix; alongside a public URL it overrides only WSS and direct legacy-session validation. Credentials retain the public origin. Without either variable, localDaemonUrl supplies the direct local API. Deployment mode is selected by the configured variable, never by scheme, host or port. launchChat(env, options, standalone) and interactiveLogin(env, standalone) preserve that environment instead of reclassifying an already resolved API base as public configuration. For example, with ELOWEN_URL=https://instance.example pairing posts to https://instance.example/api/cli/device/{code,token} and startup metadata uses https://instance.example/api/licence/client/.... clientUpdate.ts::applyInstalledInstance sets ELOWEN_URL to the install record's instanceOrigin in the client entry when neither variable is set, so an installed client and its service address the instance they were installed from. pairing.ts performs the shared device-code browser flow. The full server package with neither URL variable requests kind:'account', prints Open <verificationUriComplete> to approve this sign-in. and stores only the ordinary personal API token. Explicit URL modes request kind:'computer' and print approve this device. The standalone entry passes standalone:true through the dispatcher and implicit chat sign-in, so it always pairs even without an install record. Approval responses must match the requested kind; only computer credentials write executor retry policy. Existing credentials are not converted automatically: rerun elowen login to replace an old local device credential. chat/token.ts owns atomic, locked, mode-0600 per-origin credentials and validated legacy migration. Personal API tokens validate through the selected HTTP API; legacy sessions require the selected direct daemon. Failed validation preserves the legacy credential.

Setup's password helper returns the shared issuance union, not a token-shaped success for every 2xx. A TOTP challenge has no account authority. doctor.ts first resolves the same per-origin credential; when an interactive password attempt is challenged, it calls pairInstance(env) and uses only the browser-approved credential. Pairing denial, expiry or transport failure stops the readiness check rather than retrying the password. Non-interactive doctor requires an existing credential and never starts pairing. Headless setup also reuses chat/token.ts::resolveToken; its challenged password-only path refuses configuration with browser-approval instructions. Interactive setup stops that sign-in with the same guidance. These consumers add no credential file, terminal OTP, bypass, recovery command or change to chat/executor reconnect ownership.

An account-sign-in token has no cli_target_id, so computerRefForToken returns no computer and clientCwdFor preserves its host directory. Normal account policy still applies: this is not an administrator or computer-membership bypass. The token has no expiry, is revocable through the existing personal API-token owner surface, and survives TOTP browser-session revocation. Without a credential, chat uses exactly the same mode-aware browser flow as explicit login.

BrainClient.start never opens an executor socket: the device's single connection belongs to elowen connect. A start names no execution target: a paired credential is the computer it authenticates as, so the daemon binds a conversation opened by that device token to the computer's Project, before startup instructions are fixed, and answers 409 LOCAL_TARGET_OFFLINE while the computer's elowen connect is down. The TUI then exits before taking the terminal and elowen run prints start failed: with the connect advice; both exit with code 1, and no conversation is opened on the server. An explicit resume keeps that row's selection. There is no post-start selectProject call for creation.

BrainClient reports process.cwd() on every start and send. A paired credential authenticates with its device token, so the daemon uses that cwd for a conversation on this computer and ignores it for a conversation on another computer, which keeps its recorded directory (paired computers).

chat/picker.ts::projectFromArgument resolves /project only against the daemon's SwitchableProject listing, where a paired computer is an ordinary Project: its computer block carries the computer's name and whether its elowen connect is online, and its executionRef is { kind: 'local', projectId }. A computer's displayed name wins first, then every row resolves by exact slug and numeric Project id. Both TUI and headless pass the listed executionRef unchanged to selectProject, so choosing a computer posts its Project id. Rows are labelled computer.label, because a computer Project's slug ends in a short id nobody types, and described computer · <path> · connected|disconnected. A computer listed as disconnected stays visible and is refused where it is chosen: the picker prints the same connect advice LOCAL_TARGET_OFFLINE carries and sends nothing. /project Work laptop and /project laptop-a1b2 select the same computer; headless text and JSON project report its name. An unlisted argument is refused before selection.

The composer footer names a computer conversation by the computer too (projectLabel.ts::selectedProjectLocation). A status view carries the directory the daemon read on the computer but not the computer's name, so the chat reads the execution listing once at boot when the conversation already runs on a computer, and again on every /project load, and keeps the names by Project id (ChatState.projectLabels). Until the listing supplies a name the directory stands alone, and a disconnected computer appends · disconnected.

scripts/build-client.mjs --platform linux --arch x64 --node /absolute/node --ripgrep /absolute/ripgrep-15.2.0-x86_64-unknown-linux-musl.tar.gz --output /absolute/output builds the precompiled archive from the current package version. Its esbuild invocation ignores the root TypeScript configuration's declaration-only shared-package aliases and resolves the shared package's runtime exports; elowen-db alone points to its canonical TypeScript entry for tree-shaken shared values, without requiring a previous database-package build. The --node binary must match the requested platform and architecture (ELF, Mach-O or PE machine field), or the build fails. --ripgrep is the official ripgrep release archive for the target, which the build verifies against its pinned SHA-256 and ships as vendor/ripgrep/ with its licence files (platform.md, "Bundled tools"). Windows archives build on Linux: --platform win32 --arch x64|arm64 --node /absolute/node.exe, with node.exe taken unchanged from the official node-v22.x-win-x64.zip or win-arm64.zip, for example unzip -p node-v22.23.3-win-x64.zip node-v22.23.3-win-x64/node.exe > <dir>/node.exe. The package then holds runtime/node.exe and a one-line bin/elowen.cmd, which its package.json bin maps to elowen and elo (Linux and macOS keep the bin/elowen sh launcher and runtime/node). The launcher uses the npm cmd-shim form: a failing goto ends the batch file before node starts, so cmd never rereads a launcher an update replaced, and node's exit code is the command's. Every path inside a Windows package must stay within 122 characters, or the build fails: the deepest client directory, the update stage C:\Users\<20-character name>\AppData\Local\Elowen\client\lib\.elowen-stage-<id>\lib\node_modules\elowen\, takes 137 of the 259 characters MAX_PATH allows. Windows builds compile nothing. Darwin builds run on native macOS and invoke /usr/bin/cc from the Apple Command Line Tools to compile the descriptor-based kernel lock helper. The compiler is a host prerequisite, not an npm dependency. PI dependencies remain unchanged. The client contains the TUI, bundled Node, executable launcher, process watchdog and required rendering dependencies, without daemon/service/store modules. First authenticated startup from an extracted archive bootstraps the usual versioned package layout through installStandaloneClient, the same path client-install uses.

clientUpdate.ts consumes signed /licence/client/<platform>/<arch> metadata and version-pinned archives. It shares stageCoreSwap, apply/commit/restore, the inventory and retained releases with core. Its explicit install.json is read through src/shared/installInfo.ts; its lock uses updateLock.ts and the common kernel lock seam. Every installed candidate proves its bundled --version; a failed proof restores the previous release. A successful startup swap re-executes the stable package before any turn begins.

updateClientAtStartup(env, { offer, report }) is the startup boundary. clientBin.ts runs it through CliRuntime.beforeCommand(env, command) before every authenticated command, and elowen connect runs it before taking its lock. It first finishes an interrupted swap under the update lock and re-execs when that changed the package. Only chat passes an offer (offerUpdateInTerminal); run and api pass none and never reach the feed, and connect checks later from its own loop.

installClientUpdate(env, { prefix, offer, report }) is the one check-and-install path, shared by the chat and connect. The instance's latest route resolves to its running daemon version, sourced from LicenceServerDeps.version and fetched from the publisher as <version>.json. clientReleaseResponse owns that choice for both updates and first enrollment installation. A missing exact release returns 404 notFound, without consulting the publisher's latest. Publishing 0.30.4 therefore leaves a 0.30.0 instance serving client 0.30.0. installOfferedUpdate reads that signed descriptor and, for a newer version, calls the offer before anything is downloaded and outside the update lock, so an open question never blocks connect or another start. Then it takes the lock and installs the pinned archive, unless another process installed it meanwhile; when the chat and connect race, the lock decides and the loser only finds the result installed. The chat asks in the CLI's ui/prompts.ts::select picker ("Update now" or "Later", in the locale chat/prefs.ts::resolveLocale picks). "Later", Esc or a start without a terminal (where select cancels) starts on the installed client and asks again at the next start; "Update now" installs and re-execs. Connect accepts without asking, as described below.

src/licence/clientReleases.ts verifies strict signed descriptors and platform, architecture and version pins. Signed bytes are UTF-8 JSON of platform, arch, version, sha256, size in that order. The browser-safe contract returns a Uint8Array from TextEncoder; its bytes and signatures are identical to Node's UTF-8 Buffer encoding. clientPublicKey.ts commits the separate Ed25519 public trust key as CLIENT_RELEASE_PUBLIC_KEY_PEM; clientReleasePublicKey() constructs it with createPublicKey, matching the licence public-key pattern. Core and client builds use that same constant without source substitution or a runtime override. Tests inject freshly generated keys through the existing verifier argument or module mock. The daemon's human/session or personal-token /licence/client routes evaluate the instance licence, authenticate to the publisher, and cache size/hash-verified bytes privately. No instance licence credential reaches the browser or CLI. Invalid signatures fail explicitly. Offline or refused startup metadata is an error, not “up to date”.

Installation, background connection and uninstall

clientUpdate.ts::installStandaloneClient(env, host, { version, origin, materialize, report }) is the one install path of the standalone client. Under the prefix's update lock it recovers an unfinished swap, installs or upgrades through stageCoreSwap/applyCoreSwap/commitCoreSwap into clientPrefix(env, platform) when the given version is newer (an equal or newer install stays and only its recorded origin is refreshed), then publishes the command. clientPrefix is ~/.local/share/elowen/client from HOME, and on Windows %LOCALAPPDATA%\Elowen\client from LOCALAPPDATA (read without case through clientService.ts::windowsEnv, because a copied process.env keeps the stored spelling); a missing one throws. On Linux and macOS publishing links ~/.local/bin/elowen to <prefix>/bin/elowen and refuses any other entry at that path. On Windows the first install writes <prefix>\bin\elowen.cmd and elo.cmd, the package launcher's one-line form through lib\node_modules\elowen, and every install puts <prefix>\bin on the user Path: one Windows PowerShell call reads it from HKCU\Environment with DoNotExpandEnvironmentNames and its value kind, withPathEntry computes the new raw value (entries compared without case or trailing backslash, every other byte kept, null when present), and a second call writes it with the kind kept (ExpandString for a new value) and broadcasts WM_SETTINGCHANGE. The scripts are fixed one-line text without double quotes; values travel only in environment variables and the read answer as base64 of UTF-8, because console output is not. Uninstall removes exactly that entry through withoutPathEntry. The first-run bootstrap materializes the running package with a copy; client-install materializes the verified archive with archiveMaterializer, which runs tar from PATH, or %SystemRoot%\System32\tar.exe on Windows. clientService.ts::clientLaunch(dir, platform) is the one name of the bundled runtime (runtime/node, runtime\node.exe) and entry for the version proof, the re-exec after an update and the Windows background start. pathHint(env, platform) returns a label and a command only when the command directory is missing from this shell's PATH: export PATH="$HOME/.local/bin:$PATH", or on Windows a line that reloads $env:Path from the machine and user values.

An installed process finds its prefix through shared/installResolution.ts::packagesDirForCliPath, which splits the module path with the platform's separator (path.sep), so a Windows release path …\lib\elowen-releases\<version>-<id>\dist\… resolves like a POSIX one; its optional PlatformPath argument exists for tests that parse the other platform's paths.

On Windows the same swap changes three primitives in src/update/core.ts, all chosen by process.platform at the call. Directory links (lib\node_modules\elowen and the retained .elowen-backup-<id>) are junctions, which need no symlink privilege. Windows cannot rename over a junction, so the switch creates the new junction, removes the old one and renames the new one into place, under the update lock and after the durable applied record; a crash between the two steps leaves no link, and restoreCoreSwap, which the next install runs through its recovery, recreates it. Pruning at commit first renames an old release to .elowen-trash-<id>: a refusal with EBUSY, EPERM or EACCES means a running node.exe (an open chat) still uses it, so it stays intact with its marker and the next commit tries again; a successful rename means nothing inside is open and the trash is deleted. A trash directory that still cannot be deleted (EBUSY, EPERM or EACCES, as when an antivirus scanner opens a file right after the rename) is no release any more: the commit, already recorded, does not fail, every Windows commit first sweeps the .elowen-trash-<id> directories left before, and commitCoreSwap returns those it still had to keep. The client install reports each through its report channel: stderr at startup, the summary output of client-install, and in elowen connect its output and log. Any other deletion error fails the commit. Retained backup links are removed with unlink, never through the junction. Every swap rename goes through one helper that on Windows, and only for EBUSY, retries after 100, 500 and 2000 ms, because antivirus scanners briefly hold freshly written files open. POSIX keeps the atomic rename over the link and the direct recursive prune. For example, after an update with an old chat still open, elowen-releases holds three releases until a later update finds the oldest one free.

clientCommands.ts::runClientInstall is what the installer script (GET /cli/install.sh) runs. It takes ELOWEN_URL and ELOWEN_ENROLLMENT_TICKET and deletes the ticket from the environment before any child runs. The ticket travels only in JSON bodies: POST /cli/enroll/release returns the signed descriptor of the client's own version, checked by verifyClientRelease and the archive by verifyFileBytes (the updater's key and verifier, injectable as releaseKey in tests); a 410 names an expired or used command. A saved credential for the origin with a targetId is kept while GET /auth/me accepts it; a 401 or 403 re-enrolls through POST /cli/enroll with os.hostname(). Enrollment and approved browser pairing both require the shared strict CliDeviceCredential response { token, targetId, policy }. The client validates and saves the authenticated policy before publishing the credential. A retained pairing bootstraps a missing policy through the authenticated projection before registering the service. For example, rerunning a fresh install command on a paired laptop upgrades the package, keeps pairing and restarts the service without consuming the enrollment ticket.

clientService.ts owns the service manager behind an injected CommandRunner (on Windows also an injected Detach), so rendering and every call are unit-tested. Linux probes systemctl --user show-environment, writes ~/.config/systemd/user/elowen-connect.service, runs daemon-reload, enable, restart and then loginctl enable-linger <user> without root, reporting a refusal (the unit then runs only while the user is logged in). macOS probes launchctl print gui/<uid>, runs the account's login shell from userInfo().shell with -lc to read its PATH, then writes ~/Library/LaunchAgents/io.elowen.connect.plist with that PATH in EnvironmentVariables, boots out a loaded copy and bootstraps it. The shell command frames PATH with NUL bytes, so profile output cannot become part of PATH. Failed shell execution or an empty PATH fails before replacing the service definition or stopping a loaded service. The snapshot refreshes whenever the service is registered. A failed probe returns unavailable and the installer tells the user to run elowen connect; a failing manager call after a successful probe throws. removeClientService(host, prefix) reverses it and leaves lingering alone.

Windows has no user service manager, so the outcome there is run-key. registerClientService stops a running connect, writes the REG_SZ value ElowenConnect in HKCU\Software\Microsoft\Windows\CurrentVersion\Run with reg add … /f (argv from runKeyArgs, the value from renderRunCommand: "<SystemRoot>\System32\conhost.exe" --headless "<node.exe>" "<clientBin.js>" connect, paths through the package junction, because node.exe is a console program and would open a window at every login) and starts connect now through startConnect: detach spawns it with detached, windowsHide and no standard streams, in the home directory, and unreferences it. startConnect adds the prefix's bin to the started connect's Path through withClientOnPath (once, without case, under the spelling the environment uses), because the install writes that entry only to the registry and every shell connect runs inherits its environment; a connect started at login, or its update successor, already has it. Neither that start nor the Run key's has a console anyone reads, so on Windows clientConnect.ts::connectOutput makes connect own its log: every line it prints (the loop's output and the stderr reports) and the error that stops it are also appended, timestamped, to <prefix>\logs\connect.log through openConnectLog, the one owner of the rule that a log above 5 MB starts over when connect starts. runConnect installs one uncaughtExceptionMonitor for its lifetime. Fatal exceptions and Node's fatal unhandled rejections synchronously append the error and stack before Node exits nonzero; the monitor never resumes execution. Ordinary command-ending errors use the same stack-preserving log writer. Linux and macOS keep no such log; journald and launchd capture Node's fatal stderr. System programs (reg.exe, taskkill.exe, tar.exe, powershell.exe) run by absolute path under SystemRoot. Stopping never looks a process up by name: when connect.lock can be taken nothing runs; when it is held (KernelLockBusy) the PID in <prefix>\connect.pid, which connect writes right after taking the lock, is ended with taskkill /T /F /PID, and the stop waits up to 10 s for the lock to free, which also covers a process that ended before taskkill reached it. removeClientService stops it; uninstall removes the Run value through removeClientFromUserPath alongside its owned user Path entry. A crashed connect stays offline until the next login or elowen connect.

clientConnect.ts::runConnect is the service process. It requires a paired credential, finishes interrupted update recovery, holds <prefix>/connect.lock and starts the independent updater before policy bootstrap. computerPolicy.ts reads one bounded, strict non-secret cache under the existing client data root's computer-policies/ directory. Its filename hashes the instance origin and target ID; the stored record rechecks both identities. Authenticated enrollment, ticket, ready and policy-control consumers write through saveComputerPolicy, using the shared atomic durable JSON writer and kernel lock. Revisions cannot rewind or change contents without advancing. Cache files contain no bearer. Uninstall removes the selected device's cache only after its service stops, without deleting lock inodes.

A cold bootstrapComputerPolicy makes one authenticated GET /connection-policy with a 15-second deadline. Missing, invalid, corrupt, wrong-device or unavailable policy never becomes a built-in default. If bootstrap cannot obtain policy, the process reports the failure, pauses connection attempts and retains the updater until shutdown or update handoff. Authorization refusal instead stops the device.

localExecution.ts::connectLocalExecution sends the required literal x-elowen-client-protocol: 1, release and actual-shell headers alongside existing tunnel/PTY capabilities. The request body remains { targetId, cwd, home, platform, arch }; capabilities are not inferred from semver. It uses only the shared strict cliConnectionTicketResponseSchema: { ticket, expiresAt, socketPath, policy }. There is no legacy decoder. Socket open does not resolve connect: the client must receive a valid connection-ready with the authenticated target's identity and policy before the ticket's authoritative expiry. Expiry, cancellation, malformed control or wrong identity closes and cleans up that attempt. Executor work cannot start before readiness.

The same socket demultiplexes strict retry-policy controls before executor messages. Readiness binds the socket's authority/generation; a second ready cannot replace that identity, and closed attempts cannot publish late controls. Controls are neither command frames nor in-flight executor work. Newer policies are cached immediately; the canonical controller captures a policy for its episode, so this always-on process adopts the cached revision on its next process lifecycle rather than resetting a current attempt or announced delay. The shared createRetryController owns one long-lived-transport episode for the computer: indefinite retries are an explicit always-on exception to the finite model/request maximum. Timing remains canonical exponential, without jitter, capped at 60 s; only 60 s of continuous authenticated readiness resets progression. There is no private backoff or second maximum.

DeviceRevoked (401/403), removed credentials and service shutdown stop normally. HTTP 409 cli_protocol_required instead becomes typed ClientProtocolRequired: admission pauses without claiming revocation, and the updater stays active. Ping, pong, load sampling and the 75-second silence watchdog retain their independent liveness ownership; failed sampling is reported. See paired computers.

An independent 60-second update observer also runs while admission fails, during retry sleep, or while policy/protocol admission is paused. A live executor must have no operations or new command frames during its idle window. Only installedClientUpdate reporting a newer committed package authorizes handoff; an applied but unproven swap or an older package does not. Handoff aborts and awaits connection cleanup, then releases the connect lock before exit 75 on Linux or starting the Windows successor. No dispatched work is replayed.

clientConnect.ts::runUpdateChecks runs beside the loop with Claude Code's background cadence: the first check 10 s after start, then every 30 min, and 5 min after a failed one (reported, never fatal), so two checks are never closer than 5 min. A check that falls due while a command runs waits for the next minute without one. Each check calls installClientUpdate with an offer that always accepts, so a newer release installs silently, and the idle check above then restarts connect into it. For example, a laptop whose owner answered "Later" in the chat still runs the new release, connect and the next chat included, within about 30 min. Restart=on-failure and KeepAlive.SuccessfulExit=false restart 75 and crashes but not 0. Windows has no such manager: after runConnect releases the lock, finishConnect starts the successor through startConnect and exits 0 instead of 75, and connect first moves to the home directory, which the systemd unit and the LaunchAgent set and the Run key cannot. The bearer stays out of argv and of executor children (createLocalExecutor filters it).

One-time restart of an existing 0.30.6 client

The coordinated protocol upgrade has no admission compatibility path. An old 0.30.6 process can fetch and commit the new client through the existing update channel while its admission fails, but that old process checks update handoff only inside an admitted connection. Publishing archives alone is insufficient: latest follows the running core version. Wait for the updater to finish and the new package swap to be committed before this one-time device-local action:

  • Linux, as the same user who installed the client: systemctl --user restart elowen-connect.service.
  • Windows: sign out of the installing Windows account, then sign in again. The existing ElowenConnect REG_SZ value at HKCU\Software\Microsoft\Windows\CurrentVersion\Run starts the updated junction's runtime\node.exe dist\cli\clientBin.js connect through %SystemRoot%\System32\conhost.exe --headless. There is no scheduled task or Windows service to restart. Starting another connect before the old process exits is not a restart; the installation lock correctly refuses it.

The new client's disconnected update observer repairs subsequent handoffs. This migration does not authorize replay of interrupted commands or a server-side restart of an unreachable computer.

clientCommands.ts::runClientUninstall sends DELETE /cli/device (401 counts as already revoked), removes the service, the command link only when it is the client's (on Windows the user Path entry), the origin's credential through chat/token.ts::removeCredential and the prefix under its update lock. Windows cannot delete the runtime that runs the command, so scheduleClientRemoval detaches a hidden Windows PowerShell that waits for this process (Wait-Process) and deletes the prefix with [System.IO.Directory]::Delete, which removes junctions without following them; the PID and the prefix reach it in environment variables, and the command says the files go once it exits. HTTP 409 returns 1 before any local mutation and directs the owner to confirmed Project removal in Account > Computers. Network failures and other unsuccessful statuses also return 1 without stopping the connector or deleting its credential, policy, service or install, preserving reconnect for pending cleanup. Retry uninstall after Project cleanup succeeds and revokes access. The full server package's uninstall is a different command in serverCommands.ts.

Socket operation contract

elowen-plugin-shared/localExecution owns the command, operation, result, failure and message types in packages/plugin-shared/localExecution.d.mts. Core re-exports them for the Sandbox seam; the core executor (src/remoteCli/connections.ts) and the CLI use the same types. The runtime module exports the LOCAL_EXECUTION_LOST and LOCAL_TARGET_UNSUPPORTED error codes. No owner defines a parallel transport.

Every JSON frame is request, result, error or cancel with a socket-local id. Files carry the existing guestFiles operation/result payload and preserve its read and write chunking, versions and CAS semantics. process-start uses its request ID as processId. Started and output results have final:false; exit has final:true. File and stdin results have final:true, and errors are terminal. Stdout, stderr and the existing fd 3 cwd stream carry base64-encoded byte chunks. Stdin operations carry base64 and an end flag. Cancel names the original request and kills its process group. Ordinary nonzero exits remain process-exit results. localExecutor owns the watchdog stdin pipe's error listener for the pipe lifetime: Node emits that event even after a write/end callback receives the error. A closed reader reported as EPIPE, EOF or ERR_STREAM_DESTROYED returns process_not_found, just like a process already removed from the map; createLocalProcessSession leaves the process verdict authoritative. Other stdin errors retain their code in the RPC error. This includes branch telemetry closing stdin after a short-lived Git probe. That probe is an argv command, so Windows launches it through cross-spawn, which uses cmd.exe when Git is absent; only shell commands use PowerShell.

On Linux and macOS the process watchdog (packages/plugin-shared/localProcessWatchdog.mjs) runs shell commands as /bin/sh -c in a detached process group that inherits fd 3, kills -pid on cancel or owner loss and again after the shell closes, so descendants die with it. On Windows it runs them through elowen-plugin-shared's internal powerShell.mjs, which also owns the client's reported shell (clientShell in src/cli/localExecution.ts). The executor starts the watchdog with windowsHide and every stream piped, which Node turns into a console without a window: the detached background connect has no console, so without it Windows would show a new console for every command. PowerShell and the programs it starts share that hidden console.

  • windowsPowerShell() resolves once per process: pwsh.exe on PATH, %ProgramFiles%\PowerShell\7\pwsh.exe, the Store alias %LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe (through readlink), %USERPROFILE%\.dotnet\tools\pwsh.exe, powershell.exe on PATH, then %SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe. The file name gives the edition (pwsh or powershell) sent in the ticket's shell header. PATH is searched only for .exe, because the shell is spawned without cmd.exe. Nothing found fails connect.
  • spawnPowerShell starts -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command <fixed bootstrap> with a hidden window and no detached group. The command never reaches argv: the wrapped script travels in ELOWEN_SHELL_SCRIPT, which the bootstrap reads, removes and runs with Invoke-Expression. A wrapped script over 32 000 characters is refused with code command_too_long before anything starts.
  • The wrapper sets UTF-8 output and plain-text rendering (the console and rendering steps are skipped in Constrained Language Mode; the whole prelude is skipped for a script that opens with using, which must come first). A script opening with param(, a named block or an attribute is dot-sourced as . { … }, because no statement may follow named blocks; its variables stay in scope, but only a native exit code, not a failing cmdlet, sets its status. The environment gets NO_COLOR=1, TERM=dumb (PowerShell colours error records otherwise) and PYTHONIOENCODING=utf-8:surrogateescape unless set. Its suffix keeps the exit status ($LASTEXITCODE, else $?) and writes (Get-Location).Path to ELOWEN_CWD_FILE, a file in a private temporary folder. After the shell closes the watchdog reads it, drops a BOM, accepts only an existing directory under a drive letter (no \\.\, \\?\ or network path yet), writes it NUL-terminated to its own fd 3 exactly as the POSIX trap does, and deletes the folder. The wire protocol is unchanged. A script that calls exit itself or ends in a terminating error reports no cwd, and a using script that also has named blocks cannot take the suffix at all.
  • Cancel or owner loss runs <SystemRoot>\System32\taskkill.exe /T /F /PID <shell> (by absolute path, because Windows looks a bare name up in the command's directory first) and then kills the shell if it has neither exit code nor signal. A normal exit kills nothing more. taskkill follows parent PIDs, so a process whose parent already exited (Start-Process) survives, and one that still holds the inherited output handles keeps the stream open until it exits. argv commands start directly with a hidden window and the same kill.

The chat TUI's !command (src/cli/chat/localShell.ts) uses the same launch on Windows, and WindowsProcessTreeTermination in chat/processTermination.ts the same kill for abort, timeout and output overflow alike.

The process request carries only the existing agent-shell marker, not a daemon environment or credentials. The client builds its child environment locally, removes its CLI bearer and applies that marker. Its startup cwd is a relative-path base, not containment. Local operations run under the user's operating-system account without a namespace or extra filesystem boundary.

The authenticated ticket request (body and version header) carries all connection metadata. There is no connect frame. Socket loss rejects unfinished work with LOCAL_EXECUTION_LOST; a dispatched mutation without an answer has mutationOutcome unknown. Requests are never replayed or redirected to the server, and a replacement socket cannot complete work admitted on the old connection. The core executor binds existing Terminal process handles to the actual socket and uses the same stream contract through the Sandbox runner bridge; Paired computers and the executor socket owns it. An invalid CLI reply closes the original connection and emits the declared cli.protocol_failed report. An approval failure denies that pairing request and emits cli.pairing_failed. Both use the core's redacted-sample reporting policy. The central problem catalog and licensing's fixed English, Czech and Slovak descriptions own those report codes; request payloads are not added to reports. CLI cleanup diagnostics reuse shared errorText.

Delegated read-only Bash uses the same buildReadOnlyBoundary and resolveToolPermission as ordinary delegated tools. Daemon RPC checks every durable ancestor boundary after its account, target, tool-owner and current-grant checks. Terminal's exact foreground cwd telemetry prefix comes from shared cwdReportingCommand and is removed before permission resolution; background Bash sends the original command. Explicit user denies and the non-destructive shell clamp apply unchanged, including the existing allowance for shell redirection. Read-only file mutations, argv launches and stdin payloads remain refused. Terminal's empty stdin EOF is allowed so a permitted foreground or background Bash can close input. This is permission parity, not filesystem confinement.

Before issuing a connection ticket, redeeming its upgrade, or dispatching each file or process operation, core resolves the original credential through ApiTokenStore.resolve and requires the same device token and a live human account; there is no plugin grant. Revoking the device in Account deletes its token and closes the socket at once. A refused credential uses the existing credential-loss path: close that original socket, abort parent/child pins, reject pending work and retain an unknown outcome for dispatched mutations. No request is replayed, and a revoked device cannot obtain a new ticket or redeem an already-issued one.

Production pauses against the socket's actual bufferedAmount. There are no stream credits, acknowledgements, durable request records, application heartbeat leases or extra operation deadlines. Existing tool timeouts remain authoritative.

createLocalProcessSession(startOperation, request, signal?) is the single process stream adapter for the core executor and Sandbox runner requests. It returns { start(), cancel() }, without account, pin, lease or prepared-execution metadata. start() runs once and resolves after process-started to { stdin, stdout, stderr, cwd, closed }. Consumers drain stdout/stderr and consume or resume cwd. closed returns the actual code/signal after queued stream writes drain; transport errors, including typed loss, keep their identity. cancel() aborts the original request and resolves only after its actual transport verdict, or rejects with that failure. The request callback remains the existing operation plus signal/onResult seam. No registered owner means no request callback and therefore no local execution.

The shared contract exports isLocalExecutionOperation and isLocalExecutionResult for the runner IPC request/result boundary. File branches reuse the structural guards owned by guestFiles; destination admission and policy remain at the existing transport. These guards are not installed on the CLI's server WebSocket frame dispatch path.