aiterm-mcp 0.1.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/LICENSE +21 -0
- package/README.ja.md +124 -0
- package/README.md +124 -0
- package/dist/core.js +390 -0
- package/dist/index.js +145 -0
- package/dist/rtk.js +414 -0
- package/package.json +51 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kitepon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/og.svg" alt="aiterm-mcp — AI が握るローカル永続端末を stdio MCP サーバとして公開する" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# aiterm-mcp
|
|
6
|
+
|
|
7
|
+
[](https://github.com/kitepon-rgb/aiterm-mcp/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.npmjs.com/package/aiterm-mcp)
|
|
9
|
+
|
|
10
|
+
> *(English: [README.md](README.md))*
|
|
11
|
+
|
|
12
|
+
> AI が握る**ローカル永続端末**を stdio MCP サーバとして公開する。1 個のローカル端末を握り、SSH もコンテナも「その端末に打つ 1 コマンド」に格下げする。読み取りはトークン削減つき。
|
|
13
|
+
|
|
14
|
+
`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` の 6 ツールだけ。バックエンドは tmux なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
|
|
15
|
+
|
|
16
|
+
## なぜ
|
|
17
|
+
|
|
18
|
+
AI にコマンドを 1 個ずつ投げて結果を受け取る往復は、SSH では毎回「接続→認証→切断」を繰り返し遅く、トークンも食う。aiterm は **1 個の PTY を永続的に握り**、その中で `ssh host` や `docker exec -it x bash` と打って入る(ネスト)。セッション種別をツールで区別しない。
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pty_open() → ローカル端末を 1 個握る
|
|
22
|
+
pty_send(id, "ssh 192.168.1.2") → その端末の中で SSH に入る
|
|
23
|
+
pty_send(id, "uname -a") → リモートで実行
|
|
24
|
+
pty_read(id, { wait: true }) → 削減済みの出力を読む
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 仕組み
|
|
28
|
+
|
|
29
|
+
```mermaid
|
|
30
|
+
flowchart LR
|
|
31
|
+
AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 6 tools"]
|
|
32
|
+
S -->|"pty_read<br/>token-reduced"| AI
|
|
33
|
+
S -->|"tmux send-keys<br/>capture-pane"| P["one local PTY<br/>tmux · persistent"]
|
|
34
|
+
P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
プリミティブは「PTY を 1 個握る」ことだけ。SSH・コンテナ・REPL は、その中へ `pty_send` で打ち込む“ただのテキスト”に過ぎない。PTY は tmux 上にあるので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
|
|
38
|
+
|
|
39
|
+
## 既存手段との比較
|
|
40
|
+
|
|
41
|
+
| | **aiterm-mcp** | 1 コマンド毎の往復 | 一般的な terminal / tmux MCP |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| 永続セッション | ✅ tmux・再起動を跨ぐ | ❌ 毎回新シェル | ⚠️ まちまち |
|
|
44
|
+
| SSH / コンテナ | `pty_send` 1 回でネスト | 毎コマンド接続し直し | ⚠️ ツールが分かれがち |
|
|
45
|
+
| トークン削減読取 | ✅ コマンド別 reducer | ❌ 生出力 | ⚠️ ほぼ無し |
|
|
46
|
+
| 完了検出 | 4 層: 終了 / `until` / 静止 / timeout | 無し(毎回ブロック) | ⚠️ プロンプト一致・脆い |
|
|
47
|
+
| 人が同時操作 | ✅ 共有 tmux ソケット | ❌ | ⚠️ まちまち |
|
|
48
|
+
|
|
49
|
+
## 要件
|
|
50
|
+
|
|
51
|
+
- **Node.js >= 18**
|
|
52
|
+
- **tmux**(実行時の前提。`tmux -V` で確認。未導入なら `apt install tmux` / `brew install tmux`)
|
|
53
|
+
- 任意: [`rtk`](https://github.com/rtk-ai/rtk) バイナリ(`pty_send` の `rtk: true` 委譲で使う。無くても動く)
|
|
54
|
+
|
|
55
|
+
## インストール / 登録
|
|
56
|
+
|
|
57
|
+
Claude Code(CLI)にユーザースコープ(全プロジェクトで利用可)で登録する例:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# 公開後(npm)
|
|
61
|
+
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
62
|
+
|
|
63
|
+
# またはグローバルインストールしてコマンド名で
|
|
64
|
+
npm i -g aiterm-mcp
|
|
65
|
+
claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`~/.claude.json` に登録される。Claude Code を再起動すると、初回に承認プロンプトが出る。`/mcp` で接続状態を確認できる。
|
|
69
|
+
|
|
70
|
+
他の MCP クライアントでも、stdio で `npx -y aiterm-mcp`(または `aiterm-mcp`)を起動するだけ。
|
|
71
|
+
|
|
72
|
+
## ツール
|
|
73
|
+
|
|
74
|
+
| ツール | 役割 | 主な引数 |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `pty_open` | 端末を 1 個握り `session_id` を返す | `name?`, `shell="bash"` |
|
|
77
|
+
| `pty_send` | テキスト(コマンド)を送る | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
|
|
78
|
+
| `pty_read` | 出力を削減して読む(既定は増分) | `session_id`, `wait`, `until`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk` |
|
|
79
|
+
| `pty_key` | 制御キーを送る | `session_id`, `key`(`C-c`/`Enter`/`Up`…) |
|
|
80
|
+
| `pty_close` | セッションを閉じる | `session_id` |
|
|
81
|
+
| `pty_list` | セッション一覧 | (なし) |
|
|
82
|
+
|
|
83
|
+
### 完了検出(4 層)
|
|
84
|
+
|
|
85
|
+
`pty_read({ wait: true })` は、プロセス終了 / `until` 正規表現一致 / 出力静止 ∧ シェル復帰(quiescence)/ timeout の 4 層で「コマンドが終わったか」を判定する。ネスト中(SSH 先)はシェル復帰判定が効かないので `until` でプロンプトを指定すると綺麗に判定できる。
|
|
86
|
+
|
|
87
|
+
### トークン削減
|
|
88
|
+
|
|
89
|
+
- `pty_read` は既定で制御文字除去・連続重複圧縮・head+tail 折りたたみ(+復元ヒント・メタ併記)をかける。
|
|
90
|
+
- `pty_read({ rtk: true })` は直前に送ったコマンド別の reducer(`git status`/`git log`/`grep`/`pytest` ほか)で観測出力をさらに縮約する(rtk バイナリ非依存・自前実装)。
|
|
91
|
+
- `pty_send({ rtk: true })` は既知コマンドを `rtk` 形に書き換えて送り、実行先に `rtk` があればソースで削減を効かせる(無ければ素通し)。
|
|
92
|
+
|
|
93
|
+
### 安全
|
|
94
|
+
|
|
95
|
+
`pty_send` は送信前に破壊的コマンド(`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE` 等)を遮断し(`force: true` で越える)、ESC・ブラケットペースト終端などをサニタイズする。`pty_read` は制御文字を無害化して返す。
|
|
96
|
+
|
|
97
|
+
## 既知の制約(バグではなく仕様)
|
|
98
|
+
|
|
99
|
+
- **ネスト中(ssh / docker / REPL)は quiescence が原理的に効かない。** 前面コマンドがシェル集合(bash/sh/zsh/fish/dash)の外になるため。完了検出は `until`(プロンプト等の正規表現)か `mark: true`(終了コード付き sentinel)を使う。
|
|
100
|
+
- **`is_complete=False` は失敗ではない。** 「timeout 内に完了を観測できなかった」という意味。長時間コマンドでは `timeout` を伸ばすか `until`/`mark` を使う。
|
|
101
|
+
- **破壊ゲートはサンドボックスではなく tripwire。** よくある破壊形だけを弾く。相対パスの `rm`、`$VAR` 展開後に危険化するもの、ssh 先で実行されるコマンドは捕捉しない。
|
|
102
|
+
- **`pty_send({ rtk: true })` は単行コマンドのみ+外部 `rtk` バイナリが必要**(無ければ素通し)。一方 `pty_read({ rtk: true })` の reducer は自前実装で rtk 非依存。
|
|
103
|
+
- **`pytest` reducer は件数・罫線・`FAILURES` ブロック整形が rtk 0.42.0 と byte 一致**(回帰テストで固定)。ただし `-ra`/`-rf` 時の `FAILED` 要約行の理由は**全文を保持する**(rtk 0.42.0 は最初の `" - "` 区切りで切るが、本実装は可読性優先で情報を残すため、この行は意図的に rtk と完全一致させない)。rtk が大出力時に付ける `[full output: …]`(tee ポインタ)行は read 側では再現しない。
|
|
104
|
+
- **tmux は `-f /dev/null` 起動**なので `~/.tmux.conf` を読まない(環境差を排除するため)。
|
|
105
|
+
- **全セッションが単一 socket(`claude.sock`)上にある。** `tmux … kill-server` は全セッションを消す。
|
|
106
|
+
|
|
107
|
+
## 人が覗く
|
|
108
|
+
|
|
109
|
+
セッションは共有 tmux ソケット上にある。`pty_open` の戻り値に表示される `tmux -S … attach -t <id>` で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)。
|
|
110
|
+
|
|
111
|
+
## 開発
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm install
|
|
115
|
+
npm run build # tsc → dist/
|
|
116
|
+
npm test # build してから node:test 回帰スイート(tmux 必須)
|
|
117
|
+
npm link # ローカルで `aiterm-mcp` を PATH に
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
ロジックは `src/core.ts`(tmux 制御・削減・完了検出・安全)と `src/rtk.ts`(コマンド別 reducer)、公開は `src/index.ts`。設計の出発点と reducer の移植元(pytest reducer は本家 rtk 0.42.0 と出力が byte 一致するよう移植・回帰テストで固定)は `prototype/python/` を参照。
|
|
121
|
+
|
|
122
|
+
## ライセンス
|
|
123
|
+
|
|
124
|
+
MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src=".github/og.svg" alt="aiterm-mcp — AI holds one persistent terminal as a stdio MCP server (tmux-backed)" width="100%">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# aiterm-mcp
|
|
6
|
+
|
|
7
|
+
[](https://github.com/kitepon-rgb/aiterm-mcp/actions/workflows/ci.yml)
|
|
8
|
+
[](https://www.npmjs.com/package/aiterm-mcp)
|
|
9
|
+
|
|
10
|
+
> *(日本語: [README.ja.md](README.ja.md))*
|
|
11
|
+
|
|
12
|
+
> 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.
|
|
13
|
+
|
|
14
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Why
|
|
17
|
+
|
|
18
|
+
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.
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
pty_open() → grab one local terminal
|
|
22
|
+
pty_send(id, "ssh 192.168.1.2") → enter SSH inside that terminal
|
|
23
|
+
pty_send(id, "uname -a") → run it on the remote
|
|
24
|
+
pty_read(id, { wait: true }) → read the reduced output
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
```mermaid
|
|
30
|
+
flowchart LR
|
|
31
|
+
AI["AI / MCP client"] -->|"pty_send"| S["aiterm-mcp<br/>stdio MCP · 6 tools"]
|
|
32
|
+
S -->|"pty_read<br/>token-reduced"| AI
|
|
33
|
+
S -->|"tmux send-keys<br/>capture-pane"| P["one local PTY<br/>tmux · persistent"]
|
|
34
|
+
P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
One PTY is the only primitive. Everything else — SSH, containers, REPLs — is just text you `pty_send` into it. Because the PTY lives in tmux, sessions outlive the MCP server and the AI client.
|
|
38
|
+
|
|
39
|
+
## vs. the alternatives
|
|
40
|
+
|
|
41
|
+
| | **aiterm-mcp** | one-shot shell tool (per command) | generic terminal / tmux MCPs |
|
|
42
|
+
| --- | --- | --- | --- |
|
|
43
|
+
| Persistent session | ✅ tmux, survives restarts | ❌ new shell every call | ⚠️ varies |
|
|
44
|
+
| SSH / containers | nest with one `pty_send` | reconnect every command | ⚠️ often separate tools |
|
|
45
|
+
| Token-reduced reads | ✅ per-command reducers | ❌ raw output | ⚠️ rarely |
|
|
46
|
+
| Completion detection | 4-layer: exit / `until` / quiescence / timeout | n/a (blocks per call) | ⚠️ prompt-match, fragile |
|
|
47
|
+
| Human can co-drive | ✅ shared tmux socket (`attach`) | ❌ | ⚠️ varies |
|
|
48
|
+
|
|
49
|
+
## Requirements
|
|
50
|
+
|
|
51
|
+
- **Node.js >= 18**
|
|
52
|
+
- **tmux** (runtime prerequisite; check with `tmux -V`. Install with `apt install tmux` / `brew install tmux`)
|
|
53
|
+
- Optional: the [`rtk`](https://github.com/rtk-ai/rtk) binary (used by `pty_send`'s `rtk: true` delegation; works fine without it)
|
|
54
|
+
|
|
55
|
+
## Install / register
|
|
56
|
+
|
|
57
|
+
Register with Claude Code (CLI) at user scope (available in every project):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# After publishing (npm)
|
|
61
|
+
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
62
|
+
|
|
63
|
+
# Or install globally and use the command name
|
|
64
|
+
npm i -g aiterm-mcp
|
|
65
|
+
claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
This registers it in `~/.claude.json`. Restart Claude Code; you'll get an approval prompt the first time. Check the connection with `/mcp`.
|
|
69
|
+
|
|
70
|
+
Any other MCP client works too — just launch `npx -y aiterm-mcp` (or `aiterm-mcp`) over stdio.
|
|
71
|
+
|
|
72
|
+
## Tools
|
|
73
|
+
|
|
74
|
+
| Tool | Role | Key args |
|
|
75
|
+
| --- | --- | --- |
|
|
76
|
+
| `pty_open` | Grab one terminal, return a `session_id` | `name?`, `shell="bash"` |
|
|
77
|
+
| `pty_send` | Send text (a command) | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
|
|
78
|
+
| `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk` |
|
|
79
|
+
| `pty_key` | Send a control key | `session_id`, `key` (`C-c`/`Enter`/`Up`…) |
|
|
80
|
+
| `pty_close` | Close a session | `session_id` |
|
|
81
|
+
| `pty_list` | List sessions | (none) |
|
|
82
|
+
|
|
83
|
+
### Completion detection (4 layers)
|
|
84
|
+
|
|
85
|
+
`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.
|
|
86
|
+
|
|
87
|
+
### Token reduction
|
|
88
|
+
|
|
89
|
+
- `pty_read` by default strips control characters, collapses repeated lines, and folds long output into head+tail (with a restore hint and a meta line).
|
|
90
|
+
- `pty_read({ rtk: true })` further shrinks the observed output with a per-command reducer (`git status`/`git log`/`grep`/`pytest` and more) — a self-contained reimplementation that needs no `rtk` binary.
|
|
91
|
+
- `pty_send({ rtk: true })` rewrites a known command into `rtk` form before sending, so reduction happens at the source if `rtk` exists there (passthrough otherwise).
|
|
92
|
+
|
|
93
|
+
### Safety
|
|
94
|
+
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
## Known constraints (by design, not bugs)
|
|
98
|
+
|
|
99
|
+
- **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). Use `until` (a regex for the prompt etc.) or `mark: true` (an exit-code sentinel) for completion.
|
|
100
|
+
- **`is_complete=False` is not a failure.** It means "completion was not observed within `timeout`." For long commands, raise `timeout` or use `until`/`mark`.
|
|
101
|
+
- **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.
|
|
102
|
+
- **`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.
|
|
103
|
+
- **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.
|
|
104
|
+
- **tmux is started with `-f /dev/null`**, so it does not read `~/.tmux.conf` (to keep behavior reproducible across machines).
|
|
105
|
+
- **All sessions live on a single socket (`claude.sock`).** `tmux … kill-server` removes them all.
|
|
106
|
+
|
|
107
|
+
## A human can watch
|
|
108
|
+
|
|
109
|
+
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).
|
|
110
|
+
|
|
111
|
+
## Development
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npm install
|
|
115
|
+
npm run build # tsc → dist/
|
|
116
|
+
npm test # build, then the node:test regression suite (requires tmux)
|
|
117
|
+
npm link # put `aiterm-mcp` on PATH locally
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
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/`.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core — CLI/MCP が共有する純粋ロジック層(Node/TS 版)。
|
|
3
|
+
*
|
|
4
|
+
* 1個のローカル専用 tmux セッションを握り、send でキーストロークを流し、read で画面/出力を
|
|
5
|
+
* トークン削減して受け取る。SSH/docker は専用機能にせず send(id, "ssh host") で中に入る(ネスト)。
|
|
6
|
+
* セッションは tmux サーバ常駐ゆえ、本プロセスが毎回終了しても次回 read で再接続できる。
|
|
7
|
+
*
|
|
8
|
+
* 設計: docs/ai-terminal-design-plan.md / docs/mcp-server-plan.md。出力削減は rag/ の RTK を移植。
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as rtk from "./rtk.js";
|
|
14
|
+
const SOCKDIR = path.join(process.env.TMPDIR ?? "/tmp", "claude-tmux-sockets");
|
|
15
|
+
const SOCK = path.join(SOCKDIR, "claude.sock");
|
|
16
|
+
// 完了検出
|
|
17
|
+
export const DEFAULT_TIMEOUT = 10.0;
|
|
18
|
+
const POLL = 0.25;
|
|
19
|
+
const STABLE_POLLS = 2; // 連続でログサイズ不変ならば静止とみなす回数
|
|
20
|
+
const SHELLS = new Set(["bash", "sh", "zsh", "fish", "dash"]);
|
|
21
|
+
// 出力削減(RTK の CAP 思想を移植)
|
|
22
|
+
const MAX_LINES_BEFORE_ELIDE = 60;
|
|
23
|
+
const HEAD_LINES = 30;
|
|
24
|
+
const TAIL_LINES = 20;
|
|
25
|
+
const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
|
|
26
|
+
// 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
|
|
27
|
+
const DESTRUCTIVE = [
|
|
28
|
+
/\brm\s+-[rfRF]*[rf][rfRF]*\s+(\/|~|\$HOME|\/\*|\.\s*$|\*\s*$)/i,
|
|
29
|
+
/\bmkfs(\.\w+)?\b/i,
|
|
30
|
+
/\bdd\b[^\n]*\bof=\/dev\//i,
|
|
31
|
+
/>\s*\/dev\/(sd|nvme|hd|mmcblk)/i,
|
|
32
|
+
/\bDROP\s+(TABLE|DATABASE|SCHEMA)\b/i,
|
|
33
|
+
/\bTRUNCATE\s+TABLE\b/i,
|
|
34
|
+
/(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba)?sh\b/i,
|
|
35
|
+
/:\(\)\s*\{\s*:\|:&\s*\};:/i, // fork bomb
|
|
36
|
+
/\bchmod\s+-R\s+0*0\s+\//i,
|
|
37
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
38
|
+
];
|
|
39
|
+
// CSI/OSC/ESC エスケープ・制御文字
|
|
40
|
+
const ANSI_RE = /\x1b\[[0-9;?]*[ -\/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[@-_]/g;
|
|
41
|
+
const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g; // \t(09) \n(0a) は残す
|
|
42
|
+
const PASTE_MARKERS_RE = /\x1b\[20[01]~/g;
|
|
43
|
+
// よく使う制御キーの別名(tmux のキー名へ)
|
|
44
|
+
const KEYMAP = {
|
|
45
|
+
"ctrl-c": "C-c", "c-c": "C-c", "ctrl-d": "C-d", "c-d": "C-d",
|
|
46
|
+
"ctrl-z": "C-z", "c-z": "C-z", "ctrl-l": "C-l", "ctrl-r": "C-r",
|
|
47
|
+
enter: "Enter", tab: "Tab", esc: "Escape", escape: "Escape",
|
|
48
|
+
up: "Up", down: "Down", left: "Left", right: "Right",
|
|
49
|
+
space: "Space", bspace: "BSpace", backspace: "BSpace",
|
|
50
|
+
};
|
|
51
|
+
export class AitermError extends Error {
|
|
52
|
+
code;
|
|
53
|
+
constructor(message, code = 1) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.code = code;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function tmux(...args) {
|
|
59
|
+
// maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
|
|
60
|
+
// 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
|
|
61
|
+
const r = spawnSync("tmux", ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
62
|
+
// ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
|
|
63
|
+
// EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
|
|
64
|
+
if (r.error && r.error.code === "ENOBUFS") {
|
|
65
|
+
throw new AitermError(`tmux 出力が 64MiB を超えました(${args[0]})。範囲を絞って読んでください。`, 2);
|
|
66
|
+
}
|
|
67
|
+
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
68
|
+
}
|
|
69
|
+
function sessionExists(name) {
|
|
70
|
+
return tmux("has-session", "-t", name).code === 0;
|
|
71
|
+
}
|
|
72
|
+
function paneCurrentCommand(name) {
|
|
73
|
+
const r = tmux("display-message", "-p", "-t", name, "#{pane_current_command}");
|
|
74
|
+
return r.code === 0 ? r.stdout.trim() : "";
|
|
75
|
+
}
|
|
76
|
+
function logpath(name) {
|
|
77
|
+
return path.join(SOCKDIR, name + ".log");
|
|
78
|
+
}
|
|
79
|
+
function offsetpath(name) {
|
|
80
|
+
return path.join(SOCKDIR, name + ".offset");
|
|
81
|
+
}
|
|
82
|
+
function lastcmdpath(name) {
|
|
83
|
+
return path.join(SOCKDIR, name + ".lastcmd");
|
|
84
|
+
}
|
|
85
|
+
function readOffset(name) {
|
|
86
|
+
try {
|
|
87
|
+
const n = parseInt(fs.readFileSync(offsetpath(name), "utf8").trim(), 10);
|
|
88
|
+
return Number.isNaN(n) ? 0 : n;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function writeOffset(name, off) {
|
|
95
|
+
fs.writeFileSync(offsetpath(name), String(off));
|
|
96
|
+
}
|
|
97
|
+
function writeLastcmd(name, cmd) {
|
|
98
|
+
try {
|
|
99
|
+
fs.writeFileSync(lastcmdpath(name), cmd);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* noop */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function readLastcmd(name) {
|
|
106
|
+
try {
|
|
107
|
+
return fs.readFileSync(lastcmdpath(name), "utf8");
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return "";
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
export function attachHint(name) {
|
|
114
|
+
return (`このセッションを自分の目で見る/介入する:\n` +
|
|
115
|
+
` tmux -S ${SOCK} attach -t ${name}\n` +
|
|
116
|
+
` (抜けるには Ctrl-b d)`);
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------- 出力削減
|
|
119
|
+
function estimateTokens(s) {
|
|
120
|
+
return Math.ceil(s.length / 4.0);
|
|
121
|
+
}
|
|
122
|
+
export function stripControl(text) {
|
|
123
|
+
text = text.replace(/\r\n/g, "\n"); // CRLF 正規化
|
|
124
|
+
const out = [];
|
|
125
|
+
for (let line of text.split("\n")) {
|
|
126
|
+
if (line.includes("\r")) {
|
|
127
|
+
const parts = line.split("\r");
|
|
128
|
+
line = parts[parts.length - 1]; // 行中の\rは進捗上書き=最終状態だけ残す
|
|
129
|
+
}
|
|
130
|
+
line = line.replace(ANSI_RE, "").replace(CTRL_RE, "");
|
|
131
|
+
out.push(line.replace(/\s+$/, "")); // rstrip
|
|
132
|
+
}
|
|
133
|
+
return out.join("\n");
|
|
134
|
+
}
|
|
135
|
+
function collapseBlanks(lines) {
|
|
136
|
+
const out = [];
|
|
137
|
+
let blanks = 0;
|
|
138
|
+
for (const ln of lines) {
|
|
139
|
+
if (ln === "") {
|
|
140
|
+
blanks++;
|
|
141
|
+
if (blanks <= 1)
|
|
142
|
+
out.push(ln);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
blanks = 0;
|
|
146
|
+
out.push(ln);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
while (out.length && out[0] === "")
|
|
150
|
+
out.shift();
|
|
151
|
+
while (out.length && out[out.length - 1] === "")
|
|
152
|
+
out.pop();
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
function dedupRuns(lines) {
|
|
156
|
+
const out = [];
|
|
157
|
+
let i = 0;
|
|
158
|
+
const n = lines.length;
|
|
159
|
+
while (i < n) {
|
|
160
|
+
let j = i;
|
|
161
|
+
while (j < n && lines[j] === lines[i])
|
|
162
|
+
j++;
|
|
163
|
+
const run = j - i;
|
|
164
|
+
if (run >= DEDUP_MIN_RUN && lines[i] !== "")
|
|
165
|
+
out.push(`${lines[i]} 〈×${run}〉`);
|
|
166
|
+
else
|
|
167
|
+
out.push(...lines.slice(i, j));
|
|
168
|
+
i = j;
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
/** RTK 4戦略を移植: 制御除去 / 空白正規化 / 連続重複圧縮 / head+tail 折りたたみ。返り値 [body, meta]。 */
|
|
173
|
+
export function reduceOutput(raw, name, elide = true) {
|
|
174
|
+
const rawLinesN = (raw.match(/\n/g)?.length ?? 0) + (raw && !raw.endsWith("\n") ? 1 : 0);
|
|
175
|
+
const cleaned = stripControl(raw);
|
|
176
|
+
let lines = collapseBlanks(cleaned.split("\n"));
|
|
177
|
+
lines = dedupRuns(lines);
|
|
178
|
+
let elided = 0;
|
|
179
|
+
if (elide && lines.length > MAX_LINES_BEFORE_ELIDE) {
|
|
180
|
+
elided = lines.length - HEAD_LINES - TAIL_LINES;
|
|
181
|
+
const hint = `… 〈${elided} 行省略。全文は full=true、範囲は line_range="A:B"〉 …`;
|
|
182
|
+
lines = [...lines.slice(0, HEAD_LINES), hint, ...lines.slice(lines.length - TAIL_LINES)];
|
|
183
|
+
}
|
|
184
|
+
const body = lines.join("\n");
|
|
185
|
+
const meta = `[aiterm ${name}: ${lines.length} 行 / ~${estimateTokens(body)} tok ` +
|
|
186
|
+
`(raw ${rawLinesN} 行 / ~${estimateTokens(raw)} tok)` +
|
|
187
|
+
(elided ? `; ${elided} 行 hidden]` : "]");
|
|
188
|
+
return [body, meta];
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------- tmux 補助
|
|
191
|
+
function autoName() {
|
|
192
|
+
const r = tmux("list-sessions", "-F", "#{session_name}");
|
|
193
|
+
const existing = new Set(r.code === 0 ? r.stdout.split(/\s+/).filter(Boolean) : []);
|
|
194
|
+
let i = 1;
|
|
195
|
+
while (existing.has(`t${i}`))
|
|
196
|
+
i++;
|
|
197
|
+
return `t${i}`;
|
|
198
|
+
}
|
|
199
|
+
function captureScreen(name, lines) {
|
|
200
|
+
const args = ["capture-pane", "-p", "-J", "-t", name];
|
|
201
|
+
if (lines)
|
|
202
|
+
args.push("-S", `-${lines}`);
|
|
203
|
+
const r = tmux(...args);
|
|
204
|
+
return r.code === 0 ? r.stdout : "";
|
|
205
|
+
}
|
|
206
|
+
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
207
|
+
/** 完了境界の4層: dead / until 一致 / (出力静止 ∧ シェルに戻った) / timeout。 */
|
|
208
|
+
async function waitCompletion(name, untilRe, timeout) {
|
|
209
|
+
// 締切は単調時計で測る。Date.now() は NTP 補正やサスペンドで巻き戻り、長時間待ちで誤判定する(Python は time.monotonic)。
|
|
210
|
+
const deadline = performance.now() + timeout * 1000;
|
|
211
|
+
const start = readOffset(name);
|
|
212
|
+
let lastSize = null;
|
|
213
|
+
let stable = 0;
|
|
214
|
+
const until = untilRe ? new RegExp(untilRe) : null;
|
|
215
|
+
for (;;) {
|
|
216
|
+
const alive = sessionExists(name);
|
|
217
|
+
let size = 0;
|
|
218
|
+
try {
|
|
219
|
+
size = fs.statSync(logpath(name)).size;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
size = 0;
|
|
223
|
+
}
|
|
224
|
+
if (until && size > start) {
|
|
225
|
+
const buf = fs.readFileSync(logpath(name));
|
|
226
|
+
const neu = buf.subarray(start).toString("utf8");
|
|
227
|
+
if (until.test(stripControl(neu)))
|
|
228
|
+
return [true, "until"];
|
|
229
|
+
}
|
|
230
|
+
if (!alive)
|
|
231
|
+
return [true, "dead"];
|
|
232
|
+
if (size === lastSize) {
|
|
233
|
+
stable++;
|
|
234
|
+
if (stable >= STABLE_POLLS && SHELLS.has(paneCurrentCommand(name)))
|
|
235
|
+
return [true, "quiescent"];
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
stable = 0;
|
|
239
|
+
}
|
|
240
|
+
lastSize = size;
|
|
241
|
+
if (performance.now() >= deadline)
|
|
242
|
+
return [false, "timeout"];
|
|
243
|
+
await sleep(POLL * 1000);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
function rtkRewrite(text) {
|
|
247
|
+
if (text.trim().includes("\n"))
|
|
248
|
+
return text;
|
|
249
|
+
// timeout 無しだと rtk がハングしたとき send() ごと凍結する。Python は timeout=5。
|
|
250
|
+
const r = spawnSync("rtk", ["rewrite", text], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 });
|
|
251
|
+
if (r.error)
|
|
252
|
+
return text; // rtk 不在(ENOENT)・タイムアウト(ETIMEDOUT)等は元テキストへ素通し
|
|
253
|
+
if ((r.status === 0 || r.status === 3) && (r.stdout ?? "").trim())
|
|
254
|
+
return (r.stdout ?? "").replace(/\n+$/, "");
|
|
255
|
+
return text;
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
|
|
258
|
+
export function openSession(name, shell = "bash") {
|
|
259
|
+
fs.mkdirSync(SOCKDIR, { recursive: true });
|
|
260
|
+
const nm = name || autoName();
|
|
261
|
+
if (sessionExists(nm))
|
|
262
|
+
throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
|
|
263
|
+
const r = tmux("new-session", "-d", "-s", nm, "-f", "/dev/null", shell);
|
|
264
|
+
if (r.code !== 0)
|
|
265
|
+
throw new AitermError("tmux new-session 失敗: " + r.stderr.trim(), 2);
|
|
266
|
+
fs.closeSync(fs.openSync(logpath(nm), "a")); // touch
|
|
267
|
+
tmux("pipe-pane", "-t", nm, "-o", `cat >> ${logpath(nm)}`);
|
|
268
|
+
writeOffset(nm, 0);
|
|
269
|
+
return [nm, attachHint(nm)];
|
|
270
|
+
}
|
|
271
|
+
export function send(name, text, o = {}) {
|
|
272
|
+
const enter = o.enter ?? true;
|
|
273
|
+
if (!sessionExists(name))
|
|
274
|
+
throw new AitermError(`session '${name}' が無い(open してください)`, 2);
|
|
275
|
+
if (!o.raw) {
|
|
276
|
+
text = text.replace(PASTE_MARKERS_RE, "").replace(ANSI_RE, "").replace(CTRL_RE, "");
|
|
277
|
+
}
|
|
278
|
+
if (!o.force) {
|
|
279
|
+
for (const pat of DESTRUCTIVE) {
|
|
280
|
+
if (pat.test(text)) {
|
|
281
|
+
throw new AitermError(`破壊的の可能性があるコマンドを遮断しました: /${pat.source}/\n` +
|
|
282
|
+
` 本当に実行するなら force を有効にして再実行してください。`, 3);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
|
|
287
|
+
if (o.rtk)
|
|
288
|
+
text = rtkRewrite(text);
|
|
289
|
+
if (o.mark)
|
|
290
|
+
text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
|
|
291
|
+
tmux("send-keys", "-t", name, "-l", "--", text);
|
|
292
|
+
if (enter)
|
|
293
|
+
tmux("send-keys", "-t", name, "Enter");
|
|
294
|
+
// コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
|
|
295
|
+
return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
|
|
296
|
+
}
|
|
297
|
+
export function sendKey(name, key) {
|
|
298
|
+
if (!sessionExists(name))
|
|
299
|
+
throw new AitermError(`session '${name}' が無い`, 2);
|
|
300
|
+
const k = KEYMAP[key.toLowerCase()] ?? key;
|
|
301
|
+
tmux("send-keys", "-t", name, k);
|
|
302
|
+
return `sent key ${k} to ${name}`;
|
|
303
|
+
}
|
|
304
|
+
export async function readOutput(name, o = {}) {
|
|
305
|
+
const timeout = o.timeout ?? DEFAULT_TIMEOUT;
|
|
306
|
+
if (!sessionExists(name) && !fs.existsSync(logpath(name)))
|
|
307
|
+
throw new AitermError(`session '${name}' が無い`, 2);
|
|
308
|
+
if (o.screen) {
|
|
309
|
+
const rawTxt = captureScreen(name, o.lines || 0);
|
|
310
|
+
if (o.raw)
|
|
311
|
+
return rawTxt;
|
|
312
|
+
const [body, meta] = reduceOutput(rawTxt, name, true);
|
|
313
|
+
return body + "\n" + meta;
|
|
314
|
+
}
|
|
315
|
+
let status = null;
|
|
316
|
+
if (o.wait) {
|
|
317
|
+
const [, st] = await waitCompletion(name, o.until ?? null, timeout);
|
|
318
|
+
status = st;
|
|
319
|
+
}
|
|
320
|
+
let data;
|
|
321
|
+
try {
|
|
322
|
+
data = fs.readFileSync(logpath(name));
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
data = Buffer.alloc(0);
|
|
326
|
+
}
|
|
327
|
+
let text;
|
|
328
|
+
if (o.full || o.range) {
|
|
329
|
+
text = data.toString("utf8");
|
|
330
|
+
if (o.range) {
|
|
331
|
+
const [lo, hi] = o.range;
|
|
332
|
+
text = text.split("\n").slice(lo, hi ?? undefined).join("\n");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
const off = readOffset(name);
|
|
337
|
+
text = data.subarray(off).toString("utf8");
|
|
338
|
+
if (o.lines)
|
|
339
|
+
text = text.split("\n").slice(-o.lines).join("\n");
|
|
340
|
+
}
|
|
341
|
+
if (!o.range)
|
|
342
|
+
writeOffset(name, data.length); // 既定/full は offset を末尾へ
|
|
343
|
+
if (o.raw)
|
|
344
|
+
return text.endsWith("\n") ? text : text + "\n";
|
|
345
|
+
if (o.rtk) {
|
|
346
|
+
// コマンド別 reducer(自前移植)を観測出力へ適用
|
|
347
|
+
const cmd = readLastcmd(name);
|
|
348
|
+
if (cmd.trim()) {
|
|
349
|
+
const framed = rtk.stripShellFrame(stripControl(text), cmd);
|
|
350
|
+
const [reduced, rname] = rtk.reduce(cmd, framed);
|
|
351
|
+
if (reduced !== null) {
|
|
352
|
+
const meta = `[aiterm ${name}: rtk:${rname} 適用 / ~${estimateTokens(reduced)} tok (raw ~${estimateTokens(text)} tok)]`;
|
|
353
|
+
if (status) {
|
|
354
|
+
const complete = status !== "timeout";
|
|
355
|
+
return reduced + "\n" + meta + ` [is_complete=${complete ? "True" : "False"} via ${status}]`;
|
|
356
|
+
}
|
|
357
|
+
return reduced + "\n" + meta;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// reducer 非該当 → 汎用削減へフォールバック
|
|
361
|
+
}
|
|
362
|
+
const [body, meta] = reduceOutput(text, name, !o.range);
|
|
363
|
+
if (status) {
|
|
364
|
+
const complete = status !== "timeout";
|
|
365
|
+
return body + "\n" + meta + ` [is_complete=${complete ? "True" : "False"} via ${status}]`;
|
|
366
|
+
}
|
|
367
|
+
return body + "\n" + meta;
|
|
368
|
+
}
|
|
369
|
+
export function listSessions() {
|
|
370
|
+
const r = tmux("list-sessions", "-F", "#{session_name}\t#{pane_current_command}\t#{?session_attached,attached,detached}\t#{window_width}x#{window_height}");
|
|
371
|
+
if (r.code === 0 && r.stdout.trim())
|
|
372
|
+
return r.stdout.replace(/\s+$/, "");
|
|
373
|
+
return "(セッション無し)";
|
|
374
|
+
}
|
|
375
|
+
export function closeSession(name) {
|
|
376
|
+
tmux("kill-session", "-t", name);
|
|
377
|
+
for (const p of [logpath(name), offsetpath(name), lastcmdpath(name)]) {
|
|
378
|
+
try {
|
|
379
|
+
fs.unlinkSync(p);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
/* noop */
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return `closed ${name}`;
|
|
386
|
+
}
|
|
387
|
+
export function killAll() {
|
|
388
|
+
tmux("kill-server");
|
|
389
|
+
return "killed all sessions on this socket";
|
|
390
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* aiterm-mcp — AI が握るローカル永続端末を stdio MCP サーバとして公開する(Node/TS 版)。
|
|
4
|
+
*
|
|
5
|
+
* WSL2/Linux/mac のローカルで動かし、握るのはローカル端末1個。リモートは pty_send "ssh ..." で
|
|
6
|
+
* 中に入る(ネスト)。バックエンドは tmux(実行時の前提)。ロジックは core.ts に集約。
|
|
7
|
+
*
|
|
8
|
+
* 重要: stdio MCP は stdout が JSON-RPC 専用。診断は stderr/console.error のみ(console.log 禁止)。
|
|
9
|
+
* 起動: npx -y aiterm-mcp(または mcp 登録のコマンドに同じ)。
|
|
10
|
+
*/
|
|
11
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
import * as core from "./core.js";
|
|
15
|
+
const server = new McpServer({ name: "aiterm", version: "0.1.0" });
|
|
16
|
+
function ok(s) {
|
|
17
|
+
return { content: [{ type: "text", text: s }] };
|
|
18
|
+
}
|
|
19
|
+
function fail(e) {
|
|
20
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
21
|
+
return { content: [{ type: "text", text: "aiterm: " + msg }], isError: true };
|
|
22
|
+
}
|
|
23
|
+
server.registerTool("pty_open", {
|
|
24
|
+
description: "ローカル永続端末(tmux セッション)を1個開き、session_id を返す。tmux サーバ常駐ゆえ本サーバや " +
|
|
25
|
+
"クライアントが再起動してもセッションは生存する。リモート操作は専用ツールにせず、開いた端末の中で " +
|
|
26
|
+
'pty_send(session_id, "ssh host") と打って入る。',
|
|
27
|
+
inputSchema: {
|
|
28
|
+
name: z.string().nullish().describe("セッション名(省略時は t1, t2... を自動採番)"),
|
|
29
|
+
shell: z.string().default("bash").describe("起動シェル(既定 bash)"),
|
|
30
|
+
},
|
|
31
|
+
}, async ({ name, shell }) => {
|
|
32
|
+
try {
|
|
33
|
+
const [sid, hint] = core.openSession(name ?? null, shell);
|
|
34
|
+
return ok(`session_id: ${sid}\n${hint}`);
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
return fail(e);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
server.registerTool("pty_send", {
|
|
41
|
+
description: "セッションへテキスト(コマンド)を送る。送信後の出力は pty_read で取得する。",
|
|
42
|
+
inputSchema: {
|
|
43
|
+
session_id: z.string(),
|
|
44
|
+
text: z.string().describe("送る文字列(コマンド)"),
|
|
45
|
+
enter: z.boolean().default(true).describe("末尾で Enter を送る"),
|
|
46
|
+
mark: z.boolean().default(false).describe("完了 sentinel(終了コード付き)で包む"),
|
|
47
|
+
force: z.boolean().default(false).describe("破壊的コマンドゲートを越える"),
|
|
48
|
+
rtk: z.boolean().default(false).describe("既知コマンドを rtk 形へ委譲して送る(rtk 不在なら素通し)"),
|
|
49
|
+
raw: z.boolean().default(false).describe("送信前サニタイズを無効化"),
|
|
50
|
+
},
|
|
51
|
+
}, async ({ session_id, text, enter, mark, force, rtk, raw }) => {
|
|
52
|
+
try {
|
|
53
|
+
return ok(core.send(session_id, text, { enter, mark, force, rtk, raw }));
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return fail(e);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
server.registerTool("pty_read", {
|
|
60
|
+
description: "セッションの出力をトークン削減して読む(既定は前回読取位置からの増分)。" +
|
|
61
|
+
"削減: 制御文字除去 / 反復圧縮 / head+tail 折りたたみ+復元ヒント+メタ併記。",
|
|
62
|
+
inputSchema: {
|
|
63
|
+
session_id: z.string(),
|
|
64
|
+
wait: z.boolean().default(false).describe("完了まで待つ(4層: dead/until/出力静止∧シェル復帰/timeout)"),
|
|
65
|
+
until: z.string().nullish().describe("この正規表現が出たら完了とみなす"),
|
|
66
|
+
timeout: z.number().default(10).describe("wait の最大待ち秒数"),
|
|
67
|
+
screen: z.boolean().default(false).describe("描画済みスクリーン(TUI 向け)"),
|
|
68
|
+
full: z.boolean().default(false).describe("増分でなく全文"),
|
|
69
|
+
lines: z.number().int().nullish().describe("末尾 N 行のみ"),
|
|
70
|
+
line_range: z.string().nullish().describe('全文からの行範囲 "A:B"'),
|
|
71
|
+
raw: z.boolean().default(false).describe("削減せず生テキスト"),
|
|
72
|
+
rtk: z.boolean().default(false).describe("直前コマンド別の自前 reducer(git/grep/pytest 等)で縮約"),
|
|
73
|
+
},
|
|
74
|
+
}, async ({ session_id, wait, until, timeout, screen, full, lines, line_range, raw, rtk }) => {
|
|
75
|
+
try {
|
|
76
|
+
let range = null;
|
|
77
|
+
if (line_range) {
|
|
78
|
+
const idx = line_range.indexOf(":");
|
|
79
|
+
const lo = idx < 0 ? line_range : line_range.slice(0, idx);
|
|
80
|
+
const hi = idx < 0 ? "" : line_range.slice(idx + 1);
|
|
81
|
+
// 不正/空の上端は「末尾まで」(null) に倒す。"5:abc" を空に潰さず "5:" と同じく 5 行目以降にする。
|
|
82
|
+
const hiN = hi ? parseInt(hi, 10) : NaN;
|
|
83
|
+
range = [parseInt(lo, 10) || 0, Number.isNaN(hiN) ? null : hiN];
|
|
84
|
+
}
|
|
85
|
+
const out = await core.readOutput(session_id, {
|
|
86
|
+
wait,
|
|
87
|
+
until: until ?? null,
|
|
88
|
+
timeout,
|
|
89
|
+
screen,
|
|
90
|
+
full,
|
|
91
|
+
lines: lines ?? null,
|
|
92
|
+
range,
|
|
93
|
+
raw,
|
|
94
|
+
rtk,
|
|
95
|
+
});
|
|
96
|
+
return ok(out);
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
return fail(e);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
server.registerTool("pty_key", {
|
|
103
|
+
description: "制御キーを送る(C-c, C-d, Enter, Tab, Up, Down... の別名に対応)。",
|
|
104
|
+
inputSchema: {
|
|
105
|
+
session_id: z.string(),
|
|
106
|
+
key: z.string().describe('キー名(例 "C-c", "Enter", "Up")'),
|
|
107
|
+
},
|
|
108
|
+
}, async ({ session_id, key }) => {
|
|
109
|
+
try {
|
|
110
|
+
return ok(core.sendKey(session_id, key));
|
|
111
|
+
}
|
|
112
|
+
catch (e) {
|
|
113
|
+
return fail(e);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
server.registerTool("pty_close", {
|
|
117
|
+
description: "セッションを閉じ、ログ/読取位置を破棄する。",
|
|
118
|
+
inputSchema: { session_id: z.string() },
|
|
119
|
+
}, async ({ session_id }) => {
|
|
120
|
+
try {
|
|
121
|
+
return ok(core.closeSession(session_id));
|
|
122
|
+
}
|
|
123
|
+
catch (e) {
|
|
124
|
+
return fail(e);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
server.registerTool("pty_list", {
|
|
128
|
+
description: "握っているセッション一覧(名前 / 現在の前面コマンド / attach 状態 / サイズ)。",
|
|
129
|
+
inputSchema: {},
|
|
130
|
+
}, async () => {
|
|
131
|
+
try {
|
|
132
|
+
return ok(core.listSessions());
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
return fail(e);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
async function main() {
|
|
139
|
+
const transport = new StdioServerTransport();
|
|
140
|
+
await server.connect(transport);
|
|
141
|
+
}
|
|
142
|
+
main().catch((e) => {
|
|
143
|
+
console.error("aiterm-mcp fatal:", e);
|
|
144
|
+
process.exit(1);
|
|
145
|
+
});
|
package/dist/rtk.js
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rtk — RTK の出力削減を「自前実装」で移植した read 側リデューサ群(Node/TS 版)。
|
|
3
|
+
*
|
|
4
|
+
* 要件C: rtk のファイルは複製せず、アルゴリズムを参照に自分のコードとして書き起こす。
|
|
5
|
+
* rtk バイナリが無い場所でも縮約が効くよう、観測済み出力に対して動く。
|
|
6
|
+
* pytest / grep は人間向け標準出力を解析でき rtk のアルゴリズムに忠実
|
|
7
|
+
* (pytest は rtk 0.42.0 の出力と厳密一致するよう移植)。
|
|
8
|
+
*
|
|
9
|
+
* 公開 API: reduce(command, output) -> [reducedText|null, reducerName|null] / classify / stripShellFrame
|
|
10
|
+
*/
|
|
11
|
+
// rtk 由来のキャップ(truncate.rs / config.rs の値。数値のみ参照、コードは自作)
|
|
12
|
+
const CAP_GREP_TOTAL = 200; // grep: 全体の最大マッチ行
|
|
13
|
+
const CAP_GREP_PER_FILE = 25; // grep: ファイルあたり最大
|
|
14
|
+
const GREP_MAX_LEN = 80; // grep: 1 行の最大幅
|
|
15
|
+
const COMPACT_PATH_THRESH = 50; // grep: パス短縮の閾値
|
|
16
|
+
const MAX_PYTEST_FAILURES = 10; // pytest: 失敗ブロック最大
|
|
17
|
+
const MAX_XFAIL = 10; // pytest: xfail/xpass 行 最大
|
|
18
|
+
const PYTEST_RELEVANT_PER_FAIL = 3;
|
|
19
|
+
const LOG_LIMIT_DEFAULT = 10; // git log: 既定コミット数
|
|
20
|
+
const LOG_BODY_LINES = 3;
|
|
21
|
+
const LOG_WIDTH = 80;
|
|
22
|
+
/** char(コードポイント)ベース切り詰め(rtk utils::truncate と同等)。 */
|
|
23
|
+
function truncate(s, n) {
|
|
24
|
+
const cp = Array.from(s);
|
|
25
|
+
if (cp.length <= n)
|
|
26
|
+
return s;
|
|
27
|
+
if (n < 3)
|
|
28
|
+
return "...";
|
|
29
|
+
return cp.slice(0, n - 3).join("") + "...";
|
|
30
|
+
}
|
|
31
|
+
const PROMPT_TAIL = /[$#%>]\s*$/;
|
|
32
|
+
/** 観測ログ片からエコー行と前後のプロンプト行を落とし、コマンド出力本体だけ残す(発見的)。 */
|
|
33
|
+
export function stripShellFrame(text, command) {
|
|
34
|
+
const lines = text.split("\n");
|
|
35
|
+
const cmd = command.trim();
|
|
36
|
+
let start = 0;
|
|
37
|
+
if (cmd) {
|
|
38
|
+
for (let i = 0; i < lines.length; i++)
|
|
39
|
+
if (lines[i].includes(cmd))
|
|
40
|
+
start = i + 1;
|
|
41
|
+
}
|
|
42
|
+
let end = lines.length;
|
|
43
|
+
while (end > start) {
|
|
44
|
+
const last = lines[end - 1];
|
|
45
|
+
if (!last.trim() || (PROMPT_TAIL.test(last) && (!cmd || !last.includes(cmd))))
|
|
46
|
+
end--;
|
|
47
|
+
else
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
return lines.slice(start, end).join("\n");
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------- pytest
|
|
53
|
+
export function reducePytest(output) {
|
|
54
|
+
let summaryLine = "";
|
|
55
|
+
const failures = [];
|
|
56
|
+
const xfailLines = [];
|
|
57
|
+
let state = "header";
|
|
58
|
+
let current = [];
|
|
59
|
+
const flush = () => {
|
|
60
|
+
if (current.length) {
|
|
61
|
+
failures.push(current.join("\n"));
|
|
62
|
+
current = [];
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
for (const line of output.split("\n")) {
|
|
66
|
+
const t = line.trim();
|
|
67
|
+
if (t.startsWith("===") && t.includes("test session starts")) {
|
|
68
|
+
state = "header";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (t.startsWith("===") && t.includes("FAILURES")) {
|
|
72
|
+
state = "failures";
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (t.startsWith("===") && t.includes("short test summary")) {
|
|
76
|
+
state = "summary";
|
|
77
|
+
flush();
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (t.startsWith("===") && (t.includes("passed") || t.includes("failed") || t.includes("skipped"))) {
|
|
81
|
+
summaryLine = t;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (!summaryLine &&
|
|
85
|
+
!t.startsWith("===") &&
|
|
86
|
+
!t.startsWith("FAILED") &&
|
|
87
|
+
!t.startsWith("ERROR") &&
|
|
88
|
+
(t.includes(" passed") || t.includes(" failed") || t.includes(" skipped")) &&
|
|
89
|
+
t.includes(" in ")) {
|
|
90
|
+
summaryLine = t;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if (state === "header") {
|
|
94
|
+
if (t.startsWith("collected"))
|
|
95
|
+
state = "progress";
|
|
96
|
+
}
|
|
97
|
+
else if (state === "progress") {
|
|
98
|
+
// 進捗ドット行は捨てる
|
|
99
|
+
}
|
|
100
|
+
else if (state === "failures") {
|
|
101
|
+
if (t.startsWith("___")) {
|
|
102
|
+
flush();
|
|
103
|
+
current.push(t);
|
|
104
|
+
}
|
|
105
|
+
else if (t && !t.startsWith("===")) {
|
|
106
|
+
current.push(t);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else if (state === "summary") {
|
|
110
|
+
if (t.startsWith("FAILED") || t.startsWith("ERROR"))
|
|
111
|
+
failures.push(t);
|
|
112
|
+
else if (t.startsWith("XFAIL") || t.startsWith("XPASS"))
|
|
113
|
+
xfailLines.push(t);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
flush();
|
|
117
|
+
const [p, f, s, xf, xp] = parsePytestCounts(summaryLine);
|
|
118
|
+
if (p === 0 && f === 0 && s === 0 && xf === 0 && xp === 0)
|
|
119
|
+
return "Pytest: No tests collected";
|
|
120
|
+
const extras = s > 0 || xf > 0 || xp > 0 || xfailLines.length > 0;
|
|
121
|
+
if (f === 0 && p > 0 && !extras)
|
|
122
|
+
return `Pytest: ${p} passed`;
|
|
123
|
+
let head = `Pytest: ${p} passed, ${f} failed`;
|
|
124
|
+
if (s > 0)
|
|
125
|
+
head += `, ${s} skipped`;
|
|
126
|
+
if (xf > 0)
|
|
127
|
+
head += `, ${xf} xfailed`;
|
|
128
|
+
if (xp > 0)
|
|
129
|
+
head += `, ${xp} xpassed`;
|
|
130
|
+
const out = [head, "═".repeat(39)];
|
|
131
|
+
if (xfailLines.length) {
|
|
132
|
+
out.push("", "Expected-failure outcomes:");
|
|
133
|
+
for (const ln of xfailLines.slice(0, MAX_XFAIL))
|
|
134
|
+
out.push(" " + truncate(ln, 120));
|
|
135
|
+
if (xfailLines.length > MAX_XFAIL)
|
|
136
|
+
out.push(` … +${xfailLines.length - MAX_XFAIL} more`);
|
|
137
|
+
}
|
|
138
|
+
if (failures.length) {
|
|
139
|
+
out.push("", "Failures:");
|
|
140
|
+
const shown = failures.slice(0, MAX_PYTEST_FAILURES);
|
|
141
|
+
for (let i = 0; i < shown.length; i++) {
|
|
142
|
+
const lines = shown[i].split("\n");
|
|
143
|
+
const first = lines[0];
|
|
144
|
+
if (first.startsWith("___")) {
|
|
145
|
+
const name = first.replace(/^_+|_+$/g, "").trim();
|
|
146
|
+
out.push(`${i + 1}. [FAIL] ${name}`);
|
|
147
|
+
}
|
|
148
|
+
else if (first.startsWith("FAILED")) {
|
|
149
|
+
const parts = first.split(" - ");
|
|
150
|
+
const name = parts[0].slice("FAILED".length).trim();
|
|
151
|
+
out.push(`${i + 1}. [FAIL] ${name}`);
|
|
152
|
+
// 失敗理由は全文保持(可読性優先。rtk 0.42.0 は最初の " - " segment で切るが、本実装は情報を残す)。
|
|
153
|
+
// 末尾セパレータは continue で入れない(rtk 0.42.0 と同じ)。
|
|
154
|
+
if (parts.length > 1)
|
|
155
|
+
out.push(" " + truncate(parts.slice(1).join(" - "), 100));
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
out.push(`${i + 1}. [FAIL] ${first}`);
|
|
160
|
+
}
|
|
161
|
+
let rel = 0;
|
|
162
|
+
for (const body of lines.slice(1)) {
|
|
163
|
+
const bt = body.trim();
|
|
164
|
+
const isRel = bt.startsWith(">") ||
|
|
165
|
+
bt.startsWith("E") ||
|
|
166
|
+
bt.toLowerCase().includes("assert") ||
|
|
167
|
+
bt.toLowerCase().includes("error") ||
|
|
168
|
+
body.includes(".py:");
|
|
169
|
+
if (isRel) {
|
|
170
|
+
out.push(" " + truncate(body, 100));
|
|
171
|
+
rel++;
|
|
172
|
+
if (rel >= PYTEST_RELEVANT_PER_FAIL)
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
// rtk 0.42.0 互換: セパレータ判定は表示数(shown)でなく全失敗数(failures)基準(cap 超過時の空行数まで一致)。
|
|
177
|
+
if (i < failures.length - 1)
|
|
178
|
+
out.push("");
|
|
179
|
+
}
|
|
180
|
+
if (failures.length > MAX_PYTEST_FAILURES)
|
|
181
|
+
out.push("", `… +${failures.length - MAX_PYTEST_FAILURES} more failures`);
|
|
182
|
+
}
|
|
183
|
+
return out.join("\n").trim();
|
|
184
|
+
}
|
|
185
|
+
function parsePytestCounts(summary) {
|
|
186
|
+
let p = 0, f = 0, s = 0, xf = 0, xp = 0;
|
|
187
|
+
for (const part of summary.split(",")) {
|
|
188
|
+
const words = part.split(/\s+/).filter(Boolean);
|
|
189
|
+
for (let i = 0; i < words.length; i++) {
|
|
190
|
+
if (i === 0)
|
|
191
|
+
continue;
|
|
192
|
+
const n = parseInt(words[i - 1], 10);
|
|
193
|
+
if (Number.isNaN(n))
|
|
194
|
+
continue;
|
|
195
|
+
const w = words[i];
|
|
196
|
+
if (w.includes("xpassed"))
|
|
197
|
+
xp = n;
|
|
198
|
+
else if (w.includes("xfailed"))
|
|
199
|
+
xf = n;
|
|
200
|
+
else if (w.includes("passed"))
|
|
201
|
+
p = n;
|
|
202
|
+
else if (w.includes("failed"))
|
|
203
|
+
f = n;
|
|
204
|
+
else if (w.includes("skipped"))
|
|
205
|
+
s = n;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return [p, f, s, xf, xp];
|
|
209
|
+
}
|
|
210
|
+
// ---------------------------------------------------------------- grep
|
|
211
|
+
const GREP_LINE = /^(.*?):(\d+):(.*)$/;
|
|
212
|
+
function compactPath(p) {
|
|
213
|
+
if (p.length <= COMPACT_PATH_THRESH)
|
|
214
|
+
return p;
|
|
215
|
+
const parts = p.split("/");
|
|
216
|
+
if (parts.length <= 3)
|
|
217
|
+
return p;
|
|
218
|
+
return `${parts[0]}/.../${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
|
|
219
|
+
}
|
|
220
|
+
export function reduceGrep(output) {
|
|
221
|
+
const byFile = new Map();
|
|
222
|
+
const order = [];
|
|
223
|
+
let total = 0;
|
|
224
|
+
for (const line of output.split("\n")) {
|
|
225
|
+
const m = GREP_LINE.exec(line);
|
|
226
|
+
if (!m)
|
|
227
|
+
continue;
|
|
228
|
+
const fname = m[1], lineno = m[2], content = m[3];
|
|
229
|
+
if (!fname || !/^\d+$/.test(lineno))
|
|
230
|
+
continue;
|
|
231
|
+
total++;
|
|
232
|
+
if (!byFile.has(fname)) {
|
|
233
|
+
byFile.set(fname, []);
|
|
234
|
+
order.push(fname);
|
|
235
|
+
}
|
|
236
|
+
byFile.get(fname).push([lineno, truncate(content.trim(), GREP_MAX_LEN)]);
|
|
237
|
+
}
|
|
238
|
+
if (total === 0)
|
|
239
|
+
return null;
|
|
240
|
+
const out = [`${total} matches in ${byFile.size} files:`, ""];
|
|
241
|
+
let shown = 0;
|
|
242
|
+
for (const fname of [...order].sort()) {
|
|
243
|
+
if (shown >= CAP_GREP_TOTAL)
|
|
244
|
+
break;
|
|
245
|
+
const disp = compactPath(fname);
|
|
246
|
+
for (const [lineno, content] of byFile.get(fname).slice(0, CAP_GREP_PER_FILE)) {
|
|
247
|
+
if (shown >= CAP_GREP_TOTAL)
|
|
248
|
+
break;
|
|
249
|
+
out.push(`${disp}:${lineno}:${content}`);
|
|
250
|
+
shown++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (total > shown)
|
|
254
|
+
out.push(`[+${total - shown} more]`);
|
|
255
|
+
return out.join("\n");
|
|
256
|
+
}
|
|
257
|
+
// ---------------------------------------------------------------- git status / log
|
|
258
|
+
const GIT_HINT = /^\(use "git |^\(create\/copy files/;
|
|
259
|
+
export function reduceGitStatus(output) {
|
|
260
|
+
const lines = output.split("\n");
|
|
261
|
+
const nonempty = lines.filter((ln) => ln.trim());
|
|
262
|
+
if (!nonempty.length)
|
|
263
|
+
return null;
|
|
264
|
+
const porc = nonempty.filter((ln) => /^(##|[ MADRCU?!]{2}) /.test(ln));
|
|
265
|
+
if (porc.length && porc.length >= nonempty.length - 1) {
|
|
266
|
+
const out = [];
|
|
267
|
+
for (let i = 0; i < nonempty.length; i++) {
|
|
268
|
+
const ln = nonempty[i];
|
|
269
|
+
if (i === 0 && ln.startsWith("## "))
|
|
270
|
+
out.push("* " + ln.slice(3));
|
|
271
|
+
else
|
|
272
|
+
out.push(ln);
|
|
273
|
+
}
|
|
274
|
+
return out.join("\n");
|
|
275
|
+
}
|
|
276
|
+
const kept = [];
|
|
277
|
+
for (const ln of lines) {
|
|
278
|
+
const t = ln.trim();
|
|
279
|
+
if (!t)
|
|
280
|
+
continue;
|
|
281
|
+
if (GIT_HINT.test(t) || t.includes('(use "git add') || t.includes('(use "git restore'))
|
|
282
|
+
continue;
|
|
283
|
+
if (t.includes("nothing to commit") && t.includes("working tree clean")) {
|
|
284
|
+
kept.push(t);
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
kept.push(ln);
|
|
288
|
+
}
|
|
289
|
+
return kept.length ? kept.join("\n") : "ok";
|
|
290
|
+
}
|
|
291
|
+
export function reduceGitLog(output) {
|
|
292
|
+
if (!output.includes("commit "))
|
|
293
|
+
return null;
|
|
294
|
+
const blocks = output.split(/(?=^commit [0-9a-f]{7,40})/m).filter((b) => b.trim().startsWith("commit "));
|
|
295
|
+
if (!blocks.length)
|
|
296
|
+
return null;
|
|
297
|
+
const out = [];
|
|
298
|
+
for (const b of blocks.slice(0, LOG_LIMIT_DEFAULT)) {
|
|
299
|
+
const bl = b.split("\n").map((x) => x.replace(/\s+$/, ""));
|
|
300
|
+
let commit = "", author = "", subject = "";
|
|
301
|
+
const body = [];
|
|
302
|
+
for (const ln of bl) {
|
|
303
|
+
const t = ln.trim();
|
|
304
|
+
if (t.startsWith("commit "))
|
|
305
|
+
commit = t.split(/\s+/)[1].slice(0, 9);
|
|
306
|
+
else if (t.startsWith("Author:"))
|
|
307
|
+
author = t.slice("Author:".length).trim();
|
|
308
|
+
else if (t.startsWith("Date:") || t.startsWith("Merge:"))
|
|
309
|
+
continue;
|
|
310
|
+
else if (t && !subject && (ln.startsWith(" ") || ln.startsWith("\t")))
|
|
311
|
+
subject = t;
|
|
312
|
+
else if (t && (ln.startsWith(" ") || ln.startsWith("\t"))) {
|
|
313
|
+
if (!t.startsWith("Signed-off-by:") && !t.startsWith("Co-authored-by:"))
|
|
314
|
+
body.push(t);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const am = /<([^>]+)>/.exec(author);
|
|
318
|
+
const who = am ? am[1] : author;
|
|
319
|
+
let head = truncate(`${commit} ${subject}`.trim(), LOG_WIDTH);
|
|
320
|
+
if (who)
|
|
321
|
+
head += ` <${who}>`;
|
|
322
|
+
const entry = [head];
|
|
323
|
+
for (const bln of body.slice(0, LOG_BODY_LINES))
|
|
324
|
+
entry.push(" " + truncate(bln, LOG_WIDTH));
|
|
325
|
+
if (body.length > LOG_BODY_LINES)
|
|
326
|
+
entry.push(` [+${body.length - LOG_BODY_LINES} lines omitted]`);
|
|
327
|
+
out.push(entry.join("\n"));
|
|
328
|
+
}
|
|
329
|
+
return out.join("\n").trim();
|
|
330
|
+
}
|
|
331
|
+
const FILTERS = [
|
|
332
|
+
{ name: "df", match: /^df\b/, strip: [/^$/], maxLines: 40, onEmpty: "df: ok" },
|
|
333
|
+
{ name: "free", match: /^free\b/, strip: [/^$/], maxLines: 20, onEmpty: "free: ok" },
|
|
334
|
+
{ name: "make", match: /^make\b/, strip: [/^make\[\d+\]:/, /^$/, /^Nothing to be done/], maxLines: 50, onEmpty: "make: ok" },
|
|
335
|
+
{ name: "systemctl", match: /^systemctl\s+status\b/, strip: [/^\s*$/], maxLines: 30, onEmpty: "systemctl: ok" },
|
|
336
|
+
];
|
|
337
|
+
function applyFilter(rule, output) {
|
|
338
|
+
const lines = output.split("\n");
|
|
339
|
+
const kept = [];
|
|
340
|
+
for (const ln of lines) {
|
|
341
|
+
if (rule.strip && rule.strip.some((r) => r.test(ln)))
|
|
342
|
+
continue;
|
|
343
|
+
if (rule.keep && rule.keep.length && !rule.keep.some((r) => r.test(ln)))
|
|
344
|
+
continue;
|
|
345
|
+
kept.push(ln);
|
|
346
|
+
}
|
|
347
|
+
let result = kept;
|
|
348
|
+
if (rule.maxLines && kept.length > rule.maxLines) {
|
|
349
|
+
const omitted = kept.length - rule.maxLines;
|
|
350
|
+
result = [...kept.slice(0, rule.maxLines), `... (${omitted} lines truncated)`];
|
|
351
|
+
}
|
|
352
|
+
const body = result.join("\n").trim();
|
|
353
|
+
if (!body && rule.onEmpty)
|
|
354
|
+
return rule.onEmpty;
|
|
355
|
+
return body;
|
|
356
|
+
}
|
|
357
|
+
// ---------------------------------------------------------------- ルーティング
|
|
358
|
+
function basenameCmd(command) {
|
|
359
|
+
const toks = command.trim().split(/\s+/).filter(Boolean);
|
|
360
|
+
let i = 0;
|
|
361
|
+
while (i < toks.length && (toks[i].includes("=") || ["sudo", "env", "command", "exec"].includes(toks[i])))
|
|
362
|
+
i++;
|
|
363
|
+
if (i >= toks.length)
|
|
364
|
+
return ["", ""];
|
|
365
|
+
const verb = toks[i].split("/").pop();
|
|
366
|
+
let sub = "";
|
|
367
|
+
for (const t of toks.slice(i + 1)) {
|
|
368
|
+
if (!t.startsWith("-")) {
|
|
369
|
+
sub = t;
|
|
370
|
+
break;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return [verb, sub];
|
|
374
|
+
}
|
|
375
|
+
export function classify(command) {
|
|
376
|
+
const [verb, sub] = basenameCmd(command);
|
|
377
|
+
if (verb === "git") {
|
|
378
|
+
if (sub === "status")
|
|
379
|
+
return "git-status";
|
|
380
|
+
if (sub === "log")
|
|
381
|
+
return "git-log";
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
if (verb === "grep" || verb === "rg")
|
|
385
|
+
return "grep";
|
|
386
|
+
if (verb === "pytest" || verb === "py.test")
|
|
387
|
+
return "pytest";
|
|
388
|
+
if (verb === "python" && command.includes("pytest"))
|
|
389
|
+
return "pytest";
|
|
390
|
+
for (const rule of FILTERS)
|
|
391
|
+
if (rule.match.test(command.trim()))
|
|
392
|
+
return rule.name;
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
const REDUCERS = {
|
|
396
|
+
"git-status": reduceGitStatus,
|
|
397
|
+
"git-log": reduceGitLog,
|
|
398
|
+
grep: reduceGrep,
|
|
399
|
+
pytest: (o) => reducePytest(o),
|
|
400
|
+
};
|
|
401
|
+
/** コマンドに応じた reducer を観測出力へ適用。返り値 [reducedText|null, reducerName|null]。 */
|
|
402
|
+
export function reduce(command, output) {
|
|
403
|
+
const name = classify(command);
|
|
404
|
+
if (name === null)
|
|
405
|
+
return [null, null];
|
|
406
|
+
if (name in REDUCERS) {
|
|
407
|
+
const red = REDUCERS[name](output);
|
|
408
|
+
return red !== null ? [red, name] : [null, null];
|
|
409
|
+
}
|
|
410
|
+
for (const rule of FILTERS)
|
|
411
|
+
if (rule.name === name)
|
|
412
|
+
return [applyFilter(rule, output), name];
|
|
413
|
+
return [null, null];
|
|
414
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aiterm-mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"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
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"model-context-protocol",
|
|
8
|
+
"terminal",
|
|
9
|
+
"tmux",
|
|
10
|
+
"pty",
|
|
11
|
+
"claude",
|
|
12
|
+
"ai-agent",
|
|
13
|
+
"ssh"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "kitepon",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/kitepon-rgb/aiterm-mcp.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/kitepon-rgb/aiterm-mcp/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/kitepon-rgb/aiterm-mcp#readme",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"aiterm-mcp": "dist/index.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsc",
|
|
39
|
+
"prepublishOnly": "npm run build",
|
|
40
|
+
"start": "node dist/index.js",
|
|
41
|
+
"test": "npm run build && node --test test/*.test.mjs"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
45
|
+
"zod": "^4.4.3"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^25.9.1",
|
|
49
|
+
"typescript": "^6.0.3"
|
|
50
|
+
}
|
|
51
|
+
}
|