Navigation
ELOWEN · Documentation

Last updated: October 20, 2026

Browse documentation · Paired computers and the executor socket
Developer reference

Paired computers and the executor socket

A computer paired with the Elowen CLI is a personal API token AND a Project: pairing creates a projects row with execution_kind 'local' whose computer_target_id names that token's device, and the two are deleted together (Projects and execution targets). A conversation targets the computer by its Project ({kind:'local', projectId}) and the device is resolved from the row; the device id never appears in a ref. The token row in api_tokens carries the computer's opaque cli_target_id, the metadata it last reported (cli_metadata_json: cwd, home, platform (linux, darwin or win32), arch, the shell its Bash tool runs (sh, or pwsh and powershell on Windows) and the optional client version, validated by cliTargetMetadataSchema in src/shared/cliDevices.ts; shell and version come from request headers. Historical rows can lack a version, but new executor admission requires both headers; a row stored before the shell was reported holds sh) and cli_last_seen_at. ApiTokenStore owns these columns (createCliDevice, cliDevices, cliDevice, cliDeviceByTarget, cliDevicesForProjects, setCliMetadata, touchCliSeen, renameDevice); list() excludes device rows, so a computer appears in the Account "Computers" section through cliDevices, and on the Projects page through its Project row. The Project deletion coordinator owns token retirement. It retains the credential while files need cleanup, including when the computer is offline, so the same computer can reconnect. The internal CliConnections cleanup methods never delete the Project row or revoke its token. There is no second device table and no credential at rest besides the token hash. Direct device-token revocation through DELETE /auth/api-tokens/:id or DELETE /cli/device returns HTTP 409 {error:'project_deletion_required',projectId} without disconnecting. The account computer UI uses the core Project removal flow, not token revocation. Repository revoke rejects device tokens before mutation; internal revokeDevice requires the Project association to have been detached by successful deletion.

Visibility

The computer Project's explicit membership is its visibility grant, for administrators too. Pairing assigns the owner; sharing assigns another account to that same Project. There is no separate device-sharing table. The repository's ordered and canAccess readers own this rule; canExecute uses that same active authority. GET /projects, the Project tool, register summaries and browser pickers consume these authorized rows. Plugin HTTP and WebSocket requests receive the same finite auth.accessibleProjects set; only account-free open mode receives null. Project-scoped live events recheck canAccess at delivery, including after sharing is revoked. Activity history uses the same visible Project set. Recipient-scoped alerts remain account-private.

canManage grants computer management only to projects.creator_user_id. Membership, renames, metadata and disconnect operations use this authority; administrators assigned as members receive execution access without management rights. GET /projects?view=assignable and GET /users/:id/projects filter administration pickers, assignment rows and counts through the same authority. Account deletion remains an independent lifecycle cascade. An account that still owns a computer must first finish the confirmed Project deletion; the account DELETE route refuses before teardown, and the repository transaction also refuses direct device-token retirement. Deleting another member removes only that account's membership.

Executor socket

