No description
  • TypeScript 98.5%
  • CSS 0.7%
  • Shell 0.4%
  • Nix 0.3%
  • HTML 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
haze 6d91eab1e3 connection: browser → router → node → pi-rpc, with M0–M2 verified
Implements the whole connection architecture rather than a mock-backed shell.

packages/shared — wire envelope (hello/request/subscribe/update/directory,
correlated ids, per-subscription seq), the Keylime read model, the pi-event
projector that folds pi's streaming events into our own vocabulary, a client
reducer with idempotent replay, tolerant JSONL framing, and reversible
{deviceId, cwd, sessionId} identity. Browser-safe: no node APIs reachable from it.

packages/node — our own `pi --mode rpc` client (LF framing, response correlation,
stderr ring, watchdog, process-group kill) instead of importing pi's RpcClient, so
version drift across remote hosts costs one projector switch and not the UI. Plus
the on-disk session reader (history works with the node offline), directory
scanner, and a supervisor with refcounted leases, idle retirement, and a bounded
respawn budget.

packages/router — Hono + ws on one port; node registry with route fencing (no live
node → fast node_unavailable, never a stale route), merged directory,
snapshot-buffer-replay subscribe, lease release after a grace period on tab close,
static serving of the built UI, and router-side history reads for offline devices.

packages/web — store driven by the router; fixtures survive behind
VITE_KEYLIME_MOCK=1. Selection and transcript keys are the encoded session triple,
never pi's id alone: two devices can hold the same id.

Verified against live pi 0.85.1, each check driving real processes:
- smoke: prompt → 130+ streamed events → settled assistant entry, output as asked.
- two-tabs: two tabs share one worker with an identical snapshot, and the lease
  survives the other tab closing.
- multi-device: two nodes register separately, each contributing only its own
  projects; routing is per-device; a session id from one device does not resolve
  through the other; SIGTERM one node leaves its rows listed and its transcripts
  readable while commands to it fail fast.
- 24 unit tests over framing, identity, projection, and replay/dedupe.

Decisions worth knowing before editing:
- The node is the version-pinned adapter; the browser never sees pi's vocabulary.
- Entry ids are stable per session and `entry.end` is authoritative, which is what
  makes replay cheap; the router buffers live events until the snapshot lands.
- Run control is fire-and-forget: pi answers `prompt` at end of turn, so awaiting
  it would hold a request open for minutes. We take pi's answer only inside a short
  error window (missing credentials still surfaces) and otherwise learn from the
  stream.
- The worker that answers must own the cwd: pi resolves bare session ids within its
  cwd, so every routed call carries it.
- Teardown handlers are owned by the link that created them. A node restart evicts
  the old link, and the old socket dies afterwards; marking the device offline from
  that late teardown left a healthy new connection permanently read-only. Verified
  both ways: a same-deviceId link taking over keeps the device connected, a real
  process death still takes it offline.
- Offline is a state, not an error. When a node is unreachable and its file is on a
  filesystem the router can see, the router serves the transcript itself and says so
  with servedBy: "router", so the UI can explain a disabled Send instead of showing
  an empty session. Paths are node → router only (sourcePath stripped pre-fanout).

Found by rendering the app rather than only typechecking it:
- relTime compared against a fixture-era "now" constant, so every real session read
  as the same stale age. Ages are relative to the real clock now; fixtures rebase to
  now at load instead of rotting.
- the subscribe response — which IS the transcript snapshot — was sent but never
  correlated, so opening a session showed an empty pane above a working composer.
- BADGE_TONES was typed Record<string, string>, so an invalid tone compiled fine and
  threw during render, blanking the entire app. Tones are a literal-typed closed set
  with a fallback now.
- disk rows were hardcoded "offline", which lit the sidebar up as dead and disabled
  the composer for perfectly resumable sessions; "offline" belongs to the router,
  the only thing that knows link state.
- the context bar showed lifetime token sums (5.8M / 262K, pinned at 100%); it now
  reports window fill from the last turn, which is what pi means by /context.
- ui-state.ts shared one localStorage key between both panels (a mangled template
  literal); both panels persist separately now.
- ChatView follows the stream only while pinned to the bottom, so scrolling up to
  read something is not fought by the next token.

New chats (the first user-facing gap after M2):
- The sidebar offers "+" per project, including projects with no sessions yet — the
  old filter hid them, so a fresh directory had no way to make its first chat.
