t-acp is a local control layer for terminal-based TUI agents.
It lets you keep using interactive agents like opencode, claude-code, and codex directly in your current terminal, while also exposing a local HTTP API for scripts, automation, or other local processes to inspect status, read recent screen contents, send input, and trigger adapter actions.
t-acp is useful when:
- You want to keep the native TUI experience instead of converting an agent into a pure API workflow.
- You want another local process to observe whether the agent is busy, blocked on a permission prompt, or running on a specific model.
- You want a simple local interface to send prompts, handle structured human-interaction prompts, cycle models, or terminate an instance.
Its goal is not remote session hosting. It combines an interactive foreground terminal session with a programmable local control surface on the same machine.
For the runtime model and extension boundaries, see Architecture.
- Runs the agent in the foreground and preserves the original PTY/TUI interaction model.
- Ensures the local daemon is available and registers new instances automatically.
- Exposes a local HTTP API to list instances, inspect details, send input, submit structured interactions, run actions, and terminate instances.
- Maintains a recent terminal screen snapshot, raw PTY tail, observation events, and a local HTML observation plane.
- Exposes runtime metadata such as current agent, model, provider, reasoning effort, context usage, and terminal focus state.
- Provides specialized adapter behavior for
opencode, including structured permission/question parsing, prompt injection, interaction submission, and model cycling shortcuts. - Falls back to a generic adapter for
claude-code,codex, and unknown commands.
- Detects permission prompts
- Detects external-directory, doom-loop, and question interactions as structured
interaction_requestvalues - Uses bracketed paste for
send-promptand submits automatically - Supports
approve-permission/reject-permission - Supports unified
POST /agents/{instance_id}/interactionsubmissions for allow-once, allow-always, reject, option selection, and custom answers when available - Supports
previous-model/next-model - Attempts to extract runtime metadata: agent, model, provider, reasoning effort, and context usage
The following currently use the generic adapter:
claude-codecodex- Any command without a dedicated adapter
The generic adapter supports:
- Instance registration
- Raw input injection
send-prompt
The generic adapter does not currently provide reliable permission prompt detection or model switching behavior.
- Rust 2024 edition
- Cargo
- A terminal agent executable available on the local machine, such as
opencode
Default listen address: 127.0.0.1:48974
You can override it with an environment variable:
export T_ACP_ADDR=127.0.0.1:49001Use RUST_LOG to control logging, for example:
RUST_LOG=info cargo run -- daemoncargo buildStart it manually:
cargo run -- daemonWith an explicit address:
cargo run -- daemon --addr 127.0.0.1:49001In many cases you do not need to start it manually. When you launch an agent through the wrapper, t-acp checks the daemon first and starts it in the background if needed.
cargo run -- opencodePass additional arguments through:
cargo run -- opencode --model gpt-5You can also wrap other commands:
cargo run -- claude-code
cargo run -- codex
cargo run -- /path/to/custom-agentWhen you run t-acp <agent> ...:
- The wrapper checks whether the daemon is healthy.
- If the daemon is not running, it starts one in the background.
- The agent runs in a PTY in the foreground.
- Screen output remains visible in your terminal and is also forwarded to the daemon.
- Runtime PTY output, resize, focus, and queued input flow through the internal WebSocket at
/internal/agents/{instance_id}/ws. - The daemon records instance metadata, status, recent screen contents, raw PTY tail, observation events, structured interactions, and any runtime metadata it can infer.
Start an agent:
cargo run -- opencodeList instances:
curl http://127.0.0.1:48974/agentsSend raw input to an instance:
curl -X POST \
--data-binary $'hello from api\n' \
http://127.0.0.1:48974/agents/<instance_id>/inputSend a prompt through the adapter:
curl -X POST \
--data-binary 'Summarize the current repo structure.' \
http://127.0.0.1:48974/agents/<instance_id>/actions/send-promptApprove an opencode permission prompt:
curl -X POST \
http://127.0.0.1:48974/agents/<instance_id>/actions/approve-permissionSubmit the currently visible structured interaction:
curl -X POST \
-H 'content-type: application/json' \
-d '{"interaction_id":"<interaction_id>","option_key":"allow_once","custom_answer":null}' \
http://127.0.0.1:48974/agents/<instance_id>/interactionOpen the observation plane:
open http://127.0.0.1:48974/observeCycle to the next model:
curl -X POST \
http://127.0.0.1:48974/agents/<instance_id>/actions/next-modelTerminate an instance:
curl -X DELETE \
http://127.0.0.1:48974/agents/<instance_id>All endpoints listen on http://127.0.0.1:48974 by default.
Health check.
Example response:
{
"ok": true
}Lists registered instances.
Example response:
{
"agents": []
}Returns details for a single instance.
Returns the zero-build local HTML observation plane. It lists registered agents, lets you select an instance, and shows the current screen, structured human interaction controls, event timeline, and raw PTY tail.
The template is embedded from assets/observe.html; rebuild and restart the daemon after editing that file.
Returns the same observation plane with the given instance preselected.
Returns observation-plane JSON for one instance:
agent: the same object returned byGET /agents/{instance_id}screen: the daemon's vt100 snapshot, including size, cursor, lines, and full textevents: recent observation events such aspty_output_received,state_changed, andinteraction_detectedraw_tail_hex: recent raw PTY bytes as hexadecimalraw_tail_utf8_lossy: recent raw PTY bytes decoded lossily as UTF-8
Streams state changes for one instance as Server-Sent Events.
Streams state changes for all instances as Server-Sent Events.
Every public write endpoint except GET /health, GET /agents, and GET /agents/{instance_id} returns 202 Accepted on success:
{
"queued": true,
"adapter": "opencode"
}Notes:
queued: truemeans the command has been queued or sent through the internal runtime channeladapterindicates which adapter produced the action- Raw
inputandDELETE /agents/{instance_id}are not adapter-generated actions, so they return"adapter": null
Injects raw bytes into the instance PTY. The request body is written directly and does not need to be JSON.
Useful for:
- Regular text
\n/\r- Control characters, for example
Ctrl+Cas0x03
Sends a prompt action.
- For
opencode: wraps the body in bracketed paste and submits it automatically - For the generic adapter: appends a newline if the body does not already end with one
Approves a permission request.
- For
opencode: only succeeds when a permission prompt is currently visible - The current implementation sends Enter
Rejects a permission request.
- For
opencode: only succeeds when a permission prompt is currently visible - The current implementation sends
Esc
Submits the currently visible structured human interaction. The daemon rejects stale submissions with 409 stale_interaction if interaction_id no longer matches the visible interaction_request.id.
Request body:
{
"interaction_id": "opencode-external_directory-...",
"option_key": "allow_once",
"custom_answer": null
}Rules:
- Provide exactly one of
option_keyorcustom_answer. - Permission interactions support option keys or actions such as
allow_once,allow_persist, anddeny. - Question interactions can submit an option key; if
custom_answer_allowedistrue, they can also submitcustom_answer. interaction_request.idis semantic and redraw-stable; it does not include raw screen noise, cursor position, or spinner frames.
Cycles to the previous model.
- For
opencode: sendsShift+F2 - The generic adapter currently returns
501 unsupported_action
Cycles to the next model.
- For
opencode: sendsF2 - The generic adapter currently returns
501 unsupported_action
Reserved endpoint. The request body may eventually carry a target model identifier.
opencodecurrently returns501 unsupported_action- The generic adapter is also not implemented yet
Sends Ctrl+C to request termination of the foreground agent.
The objects returned by GET /agents and GET /agents/{instance_id} look like this:
{
"id": "opencode-12345-1716620000000",
"agent_kind": "opencode",
"adapter": "opencode",
"pid": 12345,
"cwd": "/path/to/project",
"command": "opencode --model gpt-5",
"status": "ready",
"ui_mode": "input",
"blocking_reason": null,
"current_agent": "Build",
"current_model": "GPT-5.4",
"current_provider": "GitHub Copilot",
"current_reasoning_effort": "high",
"current_context_window": "42.6K",
"current_context_usage_percent": 21,
"need_interactive": false,
"interactive_kind": null,
"interaction_request": null,
"focused": true,
"exit_status": null,
"created_at_ms": 1716620000000,
"updated_at_ms": 1716620001234,
"screen_tail": "...recent terminal screen contents..."
}Key fields:
agent_kind: normalized instance type such asopencode,claude_code, orcodexadapter: the adapter actually in use; today onlyopencodehas a dedicated adapter, while most others returngenericstatus:starting,ready,busy,blocked,exitedui_mode:unknown,normal,input,permission_prompt,model_pickerblocking_reason: currently onlypermissionwhen a permission block is detectedcurrent_agent: agent name parsed from the runtime footer, such asBuildcurrent_model: current model namecurrent_provider: current provider name, such asGitHub Copilotcurrent_reasoning_effort: current reasoning level, such ashighcurrent_context_window: current context size, such as42.6Kcurrent_context_usage_percent: current context usage percentage, such as21need_interactive: whether the adapter believes human input is neededinteractive_kind: high-level interaction kind, such aspermissionorquestioninteraction_request: structured interaction payload rendered by the observation plane when availablefocused: whether the outer terminal is currently focusedscreen_tail: recent terminal screen text maintained by the daemon for observation and adapter heuristics
Example interaction_request:
{
"id": "opencode-question-7b7c1a2f59c770dd",
"kind": "question",
"source": "opencode",
"title": "Question",
"subject": null,
"prompt": "Which implementation path should I take?",
"options": [
{
"key": "1",
"label": "Small focused patch",
"selected": true,
"action": null
}
],
"custom_answer_allowed": true,
"confidence": 90,
"evidence": [
{
"label": "source",
"value": "pty_screen"
}
],
"raw": "...recent interaction screen block..."
}focused depends on terminal focus reporting support.
The wrapper tries to enable it automatically. If the terminal or multiplexer does not support it, focused may remain at its default value.
If you are running inside tmux, enable:
set -g focus-events onCommon errors include:
404 not_found: the instance does not exist409 process_exited: the instance has already exited and can no longer accept actions409 ui_not_detected: the adapter requires a UI state that is not currently visible, for example no permission prompt is present400 bad_request: the request body is invalid, such as an empty prompt409 stale_interaction: the submitted interaction is no longer visible or its id changed501 unsupported_action: the current adapter does not support that action yet
- The daemon registry is in-memory only, so instance data is not persisted across daemon restarts
screen_tailis based on terminal screen contents, not a complete output log- Observation-plane raw PTY tail and events are in-memory ring buffers and do not survive daemon restarts
switch-modelis not implemented yet- There is no public remote resize API; wrapper-local
SIGWINCHresize is synchronized through the internal WebSocket focuseddepends on proper focus event forwarding from the terminal and any multiplexer such astmux- Adapter state detection is heuristic;
opencodeUI detection can still misclassify some screens - The service listens on loopback by default and has no authentication; do not expose it directly to the public internet
Formatting:
cargo fmt
cargo fmt --checkRun tests:
cargo testManual smoke test:
target/debug/t-acp daemon --addr 127.0.0.1:49001
T_ACP_ADDR=127.0.0.1:49001 target/debug/t-acp /bin/catThen send input from another terminal:
curl -X POST \
--data-binary $'ping\n' \
http://127.0.0.1:49001/agents/<instance_id>/inputOpen the observation plane:
open http://127.0.0.1:49001/observesrc/main.rs CLI entry point
src/wrapper.rs foreground wrapper, daemon bootstrap, PTY and RPC forwarding
src/daemon.rs local HTTP control service and instance registry
src/adapters/ adapter trait and implementations
src/adapters/generic.rs generic adapter
src/adapters/opencode.rs opencode adapter and metadata extraction
src/interactions.rs interaction ids and submission validation
src/pty.rs Unix PTY spawn and resize support
src/http.rs daemon client
src/api.rs HTTP request and response structures
src/internal.rs internal WebSocket messages between wrapper and daemon
src/util.rs small utility helpers
assets/observe.html embedded observation-plane HTML template
docs/architecture.md current architecture guide
plans/ design and implementation notes
The following endpoints are primarily for internal wrapper-daemon communication and are not intended for external callers:
POST /internal/agents/registerWebSocket /internal/agents/{instance_id}/wsPOST /internal/agents/{instance_id}/exit
The wrapper and daemon currently use /internal/agents/{instance_id}/ws to carry:
- PTY output from the wrapper to the daemon
- Resize and focus runtime events from the wrapper to the daemon
- Queued commands from the daemon back to the wrapper
In other words, the runtime data plane now goes through the internal WebSocket. Internal HTTP is mainly used for registration and exit reporting.
Do not add internal HTTP fallback endpoints for output, resize, or command polling unless compatibility explicitly requires it.