Skip to main content
Glama
hungtranbkit

Terminal MCP

by hungtranbkit
README.md
# Terminal MCP

**If you are ChatGPT, another LLM, or any agent connecting to this
server to USE it (not modify its code): read [`docs/CHATGPT_USAGE.md`](docs/CHATGPT_USAGE.md)
first.** For the full technical spec (every feature's real status,
config, schema, tests), see [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md).

## Purpose

Terminal MCP lets an MCP client discover every tmux session on the host, then observe and, when explicitly enabled, send constrained input to whitelisted (or explicitly per-session-granted -- see "Dashboard session grants" below) tmux sessions. It supports local STDIO and a loopback-only Streamable HTTP transport intended to sit behind an authenticated HTTPS tunnel.

A separate, deliberately tiny READ-ONLY surface (`terminal-mcp-observer`) exists for hosted clients that cannot be given a custom header or pass a Cloudflare Access check -- claude.ai in a browser, or the Claude phone app. It authenticates with OAuth and exposes fifteen tools that cannot change anything: five read tmux, ten read Git and source. See [`docs/claude-web-connector.md`](docs/claude-web-connector.md).

## Architecture

```text
Claude / Codex / shell
        |
       tmux
        |
   Terminal MCP
        |
 MCP (STDIO or loopback HTTP)
        |
     ChatGPT
```

tmux is the source of truth. The server calls tmux with explicit argument arrays through Python `subprocess`; it never uses `shell=True`.

## Quick start

```bash
cd /home/dell/workspace/terminal-mcp
python3 -m venv .venv
.venv/bin/pip install -e '.[test]'
.venv/bin/terminal-mcp
```

For the remote-capable, loopback-only Streamable HTTP mode:

```bash
.venv/bin/terminal-mcp-http
# MCP endpoint: http://127.0.0.1:8766/mcp
# Read-only session dashboard: http://127.0.0.1:8766/dashboard
# Session management table (grants, detach):   http://127.0.0.1:8766/dashboard/sessions
# Liveness/readiness/version/metrics (loopback-only, not tunnel-routed):
#   http://127.0.0.1:8766/health/live
#   http://127.0.0.1:8766/health/ready    -- tmux + every durable SQLite store; 503 if any is broken
#   http://127.0.0.1:8766/health/metrics  -- in-process counters (metrics.py); no external backend involved
#   http://127.0.0.1:8766/version         -- package version + running commit/dirty state
```

The dashboard lists every real tmux session on the host (not only whitelisted
ones -- discovery is not access) and shows sanitized status and recent output
for the ones it can actually read, refreshing every five seconds. A session
outside the static whitelist is still listed (name/attached/windows/activity
only, never content) but shows as restricted until an operator explicitly
grants it read (and, separately, input) from the dashboard itself -- see
"Dashboard session grants" below. It also has a text input box per readable
session: sending from it calls the same guarded `terminal_send_text`(-shaped)
path as the MCP tool, so it is still gated by `permissions.terminal_input`,
`input_policy` (allowed/denied patterns, current pane command, max length),
and is recorded in the same input audit log — the box is simply hidden
client-side (and the server still enforces it) when a session doesn't pass
those checks. Like the MCP endpoint, the dashboard remains bound to loopback
and is intended for use directly on the Dell only; the Secure MCP Tunnel does
not publish it as a general-purpose website.

### Dashboard session grants

A session outside `allowed_session_patterns`/`input_policy.allowed_session_
patterns` is still discoverable everywhere (the dashboard, and
`terminal_list_sessions` for any MCP client) but starts fully restricted: no
content, no input. From the dashboard, an operator can explicitly grant it
**read** (its output becomes visible immediately, no restart) and, separately,
**input** (requires read already granted; still gated by the global
`permissions.terminal_input`, `input_policy`'s deny patterns, and the same
sensitive-current-command check every other input path uses). An input grant
pins the session's tmux identity at grant time and re-verifies it on every
send, exactly like a binding — a session recreated under the same name never
silently keeps a prior grant; re-grant explicitly to accept the new identity.
Both are revocable independently (revoking read also revokes input). Every
grant/revoke is audited. This mechanism is dashboard-only: there is no MCP
tool to grant or revoke — an MCP client only ever sees the *result* (a
session's `read_allowed`/`read_granted`/`input_allowed`/`input_granted`
fields in `terminal_list_sessions`), never a way to create one itself.

Every one of these controls -- a lock icon on each session's row/tab, a
compact entry point in its own open card, and a bulk-select bar for
several at once -- opens the same "Quyền truy cập" modal, exposing just
two ideas (**Xem output** / **Gửi prompt**) with three one-click presets
(Xem + gửi / Chỉ xem / Thu hồi). A newly-discovered session is never
hidden, only badged "Chưa cấp quyền" until granted.

### Session management screen

