@lwmxiaobei/xbcode 1.0.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/LICENSE +21 -0
- package/README.md +631 -0
- package/README.zh-CN.md +542 -0
- package/dist/agent.js +1450 -0
- package/dist/busy-status.js +29 -0
- package/dist/clipboard-image.js +97 -0
- package/dist/commands.js +109 -0
- package/dist/compact.js +262 -0
- package/dist/config.js +516 -0
- package/dist/error-log.js +80 -0
- package/dist/http.js +89 -0
- package/dist/idle-watchdog.js +88 -0
- package/dist/index.js +2031 -0
- package/dist/input-submit.js +41 -0
- package/dist/mcp/client.js +466 -0
- package/dist/mcp/manager.js +275 -0
- package/dist/mcp/runtime.js +420 -0
- package/dist/mcp/types.js +12 -0
- package/dist/message-bus.js +180 -0
- package/dist/oauth/openai.js +326 -0
- package/dist/prompt.js +156 -0
- package/dist/session-store.js +186 -0
- package/dist/skills/frontmatter.js +85 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.js +88 -0
- package/dist/skills/render.js +35 -0
- package/dist/skills/types.js +1 -0
- package/dist/subagents.js +64 -0
- package/dist/supervisor.js +58 -0
- package/dist/task-manager.js +280 -0
- package/dist/team-types.js +1 -0
- package/dist/teammate-manager.js +266 -0
- package/dist/tools.js +1068 -0
- package/dist/trust-store.js +42 -0
- package/dist/types.js +1 -0
- package/dist/usage.js +226 -0
- package/dist/utils.js +21 -0
- package/package.json +67 -0
- package/scripts/postinstall.mjs +30 -0
- package/skills/code-review/SKILL.md +22 -0
- package/skills/pdf/SKILL.md +18 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { isPlainRecord } from "./utils.js";
|
|
5
|
+
const CONFIG_DIR = path.join(os.homedir(), ".xbcode");
|
|
6
|
+
const SETTINGS_PATH = path.join(CONFIG_DIR, "settings.json");
|
|
7
|
+
const CREDENTIALS_PATH = path.join(CONFIG_DIR, "credentials.json");
|
|
8
|
+
let cachedSettings = null;
|
|
9
|
+
let cachedSettingsWarnings = [];
|
|
10
|
+
export function getSettingsPath() {
|
|
11
|
+
return SETTINGS_PATH;
|
|
12
|
+
}
|
|
13
|
+
export function getCredentialsPath() {
|
|
14
|
+
return CREDENTIALS_PATH;
|
|
15
|
+
}
|
|
16
|
+
export function loadSettings() {
|
|
17
|
+
if (cachedSettings)
|
|
18
|
+
return cachedSettings;
|
|
19
|
+
const defaultSettings = { providers: {}, mcp: { servers: [] } };
|
|
20
|
+
try {
|
|
21
|
+
if (!fs.existsSync(SETTINGS_PATH)) {
|
|
22
|
+
cachedSettingsWarnings = [];
|
|
23
|
+
cachedSettings = defaultSettings;
|
|
24
|
+
return defaultSettings;
|
|
25
|
+
}
|
|
26
|
+
const raw = fs.readFileSync(SETTINGS_PATH, "utf8");
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
const warnings = [];
|
|
29
|
+
cachedSettings = normalizeSettings(parsed, warnings);
|
|
30
|
+
cachedSettingsWarnings = warnings;
|
|
31
|
+
return cachedSettings;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
cachedSettingsWarnings = [`[config] Failed to load settings: ${error instanceof Error ? error.message : String(error)}`];
|
|
35
|
+
cachedSettings = defaultSettings;
|
|
36
|
+
return defaultSettings;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read and normalize a settings file from an explicit path.
|
|
41
|
+
*
|
|
42
|
+
* Why this exists:
|
|
43
|
+
* - Most runtime code uses the global settings path, but targeted update helpers
|
|
44
|
+
* and tests need the same normalization behavior for arbitrary files.
|
|
45
|
+
* - Keeping the file-path variant local to this module avoids exposing another
|
|
46
|
+
* caching surface while still reusing the exact same parser rules.
|
|
47
|
+
*/
|
|
48
|
+
function loadSettingsFromFile(filePath) {
|
|
49
|
+
const defaultSettings = { providers: {}, mcp: { servers: [] } };
|
|
50
|
+
try {
|
|
51
|
+
if (!fs.existsSync(filePath)) {
|
|
52
|
+
return defaultSettings;
|
|
53
|
+
}
|
|
54
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
55
|
+
return normalizeSettings(JSON.parse(raw), []);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return defaultSettings;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function reloadSettings() {
|
|
62
|
+
cachedSettings = null;
|
|
63
|
+
cachedSettingsWarnings = [];
|
|
64
|
+
return loadSettings();
|
|
65
|
+
}
|
|
66
|
+
export function getSettingsWarnings() {
|
|
67
|
+
return [...cachedSettingsWarnings];
|
|
68
|
+
}
|
|
69
|
+
export function getProviderNames() {
|
|
70
|
+
const settings = loadSettings();
|
|
71
|
+
return Object.keys(settings.providers);
|
|
72
|
+
}
|
|
73
|
+
export function normalizeModelEntry(entry) {
|
|
74
|
+
if (typeof entry === "string")
|
|
75
|
+
return { id: entry };
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
export function getProviderModels(providerName) {
|
|
79
|
+
const settings = loadSettings();
|
|
80
|
+
return (settings.providers[providerName]?.models ?? []).map((m) => normalizeModelEntry(m).id);
|
|
81
|
+
}
|
|
82
|
+
export function loadCredentialsFile(filePath = CREDENTIALS_PATH) {
|
|
83
|
+
const empty = { providers: {} };
|
|
84
|
+
try {
|
|
85
|
+
if (!fs.existsSync(filePath)) {
|
|
86
|
+
return empty;
|
|
87
|
+
}
|
|
88
|
+
const raw = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
89
|
+
return normalizeCredentialsFile(raw);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return empty;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export async function writeCredentialsFile(filePath, credentials) {
|
|
96
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
97
|
+
await fs.promises.writeFile(filePath, `${JSON.stringify(normalizeCredentialsFile(credentials), null, 2)}\n`, "utf8");
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Persist normalized settings back to disk.
|
|
101
|
+
*
|
|
102
|
+
* Why this exists:
|
|
103
|
+
* - Runtime flows such as OAuth model discovery need to update the user's
|
|
104
|
+
* provider configuration after the process has already started.
|
|
105
|
+
* - Writing normalized settings keeps the stored file aligned with the same
|
|
106
|
+
* schema validation rules used during reads, which avoids persisting partial
|
|
107
|
+
* or malformed provider state.
|
|
108
|
+
* - Resetting the cache after the write ensures later reads in the same process
|
|
109
|
+
* see the new configuration immediately.
|
|
110
|
+
*/
|
|
111
|
+
export async function writeSettingsFile(filePath, settings) {
|
|
112
|
+
const normalized = normalizeSettings(settings, []);
|
|
113
|
+
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
|
114
|
+
await fs.promises.writeFile(filePath, `${JSON.stringify(normalized, null, 2)}\n`, "utf8");
|
|
115
|
+
cachedSettings = null;
|
|
116
|
+
cachedSettingsWarnings = [];
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Update the model list for a single provider while preserving the rest of the
|
|
120
|
+
* settings file as-is.
|
|
121
|
+
*
|
|
122
|
+
* Why this exists:
|
|
123
|
+
* - OAuth login can discover the exact API-visible model IDs for the active
|
|
124
|
+
* provider and should write only that slice of configuration.
|
|
125
|
+
* - Reusing a focused helper keeps the login flow small and avoids duplicating
|
|
126
|
+
* settings-file merge logic in UI code.
|
|
127
|
+
* - The helper normalizes incoming model IDs so callers can pass any raw API
|
|
128
|
+
* list without worrying about duplicate whitespace or empty entries.
|
|
129
|
+
*/
|
|
130
|
+
export async function updateProviderModels(filePath, providerName, modelIds) {
|
|
131
|
+
const settings = loadSettingsFromFile(filePath);
|
|
132
|
+
const provider = settings.providers[providerName];
|
|
133
|
+
if (!provider) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const normalizedModels = modelIds
|
|
137
|
+
.map((modelId) => modelId.trim())
|
|
138
|
+
.filter((modelId, index, values) => Boolean(modelId) && values.indexOf(modelId) === index);
|
|
139
|
+
await writeSettingsFile(filePath, {
|
|
140
|
+
...settings,
|
|
141
|
+
providers: {
|
|
142
|
+
...settings.providers,
|
|
143
|
+
[providerName]: {
|
|
144
|
+
...provider,
|
|
145
|
+
models: normalizedModels,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export async function clearProviderCredentials(filePath, providerName) {
|
|
151
|
+
const current = loadCredentialsFile(filePath);
|
|
152
|
+
if (!(providerName in current.providers)) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const nextProviders = { ...current.providers };
|
|
156
|
+
delete nextProviders[providerName];
|
|
157
|
+
await writeCredentialsFile(filePath, { providers: nextProviders });
|
|
158
|
+
}
|
|
159
|
+
function resolveApiMode(baseURL, explicit) {
|
|
160
|
+
const mode = (explicit ?? "").trim().toLowerCase();
|
|
161
|
+
if (["chat", "chat-completions", "chat_completions"].includes(mode)) {
|
|
162
|
+
return "chat-completions";
|
|
163
|
+
}
|
|
164
|
+
if (mode === "responses") {
|
|
165
|
+
return "responses";
|
|
166
|
+
}
|
|
167
|
+
const lowerBaseURL = baseURL.toLowerCase();
|
|
168
|
+
// Auto-detect providers that only support Chat Completions API
|
|
169
|
+
if (lowerBaseURL.includes("deepseek.com") || lowerBaseURL.includes("dashscope.aliyuncs.com")) {
|
|
170
|
+
return "chat-completions";
|
|
171
|
+
}
|
|
172
|
+
return "responses";
|
|
173
|
+
}
|
|
174
|
+
export function resolveConfig(providerName, modelName) {
|
|
175
|
+
const settings = loadSettings();
|
|
176
|
+
// Determine which provider to use: explicit arg > defaultProvider > first available key
|
|
177
|
+
const providerKeys = Object.keys(settings.providers);
|
|
178
|
+
const targetProvider = providerName || settings.defaultProvider || providerKeys[0] || "";
|
|
179
|
+
const provider = settings.providers[targetProvider];
|
|
180
|
+
const availableModels = (provider?.models ?? []).map((m) => normalizeModelEntry(m).id);
|
|
181
|
+
/**
|
|
182
|
+
* Resolve the active model from the strongest available source so startup can
|
|
183
|
+
* skip the interactive picker when the user has already chosen a stable
|
|
184
|
+
* default model.
|
|
185
|
+
*
|
|
186
|
+
* Why this order matters:
|
|
187
|
+
* - Explicit runtime switches must win for the current session.
|
|
188
|
+
* - `MODEL_ID` remains the strongest shell-level override.
|
|
189
|
+
* - `defaultModel` gives `settings.json` a persistent default model.
|
|
190
|
+
*/
|
|
191
|
+
const model = modelName ?? process.env.MODEL_ID ?? settings.defaultModel ?? "";
|
|
192
|
+
const apiKey = provider?.apiKey ?? "";
|
|
193
|
+
const baseURL = provider?.baseURL ?? "https://api.openai.com/v1";
|
|
194
|
+
const apiMode = resolveApiMode(baseURL, provider?.apiMode);
|
|
195
|
+
const showThinking = settings.showThinking ?? false;
|
|
196
|
+
return {
|
|
197
|
+
model,
|
|
198
|
+
apiKey,
|
|
199
|
+
baseURL,
|
|
200
|
+
apiMode,
|
|
201
|
+
showThinking,
|
|
202
|
+
providerName: targetProvider,
|
|
203
|
+
availableModels,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Evaluate whether a resolved startup config is complete enough to skip the
|
|
208
|
+
* interactive model picker.
|
|
209
|
+
*
|
|
210
|
+
* Why this exists:
|
|
211
|
+
* - The UI needs a deterministic rule, but tests should not have to depend on
|
|
212
|
+
* the real `~/.xbcode/settings.json` on the current machine.
|
|
213
|
+
* - Splitting the pure decision from filesystem-backed config loading keeps the
|
|
214
|
+
* startup path easy to verify without mutating user state.
|
|
215
|
+
* - The shell-level override must remain a first-class bypass so ad-hoc runs
|
|
216
|
+
* can pin a model even before settings metadata catches up.
|
|
217
|
+
*/
|
|
218
|
+
export function shouldPromptForModelSelection(resolved, hasShellModelOverride = false) {
|
|
219
|
+
if (!resolved.providerName || !resolved.model) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (hasShellModelOverride) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return !resolved.availableModels.includes(resolved.model);
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Decide whether startup must ask the user to pick a model interactively.
|
|
229
|
+
*
|
|
230
|
+
* Why this exists:
|
|
231
|
+
* - Startup needs one consistent rule that matches the documented precedence:
|
|
232
|
+
* explicit arg > `MODEL_ID` > `settings.defaultModel`.
|
|
233
|
+
* - `MODEL_ID` is intentionally a shell-level override, so it must be allowed to
|
|
234
|
+
* bypass the picker even when the local settings file has not listed that
|
|
235
|
+
* model yet.
|
|
236
|
+
* - Persistent config values should still be validated against the configured
|
|
237
|
+
* provider model list so broken saved settings continue to fall back to the
|
|
238
|
+
* chooser instead of failing later with a surprising runtime state.
|
|
239
|
+
*/
|
|
240
|
+
export function needsModelSelection(providerName, modelName) {
|
|
241
|
+
const resolved = resolveConfig(providerName, modelName);
|
|
242
|
+
return shouldPromptForModelSelection(resolved, !modelName && Boolean(process.env.MODEL_ID));
|
|
243
|
+
}
|
|
244
|
+
function normalizeAuthConfig(value, warningPrefix, warnings) {
|
|
245
|
+
if (value === undefined) {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
if (!isPlainRecord(value)) {
|
|
249
|
+
warnings.push(`${warningPrefix} must be an object.`);
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|
|
252
|
+
if (value.type === "oauth") {
|
|
253
|
+
return { type: "oauth" };
|
|
254
|
+
}
|
|
255
|
+
warnings.push(`${warningPrefix}.type must be "oauth".`);
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
function normalizeModelProfile(value, providerName, warnings) {
|
|
259
|
+
if (!isPlainRecord(value)) {
|
|
260
|
+
warnings.push(`[config] provider "${providerName}" must be an object.`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
const models = Array.isArray(value.models) ? value.models : [];
|
|
264
|
+
if (!Array.isArray(value.models)) {
|
|
265
|
+
warnings.push(`[config] provider "${providerName}".models must be an array.`);
|
|
266
|
+
}
|
|
267
|
+
const normalizedModels = [];
|
|
268
|
+
for (const entry of models) {
|
|
269
|
+
if (typeof entry === "string") {
|
|
270
|
+
normalizedModels.push(entry);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (isPlainRecord(entry) && typeof entry.id === "string" && entry.id.trim()) {
|
|
274
|
+
normalizedModels.push({
|
|
275
|
+
id: entry.id.trim(),
|
|
276
|
+
name: typeof entry.name === "string" ? entry.name : undefined,
|
|
277
|
+
description: typeof entry.description === "string" ? entry.description : undefined,
|
|
278
|
+
});
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
warnings.push(`[config] provider "${providerName}" has an invalid model entry.`);
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
models: normalizedModels,
|
|
285
|
+
apiKey: typeof value.apiKey === "string" ? value.apiKey : undefined,
|
|
286
|
+
baseURL: typeof value.baseURL === "string" ? value.baseURL : undefined,
|
|
287
|
+
apiMode: value.apiMode === "responses" || value.apiMode === "chat-completions"
|
|
288
|
+
? value.apiMode
|
|
289
|
+
: undefined,
|
|
290
|
+
auth: normalizeAuthConfig(value.auth, `[config] provider "${providerName}".auth`, warnings),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function normalizeStoredOAuthCredentials(value) {
|
|
294
|
+
if (!isPlainRecord(value) || value.type !== "oauth") {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
type: "oauth",
|
|
299
|
+
access_token: typeof value.access_token === "string" ? value.access_token : undefined,
|
|
300
|
+
refresh_token: typeof value.refresh_token === "string" ? value.refresh_token : undefined,
|
|
301
|
+
id_token: typeof value.id_token === "string" ? value.id_token : undefined,
|
|
302
|
+
expires_at: typeof value.expires_at === "string" ? value.expires_at : undefined,
|
|
303
|
+
client_id: typeof value.client_id === "string" ? value.client_id : undefined,
|
|
304
|
+
email: typeof value.email === "string" ? value.email : undefined,
|
|
305
|
+
chatgpt_account_id: typeof value.chatgpt_account_id === "string" ? value.chatgpt_account_id : undefined,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
export function normalizeCredentialsFile(raw) {
|
|
309
|
+
if (!isPlainRecord(raw) || !isPlainRecord(raw.providers)) {
|
|
310
|
+
return { providers: {} };
|
|
311
|
+
}
|
|
312
|
+
const providers = Object.entries(raw.providers)
|
|
313
|
+
.map(([providerName, value]) => [providerName, normalizeStoredOAuthCredentials(value)])
|
|
314
|
+
.filter((entry) => entry[1] !== null);
|
|
315
|
+
return { providers: Object.fromEntries(providers) };
|
|
316
|
+
}
|
|
317
|
+
function isCredentialExpired(value, now = new Date()) {
|
|
318
|
+
if (!value) {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
const expiresAt = Date.parse(value);
|
|
322
|
+
if (Number.isNaN(expiresAt)) {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
return expiresAt - now.getTime() <= 60_000;
|
|
326
|
+
}
|
|
327
|
+
export function resolveProviderAuthState(settings, providerName, credentials, now = new Date()) {
|
|
328
|
+
const provider = settings.providers[providerName];
|
|
329
|
+
const apiKey = provider?.apiKey ?? "";
|
|
330
|
+
const oauth = credentials.providers[providerName];
|
|
331
|
+
if (provider?.auth?.type === "oauth" && oauth?.type === "oauth") {
|
|
332
|
+
const accessToken = oauth.access_token?.trim() ?? "";
|
|
333
|
+
if (accessToken && !isCredentialExpired(oauth.expires_at, now)) {
|
|
334
|
+
return {
|
|
335
|
+
authMode: "oauth",
|
|
336
|
+
bearerToken: accessToken,
|
|
337
|
+
apiKey,
|
|
338
|
+
oauth,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (apiKey.trim()) {
|
|
343
|
+
return {
|
|
344
|
+
authMode: "apiKey",
|
|
345
|
+
bearerToken: apiKey,
|
|
346
|
+
apiKey,
|
|
347
|
+
oauth,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
authMode: "none",
|
|
352
|
+
bearerToken: "",
|
|
353
|
+
apiKey,
|
|
354
|
+
oauth,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
export async function resolveRuntimeAuth({ settings, providerName, credentials = loadCredentialsFile(), refreshOAuthToken, onCredentialsUpdated, now = new Date(), }) {
|
|
358
|
+
const provider = settings.providers[providerName];
|
|
359
|
+
const oauth = credentials.providers[providerName];
|
|
360
|
+
const initialState = resolveProviderAuthState(settings, providerName, credentials, now);
|
|
361
|
+
if (provider?.auth?.type !== "oauth" || oauth?.type !== "oauth" || !refreshOAuthToken) {
|
|
362
|
+
return { state: initialState, credentials, didRefresh: false };
|
|
363
|
+
}
|
|
364
|
+
const accessToken = oauth.access_token?.trim() ?? "";
|
|
365
|
+
if (accessToken && !isCredentialExpired(oauth.expires_at, now)) {
|
|
366
|
+
return { state: initialState, credentials, didRefresh: false };
|
|
367
|
+
}
|
|
368
|
+
const refreshToken = oauth.refresh_token?.trim() ?? "";
|
|
369
|
+
if (!refreshToken) {
|
|
370
|
+
return { state: initialState, credentials, didRefresh: false };
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
const refreshed = await refreshOAuthToken(oauth);
|
|
374
|
+
const nextCredentials = {
|
|
375
|
+
providers: {
|
|
376
|
+
...credentials.providers,
|
|
377
|
+
[providerName]: refreshed,
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
if (onCredentialsUpdated) {
|
|
381
|
+
await onCredentialsUpdated(nextCredentials);
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
state: resolveProviderAuthState(settings, providerName, nextCredentials, now),
|
|
385
|
+
credentials: nextCredentials,
|
|
386
|
+
didRefresh: true,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return { state: initialState, credentials, didRefresh: false };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
function normalizeStringRecord(value, warningPrefix, warnings) {
|
|
394
|
+
if (value === undefined) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
if (!isPlainRecord(value)) {
|
|
398
|
+
warnings.push(`${warningPrefix} must be an object.`);
|
|
399
|
+
return undefined;
|
|
400
|
+
}
|
|
401
|
+
const entries = Object.entries(value)
|
|
402
|
+
.filter(([, entryValue]) => entryValue !== undefined && entryValue !== null)
|
|
403
|
+
.map(([key, entryValue]) => [key, String(entryValue)]);
|
|
404
|
+
return Object.fromEntries(entries);
|
|
405
|
+
}
|
|
406
|
+
function normalizeStringArray(value, warningPrefix, warnings) {
|
|
407
|
+
if (value === undefined) {
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
if (!Array.isArray(value)) {
|
|
411
|
+
warnings.push(`${warningPrefix} must be an array.`);
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
return value.map((entry) => String(entry));
|
|
415
|
+
}
|
|
416
|
+
function normalizeMcpServer(value, index, warnings, seenNames) {
|
|
417
|
+
if (!isPlainRecord(value)) {
|
|
418
|
+
warnings.push(`[mcp] server[${index}] must be an object.`);
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
const name = String(value.name ?? "").trim();
|
|
422
|
+
if (!name) {
|
|
423
|
+
warnings.push(`[mcp] server[${index}] is missing a non-empty name.`);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
if (seenNames.has(name)) {
|
|
427
|
+
warnings.push(`[mcp] duplicate server name "${name}" was ignored.`);
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
const transport = value.transport === "stdio" || value.transport === "streamable-http"
|
|
431
|
+
? value.transport
|
|
432
|
+
: null;
|
|
433
|
+
if (!transport) {
|
|
434
|
+
warnings.push(`[mcp] server "${name}" has unsupported transport "${String(value.transport ?? "")}".`);
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const timeoutValue = value.timeoutMs;
|
|
438
|
+
const timeoutMs = typeof timeoutValue === "number" && Number.isFinite(timeoutValue) && timeoutValue > 0
|
|
439
|
+
? timeoutValue
|
|
440
|
+
: 30_000;
|
|
441
|
+
if (timeoutValue !== undefined && timeoutMs !== timeoutValue) {
|
|
442
|
+
warnings.push(`[mcp] server "${name}" has invalid timeoutMs; defaulting to 30000.`);
|
|
443
|
+
}
|
|
444
|
+
const enabled = value.enabled === undefined ? true : Boolean(value.enabled);
|
|
445
|
+
if (transport === "stdio") {
|
|
446
|
+
const command = String(value.command ?? "").trim();
|
|
447
|
+
if (!command) {
|
|
448
|
+
warnings.push(`[mcp] stdio server "${name}" is missing command.`);
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
const cwd = typeof value.cwd === "string" ? value.cwd.trim() : undefined;
|
|
452
|
+
if (value.cwd !== undefined && typeof value.cwd !== "string") {
|
|
453
|
+
warnings.push(`[mcp] server "${name}".cwd must be a string.`);
|
|
454
|
+
}
|
|
455
|
+
else if (cwd && !fs.existsSync(cwd)) {
|
|
456
|
+
warnings.push(`[mcp] stdio server "${name}" cwd does not exist: ${cwd}.`);
|
|
457
|
+
}
|
|
458
|
+
seenNames.add(name);
|
|
459
|
+
return {
|
|
460
|
+
name,
|
|
461
|
+
enabled,
|
|
462
|
+
transport,
|
|
463
|
+
command,
|
|
464
|
+
args: normalizeStringArray(value.args, `[mcp] server "${name}".args`, warnings) ?? [],
|
|
465
|
+
env: normalizeStringRecord(value.env, `[mcp] server "${name}".env`, warnings),
|
|
466
|
+
cwd: cwd || undefined,
|
|
467
|
+
timeoutMs,
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const url = String(value.url ?? "").trim();
|
|
471
|
+
if (!url) {
|
|
472
|
+
warnings.push(`[mcp] streamable-http server "${name}" is missing url.`);
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
seenNames.add(name);
|
|
476
|
+
return {
|
|
477
|
+
name,
|
|
478
|
+
enabled,
|
|
479
|
+
transport,
|
|
480
|
+
url,
|
|
481
|
+
headers: normalizeStringRecord(value.headers, `[mcp] server "${name}".headers`, warnings),
|
|
482
|
+
timeoutMs,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
export function normalizeSettings(raw, warnings) {
|
|
486
|
+
const root = isPlainRecord(raw) ? raw : {};
|
|
487
|
+
if (root.providers !== undefined && !isPlainRecord(root.providers)) {
|
|
488
|
+
warnings.push("[config] providers must be an object.");
|
|
489
|
+
}
|
|
490
|
+
const providers = isPlainRecord(root.providers)
|
|
491
|
+
? Object.fromEntries(Object.entries(root.providers)
|
|
492
|
+
.map(([providerName, value]) => [providerName, normalizeModelProfile(value, providerName, warnings)])
|
|
493
|
+
.filter((entry) => entry[1] !== null))
|
|
494
|
+
: {};
|
|
495
|
+
const mcpRoot = isPlainRecord(root.mcp) ? root.mcp : undefined;
|
|
496
|
+
if (root.mcp !== undefined && !mcpRoot) {
|
|
497
|
+
warnings.push("[mcp] mcp must be an object.");
|
|
498
|
+
}
|
|
499
|
+
const rawServers = mcpRoot?.servers;
|
|
500
|
+
if (rawServers !== undefined && !Array.isArray(rawServers)) {
|
|
501
|
+
warnings.push("[mcp] mcp.servers must be an array.");
|
|
502
|
+
}
|
|
503
|
+
const seenNames = new Set();
|
|
504
|
+
const servers = Array.isArray(rawServers)
|
|
505
|
+
? rawServers
|
|
506
|
+
.map((value, index) => normalizeMcpServer(value, index, warnings, seenNames))
|
|
507
|
+
.filter((value) => value !== null)
|
|
508
|
+
: [];
|
|
509
|
+
return {
|
|
510
|
+
providers,
|
|
511
|
+
defaultProvider: typeof root.defaultProvider === "string" ? root.defaultProvider : undefined,
|
|
512
|
+
defaultModel: typeof root.defaultModel === "string" ? root.defaultModel : undefined,
|
|
513
|
+
showThinking: typeof root.showThinking === "boolean" ? root.showThinking : undefined,
|
|
514
|
+
mcp: { servers },
|
|
515
|
+
};
|
|
516
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const LOG_DIR = path.join(os.homedir(), ".xbcode");
|
|
5
|
+
const LOG_PATH = path.join(LOG_DIR, "error.log");
|
|
6
|
+
function ensureLogDir() {
|
|
7
|
+
try {
|
|
8
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// Best-effort; logging must never crash the agent.
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function pickHeader(headers, key) {
|
|
15
|
+
if (!headers)
|
|
16
|
+
return undefined;
|
|
17
|
+
// OpenAI SDK exposes headers as a plain object on APIError.
|
|
18
|
+
const record = headers;
|
|
19
|
+
const value = record[key] ?? record[key.toLowerCase()];
|
|
20
|
+
return typeof value === "string" ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
function describeError(error) {
|
|
23
|
+
if (!error || typeof error !== "object") {
|
|
24
|
+
return { message: String(error) };
|
|
25
|
+
}
|
|
26
|
+
const err = error;
|
|
27
|
+
return {
|
|
28
|
+
name: err.name,
|
|
29
|
+
message: err.message,
|
|
30
|
+
status: err.status,
|
|
31
|
+
requestId: pickHeader(err.headers, "x-request-id"),
|
|
32
|
+
body: err.error,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Append a structured JSON record to ~/.xbcode/error.log.
|
|
37
|
+
*
|
|
38
|
+
* Why a file (not stderr): when the user sees a 400 in the TUI we want a
|
|
39
|
+
* forensic trail to grep through after the fact, including which agent (main /
|
|
40
|
+
* subagent:<name> / teammate:<name>) made the failing request.
|
|
41
|
+
*/
|
|
42
|
+
export function logApiError(caller, error, request) {
|
|
43
|
+
ensureLogDir();
|
|
44
|
+
const record = {
|
|
45
|
+
ts: new Date().toISOString(),
|
|
46
|
+
caller,
|
|
47
|
+
request,
|
|
48
|
+
error: describeError(error),
|
|
49
|
+
};
|
|
50
|
+
try {
|
|
51
|
+
fs.appendFileSync(LOG_PATH, `${JSON.stringify(record)}\n`, "utf8");
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Best-effort.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Wrap an API error so the message carries the caller tag. Preserves the
|
|
59
|
+
* original error via `cause` and copies status/headers so downstream retry /
|
|
60
|
+
* abort checks (e.g. isTransientNetworkError) still work.
|
|
61
|
+
*/
|
|
62
|
+
export function wrapApiError(caller, error) {
|
|
63
|
+
if (!(error instanceof Error)) {
|
|
64
|
+
return new Error(`[${caller}] ${String(error)}`);
|
|
65
|
+
}
|
|
66
|
+
const wrapped = new Error(`[${caller}] ${error.message}`, { cause: error });
|
|
67
|
+
wrapped.name = error.name;
|
|
68
|
+
const src = error;
|
|
69
|
+
const dst = wrapped;
|
|
70
|
+
if (src.status !== undefined)
|
|
71
|
+
dst.status = src.status;
|
|
72
|
+
if (src.headers !== undefined)
|
|
73
|
+
dst.headers = src.headers;
|
|
74
|
+
if (src.error !== undefined)
|
|
75
|
+
dst.error = src.error;
|
|
76
|
+
return wrapped;
|
|
77
|
+
}
|
|
78
|
+
export function getErrorLogPath() {
|
|
79
|
+
return LOG_PATH;
|
|
80
|
+
}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Agent, EnvHttpProxyAgent } from "undici";
|
|
2
|
+
let streamingDispatcher;
|
|
3
|
+
let proxyOnlyDispatcher;
|
|
4
|
+
export function hasProxyEnvironment() {
|
|
5
|
+
return [
|
|
6
|
+
process.env.HTTPS_PROXY,
|
|
7
|
+
process.env.https_proxy,
|
|
8
|
+
process.env.HTTP_PROXY,
|
|
9
|
+
process.env.http_proxy,
|
|
10
|
+
process.env.ALL_PROXY,
|
|
11
|
+
process.env.all_proxy,
|
|
12
|
+
].some((value) => Boolean(value?.trim()));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* 一次性请求专用 dispatcher:
|
|
16
|
+
* - 没有代理时返回 undefined,由 Node 默认 fetch 接管(保留原 OAuth 行为)。
|
|
17
|
+
* - 有代理时用 EnvHttpProxyAgent,让请求遵循 shell 代理变量。
|
|
18
|
+
*/
|
|
19
|
+
export function getProxyOnlyDispatcher() {
|
|
20
|
+
if (!hasProxyEnvironment())
|
|
21
|
+
return undefined;
|
|
22
|
+
if (!proxyOnlyDispatcher)
|
|
23
|
+
proxyOnlyDispatcher = new EnvHttpProxyAgent();
|
|
24
|
+
return proxyOnlyDispatcher;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Streaming 请求专用 dispatcher:
|
|
28
|
+
* - 没有代理时使用一条短 keep-alive 的 Agent,让闲置连接尽早从池里淘汰,
|
|
29
|
+
* 避免在工具执行间隔后下一轮 stream 拿到一条已被远端 RST 的连接,
|
|
30
|
+
* 然后立刻收到 undici 的 `TypeError: terminated`。
|
|
31
|
+
* - 有代理时延用 EnvHttpProxyAgent,它自己负责池子生命周期。
|
|
32
|
+
*/
|
|
33
|
+
export function getStreamingDispatcher() {
|
|
34
|
+
if (streamingDispatcher)
|
|
35
|
+
return streamingDispatcher;
|
|
36
|
+
streamingDispatcher = hasProxyEnvironment()
|
|
37
|
+
? new EnvHttpProxyAgent()
|
|
38
|
+
: new Agent({
|
|
39
|
+
keepAliveTimeout: 1_000,
|
|
40
|
+
keepAliveMaxTimeout: 5_000,
|
|
41
|
+
connect: { timeout: 30_000 },
|
|
42
|
+
});
|
|
43
|
+
return streamingDispatcher;
|
|
44
|
+
}
|
|
45
|
+
export function createSharedFetch() {
|
|
46
|
+
return async (input, init) => {
|
|
47
|
+
const requestInit = { ...init, dispatcher: getStreamingDispatcher() };
|
|
48
|
+
return await fetch(input, requestInit);
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const TRANSIENT_ERROR_CODES = new Set([
|
|
52
|
+
"UND_ERR_SOCKET",
|
|
53
|
+
"UND_ERR_CLOSED",
|
|
54
|
+
"UND_ERR_BODY_TIMEOUT",
|
|
55
|
+
"UND_ERR_HEADERS_TIMEOUT",
|
|
56
|
+
"UND_ERR_CONNECT_TIMEOUT",
|
|
57
|
+
"ECONNRESET",
|
|
58
|
+
"ECONNREFUSED",
|
|
59
|
+
"ETIMEDOUT",
|
|
60
|
+
"EPIPE",
|
|
61
|
+
"EAI_AGAIN",
|
|
62
|
+
]);
|
|
63
|
+
/**
|
|
64
|
+
* 判断错误是否值得重试一次 stream 请求。
|
|
65
|
+
*
|
|
66
|
+
* undici 在底层 socket 被远端关闭时抛出 `TypeError: terminated`,
|
|
67
|
+
* 它的 message 可能是字面 "terminated",也可能挂在 `cause` 上的 SocketError。
|
|
68
|
+
* 这里把已知的传输层错误码也一起识别,避免误把模型/4xx 类错误当成可重试。
|
|
69
|
+
*/
|
|
70
|
+
export function isTransientNetworkError(error) {
|
|
71
|
+
if (!error || typeof error !== "object")
|
|
72
|
+
return false;
|
|
73
|
+
const err = error;
|
|
74
|
+
if (err.message === "terminated")
|
|
75
|
+
return true;
|
|
76
|
+
const code = typeof err.code === "string" ? err.code : undefined;
|
|
77
|
+
if (code && TRANSIENT_ERROR_CODES.has(code))
|
|
78
|
+
return true;
|
|
79
|
+
const cause = err.cause;
|
|
80
|
+
if (cause) {
|
|
81
|
+
if (cause.message === "terminated")
|
|
82
|
+
return true;
|
|
83
|
+
if (typeof cause.code === "string" && TRANSIENT_ERROR_CODES.has(cause.code))
|
|
84
|
+
return true;
|
|
85
|
+
if (cause.name === "SocketError")
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|