- A brand-new session is created on a placeholder key (pi has not written the file),
  then rekeyed onto pi's real id once get_state reports it, dropping the
  placeholder's directory row and moving the projector's entry-id namespace with it.
  Without that, the client's attach-by-real-id spawned a second pi process against
  the same file, and the sidebar carried a phantom "__new__" row.
- Sessions whose projectId matches no known project render under "recent" instead of
  vanishing. An invisible session is indistinguishable from one never created.
- node.info exposes workerKeys, so "one session, one worker" is checkable from
  outside rather than assumed. That also exposed two checks asserting on the pool
  total, which tested run order rather than the design; they now assert per session.
- Two checks added: test:new-chat (create → attach → prompt → stream, one worker, no
  placeholder left) and test:ui-new-chat (clicks the "+" in real chromium over CDP
  and requires the row to appear and the composer to be typeable — the one hop only
  a browser can cover).

Mock mode fixes found by loading it with the router and node stopped (which is the
only situation mock mode exists for): attach() dialled the router anyway, so the
fixture transcript rendered under a "not connected to the router" strip. Fixtures are
seeded by bootstrap(), so attach in mock mode now just marks the session attached.

Ports, and a test that wrote into the operator's session
- Default ports move to 3141 (Vite) / 3142 (router): pi-themed, below the ephemeral
  range, and theirs was 8787, which plenty of other dev tools want. They now live in
  packages/shared/src/ports.ts instead of nine literals, because drift between them
  is what made a healthy system look offline. The launcher restates the router port
  once (bash cannot import) and a unit test fails if the two disagree — verified by
  editing the launcher and watching it go red.
- Vite gets strictPort: it now errors on a busy port instead of drifting to the next
  one, which had already cost me an afternoon of misreading the system's state.
- Importing the port defaults from vite.config.ts exposed that shared was unloadable
  under Node's strip-only TypeScript: a parameter property in jsonl.ts. Fixed, and a
  test now forbids parameter properties / enums / namespaces in shared, since each is
  legal TypeScript and fatal there with an error pointing nowhere near the cause.
- smoke and two-tabs drove "the newest session in the cwd", then prompted it. When
  the newest session is the operator's own live conversation, the test injects its
  prompt into the human's turn — it did so nine times here. Read-only checks may look
  at real history; anything that writes now creates its own session first.
- Checks no longer assume a device id (a wrong one returns empty rows, not an error);
  they ask the router for a reachable device and fail loudly when none exists.
- The launcher stopped overriding KEYLIME_DEVICE_ID with a bare hostname, which
  defeated the node's hostname+machine-id derivation meant to keep two nodes on one
  box, or a cloned image, apart.

- The checks default to /tmp/keylime-check as their cwd. Each prompt turn leaves a
  session file behind, and they are run far more often than anybody wants another
  hundred sessions in a real repo.

One-command startup, and three bugs it walked in on:
- pnpm start (build + router) and pnpm start:all (build + router + node). No pnpm up:
  that name is pnpm's own dependency command, so the script was unreachable and the
  installer ran instead - it printed "Already up to date" and did nothing.
- The router resolved its web root against process.cwd(), so under pnpm --filter
  (cwd = packages/router) it served the placeholder while the build sat two
  directories away: pnpm start was broken the day it was written. Candidates now
  resolve from import.meta.url and are probed for index.html.
- "/" was rewritten to /index.html before the asset-vs-document check, so a missing
  build gave a bare 404 and the placeholder page - the one thing that explains what
  is missing - was unreachable for the root path. Asset-vs-document now reads the
  requested URL, and the test asserts a missing .js still 404s rather than serving
  HTML that becomes a mystery parse error in the console.
- The placeholder printed the requested port, not the bound one: with port 0 it told
  the reader to visit :0.
2026-09-15 15:14:43 -04:00
docs connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
packages connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
scripts connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
.gitignore devshell: pnpm replaces corepack; ignore direnv state 2026-09-14 23:30:46 -04:00
.oxfmtrc.json connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
.oxlintrc.json connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
flake.lock flake: dev shell with node 24, biome, tsc 2026-09-14 23:17:36 -04:00
flake.nix connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
package.json connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
pnpm-lock.yaml connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
pnpm-workspace.yaml connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
README.md connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
tsconfig.base.json connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00
vitest.config.ts connection: browser → router → node → pi-rpc, with M0–M2 verified 2026-09-15 15:14:43 -04:00