`/dashboard/sessions` (linked from the main dashboard's "⚙ Quản lý"
button) is a dedicated, table-shaped view of every real tmux session --
useful once the session count grows past what the sidebar comfortably
shows. It reads the exact same `/dashboard/api/sessions` data and drives
the exact same grant-read/grant-input routes as the main page (same
"Quyền truy cập" modal, same bulk bar) -- a second view of the same
capability, not a new privilege surface. It adds a name filter, a "chỉ
hiện session chưa whitelist" toggle, and a detach control per row (the
same client-side, localStorage-only concept the main page's tabs already
use -- toggling it here is immediately reflected in the main page's tab
strip in the same browser, and vice versa, since both read/write the
same key).

### Password login

The dashboard is also reachable through a second, independent path for
whoever cannot complete Cloudflare Access: a local username/password login
at `https://terminal-login.mesflow.net/login`, backed by `webauth.py`/
`webauth_dashboard.py`. It is a completely separate mechanism from
Cloudflare Access -- neither trusts the other's signal, and a forged
Cloudflare header sent to this hostname does nothing. Its own tunnel
ingress rule allow-lists only `/login`, `/logout`, and `/app(?:/.*)?` on
this hostname; `/mcp`, `/health/*`, `/version`, and `/dashboard/*` are not
reachable through it at all -- the old `terminal-dashboard.mesflow.net`
Cloudflare-Access-gated URL is completely unaffected and still required
for that path.

Logging in here never grants a tmux session read/input on its own -- once
authenticated, `/app` is the exact same dashboard (session tabs, detach,
grants) reachable at `/dashboard`, under the exact same
`allowed_session_patterns`/grant/input-policy rules as every other entry
point.

The first time the server starts with no local account yet, it creates
one (`admin`) with a strong random password, written once to a mode-600
file next to `webauth.db` (state directory, e.g.
`~/.local/state/terminal-mcp/webauth-bootstrap.txt`) -- never logged,
never printed, never committed. Logging in with it immediately forces a
short in-app password-change form (`/app/password`) before anything else
is reachable; changing it there deletes the bootstrap file automatically
and issues a fresh session. To manage accounts from a local shell instead:

```bash
terminal-mcp-webauth list-users
terminal-mcp-webauth create-user <username>   # prompts via getpass
terminal-mcp-webauth set-password <username>  # prompts via getpass; invalidates that user's other sessions
```

Session cookies are `HttpOnly`/`Secure`/`SameSite=Strict`, last 12 hours,
and are stored server-side only as a SHA-256 hash. Login and every
mutation under `/app/api/*` require a same-origin Origin/Referer header
(the same CSRF posture as the `/dashboard/api/*` path). Repeated failed
logins back off exponentially (capped at 15 minutes), keyed by source IP
-- never a lasting lockout an operator would need to manually clear.

The HTTP bind address is deliberately fixed to `127.0.0.1`. Do not expose it
directly or change it to `0.0.0.0`; use an authenticated HTTPS tunnel that maps
only its MCP route.

The installed user service can be managed with:

```bash
systemctl --user status terminal-mcp-http.service
systemctl --user restart terminal-mcp-http.service
```

For compact agent control, prefer `terminal_batch_inspect` over repeated
status/tail calls, `terminal_send_task` over a manual text-plus-Enter sequence,
and `terminal_wait_for_state` over client-side polling. These tools compose the
existing authorization, binding, menu-detection, idempotency, and verified-submit
paths; the lower-level tools remain available and backward compatible. Treat one
logical operation as one compact call. A wait holds the MCP request for at most
20 seconds; if it returns `PENDING`, continue with `terminal_resume_wait` and its
opaque `resume_token` rather than resending the task or restarting the wait.

Execution-aware node health is stricter than heartbeat-only presence: a node is green only after a bounded execution-backend probe succeeds. `terminal_node_health` exposes transport/execution state, retry/backoff evidence, last success, and sanitized failure details without changing legacy node APIs.

For project-level intents, configure an explicit `project_profiles` allowlist
and use one composite call:

| User intent | Preferred tool |
| --- | --- |
| `check` | `project_check` |
| `giao task` | `project_dispatch` |
| `deploy preview` | `deploy_preview` |

`project_check` combines bounded session, supervisor, Git, deploy-profile, and
health evidence, including execution-aware node counts and blockers. A fresh
heartbeat alone is never green: `EXECUTION_OK` also requires a bounded session
backend probe. `terminal_node_health` exposes the additive transport,
execution, retry, and last-success fields without changing legacy node APIs.
`project_dispatch` selects only a profile target and delegates
to the existing guarded send or durable queue. `deploy_preview` accepts fixed
command/probe IDs from a preview-safe profile; it never accepts arbitrary shell
commands. ChatGPT controls whether its UI visually collapses tool cards, but
serving each intent with one MCP call minimizes the cards it needs to show.

Its unit is `~/.config/systemd/user/terminal-mcp-http.service`, runs as the
current user, and uses `Restart=on-failure`. Authentication is intentionally
not implemented as an ad-hoc MCP wrapper: it must be enforced by the HTTPS
tunnel/identity provider before traffic reaches the loopback endpoint.

For private ChatGPT Developer Mode connectivity, use OpenAI Secure MCP Tunnel.
The credential-free deployment runbook and inactive service template are in
[`deploy/secure-tunnel`](deploy/secure-tunnel). No Cloudflare/public ingress is
required for this mode.

Equivalent module command:

