aiterm-mcp 0.27.7 → 0.27.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ja.md CHANGED
@@ -151,7 +151,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
151
151
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
152
152
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
153
153
 
154
- **状態:** 開発継続中 · この分野では新参で、別の形に賭けている([既存手段との比較](#既存手段との比較)参照)· 動作対象は Linux · WSL2 · macOS · Windows ネイティブ(core PTY ツール。`agent_done` は現時点では POSIX/WSL/macOS のみ)· MIT · [変更履歴](CHANGELOG.md)。
154
+ **状態:** 開発継続中 · この分野では新参で、別の形に賭けている([既存手段との比較](#既存手段との比較)参照)· 動作対象は Linux · WSL2 · macOS · Windows ネイティブ(4 launcher と相関付き完了を含む)· MIT · [変更履歴](CHANGELOG.md)。
155
155
 
156
156
  ## なぜ今
157
157
 
@@ -430,7 +430,7 @@ handoff contextを前置きできる。この任意経路は`throughline >= 0.9.
430
430
 
431
431
  ### 完了検出(5 層)
432
432
 
433
- `pty_read({ wait: true })`は通常PTYを5層で判定し、agent sessionは第6の正確な層を使う。Codexは通常rolloutの`task_complete`、Grok/Composerは通常sessionの`turn_ended`、Claudeは通常settingsへ加算したlaunch相関Stop eventを`aiterm-wait --cursor`が観測する。親はブロックもポーリングもしない。Grok/Composerは`mcp_init_completed`確認前に入力欄が見えても送信しない。
433
+ `pty_read({ wait: true })`は通常PTYを、process終了/`mark:true` sentinel/`until`一致/shell復帰を伴う出力静止/timeoutの5層で判定する。`mark`はPOSIX shellでは終了コード、PowerShellでは成功`0`/失敗`1`を出力する。fish/csh/tcshはどちらの状態取得構文にも従わないため送信前に拒否する。agent sessionは第6の正確な層を使う。Codexは通常rolloutの`task_complete`、Grok/Composerは通常sessionの`turn_ended`、Claudeは通常settingsへ加算したlaunch相関Stop eventを`aiterm-wait --cursor`が観測する。親はブロックもポーリングもしない。Grok/Composerは`mcp_init_completed`確認前に入力欄が見えても送信しない。
434
434
 
435
435
  ### トークン削減
436
436
 
@@ -442,18 +442,18 @@ handoff contextを前置きできる。この任意経路は`throughline >= 0.9.
442
442
 
443
443
  `pty_send` は送信前に破壊的コマンド(`rm -rf /`, `mkfs`, `dd of=/dev/…`, `DROP TABLE` 等)を遮断し(`force: true` で越える)、ESC・ブラケットペースト終端などをサニタイズする。`pty_read` は既定で制御文字を無害化して返す(`raw: true` はバイトをそのまま返す)。これは**サンドボックスではなく tripwire**([既知の制約](#既知の制約バグではなく仕様)参照)。
444
444
 
445
- 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。macOSでは長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でtmux pasteし、Linux/WSLでは上限内を1回でpasteする。POSIX shellが前面にいる時のsanitize済み複数行は、改行を含まない単一の`eval`入力へ符号化する。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatch の paste はさらに tmux bracketed paste(`paste-buffer -p`)を使う: bracketed paste mode を要求している pane(vendor TUI)へは各 chunk を `ESC[200~/201~` で包んで届け、チャンク投入中のキー解釈による語中文字化け・submit 取り落としを抑える。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。そのsessionを `pty_close` して作り直すか、全sessionを破棄できる場合だけ `pty_kill_all` で安全に掃除する。
445
+ 1回の `pty_send` が受理する本文はUTF-8で最大64KiB。同一sessionへの送信はaiterm processをまたいで直列化し、chunk同士の混線を防ぐ。全OSで長いPTY入力の欠落を避けるためUTF-8境界を壊さない256-byte単位でpasteし、chunk間に10msのdrain間隔を置く。POSIX shellが前面にいる時のsanitize済み複数行は、改行を含まない単一の`eval`入力へ符号化する。shellがscript全体を所有してから先頭行を実行するため、途中で起動したpager/REPLが後続行を対話キーとして奪わない。単一行、`raw:true`、非shell前面は従来どおり直接PTYへpasteする。agent dispatch の paste はさらに tmux bracketed paste(`paste-buffer -p`)を使う: bracketed paste mode を要求している pane(vendor TUI)へは各 chunk を `ESC[200~/201~` で包んで届け、チャンク投入中のキー解釈による語中文字化け・submit 取り落としを抑える。途中chunkが失敗した場合は部分送信済みであることを明示し、自動でEnterを押さない。送信processの異常終了でlockが残った場合は送信前にfail-closedする。そのsessionを `pty_close` して作り直すか、全sessionを破棄できる場合だけ `pty_kill_all` で安全に掃除する。
446
446
 
447
447
  ## 人が覗く
448
448
 
449
- セッションは共有 tmux ソケット上にある。`pty_open`(および各エージェント起動ツール)の戻り値に表示される `tmux -S … attach -t <id>` で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)——起動した Claude/Codex/Grok/Composer のセッションを見たり、途中でキーボードを引き取ったりもできる。
449
+ セッションは共有 tmux socket(Windows nativeはpsmux namespace)上にある。`pty_open`(および各エージェント起動ツール)の戻り値に表示される `tmux -S … attach -t <id>`、Windowsでは`psmux -L … attach -t <id>`で人間が同じ端末に入って介入できる(抜けるのは `Ctrl-b d`)——起動した Claude/Codex/Grok/Composer のセッションを見たり、途中でキーボードを引き取ったりもできる。
450
450
 
451
451
  ## 要件
452
452
 
453
453
  - **Node.js >= 18**
454
- - **tmux**(実行時の前提。`tmux -V` で確認。未導入なら `apt install tmux` / `brew install tmux`)
454
+ - **tmux または psmux**(実行時の前提)
455
455
  - **macOS / Linux / WSL2** は tmux を直接使う。macOS は同梱されないので `brew install tmux` で導入する。MCP クライアントがターミナルでなく **GUI から起動**された場合、Homebrew の bin(Apple Silicon: `/opt/homebrew/bin`、Intel: `/usr/local/bin`)が `PATH` に入らないことがある。その場合 aiterm が自動で探索するか、**`AITERM_TMUX=/path/to/tmux`** で明示指定する。
456
- - **Windows ネイティブ**には tmux が無いため、aiterm は裏で **WSL の中の tmux** を透過的に使う。[WSL](https://learn.microsoft.com/ja-jp/windows/wsl/) を導入・初期化し、**WSL のディストリ内に tmux を入れる**こと(`sudo apt install tmux`)。`wsl tmux -V` で確認できる。セッション・ソケット・人の `attach` はすべて WSL 側にあり、AI は Windows 側のコマンドから操作するだけ。(Windows のツールは SSH と同じく入れ子で握る: `pty_send "powershell.exe …"` で PowerShell に入る。)
456
+ - **Windows ネイティブ**は WSL を使わず、tmux CLI互換の [psmux](https://github.com/psmux/psmux) **3.3.8以上**を直接使う(`winget install marlocarlo.psmux`)。pane shell用に Git for Windows も必要。解決先は **`AITERM_PSMUX`**/**`AITERM_BASH`** で上書きできる。Windows toolはSSHと同じく入れ子で握れ、`pty_send "powershell.exe"`でPowerShellへ入れる。
457
457
  - **エージェント起動ツール**を使う場合: 対応するベンダー CLI が導入・認証済みであること——`claude_agent` は `claude`、`codex_agent` は `codex`、`grok_agent` / `composer_agent` は `grok`。portable forkだけは追加で`throughline >= 0.9.0`が必要だが、通常のclean launchには不要。(PTY ツールだけ使うなら不要。)
458
458
  - 任意: [`rtk`](https://github.com/rtk-ai/rtk) バイナリ(`pty_send` の `rtk: true` 委譲で使う。無くても動く)
459
459
 
package/README.md CHANGED
@@ -461,7 +461,7 @@ When an agent's answer is longer than the on-screen tail (pane height ≈ 24 lin
461
461
 
462
462
  ### Completion detection (5 layers)
463
463
 
464
- `pty_read({ wait: true })` decides "is the command done?" via five layers: process exit / a `mark:true` sentinel / an `until` match / output quiescence with shell return / timeout. When `mark` or `until` is active, that requested evidence takes precedence and a momentarily quiet shell cannot complete the read as quiescent. Agent sessions add a sixth exact layer: Codex observes normal rollout `task_complete`; Grok/Composer observe normal session `turn_ended`; Claude observes its additive launch-correlated Stop event. `aiterm-wait --cursor` performs that vendor-specific observation without the parent blocking or polling. Pre-send readiness failures are MCP errors, and late completion remains recoverable without resending.
464
+ `pty_read({ wait: true })` decides "is the command done?" via five layers: process exit / a `mark:true` sentinel / an `until` match / output quiescence with shell return / timeout. `mark` emits the shell's exit status on POSIX shells and `0` (success) or `1` (failure) on PowerShell; fish/csh/tcsh are rejected before send because they do not share either status syntax. When `mark` or `until` is active, that requested evidence takes precedence and a momentarily quiet shell cannot complete the read as quiescent. Agent sessions add a sixth exact layer: Codex observes normal rollout `task_complete`; Grok/Composer observe normal session `turn_ended`; Claude observes its additive launch-correlated Stop event. `aiterm-wait --cursor` performs that vendor-specific observation without the parent blocking or polling. Pre-send readiness failures are MCP errors, and late completion remains recoverable without resending.
465
465
 
466
466
  ### Completion push for parent agents (`aiterm-wait`)
467
467
 
package/dist/core.js CHANGED
@@ -13,7 +13,7 @@ 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, settlePaneLog, paneCwdArgument, } from "./tmux-runtime.js";
16
+ import { isWin, SOCKDIR, tmuxCommand, 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, agentLineageFields, } from "./agent-shared.js";
18
18
  import { GROK_MODEL_DEFAULTS, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokEventsTranscript, latestGrokCompletion, grokInitializationComplete, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./vendors/grok.js";
19
19
  import { bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, } from "./vendors/codex.js";
@@ -651,10 +651,11 @@ export function send(name, text, o = {}) {
651
651
  assertInitialPromptNotPendingForSend(name, !!o.force);
652
652
  assertManagedClaudeCredentialCommandNotSent(name, text);
653
653
  text = prepareSendText(text, o);
654
+ let fg = "";
654
655
  if (o.mark) {
655
656
  // mark の sentinel は POSIX シェル構文。前面が fish/csh/tcsh 等の非 POSIX 対話シェルだと "$?" が
656
657
  // 壊れて sentinel が成立しない。黙って壊れた完了検出を作らず、明示エラーで until を促す(B8)。
657
- const fg = paneCurrentCommand(name);
658
+ fg = paneCurrentCommand(name);
658
659
  if (NON_POSIX_MARK_SHELLS.has(fg)) {
659
660
  throw new AitermError(`mark は POSIX シェル(bash/sh/zsh/dash)前提です。前面が ${fg} のため sentinel の "$?" が` +
660
661
  `正しく展開されません。until で完了パターンを指定してください。`, 2);
@@ -675,7 +676,7 @@ export function send(name, text, o = {}) {
675
676
  if (o.rtk && !o.force)
676
677
  assertNotDestructive(text, 3, "rtk 変換後: ");
677
678
  if (o.mark)
678
- text = text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
679
+ text = appendMarkSentinel(text, fg);
679
680
  assertSendTextSize(text, o.rtk || o.mark ? "変換後の送信文字列" : "送信文字列");
680
681
  const reportedText = text;
681
682
  if (!o.raw && text.includes("\n") && ATOMIC_MULTILINE_SHELLS.has(paneCurrentCommand(name))) {
package/dist/index.js CHANGED
@@ -112,7 +112,8 @@ server.registerTool("pty_send", {
112
112
  .boolean()
113
113
  .default(false)
114
114
  .describe("完了 sentinel(終了コード付き)で包む。pty_read(wait:true) が until 無しでも自動検出して" +
115
- "完了確定する(ネスト中や非シェル前面でも効く確実な完了検出。手で until を組む必要なし)。" +
115
+ "完了確定する(POSIX shell と PowerShell に対応。PowerShell の rc は成功0/失敗1。" +
116
+ "fish/csh/tcsh は未対応として送信前に拒否)。" +
116
117
  " enter:false と併用すると sentinel が実行されず完了検出が発火しない(送信後に pty_key(\"Enter\") で実行される)。"),
117
118
  force: z
118
119
  .boolean()
@@ -165,6 +165,18 @@ export function normalizePaneCommand(cmd) {
165
165
  return cmd;
166
166
  return path.basename(cmd).replace(/\.exe$/i, "").toLowerCase();
167
167
  }
168
+ // mark sentinel のOS差は端末runtimeが所有する。Windows native paneでPowerShellが前面なら
169
+ // PowerShellの状態構文を使い、それ以外は既存のPOSIX形式を維持する。
170
+ const WINDOWS_POWERSHELL_COMMANDS = new Set(["powershell", "pwsh"]);
171
+ export function appendMarkSentinel(text, foreground) {
172
+ if (isWin && WINDOWS_POWERSHELL_COMMANDS.has(foreground)) {
173
+ // command echoに完成済みrc=<数字>を含めない。{0}を実行時formatして早期誤完了を防ぐ。
174
+ return text +
175
+ "; if ($?) { [Console]::WriteLine([Environment]::NewLine + ('<<<AITERM_DONE rc={0}>>>' -f 0)) }" +
176
+ " else { [Console]::WriteLine([Environment]::NewLine + ('<<<AITERM_DONE rc={0}>>>' -f 1)) }";
177
+ }
178
+ return text + `; printf '\\n<<<AITERM_DONE rc=%d>>>\\n' "$?"`;
179
+ }
168
180
  // Windows の fs.Stats.mode は POSIX permission bit を持たず、常に 666/777 相当を報告する
169
181
  // (NTFS ACL は別体系)。既知制約の明示的受容として、Windows では group/other bit 検証を
170
182
  // 常に「問題なし」とする。isFile・nlink・owner・size 等の共通検証は呼び手が維持する。
@@ -0,0 +1,210 @@
1
+ // Claude Code 固有の制御。完了正本は launch 固有 Stop hook が書く event/result(ADR 0025)。
2
+ // core 所有のサービス(transcript 不在エラー)は引数で注入し、
3
+ // 依存方向を core → vendors → agent-shared の一方向に保つ。
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
7
+ import { fileURLToPath } from "node:url";
8
+ import { AitermError } from "../errors.js";
9
+ import { modeBitsWorldAccessible } from "../tmux-runtime.js";
10
+ import { spawnAgentControlCommand } from "../agent-resolver.js";
11
+ import { currentUid, writeJson0600, shq, subagentInstruction, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, } from "../agent-shared.js";
12
+ export const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
13
+ export const CLAUDE_RESULT_MAX_BYTES = 4 * 1024 * 1024;
14
+ export const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
15
+ export function agentManagedClaudeSettingsPath(name, launchId) {
16
+ assertSessionName(name);
17
+ if (!LAUNCH_ID_RE.test(launchId))
18
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
19
+ return path.join(agentsDir(), `${name}.${launchId}.claude-settings.json`);
20
+ }
21
+ export function agentClaudeResultPath(name, launchId) {
22
+ assertSessionName(name);
23
+ if (!LAUNCH_ID_RE.test(launchId))
24
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
25
+ return path.join(agentsDir(), `${name}.${launchId}.claude-result.json`);
26
+ }
27
+ export function agentClaudeOperationPath(name, launchId) {
28
+ assertSessionName(name);
29
+ if (!LAUNCH_ID_RE.test(launchId))
30
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
31
+ return path.join(agentsDir(), `${name}.${launchId}.claude-operation.json`);
32
+ }
33
+ export function agentClaudeApprovalReceiptPath(name, launchId) {
34
+ assertSessionName(name);
35
+ if (!LAUNCH_ID_RE.test(launchId))
36
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
37
+ return path.join(agentsDir(), `${name}.${launchId}.claude-approval.json`);
38
+ }
39
+ export function agentClaudeDispatchReceiptPath(name, launchId, operationId) {
40
+ assertSessionName(name);
41
+ if (!LAUNCH_ID_RE.test(launchId))
42
+ throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
43
+ const validated = validateOperationId(operationId);
44
+ return path.join(agentsDir(), `${name}.${launchId}.${validated.slice("sha256:".length)}.claude-dispatch`);
45
+ }
46
+ function claudeHookScriptPath() {
47
+ // この module は dist/vendors/ に置かれるが、stop hook 実体は dist/ 直下に build される。
48
+ return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "claude-stop-hook.js");
49
+ }
50
+ // process.execPath は Homebrew 等では Cellar の版付き実体を指す。長寿命 MCP server の起動後に
51
+ // runtime が更新されるとその実体だけが消え、既に生成済みの hook が exit 127 になる。
52
+ // hook は server と同じ継承 PATH から node を毎回解決し、安定した package script を実行する。
53
+ function nodeHookCommand(hookScript) {
54
+ return `${shq("node")} ${shq(hookScript)}`;
55
+ }
56
+ export function createClaudeCorrelationSettings(name, launchId, model, effort) {
57
+ const hookScript = claudeHookScriptPath();
58
+ if (!fs.existsSync(hookScript)) {
59
+ throw new AitermError(`Claude Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
60
+ }
61
+ const settings = agentManagedClaudeSettingsPath(name, launchId);
62
+ writeJson0600(settings, {
63
+ ...(model ? { model } : {}),
64
+ ...(effort ? { effortLevel: effort } : {}),
65
+ hooks: {
66
+ Stop: [
67
+ {
68
+ hooks: [
69
+ {
70
+ type: "command",
71
+ command: nodeHookCommand(hookScript),
72
+ timeout: 10,
73
+ },
74
+ ],
75
+ },
76
+ ],
77
+ },
78
+ });
79
+ return settings;
80
+ }
81
+ export function validateOperationId(operationId) {
82
+ if (typeof operationId !== "string" || !OPERATION_ID_RE.test(operationId)) {
83
+ throw new AitermError("operation_id は sha256:<64 lowercase hex> で指定してください", 2);
84
+ }
85
+ return operationId;
86
+ }
87
+ export function readClaudeResultText(meta, done, operationId, transcriptUnavailable) {
88
+ if (meta.kind !== "claude" || !meta.result_file || !done.result_digest || done.result_bytes == null) {
89
+ transcriptUnavailable();
90
+ }
91
+ let st;
92
+ try {
93
+ st = fs.lstatSync(meta.result_file);
94
+ }
95
+ catch {
96
+ transcriptUnavailable();
97
+ }
98
+ if (!st.isFile() ||
99
+ st.isSymbolicLink() ||
100
+ st.uid !== currentUid() ||
101
+ st.nlink !== 1 ||
102
+ modeBitsWorldAccessible(st.mode) ||
103
+ st.size > CLAUDE_RESULT_MAX_BYTES + 4096) {
104
+ throw new AitermError("Claude result file の安全検証に失敗しました", 2);
105
+ }
106
+ let result;
107
+ try {
108
+ result = JSON.parse(fs.readFileSync(meta.result_file, "utf8"));
109
+ }
110
+ catch {
111
+ throw new AitermError("Claude result file を読めません", 2);
112
+ }
113
+ const keys = result && typeof result === "object" && !Array.isArray(result) ? Object.keys(result).sort() : [];
114
+ if (keys.join(",") !== "operation_id,result_bytes,result_digest,schema,text,vendor_session_id" ||
115
+ result.schema !== "aiterm.claude-turn-result.v2" ||
116
+ result.operation_id !== done.operation_id ||
117
+ (operationId !== null && result.operation_id !== operationId) ||
118
+ result.vendor_session_id !== meta.vendor_session_id ||
119
+ result.result_digest !== done.result_digest ||
120
+ result.result_bytes !== done.result_bytes ||
121
+ typeof result.text !== "string" ||
122
+ Buffer.byteLength(result.text, "utf8") !== done.result_bytes ||
123
+ createHash("sha256").update(result.text, "utf8").digest("hex") !== done.result_digest) {
124
+ throw new AitermError("Claude result file が完了eventと一致しません", 2);
125
+ }
126
+ return result.text;
127
+ }
128
+ const CLAUDE_AUTH_STATUS_TIMEOUT_MS = 5_000;
129
+ export function assertClaudeAuthenticationReady(bin) {
130
+ const result = spawnAgentControlCommand(bin, ["auth", "status", "--json"], process.cwd(), {
131
+ encoding: "utf8",
132
+ timeout: CLAUDE_AUTH_STATUS_TIMEOUT_MS,
133
+ maxBuffer: 64 * 1024,
134
+ });
135
+ let status = null;
136
+ try {
137
+ status = JSON.parse((result.stdout ?? "").trim());
138
+ }
139
+ catch {
140
+ status = null;
141
+ }
142
+ if (result.error == null &&
143
+ result.status === 0 &&
144
+ status !== null &&
145
+ typeof status === "object" &&
146
+ !Array.isArray(status) &&
147
+ status.loggedIn === true) {
148
+ return;
149
+ }
150
+ if (status !== null &&
151
+ typeof status === "object" &&
152
+ !Array.isArray(status) &&
153
+ status.loggedIn === false) {
154
+ throw new AitermError("Claude Codeの認証を利用できません。sessionは作成していません。" +
155
+ "通常端末で `claude doctor` を実行し、Keychain/credential storeを直してから一度だけ `claude auth login` を実行してください。" +
156
+ "aiterm相関付きClaude session内で /login を繰り返さないでください。", 2);
157
+ }
158
+ const timedOut = result.error && result.error.code === "ETIMEDOUT";
159
+ throw new AitermError(`Claude Codeの認証状態を起動前に確認できません${timedOut ? "(5秒でtimeout)" : ""}。sessionは作成していません。` +
160
+ "`claude auth status --json` と `claude doctor` が成功することを通常端末で確認してください。", 2);
161
+ }
162
+ export function buildClaudeAgentCmd(bin, model, effort, prompt, meta) {
163
+ const parts = [shq(bin)];
164
+ if (meta?.kind === "claude") {
165
+ parts.push("--setting-sources", shq("user,project,local"), "--settings", shq(meta.claude_settings ?? ""), "--session-id", shq(meta.vendor_session_id ?? ""), "--append-system-prompt", shq(subagentInstruction(meta)));
166
+ }
167
+ if (model)
168
+ parts.push("--model", shq(model));
169
+ if (effort)
170
+ parts.push("--effort", shq(effort));
171
+ if (prompt)
172
+ parts.push(shq(prompt)); // 初手プロンプト(任意)
173
+ return parts.join(" ");
174
+ }
175
+ export function claudeLaunchNote(model, effort, meta) {
176
+ const writeScopeNote = writeScopeLaunchNote("claude", meta?.write_scope);
177
+ return `起動設定: model=${model ?? "CLI既定"} effort=${effort ?? "CLI既定"}。${writeScopeNote}`;
178
+ }
179
+ export function claudeTuiReady(screen) {
180
+ return screen.includes("Claude Code") && /(^|\n)\s*❯/.test(screen);
181
+ }
182
+ // submit座礁観測のcomposer領域マーカー(ready判定と同じ記号を行頭基準で探す)。
183
+ export const CLAUDE_COMPOSER_MARKER_RE = /^\s*❯/;
184
+ export function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId, launchRequestDigest, lineageContext, model, effort) {
185
+ const launchId = randomBytes(16).toString("hex");
186
+ const eventFile = agentEventPath(name, launchId);
187
+ const resultFile = agentClaudeResultPath(name, launchId);
188
+ createEmpty0600(eventFile);
189
+ createEmpty0600(resultFile);
190
+ const claudeSettings = createClaudeCorrelationSettings(name, launchId, model, effort);
191
+ const meta = {
192
+ kind: "claude",
193
+ aiterm_session: name,
194
+ launch_id: launchId,
195
+ event_file: eventFile,
196
+ created_at: new Date().toISOString(),
197
+ cwd,
198
+ vendor_session_id: randomUUID(),
199
+ initial_prompt: initialPrompt,
200
+ launch_operation_id: launchOperationId,
201
+ launch_request_digest: launchRequestDigest,
202
+ hook_route: "shared_claude_settings",
203
+ ...agentLineageFields(lineageContext),
204
+ node_platform: process.platform,
205
+ claude_settings: claudeSettings,
206
+ result_file: resultFile,
207
+ };
208
+ writeAgentMetadata(meta);
209
+ return meta;
210
+ }
@@ -0,0 +1,481 @@
1
+ // Codex 固有の制御。完了正本は root rollout transcript の task_complete(ADR 0022)。
2
+ // core 所有のサービス(transcript 行読取・rate limit 検知)は引数で注入し、
3
+ // 依存方向を core → vendors → agent-shared の一方向に保つ。
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { randomBytes } from "node:crypto";
8
+ import { AitermError } from "../errors.js";
9
+ import { shq, subagentInstruction, writeScopeLaunchNote, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, AGENT_EVENT_TAIL_BYTES, CODEX_TRANSCRIPT_INCREMENT_MAX_BYTES, } from "../agent-shared.js";
10
+ export function realCodexHome() {
11
+ return process.env.CODEX_HOME || path.join(process.env.HOME ?? os.homedir(), ".codex");
12
+ }
13
+ export function readCodexConfigPins(configPath) {
14
+ let body;
15
+ try {
16
+ body = fs.readFileSync(configPath, "utf8");
17
+ }
18
+ catch {
19
+ return { model: { present: false, value: null }, effort: { present: false, value: null } };
20
+ }
21
+ const rows = body.split(/\r?\n/);
22
+ let firstTable = rows.findIndex((l) => /^\s*\[/.test(l));
23
+ if (firstTable === -1)
24
+ firstTable = rows.length;
25
+ const pick = (key) => {
26
+ for (const l of rows.slice(0, firstTable)) {
27
+ const m = l.match(new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.*)$`));
28
+ if (m) {
29
+ const v = m[1].trim().match(/^"([^"\\]*)"\s*(?:#.*)?$/);
30
+ return { present: true, value: v ? v[1] : null };
31
+ }
32
+ }
33
+ return { present: false, value: null };
34
+ };
35
+ return { model: pick("model"), effort: pick("model_reasoning_effort") };
36
+ }
37
+ export function codexConfigSummary(configPath) {
38
+ let body;
39
+ try {
40
+ body = fs.readFileSync(configPath, "utf8");
41
+ }
42
+ catch {
43
+ return "";
44
+ }
45
+ const rows = body.split(/\r?\n/);
46
+ const mcpServers = rows.filter((line) => /^\s*\[mcp_servers\./.test(line)).length;
47
+ const firstTable = rows.findIndex((line) => /^\s*\[/.test(line));
48
+ const topLevel = rows.slice(0, firstTable === -1 ? rows.length : firstTable);
49
+ const valueOf = (key) => {
50
+ const row = topLevel.find((line) => new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.+?)\\s*(?:#.*)?$`).test(line));
51
+ if (!row)
52
+ return null;
53
+ const raw = row.match(/=\s*(.+?)(?:\s+#.*)?$/)?.[1].trim() ?? null;
54
+ if (!raw)
55
+ return null;
56
+ const quoted = raw.match(/^(?:"([^"\\]*)"|'([^'\\]*)')$/);
57
+ return quoted ? quoted[1] ?? quoted[2] : raw;
58
+ };
59
+ const bits = [`mcp_servers ${mcpServers} 個継承`];
60
+ const approvalPolicy = valueOf("approval_policy");
61
+ const sandboxMode = valueOf("sandbox_mode");
62
+ if (approvalPolicy)
63
+ bits.push(`approval_policy=${approvalPolicy}`);
64
+ if (sandboxMode)
65
+ bits.push(`sandbox_mode=${sandboxMode}`);
66
+ return `共有 config: ${bits.join(" / ")}`;
67
+ }
68
+ export function findLatestCodexTranscript(codexHome, vendorSessionId) {
69
+ const sessionsDir = path.join(codexHome, "sessions");
70
+ let latestFile = null;
71
+ let latestMtime = -Infinity;
72
+ const visit = (dir) => {
73
+ let entries;
74
+ try {
75
+ entries = fs.readdirSync(dir, { withFileTypes: true });
76
+ }
77
+ catch {
78
+ return;
79
+ }
80
+ for (const entry of entries) {
81
+ const file = path.join(dir, entry.name);
82
+ if (entry.isDirectory()) {
83
+ visit(file);
84
+ continue;
85
+ }
86
+ if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl") || !entry.name.includes(vendorSessionId))
87
+ continue;
88
+ try {
89
+ const mtimeMs = fs.statSync(file).mtimeMs;
90
+ if (mtimeMs > latestMtime) {
91
+ latestFile = file;
92
+ latestMtime = mtimeMs;
93
+ }
94
+ }
95
+ catch {
96
+ // 探索中に消えた transcript は候補にしない。候補が無ければ明示エラーにする。
97
+ }
98
+ }
99
+ };
100
+ visit(sessionsDir);
101
+ return latestFile;
102
+ }
103
+ export function listCodexTranscripts(codexHome) {
104
+ const sessionsDir = path.join(codexHome, "sessions");
105
+ const files = [];
106
+ const visit = (dir) => {
107
+ let entries;
108
+ try {
109
+ entries = fs.readdirSync(dir, { withFileTypes: true });
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ for (const entry of entries) {
115
+ const file = path.join(dir, entry.name);
116
+ if (entry.isDirectory())
117
+ visit(file);
118
+ else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl"))
119
+ files.push(file);
120
+ }
121
+ };
122
+ visit(sessionsDir);
123
+ return files.sort();
124
+ }
125
+ export function codexTranscriptMatchesLaunch(file, meta) {
126
+ const createdAt = Date.parse(meta.created_at);
127
+ try {
128
+ const st = fs.statSync(file);
129
+ if (Number.isFinite(createdAt) && st.mtimeMs + 5_000 < createdAt)
130
+ return false;
131
+ }
132
+ catch {
133
+ return false;
134
+ }
135
+ const size = Math.min(safeStatSize(file), 1024 * 1024);
136
+ if (size === 0)
137
+ return false;
138
+ const marker = `AITERM_AGENT_LAUNCH_ID=${meta.launch_id}`;
139
+ let rootCli = false;
140
+ let launchMarker = false;
141
+ for (const line of readFileRange(file, 0, size).toString("utf8").split("\n")) {
142
+ if (!line.trim())
143
+ continue;
144
+ let record;
145
+ try {
146
+ record = JSON.parse(line);
147
+ }
148
+ catch {
149
+ continue;
150
+ }
151
+ if (record?.type === "session_meta") {
152
+ rootCli = record?.payload?.originator === "codex-tui" && record?.payload?.source === "cli";
153
+ if (!rootCli)
154
+ return false;
155
+ }
156
+ if (record?.type === "response_item" &&
157
+ record?.payload?.type === "message" &&
158
+ record?.payload?.role === "developer" &&
159
+ Array.isArray(record?.payload?.content)) {
160
+ launchMarker ||= record.payload.content.some((item) => (item?.type === "input_text" || item?.type === "output_text") &&
161
+ typeof item?.text === "string" &&
162
+ item.text.includes(marker));
163
+ }
164
+ if (rootCli && launchMarker)
165
+ return true;
166
+ }
167
+ return false;
168
+ }
169
+ export function codexTranscriptSessionId(file) {
170
+ const size = Math.min(safeStatSize(file), AGENT_EVENT_TAIL_BYTES);
171
+ if (size === 0)
172
+ return null;
173
+ const text = readFileRange(file, 0, size).toString("utf8");
174
+ for (const line of text.split("\n")) {
175
+ if (!line.trim())
176
+ continue;
177
+ try {
178
+ const record = JSON.parse(line);
179
+ if (record?.type === "session_meta" && typeof record?.payload?.id === "string" && record.payload.id) {
180
+ return record.payload.id;
181
+ }
182
+ }
183
+ catch {
184
+ // startup中の未完結行は後のpollで読み直す。
185
+ }
186
+ }
187
+ return null;
188
+ }
189
+ export function codexRootTranscript(meta) {
190
+ if (meta.kind !== "codex" || !meta.codex_home)
191
+ return null;
192
+ if (meta.vendor_session_id)
193
+ return findLatestCodexTranscript(meta.codex_home, meta.vendor_session_id);
194
+ if (meta.hook_route === "shared_codex_home") {
195
+ const matches = listCodexTranscripts(meta.codex_home).filter((file) => codexTranscriptMatchesLaunch(file, meta));
196
+ if (matches.length > 1) {
197
+ throw new AitermError("共有CODEX_HOMEに同じlaunch markerのroot rolloutが複数あります。sessionを閉じて起動し直してください。", 2);
198
+ }
199
+ return matches[0] ?? null;
200
+ }
201
+ return listCodexTranscripts(meta.codex_home)[0] ?? null;
202
+ }
203
+ export function bindCodexTranscriptSession(meta) {
204
+ const transcript = codexRootTranscript(meta);
205
+ if (!transcript)
206
+ return null;
207
+ const vendorSessionId = codexTranscriptSessionId(transcript);
208
+ if (vendorSessionId && !meta.vendor_session_id) {
209
+ meta.vendor_session_id = vendorSessionId;
210
+ writeAgentMetadata(meta);
211
+ }
212
+ return transcript;
213
+ }
214
+ export function codexCompletionEvent(meta, vendorSessionId, record) {
215
+ if (record?.type !== "event_msg" ||
216
+ record?.payload?.type !== "task_complete" ||
217
+ typeof record?.payload?.turn_id !== "string" ||
218
+ !record.payload.turn_id)
219
+ return null;
220
+ return {
221
+ type: "agent_done",
222
+ vendor: "codex",
223
+ aiterm_session: meta.aiterm_session,
224
+ launch_id: meta.launch_id,
225
+ vendor_session_id: vendorSessionId,
226
+ turn_id: record.payload.turn_id,
227
+ operation_id: null,
228
+ reason: "Codex transcript task_complete",
229
+ done_status: "turn_done",
230
+ stop_hook_active: false,
231
+ at: typeof record.timestamp === "string" ? record.timestamp : new Date().toISOString(),
232
+ };
233
+ }
234
+ export function latestCodexCompletion(meta, readTranscriptLines) {
235
+ const transcript = codexRootTranscript(meta);
236
+ if (!transcript)
237
+ return null;
238
+ const vendorSessionId = meta.vendor_session_id ?? codexTranscriptSessionId(transcript);
239
+ let latest = null;
240
+ for (const line of readTranscriptLines(transcript)) {
241
+ if (!line.trim())
242
+ continue;
243
+ try {
244
+ latest = codexCompletionEvent(meta, vendorSessionId, JSON.parse(line)) ?? latest;
245
+ }
246
+ catch {
247
+ // Codexが末尾を書込み中なら、その行は次の観測で完結してから読む。
248
+ }
249
+ }
250
+ return latest;
251
+ }
252
+ export async function observeCodexDone(meta, timeout, requestedCursor, detectRateLimit) {
253
+ const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
254
+ let transcript = codexRootTranscript(meta);
255
+ const startOffset = requestedCursor ?? (transcript ? safeStatSize(transcript) : 0);
256
+ let cursor = startOffset;
257
+ let carry = "";
258
+ let malformedEvents = 0;
259
+ let discardLeadingFragment = false;
260
+ let initializedBoundary = false;
261
+ const deadline = performance.now() + timeout * 1000;
262
+ const observation = (outcome, ev = null, rateLimit = null) => ({
263
+ schema: "aiterm.agent-wait-result.v1",
264
+ session_id: meta.aiterm_session,
265
+ launch_id: meta.launch_id,
266
+ vendor: "codex",
267
+ outcome,
268
+ operation_id: null,
269
+ vendor_session_id: ev?.vendor_session_id ?? meta.vendor_session_id ?? null,
270
+ turn_id: ev?.turn_id ?? null,
271
+ malformed_events: malformedEvents,
272
+ at: ev?.at ?? null,
273
+ rate_limit: rateLimit,
274
+ });
275
+ for (;;) {
276
+ if (!fs.existsSync(metadataFile))
277
+ return observation("closed");
278
+ transcript ??= codexRootTranscript(meta);
279
+ if (transcript) {
280
+ if (!initializedBoundary) {
281
+ if (cursor > 0) {
282
+ const previous = readFileRange(transcript, cursor - 1, cursor).toString("utf8");
283
+ discardLeadingFragment = previous !== "\n";
284
+ }
285
+ initializedBoundary = true;
286
+ }
287
+ const size = safeStatSize(transcript);
288
+ if (size < cursor) {
289
+ throw new AitermError("Codex transcript が完了待機中に短くなりました。該当セッションを閉じて起動し直してください。", 2);
290
+ }
291
+ if (size - startOffset > CODEX_TRANSCRIPT_INCREMENT_MAX_BYTES) {
292
+ throw new AitermError("Codex transcript のturn増分が大きすぎます。該当セッションを閉じて起動し直してください。", 2);
293
+ }
294
+ if (size > cursor) {
295
+ carry += readFileRange(transcript, cursor, size).toString("utf8");
296
+ cursor = size;
297
+ const parts = carry.split("\n");
298
+ carry = parts.pop() ?? "";
299
+ if (discardLeadingFragment && parts.length > 0) {
300
+ parts.shift();
301
+ discardLeadingFragment = false;
302
+ }
303
+ const vendorSessionId = meta.vendor_session_id ?? codexTranscriptSessionId(transcript);
304
+ for (const line of parts) {
305
+ if (!line.trim())
306
+ continue;
307
+ if (Buffer.byteLength(line, "utf8") > AGENT_EVENT_MAX_BYTES) {
308
+ malformedEvents++;
309
+ continue;
310
+ }
311
+ try {
312
+ const done = codexCompletionEvent(meta, vendorSessionId, JSON.parse(line));
313
+ if (done)
314
+ return observation("done", done);
315
+ }
316
+ catch {
317
+ malformedEvents++;
318
+ }
319
+ }
320
+ }
321
+ }
322
+ {
323
+ const limited = detectRateLimit(meta.kind, meta.aiterm_session);
324
+ if (limited)
325
+ return observation("rate_limited", null, limited);
326
+ }
327
+ if (performance.now() >= deadline)
328
+ return observation(timeout === 0 ? "running" : "timeout");
329
+ await sleep(AGENT_DONE_POLL_MS);
330
+ }
331
+ }
332
+ export function buildCodexAgentCmd(bin, model, effort, prompt, meta) {
333
+ const parts = [shq(bin)];
334
+ // `codex --help` で確認した実在フラグ。read-only 宣言だけはCLI sandboxへ落とし、
335
+ // launcher自身が実効能力壁を作る。パス説明はCodex CLIに同等のallowlist引数がないため宣言のまま残す。
336
+ if (meta?.kind === "codex" && meta.write_scope === "read-only")
337
+ parts.push("--sandbox", "read-only");
338
+ // model/effort は共有configを書き換えず、CLI引数で明示して起動単位に優先する。
339
+ if (model)
340
+ parts.push("-m", shq(model));
341
+ if (effort)
342
+ parts.push("-c", `model_reasoning_effort=${shq(effort)}`);
343
+ if (meta?.kind === "codex" && meta.hook_route === "shared_codex_home") {
344
+ parts.push("-c", `developer_instructions=${shq(subagentInstruction(meta))}`);
345
+ }
346
+ if (prompt)
347
+ parts.push(shq(prompt)); // 初手プロンプト(任意)
348
+ return parts.join(" ");
349
+ }
350
+ // 起動応答にモデル/effort の実効値と出所を明示する。codex は端末 config のピン(model /
351
+ // model_reasoning_effort)が対話子へ波及する構造のため、引数・端末config継承・CLI既定の
352
+ // どれで起動したかを起動時点で可視化し、実効 effort=ultra は proactive 自動委譲 ON を警告する。
353
+ export function codexLaunchNote(model, effort, meta) {
354
+ const writeScopeNote = writeScopeLaunchNote("codex", meta?.write_scope);
355
+ const configPath = meta?.kind === "codex" && meta.codex_home
356
+ ? path.join(meta.codex_home, "config.toml")
357
+ : path.join(realCodexHome(), "config.toml");
358
+ const pins = readCodexConfigPins(configPath);
359
+ const describePin = (arg, pin) => arg
360
+ ? `${arg}(引数)`
361
+ : pin.present
362
+ ? pin.value
363
+ ? `${pin.value}(端末config継承)`
364
+ : "端末config継承(値未解析)"
365
+ : "CLI既定";
366
+ const effectiveEffort = effort ?? (pins.effort.present ? pins.effort.value : null);
367
+ const launch = `起動設定: model=${describePin(model, pins.model)} effort=${describePin(effort, pins.effort)}。` +
368
+ (effectiveEffort === "ultra"
369
+ ? "⚠ effort=ultra は max 推論+proactive 自動委譲 ON(子エージェント自動生成・使用量急増に注意)。"
370
+ : "");
371
+ const summary = meta?.kind === "codex" && meta.codex_home ? codexConfigSummary(configPath) : "";
372
+ return (summary ? `${launch}\n${summary}\n` : launch) + writeScopeNote;
373
+ }
374
+ export function codexTuiReady(screen) {
375
+ // 起動直後は製品header、長寿命sessionでは常駐footerがCodex TUIの識別子になる。
376
+ // capture-paneは直近45行だけなので、会話が進むとheaderは正常に画面外へ流れる。
377
+ const codexFrontend = screen.includes("OpenAI Codex")
378
+ || /(^|\n)\s*\S+\s+(?:low|medium|high|xhigh|max|ultra)(?:\s+fast)?\s+·\s+\S.*$/m.test(screen);
379
+ return codexFrontend && /(^|\n)\s*[›>]/.test(screen);
380
+ }
381
+ // submit座礁観測のcomposer領域マーカー(ready判定と同じ記号を行頭基準で探す)。
382
+ export const CODEX_COMPOSER_MARKER_RE = /^\s*[›>]/;
383
+ export function codexModelChoice(screen, model) {
384
+ for (const line of screen.slice(screen.lastIndexOf("Select Model and Effort")).split("\n")) {
385
+ const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+(\S+)/);
386
+ if (match?.[2] === model)
387
+ return match[1];
388
+ }
389
+ return null;
390
+ }
391
+ export function codexEffortChoice(screen, effort) {
392
+ const labels = {
393
+ low: /^Low\b/i,
394
+ medium: /^Medium\b/i,
395
+ high: /^High\b/i,
396
+ xhigh: /^Extra high\b/i,
397
+ max: /^Max\b/i,
398
+ ultra: /^Ultra\b/i,
399
+ };
400
+ const wanted = labels[effort.toLowerCase()];
401
+ if (!wanted)
402
+ return null;
403
+ for (const line of screen.split("\n")) {
404
+ const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+(.+?)\s{2,}/);
405
+ if (match && wanted.test(match[2]))
406
+ return match[1];
407
+ }
408
+ return null;
409
+ }
410
+ export function codexMoreReasoningChoice(screen) {
411
+ for (const line of screen.split("\n")) {
412
+ const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+More reasoning/);
413
+ if (match)
414
+ return match[1];
415
+ }
416
+ return null;
417
+ }
418
+ // 回収対象turnの最終assistantメッセージをroot rollout transcriptから抽出する。
419
+ export function codexTranscriptText(meta, turnId, readTranscriptLines, transcriptUnavailable) {
420
+ if (!meta.codex_home || !meta.vendor_session_id)
421
+ transcriptUnavailable();
422
+ const transcript = findLatestCodexTranscript(meta.codex_home, meta.vendor_session_id);
423
+ if (!transcript)
424
+ transcriptUnavailable();
425
+ const lines = readTranscriptLines(transcript);
426
+ const matching = [];
427
+ let finalAnswer = "";
428
+ for (const line of lines) {
429
+ if (!line.trim())
430
+ continue;
431
+ let record;
432
+ try {
433
+ record = JSON.parse(line);
434
+ }
435
+ catch {
436
+ continue;
437
+ }
438
+ const payload = record?.payload;
439
+ if (record?.type === "response_item" &&
440
+ payload?.type === "message" &&
441
+ payload?.role === "assistant" &&
442
+ payload?.internal_chat_message_metadata_passthrough?.turn_id === turnId &&
443
+ Array.isArray(payload?.content)) {
444
+ for (const item of payload.content) {
445
+ if (item?.type === "output_text" && typeof item.text === "string")
446
+ matching.push(item.text);
447
+ }
448
+ }
449
+ if (record?.type === "event_msg" &&
450
+ payload?.type === "agent_message" &&
451
+ payload?.phase === "final_answer" &&
452
+ typeof payload?.message === "string") {
453
+ finalAnswer = payload.message;
454
+ }
455
+ }
456
+ return matching.join("\n") || finalAnswer;
457
+ }
458
+ export function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}, writeScope, lineageContext) {
459
+ const launchId = randomBytes(16).toString("hex");
460
+ const eventFile = agentEventPath(name, launchId);
461
+ createEmpty0600(eventFile);
462
+ const codexHome = realCodexHome();
463
+ const meta = {
464
+ kind: "codex",
465
+ aiterm_session: name,
466
+ launch_id: launchId,
467
+ event_file: eventFile,
468
+ created_at: new Date().toISOString(),
469
+ cwd,
470
+ ...(writeScope === undefined ? {} : { write_scope: writeScope }),
471
+ vendor_session_id: null,
472
+ initial_prompt: initialPrompt,
473
+ hook_route: "shared_codex_home",
474
+ completion_route: "codex_transcript",
475
+ ...(lineageContext ? agentLineageFields(lineageContext) : {}),
476
+ node_platform: process.platform,
477
+ codex_home: codexHome,
478
+ };
479
+ writeAgentMetadata(meta);
480
+ return meta;
481
+ }
@@ -0,0 +1,386 @@
1
+ // Grok / Composer 固有の制御。Composer は grok CLI の別モデル起動プリセットであり、
2
+ // モデル既定値と表示名以外は Grok と完全共通(実測 2026-08-23 棚卸し・docs/32)。
3
+ // core 所有のサービス(transcript 行読取・rate limit 検知)は引数で注入し、
4
+ // 依存方向を core → vendors → agent-shared の一方向に保つ。
5
+ import * as fs from "node:fs";
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+ import { randomBytes, randomUUID } from "node:crypto";
9
+ import { AitermError } from "../errors.js";
10
+ import { modeBitsWorldAccessible, modeBitsWritableByOthers } from "../tmux-runtime.js";
11
+ import { spawnAgentControlCommand } from "../agent-resolver.js";
12
+ import { shq, subagentInstruction, writeScopeLaunchNote, currentUid, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, GROK_TRANSCRIPT_INCREMENT_MAX_BYTES, } from "../agent-shared.js";
13
+ const GROK_AUTH_MAX_BYTES = 64 * 1024;
14
+ const GROK_MODELS_MAX_BYTES = 1024 * 1024;
15
+ const GROK_MODELS_TIMEOUT_MS = 15_000;
16
+ // grok CLI はモデル未指定だと端末側 default に従うため、ツール契約として既定 slug を固定する。
17
+ // codex は既定 slug を持たず端末 config/CLI 既定に委ねる(起動応答で実効値を報告する)。
18
+ // grok 既定は dotagents 規範(docs/02_models.md: xAI 旗艦 = grok-4.6)に従う。
19
+ export const GROK_MODEL_DEFAULTS = {
20
+ grok: "grok-4.6",
21
+ composer: "grok-composer-2.5-fast",
22
+ };
23
+ export function realGrokHome() {
24
+ return path.resolve(process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok"));
25
+ }
26
+ export function resolveAndValidateGrokAuth(srcHome) {
27
+ const inheritedSet = Object.prototype.hasOwnProperty.call(process.env, "GROK_AUTH_PATH");
28
+ const inherited = process.env.GROK_AUTH_PATH;
29
+ if (inheritedSet && (!inherited || !path.isAbsolute(inherited)))
30
+ throw new AitermError("GROK_AUTH_PATH は空でない絶対パスで指定してください", 2);
31
+ const authPath = inherited ?? path.join(srcHome, "auth.json");
32
+ let fd;
33
+ try {
34
+ fd = fs.openSync(authPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
35
+ const st = fs.fstatSync(fd);
36
+ // isFile・nlink・owner・size・O_NOFOLLOW・realpath 検証は全OS共通に維持する
37
+ // (mode bit 検証のOS差は tmux-runtime の modeBits* が所有)。
38
+ const worldAccessible = modeBitsWorldAccessible(st.mode);
39
+ if (!st.isFile() || st.nlink !== 1 || st.uid !== currentUid() || worldAccessible || st.size > GROK_AUTH_MAX_BYTES) {
40
+ throw new AitermError("Grok 認証正本の安全検証に失敗しました", 2);
41
+ }
42
+ const value = JSON.parse(fs.readFileSync(fd, "utf8"));
43
+ if (!value || typeof value !== "object" || Array.isArray(value))
44
+ throw new AitermError("Grok 認証正本のJSONが不正です", 2);
45
+ // auth file 自体は O_NOFOLLOW で開いているが、中間 directory の symlink は辿り得る。
46
+ // vendor に渡す正本を path swap の入口にしないため、字句正規化した絶対 path と realpath を
47
+ // 一致させ、canonical な祖先も root まで検証する。same-UID race の排他は vendor lock の責務。
48
+ const lexicalPath = path.resolve(authPath);
49
+ const canonicalPath = fs.realpathSync(authPath);
50
+ if (lexicalPath !== canonicalPath)
51
+ throw new AitermError("Grok 認証正本の path に symlink を含められません", 2);
52
+ for (let dir = path.dirname(canonicalPath);; dir = path.dirname(dir)) {
53
+ const dirSt = fs.lstatSync(dir);
54
+ // /tmp のような root 所有 + sticky の共有 directory は、本人所有の private な
55
+ // 直下 directory を他 UID が rename/unlink できないため許可する。sticky 無しの
56
+ // group/other writable 祖先は path swap が可能なので従来どおり拒否する。
57
+ const writableByOthers = modeBitsWritableByOthers(dirSt.mode);
58
+ const protectedSharedRoot = dirSt.uid === 0 && (dirSt.mode & 0o1000) !== 0;
59
+ if (!dirSt.isDirectory()
60
+ || dirSt.isSymbolicLink()
61
+ || (dirSt.uid !== currentUid() && dirSt.uid !== 0)
62
+ || (writableByOthers && !protectedSharedRoot)) {
63
+ throw new AitermError("Grok 認証正本の祖先 directory が安全ではありません", 2);
64
+ }
65
+ const parent = path.dirname(dir);
66
+ if (parent === dir)
67
+ break;
68
+ }
69
+ return canonicalPath;
70
+ }
71
+ catch (e) {
72
+ if (e.code === "ENOENT" && !inheritedSet && process.env.XAI_API_KEY)
73
+ return null;
74
+ if (e instanceof AitermError)
75
+ throw e;
76
+ throw new AitermError(e.code === "ENOENT" ? "Grok 認証正本が見つかりません。先に grok login が必要です" : "Grok 認証正本を安全に開けません", 2);
77
+ }
78
+ finally {
79
+ if (fd !== undefined)
80
+ fs.closeSync(fd);
81
+ }
82
+ }
83
+ export function grokModelCatalog(bin, cwd) {
84
+ const result = spawnAgentControlCommand(bin, ["models"], cwd, {
85
+ cwd,
86
+ encoding: "utf8",
87
+ env: process.env,
88
+ timeout: GROK_MODELS_TIMEOUT_MS,
89
+ maxBuffer: GROK_MODELS_MAX_BYTES,
90
+ windowsHide: true,
91
+ });
92
+ if (result.error || result.status !== 0) {
93
+ const detail = result.error?.message || result.stderr?.trim() || `exit=${result.status ?? "unknown"}`;
94
+ throw new AitermError(`Grok model catalog を取得できません: ${detail}`, 2);
95
+ }
96
+ const text = result.stdout.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
97
+ const marker = "Available models:";
98
+ const start = text.indexOf(marker);
99
+ if (start < 0)
100
+ throw new AitermError("Grok model catalog の出力形式が不正です(Available models がありません)", 2);
101
+ const models = [];
102
+ for (const line of text.slice(start + marker.length).split(/\r?\n/)) {
103
+ const match = line.match(/^\s{2}[-*]\s+(.+?)(?:\s+\(default\))?\s*$/);
104
+ if (match?.[1])
105
+ models.push(match[1]);
106
+ }
107
+ if (models.length === 0)
108
+ throw new AitermError("Grok model catalog に利用可能なmodelがありません", 2);
109
+ return models;
110
+ }
111
+ export function assertGrokModelAvailable(bin, cwd, model) {
112
+ const models = grokModelCatalog(bin, cwd);
113
+ if (!models.includes(model)) {
114
+ throw new AitermError(`Grok model catalog に ${JSON.stringify(model)} がありません。利用可能: ${models.join(", ")}。` +
115
+ "別modelへfallbackせず起動を中止しました", 2);
116
+ }
117
+ }
118
+ export function grokSessionDirectory(meta) {
119
+ if ((meta.kind !== "grok" && meta.kind !== "composer") || !meta.grok_home || !meta.vendor_session_id)
120
+ return null;
121
+ const cwd = meta.cwd ?? process.cwd();
122
+ return path.join(meta.grok_home, "sessions", encodeURIComponent(cwd), meta.vendor_session_id);
123
+ }
124
+ export function grokEventsTranscript(meta) {
125
+ const dir = grokSessionDirectory(meta);
126
+ return dir ? path.join(dir, "events.jsonl") : null;
127
+ }
128
+ export function grokCompletionEvent(meta, record) {
129
+ if ((meta.kind !== "grok" && meta.kind !== "composer") ||
130
+ record?.type !== "turn_ended" ||
131
+ (record?.outcome !== "completed" && record?.outcome !== "cancelled"))
132
+ return null;
133
+ const turnId = typeof record?.ts === "string" || typeof record?.ts === "number" ? String(record.ts) : null;
134
+ return {
135
+ type: "agent_done",
136
+ vendor: meta.kind,
137
+ aiterm_session: meta.aiterm_session,
138
+ launch_id: meta.launch_id,
139
+ vendor_session_id: meta.vendor_session_id,
140
+ turn_id: turnId,
141
+ operation_id: null,
142
+ reason: `Grok transcript turn_ended:${record.outcome}`,
143
+ done_status: "turn_done",
144
+ stop_hook_active: false,
145
+ at: typeof record?.ts === "string" ? record.ts : new Date().toISOString(),
146
+ };
147
+ }
148
+ export function latestGrokCompletion(meta, readTranscriptLines) {
149
+ const transcript = grokEventsTranscript(meta);
150
+ if (!transcript || !fs.existsSync(transcript))
151
+ return null;
152
+ let latest = null;
153
+ for (const line of readTranscriptLines(transcript)) {
154
+ if (!line.trim())
155
+ continue;
156
+ try {
157
+ latest = grokCompletionEvent(meta, JSON.parse(line)) ?? latest;
158
+ }
159
+ catch {
160
+ // 末尾書込み中のlineは次の観測で完結してから読む。
161
+ }
162
+ }
163
+ return latest;
164
+ }
165
+ export function grokInitializationComplete(meta) {
166
+ const transcript = grokEventsTranscript(meta);
167
+ if (!transcript || !fs.existsSync(transcript))
168
+ return false;
169
+ const size = safeStatSize(transcript);
170
+ const from = Math.max(0, size - GROK_TRANSCRIPT_INCREMENT_MAX_BYTES);
171
+ const lines = readFileRange(transcript, from, size).toString("utf8").split("\n");
172
+ if (from > 0)
173
+ lines.shift();
174
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
175
+ const line = lines[index].trim();
176
+ if (!line)
177
+ continue;
178
+ try {
179
+ const record = JSON.parse(line);
180
+ if (record?.type === "mcp_init_completed")
181
+ return true;
182
+ if (record?.type === "mcp_init_started")
183
+ return false;
184
+ }
185
+ catch {
186
+ // 末尾書込み中や無関係な破損lineはready判定を進めない。
187
+ }
188
+ }
189
+ return false;
190
+ }
191
+ export async function observeGrokDone(meta, timeout, requestedCursor, detectRateLimit) {
192
+ const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
193
+ const transcript = grokEventsTranscript(meta);
194
+ if (!transcript)
195
+ throw new AitermError("Grok transcriptのsession相関情報がありません", 2);
196
+ const startOffset = requestedCursor ?? safeStatSize(transcript);
197
+ let cursor = startOffset;
198
+ let carry = "";
199
+ let malformedEvents = 0;
200
+ let discardLeadingFragment = false;
201
+ let initializedBoundary = false;
202
+ const deadline = performance.now() + timeout * 1000;
203
+ const observation = (outcome, ev = null, rateLimit = null) => ({
204
+ schema: "aiterm.agent-wait-result.v1",
205
+ session_id: meta.aiterm_session,
206
+ launch_id: meta.launch_id,
207
+ vendor: meta.kind,
208
+ outcome,
209
+ operation_id: null,
210
+ vendor_session_id: meta.vendor_session_id,
211
+ turn_id: ev?.turn_id ?? null,
212
+ malformed_events: malformedEvents,
213
+ at: ev?.at ?? null,
214
+ rate_limit: rateLimit,
215
+ });
216
+ for (;;) {
217
+ if (!fs.existsSync(metadataFile))
218
+ return observation("closed");
219
+ if (fs.existsSync(transcript)) {
220
+ if (!initializedBoundary) {
221
+ if (cursor > 0) {
222
+ const previous = readFileRange(transcript, cursor - 1, cursor).toString("utf8");
223
+ discardLeadingFragment = previous !== "\n";
224
+ }
225
+ initializedBoundary = true;
226
+ }
227
+ const size = safeStatSize(transcript);
228
+ if (size < cursor) {
229
+ throw new AitermError("Grok transcript が完了待機中に短くなりました。該当セッションを閉じて起動し直してください。", 2);
230
+ }
231
+ if (size - startOffset > GROK_TRANSCRIPT_INCREMENT_MAX_BYTES) {
232
+ throw new AitermError("Grok transcript のturn増分が大きすぎます。該当セッションを閉じて起動し直してください。", 2);
233
+ }
234
+ if (size > cursor) {
235
+ carry += readFileRange(transcript, cursor, size).toString("utf8");
236
+ cursor = size;
237
+ const parts = carry.split("\n");
238
+ carry = parts.pop() ?? "";
239
+ if (discardLeadingFragment && parts.length > 0) {
240
+ parts.shift();
241
+ discardLeadingFragment = false;
242
+ }
243
+ for (const line of parts) {
244
+ if (!line.trim())
245
+ continue;
246
+ if (Buffer.byteLength(line, "utf8") > AGENT_EVENT_MAX_BYTES) {
247
+ malformedEvents++;
248
+ continue;
249
+ }
250
+ try {
251
+ const done = grokCompletionEvent(meta, JSON.parse(line));
252
+ if (done)
253
+ return observation("done", done);
254
+ }
255
+ catch {
256
+ malformedEvents++;
257
+ }
258
+ }
259
+ }
260
+ }
261
+ {
262
+ const limited = detectRateLimit(meta.kind, meta.aiterm_session);
263
+ if (limited)
264
+ return observation("rate_limited", null, limited);
265
+ }
266
+ if (performance.now() >= deadline)
267
+ return observation(timeout === 0 ? "running" : "timeout");
268
+ await sleep(AGENT_DONE_POLL_MS);
269
+ }
270
+ }
271
+ export function buildGrokAgentCmd(kind, bin, model, effort, prompt, meta) {
272
+ const parts = [shq(bin)];
273
+ // grok / composer は同じ grok CLI をモデル違いで起動する。
274
+ parts.push("--no-auto-update");
275
+ if (meta?.kind === "grok" || meta?.kind === "composer")
276
+ parts.push("--no-alt-screen");
277
+ parts.push("--model", shq(model ?? GROK_MODEL_DEFAULTS[kind]));
278
+ if (effort)
279
+ parts.push("--reasoning-effort", shq(effort));
280
+ if ((meta?.kind === "grok" || meta?.kind === "composer") && meta.write_scope === "read-only") {
281
+ // read-only は sandbox が実効書込み禁止を作るため、MCP ツール許可ダイアログの自動承認を
282
+ // 付けても能力は増えない。無人 subagent が初回 MCP 使用の許可待ちで停止する実障害への対処。
283
+ // read-only 以外の launch には付けない=権限拡大しない。
284
+ parts.push("--sandbox", "read-only", "--always-approve");
285
+ }
286
+ if ((meta?.kind === "grok" || meta?.kind === "composer") && meta.hook_route === "shared_grok_home") {
287
+ parts.push("--session-id", shq(meta.vendor_session_id ?? ""), "--rules", shq(subagentInstruction(meta)));
288
+ }
289
+ if ((meta?.kind === "grok" || meta?.kind === "composer") && prompt)
290
+ parts.push("--verbatim");
291
+ if (prompt)
292
+ parts.push(shq(prompt)); // 初手プロンプト(任意)
293
+ return parts.join(" ");
294
+ }
295
+ export function grokLaunchNote(kind, model, effort, meta) {
296
+ const writeScopeNote = writeScopeLaunchNote(kind, meta?.write_scope);
297
+ return (`起動設定: model=${model ?? GROK_MODEL_DEFAULTS[kind]}(${model ? "引数" : "ツール既定"})。` +
298
+ `effort=${effort ?? "CLI/model既定"}。` + writeScopeNote);
299
+ }
300
+ // grok/composer: 検証済み auth 正本をそのままの path 形で渡す。Windows では native 強制により
301
+ // vendor は Windows process なので、Windows ドライブパスが正しい形(WSL 形への変換はしない)。
302
+ export function grokEnvTokens(meta) {
303
+ return [
304
+ ...(meta.grok_auth_path ? [`GROK_AUTH_PATH=${shq(meta.grok_auth_path)}`] : []),
305
+ "GROK_DISABLE_AUTOUPDATER=1",
306
+ ];
307
+ }
308
+ export function grokTuiReady(screen) {
309
+ // Grok Build 0.2.117 は起動完了後に製品名を消し、model footerだけを残す。
310
+ // Composerも同じfrontendでmodel名だけが異なるため、両方をvendor UIの根拠にする。
311
+ // Windows native grok.exe(1.0.4 実測)は入力欄markerを `❯` でなく `>` で描画するため両方を受ける。
312
+ const grokFrontend = screen.includes("Grok Build") || /\b(?:Grok|Composer)\s+[\w.()-]+/.test(screen);
313
+ return grokFrontend && /(^|\n|\s)[❯>]/.test(screen);
314
+ }
315
+ // submit座礁観測のcomposer領域マーカー(Windows native描画の `>` も ready 判定と同様に受ける)。
316
+ export const GROK_COMPOSER_MARKER_RE = /(^|\s)[❯>]/;
317
+ export function grokFooterHasConfiguration(screen, model, effort) {
318
+ const modelMatch = model?.match(/^grok-(\d+(?:\.\d+)*)$/) ?? null;
319
+ if (model && !modelMatch)
320
+ return false;
321
+ const modelLabel = modelMatch ? `Grok ${modelMatch[1]}` : null;
322
+ return screen.split("\n").some((line) => {
323
+ if (!line.includes("·"))
324
+ return false;
325
+ if (modelLabel && !line.includes(`${modelLabel} (`))
326
+ return false;
327
+ if (effort && !line.includes(`(${effort})`))
328
+ return false;
329
+ return modelLabel !== null || effort !== null;
330
+ });
331
+ }
332
+ // 最後のuser発話以降のassistantメッセージをchat_history.jsonlから抽出する。
333
+ export function grokTranscriptText(meta, readTranscriptLines, transcriptUnavailable) {
334
+ if (!meta.grok_home || !meta.vendor_session_id)
335
+ transcriptUnavailable();
336
+ // cwd 未指定で起動した TUI はサーバープロセスの cwd を継承する。metadata に null が残る既存
337
+ // launch との互換のため、その実際の起動 cwd を path 導出に使う(launch 側は変更しない)。
338
+ const cwd = meta.cwd ?? process.cwd();
339
+ const transcript = path.join(meta.grok_home, "sessions", encodeURIComponent(cwd), meta.vendor_session_id, "chat_history.jsonl");
340
+ const lines = readTranscriptLines(transcript);
341
+ let lastUser = -1;
342
+ const records = [];
343
+ for (const line of lines) {
344
+ if (!line.trim())
345
+ continue;
346
+ try {
347
+ const record = JSON.parse(line);
348
+ records.push(record);
349
+ if (record?.type === "user" && !("synthetic_reason" in record))
350
+ lastUser = records.length - 1;
351
+ }
352
+ catch {
353
+ // 外部 transcript の壊れた1行は残りの完結行を読む妨げにしない。
354
+ }
355
+ }
356
+ return records
357
+ .slice(lastUser + 1)
358
+ .filter((record) => record?.type === "assistant" && typeof record?.content === "string")
359
+ .map((record) => record.content)
360
+ .join("\n");
361
+ }
362
+ export function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath, writeScope, lineageContext) {
363
+ const launchId = randomBytes(16).toString("hex");
364
+ const eventFile = agentEventPath(name, launchId);
365
+ createEmpty0600(eventFile);
366
+ const grokHome = realGrokHome();
367
+ const meta = {
368
+ kind,
369
+ aiterm_session: name,
370
+ launch_id: launchId,
371
+ event_file: eventFile,
372
+ created_at: new Date().toISOString(),
373
+ cwd,
374
+ ...(writeScope === undefined ? {} : { write_scope: writeScope }),
375
+ vendor_session_id: randomUUID(),
376
+ initial_prompt: initialPrompt,
377
+ hook_route: "shared_grok_home",
378
+ completion_route: "grok_transcript",
379
+ ...(lineageContext ? agentLineageFields(lineageContext) : {}),
380
+ node_platform: process.platform,
381
+ grok_home: grokHome,
382
+ grok_auth_path: authPath,
383
+ };
384
+ writeAgentMetadata(meta);
385
+ return meta;
386
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.27.7",
3
+ "version": "0.27.9",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent tmux terminal MCP for launching and driving Claude, Codex, Grok, or Composer from any MCP client, cross-vendor or same-vendor. Also runs durable PTY sessions for SSH, containers, and REPLs.",
6
6
  "keywords": [
@@ -45,6 +45,7 @@
45
45
  },
46
46
  "files": [
47
47
  "dist/*.js",
48
+ "dist/vendors/*.js",
48
49
  "README.md",
49
50
  "LICENSE"
50
51
  ],