src/remoteCli/connections.ts (createCliConnections) implements CliConnections (src/remoteCli/types.ts). The authoritative daemon builds exactly one in buildBrainCore and passes it to the HTTP layer as ServerDeps.cliConnections; a sub-agent runner builds none.

  1. POST /cli/connections/ticket (src/api/routes/cliConnections.ts) accepts only the device's own personal API token (credentialScope 'api') and exact target. It requires x-elowen-client-protocol: 1 (CLI_CLIENT_PROTOCOL_HEADER, CLI_CLIENT_PROTOCOL in src/shared/cliDevices.ts); client release numbers never imply this capability. An authorized device without that exact protocol receives HTTP 409 {error:'cli_protocol_required',requiredProtocol:'1'}, not a credential-revocation 401/403. Refusal logs its authorized target, device name and validated reported release locally, without a token, and neither changes metadata nor mints a ticket. The strict body is {targetId,cwd,home,platform,arch}. Release and shell headers are also required: x-elowen-client-version must match CORE_VERSION_RE, and x-elowen-client-shell must name the actual sh, pwsh or powershell. Missing metadata is never inferred from a platform or an older release. issueTicket rechecks the credential and reads the canonical policy before minting. Metadata is then recorded through setCliMetadata; a target no longer owned by that token answers 403 target_forbidden. The strict cliConnectionTicketResponseSchema answers {ticket,expiresAt,socketPath:'/ws/cli/executor',policy}. policy is the existing canonical RetryPolicy, including its revision; unreadable settings answer typed 503 instead of defaults. The core:cli one-shot payload carries protocol 1 and keeps the credential only in daemon memory for live rechecks. The instance's signed client update target remains /licence/client/:platform/:arch/latest, pinned to the running core version and independent of executor admission. The web BFF's scoped FORWARD_ALLOW in web/lib/proxy.ts carries the protocol, release, shell, tunnel and PTY headers through public /api requests; the daemon remains their validator, and arbitrary headers do not gain forwarding rights.
  2. /ws/cli/executor?ticket= is dispatched by the single upgrade owner src/api/pluginWebSocket.ts after the shared same-host Origin gate, then handled by src/api/cliWebSocket.ts. The ticket is spent on the attempt; its payload is parsed with cliTicketPayloadSchema. admit verifies the credential and that the device row still belongs to that token before the 101, and once more when the upgraded socket is adopted, because a revocation during the handshake found no socket to close. Frames are capped at 16 MiB, the same bound as plugin sockets.
  3. HTTP 101 is transport opening, not application readiness. After handlers/heartbeat, successful touchCliSeen, a fresh policy read and live credential/device/current-socket rechecks, the server sends {type:'connection-ready',identity:{projectId,targetId,authorityId,generation},policy} before waking work. Only then does the registry observe the computer as connected, so the Project's server observation reports transport: 'connected' with readiness: 'ready'; disconnected readiness stays unknown. The client validates this through cliConnectionControlSchema and matches its authenticated target; it waits for this frame within the ticket's epoch-ms expiresAt, not merely for WebSocket open. The registry consumes the process-owned RetryPolicySource, also supplied by buildBrainCore. Committed revisions send {type:'retry-policy',policy} only to current ready sockets; revision fencing prevents rewind and shutdown unsubscribes without disposing the shared source. These controls never enter executor dispatch, in-flight command counts or command-activity idle tracking.
  4. Work still uses the request/result/error/cancel vocabulary of elowen-plugin-shared/localExecution. Results are validated with isLocalExecutionResult; an invalid frame closes the socket and logs cli.protocol_failed. There is no older admission decoder or alternate protocol branch.

The recovery and workflow E2E paired executor consumes the built cliDevices protocol constants and ticket/control schemas, just like the shipping CLI. Its connect() resolves only after a target- and Project-matching connection-ready within the ticket lifetime; policy controls never enter the command executor. Concurrent replacement probes require at least one authenticated ready socket and accept only a server-ended (1001) loser before readiness, rather than treating both HTTP upgrades as admitted executors. Browser-pairing fixtures, including the protected-account doctor flow, supply the required enrollment policy and verify its persisted cache alongside the credential.

One socket per target: a new socket replaces the previous one and ends all of its admitted work. Native ping/pong every 30 s detects a dead peer; there is no HTTP lease poll. The client sends an empty heartbeat pong, then a separate type:'load' data message with the computer's load snapshot (below). Backpressure waits while the socket's bufferedAmount exceeds one guest file chunk. touchCliSeen stamps every connect and disconnect.

CliConnectionDeps.onConnected(identity) is the optional core wake callback after the authenticated ready control, only while that same socket is current and not aborted. Its ComputerConnectionIdentity aliases shared CliConnectionIdentity: {projectId,targetId,authorityId,generation}. Without a callback, attachment still sends ready and returns normally. buildBrainCore submits the identity to the existing computer-prerequisite coordinator; native continuation owners recheck authority, generation and account access after asynchronous boundaries. The callback grants no permission to replay admitted mutations.

Server connection observations

src/shared/cliDevices.ts owns the erased ComputerConnectionIdentity and ComputerConnectionObservation declarations beside the paired-device facts. The executor registry, instance SSE and Project projection share those declarations. Observation-only consumers do not import the plugin-facing CliConnections aggregate. The prerequisite coordinator requires only a connectionState(targetId) reader returning this observation or null; it does not depend on executor controls.

createCliConnections assigns one random UUID authorityId to its registry lifetime and an increasing numeric generation to each accepted socket. Together they fence boot/replacement identity; generation alone is not unique across daemon restarts. connectionState(targetId) returns the latest frozen ComputerConnectionObservation: identity plus connected, per-target increasing sequence and epoch-millisecond observedAt. It returns null before an observed socket and after registry shutdown. These are server observations, not remote retry reports: disconnected counters, countdowns and presumed reconnecting state cannot be inferred from them. Registry ComputerFacts is explicitly Omit<NonNullable<ProjectView['computer']>, 'connectionState'>: raw facts do not pretend to contain the public canonical connection projection. That mapping belongs to the authorized Project/computer presentation owner, not this registry.