```bash
.venv/bin/python -m terminal_mcp.server
```

Example MCP client command configuration:

```json
{
  "command": "/home/dell/workspace/terminal-mcp/.venv/bin/terminal-mcp",
  "args": [],
  "env": {
    "TERMINAL_MCP_CONFIG": "/home/dell/workspace/terminal-mcp/config.yaml"
  }
}
```

## Config

`config.yaml` controls read/input permissions, allowed session patterns, and capture limits. Input is disabled by default.

```yaml
permissions:
  terminal_read: true
  terminal_input: false
allowed_session_patterns:
  - "claude-*"
  - "codex-*"
  - "agent-*"
  - "test-*"
max_capture_lines: 2000
default_tail_lines: 200
```

## Security model

- Discovery (`terminal_list_sessions`, the dashboard's session list) shows every real tmux session on the host -- name/attached/windows/created/activity only, tmux metadata, never pane content. Content and input remain denied unless a session's name matches a whitelist pattern, or it has been explicitly granted read/input from the dashboard (see "Dashboard session grants") -- discovery never grants either.
- Sensitive names containing `root`, `ssh`, `password`, `secret`, or `database` require an exact literal whitelist entry, and can never be dashboard-granted either.
- Output is sanitized for common API keys, bearer/authorization values, passwords, and tokens.
- There is no shell execution, arbitrary filesystem access, environment disclosure, process killing, scheduler, or autonomous agent.
- HTTP mode listens only on loopback and cannot change permissions or whitelist through requests.
- `terminal_send_text` uses tmux literal mode. `terminal_send_keys` accepts only a fixed V1 key allowlist.
- Errors for a denied/ungranted session's content or input reveal nothing beyond that denial (no pane content, no reason tied to its content).
- **Identity model is single-operator by design, not role-based.** Cloudflare Access (when configured -- see the tunnel setup) authenticates *who is allowed to reach the dashboard/tunnel at all* and that identity is recorded in the audit log for every mutation it makes, but every authenticated caller shares the same operator-level capability -- there is no separate viewer/operator/approver permission tier. This is an intentional scope boundary for a personal/small-team single-operator deployment, not an unaddressed gap: adding real RBAC would mean new grant/policy infrastructure this deployment does not need. A future multi-operator deployment should treat this as the first thing to add.

## Create an agent tmux session

```bash
tmux new -s claude-mesflow
claude
```

```bash
tmux new -s codex-mesflow
codex
```

Detach with `Ctrl-b d` and reattach with `tmux attach -t SESSION`.

### Mouse-wheel scrolling in a Codex session

Any session whose name matches `codex*` gets tmux's per-session `mouse`
option turned on automatically (a host-level `~/.tmux.conf` hook, applied
the moment the session is created -- see that file's own comments for the
full root-cause writeup). This is scoped to Codex sessions only: it is a
per-*session* tmux option, not the server-wide default (which stays
`off`), so a Claude Code session's mouse/scroll behavior is unaffected
either way. With it on, tmux's own stock wheel bindings route Codex's
scroll into tmux's native copy-mode -- a fixed 5 lines per wheel step,
real pager behavior, instead of erratic jumps -- because Codex (unlike
Claude Code) runs in the primary screen and does not request its own
mouse tracking, so tmux would otherwise never mediate its scrolling at
all. Exit copy-mode (return to the live view) by scrolling all the way
back down, pressing `q`, or `tmux send-keys -t SESSION -X cancel`; while
in copy-mode, `terminal_send_text`/`terminal_send_bound` correctly refuse
input with `PANE_IN_COPY_MODE` rather than ever falsely reporting a send
as confirmed (unchanged from the existing copy-mode guard). A session
created before this hook existed (or if the hook is ever disabled) can be
opted in by hand: `tmux set-option -t SESSION mouse on`. Rollback: see
`~/.tmux.conf`'s own comment block.

**Known limitation, confirmed against the installed Codex CLI (v0.151.0):**
tmux copy-mode (above) freezes and scrolls the *whole pane* and takes
keyboard focus away from Codex entirely -- there is no tmux-level way to
scroll only an output region while the composer stays live, because tmux
copy-mode has no concept of two independent regions within one pane.
Codex's own closest native feature, `Ctrl+T` ("view transcript" --
customizable via `/keymap` or `tui.keymap.global.open_transcript` in
`~/.codex/config.toml`), is a real, useful, *separate* full-screen pager
(its own alt-screen, deterministic ↑/↓/PgUp/PgDn/Home/End scrolling) --
but it is modal: opening it hides the composer, and `q`/`Esc` are needed
to return to it. Neither tmux copy-mode nor Codex's own transcript pager
currently provides Claude Code's exact experience (composer permanently
visible and typable while the output above it scrolls independently) --
that is an upstream Codex TUI architecture limitation, not something
fixable from tmux config or from terminal-mcp. The dashboard's own
per-session view (see "Quick start" above) already provides exactly that
split layout -- a persistently scrollable output pane plus a separate,
always-focusable input box -- as an existing, no-new-code alternative for
anyone who wants it.

## Enable terminal input

Edit only the permission in `config.yaml`:

```yaml
permissions:
  terminal_read: true
  terminal_input: true
```

