← Back to list

Give Claude Code a Terminal You Can Actually Watch

Fixing agentic coding’s supervision problem with tmux, a 150-line wrapper, and one global rule

Justin Ohms in 𝐀𝐈 𝐦𝐨𝐧𝐤𝐬.𝐢𝐨 · 2026-07-09 19:41 · 0 claps · 15.8 min read paywalled
#ai #programming
Open on Medium ↗
Wiki topics: LLM · Large Language Models AGT · AI Agents AI · AI · General 💻 · Programming 🎵 · Music & Audio

Give Claude Code a Terminal You Can Actually Watch

Fixing agentic coding’s supervision problem with tmux, a 150-line wrapper, and one global rule

Photo by Jake Walker on Unsplash

Photo by Jake Walker on Unsplash

Claude Code is remarkably good at running shell commands on your behalf. That’s also its most uncomfortable feature.

When an AI agent runs aws, terraform, npm install, or a database migration, it does so in a shell you can’t see into and can’t type into. You get a permission prompt, then a summary after the fact. Between those two moments, the command is a black box. For a lot of workflows that’s fine. For some, it’s a dealbreaker.

I hit the dealbreaker version of this problem last week, and the workaround my Claude Code session and I built together turned out to be general enough that I think it deserves a write-up.

The problem: two shells that can’t meet

First, some context on my setup: I run Claude Code inside VS Code via its extension, and I came to it from tools that live in the editor the same way — Windsurf, Cline, Cursor. Those tools got one thing right that I didn’t fully appreciate until it was gone: when their agents run a command, it executes in the IDE’s integrated terminal, right in front of you. You watch the output scroll in real time, and when a command stalls on a prompt, you click into that terminal and answer it yourself. The agent’s shell and your shell are the same shell.

Claude Code took a different path. Its Bash tool executes in its own headless shell, and what you see is the result, not the execution. In exchange you get something the terminal-sharing tools largely lack — a real permission system, sandboxing, background task management — but the interactive, watchable shell is the feature I missed most when I moved over. This post is about getting it back without giving those other things up.

My specific pain was authentication. Our AWS access goes through SSO with a browser handoff, wrapped in the 1Password CLI (aws is aliased to op plugin run — aws). Logging in means a browser popup, a device code, and a biometric approval. That flow fundamentally requires me, sitting at my terminal.

Claude Code’s Bash tool runs in its own non-interactive shell. It can’t complete a browser SSO dance. So every AWS task turned into a stilted relay: Claude tells me what to run, I run it in my terminal, I paste the output back. At that point, why am I using an agent?

But stepping back, the auth issue is just one symptom of a broader weakness:

  1. I can’t see what the agent’s shell is doing while it’s doing it. Long-running commands are invisible until they finish.
  2. I can’t interact with the agent’s shell. If a command unexpectedly prompts — “Are you sure? (y/n)”, an MFA code, a pager — the agent is stuck or guesses.
  3. The agent can’t use my shell, where my credentials, environment, and interactive capabilities live.

The naive fix is to forbid the agent from running anything and do it all yourself. That throws away most of the value. The insight that unlocked the real fix was reframing the requirement:

The problem isn’t that the agent runs commands. It’s that the commands run somewhere I can’t watch and can’t reach.

Supervision, not prohibition.

The solution: a shared tmux session

tmux has been solving “two parties, one terminal” since 2007. A tmux session is a persistent terminal that any number of clients can attach to — this is how people pair-program over SSH.

It turns out an AI agent doesn’t even need to attach in the interactive sense. tmux has first-class primitives for exactly what an agent needs:

  • tmux send-keys — type into a session programmatically
  • tmux capture-pane — read what’s on the screen (including scrollback)

So the architecture is simply:

┌─────────────────────────────────────────────┐
│ tmux session: "claude"                      │
│                                             │
│ window 0: my shell (I authed AWS here)      │
│ window 1: "aws" ← agent's shell             │
│ window 2: "build" ← agent's shell           │
│ window 3: "server" ← agent's shell          │
└─────────────────────────────────────────────┘
 ▲ ▲
 │ tmux attach -t claude │ send-keys / capture-pane
 │ │
 Me Claude Code

