Remote terminals over SSH
The portal for kolu#951 — remote terminals over SSH. This is the root of the whole plan tree — every inner note (the padi plan of record, the surface consolidation plan, the framework primers, the shipped branches' build logs, the design records, the future menu) is reachable from the map here. Status — R1–R8 shipped; the R9/R10 leg was re-architected from the ground up as padi (one workspace daemon per host owns the complete terminal state; the canvas single-host per view), and on the padi side W0–W4 and W6–W8 are shipped — kolu on master runs the multi-host switch today, with an honest connect overlay — with W5 (cross-host attention) next and the surface consolidation running in parallel. The superseded pre-padi R9/R10 decomposition (the finale) is recorded in a section here.
The portal for #951 — remote terminals over SSH. Every host — this machine and each ssh remote — runs the same daemon stack, and you switch hosts from a picker, kinda like tmux sessions. R1–R8 are shipped — the foundation, the local kaval survivor, the remote-ssh spike, the standalone pulam awareness daemon, the overflow-recovery loop, @kolu/terminal-workspace, the total-dual surface mirror, and kolu serving the shared awareness raw. The R9/R10 leg was re-architected as padi — one workspace daemon per host, the canvas single-host per view — and that plan is now mostly shipped: W0–W4 and W6–W8 are live (kolu on master runs the multi-host switch today, honest connect overlay included — #1730), W5 (cross-host attention) is next, and the surface consolidation runs in parallel. This note is the root of the plan tree: the map below reaches every inner note, and the finale record holds the superseded pre-padi R9/R10 decomposition.
The map — navigating this tree
Every plan note in the remote-terminals tree, reachable from this root:
Current plan
- padi — the plan of record: one workspace daemon per host, the canvas single-host per view; phases W0–W8 with live status.
- padi-latency-baseline — the keystroke→echo baseline the padi hop’s sub-5ms budget was measured against.
Framework primers
- surface-hosting-101 — how a surface travels between machines (serve → mirror → re-serve → sessions → the registry), the machinery under W4’s switch.
- surface-map-101 —
@kolu/surface-maptaught: the dynamic keyed map of remote surfaces, and W7’s per-host ownership design. - surface-attention-101 — the W5 attention pieces taught: the cell completed to a Dynamic (
updated), mirrors that never fabricate, the derivation algebra, the eager watcher, and service-worker delivery. - surface-reactive-bridge — the ratified backend-reactivity direction: state is a signal, derived is a computed, the wire snapshots and replays; phase 0 is W5’s framework slice.
- surface-reactor-engine — the engine decision, made: Effect’s Atom behind reactor.ts (it replaced @preact/signals-core in 13 call sites); the live-probed engine comparison.
Build logs of shipped branches
- pty-daemon — R2: kaval, the standalone PTY daemon terminals survive deploys in.
- kaval-sessions — R3: the remote-ssh spike (a kaval is a daemon you dial; remote is never a second backend).
- pulam — R4: the standalone awareness/workspace daemon (later dissolved into padi).
- pulam-web — R4.8: the browser twin that proved the browser-consumption leg (retired at padi W0).
Design records
- awareness-derive-store — observe vs remember: the memoryless producer + the one fold (its home is now padi).
- terminal-metadata-model — the terminal model:
authored ⋈ snapshot, one writer per fact. - remote-bind-parity — W3.4’s parity note: everything between today and “kolu fully works over a remote bind”, as a working ledger.
Future / demand-driven
- remote-terminals-future — the researched, tiered menu of what to build on top of the multi-host switch.
- port-forwarding — discover and open a bound host’s dev-server ports through kolu’s own origin.
- shared-canvas — share a live canvas with someone else; read-only viewers first.
User-facing description
You switch hosts from a picker — kinda like tmux sessions — and the canvas shows one host’s terminals at a time (the shipped shape; the mockup above predates padi’s single-host ruling). A remote terminal carries the same live awareness a local one does — git branch/dirty, PR status, agent state, live activity — and it survives a network blip or a kolu-server deploy with full scrollback. One local host is always present; remotes are added on demand from your ssh config.
Architecture-level changes
The rest of this section records the pre-padi architecture (the R1–R8 era) — kept as the historical record; the current architecture is padi.
One endpoint, bound to a dialed kaval. A kaval is a daemon you dial — local over a unix socket, remote over ssh — so remote is not a second backend. There is one TerminalEndpoint per terminal, resolved by HostLocation ({kind:"local"} today, {kind:"remote",hostId} in R9). That resolver is meant to be the sole place a tile maps to its kaval; everything downstream talks to the backend and never asks “which kind?”. No RemoteTerminalEndpoint — and the resolver shipped in R9.1 (resolveTerminalEndpoint, #1603): per-terminal ops already resolve off entry.meta.location and never ask “which kind?”, with the {kind:"remote"} arm failing loud until R9.2 adds the ssh driver as an additive sibling of R2’s local driver.
Two volatility axes stay open (R1/R2 closed the rest):
| Axis — changes for its own reason | Encapsulated by |
|---|---|
| Where a terminal’s state lives — this machine vs an ssh host | the HostLocation-resolved kaval endpoint — one impl + a discriminator, not a second backend |
| How the backend reaches its agent — transport · framing · reconnect | HostSession + @kolu/surface/links/stdio — unix socket today, ssh in R9 |
Zero domain knowledge crosses the transport (Hickey’s cut): the agent runs the unmodified sensors and the boundary ships their values. An earlier per-domain design (RemoteGitInfoProvider, …) was transport adapters wearing domain costumes; the single-endpoint seam dissolved them.
Already paid for, so R5–R10 are narrow. @kolu/surface-nix-host ships HostSession, provisionAgent (ships the derivation — a darwin parent drives a linux remote with no cross-builder), and resolveSystem; @kolu/surface ships mirrorRemoteSurface (now a total dual). R2 already shipped the host-count-agnostic spine: @kolu/surface-daemon-supervisor and the per-connect system.version handshake. So R9 is an ssh driver behind shipped seams; R8 made kolu serve the shared awareness (#1594); R7 completed the surface mirror (#1505); R6 grew pulam’s fs/git (#1506); R5 added the one wire signal that distinguishes a slow-subscriber drop from a PTY exit (#1591).
Implementation details
The whole epic as one tree — bird’s-eye, with each row’s right-hand link a drill-down to where that phase lives (the child notes for shipped branches, the ### sections below for the rest). R1–R8 (incl. pulam-web’s framework) are shipped; the R9/R10 leg re-architected as padi — W0–W2.2 shipped there.
Everything marked ✓ is shipped (PRs linked). R8 (#1594) ended kolu’s private awareness copy: a single-writer store served raw, kolu keeping only kolu.authored, the client joining the halves at the reader. The S1/S2 awareness foundation + the endpoint resolver shipped next (#1626, #1603) — and the rest of the R9/R10 leg was re-architected wholesale as padi: its W1 rewired the client onto one complete surface, its W2 made padi the per-host process that owns the domain, its W3/W4 landed the remote binding and the host switch. The web-UI indicator thread — R-dock-unify (#1541) · R-pip-unify (#1551) · R-activity-merge (#1555) — single-sourced the per-row indicators, so the Dock and the dashboard render one indicator from one projection. Each ✓ row drills into its detail — the child notes for shipped branches, the ### sections below for the rest.
R5 — overflow-recovery loop
Independent — needed nothing, blocked nothing; landed on its own.
Shipped (#1591). kaval sheds a slow attach subscriber by ending its iterator, which was indistinguishable on the wire from a PTY exit — so the client treated the drop as terminal and froze scrollback. The attach contract (ptyHostSurface, bumped to 5.0 — a new emitted union variant is breaking for an older client, which the version predicate would otherwise wave through) now carries a typed overflow control frame, emitted as the stream’s last frame when the host drops a lagging subscriber, distinct from a PTY exit. The web tier (terminalEndpoint/local.ts) reads it and re-attaches for a fresh snapshot — reset-then-snapshot so the repaint replaces stale rows rather than double-painting — instead of freezing; kaval-tui’s existing re-attach loop reads it to skip writing a dataless frame; pulam ignores it (activity is best-effort). The drop is reproducible on the local socket, so it is CI-tested today (inProcessPtyHost.test.ts, reattachingDeltas.test.ts), and the hardened contract is a clean prerequisite for the remote reconnect R9 leans on. herdr vs kolu flagged the same gap.
R-dock-unify — kolu’s Dock reads the shared agentProjection
Shipped (#1541) — kolu’s Dock became the third consumer of the shared @kolu/terminal-workspace/agentProjection, so the Dock, pulam-tui, and pulam-web rank/paint agents from one source; a differential test pins them equal.
R-pip-unify — one status pip across the web surfaces
Shipped (#1551) — the Dock’s StatePip lifted into @kolu/solid-statepip + a shared @kolu/theme, so the Dock and pulam-web render the identical status pip from one component.
R-activity-merge — merge the activity dot into the state pip
Shipped (#1555) — the live-activity dot and the unread alert merged into that same StatePip (a sweeping green ring + an amber corner badge), so each row renders one indicator from one leaf.
R6 — @kolu/terminal-workspace: one library, one fs/git impl, two homes
Shipped (#1506) — @kolu/terminal-workspace: one fs/git impl (createTerminalWorkspaceEndpoint), run in-process by kolu and hosted remotely by pulam. The shared impl, not yet the shared surface — the shared awareness surface is R8; the fs/git surface is R9.
R7 — mirrorRemoteSurface is now a total dual — shipped #1505
Shipped (#1505) — mirrorRemoteSurface became a total dual returning { procedures, done } (every procedure a forwarding stub), so serve ∘ mirror ≈ identity; proven by drishti’s forwarded “Kill process” action under the surface.md gate.
R8 — kolu serves awareness raw; the client joins it — shipped #1594
Shipped (#1594). kolu used to fuse awareness into its own private terminalMetadata record. R8 bisected it: the sensor fields live in a single-writer awareness store served raw on terminalWorkspaceSurface.awareness (AwarenessValue); kolu keeps only what it authors on kolu.authored (AuthoredTerminal — location · chrome · the active|sleeping discriminant). Neither side fuses them — surfaceCtx.collections.terminalMetadata is a compile error. The client joins the two halves at the reader (useTerminalMetadata → composeTerminalMetadata), and the same join authors the on-disk SavedTerminal, so disk and the read can’t diverge. One writer per fact, and the bisection reaches the consumer. Full model: the terminal model. (The awareness half was later reshaped by the awareness-derive-store cutover: the single-writer store became kolu’s fold over a memoryless producer’s observation stream, and the collection value AwarenessValue became TerminalSnapshot — the reader-join itself is unchanged.)
One surface, two homes. kolu-server and the pulam daemon both serve terminalWorkspaceSurface, but assemble it via one factory — @kolu/terminal-workspace/serveTerminalWorkspace, the volatility-boundary twin of serveFsGit: the version cell + fs/git procedures/streams live there once, and each home injects only its awareness backing (kolu projects off its registry; pulam reads its own store) and its activity source (quiet for kolu, live for pulam). So R8 unblocks R9a — kolu now serves an awareness a second process can read — and makes remote awareness in R9 a backing injection, not a rewrite.
fs/git is not part of R8 — it rides R9. terminalWorkspaceSurface deliberately serves fs/git as procedures + pulse, not value-bearing streams: fs.listAll/git.getStatus are request→response procedures, and subscribeRepoChange/subscribeFileChange are payload-free {seq} pulses you re-query on (surface.ts:89-109 — “re-queries procedures rather than streaming full diffs over the wire”). kolu’s Code tab today reads koluSurface’s value-bearing streams (app.streams.gitStatus/fsListAll/gitDiff.use, CodeTab.tsx:314-373). Making kolu read the shared surface’s fs/git means rewriting the Code tab from value-bearing streams to procedure + pulse-then-requery — a large client change the surface ties to mirroring the surface whole. So it lands with R9, where kolu mirrors the whole surface anyway.
R9–R10 — kolu dials remotes, then the canvas
Superseded — this leg became the padi architecture, after the ground-up re-evaluation recorded in History: “which host” is a property of the connection, not of each terminal, so instead of threading a per-terminal HostLocation through kolu-server, one workspace daemon per host (padi) owns the complete terminal state and the canvas shows one host at a time. What the old decomposition planned maps as: R9.2 (ssh driver) + R9.3 (remote awareness) + R9.4 (reconnect/adoption) → padi W3.1 (the remote binding, shipped); R9.5 (Code-tab rewrite) → shipped inside padi W1 (un-gated from R-pulamweb-4); R10 (canvas) → padi W4/W5 (the host switch — shipped — + cross-host attention, single-host per view; the multiplex-vs-switch question was settled by a persona UX study, switch won). Shipped on the padi side: W0–W4 and W6–W8 — kolu on master runs the multi-host switch today; W5 next. The finale record below holds the superseded R9/R10 decomposition; archived notes holds the consolidation plan (shipped).
The finale record (R9 converged · R10) — superseded
The pre-padi plan of record for this leg. Its foundations shipped and live on: S1 + S2 + R9.0 + R9.1 (#1626, #1603) — the memoryless awareness producer, kolu’s fold, the in-process local awareness, and the HostLocation endpoint resolver — all reused inside padi. The awareness types and API are recorded in awareness-derive-store.
The re-sequenced remainder — three parallel local-preserving prep PRs (PR-1 lifecycle · PR-2 fs/git · PR-3 awareness) → F-REMOTE (the one complete remote tile) → R10 (canvas + host picker) — never proceeded: the whole decomposition was superseded by padi on 2026-07-01 (its per-terminal HostLocation threading was N-site handling of a connection-level volatility), and its prep PRs #1637–#1640 were closed. The W-phase mapping is in the section above; the detailed sub-phase specs live in git history.
History
- padi W0–W2.2 shipped — kolu runs on padi (2026-07-02 → 03) — the padi plan’s first three phases landed: W0 decks cleared (the old surface frozen #1650, pulam-web retired, R5’s overflow frame #1591 riding along as kaval 5.0), W1 the padi seam (#1652 —
packages/padiborn, the client rewired onto padiSurface, the root RPC namespace deleted, the seal), W2.1 the policy-driven re-serve machinery (#1661, drishti adoptinginitialKeysas the second-consumer proof), and W2.2 padi-the-process (#1664 — state-root identity, digest rendezvous, padi owns kaval, the cutover; plus the endgame’s doctrine: deploy-adoption — a deploy ADOPTS a compatible running daemon and its live PTYs, restore is for dead daemons only — newest-wins upgrade convergence via a frozen control core, Restart-kaval recycling kaval inside padi, and the migration adopter for pre-padi installs). W2.3 (padi-tui + the pulam burial) in flight; W4 became the consolidation track (now archived); W5 demand-driven. - R9/R10 superseded by the padi architecture (2026-07-01) — a ground-up re-evaluation (7-lens adversarial review + persona UX evaluation) found “which host” to be a connection-level volatility, not a per-terminal one: the canvas becomes single-host per view, and a per-host workspace daemon (padi — pulam grown to completion + kolu-server’s whole terminal domain) serves ONE complete surface that kolu-server merely binds to. PRs #1637/#1638/#1639/#1640 closed; pulam-web/pulam-tui retire; the S1/S2 producer+fold move into padi verbatim (owner-clock replaces consumer-clock — re-settled, since no view ever compares two hosts). Plan of record: padi.
- R9 awareness architecture settled — observe vs. remember; createPulam superseded (2026-06-29) — A grounded 3-agent design debate (held to the perfection bar) converged the remote-awareness architecture: split observing from remembering. The producer is memoryless — it emits
TerminalSnapshotevents and cannot spell the memory fields, by type. kolu alone folds, owns the memory (moved tokolu.authored), and stamps recency with its own clock (killing cross-host clock skew); the wire carries an event stream, not whole values, so local and remote are one fold path and the remote reconcile disappears. This supersedes createPulam (“share the assembly” still fused deriving with storing), so R9·lib dissolves. Decided: memory→kolu.authored, identity-only recency, urgency-only dashboards, host-scoped key. Full design: the awareness note; plan: the finale record. - R9 re-grounded — the decoupling is the library, not a process; #1614 closed (2026-06-28) — A step back, grounded in the source, found the previous correction’s premise wrong: the awareness capability is already a decoupled library (
@kolu/terminal-workspace— its dependency arrow points out, six consumers) and kolu already consumes it in-process (terminalEndpoint/local.tsruns the library’sstartAwarenessagainst kaval’s taps). So no local pulam process was ever needed. PR #1614 is closed (the local-pulam-process — a misdiagnosis that also introduced a fold-clobber intrinsic to mirroring an ephemeral local process across a socket). The real gap: the library exposes only parts, with no single assembly, so there is no 1:1 between it and the pulam daemon (the assembly + the sink + the byte-tap live in the daemon, re-implemented divergently inside kolu). Corrected plan at the time — R9·lib gives the library one assembly entry point (createPulam) the daemon wraps trivially. (That createPulam direction is itself now superseded by the observe-vs-remember design — see the entry above.) - R9a discarded; an interim “local pulam process” direction (2026-06-28) — R8’s reader-join is kept; R9a (#1604) is abandoned (its approach had pulam-web reach into kolu), and the one real thing it surfaced — a
@kolu/surfacemembership bug (broadcastKeys) — landed on its own (#1609). This entry’s then-proposed direction (kolu running a local pulam process as its awareness backing) is superseded by the re-grounding above — the awareness library was already decoupled, so no process was needed. - R8 shipped — awareness served raw, joined at the reader (2026-06-27, #1594) — rather than re-fusing awareness into kolu’s own record, R8 serves two raw collections (
terminalWorkspace.snapshots+kolu.authored) and the client joins them viacomposeTerminalMetadata; the same join authors disk. A perfection-review pass collapsed an interim server-side compose (noterminalMetadataserved collection survives), and a follow-up factored the two-home serving intoserveTerminalWorkspace(theserveFsGittwin). Model: the terminal model. - R5 shipped — overflow-recovery loop (2026-06-26, #1591) — the attach contract (
ptyHostSurface5.0 — a breaking major bump, since a new emitted union variant is not backwards-compatible for an older client) gained a typedoverflowcontrol frame; the web tier re-attaches for a fresh snapshot on it instead of freezing scrollback, distinct from a PTY exit. Hardens the attach contract for both transports ahead of R9. - R8 re-scoped to awareness only; fs/git is R9 (2026-06-26) — a fresh implementing agent caught that
terminalWorkspaceSurfacedeliberately serves fs/git as procedures + a{seq}pulse (not value-bearing streams;surface.ts:89-109). So R8 is now just the awareness compose (clean, unblocks R9a); making kolu read the shared surface’s fs/git is the Code-tab rewrite to pulse-then-requery, which rides R9. R-pulamweb-4 consumes the existing procedure + pulse (needs only R-pulamweb-3). The two wire shapes are explained in surface live data. - Pierre de-gated (2026-06-26) — the
@pierre/treesswallow-emit was reproduced harmless in kolu (#1534), so the git drill-in no longer waits on a renderer proof; R-pulamweb-4 consumes the existing procedure + pulse. - R-activity-merge (2026-06-24, #1555) — activity dot + unread alert merged into
StatePip. - R-pip-unify (2026-06-23, #1551) — one status pip via
@kolu/solid-statepip+@kolu/theme. - R-dock-unify (2026-06-23, #1541) — Dock joins the shared
agentProjection. - codex late-detection fixed (2026-06-24, #1559) — the kaval pty-host retains
lastCommandand replayscommandRunsnapshot-first, so a late/restarted sensor detects command-only agents. - R-pulamweb-1/2/3 (2026-06-22 → 23) — pulam-web shipped: the reactive stream consumer (drishti #72), the framework (#1524), and the agent dashboard (#1535).
- R7 (2026-06-21, #1505) —
mirrorRemoteSurfacetotal dual. - R6 (2026-06-21, #1506) —
@kolu/terminal-workspace: one fs/git impl, two homes. - R1–R4 (2026-05-26 → 06-21) — the
TerminalEndpointseam,@kolu/surface-nix-host, the shared sensor engine, the pty-daemon multi-client survivor, and the arivu→pulam rename.