Restart the MCP child process after changing configuration. Enabling input lets the MCP client type into allowed sessions; review this change carefully.

## Tools

- `terminal_list_sessions`
- `terminal_tail`
- `terminal_capture`
- `terminal_status`
- `terminal_send_text`
- `terminal_send_keys`
- `terminal_bind`
- `terminal_get_binding`
- `terminal_list_bindings`
- `terminal_unbind`
- `terminal_tail_bound`
- `terminal_status_bound`
- `terminal_send_bound`
- `terminal_list_input_audit`
- `terminal_input_context`
- `note_create`, `note_get`, `note_search`, `note_list`, `note_update`,
  `note_delete`, `note_restore`, `note_add_attachment`,
  `note_remove_attachment`, `note_link_to_project`, `note_mark_applied`,
  `note_facets` — the cross-project notes/ideas store; see
  "Ghi chú / Ý tưởng (Notes / Ideas)" below and
  [`docs/notes.md`](docs/notes.md)
- `supervisor_watch`, `supervisor_set_verifier_policy`, `supervisor_unwatch`,
  `supervisor_list_watches`, `supervisor_status`, `supervisor_list_events`,
  `supervisor_ack_event`, `supervisor_run_once` — see "Supervisor Loop v1"
  below, and "Independent completion verification" for
  `supervisor_set_verifier_policy`

## Chat ↔ tmux logical binding

A logical binding gives a chat or work context a stable name without relying on
an internal ChatGPT conversation ID. Bindings persist in SQLite at
`~/.local/state/terminal-mcp/bindings.db` (or the path in
`TERMINAL_MCP_BINDINGS_DB`) and resolve to tmux, which remains the source of
truth.

Create an agent session:

```bash
tmux new -s claude-mesflow
claude
```

From ChatGPT, bind `mesflow-dev` to `claude-mesflow` with `terminal_bind`, then
requests such as “xem agent này đang làm gì”, “đọc 300 dòng cuối”, or “kiểm tra
nó đang chờ input không” can use:

```text
terminal_tail_bound(binding="mesflow-dev")
terminal_tail_bound(binding="mesflow-dev", lines=300)
terminal_status_bound(binding="mesflow-dev")
```

Binding names contain only lowercase letters, digits, `-`, `_`, or `.`, with a
maximum of 64 characters. A target must exist and pass the session whitelist;
sensitive session names are never bindable. Existing bindings require
`replace=true` for an explicit remap. If a tmux session disappears, its binding
is retained and status becomes `MISSING`.

New bindings use `read_enabled=true` and `input_enabled=false`. Bound input is
allowed only when both the local global permission and the binding permission
are true. Creating a binding never enables global terminal input.

## Backup & Restore

All durable state is SQLite, under `$XDG_STATE_HOME/terminal-mcp/` (default
`~/.local/state/terminal-mcp/`) unless a `TERMINAL_MCP_*_DB` environment
variable overrides an individual store's path:

| File | Store | Contents |
| --- | --- | --- |
| `audit.db` | `AuditStore` | Every input attempt (hash/length/redacted preview, never full text) |
| `bindings.db` | `BindingStore` | Chat↔tmux logical bindings and their `input_enabled` state |
| `grants.db` | `SessionGrantStore` | Dynamic, time-boxed read/input grants outside the static whitelist |
| `leases.db` | `PaneLeaseStore` | Short-lived per-pane send leases used for the submit-guarantee path |
| `supervisor.db` | `SupervisorStore` / `SupervisorV2Store` | Watch state, policy decisions, and v2 action/approval history (v1 and v2 share one file) |
| `notes.db` | `NotesStore` | Notes/ideas text + metadata, and the FTS5 search index |
| `notes_attachments/` | `NotesService` | **Not a database** — the attachment IMAGE FILES, laid out `YYYY/MM/<attachment-uuid>.<ext>`. A backup of `notes.db` alone is not a backup of the notes; see [`docs/notes.md`](docs/notes.md) for the two-part procedure. |

Plus `config.yaml` (whitelist, permissions, input policy, supervisor
config — not itself in a state directory; wherever `--config`/the default
lookup points it).

All five stores are opened with `PRAGMA journal_mode=WAL`, so a backup taken
while the service is running can still capture a mid-write `-wal`/`-shm`
sidecar file — always copy all three files for a given `*.db` (the `.db`
plus any `.db-wal` / `.db-shm` next to it) together, never the `.db` alone.

**Backup** (safe to run live; SQLite's own consistency guarantees make a
plain file copy of `.db`+`.db-wal`+`.db-shm` correct, but the built-in
`.backup` command is preferred since it doesn't depend on the sidecar files
being copied atomically with the main file):

```bash
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/terminal-mcp"
BACKUP_DIR="/path/to/backups/$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$BACKUP_DIR"
for db in audit bindings grants leases supervisor; do
  sqlite3 "$STATE_DIR/$db.db" ".backup '$BACKUP_DIR/$db.db'"
done
cp config.yaml "$BACKUP_DIR/"
```

**Restore** (stop the service first — restoring into a live WAL-mode
database while the process holds an open connection can be rejected or, in
the worst case, silently ignored):

