aiterm-mcp 0.42.1 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.43.0] - 2026-09-29
11
+
12
+ ### 変更
13
+
14
+ - 親への配送(Codexの公式キュー+同期hook、Claude CodeのasyncRewake hook、Cursorのhook+背景受信)の本体を、共通パッケージ[aiterm-steer-delivery](https://github.com/kitepon/aiterm-steer-delivery)へ移した。保存場所、hook command、entrypointのファイル名、案内文、配送の挙動は変わらない。旧中継(relay)はAitermに残す。他の製品(Peertable、gpt-connector)も同じパッケージで配送する。判断はrepositoryの`docs/adr/0073-parent-delivery-module.md`。
15
+ - Codex Steerの導入で、導入前から動いているCodexを再起動待ちとして見分ける時、引用符付きのコマンド行も照合する(Windowsで見落としていた)。
16
+
17
+ ## [0.42.2] - 2026-09-28
18
+
19
+ ### 修正
20
+
21
+ - Codexの利用上限を、pane logの末尾16KBに「You've hit your usage limit」があるかで判定していた。上限が明けた後の古い知らせだけでなく、道具の出力や依頼文にこの文字が入っただけでも`rate_limited`と誤判定していた(BellTeamの記録で4件)。Codexはturnが上限で終わると`task_complete`の`error`へ本文と`codex_error_info: "usage_limit_exceeded"`を書くので、完了待ちの中でこれを見て`rate_limited`を返す。`token_count`の`used_percent`は100%のまま返事が続くことがあるので使わない。判断はrepositoryの`docs/adr/0072-claude-codex-usage-limit.md`。
22
+
23
+ ### 変更
24
+
25
+ - BellTeam向けの合間の言葉に、Codexがturnをエラーで終えた時の本文を`kind: "error"`で含める。
26
+
10
27
  ## [0.42.1] - 2026-09-28
11
28
 
12
29
  ### 修正
@@ -1844,7 +1861,9 @@ prototype (preserved under `prototype/python/` as the porting source and referen
1844
1861
  `ubuntu-latest` for Node 18/20/22, publishing to npm on `v*` tags with
1845
1862
  provenance.
1846
1863
 
1847
- [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.42.1...HEAD
1864
+ [Unreleased]: https://github.com/kitepon/aiterm-mcp/compare/v0.43.0...HEAD
1865
+ [0.43.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.42.2...v0.43.0
1866
+ [0.42.2]: https://github.com/kitepon/aiterm-mcp/compare/v0.42.1...v0.42.2
1848
1867
  [0.42.1]: https://github.com/kitepon/aiterm-mcp/compare/v0.42.0...v0.42.1
1849
1868
  [0.42.0]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.3...v0.42.0
1850
1869
  [0.41.3]: https://github.com/kitepon/aiterm-mcp/compare/v0.41.2...v0.41.3
package/README.ja.md CHANGED
@@ -203,7 +203,7 @@ runtime-error store は canonical dotagents config の `collection.enabled: true
203
203
  場合だけ収集し、既定OFF、network送信は行いません。tag起点CIのnpm provenance(OIDC Trusted
204
204
  Publishing)で公開し、GitHub Release が Official MCP Registry を再登録します。
205
205
 
206
- **状態:** 開発継続中 · 現行公開版 **v0.42.1** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
206
+ **状態:** 開発継続中 · 現行公開版 **v0.43.0** · 動作対象は Linux · WSL2 · macOS · Windows ネイティブ · MIT · [変更履歴](CHANGELOG.md)。
207
207
 
208
208
  ### 更新と巻き戻し
209
209
 
@@ -259,7 +259,7 @@ ClaudeがAPIエラーや安全判定の拒否で終了した時は、Stop hook
259
259
 
260
260
  Grokの無人起動は公式`--trust`で指定された作業フォルダを信頼登録し、確認画面を完了してから初回promptを送る。この登録はGrok CLIの信頼ストアへ保存され、フォルダ内のhook・MCP・LSPにも適用される。read-only sandboxの制限は維持する。画面に残る完了済みhookの結果は実行中と判定しない。
261
261
 
262
- Grokで終了済みターンのweekly-limitパネルが残っている場合、次の通常`pty_send`が`Shift+X`で一度閉じ、入力受付を確認して今回の本文を送る。同じsessionと会話を保ち、receiptの`pane_input_recovery`に`grok_rate_limit_dialog_dismissed`を記録する。ターン未終了・harness不在は`GROK_RATE_LIMIT_RECOVERY_BLOCKED`、解除後の入力受付失敗は`GROK_RATE_LIMIT_RECOVERY_FAILED`となり、本文は未送信。上限の継続は`rate_limited`として返し、過去promptは再送しない。Grokの上限観測には現在の画面だけを使う。
262
+ Grokで終了済みターンのweekly-limitパネルが残っている場合、次の通常`pty_send`が`Shift+X`で一度閉じ、入力受付を確認して今回の本文を送る。同じsessionと会話を保ち、receiptの`pane_input_recovery`に`grok_rate_limit_dialog_dismissed`を記録する。ターン未終了・harness不在は`GROK_RATE_LIMIT_RECOVERY_BLOCKED`、解除後の入力受付失敗は`GROK_RATE_LIMIT_RECOVERY_FAILED`となり、本文は未送信。上限の継続は`rate_limited`として返し、過去promptは再送しない。Grokの上限観測には現在の画面だけを使う。Claude Codeの上限は現在の画面の入力欄の下に出る知らせで、Codexの上限はturnを終えた記録(`task_complete`の`codex_error_info: "usage_limit_exceeded"`)で見分ける。pane logや道具の出力に残る上限の文字では判定しない。
263
263
 
264
264
  Cursorの送信前hook(`beforeSubmitPrompt`と、互換読込するClaude Codeの`UserPromptSubmit`)がpromptを拒否すると、Cursorはpromptを捨て、turnも完了も起きない。Aitermはこの拒否の表示を見分け、起動時promptは`initial_prompt=failed`、`pty_send`は成功receiptを返さず、どちらも`USER_HOOK_BLOCKED`とhookの出力を返す。確認時間(3秒)より後の拒否は、完了待ちが`outcome=error`(`aiterm-wait`はexit 7)で返す。
265
265
 
package/README.md CHANGED
@@ -217,7 +217,7 @@ collection is off by default and performs no network I/O. It ships via
217
217
  tag-triggered CI with npm provenance (OIDC Trusted Publishing); the GitHub
218
218
  Release re-registers the Official MCP Registry entry.
219
219
 
220
- **Status:** actively maintained · current public release **v0.42.1** · 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
+ **Status:** actively maintained · current public release **v0.43.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).
221
221
 
222
222
  ### Update and rollback
223
223
 
@@ -280,7 +280,7 @@ The same primitive hosts another agent's TUI. `agent_launch` starts a selected e
280
280
 
281
281
  Grokの無人起動は公式`--trust`で指定された作業フォルダを信頼登録し、確認画面を完了してから初回promptを送る。この登録はGrok CLIの信頼ストアへ保存され、フォルダ内のhook・MCP・LSPにも適用される。read-only sandboxの制限は維持する。画面に残る完了済みhookの結果は実行中と判定しない。
282
282
 
283
- Grokで終了済みターンのweekly-limitパネルが残っている場合、次の通常`pty_send`が`Shift+X`で一度閉じ、入力受付を確認して今回の本文を送る。同じsessionと会話を保ち、receiptの`pane_input_recovery`に`grok_rate_limit_dialog_dismissed`を記録する。ターン未終了・harness不在は`GROK_RATE_LIMIT_RECOVERY_BLOCKED`、解除後の入力受付失敗は`GROK_RATE_LIMIT_RECOVERY_FAILED`となり、本文は未送信。上限の継続は`rate_limited`として返し、過去promptは再送しない。Grokの上限観測には現在の画面だけを使う。
283
+ Grokで終了済みターンのweekly-limitパネルが残っている場合、次の通常`pty_send`が`Shift+X`で一度閉じ、入力受付を確認して今回の本文を送る。同じsessionと会話を保ち、receiptの`pane_input_recovery`に`grok_rate_limit_dialog_dismissed`を記録する。ターン未終了・harness不在は`GROK_RATE_LIMIT_RECOVERY_BLOCKED`、解除後の入力受付失敗は`GROK_RATE_LIMIT_RECOVERY_FAILED`となり、本文は未送信。上限の継続は`rate_limited`として返し、過去promptは再送しない。Grokの上限観測には現在の画面だけを使う。Claude Codeの上限は現在の画面の入力欄の下に出る知らせで、Codexの上限はturnを終えた記録(`task_complete`の`codex_error_info: "usage_limit_exceeded"`)で見分ける。pane logや道具の出力に残る上限の文字では判定しない。
284
284
 
285
285
  When a Cursor pre-submit hook (`beforeSubmitPrompt`, or a Claude Code `UserPromptSubmit` hook that Cursor loads for compatibility) rejects the prompt, Cursor drops it and no turn or completion follows. Aiterm recognizes the rejection: an initial prompt returns `initial_prompt=failed`, and `pty_send` returns an error instead of a success receipt, both with `USER_HOOK_BLOCKED` and the hook's output. A rejection that comes after the 3-second start check is reported by the completion wait as `outcome=error` (`aiterm-wait` exit 7).
286
286
 
@@ -1,28 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // Claude Codeが直接起動する公式hook。MCP stdioとは別processで本文をstderrへ返す。
3
- import { prepareClaudeHookRequest, runClaudeResultHook, closeClaudeParentSession } from "./claude-parent-receiver.js";
4
- async function main() {
5
- let input = "";
6
- process.stdin.setEncoding("utf8");
7
- for await (const chunk of process.stdin)
8
- input += chunk;
9
- const event = JSON.parse(input);
10
- switch (event.hook_event_name) {
11
- case "PreToolUse":
12
- prepareClaudeHookRequest(event);
13
- break;
14
- case "PostToolUse":
15
- process.exitCode = await runClaudeResultHook(event, text => new Promise((resolve, reject) => {
16
- process.stderr.write(text, error => error ? reject(error) : resolve());
17
- }));
18
- break;
19
- case "SessionEnd":
20
- closeClaudeParentSession(event);
21
- break;
22
- default: throw new Error("CLAUDE_PARENT_HOOK_EVENT_INVALID: 未対応のhookです");
23
- }
24
- }
25
- main().catch(error => {
26
- process.stderr.write(`${error instanceof Error ? error.message : "CLAUDE_PARENT_HOOK_FAILED"}\n`);
27
- process.exitCode = 2;
28
- });
2
+ // Claude Codeが直接起動する公式hook。MCP stdioとは別processで本文をstderrへ返す。本体はaiterm-steer-delivery。
3
+ import { runClaudeHookMain } from "aiterm-steer-delivery";
4
+ import { AITERM_PROFILE } from "./steer-profile.js";
5
+ await runClaudeHookMain(AITERM_PROFILE);
@@ -1,179 +1,27 @@
1
- // Claude Codeの公式hookを受信口にする。待機processはharnessが所有し、親のturnを止めない。
2
- import * as fs from "node:fs";
1
+ // Claude Codeの公式hookを受信口にする。待機processはharnessが所有し、親のturnを止めない。本体はaiterm-steer-delivery。
3
2
  import * as path from "node:path";
4
3
  import { z } from "zod";
5
- import { ensureStateRoot, waitForFileState, writeJson0600 } from "./agent-shared.js";
6
- import { readRuntimeProcesses } from "./process-runtime.js";
7
- import { AitermError } from "./errors.js";
8
- const requestId = z.string().regex(/^[A-Za-z0-9_-]{1,160}$/);
9
- const invocationSchema = z.object({
10
- request_id: requestId, session_id: z.uuid(), agent_id: z.string().nullable(),
11
- parent_pid: z.number().int().positive(), parent_started_identity: z.string(),
12
- }).strict();
4
+ import * as steer from "aiterm-steer-delivery";
5
+ import { ensureStateRoot } from "./agent-shared.js";
6
+ import { AITERM_PROFILE } from "./steer-profile.js";
7
+ // 配送記録の検証はAitermのzodで行う(パッケージのschemaと同じ形)。
13
8
  export const claudeParentSchema = z.object({
14
- kind: z.literal("claude"), request_id: requestId, session_id: z.uuid(), hook_root: z.string(),
9
+ kind: z.literal("claude"), request_id: z.string().regex(/^[A-Za-z0-9_-]{1,160}$/), session_id: z.uuid(), hook_root: z.string(),
15
10
  }).strict();
16
- export class ClaudeDeliveryError 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
- }
11
+ export const ClaudeDeliveryError = steer.ClaudeDeliveryError;
25
12
  function defaultRoot() { return path.join(ensureStateRoot(), "claude-parent-hooks"); }
26
- function processIdentity(pid) { return readRuntimeProcesses().find(entry => entry.pid === pid)?.started_identity; }
27
- function directory(parent) { return path.join(parent.hook_root, parent.request_id); }
28
- function readInvocation(parent) {
29
- let value;
30
- try {
31
- value = invocationSchema.parse(JSON.parse(fs.readFileSync(path.join(directory(parent), "request.json"), "utf8")));
32
- }
33
- catch {
34
- throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_UNAVAILABLE", "親のhook記録がありません。aiterm-setupを実行し、Claude Codeのhookを有効にしてください");
35
- }
36
- if (value.request_id !== parent.request_id || value.session_id !== parent.session_id) {
37
- throw new ClaudeDeliveryError("CLAUDE_PARENT_SESSION_MISMATCH", "要求とhookの会話が一致しません");
38
- }
39
- return value;
40
- }
41
- function assertOpen(parent) {
42
- if (fs.existsSync(path.join(directory(parent), "closed.json"))) {
43
- throw new ClaudeDeliveryError("CLAUDE_PARENT_SESSION_CLOSED", "依頼元の会話は終了しました。回答を別の会話へ送っていません");
44
- }
45
- }
46
- export function prepareClaudeHookRequest(input, root = defaultRoot()) {
47
- const event = z.object({ session_id: z.uuid(), tool_use_id: requestId, agent_id: z.string().optional() }).parse(input);
48
- const identity = processIdentity(process.ppid);
49
- if (!identity)
50
- throw new ClaudeDeliveryError("CLAUDE_PARENT_PROCESS_UNAVAILABLE", "hookを起動した親processを確認できません");
51
- const dir = path.join(root, event.tool_use_id);
52
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
53
- writeJson0600(path.join(dir, "request.json"), {
54
- request_id: event.tool_use_id, session_id: event.session_id, agent_id: event.agent_id ?? null,
55
- parent_pid: process.ppid, parent_started_identity: identity,
56
- });
57
- }
13
+ export function prepareClaudeHookRequest(input, root = defaultRoot()) { steer.prepareClaudeHookRequest(input, root); }
58
14
  /** 起動時envのsession IDは/clearで古くなるため、実際のPreToolUseとの相関だけを使う。 */
59
15
  export function claudeParentFromRequest(clientName, metadata, root = defaultRoot()) {
60
- if (clientName !== "claude-code")
61
- return null;
62
- const parsed = requestId.safeParse(metadata?.["claudecode/toolUseId"]);
63
- if (!parsed.success)
64
- throw new ClaudeDeliveryError("CLAUDE_PARENT_ID_UNAVAILABLE", "MCP要求にtoolUseIdがありません。対応するClaude Codeへ更新してください");
65
- let invocation;
66
- try {
67
- invocation = invocationSchema.parse(JSON.parse(fs.readFileSync(path.join(root, parsed.data, "request.json"), "utf8")));
68
- }
69
- catch {
70
- throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_UNAVAILABLE", "親のPreToolUse hookを確認できません。aiterm-setupを実行し、hookを有効にしてください");
71
- }
72
- const parent = { kind: "claude", request_id: parsed.data, session_id: invocation.session_id, hook_root: root };
73
- verifyClaudeParent(parent);
74
- return parent;
75
- }
76
- export function verifyClaudeParent(parent) {
77
- const invocation = readInvocation(parent);
78
- if (invocation.agent_id)
79
- throw new ClaudeDeliveryError("CLAUDE_PARENT_SUBAGENT_UNSUPPORTED", "agent_id付きのClaude会話(--agent起動またはnative subagent)への自動配送には対応していません");
80
- assertOpen(parent);
81
- }
82
- export function bindClaudeParentDelivery(parent, deliveryId) {
83
- verifyClaudeParent(parent);
84
- writeJson0600(path.join(directory(parent), "delivery.json"), { delivery_id: z.uuid().parse(deliveryId) });
16
+ return steer.claudeParentFromRequest(AITERM_PROFILE, clientName, metadata, root);
85
17
  }
18
+ export function verifyClaudeParent(parent) { steer.verifyClaudeParent(AITERM_PROFILE, parent); }
19
+ export function bindClaudeParentDelivery(parent, deliveryId) { steer.bindClaudeParentDelivery(AITERM_PROFILE, parent, deliveryId); }
86
20
  /** SessionEndはその時点の依頼だけを終了する。同じ会話をresumeした新規依頼は別requestになる。 */
87
- export function closeClaudeParentSession(input, root = defaultRoot()) {
88
- const { session_id } = z.object({ session_id: z.uuid() }).parse(input);
89
- if (!fs.existsSync(root))
90
- return;
91
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
92
- if (!entry.isDirectory())
93
- continue;
94
- const file = path.join(root, entry.name, "request.json");
95
- if (!fs.existsSync(file))
96
- continue;
97
- const invocation = invocationSchema.parse(JSON.parse(fs.readFileSync(file, "utf8")));
98
- if (invocation.session_id === session_id)
99
- writeJson0600(path.join(root, entry.name, "closed.json"), { session_id });
100
- }
101
- }
102
- function assertParentAlive(invocation) {
103
- if (processIdentity(invocation.parent_pid) !== invocation.parent_started_identity) {
104
- throw new ClaudeDeliveryError("CLAUDE_PARENT_PROCESS_CLOSED", "依頼元のClaude processは終了しました。回答は保存したままです");
105
- }
106
- }
107
- export async function submitClaudeParentAnswer(parent, deliveryId, text) {
108
- const invocation = readInvocation(parent);
109
- const dir = directory(parent);
110
- // 会話終了でも確定本文を失わない。終了判定より先に保存する。
111
- writeJson0600(path.join(dir, "answer.json"), { delivery_id: deliveryId, text });
112
- await waitForFileState(dir, () => {
113
- const emitted = path.join(dir, "emitted.json");
114
- if (fs.existsSync(emitted)) {
115
- const value = JSON.parse(fs.readFileSync(emitted, "utf8"));
116
- if (value.delivery_id !== deliveryId)
117
- throw new ClaudeDeliveryError("CLAUDE_PARENT_DELIVERY_MISMATCH", "hookの配送IDが一致しません", true);
118
- return true;
119
- }
120
- assertOpen(parent);
121
- const failed = path.join(dir, "failed.json");
122
- if (fs.existsSync(failed)) {
123
- const value = JSON.parse(fs.readFileSync(failed, "utf8"));
124
- throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_FAILED", "親へのhook出力が失敗しました。回答は保存したままです", value.outcome_unknown === true);
125
- }
126
- const hookFile = path.join(dir, "hook.json");
127
- if (fs.existsSync(hookFile)) {
128
- const hook = z.object({ pid: z.number().int().positive(), started_identity: z.string() }).parse(JSON.parse(fs.readFileSync(hookFile, "utf8")));
129
- if (processIdentity(hook.pid) !== hook.started_identity) {
130
- throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_CLOSED", "親の受信hookが終了しました。自動再送はしていません", fs.existsSync(path.join(dir, "sending.json")));
131
- }
132
- }
133
- assertParentAlive(invocation);
134
- return undefined;
135
- });
136
- return { queued_submission_id: null };
21
+ export function closeClaudeParentSession(input, root = defaultRoot()) { steer.closeClaudeParentSession(input, root); }
22
+ export function submitClaudeParentAnswer(parent, deliveryId, text) {
23
+ return steer.submitClaudeParentAnswer(AITERM_PROFILE, parent, deliveryId, text);
137
24
  }
138
- export async function runClaudeResultHook(input, emit, root = defaultRoot()) {
139
- const event = z.object({ session_id: z.uuid(), tool_use_id: requestId }).parse(input);
140
- const parent = { kind: "claude", request_id: event.tool_use_id, session_id: event.session_id, hook_root: root };
141
- const invocation = readInvocation(parent);
142
- const dir = directory(parent);
143
- const binding = path.join(dir, "delivery.json");
144
- if (!fs.existsSync(binding)) {
145
- fs.rmSync(dir, { recursive: true });
146
- return 0;
147
- }
148
- const deliveryId = z.uuid().parse(JSON.parse(fs.readFileSync(binding, "utf8")).delivery_id);
149
- const identity = processIdentity(process.pid);
150
- if (!identity)
151
- throw new ClaudeDeliveryError("CLAUDE_PARENT_HOOK_UNAVAILABLE", "受信hookのprocessを確認できません");
152
- writeJson0600(path.join(dir, "hook.json"), { pid: process.pid, started_identity: identity });
153
- try {
154
- const answer = await waitForFileState(dir, () => {
155
- if (fs.existsSync(path.join(dir, "closed.json")))
156
- return null;
157
- assertParentAlive(invocation);
158
- const file = path.join(dir, "answer.json");
159
- if (!fs.existsSync(file))
160
- return undefined;
161
- const value = z.object({ delivery_id: z.uuid(), text: z.string() }).strict().parse(JSON.parse(fs.readFileSync(file, "utf8")));
162
- if (value.delivery_id !== deliveryId)
163
- throw new ClaudeDeliveryError("CLAUDE_PARENT_DELIVERY_MISMATCH", "保存された回答の配送IDが一致しません");
164
- return value;
165
- });
166
- if (!answer)
167
- return 0;
168
- assertOpen(parent);
169
- writeJson0600(path.join(dir, "sending.json"), { delivery_id: deliveryId });
170
- await emit(answer.text);
171
- writeJson0600(path.join(dir, "emitted.json"), { delivery_id: deliveryId });
172
- // Claudeが定めるasyncRewakeの再開信号。子の成功/失敗は本文のoutcomeで区別する。
173
- return 2;
174
- }
175
- catch (error) {
176
- writeJson0600(path.join(dir, "failed.json"), { outcome_unknown: fs.existsSync(path.join(dir, "sending.json")) });
177
- throw error;
178
- }
25
+ export function runClaudeResultHook(input, emit, root = defaultRoot()) {
26
+ return steer.runClaudeResultHook(AITERM_PROFILE, input, emit, root);
179
27
  }
@@ -1,10 +1 @@
1
- import { AitermError } from "./errors.js";
2
- export class CodexDeliveryError extends AitermError {
3
- delivery_code;
4
- outcome_unknown;
5
- constructor(delivery_code, message, outcome_unknown = false) {
6
- super(`${delivery_code}: ${message}`, 2);
7
- this.delivery_code = delivery_code;
8
- this.outcome_unknown = outcome_unknown;
9
- }
10
- }
1
+ export { CodexDeliveryError } from "aiterm-steer-delivery";
@@ -1,27 +1,6 @@
1
- // Codex DesktopのSteerで使う同梱Codex CLIの場所。Desktopは更新のたびに同梱物の場所を変えることがある
2
- // (Windowsは版ごとのcache directory、macOSはbundle内の配置)。setup時に保存した場所が消えていたら、
3
- // 使う時点で公式Desktopから探し直して設定を更新する。探せなければ理由付きで失敗し、別のCodexへは切り替えない。
4
- import * as fs from "node:fs";
5
- import * as path from "node:path";
6
- import { CodexDeliveryError } from "./codex-delivery-error.js";
7
- import { codexHookDirectory, writeHookJson } from "./codex-hook-state.js";
8
- async function platformFinder() {
9
- if (process.platform === "win32")
10
- return (await import("./windows-codex-setup.js")).findWindowsCodexBinary;
11
- return (await import("./setup-codex-relay.js")).findDesktopBinary;
12
- }
1
+ // Codex DesktopのSteerで使う同梱Codex CLIの場所。消えていたら使う時点で探し直す。本体はaiterm-steer-delivery。
2
+ import * as steer from "aiterm-steer-delivery";
3
+ import { AITERM_PROFILE } from "./steer-profile.js";
13
4
  export async function currentCodexDesktopBinary(config, options = {}) {
14
- const exists = options.exists ?? fs.existsSync;
15
- if (exists(config.binary))
16
- return config.binary;
17
- let binary;
18
- try {
19
- binary = (options.find ?? await platformFinder())();
20
- }
21
- catch (error) {
22
- throw new CodexDeliveryError("CODEX_DESKTOP_BINARY_MOVED", `Codex Desktopの更新で ${config.binary} が無くなり、新しい場所も特定できません(${error instanceof Error ? error.message : String(error)})。` +
23
- "Codex Desktopを起動してから aiterm-setup --codex-steer enable を実行してください");
24
- }
25
- writeHookJson(path.join(options.directory ?? codexHookDirectory(), "config.json"), { ...config, binary });
26
- return binary;
5
+ return steer.currentCodexDesktopBinary(AITERM_PROFILE, config, options);
27
6
  }
@@ -1,126 +1,19 @@
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"); }
1
+ // Codexの設定領域にはhook登録だけを置き、配送の所有情報はAitermが保持する。本体はaiterm-steer-delivery。
2
+ import * as steer from "aiterm-steer-delivery";
3
+ import { AITERM_PROFILE } from "./steer-profile.js";
4
+ export const codexHookConfigSchema = steer.codexHookConfigSchema(AITERM_PROFILE);
5
+ export const { writeHookJson, codexInputDirectory, hookInputSchema, answerDigest } = steer;
6
+ export function codexHookDirectory() { return steer.codexHookDirectory(AITERM_PROFILE); }
7
+ export function readCodexHookConfig(directory = codexHookDirectory()) { return steer.readCodexHookConfig(AITERM_PROFILE, directory); }
47
8
  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 });
9
+ steer.registerCodexHookInput(home, thread, id, text, root);
60
10
  }
11
+ export function finishCodexHookSubmission(home, thread, id, root) { steer.finishCodexHookSubmission(home, thread, id, root); }
61
12
  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
- }
13
+ return steer.codexHookDeliveryState(home, thread, id, root);
105
14
  }
15
+ export function ownedCodexHooks(response, command, file) { return steer.ownedCodexHooks(AITERM_PROFILE, response, command, file); }
16
+ export function assertCodexHooksReady(response, command, file) { steer.assertCodexHooksReady(AITERM_PROFILE, response, command, file); }
106
17
  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
- }
18
+ steer.assertCodexHookParentCurrent(AITERM_PROFILE, config, rows, pid);
126
19
  }
@@ -1,15 +1,5 @@
1
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
- }
2
+ // Codexが起動する同期hook(PostToolUse/Stop)。stdoutはCodexの公式hook出力だけに使う。本体はaiterm-steer-delivery。
3
+ import { runCodexHookMain } from "aiterm-steer-delivery";
4
+ import { AITERM_PROFILE } from "./steer-profile.js";
5
+ await runCodexHookMain(AITERM_PROFILE, process.argv[2]);
@@ -1,113 +1,6 @@
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 { currentCodexDesktopBinary } from "./codex-desktop-binary.js";
10
- import { answerDigest, codexHookDirectory, codexInputDirectory, hookInputSchema, readCodexHookConfig, writeHookJson } from "./codex-hook-state.js";
1
+ // 親の同期hookだけが、公式キューのAiterm回答を同じターンの入力へ移す。本体はaiterm-steer-delivery。
2
+ import * as steer from "aiterm-steer-delivery";
3
+ import { AITERM_PROFILE } from "./steer-profile.js";
11
4
  export async function runCodexResultHook(input, emit, options = {}) {
12
- const event = z.object({ session_id: z.uuid(), turn_id: z.string().min(1),
13
- hook_event_name: z.enum(["PostToolUse", "Stop"]) }).parse(input);
14
- const root = options.directory ?? codexHookDirectory();
15
- const config = readCodexHookConfig(root);
16
- if (!config?.enabled) {
17
- await emit({});
18
- return;
19
- }
20
- const home = options.codex_home ?? realCodexHome();
21
- if (fs.realpathSync(home) !== fs.realpathSync(config.codex_home)) {
22
- throw new CodexDeliveryError("CODEX_HOOK_HOME_MISMATCH", "hookと配送設定のCodex環境が一致しません");
23
- }
24
- const directory = codexInputDirectory(root, home, event.session_id);
25
- const pending = path.join(directory, "pending");
26
- if (!fs.existsSync(pending) || !fs.readdirSync(pending).length) {
27
- await emit({});
28
- return;
29
- }
30
- const claims = path.join(directory, "claims");
31
- fs.mkdirSync(claims, { recursive: true, mode: 0o700 });
32
- const taken = [];
33
- const identity = readRuntimeProcesses().find(row => row.pid === process.pid);
34
- if (!identity)
35
- throw new CodexDeliveryError("CODEX_HOOK_PROCESS_UNAVAILABLE", "受信hookのprocessを確認できません");
36
- try {
37
- await withCodexReceiver({ thread_id: event.session_id, codex_home: home }, async (request) => {
38
- // 削除でoffsetが動くため、全ページを読んでから取り出す。
39
- const entries = [];
40
- let cursor = null;
41
- do {
42
- const page = await request("thread/queue/list", { threadId: event.session_id, cursor, limit: 100 });
43
- if (!Array.isArray(page?.data) || !(page.nextCursor === null || typeof page.nextCursor === "string")) {
44
- throw new CodexDeliveryError("CODEX_HOOK_QUEUE_INVALID", "公式キューの一覧応答を認識できません");
45
- }
46
- entries.push(...page.data);
47
- cursor = page.nextCursor;
48
- } while (cursor);
49
- const queued = new Set(entries.map(entry => entry.clientUserMessageId));
50
- // 送信processが終了し、キューに残っていない所有記録を消す。投入中は触らない。
51
- for (const name of fs.readdirSync(pending)) {
52
- const ack = path.join(directory, "settled", name);
53
- if (name.endsWith(".json") && !queued.has(name.slice(0, -5)) && fs.existsSync(ack)) {
54
- fs.rmSync(path.join(pending, name), { force: true });
55
- fs.rmSync(ack, { force: true });
56
- }
57
- }
58
- for (const entry of entries) {
59
- const id = z.uuid().safeParse(entry.clientUserMessageId);
60
- if (!id.success)
61
- continue;
62
- const source = path.join(pending, `${id.data}.json`);
63
- let owner;
64
- try {
65
- owner = hookInputSchema.parse(JSON.parse(fs.readFileSync(source, "utf8")));
66
- }
67
- catch (error) {
68
- if (error.code === "ENOENT")
69
- continue;
70
- throw error;
71
- }
72
- const text = entry.input?.length === 1 && entry.input[0].type === "text" ? entry.input[0].text : undefined;
73
- if (owner.delivery_id !== id.data || owner.thread_id !== event.session_id || typeof text !== "string" || answerDigest(text) !== owner.text_sha256) {
74
- throw new CodexDeliveryError("CODEX_HOOK_INPUT_CHANGED", "Aitermの配送記録とキュー本文が一致しません。取り出していません");
75
- }
76
- const file = path.join(claims, `${id.data}.json`);
77
- // 同時hookが同じ回答を取り出すことを防ぎ、中断したclaimは自動再送しない。
78
- try {
79
- fs.linkSync(source, file);
80
- fs.unlinkSync(source);
81
- }
82
- catch (error) {
83
- if (["ENOENT", "EEXIST"].includes(error.code ?? ""))
84
- continue;
85
- throw error;
86
- }
87
- fs.rmSync(path.join(directory, "settled", `${id.data}.json`), { force: true });
88
- const item = { file, delivery_id: id.data, text };
89
- taken.push(item);
90
- writeHookJson(file, { ...owner, text, state: "deleting", turn_id: event.turn_id, queued_submission_id: entry.id,
91
- pid: identity.pid, started_identity: identity.started_identity });
92
- const result = await request("thread/queue/delete", { threadId: event.session_id, queuedSubmissionId: entry.id });
93
- if (typeof result?.deleted !== "boolean")
94
- throw new CodexDeliveryError("CODEX_HOOK_QUEUE_INVALID", "キュー削除の結果を確認できません", true);
95
- if (!result.deleted) {
96
- writeHookJson(file, { ...owner, state: "not_in_queue", turn_id: event.turn_id });
97
- taken.pop();
98
- }
99
- }
100
- }, options.runtime ?? { executable: await currentCodexDesktopBinary(config), timeout_ms: 5_000 });
101
- const text = taken.map(item => item.text).join("\n\n");
102
- await emit(!taken.length ? {} : event.hook_event_name === "Stop"
103
- ? { decision: "block", reason: text }
104
- : { hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: text } });
105
- for (const item of taken)
106
- writeHookJson(item.file, { delivery_id: item.delivery_id, state: "emitted", turn_id: event.turn_id, text: item.text });
107
- }
108
- catch (error) {
109
- for (const item of taken)
110
- writeHookJson(item.file, { delivery_id: item.delivery_id, state: "unknown", turn_id: event.turn_id, text: item.text });
111
- throw error;
112
- }
5
+ await steer.runCodexResultHook(AITERM_PROFILE, input, emit, options);
113
6
  }