Minimal observation is subscribeConnectionState(listener) followed by a synchronous connectionState(targetId) read. Subscription delivers future transitions synchronously, without replay, and returns an idempotent disposer. The caller authorizes the Project before exposing facts; subscribing confers no Project access. Sequence is in-memory within this registry, not globally durable. A new daemon lifetime requires a fresh snapshot. The connected transition follows successful authenticated connection-ready delivery and its current-identity check, after handler/heartbeat installation, last-seen persistence and the final policy read. The same Connection.policyRevision marker selects ready sockets for public connected facts, turn bindings, terminals and publication tunnels; a pending socket is offline to these consumers and cannot dispatch executor work. The wake callback follows this transition and rechecks current identity. An asynchronous wake/authorization consumer must match both authority and generation before proceeding.

Replacement ends the old socket and observes disconnected before observing the new ready connection. The pending replacement map entry is installed before waiting for old persistence, so delayed earlier attachment cannot overwrite a newer socket or wake its work; revoke, close and stop can still cancel pending admission. A socket that ends or fails its ready send before readiness publishes no connected or disconnected transition. Old pins never transfer to the successor: disconnect aborts owned work, closes tunnels and leaves dispatched mutations' outcomes unknown rather than replaying them. Registry stop emits final disconnect transitions, then clears listeners and observations. Server readiness observations remain distinct from the device's own retry controller; no remote retry count or deadline is fabricated.

Clean-cut client rollout

The protocol change has no compatibility admission. An already-running older client can download and commit a signed client update through the unchanged authenticated release channel, but its old process only notices a successor after successful executor admission. Therefore this rollout requires a one-time local restart of that computer's connection process after its updater commits the matching client release. Publishing or downloading an archive alone is not a handoff. New clients use the explicit protocol and authenticated ready controls; there is no version-based exemption for an old process.

Interactive computer terminals

The Terminal plugin opens a computer shell through Sandbox's openShell and core's localExecution.openShell. Core requires the authenticated API account, explicit computer Project membership and a live socket. It mints a private terminal:<uuid> binding; ordinary turn bindings cannot dispatch PTY operations. Administrators need membership too. Terminal's routes additionally require a signed-in web session and refuse personal API tokens and agent credentials.

The client advertises x-elowen-client-pty: 1 (CLI_CLIENT_PTY_HEADER). The BFF forwards it and the one-shot executor ticket carries ptyV1:true for that socket. Missing capability returns LOCAL_CLIENT_OUTDATED; an offline computer returns LOCAL_TARGET_OFFLINE. Neither case dispatches work. Reconnection never takes over an admitted shell.

The existing request/result protocol carries pty-start with cwd, cols, rows and the account's agentShell marker. It streams pty-started and pty-output with final:false, then pty-exit with final:true. The start request id becomes processId. pty-input, pty-resize and pty-close name that id and answer once; cancel names the original start request. Byte payloads are canonical base64 capped at 64 KiB; dimensions stay within 2–500 columns and 2–200 rows. Input operations serialize, and writableLength() reports their queued bytes.

Each pty-output frame consumes the computer's single output credit. The daemon sends pty-output-ack {processId} through the same request/result protocol only after its output stream consumes the frame; that request answers with {kind:'pty-output-ack'} before the next output frame is admitted. The executor serializes stdout and stderr behind this credit and stops reading its watchdog pipes while waiting, propagating backpressure through the Linux helper or Windows ConPTY pipe. The daemon retains at most one unread 64 KiB frame. A second unacknowledged frame or an oversized payload fails the terminal with local_protocol and cancels its original request; bytes are never silently discarded. Cancellation and socket shutdown release a waiting receipt so cleanup cannot hang. This is the coordinated unreleased ptyV1 contract; the daemon and client must update together, with no unacknowledged-output compatibility path.

Each shell holds its original socket until exit. Every input and resize rechecks membership and the device credential; membership revocation cancels the admitted shell, and socket loss rejects it with LOCAL_EXECUTION_LOST without replay. Daemon restart loses sessions. The child receives the computer user's own environment, git configuration and gh login, with the device token removed. No daemon GitHub credential travels to the computer.

Publication tunnels

The executor ticket handshake advertises x-elowen-client-tunnel: 1 (CLI_CLIENT_TUNNEL_HEADER). The web BFF forwards it and the ticket carries tunnelV1:true in daemon memory. A missing flag means an older connected executor: publication authorization and binding throw LOCAL_CLIENT_OUTDATED with status 409 and an instruction to update the CLI. Support is never guessed from a version. Offline computers can retain bindings; each accepted visitor connection closes immediately until a capable executor reconnects.

