Skip to content

feat: the terminal on the bridge, end to end - #142

Merged
deymosh merged 50 commits into
masterfrom
feat/terminal-protocol
Oct 17, 2026
Merged

deymosh merged 50 commits into
masterfrom
feat/terminal-protocol

Conversation

@deymosh

@deymosh deymosh commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

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 sets terminal.relays.

Wire (crates/protocol, v12): terminal-open/opened/input/input-from/output/resize/replay/takeover/close/exited/event; heartbeat terminals[], presence.watchingTerminal, the device label on the session-key grant, and the terminal capability (advertised only when enabled). Input carries a per-terminal seq (replay-proof) and rides LIVE_KIND, so keystrokes are never stored on a relay. terminal-output carries a byte offset; the bridge's answer to terminal-replay is marked replay: true and 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 is nologin); falling back to /bin/sh meant dash in the image, with no Tab completion. docker-compose.yml passes CODEDECK_TERMINAL through.

Phone core (client-core stores/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 own CoreEvent.TerminalOutput, never through a view. The keys held back redraw the app, which draws them as the predicted echo.

Phone UI.

  • Where to open one: a machine's page (from the sessions list and from Settings) has a Terminal group, to open a terminal or rejoin a running one. A session's options sheet offers "Terminal here", in the session's directory.
  • The emulator: Termux's emulator is vendored at v0.118.3, without JNI.
  • Typing: an extra-keys row (ESC, CTRL, ALT, TAB, arrows, | ~ /). Pinch changes the font. A paste goes as one event.
  • Predicted echo: typed keys show dimmed before the echo. It is held back after a control key until the shell echoes, so a password is never drawn.
  • Lock terminals (Account) asks for the fingerprint or screen lock first; it needs Android 11.
  • Output path: output goes to the emulator by a sink of its own, not the shared event flow, whose 64-slot buffer a shell would empty of every other 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 check green; cargo doc --workspace --no-deps with -D warnings green.
  • Android: unit tests, verifyPaparazziDebug and lintDebug ("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.
  • Device check by the maintainer on a Pixel 8a against the Docker bridge: opening, typing and Tab completion work. Typing still feels a little slow on the debug build; to be rechecked on a minified build.
  • One read-only bug hunt. Every real finding is fixed: the predicted echo never drew (no UI refresh), it blinked out after a send and lost keys typed meanwhile, alternate-screen keys caused redraws, rotating over an ended terminal opened a new one, and a service account's nologin shell would replace /bin/sh.

Breaking: none beyond v12 (the wire additions are new messages and optional fields).

🤖 Generated with Claude Code

deymosh and others added 4 commits October 16, 2026 16:34
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
deymosh marked this pull request as draft October 16, 2026 14:50
@deymosh deymosh changed the title feat(protocol): the terminal on the bridge — design and wire feat: the terminal on the bridge, end to end Oct 16, 2026
deymosh and others added 24 commits October 16, 2026 17:02
`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>
deymosh and others added 22 commits October 16, 2026 22:35
- 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
deymosh marked this pull request as ready for review October 17, 2026 15:50
@deymosh
deymosh merged commit 87c0791 into master Oct 17, 2026
6 checks passed
@deymosh
deymosh deleted the feat/terminal-protocol branch October 17, 2026 16:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant