Repository navigation
feat: the terminal on the bridge, end to end - #142
Merged
Merged
Conversation
A phone can open a real shell on a paired bridge. A shell is more than an agent in its most permissive mode, so the design puts the machine's owner in control and makes everything reconstructable afterwards: - three gates: terminals enabled on the machine, a paired identity, and the device approved on the machine (an allowlist the owner edits with the bridge's CLI; withdrawing a device closes its terminals at once); - a device id that survives session-key rotation, and a device name; - the direct link by default, relays only when the owner allows them; - a per-terminal input sequence, so a replayed keystroke event re-types nothing and keystrokes cannot evict command ids from the dedup set; - one writer, read-only attach, an explicit takeover, and a notice to every other device on each open, attach and takeover; - a hash-chained audit log and a full recording on the bridge, with input typed while echo is off recorded as hidden; - input sent when a program can react to it (Enter, Tab, Esc, arrows, chords) or on a pause, with a predicted echo, and a rate cap on relays. Co-Authored-By: Claude Code <noreply@anthropic.com>
A phone needs to drive a real shell on its paired bridge, so the v12 wire defines one, additively. Phone to bridge: `terminal-open` (with the optional session whose directory the shell starts in, since the phone never names a path), `terminal-input` (a per-terminal `seq`, so a replayed signed event types nothing twice), `terminal-resize`, `terminal-replay`, `terminal-takeover` and `terminal-close`. Bridge to phone: `terminal-opened`, `terminal-output` (byte-offset addressed, so a client can ask for exactly what it missed), `terminal-input-from`, `terminal-exited` and `terminal-event`. `terminal-input` and `terminal-output` ride LIVE_KIND — keystrokes and shell output are ephemeral, never stored on a relay — and output goes only to the clients watching that terminal, so `presence` gains `watchingTerminal` beside `watching`. The heartbeat gains `terminals[]` (`TerminalInfo`), so a phone lists shells it did not open. `TerminalEndReason`, `TerminalEventKind` and `TerminalVia` are open vocabularies with the crate's usual catch-all, so a value a newer bridge adds still decodes. The `terminal` capability is advertised only by a bridge whose owner has terminals switched on, so it is deliberately NOT in ALL_BRIDGE_CAPABILITIES: a phone that sent terminal messages to a bridge without it would wait on a terminal that never opens. `recipients::device_id` names a client by the FIRST session key it granted the bridge (pinned by a test vector), so a device keeps one identity across key rotations; `SessionKeyGrant` gains the user's `label` for the audit, display only. The bridge engine and the client runtime do not serve terminals yet, so their exhaustive matches ignore the new messages and the session list builds with `terminals: None`; no behaviour is added there. Co-Authored-By: Claude Code <noreply@anthropic.com>
Part 1 gains a Terminal section: the message list, the `terminal` capability (advertised only by a bridge whose owner has terminals switched on), LIVE_KIND in both directions, the per-terminal input `seq` and byte-offset output, `watchingTerminal`, the device id derived from a client's first session key, and the heartbeat's `terminals[]`. The session-key grant keeps its `label`, display only for the bridge's audit, and the capability and open-vocabulary lists name the new values. Co-Authored-By: Claude Code <noreply@anthropic.com>
… any reinstall is a new device The message's own name and the design read `seq` as where the writer resumes; its doc said the last one applied, so a phone following it would resend one event too late and leave a hole. A reinstall grants a fresh first session key whatever identity it signs with, so it is a new device in every case. Co-Authored-By: Claude Code <noreply@anthropic.com>
deymosh
marked this pull request as draft
October 16, 2026 14:50
`openId` on the open and its answers, `restart` among the end reasons (and the vocabulary open), `data` as any bytes rather than UTF-8 (a key chord need not be valid UTF-8), and the device's `name` optional, as the protocol crate defines them. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ommand came from A terminal needs to know which phone it is talking to across the phone's session-key rotations: the allowlist that says who may have a shell, the audit that says who typed, and the events that tell the other phones who did what all need a name that outlives a rotated key. Every client of an identity shares the identity key, so a stolen one would otherwise open a shell from a phone nobody approved — which is why the name is derived from the first session key the client granted this bridge (`protocol::recipients::device_id`), carried across every rotation, and approved on the machine. The label rides the grant and is display only: trimmed, one line, capped, never what the bridge trusts. A state file written before either field existed hydrates from the key it holds, and is stored back so the name survives the next rotation. The inbound path also learns how a command arrived and what kind of event carried it: a terminal's audit has to tell a relay from the bridge's own link (and an onion endpoint from a plain one), and a phone's keystrokes ride the live kind, which the engine has to tell apart from a command before it accepts either. Co-Authored-By: Claude Code <noreply@anthropic.com>
…the audit A terminal is a shell as the bridge's own user, strictly more than an agent in its most permissive mode, so who may have one is decided before convenience. Every terminal message passes three gates in order: the owner has terminals switched on, the way it travelled may carry one (not a relay unless the owner allows that, since even encrypted a relay sees the size and timing of each keystroke), and the device it came from is on the owner's allowlist. The identity key alone is not enough — every client of an identity holds it. What the engine decides it hands to the runtime as effects (spawn, write, resize, replay, hangup) and gets back as inputs (the shell started, output came, the shell ended): the engine runs no shell, holds no file and owns no timer of its own. A device withdrawn from the allowlist is the kill switch for a lost phone, and the terminals it opened end at once; one it had only taken over goes back to the device that opened it, so the owner keeps a terminal it can still use. A `seq` counts up by one within one writer's run — a takeover hands the pen over and tells the new writer where its numbering starts — and a seq already applied types nothing twice, which is what makes a replayed keystroke harmless. Input that arrives ahead of the one before it is held for two seconds, then the phone is asked to resend from the gap. Output goes only to the clients showing that terminal, and every decision leaves a line for the audit the runtime chains and stores: what a terminal did has to be reconstructible afterwards on the machine. The heartbeat lists what is open, and advertises the capability only when the owner has terminals on. Co-Authored-By: Claude Code <noreply@anthropic.com>
…nd's dedup slot Terminal input rides the live kind, and a typing phone sends an event per key a program reacts to and per pause. Remembering those ids in the command dedup set — the last 1000 — would push every command id out of it within minutes, and a command id that has aged out is a command a relay's replay window runs a second time. What stops a replayed keystroke from typing twice is the terminal's own `seq`, which the engine checks per terminal, so live input is read on a path of its own that remembers nothing and does not move the cursor the command subscription resumes from. The five-minute age limit still applies. Co-Authored-By: Claude Code <noreply@anthropic.com>
… input is capped A terminal kept one `last_seq` for the whole terminal and reset it to 0 on a takeover, so a device's seq only meant anything while it held the pen. Device A typed 1..5, B took over and began its own count at 1, A took the pen back and began at 1 again: a relay replaying A's earlier signed `terminal-input` events — which ingest still accepts up to 300 s old — typed them into the shell a second time. The count is now per device (`Terminal.last_seq`, a map by device id) and is never reset. A device's seq on a terminal only ever grows, so nothing it sent before can be typed again whoever wrote in between, and a takeover tells the new writer the seq its own count resumes at through `terminal-input-from` — the same message a writer gets when the bridge waits on a gap. Applying, flushing, the dedup and the gap all read the current writer's entry; a device that never typed is at 0, so its first input is seq 1. Held input was unbounded as well: `hold` took any out-of-order seq, so a writer could send seq last+1_000_000, or thousands of events, and spend the bridge's memory. A seq more than MAX_HELD_INPUTS (256) ahead of the writer's last applied is now not held at all, and what is held is capped at MAX_HELD_INPUTS entries and MAX_HELD_INPUT_BYTES (1 MiB). On either overflow everything held is dropped, the gap timer cancelled, the writer is told to resend from its last applied + 1, and a Refused audit line says what overflowed — the path the gap timeout already took, now shared as `resend_from`. Co-Authored-By: Claude Code <noreply@anthropic.com>
… terminals A terminal belongs to the identity that opened it: only that identity's clients may act on it, and everything told about it goes to them. The session-list heartbeat broke that, listing every open terminal to every paired identity — so one phone learned another identity's terminal id, its shell, and which device was running it. `terminal_infos` now takes an identity and returns only its terminals. The heartbeat is built once without a terminal list: identities owning none share that single publish, exactly as before, and each identity owning at least one gets a copy of its own. The addressed path groups the addressees by identity and sends each group the copy for it. Every copy of one heartbeat keeps the same `rev` — it is one session list. Co-Authored-By: Claude Code <noreply@anthropic.com>
The terminal engine decodes a phone's base64 keystrokes in bridge-core, which added the crate to its manifest; CI builds with --locked, which refuses a lockfile that would have to change. Co-Authored-By: Claude Code <noreply@anthropic.com>
…t reads termios from A terminal needs a real pty — a unix pty, ConPTY on Windows — and, on unix, the pty's termios to tell whether the shell is echoing what is typed: input typed with echo off is a password, and must not reach the recording. portable-pty 0.9 builds against nix 0.28, which is the version pinned here: its `get_termios` returns *that* `Termios`, and a different nix would be a different type, so the ECHO flag could not be read off it. Co-Authored-By: Claude Code <noreply@anthropic.com>
… and a recording The engine decides everything a terminal does — who may open one, who may type, what order input goes in — and emits effects, but nothing carried them out: the terminal arms in the event loop were empty. This is the other half. `crates/bridge-runtime/src/terminal` owns the ptys, the per-terminal ring of output and the recording; `runtime.rs` wires the arms to it. Spawn. The owner's configured `terminal.command` if there is one, else `$SHELL` or `/bin/sh` on unix, `pwsh`/`powershell`/`cmd` on Windows. The shell runs with a built environment — `env_clear` first, because a `CommandBuilder` seeds itself with the bridge's own environment, which can hold the agents' provider keys. A terminal is a shell as the bridge's user, so the bridge's own context is not the shell's to have. Output. One reader thread per pty hands bytes to a coalescer that delivers `Input::TerminalOutput` at most every 16 ms, or sooner past 16 KiB pending (the transport coalesces again per link). A 256 KiB ring addresses every byte by its offset in the terminal's whole output, so a phone that reconnects, or sees a gap, can replay from where it left off; a replay goes only to the client the engine named, and an oversize one is fragmented by the transport like any other message. Recording. `<home>/audit/term-<id>.cast`, asciicast v2 (`0700` directory, `0600` file on unix): every output frame, every input chunk, every resize. Input typed while the pty's echo is off is recorded as its size and never its bytes — a password prompt is exactly what must not be written to disk — and where no echo state can be read (a ConPTY) input is a byte count whatever it was, since nothing can say. Ending. A shell that exits is reaped by its reader thread, which then reports `Input::TerminalExited`; the runtime drops the pty without signalling it again, because by then the pid is free and could belong to another process. A `TerminalClose` signals the shell, and dropping the bridge kills every shell it started — a terminal is not meant to outlive the bridge that owns it. Co-Authored-By: Claude Code <noreply@anthropic.com>
…bridge's thread The bridge is one current-thread runtime shared by the relays, the engine, the direct links and the web page, so nothing a terminal does may block it or flood it. Three things did. A write to a terminal's pty happened on the bridge's thread. A program that is not reading its input — `sleep`, a stopped debugger — fills the pty's input buffer (about 4 KiB on Linux) and then the write blocks, which would block every phone's link with it. Each terminal now has a writer thread owning the pty's input end, fed by a bounded queue; `write` only queues, and drops the input with a warning when the queue is full, which is the sign the program has stopped reading. Input dropped that way is not recorded: the recording says what the shell received. The recording was written and flushed a line at a time on the bridge's thread, so a slow disk reached the event loop. Each terminal now has a recorder thread owning the cast file, fed by a channel of events. The echo check stays on the bridge's thread — a quick ioctl — so the event already says whether what was typed was a secret. Output was unbounded: the reader thread ran as fast as the shell wrote, and the coalescer emitted as soon as 16 KiB were pending. The queue between them is now small, and the coalescer emits at most one frame per 16 ms, never more than 16 KiB. What is beyond waits for the next interval, in order and contiguous. A shell writing faster than the phones can be sent it therefore runs at that pace — its output backs up, the reader blocks, the pty fills, its own write blocks — like a terminal on a slow line, and the bridge's thread spends a bounded amount on it whatever runs. Tests: a write to a program that is not reading returns at once (1 MiB into `sleep 30`, asserted under 200 ms); `yes | head -c 1000000` produces at most one frame per 16 ms of elapsed time, with contiguous offsets. Co-Authored-By: Claude Code <noreply@anthropic.com>
The terminal audit is one JSON line per event, and the record the engine hands the runtime is exactly what that line is made of. Deriving Serialize with camelCase names, and leaving a field out when it is absent (a refusal has no shell, an open no exit code), lets the runtime serialize the record straight, so the log's schema tracks the type rather than a hand-written mirror that can drift from it. The `what` variant names are the line's `what` values. Co-Authored-By: Claude Code <noreply@anthropic.com>
…d CLI The bridge half of terminals that is not the PTYs: the owner's configuration, the device allowlist file, the audit, and the commands that drive them. Configuration: a `terminal` object (enabled, relays, idleMinutes up to a day, maxTerminals, command) and an `audit` object (maxDays, maxBytes, syslog), following the usual precedence — flags > environment > file > default — with `--terminal` / `CODEDECK_TERMINAL` switching terminals on. `command` is only carried here; the PTY side reads it. Unknown keys inside the new objects are warned about like the top-level ones. The allowlist (`<home>/terminal-allow.json`, 0600, written atomically) holds the approved device ids and the devices refused a terminal. Its cache re-reads the file only when its mtime moves, so the runtime can check it cheaply before routing a command. A missing or corrupt file reads as empty, which refuses every device — fail closed. `codedeck-bridge terminal pending|allow|disallow` edits it while the bridge runs; an ambiguous id prefix is refused rather than guessed. A write goes through a thread of its own, so the runtime's single thread never writes the file. The audit (`<home>/audit/terminal.jsonl`, 0700/0600) is one JSON line per event, each carrying `prev`, the SHA-256 of the line before it (the first line's is all zeros). It is written by a thread of its own, fed by a channel and fsyncing each line off the runtime's single thread. `audit verify` recomputes the chain and reports the first break with a non-zero exit; the honest limit is that anyone running as the bridge's user can rewrite the whole file. `audit.syslog` also emits each line to the `audit` log target. Retention removes the oldest whole `term-*.cast` recordings past `audit.maxDays` or over `audit.maxBytes`; `terminal.jsonl` is kept. Co-Authored-By: Claude Code <noreply@anthropic.com>
…e runtime The runtime now builds the engine's TerminalConfig from the configuration and the allowlist file at start, opens the audit and allowlist writer threads, and carries out the two effects that touch the terminal files. The allowlist is checked on the command path — one `metadata` call before each inbound event, with the file read only when its mtime moved — so `terminal disallow` takes effect on the very next terminal command. The runtime cannot tell which commands are terminal ones (they are NIP-44 ciphertext until the engine decrypts them), so the check covers every inbound event. A second check every five seconds closes a withdrawn device's terminals even when no terminal message arrives. The two share one mtime cache, so a change is noticed and read once. `Effect::TerminalPending` merges the refused devices with the approved ones as they are on disk (the owner may have run `terminal allow` since the engine last read the file), hands the merged list to the writer thread and to the engine — whose own copy of the approved devices would otherwise be a file change behind — and writes nothing on the runtime thread. `Effect::Audit` goes to the writer thread and never blocks the runtime. Co-Authored-By: Claude Code <noreply@anthropic.com>
How to enable terminals, approve and withdraw devices, what the audit records and how `audit verify` checks it, retention, and the honest limit of the hash chain: anyone running as the bridge's user can rewrite the whole file. Co-Authored-By: Claude Code <noreply@anthropic.com>
…the configured command runs The allowlist's mtime was read before every inbound event and every five seconds on every bridge, terminals or not; with terminals off (the default) there is no terminal to close, so neither check runs. On a Docker Desktop host the file sits on a slow file share, where each read of its metadata is time the phones' traffic waits. The PTY side now runs `terminal.command` when the owner set one, instead of always the shell. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ority lane, relay pacing Terminal output now rides the live kind (24515, ephemeral) and never the stored response kind: a shell's output can hold a secret, and a stored frame would outlive the terminal. Loss is fine — every frame carries the terminal's byte offset, so a phone that sees a gap replays from the ring. A terminal is the bridge's lowest-priority traffic and must never slow the phones' other traffic, so: - the direct link accepts terminal input on the live kind beside commands (the engine takes only terminal-input on it), and each connection gets a second, bounded lane for terminal output. The serve loop drains the normal lane first and the terminal lane only when that is empty (a biased select), and drops the oldest frame when the lane is full — a gap the phone replays. - over a relay, terminal output is merged per terminal and sent once per 100 ms window, at most ten a second; past the held cap the oldest bytes are dropped. A terminal cannot spend the bridge's relay rate limit on the agents' messages, and the window runs off the main loop's own turn so a busy backlog cannot starve it. - the relay subscription carries the live kind only when the owner allows a terminal over relays (terminals on and terminal.relays), so a bridge without that never subscribes to it and a relay never sees a keystroke. Options.terminal is the seam the configuration fills in; it defaults to terminals off, so nothing changes for a run that sets nothing. Co-Authored-By: Claude Code <noreply@anthropic.com>
…he direct link The terminal's Transport section said the bridge flushes every 16 ms on the direct link and every 100 ms on relays "or sooner past 16 KiB pending" — the 16 KiB is the frame cap, not an early flush, and the relay's 100 ms is a pacing the relay path now enforces by merging. State both as they are, and fold the two lanes into the "never slows the phones' links" bullet rather than beside it: the direct link's bounded terminal lane that goes out only when the normal lane is empty, and the relay's merge-and-pace, so a terminal can never spend the bridge's rate limit on the agents' messages. The direct link paragraph in PROTOCOL.md said it takes only command events (4515); it now also takes terminal input on the live kind, with the engine accepting only terminal-input there, and the standing subscription carries the live kind when the owner allows a terminal over relays. Co-Authored-By: Claude Code <noreply@anthropic.com>
…bridge The whole terminal path, end to end over a relay: a phone the test has already approved opens a terminal, says it shows it, types `echo hi` on the live kind, and sees the shell's output come back as `terminal-output`. It exercises the transport this branch adds — the live-kind subscription that only exists because the owner allows a terminal over relays, and the relay pacing of the output. The phone core has no terminal API yet, so the messages go out as raw events, signed by the identity and NIP-44 encrypted from the session key to the bridge, and the replies are read from the core's observer (which the core calls for every decoded message, terminal ones included). The session key is seeded in the phone's KV at the real grant lifetime, so the test knows the device id the allowlist must name. Unix only: the bridge runs a real pty. Co-Authored-By: Claude Code <noreply@anthropic.com>
…the phone's own frames The terminal lane was drained whole once woken: up to 64 frames of 16 KiB went out before the loop looked at the normal lane again, which on an onion endpoint can hold a session's event back for seconds. Now one terminal frame goes per turn of the loop, and the phone's incoming frames are read before terminal output too, so neither a session's events nor a phone's commands ever wait behind a terminal's backlog. Co-Authored-By: Claude Code <noreply@anthropic.com>
… not its echo of the command The test waited for READY, which the shell's echo of the typed command line already contains before stty has run; on a fast runner the password was then typed while echo was still on, and recorded as it should be. The marker is now split in the command, so only the command's output matches. Co-Authored-By: Claude Code <noreply@anthropic.com>
…sets The phone half of `docs/TERMINAL.md`: a pure store holding the terminals a bridge lists, the one on screen, and everything that keeps a keystroke stream off the UI's snapshot path. Input is batched, not sent per keystroke. On the normal screen printable bytes wait and go in one event when a key a program could act on arrives (Enter, Tab, Esc and so any escape sequence, Backspace/Delete, any Ctrl chord, an Alt prefix), when typing pauses (150 ms over the direct link, 400 ms over relays) or past 256 bytes; a paste is one event. On the alternate screen every key is a command, so bytes go after at most 20/60 ms and a held key's repeats coalesce. Over relays at most 5 input events a second go out, the rest staying buffered and merging into the next. The flush deadline the store returns is absolute, so a change of transport is picked up on the next keystroke rather than by a re-armed window. One seq per (machine, term) counts up from 1 and never restarts. The last 256 sent events (at most 1 MiB) are kept: a `terminal-input-from` resends from the seq it names when that is still held, and otherwise the count continues from it — which is also how a takeover tells this device where to resume. Older than what is held cannot be resent, and is not. Output is not state: `output` hands the decoded bytes and their offset back for the host to emit on its own event. A frame at the expected offset is passed on; one beyond it means a frame was lost, so the store asks for a replay from the expected offset once per gap and drops frames until it arrives; one that starts behind the expected offset is trimmed to the part not yet drawn, or dropped when it is wholly behind. The terminal this phone is attached to is the writer by the heartbeat's `writer` and by every `terminal-event`'s `device`, which a bridge derives from the first session key this install ever made. The session-key ring now keeps that first key's public half so the id stays the same however often the key rotates — otherwise a terminal would lose track of who is typing after one renewal. `terminal-input` rides LIVE_KIND: keystrokes are never stored on a relay, and timing alone leaks what was typed. Every other command keeps riding COMMAND_KIND with its NIP-40 expiration, which an ephemeral event has no use for. Co-Authored-By: Claude Code <noreply@anthropic.com>
Wires the terminal store into the event loop.
- `Router` handles the five terminal messages: `terminal-opened` claims the
pending open, `terminal-output` is decoded and handed back as
`TerminalOutputEvent`, a gap answers itself with `terminal-replay`,
`terminal-input-from` resends from the seq it names, and `terminal-exited`
/ `terminal-event` update the slot. The heartbeat's `terminals` list is
applied on every session list.
- `CoreEvent::TerminalOutput { machine, term, offset, bytes }` is the
dedicated stream the hard requirement asks for: raw bytes on their own
event, so a shell writing megabytes a second never re-renders a view. The
view keeps only the low-frequency half (`UiView::terminals`).
- Input batching runs on its own timer (`Msg::TerminalFlush`), armed from
the absolute deadline the store returns, so a keystroke re-arms it and the
relay rate cap's window end is respected. The transport for the pause and
the cap is read from the live direct link, per machine.
- `presence` now carries `watching_terminal` alongside `watching`. The
settle logic compares a per-machine `Watch { session, terminal }` map, so a
terminal on one machine and a session on another are each told, and a
machine left behind is told nothing is watched there.
- The store's device id comes from the session-key ring's first key, so a
terminal can tell this phone's input from another client's.
- `OpenTerminal` is refused unless the machine advertises the `terminal`
capability: sending it to a bridge without one would wait on an answer
that never comes. Removing a machine forgets its terminals.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The eight terminal actions cross as `UniffiIntent` variants — `OpenTerminal`, `AttachTerminal`, `DetachTerminal`, `TerminalInput`, `TerminalPaste`, `ResizeTerminal`, `TakeOverTerminal`, `CloseTerminal` — mapped field for field, bytes included. `UniffiUiView` gains `terminals`: every machine's list as `UniffiTerminalInfo`, and the attached terminal as `UniffiAttachedTerminal` — its term and pending `openId`, whether this device is the writer, its size, the next output offset expected, the bytes typed and not yet sent (the predicted echo), and the refusal or the end with its reason. Output frames do not come through here: they arrive on `CoreListener.on_event(CoreEvent.TerminalOutput)`, already base64-decoded, with the offset they start at. The Kotlin bindings under `apps/android/app/src/main/java/uniffi/` are regenerated from this surface. Co-Authored-By: Claude Code <noreply@anthropic.com>
State, presence, input batching, seq, output offsets and what the app gets — the phone half of `docs/TERMINAL.md`, in the layer document a contributor reads first. Co-Authored-By: Claude Code <noreply@anthropic.com>
- A machine's "Terminal" group: the terminals running there, each with what runs in it and the device that opened it, and "Open a terminal". A bridge that does not advertise terminals says where its owner turns them on; the phone never does. - The terminal page: opening, refused (for an unapproved phone, the command that approves it on the machine, copyable — approval happens on the machine only), and ended (why, in a line). A running terminal gets one slim bar — back, what runs, close for the writer or Take over for a device that watches — and the rest of the screen is the emulator's, which plugs in where the snapshots draw a few lines of a shell. Co-Authored-By: Claude Code <noreply@anthropic.com>
… runtime thread, and cannot lose a write The bridge runs relays, the engine, the direct links and the web page on one thread, so nothing there may block on a file. Three things about `terminal-allow.json` broke that, or let a change go missing: - `Effect::TerminalPending` called `merge_pending`, which read the whole file on the runtime thread. An unapproved device spamming `terminal-open` made that happen once per message. The read, the merge and the write now all happen on the allowlist's writer thread, which is fed the refused-device list and coalesces a burst: it drains its channel and writes only the newest list. The engine keeps its own pending list, so the immediate `Input::TerminalAllowlist` echo is gone; the write's new metadata reaches the engine through the existing per-message and periodic checks. - `Cache::changed` compared only the mtime, so two writes in one mtime tick — or a filesystem with a coarse clock — looked like no change at all, and `terminal disallow` right after `terminal allow` might never be seen. It now compares a stamp of the file's mtime, length and (on unix) inode, which an atomic rename always changes. Still one `metadata` call. - The CLI and the writer thread each did a read-modify-write of the whole file with no lock, so one could lose the other's change. Both now take `terminal-allow.json.lock` (created exclusively, retried every 20 ms for up to 2 s, a lock file older than 10 s treated as a crashed holder's and removed) around the whole read-modify-write. Only the CLI and the writer thread ever take it; the runtime thread never does. Co-Authored-By: Claude Code <noreply@anthropic.com>
`Terminals::spawn` opened the asciicast recording itself — create the audit directory, open the file, write and flush the header — and the `Effect::TerminalSpawn` arm called it straight from the bridge's one thread. That thread serves the relays, the engine, the direct links and the web page, so a slow disk there stalls every phone. The file work now runs in a `tokio::task::spawn_blocking` task: the arm computes the recording path, opens the recording there, and only then hands the opened `Cast` to `Terminals::spawn`, which starts the pty. The invariant is kept — no shell runs without its recording, and a recording that cannot be opened is answered as a refusal — because the open is awaited before the pty exists. `Cast` is `Send`, so it crosses the task boundary; opening the pty and forking stay on the runtime thread as before, being fast syscalls. Co-Authored-By: Claude Code <noreply@anthropic.com>
…lind spot made checkable The hash chain catches an edited or deleted line in the middle, because the line after it disagrees. It cannot catch an edit or a deletion of the *last* line, nor a truncation: nothing follows them, and a chain with no external anchor cannot say. The docs called this an honest limit but left it unusable — an owner had nothing to compare against. `verify` now returns the line count and the head, the SHA-256 of the last line, and `audit verify` prints it: an owner compares it with the syslog copy (`audit.syslog: true`) or with a value noted earlier, and a change shows. An empty log still prints "empty" and exits 0. The honest-limit sentence in TERMINAL.md and BRIDGE.md now states which tampering the chain catches and which needs the syslog copy or a noted head hash. Co-Authored-By: Claude Code <noreply@anthropic.com>
…e does The comment claimed the flag and the environment variable "can only switch terminals on". The flag can, but `CODEDECK_TERMINAL` is parsed as a boolean and overrides the file in either direction, so `CODEDECK_TERMINAL=false` switches terminals off even with `terminal.enabled: true` in config.json. Say what the code does. Co-Authored-By: Claude Code <noreply@anthropic.com>
…ey grant The grant's `label` is what the bridge shows a person in its terminal allowlist (`codedeck-bridge terminal pending`) and in a refusal to open a terminal. The phone always sent `None`, so the bridge could name a device only by its id. `CoreConfig` gains `device_label`, sanitized once at construction (control characters stripped, trimmed, capped at 64 characters, empty becomes `None`) and carried on `PhoneKeys`, so `grant_for` stays pure and every grant — the first, a rotation or a re-grant — sends the same name. The bridge-runtime end-to-end test follows the new `CoreConfig::new` argument. Co-Authored-By: Claude Code <noreply@anthropic.com>
The Android app knows the phone's name at start-up; the core needs it to put it on every session-key grant. Passed alongside `proxy`/`tor`, an empty string meaning no name — the core sanitizes and caps it. The Kotlin bindings are regenerated for the new constructor argument. Co-Authored-By: Claude Code <noreply@anthropic.com>
The core constructor now takes the phone's name; the app builds it from
`Build.MODEL`, prefixed with the manufacturer only when the model does not
already name it ("Pixel 8a" needs no "Google"; "SM-S918B" needs
"Samsung"), so the bridge's terminal allowlist and its refusal name the
phone rather than show a bare device id. The manufacturer is capitalized
because `Build.MANUFACTURER` is often lowercase.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Copy termux/termux-app's terminal-emulator and terminal-view modules (Apache-2.0; the rest of that repository is GPLv3) into two Gradle library modules, so a Compose screen can later host a terminal whose shell runs on a remote bridge. The sources under each module's src/main are a verbatim copy of tag v0.118.3, commit 5b657c6. Upstream built the emulator with the NDK around a local pseudoterminal; this copy carries only the Java sources — no jni/ C sources, no NDK or CMake config. The JNI class and the process-backed session are replaced in the next commit. The modules are shaped like apps/android/app/build.gradle.kts (the catalog's AGP, compileSdk and minSdk, Java 17) and the upstream manifests' package attribute moves to the Gradle namespace, which AGP 9 requires. Each module's lint.xml disables the exact checks upstream's untouched code trips (DefaultLocale; ObsoleteSdkInt and UseRequiresApi), so the app's lint bar stays green without editing third-party logic. apps/android/terminal-view/README.md records the source, tag and commit and says the copy is deliberate, never merged. Co-Authored-By: Claude Code <noreply@anthropic.com>
Replace the vendored TerminalSession's local pseudoterminal process with a session a remote shell drives, and drop the JNI class that spawned it. TerminalSession keeps its name and package, so TerminalView needs no change. Its constructor is now (transcriptRows, TerminalSessionClient, TerminalSessionCallbacks) and it starts nothing: - feed(bytes) appends bytes from the bridge to the emulator on the main thread, then asks the view's client to redraw; - the write(...) paths the view uses for keys, IME text and paste call TerminalSessionCallbacks.onInput instead of writing to a file descriptor; - updateSize(...) resizes the emulator and reports the new size through TerminalSessionCallbacks.onResize; - isAlternateBufferActive() exposes the alternate-screen flag, so a host can batch input differently there. The pid, exit-status, finishIfRunning, cwd and process-cleanup machinery is gone. The TerminalOutput callbacks now null-check the client, because the emulator calls onColorsChanged from its own constructor's reset() and a session built without one would otherwise throw. A JVM test in terminal-emulator covers the three directions: bytes fed in reach the screen and move the cursor, a resize reaches onResize, written input reaches onInput, and the alternate-screen escape sequence toggles the flag. Co-Authored-By: Claude Code <noreply@anthropic.com>
A session is attached before its view is laid out, so the shell's replay of its screen so far arrives while there is still no emulator to append it to — and feed(...) dropped it. Buffer that output inside TerminalSession, in the order it arrives and bounded to 512 KiB (the oldest bytes past the cap are dropped, since the newest output is what the screen should end up showing), and append it when the first updateSize(...) creates the emulator, before any later feed. A JVM test feeds before the first updateSize and asserts the screen shows the fed text. Co-Authored-By: Claude Code <noreply@anthropic.com>
`terminal-output` gains an optional `replay` flag, set only on the bridge's answer to `terminal-replay`. A replay reads the bridge's 256 KiB ring, which starts past the requested `from` once a terminal has written more than that; the flag is what tells the client the answer's `offset` has advanced and it should adopt it rather than treat it as another gap. The paced terminal lane carries the flag through its merge, so a replay answer keeps its marking when it is coalesced before it is sent. Co-Authored-By: Claude Code <noreply@anthropic.com>
…plays forever An attach asks for a replay from 0, and a gap asks from the last offset drawn. The bridge answers from its 256 KiB ring, whose start has moved past that offset once the terminal has written more than the ring holds. The answer's own offset therefore being ahead is not loss: when a replay is pending and the frame is marked as its answer, the store adopts that offset and draws the frame. Live frames ahead of a pending replay are still dropped, and the next frame at the expected offset still clears the wait when the bridge has nothing to replay. Co-Authored-By: Claude Code <noreply@anthropic.com>
The bridge already reads CODEDECK_TERMINAL, but the compose service only forwarded the variables it lists, so terminals could be switched on from .env only by editing config.json. Listed (empty = the file decides) and documented in .env.example with the approval step. Co-Authored-By: Claude Code <noreply@anthropic.com>
The terminal screens are wired: a machine's page lists its terminals and opens one (from Settings as well as from the sessions list), and a session's options sheet offers "Terminal here" in its directory. The Termux emulator draws the shell. Its output reaches the screen on a sink of its own rather than the shared event flow, whose small buffer a shell sending a frame every 16 ms would otherwise empty of every other event. Typing, the extra-keys row (ESC, CTRL, ALT, TAB, arrows, | ~ /) and pastes go through one ordered queue to the core; a paste is sent as one event, bracketed when the program asked for it. Only the writer sends input and its size, a device that takes over sends its own size, and the open carries a size estimated from the window so the shell starts close to right. Keys typed but not yet sent are drawn dimmed at the cursor on the normal screen. A pinch changes the text size, kept per phone. "Lock terminals" (Account) asks for the fingerprint or screen lock before a terminal opens. It needs Android 11 to offer both in one prompt; an unlock survives a rotation but not a new process. Leaving the page detaches; a rotation does not, and the recreated page replays the same terminal. Co-Authored-By: Claude Code <noreply@anthropic.com>
TERMINAL.md's phone section says what the screen does now: who gets the extra keys, why output has a sink of its own, how a paste is sent, the size an open carries, detach versus rotation, and the "Lock terminals" setting with its Android 11 floor. CLIENT.md says how the core takes a replay answer that starts past the offset it asked for. Co-Authored-By: Claude Code <noreply@anthropic.com>
There every key is a command whose effect the program decides, so the app predicts nothing; reporting the bytes held there for a few ms would only give the app reasons to redraw. Co-Authored-By: Claude Code <noreply@anthropic.com>
Typing on the normal screen waits for a key a program acts on or a pause, and the app draws the waiting keys as the predicted echo. Nothing told the app they changed, so no prediction showed and each key appeared only once the shell echoed it, a pause and a round trip later. Input, paste and the batch timer now mark the UI slice changed whenever the waiting bytes do. Co-Authored-By: Claude Code <noreply@anthropic.com>
A service or a container often runs with no $SHELL, and the fallback /bin/sh is Debian's dash in the image: no line editing, no Tab completion, while the account's own shell is bash. The passwd entry's shell now comes before /bin/sh. Co-Authored-By: Claude Code <noreply@anthropic.com>
Once the core sends the held keys it clears them, but the shell's echo is a round trip behind; the dimmed letters now stay until output arrives instead of blinking out. Also: the unlock prompt's constants are read only behind the Android 11 check, the open's size estimate reads the window's real size, and the echo view has a plain constructor. Co-Authored-By: Claude Code <noreply@anthropic.com>
The account's login shell comes before /bin/sh only when it is a shell: a service account's nologin or false would end every terminal at once. Co-Authored-By: Claude Code <noreply@anthropic.com>
Nothing is predicted after a control key until the shell has echoed a key of the new line: a password prompt echoes nothing, so the dimmed letters would have drawn the password. The keys sent and not yet echoed are kept apart from those still waiting, so a key typed meanwhile extends the prediction instead of replacing it. A page recreated over a terminal that was refused or ended keeps saying so instead of opening another. Co-Authored-By: Claude Code <noreply@anthropic.com>
deymosh
marked this pull request as ready for review
October 17, 2026 15:50
This was referenced Oct 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A real shell on the bridge's machine, driven from the phone, end to end: the wire, the bridge, the phone's core and the phone's screen. The design is
docs/TERMINAL.md.Who may open one: three gates. Terminals are on in the bridge's config (
terminal.enabled, or--terminal/CODEDECK_TERMINAL), the identity is paired, and the device is approved on the machine (codedeck-bridge terminal pending | allow | disallow). Withdrawing a device closes its terminals at once. Terminals ride the direct link; relays carry them only when the owner setsterminal.relays.Wire (
crates/protocol, v12):terminal-open/opened/input/input-from/output/resize/replay/takeover/close/exited/event; heartbeatterminals[],presence.watchingTerminal, the device label on the session-key grant, and theterminalcapability (advertised only when enabled). Input carries a per-terminalseq(replay-proof) and ridesLIVE_KIND, so keystrokes are never stored on a relay.terminal-outputcarries a byteoffset; the bridge's answer toterminal-replayis markedreplay: trueand may start past the offset asked for once the 256 KiB ring no longer holds it, which the phone takes from there. Without that mark, a terminal that had written more than 256 KiB looped on replays forever.Bridge. PTYs with writer and recorder threads; output paced at one frame per 16 ms of at most 16 KiB; a per-connection, bounded, low-priority terminal lane that sends one frame per turn after everything else, so a busy shell never slows a phone's link. Also: the allowlist file with its CLI, and an audit with a hash chain, a full recording and retention (
audit verify). With no$SHELL, a terminal runs the account's login shell (unless it isnologin); falling back to/bin/shmeant dash in the image, with no Tab completion.docker-compose.ymlpassesCODEDECK_TERMINALthrough.Phone core (
client-corestores/terminals.rs, FFI intents and view). Input is batched on the keys a program acts on, or a pause (150 ms direct, 400 ms relays; 20/60 ms on the alternate screen). Offsets and gap replays are handled here. Output reaches the app as its ownCoreEvent.TerminalOutput, never through a view. The keys held back redraw the app, which draws them as the predicted echo.Phone UI.
| ~ /). Pinch changes the font. A paste goes as one event.Traffic cost
Nothing is sent while no terminal is on screen: output goes only to the devices watching it, and an idle shell writes nothing. Typing costs one event per key a program acts on, or per pause: a command line with a completion is two or three events, not thirty. Output costs one event per flush: at most ~60/s on the direct link and 10/s on relays, usually far fewer. Each event is signed and NIP-44 encrypted like any message. The predicted echo is local and costs nothing on the wire. The audit costs disk on the bridge only.
Verification
./codedeck checkgreen;cargo doc --workspace --no-depswith-D warningsgreen.verifyPaparazziDebugandlintDebug("No issues found"). New snapshots: the running terminal with the extra keys, the lock page, "Terminal here", the machine page's Terminal group and the Account switch.nologinshell would replace/bin/sh.Breaking: none beyond v12 (the wire additions are new messages and optional fields).
🤖 Generated with Claude Code