Skip to main content

agent-tty

A persistent TTY for your AI agent. Shared live terminal for humans.

bash_tool runs a command and forgets. agent-tty gives your agent a persistent TTY inside tmux — variables, cwd, imports, connections, SSH sessions, and debugger state survive across agent turns. You watch the same terminal live, interrupt with k int, or take over with tmux attach.

The package is agent-tty. The CLI command is k, intentionally short to minimise token overhead in agent tool calls. km is the companion event monitor.

Requires POSIX + tmux 3.0+ — k drives tmux, tail, and POSIX signals; it does not bundle or replace them.

Why agent-tty

bash_tool is curl. k is a socket.

Give your agent k when it needs memory between turns: Python imports, database connections, browser/CDP sockets, remote shells, debuggers, running servers. You see everything through k watch — cell markers, completion ticks, frame noise hidden — or tmux attach for native raw takeover.

km calls your agent back when a long cell finishes. k poll is a simple fallback for runtimes without monitor/interrupt support.

Quick Start

k new work bash
k run -j work "echo hello"
# {"cell_id":"...","status":"done","output":"hello"}

k new py python3 -i                         # Python 3.12 and below
k new py "env PYTHON_BASIC_REPL=1 python3 -i"  # Python 3.13+ (line-protocol mode)
k run -j py "print(42)"

State persists across cells — export, cd, def, variable assignments all survive between k run calls. That is the point: the session is a live process, not a fresh shell per call.

Before install, ./scripts/k is the same CLI entry point — no pip needed. After install, k with no arguments prints the full help.

Recommended Workflow

The agent defaults to k — it is the shared working terminal, and you watch the same cwd/env/history/output the agent sees. For code with quotes, f-strings, SQL, shell variables, or any escaping complexity, the agent writes a file with its shell tool, then loads it through k.

cat > /tmp/task.py << 'EOF'
import os

conn = db.connect(os.environ["DATABASE_URL"])
rows = conn.execute("SELECT * FROM orders WHERE status = 'pending'").fetchall()
print(f"found {len(rows)} pending orders")
EOF
k run -j py "exec(open('/tmp/task.py').read())"

The heredoc preserves content literally. source/exec loads it into the live session, so imports, variables, cwd, sockets, and database handles still persist. This avoids shell-quoting fights and the multiline-send edge cases that can confuse frame detection: the command sent to k is always one simple line.

Simple commands still go straight through k: k run -j work "echo hello".

Install

Requires: POSIX, Python 3.10+, tmux 3.0+

pip install agent-tty            # → k, km, agent-tty in PATH

To refresh a stale k/km entry point, reinstall in the same shell environment that will run it, then verify the resolved command:

python -m pip install --upgrade --force-reinstall agent-tty
k --version
km --version
agent-tty --version
python -m agent_tty --version
command -v k    # use: where.exe k  (PowerShell)

Or without pip:

git clone <repo> && cd agent-tty
./scripts/k --help               # works immediately (dev shim)

Or symlink into PATH:

ln -sf "$(pwd)/scripts/k"  /usr/local/bin/k
ln -sf "$(pwd)/scripts/km" /usr/local/bin/km

Commands

k new    <session> [cmd...] [--prompt="x"]     spawn (default: bash)
k new    <session> <cmd> --prompt=./hook        hook mode
k fire   [-t N] [session] <code>               async fire (default 300s)
k poll   [session] [cell_id]                   poll (O(1))
k run    [-j] [-t N] [session] <code>          sync (default 30s)
k await  ...                                   alias for run
k notify [session] <message>                   notification (direct to log)
k int    [session]                             ctrl-c (+ re-frame in repeat mode)
k kill   <session>                             kill + cleanup
k ls                                           list sessions
k status [session]                             health + next action
k watch  [session]                             live filtered view
k history [-n N] [session]                     last N×5 lines (default 5)
k --version                                    print agent-tty version
                                                aliases: k -V, k version

Session resolves: explicit arg > K_SESSION env > auto-detect (single session).

k status work repairs the log pipe if needed and prints the next useful command:

OK work pipe=ok state=running cell=a1b2c3d4e5f6 next='k poll work a1b2c3d4e5f6 or k int work'