LocalTunnelMessage in elowen-plugin-shared/localExecution adds three frame types alongside ordinary request/result/error/cancel frames:

  • {type:'tunnel-open',id,port} opens literal 127.0.0.1:<port> on the computer. Ports must be integers from 1 to 65535. Host fields are refused; the client performs no DNS lookup. The ready reply is {type:'tunnel-open',id,ready:true}.
  • {type:'tunnel-data',id,base64} carries at most 64 KiB of decoded bytes. {type:'tunnel-data',id,received:true} acknowledges the destination socket write. Each direction has one outstanding frame. createLocalTunnel shares these semantics between daemon and client, so a slow TCP destination also slows the remote source. Daemon sends retain the executor WebSocket buffer gate.
  • {type:'tunnel-close',id,graceful?:true,error?} ends the connection. Graceful EOF is sent only after the last source frame is acknowledged; its receiver waits for pending destination writes before ending the socket. A close without graceful:true, an error or an explicit release destroys the socket immediately. Invalid open requests answer invalid_tunnel; local connect failures answer LOCAL_TUNNEL_FAILED. There is no replay on another executor connection.

Core's localExecution.projectPublicationAuthorize({project,accountUserId}), projectPublicationBinding({project,accountUserId,publicationId,port,socketPath}) and projectPublicationRelease({project,publicationId,socketPath,retire?}) accept local Project refs. Sandbox remains their only consumer; Sites calls Sandbox's existing publication methods without a socket path. Sandbox derives the trusted path with its canonical publication socket helper; core does not derive names or know the Sandbox directory layout. Binding returns {generation:1,socketPath}. The daemon listener lives in Sandbox's Project broker directory, with directory mode 0700, socket mode 0600 and the same hashed publication socket name as managed Projects. Every accept checks the saved publishing account with canExecute, verifies the device credential and pins one new tunnel to the current connection.

Sandbox persists local intent in its existing publication rows, including executionKind:'local', port and accountUserId; core adds no store. Reconciliation restores listeners before managed-machine inventory. Releasing a binding closes the listener and every admitted tunnel. Member removal calls Sandbox's revokeProjectAccess under each publication's mutation lock before process cancellation and again through SQL afterCommit after membership removal commits. The second close also removes listeners restored by reconciliation while membership was still valid; durable intent remains. Core's revokeMember only cancels process work and turn bindings. Deleting an account closes its publications even on computers paired by someone else. Disconnect closes tunnels while leaving the listener ready for a reconnect.

Runner publication calls use the existing sandbox.local reverse RPC with daemon-only publication-authorize, publication-binding and publication-release operations. The daemon stamps the active computer and account, requires a granted Sites tool and refuses read-only scopes before calling the same Sandbox methods. These intents never reach the computer executor and create no runner-owned listener or durable writer.

Editor and Project file access

Registry plugins keep using ctx.control('sandbox'). Its projectFiles({project: ManagedProjectRef | LocalProjectRef, accountUserId, operation, expectedGeneration?, workspaceId?, startIfNeeded?}) accepts a computer Project without a conversation session. Sandbox delegates local refs to the core localExecution.projectFiles implementation, which takes a fresh daemon binding, checks canExecute for the acting account and forwards the operation through connections.localFiles. The binding is released in finally. Every subsequent local operation rechecks membership. Writes use the same device file-edit counter as agent writes; chunks count once at commit. Editor lists one directory with walkGuest(..., {maxDepth:0}). Reaching this requested depth is a complete listing, even when a child directory contains files. Local clients must not inspect those descendants or mark that response truncated. Entry-budget exhaustion still reports truncation.

Editor commands use the existing sandbox.runCommand with leaseKind:'editor' and the local Project ref. Outside a turn core owns a temporary binding until command settlement or lease release. The editor uses the guest-file operations every paired client already serves. Offline admission returns LOCAL_TARGET_OFFLINE; connection loss retains LOCAL_EXECUTION_LOST and any unknown mutation outcome. Non-member administrators receive local_target_forbidden with status 403. None of these becomes an empty file list.

Core's Project Git overview and commit pages bind localGitReader to that account and computer for the request, then release it. Project facts expose the device-reported cwd and fileSystemRoot for editor captions. These are paths on the computer, never daemon paths. The existing shared guest-file protocol owns chunking, versions and upload handles; this seam adds one authorization and binding per file operation and no inference request.

Credential verification