```bash
sudo systemctl stop terminal-mcp   # or however the service is run
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/terminal-mcp"
for db in audit bindings grants leases supervisor; do
  rm -f "$STATE_DIR/$db.db" "$STATE_DIR/$db.db-wal" "$STATE_DIR/$db.db-shm"
  cp "/path/to/backups/<timestamp>/$db.db" "$STATE_DIR/$db.db"
done
cp /path/to/backups/<timestamp>/config.yaml ./config.yaml   # review before overwriting a live config
sudo systemctl start terminal-mcp
curl -s http://127.0.0.1:8766/health/ready | python3 -m json.tool   # confirm every store re-opens clean
curl -s http://127.0.0.1:8766/version
```

Restoring an older `grants.db`/`leases.db` can resurrect grants or leases
that a newer backup had already revoked/expired; review the restored file's
contents (or accept the small window of over-permissiveness until natural
expiry) rather than assuming restore always narrows access.

A missing store file is not an error at startup — every store creates its
schema on first open — so restoring a subset of files (e.g. `audit.db` only,
after a disk incident that spared the others) is safe.

## Ghi chú / Ý tưởng (Notes / Ideas)

A cross-project store for things worth keeping. The user is chatting, sees
something good, says **"lưu lại"** — ChatGPT calls `note_create` with the
content, its own analysis, the source URL and (optionally) a screenshot.
Later the same material is findable from ChatGPT (`note_search`) or from a
browser at **`/dashboard/notes`** (gallery / list / Kanban, Vietnamese,
mobile-friendly).

- Fully local and deterministic: SQLite + FTS5 bm25. No embedding service,
  no cloud dependency, no new infrastructure.
- Not tied to any one project — `project_id`/`project_name` are optional and
  can be attached later with `note_link_to_project`.
- Images are real files on disk (`notes_attachments/YYYY/MM/`), never base64
  blobs in the database. Type is decided by the file's own magic bytes, the
  on-disk name is a generated uuid, and the bytes are served only through
  `/dashboard/api/notes/attachment?id=...` — no static mount, no path ever
  accepted from a caller.
- **The Notes web surface requires a real login** (`notes.require_auth`,
  default on): the page, its JSON API and attachment serving all demand either
  a webauth session cookie (the existing `/login` path) or a verified
  Cloudflare Access assertion. Edge-only Access is not enough — `cloudflared`
  connects over loopback, so a tunnel request is indistinguishable from a local
  one once it arrives. No new auth mechanism was introduced; this reuses the
  same `WebAuthStore` `/app/*` uses. The `note_*` MCP tools are covered by the
  MCP transport's own controls only (loopback + CIDR allowlist + authenticated
  tunnel) — see `docs/notes.md` for why gating one tool family there would be
  theater.
- Needs no configuration to work. To let `note_add_attachment(source_path=…)`
  read files already on this host, an operator must name the allowed
  directories in `notes.attachment_source_roots` — until then that transport
  is refused outright and only in-band base64 / the dashboard upload form
  can add images.

Full reference — data model, every tool with JSON examples, the routes,
the security posture, backup/restore and the V1 limitations:
[`docs/notes.md`](docs/notes.md).

## Registry hygiene: test artifacts in `session_registry`

The test suite creates **real** tmux sessions. Historically it created them on
the host's **default** tmux server — the same one a running `terminal-mcp`
service reconciles — so that service observed them and wrote them into the
**production** `session_registry.db` as if they were real sessions.

**Prevention (automatic, nothing to run).** `tests/conftest.py` gives each test
run its own tmux server via `TERMINAL_MCP_TMUX_SOCKET` (`tmux -L <socket>`).
Separate tmux servers share no sessions, so a production reconcile pass cannot
see — and therefore cannot record — anything the suite creates. Production sets
no socket and its `tmux` argv is unchanged. Held in place by
`tests/test_tmux_socket_isolation.py`.

**Cleaning up rows written before that fix** — `terminal-mcp-registry-cleanup`.
It is **dry-run by default and never deletes without two explicit flags**:

```bash
# 1. Review. Writes nothing; opens the database read-only.
terminal-mcp-registry-cleanup

# 2. Read the plan. `--verbose` lists every row; `--json` is machine-readable.
terminal-mcp-registry-cleanup --verbose

# 3. Apply ONLY the plan you just reviewed. Both flags are required, and the
#    count must still match exactly, or it refuses and changes nothing.
terminal-mcp-registry-cleanup --apply --confirm-count <N from step 1>
```

A row is selected only if **all** of these hold:

| Predicate | Why |
|---|---|
| name is an exact literal the tests pass *as a session name* (AST-mined) | resemblance (`test-*`) both misses real test rows and sweeps in real sessions |
| `status` is `MISSING` or `KILLED` | `ACTIVE` is alive; `OFFLINE` means its fate is unknown; `DELETED` is already a tombstone |
| no tmux session by that name is alive right now | never purge something running |
| not in `session_lifecycle.protected_sessions` | `terminal-mcp` is both a test literal and the control session |
| `conversation_id` is empty | a real agent session with resumable history |
| `auto_recovery_enabled` is off | something is meant to bring it back |
| `notes` is empty | a curated/backfilled record — the only surviving trace of a vanished real session |
| older than `--min-age-days` (default 7) | a recent row may still be in play |

