aiterm-mcp 0.44.0 → 0.45.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/CHANGELOG.md CHANGED
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.45.0] - 2026-09-30
11
+
12
+ ### 変更
13
+
14
+ - `pty_read({ rtk: true })`のreducerの手本をrtk 0.42.0から0.50.0へ上げた。
15
+ - grep:rtkの上限(全体200行・1ファイル25行)に収まる間は、grepの出力をそのまま返す。これまでは件数の見出しを付け、長い行を80字で切っていた。上限を超えた時だけファイルごとにまとめ、長い行は検索語の周りを残して切り、省いた分を`+N more in <file>`/`+N more files`で書く。まとめた形が元より長ければ元を返す。
16
+ - pytest:件数の見出しの下の罫線をやめた。pytestが報告の前に落ちた時(`INTERNALERROR>`、`ERROR: `)は、`Pytest: No tests collected`にせず汎用削減へ戻す。
17
+ - 縮めた結果が元より多くのトークンを使う時は、reducerを適用しない(rtkの`never_worse`)。
18
+ - pytestとgrepの回帰見本は、公開版rtk 0.50.0の実行結果から取り直した(recallの案内行は除く)。`FAILED`要約行の理由を全文残す違いは従来どおり。
19
+
20
+ ### 修正
21
+
22
+ - `git log`のreducerが、何も書かずに10件で打ち切っていた。件数(`-n N`/`-N`/`--max-count`)か範囲(`A..B`)を指定したlogは打ち切らない。件数を指定した時は、rtkと同じく1行の幅を120字にする。指定が無い時は10件にして、`[+N more commits]`で終える。
23
+
24
+ ## [0.44.1] - 2026-09-30
25
+
26
+ ### 修正
27
+
28
+ - `agent_models`のGrokが、ときどき`MODEL_CATALOG_INVALID: initializeの応答がありません(exit=0 … stdout=0bytes)`で失敗していた。要求を書いてすぐ標準入力を閉じていたため、grok 1.0.41がinitializeの処理中に入力の終わりを受けて、応答せずに終わっていた。ClaudeとGrokは、応答が出力に現れるまで標準入力を開けておき、現れてから閉じる。macOSのAqua外(launchd経由)でも同じ待ち方をする。
29
+
10
30
  ## [0.44.0] - 2026-09-30
11
31
 
12
32
  ### 追加
@@ -1918,7 +1938,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1918
1938
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1919
1939
  provenance.
1920
1940
 
