Puddle is a self-hosted orchestrator for CLI coding agents (Claude Code, Codex, OpenCode, and others) — run many agents in parallel, each isolated in its own git worktree, managed from one workspace UI. A daemon owns agent processes, worktrees, and session history; it runs on whatever machine hosts the work. On your own machine, puddle launch gives you the cockpit at localhost. On a remote box, first-class SSH support (puddle launch user@host) bootstraps the daemon and reaches the same cockpit over a single tunnel, in the spirit of VS Code Remote-SSH: live agent terminals, file editing, diff review, git history, and port forwarding. Under systemd/launchd (or a nohup-capable host) sessions keep running when the laptop sleeps or the window closes, and survive machine reboots by resuming from each agent's on-disk conversation state. An SSH host that reaps detached processes gets a cockpit-lifetime daemon instead: closing the cockpit interrupts processes, while the same durable state resumes them on the next launch (§10).
Puddle is a public, general-purpose tool. Nothing in the codebase, docs, examples, or default configuration may reference any specific company, team, or person. Use generic placeholders (alice, user@devbox, my-repo).
Goals:
- Run many coding-agent sessions in parallel on one host machine — your own laptop/workstation (local mode) or a remote box (SSH mode) — each isolated in its own git worktree and branch by default (sessions can opt out of branch isolation and share — §4 "Relaxed isolation"). Both modes use the identical daemon and UI; SSH mode only adds bootstrap and a tunnel.
- Support multiple profiles (one per collaborator on a shared box) and, within each profile, multiple accounts per agent type (separate credential/config dirs), with any number of concurrent sessions per account.
- Organise work into projects (profile + repo + sessions + persisted UI state): a window attaches to one project, multiple windows can open the same project, and reloading a window restores the project exactly — open sessions, terminals (via log replay), and editor tabs.
- Full persistence: accounts, sessions, branches, and terminal history survive daemon restarts, SSH disconnects, and machine reboots.
- A browser UI with: session sidebar with live status, xterm.js terminals (agent + shell tabs), Monaco file viewing/editing, diff review against the base branch, git history browsing, detected-port list with forwarding, and clickable file paths / URLs in terminals.
- One-command startup:
puddle launchlocally,puddle launch user@hostfor a remote box. - Agent-agnostic core with per-agent adapters; adding an agent means adding one adapter module.
- A Scratchpad: a per-profile bank of reusable prompts and free-form notes, a floating panel opened from the top bar. Each entry is either project-scoped (shown only in that project) or profile-scoped (shown everywhere in the profile); entries are drag-reorderable, filterable by tag or agent, editable inline, and insertable into the focused session in one action.
- Saved layouts: named snapshots of the workspace's tiling layout, saved and loaded from a top-bar Layouts popover in the Scratchpad's mould. Each is profile- or project-scoped, following the project-based-layout setting at save time; loading one whose scope disagrees with that setting flips the setting as part of the load, without erasing any stored per-project layout.
Non-goals (v1):
- User authentication/authorisation. Puddle assumes a single trusted OS user; profiles are identity, not access control. (This is distinct from the mandatory browser-facing token in §2 "Local security", which defends the localhost API against malicious web pages, not against local users.)
- Merge automation, PR creation, or conflict resolution.
- Replacing a full IDE. Deep editing happens via "Open in editor" deep links; Monaco covers review and quick edits.
- Multi-machine fleets. One daemon per box;
puddle launchtargets one host at a time. (Keep the CLI's host handling clean so this can grow later.) - Native Windows hosts. v1 host platforms are Linux and macOS; Windows users are served via WSL2 (which behaves as a Linux host). The client side (browser + CLI tunnel) works from any OS.
client machine host machine (local or remote)
┌──────────────────────────────┐ ┌───────────────────────────────────┐
│ browser ── localhost:7433 │ │ puddled (supervised/SSH-held) │
│ │ │ local: direct │ ├─ REST + WS API (Hono) │
│ puddle CLI ◄─┘ │ remote: ssh -L │ ├─ PTY manager (node-pty) │
│ ├─ static web UI assets │────────────────►│ ├─ worktree manager (git CLI) │
│ └─ /api + /ws proxy │ HTTP + WS │ ├─ agent adapters (per agent) │
└──────────────────────────────┘ │ ├─ SQLite (source of truth) │
│ └─ append-only session logs │
└───────────────────────────────────┘
The CLI serves the UI; the daemon is a headless API server. In both local mode (puddle launch) and SSH mode (puddle launch user@host) the browser talks to one stable local origin — http://localhost:7433, served by the puddle CLI — and the CLI reverse-proxies /api and /ws to the daemon: directly to 127.0.0.1:7434 locally, through the SSH tunnel remotely. One serving path for both modes, no CORS (single origin), and no host- or tunnel-port detail ever reaches the browser. The daemon is host-agnostic: it binds 127.0.0.1:7434 and neither knows nor cares where the client is. UI updates ship with the CLI — updating the CLI once updates the cockpit for every host it connects to; the daemon is only forced to update when the protocol breaks (§6 Protocol versioning).
puddled(daemon): Node 22 LTS — pinned, and shipped inside the release tarball, so the choice never depends on the host. Hono for HTTP,node-ptyfor terminals,better-sqlite3for state; both are native modules and ship prebuilt in the tarball. API only — no web assets. Binds127.0.0.1:7434by default (installs that predate the serving switch are migrated off the old 7433 default once, via a file-onlyconfigVersionmarker inconfig.json). If that preferred port is already in use the daemon falls back to an OS-assigned free port instead of crashing, and records wherever it actually bound in~/.puddle/runtime.jsonso clients can still find it (§10); every start prefersconfig.json's port, so the daemon returns to it as soon as the conflict clears. (Bun is fine as dev tooling; the daemon itself runs on the pinned Node.)- Web UI: React + TypeScript, xterm.js (+ fit, web-links addons), Monaco (
@monaco-editor/react). Built to static assets shipped inside the CLI npm package (@puddle-code/cli). puddleCLI (client machine): npm package; bootstraps/updates the daemon (locally or over SSH), serves the web UI and proxies/api+/wsto the daemon, opens the tunnel when remote, launches the browser, and performs the protocol handshake (§6) on every start/connect.- State layout on the host, all under
~/.puddle/:
~/.puddle/
├── puddle.db # SQLite
├── token # browser auth token (see Local security)
├── config.json # daemon settings: PREFERRED port, log caps
├── runtime.json # live port the daemon ACTUALLY bound + pid; written on bind, removed on clean shutdown (§10)
├── supervisor # install.sh's selected lifetime: systemd | launchd | nohup | none (§10)
├── puddled.pid # nohup or SSH-attached daemon pid used by bootstrap/removal lifecycle
├── profiles/<profile_id>/accounts/<agent_type>/<label>/ # per-account agent config dirs (created by puddle; id-keyed — names are display labels)
├── profiles/<profile_id>/sessions/<agent_type>/<store-key>/ # shared conversation store: canonical adopted conversation dirs, symlinked into each account (§5)
├── worktrees/<repo_id>/<session-id>/ # repo_id, not repo name — names can collide
│ /branch-<slug>/ # shared worktrees for separate_branch = false sessions (§4)
├── logs/<session-id>/<term>.log # append-only PTY output, one file per terminal (agent.log, shell-1.log, …)
├── logs/<session-id>/<term>.terminal.json # atomic headless-xterm screen + scrollback snapshot for attach (§6)
├── cockpits/<target>.json # CLIENT side: registry of running cockpit processes (§10 puddle list/kill)
└── logs/cockpit-<target>.log # CLIENT side: a background cockpit's own output
Puddle NEVER mutates or adopts agent config directories it did not create (e.g. an existing ~/.claude or ~/.codex). Every puddle-managed account gets a fresh directory under its profile's subtree, populated either by puddle's login flow or by import: POST /api/accounts {import_dir} COPIES a pre-existing config dir into the new puddle-owned dir, byte-for-byte and read-only — the source is never touched again, nothing is parsed beyond the agent's own state file, and the account's logged-in flag is set by asking the agent (adapter checkLoggedIn), never assumed (macOS keychains bind OAuth tokens to the source path, so credentials may not travel with a copy). This keeps puddle state disjoint from whatever else runs on the box.
Puddle never reads agent-account credentials — no exceptions. Subscription rate-limit usage (the profile panel's progress bars) is fetched by asking the agent's own CLI (for claude-code: claude -p /usage, print mode, run with the account's config dir), so the agent authenticates itself exactly as an interactive session would and puddle touches no tokens. Results are cached per account in the daemon (each fetch spawns a process) and every failure — logged-out account, missing binary, timeout, unrecognised output — yields no data rather than wrong numbers. The desktop's client-side SSH askpass bridge (§10) is distinct: it transiently relays a response from its authentication dialogue to the local system ssh process, but never logs, stores, or sends that response to puddled.
Accounts are strictly per-profile. The API never lists, attaches, or spawns with another profile's accounts; the UI's account picker shows only the active profile's. If a collaborator wants to use "your" underlying agent subscription, they log in again under their own profile, producing an independent config dir. Note this is organisational isolation, not security — everything runs as one OS user, so anyone with shell access can read any directory; the goal is preventing accidental credential sharing and history mixing, not defending against a malicious housemate.
Binding to 127.0.0.1 is not protection from the web: any website open in the user's browser can attempt fetch("http://localhost:7433/..."), and DNS-rebinding can defeat naive same-origin assumptions. For a daemon that can spawn agents with permissions skipped, an unauthenticated localhost API is a remote-code-execution vector via CSRF. Therefore, from Phase 1:
- Token auth: the daemon generates a random bearer token at first start (
~/.puddle/token, mode 0600). The CLI reads it (locally or over the SSH master) and appends it as a URL fragment when opening the browser; the UI reads it, immediately strips it from the address bar (history.replaceState) so it never lingers in history or copied links, stores it, and sends it on every/apirequest and as the first WS message. All/api,/ws, and/proxyroutes require it; only the static UI assets (served tokenlessly by the CLI) are public.puddle attach/status/logsuse it the same way. The CLI's proxy forwards requests verbatim — it adds no credentials; the token travels from the browser exactly as before. - Host and Origin validation: reject requests whose
Hostis notlocalhost/127.0.0.1(defeats DNS rebinding) and whoseOrigin, when present, is not a localhost origin. The CLI's UI server applies the same two checks on its own port. - Proxy scoping:
/proxy/:sid/:port/only forwards to ports currently detected for that session's process tree — it must not be a general localhost proxy. - Proxy auth (
/proxy): a browser tab navigating to/proxy/...(and the WebSocket handshake it opens) cannot attach a bearer header, so three credentials are accepted, in this order:Authorization: Bearer <token>; cookiepuddle_proxy=<token>; query?puddle_token=<token>. A?puddle_token=GET is a one-shot bootstrap: the daemon setsSet-Cookie: puddle_proxy=<token>; Path=/proxy; HttpOnly; SameSite=Laxand 302-redirects to the same URL with only that param stripped (the token never lingers in the address bar — the same instinct as the boot token-fragment strip), so every subsequent request on that path — including the un-headerable WS upgrade — carries the cookie automatically. The cookie value is the daemon token itself, not a minted second secret: a separate secret would add server-side state (a session table, expiry) without moving any trust boundary, since anyone who can read the daemon token already owns the box. All comparisons are timing-safe; Host/Origin validation (point 2) applies to/proxyas well, on both the HTTP and the raw WS-upgrade path.
The daemon is the persistence layer. It is the parent of every PTY, runs under a persistent supervisor or a cockpit-owned SSH channel, and tees all output to disk. tmux would duplicate that role with a second session registry that can drift. The "attach from a raw terminal" escape hatch tmux provided is replaced by puddle attach <session> (CLI → daemon WebSocket).
CREATE TABLE profiles (
id TEXT PRIMARY KEY, -- 10 hex chars, like projects; opaque handle
name TEXT NOT NULL UNIQUE, -- display label only (e.g. "alice") — never keys files or URLs
branch_prefix TEXT NOT NULL DEFAULT '', -- app default "puddle/" (migration 008 + create); editable per profile, may be cleared to ""
icon TEXT, -- optional lucide icon name (kebab-case); null → default person glyph (§11)
icon_colour TEXT, -- optional theme-colour key the web maps to a text-* token; null → heading colour
settings TEXT NOT NULL DEFAULT '{}', -- profile-scope settings JSON (see §11 Settings)
created_at TEXT NOT NULL
);
CREATE TABLE accounts (
id INTEGER PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id),
agent_type TEXT NOT NULL, -- adapter id: 'claude-code' | 'codex' | 'opencode' | ...
label TEXT NOT NULL, -- e.g. "personal", "org"
config_dir TEXT NOT NULL, -- under ~/.puddle/profiles/<profile_id>/accounts/
skip_permissions_default INTEGER NOT NULL DEFAULT 0, -- effective only when the profile's allowSkipPermissions gate is on (§11 Settings)
logged_in INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(profile_id, agent_type, label)
);
CREATE TABLE repos (
id INTEGER PRIMARY KEY,
path TEXT NOT NULL UNIQUE, -- canonical clone on the box
default_base_branch TEXT NOT NULL DEFAULT 'main',
onboarding_notes TEXT, -- user-authored standing setup rules, injected into every worktree onboarding (§4)
fetch_enabled INTEGER NOT NULL DEFAULT 1, -- master switch for all fetching on this repo (create-time and periodic)
last_fetched_at TEXT
);
CREATE TABLE projects (
id TEXT PRIMARY KEY, -- 10 hex chars: short, stable URL handle (/project/:id)
profile_id TEXT NOT NULL REFERENCES profiles(id),
repo_id INTEGER NOT NULL REFERENCES repos(id),
name TEXT NOT NULL, -- e.g. "teleop-latency"
abbrev TEXT, -- ≤5-char uppercase rail label (12.1); NULL derives from name
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(profile_id, name)
);
CREATE TABLE profile_states ( -- per-profile persisted workspace layout (see §11)
profile_id TEXT PRIMARY KEY REFERENCES profiles(id), -- layout follows identity, not browser or project
ui_state TEXT NOT NULL, -- JSON: layout tree, editor tabs, explorer pin, sidebar mode
updated_at TEXT NOT NULL
);
CREATE TABLE agent_conversations (
id INTEGER PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id),
agent_type TEXT NOT NULL,
agent_session_ref TEXT NOT NULL, -- immutable native conversation id
native_cwd TEXT NOT NULL, -- native store truth, not placement
native_title TEXT, -- native name; puddle title stays on sessions
parent_conversation_id INTEGER REFERENCES agent_conversations(id),
preferred_account_id INTEGER REFERENCES accounts(id),
native_created_at TEXT,
native_updated_at TEXT,
last_seen_at TEXT NOT NULL,
missing_scan_count INTEGER NOT NULL DEFAULT 0,
missing INTEGER NOT NULL DEFAULT 0,
UNIQUE(profile_id, agent_type, agent_session_ref)
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- immutable puddle placement uuid
project_id TEXT NOT NULL REFERENCES projects(id),
account_id INTEGER REFERENCES accounts(id), -- current/preferred launch account; NULL for terminals
conversation_id INTEGER REFERENCES agent_conversations(id), -- NULL for terminals and retained migration aliases
placement_alias_of TEXT REFERENCES sessions(id), -- preserves a legacy duplicate's UUID/logs/layout refs
worktree_path TEXT NOT NULL,
canonical_worktree_path TEXT NOT NULL,
base_branch TEXT NOT NULL,
branch TEXT NOT NULL,
separate_branch INTEGER NOT NULL DEFAULT 1,
branch_owned INTEGER NOT NULL DEFAULT 0, -- one placement owns worktree/branch-dependent actions
kind TEXT NOT NULL DEFAULT 'agent',
agent_type TEXT, -- NULL for terminal sessions
title TEXT, -- puddle user override; native title lives above
status TEXT NOT NULL,
native_sync TEXT, -- pending | full | fallback; NULL for terminals
skip_permissions INTEGER NOT NULL DEFAULT 0,
session_env TEXT NOT NULL DEFAULT '{}',
cwd TEXT, -- terminal-only, worktree-relative last prompt dir
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_activity_at TEXT
);
CREATE UNIQUE INDEX idx_sessions_conversation_placement
ON sessions(conversation_id, project_id, canonical_worktree_path)
WHERE conversation_id IS NOT NULL;
CREATE UNIQUE INDEX idx_sessions_branch_owner
ON sessions(canonical_worktree_path)
WHERE branch_owned = 1;
CREATE TABLE scratchpad ( -- per-profile Scratchpad: prompts + notes (see §11)
id INTEGER PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id),
scope TEXT NOT NULL DEFAULT 'project', -- 'project' | 'profile' — a hard filter, not a hint
project_id TEXT REFERENCES projects(id), -- set iff scope='project' (store-enforced)
title TEXT, -- optional short label; body's first line shown if absent
body TEXT NOT NULL, -- plaintext, inserted verbatim
tags TEXT NOT NULL DEFAULT '[]', -- JSON array of free-form strings (filter chips)
agent_type TEXT, -- optional agent association (filter)
position REAL NOT NULL, -- manual drag order; smaller = higher (top)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE layouts ( -- saved layouts: named tiling-tree snapshots (see §11)
id INTEGER PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profiles(id),
scope TEXT NOT NULL DEFAULT 'profile', -- 'project' | 'profile' — the layout mode at save time
project_id TEXT REFERENCES projects(id), -- set iff scope='project' (store-enforced)
name TEXT NOT NULL,
layout_tree TEXT, -- JSON LayoutNode (validated on read); NULL = empty workspace
active_session TEXT, -- the URL-bound session at capture time
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE events ( -- lifecycle audit trail
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
type TEXT NOT NULL, -- created|session_ref_captured|resumed|interrupted|exited|archived|...
payload TEXT, -- JSON
created_at TEXT NOT NULL
);SQLite is the source of truth; a live PTY is an ephemeral attachment to an internal stable runtime, and a session row is an immutable Puddle placement. Native identity, parentage, cwd, title, timestamps, and existence come from agent_conversations; the public Session.agent_session_ref, agent_title, parent id, and missing flag are produced by joining its placement to that row. The same conversation may have placements in several projects, but only once per project and canonical worktree. Catalogue-created duplicates never acquire branch ownership; an exact live switch transfers it. Migration 021 deduplicates native refs within the owning profile while preserving every Puddle UUID, placement title/status/environment, log/event reference, and saved-layout reference; exact legacy placement duplicates remain aliases of the canonical row.
All timestamps are ISO 8601 UTC. Git operations against a given repository (worktree add/remove, fetch, branch creation, source-control mutations) are serialised through a mutex keyed by its canonical Git common directory in the daemon — linked worktrees share the same ref/index lock domain, and concurrent Git writes otherwise race on Git's own lock files and fail spuriously.
spawn agent prompt detected
starting ────────► running ◄───────────────────────────► waiting_input
│ ▲ │
process exit │ │ resume │ process exit
▼ │ ▼
exited ◄────────────────────────────── (same as running)
│
daemon boot finds │ user archives ⇄ unarchives (worktree kept)
no live PTY ──► interrupted ─────────────► archived
startingcovers worktree creation: the daemon fetches per the fetch policy below and creates the worktree (base resolves toorigin/<base>when it exists, so sessions never branch off a stale local base). Environment setup then happens inside the agent session via onboarding (below), guided by the repo'sonboarding_notes.starting → runningwhen the agent's PTY produces first output.running ⇄ waiting_inputhas two drivers, hook signals first, regexes as the fallback:- Hook signals (authoritative). At every agent spawn the daemon injects
PUDDLE_AGENT_SIGNAL_URL+PUDDLE_AGENT_SIGNAL_NONCE(a per-runtime secret) into the PTY env; agent hook processes inherit that env and reportPOST /agent-signal {nonce, state}— a nonce-gated endpoint deliberately outside/api(the hook has no bearer token; the nonce dies with the runtime). The adapter installs the hooks: for claude-code,Stop/Notification(permission_prompt)/Notification(idle_prompt) →waiting_input, andUserPromptSubmit/PreToolUse→working(PreToolUse covers resume-after-approval, which fires no UserPromptSubmit) — written additively into the account config dir'ssettings.jsonbeside a tiny helper, inert outside puddle (status behaviour verified against Claude Code 2.1.219; lifecycle payloads against 2.1.238). A hook signal is accepted even while the row is stillstarting: the firstStopcan beat Claude's first visible TUI draw. After a session's FIRST status signal, hooks own its status and the regex detector is muted — idle TUI redraws (e.g. a resize on tab open) would otherwise misread as activity with no hook event to restorewaiting_input. - Regex fallback. Until a signal arrives (agents without hooks, older CLIs), the adapter's
statusPatternsare matched against the output stream as before (debounced;waiting_inputonly after ~2 s of quiet following a match).
- Hook signals (authoritative). At every agent spawn the daemon injects
- Any of
{starting, running, waiting_input}found without a live PTY during the daemon's boot reconcile pass →interrupted. Reconcile also sweeps the filesystem: a worktree directory with no session row is flagged in the UI (never auto-deleted); a session whose worktree is missing is badged "worktree missing" and can only be archived. - Stale-running (advisory, computed on read like
worktree_missing). Arunningagent session whose adapter-reported activity (adapter.sessionActivityAt— for claude-code, the transcript's mtime via a cached stat) is over an hour old is flaggedstale_runningon the session shape: the TUI may be redrawing, but the agent has recorded no work — probably a wedged process. The UI fades the session's status indicator (dot or glyph) with a "possibly stalled" hint. Advisory only: a very long tool call looks identical, so the daemon never kills, downgrades, or otherwise interrupts a session because of it (decision 2026-07-28). exited/interrupted→runningvia resume (adapterresumeArgs, same worktree, same config dir). On resume afterinterrupted, the daemon injects a first message — the profile's restart launch text (profileSettings.restartTemplate; absent → the built-in default "This session was interrupted (daemon or machine restart). Processes you started are gone; re-verify your environment before continuing.", empty string → no note), editable in Settings → Sessions (§4 Launch text).archived⇄exited: any non-archived session can be archived in one gesture — a live one (starting/running/waiting_input) is killed by the daemon as part of it — and archiving is a reversible placement hide, not a teardown. It changes nothing on disk and archives only that placement; duplicate placements of the native conversation remain exited. Catalogue polling never unarchives it. An exact native/resumeinto that placement does: native intent clearsarchived, adopts the live runtime state, and focuses it. Unarchive (POST /api/sessions/:id/unarchive, →exited) brings it back manually: if the worktree is still on disk the session resumes with its history intact; if it was pruned, or its branch was moved or deleted, the session returns visible for its terminal/conversation history only, with resume disabled through the read-timeworktree_missingflag. Because nothing is destroyed there is no confirmation and noforce/delete_branch; disk and branch cleanup remain explicit Worktrees actions. Archiving a project archives all its sessions and refuses while any isrunning/waiting_inputunless forced.- Auto-resume on boot is ON by default (
config.json: autoResume: true; flipped 2026-08-09 — it shipped OFF): a daemon restart resumes interrupted sessions itself, and any that fail to resume surface in the UI for one-click resume. Settings → Host toggles it.
A live agent is owned by an internal stable runtime identity, not by the placement currently shown in its URL. That runtime owns the agent PTY, shell PTYs, sidecars, captured environment, port roots, signal nonce, and an activeSessionId. A keyed mutex serialises native switches and REST resumes, and the daemon indexes runtimes by conversation so at most one Puddle runtime can own a native conversation.
Adapters report exact top-level SessionStart/SessionEnd transitions through the nonce route. /clear, /resume, and /fork may switch placement; startup binds initial identity and /compact remains the same conversation. Reviews, ephemeral Codex threads, OpenCode children with parentID, and other child/subagent sessions are excluded. On a switch the daemon resolves or creates the target conversation placement in the current project and current canonical worktree, even when native metadata records another cwd. It preserves the target placement's Puddle title, clears archived, adopts the runtime's account, environment, permission and sync state, and transfers branch ownership. It then quiesces output, closes the old log/screen segment, freezes the old placement as exited, rebinds agent and shell PTYs to the target, starts the target segment, and forces a full TUI redraw. Durable events and session-switched tell every viewer what happened; only a viewer focused on the source follows automatically, retaining the old terminal as frozen history in the pane.
If runtime A switches to conversation B while B already has a live runtime, A is stopped as an expected exit and B is focused instead. A remains visible and exited. When B's placement is in another project, the focused viewer navigates there and uses that project's layout, opening B as a preview only if absent. A REST resume of an already-live conversation returns 409 conversation_live with structured existing_session_id and existing_project_id, allowing the same focus behaviour.
Launch is capability-checked. Claude Code and Gemini use additive native hooks; OpenCode installs a Puddle-managed top-level lifecycle plugin; Codex uses a daemon-owned loopback WebSocket proxy between codex --remote and codex app-server, with the app-server process tree included in port ownership and its bridge ports hidden. Both the remote TUI (-C) and app-server process are rooted in the placement's worktree — remote mode delegates workspace creation to the sidecar, so letting that process inherit the daemon's home directory silently turns Codex's workspace/footer into ~ and removes its Git branch context even though the PTY itself was spawned correctly. If an adapter's exact channel cannot start, the daemon uses its normal direct launch, records native_sync: 'fallback', and adds one restrained terminal warning that in-agent switches will not synchronise. Catalogue discovery continues but never guesses that a runtime changed identity.
Branch-per-session is the default, not a straitjacket. Two independent axes on session creation decide where a session lands:
separate_branch(default true for agents, false for terminals): a fresh branch —<branch_prefix><slug>— checked out in its own new worktree. False works directly on the base branch (no new branch; commits land on the shared branch). Abranchname combined withseparate_branch: falseis rejected (400branch_with_shared).separate_worktree(default true for agents, false for terminals; only meaningful, and only permitted false, whenseparate_branchis false — a new branch always gets its own directory, else 400shared_worktree_needs_shared_branch): whether the session gets its own working directory or shares one. On the base branch,truegives each session its own directory — a distinctgit worktree add --forcecheckout of the base branch, so concurrent agents share the branch (commits interleave) but not the working tree. Turning it off shares a directory:join_worktree: <path>lands the session in a specific existing worktree of the repo — any entry fromGET /api/repos/:id/worktrees, validated by realpath against git's own worktree list (400unknown_worktree,detached_worktree). This is how a second agent drops into a directory another is already working in.- Omitted, it uses the base branch's default directory: the repo's own clone (
repos.path) when that branch is checked out there — puddle stays faithful to where the user cloned rather than making a second checkout beside it — otherwise the canonical shared worktree atworktrees/<repo_id>/branch-<slug>/(first such session creates it). No worktree is removed on archive (archiving is a reversible hide — §4); worktrees are reclaimed only by an explicit prune in the Worktrees manager (§8).
The shared-directory case is the default for both kinds (decision 2026-08-03, reversing the earlier discouraged stance: most sessions want the base branch as-is; separate branch/directory stay one toggle away and profileSettings.sessionDefaults re-seeds them per profile). "Use separate directory" is still greyed and forced on whenever "use separate branch" is on, and the shared case still warns that concurrent agents in one directory can and will trample each other's edits, offering a dropdown of directories already on the base branch to join. Only the session that creates a worktree receives the onboarding preamble and the .puddle/onboarding-notes.md marker-file watch; a session joining an existing directory skips onboarding (the environment already exists, as with resumes and hand-offs) but has its prompt prefixed with a concurrency heads-up — expect the working tree to shift underneath you; avoid disruptive git operations (resets, force-pushes, branch deletion). Archiving never removes a worktree (it is a reversible hide — §4); a base branch is never deleted by puddle (it isn't puddle's), and branch pickers do not badge it as a session branch.
Terminal sessions. A session's kind is agent (a coding agent driving the worktree) or terminal (a plain shell PTY — $SHELL, no agent and no account). A terminal is created through the same new-session machinery (POST /api/sessions {kind:'terminal'} — no account_id), gets a worktree the same way, and appears in the sidebar like any other session, but in blue rather than agent amber — a terminal glyph where an agent shows its brand mark — and with a terminal type line rather than an agent/account line wherever full session metadata is shown. It defaults separate_branch to false (a scratch shell usually wants the branch as-is, not a fresh one); a separate branch is still available. It receives no onboarding preamble, no .puddle/ marker watch, and no conversation store — there is nothing to onboard or adopt. account_id/agent_type are null; the permissions gate, migration (§5), and conversation sharing (§S) do not apply. Its PTY runs on the same agent term id, so terminal and editor views attach unchanged. Lifecycle is the same state machine: it goes starting → running on the shell's first output and → exited when the shell dies; a daemon restart interrupts it like any session, and resume relaunches a fresh shell in the same worktree (a shell process cannot be reattached across a restart), keeping it alive from the UI's point of view. Creation accepts an optional worktree-relative cwd — the file tree's Open Terminal in Directory (§8) — as the initial value. At each subsequent prompt the shell hook reports PWD, and the daemon updates the same field, so a resume after a daemon restart returns to the directory the user actually cd'd into; extra shell tabs open there too, and the last prompt report wins when several are open. The value stays relative to worktree_path so the two cannot drift. A prompt outside the worktree records the safe root fallback rather than an arbitrary absolute path, and a stored directory that has since been deleted likewise falls back to the root instead of making the session unspawnable.
Session shell history. Every shell tab spawned inside a Puddle session uses one durable history file under that session's private log directory; tabs in the same session share it, while other Puddle sessions, the home terminal, login terminals, and shells outside Puddle do not. The zsh and bash shims establish the private history context before sourcing the user's normal rc, so ZLE/Readline widgets, environment managers, and prompt frameworks initialise against the final history rather than having it replaced underneath them. At each prompt they append only that tab's completed commands without rewriting the shared file; Bash also imports sibling-tab appends live, while zsh loads the shared file when a tab or shell starts rather than mutating ZLE's history underneath an active editor. Up/Down recall therefore survives activation, extra tabs, and shell/daemon restarts. An rc that deliberately reads a different history file may add those commands to the in-memory list, but persistent writes remain private. Bash 3.2 retains cwd/history support even though it cannot capture exported env. Unsupported shells receive the session HISTFILE as a best effort but have no prompt hook.
Environment setup is not deterministic per repo — whether this particular worktree needs a fresh .venv, a symlink to a shared one, or none at all is often the user's call in the moment. So setup is split into standing rules and per-worktree discretion:
- Standing rules —
repos.onboarding_notes. A user-authored, freeform text block per repo (editable in the Projects settings tab, which holds per-repository settings), holding whatever the user has decided is always true: "alwayspnpm install", "shared.venvlives at<repo>/.venv; symlink it unless I say otherwise", "never install playwright browsers", "ask me before touching Docker". Empty is fine — everything is then discretionary. - Every freshly created worktree onboards — and only those. The daemon prepends an onboarding preamble (the profile's launch text, see below) to the agent's first prompt (or delivers it alone when the session was started without a task prompt): read the notes; inspect the codebase for setup requirements (README/CONTRIBUTING, lockfiles,
.tool-versions,pyproject.toml, …); apply what the notes settle without asking; ask the user about anything the notes leave open — stating trade-offs where relevant (a symlinked.venvsaves gigabytes per worktree, but parallel sessions then share mutable dependency state). Execute only what the notes prescribe or the user approves, then proceed to the user's actual task. Sessions that reuse an existing worktree — resumes, and tier-2 hand-offs (§5) — never receive the preamble; their environment already exists, and the resume note or hand-off prompt takes its place. The launch text is editable per profile (Settings → Sessions), with three templates: one for a freshly created worktree — where a{{rules}}token is replaced withrepos.onboarding_notes— one for joining an existing/shared worktree, and one sent when a session is resumed after a daemon restart or machine reboot; any may be cleared to send no preamble, defaulting to the built-in text (profileSettings.onboardingTemplate/concurrentTemplate/restartTemplate; absent → default, empty string → intentionally empty). - Rules can be taught through the agent. If during onboarding the user states a standing rule ("always do X from now on"), the preamble instructs the agent to write the updated notes to
.puddle/onboarding-notes.mdin the worktree; the daemon syncs that file intorepos.onboarding_notesand confirms with a toast. Syncs are last-writer-wins (several sessions can onboard concurrently), so the daemon logs the previous notes toeventsand the toast links the change — an unwanted overwrite is one click to inspect and revert. The notes remain user-owned prose — the agent records decisions, it doesn't invent policy. (.puddle/is git-excluded, never committed.) - Placements keep Puddle names; conversations keep native names. A session's display name is
title ?? agent_title ?? <id-prefix>.titleis the placement's user override (PATCH /api/sessions/:id), so the same native conversation may be labelled differently in different projects.agent_titleis the joinedagent_conversations.native_title— for Claude Code, the transcript'sagent-name/ai-title; for Codex,threads.name. Adapter point refreshes still run on status/exit/OSC cues and periodically, while catalogue scans catch changes in inactive conversations without reading transcript bodies. An empty UI title clears only the Puddle override. Native-title refreshes broadcastrenamedfor the live placement and catalogue scans broadcastsessions-changedfor every affected project; user overrides continue to win. The git branch is fixed at placement creation and never renamed by either title path.
Notes are repo-global, shared by all profiles — like the repo itself on a trusted box. Genuinely personal preferences are expressed in the moment (per-worktree answers); if that proves noisy in practice, a per-profile notes addendum is a natural later extension, deliberately not in v1.
The prescriptiveness of the notes is the automation dial: exhaustive notes make onboarding near-silent (the agent just executes and gets on with the task); sparse notes mean a question or two per worktree — which is exactly right when the answer genuinely varies per worktree. Onboarding runs under the session's normal permission rules — the gate (§11) is not bypassed for setup.
export FOO=bar typed in a session terminal — or exported by a sourced script, nvm use, .venv/bin/activate — persists for the session: it is re-injected into every future PTY spawn on that session (new shell tabs, agent restarts and resumes, respawns after a daemon restart). Scope is strictly per-session; the home stream and login-* PTYs have no session row and are excluded.
- Capture is part of the injected session-shell prompt hook, since a parent process cannot read a child shell's exports. Session shells spawn with a shim — zsh via a
ZDOTDIRshim directory whose zdot files chain to the user's real ones then register aprecmdhook (the user's ZDOTDIR view is restored after rc, soexec zsh, nested shells, and children never see the shim); bash via--rcfile(sources~/.bashrc, then prepends toPROMPT_COMMAND). At each prompt, when capture is enabled, the hook diffs the shell's exported env against an in-memory baseline seeded after the user's rc ran — so rc-set exports and re-injected captured vars are baseline, not deltas (no feedback loop) — and reports changes. Because it diffs the result,source,eval, and tool shims are all captured. Unsupported shells (fish, …) and bash < 4 (macOS/bin/bash3.2 lacks associative arrays) have no env capture; injection still applies, while bash retains the hook's cwd/history duties. Hook scripts are regenerated under~/.puddle/shell-hooks/at every daemon boot (0600 files, 0700 dirs). Capture is best-effort: an export with no subsequent prompt (export X=1 && exit) is missed, and a plugin that wholesale-resets the prompt hook arrays disables it silently. - Transport is OSC 7733, a puddle-internal terminal side-channel: one
ESC ] 7733 ; set ; b64(name) ; b64(value) BEL|ST(orunset ; b64(name)) sequence per changed var, pluscwd ; b64(absolute-path)whenPWDchanges — base64 so newlines and quoting survive byte-exact. The daemon parses and strips these at the single PTY-output choke point, before the log append and any broadcast, so private payloads never reach terminal recordings, replay, or viewers. - Persistence and merge:
sessions.session_env(SQLite is the source of truth). Merge is per-variable in arrival order — last write wins across a session's terminals (two shells in different dirs under direnv will ping-pong; accepted).unsetremoves a captured name only; there are no tombstones over daemon-baseline env, so a var the daemon itself provides cannot be persistently unset. Caps: 32 KiB per value, 128 vars per session — overflow is dropped with a one-time[puddle]note in the reporting terminal. A denylist (PWD,HISTFILE,SHLVL, prompt machinery, identity vars,ZDOTDIR, thePUDDLE_*prefix, …) is filtered in the hook and re-checked daemon-side;PATHis deliberately capturable (venv/nvm is the headline use case). Archive does not clear the map (unarchive + resume restores the working environment);DELETE /api/sessions/:id/envis the manual purge. - Injection order at spawn is
process.env→ captured vars → adapter/hook-control env, so adapter env (e.g.CLAUDE_CONFIG_DIR) always wins. Captured env reaches agent PTYs as well as shells. - Gate: the per-profile
captureSessionEnvsetting (default on, Settings → Sessions). Off makes env capture dormant — no env reports, deltas ignored, no re-injection — with the stored map kept for re-enabling. The session-shell shim remains for cwd tracking and isolated history. - Secrets posture: values live in
~/.puddle/puddle.db, inside the 0700 puddle home — the same trust domain as the daemon token. There is no at-rest encryption by design: a co-located key adds nothing, and headless daemons have no keychain. Typingexport FOO=secretlands in the session's private shell-history file in plaintext; the OSC strip keeps values out of terminal logs. The authenticated API returns each captured value to the cockpit (optionalvaluesince protocol 16.2); clicking a name copies its value without displaying it. This adds no privilege to a cockpit that can already sendecho "$NAME"to the session PTY and read the result, while keeping passive display and logs value-free. A worktree process can forge OSC 7733 and poison a future spawn's env — but it already runs arbitrary code as the user, so no privilege boundary is crossed.
Base-branch freshness is treated as ambient hygiene, not a user chore. The daemon runs git fetch (fetch only — worktrees and local branches are never mutated):
- on session creation (before branching, as above),
- on project open (any client loading
/project/:id), - periodically in the background —
config.json: fetchIntervalMinutes, default 15 — for every repo with at least one non-archived session.
All fetches use the OS user's normal git credentials, are serialised through the per-repo mutex, and update repos.last_fetched_at. Per-repo opt-out (fetch_enabled = 0 — disables create-time, open-time, and periodic fetching alike) exists for air-gapped boxes. For repos with no remote, fetching and freshness indicators degrade silently (ahead/behind is computed against the local base). Otherwise the UI shows each session's ahead/behind counts against origin/<base> and a subtle "base moved" indicator when the base branch has advanced since the session branched — surfacing drift early instead of at merge time. Fetch failures (offline, auth) are logged and shown as a muted repo badge; they never block session creation.
The core is agent-agnostic. Each agent is one module in packages/daemon/src/agents/<id>.ts implementing:
export interface SessionRefContext {
sessionId: string; // puddle session id
worktreePath: string;
createdAt: string; // immutable sessions.created_at anchor
nativeCreatedAt?: string; // catalogue's native creation time, when known
excludeRefs?: ReadonlySet<string>; // refs proven to belong elsewhere
}
type StorageLookup<T> = T | Promise<T>; // account-wide reads may yield
interface NativeConversation {
ref: string;
cwd: string;
title: string | null;
parentRef: string | null;
createdAt: string | null;
updatedAt: string | null;
}
export interface AgentAdapter {
id: string; // 'claude-code', 'codex', 'opencode', 'gemini-cli'
displayName: string;
binary: string; // executable name to resolve on PATH
capabilities: {
resume: boolean; // can restore a conversation
presetSessionId: boolean; // id can be chosen at launch
skipPermissions: boolean; // has a yolo/skip-prompts mode
migratableSessions: boolean; // conversation state can move between accounts (same agent)
};
env(account: AccountRow): Record<string, string>; // config-dir isolation
launchArgs(opts: LaunchOpts): string[]; // fresh session
resumeArgs(ref: string, opts: LaunchOpts): string[]; // restore session
loginArgs(): string[]; // interactive login flow
// Guidance the login dialogue shows verbatim (13.1) — for a flow that is not
// self-evidently finishable. claude-code, codex, and gemini-cli all run
// their FULL TUI so the agent's own first-run sign-in screen renders in the
// PTY (claude's method picker; codex's — its `login` subcommand only opens
// a browser + localhost callback on the daemon host, an empty panel from a
// remote cockpit; gemini has no auth subcommand at all), then must be
// exited by hand — which the hint says. opencode keeps `auth login`, an
// interactive picker that exits cleanly on its own. A clean exit is
// VERIFIED via checkLoggedIn where one exists, never assumed, since
// quitting a TUI without signing in also exits 0.
loginHint?: string;
// Minted-id adapters snapshot every ref already present in the cwd before
// launch; the core serialises snapshot → spawn → resolve per account/cwd.
existingSessionRefs?(worktree: string, account: AccountRow): StorageLookup<ReadonlySet<string>>;
// Returns the agent-native session ref. Either echoes the preset id, or
// discovers a post-launch ref that was absent from the snapshot.
resolveSessionRef(
opts: LaunchOpts,
account: AccountRow,
excludeRefs?: ReadonlySet<string>,
): Promise<string>;
// Creation-time recovery for a missing, duplicated, or mismatched stored ref.
discoverSessionRef?(
worktree: string,
account: AccountRow,
context?: SessionRefContext,
): StorageLookup<string | null>;
sessionRefMatches?(
ref: string,
context: SessionRefContext,
account: AccountRow,
): StorageLookup<boolean>;
// Legacy point lookup used by live title/activity refresh paths. Public
// agent_title is stored once on agent_conversations and joined to placements.
sessionTitle?(ref: string, account: AccountRow): string | null;
conversationDiscovery?: {
watchRoots(account: AccountRow): string[];
discover(account: AccountRow): Promise<NativeConversation[]>;
};
lifecycleSignals?: boolean;
checkLifecycleSupport?(account: AccountRow): Promise<boolean>;
prepareLifecycleLaunch?(context: LifecycleLaunchContext): Promise<LifecycleLaunchResource>;
// Move a conversation's on-disk state from one account's config dir to another's
// (same agent type). Only called when capabilities.migratableSessions.
migrateSession?(ref: string, from: AccountRow, to: AccountRow, worktree: string): Promise<void>;
// Render the conversation as readable text (for cross-agent hand-off). Falls back
// to the puddle PTY log tail when the agent's native format can't be parsed.
exportTranscript?(ref: string, account: AccountRow, worktree: string): Promise<string>;
statusPatterns: { waitingInput: RegExp[]; busy?: RegExp[]; limitReached?: RegExp[] };
}statusPatterns are matched against the output stream after stripping ANSI escape sequences — agent TUIs colour their prompts, and regexes written against clean text silently never match raw PTY bytes. They are the FALLBACK status driver: an adapter that can install agent hooks (claude-code — claude-hooks.ts) gets authoritative hook signals via POST /agent-signal instead, and the regexes only drive status until the session's first signal (§4).
Capability notes per adapter (verify every flag against the installed version during Phase 1/7 — agent CLIs change fast; encode findings in the adapter, never in core):
- claude-code: isolation via
CLAUDE_CONFIG_DIR; supports--session-id <uuid>at launch (→presetSessionId: true) andclaude --resume <uuid>; skip mode--dangerously-skip-permissions; conversations stored as JSONL under<config_dir>/projects/<escaped-cwd>/<uuid>.jsonl. AdditiveSessionStart/SessionEndhooks report startup/resume/clear/fork/compact identity (lifecycle payloads verified against 2.1.238) alongside the existing status hooks. - codex (flags, storage, and app-server lifecycle re-verified against codex-cli 0.147.0; live idle signature against 0.146.0): isolation via
CODEX_HOMEalone, which relocates config, sessions and credentials together. Conversation state is split: rollouts live at$CODEX_HOME/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl, while$CODEX_HOME/state_<n>.sqliteindexes the same threads. The first rolloutsession_metacarries the sessionid,cwd, creation timestamp, and (for child agents)parent_thread_id; the SQLite row carries the id/cwd/time sooner, before a large rollout header is necessarily readable. Resumecodex resume <id> [prompt](or--last); bypass--dangerously-bypass-approvals-and-sandbox—--yolodoes not exist in 0.147.0 despite older published docs.codex login statusexits non-zero when logged out, so the exit code alone drivescheckLoggedIn. Session ids are not presettable, sopresetSessionId: falseandagent_session_ref !== sessions.id— the first adapter where those diverge. Before launch puddle snapshots the account/cwd's existing top-level refs, then reads the state index first for a new one while excluding that snapshot; child threads are never eligible. A missing, incompatible, or stale index falls back to an asynchronous rollout index that reads only each file's boundedsession_metaheader and caches unchanged metadata — it never synchronously loads whole transcripts. This snapshot → spawn → capture sequence stays serialised per account/cwd, but yields a full event-loop turn and runs behind the session-create response: the browser can attach immediately without native-ref discovery blocking any API or PTY. Concurrent late-capture ticks share one lookup. An unresolved placeholder is never stored; status changes and the periodic title refresh retry the same creation-time-safe discovery. On resume, a ref must be the unique closest match to the Puddle session's cwd and native conversation creation time when catalogued, falling back to the immutable placement creation time for legacy rows; this prevents a real but unrelated rollout from hijacking the row without rejecting a conversation merely because its placement was materialised later. The v0.0.56 bridge briefly wrote exact refs under the daemon cwd; when a normal cwd lookup finds nothing, resume compatibility accepts only the unique account-wide top-level ref born in the same creation window, retaining the concurrent-launch ambiguity guard. The UUID remains stable when Codex renames a thread: catalogue discovery re-readsthreads.nameas the native title without rewriting the ref. Exact switching comes from successful top-levelthread/start,thread/resume, and non-ephemeralthread/forkJSON-RPC responses through the app-server bridge. Both sides of that bridge are explicitly rooted at the Puddle worktree (-Con the remote TUI andcwdon app-server), rather than relying on the daemon process's directory. Its live idle composer renders as› … <model> · <directory>after ANSI stripping; that observed signature, not the absent? for shortcutsstring found in the binary, driveswaiting_input. - opencode (verified against opencode 1.18.10): isolation needs all four XDG roots —
XDG_CONFIG_HOME,XDG_DATA_HOME,XDG_CACHE_HOME,XDG_STATE_HOME— becauseauth.jsonand the session store live under the DATA root, not the config one.OPENCODE_CONFIG_DIRrelocates nothing (verified withopencode debug paths) and is useless for account isolation. Resume--session <ses_id>; skip mode is--auto, soskipPermissions: true. Like Codex, OpenCode mints ids, so launch captures a ref absent from the pre-launch account/cwd snapshot and resume validates/re-recovers it from the stored creation time. Its account-wide metadata walk is asynchronous, caches files by size and modification time, and coalesces concurrent polls. A Puddle-managed plugin reports only top-level session lifecycle/status events and ignores events whose session hasparentID.opencode export <id>yields the transcript. Caveat: redirectingXDG_CONFIG_HOMEalso hides an XDG-located global gitignore from git commands the agent runs; identity is unaffected (~/.gitconfigis HOME-based). - gemini-cli (verified against @google/gemini-cli 0.53.1): isolation via
GEMINI_CLI_HOME, which the CLI checks beforeos.homedir(); state lands at<config_dir>/.gemini/. The widely citedGEMINI_CONFIG_DIRis ignored and would leave the CLI writing into the user's real~/.gemini, breaching §2.--session-id <uuid>presets the id (presetSessionId: true); resume--resume <ref>; skip mode--approval-mode yolo.--promptis headless and exits, so an initial prompt must use--prompt-interactive. It has noauthsubcommand, so login is a bare launch into the first-run picker andcheckLoggedIninspects the credentials file. AdditiveSessionStart/SessionEndhooks report native identity without replacing user hooks.
Codex legacy recovery treats its current SQLite state index as fast but not exhaustive: catalogue and resume merge it with top-level rollout metadata. A chat omitted from SQLite until Codex's own /resume opens it therefore remains present in Puddle. If the old remote bridge recorded its cwd as the daemon home, recovery may search the account rather than the worktree, but accepts only the unique top-level thread born in the Puddle session's creation window; subagents, refs already owned by another session, and ambiguous concurrent launches remain ineligible.
All four adapters declare exact native lifecycle integration, while status integration remains independent: Claude Code's status hooks are authoritative and Codex, OpenCode, and Gemini still use statusPatterns. Codex's pattern is verified against a live logged-in 0.146.0 PTY; OpenCode and Gemini CLI remain best-effort until their logged-in acceptance runs. docs/acceptance/phase-7-agents.md carries the evidence and remaining checks.
When a capability is false, degrade gracefully: e.g. no resume → offer "new session in the same worktree", pre-filling a prompt that summarises the branch state (git log --oneline base..HEAD + git status).
Adding an agent = adding one file + registering it; PRs adding adapters must not touch core session logic.
Conversation identity is unique by (profile_id, agent_type, agent_session_ref), not account: accounts in one profile may expose the same native store. A conversation has at most one placement per (project, canonical worktree), but may appear in several projects that register the same repository. Ref recovery uses the catalogue's native_created_at when known, otherwise the Puddle row's immutable created_at, together with cwd and the pre-launch ref snapshot; it never guesses “newest in this directory”. If no creation-time match exists, resume fails visibly rather than opening an unrelated conversation.
Each adapter's conversationDiscovery returns only normalised metadata — ref, cwd, title, parent ref, and native timestamps — using asynchronous, cached, bounded reads. Discovery never reads transcript bodies. The coordinator maps native cwd to its containing canonical Git worktree, then creates an exited, unarchived placement in every eligible project: same profile as the conversation account, same registered repository, non-archived project, and a worktree exposed by that repository. It never crosses profiles or imports into archived projects; unarchiving a project immediately makes it eligible and schedules discovery. Polling updates native titles and existence but never unarchives a placement or grants branch ownership.
Opening or transitioning to a project calls POST /api/projects/:id/conversations/refresh. The call returns 204 immediately; concurrent requests coalesce by account while the daemon scans and broadcasts sessions-changed for created, recovered, missing, renamed, or re-parented placements. This is activation-driven rather than another browser interval.
Eligible accounts install adapter-declared fs.watch roots with per-store debounce. Healthy watchers get one unref'd five-minute safety sweep. A failed/unavailable watch falls back to an asynchronous fingerprint poll beginning at 15 seconds and doubling while unchanged, capped at five minutes. In-flight scans coalesce, metadata fingerprints/cache entries are reused, and accounts with no non-archived eligible project install no watchers. Native deletion is confirmed only after two successful scans (a watch deletion schedules a short verification); a failed scan never advances missing state. Missing placements remain visible with a badge and cannot resume, and reappear immediately after a successful scan sees the conversation again.
A failure the user cannot see is a bug. Two mechanisms guarantee they surface:
- Login verification is pushed. A login PTY's clean exit only starts the answer: the daemon asks the adapter's own auth check (
checkLoggedIn) and records the verified flag afterwards — asynchronously, once the login dialog has already closed, when no request is in flight to carry the result back. WheneversetLoggedInactually changes the stored flag, the daemon broadcasts anaccountWS message (account_id,profile_id,logged_in— protocol 15.1) to every status subscriber, and the UI patches its cached account lists in place, so the settings badge goes green the moment verification lands rather than on the next unrelated refetch. - Abnormal PTY exits raise a
notice. When an agent or shell exits non-zero without having been asked to, the daemon emits anoticeWS message (level,title, anddetail— the ANSI-stripped tail of whatever the process printed). It is broadcast to every status subscriber, not just clients attached to that stream, because the user is usually looking at another tab; the UI renders it as a toast. An exit withinSTARTUP_FAILURE_MSof spawn is titled "failed to start" — the flag-drift and bad-credential case — and the process's own error text is normally the entire diagnosis. Note that the session status cannot classify this: a launch error printed to the terminal is output like any other, so it flips the session torunningon the way out. - Nothing fires for a stop we asked for.
kill,archive(which kills), akill-shellrequest, and daemon shutdown mark the stream as an expected exit, consumed by the matching exit. A notice the user learns to ignore is worse than no notice.
Client-side, every mutation is covered by a global handler, so an action can never fail silently because its call site forgot one; local handlers still run for their side effects and report through the same helper, whose message-derived toast id collapses the pair into a single toast.
binary is resolved on the daemon's PATH (already extended with config.agentPath at boot — §11). Nothing may spawn an agent without checking it first: node-pty does not fail loudly for a missing executable — on macOS its spawn-helper execvps and returns 1 — so an absent CLI otherwise produces a PTY that dies instantly with no output. A login dialog then flashes open on an empty terminal and vanishes, and a session goes starting → exited with nothing to explain why.
assertBinaryAvailable (agents/binary.ts) therefore guards login, session create/resume, migration and hand-off, rejecting with 424 agent_not_installed and a message naming both the executable and the agentPath setting. It runs before checkLoggedIn everywhere, and the boot re-verification sweep skips uninstalled agents: adapters answer checkLoggedIn by asking their own CLI, so an absent binary reads as "logged out" and would otherwise clear a perfectly good logged_in flag. Lookups are cached for 30 s, so installing an agent mid-session un-sticks the UI without a daemon restart.
GET /api/agents reports binary and available per adapter so the UI disables the add-account and login affordances up front, with a plain inline note, rather than letting the user click into a guaranteed failure. Both fields are optional on the wire; a daemon that omits them is assumed available.
Two tiers, surfaced as one "Continue on…" action in the session menu (and offered proactively when limitReached fires — see below):
-
Tier 1 — same agent, different account (migration). The conversation does not move on migration — it already lives in the profile's shared conversation store and is reachable from every account. Migration then stops the session's process (it has usually already exited — credit exhaustion), updates
sessions.account_id, and runs the normal resume path with account B's env; same worktree, same branch, same conversation, different credentials, recorded in aneventsrow. Implemented asPOST /api/sessions/:id/migrate {account_id}(§6) with a strict fall-through: (a) the target reads the conversation through the shared store's symlink → no files move; (b) otherwise, an agent that implements themigrateSessionadapter hook copies its state across (rolled back on a later resume failure); (c) neither →409 migration_unsupported. Only (a) is used for claude-code — the shared store supersedes its (still-declared)migrateSessioncapability, kept for agents whose state can't be shared this way. Ancillary caveat: per-account state that is not part of the conversation dir does not migrate — for claude-code thetodos/<uuid>*.jsonlist stays with the origin account (pinned inclaude-share.ts); a migrated session resumes its full transcript but may lose its todo list, which the agent rebuilds.Shared conversation store (Workstream S). For agents whose conversations live in per-conversation directories (claude-code:
<config_dir>/projects/<escaped-cwd>/), puddle adopts each such directory into a per-profile canonical store atprofiles/<id>/sessions/<agent_type>/<store-key>/the first time it appears on disk (adopt-after-first-write, on the session's firstwaiting_input), leaving an absolute symlink at the original location and mirroring the same symlink into every other account of that (profile, agent). The store-key is the basename of the agent's own conversation dir — for claude-code that name is escaped from the MAIN repository root, so one canonical dir may span a repo's worktrees. All agent-specific mechanics (where the store dirs are, which files belong to a conversation, which are per-account ancillary state liketodos/) live behind an adapterconversationSharehook group; the manager (ConversationShare) is agent-agnostic and serialises every filesystem mutation under ashare:<agent>:<profile>mutex. Creating an account backfills it with symlinks to the profile's existing conversations (folding in a real dir an imported config brought along); boot reconciles links (repairing missing ones, dropping dangling ones); archiving a session keeps its conversation (archiving is a reversible hide — §4), so an unarchive can resume it; deleting an account removes its config dir — which unlinks its symlinks without following them, leaving the shared store and its siblings intact. Consequence: after adoption every account of a profile reads the same conversation history through its symlinks, so per-account agent-usage token totals reflect the profile's shared conversations rather than one account's slice. Verified against Claude Code 2.1.209 that--resumereads a conversation through a symlinkedprojects/<dir>; the full two-account tier-1 flow is a Task 18 acceptance item.Codex, OpenCode, and Gemini CLI do not use that symlink store: their state is not a self-contained per-conversation directory (Codex, for example, combines date-bucketed rollouts with account-local indexes). Their account config dirs therefore remain isolated, and same-agent cross-account migration stays unsupported until an adapter can move or share the agent's complete state safely. Puddle's own restart resume uses the exact recorded agent session ref and does not depend on the agent's picker.
-
Tier 2 — different agent (hand-off). No shared conversation format exists, so the conversation is summarised, not moved: a new session is created in the same worktree on the target agent/account, seeded with a hand-off prompt built from the source adapter's
exportTranscript(tail-truncated to 12 000 characters, so recent turns survive), plusgit log --oneline base..HEADandgit status— appended after the truncation so the git context is never what gets cut. Adapters withoutexportTranscriptfall back to the session's recorded PTY output, ANSI-stripped; that fallback is core, not per-adapter. Degraded by design — the new agent knows what happened, not the old agent's private reasoning (thinking blocks are dropped and tool runs collapse to a count) — but the working tree, branch, and task context carry over completely.Implemented as
POST /api/sessions/:id/handoff {account_id}(§6), which returns the new session, not the one in the path — the reason it is a separate endpoint from/migraterather than a same-agent/different-agent branch of it. The new session reuses the source's directory through the existingjoin_worktreepath, so no secondgit worktree addhappens and the reuse correctly skips onboarding. The source session is left running and untouched (an earlier draft of this section said it "remains in its terminal state"; hand-off does not require it to have exited), and the pair is linked byhanded_off_to/handed_off_fromevent rows rather than a new column.
Limit detection: adapters may provide limitReached patterns (e.g. Claude Code's usage-limit message). On match, the session is badged in the sidebar and the notification (Phase 8) offers "Continue on…" directly — turning the out-of-credit moment from a dead end into two clicks.
All REST endpoints are JSON under /api. Request/response shapes live as zod schemas in packages/shared and are the single source of truth for both daemon and UI.
Version GET /api/version # {version, protocol: {major, minor}} — the handshake endpoint (see Protocol versioning below)
Signal POST /agent-signal {nonce, state} | {nonce,event,agent_session_ref?,cwd,source,parent_agent_session_ref?,native_title?,native_created_at?,native_updated_at?}
# backwards-compatible status or exact lifecycle side-channel (§4), OUTSIDE /api: no bearer;
# per-runtime nonce is auth, 404 on unknown/stale; never proxied by cockpits
Profiles GET /api/profiles POST /api/profiles {name, branch_prefix?} # ids are 10-hex handles, like projects
PATCH /api/profiles/:id {name?, branch_prefix?} # rename (display label, UNIQUE → 409) and/or set branch prefix; dirs are id-keyed so a rename touches nothing on disk
DELETE /api/profiles/:id # 409 while any of its sessions is non-archived; cascades rows + removes its dir
GET /api/profiles/:id/settings PATCH (profile-scope settings JSON — §11 Settings)
GET /api/profiles/:id/state PUT (the profile's ui_state JSON — one workspace shared across its projects; debounced writes; 404 no_state until first write, no cross-profile seeding — §11)
POST /api/profiles/:id/untitled # new worktree-agnostic draft in profiles/<id>/untitled/ → {name: untitled-<n>.md} (10.3, §8)
GET /api/profiles/:id/untitled/:name PUT {content} DELETE # draft content round-trip; the name pattern is the traversal guard
Config GET /api/config PATCH (daemon-scope settings; affects all profiles; the port lives in config.json / --port only and is never surfaced in the UI)
Host GET /api/host # daemon identity {username, hostname, home, displayName?} — the UI's location indicator; displayName is config.json's user-chosen host label, shown in place of the hostname when set (ssh commands always use the real hostname); the origin/port never appears in the UI
Agents GET /api/agents # registered adapters: id, display name, capabilities the UI gates on,
# plus `binary` and `available` (is that executable on the daemon's PATH)
Accounts GET /api/accounts?profile=… POST /api/accounts {profile_id, agent_type, label, skip_permissions_default?, import_dir?} # import_dir: copy a pre-existing config dir (§2)
PATCH /api/accounts/:id {label?, skip_permissions_default?} # rename (label only; config dir stays put) + §11 gate opt-in
DELETE /api/accounts/:id # 409 while any of its sessions is non-archived; removes the config dir (logs the account out)
POST /api/accounts/:id/login # spawns interactive login PTY; UI attaches like a session; response may carry an adapter `hint` (13.1) the dialogue shows
GET /api/accounts/:id/usage # session counts + last activity (puddle); best-effort agent token totals; live_usage (context fill %, cost) via the status line; subscription rate-limit windows via the agent's own CLI (logged-in accounts, daemon-cached) — all nullable
Repos GET /api/fs/dirs?prefix=… # directory autocomplete for repo registration (dirs only, dotdirs included, is_git flag)
GET /api/repos POST /api/repos {path, default_base_branch?, onboarding_notes?, fetch_enabled?} # omitted base inherits the clone's symbolic HEAD; detached HEAD falls back to main
PATCH /api/repos/:id # same fields (onboarding_notes also updatable via the .puddle marker-file sync — §4)
POST /api/repos/:id/fetch # manual fetch now; path must be an existing git repo (validated on POST; ~ expands on the host; re-registering a known path returns it)
GET /api/repos/:id/branches # local + fetched remote heads, deduped, default base first; entries are {name, is_session, session_title} so pickers can label puddle-owned branches
GET /api/repos/:id/worktrees # {worktrees:[{path,branch,is_primary,dirty,local_only}], orphan_branches:[{name,local_only}]}; feeds the join picker (§4) and worktree manager (§8)
DELETE /api/repos/:id/worktrees?path= # prune a worktree dir (branch kept); refuses clone/dirty/live-session (§8)
DELETE /api/repos/:id/branches?name=&confirm= # delete an orphaned branch (no worktree); refuses branch_in_use; confirm required when local-only (§8)
Projects GET /api/projects?profile=… POST /api/projects {profile_id, repo_id, name} # ids are 10-hex handles (/project/:id)
PATCH /api/projects/:id {name?, archived?} # rename (UNIQUE(profile,name) → 409) and/or archive (reversible hide, §11)
GET /api/projects/:id # detail incl. sessions with status
POST /api/projects/:id/archive
POST /api/projects/:id/conversations/refresh # 204 immediately; coalesced activation-driven native scan
Sessions GET /api/sessions?project=…&status=…
POST /api/sessions {project_id, account_id?, kind?, base_branch?, branch?, separate_branch?, cwd?, title?, prompt?, skip_permissions?}
# kind defaults 'agent' (needs account_id); kind:'terminal' spawns a plain shell with
# no account/agent and defaults separate_branch to false (§4 Terminal sessions)
# agents whose CLIs mint conversation ids return immediately with agent_session_ref:null;
# the daemon captures and persists the native ref asynchronously (§5)
# cwd: TERMINAL only (400 cwd_terminal_only otherwise) — a worktree-relative directory
# the shell starts in, confined to the worktree and persisted, so a resume
# returns to it (400 cwd_not_a_directory if it is not one) (§4, §8)
# separate_branch defaults true for agents; false = work directly on the base branch in a
# shared worktree (§4 Relaxed isolation) — branch must then be absent (400 branch_with_shared)
# branch naming: requested branch → prefix + title slug → prefix + first words of the
# prompt → prefix + a memorable adjective-noun-element triple (quiet-tarn-fire) — never a uuid fragment
# skip_permissions is honoured only if the profile gate AND the account opt-in allow it;
# otherwise the request is rejected (400) — enforced server-side, no CLI/API bypass
GET /api/sessions/:id # detail incl. git summary and optional conversation_id,
# parent_conversation_id, conversation_missing, branch_owner, native_sync;
# agent_session_ref/agent_title remain public join fields, not placement columns
PATCH /api/sessions/:id {title?} # rename (does not rename the git branch)
POST /api/sessions/:id/resume | /kill # lifecycle
POST /api/sessions/:id/archive | /unarchive # reversible hide, no body (§4): archive kills a live
# session first, keeps the worktree/branch/conversation, and is idempotent on an archived one;
# unarchive → exited (resume disabled if worktree gone)
POST /api/sessions/:id/migrate {account_id} # tier-1: same agent, resume on another account (§5) — IMPLEMENTED
# validations in order: target exists (404) → same profile (400 cross_profile_account) →
# same agent_type (400 agent_mismatch) → not the current account (400 same_account) →
# not archived (409 session_archived) → target logged in (409 account_logged_out) →
# conversation reachable (409 migration_unsupported when neither the shared store nor a
# migrateSession hook can carry it). A live session is killed first; skip_permissions is
# re-evaluated for the target (§11.4). Returns the resumed session detail.
POST /api/sessions/:id/handoff {account_id} # tier-2 cross-agent hand-off; returns the NEW session
# handoff order: same profile (400 cross_profile_account) → a DIFFERENT agent (400 same_agent —
# same-agent targets belong on /migrate) → not a terminal session (400 not_migratable) →
# not archived (409 session_archived) → worktree present (409 worktree_missing) → target agent
# installed (424 agent_not_installed) and logged in (409 account_logged_out). The new session
# joins the source's worktree and branch; the SOURCE IS LEFT RUNNING and untouched, and the two
# are linked by `handed_off_to` / `handed_off_from` events.
GET /api/sessions/:id/env # captured env (§4): names + byte sizes + optional values (16.2)
DELETE /api/sessions/:id/env # drop every captured var (new spawns stop receiving them); returns {cleared}
Scratchpad GET /api/scratchpad?profile=…&project=… # profile-scoped + the project's own, ordered
POST /api/scratchpad {profile_id, scope, project_id?, body, title?, tags?, agent_type?}
PATCH/DELETE /api/scratchpad/:id # PATCH position = drag-reorder (fractional midpoint)
Layouts GET /api/layouts?profile=…&project=… # saved layouts (12.2): profile-scoped + the project's
# own, by name; without `project`, ALL of the profile's
POST /api/layouts {profile_id, scope, project_id?, name, layout_tree, active_session?}
PATCH/DELETE /api/layouts/:id # PATCH name = rename; layout_tree (+active_session) = save over
Compile GET /api/compilation/capabilities # daemon-host provider availability, source/input extensions, executor, eager support (17.1)
POST /api/compilation/run # {source:{session,path,root?},provider?} → provider/executor/revision/resolved source/artefacts
PUT /api/compilation/mode # the same target + on_demand|eager; eager registers observation and performs an initial build
POST /api/compilation/status # pollable per-target mode/run/result/error state for eager builds
LaTeX POST /api/latex/synctex # managed PDF target + page/x/y → confined rooted source + line/column (17.1, §8)
Files GET /api/worktrees/:sid/tree?path=… # + optional absolute `root=` (10.2): browse override for parent-directory
GET /api/worktrees/:sid/file?path=… # navigation (§8) — on GET tree/file/media/download, the file PUT (10.4,
# so external tabs save to the file they read), since 12.3 the fs
# mutations + upload (making the browse tree the worktree tree), and
# since 12.4 the git family below. Every path stays
# `containedPath`-confined under the OVERRIDDEN root, and each
# mutation's returned `path` is relative to it. Body paths stay relative:
# an absolute one is still a 400 `path_outside_worktree`.
# `:sid` = the NIL uuid (12.4): a DIRECTORY target — no session, work against `root=` (400 `root_required`
# without it). What binds the left sidebar to a project's own repository
# directory when no session qualifies (§8); a `base` diff then compares
# against the default branch of the repo registered at that path, else
# `HEAD`. The nil uuid is already a valid `sessionId` (10.3), so nothing
# persisted had to change.
# # file GET: 5 MiB read cap (413 `file_too_large`)
PUT (write; body = full content; optimistic `expected_mtime_ms` — mismatch or a file that
# no longer exists → 409 `stale_file`; omit it to overwrite unconditionally)
GET /api/worktrees/:sid/resolve?path=…&line=…&root=… # validates terminal/palette path targets; root supports a directory target
POST /api/worktrees/:sid/paste {mime, data} # base64 clipboard image → .puddle/pastes/; returns {path} relative to the worktree (§7)
POST /api/worktrees/:sid/upload?dir=… # multipart file upload into a worktree directory, path-contained (drag-in transfer — §8);
# a multipart filename may carry a dir-relative path (folder drops) — intermediate directories are created, `..`/`.` segments dropped, never resolved;
# 512 MiB per-request cap (413 `upload_too_large`; the body is memory-buffered, so the cap guards the daemon) — the web batches big drops into ~64 MiB requests, so it binds only on one huge file; a same-name file already there is overwritten silently
GET /api/worktrees/:sid/download?path=… # file → bytes; directory → zip stream excluding `.git` and symlinks (Content-Disposition attachment) (§8)
GET /api/worktrees/:sid/media?path=… # file → raw bytes with its real content-type (image/*, video/*, audio/*, application/pdf) + inline disposition, for the media viewer; octet-stream fallback for unknown types (§8)
POST /api/worktrees/:sid/create {path, kind:file|dir} # empty file / mkdir -p; 409 `already_exists`; path-contained (§8)
POST /api/worktrees/:sid/rename {from, to} # one fs.rename — rename or move; 404 missing, 409 `already_exists` (§8)
POST /api/worktrees/:sid/copy {from, to} # recursive copy; `to` auto-suffixed ` copy` on collision; returns the final {path} (§8)
POST /api/worktrees/:sid/transfer?root=… # 16.3: {operation:copy|move, source:{session_id,root?}, from, to};
# server-local cross-filetree transfer into the URL/root-addressed destination; copy suffixes collisions,
# move rejects them and falls back to copy-then-delete only when rename crosses filesystems (§8)
POST /api/worktrees/:sid/delete {path} # recursive remove — no host trash (§8)
Git # every route below takes the same optional absolute `root=` since 12.4 (ignored before it), so the
# Changes, History, and Search navigators can report a DIRECTORY target's git state (the nil `:sid` above)
Git GET /api/worktrees/:sid/git-status # whole-worktree porcelain map [{path, status}] for tree decorations; status ∈ untracked|modified|added|deleted|renamed|conflicted|ignored (§8)
GET /api/worktrees/:sid/git-repositories # 15.3: owning + ignored nested repositories + recursive submodules;
# porcelain-v2 branch/upstream/ahead/behind metadata and staged/unstaged/conflict groups; explorer entries rebased to visible root
GET /api/worktrees/:sid/diff?against=base|head|<sha>&area=staged|unstaged # name-status list; `against=base` resolves to the
# merge-base of the base branch (`origin/<base>` when it exists, else local) and HEAD;
# `against=head` without area is working tree vs. HEAD; staged is HEAD→index, unstaged is index→working tree
GET /api/worktrees/:sid/file-at?ref=…&path=… # blob content for DiffEditor 'original'
GET /api/worktrees/:sid/index-file?path=… # deepest owning repository's index blob (staged/unstaged diff side)
GET /api/worktrees/:sid/git-original?path=… # deepest owning repository's HEAD baseline for ordinary-editor gutter indicators;
# explicitly reports outside/ignored/untracked/unborn/binary cases and follows staged renames back to the HEAD path
POST /api/worktrees/:sid/git-stage|git-unstage # {repository, paths}; literal repository-relative confined pathspecs
POST /api/worktrees/:sid/git-commit # {repository, message, stage_all?}; staged content only unless stage_all was confirmed
POST /api/worktrees/:sid/git-fetch|git-pull # {repository}; pull is fast-forward-only
POST /api/worktrees/:sid/git-push # {repository, set_upstream?}; set_upstream publishes the current named branch
GET /api/worktrees/:sid/log?limit=…&skip=… # history; each commit carries its `parents` (graph lanes)
GET /api/worktrees/:sid/show/:sha # commit detail + changed files
GET /api/worktrees/:sid/search?q=…®ex=&case=&word= # filename + content search (git grep; regex=PCRE)
Ports GET /api/sessions/:id/ports # detected listeners for the session pid tree (platform-specific — §9)
Proxy ALL /proxy/:sid/:port/* # tier-2 HTTP reverse proxy, WS upgrade passthrough
WebSocket at /ws, message envelope {t: string, ...}:
client → server:
{t:'attach', session, term, cols, rows} # term: 'agent' or a shell id ('shell-1', …)
{t:'stdin', session, term, data}
{t:'resize', session, term, cols, rows}
{t:'detach', session, term}
{t:'spawn-shell', session} # bash PTY cd'd into the worktree; reply carries the new shell id.
# On the project-less `home` stream: a shell at ~ on the daemon host
# (§11); while one is live the reply is ITS id — never a second shell
{t:'kill-shell', session, term} # terminate a shell PTY (never the agent term); viewers learn via `exit`
{t:'subscribe-status'} # sidebar live updates
server → client:
{t:'shell-spawned', session, term} # id for a spawn-shell request
{t:'replay', session, term, data} # self-contained ANSI screen + scrollback snapshot on attach
{t:'output', session, term, data}
{t:'status', session, status, last_activity_at}
{t:'renamed', session, title} # title changed (UI rename or agent self-naming)
{t:'session-switched', source_session, target_session, target_project, cause, outcome}
# exact native clear/resume/fork rebound, or focus an existing runtime
{t:'sessions-changed', project_ids} # catalogue-created/recovered/missing/renamed placements; invalidate lists
{t:'exit', session, term, code}
{t:'error', message}
Multi-viewer semantics: any number of viewers (browser windows/tabs, puddle attach) may attach to the same session concurrently; output, status, renamed, and exit are broadcast to all attached viewers, and stdin is accepted from any of them (last-writer-wins, like tmux). Attach restores canonical terminal state (2026-08-14): the daemon parses every filtered PTY byte through a headless xterm and atomically persists its screen, cursor, terminal modes, and 20,000-line scrollback beside the diagnostic raw log. An arbitrary raw-log tail is not a screen snapshot — it can begin halfway through a cursor-addressed TUI redraw, so resetting a cached browser terminal and writing that tail produced the blank screen and displaced fragments seen after leaving and returning. The replay message now carries the headless terminal's self-contained ANSI serialisation, reflowed to the attaching viewer; legacy terminals without a snapshot import their log tail once. Output arriving while a snapshot is made is held for that viewer and delivered after replay, establishing an exact boundary with neither gaps nor duplicates. The persisted state is released from daemon memory when its PTY exits and loaded on demand after a daemon restart. This lives in the agent-agnostic PTY layer and therefore covers every coding agent, login terminal, and shell. Each web terminal keeps its viewport position (2026-08-21): leaving a session, detaching its hidden viewer, and replaying the canonical snapshot on return no longer forces a deliberately scrolled-up terminal to the bottom. Browser-window state keyed by PTY identity restores the same retained buffer line after replay and across workspace remounts; a terminal that was already at the bottom continues following newly appended output. Refit/resize preserves the same distinction. Top-anchored TUI scrolling retains transcript rows (2026-08-24): xterm 6.0.0 implements CSI S by deleting rows when a scroll region starts at row 1, unlike the equivalent line-feed path; agent TUIs use that command while streaming beside a fixed input panel, so lines intermittently vanished from scrollback. Puddle applies the narrowly scoped upstream fix to both the live browser emulator and the daemon's exact-pinned headless emulator, keeping live output and later attach snapshots identical; regions with a non-zero top margin keep native xterm semantics. Live output is frame-batched (2026-08-14): the gateway coalesces the tiny chunks from a chatty TUI into at most one message per ~16 ms (or 64 KiB), and the web viewer independently coalesces messages to its next animation frame for compatibility with older daemons. Bytes and order are unchanged, output is flushed before exit/detach/replay boundaries, and the cap bounds pending memory. This prevents a 100-redraw/s Codex session from growing xterm's write queue faster than the browser can paint, which delayed echoed input and could leave a synchronised redraw blank mid-frame. Device queries get exactly one answer (2026-08-10): every viewer is a full terminal emulator (web xterm, or the user's own terminal under puddle attach) and each auto-answers a program's ESC[6n cursor-position / ESC[c device-attribute / DECRQSS queries independently — the program reads one reply and the rest landed in the shell's input line as junk keystrokes. The gateway keeps one answering viewer per (stream, term) — the most recent attacher/resizer, i.e. the viewer whose grid won the PTY size, so its cursor reports are the authoritative ones — and strips reply-shaped sequences from every other viewer's stdin (ws/device-replies.ts; typing never matches the reply shapes, with modified F3 as the one documented exception, deliberately let through). With a single viewer nothing is ever stripped, and the role falls to the most recent remaining viewer on detach. A PTY has exactly one size: the most recent attach/resize wins, and the daemon delivers the resize (SIGWINCH) so full-screen agent TUIs redraw at the new size — smaller concurrent viewers scroll rather than reflow. The focused viewer re-claims the size (2026-08-10, tmux's window-size latest): clicking into a terminal refits and re-sends that viewer's dims, so the geometry follows the window actually in use — before this, a second window's re-attach shrank the PTY under the first, which then faithfully rendered the TUI's small-screen redraw as a blank bottom/right until a page reload re-asserted its dims (a same-size re-assert is a no-op end to end: no SIGWINCH). The web viewer uses xterm's built-in renderer (2026-08-14): the optional WebGL addon was removed after context loss and silent backing-store eviction repeatedly blanked working terminals and recreating the same renderer on reload reproduced the failure. A foregrounded viewer still refreshes every row after one animation frame; this recovery does not resize or clear the renderer, so it introduces no blank intermediate frame. Genuine geometry changes remain ResizeObserver's responsibility. That size outlives the PTY (decision 2026-08-04): the daemon remembers the last size asked for per (stream, term) — whether or not anything was live to receive it — and starts the NEXT PTY on that stream at it, falling back to 120×32 when no viewer ever sized it. A resume or an account migration replaces the process under an already-attached viewer, and nothing re-sends the size then (no fresh attach, no container change), so a fixed default left the new agent drawing to a screen that was not there. The web side closes the same gap from its end: a terminal refits, repaints, and re-sends its size whenever its cell size changes (the terminal font-size setting resizes the CELL, not the container, so no ResizeObserver fires) or whenever the status feed reports its session going from a dead status back to a live one — which only a fresh process can do. Snapshot replay ends with a full viewport refresh and is render-only from the viewer's side: recorded terminal state may contain the query sequences programs once emitted, and re-parsing them must not re-answer them — the viewer's own OSC side effects (colour queries, clipboard writes) and xterm core's CSI auto-replies (ESC[6n cursor-position reports, device attributes), which surface through the same onData event as typing, are all suppressed while a replay parses. Un-suppressed, every re-attach typed the stale answers (^[[37;143R…) into the shell's input line as junk keystrokes (fixed 2026-08-10). The browser's terminal scrollback defaults to 20,000 lines (2026-08-14, migrating the exact old 5,000-line default): wrapped carriage-return progress such as tqdm can otherwise consume 5,000 rows and evict everything above it during one training run. The URL binds to one project, the workspace to the profile: routes are / (project dashboard), /project/:id (the workspace — navigator, terminals, editor), and within it /project/:id/session/:sid (the active session). Diff and History are left-navigator modes (§8), not routes. The centre tiling area is ONE surface per profile (§11 reload semantics): navigating between projects keeps the same workspace, with only the URL-bound session (and the left sidebar's binding) changing. Everything is deep-linkable, so reloading a window (or opening it on another laptop) lands back in the same state.
Resize preservation is logical-line-aware, not a raw scrollback-row replay: xterm can insert or remove any number of wrapped rows above the viewport when its column count changes, so the viewer anchors the logical line currently at the top and resolves that anchor after reflow. The anchor remains alive briefly after the PTY resize because an application may react to SIGWINCH asynchronously. If it purges and rebuilds the entire scrollback (for example, an inline TUI re-emitting its transcript at the new width), xterm necessarily disposes the exact marker; the viewer then restores the same proportional transcript progress after each parsed rebuild chunk until output settles. User scrolling supersedes the guard immediately, bottom-following viewers remain bottom-following, and alternate-screen activity never overwrites the normal transcript's saved position. The same shared fit path covers pane/window resizing, focus size reclamation, font changes, and keep-alive reattachment for every agent terminal, login terminal, and shell; zero-sized parked containers retain their last geometry and viewport.
The CLI (and the UI it serves) and the daemon ship separately from Phase 6, so the communication layer — every REST shape and WS message above — is a versioned contract. Two version numbers exist and must not be conflated:
- App version: each component's npm/tarball semver.
puddled --versionreports that daemon itself;puddle --versionis the local, offline component inventory — CLI, daemon, and desktop on separate rows, each with its own app version and speaking protocol (ornot installed; a legacy Linux AppImage whose stable filename predates self-recording is reported as installed with unknown versions rather than guessed). It reads the daemon's release metadata without executing an unknown historical binary, and the packaged desktop records its actual path/version/protocol in~/.puddle/desktop-install.json, so moved AppImages remain discoverable. Released versions predating exact component metadata use the immutable historical ledger incomponent-versions.ts. App version alone says nothing about compatibility. - Protocol version:
PROTOCOL_VERSION = {major, minor}, exported frompackages/shared— the protocol package.packages/sharedalready holds every REST and WS schema as executable zod definitions; it is the protocol description, and the single source of truth for it.packages/shared/PROTOCOL.mddocuments the bump rules; there is deliberately no second, prose copy of the schema anywhere — prose copies drift.
Compatibility rule: same major ⇒ compatible, in both directions. Everything within a major is additive-only:
- Additive (bump
minor): a new endpoint, a new optional request/response field, a new WS message type, a new enum value peers may ignore. - Breaking (bump
major, resetminor): removing or renaming an endpoint, field, or WS message; changing a type or the semantics of an existing field; changing auth or the WS handshake.
Two wire rules make the additive path safe: receivers ignore unknown WS message types and tolerate unknown JSON fields (schemas use loose objects where extension is expected). A newer client on an older daemon feature-detects against the daemon's minor and hides what the daemon cannot do; an older client on a newer daemon simply never asks for the new things.
Protocol 16.0 (2026-08-13) adds locked to the closed editorTabRefSchema.view enum (§8). This is a major bump even though it adds a value: a 15.x daemon validates ui_state, rejects a snapshot containing locked, and would make every later workspace save fail instead of safely ignoring the value. The major-version handshake therefore upgrades the daemon before the client can persist the mode. Locked-preview scroll positions are transient browser state; they add no REST field, WebSocket message, or daemon storage.
Protocol 17.1 (2026-08-28) additively adds daemon-host compilation-provider discovery, generic run/mode/status routes, LaTeX inverse SyncTeX, and optional editor-tab compile_mode/generated_by fields (§8). A newer client hides compile controls below 17.1; older clients ignore the optional tab fields and never call the new endpoints.
Protocol 17.2 (2026-08-28) additively lets compilation failures carry bounded compiler output and normalised rooted source diagnostics. A 17.2 web client renders an expandable error notification and Monaco markers; older clients continue to show the failure message and ignore the additional detail.
Handshake (CLI, on every launch, before opening the browser):
protocol.majorequal → proceed. App-version skew within a major is normal and silent;puddle upgrade daemonremains available but is never required.- Daemon
majorolder → the CLI updates the daemon automatically (re-runs the bootstrap — §10): print the count of live sessions that will be interrupted, update, restart; sessions resume through the normal reconcile path (§4).--no-upgradeaborts instead of updating, for the rare case the user must not interrupt work now. - Daemon
majornewer (this host was updated by a newer CLI elsewhere) → the CLI cannot fix itself mid-run; refuse with the exact one-line upgrade command for the CLI.
This is the Docker-daemon model reduced to its useful core: a negotiated, versioned local API — but because the CLI owns daemon installation, we keep exact-major lockstep via auto-update instead of maintaining server-side compatibility shims for old clients.
- URLs: xterm.js
web-linksaddon for plain-text URLs, plus a customlinkHandlerfor OSC 8 hyperlinks (how Claude Code prints its URLs) — both open through the same path; without the handler xterm's built-in OSC 8 fallback showed a native confirm() and then opened a blank window whose location it assigned, which the desktop shell's window-open filter denies, so accepting the dialogue opened nothing (fixed 2026-08-05). Plain click (or cmd+click—match both) opens in a new browser tab. In SSH mode, URLs pointing atlocalhost:<port>on the host are rewritten to the tier-2 proxy path so they work from the client; in local mode they are left untouched. The SSH-mode signal is the?host=boot param the CLI sends at connect time; a localpuddle launchboot clears any host stored by an earlier SSH launch on the same origin, so the mode can never go stale. - File paths: a custom xterm.js link provider (
registerLinkProvider) matchingpath(:line(:col)?)?patterns. A token is path-shaped when it has a prefix (/,~/,./,../), contains a/(a multi-segment relative path like.worktrees/hil-demos), or is a bare filename with an extension (src/foo.ts:12:3) — so extensionless directories and files light up too, while plain prose and decimals stay quiet. A leading@(Claude Code's file-ref syntax,@~/notes.md) is stripped. On hover, validate viaGET /resolvebefore underlining (the real filter — a false positive just never underlines); on cmd/ctrl+click, open the target. The command palette's Open path action accepts the same host paths directly, resolving relative input from the Files sidebar's bound worktree or project-directory fallback. Both entry points share the resolver, while directory opening reflects their intent: a terminal link to a directory enters a pinned browse there; Open path to a directory already inside the bound worktree/directory target keeps that tree, surfaces Files, expands every ancestor and the directory itself, selects it, and leaves the current pin unchanged. The whole daemon host resolves, not just the worktree (15.2): relative paths resolve against the worktree/directory target,~against the daemon host's home, and absolute paths as themselves — only a path that names nothing 404s (the pre-15.2 escape-collapses-to-404 rule is retired; the browse machinery already serves any absolute root, 12.3/12.4, so confirming existence added no capability). Target containment decides the SHAPE of the answer: a file inside opens the ordinary worktree tab (or a rootedexternaltab for the project-directory fallback); a file outside returnsroot+ relativepath, opens anexternaltab (§8), and makes its containing directory the shared Files / Search / Changes location; a directory returnskind: 'dir'with its absolute path and, when contained, its optional root-relativerelative_path(16.1). A directory outside the target — or any terminal-linked directory — binds the file tree to it as a pinned browse: the same state the..walk enters, so unpinning leaves the browse and Back to the worktree releases the directory pin as it returns. Every directory path surfaces Files (uncollapsed, or the overlay on a phone) so the result is visible. Containment is checked against the raw target root and its realpath (macOS tmpdir symlinks make agents print either spelling); a symlink inside the target still reads as inside, as the explorer treats it. - Image paste: pasting an image into a terminal (an
image/*clipboard item with no text alternative — screenshots, copied images) uploads the bytes viaPOST /api/worktrees/:sid/pasteinto the session worktree's.puddle/pastes/(git-excluded like the rest of.puddle/— §4) and inserts the returned worktree-relative path into the terminal's stdin, unsubmitted, for the agent to read. This is what makes image paste work in SSH mode: the agent's own clipboard read (e.g. Claude Code's Ctrl+V) happens on the host machine, whose clipboard does not hold the client's image — so the bytes travel over the API instead. Works identically in local mode; mixed clipboards (text + image) keep xterm's normal text-paste path. Capped at 20 MiB; png/jpeg/gif/webp. - macOS line editing: on Mac clients the browser eats ⌘←/⌘→ (history back/forward) before the PTY sees them. A custom xterm key handler intercepts them (and ⌘⌫/⌘⌦) and sends the readline control codes instead — ⌘← →
Ctrl-A(line start), ⌘→ →Ctrl-E(line end), ⌘⌫ →Ctrl-U(delete to start), ⌘⌦ →Ctrl-K(delete to end) — matching native macOS field behaviour. Applied only on Mac, so the Meta/Super key is untouched elsewhere. - Terminal copy/paste: the copy chord — ⌘C on Mac, Ctrl+Shift+C elsewhere (
Ctrl-Cstays the interrupt on every platform) — copies the terminal's local selection to the clipboard (xterm has no built-in copy); with nothing to copy it falls through and does nothing. Highlighting alone never touches the clipboard (decision 2026-07-31): an agent'sOSC 52 ; c ; <base64>clipboard write is stashed, not committed — a mouse-reporting TUI (Claude Code) emits one for every drag selection, so committing directly made highlighting auto-copy — and the copy chord commits the stash when no local selection exists. Typing discards the stash (the TUI selection behind it is gone); mouse reports (wheel scrolling) keep it; OSC 52 read requests (?) are ignored so the PTY can never exfiltrate the clipboard. ⌘V needs no special handling — xterm already pastes on the browser's native paste event (text takes xterm's normal path; images take the/pasteroute above), so intercepting it would paste twice. Selection under mouse-reporting TUIs: an agent that enables mouse tracking receives mouse drags itself, so a plain drag makes no local selection — the agent's own selection reaches the clipboard through the OSC 52 stash + copy chord above. Shift+drag always forces a local selection, and on Mac ⌥+drag does too (macOptionClickForcesSelection, matching Terminal.app/iTerm convention). - In-view find: ⌘F on Apple clients and Ctrl+F elsewhere opens the same compact find widget over every xterm surface (agent, terminal, login, and dashboard shells). The official xterm search addon searches the complete retained scrollback, follows soft-wrapped lines, highlights all matches, selects and scrolls to the active one, and updates as output arrives. Enter / Shift+Enter and the arrow controls move next / previous with wraparound; case, whole-word, and JavaScript-regex toggles match the rendered-file control (§8); Escape closes it and restores terminal focus. On macOS plain Ctrl+F remains a PTY key (readline forward-character) rather than being stolen as a second find binding.
- OSC 7733 is puddle-internal: session shells report captured-env changes over this side-channel (§4), which the daemon parses and strips at the PTY-output choke point — before recording and broadcast — so the web terminal,
puddle attach, replay, and the on-disk logs never see it. - Open in editor: a session's menu offers "Open in VS Code" / "Open in Cursor", each a deep link opened via
window.location.href(notwindow.open, which would pop an unwanted blank tab for a custom scheme handler). Local mode:vscode://file<worktree_path>/cursor://file<worktree_path>(the worktree path is absolute, so plain concatenation already yields the correct single slash; each path segment is percent-encoded withencodeURIComponentso spaces/#/?/%in filenames can't corrupt the URI, while the/separators stay literal). SSH mode:<scheme>://vscode-remote/ssh-remote+<host><worktree_path>— the host is minimally escaped (only%#?and space) souser@host:portkeeps its literal@/:for the remote-authority parser; VS Code and Cursor share the same remote-authority scheme since Cursor is a VS Code fork. Host precedence: the client setting (Settings → Editor → "SSH host for editor links") beats a captured?host=boot param (stored inlocalStorage, stripped from the address bar) beats local mode. The CLI sends?host=at connect time; for a manualssh -Ltunnel it never covers, the client setting is how a host gets configured.
Embedded preview drag shield. While a strip drag is active, every pane body carries a transparent interaction shield so an HTML or native-PDF iframe cannot swallow the pointer and make that pane unreachable. Generated LaTeX PDF tabs remain ordinary persistent external tabs: they move, reorder, and edge-split into any pane through the same layout reducer as every other file tab.
Workspace layout. Three columns: a left navigator whose top is a horizontal icon row — Files · Search · Changes · Worktrees — with the collapse control on the right of that row, over the selected navigator's content. Collapsed, the navigator becomes a slim rail of those same icons stacked vertically; clicking one expands the sidebar straight to that navigator (state persisted in ui_state). A centre is a free-form tiling area — one surface per project by default, or one shared across the profile's projects with projectBasedLayout off (§11 reload semantics) — where editor tabs (files, diffs, commit diffs, media) and agent terminals share one space: a tab drags along a strip to reorder (a caret marks the insertion point), onto a pane's body to move into it, or onto a pane's edge to split it (top/bottom/left/right) into an arbitrary recursive grid of resizable panes (VSCode editor-groups style). A STRIP drag (the tab chip itself) always moves — file tabs exactly like terminals, leaving nothing behind in the source pane; another pane's tab of the same file is an independent tab and stays put (the move takes the key from the source leaf only) — while a SIDEBAR drag opens (decision 2026-08-06, DropSpec.copy): the file tree names content, not a pane, so dropping a file already open elsewhere grows a second tab of it (both share one refcounted buffer) instead of yanking the first out of its pane — which is also what makes the same-file split possible (edge-drop a pane's only file onto its own edge; move semantics collapsed that to a no-op). Within ONE pane a tab stays unique — a copy-drop into a pane already holding the file focuses it — and a session tab is one live PTY, so it always moves whatever the gesture. Every drop pins: a preview tab deliberately placed stops being ephemeral. The sidebars feed the same engine: a file row from the tree or a session from the right sidebar (row or collapsed rail glyph) drags onto a pane and drops through the identical zone geometry — centre inserts, an edge splits — opening a PERMANENT tab, so one gesture opens and positions at once (a dropped session also claims the URL). These sidebar drags ride native HTML5 DnD (the tree and the session list already are native draggables, for internal moves and reordering) with a zod-validated application/x-puddle-tab payload; pane-to-pane tab drags keep their dnd-kit path. Terminals stay mounted and their DOM is adopted into whichever pane shows them, so a move never drops a PTY; open editors share one refcounted model per file. Preview tabs (VSCode-style): single-clicking a file, session, or terminal opens it in one reusable preview slot per pane (rendered in italics) that the next single-click replaces; double-clicking the item — or its preview tab — pins it permanently. A slot showing a rendered view stays rendered (decision 2026-08-05): the file replacing it inherits view: 'preview' when it too has a rendered view, so a directory of markdown can be skimmed by single-clicking down it instead of toggling every arrival one tab at a time. A view the caller names explicitly (the ⌘-clicked link out of a preview) wins, and a file with no rendered view inherits nothing. Every list that opens content behaves this way (decision 2026-08-03), not just the files tree: an uncommitted change, a file inside a commit, and a search hit all peek on a single click and pin on a double, so scanning a result list leaves one tab behind rather than a strip of them. A preview terminal is never silently discarded (it is a live PTY): opening something else pins it and opens the new tab alongside, so an agent you were watching can't vanish under a click. The tree persists in ui_state.layout_tree (a leaf's previewKey names its ephemeral tab; legacy snapshots migrate to an equivalent editor-over-terminal split). Double-clicking a strip's blank tail — or the tab.newUntitled hotkey (⌃⌥N; plain ⌘N on desktop), which targets the focused pane — opens a fresh untitled draft in that pane (10.3) — deliberately worktree-agnostic: the content lives in the profile's own store (profiles/<id>/untitled/untitled-<n>.md, POST/GET/PUT/DELETE /api/profiles/:id/untitled[/:name]), persisted continuously (debounced) while edited, so it survives reloads and follows the profile across machines. Its tab (kind 'untitled', session = the nil uuid — it binds to no worktree, and the sidebar falls back to the URL session while it is focused) rides the ordinary tiling machinery. ⌘S opens a save-as dialogue targeting the sidebar-bound worktree: pick a worktree-relative path (never overwrites an existing file), the draft is written there, deleted from the profile store, and the tab swaps to an ordinary file tab. Closing an untitled tab asks first, then discards the draft — nothing lists orphans, so the tab is the draft's only handle. And a right sidebar lists the project's sessions — "session" is the umbrella over both kinds, agent and terminal (§4); the creation affordances say "New agent" (robot icon) and "New terminal", in the sidebar header and the ⌘K palette alike; the header's symbols read Agent · Terminal — each creates a session (the Scratchpad is a top-bar popover, §11, no longer a sidebar view; the old ui_state.right_panel key remains in the schema for wire tolerance but nothing reads it) (header mirrors the left navigator: the collapse control on the left edge, the Agent · Terminal controls on the right; collapsible to a slim rail that keeps the reopen, new-agent, and new-terminal buttons, then — below a divider — one clickable session glyph per live session so you can switch sessions without reopening the sidebar, the active session's glyph carrying the same bg-elevated fill-shift that marks the active session when expanded — no border). In the expanded list the active/hover row bleeds its fill to both sidebar edges, and each row's actions menu (⋯) reserves no width until the row is hovered (so the title/branch/badges use the full width). The same lifecycle menu (resume/kill/rename/archive/unarchive/move/open-in-editor/open-terminal-in-worktree/spawn-agent-in-worktree — the last two spawn a session that joins the session's worktree directory: a plain shell, or an agent on a chosen account picked from a submenu whose first entry is the profile's default account, so opening it and pressing Enter spawns the default, §4) also opens on right-click of a session wherever it appears — the expanded row, the collapsed rail glyph, or its tab in the top strip. Archived sessions are not shown inline; they collapse under an Archived disclosure at the bottom of the list, where each still carries the ⋯ menu to unarchive it (§4). Open, the pane groups by project exactly as the live list does (same project order, a dimmer inert header — finding a session again is the job, not navigating), each row trading its account line for when it was last active, and its top border drags to give the pane more or less of the sidebar (decision 2026-08-04; the height is per-browser in localStorage, capped against the sidebar so it can never swallow the live list — components/resizable-height.tsx, which the Changes navigator's uncommitted/History border uses too). Archive-by-drag (decision 2026-08-03): a session dragged onto the Archived header — or, collapsed, onto the archive icon at the rail's foot (which a click expands) — is archived on drop, no confirmation (nothing is destroyed, §4). Both payload paths land there: the sidebar's own rows and rail dots (native application/x-puddle-tab drags) and the centre strip's terminal tab chips (dnd-kit — the tiling DnD context wraps the whole workspace so its drags can leave the panes); editor tabs are ignored. While a session drag is live the Archived header shows even with nothing archived yet, so there is always somewhere to drop. By default the sidebar shows every project's sessions, grouped by project — a clickable project-name header when expanded, a divider plus a five-character project label on the collapsed rail — the project order inheriting the homescreen's profileSettings.projectOrder (§11); a click navigates into that session's own project. Two INDEPENDENT per-browser client settings decide the scope (Settings → Appearance; they were one setting through v0.0.24, which forced an editor layout choice on anyone who only wanted a focused session list — decision 2026-08-03, and client-settings.ts reconciles a stored snapshot of either vintage in both directions so neither behaviour flips under an upgrade). showAllProjectSessions (default ON) is this list: off, it lists only the current project's sessions, keeping every project name as a navigation target either way, and the Archived disclosure follows it — every project's archived sessions in sidebar project order when on, only this project's when off — except under a project-based layout, which always keeps the disclosure to its own project (decision 2026-08-05: that mode's whole premise is a window about one project, so another project's archived session offers nothing to do here). Within a project the disclosure is ordered newest conversation first, by the same last-activity timestamp its rows show (falling back to creation), and takes no saved drag order — nothing is dragged into it, and "what was I last doing" is the only question it answers. projectBasedLayout (default ON since 2026-08-04) is the CENTRE: on, the tiling area persists per profile and project instead of one shared surface (§11 reload semantics) — returning to a project brings its own panes back, which is what a project-shaped workspace implies; off, one surface follows you between projects. Either combination is expressible, including a per-project layout beside a cross-project session list. The session list scrolls without a visible scrollbar while the controls stay fixed, so a long list still works; a collapsed glyph's tooltip shows the session name over its branch. The list is drag-reorderable in both views — within a project's group in the cross-project view (a session never changes project by drag), and in the collapsed rail just like the expanded rows — persisted per-profile in ui_state.session_order; a newly created session appears at the top of its group until dragged (the ordering keys on session id, so it applies uniformly to every session type). Changes and Search are navigators, not full views: each is a list, and selecting an entry opens its content as a centre-editor tab, so the editor is the single content surface. The pin binds the whole sidebar to one worktree — Files, Changes, and Search all follow it; unpinned, the sidebar follows the focused pane's active tab: every tab carries the worktree it was opened from (a file/diff tab its session, a terminal its own session), so clicking into an editor or a terminal body — like clicking its strip chip — re-binds the file tree, search, and git views to that tab's worktree (a terminal click also claims the URL), with the URL-bound session as the fallback for an empty pane. When nothing qualifies the sidebar binds to the PROJECT'S OWN repository directory (decision 2026-08-03): a project whose sessions are all archived, one with none created yet, or simply none in focus used to leave Files, Changes, Search, and History empty — four blank panels while a project was open, which is always. The fallback is a directory target (protocol 12.4): the nil session id plus ?root=, so every navigator keeps the props it already takes for a worktree and the daemon resolves the directory from the root (§6 API). It is a full binding, not a preview — the tree mutates, files open and SAVE (as external tabs, the kind whose identity and requests carry a root), Changes shows the directory's own uncommitted work, History its commits, Search greps it, and ⌘S places a draft in it. Two things are genuinely session-shaped and stay absent: the pin (there is no session id to pin — it is the fallback, so nothing can pull the binding away) and Open Terminal in Directory (the daemon confines a terminal's cwd to a worktree, 11.1). On a daemon older than 12.4 the empty state stands, since it would answer git questions about the wrong repository. The header under the icon row names the one absolute current file-tree location shared by Files, Search, and Changes (with the daemon home ~-compressed so the identifying tail gets the width), and carries the pin toggle (a solid glyph when pinned; left of the worktree dropdown) plus the dropdown to bind another. Parent-directory browsing (10.2, editable 10.4): a .. row under the Files header walks ABOVE the worktree into a browse tree (?root= on the file routes) — expand directories, keep walking up, open files as external tabs (editorTabRefSchema.kind 'external' + its absolute root; media renders through the same viewer, and markdown/HTML get the same source ⇄ rendered-view toggle — the preview pipeline keys its buffer and routes its media/asset fetches by the same (session, path, root) the source editor uses, and a ⌘-clicked link inside a rooted preview opens its target against the same root, decision 2026-08-06). External tabs are full editors: the shared buffer store, IndexedDB drafts, cross-window peer sync, the model refcount, and the conflict-safe save (409 stale_file → reload/overwrite) all key by (session, path, root) — the root is part of the buffer identity, or a rooted file and a worktree file sharing a relative path would silently share one buffer — and the save PUTs with the same root its GET read. The browse tree is the worktree tree (12.3, decision 2026-08-03): it mounts the same ExplorerProvider/FileExplorer with a browse root instead of a worktree, so every right-click affordance works above the worktree too — New File/Folder, Rename, Delete, cut/copy/paste, drag-move, drag-in upload, Download — because the fs mutation and upload routes take the same ?root=. Root-qualified Git-status queries keep decorations honest there; the one genuinely worktree-shaped action that stays absent is Open Terminal in Directory (the daemon confines a terminal's cwd to the worktree, 11.1). On a daemon between 10.2 and 12.3 — which serves the browse tree but would resolve a mutation against the WORKTREE — the tree goes read-only (menus keep only Copy Path and Download) rather than silently touching the wrong files. The shared location header renders inside the browse tree's provider, so its Refresh · Collapse Folders utilities drive that tree too. Entering the browse pins the sidebar so follow-the-active-session cannot yank the tree away mid-browse; the browse state itself is ephemeral (a reload lands back on the worktree). While browsing outside a worktree, the shared navigator header becomes that browse root; a compact row below offers .. to walk up and a return button to leave the browse. A file dragged out of it into the tiling area opens as an external tab carrying the browse root, not a worktree file tab.
Save follows logical tab focus (decision 2026-08-10): the save shortcut always targets the focused pane's active tab — an ordinary file saves its shared buffer, while an untitled draft opens save-as only when that draft itself is active. The shell captures the shortcut before Monaco because clicking a draggable tab chip can change logical pane focus without moving document.activeElement; a Monaco editor in another pane must never save merely because it retained the DOM caret.
-
File explorer (the Files navigator): the tree is always bound to exactly one worktree — there is no merged project-wide view. By default it follows the active session (via the shared sidebar binding above): switching session tabs switches the tree to that session's worktree. The sidebar pin locks the binding to a chosen worktree (so you can read session A's files while watching session B's terminal); unpinning re-enables follow-the-session. The row for the file open as the active editor tab (when that tab belongs to the bound worktree) carries the
bg-elevatedactive fill, so the currently-open file is highlighted in the tree. Folder rows carry their own name as a tooltip: directory names are the ones that run long (dated or slugged directories), and in a narrow tree an elided folder name has nothing else in the row to read it from, while an elided FILE at least names itself on the tab it opens.- Git decorations:
GET /git-status(a whole-worktreegit status --porcelainmap, polled like the diff view) drives per-row colour + a one-letter badge —Untracked/Added (success-green),Modified/Renamed (warning-amber),Deleted/Conflict (interrupted-red); ignored-but-present files dim tofg-mutedwith no badge, and a folder is tinted by its highest-priority descendant. The status set is a distinctGitStatusschema (notDiffStatus), which the tree also uses for the folder roll-up. - File-type icons: a curated per-extension/filename icon set (lucide glyphs coloured from the theme-aware
icon-*hues in tokens.css — §12), with a muted generic fallback. - Symlinks:
/treeresolves a symlink to its target kind, so a link to a directory is explorable and a link to a file opens; the entry carries asymlinkflag and the tree draws the link icon at the symlink's own row (its children keep their normal icons). Symlinks are followed even when their target is outside the worktree (read + write) — a symlink is a real object the user placed in the worktree, and puddle runs as that user. The containment guard (containedPath, shared by every file route and the terminal-link/resolve) rejects only lexical escapes: absolute paths and..climbing above the root. Because..normalises lexically before any link is resolved, following a symlink reaches exactly its target subtree, never a sibling or parent outside it. Only a broken/unresolvable link (or a non-file/dir target) stays a non-expandablesymlinkleaf. - Context menus (files, folders, empty space) match VSCode within the remote-web model: Copy Path / Copy Relative Path, Cut / Copy / Paste, Rename / Delete, Download; folders and empty space add New File / New Folder plus Open Terminal in Directory (a new terminal in the current worktree,
cd-ed to that directory — the worktree root for empty space). Every path-backed editor tab (fileorexternal, never an untitled draft) opens those same file rows on right-click, plus Reveal in Filetree: when the file's absolute path is already inside the visible Files root, that root stays put and the tree expands/selects the root-relative path (including generated PDFs below the local.puddle); otherwise a worktree file binds Files to its owning worktree and an external file temporarily rebases Files to the file's containing directory before selecting it. Choosing Rename… on a tab edits the filename inline in that tab chip (basename selected; Enter or blur commits, Escape cancels), renames it on disk, and retargets every open live view of the same buffer while preserving unsaved text and undo history; it does not move focus into the Files sidebar. Non-applicable VSCode items (Reveal in Finder, Share, …) are omitted; Open to the Side awaits the tiling-layout work. - Mutations: create / rename / move / copy / delete ride confined endpoints (below). Cut/copy state is a device-local browser clipboard, shared by same-origin Puddle windows and retained across project/filetree switches and reloads. Paste within its source filetree keeps using
/renamefor cut or/copyfor copy, including against older daemons; filetree identity is the effective absolute directory, so two sessions sharing one worktree stay on that legacy path. Cross-filetree paste on the same daemon host is protocol 16.3's/transfer: the request identifies the source session/root independently from the URL-addressed destination; moves use a direct rename when possible and copy fully before deleting the source across filesystems. A newer UI positively feature-detects 16.3 before offering this path, so an old daemon never receives an unknown mutation. Copy auto-suffixescopyon collision; moves reject collisions. Internal drag-to-move and paste both refuse moving a folder into its own subtree. Create and rename edit inline in the tree (no dialog, per HUMANS.md); delete is the one confirmation, since the host has no trash. - Selection & keyboard: ⌘/⇧-click multi-select over a flattened visible-row model; full roving arrow-key navigation (↑↓ move, →← expand/collapse/step, type-to-jump), plus
F2rename (files: also a second single click on the already-selected row within ~1.5 s, Finder-style — deferred a beat so a double-click still pins instead (lib/second-click.ts); folders: no click gesture at all — the context menu's Rename… andF2are the whole story, because a folder's clicks belong to expand/collapse and every gesture tried fought that, the second-click timer firing mid-toggle and the double-click variant opening a rename box whenever a folder was flipped open and shut quickly — decision 2026-08-04),⌘⌫delete,⌘C/X/V,⌥⌘C/⌥⇧⌘Ccopy (relative) path. Every action respects the multi-selection when the clicked/grabbed row is in it — cut/copy/delete, drag-to-move (a JSON list payload with an "N items" drag chip), Download (one download per entry, sequential), and Copy (Relative) Path (one per line); a selection holding both a folder and its descendants is pruned to the folder before acting. - Header utility bar (files mode): Refresh · Collapse Folders, plus the pin and worktree-picker controls — creation (New File / New Folder) lives only in the context menus. At rest these are hidden and the shared navigator title — the current file-tree location's absolute path in Files, Search, and Changes — spans the full width (no hidden control reserves space — the pin stays shown while pinned to keep that state visible); on header hover/focus they surface as an overlay at the right edge, and the title, a marquee, eases leftwards to reveal any tail the overlay covers.
- Git decorations:
-
Editor tab identity is (kind, session, path[, root, sha]), not path alone: a
fileeditor, a worktreediff, and acommitfile diff are distinct tabs even for the same path (labelledapi.ts·api.ts (diff)·api.ts @1a2b3c4). Andsrc/api.tsin two worktrees is two different files with independent dirty state — tabs suffix the branch when the same basename is open from more than one worktree (api.ts — alice/fix-auth); when those branch labels are themselves equal, the absolute worktree paths disambiguate them. Same-basename paths within one session show their relative paths, while two external files with the same root-relative path under different roots show their absolute paths, so physically different files in one pane never present indistinguishable labels.fileanddifftabs share the one editable buffer for their(session, path);externalbuffers additionally key onroot;committabs are read-only. -
Monaco for file view/edit. Saving PUTs the full file; daemon writes to the worktree so running agents see the change immediately. Editor keybindings are registered in one place (
editor-keybindings.ts): ⌘/Ctrl+S saves, ⌥Z toggles line wrap (flipping theeditorWordWrapclient setting so every open editor follows and the choice persists); Monaco's stock multi-cursor / comment / find bindings are kept. -
Rendered-view find: Monaco's ⌘F (Apple) / Ctrl+F (elsewhere) entry point also opens a matching find widget in every rendered Markdown or HTML file view, including preview, linked, and locked modes. It highlights all rendered-text matches, scrolls the active match into view, wraps Enter / Shift+Enter and arrow navigation, and offers case, whole-word, and JavaScript-regex toggles; Escape closes it. Markdown uses non-mutating CSS Highlight ranges, so links and sanitised markup stay intact. Sandboxed HTML keeps
allow-same-origindisabled: a credential-free injected bridge performs the same search inside the opaque-origin document and reports only match state to the parent. Each view caps simultaneous highlights at 1,000 and labels the count as a lower bound when capped. -
Media viewer for non-text files: a
filetab whose path is an image, video, audio clip, or PDF renders an inline preview (<img>/<video>/<audio>/<iframe>) instead of Monaco, fetched fromGET /mediathrough the authed API as an object URL (no token in an elementsrc) and revoked on close. Unknown binaries keep the "Binary — use Download" fallback. -
Compilation providers (protocol 17.1; command settings 17.3) are daemon-host capabilities, not browser assumptions: the generic daemon registry advertises provider ids, source extensions, open-input extensions, the selected local executor, and eager support. The generic service owns target identity, on-demand/eager mode, status revisions, one-in-flight/one-dirty-run coalescing, dependency observation, artefact descriptors, and project/file/mode settings lookup; a provider owns tool discovery, entry-point resolution, command templates and validation, exact argument arrays, dependencies, managed output, and optional navigation. LaTeX is the first provider (
.texsources and.tex/.bib/.sty/.cls/.bstinputs); adding another compiled type must not add TeX branches to the generic service, tab strip, settings storage, or dialog. Discovery runs on the machine hostingpuddled— including an SSH host — and searches its effectivePATHplus bounded conventional TeX Live, MacTeX, TinyTeX, and MiKTeX locations. LaTeX preferslatexmk, then Tectonic, then bounded directpdflatex/xelatex/lualatexpasses with bibliography helpers when present.% !TEX rootand% !TEX programaccept only confined relative roots and allow-listed engines. Built-in and customised commands are parsed into executable + argv and spawned without a shell, with shell escape disabled by the advertised defaults, a two-minute timeout, a 2 MiB captured-output ceiling, and process-group teardown on daemon stop.A compilable source chip has two minimal hover controls beside close: the play triangle performs one on-demand build; the lightning mark toggles eager observation, and the active mode is accent-coloured. Clicking either first activates the source so its dirty buffer can be saved. The selected source and every mounted dirty input buffer advertised by that provider in the same rooted file tree are saved before compilation; a conflict or failed save cancels the build.
compile_modepersists in the ordinary editor-tab snapshot (absent means on-demand) and duplicate source tabs in the live layout are rewritten together. An eager tab registers with the daemon after restore/reconnect, receives one initial build, then compiles settled on-disk changes from Puddle, agents, formatters, or other local processes. Parent-directoryfs.watchcatches direct and atomic-replacement writes, asynchronous stat signatures provide a portable Linux/macOS/WSL safety net, 350 ms debounce coalesces bursts, and a change during a build produces exactly one follow-up run. Switching to on-demand or closing the last eager source view retires its watcher; daemon shutdown closes every watcher and compiler process. Modes are not Markdown/HTML preview modes and never enter linked/locked scroll following.Per-file commands. Protocol 17.3 exposes the provider's independent
on_demandandeagercommand templates and accepts explicit overrides. A compilable file row or open source tab offers Compilation Settings…, a compact provider-named dialog with independent When clicked and Upon file change fields; the latter states honestly that enabling eager mode also performs one initial run. Each field starts with its explicit override or the currently discovered provider default. Save stores changed fields, while Use default resets only that slot. Overrides are durable SQLite rows keyed by(profile, project, provider, file type, canonical absolute file path, mode): they follow neither a transient session nor a pane, never collide across same-relative-path roots, and work for rooted external files outside the worktree. A provider advertises the placeholders its templates accept. LaTeX exposes{{source}},{{output_dir}}, and{{job_name}}, and requires the managed output placeholder so a custom command cannot move generated/intermediate files out of the source root's local.puddle. The setting changes what the corresponding play/lightning execution runs; it does not change a tab's current eager/on-demand mode. Clients below 17.3 simply omit the menu item.Every LaTeX run and output stays below the selected source root's local
.puddle/latex/<document-hash>/: a freshruns/<id>holds intermediate output, while only a successful run promotes the PDF, SyncTeX index, log, and manifest to a stable current file root. No LaTeX artefact is written to the daemon's globalPUDDLE_HOMEor beside authored source files. A failed build leaves the last good PDF intact. The daemon retains bounded compiler output and normalises standardfile:lineplus classic TeX! …/l.Nfailures into rooted diagnostics. Manual and eager failures display an expandable compiler-output notification; diagnostics mark the corresponding Monaco lines, including included TeX files, and a later successful build clears that source's markers. Success invalidates that PDF's media bytes and opens it as an ordinary permanentexternalfile tab in the invoked source pane. The whole live layout is checked first: an already-open rooted PDF is refreshed and focused rather than duplicated. Eager success performs the same refresh/deduplication but opens a missing PDF in the source pane's background without changing active pane, tab, or focus. The tab carries optionalgenerated_bymetadata across layout restore; unrelated PDFs retain the native iframe, while a generated LaTeX PDF lazily loads the locally bundled PDF.js viewer and worker.The generated PDF viewer starts fitted to the pane width and offers a minimal − / percentage / + overlay from 50–300% of that fit; the percentage resets to fit width and the buttons use deliberate stops. Trackpad and two-touch pinch gestures scale fractionally within the same bounds and keep the gesture centre anchored, while ordinary two-axis scrolling remains unchanged. Larger pages scroll horizontally, and a bounded canvas-pixel budget prevents HiDPI zoom from allocating unbounded memory. Command/Ctrl-clicking a generated PDF canvas converts its displayed page point to 72-dpi PDF coordinates at every zoom level and calls inverse SyncTeX. The daemon accepts only a PDF matching its own managed manifest and SyncTeX index, invokes the discovered
synctexbinary without a shell, canonicalises the first result, and rejects a source outside the original Puddle file root. The web globally reuses or opens that ordinary rooted source tab, moves Monaco to the returned one-based line/optional column, and focuses it. Inverse search may land in an included TeX file rather than the main document. A normal PDF never loads PDF.js or acquires source-navigation behaviour. -
Tab sizing follows content across every kind: short file labels make compact chips like short agent and terminal titles, while the shared
min-w-16floor keeps hover controls usable andmax-w-52caps long labels. A file label's marquee padding is cancelled out of its intrinsic width by an equal negative margin, but remains inside its measured scroll width so the revealed tail still clears the overlaid controls. -
Markdown/HTML preview: a
fileor rootedexternaltab whose path is markdown (.md/.markdown/.mdown) or HTML (.html/.htm) grows a four-mode view toggle on its tab chip, cycling source → preview → linked → locked → source. The current-mode glyph is file-code, eye, chain, or lock respectively; its tooltip names the next mode. The toggle and close × remain hover overlays that reserve no resting width and never resize neighbouring chips; the filename keeps its constant-speed marquee and the dirty dot stays in flow. The tab tooltip opens downwards and shows the full name, then the owning project/branch and agent/account where those identities apply; a terminal tab instead shows the terminal glyph andterminaltype. The left navigator's worktree-choice dropdown uses this same stacked session presentation instead of flattening branch and name onto one line. The optionalviewpersists inui_state.layout_tree(linkedsince protocol 15.0,lockedsince 16.0); source/preview rewrite one tab in place and in that pane only.Following modes.
linkedandlockedare distinct stable slots whose(session, path[, root])retargets to the most recently active renderablefile/externaltab in the live layout scope. Activations include chip and pane-body focus, navigator opens, drops, and the neighbour exposed by a close; neither following slot retargets from another follower. All following slots retarget together while preserving their own mode. Their constantlinked/lockedkeys do not include the target, so retargeting moves no leaf pointers or React keys and does not dirtylayoutSignature; one pane can hold one of each beside the ordinary target file. Entering, leaving, or crossing following modes remapsactiveKey/previewKey, dissolving into an existing owner on collision. A follower in the ephemeral preview position is protected from replacement. Scope is project-local underprojectBasedLayoutand profile-wide otherwise; without an eligible driver followers keep their last target. Both read the target's shared buffer, including unsaved source edits, and live-poll clean buffers for agent-written disk changes.Locked scroll following (source-aware since 2026-08-27) aligns by a semantic, one-based source-line coordinate rather than treating unlike documents as equal-height scrollbars. The active renderable source, ordinary preview, or locked preview with transient scroll ownership is the sole driver; linked views never publish. Scroll ownership normally follows logical pane activation, but a wheel/trackpad gesture over another eligible surface transfers only scroll ownership — it does not focus that pane, change the sidebar binding, or redirect the next opened tab. Monaco derives a fractional source line from its public, wrap-aware line-top geometry; Markdown measures the live geometry of the block elements annotated from markdown-it token maps; authored HTML elements carry parse5 source locations into the sandbox, whose bridge measures them after authored CSS/scripts/layout. Each receiver piecewise-interpolates that source line through its OWN current geometry, so wrapped prose, tall media, headings, lists, fonts, and iframe reflow stay aligned. A clamped scroll ratio travels beside the line as the exact beginning/end signal and the fallback for a missing or script-invalidated map. Publications remain animation-frame-coalesced; receiving surfaces reapply after content, viewport, resize, or HTML mutation reflow and never feed back. Focusing a receiving source or locked surface promotes it to the driver before user scrolling. Positions and scroll ownership are transient, target-qualified browser state: a retarget subscribes before applying, an unseen target waits for its own driver, and a rooted external file never shares a position with a same-path worktree file. Reload starts empty and multiple browser windows never cross-talk.
Preview → source navigation. Ctrl/⌘-clicking rendered content in a
linkedorlockedMarkdown/HTML preview inverse-interpolates the clicked rendered height to a source line, focuses the ordinary tab that most recently drove that follower, switches that tab from an ordinary preview to source when necessary, and moves Monaco's caret there. If that ordinary tab has since closed, it is recreated in its associated pane. The association is transient and root-aware: an external file returns to the same(session, path, root), never a same-relative-path worktree file. An actual Markdown/HTML anchor keeps its existing link behaviour instead of being mistaken for a source reveal.Rendering and security. Both rendered modes use
useEditorBufferpassively (one shared model and saver, no duplicate drafts/announcements) and follow the editor font size. Markdown ismarkdown-itGFM with task lists, one- or two-tilde strikethrough, GitHub-style bracket-reference footnotes andNOTE/TIP/IMPORTANT/WARNING/CAUTIONalerts, plus==highlights==; it is sanitised through DOMPurify, renders inline, resolves images — both ordinarysrcand responsive/theme-awaresrcsetcandidates — through authenticated media fetches/object URLs, and opens worktree links on ctrl/⌘-click. Footnote reference/back-reference navigation stays scoped to its own mounted preview, so duplicate note ids across panes cannot jump between documents. A```mermaidfence lazy-loads Mermaid and replaces its escaped source placeholder with a token-themed SVG; render jobs are serialised around Mermaid's global state, rerun on theme changes, keep strict security with diagram-side theme/security overrides disabled, and leave the source plus a concise error when parsing fails. HTML stays in an iframe sandboxed asallow-scriptswithoutallow-same-origin; parse5's source-location pass adds inert line attributes before the existing DOM/asset pass, and assets, including imagesrcsetcandidates, are baked intosrcdocas data URIs (20 MiB each; nested stylesheet/script imports are not chased). Locked HTML keeps that boundary: a generated per-mount UUID bridge reports/applies only finite ratios and source lines throughpostMessage, the parent accepts only the exact iframe window/kind/channel, and neither the daemon token nor any authenticated capability enters the document. Internal load/ResizeObserver/MutationObserverreports reapply the latest locked position after delayed or script-driven reflow; a document that removes/corrupts its own inert anchors falls back to ratio alignment.- LaTeX in both views:
$…$and\(…\)inline,$$…$$,\[…\]and```mathfences as display, typeset by KaTeX. KaTeX rather than MathJax because it is synchronous and layout-free — a preview re-renders on every keystroke of the shared model, and, decisively, the HTML document's maths is typeset here, in the parent, before serialisation: a null-origin iframe can never be handed a typesetter that reaches back.$…$follows pandoc's rules (no whitespace just inside the delimiters, no digit straight after the closing$), so prose currency —$5 or $10,$5-$7— stays prose; maths inside code spans, fences, or<pre>/<code>is never touched, and an HTML document that ships its own MathJax/KaTeX is left to it. Macros (\gdef,\newcommand) persist across one document, never between documents. Output is KaTeX HTML wrapped inrole="math"labelled with the TeX source, not MathML: DOMPurify strikes<semantics>/<annotation>out by design, and the sanitiser guarding the origin that holds the daemon token is not worth widening for it.trustis off, so\href/\includegraphicsin a worktree document cannot mint a URL (the HTML preview has no sanitiser at all), and a malformed expression renders in place, in--danger, with the parse error as its tooltip. The iframe gets the KaTeX stylesheet with its woff2 faces baked in as data URIs — a cross-origin font would need CORS the null origin will never get — loaded on demand, so only a document that actually holds maths pays the ~400 KB.
- LaTeX in both views:
-
Navigators keep what they are showing while they re-bind (decision 2026-08-04): the sidebar's worktree queries (tree, git status, uncommitted diff, log) hold their previous answer until the next one lands, and Changes/Search remount on the bound worktree path, not the session id — so switching between sessions that share a worktree, which is the default arrangement, changes nothing on screen instead of dropping every panel to "Loading…" and rebuilding it. A genuinely different directory still remounts them (an honest reload beats briefly showing another repository's history as if it were this one's).
-
Changes navigator (repository-aware source control + history, protocol 15.3): two stacked panels over the visible directory, the border between them draggable (the source-control list is sized, History takes the rest; per-browser
localStorage, capped against the sidebar height,components/resizable-height.tsx). The top lists one collapsible panel per repository, with the deepest repository owning the visible directory first, then ignored nested repositories and initialised or uninitialised recursive submodules; extra breathing room and a static divider separate adjacent repositories. Each repository name and muted relative worktree path form one continuous mixed-style line and hover marquee, so the full, stronger repository name gets the width first and the path follows it instead of competing in a second clipping region. The branch/detached state, upstream, and ahead/behind information then use their own full-width status line. Disclosure chevrons sit in the left gutter; gold change-group headings, directory chevrons, and file-status letters share the commit control's left inset, making the hierarchy's two levels explicit. An initialised panel shows a commit-message field (⌘/Ctrl+Enter), followed by compact square Fetch, Pull, and Push or Publish Branch actions beside a Commit button that fills the remaining row, andMerge Changes,Staged Changes, andChangesgroups with per-file/group Stage or Unstage controls. Those groups default to compact directory trees and share a per-repository flat-list toggle; every directory row stages or unstages all displayed descendants (both literal paths of a rename included), while the group action still covers everything. Clipped repository headings, branch/upstream/status lines, and changed paths use the same constant-speed hover marquee as History rows. Commits consume the index only; with an empty index the UI asks before sending one lock-scoped stage-all-and-commit operation. Failure keeps the message, success clears it. Uninitialised submodules are disabled; a submodule commit leaves its parent's changed gitlink for a separate commit. The bottom remains the interactive commit graph, bound to the last-selected repository. A client connected to a pre-15.3 daemon feature-detects the minor and retains the former read-only uncommitted list.- Diff tab (opened from source control): staged files show a read-only HEAD→index Monaco diff; unstaged files show index→working tree, with the modified side bound to the same shared model as that file's editor tab so edits and ⌘S use the conflict-safe save path. Added and deleted files select the meaningful single side. The session-vs-base diff mode remains unchanged.
-
Ordinary-editor dirty diff (protocol 15.3): every editable text model is compared with the file at the deepest owning repository's current
HEAD. A symlinked editor follows its resolved target for this lookup, matching the file route: Git's blob for the link itself contains only the target path and is not a content baseline; a target outside any discovered repository shows no gutter. A tiny hidden MonacoDiffEditorsuppliesonDidUpdateDiff/getLineChanges; no Monaco internals and no second diff implementation are used. The visible editor owns one decoration collection: green line-decoration bars for additions, blue bars for paired replacement lines (surplus inserted lines stay green), and red triangles immediately above deletion points, including beginning/end-of-file placements and surplus deletions. The line-decoration lane reserves width without enabling glyph margin; its markers sit four pixels after the line-number area (twice the original breathing room) while the lane keeps its width, so source text does not move. Clicking any marker inserts one read-only, unified inline hunk viewer through Monaco's public view-zone and standalone-diff APIs, sharing the same HEAD/live models and collapsing unchanged context; another marker replaces it, the × or a second click closes it, and removing the hunk by editing closes it automatically. It is informational only — no hunk stage/revert actions. Because the live model is the modified side, typing, undo/redo, and disk reloads update both marker and open peek immediately; baseline polling and Git-mutation invalidation cover commits, pulls, branch movement, and status refreshes. Untracked and unborn-repository text is wholly added; ignored, outside-Git, binary, and over-limit files show nothing. Both diff controllers, the HEAD model, listeners, view zone, and decorations are disposed with the source editor. Full diff tabs remain the review surface. -
Search navigator: filename + content search over the bound worktree in one query (Obsidian-style) — a "Files" section (path matches, subsequence-ranked) and a "Contents" section (per-file line matches from
git grep, match-highlighted), with case / whole-word / regex toggles (regex is PCRE, matching the client's JS-regex highlighter). Untracked-not-ignored files are included, mirroring the explorer. Clicking a filename opens the file; clicking a content match opens it at that line. The query is debounced; results are capped server-side with a "showing the first results" note when truncated. -
Worktrees navigator: a repo-wide manager (not bound to the pin) over
GET /api/repos/:id/worktrees, which returns every git worktree grouped by branch plus the local branches that have no worktree (orphan_branches). Each worktree is tagged with its state — the clone (primary), uncommitted (dirty), and a running session count. Two destructive actions, both guarded by the daemon and mirrored in the UI (disabled control + tooltip):- Prune a worktree (
DELETE /api/repos/:id/worktrees?path=) removes its directory — the branch is always kept, so there is no data-loss prompt. Refused for the clone (worktree_primary), a dirty worktree (worktree_dirty), or one a live session is using (worktree_busy). - Delete an orphaned branch (
DELETE /api/repos/:id/branches?name=&confirm=) — only branches with no worktree (branch_in_useotherwise; a branch with a worktree must be pruned first, and git won't delete a checked-out branch anyway). A local-only branch (commits on no remote) needsconfirm(branch_unpushed), since deleting it discards those commits — the confirm dialog warns.
- Prune a worktree (
-
File transfer (client ⇄ worktree): dragging files — or whole folders — from the OS onto the file explorer uploads them into the hovered folder of the bound worktree (
POST /upload; multiple entries per drop, path-contained to the worktree, size-capped). A dropped folder is walked in the browser (webkitGetAsEntry) and each descendant travels as a multipart file named with its relative path, which the daemon rebuilds — empty directories are skipped; on a pre-9.2 daemon (which would flatten the paths) folders are rejected with a toast, feature-detected viaGET /api/version. Pasting copied files into the explorer uploads the same way. Every explorer context menu offers Download (GET /download— a single file streams as-is, a folder arrives as a zip built on the host; a multi-selection downloads each entry in turn) alongside the file operations above. Remote→local drag-out is not portable across browsers (Chromium-onlyDownloadURL), so the download action is the v1 mechanism; drag-out can be layered on later as a progressive nicety. Everything rides the normal authenticated API through the tunnel, so local and SSH modes behave identically. -
Commit tab (opened from the Changes graph): the
sha^ → shadiff of one file, read-only. Unlike the diff tab it never touches the shared editor buffer — both sides are private Monaco models created and disposed with the tab, and the root commit's files render asaddedrather than fetching a nonexistent parent. Covers "tap into the worktree and see git histories."
- Detection: listening ports owned by the session's process tree —
ss -tlnpon Linux,lsof -iTCP -sTCP:LISTENon macOS (the daemon picks per platform). - Local mode: ports are directly reachable; the table shows plain
http://localhost:<port>links, no forwarding needed. - SSH mode, tier 1 (Phase 5): the table shows a copyable
ssh -L <port>:127.0.0.1:<port> user@hostcommand per port. - SSH mode, tier 2 (live, Phase 5):
/proxy/:sid/:port/reverse proxy through the already-open tunnel, including WebSocket upgrade (HMR), scoped and authed per §2 Local security. Forwarding is rawnode:httpto127.0.0.1:<port>(streaming both ways for SSE/chunked): the request path is preserved byte-for-byte (never decoded/re-encoded),Hostis rewritten to127.0.0.1:<port>(ViteallowedHosts-friendly), hop-by-hop headers and theAuthorizationrequest header are dropped (the full-RCE daemon token may satisfy/proxyauth but must never reach a session's — potentially agent-generated — dev server; the trade-off is that an upstream wanting its ownAuthorizationmust carry it on a different header), and both thepuddle_proxycookie pair and thepuddle_tokenquery pair (the latter matched after percent-decoding each pair name, so apuddle%5Ftoken=that authenticated via WHATWG decoding is stripped too) are removed while other cookies/query pairs pass through byte-for-byte. A connect/first-byte failure is a502; the upstream's own status (a500, say) passes through unchanged. The WS upgrade is served by a second'upgrade'listener on the Node server registered after@hono/node-server's own (whose unclaimed-socket destroy branch only fires while it is the sole upgrade listener). On a proxy request for an unknown port, re-scan the session's ports once before rejecting — a just-started dev server shouldn't 403 until the next poll (PortScanner.hasPortowns that one re-scan). UI: a session's ports render as a slim strip IN FLOW at the bottom of ITS OWN pane — below the terminal, which shrinks to make room; deliberately not an overlay, so nothing ever sits over the terminal (the old placement at the bottom of the whole workspace read as global and detached from its session; the pane's Resume button IS a small bottom-right overlay, being transient). Captured environment names share this metadata area; its top and bottom breathing room are equal, and when both rows appear their gap is exactly that same single spacing unit. Shown by whichever pane's active tab is that session's terminal; the strip pollsGET .../portsevery 5s per SHOWN live session — no manual refresh, the interval is the refresh. Each port is a chip offering the access paths that fit the window's mode (the CLI's?host=boot param is the signal): the localhost link in local mode, the proxy link in SSH mode, and a copyablessh -Lalways. Known caveats: (a) same origin — a proxied app runs on the daemon's own origin, so its cookies/localStorageland there and a hostile dev server could read the UI's stored token; accepted for v1 on a single-trusted-user box (organisational isolation, not a security boundary — §2). (b) An upstreamSet-Cookieis passed through verbatim and shares the daemon origin's cookie jar, so a proxied app's cookie names could collide with puddle's (e.g. its ownpuddle_proxywould be shadowed) — benign for the dev servers this targets. (c) Apps that assume they are served from/(absolute asset paths, absolutefetch()URLs) escape the/proxyprefix; the cockpit origin recovers them by referer: a request outside/proxy/…whoseRefereris a proxied page 307-redirects to/proxy/<sid>/<port><original path>(a redirect, not a transparent forward, so the recovered URL becomes the subresource's own base and its relative imports resolve under the prefix; requests already under/proxy/are never rewritten, so no loop is possible; only local referer hosts qualify). The cockpit's own static handler also refuses to SPA-fall-back for paths with a file extension — a missing asset is a 404, neverindex.htmlmasquerading as a module script. Residue: WS handshakes andReferrer-Policy: no-referrerapps carry no Referer and cannot be recovered (§15.5 resolution) — surface a per-port "open via proxy" and "copy ssh -L" pair so there is always a working path.
Each env/ports value list is one non-wrapping line. When it exceeds the pane width, only the values scroll horizontally, with the scrollbar hidden and the env/ports label fixed.
puddle launch [local | user@host] [--port <p>] [--remote-port <p>] [--foreground] [--no-browser]
# one verb, both modes: no target (or 'local') ensures puddled on
# THIS machine and opens the UI; user@host is SSH mode over one tunnel
puddle refresh [local | user@host] # stop the target's cockpit and run the launch flow again (same UI port)
puddle list # running cockpit processes on this client
puddle kill [local | user@host | --all] # stop a cockpit (attached-daemon state stays resumable)
puddle status [user@host]
puddle attach [user@host] <session> # raw-terminal attach over the WS
puddle install <daemon|desktop>[@version] [user@host] [--tarball <path>]
puddle upgrade [cli|daemon|desktop][@version] [user@host] [--tarball <path>] # no component: everything installed, CLI last
puddle remove <cli|daemon|desktop> [user@host] [--yes] [--purge] # confirmations default to no
puddle logs [user@host] [session]
launch with no target (local mode; decision 2026-07-28 unifying the former start/connect verbs): install/upgrade the daemon under ~/.puddle/bin, ensure the systemd user unit (same as remote), run the protocol handshake (§6), serve the UI at http://localhost:7433 with /api + /ws proxied to the daemon on 127.0.0.1:7434, open the browser. No SSH, no tunnel — but the same serve/proxy path as remote mode. status/attach/logs/upgrade daemon default to the local daemon when no host is given.
Component management (install / upgrade / remove, decision 2026-08-07, superseding 2026-07-28's subject-only upgrade): the components are cli (npm-distributed), daemon (the ~/.puddle bootstrap, the only component that lives on remote hosts), and desktop (the macOS app bundle; on Linux install desktop ASKS where the AppImage should live — default ~/puddle, ask() taking the default without a TTY — placing it under the stable name Puddle.AppImage so launchers and the in-app updater keep a fixed path, then opens the directory in the file manager (best-effort xdg-open); upgrades stay in-app — the running app knows its own $APPIMAGE — and remove declines, naming the default location). Every verb takes an optional @version (daemon@v0.0.32, the v optional) and an optional user@host — cli and desktop refuse a remote target rather than half-support package-manager or app-bundle surgery over non-interactive ssh. install ensures presence: already installed with no @version → a no-op that says so; a named version installs exactly that (downgrades included — naming a version is the point). upgrade moves to the newest release (resolved client-side via the GitHub API, falling back to the CLI's own version train when unreachable) or the named one, installing a missing component — upgrade desktop ≡ install desktop when absent; with no component it covers everything installed on the target (a remote target ⇒ its daemon), the CLI strictly last since its npm self-replace swaps the code mid-run; cli is npm install -g @puddle-code/cli@<version|latest> under the hood. An explicit daemon @version that differs from the CLI's own prints the pin warning: across a protocol major the next launch force-upgrades the daemon straight back (§6), so the version must be pinned as a set (upgrade cli@vX too) or launched --no-upgrade. remove uninstalls after a y/N confirmation that defaults to NO (--yes for non-interactive use): cli refuses unless it is a real npm global install (a repo checkout is removed the way it arrived) and notes that running cockpits keep running; desktop deletes the closed app bundle plus the staged-update cache (recent-hosts kept); daemon first inventories what dies with it — its version, the RUNNING sessions it will interrupt, its profiles — then stops and unregisters the supervisor (systemd user unit / launchd agent / nohup pidfile, whichever exists) and removes only the bootstrap-managed pieces (bin/, cache/, runtime.json): the data survives by default — puddle.db, agent config dirs, worktrees, PTY logs, the token — so a later launch reinstalls and resumes. Deleting ~/.puddle wholesale is a SECOND question (or --purge), and before honouring it the CLI sweeps the worktrees for uncommitted or unpushed work and, listing any it finds, asks once more — declining downgrades to the data-keeping removal. The removal runs as a POSIX script piped over the transport, exactly like install.sh, so local and SSH share one implementation and none of it needs sudo.
puddle --version inventories all three local component slots without contacting a release service: the running CLI's compiled version/protocol, the bootstrap-managed daemon's symlink plus its plain PROTOCOL release metadata, and the desktop installation record. The desktop installer records a chosen AppImage path immediately, while every packaged desktop refreshes that record with its exact self-reported version/protocol at launch; macOS also falls back to its conventional Applications locations. Missing components say not installed, and legacy official releases fall back to the immutable version-to-protocol ledger rather than executing a possibly pre-Phase-6 daemon whose --version flag could start it.
The UI server picks 7433 by default and auto-picks the next free port when it is taken (e.g. a second puddle launch to a different host); the chosen origin is printed and opened. Whether one CLI process can multiplex several hosts behind a single origin is an open question (§15) — v1 is one CLI process, one host, one origin.
- Release artefact: self-contained per-platform tarballs (
puddled-v<X.Y.Z>-<os>-<arch>.tar.gz; currently linux-x64, linux-arm64, and darwin-arm64 — darwin-x64 is unpublished while GitHub's Intel runners queue too slowly to block releases on) published on GitHub Releases with a checksums file. Each contains a pinned Node runtime, the bundled daemon, prebuiltnode-ptybinaries, and plainVERSION/PROTOCOLmetadata — no Node, npm, or compiler is assumed on the host; only libc andgit, andpuddle --versioncan inventory an offline daemon without starting it. Linux native modules are source-built inside a glibc 2.28 container (RHEL/Rocky 8 floor), rather than inheriting the hosted runner's newer ABI; node-gyp uses Rocky's Python 3.11 and GCC Toolset 14 for current Python/C++ syntax, with the toolset runtime linked statically. CI inspects every staged.nodefile and rejects a glibc requirement above 2.28 or a dynamic GCC Toolset runtime before a release tag. The web UI assets ship inside the CLI npm package (@puddle-code/cli; the command it installs ispuddle), so a UI release is a barepuddle upgrade(npm under the hood) on the client, touching no host. - Install location: entirely under the home directory, never sudo:
~/.puddle/bin/versions/<X.Y.Z>/with a~/.puddle/bin/currentsymlink. Upgrade = unpack new version, flip symlink, restart service (running sessions becomeinterruptedand resume — the normal reconcile path). Rollback = flip the symlink back. Uninstall = stop the service andrm -rf ~/.puddle. - Bootstrap (shared by local and SSH
launch): detect platform (uname -sm), fetch the tarball — host-sidecurlfrom GitHub Releases (checksum-verified), falling back toscpfrom the CLI's cached copy for hosts without outbound internet — unpack, then install a supervisor:- Linux: systemd user unit
~/.config/systemd/user/puddled.service(Restart=always),systemctl --user enable --now, plusloginctl enable-linger $USERfor boot-start without login. - macOS: launchd agent
~/Library/LaunchAgents/dev.puddle.puddled.plistwithKeepAlive. - Neither available: nohup + pidfile fallback with a printed warning that reboot auto-start is not configured. The installer records its choice in
~/.puddle/supervisor. During SSH launch, if the normal health probe fails, that marker saysnohup, and the exact recorded PID is dead, the CLI startspuddledin a live SSH exec channel and owns it for the cockpit's lifetime. It never does this beside a live nohup child, systemd, or launchd daemon. Closing the cockpit sends SIGTERM and waits for clean shutdown; on the next launch live rows reconcile tointerruptedand auto-resume when the host setting is enabled (the default), while SQLite, worktrees, logs, agent config and agent session refs remain under~/.puddle. A recovered SSH connection re-establishes the attached daemon before declaring its tunnel restored. - Agent PATH: a supervisor gives the daemon a bare PATH (launchd's
/usr/bin:/bin:/usr/sbin:/sbin), so both the systemd unit and the launchd plist set a PATH that includes the dirs agent CLIs install to — notably Claude Code's native-installer~/.local/bin— else the daemon cannot spawnclaude. The daemon also prependsconfig.json'sagentPath(colon-separated, tilde-expanded; default~/.local/bin:~/bin:/opt/homebrew/bin:/usr/local/bin, editable in Settings → Sessions) toprocess.env.PATHat boot, so a custom install location works without touching the supervisor.
- Linux: systemd user unit
- Manual path: a documented
install.shone-liner performing the same steps, for daemon-only installs without the CLI. - Version handshake: on every
launchthe CLI comparesprotocol.majorfromGET /api/versionand acts per §6 Protocol versioning — automatic daemon update on an older major (with the live-session interruption count printed;--no-upgradeaborts instead), a CLI-upgrade refusal on a newer major, and nothing at all on a match: app-version skew within a protocol major is normal.
launch user@host (SSH mode) flow:
- Open a master SSH connection with multiplexing (
-o ControlMaster=auto -o ControlPath=~/.puddle/cm-%C -o ControlPersist=10m). (%C— a hash of the connection — keeps the control-socket path under the Unix ~104-byte cap however longuser@hostis; decision 2026-07-14.) In the CLI, the systemsshbinary inherits the user's TTY; in the desktop app, it receives anSSH_ASKPASSexecutable backed by a dedicated modal. Password, key-passphrase, keyboard-interactive/2FA, and host-confirmation prompts therefore still come fromsshitself and are answered at most once per live master. The desktop bridge authenticates a loopback-only, one-process helper with a random token; secret responses travel dialogue → main-process memory → helper stdout →ssh, and are never logged or stored. Every subsequent exec and the tunnel reuse the master connection. Over the master: read the installed version fromreadlink ~/.puddle/bin/current(never by executing an unknownpuddled— a pre-Phase-6 binary would start a daemon on--version). - If missing/outdated: run the bootstrap above over the master connection (platform detect → fetch → unpack → supervisor install).
- Discover the daemon's port on the host —
runtime.json's live port if present (it may have fallen back off a busyconfig.jsonport), elseconfig.json's port, else 7434 — and confirm it is our daemon by probing/api/versionwith the host's token (a 401/403 means a stranger holds the port, reported as a conflict; a staleruntime.jsonreads as down and triggers a restart). If the installed nohup child was reaped, start the SSH-attached fallback above and repeat the same authenticated probe before proceeding. Open the tunnelssh -N -L <tp>:127.0.0.1:<daemon-port> user@hostover the master connection (<tp>is an auto-picked free local port — pure transport, never user-visible), plus a best-effort fixed forward of port 1455 (-L 1455:127.0.0.1:1455, decision 2026-08-06): codex's ChatGPT login redirects the CLIENT browser to its registeredhttp://localhost:1455/…redirect URI, an address no proxy rewrite can change, so the login URL a remote codex prints works as printed only with the client's own 1455 carried to the host (codex's documented headless-SSH recipe; a busy local 1455 skips the forward with a warning — the cockpit is fully functional without it, and the other agents need nothing here). Run the protocol handshake, then serve the UI athttp://localhost:7433(or the next free port — never squatting the LOCAL daemon's port, so a laterpuddle launchstill finds its own daemon) with/api+/wsproxied through the tunnel, and open the browser athttp://localhost:7433/?host=user@host. - The tunnel keeps the connection actively alive (
ServerAliveInterval=15,ServerAliveCountMax=3on every ssh spawn — idle NAT/firewall timeouts must not fell it). Its health is judged by the forward, not the spawned ssh client: over a multiplexed master a non-OpenSSH server (Tailscale SSH) installs the-Lon the master and the client exits at once while the forward keeps carrying traffic — and killing that client does not remove the forward (onlyssh -O canceldoes). So readiness is the local listener accepting plus an end-to-end probe the daemon answers/api/versionthrough (any HTTP status proves the byte path; the authenticated handshake follows), and liveness is a periodic check of that local listener. On loss the master is checked (re-opened if lapsed) and the forward respawned on the same local port; only if that port got stolen is a new one picked and the UI proxy repointed. A forward abandoned on the master is cancelled with-O cancelso it does not leak its port. (An earlierExitOnForwardFailure=yeswas dropped — those servers trip it even on a working forward.) The tunnel-down/tunnel-up events are announcements, not child-exit telemetry: an outage that heals inside a 2s grace window is silent, unless it follows a restore within 30s (flapping), which announces immediately.
launch runs the cockpit (UI server + tunnel) in the background once ready: the launching process bootstraps interactively (ssh auth prompts happen on its TTY, warming the control master), re-execs itself detached with stdio to ~/.puddle/logs/cockpit-<target>.log, relays that log to the terminal until the child reports ready, prints the URL, and exits — the terminal may close. --foreground keeps today's attached behaviour (Ctrl-C stops the cockpit). Either way the cockpit writes a record to ~/.puddle/cockpits/<target>.json (target is local or the user@host argument; one cockpit per target — a second launch reports the running one instead of duplicating it) holding pid, origin, browser URL, daemon lifetime, and a per-instance nonce the UI server echoes on every response as X-Puddle-Cockpit. puddle list and puddle kill trust a record only after verifying pid liveness AND that the recorded origin echoes the recorded nonce — identity, not reachability (the same discipline as the daemon-port probe). Only a dead pid is pruned; a live pid whose origin does not confirm (recycled pid, stranger on the port, cockpit too busy to answer) shows as unverified and is never auto-deleted — the record is the only handle to a possibly-live process, and kill still works on it. kill sends SIGTERM (the cockpit's clean-shutdown path; SIGKILL after 10s), stopping the UI server and tunnel. A persistent daemon and its sessions keep running; an SSH-attached daemon shuts down cleanly, and the CLI says explicitly that host data remains and interrupted sessions can resume on the next launch. Known residue: a detached CLI cockpit has no TTY or GUI askpass bridge, so if the control master lapses on a host that needs interactive auth (password/2FA), the tunnel's reconnect retries visibly in the cockpit log but cannot prompt — --foreground is the answer for such hosts, and the only CLI mode Windows (no ControlMaster) can re-authenticate in at all. The in-process desktop cockpit keeps its askpass bridge for reconnects.
Use the system ssh binary (spawned), not a JS SSH library: it inherits the user's ~/.ssh/config, agents, jump hosts, password/2FA prompting, and MFA for free. If no key is set up, launch works over password auth via the master connection; print a one-line hint suggesting ssh-copy-id for a smoother experience.
puddle refresh [local | user@host] (target defaulting to the sole running cockpit, as kill does) is kill-then-start as one command, for when a cockpit is wedged — an SSH drop the tunnel never recovered from, a stale unverified record, a daemon that stopped. It terminates the old cockpit whatever its state (running, starting, or unverified; a dead record is just pruned), then runs the full launch flow — bootstrap/restart the daemon if it is down, reopen the tunnel, handshake — accepting the same flags. The new cockpit prefers the old UI port (read from the record's origin; non-strict, unlike --port) so an open browser tab keeps its origin and can simply reload.
The same refresh is reachable from the UI: POST /cockpit/refresh — a cockpit-local control endpoint served by the UI server itself, never proxied to the daemon, and (like the X-Puddle-Cockpit nonce and the ?host=/#token= boot params) outside PROTOCOL_VERSION, since the UI and the cockpit serving it ship in one npm package. It applies the same Host/Origin checks as /api plus the daemon's bearer token, answers 202 {status:'refreshing'}, then a CLI cockpit spawns a detached puddle refresh <target> --no-browser (carrying its own original flags, child-marker env stripped) and lets it take over, while the desktop shell performs the same stop-and-reconnect in-process. The web UI drives this from a bottom-anchored connection banner — shown when the daemon WebSocket stays down past a ~4s grace — and a ⌘K "Refresh connection" command; after the 202 the page polls /api/version (any HTTP response proves the path is back) and reloads itself. Known residue: a detached CLI refresh has no TTY and cannot re-authenticate a password/2FA host; the desktop's in-process refresh can surface the askpass modal, and puddle refresh in a terminal remains the CLI answer.
The control surface's second endpoint is GET/PUT /cockpit/local-sync — the machine-shared settings-sync store behind Settings → Sync's "Sync locally" (§11). Same discipline as refresh (localhost Host/Origin + the daemon bearer token; outside PROTOCOL_VERSION), backed by ~/.puddle/local-sync.json on the CLIENT machine, which every cockpit on the box shares regardless of which daemon it drives. The CLI treats each profile-name key as an opaque JSON entry (the web owns the shape); writes are read-merge-rename so concurrent cockpits never tear the file. Cockpits without the store (embedded servers) answer 404 and the web hides the feature.
Implement the CLI as a thin bin wrapper over library functions in packages/cli/src/lib/ (bootstrap, tunnel, attach are all importable). That seam now has two consumers — one codebase, two downstream builds: the puddle bin, and the desktop shell (packages/desktop, Electron), whose main process calls the same startLocal() through the deliberate embedder surface packages/cli/src/lib/index.ts and points a BrowserWindow at the embedded cockpit's http://localhost:<port> origin (same UI assets, same token boot, same proxying — the web app cannot tell the shells apart except by the preload bridge window.puddleDesktop, which exists solely for capabilities a renderer lacks, e.g. raising the OS window from a notification click). Shell-only concerns in the desktop package: external links and vscode:///cursor:// deep links are handed to the OS; on macOS the native title bar is hidden (titleBarStyle: hidden with inlaid traffic lights) so the web app's top bar — which detects the bridge and becomes a drag region insetting 88px for the lights — IS the title bar (one 36px height in every shell and window state, with trafficLightPosition.y derived from it so the lights stay centred on the host name; full-screen drops the inset and the name EASES to the left edge rather than jumping, while the height never moves), and launch bounds fit within the primary display's work area, horizontally centre a narrower window, and always bottom-align it so even a preferred-height window in a slightly taller work area meets the lower edge while retaining native rounded corners and shadow; POST /cockpit/refresh maps to an in-process stop-and-restart preferring the old port (also reachable as File → Refresh Connection, ⌘⌥R, for the focused window — the menu path works even when the page is too wedged to serve the banner); and quitting the app normally stops only the UI server — a supervised daemon keeps agents running, while an SSH-attached fallback is cleanly interrupted and resumes on the next connection. Anything both shells need MUST live in lib/, never be duplicated in either shell.
On macOS the native app role is explicitly named Puddle, not its scoped npm implementation name, so the menu reads About Puddle. The About panel keeps the ordinary app version as its application version and uses the native build-version position for Protocol <major>.<minor>, producing one compact line such as 0.0.44 (Protocol 16.1).
Desktop remote hosts. File → "Connect to SSH Host…" (⌘⇧O) prompts for any ssh destination and runs the same connectRemote() the CLI uses — bootstrap over the master connection, tunnel, handshake — opening one window per target (local or user@host), which mirrors the CLI's one-cockpit-per-target model and the web app's one-daemon-per-origin assumption. Successful targets land in the host picker and a File → Recent Hosts submenu (hostnames only, in ~/.puddle/recent-hosts.json — durable client state that survives app updates and reinstalls, migrated once from the pre-2026-07-28 userData location; never credentials — auth stays the system ssh's business). When ssh needs interaction, OpenSSH invokes the app's short-lived askpass helper and the app presents a serial modal: secret responses are masked, while host authenticity is an explicit Yes/No confirmation. Cancelling fails the SSH attempt; retry prompts are shown honestly. The same helper remains available when an open desktop cockpit reconnects after its master lapses. Closing a window stops that cockpit's UI server and tunnel; a supervised remote daemon and its agents run on, while an SSH-attached fallback is interrupted and resumes from disk when the host is opened again.
Desktop new windows and restore (decision 2026-07-28, restore broadened 2026-08-13). A fresh install with no remembered windows, reopening from the dock after explicitly closing every window, ⇧⌘N (plain ⌘N belongs to the renderer's New untitled file — §11 Hotkeys), Window → New Window, and the macOS dock icon's right-click New Window all open the host picker — a small chooser listing "This machine" (local) on top, then recent hosts, then "Other SSH host…" (which hands over to the ⌘⇧O prompt). Clicking a row opens (or raises) that target's window; nothing connects until the user picks. On a clean app quit, the shell records every still-open target in ~/.puddle/reopen-windows.json (never credentials), together with its normal bounds, display id/label/work area, and — on X11 when available — EWMH virtual-desktop index; the next launch reopens that standing set. macOS and X11 restore each window's monitor-relative position, clamped into the current work area and falling back to the primary display when its former monitor is absent. X11 sets _NET_WM_DESKTOP before mapping the restored window so it returns to its former virtual desktop without moving the user there; this is best-effort when xprop or an EWMH window manager is absent. macOS exposes no public stable Space identifier or API for moving a window to a particular Space, and native Wayland deliberately leaves window placement and workspace assignment to the compositor, so those platforms restore on the OS/compositor's chosen virtual desktop without private or compositor-specific hooks. A window the user explicitly closed has already left the set, including the last window whose close causes the app to quit, so it stays closed. SSH authentication can surface through the normal askpass modal, and if no target reopens the picker takes over. The picker is shell chrome like the connect prompt (its own tiny HTML + preload, outside the web app's token system).
Desktop packaging. The release workflow packages the app per tag (dmg + zip on macos arm64, AppImage on linux x64; checksums join SHA256SUMS). macOS signing/notarisation is dormant until repository secrets exist — MAC_CERT_P12/MAC_CERT_PASSWORD (Developer ID cert) enable hardened-runtime signing, APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID add notarisation — otherwise the artefacts ship unsigned, which is the accepted default (decision 2026-07-28: no Developer Program fee for now; the README documents the Gatekeeper "Open Anyway"/xattr path and the build-from-source alternative).
Desktop self-update (decision 2026-07-28). No Squirrel and no electron-updater — Squirrel.Mac refuses unsigned bundles, and signing is dormant (above) — so updates take the CLI's own path: lib/desktop-update.ts checks the GitHub releases API, downloads the platform asset into ~/.puddle/cache/desktop/<version>/, verifies it against the release's SHA256SUMS, unpacks (ditto for the mac zip; the AppImage is the artefact itself), and swaps via a detached /bin/sh helper that waits for the app to exit, moves an existing install aside when present, moves the new one in (copy fallback across volumes, restore on upgrade failure), strips com.apple.quarantine recursively from the installed bundle (a no-op in the normal path — belt and braces against a bundle that acquired the attribute some other way), and relaunches. On Linux the swap targets $APPIMAGE — the running file's own absolute path — so a user-relocated AppImage updates in place, keeping the path launchers point at (the version-in-filename going stale is accepted). Fetches carry no Gatekeeper quarantine (only opted-in apps like browsers attach it — the same reason the curl-piped install.sh works unsigned), and the trust model is exactly the daemon bootstrap's: the GitHub release is the root of trust, the checksum guards transport integrity; signing can later replace the swap mechanism without touching the UX. The shell polls half-hourly when running from a real install (never in dev; six-hourly through v0.0.34), stages silently, and offers a dismissable toast ("Restart to update") through three preload-bridge members; the renderer never sees URLs or paths. The toast replaced a permanent bottom bar in v0.0.29 (decision 2026-08-05): dismissing it parks the offer until the next staged version or app launch, since an update demands nothing immediate. An update relaunch inherits the ordinary durable window restore above, so it lands on the same targets without a separate one-shot path. Because the check asks releases/latest, an app several versions behind stages only the newest release — and the staging cache self-cleans: each successful stage prunes every other version under ~/.puddle/cache/desktop/, and boot prunes the lot (pruneDesktopUpdateCache in lib/desktop-update.ts; a leftover stage is re-downloaded fresh by the next poll anyway), so successive releases cannot accumulate one download per version. puddle upgrade desktop drives the same pipeline from a terminal while the app is closed (a running app must use its toast — an external swap would wait on a process that isn't exiting). On macOS it is also the first-install path: when neither /Applications/Puddle.app nor ~/Applications/Puddle.app exists, it downloads the latest release and installs into system /Applications when writable, otherwise creates and uses ~/Applications; the helper does not require an old bundle to move aside. On Linux, puddle install desktop supplies the otherwise-missing conventional path and the running AppImage continues to update itself in place. The release workflow still publishes no electron-updater metadata (latest-*.yml/blockmaps) — nothing consumes it. The app icon is the puddle mark in dark-theme gold on navy, from two committed sources rendered by scripts/render-icon.sh: packages/desktop/build/icon-mac.svg (full-bleed → icns; macOS 26+ masks icons into its own squircle and plates self-rounded artwork on white) and icon.svg (self-rounded squircle → png, for Linux, which applies no mask).
The right session sidebar keeps every non-archived project visible as a navigation target, including projects with no sessions. Expanded groups use clickable project-name headers; the collapsed rail shows a ≤5-character uppercase abbreviation above each group's divider (the project's stored abbrev — chosen at creation, editable with the name in the homescreen card's Edit dialogue — or derived from the name's first five characters when unset) and uses the session's agent or terminal glyph; the label's tooltip shows the full project name. A double-click on any project's label opens that label's own MENU — the same one a right-click gives (decision 2026-08-05, replacing the double-click-to-rename of 2026-08-03): renaming is one of several things the menu offers, so jumping straight into the rename editor chose for the user, and the abbreviation editor in particular opened over a 5-character label that gives no hint it is a text field. The double-click's own first click navigates, making that project active; a single click still just navigates; and the editor is one click further in, under "Change project name" / "Change project abbreviation" (commit on Enter/blur, Esc cancels). A project name (header or collapsed label) right-clicks into a menu that starts a new agent or new terminal in that project — the same create dialogue the sidebar's fixed controls open, seeded with that project — and, below a divider, edits the label under the cursor: "Change project name" on the expanded header, "Change project abbreviation" on the collapsed rail (each menu offers exactly the field that header shows, so neither surface has to host an editor for something it does not display; since 2026-08-05 this menu is also what the double-click opens, making it the one route to the editor). The name also drags to reorder projects: while a name drags, every group's session list collapses so only the names reposition, and the drop persists into the same profileSettings.projectOrder the homescreen cards drag (one source of truth for project order, both sidebar modes). With showAllProjectSessions off (§12), other projects retain their labels while their sessions are hidden.
Profiles are identity, not auth. First load shows a profile picker (create-or-select), remembered in localStorage.
The new-session account picker sorts alphabetically by its displayed agent-type/account-label, without changing the profile's configured default account.
A settings panel, reachable from a gear icon and ⌘K → "Settings". Three scopes, each stored where it belongs:
| scope | storage | examples |
|---|---|---|
| client | localStorage (per browser) | theme (dark/light/system), cursor package, UI, terminal, and Monaco editor font sizes, terminal scrollback, editor tab size / word wrap (the explicit tab size controls spaces per indent level and disables Monaco's per-file indentation detection) |
| profile | profiles.settings JSON, via /api/profiles/:id/settings |
branch prefix, default account & agent, permissions gate, env capture (captureSessionEnv), notification preferences, per-kind new-session defaults (sessionDefaults) |
| daemon | config.json, via /api/config |
host display name (displayName), log size cap, ui_state GC retention, autoResume — marked in the UI as affecting all profiles. The port is CLI/config-file territory (--port), never shown in the UI |
Panel sections: Appearance (theme, cursor package, font sizes, density); Profile (name — also editable by double-clicking the profile name in the top-bar profile button, which closes the panel the first click opened, since the name IS its trigger (decision 2026-08-03); a name must stay filesystem-safe (it is a directory under ~/.puddle/profiles/), so an invalid or clashing one is rejected and surfaces as a toast — branch prefix, icon — one of a curated set of ~60 lucide glyphs, all statically imported (no lazy chunks), picked from a small grid — and its colour — any theme-colour key, mapped to a theme-aware text-* token; the chosen glyph AND the profile name beside it recolour wherever the profile appears: the top-bar profile button and the profile picker — default account & agent); Accounts (per agent type: login state, add — spawns the login PTY —, remove; per-account "skip permission prompts" toggle, visible only when the gate below is on); Sessions (in order: the permission-skip gate, the env-capture toggle (captureSessionEnv, §4 Captured session environment), the daemon's agent search path, terminal scrollback, the per-kind new-session defaults (profileSettings.sessionDefaults: base branch, separate branch, separate directory — what the new-agent/new-terminal modal OPENS with, every value still editable per session; built-ins when unset: both kinds share the base branch's directory, since 2026-08-03), then the editable launch-text templates of §4 — profileSettings.onboardingTemplate / concurrentTemplate / restartTemplate — then the tab-title template); Editor (tab size, word wrap, the editor-link SSH host — directly below Sessions; the old combined "Terminal & Editor" section is gone, its #settings/terminal deep link resolving to Sessions); Hotkeys (rebind the app's global shortcuts — see below); Notifications (delivery on an agent's flip to waiting_input: a desktop notification — permission requested by the toggle click, shown only while the window is UNFOCUSED, clicking it opens the session — plus an optional WebAudio sound and a (n) document-title badge counting waiting agents; per-project mute silences both. The toggle defaults to on but the browser prompt needs a user gesture, so the row surfaces the LIVE permission state whenever the toggle is on but delivery cannot happen — unrequested (with an inline request link), blocked, or unsupported — re-read on window focus so a grant made in browser/OS settings is picked up. Delivery looks the session up across the query caches and falls back to fetching the all-sessions list when absent — a tab parked on the dashboard holds no session cache and must not silently drop notifications); Repositories (per repo: base branch, fetch policy, onboarding notes — the standing setup rules, freely editable); Host (daemon scope, incl. fetchIntervalMinutes and the host display name — also editable by double-clicking the host label in the top bar, where clearing it (or typing the real hostname back) unsets displayName so the label falls back to the hostname rather than storing it twice); Sync (the "Sync locally" mirror, one shared what-to-sync checklist, one-click export-and-copy, then paste-import — see below).
Permission prompts are ON by default, everywhere. Spawning an agent with prompts skipped (e.g. --dangerously-skip-permissions) requires deliberate, layered opt-in:
- The profile's
allowSkipPermissionsgate (Settings → Sessions) is off by default. Enabling it shows a warning dialogue that spells out what an unattended, prompt-free agent can do, and requires typing the profile name to confirm. That confirmation is also the human consent for the agent's own skip disclaimer: on gate-open the daemon callsadapter.acceptSkipPermissionsfor each of the profile's skip-capable accounts (for Claude Code, recordingbypassPermissionsModeAcceptedin the account's.claude.json) — otherwise Claude 2.1.x silently downgrades--dangerously-skip-permissionsto normal prompts, since its disclaimer is only acceptable interactively and a puddle PTY is not. Verified against Claude Code 2.1.210. - With the gate on, individual accounts can opt in (
skip_permissions_default), and only then does the new-session modal show a per-session skip toggle. Opting an account in also records the agent's skip acceptance for that account (as gate-open does for the profile's existing accounts), so an account added after the gate was already open still skips correctly. - The daemon enforces the gate server-side:
skip_permissions: trueagainst a closed gate is rejected — there is no CLI, API, or UI bypass. - Re-evaluated on every spawn-like action. The effective flag for any launch — create, resume, migrate, or hand-off — is
requested ∧ profile gate ∧ target account opt-in, evaluated at that moment. A session that ran without prompts does not carry the flag through a resume after the gate was closed, and a migration or hand-off never inherits it onto an account that hasn't opted in; the session continues with prompts on (and says so in the terminal).
Turning the gate off later immediately hides the toggles; already-running sessions are unaffected but are badged so it's visible which live sessions are running without prompts.
The app's global keyboard shortcuts are a customisable registry (Settings → Hotkeys), stored per profile in profileSettings.hotkeys (action-id → a canonical binding string like shift+meta+KeyE — modifiers in the fixed order ctrl,alt,shift,meta, which the dispatcher compares by equality, so an out-of-order default silently never fires; absent = the built-in default). One module (lib/hotkeys.ts) owns the action list, binding format, and a runtime handler registry; components register a handler for their action on mount, and a single global dispatcher (HotkeysHost, mounted in the shell) turns a keydown into the matching action. When Monaco has focus, the shell captures app-global shortcuts before the event reaches the editor — Monaco uses ⌘K as a chord prefix and otherwise swallows it before a bubbling listener — while editor-owned actions still reach Monaco. Scope is the global/layout actions only — command palette, close tab, reopen last closed tab, new untitled file (the keyboard route to the strip's blank-tail double-click, §8 — a fresh draft in the focused pane), next/previous tab in the focused pane (strip order, wrapping at both ends, and NOT deferred in a terminal — switching away from the terminal you are typing in is the point), close window, toggle left/right sidebar, open a left navigator (Files/Search/Changes/Worktrees), new agent, new terminal, toggle Scratchpad, toggle Layouts, plus the two editor actions (save, word wrap) which are bound inside Monaco from the same registry. Reopen last closed tab (decision 2026-08-03) pops a per-window, in-memory stack of closures — each remembering the pane and the position in that pane's strip — and puts the tab back through the same center-drop path a drag takes, so it returns permanent and focused; a pane that has since collapsed sends it to the focused pane instead, and a tab whose session has been archived or deleted is dropped from the stack rather than resurrected. The stack is keyed by layout scope, so it reopens within the profile-wide surface or within that project's own layout, never across the two (§11 reload semantics). Untitled drafts are excluded: closing one deletes it. Close window is a shell action (it works on the dashboard too): the desktop shell closes the window over the preload bridge, since a renderer cannot close a window it did not open. Filetree navigation and terminal line-edits stay fixed (conventional, context-scoped). The settings panel records a keystroke per action, resets to default, and flags conflicts (two actions sharing a binding). Browser-reserved combos (⌘W, ⌘T, …) are bindable without warnings — a web tab simply can't intercept them, which users can discover for themselves (decision 2026-08-03, retiring the earlier reserved-combo flagging). An action may be flagged deferInTerminal to yield to a focused terminal that uses the key itself. App shortcuts win inside terminals (profileSettings.terminalAppShortcuts, 14.2 — a switch atop Settings → Hotkeys, living with the bindings it governs; default ON, 2026-08-06): xterm consumes chords it can encode before they bubble to the dispatcher — ⌃⇥/⌃⇧⇥ tab cycling died inside a focused terminal — so the terminal's key handler yields any chord bound to a registered, non-deferInTerminal, non-editor action (returning it unhandled, so it bubbles to HotkeysHost). Off, the terminal receives every key it can handle and only chords xterm ignores reach the app. Per profile, like the hotkey overrides themselves: which keys your muscle memory owns is a fact about you, not the browser you happen to be in. Every place that NAMES a shortcut in UI copy reads the live binding, never the default: the top bar's command field, an empty pane's palette button, the empty session list and empty dashboard, and the draft-save prompts all resolve through useHotkeyLabel(actionId), so a rebind is honest across the whole UI at once. The fixed keys (filetree ops, terminal line-edits) still spell themselves literally, since nothing can rebind them. Defaults (Mac): ⌘K palette · ⌃⌥W close tab · ⌃⌥T reopen closed tab · ⌃⌥N new untitled file · ⌃⌥] / ⌃⌥[ next / previous tab · ⌘W close window · ⌥⌘, / ⌥⌘. left/right sidebar · ⌘S save · ⌥Z wrap · ⌥⌘E/F/V/B Files/Search/Changes/Worktrees · ⌃⌥` new agent · ⌃` new terminal · ⌥⌘S Scratchpad · ⌥⌘L Layouts. Two default sets (decision 2026-08-03): those web defaults dodge browser-chrome combos; the DESKTOP shell (detected by the preload bridge) forks the ones where a native gesture is more intuitive — ⌘W close tab, ⇧⌘W close window, ⇧⌘T reopen closed tab, ⌃⇥ / ⌃⇧⇥ next / previous tab (the universal tabbed-app gesture, which a browser tab can never see because the chrome switches its own tabs), ⌘B / ⇧⌘B left / right sidebar, ⌘T new agent, ⌘N new untitled file (the shell's New Window menu item yields the key, moving to ⇧⌘N) — via each action's optional desktopBinding. The sidebar pair is deliberately ⌘B and ⇧⌘B rather than VSCode's own secondary-sidebar ⌥⌘B: that combination is Open Worktrees here, and a pair the eye reads as one gesture beats matching a foreign app key-for-key (decision 2026-08-03). A browser keeps ⇧⌘B for its bookmarks bar, which is why only the desktop default forks. Its File → Close Window keeps the ⇧⌘W glyph but sets registerAccelerator: false, so the key reaches the renderer and a rebind genuinely moves the shortcut (clicking the item still closes); plain ⌘W likewise reaches the renderer, where it closes a tab. Only the defaults fork: profileSettings.hotkeys overrides apply in both shells, and the settings panel shows and resets to the set the current window runs.
Settings → Sync moves a profile's machine-agnostic settings between machines. It spans the per-browser client settings, the theme preference, the profile's settings JSON (including captureSessionEnv and the desktop/sound notification toggles — never muted_projects, whose project ids are host-local: they are stripped on export and preserved through import), a few profile columns (branch prefix, icon, colour), and the profile's profile-scoped Scratchpad entries, grouped for one shared checklist — Appearance · Profile · Sessions · Editor · Hotkeys · Notifications · Scratchpad, mirroring the settings sidebar's tab order (lib/settings-sync-manifest.ts is the single definition; every sync path routes through it). It deliberately excludes anything that can't map onto another machine: accounts, repositories, project order, default account, the daemon's agent search path, worktree paths, project-scoped scratchpad entries.
Two carriers:
- The string: Export is above import and takes one click — it builds the string from the checklist, shows it, and copies it to the clipboard immediately (no separate copy step). Import follows it and updates only the fields the pasted string carries, routed back to their stores. The codec (
lib/settings-sync.ts):JSON → gzip → base64 → append the gzip bytes' CRC-32 → a random Caesar shift over a fixed alphabet. gzip+base64 always begins withH, so the decoder maps the received first character back toHto recover the shift (no marker needed), then verifies the CRC before applying — a jumbled blob that round-trips exactly and rejects a corrupted paste. - Sync locally (default ON since 2026-08-03 — a profile with no stored entry syncs every group until toggled off; the first export pass persists the entry): mirrors the selected groups through the cockpit's machine-shared store (
GET/PUT /cockpit/local-sync→~/.puddle/local-sync.jsonon the CLIENT machine — §10), keyed by profile name, so every puddle window on the box follows — across UI ports (separate localStorage origins, which is why plain localStorage cannot do this) and across daemons. While enabled, the checklist governs both directions: a window applies the store's doc on load/focus and mirrors its own changes back out (use-local-sync-engine.ts; the import pass runs first and the export pass waits out its in-flight patches, so the two never fight). Unavailable cockpits (vite dev, embedded) hide the option.
Scratchpad sync never overrides. In both carriers, imported entries are ADDED unless an existing profile-scoped entry is exactly identical in title, body, tags (order included), and agent association — then the duplicate is skipped; any difference keeps both copies. Ids, positions, and timestamps never travel.
Each profile owns an editable Scratchpad — a bank of reusable prompts and free-form notes ("write tests for what you just changed, then run them", "summarise the diff against base as a PR description", review checklists, house style rules, or just a note to self). It is a top-bar popover — its trigger sits between Settings and the profile button, and it opens a floating, scrollable panel anchored under it (the profile panel's pattern), never taking centre stage. It works from the dashboard too (profile-wide entries only there); inside a project it also lists that project's entries and can insert into the workspace's focused terminal.
- Scope is a hard filter (deviation from the earlier prompt-bank design, where the project was only a ranking hint). Every entry is either project-scoped — shown only in that project — or profile-scoped — shown in every project of the profile. In a project the panel lists the profile-scoped entries plus that project's own, in one ordered list; the scope is editable per entry (a toggle in the inline editor), and a new entry defaults to project scope.
- Order is manual. The whole entry row is drag-reorderable (no grip handle), newest on top; the order persists as a REAL
positionper entry (smaller = higher), so a profile-scoped entry keeps a consistent order across projects while project entries interleave per project. A drag writes only the moved entry's fractional midpoint position. - Filter the list by
tags(chips) oragent_type(brand icons) present in it — narrowing only, never hiding a scope. - Insert flow: an entry's Insert action pastes the body into the focused terminal's stdin without submitting — wrapped in bracketed-paste sequences (
ESC[200~ … ESC[201~), because a raw multi-line write would let each newline submit a partial prompt to the agent — plus Copy to the clipboard, Edit, and Delete (which asks for confirmation inline — a warning line with Delete/Cancel, since there is no undo). Only a focused terminal/agent tab accepts stdin; otherwise the insert nudges you to focus one. - Reading layout: an entry is text first — the optional title, then a readable multi-line body preview — with a single meta line BELOW the text carrying the scope label (Profile-wide/Project), agent mark, tag chips, and the entry's tools. The tools are always visible (never hover-revealed), so pointing at the list reflows nothing.
- Management is inline — no modal.
+opens a spacious composer at the top of the list; a click on a row expands that row into the same editor in place (multi-line body with room to breathe, optional title, scope toggle, comma-separated tags, optional agent association; ⌘↵ saves, Esc cancels). A click on the row's own action buttons does not open the editor. Outside a project the scope toggle is absent — entries there are profile-wide by definition. - v1 is literal plaintext: no templating variables, no sharing between profiles (a coworker's bank is theirs; copy-paste is the sharing mechanism). Both are possible later; neither should complicate v1.
Saved layouts (12.2) are named snapshots of the centre tiling tree a profile can save and load. The Layouts popover is a top-bar popover between Settings and the Scratchpad, in the Scratchpad's mould (anchored panel, inline management, no modal). It is a LIST, not a dashboard (redesigned 2026-08-03): the live layout's own state is carried by its ROW, so nothing above the list repeats it. The current layout's name reads green with Active when it matches what is saved and red with Unsaved when the live tree has drifted. Drift is a structural comparison (layoutSignature): node ids, active/preview tab focus, and sub-0.1 resize noise are ignored — switching tabs is not a layout change — while tab identity, split structure, and pane proportions count. What sits above the list is only ever the name FIELD for the live layout: permanently while it has no row of its own — "Unnamed layout", the scope a save would capture (the current project under projectBasedLayout — the default — and profile-wide with it off), Unsaved, and the field that names it — and on demand from +. The captured payload is exactly the scoped slice — {layout_tree, active_session} — never shell chrome (sidebar modes and sizes stay live state). Under project-based layout the catalogue narrows to the open project's layouts plus the profile-wide ones; otherwise it is the whole profile's (the ?project= filter on GET /api/layouts remains for other callers). Hiding other projects' rows is what leaves exactly one current layout in the list at any time — their slices carry their own layout_ref and are still restored on arrival, they simply are not shown or markable from a project they do not belong to. A project name under a row is what marks its scope: profile-wide rows carry no label, since that is the absence of one. The list stays filterable by project — chips in the Scratchpad's mould over what the catalogue actually holds, narrowing only, hidden when there is only one kind to show. Every row carries four actions — rename, save-as (save glyph), duplicate (copy glyph), delete — and everything they write takes the provenance the CLIENT SETTING implies right now, never the row's own: scope is fixed at creation, so a save-as invoked from another scope's row writes the current scope's namesake (created when absent) rather than moving a layout between scopes. Save-as on the current layout is simply Save. On any other row it asks first — "Overwrite “X” with the current layout?" for a row it can write in place, or "Save the current layout as “X” here?" when the row belongs to another scope — and then the live layout adopts the result as its name, exactly as Save As does elsewhere. Duplicate means the same thing on every row: a copy of what is SAVED there under a name asked for inline, leaving the live layout — and which layout is active — exactly as they were. Duplicating the current layout therefore copies its saved version WITHOUT the unsaved changes on top. Creating from the live layout is a different act with its own affordance: + in the header (the Scratchpad's composer gesture) opens the same name field an unnamed layout shows, saves the live layout under that name with the provenance the setting implies, and makes it the active layout. A save-as targets the row that was clicked, never one resolved by name — nothing stops two layouts sharing a name. Loading a project-scoped layout makes its project the active one: the load lands in that project's slice, flips projectBasedLayout on if it was off, and follows the URL to /project/<id> — without which the workspace would sit in a project whose slice is not the one just loaded and nothing visible would change. Rows load on click, and rename, naming a copy, and every confirm expand inline; the discard-confirm is computed against exactly the state a load replaces — the visible head for the open project's own layouts and profile-scoped loads under profile mode, the target project's slice for cross-project loads (its unnamed content or drift from its layout_ref), both for a project-scoped load under profile mode (the live profile layout shards away AND the target's preserved slice is replaced) — and a profile-scoped load under project mode confirms nothing, because it retains every slice. The dashboard loads (and saves) too: the layout state exists whether or not a workspace is mounted, so without one the popover drives the persisted snapshot directly (useDashboardLayouts — the working set over the profile row, written immediately rather than debounced; safe because no workspace useUiState handle is alive in that window to race with), and the next workspace open restores the result. Under the profile-wide mode the dashboard behaves as a workspace does (the profile layout is the current one, and its row says so); under project-based layout there is no single live layout at all — every project keeps its own — so the popover says that in place of any head, drops the row actions that would need one, and still loads, with the discard-confirm computed against the target project's slice. Loading prunes the stored tree against the daemon's full live-session list (untitled tabs always keep), and when it would discard unsaved changes an inline confirm warns first. The live layout keeps persisting exactly as before — closing and reopening the app never changes it; a saved layout is only a named restore point.
Cross-scope loads flip the setting, with the conversion suppressed. A saved layout remembers the mode it was captured in, and loading one whose scope disagrees with the client's projectBasedLayout setting switches the setting as part of the load — stamping layout_mode and writing the target state BEFORE flipping the setting, so the one-shot union/split conversion (Reload semantics below) finds the snapshot already converted and cannot overwrite the layout being loaded. Loading a profile-scoped layout under project mode installs its tree top-level and turns the setting off; the union that a plain toggle would run is suppressed, and the stored project_layouts slices are left in place rather than cleared — nothing is erased. Loading a project-scoped layout under profile mode splits the current profile tree into the OTHER projects exactly as the plain toggle would, while the current project takes the loaded layout instead of its shard. In every split — load-driven or plain toggle — a shard never overwrites a stored slice (mergeShardedLayouts: existing slices win, shards fill only the gaps), so per-project layout storage survives the round trip.
Projects are the workspace unit. A project belongs to one profile and one repo, and owns a set of sessions (persisted UI state belongs to the profile — see Reload semantics below). The dashboard (/) lists the current profile's projects only — there is no cross-profile view (decision 2026-07-13); day-to-day work happens inside /project/:id. On the dashboard each card can be renamed (name only; the path is the repo's clone location) and archived — a reversible hide that drops it into a collapsed "Archived" disclosure at the bottom while retaining every session, worktree, and bit of data (reopening restores it). Both actions are on hover buttons in the card's corner and on a right-click menu. Cards are drag-reorderable, newest-first until dragged, with the order persisted per profile in profileSettings.projectOrder (the same order the cross-project session sidebar inherits — §12). New sessions are always created within a project, which supplies the profile, repo, and defaults — so the new-session modal reduces to account → base branch (the branch/directory toggles seeded from profileSettings.sessionDefaults, both kinds defaulting to the shared base-branch directory since 2026-08-03; ticking separate-branch shows the Branch field, unticked warns — §4 Relaxed isolation) → title/prompt. Which project is retargetable in the modal itself (decision 2026-08-03), seeded from the gesture that opened it — the workspace's current project, or the one whose session-sidebar header was right-clicked ("New agent/terminal in this project"). It is deliberately not a peer of the pickers below it: the project name sits inside the modal's description sentence as a dim inline dropdown that brightens on hover, with no chevron-and-field chrome, and degrades to plain text when the profile has only one project — the seed is nearly always right, so it should read as a fact you can change rather than another decision to make (§12). Switching it re-seeds the branch state, since a base branch and a directory to join belong to a repository. Session branches default to <branch_prefix><slug(title)> (or the session's short id when untitled); on collision with any existing branch, append -2, -3, … — never fail session creation on a branch-name clash.
Homescreen actions and the home terminal. Two action tiles sit in the projects grid, on the same surface as the cards: Open project opens the new-project dialogue — its path field autocompletes over GET /api/fs/dirs, and a "browse…" folder picker walks the same endpoint graphically (up-navigation, git repositories flagged, every row descending on click), so picking a folder works identically for local and SSH hosts, where an OS file dialog could only ever see the client machine. The path input is the picker's single source of truth: every navigation writes the browsed directory into the field (no duplicate path line in the picker), and "choose" fills the project NAME from the chosen directory's basename and closes the picker — and Open terminal opens a plain shell in the daemon host's home directory — the place to clone a repository before opening it as a project. The shell renders in a bottom pane that exists on the dashboard alone, with no tab strip or heading. It is one-at-a-time: the PTY lives on the daemon's project-less home stream (§6), and while it is live any spawn request returns the live shell — reopening the pane (or opening it from another window, or after a reload) reattaches with scrollback replay rather than stacking a second shell. While the pane is open the tile reads Close terminal, which ends the shell (kill-shell); the pane also closes itself when the shell exits.
Reload semantics. Workspace layout is a two-tier model (decision 2026-07-14, refining the earlier single-row design below). Each window keeps its own working set in sessionStorage (puddle.ws.<profile>) — reloading that window restores it exactly, independent of any other open window, and windows never live-sync while open. The profile's row in profile_states — layout follows identity alone, not browser or project (decision 2026-07-17, replacing the earlier (project, profile) keying: the centre editor area is ONE surface shared across the profile's projects — the cross-project sidebar opens other projects' sessions as tabs in the same tree, so a per-project snapshot made the layout appear and vanish with the URL; before that, 2026-07-13 replaced client-uuid keying so layouts survive tunnel-port and machine changes) — remains the seed for fresh windows: a window with no sessionStorage entry yet (a new tab, a fresh browser, a different machine or tunnel port) loads from that row instead. The snapshot JSON holds: the tiling tree (layout_tree) — the recursive Split/Leaf tree of editor + terminal tabs and per-split pane sizes (the single source of truth for what is open and where); the explorer pin, the left-navigator mode (sidebar_mode), the URL-bound active session, and the shell sidebar sizes. The legacy session_tabs/editor_tabs/active_editor_tab fields remain in the schema for migration (layout_tree null ⇒ rebuild from them). Project-based layout (11.2, the per-browser projectBasedLayout setting — §12, ON by default since 2026-08-04) rides the SAME snapshot. Terminals held by the OTHER projects' slices stay mounted and parked (detached from their PTYs) rather than being disposed: the keep-alive host takes the union of the live tree's terminals and those slices' (parkedTerminalSessions), because otherwise every project switch tore down that project's xterms and rebuilt them — a fresh instance and a full scrollback replay — on the way back, which is the blink the mode used to cost (fixed 2026-08-04, when it became the default). Profile-wide layout never had it: one tree holds every tab. The snapshot's shape: project_layouts maps project id → { layout_tree, active_session, layout_ref } and layout_mode stamps which keying the layouts were last maintained under (absent/profile ⇒ the top-level keys are live); layout_ref (12.2, also a top-level key for profile mode) names the saved layout the live one derives from (§11 Layouts). The web converts exactly once whenever the setting disagrees with the stamp: profile → project splits the shared tree into per-project slices, each keeping the tree's structure with only that project's tabs (untitled drafts follow the open project; a slice already stored is never overwritten — the shard fills only projects without one, §11 Layouts); project → profile unions the slices back — structures side by side in a row split, tabs deduplicated in sidebar project order with the current project first — and clears them. Node ids are unique within a tree and the transition must keep them so (decision 2026-08-03): every shard is re-id'd, the union deduplicates, and a tree that loads with repeated ids is repaired (and the repair persisted) before it renders. Ids are the tiling area's React keys and its resizable-panel ids, so a repeat both aliases panes (findLeaf reaches only the first) and trips the panel library's "Panel ids must be unique" invariant during render — which, with no error boundary, blanked every window that loaded such a snapshot in v0.0.22–v0.0.23. Everything else (shell sizes, sidebar modes, explorer pin, session_order) stays profile-wide in both modes, and the two-tier persistence above carries the slices unchanged. Consequences:
- A window's own reload or browser restart restores that exact window, from its own sessionStorage entry.
- A brand-new window has no sessionStorage entry, so it seeds from the profile's most recently written server row.
- Either restore path reattaches terminals via their daemon-persisted screen and scrollback snapshots (they look untouched), reopens editor tabs, and surfaces any
interruptedorexitedsession — agent and plain terminal alike — with a plain Resume button overlaid at the bottom-right of its own pane (no verbose banner; the tab's status glyph tells the story) — restoring is not just layout numbers, it's the working session as it looked before. - Navigating between projects does NOT switch workspaces (under the default profile-based layout): the same tree stays put, and only the URL-bound session (and with it the left sidebar's binding) changes. Under project-based layout it DOES: each project swaps in its own slice, restoring that project's stored active session. Restore-on-open prunes dead tabs against the daemon's full session list — never one project's — and only auto-navigates to the stored active session when it belongs to the project being opened.
- A switch never unmounts the workspace (decision 2026-08-04). The project-detail query keeps the previous project's data while the next is in flight, so the loading gate — which now needs only the project ROW, already in hand from the profile's project list — no longer replaces the whole shell for the length of a fetch. That flash tore down every open terminal (they re-attach, but the viewport was lost) and pulled the sidebar out from under a double-click mid-gesture. Anything that must be about the project in the URL waits for that project's own detail; the stale row is used only for the PROFILE, which a within-profile switch does not change.
- Opening another project's session from the cross-project sidebar (§12) always does two things: its project becomes the active one (the URL follows), and the session opens in that project's layout — as a preview on a single click, pinned on a double click or a drag, exactly as it would in its own project. Under project-based layout the two projects are different trees, so the gesture cannot open the tab where it was made: it navigates and the arriving layout opens it. A session dragged out of another project's group therefore lands in that project's layout rather than being filed under this one (which would have hidden it behind the very switch the drag triggers); the dropped POSITION is the one thing that cannot follow, since it names a pane in a tree that is not the destination's. Under the profile-based layout there is one shared tree, so a drop lands exactly where it was made and the URL simply follows.
- Any number of your own windows each keep an independent working set — they never live-sync — and the server row is updated debounced (~2 s), last-writer-wins, by whichever window changed layout most recently; this only affects what a future fresh window seeds from, never an already-open one.
- A coworker works under their own profile and their own row. A fresh profile starts with a fresh workspace — there is no cross-profile seeding (a profile-wide layout is personal in a way a single project's wasn't), and nobody can clobber yours.
- Stale rows (not updated in 90 days) are garbage-collected by the daemon.
Transient focus (which tab is active right now) stays local to the window.
Dirty editor buffers persist independently in the browser's IndexedDB (puddle-drafts, debounced ~1 s) and are restored on reload; drafts are per-browser only, never synced through the daemon or between windows. A cached draft whose text already equals the file is retired as a no-op regardless of mtime: an editor, formatter, or Git operation can rewrite identical bytes, and timestamp movement alone must not manufacture an unsaved-draft warning on every project return.
A refused save is reconciled, not just refused (decision 2026-08-05, interaction revised 2026-08-11). The daemon rejects a write whose expected_mtime_ms no longer matches the file (409 stale_file), which in practice means the agent edited it while it was open here. Nothing is written, the buffer remains editable, and a dismissable File changed on disk notification offers one action: Compare. An unresolved conflict re-offers that notification whenever its file tab becomes the logically focused tab after having been left; dismissing it stands while the tab remains focused. Choosing Compare synchronously locks the shared buffer across source, rendered-preview, and editable-diff views, then reads the current disk version. The lock remains through loading and until Monaco has mounted the side-by-side comparison; a failed or non-text read stays locked behind an explicit Retry rather than falling back to editing. The comparison shows the file on disk read-only on the left against the buffer on the right; only after both models mount does the right side become editable, so copying a hunk across is an ordinary edit. A reconciled save expects the DISK version's mtime rather than the stale load mtime, allowing the merge to land. The two decisive answers live in the comparison (take the disk version, keep mine), and the conflict clears on reload, overwrite, or a merged save.
⌘S also works outside Monaco. The save action is editor: true — bound on the editor instance, which is right while the caret is in it and wrong the moment the focused pane's tab has no Monaco to bind (a rendered preview) or has one that has not been clicked into (a pane focused by its tab chip); ⌘S then fell through to the browser's Save Page. So each mounted editor body publishes its buffer's save in a small registry keyed by buffer identity (save-registry.ts), and the shell dispatcher (§11) sends editor.save to the FOCUSED pane's active tab whenever the caret is outside a .monaco-editor. A source tab and a preview tab of one file share one buffer and therefore one save, so whichever is mounted answers.
Any profile can view/attach any session (trusted shared box); the UI shows the owning profile on each project and session card.
Puddle's UI must read as a polished, intentional developer cockpit — dense, calm, and visually coherent — not a scaffold of framework defaults. HUMANS.md at the repo root is the human-authored design brief (minimalism, no boxes/borders, fill-shift responsiveness, secondary hints on their own line, sentence case) and overrides this section wherever they conflict.
-
Stack: Tailwind CSS v4 + shadcn/ui (Radix primitives, generated into
packages/web/src/components/ui/and treated as owned code to restyle, not a dependency),lucide-reacticons,cmdkcommand palette (⌘K: switch project/session, new session, open any host file/directory path, insert prompt, switch theme, open settings),sonnerfor toasts,react-resizable-panelsfor the workspace layout. No monolithic kits (MUI, Ant): they resist theming and read as generic enterprise chrome. -
Two-layer token architecture in
packages/web/src/styles/tokens.css:- Primitive palette: theme-independent colour ramps derived from the five core colours below.
- Semantic tokens: the only names components may use —
--bg-base,--bg-surface,--bg-elevated,--border,--text-primary/-secondary/-muted/-gold,--accent,--accent-hover,--action,--action-hover,--action-ink,--focus-ring,--danger,--success,--warning,--status-running/-waiting/-interrupted/-idle/-terminal,--selection,--diff-added,--diff-removed, plus the 16--ansi-*terminal colours. A theme is one[data-theme="<name>"]block assigning primitives to every semantic token. Because the semantic tokens exist ONLY inside those blocks, the theme has to be on<html>before the first paint: an inline script inindex.htmlsetsdata-theme(anddata-density, and the--ui-font-sizerem base) fromlocalStoragein<head>— the app's owninitTheme/initClientSettingsstill run and remain authoritative, but they are in a deferred module, and measurement (2026-08-04) showed the browser painting one unthemed frame — no ground colour at all, i.e. white — before it executed, on every load.
The Tailwind theme maps utilities onto semantic tokens; the xterm.js theme object and the Monaco theme are generated at runtime from the computed CSS variables, so adding a theme is one CSS block plus one entry in a theme registry — zero TypeScript changes. A CI script asserts each theme block defines the complete semantic set and that text pairings pass WCAG AA (4.5:1 body, 3:1 large/UI elements). Terminal, editor, and chrome must visibly share one palette — a stock-dark xterm next to Monaco's default
vs-darkinside a differently-dark app is forbidden.The file explorer's file-type icons are deliberately monochrome: per-extension glyph SHAPES in the heading colour (
--text-primary; the generic fallback and git-ignored rows muted), because colour on a tree icon is reserved for git status — the earlier per-type hues (and gold default folders) read as decorations, a gold-ish icon looking "modified" (decision 2026-07-18). Thetext-icon-*utilities inapp.cssremain for future recognised multi-hue surfaces (e.g. third-party brand glyphs) and still resolve to the theme-aware--ansi-*tokens.Monaco's ordinary-editor dirty-diff gutter follows the same semantic palette: additions use
--success, modifications--accent, and deletion triangles--danger. These are line decorations in a reserved lane, not glyph-margin icons, and therefore remain legible and consistent in both shipped themes without introducing component-local colours. -
Core palette (the brand; hue is preserved within each ramp, only lightness/saturation step):
--altitude-blue: #7dadff; /* primary accent family */ --krypton-green: #8be8b3; /* success / waiting family */ --quiet-khaki: #ddb28c; /* warm neutral / attention / running family */ --storm-navy: #001c3d; /* dark ground / light-theme ink */ --burnt-wood: #5a2f22; /* warm ink / danger family root */
Extended ramps: navy
#000A14 · #00132B · #001C3D · #0A2B52 · #163C6B; blue#A7C7FF · #7DADFF · #4A86E8 · #2E6BD6; green#8BE8B3 · #1FA26B; khaki#FBF5EC · #F7EBDA · #EAD9C0 · #DDB28C · #F0B36E · #A9743D; wood/ember#F2957C · #C2472E · #8C4A34 · #5A2F22; mist (cool text ramp for dark ground)#EAF1FB · #B9C9E0 · #7E93B3; tertiary pastels completing the ANSI set at the core pastels' lightness: cyan#7FD6DC(blue×green), violet#B9A3F2. -
Themes: v1 ships
dark(default) andlight, plus a "system" option followingprefers-color-scheme; switchable in settings and via ⌘K. Semantic assignments:semantic token dark light --bg-base#000A14#FFFFFF--bg-surface#00132B#F7F7F7--bg-elevated#001C3D#EFEFEF--border#163C6B#E5E5E5--text-primary#EAF1FB#001C3D--text-secondary#B9C9E0#163C6B--text-muted#7E93B3#6B6B6B--text-gold#DDB28C#8A7663--accent/--focus-ring#7DADFF#2E6BD6--accent-hover#A7C7FF#4A86E8--action(primary-button fill)#EAF1FB#001C3D--action-hover#B9C9E0#0A2B52--action-ink(text on the fill)#001C3D#FFFFFF--success#8BE8B3#157A50--warning#F0B36E#A9743D--status-running#F0B36E#A9743D--status-waiting#8BE8B3#157A50--status-interrupted/--danger#F2957C#C2472E--status-idle#7E93B3#6B6B6B--status-terminal#7DADFF#2E6BD6Primary actions (buttons, checked toggles) are ink, not accent: mist on the dark theme, storm navy on the light — the accent blue is reserved for links, focus, and selection. The dark theme is storm-navy ground with the pastel family as light; the light theme is a white ground (HUMANS.md: white, not beige) with navy ink for primary and secondary text and a neutral grey for muted (the earlier golden bark read as distracting on hints/metadata), keeping the deep accent steps. The warm gold survives as a deliberate accent —
--text-gold(gold in both themes) for the sidebar's glyph icons and its ALL-CAPS section headings, plus the status-running colour — while prose, paths, and metadata stay grey. Session status inverts the naive mapping (decision 2026-07-29): a running agent shows amber (work in motion, eyes-off) and waiting_input shows green (ready for you); git badges and caution copy use--success/--warning, which keep the conventional hues. Light--success/--status-waitinguse a derived deeper krypton step (#157A50) because#1FA26Bmisses the 3:1 AA floor on the elevated ground. -
Cursor packages: Appearance offers
System(the default browser/OS pointer, interactive hand, and text caret),Rangefinder(a 27 px tessellated microprism spot, shrinking to 11.7 px over interactive targets),Drafting(a four-tick 24 px precision crosshair, contracting to an accent-coloured 14 px crosshair over interactive targets), andInvert(a 14 px contrast-inverting ring, becoming an 8 px dot over interactive targets). Every custom package collapses into a type-sized caret over selectable text, and the whole Monaco surface — lines, gutters and blank trailing space — is always a caret for every package. The choice is a per-browser client setting and travels with the Appearance sync group. Custom renderers replace cursors only for fine pointers; touch and embedded documents keep their native behaviour. Their renderer is portalled at document level so full-screen dialog overlays, including the command palette's side gutters, retain the selected package rather than exposing a native pointer. -
Terminal colour queries: the DAEMON answers the OSC 10 (foreground) and OSC 11 (background) dynamic-colour queries (14.0 answered them in the web terminal — but an auto-theming agent samples the background AT SPAWN, usually before any viewer has attached, so the viewer-side answer arrived for nobody and e.g. Claude Code's
theme: autofell back to dark whatever the app theme; fixed 2026-08-06). Clients report their resolved--text-primary/--bg-baseover the WS (theme, protocol 14.1 — after auth on every connect and again on theme switches; last report wins, matching the stdin rule), and the PTY layer scans output for the query sequences (chunk-boundary-safe,pty/terminal-theme.ts) and repliesrgb:RRRR/GGGG/BBBB. Against a ≥14.1 daemon the web terminal registers no OSC 10/11 handlers (the agent must not get two replies); against an older daemon it keeps the old viewer-side answering. A theme switch still takes effect on the next agent start/resume — a running agent that already sampled the background does not re-query. -
ANSI mapping rule: dark theme maps the pastel depth of each hue (red→
#F2957C, green→#8BE8B3, yellow→#F0B36E, blue→#7DADFF, magenta→#B9A3F2, cyan→#7FD6DC, fg→#EAF1FB) over--bg-base; the light theme maps each hue's deep step (#C2472E,#1FA26B,#A9743D,#2E6BD6, …) so agent output stays legible on the white ground. Brights are one lightness step up. UI accents and terminal output are thereby the same family by construction. Every xterm viewer also setsminimumContrastRatio: 4.5, letting xterm correct a foreground that an agent places over an incompatible background while retaining deliberate dim text. This covers TUIs that cache their startup OSC 10/11 result and keep a dark input background after Puddle switches to light (or the reverse) without rewriting their terminal stream. -
Type: one UI face and one mono face, chosen deliberately and self-hosted (hosts and clients may be offline; no font CDNs). Mono is the workhorse of identity: session titles, branches, the powering account's label, paths, ports, and statuses are all set in mono. Set a real type scale.
-
Status is carried by colour, not motion. A session's state reads from the colour of its indicator alone:
--status-runningamber,--status-waitinggreen,--status-interruptedember,--status-idlegrey, and--status-terminalblue for a shell so a terminal reads apart from an agent at a glance;waiting_inputis additionally mirrored in the tab title/favicon, and astale_runningsession is faded to 45%. Where a row has room for the agent's brand mark — the expanded sidebar rows and the tiling tab chips — the mark IS the indicator (status-glyph), rather than a dot beside a mark saying the same thing twice; a terminal shows a terminal glyph. The mark is now the only indicator — the collapsed session rail was the last place a bare dot survived and it shows the glyph too (2026-08-03), sostatus-dotno longer exists.There are no status animations (decision 2026-08-03). The interface previously rippled a running dot and pulsed a waiting one — its one animated flourish — bounded to ~30 s and ~1 min so the compositor eventually settled. Both are gone: the colour already carried the state, the motion only repeated it, and every status change across every visible session was paying for frames to say so. A ring drawn around a brand mark also reads as a border badge rather than a ripple, which HUMANS.md rules out. Consequently there is nothing for
prefers-reduced-motionto degrade and no reduced-motion client setting — it existed solely to switch these off. Everything in the interface is now instant or a ≤150 ms fade. -
Density: compact paddings, information-dense lists, tabular numerals for ports/counts; generosity is reserved for primary actions and empty states. The Density setting (client scope, default compact) is real since 2026-08-03: the root carries
data-density, andcompact:utilities (app.css @custom-variant) tighten the sidebars' vertical rhythm — session rows and rail dots close up, file-tree rows drop to h-5 — while comfortable keeps the roomier baseline. -
A render throw never empties the window (decision 2026-08-03). React unmounts any tree it cannot render, so before this an exception in render — or in a commit-phase effect — left a white page with no message and no way back; that, not the bugs themselves, is what made v0.0.22's hook-count change and v0.0.23's duplicate pane ids look catastrophic.
components/error-boundary.tsxis mounted twice: around the ROUTED view (keyed by pathname, so a crash in a workspace leaves the top bar alive and navigating away clears it without a reload) and at the ROOT (the shell, providers, token gate). It states what stopped rendering, shows the error message verbatim, says plainly that sessions and worktrees are on the daemon and the layout is saved — a blank window taught users otherwise — and offers Try again / Reload. It logs the error and component stack to the console: a boundary makes a failure legible, it must never make one quiet. -
Honest form fields (decision 2026-08-03): an optional field whose empty value would be silently substituted is prefilled with that effective default (profile branch prefix, project abbreviation, session base branch, tab-title template) — the field always shows what will actually be stored. Where empty genuinely means something (no prefix, the repository default, the hostname), the placeholder or description says exactly that, never a value that would not apply. Numeric settings commit on blur/Enter (
NumberField), never per keystroke — a half-typed value must not apply mid-edit — reverting on empty/invalid input and clamping to their range. -
Narrow viewports (phones): below 768px the workspace drops the three-panel shell for the two slim rails plus overlays — expanding the navigator or the session list opens it above the tiling area over a translucent ground (tap the backdrop to dismiss), and opening a file or navigating to a session dismisses it, so the content is never left covered. Overlay visibility is ephemeral local state: the persisted
sidebar_collapsed/sessions_collapsedflags describe the desktop layout, and a phone visit never rewrites them. Controls revealed on hover (tab closes, row menus, card actions) are always visible on coarse-pointer devices (pointer-coarse:variants) — touch has no hover, and an invisible control is not a control. -
Quality floor: visible keyboard focus everywhere (
--focus-ring); session tabs, palette, and explorer fully keyboard-navigable; empty states direct action ("No sessions yet — press ⌘K to start one"); error copy states cause and fix, never apologises vaguely. -
Window title: a project workspace normally reads
<project> — <host>; while agents wait it reads● <n> waiting — <project> (<host>). Waiting status never replaces the host label, because similarly named projects can be open against different machines.
- Monorepo (pnpm workspaces):
packages/daemon,packages/web,packages/cli,packages/shared(zod schemas + WS message types shared by all three). - TypeScript strict everywhere; vitest for tests; eslint + prettier; MIT licence.
CLAUDE.mdat the repo root governs agent conduct and linksCHANGELOG.md; see those files. Archived changelogs live indocs/changelogs/CHANGELOG-vX.Y.Z.md.- Licensing rule: this is a public MIT repo. Do not copy code from AGPL projects (e.g. claude-squad) — studying their approach to worktree/PTY edge cases is fine; verbatim or near-verbatim code is not.
- No company-, team-, or person-specific strings anywhere.
Each phase must be independently verifiable before the next starts.
- Phase 0 — scaffold. Monorepo, CI (typecheck + test + build), CLAUDE.md/CHANGELOG.md conventions in place. AT:
pnpm buildproduces daemon with embedded UI assets; CI green. - Phase 1 — daemon core. Profiles/accounts/repos/projects/sessions CRUD; local-security layer (token, Host/Origin checks — §2); permissions-gate enforcement; claude-code adapter; worktree create/remove (per-repo mutex, fetch policy, onboarding preamble injection from onboarding_notes + marker-file notes sync); PTY spawn with
CLAUDE_CONFIG_DIR; WS streaming; append-only logs; reconcile pass. AT: via curl + wscat only — two sessions on two accounts stream interleaved output; requests without the token are rejected;skip_permissions: trueagainst a closed gate returns 400;systemctl --user restart puddledmarks sessions interrupted; resume restores both conversations; logs replay; a session on a fresh worktree receives the onboarding preamble (and a hand-off session in the same worktree does not); writing.puddle/onboarding-notes.mdupdatesrepos.onboarding_notes. - Phase 2 — UI shell. Design system foundation first (tokens.css with both themes + registry, CI token/contrast check, Tailwind + shadcn setup, fonts, runtime-generated xterm/Monaco themes — §12), then: project dashboard; project workspace with session tabs and live status indicators; terminal attach with replay; new-session modal (account → base branch → optional branch name; title and first prompt are given later, in the session itself); interrupted-session resume button; theme switcher; settings panel (all §11 sections; permissions gate with its confirm dialogue); ui_state persistence and restore-on-open (AT: open a project with three sessions and two editor tabs, kill the browser, reopen
/project/:id— identical workspace; AT: switching theme restyles chrome, terminal, and editor together with no reload). - Phase 3 — files, diff, history. Tree browser, Monaco editing, diff tab (editable modified side), history tab; file transfer (drag-in upload onto explorer folders, context-menu download with zipped folders — §8). AT: edit a file in the diff view; the agent's next
catof it shows the change; commit list matchesgit log; drag a file from the local desktop onto an explorer folder — it appears in the worktree; download a folder — a zip of its contents arrives. - Phase 4 — terminal links. URL addon, file-path link provider with resolve validation, open-in-editor deep links. File-path ranges span every visual row after soft wrapping, terminal-rendered hard wrapping at the right edge, and resize reflow; resolve validation keeps inferred hard-wrap joins from lighting up unrelated text. AT: cmd+click on
src/x.ts:42in agent output opens Monaco at line 42. - Phase 5 — ports. Detection table + copyable
ssh -L; then tier-2 proxy with WS upgrade. AT: a Vite dev server started by an agent is usable through/proxy/...including HMR. - Phase 6 — CLI.
puddle launchbootstrap/upgrade/tunnel/browser;attach,status,logs; the serving switch: the CLI serves the UI at a stable local origin and proxies/api+/ws(local and SSH modes alike — §2), the daemon build stops embedding web assets and its default binding moves to127.0.0.1:7434, and the protocol handshake with automatic major-mismatch daemon update goes live (§6). AT: on a box with no puddle installed, onepuddle launch user@hostlands in a working cockpit athttp://localhost:7433;puddle launchlocally lands in the same cockpit at the same origin; against a daemon with an older protocol major,launchupdates it automatically, prints the interrupted-session count, and the sessions resume. - Phase 7 — more agents + continuation. DONE. codex, opencode and gemini-cli adapters (gemini-cli beyond the original scope), capability matrix verified against installed versions, degradation paths exercised; cross-agent (tier-2) hand-off (AT: hand off a claude session to codex — new session in the same worktree opens with the transcript summary as its first prompt). Tier-1 same-agent migration ships ahead of this phase (Workstream S, on the shared conversation store):
POST /api/sessions/:id/migrateand the "Move to account…" menu are already live for claude-code; its acceptance script isdocs/acceptance/tier1-migration.md(AT: exhaust-simulate a claude session, migrate it to a second claude account — conversation resumes with history intact). Phase 7 extends migration to the newly added agents and adds the tier-2 hand-off. - Phase 8 — polish. Scratchpad (right-sidebar CRUD, drag-reorder, tag/agent filter, insert-into-terminal + copy; AT: save a profile-scoped prompt and a project-A-scoped note — from project B the profile prompt shows and the project-A note does not; insert pastes into the focused terminal without submitting); waiting_input notifications (title badge + optional sound), archive/cleanup flows, log rotation (size cap from config.json), shell tabs.
Exact resume/session flags for the installed codex and opencode versions.Resolved (Phase 7). Verified against codex-cli 0.146.0, opencode 1.18.10 and @google/gemini-cli 0.53.1; every finding is pinned in the adapter header with the version checked, and the per-adapter notes in §5 are rewritten from those runs. A logged-in Codex 0.146.0 run on 2026-08-03 also resolved its status regex: the idle composer is› … <model> · <directory>, not? for shortcuts. The remaining live-only questions — OpenCode and Gemini CLI status regexes, whether Codex honours its bypass flag on resume (openai/codex#9144), and whether Gemini's--resumeaccepts a UUID as well as an index — are tracked indocs/acceptance/phase-7-agents.md.Session-file portability between accounts (tier-1 migration).Resolved (Workstream S). For claude-code no file moves at all: the conversation lives in the profile's shared store and every account reads it through a symlink, so migration is "resume under the other account's config". Verified against Claude Code 2.1.209 that--resumereads a conversation through a symlinkedprojects/<dir>and tolerates missingtodos/(per-account, does not travel). ThemigrateSessioncopy-and-rollback hook remains the specified fallback for agents that can't share state; the full two-account end-to-end flow (adopt under A, resume under B with B's real credentials) is the acceptance scriptdocs/acceptance/tier1-migration.md.- Whether
claude --session-idis accepted by the currently installed Claude Code version; if not, fall back to post-launch discovery of the newest JSONL in<config_dir>/projects/<cwd>/. systemd user-session availability on the target box (Resolved (2026-08-13). The installer prefers a working systemd user manager and otherwise records launchd/nohup/none. An SSH launch whose recorded nohup child is dead holdsloginctl enable-linger); confirm the fallback supervisor path works.puddledin a cockpit-owned exec channel, cleanly interrupts it on close, and restores durable sessions on the next launch; a live or supervised daemon is never joined by the fallback.Proxy base-path limitation: decide whether to attempt HTML rewriting (probably not for v1) or document theResolved (2026-07-14): no HTML rewriting — referer recovery instead. The cockpit origin 307-redirects any non-ssh -Lfallback per port./proxyrequest whoseRefereris a proxied page back under that page's/proxy/<sid>/<port>prefix (see §9 tier-2 caveat (c)), which fixes absolute asset paths and absolutefetch()calls without touching response bodies. Residue: WS handshakes andno-referrerpolicies carry no Referer — the per-portssh -Lcopy remains the escape hatch.- Multi-host UI (post-Phase 6): whether one CLI process can serve several host connections from a single origin (path-per-host routing, e.g.
/h/<host>/…) instead of one origin per SSH launch. The CLI-serves-UI architecture (§2) leaves this open; one-origin-per-connect is the v1 answer.