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 +15 -1
- package/README.ja.md +7 -3
- package/README.md +7 -3
- package/dist/agent-shared.js +40 -0
- package/dist/claude-parent-receiver.js +1 -36
- package/dist/core.js +56 -6
- package/dist/cursor-parent-hook.js +35 -0
- package/dist/cursor-parent-receive.js +56 -0
- package/dist/cursor-parent-receiver.js +247 -0
- package/dist/harnesses/codex.js +25 -2
- package/dist/index.js +25 -10
- package/dist/parent-delivery.js +36 -7
- package/dist/setup-cli.js +12 -2
- package/dist/setup-integrations.js +112 -0
- package/docs/00_overview.md +2 -4
- package/docs/DESIGN.md +19 -0
- package/package.json +1 -1
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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親には子の回答本文が自動で届く。**
|
|
590
|
+
**Codex/Claude Code/Cursor親には子の回答本文が自動で届く。** Codex/Claude Codeでは、子を起動・dispatchした後は別作業へ進むか親のturnを終える。Aitermが完了を観測し、加工前の本文を保存して親へ渡す。waiter、`pty_read`による回答回収、子への返送指示は不要。子は全対応harnessから選べる。
|
|
589
591
|
|
|
590
|
-
|
|
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:
|
package/dist/agent-shared.js
CHANGED
|
@@ -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" ?
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/harnesses/codex.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"));
|
package/dist/parent-delivery.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
this.
|
|
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
|
-
|
|
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)))) {
|
package/docs/00_overview.md
CHANGED
|
@@ -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.
|
|
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": [
|