Keylime

A pi router for the LAN. Keylime is a single-entry-point server that lets you browse, start, and drive pi coding-agent sessions running on any machine in your house — reachable over one route (a Tailscale address today, plain LAN tomorrow) from any device with a browser.

It is a from-scratch web UI in the spirit of pi-web, without using any pi-web code. pi-web is studied here only as prior art (clean-room).

 laptop/browser ──┐                        ┌── device A (keylime-node → pi-rpc workers, /home/hbooth/dev/pi)
                  ├── wss://router:PORT ───┤
 phone/PWA ───────┘    (keylime-router     └── device B (keylime-node → pi-rpc workers, /home/hbooth/dev/keylime)
                       + serves web UI)

1. Goals / non-goals

Goals

  • One URL, one auth, one session list — spanning multiple machines on the LAN/tailnet.
  • Full remote control of pi sessions: prompt, steer, follow-up queue, abort, fork/branch, compact, model/thinking selection, bash passthrough.
  • Live streaming: token deltas, tool-call progress, session tree updates.
  • Browse historical sessions (read-only) from pi's own on-disk JSONL, even if the owning node is offline.
  • PWA-usable from a phone over Tailscale.

Non-goals (initially)

  • Multi-user / org features (single trusted owner, shared secret/token).
  • Internet exposure (tailnet/LAN only; no TLS termination work beyond optional).
  • Reimplementing pi features in the browser (everything routes through real pi processes or their session files).
  • Copying pi-web code, prompts, or assets — behavior inspiration only.

2. Audit: what pi actually gives us

Local reference: ~/dev/pi (monorepo, @earendil-works/pi-*), installed pi 0.85.1.

2.1 Integration surfaces (pick points of leverage)

Surface Package Status Use for
SDK @earendil-works/pi-coding-agent (createAgentSession, AgentSession, SessionManager, ModelRuntime, DefaultResourceLoader) Stable, documented (docs/sdk.md, examples/sdk/) In-process embedding on a node
RPC mode pi-rpc (coding-agent/src/modes/rpc/) Shipped in the npm package Primary choice: spawn pi-rpc per session, talk JSONL over stdio
pi-protocol / pi-server / chord protocol v8, CBOR + 4-byte BE length framing, unix sockets, opaque routed service calls, session "attachments" (multiple presentations per session), out-of-band attachment deltas Experimental, no compatibility guarantees Design reference now; possible future dep for the router↔node wire
Session files ~/.pi/agent/sessions/<encoded-cwd>/<timestamp>_<uuid>.jsonl (dir from getAgentDir(), override via PI_CODING_AGENT_DIR) Stable on-disk format Read-only history, search, sidebar without touching a node

2.2 RPC command vocabulary (modes/rpc/rpc-types.ts)

Everything keylime needs to drive a session already exists as an RPC command:

  • Run control: prompt (with streamingBehavior: steer|followUp), steer, follow_up, abort, clear_queue, bash, abort_bash
  • Sessions: new_session, switch_session, fork, clone, set_session_name, get_session_stats, export_html
  • State: get_state, get_messages, get_entries (with since: cursor — incremental!), get_tree, get_last_assistant_text, get_commands
  • Models/thinking: set_model, cycle_model, get_available_models, set_thinking_level, cycle_thinking_level, get_available_thinking_levels
  • Compaction/retry: compact, set_auto_compaction, set_auto_retry, abort_retry
  • Queue modes: set_steering_mode, set_follow_up_mode

Framing: strict JSONL, split on \n only (attachJsonlLineReader exists because U+2028/2029 are legal inside JSON strings — do not use readline).

2.3 Event stream (AgentSession.subscribe, modes/json-event.ts → JsonAgentSessionEvent)

  • message_start / message_update (with nested assistantMessageEvent: text_delta, toolcall_start, toolcall_delta, …) / message_end
  • tool_execution_start / tool_execution_update (partial results) / end events
  • agent_end, queue events, compaction events
  • Projections needed for the wire: drop partial blobs from toolcall_* deltas but keep {id, toolName} metadata; drop noisy turn_start/turn_end for clients (pi-web does exactly this — validate against our own needs).

