sintbot 1.4.16 → 1.4.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.en.json +31 -0
- package/CHANGELOG.json +31 -0
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/bin/sintbot-v2.js +17 -2
- package/docs/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/install.md +43 -7
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +4 -4
- package/docs/zh-CN/install.md +43 -7
- package/install.sh +1 -1
- package/package.json +3 -1
- package/src/agent/claudeusage.js +188 -3
- package/src/agent/envconflicts.js +74 -0
- package/src/agent/privatecore.js +65 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2install.js +1 -1
- package/src/connect.js +488 -0
- package/src/platform/feishu.js +22 -7
- package/src/ui/console.html +154 -124
- package/src/ui/serverbase.js +19 -10
- package/src/usage.js +131 -38
package/src/agent/claudeusage.js
CHANGED
|
@@ -180,21 +180,206 @@ async function fetchRaw(token, { ttl = 60_000, now = Date.now, log = warn, curl
|
|
|
180
180
|
return data
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
// ── 令牌过期兜底(2026-11-12,cc-switch takeaways B3)──────────────────────────────
|
|
184
|
+
// 纪律 2 不变:我们**不刷新**令牌。过期时让 Claude CLI 自己在自己的锁里刷新 —— 跑一次
|
|
185
|
+
// `claude -p /usage`(官方的非交互用量面板),它顺手把额度写进 `~/.claude.json` 的
|
|
186
|
+
// `cachedUsageUtilization`,我们只读那份缓存,交给核心 `usage_parse('claude','cached')` 判
|
|
187
|
+
//(当前 CLI 使用 fetchedAtMs + utilization.limits;旧 limits[] 也保留支持,超过 645 秒判旧)。
|
|
188
|
+
// 约束:
|
|
189
|
+
// · **不阻塞调用方**:需要刷新时在后台起跑(进程内一次一个),本次立刻回 null(或在跑期间回
|
|
190
|
+
// 上一次解析出的值);之后的调用再读结果。/hud、/api/claude/verify 都是秒级回复。
|
|
191
|
+
// · **只信本进程跑成功后的文件**:退出码 0 才记一次「刷新完成时刻」,读文件以它为准;文件
|
|
192
|
+
// 里没有 fetchedAt 时就用这个完成时刻 —— 别的进程(交互式 CLI)写的旧缓存不当新数。
|
|
193
|
+
// 跑失败 / 超时 ⇒ null。
|
|
194
|
+
// · 同账号(按凭据文件路径)10 分钟最多跑一次 · 45 秒后 SIGTERM、再 5 秒 SIGKILL,子进程
|
|
195
|
+
// 没 close 之前不会起下一个 · 旗子探测合成一次(短超时、有缓存),老 CLI 不认 ⇒ 跳过。
|
|
196
|
+
// · env 走 buildEnv({}) 再去掉 ANTHROPIC_API_KEY(只认 OAuth 登录态,别让 /usage 打到 API key
|
|
197
|
+
// 或第三方网关)· cwd 用临时目录(别让它读到哪个项目的 CLAUDE.md / .mcp.json)· 输出不看不记。
|
|
198
|
+
const FALLBACK_TIMEOUT_MS = 45_000
|
|
199
|
+
const FALLBACK_KILL_GRACE_MS = 5_000
|
|
200
|
+
const FALLBACK_INTERVAL_MS = 10 * 60_000
|
|
201
|
+
const FALLBACK_FLAG_PROBE_MS = 2_000
|
|
202
|
+
const FALLBACK_ARGS = Object.freeze(['-p', '/usage', '--output-format', 'json', '--tools', '', '--strict-mcp-config', '--setting-sources', '', '--no-session-persistence'])
|
|
203
|
+
const FALLBACK_FLAGS = Object.freeze(['--tools', '--strict-mcp-config', '--setting-sources', '--no-session-persistence'])
|
|
204
|
+
const USAGE_HELP_INFLIGHT = new Map()
|
|
205
|
+
|
|
206
|
+
// Some native CLI builds exit before a long help page drains through a pipe.
|
|
207
|
+
// A private regular file receives the complete page. The four flag checks share
|
|
208
|
+
// one bounded process; this temporary file contains help text only.
|
|
209
|
+
function supportsUsageFlag(bin, flag) {
|
|
210
|
+
let probe = USAGE_HELP_INFLIGHT.get(bin)
|
|
211
|
+
if (!probe) {
|
|
212
|
+
probe = (async () => {
|
|
213
|
+
const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'sintbot-usage-help-'))
|
|
214
|
+
const file = path.join(dir, 'help.txt')
|
|
215
|
+
let fd
|
|
216
|
+
try {
|
|
217
|
+
fd = fs.openSync(file, 'wx', 0o600)
|
|
218
|
+
const ok = await new Promise(resolve => {
|
|
219
|
+
let child, timer, force, settled = false
|
|
220
|
+
const finish = value => { if (settled) return; settled = true; clearTimeout(timer); clearTimeout(force); resolve(value) }
|
|
221
|
+
try {
|
|
222
|
+
const command = require('./winexec').resolveSpawn(bin, ['--help'])
|
|
223
|
+
child = require('node:child_process').spawn(command.file, command.spawnArgs,
|
|
224
|
+
{ cwd: dir, stdio: ['ignore', fd, 'ignore'], windowsHide: true })
|
|
225
|
+
} catch { return finish(false) }
|
|
226
|
+
child.once('error', () => finish(false))
|
|
227
|
+
child.once('close', code => finish(code === 0))
|
|
228
|
+
timer = setTimeout(() => {
|
|
229
|
+
try { child.kill('SIGTERM') } catch {}
|
|
230
|
+
force = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, 1000)
|
|
231
|
+
force.unref?.()
|
|
232
|
+
}, 5000)
|
|
233
|
+
timer.unref?.()
|
|
234
|
+
})
|
|
235
|
+
if (!ok || fs.statSync(file).size > 1024 * 1024) return null
|
|
236
|
+
return await fs.promises.readFile(file, 'utf8')
|
|
237
|
+
} finally {
|
|
238
|
+
if (fd !== undefined) fs.closeSync(fd)
|
|
239
|
+
await fs.promises.rm(dir, { recursive: true, force: true })
|
|
240
|
+
}
|
|
241
|
+
})().catch(() => null)
|
|
242
|
+
USAGE_HELP_INFLIGHT.set(bin, probe)
|
|
243
|
+
probe.finally(() => { if (USAGE_HELP_INFLIGHT.get(bin) === probe) USAGE_HELP_INFLIGHT.delete(bin) })
|
|
244
|
+
}
|
|
245
|
+
return probe.then(text => text === null ? null : text.includes(flag))
|
|
246
|
+
}
|
|
247
|
+
const FALLBACK = {
|
|
248
|
+
inflight: null, // 正在跑的那次(Promise<boolean>);子进程 close 前不清
|
|
249
|
+
lastAt: new Map(), // 凭据文件路径 -> 上次起跑时刻(节流)
|
|
250
|
+
doneAt: new Map(), // 凭据文件路径 -> 本进程最近一次退出码 0 的完成时刻(没有 = 不信文件)
|
|
251
|
+
lastGood: new Map(), // 凭据文件路径 -> 上一次解析出的一行(只在刷新进行中回它)
|
|
252
|
+
flags: null, // { bin, until, p } 合成一次的旗子探测
|
|
253
|
+
file: null, // { path, mtimeMs, body } 按 mtime 缓存的 ~/.claude.json 解析
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// `~/.claude.json` 原文 → 交给核心的 body(cachedUsageUtilization 原样)。不是对象 ⇒ null。
|
|
257
|
+
// 时效优先用对象自带的 fetchedAtMs/fetchedAt;两者都没有才用本进程刷新完成时刻。
|
|
258
|
+
function readCachedUtilization(raw) {
|
|
259
|
+
let j = null
|
|
260
|
+
try { j = JSON.parse(String(raw || '')) } catch { return null }
|
|
261
|
+
const u = j && j.cachedUsageUtilization
|
|
262
|
+
return u && typeof u === 'object' && !Array.isArray(u) ? u : null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 按 mtime 缓存的异步读:同一份文件只解析一次;stat/读失败 ⇒ null。
|
|
266
|
+
async function readClaudeJson(file, { readFile, stat }) {
|
|
267
|
+
let st = null
|
|
268
|
+
try { st = await stat(file) } catch { return null }
|
|
269
|
+
const mtimeMs = st && typeof st.mtimeMs === 'number' ? st.mtimeMs : null
|
|
270
|
+
const c = FALLBACK.file
|
|
271
|
+
if (c && c.path === file && mtimeMs !== null && c.mtimeMs === mtimeMs) return c.body
|
|
272
|
+
let raw = null
|
|
273
|
+
try { raw = await readFile(file, 'utf8') } catch { return null }
|
|
274
|
+
const body = readCachedUtilization(raw)
|
|
275
|
+
FALLBACK.file = { path: file, mtimeMs, body }
|
|
276
|
+
return body
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 核心的 windows[] → parseLimits 的形状,formatClaudeUsage 原样可用。
|
|
280
|
+
const windowsToLimits = (windows) => (Array.isArray(windows) ? windows : [])
|
|
281
|
+
.map((w) => ({ kind: w.kind, percent: w.percentUsed, resetsAt: w.resetsAt ?? null, scopeName: w.scopeName || '' }))
|
|
282
|
+
|
|
283
|
+
// 四个旗子合成**一次**探测:默认共享同一份完整 --help 输出,测试可注入探测函数。
|
|
284
|
+
// 短超时兜底;结论 true/false 缓存 10 分钟、不知道(null)缓存 60 秒。
|
|
285
|
+
function probeFlags(bin, supportsFlag, now, timeoutMs = FALLBACK_FLAG_PROBE_MS) {
|
|
286
|
+
const hit = FALLBACK.flags
|
|
287
|
+
if (hit && hit.bin === bin && now() < hit.until) return hit.p
|
|
288
|
+
const all = Promise.all(FALLBACK_FLAGS.map((f) => Promise.resolve().then(() => supportsFlag(bin, f))))
|
|
289
|
+
.then((a) => (a.every((v) => v === true) ? true : a.some((v) => v === false) ? false : null))
|
|
290
|
+
const late = new Promise((resolve) => { const t = setTimeout(() => resolve(null), timeoutMs); t.unref?.() })
|
|
291
|
+
const p = Promise.race([all, late]).catch(() => null)
|
|
292
|
+
FALLBACK.flags = { bin, until: now() + 60_000, p }
|
|
293
|
+
p.then((v) => { if (FALLBACK.flags && FALLBACK.flags.p === p) FALLBACK.flags.until = now() + (v === null ? 60_000 : 10 * 60_000) })
|
|
294
|
+
return p
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 跑一次 `claude -p /usage`;只在子进程 close(或起不来)后才落定 ⇒ 卡住的子进程不会与下一次重叠。
|
|
298
|
+
// 超时:SIGTERM,再过 killGraceMs 还没 close 就 SIGKILL;两个计时器都 unref。
|
|
299
|
+
function runUsageRefresh({ bin, spawn, buildEnv, tmpdir, log, timeoutMs, killGraceMs = FALLBACK_KILL_GRACE_MS }) {
|
|
300
|
+
return new Promise((resolve) => {
|
|
301
|
+
let child, settled = false, timedOut = false
|
|
302
|
+
const timers = []
|
|
303
|
+
const done = (ok, why) => { if (settled) return; settled = true; for (const t of timers) clearTimeout(t); if (why) log(why); resolve(ok) }
|
|
304
|
+
const later = (ms, fn) => { const t = setTimeout(fn, ms); t.unref?.(); timers.push(t) }
|
|
305
|
+
try {
|
|
306
|
+
const env = buildEnv({})
|
|
307
|
+
delete env.ANTHROPIC_API_KEY // 只认 OAuth 登录态
|
|
308
|
+
const { file, spawnArgs } = require('./winexec').resolveSpawn(bin, [...FALLBACK_ARGS])
|
|
309
|
+
child = spawn(file, spawnArgs, { cwd: tmpdir(), env, stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true })
|
|
310
|
+
} catch (e) { return done(false, '额度兜底:claude 起不来(' + ((e && e.message) || '未知错误') + ')') }
|
|
311
|
+
later(timeoutMs, () => {
|
|
312
|
+
timedOut = true
|
|
313
|
+
try { child.kill('SIGTERM') } catch {}
|
|
314
|
+
later(killGraceMs, () => { try { child.kill('SIGKILL') } catch {} })
|
|
315
|
+
})
|
|
316
|
+
child.on('error', (e) => done(false, '额度兜底:claude 起不来(' + ((e && e.code) || (e && e.message) || '未知错误') + ')'))
|
|
317
|
+
child.on('close', (code) => {
|
|
318
|
+
if (timedOut) return done(false, `额度兜底:claude -p /usage 超时(${timeoutMs}ms),已终止`)
|
|
319
|
+
done(code === 0, code === 0 ? null : `额度兜底:claude -p /usage 退出码 ${code}`)
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// 需要就在后台起一次刷新(不 await):进程内一次一个,同账号 10 分钟一次。
|
|
325
|
+
function startRefreshIfDue(account, { bin, now, log, spawn, supportsFlag, buildEnv, tmpdir, timeoutMs, killGraceMs, intervalMs, flagProbeMs }) {
|
|
326
|
+
if (FALLBACK.inflight) return
|
|
327
|
+
const last = FALLBACK.lastAt.get(account) || 0
|
|
328
|
+
if (last && now() - last < intervalMs) return
|
|
329
|
+
FALLBACK.lastAt.set(account, now())
|
|
330
|
+
FALLBACK.inflight = (async () => {
|
|
331
|
+
const flags = await probeFlags(bin, supportsFlag, now, flagProbeMs)
|
|
332
|
+
if (flags !== true) { log('额度兜底:本机 claude 不认所需旗子,跳过'); return false }
|
|
333
|
+
const ok = await runUsageRefresh({ bin, spawn, buildEnv, tmpdir, log, timeoutMs, killGraceMs })
|
|
334
|
+
if (ok) FALLBACK.doneAt.set(account, now())
|
|
335
|
+
else FALLBACK.doneAt.delete(account)
|
|
336
|
+
return ok
|
|
337
|
+
})().catch(() => { FALLBACK.doneAt.delete(account); return false })
|
|
338
|
+
.finally(() => { FALLBACK.inflight = null })
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 过期/缺凭据时的那条腿:(必要时)后台让 CLI 刷新 → 读缓存(只信本进程刷成功后的)→ 核心判 →
|
|
342
|
+
// 一行人话;任何一步不行 ⇒ null。调用方不会被挡住:这里只 await 一次文件读。
|
|
343
|
+
async function cachedUsageFallback({ home = os.homedir(), bin = 'claude', now = Date.now, log = warn,
|
|
344
|
+
spawn = require('node:child_process').spawn, readFile = fs.promises.readFile, stat = fs.promises.stat, tmpdir = os.tmpdir,
|
|
345
|
+
supportsFlag = null, buildEnv = null, timeoutMs = FALLBACK_TIMEOUT_MS, killGraceMs = FALLBACK_KILL_GRACE_MS,
|
|
346
|
+
intervalMs = FALLBACK_INTERVAL_MS, flagProbeMs = FALLBACK_FLAG_PROBE_MS } = {}) {
|
|
347
|
+
const account = path.join(home, '.claude', '.credentials.json')
|
|
348
|
+
startRefreshIfDue(account, { bin, now, log, spawn, tmpdir, timeoutMs, killGraceMs, intervalMs, flagProbeMs,
|
|
349
|
+
supportsFlag: supportsFlag || supportsUsageFlag, buildEnv: buildEnv || require('./claudecode').buildEnv })
|
|
350
|
+
const doneAt = FALLBACK.doneAt.get(account)
|
|
351
|
+
if (!doneAt) return FALLBACK.inflight ? (FALLBACK.lastGood.get(account) || null) : null
|
|
352
|
+
const raw = await readClaudeJson(path.join(home, '.claude.json'), { readFile, stat })
|
|
353
|
+
if (!raw) return null
|
|
354
|
+
const body = raw.fetchedAtMs != null || raw.fetchedAt != null ? raw : { ...raw, fetchedAt: doneAt }
|
|
355
|
+
const parsed = require('./privatecore').usageParse('claude', 'cached', null, body, now())
|
|
356
|
+
if (!parsed.ok) return FALLBACK.inflight ? (FALLBACK.lastGood.get(account) || null) : null
|
|
357
|
+
const line = formatClaudeUsage(windowsToLimits(parsed.windows), now())
|
|
358
|
+
if (line) FALLBACK.lastGood.set(account, line)
|
|
359
|
+
return line
|
|
360
|
+
}
|
|
361
|
+
function resetFallback() { FALLBACK.inflight = null; FALLBACK.lastAt.clear(); FALLBACK.doneAt.clear(); FALLBACK.lastGood.clear(); FALLBACK.flags = null; FALLBACK.file = null }
|
|
362
|
+
// 单测用:等后台那次刷新落定(没有就立刻回)。
|
|
363
|
+
const settleFallback = () => (FALLBACK.inflight ? FALLBACK.inflight.then(() => undefined) : Promise.resolve())
|
|
364
|
+
|
|
183
365
|
// 拉一次 → 一行人话;拿不到 null。走 fetchRaw ⇒ 与告警共享那份缓存。
|
|
184
366
|
// 缓存口径与 fetchRaw 一致:成功 60s、失败 10s(两层都压死一分钟就白搭下层的重试了)。
|
|
367
|
+
// 凭据缺失/过期 ⇒ 走 cachedUsageFallback(见上),不再直接 null。
|
|
185
368
|
const CACHE = { at: 0, val: null }
|
|
186
369
|
const TTL = 60_000
|
|
187
|
-
async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, ...io } = {}) {
|
|
370
|
+
async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, fallback = cachedUsageFallback, home, bin, ...io } = {}) {
|
|
188
371
|
if (CACHE.at && now() - CACHE.at < (CACHE.val ? ttl : FAIL_TTL_MS)) return CACHE.val
|
|
189
372
|
let val = null
|
|
190
373
|
const token = readToken()
|
|
191
374
|
if (token) {
|
|
192
|
-
const data = await fetchRaw(token, { ttl, now,
|
|
375
|
+
const data = await fetchRaw(token, { ttl, now, log: io.log, curl: io.curl, sleep: io.sleep })
|
|
193
376
|
val = formatClaudeUsage(parseLimits(data), now())
|
|
377
|
+
} else {
|
|
378
|
+
try { val = await fallback({ now, ...(home ? { home } : {}), ...(bin ? { bin } : {}), ...io }) } catch { val = null }
|
|
194
379
|
}
|
|
195
380
|
CACHE.at = now(); CACHE.val = val
|
|
196
381
|
return val
|
|
197
382
|
}
|
|
198
383
|
function clearCache() { CACHE.at = 0; CACHE.val = null; RAW.at = 0; RAW.data = null }
|
|
199
384
|
|
|
200
|
-
module.exports = { usageLine, fetchRaw, clearCache, parseCredentials, parseLimits, formatClaudeUsage, readAccessToken, fetchCurl, fetchNative, readCurlResult, fmtDurDecimal, QUOTA_SEP, TIMEOUT_MS, FAIL_TTL_MS, RETRY_WAIT_MS }
|
|
385
|
+
module.exports = { usageLine, fetchRaw, clearCache, parseCredentials, parseLimits, formatClaudeUsage, readAccessToken, fetchCurl, fetchNative, readCurlResult, fmtDurDecimal, cachedUsageFallback, readCachedUtilization, readClaudeJson, supportsUsageFlag, resetFallback, settleFallback, QUOTA_SEP, TIMEOUT_MS, FAIL_TTL_MS, RETRY_WAIT_MS, FALLBACK_ARGS, FALLBACK_FLAGS, FALLBACK_TIMEOUT_MS, FALLBACK_KILL_GRACE_MS, FALLBACK_INTERVAL_MS, FALLBACK_FLAG_PROBE_MS }
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Environment conflicts (2026-11-12, cc-switch takeaways B5): things outside
|
|
4
|
+
// SintBot's configuration that can silently reroute the Claude / Codex CLI —
|
|
5
|
+
// daemon env (`ANTHROPIC_*`, `CLAUDE_CODE_*`, `AWS_*`, `VERTEX_*`, `OPENAI_*`),
|
|
6
|
+
// `~/.claude/settings.json` (`env`, top-level `model` …) and `~/.codex/config.toml`
|
|
7
|
+
// (`model_provider`, `[model_providers.*]`). The host only gathers the inputs; the
|
|
8
|
+
// rule set lives in the binary core (`env_conflicts`, ABI 36) and it returns key
|
|
9
|
+
// names only — values never leave the process. Warn-only: `buildEnv` keeps clearing
|
|
10
|
+
// exactly what it cleared before.
|
|
11
|
+
|
|
12
|
+
const fs = require('node:fs')
|
|
13
|
+
const os = require('node:os')
|
|
14
|
+
const path = require('node:path')
|
|
15
|
+
const { envConflicts } = require('./privatecore')
|
|
16
|
+
|
|
17
|
+
// JSON file → parsed object; missing / invalid / not an object ⇒ null.
|
|
18
|
+
function readClaudeSettings(home, readFile) {
|
|
19
|
+
try {
|
|
20
|
+
const j = JSON.parse(String(readFile(path.join(home, '.claude', 'settings.json'), 'utf8')))
|
|
21
|
+
return j && typeof j === 'object' && !Array.isArray(j) ? j : null
|
|
22
|
+
} catch { return null }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// TOML text as-is (the core matches lines; no TOML parser); missing ⇒ null.
|
|
26
|
+
function readCodexConfig(home, readFile) {
|
|
27
|
+
try { return String(readFile(path.join(home, '.codex', 'config.toml'), 'utf8')) } catch { return null }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Only string-valued entries travel to the core (process.env can hold undefined).
|
|
31
|
+
const snapshotEnv = (env) => Object.fromEntries(Object.entries(env || {}).filter(([, v]) => typeof v === 'string'))
|
|
32
|
+
|
|
33
|
+
// → [{ where: 'env'|'claude-settings'|'codex-config', key, reason }]. `provider` is a
|
|
34
|
+
// resolved provider ({ baseUrl, apiKey }) or an explicit { baseUrl, hasApiKey }.
|
|
35
|
+
function collectEnvConflicts({ runner = 'claude', provider = {}, env = process.env, home = os.homedir(), readFile = fs.readFileSync } = {}) {
|
|
36
|
+
const p = provider || {}
|
|
37
|
+
const input = {
|
|
38
|
+
runner: runner === 'codex' ? 'codex' : 'claude',
|
|
39
|
+
provider: { baseUrl: p.baseUrl || null, hasApiKey: typeof p.hasApiKey === 'boolean' ? p.hasApiKey : !!p.apiKey },
|
|
40
|
+
env: snapshotEnv(env),
|
|
41
|
+
claudeSettings: readClaudeSettings(home, readFile),
|
|
42
|
+
codexConfig: readCodexConfig(home, readFile),
|
|
43
|
+
}
|
|
44
|
+
return envConflicts(input).warnings.map((w) => ({ where: w.where, key: w.key, reason: w.reason }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Preflight / log line: `<key> — <reason>`; never a value.
|
|
48
|
+
const describeWarning = (w) => `${w.key} — ${w.reason}`
|
|
49
|
+
|
|
50
|
+
// `sintbot start --preflight`: one line per key for each provider an active route
|
|
51
|
+
// uses. The snapshot is this process's environment — a launchd job inherits a
|
|
52
|
+
// different one, so the block is prefixed with that caveat. Never fatal.
|
|
53
|
+
const PREFLIGHT_SCOPE_LINE = 'preflight · 环境冲突检查基于当前终端环境(launchd 任务的环境可能不同)\n'
|
|
54
|
+
function reportEnvConflicts({ cfg, routes } = {}, write = (message) => process.stderr.write(message), { collect = collectEnvConflicts } = {}) {
|
|
55
|
+
const seen = new Set()
|
|
56
|
+
let prefixed = false
|
|
57
|
+
for (const route of routes || []) {
|
|
58
|
+
if (!route || route.paused) continue
|
|
59
|
+
const name = route.group?.provider || cfg?.defaultProvider || ''
|
|
60
|
+
if (!name || seen.has(name)) continue
|
|
61
|
+
seen.add(name)
|
|
62
|
+
if (!prefixed) { prefixed = true; write(PREFLIGHT_SCOPE_LINE) }
|
|
63
|
+
try {
|
|
64
|
+
for (const warning of collect({ runner: route.provider?.runner, provider: route.provider })) {
|
|
65
|
+
write(`preflight · ${name}: ${describeWarning(warning)}\n`)
|
|
66
|
+
}
|
|
67
|
+
} catch (error) {
|
|
68
|
+
write(`preflight · ${name}: environment check skipped (${error?.message || String(error)})\n`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return [...seen]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { collectEnvConflicts, describeWarning, reportEnvConflicts, PREFLIGHT_SCOPE_LINE }
|
package/src/agent/privatecore.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// ABI
|
|
1
|
+
// ABI v36 adapter for the private binary decision core. Manifest assembly still stays in JS.
|
|
2
2
|
const fs = require('node:fs')
|
|
3
3
|
const path = require('node:path')
|
|
4
4
|
const { createHash } = require('node:crypto')
|
|
5
5
|
|
|
6
|
-
const EXPECTED_SHA256 = '
|
|
6
|
+
const EXPECTED_SHA256 = 'c79f12ceb1ef79aceb0ab54cf87052127325a1d8fdfd5c02e3509fef39f6f511'
|
|
7
7
|
let bytes
|
|
8
8
|
try {
|
|
9
9
|
bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
|
|
@@ -26,7 +26,7 @@ try {
|
|
|
26
26
|
} catch (cause) {
|
|
27
27
|
throw new Error('SintBot binary core cannot start on this runtime', { cause })
|
|
28
28
|
}
|
|
29
|
-
if (core.abi_version() !==
|
|
29
|
+
if (core.abi_version() !== 36) throw new Error('SintBot binary core ABI mismatch')
|
|
30
30
|
const encoder = new TextEncoder()
|
|
31
31
|
const decoder = new TextDecoder('utf-8', { fatal: true })
|
|
32
32
|
|
|
@@ -771,6 +771,67 @@ function manifestPolicy(permissionMode, isCodex, allowedTools, disallowedTools)
|
|
|
771
771
|
return value
|
|
772
772
|
}
|
|
773
773
|
|
|
774
|
+
// ABI 36 JSON decisions (sintbot-core docs/ABI36_CONTRACTS.md). Each wrapper only
|
|
775
|
+
// checks the envelope shape the host relies on; the core owns the rules and wording.
|
|
776
|
+
const isObject = value => !!value && typeof value === 'object' && !Array.isArray(value)
|
|
777
|
+
|
|
778
|
+
function connectManifest(basic, userName, version) {
|
|
779
|
+
const value = coreJson('connect_manifest', { basic: !!basic, userName: String(userName || ''), version: String(version || '') }, 'build the Feishu connect manifest')
|
|
780
|
+
if (!isObject(value) || !isObject(value.appPreset) || !isObject(value.addons) ||
|
|
781
|
+
!Array.isArray(value.addons.scopes) || !Array.isArray(value.addons.events) ||
|
|
782
|
+
!Array.isArray(value.addons.callbacks) || typeof value.hint !== 'string') {
|
|
783
|
+
throw new Error('SintBot binary core returned an invalid connect manifest')
|
|
784
|
+
}
|
|
785
|
+
return value
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function connectPlan(step, { basic = false, status = null, code = null, msg = '', observed = {} } = {}) {
|
|
789
|
+
const value = coreJson('connect_plan', { step, basic: !!basic, status, code, msg: String(msg || ''), observed: isObject(observed) ? observed : {} }, `decide the connect step ${step}`)
|
|
790
|
+
if (!isObject(value) || !['ok', 'stop', 'wait', 'warn'].includes(value.verdict) || typeof value.notice !== 'string' ||
|
|
791
|
+
!(value.next === null || typeof value.next === 'string') ||
|
|
792
|
+
!(value.retryAfterMs === null || Number.isSafeInteger(value.retryAfterMs))) {
|
|
793
|
+
throw new Error('SintBot binary core returned an invalid connect decision')
|
|
794
|
+
}
|
|
795
|
+
return value
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
function connectConfig(input) {
|
|
799
|
+
const value = coreJson('connect_config', input, 'build the Feishu connect configuration')
|
|
800
|
+
if (!isObject(value) || typeof value.ok !== 'boolean' ||
|
|
801
|
+
(value.ok && (!isObject(value.config) || typeof value.readme !== 'string')) ||
|
|
802
|
+
(!value.ok && typeof value.error !== 'string')) {
|
|
803
|
+
throw new Error('SintBot binary core returned an invalid connect configuration')
|
|
804
|
+
}
|
|
805
|
+
return value
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function qrMatrix(text, ec = 'M') {
|
|
809
|
+
const value = coreJson('qr_matrix', { text: String(text), ec }, 'encode a QR code')
|
|
810
|
+
if (!isObject(value) || !Number.isInteger(value.size) || !Array.isArray(value.rows) ||
|
|
811
|
+
value.rows.length !== value.size || value.rows.some(row => typeof row !== 'string' || row.length !== value.size)) {
|
|
812
|
+
throw new Error('SintBot binary core returned an invalid QR matrix')
|
|
813
|
+
}
|
|
814
|
+
return value
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function usageParse(provider, source, status, body, now = Date.now()) {
|
|
818
|
+
const value = coreJson('usage_parse', { provider, source, status, body, now }, `parse ${provider} ${source} usage`)
|
|
819
|
+
if (!isObject(value) || typeof value.ok !== 'boolean' ||
|
|
820
|
+
(value.ok && !Array.isArray(value.windows)) || (!value.ok && typeof value.error !== 'string')) {
|
|
821
|
+
throw new Error('SintBot binary core returned an invalid usage result')
|
|
822
|
+
}
|
|
823
|
+
return value
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function envConflicts(input) {
|
|
827
|
+
const value = coreJson('env_conflicts', input, 'check environment conflicts')
|
|
828
|
+
if (!isObject(value) || !Array.isArray(value.warnings) ||
|
|
829
|
+
value.warnings.some(w => !isObject(w) || typeof w.key !== 'string' || typeof w.where !== 'string' || typeof w.reason !== 'string')) {
|
|
830
|
+
throw new Error('SintBot binary core returned an invalid environment check')
|
|
831
|
+
}
|
|
832
|
+
return value
|
|
833
|
+
}
|
|
834
|
+
|
|
774
835
|
// A trap or exhausted stack can leave the instance's own stack pointer wrong, and
|
|
775
836
|
// C1 calls hold no state, so the next call simply gets a fresh instance.
|
|
776
837
|
function guarded(fn) {
|
|
@@ -798,7 +859,7 @@ function followupCall(name, value) {
|
|
|
798
859
|
function followupEnabled(group, runner) { return followupCall('followup_policy', { group, runner }) }
|
|
799
860
|
function claudeFollowupStep(state, event) { return followupCall('claude_followup_step', { state, event }) }
|
|
800
861
|
|
|
801
|
-
const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
|
|
862
|
+
const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError, connectManifest, connectPlan, connectConfig, qrMatrix, usageParse, envConflicts }
|
|
802
863
|
for (const name of Object.keys(entries)) entries[name] = guarded(entries[name])
|
|
803
864
|
|
|
804
865
|
module.exports = { ...entries, engineStep, resetBinaryCore, HUMAN_BY_KIND: HUMAN }
|
|
Binary file
|
package/src/c2install.js
CHANGED
|
@@ -323,6 +323,6 @@ function registerAddedGroups(installation) {
|
|
|
323
323
|
return addedRoutes.map(route => route.chatId)
|
|
324
324
|
}
|
|
325
325
|
|
|
326
|
-
module.exports = { registerAddedGroups, loadInstallation, loadInstallationFromRaw, initializeInstallation, assertPrivateDirectory,
|
|
326
|
+
module.exports = { registerAddedGroups, loadInstallation, loadInstallationFromRaw, initializeInstallation, installationStateDir, assertPrivateDirectory,
|
|
327
327
|
assertJournalScope, assertInstallationJournals, assertRunnerExecutable, SUPPORTED_RUNNERS, MANIFEST,
|
|
328
328
|
VALIDATORS }
|