Every shell the agent uses is a named window in one session. I stay attached to that session. When the agent creates a shell, a new window pops up in my status bar. At any moment I can hit Ctrl-b w, jump into whatever the agent is doing, watch it scroll by, answer a prompt, or Ctrl-c the whole thing. And because those windows are running under my login shell, they inherit my aliases, my environment, and — crucially — my authenticated sessions.

The auth problem dissolves as a side effect: I authenticate once, interactively, in the session. The agent then runs aws commands in windows of that same session, where the credentials just work. When a token expires mid-task, the agent starts the login command and tells me which window needs my browser dance.

The implementation

Three pieces, all living in ~/.claude so they apply globally, not per-project.

1. The wrapper: claude-term

Raw send-keys/capture-pane works but is fiddly — the agent has to guess when a command finishes and can’t get exit codes. So we wrapped it in a small CLI, ~/.claude/bin/claude-term:

claude-term list # shells + which one I'm viewing
claude-term new <name> [dir] # create a named shell
claude-term run <name> [-t sec] - CMD # run CMD, wait, print output,
 # exit with CMD's exit code
claude-term send <name> KEYS… # raw keystrokes for interactive programs
claude-term read <name> [-n N | -a] # read a shell's screen / history
claude-term kill <name> # close a shell

The interesting part is run. How does an outside observer know when a command inside a terminal has finished, and whether it succeeded? Marker lines. The wrapper doesn’t send your command alone — it sends a compound line:

printf '__CT_BEGIN''_<id>__\n'; <your command>; printf '__CT_DONE''_<id>__:%s\n' $?

Then it polls capture-pane until the DONE marker appears, extracts exactly the lines between the markers as the command’s output, and exits with the captured $?. To the calling agent, claude-term run behaves like an ordinary command — output on stdout, real exit code — but the execution happened in a fully visible, human-interruptible terminal.

One subtlety worth stealing: the markers are sent as split string literals (’__CT_BEGIN’’_…’). The shell concatenates them when it echoes output, but the command line itself — which also appears on screen — never contains the assembled marker. Without that trick, the poller matches its own command echo and returns instantly with empty output.

Timeouts get the conventional exit code 124, and — importantly — the wrapper tells the agent the command is still running, so it checks back with read instead of blindly re-running something that might not be idempotent.