k history shows the last few results without switching to the terminal:

k history work          # last 25 lines (5 cells × 5 lines)
k history -n 2 work     # last 10 lines (2 cells × 5 lines)

Useful as a quick glance — the terminal is the full picture, but sometimes you just want to see the last couple of outputs inline.

Frame Detection

Three modes via --prompt:

--prompt= mode how
(not set) repeat 5 empty Enters → 5 identical lines → done
"(gdb)" exact match prompt string
./hook.py hook stdin lines → hook exit → done

Hook protocol: k feeds ANSI-stripped, whitespace-stripped lines to stdin. Hook exit = frame done; the line that triggered the exit is treated as the frame boundary and removed from output. Hook paths must include a path separator (/). Path is canonicalised to absolute at k new time; hook must exist and be executable.

How It Works

k fire "echo hello"
  |
  +-- acquires lock (rejected fire = zero side effects)
  +-- sends code via paste-buffer (atomic)
      bash multiline: writes 0600 temp script, sends "source <script>"
  +-- sends 5 frame Enters (repeat mode only)
  +-- starts background stream processor
  |
  stream processor tails log:
    ECHOING: skip echo_count lines
    OUTPUT:  collect lines
    DONE:    5 identical lines / prompt match / hook exit
  |
  writes result file -> exits
  |
k poll
  +-- checks result file (O(1))
  +-- returns JSON

Safety

invariant mechanism
one cell per session O_EXCL lock, acquired before send
timeout keeps lock lock marked timed_out; subsequent polls say use k int or k kill
completed-cell recovery bg watcher marks completed; next fire/run can clear a done-lock without losing the result file
orphan recovery bg process group in lock, poll checks os.killpg(pgid, 0) (POSIX)
no line-wrap skew tmux width 10000
atomic send per-session named paste-buffer k_{session}
bash multiline state private per-cell script + source, so cd/env/functions persist without interleaved prompt echoes
ctrl-c safe kills watcher, writes {"status": "error", "output": "interrupted"}, re-sends frame enters (repeat only)
session name validation [A-Za-z0-9_.-]+, no .., no path traversal
idempotent pipe restart pipe-pane replaced on every fire/run
atomic result writes tmp + fsync +os.replace — poll never reads partial JSON
no output classification "done" = prompt appeared, not success

JSON Schema (k)

fired:        {"cell_id": "...", "status": "fired"}
running:      {"cell_id": "...", "status": "running"}
done:         {"cell_id": "...", "status": "done", "output": "..."}
timeout:      {"cell_id": "...", "status": "timeout", "output": ""}
timeout(2+):  {"cell_id": "...", "status": "timeout", "output": "use k int or k kill"}
error:        {"status": "error", "output": "..."}
cell error:   {"cell_id": "...", "status": "error", "output": "..."}

JSON errors without cell_id: no session 'x'; use k new x bash, active cell 'x', pipe failed: ..., send failed: ..., no active cell on 'x', invalid cell_id. JSON errors with cell_id: interrupted, unknown cell, watcher died, result missing, lock update failed; use k int or k kill, lock release failed, interrupt failed; use k kill. Text-only errors: no session found; use k ls or k new <session> bash, no log for 'x'; use k status x, watcher kill failed; use k kill.

Metadata on Disk

$XDG_RUNTIME_DIR/k_cells/<session>/    (or /tmp/k_cells_<uid>/<session>/)
  _session.json       {name} or {name, prompt}
  _lock.json          {cell_id, log_offset, echo_count, bg_pgid, completed?, timed_out?, timeout_polled?, terminal_status?}
  _output.log         pipe-pane stream (append-only)
  <cell_id>_result.json  stream processor output (deleted after poll)

Known Limitations

agent-tty is POSIX-only: it requires tmux, tail, and POSIX process signals. WSL is fine; native Windows fails fast.

Frame collision (repeat mode): if output contains 5+ consecutive identical non-empty lines, the stream processor falsely detects completion. Extremely rare — 5 identical lines = zero information entropy.

