aiterm-mcp 0.37.9 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.38.0] - 2026-09-22
11
+
12
+ ### 追加
13
+
14
+ - Cursor親(`cursor-vscode`)へ子の回答を自動配送する。`aiterm-setup`が`~/.cursor/hooks.json`へ`afterMCPExecution`と`postToolUse`を追加し、作業中は次のツール返りへ`additional_context`で本文を差し込む。ターンを終える前にreceiptの`wait_process`を背景で起動すると、idle中の完了でも受け口が本文を出して起きる。hook未登録は送信前に`CURSOR_PARENT_HOOK_UNAVAILABLE`で止める。解除は`aiterm-setup --remove-cursor-parent-hooks`。Codex/Claude Codeの配送契約は変えない。
15
+
16
+ ## [0.37.10] - 2026-09-21
17
+
18
+ ### 修正
19
+
20
+ - Codex 0.155.1 の「Approaching rate limits」model切替dialogを専用に認識する。通常の`pty_send`と`agent_configure`は同じsessionで一時的な「Keep current model」だけを選び、入力受付を再確認してから進める。model変更と今後の表示抑止は選ばない。復旧不能時はtyped errorで本文・設定変更を送らない。
21
+
10
22
  ## [0.37.9] - 2026-09-21
11
23
 
12
24
  ### 修正
@@ -1726,7 +1738,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1726
1738
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1727
1739
  provenance.
1728
1740
 
