# session attachment Session creation and user turn admission use durable [operation receipts](operations.md). An accepted submission survives a daemon restart while it waits for transcript consumption. A retry with the same operation ID returns the original admission. The CLI app package owns session ID resolution. Full lowercase UUIDv7 IDs address roots and children directly. Shortened IDs match only root sessions, with an exact match taking precedence over prefixes. A missing full ID returns the same no-match error as a missing prefix; resume never creates a replacement. Opening reads the root list and settings once, then captures the selected session once and adds a child to its initial list if needed. opening a session attaches the cli to its event stream and independently reads `GET /sessions/{session_id}?tail=0`. replayed history does not establish the session's live phase; status and live events do. an idle status is enough to enable the composer, including a persisted `interrupted` session after a daemon restart. a missing or unrecognized phase is display metadata, not a failed connection. before live status is known, enter preserves the draft; superseded status replies cannot unblock it. starting a new turn does not require `/compact` to change the old phase. ## ACP sessions ACP creation commits the `sessions.acp` marker and `acp_overlay_identities` together. The marker survives a restart. While it is set, the session accepts input only as `acp_prompt` from the current attachment. `turn.next_batch` runs each ACP input as its own turn. `daemon/acp_attachments.gleam` owns the in-memory attachments and overlays. `daemon/http_acp.gleam` owns the routes and their authorization. The [ACP reference](acp.md) covers leases, resume, history loading, and restart behavior. ## Live status without a poll `GET /sessions/{session_id}?view=status` answers the live facts alone: `id`, `cursor`, `status`, `kernel`, `usage`, and `glances`. Its source is the session actor's own memory plus its kernel's port owner, never configuration, history, or a composition read; the one exception is a session whose sidebars no stream has published yet, where the handler observes the composition for that answer. Tail and replay parameters belong to the event stream and are refused here. Most of what that read covers also arrives on the stream, so a watching client needs no cadence of its own: - `kernel` carries `{"kernel": Kernel}`, the same object the session resource carries. `albedo_python` runs the closure the session registered with `kernel.watch` whenever a frame changes the job facts it reports (a job starting or ending, a remote job listing, a live cell, the kernel attaching or going stale), and the session publishes `session_namespace.kernel_facts` only when they differ from what it last sent. Nothing observes the kernel on a timer. - `glances` carries `{"glances": [Glance]}`, the extension sidebars. Another session writing the work ledger or a vent publishes nothing here, so while at least one client watches, the session re-reads the sidebars every 10 seconds (`session_state.glance_ms`) and publishes only a change. The observation runs on a spawned process because it queues behind kernel boots, and the last watcher leaving cancels the timer. - The turn's own phase keeps arriving as `status`, usage as `usage`, and a reset batch re-seeds all of it for a fresh attachment. A daemon from before these events never sends them, so the CLI keeps one safety read (the status view) every 60 seconds while its stream is healthy, plus the read it makes when it attaches or the terminal regains focus. ## Running observations `daemon/albedo_bus.erl` owns the ephemeral running cache. Only active turns retain a `{SessionId, OwnerPid}` record. An idle publication removes the calling actor's exact record, and both registry owner-death handling and the progress owner monitor remove the stopped actor's record. Delayed cleanup cannot remove a replacement actor's active mark. Missing records and dead owners read as idle, so the registry can check activity without calling a busy session. The table lasts for the daemon lifetime; it retains no idle or deleted session IDs after owner cleanup. Changes to this cache require a fresh daemon lifetime rather than hot-loading code. ## Multiplexed browser streams `GET /events` upgrades to a WebSocket through the ordinary bearer-token and origin admission path. One socket process owns up to 64 follows; it never spawns an SSE writer per follow. The browser proxy supplies the bearer token. Client frames select a key: `{key,type:"session",session_id,tail:0}` with optional `after_generation` and `after_seq`, `{key,type:"sessions",scope:"roots"}`, or `{key,type:"stop"}`. Subscribing an existing key replaces it. Keys contain 1 to 128 UTF-8 bytes; commands are at most 4096 bytes. Session replay parameters use the SSE validator. The response `{key,batch:...}` embeds the same SSE batch, including resets, snapshots and failures. `{key,end:true}` ends one follow, including an explicit stop, deleted session, failure or collection overflow. Invalid commands and the follow cap return `{key,failure:{code,detail}}`. A malformed command without a usable key has an empty key. `{type:"ping"}` receives `{type:"pong"}`; the socket also sends a pong every 15 seconds. `http_session_stream` owns replay reads, batch encoding and keyed watch/ack/close helpers for both transports. `http_session_collection.Follow` owns the collection filter, bounded bus drain, batch encoding and rearming for both. The socket sends only from its own process and acknowledges a cursor only after a successful frame. Each frame is at most the HTTP response limit, with envelope room reserved in the shared encoders. Producer wakes remain coalesced; stale wakes from replaced keys cannot rearm the new follow. Keyed session watches allow several follows of the same session on one socket. Unsubscribe removes watches and monitors; socket closure closes every follow, and producer monitors also clean up a dead owner. ## Stream failures and recovery Internal checkpoint and provider-started signals are suppressed before stream encoding and do not allocate SSE sequence numbers. Session streams require the `text/event-stream` content type, zstd-coded when the request accepts it ([encoding](../docs/http-api-design.md)): `http_stream` flushes the coder after every frame and ends the frame before a deliberate stop. A zstd context works only in the process that created it, so the stream process creates its own writer. Frames are coded with a 64 KiB window, which is the decoder memory a client holds for the life of the stream ([encoding](../docs/http-api-design.md)). Each batch carries a nonempty `generation`, a nonnegative integer `cursor`, and an `events` array. The generation identifies one lifetime of the session actor. Reconnect requests send both `after_generation` and `after_seq`; an initial request omits both. A missing or different generation, or a sequence outside the replay window, returns a durable transcript reset. Empty keepalive batches carry the current pair too. Attachment requires `session_replay` capability version 2. Reset snapshots include `active_output`: unfinished text and thinking captured with the same actor cursor as the durable history watermark. The session actor owns this projection. Do not reconstruct it from the replay ring or bounded activity previews, which may have already discarded the beginning of a response. The projection retains at most 64 KiB of raw inline text and emits at most 64 KiB of encoded inline content across its segments. Larger output spills to temporary files with 16 KiB buffered writes. Capture flushes pending writes before returning references. Each reference fixes the byte cutoff at capture; later deltas cannot extend that prefix. Full-content pages use `/sessions/{session_id}/active-output/{content_id}` and retain the original `snapshot` query while following `next` tokens. The CLI stages complete prefix hydration before replacing its transcript, then restores history and active output before processing later events. Provisional chunks retain message identity even after the live display buffer settles them. Canonical assistant and thinking publication precedes `committed.replaces_live_ids`, which identifies matching provisional chunks. Model commits set `replaces_all_live` to retire all provisional output, including fragments absent from the bounded projection after a storage failure. Retry identities never reuse a failed attempt's content. What a turn streamed but never finished is committed rather than dropped; see [unfinished turns](#unfinished-turns). Issued spill references live for 15 minutes across commit and retry. Existing maintenance removes expired and orphaned files; session deletion revokes them. Spill storage is bounded to 64 MiB per run, 1 GiB across retained files, and 4096 retained files. A projection has at most 256 segments. Spill directories use mode `0700`; content and metadata use `0600`. Owner identity includes the VM incarnation so reused PIDs cannot retain orphaned files after restart. Storage failure makes backfill explicitly unavailable rather than returning a truncated successful snapshot. It must not abort generation or discard a durable commit. Expired references return `410 active_output_expired` and cause the client to capture a new snapshot. Do not add compatibility readers for snapshots without active output. Text and thinking deltas reach subscribers at most once a frame (16 ms): `session_state.emit` publishes a delta after a quiet frame at once and holds later ones until the frame ends, merging neighbours of one message into one event. Any other event, a commit, a retry, and the end of a turn publish the held deltas first, so the stream keeps its order and active output never lags a commit. The replay window keeps at most 256 events and less than one response's bytes, so any replay fits a single batch. Session subscribers receive coalesced wake notifications. Event payloads stay in the session's existing replay buffer; a successful frame acknowledges its cursor before another wake is issued. A subscriber's death removes its watch without requiring another event. A session that stops (closed at shutdown or deleted) wakes its subscribers, and a stream whose session stopped or did not answer ends with a clean EOF rather than a failure batch: the daemon closes sessions before it stops listening, so that is a restart or a deletion, and the client's reconnect learns which. The CLI validates the whole batch before consuming it and saves both cursor values only after every callback succeeds. Initial attachment and a generation change require a leading reset. Within one generation, the sequence cannot decrease without a reset. A generation change with a reset is normal recovery. A change without a reset is a protocol failure. The reset replaces visible history and clears live tool progress. Reset batches include `current_progress` in the captured session snapshot. The CLI delivers that snapshot after history as live progress, so attaching during a tool call restores its current tool name, phase, and bounded code preview. Older-history navigation uses transcript row IDs. Unknown event kinds are ignored. Missing kinds and malformed known events are protocol failures. Transport failures, ordinary EOF, HTTP 408/429, and server errors reconnect with a delay starting at 500 milliseconds and increasing to at most five seconds. EOF and transient failures retain the saved pair. Cancellation ends the subscription quietly and clears the pair, as does explicit protocol recovery. Other HTTP refusals, explicit SSE failures, and consumer callback failures end the subscription with a visible notice, except an SSE failure coded `daemon_unavailable` or `daemon_stopping`, which older daemons send while they shut down and which reconnects like a server error. A terminal failure also stops status polling for that attachment. The TUI attempts one durable transcript reset per attachment after a protocol failure. It clears live tool progress and requires the recovery stream to begin with a reset. A second protocol failure ends automatic recovery; reopening the session starts a new attachment. Pending submissions and drafts remain available, and a stream failure does not claim that model execution finished. ## Notes in the transcript The TUI renders daemon notes as faint plain text, wrapping each line before styling it. Never style a whole multi-line note as one Lip Gloss block: that pads every short line to the longest line, which can span thousands of columns in a capability update. Long notes use the same click-to-expand fold as user messages. Folding changes only the display, not the stored or model-facing text. ## Repeating steps A model can loop across the steps of a turn while each reply looks fine on its own. After each step that ran python cells, the loop (`harness/repetition.gleam`, called from `harness/loop.gleam`) compares the step with the one before it: the same set of bindings called (the union of the step's cells' trace `calls`, see [kernel](kernel.md#file-change-traces)) and cell code, joined and cut to 8192 scalars, whose `difflib.SequenceMatcher` ratio is at least `similarity` percent (`albedo_similarity.erl` is a port that matches Python's, autojunk included, and tries the cheap upper bounds first). When `steps` consecutive steps match, it commits a note from origin `repetition` beside the step's tool results: the model reads it at its next call, and the user sees the same note in the transcript. The turn goes on, and the count starts again, so a model that keeps repeating hears it once per `steps` steps. It holds only the previous step and a count. These steps never count and end a run: a step with no python cell, a step whose cells called no binding or only ones that wait or read back earlier work (`cells.info/read/trace/list`, `output.read/list`, `mail.open`, `agents.self.children/siblings`). A backgrounded cell adds no calls, since its trace arrives after the step. The watch belongs to one turn, so heartbeat and wake turns, a step or two each, never add up. The daemon's own `repetition` section of `extensions.json` holds `steps` (default 8, 2 to 1000, -1 never notes) and `similarity` (default 80, 1 to 100), read at the first step of the turn that ran a cell. The defaults are the rule from dawn's agent on Strata, which flagged 13 of 30 SWE runs on a bad LoRA and none of 30 on the control. `test/e2e/repetition_test.py` covers a repeating turn, one whose code changes, and one that only waits. ## Loaded sessions and unloading A session is loaded while its actor runs; only a loaded session has a stream cursor. Opening it, watching its stream, or submitting to it loads it. Reading `GET /sessions/{session_id}/history` never does, so the session list previews stored history instead of the session resource. A history page carries a reference instead of any content field over the page's inline budget. The client resolves every reference of a page in one walk of `GET /sessions/{session_id}/history/content?entries=...`, which serves the same parts as the single-entry route for up to 200 entries and stops on the same byte budget, so a page of oversized tool output costs one request per byte budget rather than one per entry. Decode each part once: the reference resolution, the projection into events, and the tool-call argument merge all read the decoded parts, and a decoded `json` part keeps the stored bytes as its value rather than another JSON encoding of them. The maintenance sweep releases an idle kernel after `ALBEDO_IDLE_SECONDS` (default 10 minutes) and unloads a session whose last turn (`activity_at`) is older than `ALBEDO_UNLOAD_SECONDS` (default one hour): the provider's prompt cache has expired by then. The session refuses while a turn runs or waits, a client watches its stream, a background job is live, or one of its children works — runs a turn, or sits between turns on a job of its own, or has a child that works: that child's report will wake it, and its prompt cache may be kept warm meanwhile (robot-docs/cache-warming.md). Unloading saves the kernel's variables like a release. The next request loads it again. Listing sessions reads loaded sessions' summaries but does not count as attention, so an open session list never keeps a kernel alive. The same sweep collects every waiting process whose heap passed 1 MB (`albedo_daemon:collect_idle`): a long-lived actor otherwise keeps the heap a busy turn grew it to. The launcher's VM defaults (`priv/bin/albedo-daemon`) then hand the freed carriers back to the OS instead of caching them. The session list marks a session active (green) while it is loaded and its last turn is under an hour old. ## asynchronous persistence failures The dispatcher logs failures to advance delivered schedule occurrences or save mail delivery errors. Undelivered mail and unadvanced schedules remain durable and retry under the existing dispatch policy. A schedule whose submission succeeded but whose advance failed can be delivered again. ## Prompt ownership and cancellation Inputs use `PUT /sessions/{session_id}/inputs/{input_id}` with a client-generated UUIDv7. The same identity follows admission, transcript consumption, turn membership, and cancellation. Accepted inputs survive restart. The [input contract](../docs/http-api-design.md#input-admission-and-outcomes) defines message, continuation, skill, and command variants. `POST /sessions/{session_id}/inputs/{input_id}/cancel` returns the current input and `cancelled`, `interrupt_requested`, `shared_running`, or `not_pending`. A waiting cancellation commits its receipt and pending removal together. A running turn shared with other inputs continues. Cancellation never falls back to a session-wide interruption or affects a later unrelated turn. The CLI uses a separate five-second cleanup deadline and reports unconfirmed cancellation when cleanup fails. An interruption acknowledgement does not claim that work stopped. Live turn membership precedes worker output and expands when queued input steers a turn. Completion follows its final output or failure. The durable input read retains membership and terminal outcome across reconnects and restarts. Prompt clients follow their input identity instead of inferring completion from echo metadata or idle status. Drafts and unresolved input handles survive resets. ## Unfinished turns A turn that stops before its output commits keeps what happened (`session_salvage`). When the user stops it, when it fails, or when a provider stream breaks after showing output, the session commits, in order: a result for every call of the run that nothing answered, the text and thinking still in the active output, and a `` user row saying where the response ended. The call the tool gate last let start gets "may have had effects; inspect them before any retry", because the worker may have been killed mid-call (a cancel kills it after 2.5 s); every other open call gets "not run". Calls whose arguments were still streaming never ran and are named in the note. The streamed text and thinking become one chat-shaped assistant item marked `albedo_unfinished`, which history shows as thinking and text like any response. No provider signed or encrypted it, so the projection never replays it natively, and a model reads only its text, as plain assistant text. The thinking never goes back to a model: Anthropic's `reasoning_extraction` classifier refuses requests that carry unsigned reasoning, with an empty response, and keeps refusing every later turn of the session. Measured on claude-sonnet-5-5 and claude-opus-5-5, thinking sent as a `` was refused on 11 of 11 sessions; as `[Reasoning summary]` assistant text on some; left out, on none. Thinking another provider produced, which reaches a new provider as plain assistant text when a session switches, carries no label: a `[Reasoning summary]` label only invites the classifier. A user stop always gets the note ("The user stopped this response here."). A failure gets one only when something was kept. A retryable provider failure after the stream showed output does not resend the same request: the loop asks the session to commit the output with a note to continue from it, then starts the step again from that history, counting the attempt against the same retry budget. A failed attempt that showed nothing, or only partial tool call arguments, is retried unchanged. ## rewind `POST /sessions/{session_id}/rewind` (capability `history_rewind`) cuts an idle session back to before a user message and keeps everything it removes. The module doing it is `daemon/history.gleam`, beside the fork it reuses, and it runs inside the session actor (`RewindHistory`) so no submission can land mid-cut. One transaction does the work. It copies the whole session into a new archived session with `fork_in` (its name is `rewound: ` plus the title; `archived` is the ordinary preference, so it is a normal session that opens, forks and deletes like any other), then `remove_tail` deletes every row from the checkpoint on and what is keyed by those positions. The rules for that, in one place: - the rows themselves, their `transcript_calls`, `transcript_quarantine`, `submission_events`, and the captured traces of the cells they ran; - `continuation_markers` anchored at the last row kept or later, as a fork drops them; - `provider_requests` stay for usage and lose their `seq`, which is nullable for calls with no row; - LCM nodes whose source reaches past the cut go with their edges (`lcm_graph.truncate`), and the cursor falls back to what a surviving leaf covers; - the preview, and the image payloads only the removed rows named (`images.release`); - notes, rolling state and snapcompact archives are not touched: each checks its fingerprint against the history it is given, so a cut it covered is recomputed. A table keyed by `session` and `seq` that is not in that list fails `RewindStoredStateTest` in `test/e2e/rewind_test.py`, which reads the schema; a `seq` that counts something else (a kernel link's frames) is listed there as unrelated. When the cut succeeds the actor stops like an idle `Unload`, but ignoring its own watchers: their streams end, clients reconnect to the actor the registry starts on the next lookup, and a new actor generation means a durable reset. The registry learns of the archive through `Adopt`, which the HTTP handler sends before it answers. The Python namespace and files on disk are not rewound. With `summary`, the handler first asks the actor to start a summary run (`StartSummary`, work `turn.Summary`). It holds the session like a background call, sends the tail to the model through the compaction summarizer (`loop.summarize_branch`, instructions in `compaction.branch_summary_instructions`), and the worker answers the waiting handler directly with the text and the last row it covered. Only then does `rewind` run, with the summary stored after the cut as a `rewind` system note, and it refuses (`history_changed`, 409) if the session has grown past that row. A summary that fails or comes back empty is a 502 `rewind_summary_failed` and nothing has changed, not even the archive. ## Human shell commands A composer-leading `!` is a human action, not model syntax. The clients remove that prefix and submit one immutable command to `PUT /sessions/{session_id}/shell-commands/{command_id}` (UUIDv7). No request on this resource submits a model turn or boots a Python kernel. Literal bang text in ordinary inputs, pastes and model replies is never executed by the daemon. Remote workspaces are explicitly unsupported in v1. The session actor owns admission, one-running-command exclusivity, lifetime and idle transcript serialization. `daemon/shell_commands.gleam` owns durable identity, outcome and bounded output chunks through the existing store writer. Admission is durable before spawning; same ID and command returns that original resource before mutable checks, while changed intent conflicts. Up to 32 uncommitted records can wait during an active model turn. Admission also reserves at most 512 KiB of aggregate encoded snapshot space: each command reserves its JSON-escaped command bytes plus 12 KiB for preview and metadata. Large commands can therefore fill this budget before the 32-record cap. Completion is visible immediately as `shell_command` events with `{shell_command: ShellCommand}`; reset/session captures include `shell_commands`, only uncommitted records. Snapshot previews are at most 4096 UTF-8 bytes per command; direct reads retain at most 65536 preview bytes. `output_bytes` always describes retained capture. At idle, one writer transaction appends a labeled ordinary user context row and anchors its typed metadata to that transcript position. During a turn, a completed receipt is attached and returned as a user-context input at the next `DrainSteering` safe point. Canonical history shows kind `shell_command`, content `{kind:"json",field:"shell_command",value:...}`. `history_seq` records transcript attachment; required `delivery` is `unknown` (legacy), `pending` (not yet acknowledged, whether running or completed), `delivered` (included in a request whose provider emitted `Started`), or `detached` (removed by rewind). The request wrapper captures its exact shell context before sending; its first `Started` marks only matching attached pending receipts delivered, in one transaction, and emits `shell_command` updates. A failed request before `Started` leaves them pending. Neither attachment nor delivery creates input ownership or a fabricated tool call. Detached receipts remain readable for retry recovery without being reinserted. The model reads an at-most-16-KiB output excerpt, with `await output.read_shell("command-id", offset=0, limit=4000)` for full capture. A completion while idle never starts generation. Idle attachment alone does not imply model delivery; the next ordinary request includes it without reinjection. A fresh noninteractive login shell is chosen from `$SHELL`, the passwd login shell, then `/bin/sh`. It runs from the captured session workspace with stdin `DEVNULL`, merged stdout/stderr, no PTY and no persistent shell state. The standalone `priv/python/albedo_user_shell.py` is a process supervisor, not the session kernel. Its port has one acknowledged output frame in flight, applying backpressure through persistence and actor publication. It strips terminal controls and replaces invalid UTF-8 incrementally before storing any output. Capture retains a head prefix of at most 16 MiB, continuing to drain after the cap. Byte pages never split codepoints and stop at their captured cutoff. Stop targets only that command's process group and can answer `running` while the checked TERM/KILL cleanup finishes. Shell exit also ends background children; EOF on output alone is not completion. Session closure/deletion and daemon port EOF end the whole owned group. Cold boot marks any durable `running` record `interrupted`; it is never rerun. Session deletion removes records and chunks; forks copy committed typed metadata/chunks to the new row positions, and rewind keeps old operation identities as detached outcomes rather than re-admitting them.