- TypeScript 98.5%
- CSS 0.7%
- Shell 0.4%
- Nix 0.3%
- HTML 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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.
|
||
| docs | ||
| packages | ||
| scripts | ||
| .gitignore | ||
| .oxfmtrc.json | ||
| .oxlintrc.json | ||
| flake.lock | ||
| flake.nix | ||
| package.json | ||
| pnpm-lock.yaml | ||
| pnpm-workspace.yaml | ||
| README.md | ||
| tsconfig.base.json | ||
| vitest.config.ts | ||
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(withstreamingBehavior: 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(withsince: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 nestedassistantMessageEvent:text_delta,toolcall_start,toolcall_delta, …) /message_endtool_execution_start/tool_execution_update(partial results) / end eventsagent_end, queue events, compaction events- Projections needed for the wire: drop
partialblobs fromtoolcall_*deltas but keep{id, toolName}metadata; drop noisyturn_start/turn_endfor 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.tsdo 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-protocolexplicitly 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-terminalshim). 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:
- Agent registry (
lib/rpc-manager.ts): a server-side map of liveAgentSessionwrappers (built viacreateAgentSessionServices+createAgentSessionFromServices). Handles cold-start promise sharing, idle timers, liveness leases. - Commands: browser →
POST /api/agent/[id]with a JSON command; responses{success,data}|{error}(lib/agent-client.ts). - 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
streamingMessageis 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.
- open the SSE stream immediately; install the event listener; buffer events until a consistent snapshot of
- History: REST over the session JSONL files (
app/api/sessions/*,lib/session-reader.ts,session-tree.ts, pagination, lazy chat load). - Everything else is plain REST: files (
allowed-roots.tsboundary — only selected cwds + known project roots), git diffs, worktrees, models config, skills/plugins, terminal (node-pty basedterminal-manager.ts), Web Push notifications on run-complete, password auth (PI_WEB_PASSWORD, Basic Auth for API),PI_WEB_ALLOWED_HOSTSfor proxy hostnames. - 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-rpcchild 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_modelson 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_processsupervisingpi-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-rpcchild; JSONL round-trip: start session, prompt, streammessage_updatedeltas 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-nodejoins 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 bind0.0.0.0on 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 certfor 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.mjssuites. - 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 startruns with cwd =packages/router, wherepackages/web/distdoes not exist, so the router served its placeholder while a finished build sat two directories away —pnpm startwas broken the day it was written. Candidates are now resolved againstimport.meta.urland probed forindex.html. Related:/was rewritten to/index.htmlbefore 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;
strictPortby 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 inpackages/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.endis 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 inSession.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_statereports 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. projectIdhas 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 withservedBy: "router"— so the UI can explain why Send is disabled instead of rendering a session that looks empty. Paths stay node → router: the router stripssourcePathbefore 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
promptat 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).