Sleeping terminals
A plan for Sleep/Wake. Model the terminal as a sum — Terminal = active or sleeping — so a dormant terminal is a STATE on one record, not a separate thing. Sleep flips that state IN PLACE on a stable id; the live PTY/agent are released but the persisted base — cwd, layout, last agent command — stays, so wake re-spawns and resumes the agent exactly the way a server reboot already does. Presence reads the union; touching a live field narrows to active.
You asked for Sleep: leave a Claude Code terminal blocked for days, sleep it (its PTY, xterm, WebGL context, and agent all released — gone, like closing it), and wake it later in place with the agent resumed.
The model is one move: make the terminal a sum — Terminal = active | sleeping.
A dormant terminal carries the same persisted base — cwd, git, intent, its
canvasLayout slot, the last agent command — so it keeps the canvas position, dock
order, and persistence the live terminal had: it stays the same record under the
same id in the one terminal registry the canvas already iterates, just without a
PTY. Sleep flips its state flag in place and releases the live resources; wake
flips it back and re-spawns through the path a server reboot already uses. Three
phased PRs; the first is a zero-behavior-change foundation.
A terminal is active or sleeping
The fold already lives in the schema. surface.ts splits a terminal’s fields into
a persisted base (cwd · git · intent · theme · canvasLayout · the last agent
command — survives a restart) and a live overlay (agent status · foreground ·
live-PR · the PTY/xterm/attach handles — “never persisted; a restore must re-derive
it”). That partition is active-vs-sleeping, so the sum maps onto bases that
already exist — exactly one field is added:
const ActiveTerminalSchema =
PersistedTerminalFieldsSchema.merge(LiveTerminalFieldsSchema) // live overlay present
.extend({ state: z.literal("active") });
const SleepingTerminalSchema =
PersistedTerminalFieldsSchema // base only — overlay absent by type
.extend({ state: z.literal("sleeping"), sleptAt: z.number() });
const TerminalMetadataSchema = // the wire / collection shape
z.discriminatedUnion("state", [ActiveTerminalSchema, SleepingTerminalSchema]);
type Terminal =
| ({ state: "active" } & PersistedTerminalFields & LiveTerminalFields)
| ({ state: "sleeping"; sleptAt: number } & PersistedTerminalFields);
// Presence reads the union; touching a live field MUST narrow.
const placeTile = (t: Terminal) => t.canvasLayout; // both arms — no narrow
const routeInput = (t: Terminal) => {
if (t.state !== "active") return; // compiler-forced narrow
send(t /* now carries pr · agent · foreground + a live PTY */);
};
sleptAt is the sleeping arm’s analogue of the live overlay — the only new scalar.
An active terminal is base + overlay; a sleeping terminal is base + sleptAt.
Sleeping is one record whose state flag says whether its PTY is currently
spawned — sleep clears the overlay and sets the flag, wake re-derives the overlay
and clears the flag, and the id never changes.
Presence reads the union, liveness narrows
Putting the discriminant on the terminal buys two structural properties:
- Type-safe presence vs liveness. A consumer that reads
agent/foreground/pr/PTY/xterm must first narrowstate === "active"; the compiler refuses a live field on the bare union. There is no live-only list to read from by mistake — so once a sleeping terminal reaches a presence surface (canvas, dock, minimap, arrange, cycle, switcher) it cannot be mis-rendered. Reaching it is a runtime fact, not a type one: the sum stops mis-rendering, it does not by itself deliver presence — a sleeping record appears because it is the same entry in the one registry the client already subscribes to, so it rides the existing id list with nothing extra. Input routing resolves inside the active narrow, so a sleeping terminal can be the active/selected/panned-to tile yet is never an input target. The WebGL budget keys on theactivearm, so a sleeping terminal holds no WebGL context. - One persistence channel, one write sink.
SavedTerminalis alreadyPersistedTerminalFields + id— the sleeping arm’s exact payload minussleptAt. A restored terminal and a slept terminal are the same on-disk shape, distinguished only bystate, so the session snapshot serializes one list and the boot path rehydrates both arms through one seam. And because the record keeps its stable id, the ordinary write sinks (setCanvasLayout,setTheme, rename) find a sleeping entry and mutate its base in place — a sleeping tile drags, resizes, renames, and re-themes like any other, with only PTY input fenced (by type, not a guard).
The phases
Each is one reviewable PR; each leaves master shippable.
| Phase | What lands | Why separable |
|---|---|---|
| 1 — Seat the sum | Add state; flip TerminalMetadataSchema to a discriminatedUnion; presence surfaces read the Terminal union off the terminal store; ship with only the active arm constructed |
Zero behavior change — a pure structural move that makes the narrowing seam exist before any sleep logic depends on it |
| 2 — Sleep / Wake (in place) | Populate the sleeping arm: sleep flips state→sleeping on the same record (capturing the last agent command) and releases PTY/xterm/agent; wake flips it back and re-spawns through the existing session-restore path, resuming the agent exactly as a reboot does; the sleeping tile stays a full canvas citizen |
The user-facing core; reuses the proven restore path, no separate store, no merge seam, no minted id |
| 3 — Frozen screenshot body | Capture just before sleep, write under KOLU_STATE_DIR, serve through a small static image route; the reference rides the record — and the captured frame is what the live→frozen swap cross-fades into, so the sleep transition turns seamless here |
Isolated surface — one capture, one route, one fade |
(The original plan had a fourth phase — “unify wake with session-restore.” The stable-id model makes wake literally session-restore-of-one from the start, so that unification is no longer a separate step; it is how Phase 2 is built.)
Phase 1 — seat the sum (zero behavior change) · shipped
#1449. TerminalMetadataSchema flipped from a flat .merge to
z.discriminatedUnion("state", …) with only the active arm constructed — the UX
stayed pixel-identical while the state === "active" seam every later phase leans
on came into being. The union flows on the client, where every liveness reader
narrows through one activeArm seam, so a live field on a bare terminal no longer
compiles. A state.ts migration stamps state: "active" on legacy records and
bumps SCHEMA_VERSION — the one sanctioned place the default is supplied; read
sites narrow, never coalesce.
Phase 2 — Sleep / Wake (in place)
#1487. Implemented exactly as planned: the one registry holds
the Terminal union under a stable id (TerminalProcess is a discriminated
process — the sleeping arm’s PTY handle is absent by type), sleep flips in place
persist-before-kill, wake re-spawns on the same id and replays the observed
lastAgentCommand through resumeAgentCommand, and boot re-seeds sleeping records
(adopt-or-reap). The dormant tile surfaces the last-known context it was
working — cwd and branch ride the persisted base, while the live PR is snapshotted
onto the sleeping arm at sleep and discarded on wake (the PR sensor re-resolves it
live). The journey e2e asserts the real outcomes — wake resumes the same
conversation, drag a dormant tile then reload, reboot then wake, reboot
mid-sleep converges — not counts. (An agent launched through a nix run …#agent
wrapper — whose observed head token is nix, not the agent — is not resumed on
wake; it wakes to a bare shell, tracked as #1492.)
Populate the sleeping arm by flipping a flag, not minting a record.
Sleep flips active → sleeping in place. It captures the agent’s resume input
(the last agent command) onto the persisted base, flips the state flag on the
same record under the same id, writes the session durably, then releases the
PTY/xterm/WebGL/agent — persist before kill, so a crash mid-sleep loses nothing.
No new id, no second store, no retire-the-predecessor: the record the canvas was
already showing simply changes state, so the tile keeps its slot, dock order,
selection, and id with zero swap.
Wake is session-restore-of-one — literally the path a reboot runs. kolu already
rehydrates terminals on server restart: it re-spawns the PTY in the saved cwd and
resumes the agent with resumeAgentCommand. With the persisted agentSession ref
(juspay/kolu#1495) that resume targets the exact conversation that was running on
this terminal — claude --resume <id>, codex resume <id>, opencode --session <id>
— and falls back to the cwd-most-recent form (claude -c &c.) only when no session
was ever captured. Wake flips the record back to active and replays that same
path on the one record. So wake resumes your agent to exactly the degree a reboot
does — the bar you already trust — with no bespoke sleep-only resume. The persisted
base carries cwd + the last agent command + the conversation ref, which is
everything that path needs; the in-place flip keeps them on the record by default.
One registry, one list — no merge seam. A sleeping terminal is the same entry
in the one terminal registry, so it rides the one id list the client already
subscribes to: no second store to union, no “three snapshot reads must each include
sleeping” seam (the first cut’s most error-prone surface). Liveness is the state
discriminant that one canonical classifier reads, so dock, minimap, switcher,
and mobile all show a sleeping terminal coherently from a single source — no
per-surface sleeping branch to forget.
A sleeping tile is a first-class canvas citizen. Because the record keeps its stable id, the normal write sinks find it and mutate in place — it drags, resizes, renames, and re-themes like any live tile. The only thing it can’t do is take PTY input, and that’s a type fact (the overlay is absent on the sleeping arm), not a runtime lockout. The first cut disabled these because an immutable record had nowhere to write the change — the reset removes the lockout by removing the immutability.
Sleep is manual, Wake is explicit, navigation never wakes. A ☾ Sleep button on the tile title bar, a Sleep/Wake palette command, and a discoverability tip are the only triggers — no global keybind, no auto-sleep. Landing on a sleeping tile (cycle, MRU, dock click, switcher, mobile swipe) focuses it frozen: it becomes the active/selected tile showing its dormant body and an explicit Wake, never an auto-respawn — so the right panel, inspector, and theme for an active-but-sleeping tile fall back to the frozen, no-live-content view plus a Wake call-to-action. Closing a sleeping tile routes through the same close-confirm dialog, reworded to discard sleeping terminal and driven off the still-persisted git/worktree info — it removes the record (no PTY to kill) and still offers worktree removal.
Trade-offs & when we’d revisit
- A migration is mandatory.
SavedTerminalis persisted, so addingstateneeds thestate.tsmigration above. There is no shipping this as a pure refactor. - One record, mutated in place; the id is stable.
stateflips on the same record across sleep/wake — sleep releases the live overlay, wake re-derives it, but the id, layout slot, and dock order never move. This reverses the first cut, which minted a fresh id per transition (immutable records) on the theory that “immutability keeps identity un-complected.” In practice that split one terminal’s identity across two ids and two stores, then had to re-knit them with a merge seam and a write-sink lockout — and it stripped the agent session as “live overlay,” so wake resumed nothing. A stable id makes the normal write sink and the existing restore path just work: wake is restore-one, drag is a base write, presence is one classifier. We’d revisit only if mutating in place ever proved to need boot-hydration surgery (it doesn’t — wake replays the path reboot already runs). - Wake resumes the exact conversation that was running — resolved in juspay/kolu#1495.
Originally wake reused only the cwd-scoped
resumeAgentCommand(claude-c&c.), so a cwd with two conversations could wake on the wrong one. The follow-on persisted the agent’s native session id (agentSession={ kind, id }, captured live fromagent.sessionId) and resumes by it (claude --resume <id>&c.), falling back to cwd-most-recent only when no session was ever captured. Exactly the shared improvement to the one restore path this caveat anticipated — it benefits reboot and wake together, never a wake-only fork. - Wake lands a clean resumed session — no repainted scrollback. Identical to a reboot: the conversation is back, but the prior on-screen text is not repainted. Phase 3’s frozen-frame capture is what shows the last visual state during the swap; persisting the live scrollback buffer is a separate, addable follow-on if the blank-screen feel proves unacceptable.