aiterm-mcp 0.29.2 → 0.29.5

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/dist/core.js CHANGED
@@ -13,9 +13,9 @@ import * as path from "node:path";
13
13
  import { createHash, randomBytes } from "node:crypto";
14
14
  import * as rtk from "./rtk.js";
15
15
  import { AitermError, telemetryOwnedFailure, ownTelemetryFailure } from "./errors.js";
16
- import { isWin, SOCKDIR, tmuxCommand, loadPtyBufferChunk, pasteBufferBaseArgs, TMUX_EMPTY_CONFIG, attachCommand, normalizePaneCommand, appendMarkSentinel, settlePaneLog, paneCwdArgument, } from "./tmux-runtime.js";
16
+ import { isWin, SOCKDIR, tmuxCommand, sendPsmuxPayload, loadPtyBufferChunk, pasteBufferBaseArgs, TMUX_EMPTY_CONFIG, attachCommand, normalizePaneCommand, appendMarkSentinel, settlePaneLog, paneCwdArgument, } from "./tmux-runtime.js";
17
17
  import { sleep, currentUid, runtimeStateBase, safeStatSize, readFileRange, writeJson0600, createEmpty0600, shq, LAUNCH_ID_RE, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, assertSessionName, agentsDir, agentEventPath, agentMetadataPath, writeAgentMetadata, AGENT_EVENT_TAIL_BYTES, agentLabel, agentHarness, subagentInstruction, agentLineageFields, } from "./agent-shared.js";
