odai-dsh-agent 0.2.39 → 0.2.51
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.md +13 -11
- package/build/src/control-center-installer.d.mts.map +1 -1
- package/build/src/control-center-installer.mjs +5 -0
- package/build/src/control-center-installer.mjs.map +1 -1
- package/build/src/installer.d.mts.map +1 -1
- package/build/src/installer.mjs +1 -1
- package/build/src/installer.mjs.map +1 -1
- package/client/client.js +27 -6
- package/package.json +1 -1
- package/preset/odai/package.json +2 -1
- package/preset/odai/runtime/compaction-config.d.mts +2 -2
- package/preset/odai/runtime/compaction-config.d.mts.map +1 -1
- package/preset/odai/runtime/context-activation.mjs +1 -1
- package/preset/odai/runtime/context-activation.mjs.map +1 -1
- package/preset/odai/runtime/index.mjs +1 -1
- package/preset/odai/runtime/index.mjs.map +1 -1
- package/preset/odai/runtime/lifecycle-runtime.d.mts.map +1 -1
- package/preset/odai/runtime/lifecycle-runtime.mjs +25 -4
- package/preset/odai/runtime/lifecycle-runtime.mjs.map +1 -1
- package/preset/odai/runtime/model-route.d.mts +5 -0
- package/preset/odai/runtime/model-route.d.mts.map +1 -1
- package/preset/odai/runtime/model-route.mjs +21 -2
- package/preset/odai/runtime/model-route.mjs.map +1 -1
- package/preset/odai/runtime/output-config.d.mts.map +1 -1
- package/preset/odai/runtime/output-config.mjs +2 -1
- package/preset/odai/runtime/output-config.mjs.map +1 -1
- package/preset/odai/runtime/role-overlays.d.mts +1 -1
- package/preset/odai/runtime/role-overlays.d.mts.map +1 -1
- package/preset/odai/runtime/role-overlays.mjs +4 -1
- package/preset/odai/runtime/role-overlays.mjs.map +1 -1
- package/preset/odai/runtime/router.d.mts +5 -1
- package/preset/odai/runtime/router.d.mts.map +1 -1
- package/preset/odai/runtime/router.mjs +47 -2
- package/preset/odai/runtime/router.mjs.map +1 -1
- package/preset/odai/runtime/routing-config.d.mts +5 -2
- package/preset/odai/runtime/routing-config.d.mts.map +1 -1
- package/preset/odai/runtime/routing-config.mjs +87 -42
- package/preset/odai/runtime/routing-config.mjs.map +1 -1
- package/preset/odai/runtime/runtime-config.d.mts +1 -1
- package/preset/odai/runtime/runtime-config.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-config.mjs +15 -9
- package/preset/odai/runtime/runtime-config.mjs.map +1 -1
- package/preset/odai/runtime/runtime-support.d.mts +6 -1
- package/preset/odai/runtime/runtime-support.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-support.mjs +90 -7
- package/preset/odai/runtime/runtime-support.mjs.map +1 -1
- package/preset/odai/runtime/runtime-types.d.mts +8 -1
- package/preset/odai/runtime/runtime-types.d.mts.map +1 -1
- package/preset/odai/runtime/runtime-types.mjs.map +1 -1
- package/preset/odai/runtime/skill-bundle.d.mts +1 -1
- package/preset/odai/runtime/skill-bundle.d.mts.map +1 -1
- package/preset/odai/runtime/work-policy.d.mts.map +1 -1
- package/preset/odai/runtime/work-policy.mjs +8 -2
- package/preset/odai/runtime/work-policy.mjs.map +1 -1
- package/preset/odai/skills/odai-orchestration/SKILL.md +1 -1
- package/preset/odai/skills/odai-orchestration/assets/codex-agents/role.toml +1 -0
- package/preset/odai/skills/odai-orchestration/assets/routing-roles/controller.md +1 -1
- package/preset/odai/skills/odai-orchestration/assets/routing-roles/creative.md +9 -0
- package/preset/odai/skills/odai-orchestration/manifest.json +9 -4
- package/preset/odai/skills/odai-orchestration/references/install.md +31 -1
- package/preset/odai/skills/odai-orchestration/references/orchestration.md +1 -0
- package/preset/odai/skills/odai-orchestration/scripts/build-routing.mjs +78 -17
- package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.d.mts +1 -1
- package/preset/odai/skills/odai-orchestration/scripts/compose-contracts.mjs +2 -2
- package/preset/odai/skills/odai-orchestration/scripts/install-routing.mjs +101 -23
- package/preset/odai/skills/odai-orchestration/scripts/local-cli.d.mts +75 -0
- package/preset/odai/skills/odai-orchestration/scripts/local-cli.mjs +409 -0
- package/preset/odai/skills/odai-orchestration/scripts/run-role.mjs +190 -92
- package/preset/odai/skills/odai-orchestration/scripts/verify-routing.mjs +3 -3
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmodSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
const ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
|
6
|
+
const ROUTE_FIELDS = new Set(["backend", "cli", "model", "reasoningEffort", "timeoutSeconds"]);
|
|
7
|
+
const DEFAULT_TIMEOUT_SECONDS = 1200;
|
|
8
|
+
const MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
|
|
9
|
+
const STDERR_BYTES = 8192;
|
|
10
|
+
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateLocalCliRoute(value) {
|
|
16
|
+
if (!isRecord(value)) throw new TypeError("local-cli route must be an object");
|
|
17
|
+
if (value.backend !== "local-cli") throw new TypeError('backend must be "local-cli"');
|
|
18
|
+
if (Object.hasOwn(value, "provider") || Object.hasOwn(value, "maxTokens")) {
|
|
19
|
+
throw new TypeError("local-cli routes must not contain provider or maxTokens");
|
|
20
|
+
}
|
|
21
|
+
if (value.cli === "gemini") throw new TypeError("local-cli gemini: 本版本尚未支持 Gemini CLI,可改用 antigravity");
|
|
22
|
+
if (!Object.hasOwn(ADAPTERS, value.cli)) throw new TypeError("local-cli cli must be claude, codex, antigravity, or grok");
|
|
23
|
+
for (const key of Object.keys(value)) {
|
|
24
|
+
if (!ROUTE_FIELDS.has(key)) throw new TypeError(`Unknown local-cli route field: ${key}`);
|
|
25
|
+
}
|
|
26
|
+
if (typeof value.model !== "string" || !value.model || /^-|[\s\p{Cc}\p{Cf}]/u.test(value.model)) {
|
|
27
|
+
throw new TypeError("local-cli model must be non-empty, must not start with -, or contain whitespace or control characters");
|
|
28
|
+
}
|
|
29
|
+
if (value.reasoningEffort !== undefined && (typeof value.reasoningEffort !== "string" || /[^a-z]/.test(value.reasoningEffort) || !ADAPTERS[value.cli].effort.test(value.reasoningEffort))) {
|
|
30
|
+
throw new TypeError(`Invalid ${value.cli} reasoningEffort`);
|
|
31
|
+
}
|
|
32
|
+
const timeoutSeconds = value.timeoutSeconds;
|
|
33
|
+
if (timeoutSeconds !== undefined && (!Number.isInteger(timeoutSeconds) || timeoutSeconds <= 0 || timeoutSeconds > 7200)) {
|
|
34
|
+
throw new TypeError("local-cli timeoutSeconds must be an integer from 1 to 7200");
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
backend: "local-cli", cli: value.cli, model: value.model,
|
|
38
|
+
...(value.reasoningEffort !== undefined ? { reasoningEffort: value.reasoningEffort } : {}),
|
|
39
|
+
...(timeoutSeconds !== undefined ? { timeoutSeconds } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const AGY_READ_BOUNDARY = "\n\n读文件只用 view_file、grep_search、list_dir、find_by_name;命令行不可用,会被拒绝。";
|
|
44
|
+
const effortArgs = (route) => route.reasoningEffort ? ["--effort", route.reasoningEffort] : [];
|
|
45
|
+
const ADAPTERS = Object.freeze({
|
|
46
|
+
claude: {
|
|
47
|
+
effort: /^(low|medium|high|xhigh|max)$/,
|
|
48
|
+
command: "claude", override: "ODAI_CLAUDE_COMMAND", input: "stdin",
|
|
49
|
+
args: (route) => ["-p", "--output-format", "json", "--model", route.model, ...effortArgs(route),
|
|
50
|
+
"--tools", "Read,Glob,Grep", "--permission-mode", "dontAsk", "--no-session-persistence"],
|
|
51
|
+
parse: (stdout, route) => parseClaudeResult(stdout, route.model),
|
|
52
|
+
},
|
|
53
|
+
codex: {
|
|
54
|
+
effort: /^[a-z]+$/,
|
|
55
|
+
command: "codex", override: "ODAI_CODEX_COMMAND", input: "stdin",
|
|
56
|
+
args: (route, { cwd }) => ["exec", "--json", "--model", route.model,
|
|
57
|
+
...(route.reasoningEffort ? ["-c", `model_reasoning_effort=${JSON.stringify(route.reasoningEffort)}`] : []),
|
|
58
|
+
"-c", "features.multi_agent=false", "--sandbox", "read-only", "--skip-git-repo-check", "-C", cwd, "-"],
|
|
59
|
+
parse: parseCodexResult,
|
|
60
|
+
},
|
|
61
|
+
antigravity: {
|
|
62
|
+
effort: /^(low|medium|high)$/,
|
|
63
|
+
command: "agy", override: "ODAI_AGY_COMMAND", input: "argv",
|
|
64
|
+
args: (route, { prompt }) => {
|
|
65
|
+
if (typeof prompt !== "string") throw new TypeError("antigravity requires a prompt");
|
|
66
|
+
const input = prompt + AGY_READ_BOUNDARY;
|
|
67
|
+
if (Buffer.byteLength(input, "utf8") > 256 * 1024) throw new TypeError("antigravity prompt exceeds 256 KiB (including read-only instructions)");
|
|
68
|
+
return ["-p", input, "--output-format", "stream-json", "--mode", "plan", "--disable-slash-commands", "--model", route.model,
|
|
69
|
+
...effortArgs(route), "--print-timeout", `${route.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS}s`];
|
|
70
|
+
},
|
|
71
|
+
parse: parseAntigravityResult,
|
|
72
|
+
},
|
|
73
|
+
grok: {
|
|
74
|
+
effort: /^(none|minimal|low|medium|high|xhigh|max)$/,
|
|
75
|
+
command: "grok", override: "ODAI_GROK_COMMAND", input: "file",
|
|
76
|
+
args: (route, { cwd, promptFile }) => {
|
|
77
|
+
if (typeof promptFile !== "string" || !promptFile) throw new TypeError("grok requires a private prompt file");
|
|
78
|
+
return ["--prompt-file", promptFile, "--output-format", "json", "-m", route.model, ...effortArgs(route),
|
|
79
|
+
"--tools", "read_file,grep,list_dir", "--disallowed-tools", "Agent", "--sandbox", "read-only",
|
|
80
|
+
"--permission-mode", "dontAsk", "--no-memory", "--cwd", cwd];
|
|
81
|
+
},
|
|
82
|
+
parse: parseGrokResult,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export function buildLocalCliInvocation(route, env = process.env, context = {}) {
|
|
87
|
+
const normalized = validateLocalCliRoute(route);
|
|
88
|
+
if (["codex", "grok"].includes(normalized.cli) && (typeof context.cwd !== "string" || !context.cwd)) {
|
|
89
|
+
throw new TypeError(`${normalized.cli} requires cwd`);
|
|
90
|
+
}
|
|
91
|
+
const adapter = ADAPTERS[normalized.cli];
|
|
92
|
+
return { command: env[adapter.override] || adapter.command, args: adapter.args(normalized, context) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emptyResult(status, error) {
|
|
96
|
+
return {
|
|
97
|
+
status, text: "", modelVerified: false, modelVerification: "missing",
|
|
98
|
+
reasoningEffortVerification: "requested-only", ...(error ? { error } : {}),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function tail(value, length = 2000) {
|
|
103
|
+
return String(value).trim().slice(-length);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseClaudeResult(stdout, requestedModel) {
|
|
107
|
+
let payload;
|
|
108
|
+
try {
|
|
109
|
+
payload = JSON.parse(stdout);
|
|
110
|
+
} catch {
|
|
111
|
+
return emptyResult("error", `Claude returned invalid JSON${tail(stdout) ? `: ${tail(stdout)}` : ""}`);
|
|
112
|
+
}
|
|
113
|
+
if (!isRecord(payload)) return emptyResult("error", "Claude returned an invalid result object");
|
|
114
|
+
const result = {
|
|
115
|
+
...emptyResult("completed"),
|
|
116
|
+
text: typeof payload.result === "string" ? payload.result : "",
|
|
117
|
+
...(isRecord(payload.usage) ? { usage: payload.usage } : {}),
|
|
118
|
+
...(typeof payload.total_cost_usd === "number" && Number.isFinite(payload.total_cost_usd) && payload.total_cost_usd >= 0
|
|
119
|
+
? { cliReportedCostUsd: payload.total_cost_usd } : {}),
|
|
120
|
+
};
|
|
121
|
+
// Only modelUsage is model evidence; neither prose nor top-level model claims qualify.
|
|
122
|
+
const models = isRecord(payload.modelUsage)
|
|
123
|
+
? Object.entries(payload.modelUsage).filter(([model, usage]) => model.trim() && isRecord(usage))
|
|
124
|
+
: [];
|
|
125
|
+
const outputTokens = (usage) => typeof usage.outputTokens === "number" && Number.isFinite(usage.outputTokens)
|
|
126
|
+
&& usage.outputTokens >= 0 ? usage.outputTokens : 0;
|
|
127
|
+
models.sort((a, b) => outputTokens(b[1]) - outputTokens(a[1]));
|
|
128
|
+
if (models.length) {
|
|
129
|
+
const [model, usage] = models[0];
|
|
130
|
+
const canonical = typeof usage.canonicalModel === "string" ? usage.canonicalModel : "";
|
|
131
|
+
const requested = typeof requestedModel === "string" ? requestedModel.trim() : "";
|
|
132
|
+
result.actualModel = model;
|
|
133
|
+
result.modelVerified = requested.length > 0 && (ALIASES.has(requested)
|
|
134
|
+
? model.includes(requested) || canonical.includes(requested)
|
|
135
|
+
: model === requested || canonical === requested || model.startsWith(`${requested}-`));
|
|
136
|
+
result.modelVerification = result.modelVerified ? "verified" : "mismatch";
|
|
137
|
+
}
|
|
138
|
+
if (payload.is_error || payload.subtype !== "success") {
|
|
139
|
+
result.status = "error";
|
|
140
|
+
const details = typeof payload.error === "string" ? payload.error
|
|
141
|
+
: Array.isArray(payload.errors) ? payload.errors.filter((item) => typeof item === "string").join("; ") : result.text;
|
|
142
|
+
result.error = `Claude result ${typeof payload.subtype === "string" ? payload.subtype : "missing success subtype"}${details ? `: ${tail(details)}` : ""}`;
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function jsonLines(stdout) {
|
|
148
|
+
return stdout.split(/\r?\n/).filter((line) => line.trim()).map((line) => {
|
|
149
|
+
const value = JSON.parse(line);
|
|
150
|
+
if (!isRecord(value)) throw new TypeError("Invalid JSONL event");
|
|
151
|
+
return value;
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function exactModel(result, actual, requested) {
|
|
156
|
+
if (typeof actual !== "string" || !actual || /^-|[\s\p{Cc}\p{Cf}]/u.test(actual)) return;
|
|
157
|
+
result.actualModel = actual;
|
|
158
|
+
result.modelVerified = actual === requested;
|
|
159
|
+
result.modelVerification = result.modelVerified ? "verified" : "mismatch";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readEvidence(file) {
|
|
163
|
+
// Session files are untrusted input too; never load an unbounded transcript.
|
|
164
|
+
if (statSync(file).size > MAX_OUTPUT_BYTES) throw new Error("CLI evidence exceeds output limit");
|
|
165
|
+
return readFileSync(file, "utf8");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function codexContext(threadId, cwd, env) {
|
|
169
|
+
if (typeof threadId !== "string" || !/^[a-zA-Z0-9-]+$/.test(threadId)) return;
|
|
170
|
+
const root = join(env.CODEX_HOME || join(env.HOME || homedir(), ".codex"), "sessions");
|
|
171
|
+
const pending = [root];
|
|
172
|
+
const matches = [];
|
|
173
|
+
while (pending.length) {
|
|
174
|
+
for (const entry of readdirSync(pending.pop(), { withFileTypes: true })) {
|
|
175
|
+
const full = join(entry.parentPath, entry.name);
|
|
176
|
+
if (entry.isDirectory()) pending.push(full);
|
|
177
|
+
// Locate by thread ID in the filename, never scan unrelated transcript bodies.
|
|
178
|
+
else if (entry.isFile() && entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl") && entry.name.includes(threadId)) matches.push(full);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Ambiguous evidence must not depend on directory iteration order.
|
|
182
|
+
if (matches.length !== 1) return;
|
|
183
|
+
const events = jsonLines(readEvidence(matches[0]));
|
|
184
|
+
const session = events.find((event) => event.type === "session_meta")?.payload;
|
|
185
|
+
if (session?.id !== threadId || typeof session.cwd !== "string" || realpathSync(session.cwd) !== realpathSync(cwd)) return;
|
|
186
|
+
return events.findLast((event) => event.type === "turn_context")?.payload;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function parseCodexResult(stdout, route, { cwd, env }) {
|
|
190
|
+
const events = jsonLines(stdout);
|
|
191
|
+
const lastMessage = events.findLast((event) => event.type === "item.completed" && event.item?.type === "agent_message");
|
|
192
|
+
const result = { ...emptyResult("completed"), text: typeof lastMessage?.item?.text === "string" ? lastMessage.item.text : "" };
|
|
193
|
+
const usage = events.findLast((event) => event.type === "turn.completed")?.usage;
|
|
194
|
+
if (isRecord(usage)) result.usage = usage;
|
|
195
|
+
const failure = events.findLast((event) => event.type === "turn.failed" || event.type === "error");
|
|
196
|
+
if (failure) return { ...result, status: "error", error: `Codex failed: ${tail(failure.error?.message || failure.message || "unknown error")}` };
|
|
197
|
+
if (route.reasoningEffort) result.reasoningEffortVerification = "mismatch";
|
|
198
|
+
try {
|
|
199
|
+
const context = codexContext(events.find((event) => event.type === "thread.started")?.thread_id, cwd, env);
|
|
200
|
+
exactModel(result, context?.model, route.model);
|
|
201
|
+
if (typeof context?.effort === "string" && /^[a-z]+$/.test(context.effort)) {
|
|
202
|
+
result.actualReasoningEffort = context.effort;
|
|
203
|
+
result.reasoningEffortVerification = !route.reasoningEffort || route.reasoningEffort === context.effort ? "verified" : "mismatch";
|
|
204
|
+
}
|
|
205
|
+
} catch { /* Missing, malformed or unreadable session evidence stays unverified. */ }
|
|
206
|
+
return result;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseAntigravityResult(stdout, route) {
|
|
210
|
+
const events = jsonLines(stdout);
|
|
211
|
+
const init = events.find((event) => event.event === "init");
|
|
212
|
+
const payload = events.findLast((event) => event.event === "result")?.result;
|
|
213
|
+
if (!isRecord(payload)) return emptyResult("error", "Antigravity returned no result event");
|
|
214
|
+
const result = { ...emptyResult("completed"), text: typeof payload.response === "string" ? payload.response : "" };
|
|
215
|
+
exactModel(result, init?.init?.model, route.model);
|
|
216
|
+
if (typeof init?.conversation_id === "string") result.sessionId = init.conversation_id;
|
|
217
|
+
if (isRecord(payload.usage)) result.usage = payload.usage;
|
|
218
|
+
if (Array.isArray(payload.denied_actions)) result.deniedActionsCount = payload.denied_actions.length;
|
|
219
|
+
else if (Number.isSafeInteger(payload.denied_actions) && payload.denied_actions >= 0) result.deniedActionsCount = payload.denied_actions;
|
|
220
|
+
if (payload.status !== "SUCCESS") {
|
|
221
|
+
result.status = "error";
|
|
222
|
+
result.error = `Antigravity result ${payload.status || "missing status"}: ${tail(payload.error || result.text)}`;
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function grokTokenCount(usage) {
|
|
228
|
+
if (!isRecord(usage)) return 0;
|
|
229
|
+
const count = (...fields) => fields.map((field) => usage[field])
|
|
230
|
+
.find((value) => typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
231
|
+
return count("total_tokens", "totalTokens") ?? (
|
|
232
|
+
(count("input_tokens", "inputTokens") ?? 0)
|
|
233
|
+
+ (count("output_tokens", "outputTokens") ?? 0)
|
|
234
|
+
+ (count("cache_read_input_tokens", "cacheReadInputTokens") ?? 0)
|
|
235
|
+
+ (count("cache_creation_input_tokens", "cacheCreationInputTokens") ?? 0)
|
|
236
|
+
+ (count("reasoning_tokens", "reasoningTokens") ?? 0)
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseGrokResult(stdout, route, { cwd, env }) {
|
|
241
|
+
const payload = JSON.parse(stdout);
|
|
242
|
+
if (!isRecord(payload)) return emptyResult("error", "Grok returned an invalid result object");
|
|
243
|
+
const result = { ...emptyResult("completed"), text: typeof payload.text === "string" ? payload.text : "" };
|
|
244
|
+
if (payload.stopReason !== "EndTurn") return { ...result, status: "error", error: `Grok stopReason: ${tail(payload.stopReason || "missing")}` };
|
|
245
|
+
// Do not allow a CLI-supplied session ID to escape the sessions directory.
|
|
246
|
+
const safeSessionId = typeof payload.sessionId === "string" && /^[a-zA-Z0-9_-]+$/.test(payload.sessionId);
|
|
247
|
+
if (safeSessionId) result.sessionId = payload.sessionId;
|
|
248
|
+
// Canary output supplies modelUsage even though the CLI README omits it.
|
|
249
|
+
// Never replace conflicting or malformed modelUsage evidence with a summary.
|
|
250
|
+
if (payload.modelUsage != null) {
|
|
251
|
+
if (!isRecord(payload.modelUsage)) return result;
|
|
252
|
+
const models = Object.entries(payload.modelUsage);
|
|
253
|
+
models.sort((a, b) => grokTokenCount(b[1]) - grokTokenCount(a[1]));
|
|
254
|
+
if (models.length) {
|
|
255
|
+
exactModel(result, models[0][0], route.model);
|
|
256
|
+
return result;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (!safeSessionId) return result;
|
|
260
|
+
try {
|
|
261
|
+
const summary = JSON.parse(readEvidence(join(env.HOME || homedir(), ".grok", "sessions", encodeURIComponent(resolve(cwd)), payload.sessionId, "summary.json")));
|
|
262
|
+
if (isRecord(summary)) exactModel(result, summary.model ?? summary.modelId ?? summary.model_id, route.model);
|
|
263
|
+
} catch { /* Official documentation only: unknown evidence formats fail closed. */ }
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The string overload preserves the original Claude parser API. */
|
|
268
|
+
export function parseLocalCliResult(stdout, requestedRoute, context = {}) {
|
|
269
|
+
const route = typeof requestedRoute === "string" ? { backend: "local-cli", cli: "claude", model: requestedRoute } : validateLocalCliRoute(requestedRoute);
|
|
270
|
+
try {
|
|
271
|
+
return ADAPTERS[route.cli].parse(stdout, route, { ...context, env: context.env ?? process.env });
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return emptyResult("error", `${route.cli} returned invalid output: ${tail(error?.message || error)}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Executes only the target CLI. Host routing and fallback policy belong to callers. */
|
|
278
|
+
export async function executeLocalCli({
|
|
279
|
+
route, prompt, cwd, signal, env = process.env, spawnProcess = spawn, killProcess,
|
|
280
|
+
killGraceMs = 1000, maxOutputBytes = MAX_OUTPUT_BYTES,
|
|
281
|
+
}) {
|
|
282
|
+
const normalized = validateLocalCliRoute(route);
|
|
283
|
+
if (typeof prompt !== "string") throw new TypeError("local-cli prompt must be a string");
|
|
284
|
+
if (typeof cwd !== "string" || !cwd) throw new TypeError("local-cli cwd must be a non-empty string");
|
|
285
|
+
if (!Number.isSafeInteger(killGraceMs) || killGraceMs < 0) throw new TypeError("killGraceMs must be a non-negative safe integer");
|
|
286
|
+
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) throw new TypeError("maxOutputBytes must be a positive safe integer");
|
|
287
|
+
if (killProcess !== undefined && typeof killProcess !== "function") throw new TypeError("killProcess must be a function");
|
|
288
|
+
// An injected fake spawn must never cause OS signals to be sent to an invented PID.
|
|
289
|
+
// Inject killProcess as well to exercise process-group behavior with a fake executor.
|
|
290
|
+
const processGroup = process.platform !== "win32" && (spawnProcess === spawn || killProcess !== undefined);
|
|
291
|
+
const signalProcess = killProcess ?? process.kill;
|
|
292
|
+
const started = Date.now();
|
|
293
|
+
let exitCode = null;
|
|
294
|
+
const timedResult = (result) => ({ ...result, exitCode, durationMs: Date.now() - started });
|
|
295
|
+
if (signal?.aborted) return timedResult(emptyResult("cancelled", "Local CLI execution cancelled"));
|
|
296
|
+
const adapter = ADAPTERS[normalized.cli];
|
|
297
|
+
const timeoutSeconds = normalized.timeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
298
|
+
let temporary;
|
|
299
|
+
try {
|
|
300
|
+
let promptFile;
|
|
301
|
+
if (adapter.input === "file") {
|
|
302
|
+
temporary = mkdtempSync(join(tmpdir(), "odai-local-cli-"));
|
|
303
|
+
chmodSync(temporary, 0o700);
|
|
304
|
+
promptFile = join(temporary, "prompt.txt");
|
|
305
|
+
writeFileSync(promptFile, prompt, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
306
|
+
}
|
|
307
|
+
const invocation = buildLocalCliInvocation(normalized, env, { prompt, cwd, promptFile });
|
|
308
|
+
return await new Promise((resolve) => {
|
|
309
|
+
let child;
|
|
310
|
+
let settled = false;
|
|
311
|
+
let stopResult;
|
|
312
|
+
let timeout;
|
|
313
|
+
let killTimer;
|
|
314
|
+
let closed = false;
|
|
315
|
+
let groupKillSent = false;
|
|
316
|
+
let stdoutBytes = 0;
|
|
317
|
+
const stdout = [];
|
|
318
|
+
let stderr = Buffer.alloc(0);
|
|
319
|
+
const finish = (result) => {
|
|
320
|
+
if (settled) return;
|
|
321
|
+
settled = true;
|
|
322
|
+
clearTimeout(timeout);
|
|
323
|
+
clearTimeout(killTimer);
|
|
324
|
+
signal?.removeEventListener("abort", onAbort);
|
|
325
|
+
resolve(timedResult(result));
|
|
326
|
+
};
|
|
327
|
+
const kill = (terminationSignal) => {
|
|
328
|
+
try {
|
|
329
|
+
if (processGroup && child.pid) signalProcess(-child.pid, terminationSignal);
|
|
330
|
+
else child.kill(terminationSignal);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
// An exited leader can still have living descendants in its process group.
|
|
333
|
+
if (error?.code !== "ESRCH" && stopResult) stopResult.error += `; ${error?.message || String(error)}`;
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
const stop = (status, message) => {
|
|
337
|
+
if (settled || stopResult) return;
|
|
338
|
+
stopResult = emptyResult(status, message);
|
|
339
|
+
kill("SIGTERM");
|
|
340
|
+
if (!settled) killTimer = setTimeout(() => {
|
|
341
|
+
kill("SIGKILL");
|
|
342
|
+
groupKillSent = true;
|
|
343
|
+
if (closed) finish(stopResult);
|
|
344
|
+
}, killGraceMs);
|
|
345
|
+
};
|
|
346
|
+
const onAbort = () => stop("cancelled", "Local CLI execution cancelled");
|
|
347
|
+
try {
|
|
348
|
+
child = spawnProcess(invocation.command, invocation.args, {
|
|
349
|
+
cwd, env, shell: false, windowsHide: true, detached: processGroup, stdio: ["pipe", "pipe", "pipe"],
|
|
350
|
+
});
|
|
351
|
+
} catch (error) {
|
|
352
|
+
finish(emptyResult("error", `Could not start ${normalized.cli}: ${error?.message || String(error)}`));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
child.on("error", (error) => {
|
|
356
|
+
if (!child.pid) finish(stopResult || emptyResult("error", `Could not start ${normalized.cli}: ${error.message}`));
|
|
357
|
+
else stop("error", `${normalized.cli} process error: ${error.message}`);
|
|
358
|
+
});
|
|
359
|
+
child.stdout.on("data", (chunk) => {
|
|
360
|
+
if (settled || stopResult) return;
|
|
361
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
362
|
+
stdoutBytes += bytes.length;
|
|
363
|
+
if (stdoutBytes > maxOutputBytes) {
|
|
364
|
+
stdout.length = 0;
|
|
365
|
+
stop("error", `${normalized.cli} stdout exceeded the ${maxOutputBytes} byte output limit`);
|
|
366
|
+
} else stdout.push(bytes);
|
|
367
|
+
});
|
|
368
|
+
child.stderr.on("data", (chunk) => {
|
|
369
|
+
if (settled || stopResult) return;
|
|
370
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
371
|
+
stderr = Buffer.concat([stderr, bytes.subarray(-STDERR_BYTES)]).subarray(-STDERR_BYTES);
|
|
372
|
+
});
|
|
373
|
+
for (const [name, stream] of [["stdout", child.stdout], ["stderr", child.stderr]]) {
|
|
374
|
+
stream.on("error", (error) => stop("error", `${normalized.cli} ${name} error: ${error.message}`));
|
|
375
|
+
}
|
|
376
|
+
child.on("close", (code, terminationSignal) => {
|
|
377
|
+
exitCode = Number.isInteger(code) ? code : null;
|
|
378
|
+
closed = true;
|
|
379
|
+
if (stopResult) {
|
|
380
|
+
// Do not clear the escalation timer merely because the group leader closed
|
|
381
|
+
// its pipes: descendants may still be running with independent stdio.
|
|
382
|
+
if (!processGroup || groupKillSent) finish(stopResult);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (code !== 0) {
|
|
386
|
+
const details = tail(stderr.toString("utf8")) || tail(Buffer.concat(stdout).toString("utf8"));
|
|
387
|
+
finish(emptyResult("error", `${normalized.cli} exited ${terminationSignal ? `with signal ${terminationSignal}` : `with code ${code}`}${details ? `: ${details}` : ""}`));
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
finish(parseLocalCliResult(Buffer.concat(stdout).toString("utf8"), normalized, { cwd, env }));
|
|
391
|
+
});
|
|
392
|
+
child.stdin.on("error", (error) => {
|
|
393
|
+
// Early CLI failure often closes stdin first; close still supplies its stderr and exit code.
|
|
394
|
+
if (error.code !== "EPIPE" && error.code !== "ERR_STREAM_DESTROYED") stop("error", `${normalized.cli} stdin error: ${error.message}`);
|
|
395
|
+
});
|
|
396
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
397
|
+
timeout = setTimeout(() => stop("timeout", `Local CLI execution timed out after ${timeoutSeconds} seconds`), timeoutSeconds * 1000);
|
|
398
|
+
if (signal?.aborted) { onAbort(); return; }
|
|
399
|
+
try { child.stdin.end(adapter.input === "stdin" ? prompt : undefined, "utf8"); } catch (error) {
|
|
400
|
+
stop("error", `Could not send ${normalized.cli} input: ${error?.message || String(error)}`);
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
} catch (error) {
|
|
404
|
+
return timedResult(emptyResult("error", `${normalized.cli} execution failed: ${tail(error?.message || error)}`));
|
|
405
|
+
} finally {
|
|
406
|
+
// Exact private mkdtemp root owned by this invocation; never a caller-supplied path.
|
|
407
|
+
if (temporary) rmSync(temporary, { recursive: true, force: true });
|
|
408
|
+
}
|
|
409
|
+
}
|