aiterm-mcp 0.12.2 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -71,7 +71,7 @@ pty_send("codex1", "also fix the imports it broke", { wait: "agent_done" })
71
71
  | `grok_agent` | Grok Build の `grok-build` モデル(xAI) | `prompt?`, `reasoning_effort?`(`low`/`medium`/`high`/`xhigh`/`max`), `cwd?`, `session_name?`, `agent_done?` |
72
72
  | `composer_agent` | Grok Build の `grok-composer-2.5-fast` モデル(xAI) | `grok_agent` と同じ |
73
73
 
74
- 各ベンダーの CLI が導入・認証済みであること(`codex_agent` は `codex`、Grok 系 2 つは `grok`)。バイナリは `CODEX_BIN` / `GROK_BIN` → `~/.local/bin/codex` / `~/.grok/bin/grok` → `PATH` の順で解決する。前提は **session を作る前に**検証する——`grok_agent` / `composer_agent` は範囲外の `reasoning_effort` を先に弾き(値集合は固定=`low`/`medium`/`high`/`xhigh`/`max`)、CLI バイナリ不在・実在しない `cwd` は 3 つとも失敗する。前提(`reasoning_effort`・バイナリ・`cwd`)で弾かれた起動は **session の残骸をゼロ**にして返し、起動キーストロークを送れなければ session を畳む。(初回 prompt wait を使わない場合、その先でベンダー CLI が実際に立ち上がったか・認証できたかは aiterm は検証しない——起動できたかは session を読んで確認する。)(`codex_agent` は Codex の受理値が版で変わるため、`reasoning_effort` を検証せず config override〔`-c model_reasoning_effort=…`〕として Codex CLI へ渡す。)`cwd` は絶対パスで渡す——`~` は展開されない。`agent_done` は launch ごとの managed vendor home を使い、通常の hook file は書き換えない。Codex は managed `CODEX_HOME` に `auth.json` だけを symlink し、`config.toml` はコピーする。Grok/Composer はさらに `GROK_HOME` と `HOME` を隔離し、compat hook/plugin 混入を抑える。一方で Grok OAuth は通常 Grok home の `auth.json` `auth.json.lock` をセットで共有し、複数セッション間で refresh lock が分裂しないようにする。初回の未 bind `pty_send({ wait: "agent_done" })` は、vendor TUI の入力欄が出るまで待ち、入力受付状態でなければ文字列を送らず送信前エラーにする。`codex_agent` launcher の `wait:"agent_done"` も同じく `prompt` と `agent_done:true` が必須。TUI が入力欄 ready にならない場合は prompt を送らず、`initial_prompt=not_sent` として session を調査可能なまま残す。起動時 prompt が `pending`/`sent` の間は、混入防止のため通常 `pty_send` も拒否する。手動介入は `pty_key`、または意図的な `pty_send(..., force:true)` で行う。`agent_done` は `getuid`・安全な一時ファイル・hard link 検査など POSIX ファイルシステム前提を使うため、対応は Linux / WSL2 / macOS。Windows ネイティブでは core PTY と agent launcher は使えるが、`agent_done` はまだ未対応。
74
+ 各ベンダーの CLI が導入・認証済みであること(`codex_agent` は `codex`、Grok 系 2 つは `grok`)。バイナリは `CODEX_BIN` / `GROK_BIN` → `~/.local/bin/codex` / `~/.grok/bin/grok` → `PATH` の順で解決する。前提は **session を作る前に**検証する——`grok_agent` / `composer_agent` は範囲外の `reasoning_effort` を先に弾き(値集合は固定=`low`/`medium`/`high`/`xhigh`/`max`)、CLI バイナリ不在・実在しない `cwd` は 3 つとも失敗する。前提(`reasoning_effort`・バイナリ・`cwd`)で弾かれた起動は **session の残骸をゼロ**にして返し、起動キーストロークを送れなければ session を畳む。(初回 prompt wait を使わない場合、その先でベンダー CLI が実際に立ち上がったか・認証できたかは aiterm は検証しない——起動できたかは session を読んで確認する。)(`codex_agent` は Codex の受理値が版で変わるため、`reasoning_effort` を検証せず config override〔`-c model_reasoning_effort=…`〕として Codex CLI へ渡す。)`cwd` は絶対パスで渡す——`~` は展開されない。`agent_done` は launch ごとの managed vendor home を使い、通常の hook file は書き換えない。Codex は managed `CODEX_HOME` に `auth.json` だけを symlink し、`config.toml` はコピーする。Grok/Composer はさらに `GROK_HOME` と `HOME` を隔離し、compat hook/plugin 混入を抑える。Grok OAuth は検証済みの通常auth正本をvendor所有の `GROK_AUTH_PATH` で渡し、managed homeにauth/lockのsymlinkやcopyを作らない。refresh lockとatomic replacementはGrok自身が所有する。初回の未 bind `pty_send({ wait: "agent_done" })` は、vendor TUI の入力欄が出るまで待ち、入力受付状態でなければ文字列を送らず送信前エラーにする。`codex_agent` launcher の `wait:"agent_done"` も同じく `prompt` と `agent_done:true` が必須。TUI が入力欄 ready にならない場合は prompt を送らず、`initial_prompt=not_sent` として session を調査可能なまま残す。起動時 prompt が `pending`/`sent` の間は、混入防止のため通常 `pty_send` も拒否する。手動介入は `pty_key`、または意図的な `pty_send(..., force:true)` で行う。`agent_done` は `getuid`・安全な一時ファイル・hard link 検査など POSIX ファイルシステム前提を使うため、対応は Linux / WSL2 / macOS。Windows ネイティブでは core PTY と agent launcher は使えるが、`agent_done` はまだ未対応。
75
75
 
76
76
  **Claude launcher はあえて無い**し、エージェント間のプロトコルも無い: aiterm の役目は、あなたの Claude(や任意の MCP クライアント)が*他の*エージェントに手を伸ばして端末を操作できるようにすること。起動したエージェントは単なるもう 1 本の永続セッションなので、この README の他のすべて(トークン削減読取・完了検出・人の `attach` での監視/介入)がそのまま効く。
77
77
 
@@ -273,7 +273,7 @@ consumer は `aiterm-runtime-errors snapshot` を読み、durable ingestion 後
273
273
  | `grok_agent` | Grok Build の `grok-build` モデル(xAI) | `prompt?`, `reasoning_effort?`(`low`/`medium`/`high`/`xhigh`/`max`), `cwd?`, `session_name?`, `agent_done?` |
274
274
  | `composer_agent` | Grok Build の `grok-composer-2.5-fast` モデル(xAI) | `grok_agent` と同じ |
275
275
 
