aiterm-mcp 0.4.0 → 0.7.1

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 CHANGED
@@ -8,14 +8,69 @@
8
8
  [![npm](https://img.shields.io/npm/v/aiterm-mcp.svg)](https://www.npmjs.com/package/aiterm-mcp)
9
9
  [![node](https://img.shields.io/node/v/aiterm-mcp)](https://nodejs.org)
10
10
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
- [![npm downloads](https://img.shields.io/npm/dm/aiterm-mcp.svg)](https://www.npmjs.com/package/aiterm-mcp)
12
11
  [![install size](https://packagephobia.com/badge?p=aiterm-mcp)](https://packagephobia.com/result?p=aiterm-mcp)
13
12
 
14
13
  > *(English: [README.md](README.md))*
15
14
 
16
- > AI が握る**ローカル永続端末**を stdio MCP サーバとして公開する。1 個のローカル端末を握り、SSH もコンテナも「その端末に打つ 1 コマンド」に格下げする。読み取りはトークン削減つき。
15
+ > **Claude(や任意の MCP クライアント)に、本物の永続シェルを握らせる。** 端末は 1 個を開きっぱなしにし、`ssh`・`docker exec`・REPL は「その中へ送るただのテキスト」。だから AI はコマンドごとに接続し直さずに済む。読み取りはトークン削減つきで返る。
16
+ >
17
+ > *MCP = Model Context Protocol — Claude Code のようなツールが AI に機能を差し込むためのオープン標準。*
17
18
 
18
- `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` の 6 ツールだけ。バックエンドは tmux なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
19
+ `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` の PTY 6 ツールに加え、同じ永続端末の中にコーディングエージェントの TUI を起動する**対話エージェント起動 3 ツール**(`codex_agent` / `grok_agent` / `composer_agent`)。バックエンドは tmux なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
20
+
21
+ **状態:** 開発継続中 · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
22
+
23
+ ## なぜ
24
+
25
+ AI にコマンドを 1 個ずつ投げる方式だと、SSH では 1 コマンドごとに「接続→認証→切断」になる。痛みは 3 つ — **毎回再認証**(鍵のパスフレーズもワンタイムコードも毎回)、**短命なセッションが次々増殖**、そして接続が立て込むと**自分の防御に締め出される**(`fail2ban` で BAN、`MaxStartups`/`MaxSessions` で拒否、アカウントロック)。攻撃者を止めるための仕組みに、自分が締め出される。(自宅サーバで実際にやられた。この再認証地獄なしに自宅環境を Claude Code から操作したくて aiterm を作った。)
26
+
27
+ aiterm は **1 個の PTY を永続的に握り**、その中へ一度だけ `ssh host`(や `docker exec -it x bash`)で入る。以降のコマンドは同じ認証済みセッションを通る — **認証は 1 回、セッションは 1 本、防御に引っかかる隙が無い**。セッション種別をツールで区別しない。
28
+
29
+ ```
30
+ pty_open() → ローカル端末を 1 個握る
31
+ pty_send(id, "ssh 192.168.1.2") → その端末の中で一度だけ認証して入る
32
+ pty_send(id, "uname -a") → 以降のコマンドは同じセッションを通る
33
+ pty_read(id, { wait: true }) → 削減済みの出力を読む
34
+ ```
35
+
36
+ ## デモ
37
+
38
+ 実際のライブセッションから採取した本物の出力 — トークン削減も完了検出も実物で、モックではない。角括弧のメタ行は `pty_read` が実際に付けるもの。
39
+
40
+ ノイズの多い `git log` を、トークン削減して読む(458 → 273 トークン):
41
+
42
+ ```text
43
+ → pty_send("demo", "git log --oneline -12")
44
+ → pty_read("demo", { wait: true })
45
+ ← 3ce487e (HEAD -> main, origin/main) docs(readme): lead the Why with the SSH pain …
46
+ 39a9668 (tag: v0.4.0) release: v0.4.0 — nested completion early-return …
47
+ c1ed87b feat(completion): early-return nested status when nested + no until …
48
+ … 残り 9 コミット …
49
+ [aiterm demo: 13 行 / ~273 tok (raw 13 行 / ~458 tok)] [is_complete=True via quiescent]
50
+ ```
51
+
52
+ `grep` を、コマンド別 reducer でヒット行だけに畳む(127 → 46 トークン):
53
+
54
+ ```text
55
+ → pty_send("demo", "grep -rn capture-pane src/ test/")
56
+ → pty_read("demo", { wait: true, rtk: true })
57
+ ← 2 matches in 1 files:
58
+ src/core.ts:159: // maxBuffer … capture-pane(大きなスクロールバック)…
59
+ src/core.ts:329: const args = ["capture-pane", "-p", "-J", "-t", name];
60
+ [aiterm demo: rtk:grep 適用 / ~46 tok (raw ~127 tok)] [is_complete=True via quiescent]
61
+ ```
62
+
63
+ ネストは「中へ送るただのテキスト」 — 同じ PTY の*中で* Python REPL に入る:
64
+
65
+ ```text
66
+ → pty_send("demo", "python3")
67
+ → pty_read("demo", { until: ">>> " }) # 入れ子側のプロンプト = 「内側シェルが応答できる」
68
+ → pty_send("demo", "print(sum(range(1_000_000)))")
69
+ → pty_read("demo", { until: ">>> " })
70
+ ← 499999500000 [is_complete=True via until]
71
+ ```
72
+
73
+ `ssh host` や `docker exec -it … bash` もまったく同じ要領でネストする([なぜ](#なぜ))。SSH 全行程の動く GIF は準備中だが、上はすべて本物の出力で、台本ではない。ネスト中は `until`(内側プロンプト)か `mark: true` を渡すこと — そこでは quiescence が原理的に効かないため([完了検出](#完了検出4-層) / [既知の制約](#既知の制約バグではなく仕様))。同じ tmux ソケットに人が `attach` すれば、これらをライブで覗ける([人が覗く](#人が覗く))。
19
74
 
20
75
  ## クイックスタート(約60秒)
21
76
 
@@ -28,7 +83,7 @@ claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
28
83
  Claude Code を再起動して、接続を確認:
29
84
 
30
85
  ```bash
31
- /mcp # aiterm が connected・6 ツール公開、と出る
86
+ /mcp # aiterm が connected・9 ツール公開、と出る
32
87
  ```
33
88
 
34
89
  最初のセッション — 4 回の呼び出しで、1 個の永続端末:
@@ -40,76 +95,23 @@ pty_read("t1", { wait: true }) → "hello" (トークン削減・完了
40
95
  pty_close("t1") → 端末を解放
41
96
  ```
42
97
 
43
- これだけ。`t1` の端末は本物で永続 — `ssh`・`docker exec`・REPL は、そこへ `pty_send` で打ち込む“ただのテキスト”([なぜ](#なぜ))。インストールも不要にしたいなら、どの MCP クライアントからでも stdio で `npx -y aiterm-mcp` を起動するだけ。
98
+ これだけ。`t1` の端末は本物で永続 — `ssh`・`docker exec`・REPL は、そこへ `pty_send` で打ち込む“ただのテキスト”。
44
99
 
45
- ## インストール
46
-
47
- npm に公開済み。clone もビルドも不要:
100
+ **グローバル導入や別クライアントが良い場合は:**
48
101
 
49
102
  ```bash
50
- # Claude Code — 推奨(インストール不要、npx が毎回取得して起動)
51
- claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
52
-
53
- # またはグローバル導入してコマンド名で登録
103
+ # グローバル導入してコマンド名で登録
54
104
  npm i -g aiterm-mcp
55
105
  claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
56
106
  ```
57
107
 
58
- **Node 18** **tmux** が必要(Windows ネイティブでは WSL とその中の tmux。下の「要件」参照)。他の MCP クライアントは stdio で `npx -y aiterm-mcp` を起動するだけ(詳細は下の「インストール / 登録」)。
59
-
60
- ## なぜ
61
-
62
- AI にコマンドを 1 個ずつ投げて結果を受け取る往復は、SSH では毎回「接続→認証→切断」を繰り返し遅く、トークンも食う。aiterm は **1 個の PTY を永続的に握り**、その中で `ssh host` や `docker exec -it x bash` と打って入る(ネスト)。セッション種別をツールで区別しない。
63
-
64
- ```
65
- pty_open() → ローカル端末を 1 個握る
66
- pty_send(id, "ssh 192.168.1.2") → その端末の中で SSH に入る
67
- pty_send(id, "uname -a") → リモートで実行
68
- pty_read(id, { wait: true }) → 削減済みの出力を読む
69
- ```
70
-
71
- ## デモ
72
-
73
- <!-- demo gif: drop docs/demo.gif here (asciinema cast or animated GIF of the flow below) -->
74
-
75
- 決め手の流れ: PTY を 1 個開き、その**中で** SSH にネストし、リモートでコマンドを実行し、**すでにトークン削減された**出力を読む — コマンドごとの接続し直しは無い。
76
-
77
- ```text
78
- # 1 — ローカル端末を 1 個握る(tmux 上・再起動を跨ぐ)
79
- → pty_open()
80
- ← { session_id: "t1", attach: "tmux -S /…/claude.sock attach -t t1" }
81
-
82
- # 2 — その同じ端末の中で SSH にネスト(別ツールではない)
83
- → pty_send("t1", "ssh 192.168.1.2")
84
- ← sent
85
- → pty_read("t1", { until: "\\$ $" }) # リモートのプロンプト = 「シェルに戻った」
86
- ← user@remote:~$
87
-
88
- # 3 — 同じ PTY のまま、リモートでコマンド実行(接続し直し無し)
89
- → pty_send("t1", "uname -a")
90
- → pty_read("t1", { until: "\\$ $" })
91
- ← Linux remote 6.1.0 #1 SMP x86_64 GNU/Linux
92
-
93
- # 4 — ノイズの多いコマンドを、コマンド別 reducer で読む
94
- → pty_send("t1", "git status")
95
- → pty_read("t1", { until: "\\$ $", rtk: true }) # 自前実装・rtk バイナリ不要
96
- ← ## main…origin/main [ahead 1]
97
- M src/core.ts
98
- ?? notes.txt
99
- [reduced: 制御文字除去 · 重複圧縮 · git-status reducer]
100
- ```
101
-
102
- この流れが「デモのための嘘」にならない理由:
103
-
104
- - ステップ 2/3 が **`until`**(リモートのプロンプト)を使うのは、**ネスト中は quiescence が原理的に効かない**ため([完了検出](#完了検出4-層) / [既知の制約](#既知の制約バグではなく仕様))。ローカルシェルなら `{ wait: true }` だけで足りるが、ネスト中は `until`(または `mark: true`)が要る。
105
- - 角括弧の `[reduced: …]` 行は `pty_read` が付けるメタ/復元ヒントの例示で、実際の文言は出力に応じて変わる。reducer は **自前実装**の `pty_read({ rtk: true })` 経路で、外部 `rtk` バイナリは不要。
106
- - `t1` のソケットに人が `attach` すれば、同じ SSH セッションをライブで覗ける([人が覗く](#人が覗く))。
108
+ `~/.claude.json` に登録され、初回に承認プロンプトが出る。**他の MCP クライアント**(Cursor / Cline / Claude Desktop …)でも、stdio で `npx -y aiterm-mcp`(または `aiterm-mcp`)を起動するだけ。**Node 18** と **tmux** が必要 — [要件](#要件)参照。
107
109
 
108
110
  ## 仕組み
109
111
 
110
112
  ```mermaid
111
113
  flowchart LR
112
- AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 6 tools"]
114
+ AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 9 tools"]
113
115
  S -->|"pty_read<br/>token-reduced"| AI
114
116
  S -->|"tmux send-keys<br/>capture-pane"| P["one local PTY<br/>tmux · persistent"]
115
117
  P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
@@ -119,10 +121,10 @@ flowchart LR
119
121
 
120
122
  ## 既存手段との比較
121
123
 
122
- | | **aiterm-mcp** | 1 コマンド毎の往復 | 一般的な terminal / tmux MCP |
124
+ | | **aiterm-mcp** | 1 コマンド毎の往復<br/>(例: `mcp-server-commands`) | terminal / SSH / tmux MCP<br/>(例: `iterm-mcp`, `ssh-mcp`, `tmux-mcp`) |
123
125
  | --- | --- | --- | --- |
124
126
  | 永続セッション | ✅ tmux・再起動を跨ぐ | ❌ 毎回新シェル | ⚠️ まちまち |
125
- | SSH / コンテナ | `pty_send` 1 回でネスト | 毎コマンド接続し直し | ⚠️ ツールが分かれがち |
127
+ | SSH / コンテナ | `pty_send` 1 回でネスト | 毎コマンド接続し直し | ⚠️ ツールが分かれる/毎回接続しがち |
126
128
  | トークン削減読取 | ✅ コマンド別 reducer | ❌ 生出力 | ⚠️ ほぼ無し |
127
129
  | 完了検出 | 4 層: 終了 / `until` / 静止 / timeout | 無し(毎回ブロック) | ⚠️ プロンプト一致・脆い |
128
130
  | 人が同時操作 | ✅ 共有 tmux ソケット | ❌ | ⚠️ まちまち |
@@ -135,23 +137,6 @@ flowchart LR
135
137
  - **Windows ネイティブ**には tmux が無いため、aiterm は裏で **WSL の中の tmux** を透過的に使う。[WSL](https://learn.microsoft.com/ja-jp/windows/wsl/) を導入・初期化し、**WSL のディストリ内に tmux を入れる**こと(`sudo apt install tmux`)。`wsl tmux -V` で確認できる。セッション・ソケット・人の `attach` はすべて WSL 側にあり、AI は Windows 側のコマンドから操作するだけ。(Windows のツールは SSH と同じく入れ子で握る: `pty_send "powershell.exe …"` で PowerShell に入る。)
136
138
  - 任意: [`rtk`](https://github.com/rtk-ai/rtk) バイナリ(`pty_send` の `rtk: true` 委譲で使う。無くても動く)
137
139
 
138
- ## インストール / 登録
139
-
140
- Claude Code(CLI)にユーザースコープ(全プロジェクトで利用可)で登録する例:
141
-
142
- ```bash
143
- # 推奨: インストール不要、npx が毎回取得して起動
144
- claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
145
-
146
- # またはグローバルインストールしてコマンド名で
147
- npm i -g aiterm-mcp
148
- claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
149
- ```
150
-
151
- `~/.claude.json` に登録される。Claude Code を再起動すると、初回に承認プロンプトが出る。`/mcp` で接続状態を確認できる。
152
-
153
- 他の MCP クライアントでも、stdio で `npx -y aiterm-mcp`(または `aiterm-mcp`)を起動するだけ。
154
-
155
140
  ## ツール
156
141
 
157
142
  | ツール | 役割 | 主な引数 |
@@ -163,6 +148,18 @@ claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
163
148
  | `pty_close` | セッションを閉じる | `session_id` |
164
149
  | `pty_list` | セッション一覧 | (なし) |
165
150
 
151
+ ### 対話エージェント起動ツール
152
+
153
+ 各ツールは特定ベンダーの対話型コーディングエージェント TUI を新しい永続 PTY の中に起動し、`session_id` を返す。以後は他のセッションと同様に `pty_read` / `pty_send` で操作する。モデルごとに 1 ツール=ツール名を見ればどのモデルか分かる。
154
+
155
+ | ツール | 起動するもの | 主な引数 |
156
+ | --- | --- | --- |
157
+ | `codex_agent` | Codex CLI(OpenAI・モデルは CLI の既定) | `prompt?`, `reasoning_effort?`, `cwd?`, `session_name?` |
158
+ | `grok_agent` | Grok Build の `grok-build` モデル(xAI) | `prompt?`, `reasoning_effort?`(`low`/`medium`/`high`/`xhigh`/`max`), `cwd?`, `session_name?` |
159
+ | `composer_agent` | Grok Build の `grok-composer-2.5-fast` モデル(xAI) | `grok_agent` と同じ |
160
+
161
+ 各ベンダーの CLI が導入・認証済みであること(`codex_agent` は `codex`、Grok 系 2 つは `grok`)。バイナリは `CODEX_BIN` / `GROK_BIN` → `~/.local/bin/codex` / `~/.grok/bin/grok` → `PATH` の順で解決する。前提はすべて**セッション作成前に検証**する——不正な `reasoning_effort`・CLI 不在・実在しない `cwd` は明示エラーになり、セッションの残骸を残さない。
162
+
166
163
  ### 完了検出(4 層)
167
164
 
168
165
  `pty_read({ wait: true })` は、プロセス終了 / `until` 正規表現一致 / 出力静止 ∧ シェル復帰(quiescence)/ timeout の 4 層で「コマンドが終わったか」を判定する。ネスト中(SSH 先)はシェル復帰判定が効かないので `until` でプロンプトを指定すると綺麗に判定できる。
@@ -177,16 +174,6 @@ claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
177
174
 
178
175
  `pty_send` は送信前に破壊的コマンド(`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE` 等)を遮断し(`force: true` で越える)、ESC・ブラケットペースト終端などをサニタイズする。`pty_read` は制御文字を無害化して返す。
179
176
 
180
- ## 既知の制約(バグではなく仕様)
181
-
182
- - **ネスト中(ssh / docker / REPL)は quiescence が原理的に効かない。** 前面コマンドがシェル集合(bash/sh/zsh/fish/dash)の外になるため。ネスト中で `until` 未指定のときは、待っても完了を確定できる信号が無いので、`pty_read({ wait: true })` はフル `timeout` を空費せず出力静止時点で `is_complete=False via nested` と早期に返し、`until`(プロンプト等の正規表現)か `mark: true`(終了コード付き sentinel)の指定を促す。
183
- - **`is_complete=False` は失敗ではない。** 「timeout 内に完了を観測できなかった」という意味。長時間コマンドでは `timeout` を伸ばすか `until`/`mark` を使う。
184
- - **破壊ゲートはサンドボックスではなく tripwire。** よくある破壊形だけを弾く。相対パスの `rm`、`$VAR` 展開後に危険化するもの、ssh 先で実行されるコマンドは捕捉しない。
185
- - **`pty_send({ rtk: true })` は単行コマンドのみ+外部 `rtk` バイナリが必要**(無ければ素通し)。一方 `pty_read({ rtk: true })` の reducer は自前実装で rtk 非依存。
186
- - **`pytest` reducer は件数・罫線・`FAILURES` ブロック整形が rtk 0.42.0 と byte 一致**(回帰テストで固定)。ただし `-ra`/`-rf` 時の `FAILED` 要約行の理由は**全文を保持する**(rtk 0.42.0 は最初の `" - "` 区切りで切るが、本実装は可読性優先で情報を残すため、この行は意図的に rtk と完全一致させない)。rtk が大出力時に付ける `[full output: …]`(tee ポインタ)行は read 側では再現しない。
187
- - **tmux は `-f /dev/null` 起動**なので `~/.tmux.conf` を読まない(環境差を排除するため)。
188
- - **全セッションが単一 socket(`claude.sock`)上にある。** `tmux … kill-server` は全セッションを消す。
189
-
190
177
  ## 人が覗く
191
178
 
192
179
  セッションは共有 tmux ソケット上にある。`pty_open` の戻り値に表示される `tmux -S … attach -t <id>` で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)。Windows ネイティブではセッションが WSL 内にあるため、表示は WSL 形(`wsl tmux -S … attach -t <id>`)になる。
@@ -202,6 +189,16 @@ npm link # ローカルで `aiterm-mcp` を PATH に
202
189
 
203
190
  ロジックは `src/core.ts`(tmux 制御・削減・完了検出・安全)と `src/rtk.ts`(コマンド別 reducer)、公開は `src/index.ts`。設計の出発点と reducer の移植元(pytest reducer は本家 rtk 0.42.0 と出力が byte 一致するよう移植・回帰テストで固定)は `prototype/python/` を参照。
204
191
 
192
+ ## 既知の制約(バグではなく仕様)
193
+
194
+ - **ネスト中(ssh / docker / REPL)は quiescence が原理的に効かない。** 前面コマンドがシェル集合(bash/sh/zsh/fish/dash)の外になるため。ネスト中で `until` 未指定のときは、待っても完了を確定できる信号が無いので、`pty_read({ wait: true })` はフル `timeout` を空費せず出力静止時点で `is_complete=False via nested` と早期に返し、`until`(プロンプト等の正規表現)か `mark: true`(終了コード付き sentinel)の指定を促す。
195
+ - **`is_complete=False` は失敗ではない。** 「timeout 内に完了を観測できなかった」という意味。長時間コマンドでは `timeout` を伸ばすか `until`/`mark` を使う。
196
+ - **破壊ゲートはサンドボックスではなく tripwire。** よくある破壊形だけを弾く。相対パスの `rm`、`$VAR` 展開後に危険化するもの、ssh 先で実行されるコマンドは捕捉しない。
197
+ - **`pty_send({ rtk: true })` は単行コマンドのみ+外部 `rtk` バイナリが必要**(無ければ素通し)。一方 `pty_read({ rtk: true })` の reducer は自前実装で rtk 非依存。
198
+ - **`pytest` reducer は件数・罫線・`FAILURES` ブロック整形が rtk 0.42.0 と byte 一致**(回帰テストで固定)。ただし `-ra`/`-rf` 時の `FAILED` 要約行の理由は**全文を保持する**(rtk 0.42.0 は最初の `" - "` 区切りで切るが、本実装は可読性優先で情報を残すため、この行は意図的に rtk と完全一致させない)。rtk が大出力時に付ける `[full output: …]`(tee ポインタ)行は read 側では再現しない。
199
+ - **tmux は `-f /dev/null` 起動**なので `~/.tmux.conf` を読まない(環境差を排除するため)。
200
+ - **全セッションが単一 socket(`claude.sock`)上にある。** `tmux … kill-server` は全セッションを消す。
201
+
205
202
  ## 試す
206
203
 
207
204
  1 コマンド、clone もビルドも不要:
package/README.md CHANGED
@@ -8,14 +8,69 @@
8
8
  [![npm](https://img.shields.io/npm/v/aiterm-mcp.svg)](https://www.npmjs.com/package/aiterm-mcp)
9
9
  [![node](https://img.shields.io/node/v/aiterm-mcp)](https://nodejs.org)
10
10
  [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
- [![npm downloads](https://img.shields.io/npm/dm/aiterm-mcp.svg)](https://www.npmjs.com/package/aiterm-mcp)
12
11
  [![install size](https://packagephobia.com/badge?p=aiterm-mcp)](https://packagephobia.com/result?p=aiterm-mcp)
13
12
 
14
13
  > *(日本語: [README.ja.md](README.ja.md))*
15
14
 
16
- > Give an AI a **persistent local terminal** as a stdio MCP server. It holds **one** local PTY; SSH and containers are demoted to "a command you send into that terminal." Reads are token-reduced.
15
+ > **Let Claude or any MCP client drive a real, persistent shell.** One terminal stays open; `ssh`, `docker exec`, a REPL are just text you send into it, so the AI stops reconnecting for every single command. Reads come back token-reduced.
16
+ >
17
+ > *MCP = Model Context Protocol — the open standard that lets tools like Claude Code plug capabilities into an AI.*
17
18
 
18
- Just six tools — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list`. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
19
+ Six PTY tools — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — plus three interactive **agent launchers** (`codex_agent` / `grok_agent` / `composer_agent`) that start a coding-agent TUI inside that same persistent terminal. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
20
+
21
+ **Status:** actively maintained · runs on Linux · WSL2 · macOS · native Windows · MIT · see the [CHANGELOG](CHANGELOG.md).
22
+
23
+ ## Why
24
+
25
+ Send an AI **one command at a time** and, over SSH, every command becomes its own `connect → authenticate → disconnect`. That stings three ways: you **re-authenticate every time** (passphrase, one-time code, all of it), **short-lived sessions pile up**, and once connections come too fast your own defenses lock you out — `fail2ban` bans you, `MaxStartups`/`MaxSessions` reject you, the account gets locked. The security meant to stop attackers ends up stopping you. (Yes — this bit me on my own box. I built aiterm to drive my homelab from Claude Code without that re-auth hell.)
26
+
27
+ aiterm **holds one PTY persistently** and you `ssh host` (or `docker exec -it x bash`) *inside it*, **once**. Every command after that rides the same already-authenticated session: **authenticate once, one session, nothing for the defenses to trip on.** Session kind is never a tool-level distinction.
28
+
29
+ ```
30
+ pty_open() → grab one local terminal
31
+ pty_send(id, "ssh 192.168.1.2") → authenticate once, inside that terminal
32
+ pty_send(id, "uname -a") → every later command rides the SAME session
33
+ pty_read(id, { wait: true }) → read the reduced output
34
+ ```
35
+
36
+ ## Demo
37
+
38
+ Real captured output from a live session — the token reduction and completion detection are genuine, not mocked. The bracketed meta line is exactly what `pty_read` appends.
39
+
40
+ A noisy `git log`, read back token-reduced (458 → 273 tokens):
41
+
42
+ ```text
43
+ → pty_send("demo", "git log --oneline -12")
44
+ → pty_read("demo", { wait: true })
45
+ ← 3ce487e (HEAD -> main, origin/main) docs(readme): lead the Why with the SSH pain …
46
+ 39a9668 (tag: v0.4.0) release: v0.4.0 — nested completion early-return …
47
+ c1ed87b feat(completion): early-return nested status when nested + no until …
48
+ … 9 more commits …
49
+ [aiterm demo: 13 lines / ~273 tok (raw 13 lines / ~458 tok)] [is_complete=True via quiescent]
50
+ ```
51
+
52
+ A `grep`, folded by the per-command reducer to just the hits (127 → 46 tokens):
53
+
54
+ ```text
55
+ → pty_send("demo", "grep -rn capture-pane src/ test/")
56
+ → pty_read("demo", { wait: true, rtk: true })
57
+ ← 2 matches in 1 files:
58
+ src/core.ts:159: // maxBuffer … capture-pane (large scrollback) …
59
+ src/core.ts:329: const args = ["capture-pane", "-p", "-J", "-t", name];
60
+ [aiterm demo: rtk:grep applied / ~46 tok (raw ~127 tok)] [is_complete=True via quiescent]
61
+ ```
62
+
63
+ Nesting is just text you send in — here a Python REPL *inside* the same PTY:
64
+
65
+ ```text
66
+ → pty_send("demo", "python3")
67
+ → pty_read("demo", { until: ">>> " }) # nested prompt = "the inner shell is ready"
68
+ → pty_send("demo", "print(sum(range(1_000_000)))")
69
+ → pty_read("demo", { until: ">>> " })
70
+ ← 499999500000 [is_complete=True via until]
71
+ ```
72
+
73
+ `ssh host` and `docker exec -it … bash` nest exactly the same way (see [Why](#why)) — an animated GIF of the full SSH flow is on the way; everything above is real output, not a script. While nested, pass `until` (the inner prompt) or `mark: true`, because quiescence cannot fire there by design — see [Completion detection](#completion-detection-4-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)).
19
74
 
20
75
  ## Quickstart (≈60 seconds)
21
76
 
@@ -28,7 +83,7 @@ claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
28
83
  Restart Claude Code, then verify the connection:
29
84
 
30
85
  ```bash
31
- /mcp # aiterm should show as connected, exposing 6 tools
86
+ /mcp # aiterm should show as connected, exposing 9 tools
32
87
  ```
33
88
 
34
89
  Your first session — four calls, one persistent terminal:
@@ -40,76 +95,23 @@ pty_read("t1", { wait: true }) → "hello" (token-reduced, completion det
40
95
  pty_close("t1") → terminal released
41
96
  ```
42
97
 
43
- That's it. The terminal in `t1` is real and persistent — `ssh`, `docker exec`, a REPL are just text you `pty_send` into it (see [Why](#why)). Prefer no install? Any MCP client can launch `npx -y aiterm-mcp` over stdio directly.
98
+ That's it. The terminal in `t1` is real and persistent — `ssh`, `docker exec`, a REPL are just text you `pty_send` into it.
44
99
 
45
- ## Install
46
-
47
- It's on npm — no clone, no build:
100
+ **Prefer a global install, or a different client?**
48
101
 
49
102
  ```bash
50
- # Claude Code recommended (no install; npx fetches it each run)
51
- claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
52
-
53
- # or install globally, then register the command name
103
+ # install globally, then register the command name
54
104
  npm i -g aiterm-mcp
55
105
  claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
56
106
  ```
57
107
 
58
- Needs **Node 18** and **tmux** (on native Windows: WSL with tmux inside it see [Requirements](#requirements)). Other MCP clients: just run `npx -y aiterm-mcp` over stdio. More detail in [Install / register](#install--register) below.
59
-
60
- ## Why
61
-
62
- Sending an AI one command at a time and reading back the result means, over SSH, repeating connect → authenticate → disconnect every round — slow, and it burns tokens. aiterm **holds one PTY persistently** and you type `ssh host` or `docker exec -it x bash` *inside it* (nesting). Session kind is never a tool-level distinction.
63
-
64
- ```
65
- pty_open() → grab one local terminal
66
- pty_send(id, "ssh 192.168.1.2") → enter SSH inside that terminal
67
- pty_send(id, "uname -a") → run it on the remote
68
- pty_read(id, { wait: true }) → read the reduced output
69
- ```
70
-
71
- ## Demo
72
-
73
- <!-- demo gif: drop docs/demo.gif here (asciinema cast or animated GIF of the flow below) -->
74
-
75
- The killer flow: open one PTY, nest into SSH *inside it*, run a command on the remote, and read back output that's already been token-reduced — no reconnect per command.
76
-
77
- ```text
78
- # 1 — grab one local terminal (lives in tmux; survives restarts)
79
- → pty_open()
80
- ← { session_id: "t1", attach: "tmux -S /…/claude.sock attach -t t1" }
81
-
82
- # 2 — nest SSH *inside* that same terminal (not a separate tool)
83
- → pty_send("t1", "ssh 192.168.1.2")
84
- ← sent
85
- → pty_read("t1", { until: "\\$ $" }) # remote prompt = "shell is back"
86
- ← user@remote:~$
87
-
88
- # 3 — run a command on the remote, over the SAME PTY (no reconnect)
89
- → pty_send("t1", "uname -a")
90
- → pty_read("t1", { until: "\\$ $" })
91
- ← Linux remote 6.1.0 #1 SMP x86_64 GNU/Linux
92
-
93
- # 4 — a noisy command, read with the per-command reducer
94
- → pty_send("t1", "git status")
95
- → pty_read("t1", { until: "\\$ $", rtk: true }) # self-contained, no rtk binary needed
96
- ← ## main…origin/main [ahead 1]
97
- M src/core.ts
98
- ?? notes.txt
99
- [reduced: control chars stripped · repeats collapsed · git-status reducer]
100
- ```
101
-
102
- Notes that make this accurate, not a demo lie:
103
-
104
- - Step 2/3 use **`until`** with the remote prompt because **while nested, quiescence cannot fire by design** — see [Completion detection](#completion-detection-4-layers) and [Known constraints](#known-constraints-by-design-not-bugs). `{ wait: true }` alone works at the local shell; nested needs `until` (or `mark: true`).
105
- - The bracketed `[reduced: …]` line is illustrative of the meta/restore hint `pty_read` appends; the exact text comes from your output. The reducer is the **self-contained** `pty_read({ rtk: true })` path — no external `rtk` binary required.
106
- - A human can `attach` to the `t1` socket and watch the same SSH session live (see [A human can watch](#a-human-can-watch)).
108
+ This registers it in `~/.claude.json`; you'll get an approval prompt the first time. **Any other MCP client** (Cursor, Cline, Claude Desktop, …) works too just launch `npx -y aiterm-mcp` (or `aiterm-mcp`) over stdio. Needs **Node 18** and **tmux** — see [Requirements](#requirements).
107
109
 
108
110
  ## How it works
109
111
 
110
112
  ```mermaid
111
113
  flowchart LR
112
- AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 6 tools"]
114
+ AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 9 tools"]
113
115
  S -->|"pty_read<br/>token-reduced"| AI
114
116
  S -->|"tmux send-keys<br/>capture-pane"| P["one local PTY<br/>tmux · persistent"]
115
117
  P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
@@ -119,10 +121,10 @@ One PTY is the only primitive. Everything else — SSH, containers, REPLs — is
119
121
 
120
122
  ## vs. the alternatives
121
123
 
122
- | | **aiterm-mcp** | one-shot shell tool (per command) | generic terminal / tmux MCPs |
124
+ | | **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`) |
123
125
  | --- | --- | --- | --- |
124
126
  | Persistent session | ✅ tmux, survives restarts | ❌ new shell every call | ⚠️ varies |
125
- | SSH / containers | nest with one `pty_send` | reconnect every command | ⚠️ often separate tools |
127
+ | SSH / containers | nest with one `pty_send` | reconnect every command | ⚠️ often separate tools / per-call connect |
126
128
  | Token-reduced reads | ✅ per-command reducers | ❌ raw output | ⚠️ rarely |
127
129
  | Completion detection | 4-layer: exit / `until` / quiescence / timeout | n/a (blocks per call) | ⚠️ prompt-match, fragile |
128
130
  | Human can co-drive | ✅ shared tmux socket (`attach`) | ❌ | ⚠️ varies |
@@ -135,23 +137,6 @@ One PTY is the only primitive. Everything else — SSH, containers, REPLs — is
135
137
  - **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.)
136
138
  - Optional: the [`rtk`](https://github.com/rtk-ai/rtk) binary (used by `pty_send`'s `rtk: true` delegation; works fine without it)
137
139
 
138
- ## Install / register
139
-
140
- Register with Claude Code (CLI) at user scope (available in every project):
141
-
142
- ```bash
143
- # Recommended — no install, npx fetches it each run
144
- claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
145
-
146
- # Or install globally and use the command name
147
- npm i -g aiterm-mcp
148
- claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
149
- ```
150
-
151
- This registers it in `~/.claude.json`. Restart Claude Code; you'll get an approval prompt the first time. Check the connection with `/mcp`.
152
-
153
- Any other MCP client works too — just launch `npx -y aiterm-mcp` (or `aiterm-mcp`) over stdio.
154
-
155
140
  ## Tools
156
141
 
157
142
  | Tool | Role | Key args |
@@ -163,6 +148,18 @@ Any other MCP client works too — just launch `npx -y aiterm-mcp` (or `aiterm-m
163
148
  | `pty_close` | Close a session | `session_id` |
164
149
  | `pty_list` | List sessions | (none) |
165
150
 
151
+ ### Interactive agent launchers
152
+
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.
154
+
155
+ | Tool | Launches | Key args |
156
+ | --- | --- | --- |
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?` |
159
+ | `composer_agent` | Grok Build, model `grok-composer-2.5-fast` (xAI) | same as `grok_agent` |
160
+
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`. All prerequisites are validated **before** a session is created — an invalid `reasoning_effort`, a missing CLI, or a nonexistent `cwd` fails with an explicit error and leaves no session behind.
162
+
166
163
  ### Completion detection (4 layers)
167
164
 
168
165
  `pty_read({ wait: true })` decides "is the command done?" via four layers: process exit / `until` regex match / output is quiescent ∧ the shell is back (quiescence) / timeout. While nested (inside SSH), the "shell is back" check cannot fire, so pass `until` with the remote prompt for a clean decision.
@@ -177,16 +174,6 @@ Any other MCP client works too — just launch `npx -y aiterm-mcp` (or `aiterm-m
177
174
 
178
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.
179
176
 
180
- ## Known constraints (by design, not bugs)
181
-
182
- - **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.
183
- - **`is_complete=False` is not a failure.** It means "completion was not observed within `timeout`." For long commands, raise `timeout` or use `until`/`mark`.
184
- - **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.
185
- - **`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.
186
- - **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.
187
- - **tmux is started with `-f /dev/null`**, so it does not read `~/.tmux.conf` (to keep behavior reproducible across machines).
188
- - **All sessions live on a single socket (`claude.sock`).** `tmux … kill-server` removes them all.
189
-
190
177
  ## A human can watch
191
178
 
192
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.
@@ -202,6 +189,16 @@ npm link # put `aiterm-mcp` on PATH locally
202
189
 
203
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 be byte-exact with upstream rtk 0.42.0, locked by regression tests) are in `prototype/python/`.
204
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.
201
+
205
202
  ## Try it
206
203
 
207
204
  One command, no clone, no build:
package/dist/core.js CHANGED
@@ -11,7 +11,7 @@ import { spawnSync } from "node:child_process";
11
11
  import * as fs from "node:fs";
12
12
  import * as path from "node:path";
13
13
  import * as os from "node:os";
14
- import { createHash } from "node:crypto";
14
+ import { createHash, randomBytes } from "node:crypto";
15
15
  import * as rtk from "./rtk.js";
16
16
  // Windows ネイティブには tmux が無い。その場合だけ全 tmux 呼び出しを WSL 経由へ橋渡しする
17
17
  // (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く)。
@@ -305,6 +305,11 @@ function autoName() {
305
305
  i++;
306
306
  return `t${i}`;
307
307
  }
308
+ // 衝突リトライ用の乱数名。線形 t{i} は高並行だと全員が同じ「最小の空き番号」を見て衝突し続ける
309
+ // (TOCTOU)ため、2回目以降は 1600万通りの nonce 名に切り替えてリトライ上限内で実質確実に確保する。
310
+ function nonceName() {
311
+ return `t-${randomBytes(3).toString("hex")}`;
312
+ }
308
313
  function captureScreen(name, lines) {
309
314
  const args = ["capture-pane", "-p", "-J", "-t", name];
310
315
  if (lines)
@@ -417,26 +422,49 @@ function rtkRewrite(text) {
417
422
  // ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
418
423
  export function openSession(name, shell = "bash") {
419
424
  fs.mkdirSync(SOCKDIR, { recursive: true });
420
- const nm = name || autoName();
421
- assertSessionName(nm);
422
- if (sessionExists(nm))
423
- throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
424
425
  // macOS の /bin/bash は 3.2 で、起動時に zsh 移行バナーを出して最初の read を汚す。darwin かつ bash の
425
426
  // ときだけ -e で環境変数を渡して抑止する。-e は tmux>=3.2 が必要だが macOS の Homebrew tmux は常に該当。
426
427
  // (古い tmux<3.2 が残る Linux/WSL で -e を渡すと new-session が落ちるため、darwin 限定にする。)
427
428
  const banner = process.platform === "darwin" && path.basename(shell) === "bash"
428
429
  ? ["-e", "BASH_SILENCE_DEPRECATION_WARNING=1"]
429
430
  : [];
430
- const r = tmux("new-session", "-d", "-s", nm, ...banner, "-f", "/dev/null", shell);
431
- if (r.code !== 0)
432
- throw new AitermError("tmux new-session 失敗: " + r.stderr.trim(), 2);
431
+ // 並行性対策: 複数エージェントが同時に名前なし open すると autoName が同じ t{i} を返し得る(TOCTOU)。
432
+ // 自動採番は初回のみ読みやすい t{i}、衝突したら乱数 nonce 名でリトライする(線形リトライは高並行で
433
+ // 全員が同じ空き番号に殺到してスケールしない)。明示名は既存ならエラー(呼び手責任=意図的共有と区別)。
434
+ const explicit = !!name;
435
+ let nm = name || autoName();
436
+ assertSessionName(nm);
437
+ for (let attempt = 0;; attempt++) {
438
+ if (attempt >= 20)
439
+ throw new AitermError("openSession: 自動採番のリトライ上限(tmux new-session が重複以外で失敗し続けている可能性)", 2);
440
+ if (sessionExists(nm)) {
441
+ if (explicit)
442
+ throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
443
+ }
444
+ else {
445
+ const r = tmux("new-session", "-d", "-s", nm, ...banner, "-f", "/dev/null", shell);
446
+ if (r.code === 0)
447
+ break;
448
+ // 自動採番かつ「重複名」由来の失敗(他エージェントが同名を先に取った)なら次名でリトライ。
449
+ const dup = /duplicate|already exists/i.test(r.stderr);
450
+ if (explicit || !dup)
451
+ throw new AitermError("tmux new-session 失敗: " + r.stderr.trim(), 2);
452
+ }
453
+ nm = nonceName();
454
+ assertSessionName(nm);
455
+ }
433
456
  fs.closeSync(fs.openSync(logpath(nm), "a")); // touch
434
457
  // pipe-pane の引数は tmux 内部の /bin/sh -c で再解釈される(argv ではない)。パスは単一引用符で包み、
435
458
  // パス自身の ' は '\'' イディオムでエスケープする(名前は検証済みだが、Windows ユーザー名 O'Brien 等が
436
459
  // 一時パスに ' を持ち込み redirect を壊すのを防ぐ。空白対策も兼ねる)。Windows は WSL から見える /mnt/c 形へ。
437
460
  const pipeTarget = isWin ? toWslPath(logpath(nm)) : logpath(nm);
438
461
  const quoted = `'${pipeTarget.replace(/'/g, "'\\''")}'`;
439
- tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
462
+ const pr = tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
463
+ if (pr.code !== 0) {
464
+ // 配管に失敗した session は pty_read が永遠に空を返す=成功を装わない。作った session を片付けて明示エラー。
465
+ tmux("kill-session", "-t", nm);
466
+ throw new AitermError("tmux pipe-pane 失敗(出力ログを配管できないため session を破棄): " + pr.stderr.trim(), 2);
467
+ }
440
468
  writeOffset(nm, 0);
441
469
  return [nm, attachHint(nm)];
442
470
  }
@@ -564,3 +592,101 @@ export function killAll() {
564
592
  tmux("kill-server");
565
593
  return "killed all sessions on this socket";
566
594
  }
595
+ function resolveAgentBin(kind) {
596
+ const home = process.env.HOME ?? os.homedir();
597
+ const [envVar, rel, name] = kind === "codex"
598
+ ? ["CODEX_BIN", [".local", "bin", "codex"], "codex"]
599
+ : ["GROK_BIN", [".grok", "bin", "grok"], "grok"];
600
+ const fromEnv = process.env[envVar];
601
+ if (fromEnv)
602
+ return fromEnv;
603
+ const cand = path.join(home, ...rel);
604
+ if (fs.existsSync(cand))
605
+ return cand;
606
+ const w = spawnSync(isWin ? "where" : "which", [name], {
607
+ encoding: "utf8",
608
+ timeout: 5000,
609
+ });
610
+ if (w.status === 0 && (w.stdout ?? "").trim())
611
+ return w.stdout.trim().split(/\r?\n/)[0];
612
+ return null;
613
+ }
614
+ // 単一引用符で安全に包む(' は '\'' で脱出)。send は raw:true で送るため自前で quote する。
615
+ function shq(s) {
616
+ return `'${s.replace(/'/g, "'\\''")}'`;
617
+ }
618
+ function buildAgentCmd(kind, bin, effort, prompt) {
619
+ const parts = [shq(bin)];
620
+ if (kind === "codex") {
621
+ // codex は config override で reasoning effort(例: low/medium/high)を渡す。
622
+ if (effort)
623
+ parts.push("-c", `model_reasoning_effort=${shq(effort)}`);
624
+ }
625
+ else {
626
+ // grok / composer は同じ grok CLI をモデル違いで起動。effort は low/medium/high/xhigh/max。
627
+ parts.push("--model", kind === "composer" ? "grok-composer-2.5-fast" : "grok-build");
628
+ if (effort)
629
+ parts.push("--effort", shq(effort));
630
+ }
631
+ if (prompt)
632
+ parts.push(shq(prompt)); // 初手プロンプト(任意)
633
+ return parts.join(" ");
634
+ }
635
+ function agentLabel(kind) {
636
+ return kind === "composer"
637
+ ? "Grok Build(Composer)"
638
+ : kind === "grok"
639
+ ? "Grok Build(Grok)"
640
+ : "Codex";
641
+ }
642
+ // grok CLI の --effort が受ける値集合(codex は CLI 側の値集合が版で変わるため縛らない)。
643
+ const GROK_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
644
+ export function openAgent(kind, opts = {}) {
645
+ const label = agentLabel(kind);
646
+ // 前提検証は session を作る前に全部済ませる(失敗の残骸 session を作らない)。
647
+ // effort → bin → cwd の順: effort 検証は CLI 不在の端末でも同じ結果になる(テスト可能性)。
648
+ const effort = opts.reasoning_effort ?? null;
649
+ if (effort && kind !== "codex" && !GROK_EFFORTS.has(effort)) {
650
+ throw new AitermError(`reasoning_effort '${effort}' は不正です(${label} は low/medium/high/xhigh/max)`, 2);
651
+ }
652
+ const bin = resolveAgentBin(kind);
653
+ if (!bin) {
654
+ const where = kind === "codex" ? "~/.local/bin/codex" : "~/.grok/bin/grok";
655
+ throw new AitermError(`${label} の CLI が見つかりません(${where} か PATH が必要)`, 2);
656
+ }
657
+ if (opts.cwd) {
658
+ // cd 失敗はシェル内で静かに死に、エージェント未起動のまま「起動した」と偽の成功を返してしまう。
659
+ // session を作る前に実在を検証して明示エラーにする。
660
+ let st = null;
661
+ try {
662
+ st = fs.statSync(opts.cwd);
663
+ }
664
+ catch {
665
+ st = null;
666
+ }
667
+ if (!st || !st.isDirectory()) {
668
+ throw new AitermError(`cwd '${opts.cwd}' がディレクトリとして存在しません`, 2);
669
+ }
670
+ }
671
+ const [sid, hint] = openSession(opts.session_name ?? null, "bash");
672
+ const cmd = buildAgentCmd(kind, bin, effort, opts.prompt ?? null);
673
+ const full = opts.cwd ? `cd ${shq(opts.cwd)} && ${cmd}` : cmd;
674
+ try {
675
+ send(sid, full, { enter: true, mark: false, force: false, rtk: false, raw: true });
676
+ }
677
+ catch (e) {
678
+ // 起動コマンドを投入できなかった session は空のまま残る=残骸を作らない。片付けてから元エラーを伝える。
679
+ try {
680
+ closeSession(sid);
681
+ }
682
+ catch {
683
+ /* 片付け失敗より元エラーの伝達を優先 */
684
+ }
685
+ throw e;
686
+ }
687
+ return [
688
+ sid,
689
+ `${label} を session ${sid} で起動した。\n${hint}\n` +
690
+ `pty_read(${sid}) で TUI を読み、pty_send(${sid}, "...") で操作する(対話)。`,
691
+ ];
692
+ }
package/dist/index.js CHANGED
@@ -135,6 +135,45 @@ server.registerTool("pty_list", {
135
135
  return fail(e);
136
136
  }
137
137
  });
138
+ // 対話型エージェント起動ツール(モデルごとに1つ=ツール名/説明でどのモデルか一目で分かる)。
139
+ // いずれも永続端末に TUI を起動し session_id を返す。以後 pty_read/pty_send で対話操作する。
140
+ const agentEffortDesc = (grokLike) => "reasoning effort(思考レベル)。" +
141
+ (grokLike ? "low/medium/high/xhigh/max" : "low/medium/high 等") +
142
+ "。省略時は CLI 既定。";
143
+ function registerAgentTool(toolName, kind, desc, grokLike) {
144
+ server.registerTool(toolName, {
145
+ description: desc,
146
+ inputSchema: {
147
+ prompt: z.string().nullish().describe("起動時に渡す初手プロンプト(任意)。省略で素のTUI起動"),
148
+ // grok/composer の effort は有限集合=schema で拒否(session を作る前に弾く)。
149
+ // codex は CLI 側の値集合が版で変わるため縛らない(core 側も同方針)。
150
+ reasoning_effort: (grokLike ? z.enum(["low", "medium", "high", "xhigh", "max"]) : z.string())
151
+ .nullish()
152
+ .describe(agentEffortDesc(grokLike)),
153
+ cwd: z.string().nullish().describe("作業ディレクトリ(対象リポのルート等・任意)"),
154
+ session_name: z.string().nullish().describe("セッション名(省略で自動採番)"),
155
+ },
156
+ }, async ({ prompt, reasoning_effort, cwd, session_name }) => {
157
+ try {
158
+ const [sid, hint] = core.openAgent(kind, {
159
+ prompt: prompt ?? undefined,
160
+ reasoning_effort: reasoning_effort ?? undefined,
161
+ cwd: cwd ?? undefined,
162
+ session_name: session_name ?? undefined,
163
+ });
164
+ return ok(`session_id: ${sid}\n${hint}`);
165
+ }
166
+ catch (e) {
167
+ return fail(e);
168
+ }
169
+ });
170
+ }
171
+ registerAgentTool("codex_agent", "codex", "【Codex (OpenAI・モデルは Codex CLI の既定)】の対話エージェント TUI を永続端末に起動する。実装・レビュー・調査を対話で回す。" +
172
+ "起動後は pty_read で画面を読み pty_send で操作する。reasoning_effort を引数で指定可。", false);
173
+ registerAgentTool("grok_agent", "grok", "【Grok Build の Grok モデル (grok-build)】の対話エージェント TUI を永続端末に起動する。" +
174
+ "起動後は pty_read/pty_send で対話操作。reasoning_effort を引数で指定可。", true);
175
+ registerAgentTool("composer_agent", "composer", "【Grok Build の Composer モデル (grok-composer-2.5-fast)】の対話エージェント TUI を永続端末に起動する。" +
176
+ "起動後は pty_read/pty_send で対話操作。reasoning_effort を引数で指定可。", true);
138
177
  async function main() {
139
178
  const transport = new StdioServerTransport();
140
179
  await server.connect(transport);
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.4.0",
3
+ "version": "0.7.1",
4
+ "mcpName": "io.github.kitepon-rgb/aiterm-mcp",
4
5
  "description": "AI-driven persistent terminal as a local stdio MCP server (tmux-backed). Holds one local PTY; SSH and containers are just commands you send into it. Token-reducing reads.",
5
6
  "keywords": [
6
7
  "mcp",
8
+ "mcp-server",
7
9
  "model-context-protocol",
8
10
  "terminal",
9
11
  "tmux",
10
12
  "pty",
13
+ "ssh",
11
14
  "claude",
15
+ "claude-code",
16
+ "cursor",
12
17
  "ai-agent",
13
- "ssh"
18
+ "devtools"
14
19
  ],
15
20
  "license": "MIT",
16
21
  "author": "kitepon",