kolu
Docs

@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 contract useCell consumers 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 network fault (host unreachable) never terminates — it retries at capped backoff (60s). A remote fault (host answered but rejected the closure) is bounded by a max consecutive-failure count, then surfaces terminal failed.
  • recheck() versus reconnect(). reconnect() re-arms a failed/idle session and won’t disturb a live link (the manual button). recheck() force-cycles whatever is there, including a connected link (the wake/network-change companion, since a post-sleep link is often stale).
  • Liveness watchdog, default-on. While connected, it probes the reserved system.live round-trip on an interval. A timeout means a silently-wedged remote and force-cycles the child; a rejection still counts as alive. Opt out with liveness: false.
  • State progression. copying → connecting → connected; on drop, disconnected → copying → … at exponential backoff capped at 60s.