aiterm-mcp 0.12.2 → 0.15.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/README.ja.md +31 -23
- package/README.md +63 -22
- package/dist/aiterm-wait-cli.js +94 -0
- package/dist/claude-stop-hook.js +237 -0
- package/dist/core.js +1027 -149
- package/dist/index.js +133 -28
- package/dist/runtime-error-store.js +15 -7
- package/package.json +4 -3
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
const LAUNCH_ID_RE = /^[0-9a-f]{32}$/;
|
|
7
|
+
const SESSION_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
8
|
+
const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
|
|
9
|
+
const MAX_STDIN_BYTES = 8 * 1024 * 1024;
|
|
10
|
+
const MAX_RESULT_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
function fail(message) {
|
|
12
|
+
process.stderr.write(`aiterm claude-stop-hook: ${message}\n`);
|
|
13
|
+
process.exit(0);
|
|
14
|
+
}
|
|
15
|
+
function noop() {
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
function hasAitermEnv() {
|
|
19
|
+
return !!(process.env.AITERM_AGENT_KIND ||
|
|
20
|
+
process.env.AITERM_SESSION_ID ||
|
|
21
|
+
process.env.AITERM_AGENT_SESSION_ID ||
|
|
22
|
+
process.env.AITERM_AGENT_LAUNCH_ID);
|
|
23
|
+
}
|
|
24
|
+
function uid() {
|
|
25
|
+
if (typeof process.getuid !== "function")
|
|
26
|
+
fail("POSIX getuid が使えません");
|
|
27
|
+
return process.getuid();
|
|
28
|
+
}
|
|
29
|
+
function runtimeStateBase() {
|
|
30
|
+
const xdg = process.env.XDG_RUNTIME_DIR;
|
|
31
|
+
if (xdg) {
|
|
32
|
+
try {
|
|
33
|
+
if (fs.statSync(xdg).isDirectory())
|
|
34
|
+
return xdg;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
/* 壊れたXDG_RUNTIME_DIRはTMPDIRへ戻す */
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return os.tmpdir();
|
|
41
|
+
}
|
|
42
|
+
function secureAgentsDir() {
|
|
43
|
+
const root = path.join(runtimeStateBase(), `aiterm-mcp-${uid()}`);
|
|
44
|
+
const agents = path.join(root, "agents");
|
|
45
|
+
const rst = fs.lstatSync(root);
|
|
46
|
+
if (!rst.isDirectory() || rst.isSymbolicLink() || rst.uid !== uid() || (rst.mode & 0o077) !== 0) {
|
|
47
|
+
fail(`agent state root が安全ではありません: ${root}`);
|
|
48
|
+
}
|
|
49
|
+
const ast = fs.lstatSync(agents);
|
|
50
|
+
if (!ast.isDirectory() || ast.isSymbolicLink() || ast.uid !== uid() || (ast.mode & 0o077) !== 0) {
|
|
51
|
+
fail(`agent state dir が安全ではありません: ${agents}`);
|
|
52
|
+
}
|
|
53
|
+
return agents;
|
|
54
|
+
}
|
|
55
|
+
async function readStdin() {
|
|
56
|
+
const chunks = [];
|
|
57
|
+
let total = 0;
|
|
58
|
+
for await (const chunk of process.stdin) {
|
|
59
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
60
|
+
total += bytes.length;
|
|
61
|
+
if (total > MAX_STDIN_BYTES)
|
|
62
|
+
fail("payload が大きすぎます");
|
|
63
|
+
chunks.push(bytes);
|
|
64
|
+
}
|
|
65
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
66
|
+
}
|
|
67
|
+
function writeResult(file, value) {
|
|
68
|
+
const body = JSON.stringify(value) + "\n";
|
|
69
|
+
if (Buffer.byteLength(body, "utf8") > MAX_STDIN_BYTES)
|
|
70
|
+
fail("result file が大きすぎます");
|
|
71
|
+
const tmp = `${file}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
|
|
72
|
+
const nofollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
73
|
+
const fd = fs.openSync(tmp, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | nofollow, 0o600);
|
|
74
|
+
try {
|
|
75
|
+
const st = fs.fstatSync(fd);
|
|
76
|
+
if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0)
|
|
77
|
+
fail("result temp file が安全ではありません");
|
|
78
|
+
const expected = Buffer.byteLength(body, "utf8");
|
|
79
|
+
const written = fs.writeSync(fd, body, undefined, "utf8");
|
|
80
|
+
if (written !== expected)
|
|
81
|
+
fail("result file への書込みが途中で終了しました");
|
|
82
|
+
fs.fsyncSync(fd);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
fs.closeSync(fd);
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
fs.renameSync(tmp, file);
|
|
89
|
+
fs.chmodSync(file, 0o600);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
try {
|
|
93
|
+
fs.unlinkSync(tmp);
|
|
94
|
+
}
|
|
95
|
+
catch { /* noop */ }
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function appendEvent(file, event) {
|
|
100
|
+
const nofollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
101
|
+
const line = JSON.stringify(event) + "\n";
|
|
102
|
+
if (Buffer.byteLength(line, "utf8") > 64 * 1024)
|
|
103
|
+
fail("event line が大きすぎます");
|
|
104
|
+
const fd = fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_APPEND | fs.constants.O_WRONLY | nofollow, 0o600);
|
|
105
|
+
try {
|
|
106
|
+
const st = fs.fstatSync(fd);
|
|
107
|
+
if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0) {
|
|
108
|
+
fail(`event file が安全ではありません: ${file}`);
|
|
109
|
+
}
|
|
110
|
+
const expected = Buffer.byteLength(line, "utf8");
|
|
111
|
+
const written = fs.writeSync(fd, line, undefined, "utf8");
|
|
112
|
+
if (written !== expected) {
|
|
113
|
+
fs.ftruncateSync(fd, st.size);
|
|
114
|
+
fail(`event file への書込みが途中で終了しました: ${file}`);
|
|
115
|
+
}
|
|
116
|
+
fs.fsyncSync(fd);
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
fs.closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function readOperationMarker(file) {
|
|
123
|
+
const nofollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
124
|
+
let fd;
|
|
125
|
+
try {
|
|
126
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (error.code === "ENOENT")
|
|
130
|
+
return null;
|
|
131
|
+
fail(`operation markerを安全に開けません: ${file}`);
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const st = fs.fstatSync(fd);
|
|
135
|
+
if (!st.isFile() || st.uid !== uid() || st.nlink !== 1 || (st.mode & 0o077) !== 0 || st.size > 1024) {
|
|
136
|
+
fail(`operation markerが安全ではありません: ${file}`);
|
|
137
|
+
}
|
|
138
|
+
const body = fs.readFileSync(fd, "utf8");
|
|
139
|
+
const value = JSON.parse(body);
|
|
140
|
+
const keys = value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value).sort() : [];
|
|
141
|
+
if (keys.join(",") !== "operation_id,schema" ||
|
|
142
|
+
value.schema !== "aiterm.claude-operation-marker.v1" ||
|
|
143
|
+
(value.operation_id !== null &&
|
|
144
|
+
(typeof value.operation_id !== "string" || !OPERATION_ID_RE.test(value.operation_id)))) {
|
|
145
|
+
fail("operation markerのschemaまたはoperation_idが不正です");
|
|
146
|
+
}
|
|
147
|
+
return { operationId: value.operation_id, dev: st.dev, ino: st.ino };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
151
|
+
}
|
|
152
|
+
finally {
|
|
153
|
+
fs.closeSync(fd);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function consumeOperationMarker(file, marker) {
|
|
157
|
+
let st;
|
|
158
|
+
try {
|
|
159
|
+
st = fs.lstatSync(file);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
fail("operation markerが完了記録中に消失しました");
|
|
163
|
+
}
|
|
164
|
+
if (!st.isFile() ||
|
|
165
|
+
st.isSymbolicLink() ||
|
|
166
|
+
st.uid !== uid() ||
|
|
167
|
+
st.nlink !== 1 ||
|
|
168
|
+
(st.mode & 0o077) !== 0 ||
|
|
169
|
+
st.dev !== marker.dev ||
|
|
170
|
+
st.ino !== marker.ino) {
|
|
171
|
+
fail("operation markerが完了記録中に置換されました");
|
|
172
|
+
}
|
|
173
|
+
fs.unlinkSync(file);
|
|
174
|
+
}
|
|
175
|
+
async function main() {
|
|
176
|
+
if (!hasAitermEnv())
|
|
177
|
+
noop();
|
|
178
|
+
const kind = process.env.AITERM_AGENT_KIND;
|
|
179
|
+
const session = process.env.AITERM_SESSION_ID || process.env.AITERM_AGENT_SESSION_ID || "";
|
|
180
|
+
const launchId = process.env.AITERM_AGENT_LAUNCH_ID || "";
|
|
181
|
+
if (kind !== "claude")
|
|
182
|
+
fail(`AITERM_AGENT_KIND が claude ではありません: ${kind ?? ""}`);
|
|
183
|
+
if (!SESSION_RE.test(session))
|
|
184
|
+
fail(`session id が不正です: ${session}`);
|
|
185
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
186
|
+
fail(`launch id が不正です: ${launchId}`);
|
|
187
|
+
let payload;
|
|
188
|
+
try {
|
|
189
|
+
payload = JSON.parse(await readStdin());
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
fail("Stop payload がJSONではありません");
|
|
193
|
+
}
|
|
194
|
+
if (payload.hook_event_name !== "Stop")
|
|
195
|
+
fail("Stop以外のpayloadをturn完了にできません");
|
|
196
|
+
if (typeof payload.session_id !== "string" || !payload.session_id)
|
|
197
|
+
fail("Claude session_id がありません");
|
|
198
|
+
if (typeof payload.last_assistant_message !== "string")
|
|
199
|
+
fail("last_assistant_message がありません");
|
|
200
|
+
const text = payload.last_assistant_message;
|
|
201
|
+
const resultBytes = Buffer.byteLength(text, "utf8");
|
|
202
|
+
if (resultBytes > MAX_RESULT_BYTES)
|
|
203
|
+
fail(`assistant result が${MAX_RESULT_BYTES} bytesを超えています`);
|
|
204
|
+
const resultDigest = createHash("sha256").update(text, "utf8").digest("hex");
|
|
205
|
+
const agents = secureAgentsDir();
|
|
206
|
+
const resultFile = path.join(agents, `${session}.${launchId}.claude-result.json`);
|
|
207
|
+
const eventFile = path.join(agents, `${session}.${launchId}.events.jsonl`);
|
|
208
|
+
const operationFile = path.join(agents, `${session}.${launchId}.claude-operation.json`);
|
|
209
|
+
const operationMarker = readOperationMarker(operationFile);
|
|
210
|
+
const operationId = operationMarker?.operationId ?? null;
|
|
211
|
+
writeResult(resultFile, {
|
|
212
|
+
schema: "aiterm.claude-turn-result.v2",
|
|
213
|
+
operation_id: operationId,
|
|
214
|
+
vendor_session_id: payload.session_id,
|
|
215
|
+
result_digest: resultDigest,
|
|
216
|
+
result_bytes: resultBytes,
|
|
217
|
+
text,
|
|
218
|
+
});
|
|
219
|
+
appendEvent(eventFile, {
|
|
220
|
+
type: "agent_done",
|
|
221
|
+
vendor: "claude",
|
|
222
|
+
aiterm_session: session,
|
|
223
|
+
launch_id: launchId,
|
|
224
|
+
vendor_session_id: payload.session_id,
|
|
225
|
+
turn_id: null,
|
|
226
|
+
operation_id: operationId,
|
|
227
|
+
reason: "Stop",
|
|
228
|
+
done_status: "turn_done",
|
|
229
|
+
stop_hook_active: !!payload.stop_hook_active,
|
|
230
|
+
result_digest: resultDigest,
|
|
231
|
+
result_bytes: resultBytes,
|
|
232
|
+
at: new Date().toISOString(),
|
|
233
|
+
});
|
|
234
|
+
if (operationMarker)
|
|
235
|
+
consumeOperationMarker(operationFile, operationMarker);
|
|
236
|
+
}
|
|
237
|
+
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
|