aiterm-mcp 0.11.0 → 0.12.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 CHANGED
@@ -20,6 +20,8 @@
20
20
  >
21
21
  > *MCP = Model Context Protocol — Claude Code のようなツールが AI に機能を差し込むためのオープン標準。*
22
22
 
23
+ **言葉でなく実測で:** このリポジトリ自身の 203 テストで、`pty_read` はコンテキストに載るトークンを生ログの **約 7.1 分の 1** に減らす。しかも pass/fail の判定は畳んでも残る。→ [組み込みシェルツールとの使い分け](#組み込みシェルツールとの使い分け)
24
+
23
25
  9 ツール: 6 つの **PTY ツール**(`pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list`)で 1 本の永続端末を開き・操作し・読む。加えて 3 つの **エージェント起動ツール**(`codex_agent` / `grok_agent` / `composer_agent`)が、別のコーディングエージェントの TUI を新しい端末の中に起動する。バックエンドは **tmux** なので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
24
26
 
25
27
  **状態:** 開発継続中 · この分野では新参で、別の形に賭けている([既存手段との比較](#既存手段との比較)参照)· 動作対象は Linux · WSL2 · macOS · Windows ネイティブ(core PTY ツール。`agent_done` は現時点では POSIX/WSL/macOS のみ)· MIT · [変更履歴](CHANGELOG.md)。
@@ -174,6 +176,40 @@ flowchart LR
174
176
 
175
177
  primitive は「PTY を 1 個握る」ことだけ。それ以外——SSH・コンテナ・REPL・起動したエージェント TUI——は、永続端末の中で動く「対話的な何か」に過ぎず、同じ `pty_send` / `pty_read` で操作する。各起動ツールは自分専用の新しい PTY を開く。PTY は tmux 上にあるので、MCP サーバや AI クライアントが再起動してもセッションは生き残る。
176
178
 
179
+ ## 組み込みシェルツールとの使い分け
180
+
181
+ MCP クライアントには最初からシェルツールが付いている。軽い用事はそれで足りるし、aiterm が活きる場面は別にある。その線引きを、同じコマンドで両方に通して確かめた。トークンの数え方は両側で揃えてある(文字数 ÷ 4、aiterm が実際に使う見積り式)ので、比較はフェアだ。
182
+
183
+ ちょっとしたコマンドなら、組み込みツールのほうが速い。`git log --oneline -5` を 1 回で返すのに対して、aiterm は `pty_send` と `pty_read` で 2 回のやり取りが要る。この 1 往復ぶんの差が、軽いコマンドではそのまま損になる(~7 秒 vs ~13 秒)。
184
+
185
+ 出力が長いとき、あるいは状態を次の呼び出しへ持ち越したいときに、この 2 往復目が元を取る。
186
+
187
+ | コマンド | 組み込みシェル | aiterm | 判定 |
188
+ | --- | --- | --- | --- |
189
+ | `git log --oneline -5` | 1 回・~7 秒 | 2 回・~13 秒 | **シェル**(往復が少ない) |
190
+ | `npm test`(203 テスト) | ~4,292 tok | ~607 tok | **aiterm**(判定は残る) |
191
+ | `find node_modules -type f` | ~500 tok¹ | ~456 tok | 互角。aiterm は先頭も末尾も残す |
192
+ | `grep -rn "session" src/` | ~2,989 tok | ~1,096 tok | **aiterm**(長い行は切られる²) |
193
+
194
+ 例えばこのリポジトリの 203 テストを走らせてみる。組み込みツールはログ 223 行をまるごと——~4,292 トークン——コンテキストに流し込む。aiterm は同じ出力を head+tail に畳んで ~607 トークンまで縮める。
195
+
196
+ ```text
197
+ [aiterm demo: 51 行 / ~607 tok (raw 223 行 / ~4292 tok); 172 行 hidden] [is_complete=True via mark]
198
+ ```
199
+
200
+ モデルに読ませるトークンは約 **7.1 分の 1** に減る。それでも判定は残る。畳んだ後の tail に `ℹ tests 203 / ℹ pass 203 / ℹ fail 0` がちゃんと載っている。削れたのは繰り返しのノイズで、ログを開いた目的の行は生きている。実行時間はほぼ同じなので、これだけ長いコマンドになると 2 往復の差は誤差に埋もれる。
201
+
202
+ aiterm はセッションの状態も持ち越せる。組み込みツールは呼び出しごとにまっさらなシェルを立てるので、cwd は毎回プロジェクト直下に戻り、環境変数も引き継がれない。試しに `cd /tmp && export BENCH_VAR=hello123` を送って、別の呼び出しで読み返してみる。
203
+
204
+ ```text
205
+ 組み込みシェル → var= # 空。env は消え、cwd はプロジェクト直下に戻る
206
+ aiterm → cwd=/tmp var=hello123 # 1 本の tmux セッションが両方を保つ
207
+ ```
208
+
209
+ cd でディレクトリを移り、環境変数を立て、ビルドを走らせる。ssh で一度ログインして、その接続のまま 10 個コマンドを打つ。REPL や起動したエージェントの TUI を 1 ターンずつ操作する。こういう流れは、1 本の tmux セッションが状態を握っていて初めて成り立つ。端末に何かを覚えておいてほしいときは、aiterm を使う。
210
+
211
+ <sub>¹ いまのハーネスは ~192 KB の出力をいったんファイルに逃がして、先頭 ~2 KB だけを見せる。そのためトークン数はほぼ並ぶ。aiterm は行数を正確に返すうえ、あとから `line_range="A:B"` で好きな範囲(先頭でも末尾でも)を取り出せる。² `rtk` の grep 縮約は長い行(~80 字)を切り詰めて、あふれを `[+N more]` にまとめる。ざっと眺めるには向くが、全行をそのまま読みたいときは組み込みツールを使う。</sub>
212
+
177
213
  ## 既存手段との比較
178
214
 
179
215
  aiterm は 2 つの系譜の交点にいる——端末を操作する MCP サーバと、より新しい「エージェント同士が共有端末越しに会話する」という発想([aiterm の立ち位置](#aiterm-の立ち位置)参照)。各軸の並びはこうなる——相手が強い所も含めて、正直に。
@@ -208,7 +244,7 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
208
244
  | --- | --- | --- |
209
245
  | `pty_open` | 端末を 1 個握り `session_id` を返す | `name?`, `shell="bash"` |
210
246
  | `pty_send` | テキスト(コマンド)を送る | `session_id`, `text`, `enter=true`, `wait`, `timeout`, `screen`, `lines`, `mark`, `force`, `rtk`, `raw` |
211
- | `pty_read` | 出力を削減して読む(既定は増分) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk` |
247
+ | `pty_read` | 出力を削減して読む(既定は増分) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript` |
212
248
  | `pty_key` | 制御キーを送る | `session_id`, `key`(`C-c`/`Enter`/`Up`…) |
213
249
  | `pty_close` | セッションを閉じる | `session_id` |
214
250
  | `pty_list` | セッション一覧 | (なし) |
@@ -225,6 +261,8 @@ aiterm は同じ核心の洞察——端末を出会いの場にする——を
225
261
 
226
262
  各ベンダーの 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 不在ならセッション残骸なしで失敗する。
227
263
 
264
+ エージェントの回答が `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・抽出不能はいずれも明示エラー(黙って空を返さない)。
265
+
228
266
  ### 完了検出(5 層)
229
267
 
230
268
  `pty_read({ wait: true })` は、プロセス終了 / `mark:true` sentinel の自動検出(後述)/ `until` 一致(**既定はリテラル部分一致**、`until_regex: true` で正規表現)/ 出力静止 ∧ シェル復帰(quiescence)/ timeout の 5 層で「コマンドが終わったか」を判定する。ネスト中(SSH・コンテナ・REPL・起動したエージェントの TUI の中)はシェル復帰判定が効かないので、`until` で内側プロンプトを指定するか、`mark: true` で送れば `pty_read({ wait: true })` が sentinel を自動検出する(until 不要・ネストでも効く)——全画面のエージェント TUI なら、出力が落ち着いた時点で `{ screen: true }` を読む。`agent_done:true` で起動したセッションは代わりに `pty_send({ wait:"agent_done" })` を使える。必要な場合はまず agent TUI の入力欄を待ち、その後 vendor Stop hook を待ってターン境界後の画面を返す。`codex_agent` launcher の `wait:"agent_done"` は起動時 `prompt` に同じ待機を行う。`pty_send` の送信前 ready 失敗は MCP エラー、launcher の初回 prompt ready 失敗は `initial_prompt=not_sent` を返す。送信後 timeout は `is_complete=False via agent_timeout` で返り、成功扱いしない。agent session の通常 `pty_read` には `agent_event_seen=true completion_attribution=none` のような補助 metadata が付くことがあるが、古い hook event を `is_complete=True` に昇格しない。完結した hook JSONL 行が壊れていた場合は、切り分け用に timeout suffix へ `malformed_events=N` が付く。ターンは完了したが端末 screen/log が flush 窓内で安定しなかった場合は、`agent_done_but_screen_unstable` が付く。
package/README.md CHANGED
@@ -20,6 +20,8 @@
20
20
  >
21
21
  > *MCP = Model Context Protocol — the open standard that lets tools like Claude Code plug capabilities into an AI.*
22
22
 
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
+
23
25
  Nine tools: six **PTY tools** — `pty_open` / `pty_send` / `pty_read` / `pty_key` / `pty_close` / `pty_list` — to open, drive, and read one persistent terminal, plus three **agent launchers** — `codex_agent` / `grok_agent` / `composer_agent` — that each start another coding agent's TUI inside a fresh one. The backend is **tmux**, so sessions survive even if the MCP server or the AI client restarts.
24
26
 
25
27
  **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).
@@ -174,6 +176,42 @@ flowchart LR
174
176
 
175
177
  One PTY is the only primitive. Everything else — SSH, containers, REPLs, and the launched agent TUIs — is just something interactive running inside a persistent terminal, driven with the same `pty_send` / `pty_read`. Each launcher opens its own fresh PTY. Because the PTYs live in tmux, sessions outlive the MCP server and the AI client.
176
178
 
179
+ ## When to reach for it vs. the built-in shell
180
+
181
+ Your MCP client already has a shell tool, and it wins on some jobs. aiterm wins on others. We measured both on the same commands in this repo, counting tokens the same way on each side (characters ÷ 4, aiterm's own estimator), so the comparison is apples-to-apples.
182
+
183
+ Start with the built-in tool for a light one-shot. `git log --oneline -5` is one round-trip; aiterm is two — `pty_send` then `pty_read` — and that second round-trip costs more than a light command saves (~7 s vs ~13 s).
184
+
185
+ The second round-trip pays for itself once the output runs long, or the state has to outlive the call.
186
+
187
+ | Command | Built-in shell | aiterm | Verdict |
188
+ | --- | --- | --- | --- |
189
+ | `git log --oneline -5` | 1 call, ~7 s | 2 calls, ~13 s | **shell** (fewer round-trips) |
190
+ | `npm test` (203 tests) | ~4,292 tok | ~607 tok | **aiterm** (~7.1× fewer, verdict kept) |
191
+ | `find node_modules -type f` | ~500 tok¹ | ~456 tok | tokens tie; aiterm keeps head and tail + `line_range` |
192
+ | `grep -rn "session" src/` | ~2,989 tok | ~1,096 tok | **aiterm** (~2.7×; long lines get clipped²) |
193
+
194
+ On the repo's own 203-test suite the reduction is real and safe. The built-in tool drops the whole 223-line log — ~4,292 tokens — into context. aiterm folds its own capture of the run down to ~607:
195
+
196
+ ```text
197
+ [aiterm demo: 51 行 / ~607 tok (raw 223 行 / ~4292 tok); 172 行 hidden] [is_complete=True via mark]
198
+ ```
199
+
200
+ <sub>`行` = lines; the meta line is quoted verbatim from aiterm's real output.</sub>
201
+
202
+ That is about **7.1× fewer** tokens reaching the model, and the verdict survives the fold: the tail still carries `ℹ tests 203 / ℹ pass 203 / ℹ fail 0`. The reduction drops the noise and keeps the line you opened the log for. Wall-clock effectively ties, so on a run this long the extra round-trip is a small part of the total.
203
+
204
+ aiterm also holds state across calls. The built-in tool runs each call in a fresh shell, so cwd resets between calls and the environment doesn't carry. Send `cd /tmp && export BENCH_VAR=hello123`, then read it back in a second, separate call:
205
+
206
+ ```text
207
+ built-in shell → var= # empty; env dropped, cwd back at project root
208
+ aiterm → cwd=/tmp var=hello123 # one tmux session holds both
209
+ ```
210
+
211
+ `cd` then set env then build, `ssh` once then run ten commands on the authenticated session, drive a live REPL or a launched agent's TUI turn by turn — one tmux session holds all of it. Reach for aiterm when the terminal has to remember something.
212
+
213
+ <sub>¹ Today's harness auto-offloads the ~192 KB dump to a file and previews only a ~2 KB head, so the token counts nearly tie; aiterm reports the accurate line count and lets `line_range="A:B"` pull any slice later, head or tail. ² The `rtk` grep reducer truncates long lines (~80 chars) and folds the overflow into `[+N more]`, which suits scanning; use the built-in tool when you need every full line.</sub>
214
+
177
215
  ## vs. the alternatives
178
216
 
179
217
  aiterm sits at the intersection of two families: terminal-driving MCP servers, and the newer "agents talk to each other through a shared terminal" idea (see [Where aiterm fits](#where-aiterm-fits)). Here's how the axes line up — honestly, including where the others are strong.
@@ -208,10 +246,10 @@ On top of that sits a productized layer a raw tmux bridge doesn't have: **token-
208
246
  | --- | --- | --- |
209
247
  | `pty_open` | Grab one terminal, return a `session_id` | `name?`, `shell="bash"` |
210
248
  | `pty_send` | Send text (a command) | `session_id`, `text`, `enter=true`, `wait`, `timeout`, `screen`, `lines`, `mark`, `force`, `rtk`, `raw` |
211
- | `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk` |
249
+ | `pty_read` | Read output, token-reduced (incremental by default) | `session_id`, `wait`, `until`, `until_regex`, `timeout`, `screen`, `full`, `lines`, `line_range`, `raw`, `rtk`, `agent_transcript` |
212
250
  | `pty_key` | Send a control key | `session_id`, `key` (`C-c`/`Enter`/`Up`…) |
213
251
  | `pty_close` | Close a session | `session_id` |
214
- | `pty_list` | List sessions | (none) |
252
+ | `pty_list` | List sessions (agent rows carry `agent=<kind>` metadata) | (none) |
215
253
 
216
254
  ### Interactive agent launchers
217
255
 
@@ -225,6 +263,8 @@ Each launcher starts a specific vendor's interactive coding-agent TUI inside a f
225
263
 
226
264
  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.
227
265
 
266
+ 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.
267
+
228
268
  ### Completion detection (5 layers)
229
269
 
230
270
  `pty_read({ wait: true })` decides "is the command done?" via five layers: process exit / a `mark:true` sentinel (auto-detected — see below) / an `until` match (a literal substring by default; pass `until_regex: true` for a regex) / output is quiescent ∧ the shell is back (quiescence) / timeout. While nested (inside SSH, a container, a REPL, or a launched agent's TUI), the "shell is back" check cannot fire, so pass `until` with the inner prompt — or send with `mark: true` and `pty_read({ wait: true })` auto-detects the completion sentinel (no `until` needed, works nested too) — or, for a full-screen agent TUI, read `{ screen: true }` once its output settles. Sessions launched with `agent_done:true` can instead use `pty_send({ wait:"agent_done" })`, which first waits for the agent TUI input prompt when needed, then waits for the vendor Stop hook and returns the screen after the turn boundary; `codex_agent` launcher `wait:"agent_done"` does the same for the initial `prompt`. Pre-send readiness failures are MCP errors for `pty_send`, while launch-time initial prompt readiness failures return the session with `initial_prompt=not_sent`; timeouts after sending are reported as `is_complete=False via agent_timeout`, not as success. Normal `pty_read` on an agent session can append auxiliary metadata such as `agent_event_seen=true completion_attribution=none`, but a stale hook event is not promoted to `is_complete=True`. If a complete hook JSONL line is malformed, the timeout suffix includes `malformed_events=N` for diagnosis. If the turn is done but the terminal screen/log does not settle within the flush window, aiterm appends `agent_done_but_screen_unstable`.
package/dist/core.js CHANGED
@@ -55,6 +55,9 @@ const AGENT_TUI_READY_LINES = 45;
55
55
  const MAX_LINES_BEFORE_ELIDE = 60;
56
56
  const HEAD_LINES = 30;
57
57
  const TAIL_LINES = 20;
58
+ const MAX_LINE_CHARS_BEFORE_ELIDE = 2000;
59
+ const LINE_HEAD_CHARS = 1200;
60
+ const LINE_TAIL_CHARS = 600;
58
61
  const DEDUP_MIN_RUN = 3; // 同一行がこれ以上連続したら 1 行+件数に畳む
59
62
  const MAX_FULL_BYTES = 8 * 1024 * 1024; // full/range 読取で一度にメモリへ載せる上限(B7)
60
63
  // 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
@@ -62,7 +65,7 @@ const DESTRUCTIVE = [
62
65
  // rm -rf の危険な対象形(best-effort・force で越えられる)。先頭の任意クオート ['"]? で
63
66
  // `rm -rf "/"` / `'/'` / `"~"` を捕捉。`\.\/\*`=./*(相対 glob)、`\.\.?\/?\s*$`=. / .. / ./ / ../
64
67
  // (カレント/親そのもの)。`./build` 等の相対サブディレクトリは末尾でないので非該当(過剰ブロック回避)。
65
- /\brm\s+-[rfRF]*[rf][rfRF]*\s+['"]?(\/|~|\$HOME|\.\/\*|\.\.?\/?\s*$|\*\s*$)/i,
68
+ /\brm\s+-[rfRF]*[rf][rfRF]*\s+(?:--\s+)?['"]?(\/|~|\$HOME|\.\/\*|\.\.?\/?\s*$|\*\s*$)/i,
66
69
  /\bmkfs(\.\w+)?\b/i,
67
70
  /\bdd\b[^\n]*\bof=\/dev\//i,
68
71
  />\s*\/dev\/(sd|nvme|hd|mmcblk)/i,
@@ -442,6 +445,26 @@ function dedupRuns(lines) {
442
445
  }
443
446
  return out;
444
447
  }
448
+ function surrogateSafeEnd(text, end) {
449
+ return end > 0 && end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]) && /[\uDC00-\uDFFF]/.test(text[end])
450
+ ? end + 1
451
+ : end;
452
+ }
453
+ function surrogateSafeStart(text, start) {
454
+ return start > 0 && start < text.length && /[\uD800-\uDBFF]/.test(text[start - 1]) && /[\uDC00-\uDFFF]/.test(text[start])
455
+ ? start - 1
456
+ : start;
457
+ }
458
+ function elideLongLines(lines) {
459
+ return lines.map((line) => {
460
+ if (line.length <= MAX_LINE_CHARS_BEFORE_ELIDE)
461
+ return line;
462
+ const headEnd = surrogateSafeEnd(line, LINE_HEAD_CHARS);
463
+ const tailStart = surrogateSafeStart(line, line.length - LINE_TAIL_CHARS);
464
+ const omitted = tailStart - headEnd;
465
+ return `${line.slice(0, headEnd)}… 〈行内 ${omitted} 文字省略。全文は raw:true か line_range で取得〉 …${line.slice(tailStart)}`;
466
+ });
467
+ }
445
468
  /** RTK 4戦略を移植: 制御除去 / 空白正規化 / 連続重複圧縮 / head+tail 折りたたみ。返り値 [body, meta]。 */
446
469
  export function reduceOutput(raw, name, elide = true) {
447
470
  const rawLinesN = (raw.match(/\n/g)?.length ?? 0) + (raw && !raw.endsWith("\n") ? 1 : 0);
@@ -454,6 +477,8 @@ export function reduceOutput(raw, name, elide = true) {
454
477
  const hint = `… 〈${elided} 行省略。全文は full=true、範囲は line_range="A:B"〉 …`;
455
478
  lines = [...lines.slice(0, HEAD_LINES), hint, ...lines.slice(lines.length - TAIL_LINES)];
456
479
  }
480
+ if (elide)
481
+ lines = elideLongLines(lines);
457
482
  const body = lines.join("\n");
458
483
  const meta = `[aiterm ${name}: ${lines.length} 行 / ~${estimateTokens(body)} tok ` +
459
484
  `(raw ${rawLinesN} 行 / ~${estimateTokens(raw)} tok)` +
@@ -588,17 +613,25 @@ async function waitCompletion(name, untilStr, untilRegex, timeout) {
588
613
  stable++;
589
614
  if (stable >= STABLE_POLLS) {
590
615
  const fg = paneCurrentCommand(name);
591
- if (SHELLS.has(fg)) {
616
+ // サイズ標本は過去・fg は今、の時間差 race を閉じる: fg 取得(サブプロセス spawn)の間に
617
+ // 出力が伸びていたら「静止」は不成立として周回をやり直す。閉じないと、コマンド完了直後の
618
+ // 出力+シェル復帰が quiescent に誤帰属され、mark/until が帰属を取り損ねる
619
+ // (macOS CI 実測: sleep 0.6 の mark 送信が via quiescent に化けた・B1 flake の根因)。
620
+ // 次周回の先頭で新増分に対する mark/until 判定が走る。
621
+ if (safeStatSize(logpath(name)) !== size) {
622
+ stable = 0;
623
+ }
624
+ else if (SHELLS.has(fg)) {
592
625
  if (isWin)
593
626
  await settleWinLog(name);
594
627
  return [true, "quiescent"]; // 出力静止 ∧ シェル復帰 = 確証つき完了
595
628
  }
596
- // ネスト中(前面が ssh/docker/REPL 等でシェル集合外)は quiescence の「シェル復帰」条件を
597
- // 原理的に満たせない。until mark も無ければこれ以上待っても確証は増えない(until/dead/
598
- // quiescent/mark のいずれも発火し得ない)ので、出力静止時点で「未確定」のまま早期返却する。
599
- // markActive のときは sentinel を待つべく早期返却せず、非シェル前面(sleep 等)でも待ち続ける。
600
- // fg==="" は前面コマンド取得失敗=ネスト断定不可なので早期返却せず従来どおり timeout まで待つ。
601
- if (!until && !markActive && fg !== "") {
629
+ else if (!until && !markActive && fg !== "") {
630
+ // ネスト中(前面が ssh/docker/REPL 等でシェル集合外)は quiescence の「シェル復帰」条件を
631
+ // 原理的に満たせない。until も mark も無ければこれ以上待っても確証は増えない(until/dead/
632
+ // quiescent/mark のいずれも発火し得ない)ので、出力静止時点で「未確定」のまま早期返却する。
633
+ // markActive のときは sentinel を待つべく早期返却せず、非シェル前面(sleep 等)でも待ち続ける。
634
+ // fg==="" は前面コマンド取得失敗=ネスト断定不可なので早期返却せず従来どおり timeout まで待つ。
602
635
  if (isWin)
603
636
  await settleWinLog(name);
604
637
  return [false, "nested"];
@@ -639,6 +672,14 @@ function rtkRewrite(text) {
639
672
  return (r.stdout ?? "").replace(/\n+$/, "");
640
673
  return text;
641
674
  }
675
+ function assertNotDestructive(text, code, context = "") {
676
+ for (const pat of DESTRUCTIVE) {
677
+ if (pat.test(text)) {
678
+ throw new AitermError(`${context}破壊的の可能性があるコマンドを遮断しました: /${pat.source}/\n` +
679
+ ` 本当に実行するなら force を有効にして再実行してください。`, code);
680
+ }
681
+ }
682
+ }
642
683
  // ---------------------------------------------------------------- 操作(return で返す / 失敗は AitermError)
643
684
  export function openSession(name, shell = "bash") {
644
685
  fs.mkdirSync(SOCKDIR, { recursive: true });
@@ -715,14 +756,8 @@ export function send(name, text, o = {}) {
715
756
  if (!o.raw) {
716
757
  text = text.replace(PASTE_MARKERS_RE, "").replace(ANSI_RE, "").replace(CTRL_RE, "");
717
758
  }
718
- if (!o.force) {
719
- for (const pat of DESTRUCTIVE) {
720
- if (pat.test(text)) {
721
- throw new AitermError(`破壊的の可能性があるコマンドを遮断しました: /${pat.source}/\n` +
722
- ` 本当に実行するなら force を有効にして再実行してください。`, 3);
723
- }
724
- }
725
- }
759
+ if (!o.force)
760
+ assertNotDestructive(text, 3);
726
761
  if (o.mark) {
727
762
  // mark の sentinel は POSIX シェル構文。前面が fish/csh/tcsh 等の非 POSIX 対話シェルだと "$?" が
728
763
  // 壊れて sentinel が成立しない。黙って壊れた完了検出を作らず、明示エラーで until を促す(B8)。
@@ -735,6 +770,8 @@ export function send(name, text, o = {}) {
735
770
  writeLastcmd(name, text); // read rtk の reducer 分類用(書換/mark 前の素のコマンド)
736
771
  if (o.rtk)
737
772
  text = rtkRewrite(text);
773
+ if (o.rtk && !o.force)
774
+ assertNotDestructive(text, 3, "rtk 変換後: ");
738
775
  if (o.mark) {
739
776
  // 実出力は rc=<数字>、この行のエコーは rc=%d(リテラル)。MARK_DONE_RE は数字アンカーで後者に免疫。
740
777
  text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
@@ -796,6 +833,13 @@ export function utf8SafeEnd(buf, end) {
796
833
  return end; // 不正な先行バイト: そのまま(stripControl 等に委ねる)
797
834
  return end - i >= need ? end : i; // 末尾文字が完結していれば end、不完全なら開始位置まで戻す
798
835
  }
836
+ /** 途中 offset から読んだ Buffer の先頭にある UTF-8 継続バイトを最大3バイト捨てる。 */
837
+ export function utf8SafeSliceStart(buf) {
838
+ let start = 0;
839
+ while (start < buf.length && start < 3 && (buf[start] & 0xc0) === 0x80)
840
+ start++;
841
+ return buf.subarray(start);
842
+ }
799
843
  export async function readOutput(name, o = {}) {
800
844
  assertSessionName(name);
801
845
  const timeout = o.timeout ?? DEFAULT_TIMEOUT;
@@ -855,7 +899,7 @@ export async function readOutput(name, o = {}) {
855
899
  let from = 0;
856
900
  if (size > MAX_FULL_BYTES)
857
901
  from = size - MAX_FULL_BYTES;
858
- text = readRange(from, size).toString("utf8");
902
+ text = utf8SafeSliceStart(readRange(from, size)).toString("utf8");
859
903
  if (from > 0)
860
904
  text = `[… 先頭 ${from} バイトを省略(ログがサイズ上限を超過。close で破棄されます)…]\n` + text;
861
905
  if (o.range) {
@@ -873,11 +917,12 @@ export async function readOutput(name, o = {}) {
873
917
  // 空を返して「何も読めない」状態になる。末尾越えは先頭から読み直す(POSIX では no-op)。
874
918
  if (off > size)
875
919
  off = 0;
876
- const buf = readRange(off, size); // 増分のみをメモリに載せる
877
- // 末尾の不完全マルチバイト列は次回へ持ち越す(B3)。off は前回この分岐が safeEnd を書くので先頭は割れない。
920
+ const initial = readRange(off, size); // 増分のみをメモリに載せる
921
+ const buf = utf8SafeSliceStart(initial);
922
+ // 先頭の継続バイトは捨て、末尾の不完全マルチバイト列は次回へ持ち越す(B3)。
878
923
  const safeLen = utf8SafeEnd(buf, buf.length);
879
924
  text = buf.subarray(0, safeLen).toString("utf8");
880
- nextOffset = off + safeLen;
925
+ nextOffset = off + (initial.length - buf.length) + safeLen;
881
926
  if (o.lines)
882
927
  text = text.split("\n").slice(-o.lines).join("\n");
883
928
  }
@@ -909,8 +954,26 @@ export async function readOutput(name, o = {}) {
909
954
  }
910
955
  export function listSessions() {
911
956
  const r = tmux("list-sessions", "-F", "#{session_name}\t#{pane_current_command}\t#{?session_attached,attached,detached}\t#{window_width}x#{window_height}");
912
- if (r.code === 0 && r.stdout.trim())
913
- return r.stdout.replace(/\s+$/, "");
957
+ if (r.code === 0 && r.stdout.trim()) {
958
+ return r.stdout
959
+ .replace(/\s+$/, "")
960
+ .split("\n")
961
+ .map((line) => {
962
+ const name = line.split("\t", 1)[0];
963
+ const meta = tryLoadAgentMetadata(name);
964
+ if (!meta)
965
+ return line;
966
+ const agent = [
967
+ `agent=${meta.kind}`,
968
+ "agent_done=true",
969
+ meta.vendor_session_id ? `vendor_session_id=${meta.vendor_session_id}` : null,
970
+ ]
971
+ .filter(Boolean)
972
+ .join(" ");
973
+ return `${line}\t${agent}`;
974
+ })
975
+ .join("\n");
976
+ }
914
977
  return "(セッション無し)";
915
978
  }
916
979
  export function closeSession(name) {
@@ -918,6 +981,14 @@ export function closeSession(name) {
918
981
  if (agentWaitLocks.has(name)) {
919
982
  throw new AitermError(`agent session '${name}' は agent_done 待機中のため close できません`, 2);
920
983
  }
984
+ {
985
+ // 別プロセスの待機は in-memory Set に映らない。生きた file lock があれば close で state を消さない
986
+ const foreign = liveWaitLocks(name);
987
+ if (foreign.length > 0) {
988
+ const d = foreign[0];
989
+ throw new AitermError(`agent session '${name}' は別プロセス${d.pid != null ? `(pid ${d.pid})` : ""}の agent_done 待機中のため close できません`, 2);
990
+ }
991
+ }
921
992
  tmux("kill-session", "-t", name);
922
993
  for (const p of [logpath(name), offsetpath(name), lastcmdpath(name), markpath(name)]) {
923
994
  try {
@@ -934,6 +1005,14 @@ export function killAll() {
934
1005
  if (agentWaitLocks.size > 0) {
935
1006
  throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${Array.from(agentWaitLocks).join(",")}`, 2);
936
1007
  }
1008
+ {
1009
+ // 別プロセスの待機(file lock が生きているもの)も巻き添えにしない
1010
+ const foreign = liveWaitLocks(null);
1011
+ if (foreign.length > 0) {
1012
+ const list = foreign.map((d) => `${d.session}${d.pid != null ? `(pid ${d.pid})` : ""}`).join(",");
1013
+ throw new AitermError(`agent_done 待機中の session があるため killAll できません: ${list}`, 2);
1014
+ }
1015
+ }
937
1016
  tmux("kill-server");
938
1017
  // B9: SOCKDIR 内の .log/.offset/.lastcmd/.mark 残骸も掃除する(残すと B5 の stale-log 復活の温床)。
939
1018
  try {
@@ -1025,13 +1104,27 @@ function readFileRange(p, from, to) {
1025
1104
  }
1026
1105
  }
1027
1106
  function writeJson0600(p, v) {
1028
- fs.writeFileSync(p, JSON.stringify(v, null, 2) + "\n", { mode: 0o600 });
1107
+ // truncate-in-place はクラッシュ/ENOSPC の窓で空・途中 JSON を残すので、temp→rename の原子的置換にする
1108
+ const tmp = `${p}.${randomBytes(6).toString("hex")}.tmp`;
1109
+ fs.writeFileSync(tmp, JSON.stringify(v, null, 2) + "\n", { mode: 0o600, flag: "wx" });
1029
1110
  try {
1030
- fs.chmodSync(p, 0o600);
1111
+ fs.chmodSync(tmp, 0o600);
1031
1112
  }
1032
1113
  catch {
1033
1114
  /* noop */
1034
1115
  }
1116
+ try {
1117
+ fs.renameSync(tmp, p);
1118
+ }
1119
+ catch (e) {
1120
+ try {
1121
+ fs.unlinkSync(tmp);
1122
+ }
1123
+ catch {
1124
+ /* noop */
1125
+ }
1126
+ throw e;
1127
+ }
1035
1128
  }
1036
1129
  function writeText0600(p, text) {
1037
1130
  fs.writeFileSync(p, text, { mode: 0o600 });
@@ -1069,9 +1162,9 @@ function applyCodexConfigOverrides(body, overrides) {
1069
1162
  if (firstTable === -1)
1070
1163
  firstTable = rows.length;
1071
1164
  const head = rows.slice(0, firstTable).filter((l) => {
1072
- if (overrides.model && /^\s*model\s*=/.test(l))
1165
+ if (overrides.model && /^\s*(?:"model"|'model'|model)\s*=/.test(l))
1073
1166
  return false;
1074
- if (overrides.effort && /^\s*model_reasoning_effort\s*=/.test(l))
1167
+ if (overrides.effort && /^\s*(?:"model_reasoning_effort"|'model_reasoning_effort'|model_reasoning_effort)\s*=/.test(l))
1075
1168
  return false;
1076
1169
  return true;
1077
1170
  });
@@ -1094,7 +1187,7 @@ function readCodexConfigPins(configPath) {
1094
1187
  firstTable = rows.length;
1095
1188
  const pick = (key) => {
1096
1189
  for (const l of rows.slice(0, firstTable)) {
1097
- const m = l.match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`));
1190
+ const m = l.match(new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.*)$`));
1098
1191
  if (m) {
1099
1192
  const v = m[1].trim().match(/^"([^"\\]*)"\s*(?:#.*)?$/);
1100
1193
  return { present: true, value: v ? v[1] : null };
@@ -1104,6 +1197,39 @@ function readCodexConfigPins(configPath) {
1104
1197
  };
1105
1198
  return { model: pick("model"), effort: pick("model_reasoning_effort") };
1106
1199
  }
1200
+ function managedCodexConfigSummary(configPath, hookTrustBypass) {
1201
+ let body;
1202
+ try {
1203
+ body = fs.readFileSync(configPath, "utf8");
1204
+ }
1205
+ catch {
1206
+ return "";
1207
+ }
1208
+ const rows = body.split(/\r?\n/);
1209
+ const mcpServers = rows.filter((line) => /^\s*\[mcp_servers\./.test(line)).length;
1210
+ const firstTable = rows.findIndex((line) => /^\s*\[/.test(line));
1211
+ const topLevel = rows.slice(0, firstTable === -1 ? rows.length : firstTable);
1212
+ const valueOf = (key) => {
1213
+ const row = topLevel.find((line) => new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.+?)\\s*(?:#.*)?$`).test(line));
1214
+ if (!row)
1215
+ return null;
1216
+ const raw = row.match(/=\s*(.+?)(?:\s+#.*)?$/)?.[1].trim() ?? null;
1217
+ if (!raw)
1218
+ return null;
1219
+ const quoted = raw.match(/^(?:"([^"\\]*)"|'([^'\\]*)')$/);
1220
+ return quoted ? quoted[1] ?? quoted[2] : raw;
1221
+ };
1222
+ const bits = [`mcp_servers ${mcpServers} 個継承`];
1223
+ const approvalPolicy = valueOf("approval_policy");
1224
+ const sandboxMode = valueOf("sandbox_mode");
1225
+ if (approvalPolicy)
1226
+ bits.push(`approval_policy=${approvalPolicy}`);
1227
+ if (sandboxMode)
1228
+ bits.push(`sandbox_mode=${sandboxMode}`);
1229
+ if (hookTrustBypass)
1230
+ bits.push("hook trust bypass 有効");
1231
+ return `managed config: ${bits.join(" / ")}`;
1232
+ }
1107
1233
  function createManagedCodexHome(name, launchId, overrides = {}) {
1108
1234
  const srcHome = realCodexHome();
1109
1235
  let srcSt;
@@ -1330,19 +1456,107 @@ function setInitialPromptState(meta, state) {
1330
1456
  meta.initial_prompt = state;
1331
1457
  writeAgentMetadata(meta);
1332
1458
  }
1333
- function acquireAgentWaitFileLock(meta) {
1334
- const p = agentWaitLockPath(meta.aiterm_session, meta.launch_id);
1335
- const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1336
- let fd;
1459
+ // wait lock の鮮度猶予: lock は open(O_EXCL)→pid 書込みの2段なので、中身が読めない直後の lock を
1460
+ // stale と誤判定しないための下限。これより古くて pid が読めない lock だけ残骸として回収する。
1461
+ const WAIT_LOCK_FRESH_MS = 5_000;
1462
+ function isPidAlive(pid) {
1337
1463
  try {
1338
- fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
1464
+ process.kill(pid, 0);
1465
+ return true;
1339
1466
  }
1340
1467
  catch (e) {
1341
- if (e.code === "EEXIST") {
1342
- throw new AitermError(`agent session '${meta.aiterm_session}' は別プロセスの agent_done 待機中です`, 2);
1468
+ // ESRCH=不在。EPERM 等の判定不能は生存扱い(誤回収より拒否に倒す)
1469
+ return e.code !== "ESRCH";
1470
+ }
1471
+ }
1472
+ // wait lock が「生きた待機」か「消滅プロセスの残骸」かを判定する。
1473
+ // 前提: 呼び出し側は in-memory agentWaitLocks を先に確認している。よって pid=自プロセスの lock は
1474
+ // 例外経路で release が漏れた残骸と確定できる。
1475
+ function probeWaitLock(p) {
1476
+ let pid = null;
1477
+ let at = null;
1478
+ let ageMs = 0;
1479
+ try {
1480
+ const st = fs.lstatSync(p);
1481
+ if (!st.isFile() || st.isSymbolicLink())
1482
+ return { pid: null, at: null, live: true };
1483
+ ageMs = Math.max(0, Date.now() - st.mtimeMs);
1484
+ const v = JSON.parse(fs.readFileSync(p, "utf8").split("\n", 1)[0]);
1485
+ if (typeof v.pid === "number" && Number.isInteger(v.pid) && v.pid > 0)
1486
+ pid = v.pid;
1487
+ if (typeof v.at === "string")
1488
+ at = v.at;
1489
+ }
1490
+ catch {
1491
+ /* pid 不明のまま鮮度判定に落ちる */
1492
+ }
1493
+ if (pid == null)
1494
+ return { pid: null, at, live: ageMs < WAIT_LOCK_FRESH_MS };
1495
+ if (pid === process.pid)
1496
+ return { pid, at, live: false };
1497
+ return { pid, at, live: isPidAlive(pid) };
1498
+ }
1499
+ function unlinkStaleWaitLock(p) {
1500
+ try {
1501
+ const st = fs.lstatSync(p);
1502
+ if (st.isFile() && !st.isSymbolicLink() && st.uid === currentUid())
1503
+ fs.unlinkSync(p);
1504
+ }
1505
+ catch {
1506
+ /* noop */
1507
+ }
1508
+ }
1509
+ // close/killAll 用: 生きた別プロセス待機の wait lock を列挙する(stale 残骸は数えない)。
1510
+ function liveWaitLocks(name) {
1511
+ const dir = existingAgentsDir();
1512
+ if (!dir)
1513
+ return [];
1514
+ let files;
1515
+ try {
1516
+ files = fs.readdirSync(dir);
1517
+ }
1518
+ catch {
1519
+ return [];
1520
+ }
1521
+ const out = [];
1522
+ for (const f of files) {
1523
+ if (!f.endsWith(".wait.lock"))
1524
+ continue;
1525
+ const session = f.slice(0, f.indexOf("."));
1526
+ if (name != null && session !== name)
1527
+ continue;
1528
+ if (agentWaitLocks.has(session))
1529
+ continue; // in-process 待機は呼び出し側の既存ガードが担当
1530
+ const probe = probeWaitLock(path.join(dir, f));
1531
+ if (probe.live)
1532
+ out.push({ session, pid: probe.pid, at: probe.at });
1533
+ }
1534
+ return out;
1535
+ }
1536
+ function waitLockBusyError(session, probe) {
1537
+ const detail = probe.pid != null ? `(pid ${probe.pid}${probe.at ? ` / ${probe.at} 開始` : ""})` : "";
1538
+ return new AitermError(`agent session '${session}' は別プロセスの agent_done 待機中です${detail}`, 2);
1539
+ }
1540
+ function acquireAgentWaitFileLock(meta) {
1541
+ const p = agentWaitLockPath(meta.aiterm_session, meta.launch_id);
1542
+ const nofollow = fs.constants.O_NOFOLLOW ?? 0;
1543
+ let fd = null;
1544
+ for (let attempt = 0; attempt < 2 && fd == null; attempt++) {
1545
+ try {
1546
+ fd = fs.openSync(p, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
1547
+ }
1548
+ catch (e) {
1549
+ if (e.code !== "EEXIST")
1550
+ throw e;
1551
+ const probe = probeWaitLock(p);
1552
+ // 生きた待機、または回収後の再取得でも EEXIST(=直後に別プロセスが取得した race)は拒否
1553
+ if (probe.live || attempt > 0)
1554
+ throw waitLockBusyError(meta.aiterm_session, probe);
1555
+ unlinkStaleWaitLock(p);
1343
1556
  }
1344
- throw e;
1345
1557
  }
1558
+ if (fd == null)
1559
+ throw new AitermError(`agent session '${meta.aiterm_session}' は別プロセスの agent_done 待機中です`, 2);
1346
1560
  try {
1347
1561
  fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + "\n", undefined, "utf8");
1348
1562
  }
@@ -1597,6 +1811,148 @@ function latestAgentDoneEvent(meta) {
1597
1811
  }
1598
1812
  return latest;
1599
1813
  }
1814
+ function findLatestCodexTranscript(codexHome, vendorSessionId) {
1815
+ const sessionsDir = path.join(codexHome, "sessions");
1816
+ let latestFile = null;
1817
+ let latestMtime = -Infinity;
1818
+ const visit = (dir) => {
1819
+ let entries;
1820
+ try {
1821
+ entries = fs.readdirSync(dir, { withFileTypes: true });
1822
+ }
1823
+ catch {
1824
+ return;
1825
+ }
1826
+ for (const entry of entries) {
1827
+ const file = path.join(dir, entry.name);
1828
+ if (entry.isDirectory()) {
1829
+ visit(file);
1830
+ continue;
1831
+ }
1832
+ if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl") || !entry.name.includes(vendorSessionId))
1833
+ continue;
1834
+ try {
1835
+ const mtimeMs = fs.statSync(file).mtimeMs;
1836
+ if (mtimeMs > latestMtime) {
1837
+ latestFile = file;
1838
+ latestMtime = mtimeMs;
1839
+ }
1840
+ }
1841
+ catch {
1842
+ // 探索中に消えた transcript は候補にしない。候補が無ければ明示エラーにする。
1843
+ }
1844
+ }
1845
+ };
1846
+ visit(sessionsDir);
1847
+ return latestFile;
1848
+ }
1849
+ function transcriptUnavailable() {
1850
+ throw new AitermError("transcript がまだありません。ターン完了後に再取得してください。", 2);
1851
+ }
1852
+ function transcriptNotFound(vendor) {
1853
+ throw new AitermError(`最終 assistant メッセージを特定できませんでした(vendor=${vendor})。screen で確認してください。`, 2);
1854
+ }
1855
+ function readTranscriptLines(file) {
1856
+ try {
1857
+ return fs.readFileSync(file, "utf8").split("\n");
1858
+ }
1859
+ catch (e) {
1860
+ if (e.code === "ENOENT")
1861
+ transcriptUnavailable();
1862
+ throw new AitermError(`transcript を読めません: ${e.message}`, 2);
1863
+ }
1864
+ }
1865
+ /** agent vendor の構造化 transcript から直近完了ターンの最終回答を読む。 */
1866
+ export async function readAgentTranscript(name, o = {}) {
1867
+ const meta = loadAgentMetadata(name);
1868
+ if (!meta.vendor_session_id) {
1869
+ throw new AitermError(`agent session '${name}' はまだターンが完了していません。agent_done 完了後に再取得してください。`, 2);
1870
+ }
1871
+ const done = latestAgentDoneEvent(meta);
1872
+ const turnId = done?.turn_id ?? null;
1873
+ let text = "";
1874
+ if (meta.kind === "codex") {
1875
+ if (!meta.codex_home)
1876
+ transcriptUnavailable();
1877
+ const transcript = findLatestCodexTranscript(meta.codex_home, meta.vendor_session_id);
1878
+ if (!transcript)
1879
+ transcriptUnavailable();
1880
+ const lines = readTranscriptLines(transcript);
1881
+ const matching = [];
1882
+ let finalAnswer = "";
1883
+ for (const line of lines) {
1884
+ if (!line.trim())
1885
+ continue;
1886
+ let record;
1887
+ try {
1888
+ record = JSON.parse(line);
1889
+ }
1890
+ catch {
1891
+ continue;
1892
+ }
1893
+ const payload = record?.payload;
1894
+ if (record?.type === "response_item" &&
1895
+ payload?.type === "message" &&
1896
+ payload?.role === "assistant" &&
1897
+ payload?.internal_chat_message_metadata_passthrough?.turn_id === turnId &&
1898
+ Array.isArray(payload?.content)) {
1899
+ for (const item of payload.content) {
1900
+ if (item?.type === "output_text" && typeof item.text === "string")
1901
+ matching.push(item.text);
1902
+ }
1903
+ }
1904
+ if (record?.type === "event_msg" &&
1905
+ payload?.type === "agent_message" &&
1906
+ payload?.phase === "final_answer" &&
1907
+ typeof payload?.message === "string") {
1908
+ finalAnswer = payload.message;
1909
+ }
1910
+ }
1911
+ text = matching.join("\n") || finalAnswer;
1912
+ }
1913
+ else {
1914
+ if (!meta.grok_home)
1915
+ transcriptUnavailable();
1916
+ // cwd 未指定で起動した TUI はサーバープロセスの cwd を継承する。metadata に null が残る既存
1917
+ // launch との互換のため、その実際の起動 cwd を path 導出に使う(launch 側は変更しない)。
1918
+ const cwd = meta.cwd ?? process.cwd();
1919
+ const transcript = path.join(meta.grok_home, "sessions", encodeURIComponent(cwd), meta.vendor_session_id, "chat_history.jsonl");
1920
+ const lines = readTranscriptLines(transcript);
1921
+ let lastUser = -1;
1922
+ const records = [];
1923
+ for (const line of lines) {
1924
+ if (!line.trim())
1925
+ continue;
1926
+ try {
1927
+ const record = JSON.parse(line);
1928
+ records.push(record);
1929
+ if (record?.type === "user" && !("synthetic_reason" in record))
1930
+ lastUser = records.length - 1;
1931
+ }
1932
+ catch {
1933
+ // 外部 transcript の壊れた1行は残りの完結行を読む妨げにしない。
1934
+ }
1935
+ }
1936
+ text = records
1937
+ .slice(lastUser + 1)
1938
+ .filter((record) => record?.type === "assistant" && typeof record?.content === "string")
1939
+ .map((record) => record.content)
1940
+ .join("\n");
1941
+ }
1942
+ if (!text.trim())
1943
+ transcriptNotFound(meta.kind);
1944
+ if (o.lines != null)
1945
+ text = text.split("\n").slice(-o.lines).join("\n");
1946
+ const rawChars = text.length;
1947
+ const [body, outputMeta] = reduceOutput(text, name, true);
1948
+ const transcriptMeta = [
1949
+ "agent_transcript",
1950
+ `vendor=${meta.kind}`,
1951
+ `turn_id=${turnId ?? "unknown"}`,
1952
+ `raw_chars=${rawChars}`,
1953
+ ].join(" ");
1954
+ return `${body}\n${outputMeta} [${transcriptMeta}]`;
1955
+ }
1600
1956
  function inferAgentFrontend(name, meta, screen) {
1601
1957
  const fg = paneCurrentCommand(name);
1602
1958
  const view = screen ?? captureScreen(name, AGENT_TUI_READY_LINES);
@@ -2016,10 +2372,12 @@ function buildAgentLaunchNote(kind, model, effort, meta) {
2016
2372
  : "端末config継承(値未解析)"
2017
2373
  : "CLI既定";
2018
2374
  const effectiveEffort = effort ?? (pins.effort.present ? pins.effort.value : null);
2019
- return (`起動設定: model=${describePin(model, pins.model)} effort=${describePin(effort, pins.effort)}。` +
2375
+ const launch = `起動設定: model=${describePin(model, pins.model)} effort=${describePin(effort, pins.effort)}。` +
2020
2376
  (effectiveEffort === "ultra"
2021
2377
  ? "⚠ effort=ultra は max 推論+proactive 自動委譲 ON(子エージェント自動生成・使用量急増に注意)。"
2022
- : ""));
2378
+ : "");
2379
+ const summary = meta?.kind === "codex" && meta.codex_home ? managedCodexConfigSummary(configPath, true) : "";
2380
+ return summary ? `${launch}\n${summary}\n` : launch;
2023
2381
  }
2024
2382
  export function openAgent(kind, opts = {}) {
2025
2383
  const label = agentLabel(kind);
package/dist/index.js CHANGED
@@ -61,8 +61,12 @@ server.registerTool("pty_send", {
61
61
  .boolean()
62
62
  .default(false)
63
63
  .describe("完了 sentinel(終了コード付き)で包む。pty_read(wait:true) が until 無しでも自動検出して" +
64
- "完了確定する(ネスト中や非シェル前面でも効く確実な完了検出。手で until を組む必要なし)"),
65
- force: z.boolean().default(false).describe("破壊的コマンドゲートを越える"),
64
+ "完了確定する(ネスト中や非シェル前面でも効く確実な完了検出。手で until を組む必要なし)。" +
65
+ " enter:false と併用すると sentinel が実行されず完了検出が発火しない(送信後に pty_key(\"Enter\") で実行される)。"),
66
+ force: z
67
+ .boolean()
68
+ .default(false)
69
+ .describe("破壊的コマンドゲートを越える。agent 起動時 prompt の完了待ち中の混入防止ガードも同時に解除する"),
66
70
  rtk: z.boolean().default(false).describe("既知コマンドを rtk 形へ委譲して送る(rtk 不在なら素通し)"),
67
71
  raw: z.boolean().default(false).describe("送信前サニタイズを無効化"),
68
72
  },
@@ -88,7 +92,9 @@ server.registerTool("pty_send", {
88
92
  });
89
93
  server.registerTool("pty_read", {
90
94
  description: "セッションの出力をトークン削減して読む(既定は前回読取位置からの増分)。" +
91
- "削減: 制御文字除去 / 反復圧縮 / head+tail 折りたたみ+復元ヒント+メタ併記。",
95
+ "削減: 制御文字除去 / 反復圧縮 / head+tail 折りたたみ+復元ヒント+メタ併記。" +
96
+ "agent_transcript:true は agent session の直近完了ターンの最終 assistant メッセージを vendor transcript から平文で返す。" +
97
+ "長い回答が screen tail で切れた時の回収用。",
92
98
  inputSchema: {
93
99
  session_id: z.string(),
94
100
  wait: z
@@ -110,9 +116,19 @@ server.registerTool("pty_read", {
110
116
  line_range: z.string().nullish().describe('全文からの行範囲 "A:B"'),
111
117
  raw: z.boolean().default(false).describe("削減せず生テキスト"),
112
118
  rtk: z.boolean().default(false).describe("直前コマンド別の自前 reducer(git/grep/pytest 等)で縮約"),
119
+ agent_transcript: z
120
+ .boolean()
121
+ .default(false)
122
+ .describe("agent session の直近完了ターンの最終 assistant メッセージを vendor transcript から平文で返す。長い回答が screen tail で切れた時の回収用"),
113
123
  },
114
- }, async ({ session_id, wait, until, until_regex, timeout, screen, full, lines, line_range, raw, rtk }) => {
124
+ }, async ({ session_id, wait, until, until_regex, timeout, screen, full, lines, line_range, raw, rtk, agent_transcript }) => {
115
125
  try {
126
+ if (agent_transcript) {
127
+ if (screen || full || rtk || wait || line_range != null) {
128
+ throw new Error("agent_transcript:true は screen / full / rtk / line_range / wait と併用できません。lines のみ指定できます。");
129
+ }
130
+ return ok(await core.readAgentTranscript(session_id, { lines: lines ?? null }));
131
+ }
116
132
  let range = null;
117
133
  if (line_range) {
118
134
  const idx = line_range.indexOf(":");
@@ -122,7 +138,11 @@ server.registerTool("pty_read", {
122
138
  // 下端は負値を 0 にクランプする("-3:5" が負 slice にならないように・C12)。
123
139
  const loN = Math.max(0, parseInt(lo, 10) || 0);
124
140
  const hiN = hi ? parseInt(hi, 10) : NaN;
125
- range = [loN, Number.isNaN(hiN) || hiN < 0 ? null : hiN];
141
+ const upper = Number.isNaN(hiN) || hiN < 0 ? null : hiN;
142
+ if (upper !== null && upper < loN) {
143
+ throw new Error(`line_range ${JSON.stringify(line_range)} が不正です: 上端が下端より小さい`);
144
+ }
145
+ range = [loN, upper];
126
146
  }
127
147
  const out = await core.readOutput(session_id, {
128
148
  wait,
@@ -168,7 +188,7 @@ server.registerTool("pty_close", {
168
188
  }
169
189
  });
170
190
  server.registerTool("pty_list", {
171
- description: "握っているセッション一覧(名前 / 現在の前面コマンド / attach 状態 / サイズ)。",
191
+ description: "握っているセッション一覧(名前 / 現在の前面コマンド / attach 状態 / サイズ / agent 情報)。",
172
192
  inputSchema: {},
173
193
  }, async () => {
174
194
  try {
package/dist/rtk.js CHANGED
@@ -61,6 +61,7 @@ export function reducePytest(output) {
61
61
  let summaryLine = "";
62
62
  const failures = [];
63
63
  const xfailLines = [];
64
+ let pytestEvidence = false;
64
65
  let state = "header";
65
66
  let current = [];
66
67
  const flush = () => {
@@ -76,14 +77,18 @@ export function reducePytest(output) {
76
77
  continue;
77
78
  }
78
79
  if (t.startsWith("===") && t.includes("FAILURES")) {
80
+ pytestEvidence = true;
79
81
  state = "failures";
80
82
  continue;
81
83
  }
82
84
  if (t.startsWith("===") && t.includes("short test summary")) {
85
+ pytestEvidence = true;
83
86
  state = "summary";
84
87
  flush();
85
88
  continue;
86
89
  }
90
+ if (t.includes("no tests ran in"))
91
+ pytestEvidence = true;
87
92
  if (t.startsWith("===") &&
88
93
  (t.includes("passed") || t.includes("failed") || t.includes("skipped") || t.includes("error"))) {
89
94
  // 収集エラーのみ(`=== 1 error in Xs ===`)も要約行として拾う。拾わないと全ゼロ扱いで
@@ -125,6 +130,10 @@ export function reducePytest(output) {
125
130
  }
126
131
  flush();
127
132
  const [p, f, s, xf, xp, e] = parsePytestCounts(summaryLine);
133
+ if (/\b\d+\s+(?:passed|failed|skipped|xfailed|xpassed|errors?)\b/.test(summaryLine))
134
+ pytestEvidence = true;
135
+ if (!pytestEvidence)
136
+ return null;
128
137
  if (p === 0 && f === 0 && s === 0 && xf === 0 && xp === 0 && e === 0)
129
138
  return "Pytest: No tests collected";
130
139
  // error(収集/内部エラー)は失敗の一種=緑扱いにしない。extras に含め、"N passed" 早期 return を止める。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
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": [