Anything matching a test name but failing a guard is reported in a separate
**needs review** list with the guard that held it back — never selected. Deletion
goes through `SessionRegistryStore.purge()`, leaving a `DELETED` tombstone with
who purged it and when, not a bare SQL `DELETE`.

## Known limitations

- Status detection is heuristic and intentionally returns `UNKNOWN` when evidence is weak.
- Only the active pane of each tmux session is inspected.
- Capture is line-based and capped; it does not stream incremental events.
- Redaction covers common secret shapes, not full DLP.
- HTTP mode is local-only until a separately authenticated HTTPS tunnel is configured.

## Safe Input

Terminal input is deny-by-default. Setting `permissions.terminal_input: true` only
opens the global gate; a target must also match `input_policy.allowed_session_patterns`,
must not match a denied pattern, and must pass the action and current-command guards.
Logical bindings add another independent gate and default to `input_enabled: false`.

```yaml
permissions:
  terminal_read: true
  terminal_input: true

input_policy:
  allowed_session_patterns: ["claude-*", "codex-*"]
  denied_session_patterns: ["ssh-*", "prod-shell-*"]
  allow_send_text: true
  max_text_length: 12000
```

Enable one binding explicitly with `terminal_bind(binding="mesflow-dev",
session="claude-mesflow", input_enabled=true)`. Use `terminal_input_context` to
inspect the command and last 20 sanitized lines first. Both `terminal_send_text`
and `terminal_send_bound` accept `dry_run=true`; this validates every guard,
records `DRY_RUN`, and sends nothing.

Text is passed to `tmux send-keys -l` as one literal argument. It is never parsed
as a shell command, interpolated into a command string, or executed with
`shell=True`. `press_enter=true` sends Enter separately. Key input is restricted
to the configured allowlist. `C-c` and `C-d` require
`confirm_sensitive=true`; unknown keys return `KEY_NOT_ALLOWED`.

Every successful, blocked, and dry-run input attempt is appended to
`~/.local/state/terminal-mcp/audit.db` (mode `0600` where supported). The audit
stores the full text's SHA-256, length, and a short redacted preview—never the
full prompt. `terminal_list_input_audit` returns sanitized metadata and supports
binding/session filters.

Safe Input does not add an arbitrary command or file-read facility. Input cannot
bypass the session policy, and panes whose current command is `ssh`, `mysql`,
`psql`, `sudo`, or `passwd` are denied unless locally allowed. (Supervisor Loop
v1, below, is a separate, detection-only facility — it never calls
`terminal_send_text`/`terminal_send_keys` itself.)
- tmux sessions must run under the same Unix user as Terminal MCP.

## Supervisor Loop v1

**What v1 solves:** local, automatic detection of a watched session/binding
transitioning into a state that needs attention (waiting for input, an error,
or a defensible completion signal), persisted as a durable, queryable event —
so a human (or a future automation) doesn't have to keep polling by hand.

**What v1 deliberately does not do:** it never sends text/keys to a watched
session, never executes a shell command, and never bypasses
`terminal_input`/`input_policy`/binding/confirmation/audit — those gates are
completely unchanged. It also does not itself wake up or message ChatGPT; see
"v2" below for what's still needed for that.

Disabled by default. Enable in `config.yaml`:

```yaml
supervisor:
  enabled: true
  poll_interval_seconds: 20   # minimum enforced: 5
  idle_threshold_seconds: 45
  max_iterations: 20          # a watch auto-disables itself after this many polls
  same_failure_limit: 2       # ...or after this many *identical* consecutive errors
  event_retention: 500
  watched_session_patterns: ["claude-*"]   # matched against currently allowed sessions each poll
  watched_bindings: ["mesflow-dev"]        # must already exist via terminal_bind
```

Restart `terminal-mcp-http` after changing `supervisor.enabled` — the background
poll thread is only started (as a daemon thread inside that process) when it is
`true`, and only for the HTTP service (not the per-client STDIO server, which
would start/stop a loop with every client connection). One loop per process;
`supervisor_status` reports whether it is actually running.

Watches can also be created dynamically at any time via `supervisor_watch`,
independent of the config-seeded patterns/bindings above, and work even with
`supervisor.enabled: false` (only the automatic timer is gated — the tools
themselves, including `supervisor_run_once` for a single manual/deterministic
pass, are always available). A watch can never be created for, or continue
polling, a session outside the existing whitelist.

**State machine.** Reuses `classify_status()` (the same heuristic
`terminal_status` already applies) for `RUNNING`/`IDLE`/`WAITING_INPUT`/
`UNKNOWN`, and layers two more states on top from explicit evidence only:
`DONE` (an explicit completion marker — never inferred from ordinary silence,
which maps to `IDLE` via `idle_threshold_seconds` instead) and `ERROR` (a
traceback/fatal/exit-code-style marker). An event is persisted only on a
*meaningful transition* — identical repeated state/output is deduplicated,
never re-alerted.