2.4 Session storage format

  • JSONL tree: entries with parentId, enabling branching/forks; user/assistant/tool messages; compaction summaries; session-name entries.
  • Read-only features (sidebar, search, export, "resume on another day") need only a reader over these files — no live process. pi-web proves this out (its session-reader.ts / session-tree.ts do it), and it's cheap to write independently.

2.5 Auth/config sharing

  • Model credentials live in pi's agent dir (auth-storage, models.json, settings) — a keylime node running as your user automatically "has the models." No separate provider UI needed initially; reuse the node's pi config. Re-login/model management can later mirror pi-web's Models panel via the same files.

2.6 Watch-outs from the pi side

  • pi-protocol explicitly has no peer authentication — never expose it raw on a LAN; keep it behind the router's auth (or tailnet ACLs).
  • Protocol v8 is experimental; if we adopt it, version-pin and keep a fallback JSONL-over-WebSocket path.
  • Sessions are cwd-scoped (sessions/<encoded-cwd>/…): a node must present "projects" as a first-class concept (per-cwd session lists).
  • Extensions/extensions-with-UI (custom dialogs, widgets) need a headless-UI bridge if we want full parity (pi-web built a custom-ui-terminal shim). V1: surface extension UI requests as read-only notices; bridge later.

3. Audit: how pi-web works (prior art to replicate independently)

