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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_tty-0.1.3.tar.gz.
File metadata
- Download URL: agent_tty-0.1.3.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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6f7c8294887bd86bfee39e2d8baf82fad3f2d7365fd5edee798bf8f993a86444
|
|
| MD5 |
4989cd33fdbcda8d7d20ae8b5bcd9b1b
|
|
| BLAKE2b-256 |
34a5d0d0a3c41fbc1c92405007544dffab37d37baf36b44ccc39797527e9ef40
|
Provenance
The following attestation bundles were made for agent_tty-0.1.3.tar.gz:
Publisher:
release.yml on rangersui/agent-tty
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_tty-0.1.3.tar.gz -
Subject digest:
6f7c8294887bd86bfee39e2d8baf82fad3f2d7365fd5edee798bf8f993a86444 - Sigstore transparency entry: 1885270953
- Sigstore integration time:
-
Permalink:
rangersui/agent-tty@f27ba732a29ec6ed7eac3a212e7c169f610660cf -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/rangersui
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f27ba732a29ec6ed7eac3a212e7c169f610660cf -
Trigger Event:
release
-
Statement type:
File details
Details for the file agent_tty-0.1.3-py3-none-any.whl.
File metadata
- Download URL: agent_tty-0.1.3-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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1315cc91fcb76d6d6e6c14e3f245c8b7481d86bef856f1368a0f74ca4331b87f
|
|
| MD5 |
dbfe5d24bcc76dc91bda3c9870670cf7
|
|
| BLAKE2b-256 |
229fb081800d9c6ba9a68d0a5f7b1a7d159ff485665e9343f43658532ae5d284
|
Provenance
The following attestation bundles were made for agent_tty-0.1.3-py3-none-any.whl:
Publisher:
release.yml on rangersui/agent-tty
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_tty-0.1.3-py3-none-any.whl -
Subject digest:
1315cc91fcb76d6d6e6c14e3f245c8b7481d86bef856f1368a0f74ca4331b87f - Sigstore transparency entry: 1885270994
- Sigstore integration time:
-
Permalink:
rangersui/agent-tty@f27ba732a29ec6ed7eac3a212e7c169f610660cf -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/rangersui
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@f27ba732a29ec6ed7eac3a212e7c169f610660cf -
Trigger Event:
release
-
Statement type: