@kolu/surface-remote reference
Run a typed @kolu/surface agent on a remote machine over ssh. The package owns
provisioning (ship a .drv, realise it on the target), the long-lived ssh
subprocess (ref-counting, reconnect, backoff), and a reactive cell for the link’s
own lifecycle. It has two subpaths: @kolu/surface-remote and
@kolu/surface-remote/connection.
The session
A session is the reconnect appliance: makeSession is the transport-agnostic
loop, sshConnector the ssh plug you hand it. You own each session — there is no
shared pool; key your own map and destroy() them yourself.
| Export | Subpath | Role |
|---|---|---|
makeSession<C>(opts) |
. |
the reconnect/backoff/give-up/watchdog loop; returns a Session<AgentClient<C>>. Owns no transport — you supply connectOnce |
sshConnector<C>(opts) |
. |
the ssh Connector: per dial, resolve the .drv, provision, spawn ssh <host> <binary> --stdio, wire stdio to a typed client |
dialAgentOnce<C>(opts) |
. |
one-shot CLI dial: validate the baked { system → drv } env map, pin, probe, return { client, dispose } with the link already proven live |
Connector / Connection / ConnectError |
. |
the transport seam: dial once, return a Connection or reject with a classified ConnectError. Supply your own for a non-ssh transport |
Admit / AdmitVerdict |
. |
optional per-dial supervision hook: adopt / refuse(state) / replaced(reason). Omit and every connection is adopted |
sshConnector opts are { host, binary, resolveDrvPath }: host is any ssh
target ("localhost" short-circuits), binary the exe name inside the realised
closure, resolveDrvPath: () => Promise<string> resolves the agent .drv for the
target arch. dialAgentOnce’s dispose() tears down only its own session; it
is what kaval-tui --host and pulam-tui --host use.
The mirror pump
pumpRemoteSurface is the consume-side companion to makeSession: it pins the
session, loops over each successive client, and folds the agent’s frames into
your sink until the link dies.
| Export | Subpath | Role |
|---|---|---|
pumpRemoteSurface(opts) |
. |
pin → per-spawn mirrorRemoteSurface into makeSink() until each link dies, then await the next spawn |
SurfaceSink / makeSink |
. |
the sink role and its caller-supplied factory; members receive folded frames (cells.<n>.set(v), collections.<n>.{upsert,remove}) |
reServeSurface(opts) |
. |
policy-driven re-serve for one binding: owns the pump, returns { surface, router, done } |
makeClientCursor(session) |
. |
stateful cursor; cursor.next() blocks until a fresh client (post-reconnect) — the raw-loop primitive under the pump |
measureClockOffset(client, log?) |
. |
sample the bound process’s wall-clock offset (ms), RTT-halved; call once at hello. A probe failure is rethrown, never faked |
pumpRemoteSurface opts are { source, session, makeSink, liveProcedures?, liveClient?, onLinkDown?, connection? }.
makeSink takes { seq } (the spawn counter, not a client) and is rebuilt
every spawn, so no per-client state survives a reconnect. onLinkDown fires after
holders clear — the cue to drop any per-link local fold so the next spawn rebuilds
from a fresh snapshot.
reServeSurface’s policy (Record<member, "value" | "delta">) splits members:
a value member (cell, collection, value pulse) is held open and replayed
across an upstream drop; a delta member (byte or liveness stream) fails
through — the drop ends the downstream stream so the client re-subscribes
end-to-end. Holding open a byte stream is a compile error.
The fleet fan-out
| Export | Subpath | Role |
|---|---|---|
buildRemotePool(opts) |
. |
keyed Map<host, { session, handler }> a ?host= dispatcher reads; add/remove/reconnect/recheckAll + per-host socket eviction |
serveHostMap(map, pool, opts) |
. |
adapt a pool into a @kolu/surface-map MapRegistry: fuse membership with each session’s onState, project each state (folding clockOffset()) into an entry status |
buildRemotePool takes { buildEntry, persist?, controls? }; buildEntry(host)
returns the host’s { session, handler }. serveHostMap takes { linkFor, causeFor? };
linkFor(host, session) is built once per host and evicted on removal. Together
they are the server half of a host map — see
How to serve a map.
The connection cell
Connection health is state the remote agent cannot report (it can’t observe the
link to itself), so the parent authors it. This is the one graduated exception —
the cell ships browser-safe from @kolu/surface-remote/connection.
ConnectionInfo is the discriminated mirror of the session’s SessionState
sum, keyed on phase: the up arms (probing / copying / building — the ssh
connector’s provisioning phases, probing being the warm-check window where the
agent is looked up but nothing is shipped yet — plus connecting / connected).
Every arm carries two fields: a provenance-tagged log tail
({ source: "local" | "remote"; line }[]) and sinceMs, the server-stamped elapsed
of the CURRENT episode in milliseconds — a duration on the server’s single clock
(never a foreign epoch the browser would subtract), which the browser extends smoothly
with its own ticker. Both log and sinceMs are scoped to the current dial-to-up
episode and RESET on a down→up crossing, so a reconnect never shows a stale tail under
a fresh timer. disconnected adds error + cause ("network" unreachable /
"remote" refused); failed is terminal with cause pinned to the "remote" literal
(a network fault never gives up, so failed + network is unrepresentable). There is
no separate progressLines / remoteProgressLines — provenance is the source field.
| Export | Subpath | Role |
|---|---|---|
mirroredSurface(base) |
/connection |
the public composition path: adds the gate-closed, get-only connection cell and reserves the name (throws on a base that already declares one) |
connectionCell / ConnectionInfoSchema / DEFAULT_CONNECTION |
/connection |
the exported fragment — read to seed a store or assert the shape, not to spread yourself |
ConnectPhase |
/connection |
the up-but-not-yet-connected phase subset a connect/progress UI narrates — Exclude<ConnectionInfo["phase"], "connected" | "disconnected" | "failed"> (i.e. SshProv | "connecting"), the ONE vocabulary a UI’s exhaustive switch/Record keys on so a new provisioning phase fails to compile until handled (never a silent hand-listed copy) |
LogEntry / LogEntrySchema |
/connection |
one { source, line } log-tail entry — the browser type of a SessionState.log line |
pipeSessionStateToCell(session, set) |
/connection |
the node-side pump that drives the cell off session.onState (pumpRemoteSurface wires it for you when the mirrored surface carries connection) |
A mirror, end to end
const src = mirroredSurface(base);
const fragment = implementSurface(src, {
channel: inMemoryChannelByName(),
cells: {
load: { store: inMemoryStore(DEFAULT_LOAD) },
connection: seedConnectionCell(),
},
collections: {
processes: {
readAll: () => new Map<Pid, Proc>(),
upsert: () => {},
remove: () => {},
},
},
});
void pumpRemoteSurface({
source: src,
session,
makeSink: ({ seq: _seq }) => ({
cells: { load: (v) => fragment.ctx.cells.load.set(v) },
collections: { processes: { upsert: () => {}, remove: () => {} } },
}),
});
Lifecycle invariants
- Snapshot-then-delta on
onState. A listener attached at any point sees the current state synchronously before any later transition — the same contractuseCellconsumers expect. pin()is parent-lifetime intent. It bumps the ref-count unconditionally, so the session keeps reconnecting even if the first spawn fails, and resolves with the first client.destroy()drops it regardless of the count.- Network faults retry forever; remote faults are bounded. A
networkfault (host unreachable) never terminates — it retries at capped backoff (60s). Aremotefault (host answered but rejected the closure) is bounded by a max consecutive-failure count, then surfaces terminalfailed. recheck()versusreconnect().reconnect()re-arms afailed/idle session and won’t disturb a live link (the manual button).recheck()force-cycles whatever is there, including aconnectedlink (the wake/network-change companion, since a post-sleep link is often stale).- Liveness watchdog, default-on. While
connected, it probes the reservedsystem.liveround-trip on an interval. A timeout means a silently-wedged remote and force-cycles the child; a rejection still counts as alive. Opt out withliveness: false. - State progression.
copying → connecting → connected; on drop,disconnected → copying → …at exponential backoff capped at 60s.