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
ElowenConnectREG_SZ value atHKCU\Software\Microsoft\Windows\CurrentVersion\Runstarts the updated junction'sruntime\node.exe dist\cli\clientBin.js connectthrough%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.exeon PATH,%ProgramFiles%\PowerShell\7\pwsh.exe, the Store alias%LOCALAPPDATA%\Microsoft\WindowsApps\pwsh.exe(through readlink),%USERPROFILE%\.dotnet\tools\pwsh.exe,powershell.exeon PATH, then%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe. The file name gives the edition (pwshorpowershell) sent in the ticket's shell header. PATH is searched only for.exe, because the shell is spawned withoutcmd.exe. Nothing found failsconnect.spawnPowerShellstarts-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command <fixed bootstrap>with a hidden window and no detached group. The command never reaches argv: the wrapped script travels inELOWEN_SHELL_SCRIPT, which the bootstrap reads, removes and runs withInvoke-Expression. A wrapped script over 32 000 characters is refused with codecommand_too_longbefore 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 withparam(, 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 getsNO_COLOR=1,TERM=dumb(PowerShell colours error records otherwise) andPYTHONIOENCODING=utf-8:surrogateescapeunless set. Its suffix keeps the exit status ($LASTEXITCODE, else$?) and writes(Get-Location).PathtoELOWEN_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 callsexititself or ends in a terminating error reports no cwd, and ausingscript 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.argvcommands 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.