agents_control

A remote for iTerm2 and its AI agents (Claude Code, Codex) from Telegram: tab list, commands, screen, new sessions. For Claude Code — also the agent's questions and permission requests: it stops, buttons show up in Telegram, you answer, the session unblocks.

Everyone runs their own bot — the token lives in the Keychain or libsecret, never in files or in git. The daemon is invisible from outside: the port only binds to 127.0.0.1, there are no incoming connections.

Status: terminal control and Claude Code work in full. Codex sessions are visible and controllable through the terminal (like any tab), but without the question relay: its hooks don't give us anything to hook into for that yet.

Platforms and requirements

  • macOS — terminal backend: iTerm2 (via AppleScript) or tmux; secrets: Keychain; autostart: launchd.
  • Linux — terminal backend: tmux; secrets: libsecret; autostart: systemd.
  • Windows is not supported.
  • Ruby 3.1+. The only external dependency is thor (pure Ruby, no C extensions) — installing it compiles nothing.

On macOS, terminal control (sending commands, reading the screen, creating tabs) works through iTerm2 or tmux — Terminal.app isn't supported. Notifications about questions and permissions aren't tied to a terminal at all: their source is the agent's hooks, which work everywhere, including sessions with no terminal (a VS Code session, for instance), but for now only for Claude Code.

Why

You need to run a command in a session, check the screen, or switch to a tab, and you're not at the computer — now you can do that from Telegram. If the session is Claude Code, there's a bonus too: the agent stops and waits for an answer — the question and its buttons arrive in Telegram, no need to go home just to say "continue."

How it works

Hooks, not screen scraping. Claude Code itself calls agents_control when it stops — the hook waits for an answer and passes the decision back into the session. There's currently one adapter, for Claude Code; a new agent needs a file with the same interface. Codex didn't fit: its hooks only see shell commands and only understand deny — there's no "agent stopped" event to hook into at all.

The terminal is iTerm2 or tmux, and each session picks its own backend. Terminalless sessions (VS Code) are visible and answer hooks too — they just have nothing to type into and nowhere to read a screen from.

Installation

git clone https://github.com/saparjohnick/agents_control
cd agents_control
bundle install

Or straight from Claude Code, as a plugin — the repo doubles as a marketplace:

/plugin marketplace add saparjohnick/agents_control
/plugin install agents-control@agents-control

The plugin doesn't replace the install above — it's just a way to find the tool and get install instructions without leaving Claude Code.

Usage

agents_control          # opens the console and stays in the tab

The tool lives in a tab: while it's open, it listens to Telegram and receives agent events. Commands inside start with a slash, same as the bot's:

> /sessions          sessions with a live agent
> /tabs              all terminal tabs
> /away              intercept agent questions (before stepping out)
> /settings          settings; /settings away — toggle
> /doctor            check that everything is in place
> /quit              quit

State icons: working · at a shell prompt · 🖥 no terminal (VS Code) · · everything else.

One-off commands exist too — agents_control sessions, doctor, daemon — but the normal way to run it is an open console.

Telegram

Create a bot with @BotFather and run the wizard:

agents_control setup    # asks for the token, waits for your /start
agents_control           # after that, just open the console

The wizard catches your chat_id from your own message and adds it to the allowed list — no need to type in a long number by hand.

Bot commands:

Command What it does
/agents sessions with a live agent
/tabs all terminal tabs
/screen N show a tab's screen
/focus N switch to a tab
/run N command run a command in a tab
/new [directory] create a tab
/away intercept agent questions
/status current status

The number N comes from the last list shown.

This list also populates Telegram's own / command menu automatically — setup and every daemon start publish it via the Bot API, no manual BotFather step needed. If the menu still shows only /start after that, it's Telegram's client caching the old list, not a missing step on your end: close and reopen the chat, or restart the Telegram app, to force it to refresh.

Two modes

While you're at the keyboard, intercepting agent questions is counterproductive: you'll answer in the terminal faster than you can reach for your phone, and a blocked hook keeps the dialog from ever appearing on screen. So there are two modes:

  • present (default) — questions are mirrored to Telegram but stay in the terminal;
  • away (/away) — a question arrives with buttons and waits for a reply; the agent stands by until you answer or time runs out.

A question can also be answered by replying directly to the message — it goes to the right session, even with several tabs open.

Silence is treated as a refusal. If nobody answered while you were out, the action doesn't happen, and the session just keeps waiting in the terminal.