The credential is checked at ticket issue, at upgrade and before every dispatched operation: ApiTokenStore.resolve(token, now) must return the same token id and account, and that account must still exist as a human. A refused credential stops the connection before anything is sent, so revocation, regeneration of the secret, account deletion and an account that stops being human all end local execution at the next operation at the latest. Confirmed Project deletion calls completeProjectDeletion only after file cleanup. Direct device and account-token DELETE routes refuse paired credentials with project_deletion_required; DELETE /cli/device still rejects other credential kinds with 403. Account deletion must first remove its owned computer Projects through their confirmation flow.

Local execution control

Core owns the localExecution control. buildBrainCore registers connections.control with registry.registerHostControl('localExecution', ...) in the authoritative daemon only, beside the other host controls; CONTROL_AUTHORITY_CONSUMERS keeps Sandbox as its only consumer, and Sandbox still declares consumesControls: ['localExecution']. Core callers resolve Sandbox, never this control directly (see Projects and execution targets). Sandbox's daemon branch treats a missing owner as a broken host and throws; its runner branch keeps observing the inherited turn and forwards operations through host RPC (sandbox.local), unchanged.

  • resolveLocalTarget takes a computer Project and reads its device (src/remoteCli/devices.ts, computerDevice then apiTokens.cliDevice). resolveLocalTarget authorizes the ACTING account first with UserProjectsRepository.canExecute(userId, projectId): membership, so an administrator who was never added to a computer is refused local_target_forbidden exactly like any other non-member, while a member works through the socket the pairing owner's elowen connect holds. A device without reported metadata is not a target, and a Project whose device is gone answers local_target_unavailable. The LocalExecutionTarget it returns (src/plugins/environmentTypes.ts) carries the computer's Project id beside its device id and the stored platform, arch and shell, so a consumer such as Terminal knows whether the computer runs sh or PowerShell; parseDelegatedTurnRequest validates the same fields and the Project id when a runner receives the target. Authorized history projections resolve the conversation owner's device, or its delegated contribution account's device, independently of the viewer. A revoked device retains the stored history and location with connected:false; observation does not admit execution or recreate the deleted device.
  • computers(projectIds) returns a Project-keyed map of the facts a Project view and every picker show: targetId, the device name as label, connected, lastSeenAt, platform, arch, clientVersion, the device-reported cwd with its fileSystemRoot and the latest load. Callers authorize the Project ids first. apiTokens.cliDevicesForProjects reads them with one join that retains the pairing-owner predicate, then the socket map supplies live connection and load facts. Missing devices are absent; empty input performs no SQL. ProjectService.list and selectableProjectTargets each fetch one request-local batch without caching token rows across requests. Pickers project only label and connection status. This display seam grants no execution authority. isComputerConnected(projectId) is the same question without the rest, for a polled read. describeProjectDeletion(projectId, paths) captures canonical source/target receipts using existing file stat operations with symlink following. Offline receipts have no canonical identity until reconnection and renewed confirmation. Home, filesystem root, shared .elowen, client storage and their ancestors are preserved rather than making Project deletion impossible. On Windows, the existing process transport reads the connector's inherited LOCALAPPDATA with a literal, profile-free PowerShell command and strict JSON/exit validation; custom installation locations are not guessed. No client protocol change is required. A dangling source after completed deletion is acknowledged only when the pinned canonical target is also absent; it never grants authority to remove a different target. deleteProjectFiles(projectId, receipts) is the daemon deletion coordinator's internal file authority, deliberately absent from the plugin localExecution control. It requires the DB Project lifecycle deleting, fences new requests, aborts turn bindings and awaits process and pending-request cancellation. It uses the original socket's file protocol, not ordinary canExecute, which denies inactive targets. Every non-preserved receipt is resolved again before any mutation; a different target blocks cleanup. Only the confirmed absolute canonical destination is removed recursively with its current CAS version (including pre-existing directories), then statted again to prove absence. A source alias never becomes the mutation path. Offline and unknown-mutation failures reject; the token and socket remain available for a later coordinator pass. The coordinator calls completeProjectDeletion(projectId) after every selected participant succeeds: it releases publications and disconnects, without changing core rows. When deleteFiles is false, no working-directory file phase or path-inspection request is scheduled; publication release and disconnect still run. Durable finalization remains the coordinator's next transaction. Renaming a computer is its device token's name, written by renameComputer(projectId, name) through ApiTokenStore.renameDevice; PATCH /projects/:id reaches it only for the computer's owner, and only after every other rule of that request has passed. revokeMember(projectId, userId) aborts every binding that account holds on that computer, leaves the socket for the remaining members, and cancels the processes it still has running there: a turn binding is released when its turn ends, so a background service outlives the pin and prepareExecution records each admitted process by Project and account until it exits. Every operation of a prepared process is re-authorized against canExecute before it is dispatched, so a removed member's stdin and output stop even while a process is still alive. ProjectService.prepareRemoveMember checks the repository's owner-only canManage rule BEFORE calling it, keeps its in-transaction recheck, and runs the teardown before the membership row goes, the same order the managed teardown uses.
  • bindLocalTurn pins the actual connection object for a session; a child binds through its parent's pin. Each binding records its Project and its ACTING account (never the token owner), and its abort signal is the connection's lifetime AND that account's membership. A replacement socket can never complete, redirect or replay older work. A fresh owner turn (no existing pin, no parent) for a target without a live socket throws LOCAL_TARGET_OFFLINE (status 409): the computer's elowen connect service is not running. A pin or parent pin that is gone or aborted stays LOCAL_EXECUTION_LOST, because work was admitted on a connection that then went away.
  • localFiles, localRequest and prepared processes dispatch only on the pinned connection. A read-only turn (currentAccess().readOnly) cannot send a mutating operation (read_only).
  • prepareExecution supports files, terminal, mcp, lsp and editor leases; anything else answers LOCAL_TARGET_UNSUPPORTED. Its CliConnectionDeps.agentShell(): Promise<string> callback resolves the turn's ELOWEN_AGENT_SHELL marker from asynchronous account authority. Preparation awaits that marker before constructing createLocalProcessSession; a failed lookup rejects without dispatching an operation.
  • Terminal's ListProcesses and ProcessOutput read the existing process registry's metadata and buffered output under the captured account and session ownership checks. They do not heartbeat a local handle's original connection, so an exited process remains readable after disconnect/reconnect and one stale transport cannot reject the whole list. Managed membership/generation checks still apply to managed handles. Start, stdin and cancellation of a live local process continue through its original transport; no stored-output read dispatches I/O or grants access to another account.
  • Files' PDF Read executes Poppler on the computer and transfers the initial bounded classification block plus the requested text or rendered page images. It uses the computer's content-backed version instead of downloading the full PDF to hash it on the daemon. After conversion a fresh target stat must match the opening version. A complete visible render emits fileVersion; partial or omitted pages emit no mutation baseline. Read-state replay keeps that version under the exact target and normalized path, and Write/Edit compare a fresh version before the existing target CAS. Local Glob missing-path errors never inspect a server directory for sibling suggestions.
  • Losing the socket fails admitted work with LOCAL_EXECUTION_LOST; a mutation already dispatched carries mutationOutcome: 'unknown' and must not be replayed. createLocalProcessSession.cancel does nothing once the original request settled and treats a lost connection during cancellation as terminal cleanup. The original error remains on start or closed. The same adapter serves daemon commands and Files' Grep, Search and PDF commands, so their cleanup does not wrap a disconnect in an untyped AggregateError. Branch telemetry recognizes the offline computer, and Files preserves the typed failure for turn handling. Genuine cancellation failures while the connection is live still propagate.
  • A conversation works in the directory the CLI was started in on that computer, recorded per conversation; a recorded directory that no longer exists there refuses spawn and turn admission with LOCAL_WORKDIR_MISSING (Projects and execution targets).
  • Only that computer reports the directory. /brain/start passes the authenticating apiTokenId and /brain/send its apiToken, and ConversationLifecycle.clientCwdFor uses a client cwd for a local conversation only when that token is the device token of the conversation's computer (the Project's computer_target_id, resolved through computerDevice; the acting account plays no part, because the socket belongs to whoever paired the machine). The same token decides where a NEW conversation opens: a start authenticated by a device token and naming no target binds to {kind:'local', projectId} of that computer (computerRefForDeviceToken), never to a server Project. Another computer's CLI, a plain personal token or a login session leaves the recorded directory untouched: no stamp, no spawn there, and the turn works in the recorded directory. A device-token request never supplies cwd to a host or managed target, including later starts and sends after automatic recovery; other clients keep their host cwd behavior.
  • A Windows computer keeps its own path rules on the server. localTargetPath(target) (elowen-plugin-shared/localExecution) answers path.win32 for a win32 target and path.posix for every other one, and it is the only way the server resolves, joins, splits or compares a local target's paths: Files (managed.mjs resolve, ListDir, Glob, Grep and Search rows), Terminal (cwd and the reported cwd), the guest writer's parent directories (writeGuestFile/makeGuestDir paths), the recorded and placed working directories (localClientDir, delegatedWorkDir), instruction files and modules (the ContextFileSource of contextFiles.ts, whose within gives a module its /-form relPath) and the daemon's startup-instruction RPC. C:\x, C:/x, UNC and relative paths resolve as Windows resolves them and are shown in the native form; a drive-relative C:foo is refused, because the computer's per-drive directory is unknown here. The shared resolveLocalPath refusal names the drive-relative input and asks for an absolute path, so Files and transfer callers give an actionable error before any I/O. A recorded directory must be fully qualified (a drive or a UNC share). The read-before-write key of a Windows file is its normalized, lower-cased path (NTFS names are case-insensitive), and a Read result on any local target carries platform so a transcript replay keys it the same way. Managed guests and Linux or macOS computers keep their POSIX behaviour unchanged.
  • Terminal sends a PowerShell computer (shell pwsh or powershell) the raw command: the client wraps it and reports its cwd on the same cwd stream. cwdReportingCommand's POSIX trap is added only for sh, and the daemon's read-only RPC check strips that prefix only from an sh computer's command, so a PowerShell command is judged whole by the existing Bash permission rules, which understand only POSIX syntax; no PowerShell-specific refusal exists.
  • Sandbox, the owner of resolveLocalTarget, tells the model after every user message of a local turn which computer runs the tools, its OS and the shell behind Bash (registerText('turn-context', …, { placement: 'after-user' }), rendered by localTargetContext in plugins/sandbox/lib/localExecution.mjs), for example Execution: your computer "Work laptop" (Windows x64). The Bash tool runs Windows PowerShell 5.1 there: use PowerShell syntax; && and || are not available, use ; if ($?) { ... }. Paths look like C:\Users\.... The line is empty on any other target, follows runtime-context's lines because plugins load in name order, and a target the daemon cannot describe fails the provider, which the turn logs as brain.turn_context_provider_failed. No other plugin needs the Sandbox control for it.
  • Routes that open or continue a conversation map LOCAL_TARGET_OFFLINE, and the work-dir resolver's LOCAL_WORKDIR_MISSING, to HTTP 409 { error, code } through sessionRouteError (see the HTTP API). The web shows a localized sentence for each code and the CLI chat prints what to do. Existing owner conversations whose computer is offline instead use the daemon's recorded default-Project switch before opening or a fresh turn, so both clients hydrate the server-owned history normally. The same cwd event carries offlineTarget and names the offline computer and the new server target. Reconnection never switches back; /project or the web Project menu does.

