aiterm-mcp 0.10.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 +39 -1
- package/README.md +50 -10
- package/dist/core.js +519 -53
- package/dist/index.js +47 -20
- package/dist/rtk.js +9 -0
- package/package.json +1 -1
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).
|
|
@@ -60,11 +62,11 @@ One call per model, so the tool name itself tells you which model you get:
|
|
|
60
62
|
|
|
61
63
|
| Tool | Launches | Key args |
|
|
62
64
|
| --- | --- | --- |
|
|
63
|
-
| `codex_agent` | Codex CLI (OpenAI;
|
|
64
|
-
| `grok_agent` | Grok Build, model `grok-
|
|
65
|
-
| `composer_agent` | Grok Build, model `grok-composer-2.5-fast` (xAI) |
|
|
65
|
+
| `codex_agent` | Codex CLI (OpenAI; terminal config/CLI default unless overridden) | `prompt?`, `model?`, `reasoning_effort?` (`low`/`medium`/`high`/`xhigh`/`max`/`ultra`; ultra enables proactive automatic delegation), `cwd?`, `session_name?`, `agent_done?`, `wait?`, `timeout?`, `screen?`, `lines?` |
|
|
66
|
+
| `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?` |
|
|
67
|
+
| `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?` |
|
|
66
68
|
|
|
67
|
-
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:
|
|
69
|
+
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`.
|
|
68
70
|
|
|
69
71
|
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.
|
|
70
72
|
|
|
@@ -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
|
|
|
@@ -219,11 +257,13 @@ Each launcher starts a specific vendor's interactive coding-agent TUI inside a f
|
|
|
219
257
|
|
|
220
258
|
| Tool | Launches | Key args |
|
|
221
259
|
| --- | --- | --- |
|
|
222
|
-
| `codex_agent` | Codex CLI (OpenAI;
|
|
223
|
-
| `grok_agent` | Grok Build, model `grok-
|
|
224
|
-
| `composer_agent` | Grok Build, model `grok-composer-2.5-fast` (xAI) |
|
|
260
|
+
| `codex_agent` | Codex CLI (OpenAI; terminal config/CLI default unless overridden) | `prompt?`, `model?`, `reasoning_effort?` (`low`/`medium`/`high`/`xhigh`/`max`/`ultra`; ultra enables proactive automatic delegation), `cwd?`, `session_name?`, `agent_done?`, `wait?`, `timeout?`, `screen?`, `lines?` |
|
|
261
|
+
| `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?` |
|
|
262
|
+
| `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?` |
|
|
263
|
+
|
|
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.
|
|
225
265
|
|
|
226
|
-
|
|
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.
|
|
227
267
|
|
|
228
268
|
### Completion detection (5 layers)
|
|
229
269
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
|
877
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 });
|
|
@@ -1050,7 +1143,94 @@ function createEmpty0600NoFollow(p) {
|
|
|
1050
1143
|
function realCodexHome() {
|
|
1051
1144
|
return process.env.CODEX_HOME || path.join(process.env.HOME ?? os.homedir(), ".codex");
|
|
1052
1145
|
}
|
|
1053
|
-
|
|
1146
|
+
// 引数で model/effort を渡された時は managed config のピンを上書きする(端末 config の
|
|
1147
|
+
// model/model_reasoning_effort ピン——例: effort=ultra は proactive 自動委譲 ON——が対話子へ
|
|
1148
|
+
// 黙って波及するのを防ぐ)。TOML の top-level キーは最初のテーブルヘッダより前にしか置けないため、
|
|
1149
|
+
// 既存行の除去は先頭領域に限定し、上書き行はファイル先頭へ置く。
|
|
1150
|
+
function applyCodexConfigOverrides(body, overrides) {
|
|
1151
|
+
const lines = [];
|
|
1152
|
+
if (overrides.model)
|
|
1153
|
+
lines.push(`model = ${JSON.stringify(overrides.model)}`);
|
|
1154
|
+
if (overrides.effort)
|
|
1155
|
+
lines.push(`model_reasoning_effort = ${JSON.stringify(overrides.effort)}`);
|
|
1156
|
+
if (!lines.length)
|
|
1157
|
+
return body;
|
|
1158
|
+
if (body == null)
|
|
1159
|
+
return lines.join("\n") + "\n";
|
|
1160
|
+
const rows = body.split(/\r?\n/);
|
|
1161
|
+
let firstTable = rows.findIndex((l) => /^\s*\[/.test(l));
|
|
1162
|
+
if (firstTable === -1)
|
|
1163
|
+
firstTable = rows.length;
|
|
1164
|
+
const head = rows.slice(0, firstTable).filter((l) => {
|
|
1165
|
+
if (overrides.model && /^\s*(?:"model"|'model'|model)\s*=/.test(l))
|
|
1166
|
+
return false;
|
|
1167
|
+
if (overrides.effort && /^\s*(?:"model_reasoning_effort"|'model_reasoning_effort'|model_reasoning_effort)\s*=/.test(l))
|
|
1168
|
+
return false;
|
|
1169
|
+
return true;
|
|
1170
|
+
});
|
|
1171
|
+
let out = [...lines, ...head, ...rows.slice(firstTable)].join("\n");
|
|
1172
|
+
if (!out.endsWith("\n"))
|
|
1173
|
+
out += "\n";
|
|
1174
|
+
return out;
|
|
1175
|
+
}
|
|
1176
|
+
function readCodexConfigPins(configPath) {
|
|
1177
|
+
let body;
|
|
1178
|
+
try {
|
|
1179
|
+
body = fs.readFileSync(configPath, "utf8");
|
|
1180
|
+
}
|
|
1181
|
+
catch {
|
|
1182
|
+
return { model: { present: false, value: null }, effort: { present: false, value: null } };
|
|
1183
|
+
}
|
|
1184
|
+
const rows = body.split(/\r?\n/);
|
|
1185
|
+
let firstTable = rows.findIndex((l) => /^\s*\[/.test(l));
|
|
1186
|
+
if (firstTable === -1)
|
|
1187
|
+
firstTable = rows.length;
|
|
1188
|
+
const pick = (key) => {
|
|
1189
|
+
for (const l of rows.slice(0, firstTable)) {
|
|
1190
|
+
const m = l.match(new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.*)$`));
|
|
1191
|
+
if (m) {
|
|
1192
|
+
const v = m[1].trim().match(/^"([^"\\]*)"\s*(?:#.*)?$/);
|
|
1193
|
+
return { present: true, value: v ? v[1] : null };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
return { present: false, value: null };
|
|
1197
|
+
};
|
|
1198
|
+
return { model: pick("model"), effort: pick("model_reasoning_effort") };
|
|
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
|
+
}
|
|
1233
|
+
function createManagedCodexHome(name, launchId, overrides = {}) {
|
|
1054
1234
|
const srcHome = realCodexHome();
|
|
1055
1235
|
let srcSt;
|
|
1056
1236
|
try {
|
|
@@ -1078,17 +1258,22 @@ function createManagedCodexHome(name, launchId) {
|
|
|
1078
1258
|
}
|
|
1079
1259
|
fs.symlinkSync(authSrc, path.join(managedHome, "auth.json"));
|
|
1080
1260
|
const configSrc = path.join(srcHome, "config.toml");
|
|
1261
|
+
let configBody = null;
|
|
1081
1262
|
try {
|
|
1082
1263
|
const st = fs.statSync(configSrc);
|
|
1083
|
-
if (st.isFile())
|
|
1084
|
-
fs.
|
|
1085
|
-
fs.chmodSync(path.join(managedHome, "config.toml"), 0o600);
|
|
1086
|
-
}
|
|
1264
|
+
if (st.isFile())
|
|
1265
|
+
configBody = fs.readFileSync(configSrc, "utf8");
|
|
1087
1266
|
}
|
|
1088
1267
|
catch (e) {
|
|
1089
1268
|
if (e.code !== "ENOENT")
|
|
1090
1269
|
throw e;
|
|
1091
1270
|
}
|
|
1271
|
+
const configOut = applyCodexConfigOverrides(configBody, overrides);
|
|
1272
|
+
if (configOut != null) {
|
|
1273
|
+
const configDst = path.join(managedHome, "config.toml");
|
|
1274
|
+
fs.writeFileSync(configDst, configOut, { mode: 0o600 });
|
|
1275
|
+
fs.chmodSync(configDst, 0o600);
|
|
1276
|
+
}
|
|
1092
1277
|
const hookScript = codexHookScriptPath();
|
|
1093
1278
|
if (!fs.existsSync(hookScript)) {
|
|
1094
1279
|
throw new AitermError(`Codex Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
|
|
@@ -1271,19 +1456,107 @@ function setInitialPromptState(meta, state) {
|
|
|
1271
1456
|
meta.initial_prompt = state;
|
|
1272
1457
|
writeAgentMetadata(meta);
|
|
1273
1458
|
}
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
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) {
|
|
1278
1463
|
try {
|
|
1279
|
-
|
|
1464
|
+
process.kill(pid, 0);
|
|
1465
|
+
return true;
|
|
1280
1466
|
}
|
|
1281
1467
|
catch (e) {
|
|
1282
|
-
|
|
1283
|
-
|
|
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);
|
|
1284
1556
|
}
|
|
1285
|
-
throw e;
|
|
1286
1557
|
}
|
|
1558
|
+
if (fd == null)
|
|
1559
|
+
throw new AitermError(`agent session '${meta.aiterm_session}' は別プロセスの agent_done 待機中です`, 2);
|
|
1287
1560
|
try {
|
|
1288
1561
|
fs.writeSync(fd, JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + "\n", undefined, "utf8");
|
|
1289
1562
|
}
|
|
@@ -1307,11 +1580,11 @@ function acquireAgentWaitFileLock(meta) {
|
|
|
1307
1580
|
}
|
|
1308
1581
|
};
|
|
1309
1582
|
}
|
|
1310
|
-
function createCodexAgentMetadata(name, cwd, initialPrompt) {
|
|
1583
|
+
function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}) {
|
|
1311
1584
|
const launchId = randomBytes(16).toString("hex");
|
|
1312
1585
|
const eventFile = agentEventPath(name, launchId);
|
|
1313
1586
|
createEmpty0600NoFollow(eventFile);
|
|
1314
|
-
const codexHome = createManagedCodexHome(name, launchId);
|
|
1587
|
+
const codexHome = createManagedCodexHome(name, launchId, overrides);
|
|
1315
1588
|
const meta = {
|
|
1316
1589
|
kind: "codex",
|
|
1317
1590
|
aiterm_session: name,
|
|
@@ -1538,6 +1811,148 @@ function latestAgentDoneEvent(meta) {
|
|
|
1538
1811
|
}
|
|
1539
1812
|
return latest;
|
|
1540
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
|
+
}
|
|
1541
1956
|
function inferAgentFrontend(name, meta, screen) {
|
|
1542
1957
|
const fg = paneCurrentCommand(name);
|
|
1543
1958
|
const view = screen ?? captureScreen(name, AGENT_TUI_READY_LINES);
|
|
@@ -1868,23 +2283,31 @@ function resolveAgentBin(kind) {
|
|
|
1868
2283
|
function shq(s) {
|
|
1869
2284
|
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
1870
2285
|
}
|
|
1871
|
-
|
|
2286
|
+
// grok CLI はモデル未指定だと端末側 default に従うため、ツール契約として既定 slug を固定する。
|
|
2287
|
+
// codex は既定 slug を持たず端末 config/CLI 既定に委ねる(起動応答で実効値を報告する)。
|
|
2288
|
+
const GROK_MODEL_DEFAULTS = {
|
|
2289
|
+
grok: "grok-4.5",
|
|
2290
|
+
composer: "grok-composer-2.5-fast",
|
|
2291
|
+
};
|
|
2292
|
+
function buildAgentCmd(kind, bin, model, effort, prompt, meta = null) {
|
|
1872
2293
|
const parts = [shq(bin)];
|
|
1873
2294
|
if (kind === "codex") {
|
|
1874
2295
|
if (meta?.kind === "codex")
|
|
1875
2296
|
parts.push("--dangerously-bypass-hook-trust");
|
|
1876
|
-
//
|
|
2297
|
+
// model/effort は CLI 引数で明示(config 継承より優先)。agent_done 時は managed home 側
|
|
2298
|
+
// config.toml も同値で上書き済み(applyCodexConfigOverrides)。
|
|
2299
|
+
if (model)
|
|
2300
|
+
parts.push("-m", shq(model));
|
|
1877
2301
|
if (effort)
|
|
1878
2302
|
parts.push("-c", `model_reasoning_effort=${shq(effort)}`);
|
|
1879
2303
|
}
|
|
1880
2304
|
else {
|
|
1881
|
-
// grok / composer は同じ grok CLI
|
|
2305
|
+
// grok / composer は同じ grok CLI をモデル違いで起動。--effort は headless(grok -p)専用で
|
|
2306
|
+
// 対話 TUI では警告の上無視されるため渡さない(openAgent が指定を事前拒否する)。
|
|
1882
2307
|
parts.push("--no-auto-update");
|
|
1883
2308
|
if (meta?.kind === "grok" || meta?.kind === "composer")
|
|
1884
2309
|
parts.push("--no-alt-screen");
|
|
1885
|
-
parts.push("--model",
|
|
1886
|
-
if (effort)
|
|
1887
|
-
parts.push("--effort", shq(effort));
|
|
2310
|
+
parts.push("--model", shq(model ?? GROK_MODEL_DEFAULTS[kind]));
|
|
1888
2311
|
if ((meta?.kind === "grok" || meta?.kind === "composer") && prompt)
|
|
1889
2312
|
parts.push("--verbatim");
|
|
1890
2313
|
}
|
|
@@ -1929,15 +2352,55 @@ function agentLabel(kind) {
|
|
|
1929
2352
|
? "Grok Build(Grok)"
|
|
1930
2353
|
: "Codex";
|
|
1931
2354
|
}
|
|
1932
|
-
//
|
|
1933
|
-
|
|
2355
|
+
// 起動応答にモデル/effort の実効値と出所を明示する。codex は端末 config のピン(model /
|
|
2356
|
+
// model_reasoning_effort)が対話子へ波及する構造のため、引数・端末config継承・CLI既定の
|
|
2357
|
+
// どれで起動したかを起動時点で可視化し、実効 effort=ultra は proactive 自動委譲 ON を警告する。
|
|
2358
|
+
function buildAgentLaunchNote(kind, model, effort, meta) {
|
|
2359
|
+
if (kind !== "codex") {
|
|
2360
|
+
return (`起動設定: model=${model ?? GROK_MODEL_DEFAULTS[kind]}(${model ? "引数" : "ツール既定"})。` +
|
|
2361
|
+
"reasoning effort は対話 TUI 非対応=未指定で起動。");
|
|
2362
|
+
}
|
|
2363
|
+
const configPath = meta?.kind === "codex" && meta.codex_home
|
|
2364
|
+
? path.join(meta.codex_home, "config.toml")
|
|
2365
|
+
: path.join(realCodexHome(), "config.toml");
|
|
2366
|
+
const pins = readCodexConfigPins(configPath);
|
|
2367
|
+
const describePin = (arg, pin) => arg
|
|
2368
|
+
? `${arg}(引数)`
|
|
2369
|
+
: pin.present
|
|
2370
|
+
? pin.value
|
|
2371
|
+
? `${pin.value}(端末config継承)`
|
|
2372
|
+
: "端末config継承(値未解析)"
|
|
2373
|
+
: "CLI既定";
|
|
2374
|
+
const effectiveEffort = effort ?? (pins.effort.present ? pins.effort.value : null);
|
|
2375
|
+
const launch = `起動設定: model=${describePin(model, pins.model)} effort=${describePin(effort, pins.effort)}。` +
|
|
2376
|
+
(effectiveEffort === "ultra"
|
|
2377
|
+
? "⚠ effort=ultra は max 推論+proactive 自動委譲 ON(子エージェント自動生成・使用量急増に注意)。"
|
|
2378
|
+
: "");
|
|
2379
|
+
const summary = meta?.kind === "codex" && meta.codex_home ? managedCodexConfigSummary(configPath, true) : "";
|
|
2380
|
+
return summary ? `${launch}\n${summary}\n` : launch;
|
|
2381
|
+
}
|
|
1934
2382
|
export function openAgent(kind, opts = {}) {
|
|
1935
2383
|
const label = agentLabel(kind);
|
|
1936
2384
|
// 前提検証は session を作る前に全部済ませる(失敗の残骸 session を作らない)。
|
|
1937
|
-
// effort → bin → cwd の順: effort 検証は CLI 不在の端末でも同じ結果になる(テスト可能性)。
|
|
2385
|
+
// model/effort → bin → cwd の順: model/effort 検証は CLI 不在の端末でも同じ結果になる(テスト可能性)。
|
|
2386
|
+
let model = null;
|
|
2387
|
+
if (opts.model != null) {
|
|
2388
|
+
model = opts.model.trim();
|
|
2389
|
+
if (!model)
|
|
2390
|
+
throw new AitermError("model が空文字です(省略するか有効なモデル名を指定してください)", 2);
|
|
2391
|
+
}
|
|
1938
2392
|
const effort = opts.reasoning_effort ?? null;
|
|
1939
|
-
|
|
1940
|
-
|
|
2393
|
+
// grok CLI の --effort は headless(grok -p)専用で、対話 TUI では警告の上無視される。
|
|
2394
|
+
// 黙って no-op の引数を受けない=起動前に明示エラーで拒否する(codex は CLI 側の値集合が
|
|
2395
|
+
// 版で変わるため縛らず送信まで通す)。
|
|
2396
|
+
if (effort && kind === "grok") {
|
|
2397
|
+
throw new AitermError(`${label} は reasoning_effort を指定できません。grok CLI の --effort は headless(grok -p)専用で、` +
|
|
2398
|
+
"対話 TUI では警告の上無視されます(grok-4.5 の TUI 既定 effort は high)。" +
|
|
2399
|
+
"effort 制御が必要なら通常 PTY で `grok -p --effort low|medium|high ...` を使ってください", 2);
|
|
2400
|
+
}
|
|
2401
|
+
if (effort && kind === "composer") {
|
|
2402
|
+
throw new AitermError(`${label} は reasoning_effort を指定できません。grok-composer-2.5-fast は reasoning effort 非対応です` +
|
|
2403
|
+
"(モデルカタログ supports_reasoning_effort=false)", 2);
|
|
1941
2404
|
}
|
|
1942
2405
|
const agentDone = !!opts.agent_done;
|
|
1943
2406
|
const bin = resolveAgentBin(kind);
|
|
@@ -1975,13 +2438,15 @@ export function openAgent(kind, opts = {}) {
|
|
|
1975
2438
|
const binForCmd = isWin ? toWslPath(bin) : bin;
|
|
1976
2439
|
const cwdForCmd = cwd && isWin ? toWslPath(cwd) : cwd;
|
|
1977
2440
|
const [sid, hint] = openSession(opts.session_name ?? null, "bash");
|
|
2441
|
+
let launchNote = "";
|
|
1978
2442
|
try {
|
|
1979
2443
|
const meta = agentDone
|
|
1980
2444
|
? kind === "codex"
|
|
1981
|
-
? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none")
|
|
2445
|
+
? createCodexAgentMetadata(sid, cwd, opts.prompt ? "pending" : "none", { model, effort })
|
|
1982
2446
|
: createGrokAgentMetadata(kind, sid, cwd, opts.prompt ? "pending" : "none")
|
|
1983
2447
|
: null;
|
|
1984
|
-
|
|
2448
|
+
launchNote = buildAgentLaunchNote(kind, model, effort, meta);
|
|
2449
|
+
const cmd = buildAgentCmd(kind, binForCmd, model, effort, opts.prompt ?? null, meta);
|
|
1985
2450
|
const envPrefix = agentEnvPrefix(meta, sid);
|
|
1986
2451
|
const full = cwdForCmd ? `cd ${shq(cwdForCmd)} && ${envPrefix}${cmd}` : `${envPrefix}${cmd}`;
|
|
1987
2452
|
// force:true で送る。起動骨格は `bin '...'` の固定形で、prompt/cwd/effort は shq でクオート済みの
|
|
@@ -2001,7 +2466,7 @@ export function openAgent(kind, opts = {}) {
|
|
|
2001
2466
|
}
|
|
2002
2467
|
return [
|
|
2003
2468
|
sid,
|
|
2004
|
-
`${label} を session ${sid} で起動した。${agentDone ? "agent_done 待機が有効。" : ""}` +
|
|
2469
|
+
`${label} を session ${sid} で起動した。${launchNote}${agentDone ? "agent_done 待機が有効。" : ""}` +
|
|
2005
2470
|
`${agentDone && kind !== "codex" ? " hook 汚染防止のため Grok 実行中は一時 HOME を使う。" : ""}\n${hint}\n` +
|
|
2006
2471
|
`TUI の描画には数秒かかる。少し置いてから pty_read(${sid}, screen:true) で画面を読み、` +
|
|
2007
2472
|
`pty_send(${sid}, "...") で入力・pty_key(${sid}, "Enter"/"Up"/"C-c" 等) で操作する(対話)。` +
|
|
@@ -2039,6 +2504,7 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
|
|
|
2039
2504
|
}
|
|
2040
2505
|
const [sid, hint] = openAgent(kind, {
|
|
2041
2506
|
session_name: opts.session_name ?? null,
|
|
2507
|
+
model: opts.model ?? null,
|
|
2042
2508
|
reasoning_effort: opts.reasoning_effort ?? null,
|
|
2043
2509
|
cwd: opts.cwd ?? null,
|
|
2044
2510
|
prompt: null,
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
@@ -180,9 +200,15 @@ server.registerTool("pty_list", {
|
|
|
180
200
|
});
|
|
181
201
|
// 対話型エージェント起動ツール(モデルごとに1つ=ツール名/説明でどのモデルか一目で分かる)。
|
|
182
202
|
// いずれも永続端末に TUI を起動し session_id を返す。以後 pty_read/pty_send で対話操作する。
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
const agentModelDesc = (kind) => kind === "codex"
|
|
204
|
+
? "起動モデル(例: gpt-5.6-sol / gpt-5.6-terra / gpt-5.6-luna)。省略時は端末 config/CLI 既定を継承" +
|
|
205
|
+
"(端末側のピンがそのまま効く。実効値は起動応答に明示される)"
|
|
206
|
+
: `起動モデル。省略時は ${kind === "grok" ? "grok-4.5" : "grok-composer-2.5-fast"}`;
|
|
207
|
+
const agentEffortDesc = (grokLike) => grokLike
|
|
208
|
+
? "指定不可(grok CLI の --effort は headless 専用で、対話 TUI では警告の上無視される。" +
|
|
209
|
+
"composer は effort 自体非対応)。指定すると起動前にエラーを返す"
|
|
210
|
+
: "reasoning effort(思考レベル)。low/medium/high/xhigh/max/ultra(CLI 版依存)。" +
|
|
211
|
+
"ultra は max 推論+proactive 自動委譲 ON=使用量急増注意(明示要求時のみ)。省略時は端末 config/CLI 既定。";
|
|
186
212
|
function registerAgentTool(toolName, kind, desc, grokLike) {
|
|
187
213
|
const initialPromptWaitSchema = {};
|
|
188
214
|
if (kind === "codex") {
|
|
@@ -198,11 +224,10 @@ function registerAgentTool(toolName, kind, desc, grokLike) {
|
|
|
198
224
|
description: desc,
|
|
199
225
|
inputSchema: {
|
|
200
226
|
prompt: z.string().nullish().describe("起動時に渡す初手プロンプト(任意)。省略で素のTUI起動"),
|
|
201
|
-
|
|
227
|
+
model: z.string().nullish().describe(agentModelDesc(kind)),
|
|
228
|
+
// grok/composer の effort は対話 TUI で無効(headless 専用)=core 側が起動前に明示エラーで拒否。
|
|
202
229
|
// codex は CLI 側の値集合が版で変わるため縛らない(core 側も同方針)。
|
|
203
|
-
reasoning_effort:
|
|
204
|
-
.nullish()
|
|
205
|
-
.describe(agentEffortDesc(grokLike)),
|
|
230
|
+
reasoning_effort: z.string().nullish().describe(agentEffortDesc(grokLike)),
|
|
206
231
|
cwd: z.string().nullish().describe("作業ディレクトリ(対象リポのルート等・任意)"),
|
|
207
232
|
session_name: z.string().nullish().describe("セッション名(省略で自動採番)"),
|
|
208
233
|
agent_done: z
|
|
@@ -211,10 +236,11 @@ function registerAgentTool(toolName, kind, desc, grokLike) {
|
|
|
211
236
|
.describe("managed Stop hook を有効化し、pty_send(wait:'agent_done') を使えるようにする"),
|
|
212
237
|
...initialPromptWaitSchema,
|
|
213
238
|
},
|
|
214
|
-
}, async ({ prompt, reasoning_effort, cwd, session_name, agent_done, wait, timeout, screen, lines }) => {
|
|
239
|
+
}, async ({ prompt, model, reasoning_effort, cwd, session_name, agent_done, wait, timeout, screen, lines }) => {
|
|
215
240
|
try {
|
|
216
241
|
const [sid, hint] = await core.openAgentWithInitialPrompt(kind, {
|
|
217
242
|
prompt: prompt ?? undefined,
|
|
243
|
+
model: model ?? undefined,
|
|
218
244
|
reasoning_effort: reasoning_effort ?? undefined,
|
|
219
245
|
cwd: cwd ?? undefined,
|
|
220
246
|
session_name: session_name ?? undefined,
|
|
@@ -231,12 +257,13 @@ function registerAgentTool(toolName, kind, desc, grokLike) {
|
|
|
231
257
|
}
|
|
232
258
|
});
|
|
233
259
|
}
|
|
234
|
-
registerAgentTool("codex_agent", "codex", "【Codex (OpenAI
|
|
235
|
-
"起動後は pty_read で画面を読み pty_send で操作する。reasoning_effort
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
260
|
+
registerAgentTool("codex_agent", "codex", "【Codex (OpenAI)】の対話エージェント TUI を永続端末に起動する。実装・レビュー・調査を対話で回す。" +
|
|
261
|
+
"起動後は pty_read で画面を読み pty_send で操作する。model / reasoning_effort を引数で指定可" +
|
|
262
|
+
"(省略時は端末 config/CLI 既定を継承。実効値は起動応答に明示)。", false);
|
|
263
|
+
registerAgentTool("grok_agent", "grok", "【Grok Build の Grok モデル (既定 grok-4.5)】の対話エージェント TUI を永続端末に起動する。" +
|
|
264
|
+
"起動後は pty_read/pty_send で対話操作。model を引数で指定可。reasoning_effort は対話 TUI 非対応(指定はエラー)。", true);
|
|
265
|
+
registerAgentTool("composer_agent", "composer", "【Grok Build の Composer モデル (既定 grok-composer-2.5-fast)】の対話エージェント TUI を永続端末に起動する。" +
|
|
266
|
+
"起動後は pty_read/pty_send で対話操作。model を引数で指定可。reasoning_effort は非対応(指定はエラー)。", true);
|
|
240
267
|
async function main() {
|
|
241
268
|
const transport = new StdioServerTransport();
|
|
242
269
|
await server.connect(transport);
|
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.
|
|
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": [
|