aiterm-mcp 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +141 -66
- package/README.md +140 -65
- package/dist/codex-stop-hook.js +124 -0
- package/dist/core.js +1197 -45
- package/dist/grok-stop-hook.js +120 -0
- package/dist/index.js +58 -10
- package/dist/rtk.js +53 -18
- package/package.json +5 -2
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src=".github/og.svg" alt="aiterm-mcp —
|
|
2
|
+
<img src=".github/og.svg" alt="aiterm-mcp — one persistent MCP terminal your AI drives, and launches other coding agents (Codex/Grok/Composer) into (tmux-backed stdio MCP server)" width="100%">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# aiterm-mcp
|
|
@@ -12,65 +12,109 @@
|
|
|
12
12
|
|
|
13
13
|
> *(日本語: [README.ja.md](README.ja.md))*
|
|
14
14
|
|
|
15
|
-
> **Let Claude — or any MCP client —
|
|
15
|
+
> **Let your AI orchestrate other AIs.** From Claude Code — or any MCP client — one call spawns a coding agent (Codex, Grok, or Composer) inside a persistent terminal and hands you a session to drive: read what it's doing token-reduced, send it the next instruction.
|
|
16
|
+
>
|
|
17
|
+
> **What it is:** one persistent MCP terminal your AI drives — and can launch other coding agents into. `ssh`, `docker exec`, a REPL, or another agent's TUI all nest inside that one terminal as just text you send in. The mechanism is deliberately plain — your MCP client drives the other agent's terminal turn by turn: no hidden protocol, no shared memory, no autonomous negotiation.
|
|
18
|
+
>
|
|
19
|
+
> **No human at a tmux required.** aiterm is driven programmatically over MCP, so an AI can launch and drive another agent with no one sitting in the terminal — from an orchestration loop, a CI step, or a cron job.
|
|
16
20
|
>
|
|
17
21
|
> *MCP = Model Context Protocol — the open standard that lets tools like Claude Code plug capabilities into an AI.*
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
Nine tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, plus three **agent launchers** — `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
|
|
24
|
+
|
|
25
|
+
**Status:** actively maintained · the newcomer here, betting on a different shape (see [vs. the alternatives](#vs-the-alternatives)) · runs on Linux · WSL2 · macOS · native Windows for the core PTY tools (`agent_done` is POSIX/WSL/macOS only for now) · MIT · see the [CHANGELOG](CHANGELOG.md).
|
|
26
|
+
|
|
27
|
+
## Why now
|
|
20
28
|
|
|
21
|
-
|
|
29
|
+
A lot of 2026's agent tooling is converging on orchestration: a lead model delegating a mechanical refactor to Codex, running Composer on a bulk edit while it reviews the diff, fanning one task across several agents to spare its own context window. All of those agents already live in a terminal. aiterm makes that terminal a first-class, MCP-native tool — so the model doing the orchestrating can **spawn and steer the others without a human wiring up panes.**
|
|
22
30
|
|
|
23
|
-
##
|
|
31
|
+
## Two ways to use it
|
|
24
32
|
|
|
25
|
-
|
|
33
|
+
### 1. Drive SSH, containers, and REPLs in one persistent terminal — the primitive
|
|
26
34
|
|
|
27
|
-
|
|
35
|
+
This is the base, and it works with just tmux — no other CLI. `pty_open` grabs one local terminal; `ssh host`, `docker exec -it x bash`, or a REPL are just text you `pty_send` into it — **once**. Every command after that rides the same already-authenticated session. Session kind is never a tool-level distinction.
|
|
28
36
|
|
|
29
37
|
```
|
|
30
38
|
pty_open() → grab one local terminal
|
|
31
39
|
pty_send(id, "ssh 192.168.1.2") → authenticate once, inside that terminal
|
|
32
40
|
pty_send(id, "uname -a") → every later command rides the SAME session
|
|
33
|
-
pty_read(id, { wait: true }) → read the reduced output
|
|
41
|
+
pty_read(id, { wait: true }) → read the token-reduced output, completion detected
|
|
34
42
|
```
|
|
35
43
|
|
|
44
|
+
<sub>**Origin.** I built aiterm for exactly this. Driving my homelab from Claude Code one command at a time meant every SSH command became its own `connect → authenticate → disconnect`: re-typing the passphrase and one-time code each time, short-lived sessions piling up, and eventually my own defenses (`fail2ban`, `MaxStartups`/`MaxSessions`, account lockout) locking me out — the security meant to stop attackers ended up stopping me. Holding one authenticated session fixes all three at once. That pain is why the persistent terminal exists; launching whole other agents inside it is what it grew into.</sub>
|
|
45
|
+
|
|
46
|
+
### 2. Launch other coding agents into that terminal — the orchestration flagship
|
|
47
|
+
|
|
48
|
+
The same primitive hosts another agent's TUI. Three launchers each start one vendor's interactive coding-agent TUI inside a fresh persistent terminal and return a `session_id`. From there you drive it with the same `pty_read` / `pty_send` you'd use on any shell: read its output token-reduced, send it the next step. (The TUIs are full-screen apps, so `pty_read({ screen: true })` gives you the rendered view.) Agent launchers can also opt into hook-backed turn completion with `agent_done: true`, letting `pty_send({ wait: "agent_done" })` return after the agent turn ends. Codex/Grok/Composer all have passing live smokes when their vendor CLI is authenticated. This needs the vendor's own CLI installed and authenticated — see [Requirements](#requirements).
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
codex_agent({ session_name: "codex1", cwd: "/repo", agent_done: true,
|
|
52
|
+
prompt: "port test/legacy.py to vitest" })
|
|
53
|
+
→ { session_id: "codex1", … } # Codex now live in a persistent terminal
|
|
54
|
+
pty_read("codex1", { screen: true }) → read what it's doing (token-reduced)
|
|
55
|
+
pty_send("codex1", "also fix the imports it broke", { wait: "agent_done" })
|
|
56
|
+
→ steer it, then return once Codex reaches its next turn boundary
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
One call per model, so the tool name itself tells you which model you get:
|
|
60
|
+
|
|
61
|
+
| Tool | Launches | Key args |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `codex_agent` | Codex CLI (OpenAI; the CLI's default model) | `prompt?`, `reasoning_effort?`, `cwd?`, `session_name?`, `agent_done?` |
|
|
64
|
+
| `grok_agent` | Grok Build, model `grok-build` (xAI) | `prompt?`, `reasoning_effort?` (`low`/`medium`/`high`/`xhigh`/`max`), `cwd?`, `session_name?`, `agent_done?` |
|
|
65
|
+
| `composer_agent` | Grok Build, model `grok-composer-2.5-fast` (xAI) | same as `grok_agent` |
|
|
66
|
+
|
|
67
|
+
The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are checked **before** a session exists: for `grok_agent` / `composer_agent` an out-of-range `reasoning_effort` is rejected up front (the value set is fixed — `low`/`medium`/`high`/`xhigh`/`max`); a missing CLI binary or a nonexistent `cwd` fails for all three. A rejected launch — bad `reasoning_effort`, an unresolvable binary, or a nonexistent `cwd` — leaves **zero leftover session** behind, and if the launch keystroke itself can't be delivered aiterm tears the session down. (aiterm does *not* then verify the vendor CLI actually started or authenticated — `openAgent` returns once the launch command is sent; read the session to confirm the agent came up.) (`codex_agent` forwards `reasoning_effort` to the Codex CLI as a config override — `-c model_reasoning_effort=…` — rather than validating it, since Codex's accepted values vary by version.) Pass an absolute path for `cwd` — `~` is not expanded. `agent_done` uses launch-local managed vendor homes and does not edit your normal hook files; Grok/Composer additionally isolate `GROK_HOME` and `HOME` to suppress compat hook/plugin contamination, while OAuth uses the normal Grok home's `auth.json` and `auth.json.lock` as a pair so refresh locking does not split across sessions. Before the first unbound `pty_send({ wait: "agent_done" })`, aiterm waits for the vendor TUI's input prompt and fails before sending if the prompt is not ready, avoiding dropped startup input. `agent_done` currently requires POSIX filesystem semantics (`getuid`, secure temp files, hard-link checks), so it is supported on Linux, WSL2, and macOS; native Windows can still use the core PTY tools and agent launchers without `agent_done`.
|
|
68
|
+
|
|
69
|
+
There is deliberately **no Claude launcher**, and no protocol between the agents: aiterm's job is to let your Claude (or any MCP client) reach for *other* agents and drive their terminals. Because a launched agent is just another persistent session, everything else in this README applies to it: token-reduced reads, completion detection, and a human `attach` to watch or take over.
|
|
70
|
+
|
|
36
71
|
## Demo
|
|
37
72
|
|
|
38
|
-
|
|
73
|
+
<p align="center">
|
|
74
|
+
<img src=".github/demo.gif" alt="aiterm-mcp demo: pty_open, a token-reduced grep read, then a nested Python REPL — all in one persistent session" width="100%">
|
|
75
|
+
</p>
|
|
39
76
|
|
|
40
|
-
|
|
77
|
+
Real captured output — each block below was just run through aiterm in this repo; the numbers, the elision marker, and every `is_complete` verdict are the tool's own, not mocked. The bracketed meta line is what `pty_read` appends; its labels are Japanese in the actual output, translated here for readability (the [Japanese README](README.ja.md) shows them verbatim).
|
|
78
|
+
|
|
79
|
+
A long output folded head+tail — the middle is elided by the reducer, not by me (166 → 56 tokens):
|
|
41
80
|
|
|
42
81
|
```text
|
|
43
|
-
→ pty_send("demo", "
|
|
82
|
+
→ pty_send("demo", "seq 1 150")
|
|
44
83
|
→ pty_read("demo", { wait: true })
|
|
45
|
-
←
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
84
|
+
← 1
|
|
85
|
+
2
|
|
86
|
+
3
|
|
87
|
+
⋮ (head runs to line 29 — abbreviated in this README)
|
|
88
|
+
… ⟨102 lines elided · full=true, or line_range="A:B"⟩ … ← the tool's own marker
|
|
89
|
+
⋮ (tail resumes at line 132 — abbreviated in this README)
|
|
90
|
+
149
|
|
91
|
+
150
|
|
92
|
+
[aiterm demo: 51 lines / ~56 tok (raw 152 lines / ~166 tok); 102 lines hidden] [is_complete=True via quiescent]
|
|
50
93
|
```
|
|
51
94
|
|
|
52
|
-
A `grep`, folded by the per-command reducer to
|
|
95
|
+
A `grep`, folded by the per-command reducer to a count header plus just the hits:
|
|
53
96
|
|
|
54
97
|
```text
|
|
55
98
|
→ pty_send("demo", "grep -rn capture-pane src/ test/")
|
|
56
99
|
→ pty_read("demo", { wait: true, rtk: true })
|
|
57
100
|
← 2 matches in 1 files:
|
|
58
|
-
|
|
59
|
-
src/core.ts:
|
|
60
|
-
|
|
101
|
+
|
|
102
|
+
src/core.ts:159:// maxBuffer defaults to 1 MiB; capture-pane (large scrollback) … (line truncated here)
|
|
103
|
+
src/core.ts:335:const args = ["capture-pane", "-p", "-J", "-t", name];
|
|
104
|
+
[aiterm demo: rtk:grep applied / ~46 tok (raw ~53 tok)] [is_complete=True via quiescent]
|
|
61
105
|
```
|
|
62
106
|
|
|
63
|
-
Nesting is just text you send in — here a Python REPL *inside* the same PTY:
|
|
107
|
+
Nesting is just text you send in — here a Python REPL *inside* the same PTY (an `ssh host`, a `docker exec -it … bash`, or a launched coding-agent TUI nests exactly the same way):
|
|
64
108
|
|
|
65
109
|
```text
|
|
66
110
|
→ pty_send("demo", "python3")
|
|
67
|
-
→ pty_read("demo", { until: ">>>
|
|
111
|
+
→ pty_read("demo", { until: ">>>" }) # nested prompt = "the inner shell is ready"
|
|
68
112
|
→ pty_send("demo", "print(sum(range(1_000_000)))")
|
|
69
|
-
→ pty_read("demo", { until: ">>>
|
|
70
|
-
← 499999500000
|
|
113
|
+
→ pty_read("demo", { wait: true, until: ">>>" })
|
|
114
|
+
← 499999500000 [is_complete=True via until]
|
|
71
115
|
```
|
|
72
116
|
|
|
73
|
-
|
|
117
|
+
The only edits to the captures above are the two `⋮` lines (a long head/tail run abbreviated for the README) and one over-long grep line truncated to fit — the `⟨…⟩` marker, the token counts, and every `is_complete` verdict are exactly what the tool printed. (Use `until: ">>>"` without a trailing space — the captured prompt is trimmed, so `">>> "` would miss and fall through to `timeout`.) While nested, pass `until` (the inner prompt) or `mark: true`, because quiescence cannot fire there by design — see [Completion detection](#completion-detection-5-layers) and [Known constraints](#known-constraints-by-design-not-bugs). A human can `attach` to the same tmux socket and watch any of this live (see [A human can watch](#a-human-can-watch)).
|
|
74
118
|
|
|
75
119
|
## Quickstart (≈60 seconds)
|
|
76
120
|
|
|
@@ -95,7 +139,7 @@ pty_read("t1", { wait: true }) → "hello" (token-reduced, completion det
|
|
|
95
139
|
pty_close("t1") → terminal released
|
|
96
140
|
```
|
|
97
141
|
|
|
98
|
-
That's it. The terminal in `t1` is real and persistent — `ssh`, `docker exec`, a REPL are just
|
|
142
|
+
That's it. The terminal in `t1` is real and persistent — `ssh`, `docker exec`, a REPL, or a launched agent's TUI are just things that live inside it. To launch a worker agent instead, one call does it: `codex_agent()` returns a `session_id` you drive with the same `pty_read` / `pty_send`.
|
|
99
143
|
|
|
100
144
|
**Prefer a global install, or a different client?**
|
|
101
145
|
|
|
@@ -105,64 +149,85 @@ npm i -g aiterm-mcp
|
|
|
105
149
|
claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
|
|
106
150
|
```
|
|
107
151
|
|
|
108
|
-
This registers it in `~/.claude.json`; you'll get an approval prompt the first time. **Any other MCP client** (Cursor, Cline, Claude Desktop, …)
|
|
152
|
+
This registers it in `~/.claude.json`; you'll get an approval prompt the first time. **Any other MCP client** (Cursor, Cline, Claude Desktop, …) should work too — just launch `npx -y aiterm-mcp` (or `aiterm-mcp`) over stdio. Needs **Node ≥ 18** and **tmux** — see [Requirements](#requirements).
|
|
153
|
+
|
|
154
|
+
## Headless: no human at the terminal
|
|
155
|
+
|
|
156
|
+
Because an MCP client drives aiterm programmatically over stdio, everything above can run with **nobody sitting at a tmux**. Your Claude Code session can `codex_agent()` a task, `pty_read` the result, and act on it — unattended. That makes aiterm a fit for exactly the places a human-driven terminal isn't:
|
|
157
|
+
|
|
158
|
+
- **Multi-agent orchestration** — an orchestrator hands sub-tasks to Codex / Grok / Composer, each in its own persistent session, and reads them all back.
|
|
159
|
+
- **CI** — a job step can spin up an agent, drive it, and tear it down.
|
|
160
|
+
- **cron** — a scheduled run can launch an agent and collect its output.
|
|
161
|
+
|
|
162
|
+
The terminal is real and shared, so a human *can* jump in ([A human can watch](#a-human-can-watch)) — but nothing requires one to.
|
|
109
163
|
|
|
110
164
|
## How it works
|
|
111
165
|
|
|
112
166
|
```mermaid
|
|
113
167
|
flowchart LR
|
|
114
|
-
AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 9 tools"]
|
|
168
|
+
AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · codex_agent<br/>grok_agent · composer_agent"| S["aiterm-mcp<br/>stdio MCP · 9 tools"]
|
|
115
169
|
S -->|"pty_read<br/>token-reduced"| AI
|
|
116
|
-
S -->|"tmux send-keys<br/>capture-pane"| P["
|
|
170
|
+
S -->|"tmux send-keys<br/>capture-pane"| P["persistent PTYs<br/>tmux · survive restarts"]
|
|
117
171
|
P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
|
|
172
|
+
P -->|"launches a fresh PTY per agent"| A["another coding-agent TUI<br/>Codex · Grok · Composer"]
|
|
118
173
|
```
|
|
119
174
|
|
|
120
|
-
One PTY is the only primitive. Everything else — SSH, containers, REPLs — is just
|
|
175
|
+
One PTY is the only primitive. Everything else — SSH, containers, REPLs, and the launched agent TUIs — is just something interactive running inside a persistent terminal, driven with the same `pty_send` / `pty_read`. Each launcher opens its own fresh PTY. Because the PTYs live in tmux, sessions outlive the MCP server and the AI client.
|
|
121
176
|
|
|
122
177
|
## vs. the alternatives
|
|
123
178
|
|
|
124
|
-
|
|
125
|
-
| --- | --- | --- | --- |
|
|
126
|
-
| Persistent session | ✅ tmux, survives restarts | ❌ new shell every call | ⚠️ varies |
|
|
127
|
-
| SSH / containers | nest with one `pty_send` | reconnect every command | ⚠️ often separate tools / per-call connect |
|
|
128
|
-
| Token-reduced reads | ✅ per-command reducers | ❌ raw output | ⚠️ rarely |
|
|
129
|
-
| Completion detection | 4-layer: exit / `until` / quiescence / timeout | n/a (blocks per call) | ⚠️ prompt-match, fragile |
|
|
130
|
-
| Human can co-drive | ✅ shared tmux socket (`attach`) | ❌ | ⚠️ varies |
|
|
179
|
+
aiterm sits at the intersection of two families: terminal-driving MCP servers, and the newer "agents talk to each other through a shared terminal" idea (see [Where aiterm fits](#where-aiterm-fits)). Here's how the axes line up — honestly, including where the others are strong.
|
|
131
180
|
|
|
132
|
-
|
|
181
|
+
| | **aiterm-mcp** | one-shot shell MCP<br/>(e.g. `mcp-server-commands`) | terminal / SSH / tmux MCPs<br/>(e.g. `iterm-mcp`, `ssh-mcp`, `tmux-mcp`) | shared-tmux agent-to-agent<br/>(e.g. `smux`) |
|
|
182
|
+
| --- | --- | --- | --- | --- |
|
|
183
|
+
| Persistent session | ✅ tmux, survives restarts | ❌ new shell every call | ⚠️ varies | ✅ tmux |
|
|
184
|
+
| SSH / containers / REPLs | nest with one `pty_send` | reconnect every command | ⚠️ often separate tools | ✅ tmux (human drives) |
|
|
185
|
+
| Launch another agent in one call | ✅ `codex_agent` / `grok_agent` / `composer_agent` | ❌ | ❌ | ⚠️ agents join a human-run tmux via a CLI + skills |
|
|
186
|
+
| Headless (no human at a tmux) | ✅ MCP-driven, programmatic | ✅ | ⚠️ varies | ❌ built around a human in the tmux |
|
|
187
|
+
| MCP-native (any MCP client) | ✅ one `claude mcp add` | ✅ | ✅ (they are MCPs) | ❌ tmux config + CLI + Agent Skills |
|
|
188
|
+
| Token-reduced reads | ✅ per-command reducers | ❌ raw output | ⚠️ rarely | ❌ raw tmux |
|
|
189
|
+
| Completion detection | 5-layer: exit / `mark` / `until` / quiescence / timeout | n/a (blocks per call) | ⚠️ prompt-match, fragile | ❌ agent reads the pane |
|
|
190
|
+
| Destructive-command gate | ✅ tripwire (override with `force`) | ❌ | ⚠️ varies | ❌ |
|
|
191
|
+
| Human can co-drive | ✅ shared tmux socket (`attach`) | ❌ | ⚠️ varies | ✅ (its core model) |
|
|
133
192
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
193
|
+
## Where aiterm fits
|
|
194
|
+
|
|
195
|
+
"AIs talking to each other through a shared terminal" is becoming its own category — and it's a genuinely good idea. The terminal is a universal interface every coding agent already speaks, so no bespoke agent-to-agent protocol is needed; the shell *is* the shared surface. `smux` (by @shawn_pana) popularized this framing as a one-command shared tmux environment a human sets up, that agents then join via a `tmux-bridge` CLI and Agent Skills. It's good at the in-the-loop, shared-pane workflow it's built for, and it has real traction.
|
|
196
|
+
|
|
197
|
+
aiterm takes the same core insight — the terminal as the meeting point — and makes three deliberate, different choices:
|
|
198
|
+
|
|
199
|
+
1. **Headless by construction.** Because aiterm is driven programmatically over MCP, an AI can launch and drive another agent with *no human sitting in the tmux* — from an orchestration loop, a CI step, or a cron job. The shared-tmux tools lead with a human at the keyboard (their docs center on interactive pane navigation), so unattended operation isn't their native mode; aiterm's is.
|
|
200
|
+
2. **MCP-native, not a workflow you adopt.** aiterm is a stdio MCP server: one `claude mcp add` line and it works as structured tools in any MCP client that speaks stdio (tested in Claude Code; Cursor, Cline, and Claude Desktop speak the same protocol and should work the same way). It doesn't ask you to adopt a tmux config, learn pane navigation, or install skills into your setup — the client already knows how to call tools.
|
|
201
|
+
3. **Launching an agent is one tool call — an orchestration primitive.** `codex_agent()` spawns Codex in a persistent terminal and returns a session you drive immediately. You don't arrange panes or paste between them by hand; the launch, the steering, and the reads are all tool calls the orchestrating model can make on its own.
|
|
202
|
+
|
|
203
|
+
On top of that sits a productized layer a raw tmux bridge doesn't have: **token-reduced reads**, **5-layer completion detection**, and a **destructive-command tripwire**. None of this makes the human-in-the-tmux model wrong — it's a different, complementary bet on where the human is standing.
|
|
139
204
|
|
|
140
205
|
## Tools
|
|
141
206
|
|
|
142
207
|
| Tool | Role | Key args |
|
|
143
208
|
| --- | --- | --- |
|
|
144
209
|
| `pty_open` | Grab one terminal, return a `session_id` | `name?`, `shell="bash"` |
|
|
145
|
-
| `pty_send` | Send text (a command) | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
|
|
146
|
-
| `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk` |
|
|
210
|
+
| `pty_send` | Send text (a command) | `session_id`, `text`, `enter=true`, `wait`, `timeout`, `screen`, `lines`, `mark`, `force`, `rtk`, `raw` |
|
|
211
|
+
| `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk` |
|
|
147
212
|
| `pty_key` | Send a control key | `session_id`, `key` (`C-c`/`Enter`/`Up`…) |
|
|
148
213
|
| `pty_close` | Close a session | `session_id` |
|
|
149
214
|
| `pty_list` | List sessions | (none) |
|
|
150
215
|
|
|
151
216
|
### Interactive agent launchers
|
|
152
217
|
|
|
153
|
-
Each launcher starts a specific vendor's interactive coding-agent TUI inside a fresh persistent PTY and returns its `session_id` — from there you drive it with plain `pty_read` / `pty_send`, exactly like any other session. One tool per model, so the tool name itself tells you which model you get.
|
|
218
|
+
Each launcher starts a specific vendor's interactive coding-agent TUI inside a fresh persistent PTY and returns its `session_id` — from there you drive it with plain `pty_read` / `pty_send`, exactly like any other session. One tool per model, so the tool name itself tells you which model you get. The TUI is a full-screen app, so read it with `pty_read({ screen: true })` for the rendered view.
|
|
154
219
|
|
|
155
220
|
| Tool | Launches | Key args |
|
|
156
221
|
| --- | --- | --- |
|
|
157
|
-
| `codex_agent` | Codex CLI (OpenAI; the CLI's default model) | `prompt?`, `reasoning_effort?`, `cwd?`, `session_name?` |
|
|
158
|
-
| `grok_agent` | Grok Build, model `grok-build` (xAI) | `prompt?`, `reasoning_effort?` (`low`/`medium`/`high`/`xhigh`/`max`), `cwd?`, `session_name?` |
|
|
222
|
+
| `codex_agent` | Codex CLI (OpenAI; the CLI's default model) | `prompt?`, `reasoning_effort?`, `cwd?`, `session_name?`, `agent_done?` |
|
|
223
|
+
| `grok_agent` | Grok Build, model `grok-build` (xAI) | `prompt?`, `reasoning_effort?` (`low`/`medium`/`high`/`xhigh`/`max`), `cwd?`, `session_name?`, `agent_done?` |
|
|
159
224
|
| `composer_agent` | Grok Build, model `grok-composer-2.5-fast` (xAI) | same as `grok_agent` |
|
|
160
225
|
|
|
161
|
-
The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`.
|
|
226
|
+
The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are validated before a session is created (grok/composer reject an out-of-range `reasoning_effort`; a missing CLI or a nonexistent `cwd` fails for all three), and a failed launch leaves no session behind. Full details under [Launch other coding agents into that terminal](#2-launch-other-coding-agents-into-that-terminal--the-orchestration-flagship). Pass an absolute path for `cwd` — `~` is not expanded. `agent_done` is hook-backed for agent launchers; Codex/Grok/Composer have passing live smokes on Linux/WSL2/macOS; native Windows can launch agents but `agent_done` is not supported yet. Before the first unbound agent send, `pty_send({ wait:"agent_done" })` waits for the vendor TUI input prompt and fails before sending if it is not ready. In Grok OAuth mode, aiterm keeps hook/config isolation per launch but shares both `auth.json` and `auth.json.lock` with the normal Grok home; missing OAuth auth fails without leaving a session behind.
|
|
162
227
|
|
|
163
|
-
### Completion detection (
|
|
228
|
+
### Completion detection (5 layers)
|
|
164
229
|
|
|
165
|
-
`pty_read({ wait: true })` decides "is the command done?" via
|
|
230
|
+
`pty_read({ wait: true })` decides "is the command done?" via five layers: process exit / a `mark:true` sentinel (auto-detected — see below) / an `until` match (a literal substring by default; pass `until_regex: true` for a regex) / output is quiescent ∧ the shell is back (quiescence) / timeout. While nested (inside SSH, a container, a REPL, or a launched agent's TUI), the "shell is back" check cannot fire, so pass `until` with the inner prompt — or send with `mark: true` and `pty_read({ wait: true })` auto-detects the completion sentinel (no `until` needed, works nested too) — or, for a full-screen agent TUI, read `{ screen: true }` once its output settles. Sessions launched with `agent_done:true` can instead use `pty_send({ wait:"agent_done" })`, which first waits for the agent TUI input prompt when needed, then waits for the vendor Stop hook and returns the screen after the turn boundary; pre-send readiness failures are MCP errors and timeouts after sending are reported as `is_complete=False via agent_timeout`, not as success. If a complete hook JSONL line is malformed, the timeout suffix includes `malformed_events=N` for diagnosis. If the turn is done but the terminal screen/log does not settle within the flush window, aiterm appends `agent_done_but_screen_unstable`.
|
|
166
231
|
|
|
167
232
|
### Token reduction
|
|
168
233
|
|
|
@@ -172,11 +237,31 @@ The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `
|
|
|
172
237
|
|
|
173
238
|
### Safety
|
|
174
239
|
|
|
175
|
-
Before sending, `pty_send` blocks destructive commands (`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE`, …) — pass `force: true` to override — and sanitizes ESC / bracketed-paste terminators. `pty_read` neutralizes control characters in what it returns.
|
|
240
|
+
Before sending, `pty_send` blocks destructive commands (`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE`, …) — pass `force: true` to override — and sanitizes ESC / bracketed-paste terminators. `pty_read` neutralizes control characters in what it returns by default (`raw: true` returns the bytes verbatim). This is a **tripwire, not a sandbox** (see [Known constraints](#known-constraints-by-design-not-bugs)).
|
|
176
241
|
|
|
177
242
|
## A human can watch
|
|
178
243
|
|
|
179
|
-
Sessions live on a shared tmux socket. The `tmux -S … attach -t <id>` line printed by `pty_open` lets a human attach to the same terminal and intervene (`Ctrl-b d` to detach). On native Windows the printed line is the WSL form — `wsl tmux -S … attach -t <id>` — since the session lives inside WSL.
|
|
244
|
+
Sessions live on a shared tmux socket. The `tmux -S … attach -t <id>` line printed by `pty_open` (and by each agent launcher) lets a human attach to the same terminal and intervene (`Ctrl-b d` to detach) — including watching a launched Codex/Grok/Composer session run and taking the keyboard from your AI mid-task. On native Windows the printed line is the WSL form — `wsl tmux -S … attach -t <id>` — since the session lives inside WSL.
|
|
245
|
+
|
|
246
|
+
## Requirements
|
|
247
|
+
|
|
248
|
+
- **Node.js >= 18**
|
|
249
|
+
- **tmux** (runtime prerequisite; check with `tmux -V`. Install with `apt install tmux` / `brew install tmux`)
|
|
250
|
+
- **macOS / Linux / WSL2** run tmux directly. On macOS install it with `brew install tmux` (stock macOS ships none). If your MCP client is launched from the **GUI** rather than a terminal, Homebrew's bin (`/opt/homebrew/bin` on Apple Silicon, `/usr/local/bin` on Intel) may be off its `PATH`; aiterm auto-searches those locations, or set **`AITERM_TMUX=/path/to/tmux`** to point at it explicitly.
|
|
251
|
+
- **Native Windows** has no tmux, so aiterm transparently runs tmux **inside WSL**. It needs [WSL](https://learn.microsoft.com/windows/wsl/) installed and initialized, with **tmux installed inside your WSL distro** (`sudo apt install tmux`); verify with `wsl tmux -V`. Sessions, the socket, and human `attach` all live on the WSL side — the AI just drives them from the Windows-side command. (You reach Windows tools the same way you reach SSH: `pty_send "powershell.exe …"` nests into PowerShell.)
|
|
252
|
+
- For the **agent launchers**: the corresponding vendor CLI, installed and authenticated — `codex` for `codex_agent`, `grok` for `grok_agent` / `composer_agent`. (Not needed if you only use the PTY tools.)
|
|
253
|
+
- Optional: the [`rtk`](https://github.com/rtk-ai/rtk) binary (used by `pty_send`'s `rtk: true` delegation; works fine without it)
|
|
254
|
+
|
|
255
|
+
## Known constraints (by design, not bugs)
|
|
256
|
+
|
|
257
|
+
- **While nested (ssh / docker / REPL / a launched agent TUI), quiescence cannot fire by design**, because the foreground command is no longer in the shell set (bash/sh/zsh/fish/dash). When nested with no `until` and no `mark`, `pty_read({ wait: true })` returns early as `is_complete=False via nested` (rather than burning the full `timeout`, since no signal can confirm completion there) with a note to pass `until` (a literal substring by default; `until_regex: true` for a regex) or `mark: true` (an exit-code sentinel, auto-detected) for a confirmed completion. For a full-screen agent TUI, read `{ screen: true }` once its output settles.
|
|
258
|
+
- **`is_complete=False` is not a failure.** It means "completion was not observed within `timeout`." For long commands, raise `timeout` or use `until`/`mark`.
|
|
259
|
+
- **The destructive gate is a tripwire, not a sandbox.** It blocks common destructive forms only. It does **not** catch relative-path `rm`, things that become dangerous after `$VAR` expansion, or commands run on the far side of an SSH session — and it does not police what a launched coding agent does inside its own session.
|
|
260
|
+
- **The agent launchers spawn a vendor TUI; they don't wrap or proxy it.** aiterm validates prerequisites and starts the CLI in a persistent PTY — the model, auth, and behavior are the vendor CLI's. There is no `claude` launcher and no protocol between agents; "conversation" is your MCP client driving the TUI (send input, read output).
|
|
261
|
+
- **`pty_send({ rtk: true })` is single-line only and needs the external `rtk` binary** (passthrough without it). The `pty_read({ rtk: true })` reducer, by contrast, is self-contained and rtk-independent.
|
|
262
|
+
- **The `pytest` reducer matches rtk 0.42.0** on test counts, the rule line, and `FAILURES`-block formatting (locked by regression tests). It **deliberately preserves the full failure reason** on the `FAILED` summary lines (emitted under `-ra`/`-rf`), whereas rtk 0.42.0 truncates the reason at the first `" - "` — a readability choice, so those lines are intentionally not byte-identical to rtk. The `[full output: …]` tee-pointer line rtk appends on large output is not reproduced on the read side.
|
|
263
|
+
- **tmux is started with `-f /dev/null`**, so it does not read `~/.tmux.conf` (to keep behavior reproducible across machines).
|
|
264
|
+
- **All sessions live on a single socket (`claude.sock` on POSIX).** `tmux … kill-server` removes them all.
|
|
180
265
|
|
|
181
266
|
## Development
|
|
182
267
|
|
|
@@ -187,17 +272,7 @@ npm test # build, then the node:test regression suite (requires tmux)
|
|
|
187
272
|
npm link # put `aiterm-mcp` on PATH locally
|
|
188
273
|
```
|
|
189
274
|
|
|
190
|
-
Logic lives in `src/core.ts` (tmux control, reduction, completion detection, safety) and `src/rtk.ts` (per-command reducers); `src/index.ts` is the MCP surface. The design origin and the reducer's porting source (the pytest reducer is ported to
|
|
191
|
-
|
|
192
|
-
## Known constraints (by design, not bugs)
|
|
193
|
-
|
|
194
|
-
- **While nested (ssh / docker / REPL), quiescence cannot fire by design**, because the foreground command is no longer in the shell set (bash/sh/zsh/fish/dash). When nested with no `until`, `pty_read({ wait: true })` returns early as `is_complete=False via nested` (rather than burning the full `timeout`, since no signal can confirm completion there) with a note to pass `until` (a regex for the prompt) or `mark: true` (an exit-code sentinel) for a confirmed completion.
|
|
195
|
-
- **`is_complete=False` is not a failure.** It means "completion was not observed within `timeout`." For long commands, raise `timeout` or use `until`/`mark`.
|
|
196
|
-
- **The destructive gate is a tripwire, not a sandbox.** It blocks common destructive forms only. It does **not** catch relative-path `rm`, things that become dangerous after `$VAR` expansion, or commands run on the far side of an SSH session.
|
|
197
|
-
- **`pty_send({ rtk: true })` is single-line only and needs the external `rtk` binary** (passthrough without it). The `pty_read({ rtk: true })` reducer, by contrast, is self-contained and rtk-independent.
|
|
198
|
-
- **The `pytest` reducer matches rtk 0.42.0** on test counts, the rule line, and `FAILURES`-block formatting (locked by regression tests). It **deliberately preserves the full failure reason** on the `FAILED` summary lines (emitted under `-ra`/`-rf`), whereas rtk 0.42.0 truncates the reason at the first `" - "` — a readability choice, so those lines are intentionally not byte-identical to rtk. The `[full output: …]` tee-pointer line rtk appends on large output is not reproduced on the read side.
|
|
199
|
-
- **tmux is started with `-f /dev/null`**, so it does not read `~/.tmux.conf` (to keep behavior reproducible across machines).
|
|
200
|
-
- **All sessions live on a single socket (`claude.sock`).** `tmux … kill-server` removes them all.
|
|
275
|
+
Logic lives in `src/core.ts` (tmux control, reduction, completion detection, safety, agent launch) and `src/rtk.ts` (per-command reducers); `src/index.ts` is the MCP surface. The design origin and the reducer's porting source (the pytest reducer is ported to match upstream rtk 0.42.0, except the deliberate `FAILED`-line difference noted above, and is locked by regression tests) are in `prototype/python/`.
|
|
201
276
|
|
|
202
277
|
## Try it
|
|
203
278
|
|
|
@@ -207,7 +282,7 @@ One command, no clone, no build:
|
|
|
207
282
|
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
208
283
|
```
|
|
209
284
|
|
|
210
|
-
If aiterm saved you a round-trip of tokens
|
|
285
|
+
If aiterm let your AI hand a task to another agent — or saved you a round-trip of tokens — **[star the repo](https://github.com/kitepon-rgb/aiterm-mcp)**. It's the cheapest way to help others find it.
|
|
211
286
|
|
|
212
287
|
- **npm:** https://www.npmjs.com/package/aiterm-mcp
|
|
213
288
|
- **Issues / bug reports:** https://github.com/kitepon-rgb/aiterm-mcp/issues
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
const LAUNCH_ID_RE = /^[0-9a-f]{32}$/;
|
|
6
|
+
const SESSION_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
7
|
+
const MAX_STDIN_BYTES = 1024 * 1024;
|
|
8
|
+
function fail(message) {
|
|
9
|
+
process.stderr.write(`aiterm codex-stop-hook: ${message}\n`);
|
|
10
|
+
process.stdout.write(JSON.stringify({ continue: false }) + "\n");
|
|
11
|
+
process.exit(0);
|
|
12
|
+
}
|
|
13
|
+
function noop() {
|
|
14
|
+
process.stdout.write(JSON.stringify({ continue: false }) + "\n");
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
function hasAitermEnv() {
|
|
18
|
+
return !!(process.env.AITERM_AGENT_KIND ||
|
|
19
|
+
process.env.AITERM_SESSION_ID ||
|
|
20
|
+
process.env.AITERM_AGENT_SESSION_ID ||
|
|
21
|
+
process.env.AITERM_AGENT_LAUNCH_ID);
|
|
22
|
+
}
|
|
23
|
+
function uid() {
|
|
24
|
+
if (typeof process.getuid !== "function")
|
|
25
|
+
fail("POSIX getuid が使えません");
|
|
26
|
+
return process.getuid();
|
|
27
|
+
}
|
|
28
|
+
function runtimeStateBase() {
|
|
29
|
+
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
30
|
+
if (xdg) {
|
|
31
|
+
try {
|
|
32
|
+
if (fs.statSync(xdg).isDirectory())
|
|
33
|
+
return xdg;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
/* XDG_RUNTIME_DIR が壊れている CI/非 login 環境では os.tmpdir() に戻す */
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return os.tmpdir();
|
|
40
|
+
}
|
|
41
|
+
function secureAgentsDir() {
|
|
42
|
+
const root = path.join(runtimeStateBase(), `aiterm-mcp-${uid()}`);
|
|
43
|
+
const agents = path.join(root, "agents");
|
|
44
|
+
const rst = fs.lstatSync(root);
|
|
45
|
+
if (!rst.isDirectory() || rst.isSymbolicLink() || rst.uid !== uid() || (rst.mode & 0o077) !== 0) {
|
|
46
|
+
fail(`agent state root が安全ではありません: ${root}`);
|
|
47
|
+
}
|
|
48
|
+
const ast = fs.lstatSync(agents);
|
|
49
|
+
if (!ast.isDirectory() || ast.isSymbolicLink() || ast.uid !== uid() || (ast.mode & 0o077) !== 0) {
|
|
50
|
+
fail(`agent state dir が安全ではありません: ${agents}`);
|
|
51
|
+
}
|
|
52
|
+
return agents;
|
|
53
|
+
}
|
|
54
|
+
function str(v) {
|
|
55
|
+
return typeof v === "string" ? v : null;
|
|
56
|
+
}
|
|
57
|
+
async function readStdin() {
|
|
58
|
+
const chunks = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
for await (const chunk of process.stdin) {
|
|
61
|
+
const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
62
|
+
total += b.length;
|
|
63
|
+
if (total > MAX_STDIN_BYTES)
|
|
64
|
+
fail("payload が大きすぎます");
|
|
65
|
+
chunks.push(b);
|
|
66
|
+
}
|
|
67
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
68
|
+
}
|
|
69
|
+
function appendEvent(file, event) {
|
|
70
|
+
const nofollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
71
|
+
const line = JSON.stringify(event) + "\n";
|
|
72
|
+
if (Buffer.byteLength(line, "utf8") > 64 * 1024)
|
|
73
|
+
fail("event line が大きすぎます");
|
|
74
|
+
const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_WRONLY | nofollow, 0o600);
|
|
75
|
+
try {
|
|
76
|
+
const st = fs.fstatSync(fd);
|
|
77
|
+
if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
|
|
78
|
+
fail(`event file が安全ではありません: ${file}`);
|
|
79
|
+
}
|
|
80
|
+
fs.writeSync(fd, line, undefined, "utf8");
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
fs.closeSync(fd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function main() {
|
|
87
|
+
if (!hasAitermEnv())
|
|
88
|
+
noop();
|
|
89
|
+
const kind = process.env.AITERM_AGENT_KIND;
|
|
90
|
+
const session = process.env.AITERM_SESSION_ID || process.env.AITERM_AGENT_SESSION_ID || "";
|
|
91
|
+
const launchId = process.env.AITERM_AGENT_LAUNCH_ID || "";
|
|
92
|
+
if (kind !== "codex")
|
|
93
|
+
fail(`AITERM_AGENT_KIND が codex ではありません: ${kind ?? ""}`);
|
|
94
|
+
if (!SESSION_RE.test(session))
|
|
95
|
+
fail(`session id が不正です: ${session}`);
|
|
96
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
97
|
+
fail(`launch id が不正です: ${launchId}`);
|
|
98
|
+
let payload = {};
|
|
99
|
+
const input = await readStdin();
|
|
100
|
+
if (input.trim()) {
|
|
101
|
+
try {
|
|
102
|
+
payload = JSON.parse(input);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
payload = {};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const agents = secureAgentsDir();
|
|
109
|
+
const eventFile = path.join(agents, `${session}.${launchId}.events.jsonl`);
|
|
110
|
+
appendEvent(eventFile, {
|
|
111
|
+
type: "agent_done",
|
|
112
|
+
vendor: "codex",
|
|
113
|
+
aiterm_session: session,
|
|
114
|
+
launch_id: launchId,
|
|
115
|
+
vendor_session_id: str(payload.session_id),
|
|
116
|
+
turn_id: str(payload.turn_id),
|
|
117
|
+
reason: str(payload.hook_event_name) ?? "Stop",
|
|
118
|
+
done_status: "turn_done",
|
|
119
|
+
stop_hook_active: !!payload.stop_hook_active,
|
|
120
|
+
at: new Date().toISOString(),
|
|
121
|
+
});
|
|
122
|
+
process.stdout.write(JSON.stringify({ continue: false }) + "\n");
|
|
123
|
+
}
|
|
124
|
+
main().catch((e) => fail(e instanceof Error ? e.message : String(e)));
|