**Stop policy.** A watch auto-disables itself (an event with
`event_type: "stalled"` is recorded) when either limit is hit, and stays
disabled until explicitly resumed (call `supervisor_watch` again for the same
target):
- `same_failure_limit` — the same `ERROR` with unchanged output repeats this
  many times in a row.
- `max_iterations` — a hard poll-count ceiling per watch, regardless of state.

A denied, since-excluded, or vanished session/binding emits
`event_type: "watch_target_missing"` and also auto-disables the watch rather
than retrying it.

**Event types:** `state_changed`, `attention_required` (entering
`WAITING_INPUT`), `completed` (entering `DONE`), `error_detected` (entering
`ERROR`), `stalled`, `watch_target_missing`.

**Event schema** (also in `terminal_mcp/supervisor.py`'s `EVENT_SCHEMA_VERSION`
docstring — the stable JSON shape a future v2 webhook forwarder can build
against):

```json
{
  "schema_version": 1, "id": 1, "timestamp": "2026-...Z",
  "watch_key": "session:claude-mesflow", "kind": "session", "target": "claude-mesflow",
  "previous_state": "RUNNING", "state": "WAITING_INPUT",
  "event_type": "attention_required",
  "reason": "recent prompt matched ... at bottom offset 0",
  "output_preview": "Do you want to continue? [y/N]",
  "output_hash": "sha256...", "iteration_count": 3,
  "acknowledged_at": null, "metadata": {"source": "manual"}
}
```

`output_preview` is redacted (the same `redact_text`) and truncated *before*
it is ever written to SQLite — never the full/raw pane output.

Persisted in SQLite at `~/.local/state/terminal-mcp/supervisor.db` (or
`TERMINAL_MCP_SUPERVISOR_DB`), same pattern as `bindings.db`/`audit.db`: a
`watches` table (state/iteration/failure bookkeeping per target) and a
`supervisor_events` table.

The dashboard shows a compact "🛰" badge (hidden entirely when there are zero
watches) with per-state counts and unacknowledged events in a small overlay
panel; acknowledging an event from there only stamps `acknowledged_at` in
SQLite — it is not a terminal-input path.

## Supervisor Loop v2

**What v2 solves:** when v1 emits an actionable, unacknowledged event
(`WAITING_INPUT`/`ERROR`), v2 provides a safe, auditable, restart-safe
claim → decide → approve → send pipeline that can continue a watched session
without a human re-typing "check"/"continue" every time — while still going
through the exact same guarded send path as manual input.

**What v2 does not build:** no ChatGPT/webhook callback exists to invoke, and
none is faked here. v2 is the local queue/claim/decide/send contract an
external caller (ChatGPT, a script, a human) drives via the `supervisor2_*`
MCP tools — it never invents a way to wake an external agent on its own. It
also never adds a second send path: `execute_send` calls the exact same
`terminal_send_text`/`terminal_send_bound` methods `terminal_send_text`/
`terminal_input` already use, so `terminal_input`, whitelist, binding
`input_enabled`, `input_policy`, confirmation, sensitive-target, redaction,
audit, and length limits all still apply unchanged.

**Policy modes** (per watch, via `supervisor2_set_policy`; default for every
watch is `observe_only` — nothing is ever auto-sent unless a watch is
explicitly opted in):

- `observe_only` (default) — v2 never offers, claims, or sends anything for
  this watch; `supervisor2_list_actionable_events` never returns its events.
- `suggest_only` — a decision/prompt can be claimed and submitted, but always
  needs an explicit `supervisor2_review_action(decision="approve")` before
  `supervisor2_execute_send` will do anything.
- `approved_auto_continue` — requires an `approved_template` string set on the
  policy. A submitted prompt auto-approves **only** if it is byte-for-byte
  equal (after redaction) to that template — no free-form filling, no partial
  match. This is the whole mechanism that keeps auto-continue inside the
  scope the watch owner pre-approved; anything else falls back to needing
  `supervisor2_review_action`.

**Hard stop conditions** — any of these halts the action (state `blocked` or
`failed`) and surfaces the reason rather than guessing:
`max_auto_actions`, `wall_clock_timeout_seconds` (since the watch's first v2
action), `same_prompt_repeat_limit` (identical prompt sent too many times in a
row), `no_progress_limit` (output hash unchanged across repeated post-send
checks), a stale/expired claim lease, and content screening against
`ATTENTION_STOP_PATTERNS` — password/API-key/credential/token requests,
confirmation prompts ("are you sure", "irreversible", "cannot be undone"),
and destructive-looking commands (`rm -rf`, `force-push`, `drop table`,
`sudo`, ...) — checked at both claim time (against the triggering output) and
decision time (against the proposed prompt and the freshly re-fetched current
output). A match blocks the action **and** the watch's policy
(`blocked_reason` set) so no repeated attempt can slip through.

**Idempotency & concurrency.** A small SQLite compare-and-swap
(`UPDATE supervisor_actions SET state=? WHERE id=? AND state=<expected>`) is
the only concurrency primitive — no external queue/broker. `execute_send`
CASes `approved → sent` *before* calling the guarded send, so a retry,
duplicate call, or a restart mid-send always finds `state != approved` and
is a safe no-op — it can never send twice. Only one open (non-terminal)
action is allowed per watch at a time, so two workers can't double-claim or
double-decide the same watch concurrently.

