aiterm-mcp 0.45.0 → 0.45.2
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 +17 -1
- package/README.ja.md +3 -2
- package/README.md +3 -2
- package/dist/core.js +4 -3
- package/dist/remote.js +16 -2
- package/dist/tmux-runtime.js +2 -1
- package/dist/windows-codex-state.js +2 -1
- package/docs/DESIGN.md +8 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.45.2] - 2026-10-01
|
|
11
|
+
|
|
12
|
+
### 修正
|
|
13
|
+
|
|
14
|
+
- WindowsのSteer設定を再実行すると、PowerShellのSet-Aclが監査ACLの権限まで要求し、Network Serviceで失敗していた。所有者とDACLの変更だけを.NETのAPIへ渡し、再実行時も追加権限を要求しない。
|
|
15
|
+
- macOSのAqua外でmodel照会のCLIが応答前にエラー終了すると、標準入力を保持する処理が終了を認識せずtimeoutしていた。CLIの終了で入力保持も終え、元の終了コードとstderrを返す。
|
|
16
|
+
- macOSの長いTMPDIRで`remote`呼び出しが`REMOTE_CONNECT_FAILED`(`ControlPath too long`)となっていた。POSIXのSSH共有ソケットを短い`/tmp`の専用領域へ置き、OpenSSHの作成時suffixまで含めて98 bytesに収める。state rootごとの分離、process間の接続共有と`ControlPersist=600`によるcleanupを保つ。共有領域の所有者・権限・種類が不正なら`REMOTE_CONTROL_DIR_INVALID`で止め、既存領域を変更しない。公開APIとstate schemaは変わらず、旧版への巻き戻しは通常のnpm installとMCP client再起動で行える。
|
|
17
|
+
|
|
18
|
+
## [0.45.1] - 2026-10-01
|
|
19
|
+
|
|
20
|
+
### 修正
|
|
21
|
+
|
|
22
|
+
- POSIXでagent TUIへ256byteを超える本文を送ると、TUIが別々の貼り付けとして受け取っていた。256byteのchunkごとに`paste-buffer -p`で包んでいたためで、Claude Codeでは切れ目にかかった画像pathが画像として読まれず、最後の短い貼り付けが入力欄の先頭へずれた。本文全体を`ESC[200~`/`ESC[201~`で1回だけ包み、中身はchunkのまま流す(Windowsと同じ形)。判断はADR 0075。
|
|
23
|
+
|
|
10
24
|
## [0.45.0] - 2026-09-30
|
|
11
25
|
|
|
12
26
|
### 変更
|
|
@@ -1938,7 +1952,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
|
|
|
1938
1952
|
`ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
|
|
1939
1953
|
provenance.
|
|
1940
1954
|
|
|
1941
|
-
[Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.
|
|
1955
|
+
[Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.2...HEAD
|
|
1956
|
+
[0.45.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.1...v0.45.2
|
|
1957
|
+
[0.45.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.45.0...v0.45.1
|
|
1942
1958
|
[0.45.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.44.1...v0.45.0
|
|
1943
1959
|
[0.44.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.44.0...v0.44.1
|
|
1944
1960
|
[0.44.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.43.7...v0.44.0
|
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.45.
|
|
206
|
+
**状態:** 開発継続中 · 現行公開版 **v0.45.2** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
|
|
207
207
|
|
|
208
208
|
### 更新と巻き戻し
|
|
209
209
|
|
|
@@ -620,6 +620,7 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
|
|
|
620
620
|
- 以後の`pty_send`、`pty_read`、`pty_close`、`pty_observe`などにも同じ`remote`を付ける。session名は現地のもので、この端末の同名sessionとは別に扱う。
|
|
621
621
|
- Codex/Claude Code/Cursor親には、この端末の子と同じく回答本文が自動で届く。完了は`ssh <host> aiterm-wait`で観測し、SSHが切れても同じcursorでつなぎ直す。それ以外の親には、sshを使う`wait_process`を返す。
|
|
622
622
|
- 同じ接続先への呼び出しはControlMasterで1本のSSHに相乗りする。`remote`付きの`image`添付と`claude_turn issue`は未対応。
|
|
623
|
+
- POSIXのSSH共有ソケットは長いTMPDIRでも動く短い専用領域に置く。state rootごとに分離し、無通信600秒後の接続終了とソケット削除はOpenSSHが行う。
|
|
623
624
|
|
|
624
625
|
### トークン削減
|
|
625
626
|
|
|
@@ -631,7 +632,7 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
|
|
|
631
632
|
|
|
632
633
|
`pty_send` はコマンドやpromptの意味を判定せず、指定された本文を端末へ送る。既定ではESC・ブラケットペースト終端などをサニタイズし、`pty_read`も制御文字を無害化して返す(`raw: true`はそのまま扱う)。コマンドの許可・拒否はshell、接続先、起動したharnessが所有する。
|
|
633
634
|
|
|
634
|
-
1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。全OSで長いPTY入力の欠落を避けるためplatformのmultiplexerへUTF-8境界を壊さない256-byte単位でpasteし、chunk間に10msのdrain間隔を置く。POSIX shellまたはPowerShellが前面にいる時のsanitize済み複数行は、改行を含まない単一入力へ符号化する。POSIXは`eval`、PowerShellはUTF-8のBase64を復元したscriptblockのdot-sourceを使い、変数と作業場所を現在のshellに保つ。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatch
|
|
635
|
+
1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。全OSで長いPTY入力の欠落を避けるためplatformのmultiplexerへUTF-8境界を壊さない256-byte単位でpasteし、chunk間に10msのdrain間隔を置く。POSIX shellまたはPowerShellが前面にいる時のsanitize済み複数行は、改行を含まない単一入力へ符号化する。POSIXは`eval`、PowerShellはUTF-8のBase64を復元したscriptblockのdot-sourceを使い、変数と作業場所を現在のshellに保つ。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatchは本文全体を`ESC[200~/201~`で1回だけ包み、包んだ全体を同じchunkで流す。agent TUIには1回の貼り付けに見えるので画像pathが貼り付けの間で切れず、chunk投入中のキー解釈による語中文字化け・submit取り落としも抑える。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。`pty_list`で対象sessionを確認し、`pty_close`で閉じてから同じsession IDを作り直す。公開の一括停止toolは存在しない。
|
|
635
636
|
|
|
636
637
|
## 人が覗く
|
|
637
638
|
|
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.45.
|
|
220
|
+
**Status:** actively maintained · current public release **v0.45.2** · 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
|
|
|
@@ -666,6 +666,7 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
|
|
|
666
666
|
- Pass the same `remote` to later `pty_send`, `pty_read`, `pty_close`, `pty_observe`, and so on. Session names belong to the remote machine and never collide with local sessions of the same name.
|
|
667
667
|
- Codex, Claude Code, and Cursor parents receive the answer automatically, as with a local child. Completion is observed with `ssh <host> aiterm-wait`, reconnecting at the same cursor if SSH drops. Other parents get an ssh-based `wait_process`.
|
|
668
668
|
- Calls to the same destination share one SSH connection through ControlMaster. `image` attachments and `claude_turn issue` are not yet supported with `remote`.
|
|
669
|
+
- POSIX SSH control sockets use a short private directory even with a long TMPDIR. Connections stay isolated by state root; OpenSSH closes idle masters and removes their sockets after 600 seconds.
|
|
669
670
|
|
|
670
671
|
### Token reduction
|
|
671
672
|
|
|
@@ -677,7 +678,7 @@ agent_launch({ "harness": "codex-cli", "remote": { "host": "rabbit" }, "cwd": "/
|
|
|
677
678
|
|
|
678
679
|
`pty_send` does not interpret command or prompt meaning; it delivers the requested text to the terminal. By default it sanitizes ESC and bracketed-paste terminators, while `pty_read` neutralizes control characters in returned output (`raw: true` keeps them unchanged). The shell, remote endpoint, or launched harness owns command authorization.
|
|
679
680
|
|
|
680
|
-
Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. Every OS pastes through its multiplexer in UTF-8-safe 256-byte chunks with a 10 ms drain interval; macOS, Linux, and WSL2 have all demonstrated silent middle/trailing loss when a long input is pushed without that boundary. Sanitized multiline text sent while a POSIX shell or PowerShell is in the foreground is encoded as one newline-free input (`eval` for POSIX shells; a dot-sourced, UTF-8 Base64-decoded scriptblock for PowerShell): the shell receives the complete script before it runs the first line, so a pager or REPL started mid-script cannot consume later lines as interactive keystrokes. Single-line input, `raw:true`, and non-shell frontends remain direct PTY pastes. Agent dispatches additionally
|
|
681
|
+
Each `pty_send` accepts at most 64 KiB of UTF-8 text. Sends to the same session are serialized across aiterm processes so chunks cannot interleave. Every OS pastes through its multiplexer in UTF-8-safe 256-byte chunks with a 10 ms drain interval; macOS, Linux, and WSL2 have all demonstrated silent middle/trailing loss when a long input is pushed without that boundary. Sanitized multiline text sent while a POSIX shell or PowerShell is in the foreground is encoded as one newline-free input (`eval` for POSIX shells; a dot-sourced, UTF-8 Base64-decoded scriptblock for PowerShell): the shell receives the complete script before it runs the first line, so a pager or REPL started mid-script cannot consume later lines as interactive keystrokes. Single-line input, `raw:true`, and non-shell frontends remain direct PTY pastes. Agent dispatches additionally wrap the whole text once in `ESC[200~/201~` and stream the wrapped text in the same chunks, so the agent TUI sees one paste (an image path is never split across pastes), hardening prompt injection against mid-word key-interpretation corruption and dropped submits. If a later chunk fails, aiterm reports the partial-send state and does not press Enter automatically. A lock left by a terminated sender fails closed before sending; use `pty_list` to confirm the affected session, close it with `pty_close`, then recreate the same session ID. There is no public kill-all tool.
|
|
681
682
|
|
|
682
683
|
## A human can watch
|
|
683
684
|
|
package/dist/core.js
CHANGED
|
@@ -806,7 +806,10 @@ export function send(name, text, o = {}) {
|
|
|
806
806
|
}
|
|
807
807
|
}
|
|
808
808
|
else {
|
|
809
|
-
|
|
809
|
+
// tmuxの`paste-buffer -p`はchunkごとに包むため使わない。Claude Codeは貼り付け1回ごとに
|
|
810
|
+
// 画像pathを探すので、chunk境界でpathが切れて画像を落とし、行もずれた(2026-10-01、
|
|
811
|
+
// 画像4枚付きの約500byte)。全体を1回だけ包み、中身はchunkのまま流す。
|
|
812
|
+
const chunks = splitPtyText(o.bracketedPaste ? `\x1b[200~${text}\x1b[201~` : text);
|
|
810
813
|
const pasteSupportsNoSanitize = pasteBufferSupportsNoSanitizeFlag();
|
|
811
814
|
const bufferBase = `aiterm-${process.pid}-${randomBytes(8).toString("hex")}`;
|
|
812
815
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
@@ -821,8 +824,6 @@ export function send(name, text, o = {}) {
|
|
|
821
824
|
`(chunk ${i + 1}/${chunks.length}): ${loaded.stderr.trim() || `code=${loaded.code}`}.${partial}`, 2);
|
|
822
825
|
}
|
|
823
826
|
const pasteArgs = pasteBufferBaseArgs();
|
|
824
|
-
if (o.bracketedPaste)
|
|
825
|
-
pasteArgs.push("-p");
|
|
826
827
|
if (pasteSupportsNoSanitize)
|
|
827
828
|
pasteArgs.push("-S");
|
|
828
829
|
pasteArgs.push("-b", bufferName, "-t", name);
|
package/dist/remote.js
CHANGED
|
@@ -9,7 +9,8 @@ import { z } from "zod";
|
|
|
9
9
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
10
10
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
11
11
|
import { AitermError, observeAgentDone, readAgentTranscriptResult, windowsStartProcessArgumentList } from "./core.js";
|
|
12
|
-
import { ensureStateRoot } from "./agent-shared.js";
|
|
12
|
+
import { ensureStateRoot, stateRoot } from "./agent-shared.js";
|
|
13
|
+
import { currentUid } from "./state-root.js";
|
|
13
14
|
const pkg = createRequire(import.meta.url)("../package.json");
|
|
14
15
|
// ssh_configの接続名、DNS名、IPv4/IPv6。先頭の`-`はsshのoptionとして解釈されるため受け付けない。
|
|
15
16
|
const hostPattern = /^(?!-)[A-Za-z0-9._%:\[\]-]{1,255}$/;
|
|
@@ -51,6 +52,19 @@ function remoteDir() {
|
|
|
51
52
|
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
52
53
|
return dir;
|
|
53
54
|
}
|
|
55
|
+
function controlPath() {
|
|
56
|
+
// TMPDIRやXDG_RUNTIME_DIRは長くなり得る。%Cの40字とOpenSSHの一時suffix(17字)を含めても
|
|
57
|
+
// macOSの104 byte制限に収まる短い領域へ、state rootごとに共有ソケットだけを置く。
|
|
58
|
+
const namespace = createHash("sha256").update(stateRoot()).digest("hex").slice(0, 20);
|
|
59
|
+
const dir = path.join("/tmp", `aiterm-ssh-${namespace}`);
|
|
60
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
61
|
+
// 共有/tmpに先に作られた他者の領域やsymlinkを使わず、既存領域の権限も変更しない。
|
|
62
|
+
const stat = fs.lstatSync(dir);
|
|
63
|
+
if (!stat.isDirectory() || stat.uid !== currentUid() || (stat.mode & 0o777) !== 0o700) {
|
|
64
|
+
throw new AitermError("REMOTE_CONTROL_DIR_INVALID: SSH共有ソケットの領域が自分所有の0700ディレクトリではありません", 2);
|
|
65
|
+
}
|
|
66
|
+
return path.join(dir, "cm-%C");
|
|
67
|
+
}
|
|
54
68
|
function askpassScript() {
|
|
55
69
|
const file = path.join(remoteDir(), "askpass.sh");
|
|
56
70
|
const body = "#!/bin/sh\nprintf '%s\\n' \"$AITERM_SSH_PASSPHRASE\"\n";
|
|
@@ -78,7 +92,7 @@ export function sshInvocation(target, remoteCommand, options = {}) {
|
|
|
78
92
|
const args = ["-T", "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=4"];
|
|
79
93
|
// 同じ接続先への後続呼び出しと完了待ちを一本のSSHに相乗りさせる。Windows版OpenSSHはControlMaster非対応。
|
|
80
94
|
if (process.platform !== "win32") {
|
|
81
|
-
args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${
|
|
95
|
+
args.push("-o", "ControlMaster=auto", "-o", `ControlPath=${controlPath()}`, "-o", "ControlPersist=600");
|
|
82
96
|
}
|
|
83
97
|
const passphrase = options.passphrase === false ? null : passphraseFor(target);
|
|
84
98
|
if (passphrase) {
|
package/dist/tmux-runtime.js
CHANGED
|
@@ -225,7 +225,8 @@ function runInMacGui(argv, env, cwd, timeoutMs, timeoutMessage, input, until) {
|
|
|
225
225
|
fs.writeFileSync(inputFile, input, { mode: 0o600 });
|
|
226
226
|
argv = until === undefined
|
|
227
227
|
? ["/bin/sh", "-c", 'f=$1; shift; exec "$@" <"$f"', "aiterm-gui-input", inputFile, ...argv]
|
|
228
|
-
|
|
228
|
+
// CLIが応答前に終了した場合も入力の保持を終え、pipeline全体がCLIの終了コードを返す。
|
|
229
|
+
: ["/bin/sh", "-c", 'f=$1; m=$2; o=$3; shift 3; { cat "$f"; while [ ! -e "$f.done" ] && ! grep -qF -- "$m" "$o" 2>/dev/null; do sleep 0.05; done; } | { "$@"; r=$?; touch "$f.done"; exit "$r"; }',
|
|
229
230
|
"aiterm-gui-input", inputFile, until, path.join(dir, "out"), ...argv];
|
|
230
231
|
}
|
|
231
232
|
fs.writeFileSync(plist, macGuiTmuxPlist(label, dir, argv, env, cwd));
|
|
@@ -32,7 +32,8 @@ $acl.SetOwner($sid)
|
|
|
32
32
|
$acl.SetAccessRuleProtection($true, $false)
|
|
33
33
|
$rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', '${directory ? "ContainerInherit, ObjectInherit" : "None"}', 'None', 'Allow')
|
|
34
34
|
$acl.AddAccessRule($rule)
|
|
35
|
-
Set-Acl
|
|
35
|
+
# Set-Aclは再実行時にSACL更新の権限まで要求する。所有者とDACLの変更だけを.NETへ渡す。
|
|
36
|
+
[System.IO.FileSystemAclExtensions]::SetAccessControl([System.IO.${directory ? "DirectoryInfo" : "FileInfo"}]::new($target), $acl)
|
|
36
37
|
$actual = Get-Acl -LiteralPath $target
|
|
37
38
|
if ($actual.GetOwner([System.Security.Principal.SecurityIdentifier]).Value -ne $sid.Value -or !$actual.AreAccessRulesProtected) { throw 'ACLの適用を確認できません' }
|
|
38
39
|
`);
|
package/docs/DESIGN.md
CHANGED
|
@@ -120,6 +120,8 @@ macOSの専用LaunchAgent、Windowsのユーザー環境変数、所有外の値
|
|
|
120
120
|
`ready`は公式APIによるhook登録・承認の読戻しと、更新前processの終了を確認した状態である。
|
|
121
121
|
Codexの通常起動はAitermのNode、module、socketに依存しない。
|
|
122
122
|
Windowsでは公式Desktopが展開した実行ファイルを照合して配送用の公式APIへ接続する。Desktop更新後はsetupで再検出する。
|
|
123
|
+
WindowsのSteer領域は所有者とDACLだけを.NETのFileSystemAclExtensionsで更新する。監査ACLは変更せず、
|
|
124
|
+
再実行にSeSecurityPrivilegeを要求しない。所有者の照合と更新後の読戻しは維持する。
|
|
123
125
|
LinuxのSteer選択はunsupportedとし、単品のキュー配送は全対応OSで維持する。
|
|
124
126
|
|
|
125
127
|
`parent_delivery`は配送IDと状態を返し、自動配送時の`wait_process`/`wait_command`はnullとなる。
|
|
@@ -211,6 +213,12 @@ WindowsはユーザーのPATHで`aiterm-mcp`と`aiterm-wait`をそのまま呼
|
|
|
211
213
|
`SSH_ASKPASS`でsshへ渡す。受け取っていない時は`BatchMode`で止め、端末での対話入力はしない。
|
|
212
214
|
同じ接続先への呼び出しはControlMasterで1本のSSHに相乗りする。
|
|
213
215
|
|
|
216
|
+
POSIXの共有ソケットは`/tmp/aiterm-ssh-<state rootのSHA-256先頭20桁>/cm-%C`に置く。
|
|
217
|
+
TMPDIRやXDG_RUNTIME_DIRが長くても、OpenSSHが作成時に付ける17字のsuffix込みで98 bytesとなり、
|
|
218
|
+
macOSの104 bytes制限に収まる。state rootごとの分離とprocess間の共有を維持し、askpassと配送記録の場所は変えない。
|
|
219
|
+
共有`/tmp`の既存領域は、自分所有の0700ディレクトリだけを使い、それ以外は`REMOTE_CONTROL_DIR_INVALID`で止める。
|
|
220
|
+
ソケットの寿命と削除は従来どおりOpenSSHの`ControlPersist=600`が所有する。判断はADR 0076。
|
|
221
|
+
|
|
214
222
|
自動配送は`ParentDeliveryManager`を別インスタンスで使い、記録を`remote-`付きの保存場所へ分ける。
|
|
215
223
|
旧版のreaderは`boundary.remote`を知らないため、同じ保存場所に置くと旧processの照会が壊れる。
|
|
216
224
|
子の予約と照会の単位は`deliveryKey`で、接続先のhashを前置きして、この端末の同名sessionと衝突させない。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiterm-mcp",
|
|
3
|
-
"version": "0.45.
|
|
3
|
+
"version": "0.45.2",
|
|
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": [
|