The source/exec workflow avoids shell-quoting problems and the multiline-send edge cases that can confuse frame detection: the command sent to k is always a single simple line, while the real code loads inside the live session. Repeat-mode frame collision from command output (5+ identical non-empty lines) is a separate limitation that still applies regardless of how code is sent.

echo_count heuristic: generic REPL mode assumes 1 sent line = 1 echoed line. Bash multiline cells avoid this by sourcing a private per-cell script; other REPLs still rely on prompt filtering or hook/exact prompt mode.

Hook mode: no ... filtering (user takes full control). Hook paths must include a path separator to distinguish them from string prompts.

Line-protocol REPLs only — not TUIs: agent-tty works with line-protocol REPLs: programs that emit mostly clean text with stable prompts (>>> , sqlite> , $ ). bash, python3 -i (classic), sqlite3, and node all work this way.

TUIs (text user interfaces) are a fundamentally different category. Programs like vim, htop, less — and, in practice, IPython and Python 3.13+'s _pyrepl — paint the screen with cursor-positioning sequences, color codes, and full redraws. They are designed for human eyes, not programmatic consumption. k reads pipe-pane, which delivers the raw terminal control stream, not the rendered screen. A single IPython prompt redraw looks like this in the stream:

^[[?25l^[[?7l^[[8D^[[0m^[[J^[[0;32mIn [^[[0;92;1m4^[[0;32m]: ...

Cursor hides, resets, redraws the prompt in color, moves the cursor back and forth, then does the whole thing again. One empty Enter redraws the prompt twice. After ANSI stripping you get In [4]: In [4]: — doubled prompts, phantom lines, broken framing. The assumption that "output is text lines separated by newlines" is false; pipe-pane delivers VT100 draw instructions, not text. This is an impedance mismatch — not a bug to fix, but a category error to avoid.

PYTHON_BASIC_REPL=1 switches Python 3.13+ back to the classic line-protocol REPL. For agent work surfaces, always prefer line-protocol REPLs:

k new py python3 -i                          # Python 3.12 and below (already line-protocol)
k new py "env PYTHON_BASIC_REPL=1 python3 -i" # Python 3.13+ (switch back to line-protocol)
# avoid: k new py ipython                     # TUI — unreliable framing

km — callback monitor

km wakes your agent when a long cell finishes. It tails the session log and emits one JSON event per line to stdout — no polling, no sleep loops.

Works with any agent host that has background-notification support: Claude Code's Monitor tool, Codex App Server via vendor/codex_bridge.py, or a plain subprocess reader.

km <session> [cell_id] [-1]

-1 exits after first completion — one-shot .then() for agent orchestration. With a cell_id, it waits for that specific cell. Without one, it pre-scans the log and returns the most recent completion — useful as a "did anything finish?" query, but be aware it may return an older cell (e.g. one that was interrupted) if nothing new has completed.

Why km after k

k is the agent's stateful terminal. km is the callback channel for long-running cells. Background task support alone is not enough when process state matters; km lets the persistent TTY keep running and wakes the agent when a cell finishes. k poll works for simple scripts, but poll loops waste tokens and add latency:

# poll loop: agent burns a tool call every N seconds
# k poll → "running" → k poll → "running" → k poll → "done"

# km: one tool call, block until done
km work -1
# {"cell_id": "...", "session": "work", "status": "done", "ts": "..."}

With km -1, the agent fires a long task, starts the monitor in the background, and gets woken exactly once on completion. Zero wasted calls.

Continuous mode

Without -1, km runs indefinitely — every event streams as a JSON line. Useful for multi-cell orchestration where your agent reacts to each completion in sequence.

Codex bridge (experimental)

vendor/codex_bridge.py is a local experiment for hosts that expose Codex App Server. It reads km stdout, polls completed cells with k poll, and starts a visible Codex turn with turn/start when the target thread is idle. If the thread is already active, events are queued and batched into one visible turn after the thread becomes idle. That makes the event visible as a normal Codex turn instead of hiding it in prompt history.

Important caveat from local testing on 2026-06-20: Codex App Server does not expose a single Monitor-like primitive. thread/inject_items persists data for later turns but does not wake an agent; turn/steer needs an active turn and expected turn id; turn/interrupt stops work but does not carry payload; and turn/start wakes an idle thread but can create a parallel side turn if another turn is still active. The bridge owns that state machine.

Codex Desktop may also fail to live-refresh turns started by another app-server client. Treat this bridge as better suited to headless/remote automation and external sinks such as tmux, a web UI, or email. It is not a guarantee that the Desktop UI will update in real time.

# find candidates first
python vendor/codex_bridge.py --list-threads --thread-cwd . --thread-search "agent tty"
python vendor/codex_bridge.py --list-loaded

# run the bridge daemon
python vendor/codex_bridge.py --session work --thread-id THREAD_ID

The bridge is deliberately type-sealed: km lines must parse into KmEvent, thread ids must become ThreadHandle, thread runtime must become ThreadRuntimeStatus, idle delivery must become IdleThread, and visible turn/start calls only accept an IdleThread plus an EventPrompt derived from a validated event and optional PollResult. The static check is python tests/test_bridge_contracts.py.

Events

fired:       {"cell_id": "...", "session": "...", "status": "fired",       "ts": "..."}
done:        {"cell_id": "...", "session": "...", "status": "done",        "ts": "..."}
timeout:     {"cell_id": "...", "session": "...", "status": "timeout",     "ts": "..."}
interrupted: {"cell_id": "...", "session": "...", "status": "interrupted", "ts": "..."}
notify:      {"session": "...", "status": "notify", "from": "...", "message": "...", "ts": "..."}
closed:      {"session": "...", "status": "closed", "ts": "..."}
error:       {"session": "...", "status": "error",  "message": "...", "ts": "..."}

Testing

python tests/test_contracts.py      # static code contracts, no tmux
python tests/test_bridge_contracts.py # Codex bridge type-seal contracts, no app-server
python tests/test_docs.py           # docs/package drift, no tmux
python -m mypy --platform linux src vendor tests # POSIX type surface
bash tests/test.sh                  # 66 tests (64 without gdb), runtime smoke suite
python tests/test_regressions.py    # targeted audit regressions
python tests/run_all.py             # all suites

Files

src/agent_tty/cli.py       k — main script
src/agent_tty/monitor.py   km — event monitor
scripts/k, scripts/km      dev shims (no pip install needed)
vendor/codex_bridge.py     experimental km → Codex App Server bridge
pyproject.toml             pip install agent-tty → agent-tty, k, km in PATH
man/agent-tty.1            man page source
tests/test.sh              runtime smoke suite
tests/*.py                 static, docs, and regression suites
SKILL.md                   agent reference
EXAMPLES.md                patterns + philosophy

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

agent_tty-0.1.4.tar.gz (72.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_tty-0.1.4-py3-none-any.whl (30.9 kB view details)

Uploaded Python 3

File details

Details for the file agent_tty-0.1.4.tar.gz.

File metadata

  • Download URL: agent_tty-0.1.4.tar.gz
  • Upload date:
  • Size: 72.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for agent_tty-0.1.4.tar.gz
Algorithm Hash digest
SHA256 dbe054769e163937b84076f2f2f9534646a4a3688c954b3dfcb5181c74396a3a
MD5 9b4a7836ba021f573bb9deaff9fc8d83
BLAKE2b-256 a629bd9aa4d19561639f9a9f54cd02b917b7c87d1d28386b4d2bec7909d4e64e

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_tty-0.1.4.tar.gz:

Publisher: release.yml on rangersui/agent-tty

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file agent_tty-0.1.4-py3-none-any.whl.

File metadata

  • Download URL: agent_tty-0.1.4-py3-none-any.whl
  • Upload date:
  • Size: 30.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for agent_tty-0.1.4-py3-none-any.whl
Algorithm Hash digest
SHA256 39270c4b4fa2b50340ce1fc894aed2712d643484b6fbade44662bc386757baa7
MD5 9556fd0029f2d7a162941d283f512dbf
BLAKE2b-256 e88ef6839a393f74254edc5d05600977628328294cf058d64a1fe833d5717da4

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_tty-0.1.4-py3-none-any.whl:

Publisher: release.yml on rangersui/agent-tty

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page