276
- 各ベンダーの CLI が導入・認証済みであること(`codex_agent` は `codex`、Grok 系 2 つは `grok`)。バイナリは `CODEX_BIN` / `GROK_BIN` → `~/.local/bin/codex` / `~/.grok/bin/grok` → `PATH` の順で解決する。前提はセッション作成前に検証し(grok/composer は範囲外の `reasoning_effort` を弾く。CLI 不在・実在しない `cwd` は 3 つとも失敗)、起動に失敗してもセッションの残骸は残さない。詳細は [その端末の中に他のコーディングエージェントを起動する](#2-その端末の中に他のコーディングエージェントを起動する--オーケストレーションの旗艦)。`cwd` は絶対パスで渡す——`~` は展開されない。`agent_done` は hook-backed で、Codex/Grok/Composer は Linux/WSL2/macOS の後続 `pty_send(wait:"agent_done")` で実 smoke 済み。Codex は launcher の `prompt + wait:"agent_done"` も実 smoke 済み。Windows ネイティブでは agent launcher は使えるが `agent_done` はまだ未対応。Codex の managed `CODEX_HOME` は `auth.json` だけを symlink し、`config.toml` をコピーし、`hooks.json` は aiterm が所有する。初回の未 bind agent send では `pty_send({ wait:"agent_done" })` が vendor TUI の入力欄を待ち、未 ready なら送信前エラーにする。`codex_agent` launcher の `wait:"agent_done"` は `prompt` と `agent_done:true` が必須で、TUI ready 後にだけ初回 prompt を送る。入力欄 ready にならない場合は prompt を送らず、`initial_prompt=not_sent` を返して session を残す。Grok OAuth mode では hook/config 隔離は launch ごとに維持しつつ、通常 Grok home の `auth.json` と `auth.json.lock` をセットで共有する。OAuth auth 不在ならセッション残骸なしで失敗する。
276
+ 各ベンダーの CLI が導入・認証済みであること(`codex_agent` は `codex`、Grok 系 2 つは `grok`)。バイナリは `CODEX_BIN` / `GROK_BIN` → `~/.local/bin/codex` / `~/.grok/bin/grok` → `PATH` の順で解決する。前提はセッション作成前に検証し(grok/composer は範囲外の `reasoning_effort` を弾く。CLI 不在・実在しない `cwd` は 3 つとも失敗)、起動に失敗してもセッションの残骸は残さない。詳細は [その端末の中に他のコーディングエージェントを起動する](#2-その端末の中に他のコーディングエージェントを起動する--オーケストレーションの旗艦)。`cwd` は絶対パスで渡す——`~` は展開されない。`agent_done` は hook-backed で、Codex/Grok/Composer は Linux/WSL2/macOS の後続 `pty_send(wait:"agent_done")` で実 smoke 済み。Codex は launcher の `prompt + wait:"agent_done"` も実 smoke 済み。Windows ネイティブでは agent launcher は使えるが `agent_done` はまだ未対応。Codex の managed `CODEX_HOME` は `auth.json` だけを symlink し、`config.toml` をコピーし、`hooks.json` は aiterm が所有する。初回の未 bind agent send では `pty_send({ wait:"agent_done" })` が vendor TUI の入力欄を待ち、未 ready なら送信前エラーにする。`codex_agent` launcher の `wait:"agent_done"` は `prompt` と `agent_done:true` が必須で、TUI ready 後にだけ初回 prompt を送る。入力欄 ready にならない場合は prompt を送らず、`initial_prompt=not_sent` を返して session を残す。Grok OAuth mode ではhook/config隔離をlaunchごとに維持し、検証済みの通常auth正本を`GROK_AUTH_PATH`でvendorへ渡す。managed homeauth/lockは作らない。OAuth auth 不在ならセッション残骸なしで失敗する。
277
277
 
278
278
  エージェントの回答が `wait:"agent_done"` の画面 tail(ペイン高 ≒ 24 行)より長くて切れたら、`pty_read({ agent_transcript: true })` で全文回収する。managed home 配下の vendor 構造化 transcript から、直近完了ターンの最終 assistant メッセージを平文で返す(再プロンプト不要)。Codex は Stop hook の `turn_id` で join、Grok/Composer は最後の実ユーザー行より後ろの assistant 行を採る。読み取り専用で、`screen`/`full`/`rtk`/`line_range`/`wait` とは併用不可(`lines` のみ可)。transcript 不在・非 agent session・抽出不能はいずれも明示エラー(黙って空を返さない)。
279
279
 
@@ -291,6 +291,8 @@ consumer は `aiterm-runtime-errors snapshot` を読み、durable ingestion 後
291
291
 
292
292
  `pty_send` は送信前に破壊的コマンド(`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE` 等)を遮断し(`force: true` で越える)、ESC・ブラケットペースト終端などをサニタイズする。`pty_read` は既定で制御文字を無害化して返す(`raw: true` はバイトをそのまま返す)。これは**サンドボックスではなく tripwire**([既知の制約](#既知の制約バグではなく仕様)参照)。
293
293
 
294
+ 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。macOSでは長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でtmux pasteし、Linux/WSLでは上限内を1回でpasteする。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。そのsessionを `pty_close` して作り直すか、全sessionを破棄できる場合だけ `pty_kill_all` で安全に掃除する。
295
+
294
296
  ## 人が覗く
295
297
 
296
298
  セッションは共有 tmux ソケット上にある。`pty_open`(および各エージェント起動ツール)の戻り値に表示される `tmux -S … attach -t <id>` で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)——起動した Codex/Grok/Composer のセッションが走るのを覗いたり、作業の途中で AI からキーボードを奪ったりも。Windows ネイティブではセッションが WSL 内にあるため、表示は WSL 形(`wsl tmux -S … attach -t <id>`)になる。
package/README.md CHANGED
@@ -24,11 +24,11 @@
24
24
 
25
25
  Ten tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, three **agent launchers** — `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one, 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
- **v0.12.2 is a release candidate (pending).** Factory diagnostics and the local
27
+ **v0.12.2 was published on 2026-07-13.** Factory diagnostics and the local
28
28
  runtime-error store collect only when canonical dotagents config explicitly sets
29
29
  `collection.enabled: true`; collection is off by default and performs no network
30
- I/O. Publication, tag, CI, registry registration, and registry-install verification
31
- are pending.
30
+ I/O. npm `latest`, provenance CI, tag / GitHub Release, Official MCP Registry
31
+ registration, and a registry-derived isolated install were verified.
32
32
 
33
33
  **Status:** actively maintained · the newcomer here, betting on a different shape (see [vs. the alternatives](#vs-the-alternatives)) · runs on Linux · WSL2 · macOS · native Windows for the core PTY tools (`agent_done` is POSIX/WSL/macOS only for now) · MIT · see the [CHANGELOG](CHANGELOG.md).
34
34
 
@@ -72,7 +72,7 @@ One call per model, so the tool name itself tells you which model you get:
72
72
  | `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?`, `agent_done?` |
73
73
  | `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?`, `agent_done?` |
74
74
 
75
- The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are checked **before** a session exists: empty `model` values and any `reasoning_effort` for `grok_agent` / `composer_agent` are rejected up front; a missing CLI binary or a nonexistent `cwd` fails for all three. A rejected launch leaves **zero leftover session** behind, and if the launch keystroke itself can't be delivered aiterm tears the session down. (Without an initial prompt wait, aiterm does *not* then verify the vendor CLI actually started or authenticated — read the session to confirm the agent came up.) `codex_agent` forwards `model` as `-m` and `reasoning_effort` as `-c model_reasoning_effort=…` without validating the effort value, since Codex's accepted values vary by version. Grok/Composer reject `reasoning_effort`: Grok CLI `--effort` is only for headless `grok -p`, and the interactive TUI ignores it with a warning. Pass an absolute path for `cwd` — `~` is not expanded. Codex launch responses always report the effective model/effort and where each came from (argument, terminal-config inheritance, or CLI default), with an explicit warning when the effective effort is `ultra` (max reasoning plus proactive automatic delegation). `agent_done` uses launch-local managed vendor homes and does not edit your normal hook files; Codex links only `auth.json` and copies `config.toml` into its managed `CODEX_HOME` — explicitly passed `model` / `reasoning_effort` values also rewrite the matching top-level pins in that copy, so terminal pins don't silently leak into the child — while Grok/Composer additionally isolate `GROK_HOME` and `HOME` to suppress compat hook/plugin contamination. Grok OAuth uses the normal Grok home's `auth.json` and `auth.json.lock` as a pair so refresh locking does not split across sessions. Before the first unbound `pty_send({ wait: "agent_done" })`, aiterm waits for the vendor TUI's input prompt and fails before sending if the prompt is not ready, avoiding dropped startup input. `codex_agent` launcher `wait:"agent_done"` has the same requirement and is only valid with both `prompt` and `agent_done:true`; if the TUI is not ready, the prompt is not sent and the session is left inspectable with `initial_prompt=not_sent`. While an initial prompt is still `pending`/`sent`, ordinary `pty_send` is rejected to prevent mixed input; manual takeover can still use `pty_key`, or `pty_send(..., force:true)` if you intentionally want to type into that live TUI. `agent_done` currently requires POSIX filesystem semantics (`getuid`, secure temp files, hard-link checks), so it is supported on Linux, WSL2, and macOS; native Windows can still use the core PTY tools and agent launchers without `agent_done`.
75
+ The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are checked **before** a session exists: empty `model` values and any `reasoning_effort` for `grok_agent` / `composer_agent` are rejected up front; a missing CLI binary or a nonexistent `cwd` fails for all three. A rejected launch leaves **zero leftover session** behind, and if the launch keystroke itself can't be delivered aiterm tears the session down. (Without an initial prompt wait, aiterm does *not* then verify the vendor CLI actually started or authenticated — read the session to confirm the agent came up.) `codex_agent` forwards `model` as `-m` and `reasoning_effort` as `-c model_reasoning_effort=…` without validating the effort value, since Codex's accepted values vary by version. Grok/Composer reject `reasoning_effort`: Grok CLI `--effort` is only for headless `grok -p`, and the interactive TUI ignores it with a warning. Pass an absolute path for `cwd` — `~` is not expanded. Codex launch responses always report the effective model/effort and where each came from (argument, terminal-config inheritance, or CLI default), with an explicit warning when the effective effort is `ultra` (max reasoning plus proactive automatic delegation). `agent_done` uses launch-local managed vendor homes and does not edit your normal hook files; Codex links only `auth.json` and copies `config.toml` into its managed `CODEX_HOME` — explicitly passed `model` / `reasoning_effort` values also rewrite the matching top-level pins in that copy, so terminal pins don't silently leak into the child — while Grok/Composer additionally isolate `GROK_HOME` and `HOME` to suppress compat hook/plugin contamination. Grok OAuth passes the validated normal auth canonical file through vendor-owned `GROK_AUTH_PATH`; managed homes contain no auth/lock symlink or copy, so refresh locking and atomic replacement stay in Grok itself. Before the first unbound `pty_send({ wait: "agent_done" })`, aiterm waits for the vendor TUI's input prompt and fails before sending if the prompt is not ready, avoiding dropped startup input. `codex_agent` launcher `wait:"agent_done"` has the same requirement and is only valid with both `prompt` and `agent_done:true`; if the TUI is not ready, the prompt is not sent and the session is left inspectable with `initial_prompt=not_sent`. While an initial prompt is still `pending`/`sent`, ordinary `pty_send` is rejected to prevent mixed input; manual takeover can still use `pty_key`, or `pty_send(..., force:true)` if you intentionally want to type into that live TUI. `agent_done` currently requires POSIX filesystem semantics (`getuid`, secure temp files, hard-link checks), so it is supported on Linux, WSL2, and macOS; native Windows can still use the core PTY tools and agent launchers without `agent_done`.
76
76
 
77
77
  There is deliberately **no Claude launcher**, and no protocol between the agents: aiterm's job is to let your Claude (or any MCP client) reach for *other* agents and drive their terminals. Because a launched agent is just another persistent session, everything else in this README applies to it: token-reduced reads, completion detection, and a human `attach` to watch or take over.
78
78
 
@@ -276,7 +276,7 @@ Each launcher starts a specific vendor's interactive coding-agent TUI inside a f
276
276
  | `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?`, `agent_done?` |
277
277
  | `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?`, `agent_done?` |
278
278
 
279
- The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are validated before a session is created: empty `model` and any Grok/Composer `reasoning_effort` are errors, while a missing CLI or nonexistent `cwd` fails for all three; a failed launch leaves no session behind. Codex forwards `model` as `-m` and `reasoning_effort` as `-c model_reasoning_effort=…`; Grok/Composer reject effort because interactive TUI does not support it (Grok CLI `--effort` is headless-only). Full details under [Launch other coding agents into that terminal](#2-launch-other-coding-agents-into-that-terminal--the-orchestration-flagship). Pass an absolute path for `cwd` — `~` is not expanded. `agent_done` is hook-backed for agent launchers; Codex/Grok/Composer have passing live smokes on Linux/WSL2/macOS for follow-up `pty_send(wait:"agent_done")`, and Codex has a live smoke for launcher `prompt + wait:"agent_done"`. Native Windows can launch agents but `agent_done` is not supported yet. Codex managed `CODEX_HOME` links only `auth.json`, copies `config.toml`, and owns its own `hooks.json`. Before the first unbound agent send, `pty_send({ wait:"agent_done" })` waits for the vendor TUI input prompt and fails before sending if it is not ready. `codex_agent` launcher `wait:"agent_done"` requires `prompt` plus `agent_done:true`; it sends the initial prompt only after the TUI is ready, and returns `initial_prompt=not_sent` without sending if the TUI is blocked before input. In Grok OAuth mode, aiterm keeps hook/config isolation per launch but shares both `auth.json` and `auth.json.lock` with the normal Grok home; missing OAuth auth fails without leaving a session behind.
279
+ The vendor CLI must be installed and authenticated (`codex` for `codex_agent`; `grok` for both Grok tools). aiterm resolves the binary via `CODEX_BIN` / `GROK_BIN`, then `~/.local/bin/codex` / `~/.grok/bin/grok`, then `PATH`. Prerequisites are validated before a session is created: empty `model` and any Grok/Composer `reasoning_effort` are errors, while a missing CLI or nonexistent `cwd` fails for all three; a failed launch leaves no session behind. Codex forwards `model` as `-m` and `reasoning_effort` as `-c model_reasoning_effort=…`; Grok/Composer reject effort because interactive TUI does not support it (Grok CLI `--effort` is headless-only). Full details under [Launch other coding agents into that terminal](#2-launch-other-coding-agents-into-that-terminal--the-orchestration-flagship). Pass an absolute path for `cwd` — `~` is not expanded. `agent_done` is hook-backed for agent launchers; Codex/Grok/Composer have passing live smokes on Linux/WSL2/macOS for follow-up `pty_send(wait:"agent_done")`, and Codex has a live smoke for launcher `prompt + wait:"agent_done"`. Native Windows can launch agents but `agent_done` is not supported yet. Codex managed `CODEX_HOME` links only `auth.json`, copies `config.toml`, and owns its own `hooks.json`. Before the first unbound agent send, `pty_send({ wait:"agent_done" })` waits for the vendor TUI input prompt and fails before sending if it is not ready. `codex_agent` launcher `wait:"agent_done"` requires `prompt` plus `agent_done:true`; it sends the initial prompt only after the TUI is ready, and returns `initial_prompt=not_sent` without sending if the TUI is blocked before input. In Grok OAuth mode, aiterm keeps hook/config isolation per launch and passes the validated normal auth canonical file through `GROK_AUTH_PATH`; managed homes contain no auth/lock file, and missing OAuth auth fails before a session exists.
280
280
 
281
281
  When an agent's answer is longer than the `wait:"agent_done"` screen tail (pane height ≈ 24 lines), recover it in full with `pty_read({ agent_transcript: true })`. It returns the most recently completed turn's final assistant message in plain text, read from the vendor's structured session transcript under the managed home — no re-prompting. Codex joins on the Stop hook `turn_id`; Grok/Composer take the assistant rows after the last real user row. Read-only, and mutually exclusive with `screen`/`full`/`rtk`/`line_range`/`wait` (`lines` is allowed). A missing transcript, a non-agent session, or an unextractable message is an explicit error, never a silent empty.
282
282
 
@@ -294,6 +294,8 @@ When an agent's answer is longer than the `wait:"agent_done"` screen tail (pane
294
294
 
295
295
  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)).
296
296
 
297
+ 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. 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.
298
+
297
299
  ## A human can watch
298
300
 
299
301
  Sessions live on a shared tmux socket. The `tmux -S … attach -t <id>` line printed by `pty_open` (and by each agent launcher) lets a human attach to the same terminal and intervene (`Ctrl-b d` to detach) — including watching a launched Codex/Grok/Composer session run and taking the keyboard from your AI mid-task. On native Windows the printed line is the WSL form — `wsl tmux -S … attach -t <id>` — since the session lives inside WSL.
@@ -344,4 +346,15 @@ If aiterm let your AI hand a task to another agent — or saved you a round-trip
344
346
 
345
347
  ## License
346
348
 
349
+ > Grok OAuthのauth/lock共有は0.9.1当時の契約で、2026-07-14に廃止した。現行はmanaged隔離を維持し、検証済み通常auth正本を`GROK_AUTH_PATH`でvendorへ渡す。aitermはlock・atomic replace・copy-backを所有しない。
350
+
347
351
  MIT
352
+
353
+ ## Grok OAuth isolation
354
+
355
+ For `agent_done`, Grok/Composer keep launch-local `GROK_HOME` and fake `HOME`.
356
+ The child receives `GROK_AUTH_PATH` pointing at the validated normal auth
357
+ canonical file; managed homes never contain auth or lock symlinks/copies.
358
+ aiterm does not create locks or copy credentials back. An inherited
359
+ `GROK_AUTH_PATH` must be absolute and safe; only an absent default auth file is
360
+ allowed when `XAI_API_KEY` is set.
package/dist/core.js CHANGED
@@ -54,6 +54,7 @@ const AGENT_METADATA_NEGATIVE_CACHE_TTL_MS = 2_000;
54
54
  const AGENT_TUI_READY_TIMEOUT_MS = 30_000;
55
55
  const AGENT_TUI_READY_POLL_MS = 500;
56
56
  const AGENT_TUI_READY_LINES = 45;
57
+ const GROK_AUTH_MAX_BYTES = 64 * 1024;
57
58
  // 出力削減(RTK の CAP 思想を移植)
58
59
  const MAX_LINES_BEFORE_ELIDE = 60;
59
60
  const HEAD_LINES = 30;
@@ -63,6 +64,12 @@ const LINE_HEAD_CHARS = 1200;
63
64
  const LINE_TAIL_CHARS = 600;
64
65
  const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
65
66
  const MAX_FULL_BYTES = 8 * 1024 * 1024; // full/range 読取で一度にメモリへ載せる上限(B7)
67
+ const MAX_SEND_BYTES = 64 * 1024;
68
+ // macOSのPTY入力queueは、tmuxが長文を1回で流すと後半を落とすことがある。
69
+ // UTF-8境界を守って小さいtmux client roundtripに分け、各回にserver event loopがPTYへdrainできる境界を作る。
70
+ const PTY_PASTE_CHUNK_BYTES = process.platform === "darwin" ? 256 : MAX_SEND_BYTES;
71
+ const SESSION_SEND_LOCK_WAIT_MS = 10_000;
72
+ const SESSION_SEND_LOCK_POLL_MS = 25;
66
73
  // 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
67
74
  const DESTRUCTIVE = [
68
75
  // rm -rf の危険な対象形(best-effort・force で越えられる)。先頭の任意クオート ['"]? で
@@ -199,18 +206,19 @@ function resolveTmux(observe = true) {
199
206
  }
200
207
  ptyDependencyError(tmuxMissingMessage(), observe);
201
208
  }
202
- function tmuxCommand(observe, ...args) {
209
+ function tmuxCommandWithInput(observe, input, ...args) {
203
210
  // maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)や多セッションの list-sessions で
204
211
  // 頭打ちになり stdout が切れる/空になる。Python の subprocess.run は無制限だったので 64MiB へ広げる。
205
212
  // Windows は同じ tmux を WSL 経由(-e でログインシェル非経由=$ 展開やクオート崩れを防ぐ)で叩く。
206
213
  let r;
214
+ const spawnOpts = { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, input };
207
215
  if (isWin) {
208
216
  ensureWinBridge(observe);
209
- r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
217
+ r = spawnSync("wsl.exe", ["-e", "tmux", "-S", SOCK, ...args], spawnOpts);
210
218
  }
211
219
  else {
212
220
  // resolveTmux() は tmux を解決できなければ明確な AitermError を投げる(POSIX 版の事前確認)。
213
- r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
221
+ r = spawnSync(resolveTmux(observe), ["-S", SOCK, ...args], spawnOpts);
214
222
  }
215
223
  // ENOBUFS(出力が 64MiB 超)を「code=1 の失敗」へ握り潰すと部分/空 stdout を正常扱いしてしまう。区別して投げる。
216
224
  // EXPECTED-FAILURE: 外部システム境界(tmux 出力過大)
@@ -225,9 +233,15 @@ function tmuxCommand(observe, ...args) {
225
233
  }
226
234
  return { code: r.status ?? 1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
227
235
  }
236
+ function tmuxCommand(observe, ...args) {
237
+ return tmuxCommandWithInput(observe, undefined, ...args);
238
+ }
228
239
  function tmux(...args) {
229
240
  return tmuxCommand(true, ...args);
230
241
  }
242
+ function tmuxWithInput(input, ...args) {
243
+ return tmuxCommandWithInput(true, input, ...args);
244
+ }
231
245
  function tmuxCleanup(...args) {
232
246
  return tmuxCommand(false, ...args);
233
247
  }
@@ -238,6 +252,42 @@ function paneCurrentCommand(name) {
238
252
  const r = tmux("display-message", "-p", "-t", name, "#{pane_current_command}");
239
253
  return r.code === 0 ? r.stdout.trim() : "";
240
254
  }
255
+ function pasteBufferSupportsNoSanitizeFlag() {
256
+ const listed = tmux("list-commands");
257
+ if (listed.code !== 0) {
258
+ throw new AitermError(`tmux paste-buffer 能力の確認に失敗しました: ${listed.stderr.trim() || `code=${listed.code}`}`, 2);
259
+ }
260
+ const usage = listed.stdout.split("\n").find((line) => line.startsWith("paste-buffer "));
261
+ if (!usage)
262
+ throw new AitermError("tmux list-commands にpaste-bufferがありません", 2);
263
+ // tmux 3.4は制御文字を無変換でpasteし、-S自体が無い。3.7は既定でvis(3)変換し、
264
+ // -Sが無変換を選ぶ。version文字比較で推測せず、実際のcommand usageにflagがあるかを見る。
265
+ return /\[-[^\]]*S[^\]]*\]/.test(usage);
266
+ }
267
+ function splitPtyText(text) {
268
+ const chunks = [];
269
+ let chunk = "";
270
+ let chunkBytes = 0;
271
+ for (const codePoint of text) {
272
+ const bytes = Buffer.byteLength(codePoint, "utf8");
273
+ if (chunk && chunkBytes + bytes > PTY_PASTE_CHUNK_BYTES) {
274
+ chunks.push(chunk);
275
+ chunk = "";
276
+ chunkBytes = 0;
277
+ }
278
+ chunk += codePoint;
279
+ chunkBytes += bytes;
280
+ }
281
+ if (chunk)
282
+ chunks.push(chunk);
283
+ return chunks;
284
+ }
285
+ function assertSendTextSize(text, context = "送信文字列") {
286
+ const bytes = Buffer.byteLength(text, "utf8");
287
+ if (bytes > MAX_SEND_BYTES) {
288
+ throw new AitermError(`${context}が${MAX_SEND_BYTES} bytesを超えています(${bytes} bytes)`, 2);
289
+ }
290
+ }
241
291
  // session 名はファイルパス(logpath 等)と pipe-pane の /bin/sh 文字列へ流れる。英数 _ - のみ・64字に
242
292
  // 限定し、パストラバーサル(../)とシェルインジェクション(' でのクオート破り・$・; 等)を全入口で断つ。
243
293
  function assertSessionName(name) {
@@ -393,6 +443,10 @@ function lastcmdpath(name) {
393
443
  function markpath(name) {
394
444
  return path.join(SOCKDIR, name + ".mark");
395
445
  }
446
+ function sendLockPath(name) {
447
+ assertSessionName(name);
448
+ return path.join(SOCKDIR, name + ".send.lock");
449
+ }
396
450
  function readOffset(name) {
397
451
  try {
398
452
  const n = parseInt(fs.readFileSync(offsetpath(name), "utf8").trim(), 10);
@@ -822,35 +876,78 @@ export function send(name, text, o = {}) {
822
876
  `正しく展開されません。until で完了パターンを指定してください。`, 2);
823
877
  }
824
878
  }
825
- writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
826
- if (o.rtk)
827
- text = rtkRewrite(text);
828
- if (o.rtk && !o.force)
829
- assertNotDestructive(text, 3, "rtk 変換後: ");
830
- if (o.mark) {
831
- // 実出力は rc=<数字>、この行のエコーは rc=%d(リテラル)。MARK_DONE_RE は数字アンカーで後者に免疫。
832
- text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
833
- try {
834
- fs.writeFileSync(markpath(name), "1"); // waitCompletion sentinel 完了検出を有効化させる
879
+ assertSendTextSize(text);
880
+ const releaseSendLock = acquireSessionSendFileLock(name);
881
+ try {
882
+ writeLastcmd(name, text); // read rtk reducer 分類用(書換/mark 前の素のコマンド)
883
+ if (o.rtk)
884
+ text = rtkRewrite(text);
885
+ if (o.rtk && !o.force)
886
+ assertNotDestructive(text, 3, "rtk 変換後: ");
887
+ if (o.mark)
888
+ text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
889
+ assertSendTextSize(text, o.rtk || o.mark ? "変換後の送信文字列" : "送信文字列");
890
+ if (o.mark) {
891
+ try {
892
+ fs.writeFileSync(markpath(name), "1"); // waitCompletion に sentinel 完了検出を有効化させる
893
+ }
894
+ catch {
895
+ /* noop(フラグ書けなくても until/quiescence 経路は生きる) */
896
+ }
835
897
  }
836
- catch {
837
- /* noop(フラグ書けなくても until/quiescence 経路は生きる) */
898
+ else {
899
+ // mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
900
+ try {
901
+ fs.unlinkSync(markpath(name));
902
+ }
903
+ catch {
904
+ /* noop */
905
+ }
838
906
  }
839
- }
840
- else {
841
- // mark 送信は、未消化の古い mark 完了待ちを無効化する(前コマンドの sentinel を待ち続けない)。
842
- try {
843
- fs.unlinkSync(markpath(name));
907
+ // `send-keys -l`と単発`paste-buffer`は、長文をPTY入力queueへ一度に流し、macOS CIで
908
+ // 途中以降が欠落しても tmux 自体は code=0 を返した。macOSだけUTF-8を壊さない256byte以下に分け、
909
+ // Linux/WSLは一括のままとする。全chunk+Enterはsession単位のcross-process lock内で直列化する。
910
+ const pasteSupportsNoSanitize = pasteBufferSupportsNoSanitizeFlag();
911
+ const chunks = splitPtyText(text);
912
+ const bufferBase = `aiterm-${process.pid}-${randomBytes(8).toString("hex")}`;
913
+ for (let i = 0; i < chunks.length; i += 1) {
914
+ const bufferName = `${bufferBase}-${i}`;
915
+ const partial = i > 0
916
+ ? " 先行chunkはPTYに入力済みでEnterは未送信です。再送前に入力を確認・消去してください。"
917
+ : "";
918
+ const loaded = tmuxWithInput(chunks[i], "load-buffer", "-b", bufferName, "-");
919
+ if (loaded.code !== 0) {
920
+ tmuxCleanup("delete-buffer", "-b", bufferName);
921
+ throw new AitermError(`tmux bufferへの送信準備に失敗しました` +
922
+ `(chunk ${i + 1}/${chunks.length}): ${loaded.stderr.trim() || `code=${loaded.code}`}.${partial}`, 2);
923
+ }
924
+ // -r: LF→CR 置換を無効化。-Sは対応新版だけでvis(3)制御文字変換を無効化する。
925
+ // send 自身の raw/sanitize 契約だけを真実とし、tmux 側で黙って再変換させない。
926
+ const pasteArgs = ["paste-buffer", "-d", "-r"];
927
+ if (pasteSupportsNoSanitize)
928
+ pasteArgs.push("-S");
929
+ pasteArgs.push("-b", bufferName, "-t", name);
930
+ const pasted = tmux(...pasteArgs);
931
+ if (pasted.code !== 0) {
932
+ // paste 失敗時は -d で消えないbufferを明示的に掃除する。元エラーを優先する。
933
+ tmuxCleanup("delete-buffer", "-b", bufferName);
934
+ throw new AitermError(`tmux bufferのPTY送信に失敗しました` +
935
+ `(chunk ${i + 1}/${chunks.length}): ${pasted.stderr.trim() || `code=${pasted.code}`}.${partial}`, 2);
936
+ }
844
937
  }
845
- catch {
846
- /* noop */
938
+ if (enter) {
939
+ const entered = tmux("send-keys", "-t", name, "Enter");
940
+ if (entered.code !== 0) {
941
+ throw new AitermError(`文字列はPTYに入力済みですがtmuxへEnterを送れませんでした: ` +
942
+ `${entered.stderr.trim() || `code=${entered.code}`}。再送前に入力を確認・消去してください`, 2);
943
+ }
847
944
  }
945
+ // コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
946
+ return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
947
+ }
948
+ finally {
949
+ releaseSendLock();
848
950
  }
849
- tmux("send-keys", "-t", name, "-l", "--", text);
850
- if (enter)
851
- tmux("send-keys", "-t", name, "Enter");
852
- // コードポイント数で数える(JS の .length は UTF-16 単位で絵文字等がズレる。Python は len()=コードポイント)。
853
- return `sent ${[...text].length} chars to ${name}` + (enter ? " (+Enter)" : "");
854
951
  }
855
952
  export function sendKey(name, key) {
856
953
  assertSessionName(name);
@@ -1066,8 +1163,15 @@ function closeSessionInternal(name, observeDependency = true) {
1066
1163
  throw new AitermError(`agent session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の agent_done 待機中のため close できません`, 2);
1067
1164
  }
1068
1165
  }
1166
+ {
1167
+ const sending = liveSendLocks(name);
1168
+ if (sending.length > 0) {
1169
+ const d = sending[0];
1170
+ throw new AitermError(`session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の送信中のため close できません`, 2);
1171
+ }
1172
+ }
1069
1173
  (observeDependency ? tmux : tmuxCleanup)("kill-session", "-t", name);
1070
- for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
1174
+ for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name), sendLockPath(name)]) {
1071
1175
  try {
1072
1176
  fs.unlinkSync(p);
1073
1177
  }
@@ -1093,11 +1197,18 @@ export function killAll() {
1093
1197
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${list}`, 2);
1094
1198
  }
1095
1199
  }