There’s one more safety check worth calling out. The marker technique only works if the thing listening to the keyboard is a shell — type that compound line into a Python REPL and you get a SyntaxError, a poller that waits forever, and in the worst case keystrokes submitted to some live (y/n) prompt. So before typing anything, run asks tmux what’s in the foreground of the target window (#{pane_current_command}) and refuses — instantly, with a clear message — if it isn’t a shell:

claude-term: shell 'repl' is busy running 'python3.14' - drive it with
send/read, or use run -f if it is really at a shell prompt

The -f flag exists for the one honest false negative: an ssh window whose remote end is sitting at a perfectly capable shell prompt looks like ssh from the local side.

run also polices the shape of the command itself, and the story of how is my favorite part of building this. While testing, the agent passed a multi-line script to run — and since send-keys types text verbatim, every embedded newline became an Enter keystroke. The “one command” executed as several separate ones, and the completion markers, glued to what was supposed to be a single line, got orphaned. A footgun… until we looked again. Typing multiple lines into a terminal, each executing in order, is pasting a block of commands — a genuinely useful thing. The same behavior was a bug in run and a feature in send.

So the design splits it by intent:

  • run enforces one line. Embedded newlines are always fatal — no override flag, because no override could make the markers survive. And beyond ~500 characters (configurable), run refuses too: a wall of text snaking across five wrapped terminal lines is unreadable for the human supervising it, which defeats the point of the shared session. The error message says what to do instead: write a temp script and run that.
  • send embraces multiple lines. Need a sequence typed in order — setup steps, priming a REPL — without per-command exit codes? A multi-line send executes each line sequentially, exactly like pasting into a terminal, and a read afterwards confirms the state.

Two quality-of-life details rounded out the wrapper. First, since agent shells are real interactive shells, they run your full dotfiles — in my case that meant three nohup backup jobs and an nvm auto-use firing in every window the agent opened. The fix: the wrapper creates every window with a CLAUDE_TERM=1 environment variable (tmux ≥3.2’s new-window -e), and one guard line at the top of the offending startup file skips the heavy bits in agent shells while leaving human terminals untouched. Second, new doesn’t return until the shell has actually finished starting up — it types a probe line and waits for the shell to execute it. Without that, keystrokes sent to a fresh window get buffered behind a still-initializing zsh, which is exactly the kind of race that produces keystrokes landing in programs they weren’t meant for (we hit it once in testing; once was enough).

The full script is at the end of this post.

2. The skill: teaching the agent the workflow

A wrapper the agent doesn’t know how to use is furniture. Claude Code supports skills markdown documents the agent loads to learn a capability. Ours lives at ~/.claude/skills/shared-terminals/SKILL.md and encodes the workflow rules that make the system pleasant rather than merely functional:

  • One window per concern. aws, build, server — not a new window per command, and reuse existing windows for the same concern.
  • run for batch, send/read for interactive. The marker technique needs a shell prompt, so REPLs, ssh sessions, and wizards are driven with raw keystrokes and screen reads.
  • One line for run, blocks for send, scripts for everything bigger. run takes a single line under the length limit; a sequence of commands goes through multi-line send (paste semantics); anything longer or needing per-step exit codes becomes a temp script the agent writes and runs.
  • The wrapper is the interface, not the wall. The invariant is the shared session, not the script — when the wrapper’s verbs don’t fit, the agent uses raw tmux commands against the same session (capture-pane -J to unwrap long REPL lines, pipe-pane to stream a server’s output to a log, respawn-window to revive a dead shell). What’s banned is creating shells anywhere the human can’t attach.
  • Auth handoffs are a first-class flow. When a command needs the human (browser SSO, MFA, 1Password approval), the agent starts it, names the window that needs me, and waits for my confirmation.
  • Read before you type. The human might be typing in any window at any moment; the agent checks a window’s state before sending keys to it.
  • Never print credential values. Captured screens land in the conversation transcript, so the skill bans dumping AWS_*-style env var values — variable names only. (Claude Code’s own safety classifier independently blocked an attempt to do exactly this during setup, which was reassuring to watch.)
  • The human’s own window is off-limits for uninvited commands.

3. The rule: making it non-negotiable

Skills are loaded when relevant; rules are always on. A short block in the global ~/.claude/CLAUDE.md — which Claude Code injects into every session in every project — makes the policy unconditional:

Every shell that does real work must be attachable and supervisable by me. 
Do not run workload commands directly in your own Bash tool shell. 
Run them in named tmux windows in the shared session `claude` via `~/.claude/bin/claude-term`. 
Your own shell is only the pipe to tmux.

The agent’s built-in shell doesn’t disappear — it becomes plumbing. Its only job is invoking claude-term, which is itself just keystrokes and screen-reads against terminals I can see.

What it’s like to use

Me: ”Check whether the staging ECS service is healthy.”

The agent runs claude-term run aws — ‘aws ecs describe-services …’. In my terminal, an aws window appears in the status bar; if I switch to it, I watch the command type itself out and the JSON scroll by — in my shell, with my aliases, under my credentials. The agent gets the output and the exit code and carries on. If the SSO token has expired, the login prompt appears in that window, the agent tells me “the aws window needs your browser auth,” I approve in 1Password, and we continue.

The command relay is gone, but so is the black box. Every command the agent has run this session is sitting in a window I can scroll back through. That changes the feel of delegation more than I expected — trust but verify, with verify being one keystroke away.

A bonus the black box can’t offer: one window per AWS account

Here’s a capability that fell out of the architecture for free. The agent’s built-in Bash tool is effectively one shell with one environment — whatever AWS_PROFILE is active is the account it’s talking to, and multi-account work means constantly switching context (and hoping nothing runs against the wrong account mid-switch).

But windows in the shared session are each their own long-lived shell process, and an exported variable persists for the life of the window. So the agent pins each window to an account once:

claude-term run aws-test009 - 'export AWS_PROFILE="aws-byoc-test009/Admin"'
claude-term run aws-payi-dev - 'export AWS_PROFILE="Pay-i-Incorporated/DevPowerUserAccess"'

From then on, every command in aws-test009 targets the sandbox account and every command in aws-payi-dev targets the dev account — concurrently, no switching, no — profile on every call. When we tested this, both windows answered aws sts get-caller-identity with different account IDs at the same moment. My SSO setup (Granted, with tokens in the macOS Keychain and credential_process in each profile) means one browser login covers the whole org, so adding a third account is just another window — no re-auth.

The supervision story gets better with this, not worse: the window’s name tells you at a glance which account any command is hitting, which beats squinting at a — profile flag in scrollback. For work that spans accounts — comparing a customer deployment against your own infrastructure, say — the agent hops between windows per command while you watch whichever one makes you nervous.

Honest limitations

  • It’s slower. Polling for markers adds around a second per command versus a direct shell. For supervised infrastructure work, that’s noise; for a tight edit-test loop on local code you trust, you might scope the rule down.
  • run assumes a shell prompt. The foreground-process guard catches the obvious case (it refused a live python3.14 instantly in testing), but it’s a snapshot in time: if input is still buffered — say the window’s shell is mid-startup and a queued command hasn’t launched yet — the check can pass and the marker line still lands in the wrong program. We hit exactly this race while testing. The skill’s “read a window before you send to it” habit is the real defense; the guard just makes the common mistake cheap instead of a two-minute timeout.
  • Shared screen means shared transcript. Anything visible in a pane can be captured into the conversation. The no-credential-dumping rule matters; so does the habit of not cat-ing secrets in shared windows.
  • One session to rule them all works great for a single machine and human. Multi-machine or team setups would need session naming conventions the wrapper only gestures at (CLAUDE_TERM_SESSION).

The bigger point

Agentic tools keep being framed as a binary: either the agent acts autonomously, or the human approves each step through a dialog box. Both miss what made working with a human colleague comfortable — you could always look over their shoulder, and they could always slide the keyboard to you.

Terminals had the technology for this decades before AI agents existed. A tmux session, ~150 lines of bash, and a markdown file describing the rules of engagement turn Claude Code from a contractor who works behind a closed door into a pair programmer at the desk next to you.

The door was never the hard part. We just had to stop closing it.

Appendix:

the full claude-term script Save as ~/.claude/bin/claude-term and chmod +x it.


#!/usr/bin/env bash
# claude-term - supervised shells for Claude, hosted in a shared tmux session.
#
# Every shell Claude uses lives as a named window in one tmux session so the
# user can attach (`tmux attach -t claude`) and watch or take over at any time.
#
# Usage:
# claude-term list List shells (windows) in the session
# claude-term new <name> [dir] Create a shell named <name> (cwd = dir or $PWD)
# claude-term run <name> [-t sec] [-f] - CMD
# Run CMD in shell <name>, wait, print output.
# Exits with CMD's exit code; 124 on timeout;
# 2 if the shell is busy in an interactive
# program (-f/ - force overrides, e.g. for a
# remote shell prompt over ssh).
# claude-term send <name> KEYS… Raw `tmux send-keys` passthrough (interactive
# programs; e.g. send myshell "y" Enter, or C-c)
# claude-term read <name> [-n N | -a] Show shell's screen (last N lines, or -a = all history)
# claude-term kill <name> Close a shell
# claude-term attach Print the command the user runs to attach
#
# Notes:
# - `run` auto-creates the shell if it doesn't exist.
# - `run` takes a single line, max 500 chars (CLAUDE_TERM_MAXLEN overrides;
# -f skips the length check). Longer/multi-line work: temp script, or
# `send` the lines as a batch (a feature: each line executes in order,
# but without per-command exit codes - `read` the screen afterwards).
# - `run` requires the shell to be at a prompt (not inside an interactive
# program). For REPLs/ssh/debuggers, use `send` + `read` instead.
# - Session name defaults to "claude"; override with CLAUDE_TERM_SESSION.
set -euo pipefail
SESSION="${CLAUDE_TERM_SESSION:-claude}"
die() { echo "claude-term: $*" >&2; exit 2; }
ensure_session() {
 tmux has-session -t "=$SESSION" 2>/dev/null \
 || tmux new-session -d -s "$SESSION" -c "$PWD"
}
win_exists() {
 tmux list-windows -t "=$SESSION" -F '#{window_name}' 2>/dev/null | grep -qxF "$1"
}
target() { printf '=%s:%s' "$SESSION" "$1"; }
cmd_list() {
 ensure_session
 echo "session: $SESSION (user attaches with: tmux attach -t $SESSION)"
 tmux list-windows -t "=$SESSION" \
 -F '#{window_name} #{pane_current_command} #{pane_width}x#{pane_height}#{?window_active, [user is viewing this one],}'
}
cmd_new() {
 local name="${1:?usage: claude-term new <name> [dir]}" dir="${2:-$PWD}"
 ensure_session
 win_exists "$name" && die "shell '$name' already exists"
 tmux new-window -d -e CLAUDE_TERM=1 -t "=$SESSION:" -n "$name" -c "$dir"
# Block until the shell has finished startup and executed our probe, so
 # callers can immediately `send` to it without racing buffered input.
 local id="$$.$(date +%s)" i=0
 tmux send-keys -t "$(target "$name")" " printf '__CT_READY''_${id}__\\n'" Enter
 until tmux capture-pane -p -t "$(target "$name")" -S -200 | grep -qx "__CT_READY_${id}__"; do
 i=$((i + 1))
 if [ "$i" -gt 150 ]; then
 echo "claude-term: warning: shell '$name' not at a prompt after 30s" >&2
 break
 fi
 sleep 0.2
 done
 echo "created shell '$name' (cwd: $dir)"
}
cmd_run() {
 local name="${1:?usage: claude-term run <name> [-t sec] [-f] - command…}"; shift
 local timeout=120 force=0
 while [ $# -gt 0 ]; do
 case "$1" in
 -t) timeout="${2:?-t needs seconds}"; shift 2 ;;
 -f| - force) force=1; shift ;;
 - ) shift; break ;;
 *) break ;;
 esac
 done
 [ $# -gt 0 ] || die "no command given"
 local cmd="$*"
# The command is typed into the terminal, so it must be one line: embedded
 # newlines act as Enter, splitting it into separate commands and orphaning
 # the completion markers. No override - this can never work.
 case "$cmd" in
 *$'\n'*) die "command contains newlines - run takes a single line. Join steps with && or ;, send the lines as a batch with 'send', or write a temp script and run that." ;;
 esac
