aiterm-mcp 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +21 -2
- package/README.md +21 -2
- package/dist/core.js +167 -10
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/kitepon-rgb/aiterm-mcp/actions/workflows/ci.yml)
|
|
8
8
|
[](https://www.npmjs.com/package/aiterm-mcp)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
|
+
[](LICENSE)
|
|
9
11
|
|
|
10
12
|
> *(English: [README.md](README.md))*
|
|
11
13
|
|
|
@@ -13,6 +15,21 @@
|
|
|
13
15
|
|
|
14
16
|
`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` の 6 ツールだけ。バックエンドは tmux なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
|
|
15
17
|
|
|
18
|
+
## インストール
|
|
19
|
+
|
|
20
|
+
npm に公開済み。clone もビルドも不要:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Claude Code — 推奨(インストール不要、npx が毎回取得して起動)
|
|
24
|
+
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
25
|
+
|
|
26
|
+
# またはグローバル導入してコマンド名で登録
|
|
27
|
+
npm i -g aiterm-mcp
|
|
28
|
+
claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Node ≥ 18** と **tmux** が必要(Windows ネイティブでは WSL とその中の tmux。下の「要件」参照)。他の MCP クライアントは stdio で `npx -y aiterm-mcp` を起動するだけ(詳細は下の「インストール / 登録」)。
|
|
32
|
+
|
|
16
33
|
## なぜ
|
|
17
34
|
|
|
18
35
|
AI にコマンドを 1 個ずつ投げて結果を受け取る往復は、SSH では毎回「接続→認証→切断」を繰り返し遅く、トークンも食う。aiterm は **1 個の PTY を永続的に握り**、その中で `ssh host` や `docker exec -it x bash` と打って入る(ネスト)。セッション種別をツールで区別しない。
|
|
@@ -50,6 +67,8 @@ flowchart LR
|
|
|
50
67
|
|
|
51
68
|
- **Node.js >= 18**
|
|
52
69
|
- **tmux**(実行時の前提。`tmux -V` で確認。未導入なら `apt install tmux` / `brew install tmux`)
|
|
70
|
+
- **macOS / Linux / WSL2** は tmux を直接使う。macOS は同梱されないので `brew install tmux` で導入する。MCP クライアントがターミナルでなく **GUI から起動**された場合、Homebrew の bin(Apple Silicon: `/opt/homebrew/bin`、Intel: `/usr/local/bin`)が `PATH` に入らないことがある。その場合 aiterm が自動で探索するか、**`AITERM_TMUX=/path/to/tmux`** で明示指定する。
|
|
71
|
+
- **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 に入る。)
|
|
53
72
|
- 任意: [`rtk`](https://github.com/rtk-ai/rtk) バイナリ(`pty_send` の `rtk: true` 委譲で使う。無くても動く)
|
|
54
73
|
|
|
55
74
|
## インストール / 登録
|
|
@@ -57,7 +76,7 @@ flowchart LR
|
|
|
57
76
|
Claude Code(CLI)にユーザースコープ(全プロジェクトで利用可)で登録する例:
|
|
58
77
|
|
|
59
78
|
```bash
|
|
60
|
-
#
|
|
79
|
+
# 推奨: インストール不要、npx が毎回取得して起動
|
|
61
80
|
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
62
81
|
|
|
63
82
|
# またはグローバルインストールしてコマンド名で
|
|
@@ -106,7 +125,7 @@ claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
|
|
|
106
125
|
|
|
107
126
|
## 人が覗く
|
|
108
127
|
|
|
109
|
-
セッションは共有 tmux ソケット上にある。`pty_open` の戻り値に表示される `tmux -S … attach -t <id>` で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)。
|
|
128
|
+
セッションは共有 tmux ソケット上にある。`pty_open` の戻り値に表示される `tmux -S … attach -t <id>` で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)。Windows ネイティブではセッションが WSL 内にあるため、表示は WSL 形(`wsl tmux -S … attach -t <id>`)になる。
|
|
110
129
|
|
|
111
130
|
## 開発
|
|
112
131
|
|
package/README.md
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
[](https://github.com/kitepon-rgb/aiterm-mcp/actions/workflows/ci.yml)
|
|
8
8
|
[](https://www.npmjs.com/package/aiterm-mcp)
|
|
9
|
+
[](https://nodejs.org)
|
|
10
|
+
[](LICENSE)
|
|
9
11
|
|
|
10
12
|
> *(日本語: [README.ja.md](README.ja.md))*
|
|
11
13
|
|
|
@@ -13,6 +15,21 @@
|
|
|
13
15
|
|
|
14
16
|
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
17
|
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
It's on npm — no clone, no build:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Claude Code — recommended (no install; npx fetches it each run)
|
|
24
|
+
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
25
|
+
|
|
26
|
+
# or install globally, then register the command name
|
|
27
|
+
npm i -g aiterm-mcp
|
|
28
|
+
claude mcp add --scope user --transport stdio aiterm -- aiterm-mcp
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
16
33
|
## Why
|
|
17
34
|
|
|
18
35
|
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.
|
|
@@ -50,6 +67,8 @@ One PTY is the only primitive. Everything else — SSH, containers, REPLs — is
|
|
|
50
67
|
|
|
51
68
|
- **Node.js >= 18**
|
|
52
69
|
- **tmux** (runtime prerequisite; check with `tmux -V`. Install with `apt install tmux` / `brew install tmux`)
|
|
70
|
+
- **macOS / Linux / WSL2** run tmux directly. On macOS install it with `brew install tmux` (stock macOS ships none). If your MCP client is launched from the **GUI** rather than a terminal, Homebrew's bin (`/opt/homebrew/bin` on Apple Silicon, `/usr/local/bin` on Intel) may be off its `PATH`; aiterm auto-searches those locations, or set **`AITERM_TMUX=/path/to/tmux`** to point at it explicitly.
|
|
71
|
+
- **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.)
|
|
53
72
|
- Optional: the [`rtk`](https://github.com/rtk-ai/rtk) binary (used by `pty_send`'s `rtk: true` delegation; works fine without it)
|
|
54
73
|
|
|
55
74
|
## Install / register
|
|
@@ -57,7 +76,7 @@ One PTY is the only primitive. Everything else — SSH, containers, REPLs — is
|
|
|
57
76
|
Register with Claude Code (CLI) at user scope (available in every project):
|
|
58
77
|
|
|
59
78
|
```bash
|
|
60
|
-
#
|
|
79
|
+
# Recommended — no install, npx fetches it each run
|
|
61
80
|
claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
62
81
|
|
|
63
82
|
# Or install globally and use the command name
|
|
@@ -106,7 +125,7 @@ Before sending, `pty_send` blocks destructive commands (`rm -rf /`, `mkfs`, `dd
|
|
|
106
125
|
|
|
107
126
|
## A human can watch
|
|
108
127
|
|
|
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).
|
|
128
|
+
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.
|
|
110
129
|
|
|
111
130
|
## Development
|
|
112
131
|
|
package/dist/core.js
CHANGED
|
@@ -10,9 +10,22 @@
|
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
11
|
import * as fs from "node:fs";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
13
15
|
import * as rtk from "./rtk.js";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
// Windows ネイティブには tmux が無い。その場合だけ全 tmux 呼び出しを WSL 経由へ橋渡しする
|
|
17
|
+
// (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く)。
|
|
18
|
+
const isWin = process.platform === "win32";
|
|
19
|
+
// .log/.offset/.lastcmd を置くディレクトリ(Node が直接読み書きする)。
|
|
20
|
+
// POSIX は従来どおり。Windows は TMPDIR→TEMP→os.tmpdir() の順で Windows 側の一時領域に置く。
|
|
21
|
+
const SOCKDIR = path.join(process.env.TMPDIR ?? (isWin ? process.env.TEMP ?? os.tmpdir() : "/tmp"), "claude-tmux-sockets");
|
|
22
|
+
// tmux -S に渡すソケットパス。POSIX はログと同じツリーに置く。
|
|
23
|
+
// Windows は tmux が WSL 内で動くため、ソケットは WSL ネイティブ fs に置く必要がある
|
|
24
|
+
// (/mnt drvfs(9p) 上では AF_UNIX 非対応)。SOCKDIR から短い安定名を導出し、
|
|
25
|
+
// TMPDIR ごとの隔離(テスト)と再起動跨ぎ再接続を両立する。
|
|
26
|
+
const SOCK = isWin
|
|
27
|
+
? `/tmp/aiterm-${createHash("sha1").update(SOCKDIR).digest("hex").slice(0, 12)}.sock`
|
|
28
|
+
: path.join(SOCKDIR, "claude.sock");
|
|
16
29
|
// 完了検出
|
|
17
30
|
export const DEFAULT_TIMEOUT = 10.0;
|
|
18
31
|
const POLL = 0.25;
|
|
@@ -55,15 +68,103 @@ export class AitermError extends Error {
|
|
|
55
68
|
this.code = code;
|
|
56
69
|
}
|
|
57
70
|
}
|
|
71
|
+
// Windows のドライブパス (C:\a\b) を WSL から見える /mnt/c/a/b へ変換する。
|
|
72
|
+
// 一時領域は常にドライブ直下なので UNC は想定外=弾く(黙って壊れた //server パスを作らない)。
|
|
73
|
+
export function toWslPath(p) {
|
|
74
|
+
const m = /^([A-Za-z]):\/(.*)$/.exec(p.replace(/\\/g, "/"));
|
|
75
|
+
if (!m)
|
|
76
|
+
throw new AitermError(`WSL へ橋渡しできない一時パスです(ドライブ直下のみ対応): ${p}`, 2);
|
|
77
|
+
return `/mnt/${m[1].toLowerCase()}/${m[2]}`;
|
|
78
|
+
}
|
|
79
|
+
// Windows で最初の tmux 呼び出し前に一度だけ WSL+tmux の可用性を確かめ、失敗は原因別に投げる。
|
|
80
|
+
// -e(ログインシェル非経由)+短い timeout で、初回セットアップ未完了の wsl によるハングも防ぐ。
|
|
81
|
+
let winBridgeOk = false;
|
|
82
|
+
function ensureWinBridge() {
|
|
83
|
+
if (winBridgeOk)
|
|
84
|
+
return;
|
|
85
|
+
const r = spawnSync("wsl.exe", ["-e", "tmux", "-V"], { encoding: "utf8", timeout: 10000 });
|
|
86
|
+
if (r.error) {
|
|
87
|
+
const code = r.error.code;
|
|
88
|
+
if (code === "ETIMEDOUT")
|
|
89
|
+
throw new AitermError("WSL が応答しません(初回セットアップ未完了の可能性)。一度 `wsl` を起動してから再実行してください。", 2);
|
|
90
|
+
if (code === "ENOENT")
|
|
91
|
+
throw new AitermError("wsl.exe が見つかりません。Windows では WSL 上の tmux 経由で動作します。WSL と tmux を導入してください。", 2);
|
|
92
|
+
throw new AitermError(`wsl.exe を起動できませんでした(${code ?? "unknown"})。`, 2);
|
|
93
|
+
}
|
|
94
|
+
// wsl.exe は System32 にあるので「起動」は成功するが、ディストリ未導入や distro 内に tmux が無いと
|
|
95
|
+
// 非ゼロで終わる。両方を区別せず(wsl の出力は UTF-16 で文字化けし得るため)正直に表す。
|
|
96
|
+
if (r.status !== 0)
|
|
97
|
+
throw new AitermError("WSL 経由で tmux を起動できませんでした。WSL のディストリ未導入、または distro 内に tmux が無い可能性があります。`wsl tmux -V` が通るか確認してください(tmux 導入例: sudo apt install tmux)。", 2);
|
|
98
|
+
winBridgeOk = true;
|
|
99
|
+
}
|
|
100
|
+
// tmux が見つからないときの説明。macOS は tmux を同梱せず、Homebrew の bin は GUI 起動時の PATH に
|
|
101
|
+
// 入らないため、原因と対処(導入・自動探索・AITERM_TMUX 上書き)を正直に提示する。
|
|
102
|
+
function tmuxMissingMessage() {
|
|
103
|
+
const head = "tmux が見つかりません(PATH 上に存在しません)。aiterm は実行時に tmux を必要とします。";
|
|
104
|
+
const hint = process.platform === "darwin"
|
|
105
|
+
? "macOS では `brew install tmux` で導入してください。GUI から起動された場合、Homebrew の bin " +
|
|
106
|
+
"(Apple Silicon: /opt/homebrew/bin、Intel: /usr/local/bin)が PATH に含まれないことがあります" +
|
|
107
|
+
"(その場合 aiterm が自動で探索します)。"
|
|
108
|
+
: "(例: Debian/Ubuntu は `sudo apt install tmux`)。";
|
|
109
|
+
const override = "別の場所にある tmux を使うには AITERM_TMUX=/path/to/tmux を指定してください。";
|
|
110
|
+
return `${head}${hint}${override}`;
|
|
111
|
+
}
|
|
112
|
+
// POSIX(Linux/WSL2/macOS) 用の tmux バイナリ解決。Windows の ensureWinBridge() に対応する事前確認。
|
|
113
|
+
// 解決順: AITERM_TMUX(明示指定)→ PATH 上の tmux → Homebrew 既定パス。一度だけ実行しキャッシュする。
|
|
114
|
+
// 見つからなければ tmuxMissingMessage で投げる(黙ってフォールバックせず、原因が見えるようにする)。
|
|
115
|
+
let tmuxBin = null;
|
|
116
|
+
function resolveTmux() {
|
|
117
|
+
if (tmuxBin)
|
|
118
|
+
return tmuxBin;
|
|
119
|
+
const override = process.env.AITERM_TMUX;
|
|
120
|
+
if (override) {
|
|
121
|
+
const r = spawnSync(override, ["-V"], { encoding: "utf8", timeout: 5000 });
|
|
122
|
+
if (!r.error && r.status === 0)
|
|
123
|
+
return (tmuxBin = override);
|
|
124
|
+
throw new AitermError(`AITERM_TMUX に指定された tmux を起動できません: ${override}(\`${override} -V\` が通りません)`, 2);
|
|
125
|
+
}
|
|
126
|
+
// CLI/開発時は PATH 上の tmux をそのまま使う(最優先)。
|
|
127
|
+
const onPath = spawnSync("tmux", ["-V"], { encoding: "utf8", timeout: 5000 });
|
|
128
|
+
if (!onPath.error && onPath.status === 0)
|
|
129
|
+
return (tmuxBin = "tmux");
|
|
130
|
+
// GUI 起動などで PATH に Homebrew の bin が無い場合、既定の場所を探す。
|
|
131
|
+
// 使う場合は黙らず stderr へ告知する(stdout は JSON-RPC 専用=index.ts の制約)。
|
|
132
|
+
for (const cand of ["/opt/homebrew/bin/tmux", "/usr/local/bin/tmux"]) {
|
|
133
|
+
try {
|
|
134
|
+
fs.accessSync(cand, fs.constants.X_OK);
|
|
135
|
+
console.error(`aiterm: tmux が PATH 上に無いため ${cand} を使用します`);
|
|
136
|
+
return (tmuxBin = cand);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* 次の候補へ */
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
throw new AitermError(tmuxMissingMessage(), 2);
|
|
143
|
+
}
|
|
58
144
|
function tmux(...args) {
|
|
59
145
|
// maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
|
|
60
146
|
// 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
|
|
61
|
-
|
|
147
|
+
// Windows は同じ tmux を WSL 経由(-e でログインシェル非経由=$ 展開やクオート崩れを防ぐ)で叩く。
|
|
148
|
+
let r;
|
|
149
|
+
if (isWin) {
|
|
150
|
+
ensureWinBridge();
|
|
151
|
+
r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
|
|
155
|
+
r = spawnSync(resolveTmux(), ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
156
|
+
}
|
|
62
157
|
// ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
|
|
63
158
|
// EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
|
|
64
159
|
if (r.error && r.error.code === "ENOBUFS") {
|
|
65
160
|
throw new AitermError(`tmux 出力が 64MiB を超えました(${args[0]})。範囲を絞って読んでください。`, 2);
|
|
66
161
|
}
|
|
162
|
+
// 解決済み tmux が実行時に消えた(アンインストール等)場合の ENOENT を、空 stderr の code=1 へ握り潰さない。
|
|
163
|
+
// 通常は resolveTmux() が事前に弾くため発火しないが、mid-run 消滅に対する正直な防御。
|
|
164
|
+
if (!isWin && r.error && r.error.code === "ENOENT") {
|
|
165
|
+
tmuxBin = null; // 次回 resolveTmux で再解決を許す
|
|
166
|
+
throw new AitermError(tmuxMissingMessage(), 2);
|
|
167
|
+
}
|
|
67
168
|
return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
68
169
|
}
|
|
69
170
|
function sessionExists(name) {
|
|
@@ -73,6 +174,12 @@ function paneCurrentCommand(name) {
|
|
|
73
174
|
const r = tmux("display-message", "-p", "-t", name, "#{pane_current_command}");
|
|
74
175
|
return r.code === 0 ? r.stdout.trim() : "";
|
|
75
176
|
}
|
|
177
|
+
// session 名はファイルパス(logpath 等)と pipe-pane の /bin/sh 文字列へ流れる。英数 _ - のみ・64字に
|
|
178
|
+
// 限定し、パストラバーサル(../)とシェルインジェクション(' でのクオート破り・$・; 等)を全入口で断つ。
|
|
179
|
+
function assertSessionName(name) {
|
|
180
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(name))
|
|
181
|
+
throw new AitermError(`session 名は英数字と _ - のみ・64文字以内にしてください: ${JSON.stringify(name)}`, 2);
|
|
182
|
+
}
|
|
76
183
|
function logpath(name) {
|
|
77
184
|
return path.join(SOCKDIR, name + ".log");
|
|
78
185
|
}
|
|
@@ -111,8 +218,10 @@ function readLastcmd(name) {
|
|
|
111
218
|
}
|
|
112
219
|
}
|
|
113
220
|
export function attachHint(name) {
|
|
221
|
+
// Windows は WSL 内 tmux なので、人が打つのは wsl 経由(SOCK は WSL パス)。
|
|
222
|
+
const cmd = isWin ? `wsl tmux -S ${SOCK} attach -t ${name}` : `tmux -S ${SOCK} attach -t ${name}`;
|
|
114
223
|
return (`このセッションを自分の目で見る/介入する:\n` +
|
|
115
|
-
`
|
|
224
|
+
` ${cmd}\n` +
|
|
116
225
|
` (抜けるには Ctrl-b d)`);
|
|
117
226
|
}
|
|
118
227
|
// ---------------------------------------------------------------- 出力削減
|
|
@@ -204,6 +313,25 @@ function captureScreen(name, lines) {
|
|
|
204
313
|
return r.code === 0 ? r.stdout : "";
|
|
205
314
|
}
|
|
206
315
|
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));
|
|
316
|
+
// Windows 専用: pipe-pane のログは /mnt/c (9p) 境界を越えて書かれるため、tmux が完了/セッション
|
|
317
|
+
// 消滅を報告した後も最後の数百バイトが少し遅れて現れる。完了と判定する直前にログサイズが
|
|
318
|
+
// 伸びなくなるまで待ち、readOutput が末尾欠けの出力を返さないようにする(POSIX は同一fsゆえ不要)。
|
|
319
|
+
async function settleWinLog(name) {
|
|
320
|
+
let prev = -1;
|
|
321
|
+
for (let i = 0; i < 8; i++) {
|
|
322
|
+
let sz = 0;
|
|
323
|
+
try {
|
|
324
|
+
sz = fs.statSync(logpath(name)).size;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
sz = 0;
|
|
328
|
+
}
|
|
329
|
+
if (sz === prev)
|
|
330
|
+
return;
|
|
331
|
+
prev = sz;
|
|
332
|
+
await sleep(POLL * 1000);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
207
335
|
/** 完了境界の4層: dead / until 一致 / (出力静止 ∧ シェルに戻った) / timeout。 */
|
|
208
336
|
async function waitCompletion(name, untilRe, timeout) {
|
|
209
337
|
// 締切は単調時計で測る。Date.now() は NTP 補正やサスペンドで巻き戻り、長時間待ちで誤判定する(Python は time.monotonic)。
|
|
@@ -227,12 +355,18 @@ async function waitCompletion(name, untilRe, timeout) {
|
|
|
227
355
|
if (until.test(stripControl(neu)))
|
|
228
356
|
return [true, "until"];
|
|
229
357
|
}
|
|
230
|
-
if (!alive)
|
|
358
|
+
if (!alive) {
|
|
359
|
+
if (isWin)
|
|
360
|
+
await settleWinLog(name);
|
|
231
361
|
return [true, "dead"];
|
|
362
|
+
}
|
|
232
363
|
if (size === lastSize) {
|
|
233
364
|
stable++;
|
|
234
|
-
if (stable >= STABLE_POLLS && SHELLS.has(paneCurrentCommand(name)))
|
|
365
|
+
if (stable >= STABLE_POLLS && SHELLS.has(paneCurrentCommand(name))) {
|
|
366
|
+
if (isWin)
|
|
367
|
+
await settleWinLog(name);
|
|
235
368
|
return [true, "quiescent"];
|
|
369
|
+
}
|
|
236
370
|
}
|
|
237
371
|
else {
|
|
238
372
|
stable = 0;
|
|
@@ -247,7 +381,10 @@ function rtkRewrite(text) {
|
|
|
247
381
|
if (text.trim().includes("\n"))
|
|
248
382
|
return text;
|
|
249
383
|
// timeout 無しだと rtk がハングしたとき send() ごと凍結する。Python は timeout=5。
|
|
250
|
-
|
|
384
|
+
// Windows はコマンドが WSL 内で走るので rtk も WSL 側(-e)で評価する。不在は素通し。
|
|
385
|
+
const r = isWin
|
|
386
|
+
? spawnSync("wsl.exe", ["-e", "rtk", "rewrite", text], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 })
|
|
387
|
+
: spawnSync("rtk", ["rewrite", text], { encoding: "utf8", timeout: 5000, maxBuffer: 1024 * 1024 });
|
|
251
388
|
if (r.error)
|
|
252
389
|
return text; // rtk 不在(ENOENT)・タイムアウト(ETIMEDOUT)等は元テキストへ素通し
|
|
253
390
|
if ((r.status === 0 || r.status === 3) && (r.stdout ?? "").trim())
|
|
@@ -258,17 +395,30 @@ function rtkRewrite(text) {
|
|
|
258
395
|
export function openSession(name, shell = "bash") {
|
|
259
396
|
fs.mkdirSync(SOCKDIR, { recursive: true });
|
|
260
397
|
const nm = name || autoName();
|
|
398
|
+
assertSessionName(nm);
|
|
261
399
|
if (sessionExists(nm))
|
|
262
400
|
throw new AitermError(`session '${nm}' は既に存在します(list で確認)`, 2);
|
|
263
|
-
|
|
401
|
+
// macOS の /bin/bash は 3.2 で、起動時に zsh 移行バナーを出して最初の read を汚す。darwin かつ bash の
|
|
402
|
+
// ときだけ -e で環境変数を渡して抑止する。-e は tmux>=3.2 が必要だが macOS の Homebrew tmux は常に該当。
|
|
403
|
+
// (古い tmux<3.2 が残る Linux/WSL で -e を渡すと new-session が落ちるため、darwin 限定にする。)
|
|
404
|
+
const banner = process.platform === "darwin" && path.basename(shell) === "bash"
|
|
405
|
+
? ["-e", "BASH_SILENCE_DEPRECATION_WARNING=1"]
|
|
406
|
+
: [];
|
|
407
|
+
const r = tmux("new-session", "-d", "-s", nm, ...banner, "-f", "/dev/null", shell);
|
|
264
408
|
if (r.code !== 0)
|
|
265
409
|
throw new AitermError("tmux new-session 失敗: " + r.stderr.trim(), 2);
|
|
266
410
|
fs.closeSync(fs.openSync(logpath(nm), "a")); // touch
|
|
267
|
-
|
|
411
|
+
// pipe-pane の引数は tmux 内部の /bin/sh -c で再解釈される(argv ではない)。パスは単一引用符で包み、
|
|
412
|
+
// パス自身の ' は '\'' イディオムでエスケープする(名前は検証済みだが、Windows ユーザー名 O'Brien 等が
|
|
413
|
+
// 一時パスに ' を持ち込み redirect を壊すのを防ぐ。空白対策も兼ねる)。Windows は WSL から見える /mnt/c 形へ。
|
|
414
|
+
const pipeTarget = isWin ? toWslPath(logpath(nm)) : logpath(nm);
|
|
415
|
+
const quoted = `'${pipeTarget.replace(/'/g, "'\\''")}'`;
|
|
416
|
+
tmux("pipe-pane", "-t", nm, "-o", `cat >> ${quoted}`);
|
|
268
417
|
writeOffset(nm, 0);
|
|
269
418
|
return [nm, attachHint(nm)];
|
|
270
419
|
}
|
|
271
420
|
export function send(name, text, o = {}) {
|
|
421
|
+
assertSessionName(name);
|
|
272
422
|
const enter = o.enter ?? true;
|
|
273
423
|
if (!sessionExists(name))
|
|
274
424
|
throw new AitermError(`session '${name}' が無い(open してください)`, 2);
|
|
@@ -295,6 +445,7 @@ export function send(name, text, o = {}) {
|
|
|
295
445
|
return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
|
|
296
446
|
}
|
|
297
447
|
export function sendKey(name, key) {
|
|
448
|
+
assertSessionName(name);
|
|
298
449
|
if (!sessionExists(name))
|
|
299
450
|
throw new AitermError(`session '${name}' が無い`, 2);
|
|
300
451
|
const k = KEYMAP[key.toLowerCase()] ?? key;
|
|
@@ -302,6 +453,7 @@ export function sendKey(name, key) {
|
|
|
302
453
|
return `sent key ${k} to ${name}`;
|
|
303
454
|
}
|
|
304
455
|
export async function readOutput(name, o = {}) {
|
|
456
|
+
assertSessionName(name);
|
|
305
457
|
const timeout = o.timeout ?? DEFAULT_TIMEOUT;
|
|
306
458
|
if (!sessionExists(name) && !fs.existsSync(logpath(name)))
|
|
307
459
|
throw new AitermError(`session '${name}' が無い`, 2);
|
|
@@ -333,7 +485,11 @@ export async function readOutput(name, o = {}) {
|
|
|
333
485
|
}
|
|
334
486
|
}
|
|
335
487
|
else {
|
|
336
|
-
|
|
488
|
+
let off = readOffset(name);
|
|
489
|
+
// WSL 再起動等でログが作り直されると、Windows 側に残った旧 offset が新ログ長を超え、
|
|
490
|
+
// subarray が空を返して「何も読めない」状態になる。末尾越えは先頭から読み直す(POSIX では no-op)。
|
|
491
|
+
if (off > data.length)
|
|
492
|
+
off = 0;
|
|
337
493
|
text = data.subarray(off).toString("utf8");
|
|
338
494
|
if (o.lines)
|
|
339
495
|
text = text.split("\n").slice(-o.lines).join("\n");
|
|
@@ -373,6 +529,7 @@ export function listSessions() {
|
|
|
373
529
|
return "(セッション無し)";
|
|
374
530
|
}
|
|
375
531
|
export function closeSession(name) {
|
|
532
|
+
assertSessionName(name);
|
|
376
533
|
tmux("kill-session", "-t", name);
|
|
377
534
|
for (const p of [logpath(name), offsetpath(name), lastcmdpath(name)]) {
|
|
378
535
|
try {
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
12
12
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import * as core from "./core.js";
|
|
15
|
-
const server = new McpServer({ name: "aiterm", version: "0.
|
|
15
|
+
const server = new McpServer({ name: "aiterm", version: "0.3.0" });
|
|
16
16
|
function ok(s) {
|
|
17
17
|
return { content: [{ type: "text", text: s }] };
|
|
18
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiterm-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
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
5
|
"keywords": [
|
|
6
6
|
"mcp",
|