**Recovery.** Nothing is replayed on restart: an action already in `sent` or
beyond is never re-sent (the CAS guard above), and a `claimed`/`decided`
action past its 5-minute lease is treated as expired and can be reclaimed
rather than resumed blindly.

**Reconciliation → DONE.** After every `supervisor_run_once`/poll cycle, v2
checks every `observing` (post-send) action: if the watch's output hash
changed, the action completes and links `resulting_event_id`; if the watch
also reached `DONE`, the watch's v2 counters (`auto_action_count`, repeat/
no-progress counters) reset — "the loop stops cleanly at DONE". If output
never changes within `no_progress_limit` checks, the action is blocked
instead.

**MCP tools:** `supervisor2_set_policy`, `supervisor2_get_policy`,
`supervisor2_list_actionable_events`, `supervisor2_claim_event`,
`supervisor2_submit_decision`, `supervisor2_review_action`,
`supervisor2_execute_send`, `supervisor2_list_actions`. Persisted in the same
`supervisor.db` as v1, in two new tables: `supervisor_policies` (one row per
watch opted into v2) and `supervisor_actions` (the full claim → decision →
approval → send → outcome record per action, linking back to the triggering
`supervisor_events.id`). Never stores secrets or raw unredacted output —
prompts are redacted before storage and before send, and `send_result` only
ever holds `terminal_send_text`/`terminal_send_bound`'s own return value
(a character count, never the text itself).

**Dashboard.** The existing 🛰 Supervisor overlay gained a compact per-watch
v2 section (policy badge, auto-action count, latest action's state/blocked
reason/send result, and a one-click "Pause (observe only)" button) — it does
not touch or resize the main terminal viewer.

Still fully manual/opt-in end to end: a fresh install defaults every watch to
`observe_only`, and even `approved_auto_continue` only ever sends the one
exact template a human configured for that watch.

## Independent completion verification

For a watch under `approved_auto_continue` policy (with v2's global
`supervisor.v2_enabled` also on — both gates, same as `execute_send`
requires), prose/marker "done" evidence alone is **not** sufficient to reach
`VERIFIED_DONE` and reset the auto-continue chain: quiet-window/nonce
evidence that would promote any other watch instead moves this one through a
new `VERIFYING` state while a real, independent verifier runs *outside* the
target pane. Every other watch (the default) is completely unaffected —
unchanged, direct promotion, exactly as described above.

Configure the verifier once per watch with `supervisor_set_verifier_policy`:

```text
supervisor_set_verifier_policy(
  session="claude-mesflow",
  worktree="/home/you/project",      # real subprocess cwd, `git -C` target
  require_git_clean=true,            # fail if `git status --porcelain` is non-empty
  require_commit_matches="<sha>",    # optional: pin to a specific commit
  test_command=["pytest", "-q"],     # a literal argv list -- never a shell string
  timeout_seconds=300,
)
```

Only `git rev-parse`/`git status`/`git diff --stat` (read-only) and, if
configured, that one fixed `test_command` ever run — always
`subprocess.run(..., shell=False)`, always a fixed argument list, never
anything parsed out of what the watched pane printed. An autonomous watch
with **no** verifier policy configured goes to `BLOCKED` rather than ever
reaching `VERIFIED_DONE` on prose alone — this is the actual enforcement of
"independent verification required", not an oversight to work around.

**New states:** `VERIFYING` (a real verifier run in progress — durable,
survives a process restart mid-run, the next poll safely re-verifies),
`FAILED` (the verifier ran and rejected the claim -- e.g. a failing test, a
dirty worktree, a commit mismatch), `BLOCKED` (autonomous, but no verifier
configured, or one that couldn't even run). Both `FAILED` and `BLOCKED`
disable the watch (no repeated re-verification against unchanged pane
output) and set the v2 policy's `blocked_reason`, so no further autonomous
send happens until an operator fixes the underlying issue and explicitly
`supervisor_watch`s the target again.
# terminal-mcp

TDQS

B3.2/5.0

Scored across 13 tools

Disambiguation4/5

Tools are mostly distinct, with a clear split between direct session operations and logical binding operations. The only mild ambiguity is between terminal_tail and terminal_capture, but their descriptions differentiate recent output from larger scrollback.

Naming Consistency4/5

All tools share a terminal_ prefix and use snake_case, with a consistent _bound suffix for binding-resolved variants. Minor inconsistencies exist, such as get_binding vs list_bindings and send_bound omitting the _text qualifier used by send_text, but the overall pattern is predictable.

Tool Count5/5

Thirteen tools is well-scoped for a terminal/tmux MCP server. The surface is organized into two coherent families—session inspection/control and persistent bindings—without obvious redundancy or bloat.

Completeness4/5

The set covers core listing, reading, status, input, and binding lifecycle operations. There are minor gaps such as no bound capture variant and no explicit binding update, but most expected workflows for a restricted terminal interface are supported.

Maintenance

ActivityMaintained
ResponsivenessNo issues