pi-web is a Next.js app that embeds pi in the web server process:

  1. Agent registry (lib/rpc-manager.ts): a server-side map of live AgentSession wrappers (built via createAgentSessionServices + createAgentSessionFromServices). Handles cold-start promise sharing, idle timers, liveness leases.
  2. Commands: browser → POST /api/agent/[id] with a JSON command; responses {success,data}|{error} (lib/agent-client.ts).
  3. Events: GET /api/agent/[id]/events → SSE (lib/agent-event-stream.ts). Notable bootstrap protocol:
    • open the SSE stream immediately; install the event listener; buffer events until a consistent snapshot of streamingMessage is available; replay buffer, then live-forward.
    • dedupe via isEventIncludedInSnapshot(event, snapshot); 30 s heartbeat comments; client-side reconnect (lib/agent-event-connection.ts) with single shared connection per session.
  4. History: REST over the session JSONL files (app/api/sessions/*, lib/session-reader.ts, session-tree.ts, pagination, lazy chat load).
  5. Everything else is plain REST: files (allowed-roots.ts boundary — only selected cwds + known project roots), git diffs, worktrees, models config, skills/plugins, terminal (node-pty based terminal-manager.ts), Web Push notifications on run-complete, password auth (PI_WEB_PASSWORD, Basic Auth for API), PI_WEB_ALLOWED_HOSTS for proxy hostnames.
  6. Key limitation keylime fixes: pi-web is one host, one process, loopback-first. All sessions share the web server's filesystem and cwd. Keylime adds the machine dimension.

What to keep conceptually: SSE-style bootstrap/snapshot/replay, leases + idle shutdown, command channel separated from event channel, file-access allow-roots, read-only JSONL history. What to change: agent must NOT live in the web server; introduce a node/router split; one multiplexed connection instead of many REST endpoints per feature.


4. Keylime architecture

4.1 Components

keylime/
  packages/
    shared/     # types, wire schema (JSONL today → pi-protocol later)
    node/       # keylime-node: runs on every device hosting repos
    router/     # keylime-router: the ONE route; auth, routing, static UI
    web/        # browser UI (React + Vite; no Next needed — no SSR story required)

keylime-node (one per device, systemd user unit, binds tailnet/LAN)

  • Spawn/supervise pi-rpc child processes, one per active session. JSONL stdio ↔ node.
  • Node capabilities: listProjects(), listSessions(project), readSession(id), startSession({project, model…}), attach(sessionId) (event stream + commands), stopSession(id).
  • Idle policy: worker-less sessions are pure-file reads; workers exit after idle timeout (config, default ~10 min) unless a lease is held by an attached client.
  • Publishes a SessionDirectory (replicated state) so the router's sidebar doesn't poll.

keylime-router (the single route — e.g. https://keylime.tailnet.tsnet:PORT or http://box.local:PORT)

  • Serves the static web UI.
  • Auth gate: shared secret → per-device bearer tokens (see §6).
  • Session graph: {deviceId, projectId, sessionId} → owning node connection; stale/ambiguous routes rejected (mirrors pi-server's route-fencing idea).
  • Multiplexed WebSocket to each node: correlated requests, subscription updates, cancellation (same envelope ideas as pi-protocol v8: {type: hello/version}, request/response, subscription update, attachment attach/detach).
  • Reconnect handling: node down → sessions degrade to offline/read-only from last-synced directory; commands fail fast with node_unavailable.
  • Presence/leases: a client "attaches" to a session; multiple tabs share one worker (presentation attachments, pi-server-style); zero attachments + idle ⇒ node retires the worker.

keylime-web

  • Session sidebar grouped by device → project → session, running/cost/context badges (from directory state, not polling).
  • Chat view: streaming text + thinking + tool-call cards with live partial results; abort/steer/follow-up queue UI; fork/"branch from here" via get_tree + fork.
  • Model & thinking selectors (from get_available_models on the owning node).
  • Read-only history mode for offline nodes; session search.
  • Later: file viewer/git diff (delegated to the node as a namespaced file service with allow-roots), terminal tab (pty over node), push notifications.

4.2 Wire protocol (v1: pragmatic; v2: converge on pi-protocol)

v1 over a single WebSocket per hop, JSON messages — deliberately the same shapes on browser↔router and router↔node where possible:

// client → router → node
{ id, type: "request", deviceId?, target: { sessionId? }, method: "session.prompt", params: {...} }
{ id, type: "cancel", requestId }
{ id, type: "subscribe", method: "session.events", params: { sessionId } }
// reverse
{ id, type: "response", ok: true, data } | { id, type: "response", ok: false, error: { code, message } }
{ type: "update", subscriptionId, params }          // AgentSessionEvent pass-through (projected)
{ type: "directory", ... }                            // replicated SessionDirectory snapshots/deltas

Rejection rules borrowed from pi-protocol discipline: strict JSON only (reject NaN/undefined/cycles), unknown properties rejected, bounded message size, LF-only JSONL if we ever spill to stdio.

4.3 Tech stack (proposed)

  • Node 22+ / TypeScript everywhere, ESM, tsx/tsdown, vitest + biome (match pi repo conventions).
  • Router: Hono (small, WS-friendly) or Fastify + ws; single process is fine at this scale.
  • Node: node:child_process supervising pi-rpc (already shipped by the pi package — no embedding pain, crash isolation, per-session env/cwd).
  • Web: React 19 + Vite + SSE-or-WS client, Tailwind; PWA manifest; no server framework in the loop.
  • State sync: server-published directory snapshots (hand-rolled delta or Chord once it stabilizes).

5. Milestones

  • M0 — Same-box spike (router+node+web, 1 device) Node spawns one pi-rpc child; JSONL round-trip: start session, prompt, stream message_update deltas to a bare-bones browser chat over WS; abort works. Exit: type a prompt on your phone, watch tokens stream.
  • M1 — Router + directory + multi-session Session directory replication, sidebar, open/attach/detach, idle worker retirement, leases, reconnect/replay (snapshot+buffer protocol), auth token gate. Exit: two tabs, one worker; kill router, both recover.
  • M2 — Multi-device Second keylime-node joins via tailnet with device token; session list groups by device; router routes by {deviceId, sessionId}; offline-node read-only mode from synced metadata + local JSONL reader. Exit: sessions on box A and laptop B from one URL on your phone.
  • M3 — Feature parity core Fork/branch tree UI, model/thinking selectors, compaction UI, session naming, follow-up queue, Web Push on agent_end, project picker per node.
  • M4 — Nice-to-haves File/git services (allow-rooted), terminal tab, extension-UI bridge, skills/plugins/config panels, pi-protocol/CBOR transport swap-in, worktree support.

6. Security notes (LAN/tailnet)

  • Bind node + router to the tailscale interface (or use Tailscale ACLs / tailscale serve) — don't bind 0.0.0.0 on a trusted-LAN-without-auth.
  • Router shared secret → issues short-lived per-device tokens; WS connections require a token; nodes authenticate to the router with per-node tokens (rotatable). No plaintext pi-rpc access ever reaches the browser directly.
  • The agent executes shell as your user: treat the router UI as root-equivalent. Log every command-routed invocation; consider a per-project "trust" gate like pi-web's.
  • Keylime never sends model credentials anywhere; they stay on the node in pi's own storage.
  • Optional later: tailscale cert for real TLS, or front with caddy on the box.

7. Risks & open questions

  • pi-rpc vs SDK embedding: RPC children give isolation but pay process startup + duplicate config loading; SDK gives richer events (extension UI, widgets). Likely: RPC for run control, revisit embedding for extension-UI parity.
  • pi-protocol churn: experimental; quarantine it behind our own transport interface.
  • Event projection correctness: dedupe/replay semantics (streaming snapshot vs buffered events) is the trickiest correctness area — port tests for behavior, not code, from observations of pi-web's .test.mjs suites.
  • Session file format drift: JSONL reader must tolerate forward-compatible unknown entry types (ignore, don't crash).
  • Decide: single router process vs. router+per-node directory sync conflict resolution (last-writer-wins is probably fine for one human).

8. References (local)

  • ~/dev/pi — monorepo; packages/coding-agent/src/modes/rpc/ (RPC vocabulary), docs/sdk.md, packages/{protocol,server,chord} (router design reference)
  • ~/dev/pi-web — prior art; lib/rpc-manager.ts (agent registry), lib/agent-event-stream.ts + agent-event-connection.ts (stream bootstrap protocol), lib/session-reader.ts/session-tree.ts (JSONL reading), lib/web-auth.ts, lib/allowed-roots.ts, CONTEXT.md

9. Status — M0, M1 and M2 are implemented

The connection path is real: browser → router → node → pi --mode rpc, streaming both ways. Verified against live pi 0.85.1 on this machine (130+ historical sessions discovered off disk, streamed turns end-to-end, two tabs sharing one worker, two devices routing independently, and one node killed with its history still readable).

packages/shared   wire protocol, read model, pi-event projector, client reducer,
                  JSONL framing, session identity      (browser-safe, no node APIs)
packages/node     pi-rpc worker (spawn + JSONL framing), session-file reader,
                  directory scanner, worker supervisor (leases, idle retirement,
                  respawn budget), router connection
packages/router   Hono app + two WebSocket endpoints, node registry with route
                  fencing, merged directory, client hub, static serving of the build
packages/web      Solid UI (already built against the Oxide system in docs/DESIGN.md)
                  now driven by the store, with fixtures behind VITE_KEYLIME_MOCK=1

Running it

pnpm install
pnpm dev                      # router :3142, node, vite :3141 (proxies /ws and /api)
pnpm dev:web                  # UI only, against fixtures:  VITE_KEYLIME_MOCK=1
pnpm build && pnpm --filter @keylime/router start   # single-origin: router serves the UI
pnpm start                    # build + router, one command
pnpm start:all                # build + router + node, one command (single origin)
scripts/keylime-run.sh up     # router + node under the nix devshell, logs in /tmp

start runs the router only, so the UI lists history but every session is read-only; start:all also starts a node, which is what makes Send work. Both need the nix dev shell (nix develop --command pnpm start:all) unless node and pnpm are on PATH.

There is no pnpm up: that is pnpm's own dependency-update command, and a script named up is silently unreachable — the installer runs instead, prints "Already up to date", and does nothing else.

Then open http://127.0.0.1:3141 (dev, via Vite) or http://127.0.0.1:3142 (the router serving the build). Both bind loopback by default — exposure beyond that is tailscale serve or an explicit KEYLIME_BIND.

Checks: pnpm check (format, lint, typecheck, unit tests).

Integration drivers: smoke and two-tabs drive an already-running stack (scripts/keylime-run.sh up); multi-device starts its own router and two nodes and asserts against real processes — including SIGTERM-ing one node and confirming its sessions stay readable while commands to it fail fast.

pnpm --filter @keylime/router smoke              # prompt → streamed tokens → settled entry
pnpm --filter @keylime/router test:two-tabs      # two tabs, one worker, shared stream
pnpm --filter @keylime/router test:multi-device  # two nodes, routing, offline history
pnpm --filter @keylime/router test:new-chat      # new chat: create → attach → stream
pnpm --filter @keylime/router test:ui-new-chat   # click the sidebar + in real chromium

test:ui-new-chat needs CHROMIUM=<path> and drives the built UI over the DevTools protocol: it clicks the "+", then requires that a row appeared and the composer is typeable. The protocol checks cannot cover that last hop — only a browser can.

Environment

var default meaning
KEYLIME_ROUTER_URL ws://127.0.0.1:3142/ws/node where the node dials
KEYLIME_DEVICE_ID stable id persisted in stateDir device identity, part of every session key
KEYLIME_PROJECT_ROOTS $HOME directory scan scope
KEYLIME_PI_BIN pi from PATH which pi to spawn
KEYLIME_IDLE_TIMEOUT_MS 600000 worker retirement after last lease
KEYLIME_BIND / KEYLIME_PORT 127.0.0.1 / 3142 router bind
KEYLIME_WEB_PORT 3141 Vite dev server (strictPort: it errors rather than drifting)
KEYLIME_TOKEN unset shared secret for both WS endpoints
VITE_KEYLIME_MOCK unset 1 renders the design concept from fixtures, no router

Ports live in one file, packages/shared/src/ports.ts. They used to be literals in nine places, which is how the router, the node, the dev proxy and the checks drifted apart: a stale process on the old port makes a healthy system look offline with nothing obviously wrong. The launcher restates the router port once (bash cannot import the module) and a unit test fails if the two disagree — a guard that is only real if it can fail, so it was verified by editing the launcher and watching the test go red.

Design decisions worth knowing before editing

  • The router finds its own web root; it does not ask the cwd. pnpm --filter @keylime/router start runs with cwd = packages/router, where packages/web/dist does not exist, so the router served its placeholder while a finished build sat two directories away — pnpm start was broken the day it was written. Candidates are now resolved against import.meta.url and probed for index.html. Related: / was rewritten to /index.html before the asset-vs-document check, so a missing build returned a bare 404 and the explanatory placeholder was unreachable exactly when you needed it; and that page printed the requested port rather than the bound one, so with port 0 it told you to visit :0.
  • A guard you have not seen fail is decoration. The port-drift test was verified by editing the launcher and watching it go red; strictPort by squatting the port and watching Vite refuse instead of drifting.
  • The node is the version-pinned adapter. pi's streaming events are folded into Keylime SessionEvents in packages/shared/src/projector.ts; the browser never sees pi's vocabulary. A pi upgrade changes one switch statement, not the UI.
  • Entry ids are stable per session and entry.end is authoritative: delivery is idempotent, which is what makes reconnect replay cheap. The router buffers live events until the node's snapshot lands, then replays them.
  • {deviceId, cwd, sessionId} is the identity, encoded in Session.sessionKey. Never key a transcript on pi's id alone — two devices can hold the same id.
  • The worker that answers must be the worker that owns the cwd. pi resolves bare session ids within its cwd, so every routed call carries cwd.
  • One session, one pi process — and pi owns the id. A new chat starts its worker on a placeholder key because pi has not written the file yet; when get_state reports the real id the worker is rekeyed onto it, the placeholder's directory row is dropped, and the projector's entry-id namespace follows. Anything less lets a client's attach-by-real-id spawn a second pi against the same file.
  • projectId has exactly one derivation (projectKey(deviceId, cwd)). The sidebar groups rows by project, so an ad-hoc id in one code path makes a session invisible — which is how a just-created chat failed to appear in the list.
  • Offline is a state, not an error. Rows come from the router's replicated directory. When a node is unreachable and its session file sits on a filesystem the router can see, the router serves the transcript itself (router/src/history.ts) and says so with servedBy: "router" — so the UI can explain why Send is disabled instead of rendering a session that looks empty. Paths stay node → router: the router strips sourcePath before client fan-out.
  • Two nodes must not be able to claim the same project. Sessions are keyed by the device-aware triple, and the multi-device check asserts no project appears under both devices.
  • Run-control commands are fire-and-forget. pi answers prompt at end of turn, so awaiting it would hold a browser request open for minutes; we take pi's answer only if it arrives inside a short error window (missing credentials) and otherwise learn from the event stream.

Lint rules switched off, and why

oxlint runs at --deny-warnings; five rules are off because the flagged form is correct here: no-await-in-loop (session files are read one at a time so a device with thousands of them cannot exhaust file descriptors), prefer-add-event-listener (socket handlers are assigned so exactly one owner exists and reconnects retire the old handler), no-map-spread / no-array-sort (explicit copies read better than in-place mutation in the directory builders), and consistent-function-scoping (Solid's derived-value idiom is a zero-arg closure over store reads, per component), and no-unassigned-vars (Solid's let el; <div bind:ref={el}> idiom assigns through the compiler, which the rule cannot see).