Usage card data

Account > Computers shows one usage card per computer from three sources, each with one owner:

  • Turns, errors, tokens, cost. The settled-turn counter usage_by_origin carries the execution target in its key: billSettledTurn resolves a conversation on a computer to that Project's device id and writes the turn into that computer's bucket, whichever surface started it, and counts a failed run as one error, including a run whose overflow recovery gave up without a terminal agent_end, which adds that error and no turn, tokens or cost (see "Activity rollups" in persistence). There is no parallel turn or cost counter.
  • Commands and edited files. request() counts an operation once it is on the socket, through ApiTokenStore.countCliActivity into cli_device_activity_days: process-start and pty-start are a command; write, write-commit, remove, rename and copy are edited files. Reads, mkdir (a write that creates its parents is one file), upload chunks and stdin count nothing; work refused before dispatch counts nothing. Only counts are stored. A failed counter write is logged locally and never fails the dispatched work. The daemon's own read of a conversation's branch for GET /brain/status is not counted: CliConnections.prepareDaemonRead dispatches its git process exactly like a turn's, under the conversation's binding, but without the count, so a status poll never shows up as a command the user or the agent ran. It is core only and absent from the localExecution control, so no plugin can run uncounted work (see Projects and execution targets).
  • Load. elowen connect answers each 30 s ping with an empty pong and then a typed data message, validated by cliLoadMessageSchema: {type:'load', cpu, cores, mem:[used,total], disk:[used,total], io:{readBytesPerSec,writeBytesPerSec}, network:{rxBytesPerSec,txBytesPerSec}}. The data frame has no 125-byte control-frame ceiling. CPU is the busy share since the previous ping, with its baseline taken at connection start; memory is totalmem - freemem and disk is statfs of the home volume. Linux IO and network counters use elowen-plugin-shared/computerMetrics, the same physical-disk, non-loopback parsers and rate helper as the host reader. Missing or reset counters have null rates, while a zero delta is a measured idle rate. Windows starts one hidden, long-lived child using windowsPowerShell(): every 30 s it writes one JSON line of raw Win32_PerfRawData_PerfDisk_PhysicalDisk _Total and Win32_PerfRawData_Tcpip_NetworkInterface counters. Names are not localized. src/cli/windowsMetrics.ts validates lines and computes deltas; failures reach stderr and stale rates become unavailable. Closing the connection reaps the child. macOS rate collection is outside this contract. The daemon keeps the latest snapshot in memory until disconnect and appends at most one sample per minute under computer:<projectId> to the subject-aware core history. Old-client pong payloads are ignored.