1729
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.9...HEAD
1741
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.38.0...HEAD
1742
+ [0.38.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.10...v0.38.0
1743
+ [0.37.10]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.9...v0.37.10
1730
1744
  [0.37.9]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.8...v0.37.9
1731
1745
  [0.37.8]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.7...v0.37.8
1732
1746
  [0.37.7]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.6...v0.37.7
package/README.ja.md CHANGED
@@ -202,7 +202,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
202
202
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
203
203
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
204
204
 
205
- **状態:** 開発継続中 · 現行公開版 **v0.37.9** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
205
+ **状態:** 開発継続中 · 現行公開版 **v0.38.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
206
206
 
207
207
  ### 更新と巻き戻し
208
208
 
@@ -250,6 +250,8 @@ Grok/Composerがread-only sandboxの適用を拒否した場合、prompt送信
250
250
 
251
251
  この判定はGrok専用アダプターが所有し、同じCLIを使うComposerにも適用する。初回prompt付きの`agent_launch`と通常の`pty_send`で、入力受付待ち中に拒否を検出すると未送信のエラーを返す。promptなし・`trust_project`指定なしの起動応答は入力受付を保証しない。`trust_project:true`では入力受付まで確認し、`startup.status`を返す。Grokのprivacy notice起動設定も同アダプターが所有する。実装の責務分担は[DESIGN](docs/DESIGN.md#failure-and-recovery)を参照。
252
252
 
253
+ Codex 0.155.1の「Approaching rate limits」model切替dialogは、通常の`pty_send`と`agent_configure`で同じsessionのまま一時的な**2. Keep current model**だけを選ぶ。入力受付を再確認してから本文または設定変更を進め、dispatch receiptの`pane_input_recovery`には`codex_rate_limit_model_switch_kept_current`を記録する。model切替と今後の表示抑止は選ばない。入力受付へ戻らなければ`CODEX_RATE_LIMIT_MODEL_SWITCH_RECOVERY_FAILED`となり、本文・設定変更は未送信。このdialogは`agent_approval`の対象ではなく、inspectは`reason="rate_limit_model_switch"`だけを返し、prompt digestとchoicesを出さない。
254
+
253
255
  ```text
254
256
  agent_launch({ harness: "codex-cli", session_name: "codex1", cwd: "/repo",
255
257
  prompt: "port test/legacy.py to vitest",
@@ -548,11 +550,11 @@ SSH先がPowerShellの場合、`mark:true`は現在の標準`PS ...>`プロン
548
550
 
549
551
  `pty_read({ wait: true })`は通常PTYを、process終了/`mark:true` sentinel/`until`一致/shell復帰を伴う出力静止/timeoutの5層で判定する。agent sessionは第6の正確な層を使い、Codexは通常rollout、Grokは通常session event、Claudeはlaunch相関Stop event、Cursorは通常agent transcriptの`turn_ended`を`aiterm-wait --cursor`が観測する。親はブロックもポーリングもしない。
550
552
 
551
- ### Codex/Claude Code親への回答自動配送
553
+ ### Codex/Claude Code/Cursor親への回答自動配送
552
554
 
553
555
  Codex/Claude Codeから子を起動・通常dispatchした後は、別作業へ進むか親のturnを終了するだけでよい。Aitermが完了を観測し、加工前の回答を保存して親へ届ける。waiter、`pty_read`による回答回収、子への送信指示は不要。子は全対応harnessから選べる。
554
556
 
555
- 自動配送時はreceiptに`parent_delivery`が付き、`wait_process`/`wait_command`はnullになる。`pty_observe`の`parent_deliveries`で`waiting`、`ready`、`sending`、`submitted`、`failed`、`unknown`を確認できる。`submitted`はCodexの公式受信口での受付またはClaudeのhookへの本文出力を示し、modelの読了ではない。MCP再接続後は未送信の記録を再開し、送信中に接続が切れて結果が分からない場合は本文を保持して`unknown`とする。自動再送はしない。
557
+ Codex/Claude Codeの自動配送ではreceiptに`parent_delivery`が付き、`wait_process`/`wait_command`はnullになる。`pty_observe`の`parent_deliveries`で`waiting`、`ready`、`sending`、`submitted`、`failed`、`unknown`を確認できる。`submitted`はCodexの公式受信口での受付またはClaudeのhookへの本文出力を示し、modelの読了ではない。MCP再接続後は未送信の記録を再開し、送信中に接続が切れて結果が分からない場合は本文を保持して`unknown`とする。自動再送はしない。
556
558
 
557
559
  単品導入のCodexにはMCPの`_meta.threadId`と公式`thread/queue` APIが必要で、Codex CLI 0.154.0で確認している。`aiterm-setup`はインストールされた公式queue入口を確認し、各dispatchでは実際の親threadの受入可否を確認する。Codexのnative sub-agentは外部からのqueue入力を拒否するため、自動配送の親としては未対応。
558
560
 
@@ -562,6 +564,8 @@ Claude Codeは2.1.259以上の対話sessionに対応する。`aiterm-setup`が
562
564
 
563
565
  hookを持たない旧版へ戻す時は、install前に`aiterm-setup --remove-claude-parent-hooks`を実行する。Aiterm専用hookだけを解除し、他製品のhookと設定は保持する。
564
566
 
567
+ Cursor親(`clientInfo.name`が`cursor-vscode`)も同じ完了観測と本文保存を使う。`aiterm-setup`が`~/.cursor/hooks.json`へ`afterMCPExecution`と`postToolUse`を追加し、他製品のhookと順序は保持する。hookが無い場合は子への送信前に`CURSOR_PARENT_HOOK_UNAVAILABLE`で止める。作業を続けていれば次のツール返りへ`additional_context`で本文が差し込まれ、ターンを終える前にreceiptの`wait_process`を背景で起動しておけばidle中の完了でも起きられる。`wait_command`はnull。`submitted`はhookまたは受け口が本文を受け取った状態であり、modelの読了ではない。24時間以内に受け取りが無ければ`failed`とし、本文は残して自動再送しない。解除は`aiterm-setup --remove-cursor-parent-hooks`。Cursor Cloud Agent/Background Agentはこの受信契約に含めない。
568
+
565
569
  Claudeをリンク経由の`cwd`から起動した場合も、実体パスに対応する会話記録を参照する。
566
570
 
567
571
 
package/README.md CHANGED
@@ -216,7 +216,7 @@ collection is off by default and performs no network I/O. It ships via
216
216
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
217
217
  Release re-registers the Official MCP Registry entry.
218
218
 
219
- **Status:** actively maintained · current public release **v0.37.9** · 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).
219
+ **Status:** actively maintained · current public release **v0.38.0** · runs on Linux · WSL2 · macOS · native Windows (tmux on POSIX, the tmux-CLI-compatible [psmux](https://github.com/psmux/psmux) on native Windows — no WSL required) · MIT · see the [CHANGELOG](CHANGELOG.md).
220
220
 
221
221
  ### Update and rollback
222
222
 
@@ -270,6 +270,8 @@ Grok/Composerがread-only sandboxの適用を拒否した場合、prompt送信
270
270
 
271
271
  この判定はGrok専用アダプターが所有し、同じCLIを使うComposerにも適用する。初回prompt付きの`agent_launch`と通常の`pty_send`で、入力受付待ち中に拒否を検出すると未送信のエラーを返す。promptなし・`trust_project`指定なしの起動応答は入力受付を保証しない。`trust_project:true`では入力受付まで確認し、`startup.status`を返す。Grokのprivacy notice起動設定も同アダプターが所有する。実装の責務分担は[DESIGN](docs/DESIGN.md#failure-and-recovery)を参照。
272
272
 
273
+ Codex 0.155.1の「Approaching rate limits」model切替dialogは、通常の`pty_send`と`agent_configure`で同じsessionのまま一時的な**2. Keep current model**だけを選ぶ。入力受付を再確認してから本文または設定変更を進め、dispatch receiptの`pane_input_recovery`には`codex_rate_limit_model_switch_kept_current`を記録する。model切替と今後の表示抑止は選ばない。入力受付へ戻らなければ`CODEX_RATE_LIMIT_MODEL_SWITCH_RECOVERY_FAILED`となり、本文・設定変更は未送信。このdialogは`agent_approval`の対象ではなく、inspectは`reason="rate_limit_model_switch"`だけを返し、prompt digestとchoicesを出さない。
274
+
273
275
  For a correlated Claude turn stopped at `Do you want to proceed?`, use `claude_approval(action: "inspect", ...)` to capture the active operation and SHA-256 screen digest, review the displayed command, then call `respond` with that exact digest and either `approve_once` or `deny`. The relay rechecks the operation and screen under the send lock, never exposes arbitrary input or permanent approval, keeps the active marker intact, and records a prompt-free owner-only receipt. `pty_send(force: true)` does not bypass this boundary.
274
276
 
275
277
  ```text
@@ -585,9 +587,9 @@ For PowerShell over SSH, `mark:true` recognizes the current standard `PS ...>` p
585
587
 
586
588
  ### Completion push for parent agents (`aiterm-wait`)
587
589
 
588
- **Codex/Claude Code親には子の回答本文が自動で届く。** 子を起動・dispatchした後は、別作業へ進むか親のturnを終える。Aitermが完了を観測し、加工前の本文を保存して親へ渡す。waiter、`pty_read`による回答回収、子への返送指示は不要。子は全対応harnessから選べる。
590
+ **Codex/Claude Code/Cursor親には子の回答本文が自動で届く。** Codex/Claude Codeでは、子を起動・dispatchした後は別作業へ進むか親のturnを終える。Aitermが完了を観測し、加工前の本文を保存して親へ渡す。waiter、`pty_read`による回答回収、子への返送指示は不要。子は全対応harnessから選べる。
589
591
 
590
- 自動配送のreceiptには`parent_delivery`が付き、`wait_process`/`wait_command`はnullになる。`pty_observe`の`parent_deliveries`で`waiting`、`ready`、`sending`、`submitted`、`failed`、`unknown`を確認できる。`submitted`はCodexの公式受信口での受付またはClaudeのhookへの本文出力を示し、modelの読了ではない。MCP再接続後は未送信の記録を再開し、出力中断で結果が分からない場合は本文を保持して`unknown`とする。自動再送はしない。
592
+ Codex/Claude Codeの自動配送ではreceiptに`parent_delivery`が付き、`wait_process`/`wait_command`はnullになる。`pty_observe`の`parent_deliveries`で`waiting`、`ready`、`sending`、`submitted`、`failed`、`unknown`を確認できる。`submitted`はCodexの公式受信口での受付またはClaudeのhookへの本文出力を示し、modelの読了ではない。MCP再接続後は未送信の記録を再開し、出力中断で結果が分からない場合は本文を保持して`unknown`とする。自動再送はしない。
591
593
 
592
594
  For queue delivery, use a Codex runtime that supplies MCP `_meta.threadId` and the official `thread/queue` API (verified with Codex CLI 0.154.0). `aiterm-setup` checks the installed queue entry point; Aiterm verifies the requesting thread before each dispatch. Codex native sub-agents reject external queue input and cannot be automatic-delivery parents. Steer相当の選択時も公式キューへ投入し、専用hookが同一ターンへ取り込みます。
593
595
 
@@ -597,6 +599,8 @@ Claude Codeは2.1.259以上の対話sessionに対応する。`aiterm-setup`が
597
599
 
598
600
  hookを持たない旧版へ戻す時は、install前に`aiterm-setup --remove-claude-parent-hooks`を実行する。Aiterm専用hookだけを解除し、他製品のhookと設定は保持する。
599
601
 
602
+ Cursor parents (`clientInfo.name` of `cursor-vscode`) use the same completion capture. `aiterm-setup` adds `afterMCPExecution` and `postToolUse` to `~/.cursor/hooks.json` and keeps every other hook and its position. A missing registration fails the dispatch with `CURSOR_PARENT_HOOK_UNAVAILABLE` before the child is sent. While the parent keeps calling tools, the answer is injected through `additional_context` on the next tool result. If the parent ends the turn, start the receipt `wait_process` in the background first; that receiver exits when the answer arrives. `wait_command` is null. `submitted` means the hook or the receiver claimed the text, not that the model has read it. No claim within 24 hours is `failed`, the text is kept, and nothing is resent. Remove only Aiterm's entries with `aiterm-setup --remove-cursor-parent-hooks`. Cursor Cloud Agents and Background Agents are outside this contract.
603
+
600
604
  Claudeをリンク経由の`cwd`から起動した場合も、実体パスに対応する会話記録を参照する。
601
605
 
602
606
  **For other parent hosts**, dispatch and start the receipt's waiter in a separate process:
@@ -113,6 +113,46 @@ export function ensureStateRoot() {
113
113
  export function agentsDir() {
114
114
  return path.join(ensureStateRoot(), "agents");
115
115
  }
116
+ /** directory内の状態変化を待つ。inspectが値を返した時点でresolveする。timeoutMsを省くと期限なし。 */
117
+ export function waitForFileState(dir, inspect, timeoutMs) {
118
+ return new Promise((resolve, reject) => {
119
+ let finished = false;
120
+ let timer;
121
+ let timeout;
122
+ const watcher = fs.watch(dir, () => check());
123
+ const finish = (error, value) => {
124
+ if (finished)
125
+ return;
126
+ finished = true;
127
+ watcher.close();
128
+ if (timer)
129
+ clearInterval(timer);
130
+ if (timeout)
131
+ clearTimeout(timeout);
132
+ if (error)
133
+ reject(error);
134
+ else
135
+ resolve(value);
136
+ };
137
+ const check = () => {
138
+ if (finished)
139
+ return;
140
+ try {
141
+ const value = inspect();
142
+ if (value !== undefined)
143
+ finish(null, value);
144
+ }
145
+ catch (error) {
146
+ finish(error);
147
+ }
148
+ };
149
+ watcher.on("error", error => finish(error));
150
+ timer = setInterval(check, 5000);
151
+ if (timeoutMs !== undefined)
152
+ timeout = setTimeout(() => finish(new Error("WAIT_FOR_FILE_TIMEOUT")), timeoutMs);
153
+ check();
154
+ });
155
+ }
116
156
  export function agentEventPath(name, launchId) {
117
157
  assertSessionName(name);
118
158
  if (!LAUNCH_ID_RE.test(launchId))
@@ -2,7 +2,7 @@
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { z } from "zod";
5
- import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
5
+ import { ensureStateRoot, waitForFileState, writeJson0600 } from "./agent-shared.js";
6
6
  import { readRuntimeProcesses } from "./process-runtime.js";
7
7
  import { AitermError } from "./errors.js";
8
8
  const requestId = z.string().regex(/^[A-Za-z0-9_-]{1,160}$/);
@@ -99,41 +99,6 @@ export function closeClaudeParentSession(input, root = defaultRoot()) {
99
99
  writeJson0600(path.join(root, entry.name, "closed.json"), { session_id });
100
100
  }
101
101
  }
102
- // filesystemの通知を先に登録してから状態を読む。producerの終了は低頻度のprocess照合でも検出する。
103
- function waitForFileState(dir, inspect) {
104
- return new Promise((resolve, reject) => {
105
- let finished = false;
106
- let timer;
107
- const watcher = fs.watch(dir, () => check());
108
- const finish = (error, value) => {
109
- if (finished)
110
- return;
111
- finished = true;
112
- watcher.close();
113
- if (timer)
114
- clearInterval(timer);
115
- if (error)
116
- reject(error);
117
- else
118
- resolve(value);
119
- };
120
- const check = () => {
121
- if (finished)
122
- return;
123
- try {
124
- const value = inspect();
125
- if (value !== undefined)
126
- finish(null, value);
127
- }
128
- catch (error) {
129
- finish(error);
130
- }
131
- };
132
- watcher.on("error", error => finish(error));
133
- timer = setInterval(check, 5000);
134
- check();
135
- });
136
- }
137
102
  function assertParentAlive(invocation) {
138
103
  if (processIdentity(invocation.parent_pid) !== invocation.parent_started_identity) {
139
104
  throw new ClaudeDeliveryError("CLAUDE_PARENT_PROCESS_CLOSED", "依頼元のClaude processは終了しました。回答は保存したままです");
package/dist/core.js CHANGED
@@ -19,7 +19,7 @@ import { AitermError, telemetryOwnedFailure, ownTelemetryFailure } from "./error
19
19
  import { isWin, SOCKDIR, tmuxCommand, sendPsmuxPayload, loadPtyBufferChunk, pasteBufferBaseArgs, TMUX_EMPTY_CONFIG, attachCommand, normalizePaneCommand, atomicShellMultiline, appendMarkSentinel, markShellCommand, settlePaneLog, paneCwdArgument, sessionEnvironmentLaunch, } from "./tmux-runtime.js";
20
20
  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";
21
21
  import { GROK_MODEL_DEFAULTS, realGrokHome, resolveAndValidateGrokAuth, assertGrokModelAvailable, grokEventsTranscript, latestGrokCompletion, observeGrokDone, buildGrokAgentCmd, grokLaunchNote, grokEnvTokens, grokTuiReady, grokTuiBusy, grokPaneObservation, grokRateLimitDialog, grokStartupAction, grokLaunchBlockingDialog, assertGrokSandboxNotRejected, GROK_COMPOSER_MARKER_RE, grokFooterHasConfiguration, grokTranscriptText, createGrokAgentMetadata, } from "./harnesses/grok.js";
22
- import { bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, } from "./harnesses/codex.js";
22
+ import { bindCodexTranscriptSession, latestCodexCompletion, observeCodexDone, buildCodexAgentCmd, codexLaunchNote, codexTuiReady, codexPaneObservation, codexRateLimitModelSwitchDialog, codexApprovalDialog, codexStartupAction, CODEX_COMPOSER_MARKER_RE, codexModelChoice, codexEffortChoice, codexMoreReasoningChoice, codexTranscriptText, createCodexAgentMetadata, } from "./harnesses/codex.js";
23
23
  import { OPERATION_ID_RE, CLAUDE_RESULT_MAX_BYTES, CLAUDE_EFFORTS, agentManagedClaudeSettingsPath, agentClaudeResultPath, agentClaudeOperationPath, agentClaudeApprovalReceiptPath, agentClaudeDispatchReceiptPath, validateOperationId, readClaudeResultText, assertClaudeAuthenticationReady, buildClaudeAgentCmd, claudeLaunchNote, claudeTuiReady, claudePaneObservation, claudeStartupAction, CLAUDE_COMPOSER_MARKER_RE, createClaudeAgentMetadata, claudeSessionTranscriptPath, claudeApiErrorFromLine, } from "./harnesses/claude.js";
24
24
  import { bindCursorTranscriptSession, cursorTurnBoundary, latestCursorCompletion, observeCursorDone, cursorTranscriptText, assertCursorAuthenticationReady, assertCursorModelAvailable, buildCursorAgentCmd, cursorPromptWithLineage, createCursorAgentMetadata, cursorLaunchNote, cursorEffortNavigation, cursorTuiReady, cursorPaneObservation, CURSOR_SUBMIT_SEQUENCE, CURSOR_COMPOSER_CONTENT_MARKER_RE, validateCursorModelEffort, } from "./harnesses/cursor.js";
25
25
  import { resolveAgentBin, resolveThroughlineBin, runThroughlineHandoffContext, isWindowsNativeExecutable, agentBinForPaneShell, resolveWinPaneShell } from "./agent-resolver.js";
@@ -1414,7 +1414,7 @@ export function runAgentApproval(options) {
1414
1414
  const dialog = codexApprovalDialog(screen);
1415
1415
  if (!dialog) {
1416
1416
  result.status = state.state === "blocked" || state.state === "unknown" || action === "respond" ? "blocked" : "none";
1417
- result.reason = state.state === "blocked" ? "unknown_dialog" : state.state === "unknown" ? "unrecognized_screen" : "no_current_dialog";
1417
+ result.reason = state.state === "blocked" ? state.reason : state.state === "unknown" ? "unrecognized_screen" : "no_current_dialog";
1418
1418
  return result;
1419
1419
  }
1420
1420
  result.kind = dialog.kind;
@@ -2556,7 +2556,13 @@ export function agentWaitProcess(session, cursor, runtime = {}) {
2556
2556
  // 親ホストの識別(MCP initialize の clientInfo.name)。配送の可否はMCP入口が検証し、ここは案内だけを作る。
2557
2557
  let parentClientName = null;
2558
2558
  function autoDeliveryParent() {
2559
- return parentClientName === "codex-mcp-client" ? "Codex" : parentClientName === "claude-code" ? "Claude Code" : null;
2559
+ if (parentClientName === "codex-mcp-client")
2560
+ return "Codex";
2561
+ if (parentClientName === "claude-code")
2562
+ return "Claude Code";
2563
+ if (parentClientName === "cursor-vscode" || parentClientName?.startsWith("cursor-vscode "))
2564
+ return "Cursor";
2565
+ return null;
2560
2566
  }
2561
2567
  export function setParentClient(name) {
2562
2568
  const trimmed = typeof name === "string" ? name.trim() : "";
@@ -2573,6 +2579,11 @@ export function agentWaitLaunchForm(command) {
2573
2579
  // dispatch / 起動時 prompt 送信後の共通案内。第一文で「待たない」を宣言し、待ち方は後段に置く。
2574
2580
  export function agentDispatchGuide(session, cursor) {
2575
2581
  const parent = autoDeliveryParent();
2582
+ if (parent === "Cursor") {
2583
+ return "回答はこの会話へ自動で届く。作業を続ければ次のツール返りに差し込まれる。" +
2584
+ "ターンを終える前にreceiptのwait_processを背景(block_until_ms: 0)で起動しておけば、idle中に完了しても起きられる。" +
2585
+ "ポーリング・pty_read(agent_transcript:true)は不要。";
2586
+ }
2576
2587
  if (parent) {
2577
2588
  return `回答本文はAitermがこの${parent}親へ自動配送する。wait起動・ポーリング・通常の回答回収は不要。` +
2578
2589
  "親は作業を続けるかターンを終え、順番待ちから届く子の回答で続行する。";
@@ -2585,6 +2596,8 @@ export function agentDispatchGuide(session, cursor) {
2585
2596
  // 未完了 session へ触った時の共通案内。ここでも待つのは waiter プロセスであって親ではない。
2586
2597
  export function agentWaitGuide(session) {
2587
2598
  const parent = autoDeliveryParent();
2599
+ if (parent === "Cursor")
2600
+ return "回答はこの会話へ届く。完了通知はreceiptのwait_processを背景で起動して受ける。ポーリングは不要。";
2588
2601
  if (parent)
2589
2602
  return `回答本文はこの${parent}親へ自動配送される。親は作業を続けるかターンを終える。`;
2590
2603
  const cmd = `aiterm-wait --session ${session ?? "<session_id>"} --cursor 0`;
@@ -3242,6 +3255,31 @@ function sendMenuChoice(name, choice) {
3242
3255
  throw new AitermError(`agent設定の選択を送れませんでした: ${sent.stderr.trim() || `code=${sent.code}`}`, 2);
3243
3256
  }
3244
3257
  }
3258
+ /** Codexの上限接近modalは承認APIへ出さず、一時keepの2だけを選んで共通ready gateへ戻す。 */
3259
+ async function recoverCodexRateLimitModelSwitch(name, meta) {
3260
+ const dialog = meta.kind === "codex"
3261
+ ? codexRateLimitModelSwitchDialog(captureScreen(name, AGENT_TUI_READY_LINES))
3262
+ : null;
3263
+ if (!dialog)
3264
+ return false;
3265
+ const live = observeSession(name);
3266
+ if (live.harness_alive !== true) {
3267
+ throw new AitermError(`CODEX_RATE_LIMIT_MODEL_SWITCH_RECOVERY_BLOCKED: ${live.reason}。今回の文字列は送信していません。`, 2);
3268
+ }
3269
+ sendMenuChoice(name, String(dialog.keepCurrentIndex));
3270
+ return true;
3271
+ }
3272
+ /** ready待機中に現れたCodex利用上限modalも、同じ一時keep復旧へ戻す。 */
3273
+ async function waitAgentTuiReadyAfterCodexRateLimitRecovery(name, meta, timeoutMs) {
3274
+ let codexRateLimitModelSwitch = await recoverCodexRateLimitModelSwitch(name, meta);
3275
+ let ready = await waitAgentTuiReady(name, meta, timeoutMs);
3276
+ if (!ready.ready && !codexRateLimitModelSwitch) {
3277
+ codexRateLimitModelSwitch = await recoverCodexRateLimitModelSwitch(name, meta);
3278
+ if (codexRateLimitModelSwitch)
3279
+ ready = await waitAgentTuiReady(name, meta, timeoutMs);
3280
+ }
3281
+ return { ready, codexRateLimitModelSwitch };
3282
+ }
3245
3283
  /** 同じ対話sessionを保ったまま、harness標準の操作でmodel/effortを変更する。 */
3246
3284
  export async function configureAgent(name, opts) {
3247
3285
  assertSessionName(name);
@@ -3254,9 +3292,13 @@ export async function configureAgent(name, opts) {
3254
3292
  }
3255
3293
  const meta = loadAgentMetadata(name);
3256
3294
  bindCompletedInitialPrompt(meta);
3257
- const ready = await waitAgentTuiReady(name, meta, AGENT_TUI_READY_TIMEOUT_MS);
3258
- if (!ready.ready)
3295
+ const { ready, codexRateLimitModelSwitch } = await waitAgentTuiReadyAfterCodexRateLimitRecovery(name, meta, AGENT_TUI_READY_TIMEOUT_MS);
3296
+ if (!ready.ready) {
3297
+ if (codexRateLimitModelSwitch) {
3298
+ throw new AitermError(`CODEX_RATE_LIMIT_MODEL_SWITCH_RECOVERY_FAILED: ${codexPaneObservation(ready.lastScreen).reason}。設定変更は送信していません。`, 2);
3299
+ }
3259
3300
  throw new AitermError(`agent session '${name}' は入力待ちではありません`, 2);
3301
+ }
3260
3302
  if (meta.kind === "claude") {
3261
3303
  if (model) {
3262
3304
  await sendAgentPromptText(name, `/model ${model}`);
@@ -3441,6 +3483,7 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3441
3483
  && agentCompletionCursor(meta) === 0
3442
3484
  && readClaudeOperationMarker(meta) === null;
3443
3485
  const paneInputRecovery = await ensureAgentOwnsPaneInput(name, meta.kind);
3486
+ let codexRateLimitModelSwitch = false;
3444
3487
  const limitDialog = meta.kind === "grok" || meta.kind === "composer"
3445
3488
  ? grokRateLimitDialog(captureScreen(name, 0)) : null;
3446
3489
  if (limitDialog) {
@@ -3453,8 +3496,13 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3453
3496
  sendKey(name, limitDialog.dismissKey);
3454
3497
  }
3455
3498
  if (meta.kind !== "claude" || claudeColdStart) {
3456
- const ready = await waitAgentTuiReady(name, meta, o.ready_timeout ?? AGENT_TUI_READY_TIMEOUT_MS);
3499
+ const recovery = await waitAgentTuiReadyAfterCodexRateLimitRecovery(name, meta, o.ready_timeout ?? AGENT_TUI_READY_TIMEOUT_MS);
3500
+ codexRateLimitModelSwitch = recovery.codexRateLimitModelSwitch;
3501
+ const { ready } = recovery;
3457
3502
  if (!ready.ready) {
3503
+ if (codexRateLimitModelSwitch) {
3504
+ throw new AitermError(`CODEX_RATE_LIMIT_MODEL_SWITCH_RECOVERY_FAILED: ${codexPaneObservation(ready.lastScreen).reason}。今回の文字列は送信していません。`, 2);
3505
+ }
3458
3506
  if (limitDialog) {
3459
3507
  const reason = grokPaneObservation(ready.lastScreen).reason;
3460
3508
  throw new AitermError(`GROK_RATE_LIMIT_RECOVERY_FAILED: ${reason}。上限パネル解除後の入力受付を確認できません。今回の文字列は送信していません。`, 2);
@@ -3463,6 +3511,8 @@ export async function dispatchAgentTurn(name, text, o = {}) {
3463
3511
  "少し後で pty_read(screen:true) を確認し、TUI が起動済みなら再度 pty_send してください。", 2);
3464
3512
  }
3465
3513
  }
3514
+ if (codexRateLimitModelSwitch)
3515
+ paneInputRecovery.push("codex_rate_limit_model_switch_kept_current");
3466
3516
  if (limitDialog)
3467
3517
  paneInputRecovery.push("grok_rate_limit_dialog_dismissed");
3468
3518
  const startOffset = agentCompletionCursor(meta);
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env node
2
+ // Cursorが起動する公式hook。MCP stdioとは別processで、差し込み文をstdoutのJSONへ返す。
3
+ import { fileURLToPath } from "node:url";
4
+ import * as fs from "node:fs";
5
+ import { handleCursorHook } from "./cursor-parent-receiver.js";
6
+ async function main() {
7
+ let input = "";
8
+ process.stdin.setEncoding("utf8");
9
+ for await (const chunk of process.stdin)
10
+ input += chunk;
11
+ const result = await handleCursorHook(input.length > 0 ? input : "{}");
12
+ process.stdout.write(`${JSON.stringify(result)}\n`);
13
+ }
14
+ function isDirectExecution() {
15
+ const entry = process.argv[1];
16
+ if (!entry)
17
+ return false;
18
+ try {
19
+ const self = fileURLToPath(import.meta.url);
20
+ const a = fs.realpathSync(entry);
21
+ const b = fs.realpathSync(self);
22
+ if (a === b)
23
+ return true;
24
+ return process.platform === "win32" && a.toLowerCase() === b.toLowerCase();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ if (isDirectExecution()) {
31
+ main().catch(error => {
32
+ process.stderr.write(`${error instanceof Error ? error.message : "CURSOR_PARENT_HOOK_FAILED"}\n`);
33
+ process.exitCode = 2;
34
+ });
35
+ }
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // Cursor親がidleのとき、背景で起動して回答本文の到着を待つ受け口。
3
+ import { fileURLToPath } from "node:url";
4
+ import * as fs from "node:fs";
5
+ import { cursorHookRoot, receiveCursorAnswer } from "./cursor-parent-receiver.js";
6
+ import { windowsStartProcessArgumentList } from "./core.js";
7
+ export function cursorReceiveProcess(deliveryId, executable = process.execPath) {
8
+ const args = [fileURLToPath(import.meta.url), "--delivery", deliveryId];
9
+ return {
10
+ executable,
11
+ args,
12
+ windows_start_process_argument_list: process.platform === "win32" ? windowsStartProcessArgumentList(args) : null,
13
+ };
14
+ }
15
+ function emit(value) {
16
+ process.stdout.write(JSON.stringify(value) + "\n");
17
+ }
18
+ export async function main(argv) {
19
+ if (argv.length !== 2 || argv[0] !== "--delivery" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(argv[1])) {
20
+ emit({ ok: false, code: "CURSOR_PARENT_RECEIVE_USAGE", message: "usage: cursor-parent-receive --delivery <uuid>" });
21
+ return 1;
22
+ }
23
+ const result = await receiveCursorAnswer(cursorHookRoot(), argv[1]);
24
+ if (result.outcome === "timeout") {
25
+ emit({ delivery_id: argv[1], outcome: "timeout" });
26
+ return 3;
27
+ }
28
+ if (result.outcome === "delivered_by_hook") {
29
+ emit({ delivery_id: argv[1], outcome: "delivered_by_hook" });
30
+ return 0;
31
+ }
32
+ emit({ delivery_id: argv[1], outcome: "delivered", text: result.text });
33
+ return 0;
34
+ }
35
+ function isDirectExecution() {
36
+ const entry = process.argv[1];
37
+ if (!entry)
38
+ return false;
39
+ try {
40
+ const self = fileURLToPath(import.meta.url);
41
+ const a = fs.realpathSync(entry);
42
+ const b = fs.realpathSync(self);
43
+ if (a === b)
44
+ return true;
45
+ return process.platform === "win32" && a.toLowerCase() === b.toLowerCase();
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
51
+ if (isDirectExecution()) {
52
+ main(process.argv.slice(2)).then(code => { process.exitCode = code; }).catch(error => {
53
+ emit({ ok: false, code: "CURSOR_PARENT_RECEIVE_FAILED", message: error instanceof Error ? error.message : "cursor-parent-receive: operation failed" });
54
+ process.exitCode = 1;
55
+ });
56
+ }
@@ -0,0 +1,247 @@
1
+ // Cursor親の受信口。会話への差し込みはCursor公式hookのadditional_context、idle時の起床は受け口processが担う。
2
+ import * as fs from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import * as path from "node:path";
5
+ import { z } from "zod";
6
+ import { ensureStateRoot, waitForFileState, writeJson0600 } from "./agent-shared.js";
7
+ import { AitermError } from "./errors.js";
8
+ export const cursorParentSchema = z.object({
9
+ kind: z.literal("cursor"),
10
+ hook_root: z.string(),
11
+ }).strict();
12
+ const deliveryIdSchema = z.string().uuid();
13
+ const dispatchTools = new Set([
14
+ "agent_launch", "claude_agent", "codex_agent", "grok_agent", "composer_agent", "pty_send", "claude_turn",
15
+ ]);
16
+ export class CursorDeliveryError extends AitermError {
17
+ delivery_code;
18
+ outcome_unknown;
19
+ constructor(delivery_code, message, outcome_unknown = false) {
20
+ super(`${delivery_code}: ${message}`, 2);
21
+ this.delivery_code = delivery_code;
22
+ this.outcome_unknown = outcome_unknown;
23
+ }
24
+ }
25
+ export function isCursorMcpClient(clientName) {
26
+ if (clientName === undefined)
27
+ return false;
28
+ return clientName === "cursor-vscode" || clientName.startsWith("cursor-vscode ");
29
+ }
30
+ export function cursorHookRoot(state = ensureStateRoot()) {
31
+ return path.join(state, "cursor-parent-hooks");
32
+ }
33
+ export function cursorHooksFile(home = process.env.HOME ?? homedir()) {
34
+ return path.join(process.env.CURSOR_HOME ?? path.join(home, ".cursor"), "hooks.json");
35
+ }
36
+ export function cursorParentHooksRegistered(document) {
37
+ if (document === null || typeof document !== "object" || Array.isArray(document))
38
+ return false;
39
+ const hooks = document.hooks;
40
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks))
41
+ return false;
42
+ const owns = (event) => {
43
+ const list = hooks[event];
44
+ return Array.isArray(list) && list.some(entry => {
45
+ if (entry === null || typeof entry !== "object")
46
+ return false;
47
+ const command = entry.command;
48
+ return typeof command === "string" && command.includes("cursor-parent-hook.js");
49
+ });
50
+ };
51
+ return owns("postToolUse") && owns("afterMCPExecution");
52
+ }
53
+ export function verifyCursorParent(parent, hooksFile = cursorHooksFile()) {
54
+ cursorParentSchema.parse(parent);
55
+ let document;
56
+ try {
57
+ document = JSON.parse(fs.readFileSync(hooksFile, "utf8"));
58
+ }
59
+ catch {
60
+ throw new CursorDeliveryError("CURSOR_PARENT_HOOK_UNAVAILABLE", "Cursorのhookが登録されていません。aiterm-setupを実行してください");
61
+ }
62
+ if (!cursorParentHooksRegistered(document)) {
63
+ throw new CursorDeliveryError("CURSOR_PARENT_HOOK_UNAVAILABLE", "Cursorのhookが登録されていません。aiterm-setupを実行してください");
64
+ }
65
+ }
66
+ export function cursorParentFromRequest(clientName, options = {}) {
67
+ if (!isCursorMcpClient(clientName))
68
+ return null;
69
+ const parent = { kind: "cursor", hook_root: options.hookRoot ?? cursorHookRoot() };
70
+ verifyCursorParent(parent, options.hooksFile);
71
+ return parent;
72
+ }
73
+ function deliveryDir(parent, deliveryId) {
74
+ return path.join(parent.hook_root, "deliveries", deliveryIdSchema.parse(deliveryId));
75
+ }
76
+ export function prepareCursorDelivery(parent, deliveryId) {
77
+ cursorParentSchema.parse(parent);
78
+ fs.mkdirSync(deliveryDir(parent, deliveryId), { recursive: true, mode: 0o700 });
79
+ }
80
+ export async function submitCursorParentAnswer(parent, deliveryId, text, timeoutMs = 86_400_000) {
81
+ const dir = deliveryDir(parent, deliveryId);
82
+ writeJson0600(path.join(dir, "answer.json"), { delivery_id: deliveryId, text });
83
+ try {
84
+ await waitForFileState(dir, () => fs.existsSync(path.join(dir, "claim.json")) ? true : undefined, timeoutMs);
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error && error.message === "WAIT_FOR_FILE_TIMEOUT") {
88
+ throw new CursorDeliveryError("CURSOR_PARENT_DELIVERY_UNCLAIMED", "Cursor親が時間内に回答を受け取りませんでした。回答は保存したままです");
89
+ }
90
+ throw error;
91
+ }
92
+ return { queued_submission_id: null };
93
+ }
94
+ function conversationId(value) {
95
+ if (typeof value !== "string" || value.length === 0 || value.length > 200)
96
+ return null;
97
+ if (value.includes("/") || value.includes("\\") || value.includes(".."))
98
+ return null;
99
+ return value;
100
+ }
101
+ function parseJson(value) {
102
+ try {
103
+ return JSON.parse(value);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
108
+ }
109
+ function deliveryIdFromResult(value, depth = 0) {
110
+ if (depth > 3 || value === null || value === undefined)
111
+ return null;
112
+ const parsed = typeof value === "string" ? parseJson(value) : value;
113
+ if (parsed === null || typeof parsed !== "object")
114
+ return null;
115
+ const record = parsed;
116
+ const structured = record.structuredContent;
117
+ const carrier = structured !== null && typeof structured === "object" ? structured : record;
118
+ const parentDelivery = carrier.parent_delivery;
119
+ if (parentDelivery !== null && typeof parentDelivery === "object") {
120
+ const id = parentDelivery.delivery_id;
121
+ if (typeof id === "string" && deliveryIdSchema.safeParse(id).success)
122
+ return id;
123
+ }
124
+ if (depth === 0 && Array.isArray(record.content)) {
125
+ for (const entry of record.content) {
126
+ if (entry !== null && typeof entry === "object" && entry.type === "text") {
127
+ const text = entry.text;
128
+ if (typeof text === "string") {
129
+ const id = deliveryIdFromResult(text, depth + 1);
130
+ if (id)
131
+ return id;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ function toolBaseName(name) {
139
+ const tail = name.split(/[:.]/).pop() ?? name;
140
+ return tail.replace(/^mcp__aiterm__/, "");
141
+ }
142
+ function claim(dir, channel) {
143
+ try {
144
+ fs.writeFileSync(path.join(dir, "claim.json"), JSON.stringify({ channel, at: new Date().toISOString() }), { flag: "wx", mode: 0o600 });
145
+ return true;
146
+ }
147
+ catch (error) {
148
+ if (error.code === "EEXIST")
149
+ return false;
150
+ throw error;
151
+ }
152
+ }
153
+ function bind(hookRoot, deliveryId, conv) {
154
+ const dir = path.join(hookRoot, "deliveries", deliveryId);
155
+ if (!fs.existsSync(dir))
156
+ return;
157
+ const bindFile = path.join(dir, "bind.json");
158
+ if (!fs.existsSync(bindFile)) {
159
+ writeJson0600(bindFile, { conversation_id: conv, created_at: new Date().toISOString() });
160
+ }
161
+ const indexDir = path.join(hookRoot, "conversations", conv);
162
+ fs.mkdirSync(indexDir, { recursive: true, mode: 0o700 });
163
+ const index = path.join(indexDir, deliveryId);
164
+ if (!fs.existsSync(index))
165
+ fs.writeFileSync(index, "", { mode: 0o600 });
166
+ }
167
+ function inject(hookRoot, conv) {
168
+ const indexDir = path.join(hookRoot, "conversations", conv);
169
+ if (!fs.existsSync(indexDir))
170
+ return [];
171
+ const pending = fs.readdirSync(indexDir).flatMap(deliveryId => {
172
+ const dir = path.join(hookRoot, "deliveries", deliveryId);
173
+ const answerFile = path.join(dir, "answer.json");
174
+ const bindFile = path.join(dir, "bind.json");
175
+ if (!fs.existsSync(answerFile) || !fs.existsSync(bindFile))
176
+ return [];
177
+ const answer = z.object({ delivery_id: z.string(), text: z.string() }).parse(JSON.parse(fs.readFileSync(answerFile, "utf8")));
178
+ const bound = z.object({ conversation_id: z.string(), created_at: z.string() }).parse(JSON.parse(fs.readFileSync(bindFile, "utf8")));
179
+ if (answer.delivery_id !== deliveryId || bound.conversation_id !== conv)
180
+ return [];
181
+ return [{ deliveryId, dir, created_at: bound.created_at, text: answer.text }];
182
+ }).sort((a, b) => a.created_at.localeCompare(b.created_at));
183
+ const texts = [];
184
+ for (const item of pending) {
185
+ const index = path.join(indexDir, item.deliveryId);
186
+ if (!claim(item.dir, "hook")) {
187
+ if (fs.existsSync(path.join(item.dir, "claim.json")))
188
+ fs.rmSync(index, { force: true });
189
+ continue;
190
+ }
191
+ texts.push(item.text);
192
+ fs.rmSync(index, { force: true });
193
+ }
194
+ return texts;
195
+ }
196
+ export async function handleCursorHook(raw, hookRoot = cursorHookRoot()) {
197
+ let parsed;
198
+ try {
199
+ parsed = JSON.parse(raw);
200
+ }
201
+ catch {
202
+ return {};
203
+ }
204
+ if (parsed === null || typeof parsed !== "object")
205
+ return {};
206
+ const event = parsed;
207
+ const name = event.hook_event_name;
208
+ if (name !== "afterMCPExecution" && name !== "postToolUse" && name !== "postToolUseFailure")
209
+ return {};
210
+ const conv = conversationId(event.conversation_id);
211
+ if (!conv)
212
+ return {};
213
+ const toolName = typeof event.tool_name === "string" ? event.tool_name : "";
214
+ if (dispatchTools.has(toolBaseName(toolName))) {
215
+ const payload = name === "afterMCPExecution" ? event.result_json : event.tool_output;
216
+ const deliveryId = deliveryIdFromResult(payload);
217
+ if (deliveryId)
218
+ bind(hookRoot, deliveryId, conv);
219
+ }
220
+ if (name === "postToolUse" || name === "postToolUseFailure") {
221
+ const texts = inject(hookRoot, conv);
222
+ if (texts.length > 0)
223
+ return { additional_context: texts.join("\n\n") };
224
+ }
225
+ return {};
226
+ }
227
+ export async function receiveCursorAnswer(hookRoot, deliveryId, timeoutMs = 86_400_000) {
228
+ const id = deliveryIdSchema.parse(deliveryId);
229
+ const dir = path.join(hookRoot, "deliveries", id);
230
+ if (!fs.existsSync(dir))
231
+ throw new CursorDeliveryError("CURSOR_PARENT_DELIVERY_UNKNOWN", "Cursor配送の記録がありません");
232
+ const answerFile = path.join(dir, "answer.json");
233
+ try {
234
+ await waitForFileState(dir, () => fs.existsSync(answerFile) ? true : undefined, timeoutMs);
235
+ }
236
+ catch (error) {
237
+ if (error instanceof Error && error.message === "WAIT_FOR_FILE_TIMEOUT")
238
+ return { outcome: "timeout" };
239
+ throw error;
240
+ }
241
+ const answer = z.object({ delivery_id: z.string(), text: z.string() }).parse(JSON.parse(fs.readFileSync(answerFile, "utf8")));
242
+ if (answer.delivery_id !== id)
243
+ throw new CursorDeliveryError("CURSOR_PARENT_DELIVERY_MISMATCH", "保存された回答の配送IDが一致しません");
244
+ if (!claim(dir, "receiver"))
245
+ return { outcome: "delivered_by_hook" };
246
+ return { outcome: "delivered", text: answer.text };
247
+ }
@@ -390,7 +390,7 @@ function currentCodexDialog(screen) {
390
390
  // 過去の完了footerより前の質問・選択番号を、現在のdialogへ持ち込まない。
391
391
  const previousFooter = footers.at(-2);
392
392
  const current = previousFooter ? screen.slice(previousFooter.index + previousFooter[0].length) : screen;
393
- const heading = [...current.matchAll(/Would you like to run the following command\?|Allow the [^\n]+ MCP server to run tool|Hooks need review|Do you trust the contents of this directory|Update available!/g)].at(-1);
393
+ const heading = [...current.matchAll(/Would you like to run the following command\?|Allow the [^\n]+ MCP server to run tool|Hooks need review|Do you trust the contents of this directory|Update available!|Approaching rate limits/g)].at(-1);
394
394
  return heading ? current.slice(heading.index) : current;
395
395
  }
396
396
  export function codexPaneObservation(screen) {
@@ -400,7 +400,7 @@ export function codexPaneObservation(screen) {
400
400
  const tail = screen.split("\n").slice(-24).join("\n");
401
401
  // 現在のmodal footerがある時だけ、折返しで上へ出た質問を画面全体から探す。
402
402
  const lastComposer = [...tail.matchAll(/(?:^|\n)[ \t]*[›>](?![ \t]*\d+\.)/g)].at(-1)?.index ?? -1;
403
- const lastDialog = [...tail.matchAll(/Press enter to confirm or esc to (?:cancel|go back)|enter to submit\s*\|\s*esc to cancel|Would you like to run the following command\?|Allow the [^\n]+ MCP server to run tool|Hooks need review|Do you trust the contents of this directory|Update available!/gi)].at(-1)?.index ?? -1;
403
+ const lastDialog = [...tail.matchAll(/Press enter to confirm or esc to (?:cancel|go back)|enter to submit\s*\|\s*esc to cancel|Would you like to run the following command\?|Allow the [^\n]+ MCP server to run tool|Hooks need review|Do you trust the contents of this directory|Update available!|Approaching rate limits/gi)].at(-1)?.index ?? -1;
404
404
  const modal = lastDialog > lastComposer;
405
405
  if (!modal) {
406
406
  if (/esc to interrupt/i.test(tail))
@@ -415,12 +415,35 @@ export function codexPaneObservation(screen) {
415
415
  return { state: "blocked", reason: "mcp_approval" };
416
416
  if (/Hooks need review/.test(current))
417
417
  return { state: "blocked", reason: "hooks_review" };
418
+ if (codexRateLimitModelSwitchDialog(current))
419
+ return { state: "blocked", reason: "rate_limit_model_switch" };
418
420
  if (codexLaunchBlockingDialog(current))
419
421
  return { state: "blocked", reason: "startup_dialog" };
420
422
  if (modal)
421
423
  return { state: "blocked", reason: "unknown_dialog" };
422
424
  return { state: "unknown", reason: "unrecognized_screen" };
423
425
  }
426
+ /**
427
+ * Codex 0.155.1 の利用上限接近modal。agent_approvalの対象ではないため、選択肢は公開しない。
428
+ * 1はmodel切替、3は今後の表示抑止なので、Aitermが選んでよいのは一時keepの2だけである。
429
+ */
430
+ export function codexRateLimitModelSwitchDialog(screen) {
431
+ const current = currentCodexDialog(screen);
432
+ const footer = current.lastIndexOf("Press enter to confirm or esc to go back");
433
+ const composer = [...current.matchAll(/(?:^|\n)[ \t]*[›>](?![ \t]*\d+\.)/g)].at(-1)?.index ?? -1;
434
+ // 現在のcomposerがmodal footerより後なら、scrollbackの古いmodalである。
435
+ if (composer > footer)
436
+ return null;
437
+ if (!/(?:^|\n)\s*Approaching rate limits\s*(?:\n|$)/.test(current)
438
+ || !/(?:^|\n)\s*Switch to gpt-5\.6-luna for lower credit usage\?\s*(?:\n|$)/.test(current)
439
+ || !/(?:^|\n)[ \t]*(?:›[ \t]*)?1\. Switch to gpt-5\.6-luna(?:[ \t]{2,}[^\n]*)?[ \t]*(?:\n|$)/.test(current)
440
+ || !/(?:^|\n)[ \t]*(?:›[ \t]*)?2\. Keep current model[ \t]*(?:\n|$)/.test(current)
441
+ || !/(?:^|\n)[ \t]*(?:›[ \t]*)?3\. Keep current model \(never show again\)(?:[ \t]{2,}[^\n]*)?[ \t]*(?:\n|$)/.test(current)
442
+ || !/Press enter to confirm or esc to go back/i.test(current))
443
+ return null;
444
+ const selected = current.match(/(?:^|\n)[ \t]*›[ \t]*([123])\. (?:Switch to gpt-5\.6-luna|Keep current model(?: \(never show again\))?)(?:[ \t]{2,}[^\n]*)?[ \t]*(?:\n|$)/)?.[1];
445
+ return { keepCurrentIndex: 2, selectedIndex: selected ? Number(selected) : null };
446
+ }
424
447
  export function codexStartupFailure(screen) {
425
448
  return /(?:^|\n)[ \t]*[›>]?[ \t]*Error loading config\.toml:/m.test(screen)
426
449
  ? "configuration_error" : null;
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import { createRequire } from "node:module";
18
18
  import { ParentDeliveryManager } from "./parent-delivery.js";
19
19
  import { codexParentFromRequest } from "./codex-parent-receiver.js";
20
20
  import { claudeParentFromRequest } from "./claude-parent-receiver.js";
21
+ import { cursorParentFromRequest, isCursorMcpClient } from "./cursor-parent-receiver.js";
21
22
  // package.json の version を実行時に読み、MCP initialize で配るサーバ版と一致させる。
22
23
  // createRequire を使うのは、import 属性 `with { type: "json" }` が Node 18.20+ 限定で
23
24
  // engines "node >=18"(18.0〜18.19)を SyntaxError で壊し、旧 `assert` 構文は逆に Node 22 で
@@ -25,15 +26,29 @@ import { claudeParentFromRequest } from "./claude-parent-receiver.js";
25
26
  const pkg = createRequire(import.meta.url)("../package.json");
26
27
  const server = new McpServer({ name: "aiterm", version: pkg.version });
27
28
  let parentDelivery = null;
29
+ function deliveryParentKind(clientName) {
30
+ if (clientName === "claude-code")
31
+ return "claude";
32
+ if (isCursorMcpClient(clientName))
33
+ return "cursor";
34
+ return undefined;
35
+ }
28
36
  async function deliveryForRequest(extra) {
29
37
  const clientName = server.server.getClientVersion()?.name;
30
- const parent = codexParentFromRequest(clientName, extra._meta) ?? claudeParentFromRequest(clientName, extra._meta);
38
+ const parent = codexParentFromRequest(clientName, extra._meta) ?? claudeParentFromRequest(clientName, extra._meta) ?? cursorParentFromRequest(clientName);
31
39
  if (!parent)
32
40
  return null;
33
- parentDelivery ??= new ParentDeliveryManager({ parent_kind: clientName === "claude-code" ? "claude" : undefined });
41
+ parentDelivery ??= new ParentDeliveryManager({ parent_kind: deliveryParentKind(clientName) });
34
42
  await parentDelivery.prepare(parent);
35
43
  return parentDelivery.request(parent);
36
44
  }
45
+ function completionWait(delivery, session, eventCursor) {
46
+ if (eventCursor === null)
47
+ return { wait_process: null, wait_command: null };
48
+ if (delivery)
49
+ return { wait_process: delivery.wait_process(), wait_command: null };
50
+ return { wait_process: core.agentWaitProcess(session, eventCursor), wait_command: `aiterm-wait --session ${session} --cursor ${eventCursor}` };
51
+ }
37
52
  /**
38
53
  * dispatch 系の説明で共有する非ブロック規範。tool description は registerTool 時=initialize 前に
39
54
  * 固定されるため親ホストを名指しできない(ホスト別の具体形は receipt 側が core.agentWaitLaunchForm で出す)。
@@ -41,6 +56,7 @@ async function deliveryForRequest(extra) {
41
56
  */
42
57
  const NON_BLOCKING_RULE = "dispatch した子は投げっぱなしでよい=親はここで待たない。" +
43
58
  "Codex親とClaude Code親にはAitermが回答本文を自動配送する。parent_deliveryがある場合はwait起動も通常の回答回収も不要。親は作業を続けるかターンを終える。" +
59
+ "Cursor親にはparent_deliveryとwait_processが付く。作業を続ければ次のツール返りに回答が差し込まれ、ターンを終える前にwait_processを背景で起動するとidle中の完了でも起きられる。ポーリングとpty_read(agent_transcript:true)は不要。" +
44
60
  "その他の親では、完了通知をreceiptの `wait_process.executable` と `wait_process.args` をそのまま親のターンを塞がない別プロセスAPIへ渡して受け、" +
45
61
  "PowerShell 7のStart-Processだけは `windows_start_process_argument_list` を単一文字列として渡す。" +
46
62
  `exit を完了通知として扱う(${core.AITERM_WAIT_OUTCOME_NOTE}。ポーリング不要)。` +
@@ -190,7 +206,7 @@ server.registerTool("pty_send", {
190
206
  throw new Error("agent session への dispatch は rtk:true と併用できません");
191
207
  delivery = await deliveryForRequest(extra);
192
208
  const receipt = await core.dispatchAgentTurn(session_id, core.attachImages(text, image), { raw, before_send: delivery?.before_send });
193
- const waitProcess = delivery ? null : core.agentWaitProcess(receipt.session_id, receipt.event_cursor);
209
+ const waited = completionWait(delivery, receipt.session_id, receipt.event_cursor);
194
210
  return {
195
211
  content: [
196
212
  {
@@ -205,7 +221,7 @@ server.registerTool("pty_send", {
205
221
  mode: "agent_dispatch",
206
222
  session_id: receipt.session_id,
207
223
  event_cursor: receipt.event_cursor,
208
- wait_process: waitProcess,
224
+ wait_process: waited.wait_process,
209
225
  ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}),
210
226
  launch_id: receipt.launch_id,
211
227
  vendor: receipt.vendor,
@@ -687,8 +703,7 @@ async function launchAgent(kind, args, extra) {
687
703
  session_id: sid,
688
704
  managed_completion: true,
689
705
  event_cursor: eventCursor,
690
- wait_process: eventCursor === null || delivery ? null : core.agentWaitProcess(sid, eventCursor),
691
- wait_command: eventCursor === null || delivery ? null : `aiterm-wait --session ${sid} --cursor ${eventCursor}`,
706
+ ...completionWait(delivery, sid, eventCursor),
692
707
  ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}),
693
708
  submit_residue: submitResidue,
694
709
  initial_prompt: initialDelivery,
@@ -716,8 +731,7 @@ async function launchAgent(kind, args, extra) {
716
731
  structuredContent: {
717
732
  schema: "aiterm.agent-launch-result.v1", provider: kind, harness: core.agentHarness(kind),
718
733
  session_id: e.session_id, managed_completion: true, event_cursor: e.event_cursor,
719
- wait_process: e.event_cursor === null || delivery ? null : core.agentWaitProcess(e.session_id, e.event_cursor),
720
- wait_command: e.event_cursor === null || delivery ? null : `aiterm-wait --session ${e.session_id} --cursor ${e.event_cursor}`,
734
+ ...completionWait(delivery, e.session_id, e.event_cursor),
721
735
  ...(delivery?.result() ? { parent_delivery: delivery.result() } : {}),
722
736
  submit_residue: null, initial_prompt: e.initial_prompt, startup: e.startup,
723
737
  },
@@ -863,8 +877,9 @@ async function main() {
863
877
  server.server.oninitialized = () => {
864
878
  const name = server.server.getClientVersion()?.name;
865
879
  core.setParentClient(name ?? null);
866
- if (name === "codex-mcp-client" || name === "claude-code")
867
- parentDelivery ??= new ParentDeliveryManager({ parent_kind: name === "claude-code" ? "claude" : undefined });
880
+ if (name === "codex-mcp-client" || name === "claude-code" || isCursorMcpClient(name)) {
881
+ parentDelivery ??= new ParentDeliveryManager({ parent_kind: deliveryParentKind(name) });
882
+ }
868
883
  };
869
884
  server.server.onclose = () => {
870
885
  void parentDelivery?.close().catch(() => process.stderr.write("aiterm: PARENT_DELIVERY_CLOSE_FAILED\n"));
@@ -8,6 +8,8 @@ import { ensureStateRoot, writeJson0600 } from "./agent-shared.js";
8
8
  import { readRuntimeProcesses } from "./process-runtime.js";
9
9
  import { CodexDeliveryError, submitCodexParentAnswer, verifyCodexParent } from "./codex-parent-receiver.js";
10
10
  import { claudeParentSchema, ClaudeDeliveryError, bindClaudeParentDelivery, submitClaudeParentAnswer, verifyClaudeParent } from "./claude-parent-receiver.js";
11
+ import { cursorParentSchema, CursorDeliveryError, prepareCursorDelivery, submitCursorParentAnswer, verifyCursorParent } from "./cursor-parent-receiver.js";
12
+ import { cursorReceiveProcess } from "./cursor-parent-receive.js";
11
13
  import { AitermError } from "./errors.js";
12
14
  import { codexHookDeliveryState } from "./codex-hook-state.js";
13
15
  const recordSchema = z.object({
@@ -15,7 +17,7 @@ const recordSchema = z.object({
15
17
  delivery_id: z.uuid(),
16
18
  created_at: z.string(),
17
19
  updated_at: z.string(),
18
- parent: z.union([z.object({ thread_id: z.uuid(), codex_home: z.string() }).strict(), claudeParentSchema]),
20
+ parent: z.union([z.object({ thread_id: z.uuid(), codex_home: z.string() }).strict(), claudeParentSchema, cursorParentSchema]),
19
21
  boundary: z.object({
20
22
  session_id: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/),
21
23
  launch_id: z.string().regex(/^[0-9a-f]{32}$/),
@@ -31,14 +33,22 @@ const recordSchema = z.object({
31
33
  queued_submission_id: z.string().nullable(),
32
34
  }).strict();
33
35
  const isClaude = (parent) => "kind" in parent && parent.kind === "claude";
36
+ const isCursor = (parent) => "kind" in parent && parent.kind === "cursor";
37
+ const isCodex = (parent) => !isClaude(parent) && !isCursor(parent);
34
38
  async function verifyParent(parent) {
35
39
  if (isClaude(parent))
36
40
  verifyClaudeParent(parent);
41
+ else if (isCursor(parent))
42
+ verifyCursorParent(parent);
37
43
  else
38
44
  await verifyCodexParent(parent);
39
45
  }
40
46
  async function submitParentAnswer(parent, deliveryId, text) {
41
- return isClaude(parent) ? submitClaudeParentAnswer(parent, deliveryId, text) : submitCodexParentAnswer(parent, deliveryId, text);
47
+ if (isClaude(parent))
48
+ return submitClaudeParentAnswer(parent, deliveryId, text);
49
+ if (isCursor(parent))
50
+ return submitCursorParentAnswer(parent, deliveryId, text);
51
+ return submitCodexParentAnswer(parent, deliveryId, text);
42
52
  }
43
53
  function readRecord(file) {
44
54
  try {
@@ -54,7 +64,7 @@ function readRecord(file) {
54
64
  }
55
65
  }
56
66
  function receipt(record) {
57
- const hookState = !isClaude(record.parent) && record.state === "submitted"
67
+ const hookState = isCodex(record.parent) && record.state === "submitted"
58
68
  ? codexHookDeliveryState(record.parent.codex_home, record.parent.thread_id, record.delivery_id) : null;
59
69
  return { delivery_id: record.delivery_id, state: hookState ?? record.state, child_outcome: record.child_outcome,
60
70
  child_turn_id: record.child_turn_id, queued_submission_id: record.queued_submission_id,
@@ -87,9 +97,15 @@ export class ParentDeliveryManager {
87
97
  this.deps = { observe: observeAgentDone, answer: readAgentTranscriptResult, submit: submitParentAnswer,
88
98
  verify: verifyParent, processes: readRuntimeProcesses, ...options.dependencies };
89
99
  const stateRoot = ensureStateRoot();
90
- this.root = options.root ?? path.join(stateRoot, options.parent_kind === "claude" ? "claude-parent-deliveries" : "parent-deliveries");
91
- // 旧版のCodex readerへ未知のparentを渡さない。公開照会と子の予約だけは両方で共有する。
92
- this.recordRoots = options.root ? [options.root] : [path.join(stateRoot, "parent-deliveries"), path.join(stateRoot, "claude-parent-deliveries")];
100
+ const directory = options.parent_kind === "claude" ? "claude-parent-deliveries"
101
+ : options.parent_kind === "cursor" ? "cursor-parent-deliveries" : "parent-deliveries";
102
+ this.root = options.root ?? path.join(stateRoot, directory);
103
+ // 旧版のCodex/Claude readerへ未知のparentを渡さない。公開照会と子の予約だけは保存場所を横断する。
104
+ this.recordRoots = options.root ? [options.root] : [
105
+ path.join(stateRoot, "parent-deliveries"),
106
+ path.join(stateRoot, "claude-parent-deliveries"),
107
+ path.join(stateRoot, "cursor-parent-deliveries"),
108
+ ];
93
109
  this.active = path.join(this.root, "active");
94
110
  this.results = path.join(this.root, "results");
95
111
  this.claims = path.join(options.root ?? path.join(stateRoot, "parent-deliveries"), "claims");
@@ -148,6 +164,17 @@ export class ParentDeliveryManager {
148
164
  }
149
165
  if (isClaude(parent))
150
166
  bindClaudeParentDelivery(parent, record.delivery_id);
167
+ if (isCursor(parent)) {
168
+ try {
169
+ prepareCursorDelivery(parent, record.delivery_id);
170
+ }
171
+ catch (error) {
172
+ this.releaseClaim(job);
173
+ fs.unlinkSync(job.file);
174
+ job = null;
175
+ throw error;
176
+ }
177
+ }
151
178
  this.jobs.set(record.delivery_id, job);
152
179
  this.watch(job);
153
180
  });
@@ -161,6 +188,7 @@ export class ParentDeliveryManager {
161
188
  }
162
189
  },
163
190
  result: () => job ? receipt(job.record) : null,
191
+ wait_process: () => job && isCursor(parent) ? cursorReceiveProcess(job.record.delivery_id) : null,
164
192
  failed: (error) => {
165
193
  if (!job || job.record.state !== "waiting")
166
194
  return;
@@ -264,7 +292,8 @@ export class ParentDeliveryManager {
264
292
  job.record.state = "submitted";
265
293
  }
266
294
  catch (error) {
267
- job.record.state = (error instanceof CodexDeliveryError || error instanceof ClaudeDeliveryError) && !error.outcome_unknown ? "failed" : "unknown";
295
+ const known = error instanceof CodexDeliveryError || error instanceof ClaudeDeliveryError || error instanceof CursorDeliveryError;
296
+ job.record.state = known && !error.outcome_unknown ? "failed" : "unknown";
268
297
  job.record.error = error instanceof Error ? error.message : String(error);
269
298
  }
270
299
  this.finish(job);
package/dist/setup-cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { runSetup } from "./setup.js";
3
- import { removeClaudeParentHooks } from "./setup-integrations.js";
3
+ import { removeClaudeParentHooks, removeCursorParentHooks } from "./setup-integrations.js";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
@@ -16,8 +16,18 @@ if (args.length === 1 && args[0] === "--remove-claude-parent-hooks") {
16
16
  process.exitCode = 2;
17
17
  }
18
18
  }
19
+ else if (args.length === 1 && args[0] === "--remove-cursor-parent-hooks") {
20
+ try {
21
+ const status = removeCursorParentHooks(join(process.env.CURSOR_HOME ?? join(process.env.HOME ?? homedir(), ".cursor"), "hooks.json"));
22
+ process.stdout.write(`${JSON.stringify({ schema: "aiterm.cursor-parent-hooks-remove-result.v1", status })}\n`);
23
+ }
24
+ catch (error) {
25
+ process.stderr.write(`aiterm-setup: ${error instanceof Error ? error.message : String(error)}\n`);
26
+ process.exitCode = 2;
27
+ }
28
+ }
19
29
  else if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
20
- process.stdout.write("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]\n依存準備、AIへの登録、MCPと端末の実動作確認を行います。対話実行ではAiterm単品かCodex Desktop Steer付きかを選べます。SteerはmacOS・Windows対応で、初回はCodexの再起動が必要です。disableは専用hookを解除し、statusは公式hookの登録・承認と再起動の必要性を確認します。旧中継はhook導入後に解除します。--jsonは対話せず、Steerの選択を維持します。\n旧版へ戻す前のClaude専用hook解除: --remove-claude-parent-hooks\n");
30
+ process.stdout.write("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]\n依存準備、AIへの登録、MCPと端末の実動作確認を行います。対話実行ではAiterm単品かCodex Desktop Steer付きかを選べます。SteerはmacOS・Windows対応で、初回はCodexの再起動が必要です。disableは専用hookを解除し、statusは公式hookの登録・承認と再起動の必要性を確認します。旧中継はhook導入後に解除します。--jsonは対話せず、Steerの選択を維持します。\n旧版へ戻す前のClaude専用hook解除: --remove-claude-parent-hooks\n旧版へ戻す前のCursor専用hook解除: --remove-cursor-parent-hooks\n");
21
31
  }
22
32
  else {
23
33
  try {
@@ -156,6 +156,116 @@ export function removeClaudeParentHooks(file) {
156
156
  throw new SetupError("config_readback_failed", "Claude hook解除の読戻しが一致しません");
157
157
  return "removed";
158
158
  }
159
+ function shellQuote(value) {
160
+ if (process.platform === "win32")
161
+ return `'${value.replace(/'/g, "''")}'`;
162
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
163
+ }
164
+ export function cursorParentHookCommand(registration) {
165
+ const script = join(dirname(registration.args[0]), "cursor-parent-hook.js");
166
+ const command = `${shellQuote(registration.command)} ${shellQuote(script)}`;
167
+ return process.platform === "win32" ? `& ${command}` : command;
168
+ }
169
+ function ownsCursorParentHook(hook) {
170
+ return record(hook) && typeof hook.command === "string" && hook.command.includes("cursor-parent-hook.js");
171
+ }
172
+ export function mergeCursorParentHooks(file, registration) {
173
+ const target = existsSync(file) ? realpathSync(file) : file;
174
+ let current = { version: 1, hooks: {} };
175
+ if (existsSync(target)) {
176
+ try {
177
+ current = JSON.parse(readFileSync(target, "utf8"));
178
+ }
179
+ catch {
180
+ throw new SetupError("config_invalid", "Cursorのhook設定JSONを読めません");
181
+ }
182
+ }
183
+ else {
184
+ try {
185
+ if (lstatSync(file).isSymbolicLink())
186
+ throw new SetupError("config_invalid", "Cursorのhooks設定symlinkの参照先がありません");
187
+ }
188
+ catch (error) {
189
+ if (error.code !== "ENOENT")
190
+ throw error;
191
+ }
192
+ }
193
+ if (!record(current) || (current.hooks !== undefined && !record(current.hooks))) {
194
+ throw new SetupError("config_invalid", "Cursorのhooks設定はobjectである必要があります");
195
+ }
196
+ const hooks = { ...current.hooks };
197
+ const entry = { command: cursorParentHookCommand(registration), timeout: 15 };
198
+ for (const event of ["afterMCPExecution", "postToolUse"]) {
199
+ const previous = hooks[event] ?? [];
200
+ if (!Array.isArray(previous))
201
+ throw new SetupError("config_invalid", `Cursorの${event} hook形式を読めません`);
202
+ const retained = previous.filter(hook => !ownsCursorParentHook(hook));
203
+ const owned = previous.filter(hook => ownsCursorParentHook(hook));
204
+ if (owned.length === 1 && isDeepStrictEqual(owned[0], entry) && retained.length + 1 === previous.length)
205
+ continue;
206
+ const index = previous.findIndex(hook => ownsCursorParentHook(hook));
207
+ hooks[event] = index < 0 ? [...retained, entry] : previous.map((hook, i) => i === index ? entry : hook).filter((hook, i) => i === index || !ownsCursorParentHook(hook));
208
+ }
209
+ const next = { ...current, version: current.version ?? 1, hooks };
210
+ if (isDeepStrictEqual(current, next))
211
+ return "unchanged";
212
+ mkdirSync(dirname(target), { recursive: true });
213
+ const temporary = `${target}.aiterm-${randomUUID()}`;
214
+ try {
215
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600, flag: "wx" });
216
+ if (existsSync(target))
217
+ copyFileSync(target, `${target}.aiterm-backup`);
218
+ renameSync(temporary, target);
219
+ }
220
+ finally {
221
+ if (existsSync(temporary))
222
+ unlinkSync(temporary);
223
+ }
224
+ if (!isDeepStrictEqual(JSON.parse(readFileSync(target, "utf8")), next)) {
225
+ throw new SetupError("config_readback_failed", "Cursorのhook登録の読戻しが一致しません");
226
+ }
227
+ return "configured";
228
+ }
229
+ export function removeCursorParentHooks(file) {
230
+ if (!existsSync(file))
231
+ return "unchanged";
232
+ const target = realpathSync(file);
233
+ const current = JSON.parse(readFileSync(target, "utf8"));
234
+ if (!record(current) || (current.hooks !== undefined && !record(current.hooks))) {
235
+ throw new SetupError("config_invalid", "Cursorのhook設定を読めません");
236
+ }
237
+ if (current.hooks === undefined)
238
+ return "unchanged";
239
+ const hooks = { ...current.hooks };
240
+ for (const event of ["afterMCPExecution", "postToolUse"]) {
241
+ if (hooks[event] === undefined)
242
+ continue;
243
+ if (!Array.isArray(hooks[event]))
244
+ throw new SetupError("config_invalid", "Cursorのhook設定を読めません");
245
+ const retained = hooks[event].filter(hook => !ownsCursorParentHook(hook));
246
+ if (retained.length)
247
+ hooks[event] = retained;
248
+ else
249
+ delete hooks[event];
250
+ }
251
+ const next = { ...current, hooks };
252
+ if (isDeepStrictEqual(current, next))
253
+ return "unchanged";
254
+ const temporary = `${target}.aiterm-${randomUUID()}`;
255
+ try {
256
+ writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600, flag: "wx" });
257
+ copyFileSync(target, `${target}.aiterm-backup`);
258
+ renameSync(temporary, target);
259
+ }
260
+ finally {
261
+ if (existsSync(temporary))
262
+ unlinkSync(temporary);
263
+ }
264
+ if (!isDeepStrictEqual(JSON.parse(readFileSync(target, "utf8")), next)) {
265
+ throw new SetupError("config_readback_failed", "Cursor hook解除の読戻しが一致しません");
266
+ }
267
+ return "removed";
268
+ }
159
269
  export function configureIntegrations(home, registration, run = runSetupCommand, resolveClient = resolveAgentBin) {
160
270
  const results = {};
161
271
  for (const client of ["claude", "codex", "grok", "cursor"]) {
@@ -169,6 +279,8 @@ export function configureIntegrations(home, registration, run = runSetupCommand,
169
279
  const file = client === "cursor" ? join(home, ".cursor", "mcp.json")
170
280
  : process.env.CLAUDE_CONFIG_DIR ? join(process.env.CLAUDE_CONFIG_DIR, ".claude.json") : join(home, ".claude.json");
171
281
  mergeJsonMcp(file, client === "claude" ? { type: "stdio", ...registration } : registration);
282
+ if (client === "cursor")
283
+ mergeCursorParentHooks(join(home, ".cursor", "hooks.json"), registration);
172
284
  if (client === "claude") {
173
285
  const version = /\b(\d+)\.(\d+)\.(\d+)\b/.exec(run(executable, ["--version"]));
174
286
  if (!version || Number(version[1]) < 2 || (Number(version[1]) === 2 && (Number(version[2]) < 1 || (Number(version[2]) === 1 && Number(version[3]) < 259)))) {
@@ -20,12 +20,10 @@ Grok/Composerのsandbox起動拒否については、[DESIGNの失敗と復旧
20
20
  Codex親への回答の自動配送は[DESIGN](DESIGN.md#codex親への自動配送)に、対応範囲と失敗時の状態を置く。
21
21
  Claude Code親の非同期hookによる受信は[DESIGN](DESIGN.md#claude-code親への自動配送)を参照する。
22
22
 
23
- ## 実装待ちの設計
24
-
25
- - [Grokの過去の利用上限表示からの復帰](https://github.com/kitepon/aiterm-mcp/blob/main/docs/grok-rate-limit-recovery-plan.md): 設計完了・実装未着手。通常dispatchでのパネル解除、現在の画面による上限観測、試験と実機受入を定める。
26
-
27
23
  ## 履歴と証拠
28
24
 
25
+ - [Grokの上限パネル復帰の受入](https://github.com/kitepon/aiterm-mcp/blob/main/docs/adr/0069-grok-rate-limit-recovery.md): 通常送信による解除、公開package試験、本番導入と実機未検証範囲。
26
+
29
27
  - [`archive/`](https://github.com/kitepon/aiterm-mcp/tree/main/docs/archive): 完了・棄却・中断・失効・置換により現行制御から外れたsnapshot。
30
28
  - [`adr/`](https://github.com/kitepon/aiterm-mcp/tree/main/docs/adr): 採択・棄却した設計判断とrelease受入。
31
29
  - [`evidence/`](https://github.com/kitepon/aiterm-mcp/tree/main/docs/evidence): Lattice/campaignの検証証拠。
package/docs/DESIGN.md CHANGED
@@ -151,6 +151,25 @@ Claude Desktopのチャット、Web、`agent_id`付きの会話(`--agent`で
151
151
  Claudeの起動metadataには指定cwdの実体パスを保存する。Claude Codeが実体パスから作るproject slugと
152
152
  APIエラー監視の参照先を一致させ、監視中のリンク変更で保存場所を取り違えない。
153
153
 
154
+ ### Cursor親への自動配送
155
+
156
+ `aiterm-setup`は`~/.cursor/hooks.json`へAiterm専用の`afterMCPExecution`と`postToolUse`を追加する。
157
+ 他製品のhookと順序は保持し、`command`に`cursor-parent-hook.js`を含むentryだけを更新する。
158
+ 親の識別はMCP `initialize`の`clientInfo.name`が`cursor-vscode`(またはその後ろに空白を挟む派生名)であることだけで、
159
+ 会話IDはMCPの`_meta`に来ない。hook未登録は子への送信前に`CURSOR_PARENT_HOOK_UNAVAILABLE`で止め、waiterへ切り替えない。
160
+
161
+ `afterMCPExecution`またはdispatch toolの`postToolUse`が、tool返りの`parent_delivery.delivery_id`とhook入力の
162
+ `conversation_id`を結ぶ。`delivery_id`は`structuredContent`か、`content`のtextをJSONとして読んだ中から取る。
163
+ 完了観測と本文保存は`parent-delivery.ts`が所有し、記録は`cursor-parent-deliveries`へ分ける。子の予約`claims`は共有する。
164
+
165
+ 親が次のツールを呼ぶと`postToolUse`が未受領の本文を`additional_context`で会話へ差し込む。
166
+ 親がターンを終えている場合は、receiptの`wait_process`で起動した`cursor-parent-receive`が本文をstdoutへ出して終了する。
167
+ `wait_command`はnull。受け取りは`claim.json`の排他作成で一つに決め、hookと受け口の両方へ本文を出さない。
168
+ `submitted`はどちらかがclaimした状態であり、modelの読了ではない。24時間以内にclaimが無ければ`failed`とし、
169
+ 本文は残して自動再送しない。Cursor Cloud Agent/Background Agentはこの受信契約に含めない。
170
+
171
+ hookを外す時は`aiterm-setup --remove-cursor-parent-hooks`で専用entryだけを解除する。
172
+
154
173
  `trust_project:true`は対象projectの既知のworkspace、hooks、MCP初期同意を起動準備として進める意図である。
155
174
  promptなしでも入力受付とharness生存を確認して`startup.ready`を返す。指定なしのpromptなし起動は
156
175
  従来どおり`startup.not_checked`で返す。初手receiptは未要求・未送信・送信済み未確認・開始確認を分ける。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiterm-mcp",
3
- "version": "0.37.9",
3
+ "version": "0.38.0",
4
4
  "mcpName": "io.github.kitepon/aiterm-mcp",
5
5
  "description": "Persistent terminal MCP with one harness-based launcher for Claude Code, Codex CLI, Grok CLI, and Cursor Agent CLI, plus durable PTYs for SSH, containers, and REPLs.",
6
6
  "keywords": [