aiterm-mcp 0.37.4 → 0.37.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -1
- package/README.ja.md +20 -18
- package/README.md +21 -19
- package/dist/codex-hook-state.js +126 -0
- package/dist/codex-parent-hook.js +15 -0
- package/dist/codex-parent-hooks.js +112 -0
- package/dist/codex-parent-receiver.js +33 -14
- package/dist/parent-delivery.js +6 -2
- package/dist/setup-cli.js +3 -4
- package/dist/setup-codex-hooks.js +142 -0
- package/dist/setup-codex-relay.js +1 -1
- package/dist/setup.js +2 -3
- package/docs/DESIGN.md +33 -43
- package/docs/RELEASE.md +15 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.37.5] - 2026-09-19
|
|
11
|
+
|
|
12
|
+
### 修正
|
|
13
|
+
|
|
14
|
+
- Codex親へのSteer相当の配送を、公式キューと同期hookへ変更する。ツール実行後または回答終了直前に同じターンへ追加し、終了後は公式キューで同じ会話を再開する。終了後の再開には約10秒かかる場合がある。
|
|
15
|
+
- 選択導入は専用hookだけを公式APIで承認・読戻しし、その後に旧中継の起動差し替えを解除する。Codexの通常起動はAitermのNodeとmoduleに依存しない。
|
|
16
|
+
- 利用者自身のキュー入力は保持する。並行hookの取得を一つに制限し、取り出し中断・出力失敗は本文を保存して`unknown`として照会できる。
|
|
17
|
+
|
|
10
18
|
## [0.37.4] - 2026-09-19
|
|
11
19
|
|
|
12
20
|
### 修正
|
|
@@ -1692,7 +1700,8 @@ prototype (preserved under `prototype/python/` as the porting source and referen
|
|
|
1692
1700
|
`ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
|
|
1693
1701
|
provenance.
|
|
1694
1702
|
|
|
1695
|
-
[Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.
|
|
1703
|
+
[Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.5...HEAD
|
|
1704
|
+
[0.37.5]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.4...v0.37.5
|
|
1696
1705
|
[0.37.4]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.3...v0.37.4
|
|
1697
1706
|
[0.37.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.2...v0.37.3
|
|
1698
1707
|
[0.37.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.37.1...v0.37.2
|
package/README.ja.md
CHANGED
|
@@ -42,7 +42,7 @@ Ubuntu/Debianはsudoとaptでtmuxを準備する。必要な公式package mana
|
|
|
42
42
|
既存設定の他サーバーを保持し、JSON設定は変更前の`.aiterm-backup`を残す。
|
|
43
43
|
結果の`status`は`ready`/`unsupported`/`failed`/`restart_required`。未検出のAIは`not_detected`とし、全AI未検出は成功にしない。
|
|
44
44
|
登録先はglobal packageのNodeとMCP入口の絶対パスで、npm一時cacheやsource checkoutは登録しない。
|
|
45
|
-
HomebrewのNodeは更新後も有効な`opt`のパスをMCP登録とCodex
|
|
45
|
+
HomebrewのNodeは更新後も有効な`opt`のパスをMCP登録とCodexのhookに使う。旧版の登録でNode更新後に起動できなくなった場合も、更新後の`aiterm-setup --json`で修復できる。
|
|
46
46
|
更新後も同じ入口を実行し、MCP clientを再起動する。npm install自体はユーザー設定を変更しない。
|
|
47
47
|
公開JSONは`schema: "aiterm.setup-result.v1"`、全体の`status`、端末の`backend`、
|
|
48
48
|
AI別の`integrations`と選択機能の`codex_steer`を持つ。失敗時は`reason_code`を付け、終了コードはreadyなら0、再起動待ちは3、それ以外は2となる。
|
|
@@ -56,22 +56,24 @@ AI別の`integrations`と選択機能の`codex_steer`を持つ。失敗時は`re
|
|
|
56
56
|
aiterm-setup --json --codex-steer enable
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
公式キューと公式hookを使い、実行中の親には同じターンの次の推論へ回答を渡し、終了後は同じ会話を自動再開します。
|
|
60
|
+
Codexの起動プログラムと通常のstdio通信は変更しません。hookの実行ファイルが失われてもCodexの起動・応答は継続します。
|
|
61
|
+
終了後の再開は公式キューの監視周期に従い、約10秒かかる場合があります。
|
|
62
|
+
|
|
63
|
+
`aiterm-setup`は`CODEX_HOME/hooks.json`へ専用の`PostToolUse`と`Stop`を追加し、公式APIでその2件だけを承認・読戻しします。
|
|
64
|
+
他のhookや承認は保持します。選択と配送の所有記録は`~/.config/aiterm-mcp/codex-parent-hooks/`へ保存します。
|
|
65
|
+
既存の中継は新しいhookの確認後に解除し、保存していた`CODEX_CLI_PATH`を復元します。macOSの専用LaunchAgentも解除します。
|
|
66
|
+
移行前から動いているCodexがあれば`restart_required`(終了コード3)を返します。完全終了・再起動後に
|
|
67
|
+
`aiterm-setup --codex-steer status`で`ready`を確認してください。旧設定は移行を実行するまで維持します。
|
|
68
|
+
|
|
69
|
+
hookはAiterm自身の配送記録と本文が一致する回答だけを取り出し、利用者がキューに入れた入力は保持します。
|
|
70
|
+
取り出し中断や出力失敗は`parent_deliveries`に`unknown`と`CODEX_HOOK_DELIVERY_UNCONFIRMED`で現れ、本文を保存します。
|
|
71
|
+
自動再送はしません。長い回答はCodexの公式hook処理で抜粋と全文ファイルへの参照になる場合があります。
|
|
72
|
+
|
|
73
|
+
解除・hook未対応の旧版への巻き戻し前は`aiterm-setup --codex-steer disable`を実行してCodexを再起動してください。
|
|
74
|
+
macOS・Windowsの公式Codex Desktopと、公式キュー・hookに対応する同梱CLIを対象にします。
|
|
75
|
+
WindowsのDesktop更新後はsetupを再実行してください。LinuxのSteer付き導入は理由付き`unsupported`を返します。
|
|
76
|
+
Aiterm単品の公式キュー配送は従来どおり利用できます。
|
|
75
77
|
|
|
76
78
|
cloneもビルドも不要。どのクライアントでも公開パッケージを次のコマンドで起動する:
|
|
77
79
|
|
|
@@ -198,7 +200,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
|
|
|
198
200
|
場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
|
|
199
201
|
Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
|
|
200
202
|
|
|
201
|
-
**状態:** 開発継続中 · 現行公開版 **v0.37.
|
|
203
|
+
**状態:** 開発継続中 · 現行公開版 **v0.37.5** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
|
|
202
204
|
|
|
203
205
|
### 更新と巻き戻し
|
|
204
206
|
|
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Ubuntu/Debianはsudoとaptでtmuxを準備する。必要な公式package mana
|
|
|
42
42
|
既存設定の他サーバーを保持し、JSON設定は変更前の`.aiterm-backup`を残す。
|
|
43
43
|
結果の`status`は`ready`/`unsupported`/`failed`/`restart_required`。未検出のAIは`not_detected`とし、全AI未検出は成功にしない。
|
|
44
44
|
登録先はglobal packageのNodeとMCP入口の絶対パスで、npm一時cacheやsource checkoutは登録しない。
|
|
45
|
-
HomebrewのNodeは更新後も有効な`opt`のパスをMCP登録とCodex
|
|
45
|
+
HomebrewのNodeは更新後も有効な`opt`のパスをMCP登録とCodexのhookに使う。旧版の登録でNode更新後に起動できなくなった場合も、更新後の`aiterm-setup --json`で修復できる。
|
|
46
46
|
更新後も同じ入口を実行し、MCP clientを再起動する。npm install自体はユーザー設定を変更しない。
|
|
47
47
|
公開JSONは`schema: "aiterm.setup-result.v1"`、全体の`status`、端末の`backend`、
|
|
48
48
|
AI別の`integrations`と選択機能の`codex_steer`を持つ。失敗時は`reason_code`を付け、終了コードはreadyなら0、再起動待ちは3、それ以外は2となる。
|
|
@@ -56,22 +56,24 @@ AI別の`integrations`と選択機能の`codex_steer`を持つ。失敗時は`re
|
|
|
56
56
|
aiterm-setup --json --codex-steer enable
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
59
|
+
公式キューと公式hookを使い、実行中の親には同じターンの次の推論へ回答を渡し、終了後は同じ会話を自動再開します。
|
|
60
|
+
Codexの起動プログラムと通常のstdio通信は変更しません。hookの実行ファイルが失われてもCodexの起動・応答は継続します。
|
|
61
|
+
終了後の再開は公式キューの監視周期に従い、約10秒かかる場合があります。
|
|
62
|
+
|
|
63
|
+
`aiterm-setup`は`CODEX_HOME/hooks.json`へ専用の`PostToolUse`と`Stop`を追加し、公式APIでその2件だけを承認・読戻しします。
|
|
64
|
+
他のhookや承認は保持します。選択と配送の所有記録は`~/.config/aiterm-mcp/codex-parent-hooks/`へ保存します。
|
|
65
|
+
既存の中継は新しいhookの確認後に解除し、保存していた`CODEX_CLI_PATH`を復元します。macOSの専用LaunchAgentも解除します。
|
|
66
|
+
移行前から動いているCodexがあれば`restart_required`(終了コード3)を返します。完全終了・再起動後に
|
|
67
|
+
`aiterm-setup --codex-steer status`で`ready`を確認してください。旧設定は移行を実行するまで維持します。
|
|
68
|
+
|
|
69
|
+
hookはAiterm自身の配送記録と本文が一致する回答だけを取り出し、利用者がキューに入れた入力は保持します。
|
|
70
|
+
取り出し中断や出力失敗は`parent_deliveries`に`unknown`と`CODEX_HOOK_DELIVERY_UNCONFIRMED`で現れ、本文を保存します。
|
|
71
|
+
自動再送はしません。長い回答はCodexの公式hook処理で抜粋と全文ファイルへの参照になる場合があります。
|
|
72
|
+
|
|
73
|
+
解除・hook未対応の旧版への巻き戻し前は`aiterm-setup --codex-steer disable`を実行してCodexを再起動してください。
|
|
74
|
+
macOS・Windowsの公式Codex Desktopと、公式キュー・hookに対応する同梱CLIを対象にします。
|
|
75
|
+
WindowsのDesktop更新後はsetupを再実行してください。LinuxのSteer付き導入は理由付き`unsupported`を返します。
|
|
76
|
+
Aiterm単品の公式キュー配送は従来どおり利用できます。
|
|
75
77
|
|
|
76
78
|
No clone or build is required. Each client launches the published package with:
|
|
77
79
|
|
|
@@ -212,7 +214,7 @@ collection is off by default and performs no network I/O. It ships via
|
|
|
212
214
|
tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
|
|
213
215
|
Release re-registers the Official MCP Registry entry.
|
|
214
216
|
|
|
215
|
-
**Status:** actively maintained · current public release **v0.37.
|
|
217
|
+
**Status:** actively maintained · current public release **v0.37.5** · 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).
|
|
216
218
|
|
|
217
219
|
### Update and rollback
|
|
218
220
|
|
|
@@ -583,7 +585,7 @@ For PowerShell over SSH, `mark:true` recognizes the current standard `PS ...>` p
|
|
|
583
585
|
|
|
584
586
|
自動配送の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`とする。自動再送はしない。
|
|
585
587
|
|
|
586
|
-
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.
|
|
588
|
+
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が同一ターンへ取り込みます。
|
|
587
589
|
|
|
588
590
|
Claude Codeは2.1.259以上の対話sessionに対応する。`aiterm-setup`が専用の`PreToolUse`、`PostToolUse`、`SessionEnd`を登録するため、Channelsの起動flagは不要。公式`asyncRewake` hookだけが裏で待ち、親はその間も次のturnへ進める。回答は`Stop hook feedback`として届く。hookのexit 2は親の再開信号であり、子の成功・失敗は本文の`outcome`で区別する。
|
|
589
591
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Codexの設定領域にはhook登録だけを置き、配送の所有情報はAitermが保持する。
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { relayConfigDirectory } from "./codex-relay-config.js";
|
|
7
|
+
import { CodexDeliveryError } from "./codex-delivery-error.js";
|
|
8
|
+
import { readRuntimeProcesses } from "./process-runtime.js";
|
|
9
|
+
const processSchema = z.object({ pid: z.number().int(), started_identity: z.string() });
|
|
10
|
+
export const codexHookConfigSchema = z.object({
|
|
11
|
+
schema: z.literal("aiterm.codex-parent-hooks.v1"), enabled: z.boolean(),
|
|
12
|
+
codex_home: z.string(), binary: z.string(), command: z.string(), node: z.string(), hook: z.string(),
|
|
13
|
+
stale_processes: z.array(processSchema),
|
|
14
|
+
}).strict();
|
|
15
|
+
export function codexHookDirectory() { return path.join(path.dirname(relayConfigDirectory()), "codex-parent-hooks"); }
|
|
16
|
+
export function writeHookJson(file, value) {
|
|
17
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
18
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
19
|
+
try {
|
|
20
|
+
fs.writeFileSync(temporary, JSON.stringify(value) + "\n", { flag: "wx", mode: 0o600 });
|
|
21
|
+
fs.renameSync(temporary, file);
|
|
22
|
+
}
|
|
23
|
+
finally {
|
|
24
|
+
if (fs.existsSync(temporary))
|
|
25
|
+
fs.unlinkSync(temporary);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function readCodexHookConfig(directory = codexHookDirectory()) {
|
|
29
|
+
try {
|
|
30
|
+
return codexHookConfigSchema.parse(JSON.parse(fs.readFileSync(path.join(directory, "config.json"), "utf8")));
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (error.code === "ENOENT")
|
|
34
|
+
return null;
|
|
35
|
+
throw new CodexDeliveryError("CODEX_HOOK_CONFIG_INVALID", "AitermのCodex hook設定を読めません");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function codexInputDirectory(root, home, thread) {
|
|
39
|
+
z.uuid().parse(thread);
|
|
40
|
+
const hash = createHash("sha256").update(fs.realpathSync(home)).digest("hex");
|
|
41
|
+
return path.join(root, "inputs", hash, thread);
|
|
42
|
+
}
|
|
43
|
+
export const hookInputSchema = z.object({
|
|
44
|
+
delivery_id: z.uuid(), thread_id: z.uuid(), text_sha256: z.string(),
|
|
45
|
+
}).strict();
|
|
46
|
+
export function answerDigest(text) { return createHash("sha256").update(text).digest("hex"); }
|
|
47
|
+
export function registerCodexHookInput(home, thread, id, text, root = codexHookDirectory()) {
|
|
48
|
+
const input = hookInputSchema.parse({ delivery_id: id, thread_id: thread, text_sha256: answerDigest(text) });
|
|
49
|
+
const directory = codexInputDirectory(root, home, thread);
|
|
50
|
+
fs.mkdirSync(path.join(directory, "pending"), { recursive: true, mode: 0o700 });
|
|
51
|
+
// 一つの配送IDを別の本文で再使用しない。送信前の所有記録は再送の指示にはしない。
|
|
52
|
+
fs.writeFileSync(path.join(directory, "pending", `${id}.json`), JSON.stringify(input) + "\n", { flag: "wx", mode: 0o600 });
|
|
53
|
+
}
|
|
54
|
+
export function finishCodexHookSubmission(home, thread, id, root) {
|
|
55
|
+
const directory = codexInputDirectory(root, home, thread);
|
|
56
|
+
const file = path.join(directory, "settled", `${id}.json`);
|
|
57
|
+
writeHookJson(file, { delivery_id: id });
|
|
58
|
+
if (!fs.existsSync(path.join(directory, "pending", `${id}.json`)))
|
|
59
|
+
fs.rmSync(file, { force: true });
|
|
60
|
+
}
|
|
61
|
+
export function codexHookDeliveryState(home, thread, id, root = codexHookDirectory()) {
|
|
62
|
+
if (!fs.existsSync(path.join(root, "inputs")))
|
|
63
|
+
return null;
|
|
64
|
+
let value;
|
|
65
|
+
try {
|
|
66
|
+
value = JSON.parse(fs.readFileSync(path.join(codexInputDirectory(root, home, thread), "claims", `${z.uuid().parse(id)}.json`), "utf8"));
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
if (error.code === "ENOENT")
|
|
70
|
+
return null;
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
// 所有権のlinkだけを作った段階では、公式キューからまだ削除していない。
|
|
74
|
+
if (hookInputSchema.safeParse(value).success)
|
|
75
|
+
return null;
|
|
76
|
+
if (value.state === "unknown")
|
|
77
|
+
return "unknown";
|
|
78
|
+
if (value.state === "deleting")
|
|
79
|
+
return readRuntimeProcesses().some(row => row.pid === value.pid && row.started_identity === value.started_identity) ? "sending" : "unknown";
|
|
80
|
+
if (["emitted", "not_in_queue"].includes(value.state))
|
|
81
|
+
return null;
|
|
82
|
+
throw new CodexDeliveryError("CODEX_HOOK_STATE_INVALID", "hookの配送状態を読めません");
|
|
83
|
+
}
|
|
84
|
+
export function ownedCodexHooks(response, command, file) {
|
|
85
|
+
const rows = response?.data;
|
|
86
|
+
if (!Array.isArray(rows) || rows.length !== 1 || !Array.isArray(rows[0]?.hooks) || rows[0]?.errors?.length) {
|
|
87
|
+
throw new CodexDeliveryError("CODEX_HOOK_UNAVAILABLE", "公式APIでCodexのhook設定を読めません");
|
|
88
|
+
}
|
|
89
|
+
const source = fs.realpathSync(file);
|
|
90
|
+
const owned = rows[0].hooks.filter((hook) => hook.command === command && hook.sourcePath === source);
|
|
91
|
+
for (const event of ["postToolUse", "stop"]) {
|
|
92
|
+
const matches = owned.filter((hook) => hook.eventName === event);
|
|
93
|
+
if (matches.length !== 1 || matches[0].async || matches[0].handlerType !== "command") {
|
|
94
|
+
throw new CodexDeliveryError("CODEX_HOOK_UNAVAILABLE", "Aitermの同期hook登録を確認できません");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (owned.length !== 2)
|
|
98
|
+
throw new CodexDeliveryError("CODEX_HOOK_UNAVAILABLE", "Aitermのhook登録が重複しています");
|
|
99
|
+
return owned;
|
|
100
|
+
}
|
|
101
|
+
export function assertCodexHooksReady(response, command, file) {
|
|
102
|
+
if (ownedCodexHooks(response, command, file).some(hook => !hook.enabled || !["trusted", "managed"].includes(hook.trustStatus))) {
|
|
103
|
+
throw new CodexDeliveryError("CODEX_HOOK_UNTRUSTED", "AitermのCodex hookが無効または未承認です。aiterm-setupを実行してください");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function assertCodexHookParentCurrent(config, rows, pid = process.pid) {
|
|
107
|
+
try {
|
|
108
|
+
fs.accessSync(config.node, fs.constants.X_OK);
|
|
109
|
+
fs.accessSync(config.hook, fs.constants.R_OK);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new CodexDeliveryError("CODEX_HOOK_RUNTIME_UNAVAILABLE", "Codex親hookの実行ファイルを確認できません。aiterm-setupを実行してください");
|
|
113
|
+
}
|
|
114
|
+
if (!config.stale_processes.length)
|
|
115
|
+
return;
|
|
116
|
+
const processes = new Map((rows ?? readRuntimeProcesses()).map(row => [row.pid, row]));
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
let current = processes.get(pid);
|
|
119
|
+
while (current && !seen.has(current.pid)) {
|
|
120
|
+
seen.add(current.pid);
|
|
121
|
+
if (config.stale_processes.some(stale => stale.pid === current.pid && stale.started_identity === current.started_identity)) {
|
|
122
|
+
throw new CodexDeliveryError("CODEX_STEER_RESTART_REQUIRED", "親のCodexはhookの導入前から動いています。完全終了して再起動してください");
|
|
123
|
+
}
|
|
124
|
+
current = processes.get(current.parent_pid);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stdoutはCodexの公式hook出力だけに使う。失敗をStop継続のexit 2に変換しない。
|
|
3
|
+
import { runCodexResultHook } from "./codex-parent-hooks.js";
|
|
4
|
+
let input = "";
|
|
5
|
+
try {
|
|
6
|
+
for await (const chunk of process.stdin)
|
|
7
|
+
input += chunk;
|
|
8
|
+
await runCodexResultHook(JSON.parse(input), value => new Promise((resolve, reject) => {
|
|
9
|
+
process.stdout.write(JSON.stringify(value) + "\n", error => error ? reject(error) : resolve());
|
|
10
|
+
}), { directory: process.argv[2] });
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
process.stderr.write(`CODEX_PARENT_HOOK_FAILED: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
14
|
+
process.exitCode = 1;
|
|
15
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// 親の同期hookだけが、公式キューのAiterm回答を同じターンの入力へ移す。
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { realCodexHome } from "./harnesses/codex.js";
|
|
6
|
+
import { withCodexReceiver } from "./codex-parent-receiver.js";
|
|
7
|
+
import { CodexDeliveryError } from "./codex-delivery-error.js";
|
|
8
|
+
import { readRuntimeProcesses } from "./process-runtime.js";
|
|
9
|
+
import { answerDigest, codexHookDirectory, codexInputDirectory, hookInputSchema, readCodexHookConfig, writeHookJson } from "./codex-hook-state.js";
|
|
10
|
+
export async function runCodexResultHook(input, emit, options = {}) {
|
|
11
|
+
const event = z.object({ session_id: z.uuid(), turn_id: z.string().min(1),
|
|
12
|
+
hook_event_name: z.enum(["PostToolUse", "Stop"]) }).parse(input);
|
|
13
|
+
const root = options.directory ?? codexHookDirectory();
|
|
14
|
+
const config = readCodexHookConfig(root);
|
|
15
|
+
if (!config?.enabled) {
|
|
16
|
+
await emit({});
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const home = options.codex_home ?? realCodexHome();
|
|
20
|
+
if (fs.realpathSync(home) !== fs.realpathSync(config.codex_home)) {
|
|
21
|
+
throw new CodexDeliveryError("CODEX_HOOK_HOME_MISMATCH", "hookと配送設定のCodex環境が一致しません");
|
|
22
|
+
}
|
|
23
|
+
const directory = codexInputDirectory(root, home, event.session_id);
|
|
24
|
+
const pending = path.join(directory, "pending");
|
|
25
|
+
if (!fs.existsSync(pending) || !fs.readdirSync(pending).length) {
|
|
26
|
+
await emit({});
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const claims = path.join(directory, "claims");
|
|
30
|
+
fs.mkdirSync(claims, { recursive: true, mode: 0o700 });
|
|
31
|
+
const taken = [];
|
|
32
|
+
const identity = readRuntimeProcesses().find(row => row.pid === process.pid);
|
|
33
|
+
if (!identity)
|
|
34
|
+
throw new CodexDeliveryError("CODEX_HOOK_PROCESS_UNAVAILABLE", "受信hookのprocessを確認できません");
|
|
35
|
+
try {
|
|
36
|
+
await withCodexReceiver({ thread_id: event.session_id, codex_home: home }, async (request) => {
|
|
37
|
+
// 削除でoffsetが動くため、全ページを読んでから取り出す。
|
|
38
|
+
const entries = [];
|
|
39
|
+
let cursor = null;
|
|
40
|
+
do {
|
|
41
|
+
const page = await request("thread/queue/list", { threadId: event.session_id, cursor, limit: 100 });
|
|
42
|
+
if (!Array.isArray(page?.data) || !(page.nextCursor === null || typeof page.nextCursor === "string")) {
|
|
43
|
+
throw new CodexDeliveryError("CODEX_HOOK_QUEUE_INVALID", "公式キューの一覧応答を認識できません");
|
|
44
|
+
}
|
|
45
|
+
entries.push(...page.data);
|
|
46
|
+
cursor = page.nextCursor;
|
|
47
|
+
} while (cursor);
|
|
48
|
+
const queued = new Set(entries.map(entry => entry.clientUserMessageId));
|
|
49
|
+
// 送信processが終了し、キューに残っていない所有記録を消す。投入中は触らない。
|
|
50
|
+
for (const name of fs.readdirSync(pending)) {
|
|
51
|
+
const ack = path.join(directory, "settled", name);
|
|
52
|
+
if (name.endsWith(".json") && !queued.has(name.slice(0, -5)) && fs.existsSync(ack)) {
|
|
53
|
+
fs.rmSync(path.join(pending, name), { force: true });
|
|
54
|
+
fs.rmSync(ack, { force: true });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
const id = z.uuid().safeParse(entry.clientUserMessageId);
|
|
59
|
+
if (!id.success)
|
|
60
|
+
continue;
|
|
61
|
+
const source = path.join(pending, `${id.data}.json`);
|
|
62
|
+
let owner;
|
|
63
|
+
try {
|
|
64
|
+
owner = hookInputSchema.parse(JSON.parse(fs.readFileSync(source, "utf8")));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === "ENOENT")
|
|
68
|
+
continue;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
const text = entry.input?.length === 1 && entry.input[0].type === "text" ? entry.input[0].text : undefined;
|
|
72
|
+
if (owner.delivery_id !== id.data || owner.thread_id !== event.session_id || typeof text !== "string" || answerDigest(text) !== owner.text_sha256) {
|
|
73
|
+
throw new CodexDeliveryError("CODEX_HOOK_INPUT_CHANGED", "Aitermの配送記録とキュー本文が一致しません。取り出していません");
|
|
74
|
+
}
|
|
75
|
+
const file = path.join(claims, `${id.data}.json`);
|
|
76
|
+
// 同時hookが同じ回答を取り出すことを防ぎ、中断したclaimは自動再送しない。
|
|
77
|
+
try {
|
|
78
|
+
fs.linkSync(source, file);
|
|
79
|
+
fs.unlinkSync(source);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (["ENOENT", "EEXIST"].includes(error.code ?? ""))
|
|
83
|
+
continue;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
fs.rmSync(path.join(directory, "settled", `${id.data}.json`), { force: true });
|
|
87
|
+
const item = { file, delivery_id: id.data, text };
|
|
88
|
+
taken.push(item);
|
|
89
|
+
writeHookJson(file, { ...owner, text, state: "deleting", turn_id: event.turn_id, queued_submission_id: entry.id,
|
|
90
|
+
pid: identity.pid, started_identity: identity.started_identity });
|
|
91
|
+
const result = await request("thread/queue/delete", { threadId: event.session_id, queuedSubmissionId: entry.id });
|
|
92
|
+
if (typeof result?.deleted !== "boolean")
|
|
93
|
+
throw new CodexDeliveryError("CODEX_HOOK_QUEUE_INVALID", "キュー削除の結果を確認できません", true);
|
|
94
|
+
if (!result.deleted) {
|
|
95
|
+
writeHookJson(file, { ...owner, state: "not_in_queue", turn_id: event.turn_id });
|
|
96
|
+
taken.pop();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, options.runtime ?? { executable: config.binary, timeout_ms: 5_000 });
|
|
100
|
+
const text = taken.map(item => item.text).join("\n\n");
|
|
101
|
+
await emit(!taken.length ? {} : event.hook_event_name === "Stop"
|
|
102
|
+
? { decision: "block", reason: text }
|
|
103
|
+
: { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } });
|
|
104
|
+
for (const item of taken)
|
|
105
|
+
writeHookJson(item.file, { delivery_id: item.delivery_id, state: "emitted", turn_id: event.turn_id, text: item.text });
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
for (const item of taken)
|
|
109
|
+
writeHookJson(item.file, { delivery_id: item.delivery_id, state: "unknown", turn_id: event.turn_id, text: item.text });
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Codex
|
|
1
|
+
// Codex親の配送は公式queueを使う。旧中継の選択はsetupで移行するまで維持する。
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { createInterface } from "node:readline";
|
|
4
4
|
import * as path from "node:path";
|
|
@@ -7,6 +7,7 @@ import { realCodexHome } from "./harnesses/codex.js";
|
|
|
7
7
|
import { CodexDeliveryError } from "./codex-delivery-error.js";
|
|
8
8
|
import { readRelayConfig, parentRelaySocket } from "./codex-relay-config.js";
|
|
9
9
|
import { withCodexRelay, verifyLoadedParent } from "./codex-relay-client.js";
|
|
10
|
+
import { finishCodexHookSubmission, assertCodexHookParentCurrent, assertCodexHooksReady, codexHookDirectory, readCodexHookConfig, registerCodexHookInput } from "./codex-hook-state.js";
|
|
10
11
|
export { CodexDeliveryError } from "./codex-delivery-error.js";
|
|
11
12
|
/** modelの引数ではなく、CodexがMCP要求へ付けるmetadataだけを宛先にする。 */
|
|
12
13
|
export function codexParentFromRequest(clientName, metadata) {
|
|
@@ -21,11 +22,14 @@ export function codexParentFromRequest(clientName, metadata) {
|
|
|
21
22
|
function relaySocket(runtime) {
|
|
22
23
|
if (runtime)
|
|
23
24
|
return runtime.socket_path ?? null;
|
|
25
|
+
if (readCodexHookConfig()?.enabled)
|
|
26
|
+
return null;
|
|
24
27
|
const config = readRelayConfig();
|
|
25
28
|
return config?.enabled ? parentRelaySocket(config) : null;
|
|
26
29
|
}
|
|
27
|
-
async function withCodexReceiver(parent, action, runtime = {}) {
|
|
28
|
-
const
|
|
30
|
+
export async function withCodexReceiver(parent, action, runtime = {}) {
|
|
31
|
+
const config = runtime.executable ? null : readCodexHookConfig();
|
|
32
|
+
const executable = runtime.executable ?? (config?.enabled ? config.binary : null) ?? resolveAgentBin("codex");
|
|
29
33
|
if (!executable)
|
|
30
34
|
throw new CodexDeliveryError("CODEX_RECEIVER_UNAVAILABLE", "Codexの実行ファイルを確認できません");
|
|
31
35
|
const child = spawn(executable, runtime.args ?? ["app-server", "--listen", "stdio://"], {
|
|
@@ -123,6 +127,11 @@ export async function verifyCodexParent(parent, runtime) {
|
|
|
123
127
|
throw new CodexDeliveryError("CODEX_PARENT_UNSUPPORTED", "Codexのnative sub-agentは外部processからのキュー入力を受け付けません");
|
|
124
128
|
}
|
|
125
129
|
await request("thread/queue/list", { threadId: parent.thread_id, limit: 1 });
|
|
130
|
+
const config = runtime ? (runtime.hook_directory ? readCodexHookConfig(runtime.hook_directory) : null) : readCodexHookConfig();
|
|
131
|
+
if (config?.enabled) {
|
|
132
|
+
assertCodexHookParentCurrent(config);
|
|
133
|
+
assertCodexHooksReady(await request("hooks/list", { cwds: [response.thread.cwd] }), config.command, path.join(parent.codex_home, "hooks.json"));
|
|
134
|
+
}
|
|
126
135
|
}, runtime);
|
|
127
136
|
}
|
|
128
137
|
export async function submitCodexParentAnswer(parent, deliveryId, text, runtime) {
|
|
@@ -139,15 +148,25 @@ export async function submitCodexParentAnswer(parent, deliveryId, text, runtime)
|
|
|
139
148
|
return { queued_submission_id: null };
|
|
140
149
|
}, runtime?.timeout_ms);
|
|
141
150
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
const root = runtime ? runtime.hook_directory : codexHookDirectory();
|
|
152
|
+
const hook = root && readCodexHookConfig(root)?.enabled;
|
|
153
|
+
if (hook)
|
|
154
|
+
registerCodexHookInput(parent.codex_home, parent.thread_id, deliveryId, text, root);
|
|
155
|
+
try {
|
|
156
|
+
return await withCodexReceiver(parent, async (request) => {
|
|
157
|
+
const result = await request("thread/queue/add", {
|
|
158
|
+
threadId: parent.thread_id,
|
|
159
|
+
input: [{ type: "text", text, text_elements: [] }],
|
|
160
|
+
clientUserMessageId: deliveryId,
|
|
161
|
+
});
|
|
162
|
+
if (typeof result?.queuedSubmission?.id !== "string") {
|
|
163
|
+
throw new CodexDeliveryError("CODEX_RECEIVER_INVALID_RESPONSE", "キューの受付IDを確認できません", true);
|
|
164
|
+
}
|
|
165
|
+
return { queued_submission_id: result.queuedSubmission.id };
|
|
166
|
+
}, runtime);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
if (hook)
|
|
170
|
+
finishCodexHookSubmission(parent.codex_home, parent.thread_id, deliveryId, root);
|
|
171
|
+
}
|
|
153
172
|
}
|
package/dist/parent-delivery.js
CHANGED
|
@@ -9,6 +9,7 @@ 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
11
|
import { AitermError } from "./errors.js";
|
|
12
|
+
import { codexHookDeliveryState } from "./codex-hook-state.js";
|
|
12
13
|
const recordSchema = z.object({
|
|
13
14
|
schema: z.literal("aiterm.parent-delivery.v1"),
|
|
14
15
|
delivery_id: z.uuid(),
|
|
@@ -53,9 +54,12 @@ function readRecord(file) {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
function receipt(record) {
|
|
56
|
-
|
|
57
|
+
const hookState = !isClaude(record.parent) && record.state === "submitted"
|
|
58
|
+
? codexHookDeliveryState(record.parent.codex_home, record.parent.thread_id, record.delivery_id) : null;
|
|
59
|
+
return { delivery_id: record.delivery_id, state: hookState ?? record.state, child_outcome: record.child_outcome,
|
|
57
60
|
child_turn_id: record.child_turn_id, queued_submission_id: record.queued_submission_id,
|
|
58
|
-
error_code:
|
|
61
|
+
error_code: hookState === "unknown" ? "CODEX_HOOK_DELIVERY_UNCONFIRMED"
|
|
62
|
+
: record.error ? /^[A-Z][A-Z0-9_]+(?=:)/.exec(record.error)?.[0] ?? "PARENT_DELIVERY_FAILED" : null };
|
|
59
63
|
}
|
|
60
64
|
function answerMessage(record) {
|
|
61
65
|
return "Aitermの子エージェントからの実行結果です。子の回答として扱ってください。\n" +
|
package/dist/setup-cli.js
CHANGED
|
@@ -4,8 +4,7 @@ import { removeClaudeParentHooks } 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";
|
|
7
|
-
import { configureCodexSteer } from "./setup-codex-
|
|
8
|
-
import { readRelayConfig } from "./codex-relay-config.js";
|
|
7
|
+
import { configureCodexSteer, codexSteerSelected } from "./setup-codex-hooks.js";
|
|
9
8
|
const args = process.argv.slice(2);
|
|
10
9
|
if (args.length === 1 && args[0] === "--remove-claude-parent-hooks") {
|
|
11
10
|
try {
|
|
@@ -18,7 +17,7 @@ if (args.length === 1 && args[0] === "--remove-claude-parent-hooks") {
|
|
|
18
17
|
}
|
|
19
18
|
}
|
|
20
19
|
else if (args.length === 1 && ["--help", "-h"].includes(args[0])) {
|
|
21
|
-
process.stdout.write("使い方: aiterm-setup [--json] [--codex-steer enable|disable|status]\n依存準備、AIへの登録、MCPと端末の実動作確認を行います。対話実行ではAiterm単品かCodex Desktop Steer付きかを選べます。SteerはmacOS・Windows対応で、初回はCodexの再起動が必要です。disable
|
|
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");
|
|
22
21
|
}
|
|
23
22
|
else {
|
|
24
23
|
try {
|
|
@@ -34,7 +33,7 @@ else {
|
|
|
34
33
|
}
|
|
35
34
|
const onlySteer = action === "disable" || action === "status";
|
|
36
35
|
if (!action && !json && process.stdin.isTTY && process.stderr.isTTY && ["darwin", "win32"].includes(process.platform)) {
|
|
37
|
-
const enabled =
|
|
36
|
+
const enabled = codexSteerSelected();
|
|
38
37
|
const prompt = createInterface({ input: process.stdin, output: process.stderr });
|
|
39
38
|
try {
|
|
40
39
|
const answer = (await prompt.question(`導入方法: 1=Aiterm単品(公式キュー配送)、2=Codex DesktopへSteerと終了後再開を有効化 [${enabled ? "2" : "1"}]: `)).trim();
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Steerの正規導入。公式hookを登録・承認してから、旧中継の起動差し替えを解除する。
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { withCodexReceiver } from "./codex-parent-receiver.js";
|
|
7
|
+
import { realCodexHome } from "./harnesses/codex.js";
|
|
8
|
+
import { SetupError } from "./setup-platform.js";
|
|
9
|
+
import { setupNodeExecutable } from "./setup-node.js";
|
|
10
|
+
import { readRelayConfig } from "./codex-relay-config.js";
|
|
11
|
+
import { configureCodexSteer as configureLegacyRelay, findDesktopBinary } from "./setup-codex-relay.js";
|
|
12
|
+
import { findWindowsCodexBinary } from "./windows-codex-setup.js";
|
|
13
|
+
import { readRuntimeProcesses } from "./process-runtime.js";
|
|
14
|
+
import { resolveWindowsPowerShell7 } from "./windows-powershell.js";
|
|
15
|
+
import { quotePowerShell } from "./windows-codex-state.js";
|
|
16
|
+
import { assertCodexHooksReady, codexHookDirectory, ownedCodexHooks, readCodexHookConfig, writeHookJson } from "./codex-hook-state.js";
|
|
17
|
+
export function codexSteerSelected() { return readCodexHookConfig()?.enabled === true || readRelayConfig()?.enabled === true; }
|
|
18
|
+
export function codexHookCommand(node, hook, directory, platform = process.platform) {
|
|
19
|
+
if (platform === "win32") {
|
|
20
|
+
const script = `& ${[node, hook, directory].map(quotePowerShell).join(" ")}; exit $LASTEXITCODE`;
|
|
21
|
+
return `"${resolveWindowsPowerShell7()}" -NoLogo -NoProfile -NonInteractive -EncodedCommand ${Buffer.from(script, "utf16le").toString("base64")}`;
|
|
22
|
+
}
|
|
23
|
+
return [node, hook, directory].map(value => "'" + value.replace(/'/g, "'\"'\"'") + "'").join(" ");
|
|
24
|
+
}
|
|
25
|
+
export function mergeCodexParentHooks(file, command, previousCommand) {
|
|
26
|
+
if (!fs.existsSync(file)) {
|
|
27
|
+
try {
|
|
28
|
+
if (fs.lstatSync(file).isSymbolicLink())
|
|
29
|
+
throw new SetupError("codex_hook_config_invalid", "hook設定symlinkの参照先がありません");
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
if (error.code !== "ENOENT")
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const target = fs.existsSync(file) ? fs.realpathSync(file) : file;
|
|
37
|
+
let current = {};
|
|
38
|
+
if (fs.existsSync(target)) {
|
|
39
|
+
try {
|
|
40
|
+
current = JSON.parse(fs.readFileSync(target, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new SetupError("codex_hook_config_invalid", "Codexのhook設定JSONを読めません");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const object = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
47
|
+
if (!object(current) || (current.hooks !== undefined && !object(current.hooks)))
|
|
48
|
+
throw new SetupError("codex_hook_config_invalid", "Codexのhooks設定を読めません");
|
|
49
|
+
const hooks = { ...current.hooks };
|
|
50
|
+
for (const event of ["PostToolUse", "Stop"]) {
|
|
51
|
+
const groups = hooks[event] ?? [];
|
|
52
|
+
if (!Array.isArray(groups) || groups.some(group => !object(group) || !Array.isArray(group.hooks)))
|
|
53
|
+
throw new SetupError("codex_hook_config_invalid", `${event}のhook設定を読めません`);
|
|
54
|
+
hooks[event] = groups.map(group => ({ ...group, hooks: group.hooks.filter((hook) => !(hook?.type === "command" && ((command !== null && hook.command === command) || (previousCommand && hook.command === previousCommand)))) })).filter(group => group.hooks.length);
|
|
55
|
+
if (command !== null)
|
|
56
|
+
hooks[event].push({ ...(event === "PostToolUse" ? { matcher: ".*" } : {}),
|
|
57
|
+
hooks: [{ type: "command", command, timeout: 20, ...(event === "PostToolUse" ? { additionalContextLimit: 0 } : {}) }] });
|
|
58
|
+
if (!hooks[event].length)
|
|
59
|
+
delete hooks[event];
|
|
60
|
+
}
|
|
61
|
+
const next = { ...current, hooks };
|
|
62
|
+
if (isDeepStrictEqual(current, next))
|
|
63
|
+
return false;
|
|
64
|
+
if (fs.existsSync(target))
|
|
65
|
+
fs.copyFileSync(target, `${target}.aiterm-backup`);
|
|
66
|
+
writeHookJson(target, next);
|
|
67
|
+
if (!isDeepStrictEqual(JSON.parse(fs.readFileSync(target, "utf8")), next))
|
|
68
|
+
throw new SetupError("codex_hook_readback_failed", "Codex hookの読戻しが一致しません");
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export async function verifyCodexHookRegistration(config, approve) {
|
|
72
|
+
fs.accessSync(config.node, fs.constants.X_OK);
|
|
73
|
+
fs.accessSync(config.hook, fs.constants.R_OK);
|
|
74
|
+
const file = path.join(config.codex_home, "hooks.json");
|
|
75
|
+
await withCodexReceiver({ thread_id: "00000000-0000-4000-8000-000000000000", codex_home: config.codex_home }, async (request) => {
|
|
76
|
+
const list = () => request("hooks/list", { cwds: [config.codex_home] });
|
|
77
|
+
const hooks = ownedCodexHooks(await list(), config.command, file);
|
|
78
|
+
if (approve) {
|
|
79
|
+
const edits = hooks.filter(hook => !hook.enabled || hook.trustStatus !== "trusted").flatMap(hook => {
|
|
80
|
+
if (typeof hook.key !== "string" || typeof hook.currentHash !== "string")
|
|
81
|
+
throw new SetupError("codex_hook_schema_unknown", "公式hookの承認情報を認識できません");
|
|
82
|
+
const key = `hooks.state.${JSON.stringify(hook.key)}`;
|
|
83
|
+
return [
|
|
84
|
+
{ keyPath: `${key}.trusted_hash`, value: hook.currentHash, mergeStrategy: "replace" },
|
|
85
|
+
{ keyPath: `${key}.enabled`, value: true, mergeStrategy: "replace" },
|
|
86
|
+
];
|
|
87
|
+
});
|
|
88
|
+
if (edits.length)
|
|
89
|
+
await request("config/batchWrite", { edits, filePath: path.join(config.codex_home, "config.toml") });
|
|
90
|
+
}
|
|
91
|
+
assertCodexHooksReady(await list(), config.command, file);
|
|
92
|
+
}, { executable: config.binary });
|
|
93
|
+
}
|
|
94
|
+
export async function configureCodexSteer(action = "status", overrides = {}) {
|
|
95
|
+
const runtime = { platform: process.platform, directory: codexHookDirectory(), codex_home: realCodexHome(),
|
|
96
|
+
node: process.execPath, hook: fileURLToPath(new URL("./codex-parent-hook.js", import.meta.url)),
|
|
97
|
+
findBinary: process.platform === "win32" ? findWindowsCodexBinary : findDesktopBinary,
|
|
98
|
+
processes: readRuntimeProcesses, legacy: readRelayConfig,
|
|
99
|
+
disableLegacy: () => configureLegacyRelay("disable"), verify: verifyCodexHookRegistration, ...overrides };
|
|
100
|
+
const previous = readCodexHookConfig(runtime.directory);
|
|
101
|
+
const legacy = runtime.legacy();
|
|
102
|
+
const file = path.join(action === "disable" && previous ? previous.codex_home : runtime.codex_home, "hooks.json");
|
|
103
|
+
const save = (config) => writeHookJson(path.join(runtime.directory, "config.json"), config);
|
|
104
|
+
const needsRestart = (config) => runtime.processes().some(process => config.stale_processes.some(stale => stale.pid === process.pid && stale.started_identity === process.started_identity));
|
|
105
|
+
if (action === "status") {
|
|
106
|
+
if (!previous?.enabled)
|
|
107
|
+
return legacy?.enabled ? { status: "failed", reason_code: "codex_steer_migration_required" } : { status: "disabled" };
|
|
108
|
+
await runtime.verify(previous, false);
|
|
109
|
+
return legacy?.enabled || needsRestart(previous) ? { status: "restart_required", reason_code: "codex_restart_required" } : { status: "ready" };
|
|
110
|
+
}
|
|
111
|
+
if (action === "disable") {
|
|
112
|
+
if (previous?.enabled) {
|
|
113
|
+
mergeCodexParentHooks(file, null, previous.command);
|
|
114
|
+
save({ ...previous, enabled: false });
|
|
115
|
+
}
|
|
116
|
+
if (legacy?.enabled)
|
|
117
|
+
return runtime.disableLegacy();
|
|
118
|
+
return previous?.enabled ? { status: "restart_required", reason_code: "codex_restart_required" } : { status: "disabled" };
|
|
119
|
+
}
|
|
120
|
+
if (!["darwin", "win32"].includes(runtime.platform))
|
|
121
|
+
return { status: "unsupported", reason_code: "codex_steer_platform_unsupported" };
|
|
122
|
+
if (previous?.enabled && fs.realpathSync(previous.codex_home) !== fs.realpathSync(runtime.codex_home))
|
|
123
|
+
throw new SetupError("codex_steer_configuration_conflict", "別のCODEX_HOMEでSteerが有効です。先に既存の選択を解除してください");
|
|
124
|
+
const node = setupNodeExecutable(runtime.node);
|
|
125
|
+
fs.accessSync(node, fs.constants.X_OK);
|
|
126
|
+
fs.accessSync(runtime.hook, fs.constants.R_OK);
|
|
127
|
+
const binary = runtime.findBinary();
|
|
128
|
+
const command = codexHookCommand(node, runtime.hook, runtime.directory, runtime.platform);
|
|
129
|
+
const changed = mergeCodexParentHooks(file, command, previous?.command);
|
|
130
|
+
const stale = !previous?.enabled || changed || legacy?.enabled
|
|
131
|
+
? runtime.processes().filter(row => row.executable === binary || row.command.startsWith(binary + " "))
|
|
132
|
+
.map(({ pid, started_identity }) => ({ pid, started_identity }))
|
|
133
|
+
: previous.stale_processes;
|
|
134
|
+
const config = { schema: "aiterm.codex-parent-hooks.v1", enabled: true,
|
|
135
|
+
codex_home: runtime.codex_home, binary, command, node, hook: runtime.hook, stale_processes: stale };
|
|
136
|
+
await runtime.verify(config, true);
|
|
137
|
+
// 解除が中断しても、次のenableで移行を続行できるよう所有情報を先に保存する。
|
|
138
|
+
save(config);
|
|
139
|
+
if (legacy?.enabled)
|
|
140
|
+
await runtime.disableLegacy();
|
|
141
|
+
return needsRestart(config) ? { status: "restart_required", reason_code: "codex_restart_required" } : { status: "ready" };
|
|
142
|
+
}
|
|
@@ -29,7 +29,7 @@ function getGui(key) {
|
|
|
29
29
|
throw new SetupError("codex_steer_environment_unavailable", "GUIの起動設定を確認できません");
|
|
30
30
|
return result.stdout.trim() || null;
|
|
31
31
|
}
|
|
32
|
-
function findDesktopBinary() {
|
|
32
|
+
export function findDesktopBinary() {
|
|
33
33
|
const search = spawnSync("/usr/bin/mdfind", ["kMDItemCFBundleIdentifier == 'com.openai.codex'"], { encoding: "utf8", timeout: 10_000 });
|
|
34
34
|
const candidates = [...new Set(["/Applications/Codex.app", "/Applications/ChatGPT.app", ...(search.status === 0 ? search.stdout.trim().split("\n") : [])])];
|
|
35
35
|
const found = candidates.filter(app => {
|
package/dist/setup.js
CHANGED
|
@@ -8,8 +8,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
8
8
|
import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import { prepareBackend, runSetupCommand, SetupError } from "./setup-platform.js";
|
|
10
10
|
import { configureIntegrations } from "./setup-integrations.js";
|
|
11
|
-
import { configureCodexSteer } from "./setup-codex-
|
|
12
|
-
import { readRelayConfig } from "./codex-relay-config.js";
|
|
11
|
+
import { configureCodexSteer, codexSteerSelected } from "./setup-codex-hooks.js";
|
|
13
12
|
import { setupNodeExecutable } from "./setup-node.js";
|
|
14
13
|
export function globalRegistration(run = runSetupCommand) {
|
|
15
14
|
const root = run(process.platform === "win32" ? "npm.cmd" : "npm", ["root", "-g"]).trim();
|
|
@@ -80,7 +79,7 @@ export async function runSetup(options = {}) {
|
|
|
80
79
|
result.status = "ready";
|
|
81
80
|
if (result.status === "ready") {
|
|
82
81
|
stage = "codex_steer";
|
|
83
|
-
result.codex_steer = await (options.steer ?? configureCodexSteer)(options.codex_steer ?? (
|
|
82
|
+
result.codex_steer = await (options.steer ?? configureCodexSteer)(options.codex_steer ?? (codexSteerSelected() ? "enable" : "status"));
|
|
84
83
|
if (["failed", "unsupported", "restart_required"].includes(result.codex_steer.status)) {
|
|
85
84
|
result.status = result.codex_steer.status;
|
|
86
85
|
result.reason_code = result.codex_steer.reason_code;
|
package/docs/DESIGN.md
CHANGED
|
@@ -68,49 +68,39 @@ Cursorのsubmitはadapterがextended keyboard protocolのEnterへ変換し、呼
|
|
|
68
68
|
宛先はCodexのMCP handshakeと各要求の`_meta.threadId`から取得する。modelが指定したIDや環境変数で代用しない。
|
|
69
69
|
単品導入の`src/codex-parent-receiver.ts`は同じ`CODEX_HOME`の公式app-serverへstdioで接続し、
|
|
70
70
|
`thread/read`と`thread/queue/list`で宛先を確認してから`thread/queue/add`へ本文をJSONで渡す。
|
|
71
|
-
Steerを選択したmacOS・Windows
|
|
72
|
-
`
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
`
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Windows
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
所有するprocessだけを終了し、公式serverの終了確認後に戻る。
|
|
105
|
-
両OSとも有効化前に元の起動設定を保存し、Aitermのlauncherを設定する。解除は元の値へ戻す。
|
|
106
|
-
旧Windows版で既存launcherを共有した設定は読取り可能だが、次のenableでAitermのlauncherへ移行する。
|
|
107
|
-
起動・配送・設定・診断は他製品のコード、設定、コマンドへ依存しない。
|
|
108
|
-
LinuxのSteer選択はunsupportedとする。通常の単品導入・queue配送は全対応OSで維持する。
|
|
109
|
-
|
|
110
|
-
単品導入のqueue配送は親の実行中turnを中断せず、親がidleになった後に処理される。
|
|
111
|
-
`parent_delivery`が配送IDと状態を返し、自動配送時の`wait_process`/`wait_command`はnullになる。
|
|
112
|
-
`pty_observe`の`parent_deliveries`で状態を確認できる。`submitted`は公式受信口の受付済みを示し、modelの読了を意味しない。
|
|
113
|
-
Steer配送の`queued_submission_id`はnullである。既存の配送record schemaは維持する。
|
|
71
|
+
Steerを選択したmacOS・Windowsでも配送の入口は公式キューとする。
|
|
72
|
+
`src/codex-parent-hooks.ts`の同期`PostToolUse`がAitermの回答を同じターンの文脈へ渡し、
|
|
73
|
+
`Stop`が最終応答の生成中に届いた回答で同じターンを継続する。
|
|
74
|
+
取り込まれていない回答は公式キューに残り、親がidleになった時に通常配送される。終了後の再開には約10秒かかる場合がある。
|
|
75
|
+
親を別processでload/resumeせず、modelや権限のoverrideを渡さない。native sub-agentは自動配送の親にしない。
|
|
76
|
+
|
|
77
|
+
`src/codex-hook-state.ts`は同じCodex home・thread・配送UUID・本文hashへの所有記録を保持する。
|
|
78
|
+
キューの全ページを読んでから所有分を取り出し、他の利用者入力には触れない。
|
|
79
|
+
同時hookの取得は本人専用ディレクトリ内の排他的hard linkで一つに決め、公式の削除結果がtrueの本文だけを出力する。
|
|
80
|
+
削除中はprocess開始識別子を記録する。中断と出力失敗は`unknown`で本文を残し、自動再送しない。
|
|
81
|
+
`parent_deliveries`はその状態と`CODEX_HOOK_DELIVERY_UNCONFIRMED`を表示する。
|
|
82
|
+
`emitted`はhookへの出力完了でありmodelの読了ではない。公式キューが先に通常配送した入力の所有記録は次のhookで整理する。
|
|
83
|
+
|
|
84
|
+
`aiterm-setup --codex-steer enable|disable|status`は`src/setup-codex-hooks.ts`が所有する。
|
|
85
|
+
`CODEX_HOME/hooks.json`の他の登録を保持し、専用の同期hookを追加する。
|
|
86
|
+
公式`hooks/list`から得た2件のkeyとhashだけを公式`config/batchWrite`で承認し、再読して有効・承認済みであることを確認する。
|
|
87
|
+
承認省略flagを恒久設定へ書かない。利用者の別hookを承認しない。
|
|
88
|
+
選択と配送の所有記録は`~/.config/aiterm-mcp/codex-parent-hooks/`に置く。
|
|
89
|
+
Nodeは`src/setup-node.ts`でHomebrewの同じformulaの`opt`へ正規化し、更新で消えるCellar実体を保存しない。
|
|
90
|
+
|
|
91
|
+
新規の起動差し替えは作らない。旧中継の設定は新しいhookの確認後に従来の解除処理で復元する。
|
|
92
|
+
macOSの専用LaunchAgent、Windowsのユーザー環境変数、所有外の値の保持は旧adapterが担う。
|
|
93
|
+
旧中継の互換読取りと移行用コードは残し、setupを実行するまでは旧設定の配送を維持する。
|
|
94
|
+
移行が中断した場合は保存済みの所有情報を使って次のsetupで継続し、未完了をreadyにしない。
|
|
95
|
+
有効化前から動いている公式processのPIDと開始識別子を保存し、それが残っている間はrestart_requiredとする。
|
|
96
|
+
`ready`は公式APIによるhook登録・承認の読戻しと、更新前processの終了を確認した状態である。
|
|
97
|
+
Codexの通常起動はAitermのNode、module、socketに依存しない。
|
|
98
|
+
Windowsでは公式Desktopが展開した実行ファイルを照合して配送用の公式APIへ接続する。Desktop更新後はsetupで再検出する。
|
|
99
|
+
LinuxのSteer選択はunsupportedとし、単品のキュー配送は全対応OSで維持する。
|
|
100
|
+
|
|
101
|
+
`parent_delivery`は配送IDと状態を返し、自動配送時の`wait_process`/`wait_command`はnullとなる。
|
|
102
|
+
`submitted`は公式キューの受付またはhook出力の完了を示す。キューIDはSteer相当の選択時も保持する。
|
|
103
|
+
長いStop継続入力はCodex自身のhook処理で抜粋と全文ファイルへの参照になる場合がある。
|
|
114
104
|
次の依頼へ進める前に前回の回答を保存し、harness所有記録を後の回答と取り違えない。
|
|
115
105
|
|
|
116
106
|
Codexの配送記録と本文はAiterm stateの`parent-deliveries`へ保存する。ownerのPIDと開始識別子で生存を判定し、
|
package/docs/RELEASE.md
CHANGED
|
@@ -35,19 +35,18 @@ Aitermのreleaseはこのrepositoryが所有する。`.github/workflows/product-
|
|
|
35
35
|
|
|
36
36
|
## 公開後smoke
|
|
37
37
|
|
|
38
|
-
Codex Steerを変更した場合は、公式バイナリを指定した`test/codex-
|
|
39
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Linuxの未対応Steer選択が理由付きで停止することもCIで確認する。
|
|
38
|
+
Codex Steerを変更した場合は、公式バイナリを指定した`test/codex-parent-hooks-official.test.mjs`で
|
|
39
|
+
通常stdio起動、公式hookの個別承認、同一ターン配送、終了後再開、hook消失、終了との競合、利用者キューの保持を確認する。
|
|
40
|
+
`AITERM_TEST_CODEX_BINARY`に公式Desktopの同梱実行ファイルを指定し、一時HOMEと模擬モデルで実行する。
|
|
41
|
+
試験は実認証と稼働中Desktopの設定を使わない。指定のないCIではこの実機試験をskipする。
|
|
42
|
+
公開packageの`aiterm-setup --json --codex-steer enable`で選択導入し、`restart_required`ならDesktopを完全再起動する。
|
|
43
|
+
再起動後に`aiterm-setup --codex-steer status`が`ready`となり、通常の親が同じターンで子の回答を受け取ることを確認する。
|
|
44
|
+
アプリ内ツールと通常起動も確認し、公式バイナリの隔離試験だけでDesktop統合を完了扱いしない。
|
|
45
|
+
|
|
46
|
+
旧中継の移行を変更した場合は`test/setup-codex-relay.test.mjs`と`test/windows-codex.test.mjs`で
|
|
47
|
+
元の起動設定・専用LaunchAgent・ユーザー環境変数の復元と、所有外の値の保持を確認する。
|
|
48
|
+
新しいhookの読戻し失敗では旧設定を解除しないこと、解除中断では再実行で移行を完了できることも確認する。
|
|
49
|
+
新方式の登録と配送stateは`test/setup-codex-hooks.test.mjs`と`test/codex-parent-hooks.test.mjs`が検証する。
|
|
51
50
|
|
|
52
51
|
setupを変更した場合は、公開packageのglobal install後に`aiterm-setup --json`を実行し、
|
|
53
52
|
端末実行と検出した各AIの登録結果を確認する。初回と再実行は一時設定領域でも試験し、所有外の設定保持を確かめる。
|
|
@@ -76,10 +75,10 @@ aiterm-setup --json
|
|
|
76
75
|
|
|
77
76
|
巻き戻しは既知の正常版を指定する。
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
macOS
|
|
78
|
+
新しいCodex親hookを持たない旧版へ戻す時は、install前に`aiterm-setup --codex-steer disable`を実行する。
|
|
79
|
+
専用hookの解除を確認し、Codexを完全再起動する。旧中継が残っている場合は、macOSの専用LaunchAgentとWindowsのユーザー環境変数も復元する。
|
|
81
80
|
互換launcherを共有していた場合はAitermのSteer選択だけを解除し、所有外の起動設定を保持する。
|
|
82
|
-
回答record schema
|
|
81
|
+
回答record schemaは従来と共通であり、新方式のSteer相当配送も`queued_submission_id`を保持する。
|
|
83
82
|
|
|
84
83
|
Claude親配送hookのない旧版へ戻す場合は、旧版のinstall前に`aiterm-setup --remove-claude-parent-hooks`を
|
|
85
84
|
実行する。Aiterm専用の3 hookだけを解除し、他製品のhookと設定を保持する。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiterm-mcp",
|
|
3
|
-
"version": "0.37.
|
|
3
|
+
"version": "0.37.5",
|
|
4
4
|
"mcpName": "io.github.kitepon/aiterm-mcp",
|
|
5
5
|
"description": "Persistent terminal MCP with one harness-based launcher for Claude Code, Codex CLI, Grok CLI, and Cursor Agent CLI, plus durable PTYs for SSH, containers, and REPLs.",
|
|
6
6
|
"keywords": [
|