18
- import { GROK_MODEL_DEFAULTS, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokEventsTranscript, latestGrokCompletion, grokInitializationComplete, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
18
+ import { GROK_MODEL_DEFAULTS, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokEventsTranscript, latestGrokCompletion, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
19
19
  import { bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexLaunchBlockingDialog, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, } from "./harnesses/codex.js";
20
20
  import { OPERATION_ID_RE, CLAUDE_RESULT_MAX_BYTES, CLAUDE_EFFORTS, agentManagedClaudeSettingsPath, agentClaudeResultPath, agentClaudeOperationPath, agentClaudeApprovalReceiptPath, agentClaudeDispatchReceiptPath, validateOperationId, readClaudeResultText, assertClaudeAuthenticationReady, buildClaudeAgentCmd, claudeLaunchNote, claudeTuiReady, CLAUDE_COMPOSER_MARKER_RE, createClaudeAgentMetadata, } from "./harnesses/claude.js";
21
21
  import { bindCursorTranscriptSession, cursorTurnBoundary, latestCursorCompletion, observeCursorDone, cursorTranscriptText, assertCursorAuthenticationReady, assertCursorModelAvailable, buildCursorAgentCmd, createCursorAgentMetadata, cursorLaunchNote, cursorEffortNavigation, cursorTuiReady, CURSOR_COMPOSER_MARKER_RE, validateCursorModelEffort, } from "./harnesses/cursor.js";
@@ -75,7 +75,7 @@ const MAX_SEND_BYTES = 64 * 1024;
75
75
  const PTY_PASTE_CHUNK_BYTES = 256;
76
76
  const PTY_PASTE_CHUNK_PAUSE_MS = 10;
77
77
  const PTY_PASTE_PAUSE_BUFFER = new Int32Array(new SharedArrayBuffer(4));
78
- const SESSION_SEND_LOCK_WAIT_MS = 10_000;
78
+ const SESSION_SEND_LOCK_WAIT_MS = isWin ? 130_000 : 10_000;
79
79
  const SESSION_SEND_LOCK_POLL_MS = 25;
80
80
  // 安全: send 前に弾く破壊的コマンド(外部システム境界の防御)
81
81
  const DESTRUCTIVE = [
@@ -121,6 +121,15 @@ function paneCurrentCommand(name) {
121
121
  return "";
122
122
  return normalizePaneCommand(r.stdout.trim());
123
123
  }
124
+ function paneCurrentCommandForMark(name) {
125
+ const foreground = paneCurrentCommand(name);
126
+ if (!isWin || foreground === "powershell" || foreground === "pwsh")
127
+ return foreground;
128
+ // psmuxはnew-session直後だけpane_current_commandへ起動元shellを返すことがある。
129
+ // 現在の末尾画面にPowerShell promptが描画済みなら、mark構文はその実効shellへ合わせる。
130
+ const screen = captureScreen(name, 4).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "");
131
+ return /(?:^|\n)PS [^\r\n]*>\s*$/m.test(screen) ? "pwsh" : foreground;
132
+ }
124
133
  function pasteBufferSupportsNoSanitizeFlag() {
125
134
  const listed = tmux("list-commands");
126
135
  if (listed.code !== 0) {
@@ -658,7 +667,7 @@ export function send(name, text, o = {}) {
658
667
  if (o.mark) {
659
668
  // mark の sentinel は POSIX シェル構文。前面が fish/csh/tcsh 等の非 POSIX 対話シェルだと "$?" が
660
669
  // 壊れて sentinel が成立しない。黙って壊れた完了検出を作らず、明示エラーで until を促す(B8)。
661
- fg = paneCurrentCommand(name);
670
+ fg = paneCurrentCommandForMark(name);
662
671
  if (NON_POSIX_MARK_SHELLS.has(fg)) {
663
672
  throw new AitermError(`mark は POSIX シェル(bash/sh/zsh/dash)前提です。前面が ${fg} のため sentinel の "$?" が` +
664
673
  `正しく展開されません。until で完了パターンを指定してください。`, 2);
@@ -702,42 +711,44 @@ export function send(name, text, o = {}) {
702
711
  /* noop */
703
712
  }
704
713
  }
705
- // `send-keys -l`と単発`paste-buffer`は、長文をPTY入力queueへ一度に流すと、OSを問わず
706
- // 途中以降が欠落しても tmux 自体は code=0 を返す。UTF-8を壊さない256byte以下に分け、
707
- // 全chunk+Enterをsession単位のcross-process lock内で直列化する。
708
- const pasteSupportsNoSanitize = pasteBufferSupportsNoSanitizeFlag();
709
- const chunks = splitPtyText(text);
710
- const bufferBase = `aiterm-${process.pid}-${randomBytes(8).toString("hex")}`;
711
- for (let i = 0; i < chunks.length; i += 1) {
712
- const bufferName = `${bufferBase}-${i}`;
713
- const partial = i > 0
714
- ? " 先行chunkはPTYに入力済みでEnterは未送信です。再送前に入力を確認・消去してください。"
715
- : "";
716
- const loaded = loadPtyBufferChunk(true, bufferName, chunks[i]);
717
- if (loaded.code !== 0) {
718
- tmuxCleanup("delete-buffer", "-b", bufferName);
719
- throw new AitermError(`tmux bufferへの送信準備に失敗しました` +
720
- `(chunk ${i + 1}/${chunks.length}): ${loaded.stderr.trim() || `code=${loaded.code}`}.${partial}`, 2);
721
- }
722
- // -Sは対応新版だけでvis(3)制御文字変換を無効化する。
723
- // send 自身の raw/sanitize 契約だけを真実とし、tmux 側で黙って再変換させない。
724
- const pasteArgs = pasteBufferBaseArgs();
725
- if (o.bracketedPaste)
726
- pasteArgs.push("-p");
727
- if (pasteSupportsNoSanitize)
728
- pasteArgs.push("-S");
729
- pasteArgs.push("-b", bufferName, "-t", name);
730
- const pasted = tmux(...pasteArgs);
731
- if (pasted.code !== 0) {
732
- // paste 失敗時は -d で消えないbufferを明示的に掃除する。元エラーを優先する。
733
- tmuxCleanup("delete-buffer", "-b", bufferName);
734
- throw new AitermError(`tmux bufferのPTY送信に失敗しました` +
735
- `(chunk ${i + 1}/${chunks.length}): ${pasted.stderr.trim() || `code=${pasted.code}`}.${partial}`, 2);
714
+ // POSIX tmuxはUTF-8安全な256byte pasteへ分割する。Windows psmuxは認証済み
715
+ // server commandでpayload全体を単一SendBytesへ変換し、別processとの交差を構造的に防ぐ。
716
+ if (isWin) {
717
+ const sent = sendPsmuxPayload(true, name, text, !!o.bracketedPaste);
718
+ if (sent.code !== 0) {
719
+ throw new AitermError(`psmuxのPTY送信に失敗しました: ${sent.stderr.trim() || `code=${sent.code}`}`, 2);
736
720
  }
737
- if (i + 1 < chunks.length) {
738
- // tmux clientの終了だけでは、serverが前chunkをPTYへdrainしたことを保証しない。
739
- // 次chunkまで短く間を空け、外部PTY入力queueの飽和による黙った末尾欠落を防ぐ。
740
- Atomics.wait(PTY_PASTE_PAUSE_BUFFER, 0, 0, PTY_PASTE_CHUNK_PAUSE_MS);
721
+ }
722
+ else {
723
+ const chunks = splitPtyText(text);
724
+ const pasteSupportsNoSanitize = pasteBufferSupportsNoSanitizeFlag();
725
+ const bufferBase = `aiterm-${process.pid}-${randomBytes(8).toString("hex")}`;
726
+ for (let i = 0; i < chunks.length; i += 1) {
727
+ const bufferName = `${bufferBase}-${i}`;
728
+ const partial = i > 0
729
+ ? " 先行chunkはPTYに入力済みでEnterは未送信です。再送前に入力を確認・消去してください。"
730
+ : "";
731
+ const loaded = loadPtyBufferChunk(true, bufferName, chunks[i]);
732
+ if (loaded.code !== 0) {
733
+ tmuxCleanup("delete-buffer", "-b", bufferName);
734
+ throw new AitermError(`tmux bufferへの送信準備に失敗しました` +
735
+ `(chunk ${i + 1}/${chunks.length}): ${loaded.stderr.trim() || `code=${loaded.code}`}.${partial}`, 2);
736
+ }
737
+ const pasteArgs = pasteBufferBaseArgs();
738
+ if (o.bracketedPaste)
739
+ pasteArgs.push("-p");
740
+ if (pasteSupportsNoSanitize)
741
+ pasteArgs.push("-S");
742
+ pasteArgs.push("-b", bufferName, "-t", name);
743
+ const pasted = tmux(...pasteArgs);
744
+ if (pasted.code !== 0) {
745
+ tmuxCleanup("delete-buffer", "-b", bufferName);
746
+ throw new AitermError(`tmux bufferのPTY送信に失敗しました` +
747
+ `(chunk ${i + 1}/${chunks.length}): ${pasted.stderr.trim() || `code=${pasted.code}`}.${partial}`, 2);
748
+ }
749
+ if (i + 1 < chunks.length) {
750
+ Atomics.wait(PTY_PASTE_PAUSE_BUFFER, 0, 0, PTY_PASTE_CHUNK_PAUSE_MS);
751
+ }
741
752
  }
742
753
  }
743
754
  if (enter) {
@@ -2294,12 +2305,19 @@ function isAgentTuiReady(kind, screen) {
2294
2305
  // Codex/Claude は実行中に「(esc to interrupt)」、Cursor は「Working」+
2295
2306
  // 「ctrl+c to stop」を表示する(いずれも実機採取)。startup 側の処理(MCP initialize 等)が
2296
2307
  // 走ったまま古い composer がscrollbackに残る画面は入力受付とみなさない。
2297
- // Grok/Composer は busy 表示文字列の実機根拠が未採取のため対象外(誤ブロックで起動不能にしない)。
2308
+ // Grok/Composer は実機で `Waiting for response` / `Responding…` / `[stop]` を表示する。
2298
2309
  function isAgentTuiBusy(kind, screen) {
2299
2310
  if (kind === "cursor")
2300
2311
  return /ctrl\+c to stop/i.test(screen);
2301
2312
  if (kind === "codex" || kind === "claude")
2302
2313
  return /esc to interrupt/i.test(screen);
2314
+ if (kind === "grok" || kind === "composer") {
2315
+ return screen.includes("Waiting for response")
2316
+ || screen.includes("Responding…")
2317
+ || screen.includes("Responding...")
2318
+ || screen.includes("[stop]")
2319
+ || /\[hooks:\s*\d+\/\d+\]/u.test(screen);
2320
+ }
2303
2321
  return false;
2304
2322
  }
2305
2323
  // ready gate 用: 入力欄マーカーがあっても busy 表示中は ready と数えない。
@@ -2353,11 +2371,7 @@ async function waitAgentTuiReadyImpl(kind, sample, sleepFn, opts = {}) {
2353
2371
  }
2354
2372
  }
2355
2373
  async function waitAgentTuiReady(name, meta, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2356
- return waitAgentTuiReadyImpl(meta.kind, () => {
2357
- if ((meta.kind === "grok" || meta.kind === "composer") && !grokInitializationComplete(meta))
2358
- return "";
2359
- return captureScreen(name, AGENT_TUI_READY_LINES);
2360
- }, sleep, { timeoutMs });
2374
+ return waitAgentTuiReadyImpl(meta.kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
2361
2375
  }
2362
2376
  async function waitAgentTuiReadyByKind(name, kind, timeoutMs = AGENT_TUI_READY_TIMEOUT_MS) {
2363
2377
  return waitAgentTuiReadyImpl(kind, () => captureScreen(name, AGENT_TUI_READY_LINES), sleep, { timeoutMs });
@@ -3207,22 +3221,22 @@ export async function openAgentWithInitialPrompt(kind, opts = {}) {
3207
3221
  }
3208
3222
  // v0.16.0: launcher は常に managed(Stop hook つき)で立つ。手動運転したい場合は
3209
3223
  // pty_open で素の PTY を開き、harness CLI を自分で send する。
3210
- // 第3要素は「起動時点でturnが走っているか」の event_cursor: Grok/Composer/Cursor の argv prompt は
3211
- // event file 新規作成直後の起動=境界0、prompt なしの起動は turn なし=null。
3212
- if (!prompt || (kind !== "codex" && kind !== "claude")) {
3224
+ // prompt無しはTUIを起動するだけ。prompt有りはharnessを問わず、TUI ready確認後の
3225
+ // sendInitialAgentPromptへ一本化する。Grokの--verbatim argvはCLIが本文をqueueへ置いただけで
3226
+ // turnを開始しない版があり、event_cursor=0を「実行中」と返すと利用側へ嘘をつく。
3227
+ if (!prompt) {
3213
3228
  const [sid, hint] = openAgent(kind, {
3214
3229
  session_name: opts.session_name ?? null,
3215
3230
  model: opts.model ?? null,
3216
3231
  reasoning_effort: opts.reasoning_effort ?? null,
3217
3232
  cwd: opts.cwd ?? null,
3218
- prompt,
3233
+ prompt: null,
3219
3234
  agent_done: true,
3220
3235
  launch_operation_id: opts.launch_operation_id ?? null,
3221
3236
  write_scope: opts.write_scope,
3222
3237
  env_vars: opts.env_vars,
3223
3238
  });
3224
- // argv prompt(grok/composer/cursor)は composer を経由しないため submit 座礁観測の対象外。
3225
- return [sid, hint, prompt ? 0 : null, null];
3239
+ return [sid, hint, null, null];
3226
3240
  }
3227
3241
  const [sid, hint] = openAgent(kind, {
3228
3242
  session_name: opts.session_name ?? null,
package/dist/index.js CHANGED
@@ -563,7 +563,7 @@ function registerAgentTool(toolName, kind, desc) {
563
563
  event_cursor: z.number().int().nullable(),
564
564
  wait_command: z.string().nullable(),
565
565
  // 初回prompt dispatch後のsubmit座礁観測(additive)。true=composerに残存を確認(submit未成立の疑い)/
566
- // false=残存を観測せず(submit成立の保証ではない)/ null=promptなし・argv prompt・判定不能。
566
+ // false=残存を観測せず(submit成立の保証ではない)/ null=promptなし・判定不能。
567
567
  submit_residue: z.boolean().nullable(),
568
568
  ...writeScopeOutputSchema,
569
569
  },
@@ -0,0 +1,99 @@
1
+ import * as fs from "node:fs";
2
+ import * as net from "node:net";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ const MAX_PAYLOAD_BYTES = 64 * 1024 + 12;
6
+ const PTY_CHUNK_BYTES = 256;
7
+ const MAX_FINAL_DRAIN_MS = 3_000;
8
+ const SESSION_BASE_RE = /^[A-Za-z0-9_-]{1,160}$/;
9
+ const KEY_RE = /^[0-9a-f]{16}$/i;
10
+ async function readPayload() {
11
+ const chunks = [];
12
+ let total = 0;
13
+ for await (const chunk of process.stdin) {
14
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
15
+ total += bytes.length;
16
+ if (total > MAX_PAYLOAD_BYTES)
17
+ throw new Error(`payload exceeds ${MAX_PAYLOAD_BYTES} bytes`);
18
+ chunks.push(bytes);
19
+ }
20
+ return Buffer.concat(chunks);
21
+ }
22
+ function psmuxDataDir() {
23
+ const override = process.env.PSMUX_DATA_DIR;
24
+ if (override) {
25
+ if (!path.isAbsolute(override))
26
+ throw new Error("PSMUX_DATA_DIR must be absolute");
27
+ return override;
28
+ }
29
+ return path.join(process.env.USERPROFILE || os.homedir(), ".psmux");
30
+ }
31
+ async function sendCommand(port, key, command, expectedResponse) {
32
+ await new Promise((resolve, reject) => {
33
+ const socket = net.createConnection({ host: "127.0.0.1", port });
34
+ let response = "";
35
+ let settled = false;
36
+ const finish = (error) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ socket.destroy();
41
+ if (error)
42
+ reject(error);
43
+ else
44
+ resolve();
45
+ };
46
+ socket.setTimeout(2_000, () => finish(new Error("psmux command timed out")));
47
+ socket.on("connect", () => socket.end(`AUTH ${key}\n${command}\n`));
48
+ socket.on("data", (chunk) => {
49
+ response += chunk.toString("utf8");
50
+ });
51
+ socket.on("end", () => {
52
+ if (response.split(/\r?\n/).includes("OK") &&
53
+ (expectedResponse == null || response.includes(expectedResponse)))
54
+ finish();
55
+ else
56
+ finish(new Error(`psmux authentication failed: ${response.trim() || "empty response"}`));
57
+ });
58
+ socket.on("error", (error) => finish(error));
59
+ });
60
+ }
61
+ function delay(ms) {
62
+ return new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+ async function send() {
65
+ const sessionBase = process.argv[2] ?? "";
66
+ if (!SESSION_BASE_RE.test(sessionBase))
67
+ throw new Error("invalid psmux session base");
68
+ const payload = await readPayload();
69
+ const dir = psmuxDataDir();
70
+ const portText = fs.readFileSync(path.join(dir, `${sessionBase}.port`), "utf8").trim();
71
+ const key = fs.readFileSync(path.join(dir, `${sessionBase}.key`), "utf8").trim();
72
+ const port = Number(portText);
73
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
74
+ throw new Error("invalid psmux port");
75
+ if (!KEY_RE.test(key))
76
+ throw new Error("invalid psmux auth key");
77
+ const subcommands = [];
78
+ for (let offset = 0; offset < payload.length; offset += PTY_CHUNK_BYTES) {
79
+ const chunk = payload.subarray(offset, offset + PTY_CHUNK_BYTES);
80
+ const hex = Array.from(chunk, (byte) => byte.toString(16).padStart(2, "0")).join(" ");
81
+ subcommands.push(`send -H ${hex}`);
82
+ }
83
+ const chunkDrainMs = payload.length > 1024 ? 300 : 100;
84
+ for (const command of subcommands) {
85
+ await sendCommand(port, key, command);
86
+ await delay(chunkDrainMs);
87
+ }
88
+ const completionMarker = `AITERM_PSMUX_SEND_DONE_${process.pid}`;
89
+ await sendCommand(port, key, `display-message -p ${completionMarker}`, completionMarker);
90
+ const finalDrainMs = Math.min(MAX_FINAL_DRAIN_MS, Math.max(200, Math.ceil(payload.length / 1024) * 500));
91
+ await delay(finalDrainMs);
92
+ if (process.env.AITERM_PSMUX_DEBUG === "1") {
93
+ process.stderr.write(`psmux-send-worker debug: chunks=${subcommands.length} sent=${subcommands.length}\n`);
94
+ }
95
+ }
96
+ send().catch((error) => {
97
+ process.stderr.write(`psmux-send-worker: ${error instanceof Error ? error.message : String(error)}\n`);
98
+ process.exitCode = 1;
99
+ });
@@ -6,6 +6,7 @@ import * as fs from "node:fs";
6
6
  import * as path from "node:path";
7
7
  import * as os from "node:os";
8
8
  import { createHash } from "node:crypto";
9
+ import { fileURLToPath } from "node:url";
9
10
  import { AitermError, ptyDependencyError } from "./errors.js";
10
11
  // Windows ネイティブには tmux が無いため、tmux CLI 互換の native psmux を叩く
11
12
  // (POSIX = Linux/WSL2/macOS は従来どおり tmux を直接叩く。WSL 橋は e3f5fc8 で全廃)。
@@ -129,6 +130,28 @@ export function tmuxCommandWithInput(observe, input, ...args) {
129
130
  export function tmuxCommand(observe, ...args) {
130
131
  return tmuxCommandWithInput(observe, undefined, ...args);
131
132
  }
133
+ export function sendPsmuxPayload(observe, sessionName, text, bracketedPaste) {
134
+ if (!isWin)
135
+ return { code: 1, stdout: "", stderr: "psmux direct send is Windows-only" };
136
+ ensureWinPsmux(observe);
137
+ const worker = fileURLToPath(new URL("./psmux-send-worker.js", import.meta.url));
138
+ const body = bracketedPaste ? `\x1b[200~${text}\x1b[201~` : text;
139
+ const result = spawnSync(process.execPath, [worker, `${WIN_NS}__${sessionName}`], {
140
+ encoding: "utf8",
141
+ maxBuffer: 1024 * 1024,
142
+ input: Buffer.from(body, "utf8"),
143
+ env: tmuxSpawnEnv(),
144
+ timeout: 120_000,
145
+ });
146
+ if (result.error) {
147
+ return {
148
+ code: 1,
149
+ stdout: result.stdout ?? "",
150
+ stderr: `${result.error.code ?? "worker error"}: ${result.error.message}`,
151
+ };
152
+ }
153
+ return { code: result.status ?? 1, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
154
+ }
132
155
  // psmux の load-buffer は stdin (`-`) 非対応で path 位置引数だけを取る。
133
156
  // Windows は owner-only の SOCKDIR 内へ一時ファイル経由で渡し、POSIX は stdin で渡す。
134
157
  export function loadPtyBufferChunk(observe, bufferName, chunk) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.29.2",
3
+ "version": "0.29.5",
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": [