aiterm-mcp 0.27.7 → 0.27.8
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/dist/vendors/claude.js +210 -0
- package/dist/vendors/codex.js +481 -0
- package/dist/vendors/grok.js +386 -0
- package/package.json +2 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Claude Code 固有の制御。完了正本は launch 固有 Stop hook が書く event/result(ADR 0025)。
|
|
2
|
+
// core 所有のサービス(transcript 不在エラー)は引数で注入し、
|
|
3
|
+
// 依存方向を core → vendors → agent-shared の一方向に保つ。
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { AitermError } from "../errors.js";
|
|
9
|
+
import { modeBitsWorldAccessible } from "../tmux-runtime.js";
|
|
10
|
+
import { spawnAgentControlCommand } from "../agent-resolver.js";
|
|
11
|
+
import { currentUid, writeJson0600, shq, subagentInstruction, writeScopeLaunchNote, agentEventPath, createEmpty0600, writeAgentMetadata, agentLineageFields, assertSessionName, agentsDir, LAUNCH_ID_RE, } from "../agent-shared.js";
|
|
12
|
+
export const OPERATION_ID_RE = /^sha256:[0-9a-f]{64}$/;
|
|
13
|
+
export const CLAUDE_RESULT_MAX_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
export const CLAUDE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
15
|
+
export function agentManagedClaudeSettingsPath(name, launchId) {
|
|
16
|
+
assertSessionName(name);
|
|
17
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
18
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
19
|
+
return path.join(agentsDir(), `${name}.${launchId}.claude-settings.json`);
|
|
20
|
+
}
|
|
21
|
+
export function agentClaudeResultPath(name, launchId) {
|
|
22
|
+
assertSessionName(name);
|
|
23
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
24
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
25
|
+
return path.join(agentsDir(), `${name}.${launchId}.claude-result.json`);
|
|
26
|
+
}
|
|
27
|
+
export function agentClaudeOperationPath(name, launchId) {
|
|
28
|
+
assertSessionName(name);
|
|
29
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
30
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
31
|
+
return path.join(agentsDir(), `${name}.${launchId}.claude-operation.json`);
|
|
32
|
+
}
|
|
33
|
+
export function agentClaudeApprovalReceiptPath(name, launchId) {
|
|
34
|
+
assertSessionName(name);
|
|
35
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
36
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
37
|
+
return path.join(agentsDir(), `${name}.${launchId}.claude-approval.json`);
|
|
38
|
+
}
|
|
39
|
+
export function agentClaudeDispatchReceiptPath(name, launchId, operationId) {
|
|
40
|
+
assertSessionName(name);
|
|
41
|
+
if (!LAUNCH_ID_RE.test(launchId))
|
|
42
|
+
throw new AitermError(`launch_id が不正です: ${launchId}`, 2);
|
|
43
|
+
const validated = validateOperationId(operationId);
|
|
44
|
+
return path.join(agentsDir(), `${name}.${launchId}.${validated.slice("sha256:".length)}.claude-dispatch`);
|
|
45
|
+
}
|
|
46
|
+
function claudeHookScriptPath() {
|
|
47
|
+
// この module は dist/vendors/ に置かれるが、stop hook 実体は dist/ 直下に build される。
|
|
48
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "claude-stop-hook.js");
|
|
49
|
+
}
|
|
50
|
+
// process.execPath は Homebrew 等では Cellar の版付き実体を指す。長寿命 MCP server の起動後に
|
|
51
|
+
// runtime が更新されるとその実体だけが消え、既に生成済みの hook が exit 127 になる。
|
|
52
|
+
// hook は server と同じ継承 PATH から node を毎回解決し、安定した package script を実行する。
|
|
53
|
+
function nodeHookCommand(hookScript) {
|
|
54
|
+
return `${shq("node")} ${shq(hookScript)}`;
|
|
55
|
+
}
|
|
56
|
+
export function createClaudeCorrelationSettings(name, launchId, model, effort) {
|
|
57
|
+
const hookScript = claudeHookScriptPath();
|
|
58
|
+
if (!fs.existsSync(hookScript)) {
|
|
59
|
+
throw new AitermError(`Claude Stop hook wrapper が見つかりません。npm run build を実行してください: ${hookScript}`, 2);
|
|
60
|
+
}
|
|
61
|
+
const settings = agentManagedClaudeSettingsPath(name, launchId);
|
|
62
|
+
writeJson0600(settings, {
|
|
63
|
+
...(model ? { model } : {}),
|
|
64
|
+
...(effort ? { effortLevel: effort } : {}),
|
|
65
|
+
hooks: {
|
|
66
|
+
Stop: [
|
|
67
|
+
{
|
|
68
|
+
hooks: [
|
|
69
|
+
{
|
|
70
|
+
type: "command",
|
|
71
|
+
command: nodeHookCommand(hookScript),
|
|
72
|
+
timeout: 10,
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return settings;
|
|
80
|
+
}
|
|
81
|
+
export function validateOperationId(operationId) {
|
|
82
|
+
if (typeof operationId !== "string" || !OPERATION_ID_RE.test(operationId)) {
|
|
83
|
+
throw new AitermError("operation_id は sha256:<64 lowercase hex> で指定してください", 2);
|
|
84
|
+
}
|
|
85
|
+
return operationId;
|
|
86
|
+
}
|
|
87
|
+
export function readClaudeResultText(meta, done, operationId, transcriptUnavailable) {
|
|
88
|
+
if (meta.kind !== "claude" || !meta.result_file || !done.result_digest || done.result_bytes == null) {
|
|
89
|
+
transcriptUnavailable();
|
|
90
|
+
}
|
|
91
|
+
let st;
|
|
92
|
+
try {
|
|
93
|
+
st = fs.lstatSync(meta.result_file);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
transcriptUnavailable();
|
|
97
|
+
}
|
|
98
|
+
if (!st.isFile() ||
|
|
99
|
+
st.isSymbolicLink() ||
|
|
100
|
+
st.uid !== currentUid() ||
|
|
101
|
+
st.nlink !== 1 ||
|
|
102
|
+
modeBitsWorldAccessible(st.mode) ||
|
|
103
|
+
st.size > CLAUDE_RESULT_MAX_BYTES + 4096) {
|
|
104
|
+
throw new AitermError("Claude result file の安全検証に失敗しました", 2);
|
|
105
|
+
}
|
|
106
|
+
let result;
|
|
107
|
+
try {
|
|
108
|
+
result = JSON.parse(fs.readFileSync(meta.result_file, "utf8"));
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
throw new AitermError("Claude result file を読めません", 2);
|
|
112
|
+
}
|
|
113
|
+
const keys = result && typeof result === "object" && !Array.isArray(result) ? Object.keys(result).sort() : [];
|
|
114
|
+
if (keys.join(",") !== "operation_id,result_bytes,result_digest,schema,text,vendor_session_id" ||
|
|
115
|
+
result.schema !== "aiterm.claude-turn-result.v2" ||
|
|
116
|
+
result.operation_id !== done.operation_id ||
|
|
117
|
+
(operationId !== null && result.operation_id !== operationId) ||
|
|
118
|
+
result.vendor_session_id !== meta.vendor_session_id ||
|
|
119
|
+
result.result_digest !== done.result_digest ||
|
|
120
|
+
result.result_bytes !== done.result_bytes ||
|
|
121
|
+
typeof result.text !== "string" ||
|
|
122
|
+
Buffer.byteLength(result.text, "utf8") !== done.result_bytes ||
|
|
123
|
+
createHash("sha256").update(result.text, "utf8").digest("hex") !== done.result_digest) {
|
|
124
|
+
throw new AitermError("Claude result file が完了eventと一致しません", 2);
|
|
125
|
+
}
|
|
126
|
+
return result.text;
|
|
127
|
+
}
|
|
128
|
+
const CLAUDE_AUTH_STATUS_TIMEOUT_MS = 5_000;
|
|
129
|
+
export function assertClaudeAuthenticationReady(bin) {
|
|
130
|
+
const result = spawnAgentControlCommand(bin, ["auth", "status", "--json"], process.cwd(), {
|
|
131
|
+
encoding: "utf8",
|
|
132
|
+
timeout: CLAUDE_AUTH_STATUS_TIMEOUT_MS,
|
|
133
|
+
maxBuffer: 64 * 1024,
|
|
134
|
+
});
|
|
135
|
+
let status = null;
|
|
136
|
+
try {
|
|
137
|
+
status = JSON.parse((result.stdout ?? "").trim());
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
status = null;
|
|
141
|
+
}
|
|
142
|
+
if (result.error == null &&
|
|
143
|
+
result.status === 0 &&
|
|
144
|
+
status !== null &&
|
|
145
|
+
typeof status === "object" &&
|
|
146
|
+
!Array.isArray(status) &&
|
|
147
|
+
status.loggedIn === true) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (status !== null &&
|
|
151
|
+
typeof status === "object" &&
|
|
152
|
+
!Array.isArray(status) &&
|
|
153
|
+
status.loggedIn === false) {
|
|
154
|
+
throw new AitermError("Claude Codeの認証を利用できません。sessionは作成していません。" +
|
|
155
|
+
"通常端末で `claude doctor` を実行し、Keychain/credential storeを直してから一度だけ `claude auth login` を実行してください。" +
|
|
156
|
+
"aiterm相関付きClaude session内で /login を繰り返さないでください。", 2);
|
|
157
|
+
}
|
|
158
|
+
const timedOut = result.error && result.error.code === "ETIMEDOUT";
|
|
159
|
+
throw new AitermError(`Claude Codeの認証状態を起動前に確認できません${timedOut ? "(5秒でtimeout)" : ""}。sessionは作成していません。` +
|
|
160
|
+
"`claude auth status --json` と `claude doctor` が成功することを通常端末で確認してください。", 2);
|
|
161
|
+
}
|
|
162
|
+
export function buildClaudeAgentCmd(bin, model, effort, prompt, meta) {
|
|
163
|
+
const parts = [shq(bin)];
|
|
164
|
+
if (meta?.kind === "claude") {
|
|
165
|
+
parts.push("--setting-sources", shq("user,project,local"), "--settings", shq(meta.claude_settings ?? ""), "--session-id", shq(meta.vendor_session_id ?? ""), "--append-system-prompt", shq(subagentInstruction(meta)));
|
|
166
|
+
}
|
|
167
|
+
if (model)
|
|
168
|
+
parts.push("--model", shq(model));
|
|
169
|
+
if (effort)
|
|
170
|
+
parts.push("--effort", shq(effort));
|
|
171
|
+
if (prompt)
|
|
172
|
+
parts.push(shq(prompt)); // 初手プロンプト(任意)
|
|
173
|
+
return parts.join(" ");
|
|
174
|
+
}
|
|
175
|
+
export function claudeLaunchNote(model, effort, meta) {
|
|
176
|
+
const writeScopeNote = writeScopeLaunchNote("claude", meta?.write_scope);
|
|
177
|
+
return `起動設定: model=${model ?? "CLI既定"} effort=${effort ?? "CLI既定"}。${writeScopeNote}`;
|
|
178
|
+
}
|
|
179
|
+
export function claudeTuiReady(screen) {
|
|
180
|
+
return screen.includes("Claude Code") && /(^|\n)\s*❯/.test(screen);
|
|
181
|
+
}
|
|
182
|
+
// submit座礁観測のcomposer領域マーカー(ready判定と同じ記号を行頭基準で探す)。
|
|
183
|
+
export const CLAUDE_COMPOSER_MARKER_RE = /^\s*❯/;
|
|
184
|
+
export function createClaudeAgentMetadata(name, cwd, initialPrompt, launchOperationId, launchRequestDigest, lineageContext, model, effort) {
|
|
185
|
+
const launchId = randomBytes(16).toString("hex");
|
|
186
|
+
const eventFile = agentEventPath(name, launchId);
|
|
187
|
+
const resultFile = agentClaudeResultPath(name, launchId);
|
|
188
|
+
createEmpty0600(eventFile);
|
|
189
|
+
createEmpty0600(resultFile);
|
|
190
|
+
const claudeSettings = createClaudeCorrelationSettings(name, launchId, model, effort);
|
|
191
|
+
const meta = {
|
|
192
|
+
kind: "claude",
|
|
193
|
+
aiterm_session: name,
|
|
194
|
+
launch_id: launchId,
|
|
195
|
+
event_file: eventFile,
|
|
196
|
+
created_at: new Date().toISOString(),
|
|
197
|
+
cwd,
|
|
198
|
+
vendor_session_id: randomUUID(),
|
|
199
|
+
initial_prompt: initialPrompt,
|
|
200
|
+
launch_operation_id: launchOperationId,
|
|
201
|
+
launch_request_digest: launchRequestDigest,
|
|
202
|
+
hook_route: "shared_claude_settings",
|
|
203
|
+
...agentLineageFields(lineageContext),
|
|
204
|
+
node_platform: process.platform,
|
|
205
|
+
claude_settings: claudeSettings,
|
|
206
|
+
result_file: resultFile,
|
|
207
|
+
};
|
|
208
|
+
writeAgentMetadata(meta);
|
|
209
|
+
return meta;
|
|
210
|
+
}
|
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
// Codex 固有の制御。完了正本は root rollout transcript の task_complete(ADR 0022)。
|
|
2
|
+
// core 所有のサービス(transcript 行読取・rate limit 検知)は引数で注入し、
|
|
3
|
+
// 依存方向を core → vendors → agent-shared の一方向に保つ。
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
import * as os from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { AitermError } from "../errors.js";
|
|
9
|
+
import { shq, subagentInstruction, writeScopeLaunchNote, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, AGENT_EVENT_TAIL_BYTES, CODEX_TRANSCRIPT_INCREMENT_MAX_BYTES, } from "../agent-shared.js";
|
|
10
|
+
export function realCodexHome() {
|
|
11
|
+
return process.env.CODEX_HOME || path.join(process.env.HOME ?? os.homedir(), ".codex");
|
|
12
|
+
}
|
|
13
|
+
export function readCodexConfigPins(configPath) {
|
|
14
|
+
let body;
|
|
15
|
+
try {
|
|
16
|
+
body = fs.readFileSync(configPath, "utf8");
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { model: { present: false, value: null }, effort: { present: false, value: null } };
|
|
20
|
+
}
|
|
21
|
+
const rows = body.split(/\r?\n/);
|
|
22
|
+
let firstTable = rows.findIndex((l) => /^\s*\[/.test(l));
|
|
23
|
+
if (firstTable === -1)
|
|
24
|
+
firstTable = rows.length;
|
|
25
|
+
const pick = (key) => {
|
|
26
|
+
for (const l of rows.slice(0, firstTable)) {
|
|
27
|
+
const m = l.match(new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.*)$`));
|
|
28
|
+
if (m) {
|
|
29
|
+
const v = m[1].trim().match(/^"([^"\\]*)"\s*(?:#.*)?$/);
|
|
30
|
+
return { present: true, value: v ? v[1] : null };
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { present: false, value: null };
|
|
34
|
+
};
|
|
35
|
+
return { model: pick("model"), effort: pick("model_reasoning_effort") };
|
|
36
|
+
}
|
|
37
|
+
export function codexConfigSummary(configPath) {
|
|
38
|
+
let body;
|
|
39
|
+
try {
|
|
40
|
+
body = fs.readFileSync(configPath, "utf8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
45
|
+
const rows = body.split(/\r?\n/);
|
|
46
|
+
const mcpServers = rows.filter((line) => /^\s*\[mcp_servers\./.test(line)).length;
|
|
47
|
+
const firstTable = rows.findIndex((line) => /^\s*\[/.test(line));
|
|
48
|
+
const topLevel = rows.slice(0, firstTable === -1 ? rows.length : firstTable);
|
|
49
|
+
const valueOf = (key) => {
|
|
50
|
+
const row = topLevel.find((line) => new RegExp(`^\\s*(?:"${key}"|'${key}'|${key})\\s*=\\s*(.+?)\\s*(?:#.*)?$`).test(line));
|
|
51
|
+
if (!row)
|
|
52
|
+
return null;
|
|
53
|
+
const raw = row.match(/=\s*(.+?)(?:\s+#.*)?$/)?.[1].trim() ?? null;
|
|
54
|
+
if (!raw)
|
|
55
|
+
return null;
|
|
56
|
+
const quoted = raw.match(/^(?:"([^"\\]*)"|'([^'\\]*)')$/);
|
|
57
|
+
return quoted ? quoted[1] ?? quoted[2] : raw;
|
|
58
|
+
};
|
|
59
|
+
const bits = [`mcp_servers ${mcpServers} 個継承`];
|
|
60
|
+
const approvalPolicy = valueOf("approval_policy");
|
|
61
|
+
const sandboxMode = valueOf("sandbox_mode");
|
|
62
|
+
if (approvalPolicy)
|
|
63
|
+
bits.push(`approval_policy=${approvalPolicy}`);
|
|
64
|
+
if (sandboxMode)
|
|
65
|
+
bits.push(`sandbox_mode=${sandboxMode}`);
|
|
66
|
+
return `共有 config: ${bits.join(" / ")}`;
|
|
67
|
+
}
|
|
68
|
+
export function findLatestCodexTranscript(codexHome, vendorSessionId) {
|
|
69
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
70
|
+
let latestFile = null;
|
|
71
|
+
let latestMtime = -Infinity;
|
|
72
|
+
const visit = (dir) => {
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const file = path.join(dir, entry.name);
|
|
82
|
+
if (entry.isDirectory()) {
|
|
83
|
+
visit(file);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl") || !entry.name.includes(vendorSessionId))
|
|
87
|
+
continue;
|
|
88
|
+
try {
|
|
89
|
+
const mtimeMs = fs.statSync(file).mtimeMs;
|
|
90
|
+
if (mtimeMs > latestMtime) {
|
|
91
|
+
latestFile = file;
|
|
92
|
+
latestMtime = mtimeMs;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// 探索中に消えた transcript は候補にしない。候補が無ければ明示エラーにする。
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
visit(sessionsDir);
|
|
101
|
+
return latestFile;
|
|
102
|
+
}
|
|
103
|
+
export function listCodexTranscripts(codexHome) {
|
|
104
|
+
const sessionsDir = path.join(codexHome, "sessions");
|
|
105
|
+
const files = [];
|
|
106
|
+
const visit = (dir) => {
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
for (const entry of entries) {
|
|
115
|
+
const file = path.join(dir, entry.name);
|
|
116
|
+
if (entry.isDirectory())
|
|
117
|
+
visit(file);
|
|
118
|
+
else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl"))
|
|
119
|
+
files.push(file);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
visit(sessionsDir);
|
|
123
|
+
return files.sort();
|
|
124
|
+
}
|
|
125
|
+
export function codexTranscriptMatchesLaunch(file, meta) {
|
|
126
|
+
const createdAt = Date.parse(meta.created_at);
|
|
127
|
+
try {
|
|
128
|
+
const st = fs.statSync(file);
|
|
129
|
+
if (Number.isFinite(createdAt) && st.mtimeMs + 5_000 < createdAt)
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
const size = Math.min(safeStatSize(file), 1024 * 1024);
|
|
136
|
+
if (size === 0)
|
|
137
|
+
return false;
|
|
138
|
+
const marker = `AITERM_AGENT_LAUNCH_ID=${meta.launch_id}`;
|
|
139
|
+
let rootCli = false;
|
|
140
|
+
let launchMarker = false;
|
|
141
|
+
for (const line of readFileRange(file, 0, size).toString("utf8").split("\n")) {
|
|
142
|
+
if (!line.trim())
|
|
143
|
+
continue;
|
|
144
|
+
let record;
|
|
145
|
+
try {
|
|
146
|
+
record = JSON.parse(line);
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (record?.type === "session_meta") {
|
|
152
|
+
rootCli = record?.payload?.originator === "codex-tui" && record?.payload?.source === "cli";
|
|
153
|
+
if (!rootCli)
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
if (record?.type === "response_item" &&
|
|
157
|
+
record?.payload?.type === "message" &&
|
|
158
|
+
record?.payload?.role === "developer" &&
|
|
159
|
+
Array.isArray(record?.payload?.content)) {
|
|
160
|
+
launchMarker ||= record.payload.content.some((item) => (item?.type === "input_text" || item?.type === "output_text") &&
|
|
161
|
+
typeof item?.text === "string" &&
|
|
162
|
+
item.text.includes(marker));
|
|
163
|
+
}
|
|
164
|
+
if (rootCli && launchMarker)
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
export function codexTranscriptSessionId(file) {
|
|
170
|
+
const size = Math.min(safeStatSize(file), AGENT_EVENT_TAIL_BYTES);
|
|
171
|
+
if (size === 0)
|
|
172
|
+
return null;
|
|
173
|
+
const text = readFileRange(file, 0, size).toString("utf8");
|
|
174
|
+
for (const line of text.split("\n")) {
|
|
175
|
+
if (!line.trim())
|
|
176
|
+
continue;
|
|
177
|
+
try {
|
|
178
|
+
const record = JSON.parse(line);
|
|
179
|
+
if (record?.type === "session_meta" && typeof record?.payload?.id === "string" && record.payload.id) {
|
|
180
|
+
return record.payload.id;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// startup中の未完結行は後のpollで読み直す。
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
export function codexRootTranscript(meta) {
|
|
190
|
+
if (meta.kind !== "codex" || !meta.codex_home)
|
|
191
|
+
return null;
|
|
192
|
+
if (meta.vendor_session_id)
|
|
193
|
+
return findLatestCodexTranscript(meta.codex_home, meta.vendor_session_id);
|
|
194
|
+
if (meta.hook_route === "shared_codex_home") {
|
|
195
|
+
const matches = listCodexTranscripts(meta.codex_home).filter((file) => codexTranscriptMatchesLaunch(file, meta));
|
|
196
|
+
if (matches.length > 1) {
|
|
197
|
+
throw new AitermError("共有CODEX_HOMEに同じlaunch markerのroot rolloutが複数あります。sessionを閉じて起動し直してください。", 2);
|
|
198
|
+
}
|
|
199
|
+
return matches[0] ?? null;
|
|
200
|
+
}
|
|
201
|
+
return listCodexTranscripts(meta.codex_home)[0] ?? null;
|
|
202
|
+
}
|
|
203
|
+
export function bindCodexTranscriptSession(meta) {
|
|
204
|
+
const transcript = codexRootTranscript(meta);
|
|
205
|
+
if (!transcript)
|
|
206
|
+
return null;
|
|
207
|
+
const vendorSessionId = codexTranscriptSessionId(transcript);
|
|
208
|
+
if (vendorSessionId && !meta.vendor_session_id) {
|
|
209
|
+
meta.vendor_session_id = vendorSessionId;
|
|
210
|
+
writeAgentMetadata(meta);
|
|
211
|
+
}
|
|
212
|
+
return transcript;
|
|
213
|
+
}
|
|
214
|
+
export function codexCompletionEvent(meta, vendorSessionId, record) {
|
|
215
|
+
if (record?.type !== "event_msg" ||
|
|
216
|
+
record?.payload?.type !== "task_complete" ||
|
|
217
|
+
typeof record?.payload?.turn_id !== "string" ||
|
|
218
|
+
!record.payload.turn_id)
|
|
219
|
+
return null;
|
|
220
|
+
return {
|
|
221
|
+
type: "agent_done",
|
|
222
|
+
vendor: "codex",
|
|
223
|
+
aiterm_session: meta.aiterm_session,
|
|
224
|
+
launch_id: meta.launch_id,
|
|
225
|
+
vendor_session_id: vendorSessionId,
|
|
226
|
+
turn_id: record.payload.turn_id,
|
|
227
|
+
operation_id: null,
|
|
228
|
+
reason: "Codex transcript task_complete",
|
|
229
|
+
done_status: "turn_done",
|
|
230
|
+
stop_hook_active: false,
|
|
231
|
+
at: typeof record.timestamp === "string" ? record.timestamp : new Date().toISOString(),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
export function latestCodexCompletion(meta, readTranscriptLines) {
|
|
235
|
+
const transcript = codexRootTranscript(meta);
|
|
236
|
+
if (!transcript)
|
|
237
|
+
return null;
|
|
238
|
+
const vendorSessionId = meta.vendor_session_id ?? codexTranscriptSessionId(transcript);
|
|
239
|
+
let latest = null;
|
|
240
|
+
for (const line of readTranscriptLines(transcript)) {
|
|
241
|
+
if (!line.trim())
|
|
242
|
+
continue;
|
|
243
|
+
try {
|
|
244
|
+
latest = codexCompletionEvent(meta, vendorSessionId, JSON.parse(line)) ?? latest;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Codexが末尾を書込み中なら、その行は次の観測で完結してから読む。
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return latest;
|
|
251
|
+
}
|
|
252
|
+
export async function observeCodexDone(meta, timeout, requestedCursor, detectRateLimit) {
|
|
253
|
+
const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
|
|
254
|
+
let transcript = codexRootTranscript(meta);
|
|
255
|
+
const startOffset = requestedCursor ?? (transcript ? safeStatSize(transcript) : 0);
|
|
256
|
+
let cursor = startOffset;
|
|
257
|
+
let carry = "";
|
|
258
|
+
let malformedEvents = 0;
|
|
259
|
+
let discardLeadingFragment = false;
|
|
260
|
+
let initializedBoundary = false;
|
|
261
|
+
const deadline = performance.now() + timeout * 1000;
|
|
262
|
+
const observation = (outcome, ev = null, rateLimit = null) => ({
|
|
263
|
+
schema: "aiterm.agent-wait-result.v1",
|
|
264
|
+
session_id: meta.aiterm_session,
|
|
265
|
+
launch_id: meta.launch_id,
|
|
266
|
+
vendor: "codex",
|
|
267
|
+
outcome,
|
|
268
|
+
operation_id: null,
|
|
269
|
+
vendor_session_id: ev?.vendor_session_id ?? meta.vendor_session_id ?? null,
|
|
270
|
+
turn_id: ev?.turn_id ?? null,
|
|
271
|
+
malformed_events: malformedEvents,
|
|
272
|
+
at: ev?.at ?? null,
|
|
273
|
+
rate_limit: rateLimit,
|
|
274
|
+
});
|
|
275
|
+
for (;;) {
|
|
276
|
+
if (!fs.existsSync(metadataFile))
|
|
277
|
+
return observation("closed");
|
|
278
|
+
transcript ??= codexRootTranscript(meta);
|
|
279
|
+
if (transcript) {
|
|
280
|
+
if (!initializedBoundary) {
|
|
281
|
+
if (cursor > 0) {
|
|
282
|
+
const previous = readFileRange(transcript, cursor - 1, cursor).toString("utf8");
|
|
283
|
+
discardLeadingFragment = previous !== "\n";
|
|
284
|
+
}
|
|
285
|
+
initializedBoundary = true;
|
|
286
|
+
}
|
|
287
|
+
const size = safeStatSize(transcript);
|
|
288
|
+
if (size < cursor) {
|
|
289
|
+
throw new AitermError("Codex transcript が完了待機中に短くなりました。該当セッションを閉じて起動し直してください。", 2);
|
|
290
|
+
}
|
|
291
|
+
if (size - startOffset > CODEX_TRANSCRIPT_INCREMENT_MAX_BYTES) {
|
|
292
|
+
throw new AitermError("Codex transcript のturn増分が大きすぎます。該当セッションを閉じて起動し直してください。", 2);
|
|
293
|
+
}
|
|
294
|
+
if (size > cursor) {
|
|
295
|
+
carry += readFileRange(transcript, cursor, size).toString("utf8");
|
|
296
|
+
cursor = size;
|
|
297
|
+
const parts = carry.split("\n");
|
|
298
|
+
carry = parts.pop() ?? "";
|
|
299
|
+
if (discardLeadingFragment && parts.length > 0) {
|
|
300
|
+
parts.shift();
|
|
301
|
+
discardLeadingFragment = false;
|
|
302
|
+
}
|
|
303
|
+
const vendorSessionId = meta.vendor_session_id ?? codexTranscriptSessionId(transcript);
|
|
304
|
+
for (const line of parts) {
|
|
305
|
+
if (!line.trim())
|
|
306
|
+
continue;
|
|
307
|
+
if (Buffer.byteLength(line, "utf8") > AGENT_EVENT_MAX_BYTES) {
|
|
308
|
+
malformedEvents++;
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const done = codexCompletionEvent(meta, vendorSessionId, JSON.parse(line));
|
|
313
|
+
if (done)
|
|
314
|
+
return observation("done", done);
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
malformedEvents++;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
{
|
|
323
|
+
const limited = detectRateLimit(meta.kind, meta.aiterm_session);
|
|
324
|
+
if (limited)
|
|
325
|
+
return observation("rate_limited", null, limited);
|
|
326
|
+
}
|
|
327
|
+
if (performance.now() >= deadline)
|
|
328
|
+
return observation(timeout === 0 ? "running" : "timeout");
|
|
329
|
+
await sleep(AGENT_DONE_POLL_MS);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
export function buildCodexAgentCmd(bin, model, effort, prompt, meta) {
|
|
333
|
+
const parts = [shq(bin)];
|
|
334
|
+
// `codex --help` で確認した実在フラグ。read-only 宣言だけはCLI sandboxへ落とし、
|
|
335
|
+
// launcher自身が実効能力壁を作る。パス説明はCodex CLIに同等のallowlist引数がないため宣言のまま残す。
|
|
336
|
+
if (meta?.kind === "codex" && meta.write_scope === "read-only")
|
|
337
|
+
parts.push("--sandbox", "read-only");
|
|
338
|
+
// model/effort は共有configを書き換えず、CLI引数で明示して起動単位に優先する。
|
|
339
|
+
if (model)
|
|
340
|
+
parts.push("-m", shq(model));
|
|
341
|
+
if (effort)
|
|
342
|
+
parts.push("-c", `model_reasoning_effort=${shq(effort)}`);
|
|
343
|
+
if (meta?.kind === "codex" && meta.hook_route === "shared_codex_home") {
|
|
344
|
+
parts.push("-c", `developer_instructions=${shq(subagentInstruction(meta))}`);
|
|
345
|
+
}
|
|
346
|
+
if (prompt)
|
|
347
|
+
parts.push(shq(prompt)); // 初手プロンプト(任意)
|
|
348
|
+
return parts.join(" ");
|
|
349
|
+
}
|
|
350
|
+
// 起動応答にモデル/effort の実効値と出所を明示する。codex は端末 config のピン(model /
|
|
351
|
+
// model_reasoning_effort)が対話子へ波及する構造のため、引数・端末config継承・CLI既定の
|
|
352
|
+
// どれで起動したかを起動時点で可視化し、実効 effort=ultra は proactive 自動委譲 ON を警告する。
|
|
353
|
+
export function codexLaunchNote(model, effort, meta) {
|
|
354
|
+
const writeScopeNote = writeScopeLaunchNote("codex", meta?.write_scope);
|
|
355
|
+
const configPath = meta?.kind === "codex" && meta.codex_home
|
|
356
|
+
? path.join(meta.codex_home, "config.toml")
|
|
357
|
+
: path.join(realCodexHome(), "config.toml");
|
|
358
|
+
const pins = readCodexConfigPins(configPath);
|
|
359
|
+
const describePin = (arg, pin) => arg
|
|
360
|
+
? `${arg}(引数)`
|
|
361
|
+
: pin.present
|
|
362
|
+
? pin.value
|
|
363
|
+
? `${pin.value}(端末config継承)`
|
|
364
|
+
: "端末config継承(値未解析)"
|
|
365
|
+
: "CLI既定";
|
|
366
|
+
const effectiveEffort = effort ?? (pins.effort.present ? pins.effort.value : null);
|
|
367
|
+
const launch = `起動設定: model=${describePin(model, pins.model)} effort=${describePin(effort, pins.effort)}。` +
|
|
368
|
+
(effectiveEffort === "ultra"
|
|
369
|
+
? "⚠ effort=ultra は max 推論+proactive 自動委譲 ON(子エージェント自動生成・使用量急増に注意)。"
|
|
370
|
+
: "");
|
|
371
|
+
const summary = meta?.kind === "codex" && meta.codex_home ? codexConfigSummary(configPath) : "";
|
|
372
|
+
return (summary ? `${launch}\n${summary}\n` : launch) + writeScopeNote;
|
|
373
|
+
}
|
|
374
|
+
export function codexTuiReady(screen) {
|
|
375
|
+
// 起動直後は製品header、長寿命sessionでは常駐footerがCodex TUIの識別子になる。
|
|
376
|
+
// capture-paneは直近45行だけなので、会話が進むとheaderは正常に画面外へ流れる。
|
|
377
|
+
const codexFrontend = screen.includes("OpenAI Codex")
|
|
378
|
+
|| /(^|\n)\s*\S+\s+(?:low|medium|high|xhigh|max|ultra)(?:\s+fast)?\s+·\s+\S.*$/m.test(screen);
|
|
379
|
+
return codexFrontend && /(^|\n)\s*[›>]/.test(screen);
|
|
380
|
+
}
|
|
381
|
+
// submit座礁観測のcomposer領域マーカー(ready判定と同じ記号を行頭基準で探す)。
|
|
382
|
+
export const CODEX_COMPOSER_MARKER_RE = /^\s*[›>]/;
|
|
383
|
+
export function codexModelChoice(screen, model) {
|
|
384
|
+
for (const line of screen.slice(screen.lastIndexOf("Select Model and Effort")).split("\n")) {
|
|
385
|
+
const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+(\S+)/);
|
|
386
|
+
if (match?.[2] === model)
|
|
387
|
+
return match[1];
|
|
388
|
+
}
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
export function codexEffortChoice(screen, effort) {
|
|
392
|
+
const labels = {
|
|
393
|
+
low: /^Low\b/i,
|
|
394
|
+
medium: /^Medium\b/i,
|
|
395
|
+
high: /^High\b/i,
|
|
396
|
+
xhigh: /^Extra high\b/i,
|
|
397
|
+
max: /^Max\b/i,
|
|
398
|
+
ultra: /^Ultra\b/i,
|
|
399
|
+
};
|
|
400
|
+
const wanted = labels[effort.toLowerCase()];
|
|
401
|
+
if (!wanted)
|
|
402
|
+
return null;
|
|
403
|
+
for (const line of screen.split("\n")) {
|
|
404
|
+
const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+(.+?)\s{2,}/);
|
|
405
|
+
if (match && wanted.test(match[2]))
|
|
406
|
+
return match[1];
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
export function codexMoreReasoningChoice(screen) {
|
|
411
|
+
for (const line of screen.split("\n")) {
|
|
412
|
+
const match = line.match(/^\s*(?:›\s*)?(\d+)\.\s+More reasoning/);
|
|
413
|
+
if (match)
|
|
414
|
+
return match[1];
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
// 回収対象turnの最終assistantメッセージをroot rollout transcriptから抽出する。
|
|
419
|
+
export function codexTranscriptText(meta, turnId, readTranscriptLines, transcriptUnavailable) {
|
|
420
|
+
if (!meta.codex_home || !meta.vendor_session_id)
|
|
421
|
+
transcriptUnavailable();
|
|
422
|
+
const transcript = findLatestCodexTranscript(meta.codex_home, meta.vendor_session_id);
|
|
423
|
+
if (!transcript)
|
|
424
|
+
transcriptUnavailable();
|
|
425
|
+
const lines = readTranscriptLines(transcript);
|
|
426
|
+
const matching = [];
|
|
427
|
+
let finalAnswer = "";
|
|
428
|
+
for (const line of lines) {
|
|
429
|
+
if (!line.trim())
|
|
430
|
+
continue;
|
|
431
|
+
let record;
|
|
432
|
+
try {
|
|
433
|
+
record = JSON.parse(line);
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
const payload = record?.payload;
|
|
439
|
+
if (record?.type === "response_item" &&
|
|
440
|
+
payload?.type === "message" &&
|
|
441
|
+
payload?.role === "assistant" &&
|
|
442
|
+
payload?.internal_chat_message_metadata_passthrough?.turn_id === turnId &&
|
|
443
|
+
Array.isArray(payload?.content)) {
|
|
444
|
+
for (const item of payload.content) {
|
|
445
|
+
if (item?.type === "output_text" && typeof item.text === "string")
|
|
446
|
+
matching.push(item.text);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (record?.type === "event_msg" &&
|
|
450
|
+
payload?.type === "agent_message" &&
|
|
451
|
+
payload?.phase === "final_answer" &&
|
|
452
|
+
typeof payload?.message === "string") {
|
|
453
|
+
finalAnswer = payload.message;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return matching.join("\n") || finalAnswer;
|
|
457
|
+
}
|
|
458
|
+
export function createCodexAgentMetadata(name, cwd, initialPrompt, overrides = {}, writeScope, lineageContext) {
|
|
459
|
+
const launchId = randomBytes(16).toString("hex");
|
|
460
|
+
const eventFile = agentEventPath(name, launchId);
|
|
461
|
+
createEmpty0600(eventFile);
|
|
462
|
+
const codexHome = realCodexHome();
|
|
463
|
+
const meta = {
|
|
464
|
+
kind: "codex",
|
|
465
|
+
aiterm_session: name,
|
|
466
|
+
launch_id: launchId,
|
|
467
|
+
event_file: eventFile,
|
|
468
|
+
created_at: new Date().toISOString(),
|
|
469
|
+
cwd,
|
|
470
|
+
...(writeScope === undefined ? {} : { write_scope: writeScope }),
|
|
471
|
+
vendor_session_id: null,
|
|
472
|
+
initial_prompt: initialPrompt,
|
|
473
|
+
hook_route: "shared_codex_home",
|
|
474
|
+
completion_route: "codex_transcript",
|
|
475
|
+
...(lineageContext ? agentLineageFields(lineageContext) : {}),
|
|
476
|
+
node_platform: process.platform,
|
|
477
|
+
codex_home: codexHome,
|
|
478
|
+
};
|
|
479
|
+
writeAgentMetadata(meta);
|
|
480
|
+
return meta;
|
|
481
|
+
}
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
// Grok / Composer 固有の制御。Composer は grok CLI の別モデル起動プリセットであり、
|
|
2
|
+
// モデル既定値と表示名以外は Grok と完全共通(実測 2026-08-23 棚卸し・docs/32)。
|
|
3
|
+
// core 所有のサービス(transcript 行読取・rate limit 検知)は引数で注入し、
|
|
4
|
+
// 依存方向を core → vendors → agent-shared の一方向に保つ。
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { AitermError } from "../errors.js";
|
|
10
|
+
import { modeBitsWorldAccessible, modeBitsWritableByOthers } from "../tmux-runtime.js";
|
|
11
|
+
import { spawnAgentControlCommand } from "../agent-resolver.js";
|
|
12
|
+
import { shq, subagentInstruction, writeScopeLaunchNote, currentUid, safeStatSize, readFileRange, sleep, agentMetadataPath, writeAgentMetadata, agentEventPath, createEmpty0600, agentLineageFields, AGENT_DONE_POLL_MS, AGENT_EVENT_MAX_BYTES, GROK_TRANSCRIPT_INCREMENT_MAX_BYTES, } from "../agent-shared.js";
|
|
13
|
+
const GROK_AUTH_MAX_BYTES = 64 * 1024;
|
|
14
|
+
const GROK_MODELS_MAX_BYTES = 1024 * 1024;
|
|
15
|
+
const GROK_MODELS_TIMEOUT_MS = 15_000;
|
|
16
|
+
// grok CLI はモデル未指定だと端末側 default に従うため、ツール契約として既定 slug を固定する。
|
|
17
|
+
// codex は既定 slug を持たず端末 config/CLI 既定に委ねる(起動応答で実効値を報告する)。
|
|
18
|
+
// grok 既定は dotagents 規範(docs/02_models.md: xAI 旗艦 = grok-4.6)に従う。
|
|
19
|
+
export const GROK_MODEL_DEFAULTS = {
|
|
20
|
+
grok: "grok-4.6",
|
|
21
|
+
composer: "grok-composer-2.5-fast",
|
|
22
|
+
};
|
|
23
|
+
export function realGrokHome() {
|
|
24
|
+
return path.resolve(process.env.GROK_HOME || path.join(process.env.HOME ?? os.homedir(), ".grok"));
|
|
25
|
+
}
|
|
26
|
+
export function resolveAndValidateGrokAuth(srcHome) {
|
|
27
|
+
const inheritedSet = Object.prototype.hasOwnProperty.call(process.env, "GROK_AUTH_PATH");
|
|
28
|
+
const inherited = process.env.GROK_AUTH_PATH;
|
|
29
|
+
if (inheritedSet && (!inherited || !path.isAbsolute(inherited)))
|
|
30
|
+
throw new AitermError("GROK_AUTH_PATH は空でない絶対パスで指定してください", 2);
|
|
31
|
+
const authPath = inherited ?? path.join(srcHome, "auth.json");
|
|
32
|
+
let fd;
|
|
33
|
+
try {
|
|
34
|
+
fd = fs.openSync(authPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
35
|
+
const st = fs.fstatSync(fd);
|
|
36
|
+
// isFile・nlink・owner・size・O_NOFOLLOW・realpath 検証は全OS共通に維持する
|
|
37
|
+
// (mode bit 検証のOS差は tmux-runtime の modeBits* が所有)。
|
|
38
|
+
const worldAccessible = modeBitsWorldAccessible(st.mode);
|
|
39
|
+
if (!st.isFile() || st.nlink !== 1 || st.uid !== currentUid() || worldAccessible || st.size > GROK_AUTH_MAX_BYTES) {
|
|
40
|
+
throw new AitermError("Grok 認証正本の安全検証に失敗しました", 2);
|
|
41
|
+
}
|
|
42
|
+
const value = JSON.parse(fs.readFileSync(fd, "utf8"));
|
|
43
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
44
|
+
throw new AitermError("Grok 認証正本のJSONが不正です", 2);
|
|
45
|
+
// auth file 自体は O_NOFOLLOW で開いているが、中間 directory の symlink は辿り得る。
|
|
46
|
+
// vendor に渡す正本を path swap の入口にしないため、字句正規化した絶対 path と realpath を
|
|
47
|
+
// 一致させ、canonical な祖先も root まで検証する。same-UID race の排他は vendor lock の責務。
|
|
48
|
+
const lexicalPath = path.resolve(authPath);
|
|
49
|
+
const canonicalPath = fs.realpathSync(authPath);
|
|
50
|
+
if (lexicalPath !== canonicalPath)
|
|
51
|
+
throw new AitermError("Grok 認証正本の path に symlink を含められません", 2);
|
|
52
|
+
for (let dir = path.dirname(canonicalPath);; dir = path.dirname(dir)) {
|
|
53
|
+
const dirSt = fs.lstatSync(dir);
|
|
54
|
+
// /tmp のような root 所有 + sticky の共有 directory は、本人所有の private な
|
|
55
|
+
// 直下 directory を他 UID が rename/unlink できないため許可する。sticky 無しの
|
|
56
|
+
// group/other writable 祖先は path swap が可能なので従来どおり拒否する。
|
|
57
|
+
const writableByOthers = modeBitsWritableByOthers(dirSt.mode);
|
|
58
|
+
const protectedSharedRoot = dirSt.uid === 0 && (dirSt.mode & 0o1000) !== 0;
|
|
59
|
+
if (!dirSt.isDirectory()
|
|
60
|
+
|| dirSt.isSymbolicLink()
|
|
61
|
+
|| (dirSt.uid !== currentUid() && dirSt.uid !== 0)
|
|
62
|
+
|| (writableByOthers && !protectedSharedRoot)) {
|
|
63
|
+
throw new AitermError("Grok 認証正本の祖先 directory が安全ではありません", 2);
|
|
64
|
+
}
|
|
65
|
+
const parent = path.dirname(dir);
|
|
66
|
+
if (parent === dir)
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
return canonicalPath;
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e.code === "ENOENT" && !inheritedSet && process.env.XAI_API_KEY)
|
|
73
|
+
return null;
|
|
74
|
+
if (e instanceof AitermError)
|
|
75
|
+
throw e;
|
|
76
|
+
throw new AitermError(e.code === "ENOENT" ? "Grok 認証正本が見つかりません。先に grok login が必要です" : "Grok 認証正本を安全に開けません", 2);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (fd !== undefined)
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export function grokModelCatalog(bin, cwd) {
|
|
84
|
+
const result = spawnAgentControlCommand(bin, ["models"], cwd, {
|
|
85
|
+
cwd,
|
|
86
|
+
encoding: "utf8",
|
|
87
|
+
env: process.env,
|
|
88
|
+
timeout: GROK_MODELS_TIMEOUT_MS,
|
|
89
|
+
maxBuffer: GROK_MODELS_MAX_BYTES,
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
});
|
|
92
|
+
if (result.error || result.status !== 0) {
|
|
93
|
+
const detail = result.error?.message || result.stderr?.trim() || `exit=${result.status ?? "unknown"}`;
|
|
94
|
+
throw new AitermError(`Grok model catalog を取得できません: ${detail}`, 2);
|
|
95
|
+
}
|
|
96
|
+
const text = result.stdout.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
|
|
97
|
+
const marker = "Available models:";
|
|
98
|
+
const start = text.indexOf(marker);
|
|
99
|
+
if (start < 0)
|
|
100
|
+
throw new AitermError("Grok model catalog の出力形式が不正です(Available models がありません)", 2);
|
|
101
|
+
const models = [];
|
|
102
|
+
for (const line of text.slice(start + marker.length).split(/\r?\n/)) {
|
|
103
|
+
const match = line.match(/^\s{2}[-*]\s+(.+?)(?:\s+\(default\))?\s*$/);
|
|
104
|
+
if (match?.[1])
|
|
105
|
+
models.push(match[1]);
|
|
106
|
+
}
|
|
107
|
+
if (models.length === 0)
|
|
108
|
+
throw new AitermError("Grok model catalog に利用可能なmodelがありません", 2);
|
|
109
|
+
return models;
|
|
110
|
+
}
|
|
111
|
+
export function assertGrokModelAvailable(bin, cwd, model) {
|
|
112
|
+
const models = grokModelCatalog(bin, cwd);
|
|
113
|
+
if (!models.includes(model)) {
|
|
114
|
+
throw new AitermError(`Grok model catalog に ${JSON.stringify(model)} がありません。利用可能: ${models.join(", ")}。` +
|
|
115
|
+
"別modelへfallbackせず起動を中止しました", 2);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function grokSessionDirectory(meta) {
|
|
119
|
+
if ((meta.kind !== "grok" && meta.kind !== "composer") || !meta.grok_home || !meta.vendor_session_id)
|
|
120
|
+
return null;
|
|
121
|
+
const cwd = meta.cwd ?? process.cwd();
|
|
122
|
+
return path.join(meta.grok_home, "sessions", encodeURIComponent(cwd), meta.vendor_session_id);
|
|
123
|
+
}
|
|
124
|
+
export function grokEventsTranscript(meta) {
|
|
125
|
+
const dir = grokSessionDirectory(meta);
|
|
126
|
+
return dir ? path.join(dir, "events.jsonl") : null;
|
|
127
|
+
}
|
|
128
|
+
export function grokCompletionEvent(meta, record) {
|
|
129
|
+
if ((meta.kind !== "grok" && meta.kind !== "composer") ||
|
|
130
|
+
record?.type !== "turn_ended" ||
|
|
131
|
+
(record?.outcome !== "completed" && record?.outcome !== "cancelled"))
|
|
132
|
+
return null;
|
|
133
|
+
const turnId = typeof record?.ts === "string" || typeof record?.ts === "number" ? String(record.ts) : null;
|
|
134
|
+
return {
|
|
135
|
+
type: "agent_done",
|
|
136
|
+
vendor: meta.kind,
|
|
137
|
+
aiterm_session: meta.aiterm_session,
|
|
138
|
+
launch_id: meta.launch_id,
|
|
139
|
+
vendor_session_id: meta.vendor_session_id,
|
|
140
|
+
turn_id: turnId,
|
|
141
|
+
operation_id: null,
|
|
142
|
+
reason: `Grok transcript turn_ended:${record.outcome}`,
|
|
143
|
+
done_status: "turn_done",
|
|
144
|
+
stop_hook_active: false,
|
|
145
|
+
at: typeof record?.ts === "string" ? record.ts : new Date().toISOString(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export function latestGrokCompletion(meta, readTranscriptLines) {
|
|
149
|
+
const transcript = grokEventsTranscript(meta);
|
|
150
|
+
if (!transcript || !fs.existsSync(transcript))
|
|
151
|
+
return null;
|
|
152
|
+
let latest = null;
|
|
153
|
+
for (const line of readTranscriptLines(transcript)) {
|
|
154
|
+
if (!line.trim())
|
|
155
|
+
continue;
|
|
156
|
+
try {
|
|
157
|
+
latest = grokCompletionEvent(meta, JSON.parse(line)) ?? latest;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// 末尾書込み中のlineは次の観測で完結してから読む。
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return latest;
|
|
164
|
+
}
|
|
165
|
+
export function grokInitializationComplete(meta) {
|
|
166
|
+
const transcript = grokEventsTranscript(meta);
|
|
167
|
+
if (!transcript || !fs.existsSync(transcript))
|
|
168
|
+
return false;
|
|
169
|
+
const size = safeStatSize(transcript);
|
|
170
|
+
const from = Math.max(0, size - GROK_TRANSCRIPT_INCREMENT_MAX_BYTES);
|
|
171
|
+
const lines = readFileRange(transcript, from, size).toString("utf8").split("\n");
|
|
172
|
+
if (from > 0)
|
|
173
|
+
lines.shift();
|
|
174
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
175
|
+
const line = lines[index].trim();
|
|
176
|
+
if (!line)
|
|
177
|
+
continue;
|
|
178
|
+
try {
|
|
179
|
+
const record = JSON.parse(line);
|
|
180
|
+
if (record?.type === "mcp_init_completed")
|
|
181
|
+
return true;
|
|
182
|
+
if (record?.type === "mcp_init_started")
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// 末尾書込み中や無関係な破損lineはready判定を進めない。
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
export async function observeGrokDone(meta, timeout, requestedCursor, detectRateLimit) {
|
|
192
|
+
const metadataFile = agentMetadataPath(meta.aiterm_session, meta.launch_id);
|
|
193
|
+
const transcript = grokEventsTranscript(meta);
|
|
194
|
+
if (!transcript)
|
|
195
|
+
throw new AitermError("Grok transcriptのsession相関情報がありません", 2);
|
|
196
|
+
const startOffset = requestedCursor ?? safeStatSize(transcript);
|
|
197
|
+
let cursor = startOffset;
|
|
198
|
+
let carry = "";
|
|
199
|
+
let malformedEvents = 0;
|
|
200
|
+
let discardLeadingFragment = false;
|
|
201
|
+
let initializedBoundary = false;
|
|
202
|
+
const deadline = performance.now() + timeout * 1000;
|
|
203
|
+
const observation = (outcome, ev = null, rateLimit = null) => ({
|
|
204
|
+
schema: "aiterm.agent-wait-result.v1",
|
|
205
|
+
session_id: meta.aiterm_session,
|
|
206
|
+
launch_id: meta.launch_id,
|
|
207
|
+
vendor: meta.kind,
|
|
208
|
+
outcome,
|
|
209
|
+
operation_id: null,
|
|
210
|
+
vendor_session_id: meta.vendor_session_id,
|
|
211
|
+
turn_id: ev?.turn_id ?? null,
|
|
212
|
+
malformed_events: malformedEvents,
|
|
213
|
+
at: ev?.at ?? null,
|
|
214
|
+
rate_limit: rateLimit,
|
|
215
|
+
});
|
|
216
|
+
for (;;) {
|
|
217
|
+
if (!fs.existsSync(metadataFile))
|
|
218
|
+
return observation("closed");
|
|
219
|
+
if (fs.existsSync(transcript)) {
|
|
220
|
+
if (!initializedBoundary) {
|
|
221
|
+
if (cursor > 0) {
|
|
222
|
+
const previous = readFileRange(transcript, cursor - 1, cursor).toString("utf8");
|
|
223
|
+
discardLeadingFragment = previous !== "\n";
|
|
224
|
+
}
|
|
225
|
+
initializedBoundary = true;
|
|
226
|
+
}
|
|
227
|
+
const size = safeStatSize(transcript);
|
|
228
|
+
if (size < cursor) {
|
|
229
|
+
throw new AitermError("Grok transcript が完了待機中に短くなりました。該当セッションを閉じて起動し直してください。", 2);
|
|
230
|
+
}
|
|
231
|
+
if (size - startOffset > GROK_TRANSCRIPT_INCREMENT_MAX_BYTES) {
|
|
232
|
+
throw new AitermError("Grok transcript のturn増分が大きすぎます。該当セッションを閉じて起動し直してください。", 2);
|
|
233
|
+
}
|
|
234
|
+
if (size > cursor) {
|
|
235
|
+
carry += readFileRange(transcript, cursor, size).toString("utf8");
|
|
236
|
+
cursor = size;
|
|
237
|
+
const parts = carry.split("\n");
|
|
238
|
+
carry = parts.pop() ?? "";
|
|
239
|
+
if (discardLeadingFragment && parts.length > 0) {
|
|
240
|
+
parts.shift();
|
|
241
|
+
discardLeadingFragment = false;
|
|
242
|
+
}
|
|
243
|
+
for (const line of parts) {
|
|
244
|
+
if (!line.trim())
|
|
245
|
+
continue;
|
|
246
|
+
if (Buffer.byteLength(line, "utf8") > AGENT_EVENT_MAX_BYTES) {
|
|
247
|
+
malformedEvents++;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
const done = grokCompletionEvent(meta, JSON.parse(line));
|
|
252
|
+
if (done)
|
|
253
|
+
return observation("done", done);
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
malformedEvents++;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
{
|
|
262
|
+
const limited = detectRateLimit(meta.kind, meta.aiterm_session);
|
|
263
|
+
if (limited)
|
|
264
|
+
return observation("rate_limited", null, limited);
|
|
265
|
+
}
|
|
266
|
+
if (performance.now() >= deadline)
|
|
267
|
+
return observation(timeout === 0 ? "running" : "timeout");
|
|
268
|
+
await sleep(AGENT_DONE_POLL_MS);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function buildGrokAgentCmd(kind, bin, model, effort, prompt, meta) {
|
|
272
|
+
const parts = [shq(bin)];
|
|
273
|
+
// grok / composer は同じ grok CLI をモデル違いで起動する。
|
|
274
|
+
parts.push("--no-auto-update");
|
|
275
|
+
if (meta?.kind === "grok" || meta?.kind === "composer")
|
|
276
|
+
parts.push("--no-alt-screen");
|
|
277
|
+
parts.push("--model", shq(model ?? GROK_MODEL_DEFAULTS[kind]));
|
|
278
|
+
if (effort)
|
|
279
|
+
parts.push("--reasoning-effort", shq(effort));
|
|
280
|
+
if ((meta?.kind === "grok" || meta?.kind === "composer") && meta.write_scope === "read-only") {
|
|
281
|
+
// read-only は sandbox が実効書込み禁止を作るため、MCP ツール許可ダイアログの自動承認を
|
|
282
|
+
// 付けても能力は増えない。無人 subagent が初回 MCP 使用の許可待ちで停止する実障害への対処。
|
|
283
|
+
// read-only 以外の launch には付けない=権限拡大しない。
|
|
284
|
+
parts.push("--sandbox", "read-only", "--always-approve");
|
|
285
|
+
}
|
|
286
|
+
if ((meta?.kind === "grok" || meta?.kind === "composer") && meta.hook_route === "shared_grok_home") {
|
|
287
|
+
parts.push("--session-id", shq(meta.vendor_session_id ?? ""), "--rules", shq(subagentInstruction(meta)));
|
|
288
|
+
}
|
|
289
|
+
if ((meta?.kind === "grok" || meta?.kind === "composer") && prompt)
|
|
290
|
+
parts.push("--verbatim");
|
|
291
|
+
if (prompt)
|
|
292
|
+
parts.push(shq(prompt)); // 初手プロンプト(任意)
|
|
293
|
+
return parts.join(" ");
|
|
294
|
+
}
|
|
295
|
+
export function grokLaunchNote(kind, model, effort, meta) {
|
|
296
|
+
const writeScopeNote = writeScopeLaunchNote(kind, meta?.write_scope);
|
|
297
|
+
return (`起動設定: model=${model ?? GROK_MODEL_DEFAULTS[kind]}(${model ? "引数" : "ツール既定"})。` +
|
|
298
|
+
`effort=${effort ?? "CLI/model既定"}。` + writeScopeNote);
|
|
299
|
+
}
|
|
300
|
+
// grok/composer: 検証済み auth 正本をそのままの path 形で渡す。Windows では native 強制により
|
|
301
|
+
// vendor は Windows process なので、Windows ドライブパスが正しい形(WSL 形への変換はしない)。
|
|
302
|
+
export function grokEnvTokens(meta) {
|
|
303
|
+
return [
|
|
304
|
+
...(meta.grok_auth_path ? [`GROK_AUTH_PATH=${shq(meta.grok_auth_path)}`] : []),
|
|
305
|
+
"GROK_DISABLE_AUTOUPDATER=1",
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
export function grokTuiReady(screen) {
|
|
309
|
+
// Grok Build 0.2.117 は起動完了後に製品名を消し、model footerだけを残す。
|
|
310
|
+
// Composerも同じfrontendでmodel名だけが異なるため、両方をvendor UIの根拠にする。
|
|
311
|
+
// Windows native grok.exe(1.0.4 実測)は入力欄markerを `❯` でなく `>` で描画するため両方を受ける。
|
|
312
|
+
const grokFrontend = screen.includes("Grok Build") || /\b(?:Grok|Composer)\s+[\w.()-]+/.test(screen);
|
|
313
|
+
return grokFrontend && /(^|\n|\s)[❯>]/.test(screen);
|
|
314
|
+
}
|
|
315
|
+
// submit座礁観測のcomposer領域マーカー(Windows native描画の `>` も ready 判定と同様に受ける)。
|
|
316
|
+
export const GROK_COMPOSER_MARKER_RE = /(^|\s)[❯>]/;
|
|
317
|
+
export function grokFooterHasConfiguration(screen, model, effort) {
|
|
318
|
+
const modelMatch = model?.match(/^grok-(\d+(?:\.\d+)*)$/) ?? null;
|
|
319
|
+
if (model && !modelMatch)
|
|
320
|
+
return false;
|
|
321
|
+
const modelLabel = modelMatch ? `Grok ${modelMatch[1]}` : null;
|
|
322
|
+
return screen.split("\n").some((line) => {
|
|
323
|
+
if (!line.includes("·"))
|
|
324
|
+
return false;
|
|
325
|
+
if (modelLabel && !line.includes(`${modelLabel} (`))
|
|
326
|
+
return false;
|
|
327
|
+
if (effort && !line.includes(`(${effort})`))
|
|
328
|
+
return false;
|
|
329
|
+
return modelLabel !== null || effort !== null;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
// 最後のuser発話以降のassistantメッセージをchat_history.jsonlから抽出する。
|
|
333
|
+
export function grokTranscriptText(meta, readTranscriptLines, transcriptUnavailable) {
|
|
334
|
+
if (!meta.grok_home || !meta.vendor_session_id)
|
|
335
|
+
transcriptUnavailable();
|
|
336
|
+
// cwd 未指定で起動した TUI はサーバープロセスの cwd を継承する。metadata に null が残る既存
|
|
337
|
+
// launch との互換のため、その実際の起動 cwd を path 導出に使う(launch 側は変更しない)。
|
|
338
|
+
const cwd = meta.cwd ?? process.cwd();
|
|
339
|
+
const transcript = path.join(meta.grok_home, "sessions", encodeURIComponent(cwd), meta.vendor_session_id, "chat_history.jsonl");
|
|
340
|
+
const lines = readTranscriptLines(transcript);
|
|
341
|
+
let lastUser = -1;
|
|
342
|
+
const records = [];
|
|
343
|
+
for (const line of lines) {
|
|
344
|
+
if (!line.trim())
|
|
345
|
+
continue;
|
|
346
|
+
try {
|
|
347
|
+
const record = JSON.parse(line);
|
|
348
|
+
records.push(record);
|
|
349
|
+
if (record?.type === "user" && !("synthetic_reason" in record))
|
|
350
|
+
lastUser = records.length - 1;
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// 外部 transcript の壊れた1行は残りの完結行を読む妨げにしない。
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return records
|
|
357
|
+
.slice(lastUser + 1)
|
|
358
|
+
.filter((record) => record?.type === "assistant" && typeof record?.content === "string")
|
|
359
|
+
.map((record) => record.content)
|
|
360
|
+
.join("\n");
|
|
361
|
+
}
|
|
362
|
+
export function createGrokAgentMetadata(kind, name, cwd, initialPrompt, authPath, writeScope, lineageContext) {
|
|
363
|
+
const launchId = randomBytes(16).toString("hex");
|
|
364
|
+
const eventFile = agentEventPath(name, launchId);
|
|
365
|
+
createEmpty0600(eventFile);
|
|
366
|
+
const grokHome = realGrokHome();
|
|
367
|
+
const meta = {
|
|
368
|
+
kind,
|
|
369
|
+
aiterm_session: name,
|
|
370
|
+
launch_id: launchId,
|
|
371
|
+
event_file: eventFile,
|
|
372
|
+
created_at: new Date().toISOString(),
|
|
373
|
+
cwd,
|
|
374
|
+
...(writeScope === undefined ? {} : { write_scope: writeScope }),
|
|
375
|
+
vendor_session_id: randomUUID(),
|
|
376
|
+
initial_prompt: initialPrompt,
|
|
377
|
+
hook_route: "shared_grok_home",
|
|
378
|
+
completion_route: "grok_transcript",
|
|
379
|
+
...(lineageContext ? agentLineageFields(lineageContext) : {}),
|
|
380
|
+
node_platform: process.platform,
|
|
381
|
+
grok_home: grokHome,
|
|
382
|
+
grok_auth_path: authPath,
|
|
383
|
+
};
|
|
384
|
+
writeAgentMetadata(meta);
|
|
385
|
+
return meta;
|
|
386
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiterm-mcp",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.8",
|
|
4
4
|
"mcpName": "io.github.kitepon/aiterm-mcp",
|
|
5
5
|
"description": "Persistent tmux terminal MCP for launching and driving Claude, Codex, Grok, or Composer from any MCP client, cross-vendor or same-vendor. Also runs durable PTY sessions for SSH, containers, and REPLs.",
|
|
6
6
|
"keywords": [
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"files": [
|
|
47
47
|
"dist/*.js",
|
|
48
|
+
"dist/vendors/*.js",
|
|
48
49
|
"README.md",
|
|
49
50
|
"LICENSE"
|
|
50
51
|
],
|