AskUserQuestion is the one exception to all of this: its answer never travels back through the hook at all, so there's nothing to block on — it always arrives with real buttons for each option, in both modes. Tapping one types that choice straight into the terminal, the same keystroke you'd type by hand. An open-ended option ("something else," "explain what you mean") has no button — just reply to the message with your own words instead. With more than one question in a single batch, or several tabs sharing the same directory so the target pane is ambiguous, buttons are skipped in favor of a plain reply, since guessing at the terminal's exact sequencing there risks typing into the wrong place.

A "continue" reply is sent automatically, but tool permissions aren't. These are two independent settings on purpose: merged into one, they'd produce an agent that approves itself everything while nobody's watching. A question that offers a choice ("rewrite it or leave it?") is never answered automatically, even if it contains the word "continue."

Hooks

The daemon connects hooks on start and removes them on stop — otherwise the agent prints a warning about an unreachable address in every session. If the daemon crashed and the hooks are still there:

agents_control hooks          # check status
agents_control hooks uninstall

Entries in ~/.claude/settings.json are tagged, and other settings aren't touched: installing and removing return the file to exactly its original shape.

Rate-limit anchors

A five-hour window starts at the minute of the first message and expires exactly three hundred minutes later. An anchor doesn't add a single extra token — it moves window boundaries to where they're convenient: the difference between "the window reset at 2:37pm, mid-work" and "windows at exactly 7am, noon, and 5pm."

The ping uses a cheap model, and that's not economizing for its own sake. The five-hour window is shared across the account, but weekly limits are tracked per model family: an anchor on opus would spend the scarcest bucket for an effect haiku gives for free.

Turned on in /settings. If you were working recently and a window is already open, the ping is skipped — the daemon sees every agent event and knows this without polling anything.

On macOS, a 7am anchor won't fire if the laptop is asleep: doctor catches this and suggests pmset repeat wakeorpoweron.

Watchers

Hooks see the agent's own decisions, but not everything: the CLI's own local menus (model switch, folder trust) and text on screen (a rate-limit message) aren't covered by hooks at all — these events never produce a single hook call. Two independent watchers handle them, working over the screen rather than over Claude Code. Both go through the full Registry — they see bare iTerm2 tabs and tmux panes alike.

CLI menus (terminal.watch_menus, on by default, polled every 20 seconds — terminal.menu_poll_interval). Notices the "❯ 1. … / 2. …" pattern Claude Code uses to draw any choice, and sends it to Telegram as buttons — pressing one types the option's number straight into the pane.

Limit reset (answers.auto_resume_after_limit, on by default, polled once a minute — terminal.rate_limit_poll_interval). Notices a message like "resets 3pm (UTC)" / "resets Oct 9, 10am" and types the continuation itself once the time comes (with a minute of headroom).

Checking and autostart

agents_control doctor           # is everything in place
agents_control service install  # autostart (launchd / systemd)

doctor checks the environment the daemon will actually get, not the current one: an interactive shell can show a different Ruby and a different PATH than the process a service manager launches separately — for instance, if a Ruby version manager puts a broken shim on PATH ahead of the working interpreter.

That's why the service always starts via an absolute path to the interpreter, never through PATH.

Why a tab's title can't be trusted

An agent sets a tab's title via an OSC sequence, and the title stays up after it exits — a tab with an agent icon isn't necessarily still running anything. That's why an agent's presence is confirmed by the process tree, and the title is only ever used as a label.

Security

This tool lets you run commands on your machine from Telegram. That comes with some rules:

  • There's no shared service. Everyone sets up their own bot with @BotFather and connects it with their own token — the daemon talks to a bot only you own, not to any third-party infrastructure.
  • The daemon's port only listens on 127.0.0.1. Reaching it from outside the machine isn't just blocked by a password, it's physically impossible. The daemon talks to Telegram itself via long polling — outgoing connections only; no incoming port is opened for this either.
  • An allowed chat_id list is required. While it's empty, the bot answers nobody, even if someone learns its name. The bot's token isn't an access secret; the access secret is the chat_id filter.
  • A leaked bot token is equivalent to remote code execution.
  • The token is stored in the Keychain (macOS) or libsecret (Linux) — the same place as website and Wi-Fi passwords — and never lands in the config. There's deliberately no command-line argument for the token: it would leak into ps and shell history.
  • This protects against leaks through git, config files, ps, and shell history — not against malicious code already running as the same user: like any CLI tool without its own signed .app, the Keychain entry trusts the security utility itself, not agents_control specifically, so any process on the same account that knows the service name can read the token.
  • Auto-replying "continue" is on by default; automatic tool approval is off. These are separate settings on purpose.

Development

rake test

Tests run on minitest, with external commands stubbed via FakeExecutor — neither iTerm2 nor tmux is needed to run them. Fixtures live in test/fixtures.rb — recorded output from real commands.

License

Apache 2.0.