# Very long single lines are unreadable in the shared terminal and fragile
 # to type/echo. Prefer a temp script. -f overrides if you must.
 if [ "${#cmd}" -gt "${CLAUDE_TERM_MAXLEN:-500}" ] && [ "$force" -ne 1 ]; then
 die "command is ${#cmd} chars (limit ${CLAUDE_TERM_MAXLEN:-500}) - write it to a temp script and run that, or use -f to override"
 fi
ensure_session
 win_exists "$name" || tmux new-window -d -e CLAUDE_TERM=1 -t "=$SESSION:" -n "$name" -c "$PWD"
 local t; t="$(target "$name")"
# Refuse to type shell syntax into a window occupied by an interactive
 # program (REPL, ssh, pager…) - the markers would never execute and the
 # keystrokes could be misinterpreted as input. -f overrides for the ssh
 # false negative (remote end at a shell prompt).
 local fg; fg="$(tmux display-message -p -t "$t" '#{pane_current_command}')"
 case "${fg#-}" in
 zsh|bash|sh|dash|ksh|fish) : ;;
 *) [ "$force" -eq 1 ] || die "shell '$name' is busy running '$fg' - drive it with send/read, or use run -f if it is really at a shell prompt" ;;
 esac
# Unique markers; sent as split string literals ('__CT_BEGIN''_…) so the
 # echoed command line in the pane never matches the markers we search for.
 local id="$$.$(date +%s)"
 local begin="__CT_BEGIN_${id}__" end="__CT_DONE_${id}__"