GET /projects supplies computer facts through ProjectView.computer. Its load uses CliDeviceLoad, declared in the import-free src/shared/wireContract.ts: { at, cpu: { percent, cores }, memory: { usedBytes, totalBytes }, disk: { usedBytes, totalBytes }, io: { readBytesPerSec, writeBytesPerSec }, network: { rxBytesPerSec, txBytesPerSec } }. The daemon preserves its receive timestamp. GET /projects/:id/resource-history reads this computer's stored samples for Project members through canExecute; a non-member administrator is refused too. Its response is the shared resource-history shape, with no computer-specific chart protocol. GET /cli/devices/:targetId/stats?from&to (browser session, the token stats window rules and API_TOKEN_STATS_MAX_DAYS) merges the two counters per UTC day into { from, to, totals, days }, CliDeviceStats in src/shared/cliDevices.ts. A member of the computer's Project may read it, not only the account that paired it, and the settled-turn side is scoped to THAT account (usage.targetDays takes the account, so a shared computer never sums its members' turns into one figure). Revoking the computer deletes its dispatch counters; its rows in usage_by_origin stay as origin spend and are no longer readable through a device.

src/shared/cliDevices.ts also owns CliDeviceCounters, including nullable costUsd. Browser consumers import CliDeviceStats from web/lib/types.ts through export type, which also derives CliDeviceDay as CliDeviceStats['days'][number]. Counters remain canonical but have no separate web export; the browser test daemon uses CliDeviceStats['totals'] for its totals. For example, elowenClient.cliDeviceStats and useCliDeviceStats use the shared response while ComputerUsage consumes its day projection. These erased imports add no browser runtime dependency on the canonical module's validators and perform no work; an empty response retains days: []. web/tests/lib/dtoMirror.test.ts pins both web names to the canonical types at typecheck time.

Windows notebook verification

On Windows with the released client, start elowen connect and wait at least 65 seconds. In Task Manager, confirm one hidden PowerShell child of that connect process, with no new child per ping. Generate disk writes by copying a large file and network traffic by downloading a file. In Projects, verify CPU, Memory, Disk, IO in MB/s and Network in Mb/s; IO and Network have absolute figures without arcs. Open each ring, select IO and Network together, and wait for a second minute of history. Compare the directions with Task Manager's disk and network activity. Stop connect with Ctrl+C and verify its PowerShell child disappears. Reconnect and verify one new child and a fresh baseline, with no spike from the previous connection. Run this check with PowerShell 7 available and again with only Windows PowerShell 5.1 discoverable. Automated tests validate JSON, deltas, hidden-child creation and cleanup; they do not substitute for this real CIM check.

Lifecycle and limits

Sockets and tickets live in daemon memory. At shutdown the daemon closes every executor socket after the pause checkpoint (beforeExit in src/daemon/bootstrap.ts); the computer's client reconnects after the restart and gets new tickets. A ticket lives 30 s. Core publishes live, Project-authorized computer-connection observations through its existing event bus. The Project view projects the registry snapshot; it never invents the disconnected computer's local retry schedule.

Regression coverage: tests/api/cliAdmissionProtocol.test.ts (authorized protocol refusal, required policy, no metadata/ticket mutation on refusal), tests/remoteCli/admissionReady.test.ts (ready ordering, live credential/generation fences, committed policy revisions and subscription disposal), tests/remoteCli/computerMembers.test.ts (a member binds and dispatches, a non-member and a non-member administrator are refused, removing a member aborts their binding and keeps the socket, the Project view facts, and a Project deletion that takes the token with it), tests/remoteCli/computerStart.test.ts (a device token with no target opens on that computer), tests/remoteCli/connections.test.ts (pins, loss, streaming, read-only, credential rechecks, dispatch counters, load snapshots), tests/api/cliConnections.test.ts (tickets, the version header, upgrade, revoke through Account and through the device, account deletion), tests/api/cliDevices.test.ts (device list, stats route) and tests/cli/clientConnect.test.ts (client request metadata and typed load messages). Windows parsing and child cleanup are covered by tests/cli/windowsMetrics.test.ts.