1921
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.44.0...HEAD
1941
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.0...HEAD
1942
+ [0.45.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.44.1...v0.45.0
1943
+ [0.44.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.44.0...v0.44.1
1922
1944
  [0.44.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.43.7...v0.44.0
1923
1945
  [0.43.7]: https://github.com/kitepon/aiterm-mcp/compare/v0.43.6...v0.43.7
1924
1946
  [0.43.6]: https://github.com/kitepon/aiterm-mcp/compare/v0.43.5...v0.43.6
package/README.ja.md CHANGED
@@ -203,7 +203,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
203
203
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
204
204
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
205
205
 
206
- **状態:** 開発継続中 · 現行公開版 **v0.44.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
206
+ **状態:** 開発継続中 · 現行公開版 **v0.45.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
207
207
 
208
208
  ### 更新と巻き戻し
209
209
 
@@ -338,16 +338,19 @@ Throughline自体が不要である。
338
338
  [aiterm demo: 51 行 / ~56 tok (raw 152 行 / ~166 tok); 102 行 hidden] [is_complete=True via quiescent]
339
339
  ```
340
340
 
341
- `grep` を、コマンド別 reducer で「件数ヘッダ+ヒット行だけ」に畳む:
341
+ `grep` は、rtk の上限(全体 200 行・1 ファイル 25 行)に収まる間は grep の出力をそのまま返す。上限を超えた時だけ、コマンド別 reducer がファイルごとにまとめ、省いた分を書き添える:
342
342
 
343
343
  ```text
344
- → pty_send("demo", "grep -rn capture-pane src/ test/")
344
+ → pty_send("demo", "grep -rn session src/")
345
345
  → pty_read("demo", { wait: true, rtk: true })
346
- ← 2 matches in 1 files:
347
-
348
- src/core.ts:159:// maxBuffer は既定 1MiB。capture-pane(大きなスクロールバック)… (行はここで truncate)
349
- src/core.ts:335:const args = ["capture-pane", "-p", "-J", "-t", name];
350
- [aiterm demo: rtk:grep 適用 / ~46 tok (raw ~53 tok)] [is_complete=True via quiescent]
346
+ ← 550 matches in 23 files:
347
+
348
+ src/agent-resolver.ts:211:// …(長い行は検索語の周りを ~80 字だけ残す)
349
+ …
350
+ src/remote.ts:268:session_id: session, agent_transcript: true, raw: true,
351
+ +4 more in src/remote.ts
352
+ +7 more files
353
+ [aiterm demo: rtk:grep 適用 / ~4286 tok (raw ~12546 tok)] [is_complete=True via quiescent]
351
354
  ```
352
355
 
353
356
  ネストは「中へ送るただのテキスト」——同じ PTY の*中で* Python REPL に入る(`ssh host`・`docker exec -it … bash`・起動したコーディングエージェントの TUI も、まったく同じ要領でネストする):
@@ -449,7 +452,7 @@ aiterm → cwd=/tmp var=hello123 # 1 本の永続PTYが両方を保
449
452
 
450
453
  cd でディレクトリを移り、環境変数を立て、ビルドを走らせる。ssh で一度ログインして、その接続のまま 10 個コマンドを打つ。REPL や起動したエージェントの TUI を 1 ターンずつ操作する。こういう流れは、1 本の永続PTYが状態を握っていて初めて成り立つ。端末に何かを覚えておいてほしいときは、aiterm を使う。
451
454
 
452
- <sub>¹ いまのハーネスは ~192 KB の出力をいったんファイルに逃がして、先頭 ~2 KB だけを見せる。そのためトークン数はほぼ並ぶ。aiterm は行数を正確に返すうえ、あとから `line_range="A:B"` で好きな範囲(先頭でも末尾でも)を取り出せる。² `rtk` の grep 縮約は長い行(~80 字)を切り詰めて、あふれを `[+N more]` にまとめる。ざっと眺めるには向くが、全行をそのまま読みたいときは組み込みツールを使う。</sub>
455
+ <sub>¹ いまのハーネスは ~192 KB の出力をいったんファイルに逃がして、先頭 ~2 KB だけを見せる。そのためトークン数はほぼ並ぶ。aiterm は行数を正確に返すうえ、あとから `line_range="A:B"` で好きな範囲(先頭でも末尾でも)を取り出せる。² `rtk` の grep 縮約は、rtk の上限(全体 200 行・1 ファイル 25 行)に収まる間は grep の出力をそのまま返す。上限を超えるとファイルごとにまとめ、長い行は検索語の周りを ~80 字だけ残し、あふれを `+N more in <file>`/`+N more files` にまとめる。大きな検索の全行をそのまま読みたいときは組み込みツールを使う。</sub>
453
456
 
454
457
  ## 既存手段との比較
455
458
 
@@ -649,7 +652,8 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
649
652
  - **`is_complete=False` は失敗ではない。** 「timeout 内に完了を観測できなかった」という意味。長時間コマンドでは `timeout` を伸ばすか `until`/`mark` を使う。
650
653
  - **agent harnessは実物TUIを起動し、model APIを代理しない。** model・認証・挙動は選んだharnessのもの。隠れたagent間protocolはなく、MCPクライアントがClaude/Codex/Grok/Cursor TUIへ入力を送り出力を読む。
651
654
  - **`pty_send({ rtk: true })` は単行コマンドのみ+外部 `rtk` バイナリが必要**(無ければ素通し)。一方 `pty_read({ rtk: true })` の reducer は自前実装で rtk 非依存。
652
- - **`pytest` reducer は件数・罫線・`FAILURES` ブロック整形が rtk 0.42.0 と byte 一致**(回帰テストで固定)。ただし `-ra`/`-rf` 時の `FAILED` 要約行の理由は**全文を保持する**(rtk 0.42.0 は最初の `" - "` 区切りで切るが、本実装は可読性優先で情報を残すため、この行は意図的に rtk と完全一致させない)。rtk が大出力時に付ける `[full output: …]`(tee ポインタ)行は read 側では再現しない。
655
+ - **`pytest` reducer は件数・`FAILURES` ブロック整形が rtk 0.50.0 と byte 一致**(回帰テストで固定)。ただし `-ra`/`-rf` 時の `FAILED` 要約行の理由は**全文を保持する**(rtk 0.50.0 は最初の `" - "` 区切りで切るが、本実装は可読性優先で情報を残すため、この行は意図的に rtk と完全一致させない)。rtk が大出力時に付ける `[full output: …]`(recall ポインタ)行は read 側では再現しない。
656
+ - **`grep` reducer は rtk 0.50.0 と一致**する。上限に収まる間は出力をそのまま返し、上限を超えた時のまとめ方は recall の案内を除いて `rtk grep` と byte 一致する。`git log` は件数(`-n`)か範囲(`A..B`)を指定すると全コミットを残し、指定が無ければ 10 件にして `[+N more commits]` で終える。rtk の `never_worse` と同じく、縮めた結果が元より多くのトークンを使う reducer は適用しない。
653
657
  - **tmux は `-f /dev/null` 起動**なので `~/.tmux.conf` を読まない(環境差を排除するため)。
654
658
  - **全セッションが単一multiplexer endpoint(POSIXは`claude.sock`、Windows nativeは1つのpsmux namespace)を共有する。** platformの`kill-server` commandは全セッションを消す。
655
659
 
@@ -668,7 +672,7 @@ Windows固有だけの変更はLinuxとWindowsを選ぶ。版番号だけの変
668
672
  `npm run release -- <version>`がversion同期・commit・tag・GitHub Releaseを一回で行い、tag起点のnpm公開は
669
673
  tagged commitが`origin/main`の祖先であることだけを確認して、他のCI結果を待ちません。
670
674
 
671
- 共通進行は`src/core.ts`、harness固有は`src/harnesses/`、OS差は`src/tmux-runtime.ts`/`src/agent-resolver.ts`、reducerは`src/rtk.ts`、公開面は`src/index.ts`が所有する。現行設計は[`docs/DESIGN.md`](docs/DESIGN.md)、release手順は[`docs/RELEASE.md`](docs/RELEASE.md)を正とする。`prototype/python/`はreducerの歴史的移植元であり、pytest reducerは本家rtk 0.42.0と一致する(上記の`FAILED`行の差異だけは意図的・回帰テストで固定)。
675
+ 共通進行は`src/core.ts`、harness固有は`src/harnesses/`、OS差は`src/tmux-runtime.ts`/`src/agent-resolver.ts`、reducerは`src/rtk.ts`、公開面は`src/index.ts`が所有する。現行設計は[`docs/DESIGN.md`](docs/DESIGN.md)、release手順は[`docs/RELEASE.md`](docs/RELEASE.md)を正とする。`prototype/python/`はreducerの歴史的移植元であり、pytestとgrepのreducerは本家rtk 0.50.0と一致する(上記の`FAILED`行の差異だけは意図的・回帰テストで固定)。
672
676
 
673
677
  ## 試す
674
678
 
package/README.md CHANGED
@@ -217,7 +217,7 @@ collection is off by default and performs no network I/O. It ships via
217
217
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
218
218
  Release re-registers the Official MCP Registry entry.
219
219
 
220
- **Status:** actively maintained · current public release **v0.44.0** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
220
+ **Status:** actively maintained · current public release **v0.45.0** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
221
221
 
222
222
  ### Update and rollback
223
223
 
@@ -367,16 +367,19 @@ A long output folded head+tail — the middle is elided by the reducer, not by m
367
367
  [aiterm demo: 51 lines / ~56 tok (raw 152 lines / ~166 tok); 102 lines hidden] [is_complete=True via quiescent]
368
368
  ```
369
369
 
370
- A `grep`, folded by the per-command reducer to a count header plus just the hits:
370
+ A `grep` comes back exactly as grep printed it while it stays within rtk's caps (200 lines in all, 25 per file). Past a cap, the per-command reducer groups the hits by file and says what it left out:
371
371
 
372
372
  ```text
373
- → pty_send("demo", "grep -rn capture-pane src/ test/")
373
+ → pty_send("demo", "grep -rn session src/")
374
374
  → pty_read("demo", { wait: true, rtk: true })
375
- ← 2 matches in 1 files:
376
-
377
- src/core.ts:159:// maxBuffer defaults to 1 MiB; capture-pane (large scrollback) … (line truncated here)
378
- src/core.ts:335:const args = ["capture-pane", "-p", "-J", "-t", name];
379
- [aiterm demo: rtk:grep applied / ~46 tok (raw ~53 tok)] [is_complete=True via quiescent]
375
+ ← 550 matches in 23 files:
376
+
377
+ src/agent-resolver.ts:211:// …(long lines keep ~80 chars around the pattern)
378
+ …
379
+ src/remote.ts:268:session_id: session, agent_transcript: true, raw: true,
380
+ +4 more in src/remote.ts
381
+ +7 more files
382
+ [aiterm demo: rtk:grep applied / ~4286 tok (raw ~12546 tok)] [is_complete=True via quiescent]
380
383
  ```
381
384
 
382
385
  Nesting is just text you send in — here a Python REPL *inside* the same PTY (an `ssh host`, a `docker exec -it … bash`, or a launched coding-agent TUI nests exactly the same way):
@@ -480,7 +483,7 @@ aiterm → cwd=/tmp var=hello123 # one persistent PTY holds both
480
483
 
481
484
  `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 persistent PTY holds all of it. Reach for aiterm when the terminal has to remember something.
482
485
 
483
- <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>
486
+ <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 returns grep's output unchanged while it fits rtk's caps (200 lines, 25 per file). Past a cap it groups by file, keeps ~80 chars of each long line around the pattern, and folds the rest into `+N more in <file>` / `+N more files`; use the built-in tool when you need every full line of a large search.</sub>
484
487
 
485
488
  ## vs. the alternatives
486
489
 
@@ -695,7 +698,8 @@ Sessions live on a shared tmux socket on POSIX or a shared psmux namespace on na
695
698
  - **`is_complete=False` is not a failure.** It means "completion was not observed within `timeout`." For long commands, raise `timeout` or use `until`/`mark`.
696
699
  - **Agent harnesses run their real TUI; aiterm doesn't proxy the model API.** The selected harness owns model choice, authentication, and behavior. There is no hidden inter-agent protocol; the MCP client drives the Claude/Codex/Grok/Cursor TUI with ordinary send/read operations.
697
700
  - **`pty_send({ rtk: true })` is single-line only and needs the external `rtk` binary** (passthrough without it). The `pty_read({ rtk: true })` reducer, by contrast, is self-contained and rtk-independent.
698
- - **The `pytest` reducer matches rtk 0.42.0** on test counts, the rule line, and `FAILURES`-block formatting (locked by regression tests). It **deliberately preserves the full failure reason** on the `FAILED` summary lines (emitted under `-ra`/`-rf`), whereas rtk 0.42.0 truncates the reason at the first `" - "` — a readability choice, so those lines are intentionally not byte-identical to rtk. The `[full output: …]` tee-pointer line rtk appends on large output is not reproduced on the read side.
701
+ - **The `pytest` reducer matches rtk 0.50.0** on test counts and `FAILURES`-block formatting (locked by regression tests). It **deliberately preserves the full failure reason** on the `FAILED` summary lines (emitted under `-ra`/`-rf`), whereas rtk 0.50.0 truncates the reason at the first `" - "` — a readability choice, so those lines are intentionally not byte-identical to rtk. The `[full output: …]` recall-pointer line rtk appends on large output is not reproduced on the read side.
702
+ - **The `grep` reducer matches rtk 0.50.0**: within the caps it returns the output unchanged, and past a cap its grouped form is byte-identical to `rtk grep` without the recall hints. `git log` keeps every commit when you set a count (`-n`) or a range (`A..B`); otherwise it shows 10 and ends with `[+N more commits]`. Like rtk's `never_worse`, a reducer whose result would cost more tokens than the output is skipped.
699
703
  - **tmux is started with `-f /dev/null`**, so it does not read `~/.tmux.conf` (to keep behavior reproducible across machines).
700
704
  - **All sessions share one multiplexer endpoint** (`claude.sock` on POSIX, one psmux namespace on native Windows). The platform's `kill-server` command removes them all.
701
705
 
@@ -718,7 +722,7 @@ Windows runner needs psmux ≥ 3.3.8 and Git for Windows on its PATH, and must r
718
722
  interactive Windows user; `NETWORK SERVICE` lacks the per-user environment the pane shell and
719
723
  harness CLIs rely on and is not a valid runner identity.
720
724
 
721
- Logic lives in `src/core.ts` (tmux control, reduction, completion detection, safety, agent launch) and `src/rtk.ts` (per-command reducers); `src/index.ts` is the MCP surface. The current architecture is in [`docs/DESIGN.md`](docs/DESIGN.md), the release procedure is in [`docs/RELEASE.md`](docs/RELEASE.md), and `prototype/python/` remains the reducer's historical porting source (the pytest reducer is ported to match upstream rtk 0.42.0, except the deliberate `FAILED`-line difference noted above, and is locked by regression tests).
725
+ Logic lives in `src/core.ts` (tmux control, reduction, completion detection, safety, agent launch) and `src/rtk.ts` (per-command reducers); `src/index.ts` is the MCP surface. The current architecture is in [`docs/DESIGN.md`](docs/DESIGN.md), the release procedure is in [`docs/RELEASE.md`](docs/RELEASE.md), and `prototype/python/` remains the reducer's historical porting source (the pytest and grep reducers are ported to match upstream rtk 0.50.0, except the deliberate `FAILED`-line difference noted above, and is locked by regression tests).
722
726
 
723
727
  ## Try it
724
728
 
@@ -1,6 +1,6 @@
1
1
  // エージェント CLI(claude / codex / grok / cursor-agent)・Throughline・pane shell(Windows は Git Bash)の
2
2
  // 実行ファイルをどう見つけ、どう起動するかの所有者(OS 分岐の所有者。tmux とは独立)。
3
- import { spawnSync } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import * as os from "node:os";
@@ -75,11 +75,7 @@ export function spawnAgentControlCommand(bin, args, _cwd, options) {
75
75
  if (/\.(?:cmd|bat)$/i.test(bin)) {
76
76
  // Node は CVE-2024-27980 対処以降、.cmd/.bat の直接 spawn を EINVAL で拒否する。
77
77
  // 受入が .cmd/.bat を許す以上、control 経路は shell 経由で実行する(args は固定語彙とAiterm所有の一時path)。
78
- // shell経由ではNodeが引数を引用しないので、空白を含むpathだけ二重引用符でくくる。引用符とshell記号は受け付けない。
79
- if (args.some(arg => /["%^&|<>!]/.test(arg))) {
80
- throw new AitermError(`${path.basename(bin)} のcontrol commandへ渡せない文字を含む引数があります`, 2);
81
- }
82
- return spawnSync(bin, args.map(arg => /\s/.test(arg) ? `"${arg}"` : arg), { ...options, shell: true });
78
+ return spawnSync(bin, windowsControlArgs(bin, args), { ...options, shell: true });
83
79
  }
84
80
  // 受入は pane shell(Git Bash)が shebang で実行できる script も許す。Windows の
85
81
  // CreateProcess は shebang を解さないため、control command も同じ Git Bash で実行する。
@@ -88,6 +84,72 @@ export function spawnAgentControlCommand(bin, args, _cwd, options) {
88
84
  }
89
85
  return spawnInMacGuiWhenOutsideAqua(bin, args, options) ?? spawnSync(bin, args, options);
90
86
  }
87
+ function windowsControlArgs(bin, args) {
88
+ // shell経由ではNodeが引数を引用しないので、空白を含むpathだけ二重引用符でくくる。引用符とshell記号は受け付けない。
89
+ if (args.some(arg => /["%^&|<>!]/.test(arg))) {
90
+ throw new AitermError(`${path.basename(bin)} のcontrol commandへ渡せない文字を含む引数があります`, 2);
91
+ }
92
+ return args.map(arg => /\s/.test(arg) ? `"${arg}"` : arg);
93
+ }
94
+ /**
95
+ * 標準入出力で要求と応答をやりとりするcontrol command(`claude -p --input-format stream-json`、`grok agent stdio`)。
96
+ * inputを書いた後、untilの文字列がstdoutに現れるまで標準入力を開けておき、現れたら閉じて終了を待つ。
97
+ * 応答前に標準入力を閉じると、応答せずに正常終了するCLIがある(grok 1.0.41で実測)。
98
+ * 起動方法のOS差はspawnAgentControlCommandと同じ。macOSのAqua外はlaunchdの仕事の中で同じ待ち方をする。
99
+ */
100
+ export async function runAgentProtocolCommand(bin, args, options) {
101
+ const gui = spawnInMacGuiWhenOutsideAqua(bin, args, options);
102
+ if (gui)
103
+ return { status: gui.status, signal: gui.signal, stdout: gui.stdout, stderr: gui.stderr, ...(gui.error ? { error: gui.error } : {}) };
104
+ let command = bin;
105
+ let argv = args;
106
+ let shell = false;
107
+ if (isWin && !/\.(?:exe|com)$/i.test(bin)) {
108
+ if (/\.(?:cmd|bat)$/i.test(bin)) {
109
+ argv = windowsControlArgs(bin, args);
110
+ shell = true;
111
+ }
112
+ else {
113
+ command = resolveWinPaneShell("bash");
114
+ argv = [bin, ...args];
115
+ }
116
+ }
117
+ return new Promise((resolve) => {
118
+ const child = spawn(command, argv, { cwd: options.cwd, env: options.env, shell, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
119
+ let stdout = "";
120
+ let stderr = "";
121
+ let error;
122
+ let settled = false;
123
+ const finish = (status, signal) => {
124
+ if (settled)
125
+ return;
126
+ settled = true;
127
+ clearTimeout(timer);
128
+ resolve({ status, signal, stdout, stderr, ...(error ? { error } : {}) });
129
+ };
130
+ const stop = (reason) => {
131
+ error ??= reason;
132
+ child.stdin.destroy();
133
+ child.kill();
134
+ };
135
+ const timer = setTimeout(() => stop(Object.assign(new Error(`${path.basename(bin)} ${args.join(" ")} が${options.timeout}ms以内に応答しませんでした`), { code: "ETIMEDOUT" })), options.timeout);
136
+ child.on("error", (reason) => { error ??= reason; finish(null, null); });
137
+ child.on("close", (status, signal) => finish(status, signal));
138
+ child.stdin.on("error", () => { });
139
+ child.stderr.setEncoding("utf8");
140
+ child.stderr.on("data", (chunk) => { if (stderr.length < options.maxBuffer)
141
+ stderr += chunk; });
142
+ child.stdout.setEncoding("utf8");
143
+ child.stdout.on("data", (chunk) => {
144
+ stdout += chunk;
145
+ if (stdout.length > options.maxBuffer)
146
+ return stop(new Error(`${path.basename(bin)} の出力が${options.maxBuffer}bytesを超えました`));
147
+ if (child.stdin.writable && stdout.includes(options.until))
148
+ child.stdin.end();
149
+ });
150
+ child.stdin.write(options.input);
151
+ });
152
+ }
91
153
  export function resolveWinPaneShell(shell) {
92
154
  if (!isWin)
93
155
  return shell;
@@ -8,7 +8,7 @@ import { createHash, randomBytes, randomUUID } from "node:crypto";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { AitermError } from "../errors.js";
10
10
  import { modeBitsWorldAccessible } from "../tmux-runtime.js";
11
- import { spawnAgentControlCommand } from "../agent-resolver.js";
11
+ import { runAgentProtocolCommand, spawnAgentControlCommand } from "../agent-resolver.js";
12
12
  import { catalogInvalid, catalogUnavailable, checkedCatalog, findJsonLine, processSummary } from "../model-catalog.js";
13
13
  import { currentUid, writeJson0600, shq, subagentInstruction, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, AGENT_EVENT_TAIL_BYTES, readFileRange, } from "../agent-shared.js";
14
14
  export const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
@@ -146,24 +146,23 @@ const CLAUDE_ULTRACODE_NOTE = "Claude Codeの--effortが受け付けるsession
146
146
  * (SDKの公開型 SDKControlRequest/SDKControlInitializeResponse)の応答にある models を読む。
147
147
  * promptは送らず、sessionを保存せず、利用者のhookとMCP serverを起動しない。
148
148
  */
149
- export function claudeModelChoices(bin, cwd) {
149
+ export async function claudeModelChoices(bin, cwd) {
150
150
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "aiterm-claude-models-"));
151
151
  try {
152
152
  const settings = path.join(dir, "settings.json");
153
153
  fs.writeFileSync(settings, JSON.stringify({ disableAllHooks: true }), { mode: 0o600 });
154
154
  const requestId = `aiterm-models-${randomUUID()}`;
155
155
  const request = { type: "control_request", request_id: requestId, request: { subtype: "initialize" } };
156
- const result = spawnAgentControlCommand(bin, [
156
+ const result = await runAgentProtocolCommand(bin, [
157
157
  "-p", "--output-format", "stream-json", "--verbose", "--input-format", "stream-json",
158
158
  "--no-session-persistence", "--strict-mcp-config", "--settings", settings,
159
- ], cwd, {
159
+ ], {
160
160
  cwd,
161
- encoding: "utf8",
162
161
  env: process.env,
163
162
  input: JSON.stringify(request) + "\n",
163
+ until: requestId,
164
164
  timeout: CLAUDE_MODELS_TIMEOUT_MS,
165
165
  maxBuffer: CLAUDE_MODELS_MAX_BYTES,
166
- windowsHide: true,
167
166
  });
168
167
  if (result.error || result.status !== 0) {
169
168
  throw catalogUnavailable("Claude Code", result.error?.message || result.stderr?.trim() || `exit=${result.status ?? "unknown"}`);
@@ -7,7 +7,7 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { randomBytes, randomUUID } from "node:crypto";
9
9
  import { AitermError } from "../errors.js";
10
- import { spawnAgentControlCommand } from "../agent-resolver.js";
10
+ import { runAgentProtocolCommand, spawnAgentControlCommand } from "../agent-resolver.js";
11
11
  import { catalogInvalid, catalogUnavailable, checkedCatalog, findJsonLine, processSummary } from "../model-catalog.js";
12
12
  import { shq, subagentInstruction, writeScopeLaunchNote, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, GROK_TRANSCRIPT_INCREMENT_MAX_BYTES, agentHarness, } from "../agent-shared.js";
13
13
  const GROK_MODELS_MAX_BYTES = 1024 * 1024;
@@ -67,16 +67,16 @@ const GROK_AGENT_CATALOG_TIMEOUT_MS = 30_000;
67
67
  * `initialize` 応答にある `_meta.modelState` を読む。`session/new` は同じ候補を返すがsessionを保存するので使わない。
68
68
  * promptもsessionも作らず、推論は起きない。
69
69
  */
70
- export function grokModelChoices(bin, cwd) {
70
+ export async function grokModelChoices(bin, cwd) {
71
71
  const request = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientCapabilities: {} } };
72
- const result = spawnAgentControlCommand(bin, ["agent", "--no-leader", "stdio"], cwd, {
72
+ // 応答前に標準入力を閉じると、grok 1.0.41は応答せずに正常終了することがある。応答を読むまで開けておく。
73
+ const result = await runAgentProtocolCommand(bin, ["agent", "--no-leader", "stdio"], {
73
74
  cwd,
74
- encoding: "utf8",
75
75
  env: process.env,
76
76
  input: JSON.stringify(request) + "\n",
77
+ until: '"id":1',
77
78
  timeout: GROK_AGENT_CATALOG_TIMEOUT_MS,
78
79
  maxBuffer: GROK_MODELS_MAX_BYTES,
79
- windowsHide: true,
80
80
  });
81
81
  if (result.error || result.status !== 0) {
82
82
  throw catalogUnavailable("Grok", result.error?.message || result.stderr?.trim() || `exit=${result.status ?? "unknown"}`);
package/dist/rtk.js CHANGED
@@ -3,15 +3,15 @@
3
3
  *
4
4
  * 要件C: rtk のファイルは複製せず、アルゴリズムを参照に自分のコードとして書き起こす。
5
5
  * rtk バイナリが無い場所でも縮約が効くよう、観測済み出力に対して動く。
6
- * pytest / grep は人間向け標準出力を解析でき rtk のアルゴリズムに忠実
7
- * (pytest は rtk 0.42.0 の出力と厳密一致するよう移植)。
6
+ * 手本は rtk 0.50.0。pytest は同版の出力と厳密一致させ、grep は上限に届かなければ出力をそのまま返す。
7
+ * rtk の never_worse と同じく、縮めた結果が元より多くのトークンを使うなら reducer を適用しない。
8
8
  *
9
9
  * 公開 API: reduce(command, output) -> [reducedText|null, reducerName|null] / classify / stripShellFrame
10
10
  */
11
- // rtk 由来のキャップ(truncate.rs / config.rs の値。数値のみ参照、コードは自作)
12
- const CAP_GREP_TOTAL = 200; // grep: 全体の最大マッチ行
13
- const CAP_GREP_PER_FILE = 25; // grep: ファイルあたり最大
14
- const GREP_MAX_LEN = 80; // grep: 1 行の最大幅
11
+ // rtk 由来のキャップ(truncate.rs / config.rs / main.rs の値。数値のみ参照、コードは自作)
12
+ const CAP_GREP_TOTAL = 200; // grep: まとめる時に表示する最大行(文脈行を含む)
13
+ const CAP_GREP_PER_FILE = 25; // grep: まとめる時のファイルあたり最大
14
+ const GREP_MAX_LEN = 80; // grep: まとめる時の 1 行の最大幅(UTF-8 バイトで判定)
15
15
  const COMPACT_PATH_THRESH = 50; // grep: パス短縮の閾値
16
16
  const MAX_PYTEST_FAILURES = 10; // pytest: 失敗ブロック最大
17
17
  const MAX_XFAIL = 10; // pytest: xfail/xpass 行 最大
@@ -19,6 +19,7 @@ const PYTEST_RELEVANT_PER_FAIL = 3;
19
19
  const LOG_LIMIT_DEFAULT = 10; // git log: 既定コミット数
20
20
  const LOG_BODY_LINES = 3;
21
21
  const LOG_WIDTH = 80;
22
+ const LOG_WIDTH_USER_LIMIT = 120; // git log: 利用者が件数を決めた時の幅
22
23
  /** char(コードポイント)ベース切り詰め(rtk utils::truncate と同等)。 */
23
24
  function truncate(s, n) {
24
25
  const cp = Array.from(s);
@@ -134,8 +135,13 @@ export function reducePytest(output) {
134
135
  pytestEvidence = true;
135
136
  if (!pytestEvidence)
136
137
  return null;
137
- if (p === 0 && f === 0 && s === 0 && xf === 0 && xp === 0 && e === 0)
138
+ if (p === 0 && f === 0 && s === 0 && xf === 0 && xp === 0 && e === 0) {
139
+ // rtk 0.50.0 は報告前に落ちた実行(exit≠0,5)を "No tests collected" にせず元の出力を返す。
140
+ // read 側は終了コードを持たないので、pytest 自身の異常終了の印で見分ける。
141
+ if (/^(INTERNALERROR>|ERROR: )/m.test(output))
142
+ return null;
138
143
  return "Pytest: No tests collected";
144
+ }
139
145
  // error(収集/内部エラー)は失敗の一種=緑扱いにしない。extras に含め、"N passed" 早期 return を止める。
140
146
  const extras = s > 0 || xf > 0 || xp > 0 || e > 0 || xfailLines.length > 0;
141
147
  if (f === 0 && p > 0 && !extras)
@@ -149,7 +155,7 @@ export function reducePytest(output) {
149
155
  head += `, ${xf} xfailed`;
150
156
  if (xp > 0)
151
157
  head += `, ${xp} xpassed`;
152
- const out = [head, "═".repeat(39)];
158
+ const out = [head];
153
159
  if (xfailLines.length) {
154
160
  out.push("", "Expected-failure outcomes:");
155
161
  for (const ln of xfailLines.slice(0, MAX_XFAIL))
@@ -171,8 +177,8 @@ export function reducePytest(output) {
171
177
  const parts = first.split(" - ");
172
178
  const name = parts[0].slice("FAILED".length).trim();
173
179
  out.push(`${i + 1}. [FAIL] ${name}`);
174
- // 失敗理由は全文保持(可読性優先。rtk 0.42.0 は最初の " - " segment で切るが、本実装は情報を残す)。
175
- // 末尾セパレータは continue で入れない(rtk 0.42.0 と同じ)。
180
+ // 失敗理由は全文保持(可読性優先。rtk 0.50.0 は最初の " - " segment で切るが、本実装は情報を残す)。
181
+ // 末尾セパレータは continue で入れない(rtk 0.50.0 と同じ)。
176
182
  if (parts.length > 1)
177
183
  out.push(" " + truncate(parts.slice(1).join(" - "), 100));
178
184
  continue;
@@ -195,7 +201,7 @@ export function reducePytest(output) {
195
201
  break;
196
202
  }
197
203
  }
198
- // rtk 0.42.0 互換: セパレータ判定は表示数(shown)でなく全失敗数(failures)基準(cap 超過時の空行数まで一致)。
204
+ // rtk 0.50.0 互換: セパレータ判定は表示数(shown)でなく全失敗数(failures)基準(cap 超過時の空行数まで一致)。
199
205
  if (i < failures.length - 1)
200
206
  out.push("");
201
207
  }
@@ -234,6 +240,7 @@ function parsePytestCounts(summary) {
234
240
  }
235
241
  // ---------------------------------------------------------------- grep
236
242
  const GREP_LINE = /^(.*?):(\d+):(.*)$/;
243
+ const GREP_CONTEXT_TAIL = /^(\d+)-(.*)$/;
237
244
  function compactPath(p) {
238
245
  if (p.length <= COMPACT_PATH_THRESH)
239
246
  return p;
@@ -242,42 +249,118 @@ function compactPath(p) {
242
249
  return p;
243
250
  return `${parts[0]}/.../${parts[parts.length - 2]}/${parts[parts.length - 1]}`;
244
251
  }
245
- export function reduceGrep(output) {
252
+ /** rtk search.rs の clean_line と同じ: 長い行はパターンの周りを残し、無ければ先頭を残す。 */
253
+ function cleanGrepLine(line, maxLen, pattern) {
254
+ const trimmed = line.trim();
255
+ if (Buffer.byteLength(trimmed, "utf8") <= maxLen)
256
+ return trimmed;
257
+ const chars = Array.from(trimmed);
258
+ const lower = trimmed.toLowerCase();
259
+ const pos = pattern ? lower.indexOf(pattern.toLowerCase()) : -1;
260
+ if (pos < 0)
261
+ return chars.slice(0, maxLen - 3).join("") + "...";
262
+ const charPos = Array.from(lower.slice(0, pos)).length;
263
+ const charLen = chars.length;
264
+ let start = Math.max(0, charPos - Math.floor(maxLen / 3));
265
+ const end = Math.min(start + maxLen, charLen);
266
+ if (end === charLen)
267
+ start = Math.max(0, end - maxLen);
268
+ const slice = chars.slice(start, end).join("");
269
+ if (start > 0 && end < charLen)
270
+ return `...${slice}...`;
271
+ if (start > 0)
272
+ return `...${slice}`;
273
+ return `${slice}...`;
274
+ }
275
+ /** `file:N:content`(一致)と、既知ファイルの `file-N-content`(文脈)を読む。 */
276
+ function parseGrepLine(line, knownFiles) {
277
+ for (const f of knownFiles) {
278
+ if (!line.startsWith(f + "-"))
279
+ continue;
280
+ const m = GREP_CONTEXT_TAIL.exec(line.slice(f.length + 1));
281
+ if (m)
282
+ return [f, parseInt(m[1], 10), false, m[2]];
283
+ }
284
+ const m = GREP_LINE.exec(line);
285
+ if (!m || !m[1])
286
+ return null;
287
+ return [m[1], parseInt(m[2], 10), true, m[3]];
288
+ }
289
+ /**
290
+ * rtk 0.50.0 の grep: 上限(全体 200 行・ファイルあたり 25 行)に届かなければ出力をそのまま返す。
291
+ * 上限を超えた時だけファイルごとにまとめ、それが元より短い時に限りまとめた形を返す。
292
+ * 読めるのはファイル名と行番号の付いた出力だけで、それ以外は null(→ 汎用削減)。
293
+ */
294
+ export function reduceGrep(output, pattern = "") {
295
+ const lines = output.split("\n");
296
+ const candidates = new Set();
297
+ for (const line of lines) {
298
+ const m = GREP_LINE.exec(line);
299
+ if (m && m[1])
300
+ candidates.add(m[1]);
301
+ }
302
+ if (!candidates.size)
303
+ return null;
304
+ // 長いファイル名から照合し、`a-1-x` を `a` の文脈行として先に拾う
305
+ const knownFiles = [...candidates].sort((a, b) => b.length - a.length);
246
306
  const byFile = new Map();
247
- const order = [];
248
307
  let total = 0;
249
- for (const line of output.split("\n")) {
250
- const m = GREP_LINE.exec(line);
251
- if (!m)
308
+ let hasContext = false;
309
+ for (const line of lines) {
310
+ if (line === "--") {
311
+ hasContext = true;
252
312
  continue;
253
- const fname = m[1], lineno = m[2], content = m[3];
254
- if (!fname || !/^\d+$/.test(lineno))
313
+ }
314
+ const parsed = parseGrepLine(line, knownFiles);
315
+ if (!parsed)
255
316
  continue;
256
- total++;
257
- if (!byFile.has(fname)) {
317
+ const [fname, lineno, isMatch, content] = parsed;
318
+ if (isMatch)
319
+ total++;
320
+ else
321
+ hasContext = true;
322
+ if (!byFile.has(fname))
258
323
  byFile.set(fname, []);
259
- order.push(fname);
260
- }
261
- byFile.get(fname).push([lineno, truncate(content.trim(), GREP_MAX_LEN)]);
324
+ byFile.get(fname).push([lineno, isMatch, cleanGrepLine(content, GREP_MAX_LEN, pattern)]);
262
325
  }
263
326
  if (total === 0)
264
327
  return null;
265
- const out = [`${total} matches in ${byFile.size} files:`, ""];
328
+ const plain = output.replace(/\n+$/, "");
329
+ const body = [];
266
330
  let shown = 0;
267
- for (const fname of [...order].sort()) {
268
- if (shown >= CAP_GREP_TOTAL)
269
- break;
331
+ let skippedFiles = 0;
332
+ for (const fname of [...byFile.keys()].sort()) {
333
+ const entries = byFile.get(fname);
334
+ if (shown >= CAP_GREP_TOTAL) {
335
+ skippedFiles++;
336
+ continue;
337
+ }
270
338
  const disp = compactPath(fname);
271
- for (const [lineno, content] of byFile.get(fname).slice(0, CAP_GREP_PER_FILE)) {
339
+ let fileShown = 0;
340
+ let prev = 0;
341
+ for (const [lineno, isMatch, content] of entries.slice(0, CAP_GREP_PER_FILE)) {
272
342
  if (shown >= CAP_GREP_TOTAL)
273
343
  break;
274
- out.push(`${disp}:${lineno}:${content}`);
344
+ if (hasContext && prev > 0 && lineno > prev + 1)
345
+ body.push("--");
346
+ prev = lineno;
347
+ const sep = isMatch ? ":" : "-";
348
+ body.push(`${disp}${sep}${lineno}${sep}${content}`);
275
349
  shown++;
350
+ fileShown++;
276
351
  }
352
+ const remaining = entries.length - fileShown;
353
+ if (remaining > 0)
354
+ body.push(` +${remaining} more in ${disp}`);
277
355
  }
278
- if (total > shown)
279
- out.push(`[+${total - shown} more]`);
280
- return out.join("\n");
356
+ if (skippedFiles > 0)
357
+ body.push(`+${skippedFiles} more files`);
358
+ // rtk と同じく、表示数(文脈行を含む)と一致数を比べて上限で削ったかを決める
359
+ const capped = shown < total || skippedFiles > 0;
360
+ if (!capped)
361
+ return plain;
362
+ const grouped = `${total} matches in ${byFile.size} files:\n\n${body.join("\n")}`;
363
+ return Buffer.byteLength(grouped, "utf8") < Buffer.byteLength(plain, "utf8") ? grouped : plain;
281
364
  }
282
365
  // ---------------------------------------------------------------- git status / log
283
366
  const GIT_HINT = /^\(use "git |^\(create\/copy files/;
@@ -313,14 +396,40 @@ export function reduceGitStatus(output) {
313
396
  }
314
397
  return kept.length ? kept.join("\n") : "ok";
315
398
  }
316
- export function reduceGitLog(output) {
399
+ /**
400
+ * git log の引数から、利用者が件数(-n N / -N / --max-count)や範囲(A..B)を決めたかを読む。
401
+ * rtk は件数を決めたlogを打ち切らず幅を 120 にする。0.50.0 は範囲も利用者の決めた境界として打ち切らない。
402
+ */
403
+ function gitLogBounds(args) {
404
+ let userLimit = false;
405
+ let range = false;
406
+ for (let i = 0; i < args.length; i++) {
407
+ const a = args[i];
408
+ if (a === "--")
409
+ break;
410
+ if (/^-\d+$/.test(a) || /^-n\d+$/.test(a) || /^--max-count=\d+$/.test(a))
411
+ userLimit = true;
412
+ else if ((a === "-n" || a === "--max-count") && /^\d+$/.test(args[i + 1] ?? "")) {
413
+ userLimit = true;
414
+ i++;
415
+ }
416
+ else if (!a.startsWith("-") && a.includes(".."))
417
+ range = true;
418
+ }
419
+ return { userLimit, range };
420
+ }
421
+ export function reduceGitLog(output, args = []) {
317
422
  if (!output.includes("commit "))
318
423
  return null;
319
424
  const blocks = output.split(/(?=^commit [0-9a-f]{7,40})/m).filter((b) => b.trim().startsWith("commit "));
320
425
  if (!blocks.length)
321
426
  return null;
427
+ const { userLimit, range } = gitLogBounds(args);
428
+ const width = userLimit ? LOG_WIDTH_USER_LIMIT : LOG_WIDTH;
429
+ // 件数も範囲も決めていない時だけ既定の 10 件に絞り、絞ったことを書く(黙って捨てない)
430
+ const limit = userLimit || range ? blocks.length : LOG_LIMIT_DEFAULT;
322
431
  const out = [];
323
- for (const b of blocks.slice(0, LOG_LIMIT_DEFAULT)) {
432
+ for (const b of blocks.slice(0, limit)) {
324
433
  const bl = b.split("\n").map((x) => x.replace(/\s+$/, ""));
325
434
  let commit = "", author = "", subject = "";
326
435
  const body = [];
@@ -342,16 +451,18 @@ export function reduceGitLog(output) {
342
451
  }
343
452
  const am = /<([^>]+)>/.exec(author);
344
453
  const who = am ? am[1] : author;
345
- let head = truncate(`${commit} ${subject}`.trim(), LOG_WIDTH);
454
+ let head = truncate(`${commit} ${subject}`.trim(), width);
346
455
  if (who)
347
456
  head += ` <${who}>`;
348
457
  const entry = [head];
349
458
  for (const bln of body.slice(0, LOG_BODY_LINES))
350
- entry.push(" " + truncate(bln, LOG_WIDTH));
459
+ entry.push(" " + truncate(bln, width));
351
460
  if (body.length > LOG_BODY_LINES)
352
461
  entry.push(` [+${body.length - LOG_BODY_LINES} lines omitted]`);
353
462
  out.push(entry.join("\n"));
354
463
  }
464
+ if (blocks.length > limit)
465
+ out.push(`[+${blocks.length - limit} more commits]`);
355
466
  return out.join("\n").trim();
356
467
  }
357
468
  const FILTERS = [
@@ -432,12 +543,116 @@ export function classify(command) {
432
543
  return rule.name;
433
544
  return null;
434
545
  }
546
+ /** 引用符('…' "…")と \\ を解いた語の列。読み取り専用の解析なので、展開や置換はしない。 */
547
+ function shellWords(command) {
548
+ const words = [];
549
+ let cur = "";
550
+ let has = false;
551
+ let quote = "";
552
+ for (let i = 0; i < command.length; i++) {
553
+ const c = command[i];
554
+ if (quote) {
555
+ if (c === quote)
556
+ quote = "";
557
+ else if (c === "\\" && quote === '"' && i + 1 < command.length)
558
+ cur += command[++i];
559
+ else
560
+ cur += c;
561
+ }
562
+ else if (c === "'" || c === '"') {
563
+ quote = c;
564
+ has = true;
565
+ }
566
+ else if (c === "\\" && i + 1 < command.length) {
567
+ cur += command[++i];
568
+ has = true;
569
+ }
570
+ else if (/\s/.test(c)) {
571
+ if (has)
572
+ words.push(cur);
573
+ cur = "";
574
+ has = false;
575
+ }
576
+ else {
577
+ cur += c;
578
+ has = true;
579
+ }
580
+ }
581
+ if (has)
582
+ words.push(cur);
583
+ return words;
584
+ }
585
+ /** verb(git/grep 等)より後ろの語。前置ラッパーは basenameCmd と同じく読み飛ばす。 */
586
+ function argsAfterVerb(command) {
587
+ const [verb, , stripped] = basenameCmd(command);
588
+ if (!verb)
589
+ return [];
590
+ const words = shellWords(stripped);
591
+ return words.slice(1);
592
+ }
593
+ // 値を取る grep / rg のフラグ(別の語で値を渡す形)。パターンを値と取り違えないために使う。
594
+ const GREP_VALUE_SHORT = new Set(["e", "f", "m", "A", "B", "C", "d", "D"]);
595
+ const RG_VALUE_SHORT = new Set(["e", "f", "m", "A", "B", "C", "d", "g", "t", "T", "M", "j", "E", "r"]);
596
+ const GREP_VALUE_LONG = new Set([
597
+ "regexp", "file", "max-count", "after-context", "before-context", "context", "devices", "directories",
598
+ "include", "exclude", "exclude-dir", "exclude-from", "label", "glob", "iglob", "type", "type-not",
599
+ "max-columns", "threads", "max-depth", "color", "colors", "sort", "sortr", "encoding", "pre", "replace",
600
+ ]);
601
+ /** grep / rg の検索パターン(-e が複数なら `|` で結ぶ。rtk の pattern_display と同じ)。 */
602
+ export function grepPattern(command) {
603
+ const args = argsAfterVerb(command);
604
+ const valueShort = basenameCmd(command)[0] === "rg" ? RG_VALUE_SHORT : GREP_VALUE_SHORT;
605
+ const explicit = [];
606
+ const positional = [];
607
+ let endOfFlags = false;
608
+ for (let i = 0; i < args.length; i++) {
609
+ const a = args[i];
610
+ if (endOfFlags || !a.startsWith("-") || a === "-") {
611
+ positional.push(a);
612
+ continue;
613
+ }
614
+ if (a === "--") {
615
+ endOfFlags = true;
616
+ continue;
617
+ }
618
+ if (a.startsWith("--")) {
619
+ const [name, attached] = a.slice(2).split(/=(.*)/s);
620
+ const value = attached !== undefined ? attached : GREP_VALUE_LONG.has(name) ? args[++i] : undefined;
621
+ if (name === "regexp" && value !== undefined)
622
+ explicit.push(value);
623
+ continue;
624
+ }
625
+ const cluster = a.slice(1);
626
+ for (let j = 0; j < cluster.length; j++) {
627
+ if (!valueShort.has(cluster[j]))
628
+ continue;
629
+ const rest = cluster.slice(j + 1);
630
+ const value = rest || args[++i];
631
+ if (cluster[j] === "e" && value !== undefined)
632
+ explicit.push(value);
633
+ break;
634
+ }
635
+ }
636
+ if (explicit.length)
637
+ return explicit.join("|");
638
+ return positional[0] ?? "";
639
+ }
640
+ /** `git [opts] log <args>` の log より後ろの語。 */
641
+ function gitLogArgs(command) {
642
+ const args = argsAfterVerb(command);
643
+ const i = args.indexOf("log");
644
+ return i >= 0 ? args.slice(i + 1) : [];
645
+ }
435
646
  const REDUCERS = {
436
- "git-status": reduceGitStatus,
437
- "git-log": reduceGitLog,
438
- grep: reduceGrep,
647
+ "git-status": (o) => reduceGitStatus(o),
648
+ "git-log": (o, c) => reduceGitLog(o, gitLogArgs(c)),
649
+ grep: (o, c) => reduceGrep(o, grepPattern(c)),
439
650
  pytest: (o) => reducePytest(o),
440
651
  };
652
+ /** rtk の estimate_tokens と同じ(UTF-8 バイト数 / 4 の切り上げ)。 */
653
+ function estimateTokens(s) {
654
+ return Math.ceil(Buffer.byteLength(s, "utf8") / 4);
655
+ }
441
656
  /**
442
657
  * コマンドに応じた reducer を観測出力へ適用。返り値 [reducedText|null, reducerName|null]。
443
658
  * 前提(C13): `output` は制御文字除去済み(ANSI/CR 等)であること。色付き/生 PTY 出力を直接渡すと
@@ -447,12 +662,18 @@ export function reduce(command, output) {
447
662
  const name = classify(command);
448
663
  if (name === null)
449
664
  return [null, null];
450
- if (name in REDUCERS) {
451
- const red = REDUCERS[name](output);
452
- return red !== null ? [red, name] : [null, null];
665
+ let red = null;
666
+ if (name in REDUCERS)
667
+ red = REDUCERS[name](output, command);
668
+ else {
669
+ const rule = FILTERS.find((r) => r.name === name);
670
+ if (rule)
671
+ red = applyFilter(rule, output);
453
672
  }
454
- for (const rule of FILTERS)
455
- if (rule.name === name)
456
- return [applyFilter(rule, output), name];
457
- return [null, null];
673
+ if (red === null)
674
+ return [null, null];
675
+ // rtk の never_worse: 縮めた結果の方がトークンを使うなら適用しない(→ 呼び手の汎用削減へ)
676
+ if (estimateTokens(red) > estimateTokens(output))
677
+ return [null, null];
678
+ return [red, name];
458
679
  }
@@ -201,9 +201,9 @@ export function spawnInMacGuiWhenOutsideAqua(bin, args, options) {
201
201
  return null;
202
202
  const timeout = options.timeout ?? 30_000;
203
203
  const input = options.input === undefined ? undefined : typeof options.input === "string" ? options.input : Buffer.from(options.input.buffer, options.input.byteOffset, options.input.byteLength);
204
- return runInMacGui([bin, ...args], options.env ?? process.env, String(options.cwd ?? process.cwd()), timeout, `launchd経由の ${path.basename(bin)} ${args.join(" ")} が${timeout}ms以内に終わりませんでした`, input);
204
+ return runInMacGui([bin, ...args], options.env ?? process.env, String(options.cwd ?? process.cwd()), timeout, `launchd経由の ${path.basename(bin)} ${args.join(" ")} が${timeout}ms以内に終わりませんでした`, input, options.until);
205
205
  }
206
- function runInMacGui(argv, env, cwd, timeoutMs, timeoutMessage, input) {
206
+ function runInMacGui(argv, env, cwd, timeoutMs, timeoutMessage, input, until) {
207
207
  const uid = process.getuid?.();
208
208
  if (uid === undefined)
209
209
  return null;
@@ -219,10 +219,14 @@ function runInMacGui(argv, env, cwd, timeoutMs, timeoutMessage, input) {
219
219
  const plist = path.join(dir, "job.plist");
220
220
  try {
221
221
  // launchdの仕事は標準入力を持たない。入力を渡す時はファイルに書き、同じ仕事の中でつなぐ。
222
+ // untilを渡した時は、その文字列が出力に現れるまで標準入力を開けておく(閉じると応答前に終わるCLIがある)。
222
223
  if (input !== undefined) {
223
224
  const inputFile = path.join(dir, "in");
224
225
  fs.writeFileSync(inputFile, input, { mode: 0o600 });
225
- argv = ["/bin/sh", "-c", 'f=$1; shift; exec "$@" <"$f"', "aiterm-gui-input", inputFile, ...argv];
226
+ argv = until === undefined
227
+ ? ["/bin/sh", "-c", 'f=$1; shift; exec "$@" <"$f"', "aiterm-gui-input", inputFile, ...argv]
228
+ : ["/bin/sh", "-c", 'f=$1; m=$2; o=$3; shift 3; { cat "$f"; until grep -qF -- "$m" "$o" 2>/dev/null; do sleep 0.05; done; } | "$@"',
229
+ "aiterm-gui-input", inputFile, until, path.join(dir, "out"), ...argv];
226
230
  }
227
231
  fs.writeFileSync(plist, macGuiTmuxPlist(label, dir, argv, env, cwd));
228
232
  const boot = spawnSync("launchctl", ["bootstrap", `gui/${uid}`, plist], { encoding: "utf8", timeout: 10000 });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.44.0",
3
+ "version": "0.45.0",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent terminal MCP with one harness-based launcher for Claude Code, Codex CLI, Grok CLI, and Cursor Agent CLI, plus durable PTYs for SSH, containers, and REPLs.",
6
6
  "keywords": [