aiterm-mcp 0.18.2 → 0.19.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 +7 -6
- package/README.md +8 -5
- package/dist/core.js +159 -1
- package/dist/index.js +54 -3
- package/package.json +1 -1
package/README.ja.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
**言葉でなく実測で:** このリポジトリ自身の 203 テストで、`pty_read` はコンテキストに載るトークンを生ログの **約 7.1 分の 1** に減らす。しかも pass/fail の判定は畳んでも残る。→ [組み込みシェルツールとの使い分け](#組み込みシェルツールとの使い分け)
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
13 ツール: 6 つの **PTY ツール**(`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list`)で 1 本の永続端末を開き・操作し・読む。加えて 4 つの **エージェント起動ツール**(`claude_agent` / `codex_agent` / `grok_agent` / `composer_agent`)が別のコーディングエージェントの TUI を新しい端末の中に起動し、`claude_turn`がdurable caller向けの構造化issue/recoveryを、`claude_approval`がmanaged Claudeの相関済み承認UI中継を、`diagnostics`が安全なfactory readinessを返す。バックエンドは **tmux** なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
|
|
26
26
|
|
|
27
27
|
**v0.18.1 を 2026-07-18 に公開**(0.18.0+stale案内文言の修理1件)。実運用障害の還流による agent dispatch の hardening:
|
|
28
28
|
全 dispatch/launch receipt に **submit 座礁観測** `submit_residue` を追加
|
|
@@ -82,7 +82,7 @@ $ aiterm-wait --session codex1 --cursor <event_cursor> # exit 0=done / 3=timeo
|
|
|
82
82
|
| `grok_agent` | Grok Build(xAI、既定`grok-4.5`、`model?`で上書き) | `prompt?`, `model?`, `reasoning_effort?`は非対応(指定時は明示エラー), `cwd?`, `session_name?` |
|
|
83
83
|
| `composer_agent` | Grok Build(xAI、既定`grok-composer-2.5-fast`、`model?`で上書き) | `prompt?`, `model?`, `reasoning_effort?`は非対応(指定時は明示エラー), `cwd?`, `session_name?` |
|
|
84
84
|
|
|
85
|
-
各ベンダーの CLI が導入・認証済みであること(`claude_agent` は `claude`、`codex_agent` は `codex`、Grok 系は `grok`)。バイナリは `CLAUDE_BIN` / `CODEX_BIN` / `GROK_BIN`、各既定path、`PATH` の順で解決する。CLI不在・不正なmodel/effort・実在しない`cwd`はsession作成前に失敗し、残骸を残さない。Claudeは通常settingsを継承しないlaunch専用settingsとStop hookを使い、本文なしeventとowner-only bounded resultを分離する。`pty_read({ agent_transcript:true })`はdigestとbyte数を検証したresultだけを返し、Claude private transcriptを読まない。後着resultは同じsessionからprompt再送なしで回収できる。managed Claude
|
|
85
|
+
各ベンダーの CLI が導入・認証済みであること(`claude_agent` は `claude`、`codex_agent` は `codex`、Grok 系は `grok`)。バイナリは `CLAUDE_BIN` / `CODEX_BIN` / `GROK_BIN`、各既定path、`PATH` の順で解決する。CLI不在・不正なmodel/effort・実在しない`cwd`はsession作成前に失敗し、残骸を残さない。Claudeは通常settingsを継承しないlaunch専用settingsとStop hookを使い、本文なしeventとowner-only bounded resultを分離する。`pty_read({ agent_transcript:true })`はdigestとbyte数を検証したresultだけを返し、Claude private transcriptを読まない。後着resultは同じsessionからprompt再送なしで回収できる。managed Claudeのactive turn中はC-c以外の`pty_key`と素送信を拒否する。Claudeが`Do you want to proceed?`を表示したら、`claude_approval(action:"inspect", ...)`で画面digestを取得し、表示内容を判断してから、そのdigestと`approve_once`または`deny`を`respond`へ渡す。同じoperation・同じ画面が維持されている時だけ入力し、任意文字列や恒久許可選択肢は中継しない。中断は`C-c`、解除は`pty_close`。
|
|
86
86
|
|
|
87
87
|
エージェント間の隠れたプロトコルは無い。起動したClaude/Codex/Grok/Composerは利用者がattachできるもう1本の永続sessionであり、MCPクライアントが通常のPTY操作で駆動する。
|
|
88
88
|
|
|
@@ -145,7 +145,7 @@ claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
|
145
145
|
Claude Code を再起動して、接続を確認:
|
|
146
146
|
|
|
147
147
|
```bash
|
|
148
|
-
/mcp # aiterm が connected・
|
|
148
|
+
/mcp # aiterm が connected・13 ツール公開、と出る
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
最初のセッション——4 回の呼び出しで、1 個の永続端末:
|
|
@@ -186,7 +186,7 @@ MCP クライアントが aiterm を stdio 越しにプログラムから駆動
|
|
|
186
186
|
|
|
187
187
|
```mermaid
|
|
188
188
|
flowchart LR
|
|
189
|
-
AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · claude_agent · claude_turn · codex_agent<br/>grok_agent · composer_agent · diagnostics"| S["aiterm-mcp<br/>stdio MCP ·
|
|
189
|
+
AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · claude_agent · claude_turn · claude_approval · codex_agent<br/>grok_agent · composer_agent · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 13 tools"]
|
|
190
190
|
S -->|"pty_read<br/>token-reduced"| AI
|
|
191
191
|
S -->|"tmux send-keys<br/>capture-pane"| P["persistent PTYs<br/>tmux · survive restarts"]
|
|
192
192
|
P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
|
|
@@ -262,12 +262,13 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
|
|
|
262
262
|
| ツール | 役割 | 主な引数 |
|
|
263
263
|
| --- | --- | --- |
|
|
264
264
|
| `pty_open` | 端末を 1 個握り `session_id` を返す | `name?`, `shell="bash"` |
|
|
265
|
-
| `pty_send` |
|
|
265
|
+
| `pty_send` | テキストを送る。agent sessionでは非ブロックdispatchとして`event_cursor`を返す | `session_id`, `text`, `enter=true`, `mark`, `force`, `rtk`, `raw` |
|
|
266
266
|
| `pty_read` | 出力を削減して読む(既定は増分) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript`, `operation_id` |
|
|
267
267
|
| `pty_key` | 制御キーを送る | `session_id`, `key`(`C-c`/`Enter`/`Up`…) |
|
|
268
268
|
| `pty_close` | 冪等に閉じ、`closed` / `already_closed`を返す | `session_id` |
|
|
269
269
|
| `pty_list` | セッション一覧 | (なし) |
|
|
270
270
|
| `claude_turn` | 相関済みmanaged Claude operationをdispatch(issue)または回収(recover) | `action`, `session_id`, `operation_id`, `text?` |
|
|
271
|
+
| `claude_approval` | 現在表示中の相関済みmanaged Claude承認UIを検査または応答 | `action`, `session_id`, `operation_id?`, `approval_choice?`, `observed_prompt_digest?` |
|
|
271
272
|
| `diagnostics` | 機械可読 JSON による read-only factory readiness | (なし) |
|
|
272
273
|
|
|
273
274
|
`diagnostics` は PTY やエージェントを起動しない。パッケージ版、MCP 呼出 readiness、read-only な PTY 一覧要約、bounded runtime-error-store status、任意 vendor launcher の可用性だけを返す。path・環境値・認証情報・コマンド本文・PTY 出力・raw log は意図的に返さない。通常未設定の任意依存は `not_applicable`、安全に確定できない状態は `unverified` と表す。
|
|
@@ -307,7 +308,7 @@ consumer は `aiterm-runtime-errors snapshot` を読み、durable ingestion 後
|
|
|
307
308
|
|
|
308
309
|
`pty_send` は送信前に破壊的コマンド(`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE` 等)を遮断し(`force: true` で越える)、ESC・ブラケットペースト終端などをサニタイズする。`pty_read` は既定で制御文字を無害化して返す(`raw: true` はバイトをそのまま返す)。これは**サンドボックスではなく tripwire**([既知の制約](#既知の制約バグではなく仕様)参照)。
|
|
309
310
|
|
|
310
|
-
1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。macOSでは長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でtmux pasteし、Linux/WSLでは上限内を1回でpasteする。agent dispatch の paste はさらに tmux bracketed paste(`paste-buffer -p`)を使う: bracketed paste mode を要求している pane(vendor TUI)へは各 chunk を `ESC[200~/201~` で包んで届け、チャンク投入中のキー解釈による語中文字化け・submit
|
|
311
|
+
1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。macOSでは長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でtmux pasteし、Linux/WSLでは上限内を1回でpasteする。POSIX shellが前面にいる時のsanitize済み複数行は、改行を含まない単一の`eval`入力へ符号化する。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatch の paste はさらに tmux bracketed paste(`paste-buffer -p`)を使う: bracketed paste mode を要求している pane(vendor TUI)へは各 chunk を `ESC[200~/201~` で包んで届け、チャンク投入中のキー解釈による語中文字化け・submit 取り落としを抑える。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。そのsessionを `pty_close` して作り直すか、全sessionを破棄できる場合だけ `pty_kill_all` で安全に掃除する。
|
|
311
312
|
|
|
312
313
|
## 人が覗く
|
|
313
314
|
|
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
**Measured, not claimed:** on this repo's own 203-test suite, a `pty_read` puts **~7.1× fewer tokens** in your context than the raw log — and the pass/fail verdict survives the fold. → [When to reach for it vs. the built-in shell](#when-to-reach-for-it-vs-the-built-in-shell)
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Thirteen tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, four **agent launchers** — `claude_agent` / `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one, `claude_turn` for durable structured issue/recovery, `claude_approval` for correlated managed-Claude approval prompts, and `diagnostics` for safe factory readiness. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
|
|
26
26
|
|
|
27
27
|
**v0.18.1 was published on 2026-07-18** (v0.18.0 plus one stale-guidance
|
|
28
28
|
message fix). Field-failure hardening for agent
|
|
@@ -75,6 +75,8 @@ pty_read(id, { wait: true }) → read the token-reduced output, completion
|
|
|
75
75
|
|
|
76
76
|
The same primitive hosts another agent's TUI. Four launchers each start one vendor's interactive coding-agent TUI inside a fresh persistent terminal and return a `session_id`. Their existing human-readable text is accompanied by an `aiterm.agent-launch-result.v1` structured receipt, so durable callers never parse display text for the session handle; when the launch carries an initial `prompt`, the receipt also includes the `event_cursor`, a ready-made `wait_command` for the completion waiter, and a `submit_residue` observation (`true` = the prompt is likely still sitting unsubmitted in the composer — the hint explains recovery; `false` = no residue observed, not a proof of submission; `null` = not applicable). From there you drive it with the same `pty_read` / `pty_send` you'd use on any shell: read its output token-reduced, send it the next step. (The TUIs are full-screen apps, so `pty_read({ screen: true })` gives you the rendered view.) Every launch is **managed**: aiterm installs its own Stop hook, so turn completion is a first-class event. Sending to an agent session is a non-blocking **dispatch** — the call returns immediately with an `event_cursor`, and completion arrives via [`aiterm-wait`](#completion-push-for-parent-agents-aiterm-wait). Durable machine callers use `claude_turn({ action: "issue" | "recover", session_id, operation_id, ... })`: it returns fixed `accepted` / `pending` / `completed` / `unknown` states without parsing human-facing errors, never resends during recovery, and includes exact `raw_output` only for a verified completion. The same operation ID is carried through the dispatch receipt, active marker, Stop event, and result. The ordinary `pty_send` / `pty_read` surface remains available for interactive callers and humans. `C-c` keeps the marker for a delayed Stop; if no Stop arrives, close the session. An initial `prompt` on `claude_agent`/`codex_agent` is submitted through the same ready gate and the launcher returns without waiting; on Grok/Composer it is passed on the CLI's argv. This needs the vendor's own CLI installed and authenticated — see [Requirements](#requirements).
|
|
77
77
|
|
|
78
|
+
For a managed Claude turn stopped at `Do you want to proceed?`, use `claude_approval(action: "inspect", ...)` to capture the active operation and SHA-256 screen digest, review the displayed command, then call `respond` with that exact digest and either `approve_once` or `deny`. The relay rechecks the operation and screen under the send lock, never exposes arbitrary input or permanent approval, keeps the active marker intact, and records a prompt-free owner-only receipt. `pty_send(force: true)` does not bypass this boundary.
|
|
79
|
+
|
|
78
80
|
```text
|
|
79
81
|
codex_agent({ session_name: "codex1", cwd: "/repo",
|
|
80
82
|
prompt: "port test/legacy.py to vitest" })
|
|
@@ -95,7 +97,7 @@ One call per model, so the tool name itself tells you which model you get:
|
|
|
95
97
|
| `grok_agent` | Grok Build, model `grok-4.5` by default (`model?` overrides) (xAI) | `prompt?`, `model?`, `reasoning_effort?` unsupported (an explicit value is an error; Grok CLI `--effort` is headless-only), `cwd?`, `session_name?` |
|
|
96
98
|
| `composer_agent` | Grok Build, model `grok-composer-2.5-fast` by default (`model?` overrides) (xAI) | `prompt?`, `model?`, `reasoning_effort?` unsupported (an explicit value is an error), `cwd?`, `session_name?` |
|
|
97
99
|
|
|
98
|
-
The vendor CLI must be installed and authenticated (`claude` for `claude_agent`; `codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CLAUDE_BIN` / `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/claude` / `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are checked **before** a session exists: empty `model` values and unsupported effort values are rejected up front; a missing CLI binary or a nonexistent `cwd` fails for all four. A rejected launch leaves **zero leftover session** behind. Claude and Codex launchers forward `model` and `reasoning_effort` through their vendor CLI's public flags; Grok/Composer reject `reasoning_effort` because it is headless-only. Pass an absolute path for `cwd` — `~` is not expanded. Durable callers can make a promptless Claude launch exactly replayable by passing an explicit `session_name` and a `launch_operation_id` formatted as `sha256:<64 lowercase hex>`. Repeating the identical launch returns the same structured session receipt without starting the CLI twice; a different correlation ID or launch argument for that session fails explicitly. Claude uses launch-local managed settings containing only aiterm's Stop hook: normal user/project/local hooks are not inherited, the hook event contains no answer body, and the bounded owner-only result is returned by `pty_read({ agent_transcript:true })` without reading Claude's private transcript. A late result remains recoverable from the same session without re-sending the prompt.
|
|
100
|
+
The vendor CLI must be installed and authenticated (`claude` for `claude_agent`; `codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CLAUDE_BIN` / `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/claude` / `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are checked **before** a session exists: empty `model` values and unsupported effort values are rejected up front; a missing CLI binary or a nonexistent `cwd` fails for all four. A rejected launch leaves **zero leftover session** behind. Claude and Codex launchers forward `model` and `reasoning_effort` through their vendor CLI's public flags; Grok/Composer reject `reasoning_effort` because it is headless-only. Pass an absolute path for `cwd` — `~` is not expanded. Durable callers can make a promptless Claude launch exactly replayable by passing an explicit `session_name` and a `launch_operation_id` formatted as `sha256:<64 lowercase hex>`. Repeating the identical launch returns the same structured session receipt without starting the CLI twice; a different correlation ID or launch argument for that session fails explicitly. Claude uses launch-local managed settings containing only aiterm's Stop hook: normal user/project/local hooks are not inherited, the hook event contains no answer body, and the bounded owner-only result is returned by `pty_read({ agent_transcript:true })` without reading Claude's private transcript. A late result remains recoverable from the same session without re-sending the prompt. While a managed Claude turn is active, raw sends and non-interrupt keys are rejected. If Claude displays `Do you want to proceed?`, call `claude_approval(action:"inspect", ...)`, decide from the visible prompt, then call `respond` with the returned digest and either `approve_once` or `deny`. The response is accepted only while the same operation and screen digest remain current; arbitrary text and persistent-allow choices are never relayed. Use `pty_key("C-c")` to interrupt and `pty_close` to abandon the session. For unconstrained manual key-by-key driving, open a plain `pty_open` session and start the vendor CLI yourself. Codex uses a managed `CODEX_HOME`; Grok/Composer isolate their managed homes and pass validated OAuth state through `GROK_AUTH_PATH`. Before the first unbound dispatch, aiterm waits for the vendor TUI's input prompt and fails before sending if it is not ready. Managed completion requires POSIX filesystem semantics (Linux, WSL2, macOS).
|
|
99
101
|
|
|
100
102
|
The managed Codex home links authentication, privately snapshots `config.toml` and `agents/*.toml` custom-role definitions, and keeps sessions/caches isolated. A symlinked role definition is resolved into a regular-file snapshot rather than shared with the source home.
|
|
101
103
|
|
|
@@ -160,7 +162,7 @@ claude mcp add --scope user --transport stdio aiterm -- npx -y aiterm-mcp
|
|
|
160
162
|
Restart Claude Code, then verify the connection:
|
|
161
163
|
|
|
162
164
|
```bash
|
|
163
|
-
/mcp # aiterm should show as connected, exposing
|
|
165
|
+
/mcp # aiterm should show as connected, exposing 13 tools
|
|
164
166
|
```
|
|
165
167
|
|
|
166
168
|
Your first session — four calls, one persistent terminal:
|
|
@@ -201,7 +203,7 @@ The terminal is real and shared, so a human *can* jump in ([A human can watch](#
|
|
|
201
203
|
|
|
202
204
|
```mermaid
|
|
203
205
|
flowchart LR
|
|
204
|
-
AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · claude_agent · claude_turn · codex_agent<br/>grok_agent · composer_agent · diagnostics"| S["aiterm-mcp<br/>stdio MCP ·
|
|
206
|
+
AI["AI / MCP client<br/>(the orchestrator)"] -->|"pty_send · claude_agent · claude_turn · claude_approval · codex_agent<br/>grok_agent · composer_agent · diagnostics"| S["aiterm-mcp<br/>stdio MCP · 13 tools"]
|
|
205
207
|
S -->|"pty_read<br/>token-reduced"| AI
|
|
206
208
|
S -->|"tmux send-keys<br/>capture-pane"| P["persistent PTYs<br/>tmux · survive restarts"]
|
|
207
209
|
P -->|"ssh · docker · repl"| R["nested<br/>remote · container · REPL"]
|
|
@@ -285,6 +287,7 @@ On top of that sits a productized layer a raw tmux bridge doesn't have: **token-
|
|
|
285
287
|
| `pty_close` | Close idempotently; return `closed` / `already_closed` | `session_id` |
|
|
286
288
|
| `pty_list` | List sessions (agent rows carry `agent=<kind>` metadata) | (none) |
|
|
287
289
|
| `claude_turn` | Issue (dispatch-only) or recover one correlated managed-Claude operation | `action`, `session_id`, `operation_id`, `text?` |
|
|
290
|
+
| `claude_approval` | Inspect or answer the current correlated managed-Claude approval prompt | `action`, `session_id`, `operation_id?`, `approval_choice?`, `observed_prompt_digest?` |
|
|
288
291
|
| `diagnostics` | Read-only factory readiness as machine-readable JSON | (none) |
|
|
289
292
|
|
|
290
293
|
`diagnostics` never starts a PTY or agent. It reports package version, MCP call readiness, a read-only PTY-list summary, bounded runtime-error-store status, and optional vendor-launcher availability. It deliberately excludes paths, environment values, credentials, command text, PTY output, and raw logs; normal unset optional dependencies are `not_applicable`, while an indeterminate probe is `unverified`.
|
|
@@ -335,7 +338,7 @@ As of v0.16 a parent agent **never blocks** on aiterm — there is no wait param
|
|
|
335
338
|
|
|
336
339
|
Before sending, `pty_send` blocks destructive commands (`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE`, …) — pass `force: true` to override — and sanitizes ESC / bracketed-paste terminators. `pty_read` neutralizes control characters in what it returns by default (`raw: true` returns the bytes verbatim). This is a **tripwire, not a sandbox** (see [Known constraints](#known-constraints-by-design-not-bugs)).
|
|
337
340
|
|
|
338
|
-
Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. On macOS, text is pasted through tmux in UTF-8-safe 256-byte chunks to avoid the platform PTY truncation observed with long input; Linux and WSL use one bounded paste. Agent dispatches additionally paste with tmux bracketed paste (`paste-buffer -p`): panes that requested bracketed-paste mode (the vendor TUIs) receive each chunk wrapped in `ESC[200~/201~`, hardening prompt injection against mid-word key-interpretation corruption and dropped submits
|
|
341
|
+
Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. On macOS, text is pasted through tmux in UTF-8-safe 256-byte chunks to avoid the platform PTY truncation observed with long input; Linux and WSL use one bounded paste. Sanitized multiline text sent while a POSIX shell is in the foreground is encoded as one newline-free `eval` input: the shell receives the complete script before it runs the first line, so a pager or REPL started mid-script cannot consume later lines as interactive keystrokes. Single-line input, `raw:true`, and non-shell frontends remain direct PTY pastes. Agent dispatches additionally paste with tmux bracketed paste (`paste-buffer -p`): panes that requested bracketed-paste mode (the vendor TUIs) receive each chunk wrapped in `ESC[200~/201~`, hardening prompt injection against mid-word key-interpretation corruption and dropped submits. If a later chunk fails, aiterm reports the partial-send state and does not press Enter automatically. A lock left by a terminated sender fails closed before sending; close and recreate that session (or use `pty_kill_all` when every session is disposable) to clean it up safely.
|
|
339
342
|
|
|
340
343
|
## A human can watch
|
|
341
344
|
|
package/dist/core.js
CHANGED
|
@@ -33,6 +33,11 @@ export const DEFAULT_TIMEOUT = 10.0;
|
|
|
33
33
|
const POLL = 0.25;
|
|
34
34
|
const STABLE_POLLS = 2; // 連続でログサイズ不変ならば静止とみなす回数
|
|
35
35
|
const SHELLS = new Set(["bash", "sh", "zsh", "fish", "dash"]);
|
|
36
|
+
// 通常PTYへ複数行をそのままpasteすると、途中で起動したpager/REPLが後続行を
|
|
37
|
+
// キー入力として消費する。POSIX shell前面では改行を持たないeval 1行へ包み、
|
|
38
|
+
// script全体をshell内部へ取り込んでから実行する。fish等の非POSIX shellや
|
|
39
|
+
// ssh/REPL前面は従来の生pasteを維持する。
|
|
40
|
+
const ATOMIC_MULTILINE_SHELLS = new Set(["bash", "sh", "zsh", "dash"]);
|
|
36
41
|
// mark の sentinel は POSIX シェル構文(; と "$?")に依存する。これらの非 POSIX 対話シェルが
|
|
37
42
|
// 前面のときは "$?" が正しく展開されず sentinel が壊れるので mark を拒否する(B8)。ssh/docker で
|
|
38
43
|
// リモート POSIX シェルに入っている場合は前面が "ssh"/"docker" 等で本集合に含まれず=許可される。
|
|
@@ -56,6 +61,7 @@ const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
|
|
|
56
61
|
const AGENT_TUI_READY_POLL_MS = 500;
|
|
57
62
|
const AGENT_TUI_READY_STABLE_SAMPLES = 11;
|
|
58
63
|
const AGENT_TUI_READY_LINES = 45;
|
|
64
|
+
const CLAUDE_APPROVAL_SCREEN_LINES = 80;
|
|
59
65
|
// submit座礁観測(dispatch後にcomposerへ送信textが残存していないかの有界チェック)
|
|
60
66
|
const AGENT_SUBMIT_RESIDUE_DELAY_MS = 250;
|
|
61
67
|
const AGENT_SUBMIT_RESIDUE_POLL_MS = 300;
|
|
@@ -306,6 +312,19 @@ function splitPtyText(text) {
|
|
|
306
312
|
chunks.push(chunk);
|
|
307
313
|
return chunks;
|
|
308
314
|
}
|
|
315
|
+
function quoteForPrintfB(text) {
|
|
316
|
+
return text
|
|
317
|
+
.replace(/\\/g, "\\\\")
|
|
318
|
+
.replace(/\n/g, "\\n")
|
|
319
|
+
.replace(/\r/g, "\\r")
|
|
320
|
+
.replace(/\t/g, "\\t")
|
|
321
|
+
.replace(/'/g, `'"'"'`);
|
|
322
|
+
}
|
|
323
|
+
function atomicShellMultiline(text) {
|
|
324
|
+
// POSIX printf %bで元の改行・backslashを復元し、evalは現在shell内で実行する。
|
|
325
|
+
// command substitutionが末尾LFを落とす点は、pty_sendのsubmitを担うEnterと同値。
|
|
326
|
+
return `eval "$(command printf '%b' '${quoteForPrintfB(text)}')"`;
|
|
327
|
+
}
|
|
309
328
|
function assertSendTextSize(text, context = "送信文字列") {
|
|
310
329
|
const bytes = Buffer.byteLength(text, "utf8");
|
|
311
330
|
if (bytes > MAX_SEND_BYTES) {
|
|
@@ -404,6 +423,12 @@ function agentClaudeOperationPath(name, launchId) {
|
|
|
404
423
|
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
405
424
|
return path.join(agentsDir(), `${name}.${launchId}.claude-operation.json`);
|
|
406
425
|
}
|
|
426
|
+
function agentClaudeApprovalReceiptPath(name, launchId) {
|
|
427
|
+
assertSessionName(name);
|
|
428
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
429
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
430
|
+
return path.join(agentsDir(), `${name}.${launchId}.claude-approval.json`);
|
|
431
|
+
}
|
|
407
432
|
function agentClaudeDispatchReceiptPath(name, launchId, operationId) {
|
|
408
433
|
assertSessionName(name);
|
|
409
434
|
if (!LAUNCH_ID_RE.test(launchId))
|
|
@@ -470,6 +495,7 @@ function cleanupAgentState(name) {
|
|
|
470
495
|
f.endsWith(".claude-settings.json") ||
|
|
471
496
|
f.endsWith(".claude-result.json") ||
|
|
472
497
|
f.endsWith(".claude-operation.json") ||
|
|
498
|
+
f.endsWith(".claude-approval.json") ||
|
|
473
499
|
f.endsWith(".claude-dispatch"))
|
|
474
500
|
fs.unlinkSync(p);
|
|
475
501
|
else if (f.endsWith(".codex-home") || f.endsWith(".grok-home") || f.endsWith(".home")) {
|
|
@@ -952,6 +978,10 @@ export function send(name, text, o = {}) {
|
|
|
952
978
|
if (o.mark)
|
|
953
979
|
text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
|
|
954
980
|
assertSendTextSize(text, o.rtk || o.mark ? "変換後の送信文字列" : "送信文字列");
|
|
981
|
+
const reportedText = text;
|
|
982
|
+
if (!o.raw && text.includes("\n") && ATOMIC_MULTILINE_SHELLS.has(paneCurrentCommand(name))) {
|
|
983
|
+
text = atomicShellMultiline(text);
|
|
984
|
+
}
|
|
955
985
|
if (o.mark) {
|
|
956
986
|
try {
|
|
957
987
|
fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
|
|
@@ -1010,7 +1040,7 @@ export function send(name, text, o = {}) {
|
|
|
1010
1040
|
}
|
|
1011
1041
|
}
|
|
1012
1042
|
// コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
|
|
1013
|
-
return `sent ${[...
|
|
1043
|
+
return `sent ${[...reportedText].length} chars to ${name}` + (enter ? " (+Enter)" : "");
|
|
1014
1044
|
}
|
|
1015
1045
|
finally {
|
|
1016
1046
|
releaseSendLock();
|
|
@@ -1863,6 +1893,134 @@ function managedClaudeOperation(name) {
|
|
|
1863
1893
|
return undefined;
|
|
1864
1894
|
return readClaudeOperationMarker(meta);
|
|
1865
1895
|
}
|
|
1896
|
+
function canonicalClaudeApprovalScreen(screen) {
|
|
1897
|
+
return stripControl(screen)
|
|
1898
|
+
.split("\n")
|
|
1899
|
+
.map((line) => line.replace(/^\s*[❯>]\s*/, "").replace(/\s+$/, ""))
|
|
1900
|
+
.join("\n")
|
|
1901
|
+
.trim();
|
|
1902
|
+
}
|
|
1903
|
+
function parseClaudeApprovalScreen(screen) {
|
|
1904
|
+
const canonical = canonicalClaudeApprovalScreen(screen);
|
|
1905
|
+
const lines = canonical.split("\n");
|
|
1906
|
+
let question = -1;
|
|
1907
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1908
|
+
if (lines[i].trim() === "Do you want to proceed?")
|
|
1909
|
+
question = i;
|
|
1910
|
+
}
|
|
1911
|
+
if (question < 0) {
|
|
1912
|
+
throw new AitermError("managed Claudeの承認UIを現在画面で確認できません(Do you want to proceed? がありません)", 2);
|
|
1913
|
+
}
|
|
1914
|
+
const choices = [];
|
|
1915
|
+
const seen = new Set();
|
|
1916
|
+
for (const line of lines.slice(question + 1)) {
|
|
1917
|
+
const match = line.trim().match(/^(\d+)\.\s+(.+?)\s*$/);
|
|
1918
|
+
if (!match)
|
|
1919
|
+
continue;
|
|
1920
|
+
const index = Number(match[1]);
|
|
1921
|
+
const label = match[2];
|
|
1922
|
+
const decision = /^yes$/i.test(label)
|
|
1923
|
+
? "approve_once"
|
|
1924
|
+
: /^no$/i.test(label)
|
|
1925
|
+
? "deny"
|
|
1926
|
+
: null;
|
|
1927
|
+
// 「常に許可」等は意図的に公開しない。単発Yes/No以外を自動操作できる契約にしない。
|
|
1928
|
+
if (!decision)
|
|
1929
|
+
continue;
|
|
1930
|
+
if (!Number.isSafeInteger(index) || index < 1 || seen.has(decision)) {
|
|
1931
|
+
throw new AitermError("managed Claudeの承認UI選択肢が一意に解釈できません", 2);
|
|
1932
|
+
}
|
|
1933
|
+
seen.add(decision);
|
|
1934
|
+
choices.push({ decision, index, label });
|
|
1935
|
+
}
|
|
1936
|
+
if (!seen.has("approve_once") || !seen.has("deny")) {
|
|
1937
|
+
throw new AitermError("managed Claudeの承認UIに安全な単発Yes/No選択肢を確認できません", 2);
|
|
1938
|
+
}
|
|
1939
|
+
return {
|
|
1940
|
+
promptDigest: `sha256:${createHash("sha256").update(canonical, "utf8").digest("hex")}`,
|
|
1941
|
+
choices,
|
|
1942
|
+
};
|
|
1943
|
+
}
|
|
1944
|
+
function assertExpectedClaudeOperation(meta, expectedOperationId) {
|
|
1945
|
+
const active = readClaudeOperationMarker(meta);
|
|
1946
|
+
if (!active)
|
|
1947
|
+
throw new AitermError("managed Claudeに未解決のactive operationがありません", 2);
|
|
1948
|
+
if (active.operationId !== expectedOperationId) {
|
|
1949
|
+
const actual = active.operationId ?? "operation_idなし";
|
|
1950
|
+
const expected = expectedOperationId ?? "operation_idなし";
|
|
1951
|
+
throw new AitermError(`active operationが一致しません(expected=${expected}, actual=${actual})`, 2);
|
|
1952
|
+
}
|
|
1953
|
+
return active;
|
|
1954
|
+
}
|
|
1955
|
+
export function runClaudeApproval({ action, session_id: name, operation_id: operationIdInput, approval_choice: approvalChoice, observed_prompt_digest: observedPromptDigest, }) {
|
|
1956
|
+
assertSessionName(name);
|
|
1957
|
+
if (!sessionExists(name))
|
|
1958
|
+
throw new AitermError(`session '${name}' が無い`, 2);
|
|
1959
|
+
const meta = loadAgentMetadata(name);
|
|
1960
|
+
if (meta.kind !== "claude")
|
|
1961
|
+
throw new AitermError("claude_approvalはmanaged Claude agent sessionだけで使用できます", 2);
|
|
1962
|
+
const operationId = operationIdInput == null ? null : validateOperationId(operationIdInput);
|
|
1963
|
+
if (action === "inspect") {
|
|
1964
|
+
if (approvalChoice != null || observedPromptDigest != null) {
|
|
1965
|
+
throw new AitermError("claude_approval inspectにapproval_choice/observed_prompt_digestは指定できません", 2);
|
|
1966
|
+
}
|
|
1967
|
+
assertExpectedClaudeOperation(meta, operationId);
|
|
1968
|
+
const observed = parseClaudeApprovalScreen(captureScreen(name, CLAUDE_APPROVAL_SCREEN_LINES));
|
|
1969
|
+
return {
|
|
1970
|
+
schema: "aiterm.claude-approval-result.v1",
|
|
1971
|
+
action,
|
|
1972
|
+
status: "approval_required",
|
|
1973
|
+
session_id: name,
|
|
1974
|
+
operation_id: operationId,
|
|
1975
|
+
prompt_digest: observed.promptDigest,
|
|
1976
|
+
choices: observed.choices,
|
|
1977
|
+
selected_choice: null,
|
|
1978
|
+
at: new Date().toISOString(),
|
|
1979
|
+
};
|
|
1980
|
+
}
|
|
1981
|
+
if (action !== "respond")
|
|
1982
|
+
throw new AitermError(`claude_approval actionが不正です: ${action}`, 2);
|
|
1983
|
+
if (approvalChoice == null || observedPromptDigest == null) {
|
|
1984
|
+
throw new AitermError("claude_approval respondにはapproval_choiceとobserved_prompt_digestが必要です", 2);
|
|
1985
|
+
}
|
|
1986
|
+
if (!OPERATION_ID_RE.test(observedPromptDigest)) {
|
|
1987
|
+
throw new AitermError("observed_prompt_digestはsha256:<64 lowercase hex>で指定してください", 2);
|
|
1988
|
+
}
|
|
1989
|
+
const releaseSendLock = acquireSessionSendFileLock(name);
|
|
1990
|
+
try {
|
|
1991
|
+
// inspect後にoperationまたは画面が変わっていないことを、入力と同じsend lock内で再検証する。
|
|
1992
|
+
assertExpectedClaudeOperation(meta, operationId);
|
|
1993
|
+
const observed = parseClaudeApprovalScreen(captureScreen(name, CLAUDE_APPROVAL_SCREEN_LINES));
|
|
1994
|
+
if (observed.promptDigest !== observedPromptDigest) {
|
|
1995
|
+
throw new AitermError("承認UIがinspect後に変化しました。再度inspectしてから判断してください", 2);
|
|
1996
|
+
}
|
|
1997
|
+
const choice = observed.choices.find((entry) => entry.decision === approvalChoice);
|
|
1998
|
+
if (!choice)
|
|
1999
|
+
throw new AitermError(`承認UIに${approvalChoice}の安全な選択肢がありません`, 2);
|
|
2000
|
+
const sent = tmux("send-keys", "-t", name, String(choice.index), "Enter");
|
|
2001
|
+
if (sent.code !== 0) {
|
|
2002
|
+
throw new AitermError(`Claude承認入力を送れませんでした: ${sent.stderr.trim() || `code=${sent.code}`}`, 2);
|
|
2003
|
+
}
|
|
2004
|
+
const at = new Date().toISOString();
|
|
2005
|
+
const result = {
|
|
2006
|
+
schema: "aiterm.claude-approval-result.v1",
|
|
2007
|
+
action,
|
|
2008
|
+
status: "submitted",
|
|
2009
|
+
session_id: name,
|
|
2010
|
+
operation_id: operationId,
|
|
2011
|
+
prompt_digest: observed.promptDigest,
|
|
2012
|
+
choices: observed.choices,
|
|
2013
|
+
selected_choice: approvalChoice,
|
|
2014
|
+
at,
|
|
2015
|
+
};
|
|
2016
|
+
// prompt本文は保存せず、相関ID・digest・選択だけをowner-only receiptへ残す。
|
|
2017
|
+
writeJson0600(agentClaudeApprovalReceiptPath(name, meta.launch_id), result);
|
|
2018
|
+
return result;
|
|
2019
|
+
}
|
|
2020
|
+
finally {
|
|
2021
|
+
releaseSendLock();
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
1866
2024
|
function hasClaudeDispatchReceipt(meta, operationId) {
|
|
1867
2025
|
const file = agentClaudeDispatchReceiptPath(meta.aiterm_session, meta.launch_id, operationId);
|
|
1868
2026
|
let st;
|
package/dist/index.js
CHANGED
|
@@ -92,7 +92,7 @@ server.registerTool("pty_send", {
|
|
|
92
92
|
"ホストのバックグラウンドタスクとして実行し、exit時にreceiptのoutcomeで判定する" +
|
|
93
93
|
`(${core.AITERM_WAIT_OUTCOME_NOTE}。ポーリング不要)。` +
|
|
94
94
|
"結果回収は pty_read(agent_transcript:true)、Claude の durable turn は claude_turn を使う。" +
|
|
95
|
-
"force:true
|
|
95
|
+
"force:true は非Claude agent sessionへの手動介入用の素送信。managed Claudeの承認UIはclaude_approvalを使う。",
|
|
96
96
|
inputSchema: {
|
|
97
97
|
session_id: z.string(),
|
|
98
98
|
text: z
|
|
@@ -108,7 +108,7 @@ server.registerTool("pty_send", {
|
|
|
108
108
|
force: z
|
|
109
109
|
.boolean()
|
|
110
110
|
.default(false)
|
|
111
|
-
.describe("
|
|
111
|
+
.describe("破壊的コマンドゲートを越える。非Claude agent sessionではdispatchせず素送信する。managed Claudeのactive turnには使えない"),
|
|
112
112
|
rtk: z.boolean().default(false).describe("既知コマンドを rtk 形へ委譲して送る(rtk 不在なら素通し)"),
|
|
113
113
|
raw: z.boolean().default(false).describe("送信前サニタイズを無効化"),
|
|
114
114
|
},
|
|
@@ -255,7 +255,7 @@ server.registerTool("pty_read", {
|
|
|
255
255
|
}
|
|
256
256
|
});
|
|
257
257
|
server.registerTool("pty_key", {
|
|
258
|
-
description: "制御キーを送る(C-c, C-d, Enter, Tab, Up, Down... の別名に対応)。managed Claude sessionではturn相関を守るためC-c
|
|
258
|
+
description: "制御キーを送る(C-c, C-d, Enter, Tab, Up, Down... の別名に対応)。managed Claude sessionではturn相関を守るためC-cだけを許可し、承認UIはclaude_approvalで操作する。",
|
|
259
259
|
inputSchema: {
|
|
260
260
|
session_id: z.string(),
|
|
261
261
|
key: z.string().describe('キー名(例 "C-c", "Enter", "Up")'),
|
|
@@ -338,6 +338,57 @@ server.registerTool("claude_turn", {
|
|
|
338
338
|
return fail(e);
|
|
339
339
|
}
|
|
340
340
|
});
|
|
341
|
+
server.registerTool("claude_approval", {
|
|
342
|
+
description: "managed Claudeのactive turn中に表示された権限確認UIを、turn相関を保ったまま検査・応答する専用面。" +
|
|
343
|
+
"inspectで画面digestと安全な単発Yes/Noだけを取得し、respondは同じoperation・同じdigestが現在も表示中の場合だけ送信する。",
|
|
344
|
+
inputSchema: {
|
|
345
|
+
action: z.enum(["inspect", "respond"]),
|
|
346
|
+
session_id: z.string(),
|
|
347
|
+
operation_id: z
|
|
348
|
+
.string()
|
|
349
|
+
.regex(/^sha256:[0-9a-f]{64}$/)
|
|
350
|
+
.nullish()
|
|
351
|
+
.describe("durable operationのID。通常pty_send由来の匿名turnでは省略する"),
|
|
352
|
+
approval_choice: z.enum(["approve_once", "deny"]).optional().describe("respondだけに指定する"),
|
|
353
|
+
observed_prompt_digest: z
|
|
354
|
+
.string()
|
|
355
|
+
.regex(/^sha256:[0-9a-f]{64}$/)
|
|
356
|
+
.optional()
|
|
357
|
+
.describe("直前のinspectが返したdigest。respondだけに指定する"),
|
|
358
|
+
},
|
|
359
|
+
outputSchema: {
|
|
360
|
+
schema: z.literal("aiterm.claude-approval-result.v1"),
|
|
361
|
+
action: z.enum(["inspect", "respond"]),
|
|
362
|
+
status: z.enum(["approval_required", "submitted"]),
|
|
363
|
+
session_id: z.string(),
|
|
364
|
+
operation_id: z.string().regex(/^sha256:[0-9a-f]{64}$/).nullable(),
|
|
365
|
+
prompt_digest: z.string().regex(/^sha256:[0-9a-f]{64}$/),
|
|
366
|
+
choices: z.array(z.object({
|
|
367
|
+
decision: z.enum(["approve_once", "deny"]),
|
|
368
|
+
index: z.number().int().positive(),
|
|
369
|
+
label: z.string(),
|
|
370
|
+
})),
|
|
371
|
+
selected_choice: z.enum(["approve_once", "deny"]).nullable(),
|
|
372
|
+
at: z.string(),
|
|
373
|
+
},
|
|
374
|
+
}, async ({ action, session_id, operation_id, approval_choice, observed_prompt_digest }) => {
|
|
375
|
+
try {
|
|
376
|
+
const result = core.runClaudeApproval({
|
|
377
|
+
action,
|
|
378
|
+
session_id,
|
|
379
|
+
operation_id: operation_id ?? null,
|
|
380
|
+
approval_choice,
|
|
381
|
+
observed_prompt_digest,
|
|
382
|
+
});
|
|
383
|
+
return {
|
|
384
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
385
|
+
structuredContent: { ...result },
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
catch (e) {
|
|
389
|
+
return fail(e);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
341
392
|
// 対話型エージェント起動ツール(モデルごとに1つ=ツール名/説明でどのモデルか一目で分かる)。
|
|
342
393
|
// いずれも永続端末に TUI を起動し session_id を返す。以後 pty_read/pty_send で対話操作する。
|
|
343
394
|
const agentModelDesc = (kind) => kind === "claude"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiterm-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.1",
|
|
4
4
|
"mcpName": "io.github.kitepon-rgb/aiterm-mcp",
|
|
5
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. Also launches interactive Claude/Codex/Grok/Composer agent TUIs in a persistent terminal. Token-reducing reads.",
|
|
6
6
|
"keywords": [
|