1200
+ {
1201
+ const sending = liveSendLocks(null);
1202
+ if (sending.length > 0) {
1203
+ const list = sending.map((d) => `${d.session}${d.pid != null ? `(pid ${d.pid})` : ""}`).join(",");
1204
+ throw new AitermError(`送信中の session があるため killAll できません: ${list}`, 2);
1205
+ }
1206
+ }
1096
1207
  tmux("kill-server");
1097
- // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark 残骸も掃除する(残すと B5 の stale-log 復活の温床)。
1208
+ // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark/.send.lock 残骸も掃除する。
1098
1209
  try {
1099
1210
  for (const f of fs.readdirSync(SOCKDIR)) {
1100
- if (/\.(log|offset|lastcmd|mark)$/.test(f)) {
1211
+ if (/\.(log|offset|lastcmd|mark)$/.test(f) || f.endsWith(".send.lock")) {
1101
1212
  try {
1102
1213
  fs.unlinkSync(path.join(SOCKDIR, f));
1103
1214
  }
@@ -1380,70 +1491,61 @@ function createManagedCodexHome(name, launchId, overrides = {}) {
1380
1491
  return managedHome;
1381
1492
  }
1382
1493
  function realGrokHome() {
1383
- return process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok");
1384
- }
1385
- function validateGrokAuthLock(lockPath) {
1386
- let st;
1387
- try {
1388
- st = fs.lstatSync(lockPath);
1389
- }
1390
- catch (e) {
1391
- throw e;
1392
- }
1393
- if (st.isSymbolicLink())
1394
- throw new AitermError(`Grok auth lock が symlink です: ${lockPath}`, 2);
1395
- if (!st.isFile())
1396
- throw new AitermError(`Grok auth lock が通常ファイルではありません: ${lockPath}`, 2);
1397
- if (st.nlink !== 1)
1398
- throw new AitermError(`Grok auth lock が hard link です: ${lockPath}`, 2);
1399
- try {
1400
- fs.chmodSync(lockPath, 0o600);
1401
- }
1402
- catch {
1403
- /* lock file permission tightening is best-effort */
1404
- }
1405
- }
1406
- function ensureGrokAuthLock(srcHome) {
1407
- const lockPath = path.join(srcHome, "auth.json.lock");
1408
- try {
1409
- validateGrokAuthLock(lockPath);
1410
- return lockPath;
1411
- }
1412
- catch (e) {
1413
- if (e.code !== "ENOENT")
1414
- throw e;
1415
- }
1416
- try {
1417
- createEmpty0600NoFollow(lockPath);
1418
- }
1419
- catch (e) {
1420
- if (e.code !== "EEXIST")
1421
- throw e;
1422
- }
1423
- validateGrokAuthLock(lockPath);
1424
- return lockPath;
1425
- }
1426
- function linkGrokOauthFiles(srcHome, grokHome) {
1427
- const srcAuth = path.join(srcHome, "auth.json");
1428
- let authExists = false;
1494
+ return path.resolve(process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok"));
1495
+ }
1496
+ function resolveAndValidateGrokAuth(srcHome) {
1497
+ const inheritedSet = Object.prototype.hasOwnProperty.call(process.env, "GROK_AUTH_PATH");
1498
+ const inherited = process.env.GROK_AUTH_PATH;
1499
+ if (inheritedSet && (!inherited || !path.isAbsolute(inherited)))
1500
+ throw new AitermError("GROK_AUTH_PATH は空でない絶対パスで指定してください", 2);
1501
+ const authPath = inherited ?? path.join(srcHome, "auth.json");
1502
+ let fd;
1429
1503
  try {
1430
- const authSt = fs.statSync(srcAuth);
1431
- if (!authSt.isFile())
1432
- throw new AitermError(`Grok auth.json が通常ファイルではありません: ${srcAuth}`, 2);
1433
- authExists = true;
1504
+ fd = fs.openSync(authPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
1505
+ const st = fs.fstatSync(fd);
1506
+ if (!st.isFile() || st.nlink !== 1 || st.uid !== currentUid() || (st.mode & 0o077) !== 0 || st.size > GROK_AUTH_MAX_BYTES) {
1507
+ throw new AitermError("Grok 認証正本の安全検証に失敗しました", 2);
1508
+ }
1509
+ const value = JSON.parse(fs.readFileSync(fd, "utf8"));
1510
+ if (!value || typeof value !== "object" || Array.isArray(value))
1511
+ throw new AitermError("Grok 認証正本のJSONが不正です", 2);
1512
+ // auth file 自体は O_NOFOLLOW で開いているが、中間 directory の symlink は辿り得る。
1513
+ // vendor に渡す正本を path swap の入口にしないため、字句正規化した絶対 path と realpath を
1514
+ // 一致させ、canonical な祖先も root まで検証する。same-UID race の排他は vendor lock の責務。
1515
+ const lexicalPath = path.resolve(authPath);
1516
+ const canonicalPath = fs.realpathSync(authPath);
1517
+ if (lexicalPath !== canonicalPath)
1518
+ throw new AitermError("Grok 認証正本の path に symlink を含められません", 2);
1519
+ for (let dir = path.dirname(canonicalPath);; dir = path.dirname(dir)) {
1520
+ const dirSt = fs.lstatSync(dir);
1521
+ // /tmp のような root 所有 + sticky の共有 directory は、本人所有の private な
1522
+ // 直下 directory を他 UID が rename/unlink できないため許可する。sticky 無しの
1523
+ // group/other writable 祖先は path swap が可能なので従来どおり拒否する。
1524
+ const writableByOthers = (dirSt.mode & 0o022) !== 0;
1525
+ const protectedSharedRoot = dirSt.uid === 0 && (dirSt.mode & 0o1000) !== 0;
1526
+ if (!dirSt.isDirectory()
1527
+ || dirSt.isSymbolicLink()
1528
+ || (dirSt.uid !== currentUid() && dirSt.uid !== 0)
1529
+ || (writableByOthers && !protectedSharedRoot)) {
1530
+ throw new AitermError("Grok 認証正本の祖先 directory が安全ではありません", 2);
1531
+ }
1532
+ const parent = path.dirname(dir);
1533
+ if (parent === dir)
1534
+ break;
1535
+ }
1536
+ return canonicalPath;
1434
1537
  }
1435
1538
  catch (e) {
1436
- if (e.code !== "ENOENT")
1539
+ if (e.code === "ENOENT" && !inheritedSet && process.env.XAI_API_KEY)
1540
+ return null;
1541
+ if (e instanceof AitermError)
1437
1542
  throw e;
1543
+ throw new AitermError(e.code === "ENOENT" ? "Grok 認証正本が見つかりません。先に grok login が必要です" : "Grok 認証正本を安全に開けません", 2);
1438
1544
  }
1439
- if (!authExists) {
1440
- if (process.env.XAI_API_KEY)
1441
- return;
1442
- throw new AitermError(`Grok auth.json が見つかりません。先に grok login が必要です: ${srcHome}`, 2);
1545
+ finally {
1546
+ if (fd !== undefined)
1547
+ fs.closeSync(fd);
1443
1548
  }
1444
- const srcLock = ensureGrokAuthLock(srcHome);
1445
- fs.symlinkSync(srcAuth, path.join(grokHome, "auth.json"));
1446
- fs.symlinkSync(srcLock, path.join(grokHome, "auth.json.lock"));
1447
1549
  }
1448
1550
  function writeManagedGrokConfig(grokHome) {
1449
1551
  fs.mkdirSync(path.join(grokHome, "hooks"), { recursive: false, mode: 0o700 });
@@ -1471,17 +1573,7 @@ function writeManagedGrokConfig(grokHome) {
1471
1573
  "",
1472
1574
  ].join("\n"));
1473
1575
  }
1474
- function createManagedGrokHome(name, launchId) {
1475
- const srcHome = realGrokHome();
1476
- let srcSt;
1477
- try {
1478
- srcSt = fs.statSync(srcHome);
1479
- }
1480
- catch {
1481
- throw new AitermError(`Grok home が見つかりません: ${srcHome}`, 2);
1482
- }
1483
- if (!srcSt.isDirectory())
1484
- throw new AitermError(`Grok home が directory ではありません: ${srcHome}`, 2);
1576
+ function createManagedGrokHome(name, launchId, authPath) {
1485
1577
  const grokHome = agentManagedGrokHomePath(name, launchId);
1486
1578
  const fakeHome = agentManagedGrokUserHomePath(name, launchId);
1487
1579
  fs.mkdirSync(grokHome, { recursive: false, mode: 0o700 });
@@ -1489,9 +1581,6 @@ function createManagedGrokHome(name, launchId) {
1489
1581
  fs.chmodSync(grokHome, 0o700);
1490
1582
  fs.chmodSync(fakeHome, 0o700);
1491
1583
  fs.symlinkSync(grokHome, path.join(fakeHome, ".grok"));
1492
- // OAuth refresh token rotation は auth.json だけでなく vendor lock と同じ実体を共有させる。
1493
- // per-launch GROK_HOME 隔離は維持し、通常 Grok home の hook/config/session は読ませない。
1494
- linkGrokOauthFiles(srcHome, grokHome);
1495
1584
  const hookScript = grokHookScriptPath();
1496
1585
  if (!fs.existsSync(hookScript)) {
1497
1586
  throw new AitermError(`Grok Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
@@ -1514,7 +1603,7 @@ function createManagedGrokHome(name, launchId) {
1514
1603
  });
1515
1604
  // Grok 0.2.87 は compat false でも ~/.claude/plugins の hook file を拾う。HOME を一時化して
1516
1605
  // plugin/hook source を完全に 0 にする。実 HOME は必要なら hook/agent 側で参照できるよう env で渡す。
1517
- return { grokHome, home: fakeHome };
1606
+ return { grokHome, home: fakeHome, authPath };
1518
1607
  }
1519
1608
  function writeAgentMetadata(meta) {
1520
1609
  writeJson0600(agentMetadataPath(meta.aiterm_session, meta.launch_id), meta);
@@ -1581,13 +1670,101 @@ function probeWaitLock(p) {
1581
1670
  function unlinkStaleWaitLock(p) {
1582
1671
  try {
1583
1672
  const st = fs.lstatSync(p);
1584
- if (st.isFile() && !st.isSymbolicLink() && st.uid === currentUid())
1673
+ const uid = typeof process.getuid === "function" ? process.getuid() : null;
1674
+ if (st.isFile() && !st.isSymbolicLink() && (uid == null || st.uid === uid))
1585
1675
  fs.unlinkSync(p);
1586
1676
  }
1587
1677
  catch {
1588
1678
  /* noop */
1589
1679
  }
1590
1680
  }
1681
+ function liveSendLocks(name) {
1682
+ let files;
1683
+ try {
1684
+ files = fs.readdirSync(SOCKDIR);
1685
+ }
1686
+ catch {
1687
+ return [];
1688
+ }
1689
+ const out = [];
1690
+ for (const f of files) {
1691
+ if (!f.endsWith(".send.lock"))
1692
+ continue;
1693
+ const session = f.slice(0, -".send.lock".length);
1694
+ if (name != null && session !== name)
1695
+ continue;
1696
+ const p = path.join(SOCKDIR, f);
1697
+ const probe = probeWaitLock(p);
1698
+ if (probe.live)
1699
+ out.push({ session, pid: probe.pid, at: probe.at });
1700
+ // dead send lockはここでunlinkしない。probe後に別processが同pathへ新しいlive lockを作る
1701
+ // ABAが起きると、そのlive lockを消して二重owner化できる。close/killAllがsessionを止めた後に掃除する。
1702
+ }
1703
+ return out;
1704
+ }
1705
+ function acquireSessionSendFileLock(name) {
1706
+ const p = sendLockPath(name);
1707
+ const token = randomBytes(16).toString("hex");
1708
+ const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1709
+ const deadline = Date.now() + SESSION_SEND_LOCK_WAIT_MS;
1710
+ let fd = null;
1711
+ let lastProbe = { pid: null, at: null, live: true };
1712
+ while (fd == null) {
1713
+ try {
1714
+ fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
1715
+ }
1716
+ catch (e) {
1717
+ if (e.code !== "EEXIST")
1718
+ throw e;
1719
+ lastProbe = probeWaitLock(p);
1720
+ if (!lastProbe.live) {
1721
+ const detail = lastProbe.pid != null ? `pid ${lastProbe.pid}` : "owner不明";
1722
+ throw new AitermError(`session '${name}' に前回送信のlock残骸があります(${detail})。` +
1723
+ `自動回収は並行送信の混線を招くため行いません。pty_closeでsessionを閉じてから再作成するか、` +
1724
+ `不要な全sessionをpty_kill_allで停止して残骸を掃除してください`, 2);
1725
+ }
1726
+ if (Date.now() >= deadline) {
1727
+ const detail = lastProbe.pid != null ? `pid ${lastProbe.pid}` : "owner不明";
1728
+ throw new AitermError(`session '${name}' は別プロセスの送信中です(${detail})。${SESSION_SEND_LOCK_WAIT_MS}ms待ってもlockを取得できませんでした`, 2);
1729
+ }
1730
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, SESSION_SEND_LOCK_POLL_MS);
1731
+ }
1732
+ }
1733
+ try {
1734
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }) + "\n", "utf8");
1735
+ }
1736
+ catch (error) {
1737
+ try {
1738
+ fs.closeSync(fd);
1739
+ }
1740
+ catch {
1741
+ /* noop */
1742
+ }
1743
+ // pathを再確認せずunlinkすると、外部置換後のlockを消し得る。書込失敗は残骸としてfail closedし、
1744
+ // close/killAllのsession停止後cleanupへ委ねる。
1745
+ throw error;
1746
+ }
1747
+ fs.closeSync(fd);
1748
+ try {
1749
+ fs.chmodSync(p, 0o600);
1750
+ }
1751
+ catch {
1752
+ /* Windows等でmode強制できなくてもOS user temp境界とO_EXCLは維持される */
1753
+ }
1754
+ return () => {
1755
+ try {
1756
+ const st = fs.lstatSync(p);
1757
+ if (!st.isFile() || st.isSymbolicLink())
1758
+ return;
1759
+ const current = JSON.parse(fs.readFileSync(p, "utf8").split("\n", 1)[0]);
1760
+ if (current.token === token)
1761
+ fs.unlinkSync(p);
1762
+ }
1763
+ catch {
1764
+ /* 別ownerのlockや置換済みpathは消さない */
1765
+ }
1766
+ };
1767
+ }
1591
1768
  // close/killAll 用: 生きた別プロセス待機の wait lock を列挙する(stale 残骸は数えない)。
1592
1769
  function liveWaitLocks(name) {
1593
1770
  const dir = existingAgentsDir();
@@ -1683,11 +1860,11 @@ function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}) {
1683
1860
  writeAgentMetadata(meta);
1684
1861
  return meta;
1685
1862
  }
1686
- function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
1863
+ function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath) {
1687
1864
  const launchId = randomBytes(16).toString("hex");
1688
1865
  const eventFile = agentEventPath(name, launchId);
1689
1866
  createEmpty0600NoFollow(eventFile);
1690
- const managed = createManagedGrokHome(name, launchId);
1867
+ const managed = createManagedGrokHome(name, launchId, authPath);
1691
1868
  const meta = {
1692
1869
  kind,
1693
1870
  aiterm_session: name,
@@ -1701,6 +1878,7 @@ function createGrokAgentMetadata(kind, name, cwd, initialPrompt) {
1701
1878
  node_platform: process.platform,
1702
1879
  grok_home: managed.grokHome,
1703
1880
  home: managed.home,
1881
+ grok_auth_path: managed.authPath,
1704
1882
  };
1705
1883
  writeAgentMetadata(meta);
1706
1884
  return meta;
@@ -1759,6 +1937,10 @@ function loadAgentMetadata(name) {
1759
1937
  if (m.hook_route !== "managed_grok_home" || m.grok_home !== expectedGrokHome || m.home !== expectedHome) {
1760
1938
  throw new AitermError("agent metadata の path が現在の secure state root と一致しません", 2);
1761
1939
  }
1940
+ const expectedAuthPath = resolveAndValidateGrokAuth(realGrokHome());
1941
+ if ((typeof m.grok_auth_path === "string" ? m.grok_auth_path : null) !== expectedAuthPath) {
1942
+ throw new AitermError("agent metadata の認証正本が現在の設定と一致しません", 2);
1943
+ }
1762
1944
  return {
1763
1945
  kind: m.kind,
1764
1946
  aiterm_session: name,
@@ -1772,6 +1954,7 @@ function loadAgentMetadata(name) {
1772
1954
  node_platform: process.platform,
1773
1955
  grok_home: expectedGrokHome,
1774
1956
  home: expectedHome,
1957
+ grok_auth_path: expectedAuthPath,
1775
1958
  };
1776
1959
  }
1777
1960
  function parseAgentDoneEvent(line, meta) {
@@ -2456,6 +2639,7 @@ function agentEnvPrefix(meta, sid) {
2456
2639
  return [
2457
2640
  `HOME=${shq(meta.home ?? "")}`,
2458
2641
  `GROK_HOME=${shq(meta.grok_home ?? "")}`,
2642
+ ...(meta.grok_auth_path ? [`GROK_AUTH_PATH=${shq(meta.grok_auth_path)}`] : []),
2459
2643
  "GROK_DISABLE_AUTOUPDATER=1",
2460
2644
  "GROK_CLAUDE_HOOKS_ENABLED=false",
2461
2645
  "GROK_CURSOR_HOOKS_ENABLED=false",
@@ -2569,13 +2753,14 @@ export function openAgent(kind, opts = {}) {
2569
2753
  // でしか確認できない(CI 非対象。docs/03_audit-sweep-2026-07.md 参照)。
2570
2754
  const binForCmd = isWin ? toWslPath(bin) : bin;
2571
2755
  const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
2756
+ const grokAuthPath = agentDone && kind !== "codex" ? resolveAndValidateGrokAuth(realGrokHome()) : null;
2572
2757
  const [sid, hint] = openSession(opts.session_name ?? null, "bash");
2573
2758
  let launchNote = "";
2574
2759
  try {
2575
2760
  const meta = agentDone
2576
2761
  ? kind === "codex"
2577
2762
  ? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort })
2578
- : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none")
2763
+ : createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none", grokAuthPath)
2579
2764
  : null;
2580
2765
  if (meta)
2581
2766
  agentMetadataNegativeCache.delete(sid);
package/dist/index.js CHANGED
@@ -84,7 +84,9 @@ server.registerTool("pty_send", {
84
84
  "agent_done:true で起動した Codex/Grok/Composer セッションは wait:'agent_done' で Stop hook まで待てる。",
85
85
  inputSchema: {
86
86
  session_id: z.string(),
87
- text: z.string().describe("送る文字列(コマンド)"),
87
+ text: z
88
+ .string()
89
+ .describe("送る文字列(コマンド)。UTF-8で最大64KiB"),
88
90
  enter: z.boolean().default(true).describe("末尾で Enter を送る"),
89
91
  wait: z
90
92
  .enum(["none", "agent_done"])
@@ -31,6 +31,11 @@ const RECORD_KEYS = [
31
31
  const DIAGNOSTIC_KEYS = ["status", "collection", "record_count", "unacknowledged_count"];
32
32
  const ARCHES = new Set(["x64", "arm64", "arm", "ia32"]);
33
33
  const EMPTY_STATE = () => ({ schema_version: STORE_SCHEMA, cursor: 0, acknowledged_cursor: 0, records: [] });
34
+ // readLock と queue scan の内部だけで使う。entry 本文など外部入力の Error message で
35
+ // disappearance を判定しないため、公開せず型そのものを識別子にする。
36
+ class LockDisappearedOrReplacedError extends Error {
37
+ constructor() { super("runtime error store lock が消滅または置換されました"); }
38
+ }
34
39
  function isObject(value) {
35
40
  return value !== null && typeof value === "object" && !Array.isArray(value);
36
41
  }
@@ -426,6 +431,10 @@ export class RuntimeErrorStore {
426
431
  }
427
432
  else {
428
433
  const before = fs.lstatSync(lock);
434
+ // APFS の高競合では readdir snapshot 後、正当に unlink 済みの entry が pre-open
435
+ // lstat で nlink=0 と観測される。これは置換/消滅として caller の既存 skip 経路へ渡す。
436
+ if (before.nlink === 0)
437
+ throw new LockDisappearedOrReplacedError();
429
438
  if (!before.isFile() || before.isSymbolicLink() || before.nlink < 1 || before.nlink > 2
430
439
  || before.uid !== process.getuid() || (before.mode & 0o777) !== 0o600 || before.size > 4096) {
431
440
  throw new Error("runtime error store lock が不正です");
@@ -433,9 +442,10 @@ export class RuntimeErrorStore {
433
442
  const fd = fs.openSync(lock, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0));
434
443
  try {
435
444
  const after = fs.fstatSync(fd);
436
- if (before.dev !== after.dev || before.ino !== after.ino || after.nlink < 1 || after.nlink > 2) {
437
- throw new Error("runtime error store lock が置換されました");
438
- }
445
+ if (before.dev !== after.dev || before.ino !== after.ino || after.nlink === 0)
446
+ throw new LockDisappearedOrReplacedError();
447
+ if (after.nlink < 1 || after.nlink > 2)
448
+ throw new Error("runtime error store lock が不正です");
439
449
  text = fs.readFileSync(fd, "utf8");
440
450
  }
441
451
  finally {
@@ -532,8 +542,7 @@ export class RuntimeErrorStore {
532
542
  current = this.readLock(currentPath);
533
543
  }
534
544
  catch (error) {
535
- if (error.code === "ENOENT"
536
- || (error instanceof Error && error.message.includes("置換されました")))
545
+ if (error.code === "ENOENT" || error instanceof LockDisappearedOrReplacedError)
537
546
  continue;
538
547
  throw error;
539
548
  }
@@ -568,8 +577,7 @@ export class RuntimeErrorStore {
568
577
  current = this.readLock(currentPath);
569
578
  }
570
579
  catch (error) {
571
- if (error.code === "ENOENT"
572
- || (error instanceof Error && error.message.includes("置換されました")))
580
+ if (error.code === "ENOENT" || error instanceof LockDisappearedOrReplacedError)
573
581
  continue;
574
582
  throw error;
575
583
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.12.2",
3
+ "version": "0.12.3",
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 Codex/Grok/Composer agent TUIs in a persistent terminal. Token-reducing reads.",
6
6
  "keywords": [