tmux send-keys -t "$t" \
 " printf '__CT_BEGIN''_${id}__\\n'; ${cmd}; printf '__CT_DONE''_${id}__:%s\\n' \$?" Enter
local waited=0 buf=""
 while :; do
 buf="$(tmux capture-pane -p -t "$t" -S -2000)"
 if printf '%s\n' "$buf" | grep -q "^${end}:"; then break; fi
 if [ "$waited" -ge "$timeout" ]; then
 echo "claude-term: timed out after ${timeout}s; command may still be running in '$name'." >&2
 echo "claude-term: check later with: claude-term read $name" >&2
 printf '%s\n' "$buf" | grep -v '__CT_' | tail -15
 exit 124
 fi
 sleep 1; waited=$((waited + 1))
 done
# Print only the output between the marker lines; exit with the command's status.
 printf '%s\n' "$buf" | awk -v b="$begin" -v e="$end" '
 index($0, e ":") == 1 { code = substr($0, length(e) + 2); on = 0 }
 on { print }
 $0 == b { on = 1 }
 END { exit (code == "" ? 1 : code) }'
}
cmd_send() {
 local name="${1:?usage: claude-term send <name> keys…}"; shift
 ensure_session
 win_exists "$name" || die "no shell named '$name'"
 tmux send-keys -t "$(target "$name")" "$@"
}
cmd_read() {
 local name="${1:?usage: claude-term read <name> [-n N | -a]}"; shift || true
 local lines=40 all=""
 while [ $# -gt 0 ]; do
 case "$1" in
 -n) lines="${2:?-n needs a count}"; shift 2 ;;
 -a) all=1; shift ;;
 *) die "unknown read option: $1" ;;
 esac
 done
 ensure_session
 win_exists "$name" || die "no shell named '$name'"
 if [ -n "$all" ]; then
 tmux capture-pane -p -t "$(target "$name")" -S -
 else
 tmux capture-pane -p -t "$(target "$name")" -S -2000 | sed '/^[[:space:]]*$/d' | tail -"$lines"
 fi
}
cmd_kill() {
 local name="${1:?usage: claude-term kill <name>}"
 ensure_session
 win_exists "$name" || die "no shell named '$name'"
 tmux kill-window -t "$(target "$name")"
 echo "closed shell '$name'"
}
case "${1:-}" in
 list) shift; cmd_list "$@" ;;
 new) shift; cmd_new "$@" ;;
 run) shift; cmd_run "$@" ;;
 send) shift; cmd_send "$@" ;;
 read) shift; cmd_read "$@" ;;
 kill) shift; cmd_kill "$@" ;;
 attach) echo "tmux attach -t $SESSION" ;;
 *) sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac


*This post — including the wrapper, the skill, and the setup — was built collaboratively with Claude Code itself, running under the very rules it describes.*

메타데이터
post_id
cc10a4d7240c
slug
give-claude-code-a-terminal-you-can-actually-watch-cc10a4d7240c
url
https://medium.com/aimonks/give-claude-code-a-terminal-you-can-actually-watch-cc10a4d7240c
canonical_url
https://medium.com/aimonks/give-claude-code-a-terminal-you-can-actually-watch-cc10a4d7240c
author_url
https://medium.com/@justinohms
status
ok
fetched_at
2026-07-13 06:23:13