sintbot 1.4.17 → 1.4.18
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 +65 -2
- package/docs/AI-ASSISTED-INSTALL.md +3 -3
- package/docs/install.md +27 -11
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +3 -3
- package/docs/zh-CN/install.md +28 -12
- package/install.sh +1 -1
- package/package.json +2 -1
- package/src/agent/claudecode.js +41 -3
- package/src/agent/claudeusage.js +53 -4
- package/src/agent/privatecore.js +25 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/agent/runner.js +9 -1
- package/src/c2configcontrol.js +7 -0
- package/src/c2console.js +3 -0
- package/src/c2multigroupservice.js +1 -1
- package/src/connect.js +100 -22
- package/src/core/c2runnerinput.js +47 -1
- package/src/core/v2engine.js +211 -11
- package/src/setupserver.js +447 -0
- package/src/ui/console.html +10 -7
package/src/agent/claudeusage.js
CHANGED
|
@@ -171,7 +171,56 @@ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
|
171
171
|
// 里所有人都查不到额度(而这接口恰恰偶发超时/限流);完全不压又让连点几下就把限流捅穿。
|
|
172
172
|
// 10 秒 = 人再问一次就真的重试,机器连点仍被挡住。
|
|
173
173
|
const RAW = { at: 0, data: null }
|
|
174
|
+
|
|
175
|
+
// ── 额度事件(ABI 37)──────────────────────────────────────────────────────────
|
|
176
|
+
// claude CLI 每次运行都在 stream 里吐一个 `rate_limit_event`(订阅登录时),runner 收到就喂进来;解析在核心
|
|
177
|
+
// `usage_parse('claude','event')`。EVENT_TTL 内有事件 ⇒ 5 小时窗 / 7 天窗用它,不打接口;按模型分的周窗
|
|
178
|
+
// 事件里没有的窗口仍取接口;事件新鲜时接口最多 SCOPED_TTL 请求一次,避免频繁探测触发限流。
|
|
179
|
+
const EVENT_TTL_MS = 10 * 60_000
|
|
180
|
+
const SCOPED_TTL_MS = 30 * 60_000
|
|
181
|
+
const EVT = { at: 0, windows: null } // windows: { session?: {percent, resetsAt, at}, weekly_all?: {…} }
|
|
182
|
+
const EVENT_KIND = { session: 'session', weekly: 'weekly_all' } // 核心的窗口名 → 接口 limits[] 的 kind
|
|
183
|
+
function noteRateLimit(info, now = Date.now()) {
|
|
184
|
+
if (!info || typeof info !== 'object') return false
|
|
185
|
+
let parsed
|
|
186
|
+
try { parsed = require('./privatecore').usageParse('claude', 'event', null, info, now) } catch { return false }
|
|
187
|
+
if (!parsed.ok) return false
|
|
188
|
+
const windows = {}
|
|
189
|
+
for (const w of parsed.windows) {
|
|
190
|
+
const kind = EVENT_KIND[w.kind]
|
|
191
|
+
if (kind) windows[kind] = { percent: w.percentUsed, resetsAt: w.resetsAt ?? null, at: now }
|
|
192
|
+
}
|
|
193
|
+
if (!Object.keys(windows).length) return false
|
|
194
|
+
EVT.windows = { ...(EVT.windows || {}), ...windows } // 只有一个窗时别把另一个窗的旧值丢了
|
|
195
|
+
EVT.at = now
|
|
196
|
+
// A new event must be visible immediately without invalidating the endpoint cache.
|
|
197
|
+
CACHE.at = 0; CACHE.val = null
|
|
198
|
+
return true
|
|
199
|
+
}
|
|
200
|
+
const eventFresh = (now = Date.now()) => !!(EVT.at && now - EVT.at < EVENT_TTL_MS && EVT.windows)
|
|
201
|
+
// 事件与接口数据合成 parseLimits 的形状;同一个窗两边都有 ⇒ 取更新的那份(人点了「刷新」时接口比事件新)。
|
|
202
|
+
function mergeEvent(limits, dataAt, now = Date.now()) {
|
|
203
|
+
const out = Array.isArray(limits) ? limits.slice() : []
|
|
204
|
+
for (const [kind, w] of Object.entries(EVT.windows || {})) {
|
|
205
|
+
if (!w || now - w.at >= EVENT_TTL_MS) continue
|
|
206
|
+
const i = out.findIndex(l => l && l.kind === kind)
|
|
207
|
+
if (i >= 0 && dataAt > w.at) continue
|
|
208
|
+
const row = { kind, percent: w.percent, resetsAt: w.resetsAt, scopeName: '' }
|
|
209
|
+
if (i >= 0) out[i] = row; else out.push(row)
|
|
210
|
+
}
|
|
211
|
+
return out
|
|
212
|
+
}
|
|
213
|
+
function clearEvents() { EVT.at = 0; EVT.windows = null } // 单测用
|
|
214
|
+
|
|
174
215
|
async function fetchRaw(token, { ttl = 60_000, now = Date.now, log = warn, curl = fetchCurl, sleep = sleepMs } = {}) {
|
|
216
|
+
const t = now()
|
|
217
|
+
if (eventFresh(t)) {
|
|
218
|
+
// 事件新鲜:接口只为补「按模型分的周窗」,最多 SCOPED_TTL 打一次(成败都算一次 —— 限流时不连环重试)。
|
|
219
|
+
if ((RAW.at && t - RAW.at < SCOPED_TTL_MS) || !token) return RAW.data
|
|
220
|
+
const data = await curl(token, { log }) // 不重试:事件已经给了主要的两个窗
|
|
221
|
+
RAW.at = now(); RAW.data = data
|
|
222
|
+
return data
|
|
223
|
+
}
|
|
175
224
|
if (RAW.at && now() - RAW.at < (RAW.data ? ttl : FAIL_TTL_MS)) return RAW.data
|
|
176
225
|
// 只走 curl(纪律 1);失败隔一下再问一次 —— 超时与短限流重试一次往往就通,再不通就认了。
|
|
177
226
|
let data = await curl(token, { log })
|
|
@@ -371,9 +420,9 @@ async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToke
|
|
|
371
420
|
if (CACHE.at && now() - CACHE.at < (CACHE.val ? ttl : FAIL_TTL_MS)) return CACHE.val
|
|
372
421
|
let val = null
|
|
373
422
|
const token = readToken()
|
|
374
|
-
if (token) {
|
|
375
|
-
const data = await fetchRaw(token, { ttl, now, log: io.log, curl: io.curl, sleep: io.sleep })
|
|
376
|
-
val = formatClaudeUsage(parseLimits(data), now())
|
|
423
|
+
if (token || eventFresh(now())) { // 令牌读不到(钥匙串偶发)但有新鲜的额度事件,照样出数
|
|
424
|
+
const data = token ? await fetchRaw(token, { ttl, now, log: io.log, curl: io.curl, sleep: io.sleep }) : null
|
|
425
|
+
val = formatClaudeUsage(mergeEvent(parseLimits(data), RAW.at, now()), now())
|
|
377
426
|
} else {
|
|
378
427
|
try { val = await fallback({ now, ...(home ? { home } : {}), ...(bin ? { bin } : {}), ...io }) } catch { val = null }
|
|
379
428
|
}
|
|
@@ -382,4 +431,4 @@ async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToke
|
|
|
382
431
|
}
|
|
383
432
|
function clearCache() { CACHE.at = 0; CACHE.val = null; RAW.at = 0; RAW.data = null }
|
|
384
433
|
|
|
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 }
|
|
434
|
+
module.exports = { usageLine, fetchRaw, clearCache, clearEvents, noteRateLimit, eventFresh, mergeEvent, 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 }
|
package/src/agent/privatecore.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// ABI
|
|
1
|
+
// ABI v37 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 = '9705ac11c9ddcd47c9532471603e5c0c21d189ac89e353b33fcea55de91feb51'
|
|
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() !== 37) 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
|
|
|
@@ -823,6 +823,27 @@ function usageParse(provider, source, status, body, now = Date.now()) {
|
|
|
823
823
|
return value
|
|
824
824
|
}
|
|
825
825
|
|
|
826
|
+
// ABI 37 (docs/ABI37_CONTRACTS.md in the core): idle compaction rules. The host
|
|
827
|
+
// only spawns, stops and logs; start/skip and settle outcomes are decided here.
|
|
828
|
+
function idleCompactPlan(input) {
|
|
829
|
+
const value = coreJson('idle_compact_plan', input, 'plan idle compaction')
|
|
830
|
+
if (!isObject(value) || typeof value.start !== 'boolean' || !Number.isFinite(value.at) ||
|
|
831
|
+
typeof value.reason !== 'string' || typeof value.keep !== 'string' || !Number.isFinite(value.maxMs)) {
|
|
832
|
+
throw new Error('SintBot binary core returned an invalid idle compaction plan')
|
|
833
|
+
}
|
|
834
|
+
return value
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function idleCompactSettle(input) {
|
|
838
|
+
const value = coreJson('idle_compact_settle', input, 'settle idle compaction')
|
|
839
|
+
if (!isObject(value) || !['compacted', 'stopped', 'failed'].includes(value.status) ||
|
|
840
|
+
!Number.isFinite(value.fails) || typeof value.notice !== 'string' ||
|
|
841
|
+
(value.ctx !== null && !Number.isFinite(value.ctx)) || (value.restUntil !== null && !Number.isFinite(value.restUntil))) {
|
|
842
|
+
throw new Error('SintBot binary core returned an invalid idle compaction result')
|
|
843
|
+
}
|
|
844
|
+
return value
|
|
845
|
+
}
|
|
846
|
+
|
|
826
847
|
function envConflicts(input) {
|
|
827
848
|
const value = coreJson('env_conflicts', input, 'check environment conflicts')
|
|
828
849
|
if (!isObject(value) || !Array.isArray(value.warnings) ||
|
|
@@ -859,7 +880,7 @@ function followupCall(name, value) {
|
|
|
859
880
|
function followupEnabled(group, runner) { return followupCall('followup_policy', { group, runner }) }
|
|
860
881
|
function claudeFollowupStep(state, event) { return followupCall('claude_followup_step', { state, event }) }
|
|
861
882
|
|
|
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 }
|
|
883
|
+
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, idleCompactPlan, idleCompactSettle }
|
|
863
884
|
for (const name of Object.keys(entries)) entries[name] = guarded(entries[name])
|
|
864
885
|
|
|
865
886
|
module.exports = { ...entries, engineStep, resetBinaryCore, HUMAN_BY_KIND: HUMAN }
|
|
Binary file
|
package/src/agent/runner.js
CHANGED
|
@@ -22,6 +22,14 @@ function runTurn(opts) {
|
|
|
22
22
|
return claudeAgent.runTurn(opts)
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
// 空闲整理只有 Claude 路线有(无头 /compact);GPT ⇒ null = 不支持,引擎只在轮内压缩。
|
|
26
|
+
function compactSession(opts) {
|
|
27
|
+
const route = routeBackend(opts && opts.provider && opts.provider.runner, opts && opts.codexApp, opts && opts.codexSteer, opts && opts.codexProjectDocFallback)
|
|
28
|
+
if (route !== 'claude') return null
|
|
29
|
+
const compact = Number(opts.autocompactTokens ?? opts.provider?.autocompactTokens) || 0
|
|
30
|
+
return claudeAgent.compactSession({ ...opts, autocompactTokens: compact > 0 ? Math.min(1_000_000, Math.max(100_000, Math.floor(compact))) : 0 })
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
const isClosedRunnerResult = value => [claudeAgent, codexAgent, codexApp].some(agent => agent.isClosedRunnerResult(value))
|
|
26
34
|
const isClosedRunnerError = value => [claudeAgent, codexAgent, codexApp].some(agent => agent.isClosedRunnerError(value))
|
|
27
|
-
module.exports = { runTurn, probeVersion: claudeAgent.probeVersion, isClosedRunnerResult, isClosedRunnerError }
|
|
35
|
+
module.exports = { runTurn, compactSession, probeVersion: claudeAgent.probeVersion, isClosedRunnerResult, isClosedRunnerError }
|
package/src/c2configcontrol.js
CHANGED
|
@@ -200,6 +200,13 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
|
|
|
200
200
|
if (!prepared || prepared.raw !== JSON.stringify(raw)) {
|
|
201
201
|
throw new Error('Configuration was not validated against the current service')
|
|
202
202
|
}
|
|
203
|
+
const bindings = getBindings()
|
|
204
|
+
// Stop every affected compactor before mutating any session or file.
|
|
205
|
+
let stopping = false
|
|
206
|
+
for (const chatId of prepared.resets) {
|
|
207
|
+
if (bindings.get(chatId)?.engine?.stopIdleCompact?.(chatId, '会话已重置')) stopping = true
|
|
208
|
+
}
|
|
209
|
+
if (stopping) throw new Error('正在停止会话整理,请稍后再保存设置')
|
|
203
210
|
for (const chatId of prepared.resets) store.resetGroupSession(chatId)
|
|
204
211
|
if (prepared.added.length) {
|
|
205
212
|
for (const route of prepared.added) {
|
package/src/c2console.js
CHANGED
|
@@ -110,6 +110,9 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
|
|
|
110
110
|
try { assertJournalScope(binding.journal, chatId, { requireSettled: true }) } catch (error) {
|
|
111
111
|
throw new Error(busy, { cause: error })
|
|
112
112
|
}
|
|
113
|
+
if (binding.engine.stopIdleCompact?.(chatId, '会话已重置')) {
|
|
114
|
+
throw new Error('正在停止会话整理,请稍后再开新会话')
|
|
115
|
+
}
|
|
113
116
|
binding.engine.store.resetSessionAndStats(chatId)
|
|
114
117
|
return true
|
|
115
118
|
}
|
|
@@ -164,7 +164,7 @@ function createC2MultiGroupService({ routes, assertWriterLock,
|
|
|
164
164
|
binding.engine.shutdown()
|
|
165
165
|
await bounded(() => binding.engine.drainDurableV2(drainTimeoutMs), drainTimeoutMs + 100)
|
|
166
166
|
const quiescent = [...binding.engine.rts.values()].every(rt =>
|
|
167
|
-
!rt.v2Controller && !rt.v2NormalRouteActive)
|
|
167
|
+
!rt.v2Controller && !rt.v2NormalRouteActive && !rt.v2Compacting)
|
|
168
168
|
if (!quiescent || !writerHeld() || !route) throw new Error('C2 group fault is not quiescent')
|
|
169
169
|
const turns = activeTurns(binding.journal.snapshot())
|
|
170
170
|
const proofs = turns.length ? binding.engine.durableV2ActiveFaultProofs?.(turns) || null : null
|
package/src/connect.js
CHANGED
|
@@ -12,9 +12,11 @@ const path = require('node:path')
|
|
|
12
12
|
const readline = require('node:readline')
|
|
13
13
|
const { parseArgs } = require('node:util')
|
|
14
14
|
const core = require('./agent/privatecore')
|
|
15
|
-
const { atomicWriteJSON } = require('./core/store')
|
|
15
|
+
const { atomicWriteJSON, withFileLock } = require('./core/store')
|
|
16
16
|
const { installationStateDir, MANIFEST } = require('./c2install')
|
|
17
17
|
const { defaultProjectsRoot, tooWideReason, realpathLenient } = require('./projectsroot')
|
|
18
|
+
const { fillPicks } = require('./core/modelcmd')
|
|
19
|
+
const { applyBrainChange } = require('./groupbrain')
|
|
18
20
|
|
|
19
21
|
const CONNECT_OPTIONS = Object.freeze({
|
|
20
22
|
config: { type: 'string' }, 'data-dir': { type: 'string' }, workdir: { type: 'string' },
|
|
@@ -195,8 +197,48 @@ function dirObserved(fs, workdir, explicit, home) {
|
|
|
195
197
|
return { workdir, exists, empty, tooWide, explicit }
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
// The same CLI catalog and recommendation used by /model and the console.
|
|
201
|
+
// No saved cache or model prompt: a new group must pin a currently observed
|
|
202
|
+
// recommendation, not inherit an old account's defaults or guess a fallback.
|
|
203
|
+
function connectModelCatalog() {
|
|
204
|
+
const { createModelCatalog } = require('./modelcatalog')
|
|
205
|
+
const codex = require('./agent/codexstatus')
|
|
206
|
+
const claude = require('./agent/claudemodels')
|
|
207
|
+
return createModelCatalog({ cacheFile: null,
|
|
208
|
+
codexstatus: { listModels: bin => codex.listModels(bin, 12000) },
|
|
209
|
+
claudemodels: { ...claude, probeModels: bin => claude.probeModels(bin, { timeoutMs: 12000 }) },
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function applyInitialGroupDefaults(config, chatId, cat) {
|
|
214
|
+
const current = applyBrainChange(config, chatId)
|
|
215
|
+
if (current.error) throw new Error(`Cannot set the new group's model defaults (${current.error})`)
|
|
216
|
+
const picks = fillPicks({ switching: true, currentModel: current.before.model || '',
|
|
217
|
+
currentEffort: current.before.effort || '', cat })
|
|
218
|
+
// fillPicks may suggest replacing an unsupported old depth on a backend
|
|
219
|
+
// switch. Onboarding only fills missing fields; it never replaces a choice.
|
|
220
|
+
const change = {}
|
|
221
|
+
if (!current.before.model && picks.model) change.model = picks.model
|
|
222
|
+
if (!current.before.effort && picks.effort) change.effort = picks.effort
|
|
223
|
+
const result = applyBrainChange(config, chatId, change)
|
|
224
|
+
if (result.error || !result.after.model || !result.after.effort) throw new Error('The live model catalog did not provide a valid model and thinking depth. Check the executor login and retry.')
|
|
225
|
+
return { provider: result.target, model: result.after.model, effort: result.after.effort,
|
|
226
|
+
name: cat.models?.find(m => m.id === result.after.model)?.name || result.after.model, source: cat.source }
|
|
227
|
+
}
|
|
228
|
+
|
|
198
229
|
async function runConnect(argv, deps = {}) {
|
|
199
230
|
const d = defaultDeps(deps)
|
|
231
|
+
// Ctrl-C (or the setup page's cancel) aborts at the next step boundary for
|
|
232
|
+
// the whole run, not only during the scan; nothing is written after an abort.
|
|
233
|
+
const abort = new AbortController()
|
|
234
|
+
const onSignal = () => abort.abort()
|
|
235
|
+
for (const signal of ['SIGINT', 'SIGTERM']) d.signals.on(signal, onSignal)
|
|
236
|
+
try { return await connectFlow(argv, d, abort) } finally {
|
|
237
|
+
for (const signal of ['SIGINT', 'SIGTERM']) d.signals.off(signal, onSignal)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function connectFlow(argv, d, abort) {
|
|
200
242
|
const { fs, feishu, stderr, stdout } = d
|
|
201
243
|
const { values } = parseArgs({ args: argv, allowPositionals: false, options: CONNECT_OPTIONS })
|
|
202
244
|
const basic = values.basic === true
|
|
@@ -206,6 +248,11 @@ async function runConnect(argv, deps = {}) {
|
|
|
206
248
|
if (givenChatId !== undefined && !CHAT_ID.test(givenChatId)) throw new Error('--chat must be a Feishu group id (oc_…)')
|
|
207
249
|
if (values.provider !== undefined && !RUNNERS.includes(values.provider)) throw new Error('--provider must be codex or claude')
|
|
208
250
|
const say = text => stderr.write(`${text}\n`)
|
|
251
|
+
const interrupted = () => {
|
|
252
|
+
if (!abort.signal.aborted) return false
|
|
253
|
+
say('已取消,没有写入任何文件。')
|
|
254
|
+
return true
|
|
255
|
+
}
|
|
209
256
|
// A wait notice repeats on every poll; it is printed once per change.
|
|
210
257
|
let lastWait = null
|
|
211
258
|
const decide = (step, input) => {
|
|
@@ -268,8 +315,21 @@ async function runConnect(argv, deps = {}) {
|
|
|
268
315
|
// let the core validate it now with placeholders for the scanned identities.
|
|
269
316
|
const configInput = { appId: reuseAppId || 'cli_pending', appSecret: 'pending', botOpenId: 'ou_pending', adminOpenId: 'ou_pending',
|
|
270
317
|
chatId: givenChatId || null, workdir, home: d.home, provider, runners }
|
|
271
|
-
const dryRun = core.connectConfig(configInput)
|
|
318
|
+
const dryRun = core.connectConfig({ ...configInput, chatId: givenChatId || 'oc_onboarding_pending' })
|
|
272
319
|
if (!dryRun.ok) throw new Error(dryRun.error)
|
|
320
|
+
const catalog = d.modelCatalog || connectModelCatalog()
|
|
321
|
+
say('正在读取执行器推荐的型号和思考深度…')
|
|
322
|
+
let selectedCatalog
|
|
323
|
+
try { selectedCatalog = await catalog(provider, { runner: provider }) } catch {}
|
|
324
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
325
|
+
if (!selectedCatalog || selectedCatalog.ok !== true || selectedCatalog.source !== 'live' ||
|
|
326
|
+
!Array.isArray(selectedCatalog.models) || !selectedCatalog.models.length) {
|
|
327
|
+
throw new Error(`Could not read live model defaults for ${provider}. Check the executor login and network, then retry. No new Feishu app was created.`)
|
|
328
|
+
}
|
|
329
|
+
const modelDefaults = applyInitialGroupDefaults(dryRun.config, givenChatId || 'oc_onboarding_pending', selectedCatalog)
|
|
330
|
+
d.onModelDefaults?.(modelDefaults)
|
|
331
|
+
say(`新群默认:${modelDefaults.name} · ${modelDefaults.effort}。接入后可在控制台修改。`)
|
|
332
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
273
333
|
|
|
274
334
|
// Step 1: the scan. The link is printed, and a local page renders the QR
|
|
275
335
|
// code from the core's matrix; the page is removed however this ends.
|
|
@@ -285,9 +345,6 @@ async function runConnect(argv, deps = {}) {
|
|
|
285
345
|
qrDir = null
|
|
286
346
|
}
|
|
287
347
|
process.once('exit', cleanupQr)
|
|
288
|
-
const abort = new AbortController()
|
|
289
|
-
const onSignal = () => abort.abort()
|
|
290
|
-
for (const signal of ['SIGINT', 'SIGTERM']) d.signals.on(signal, onSignal)
|
|
291
348
|
let lastStatus = null
|
|
292
349
|
let registration
|
|
293
350
|
try {
|
|
@@ -324,7 +381,6 @@ async function runConnect(argv, deps = {}) {
|
|
|
324
381
|
} finally {
|
|
325
382
|
cleanupQr()
|
|
326
383
|
process.off('exit', cleanupQr)
|
|
327
|
-
for (const signal of ['SIGINT', 'SIGTERM']) d.signals.off(signal, onSignal)
|
|
328
384
|
}
|
|
329
385
|
const appId = registration?.client_id || ''
|
|
330
386
|
const appSecret = registration?.client_secret || ''
|
|
@@ -346,6 +402,7 @@ async function runConnect(argv, deps = {}) {
|
|
|
346
402
|
plan = decide('token', {})
|
|
347
403
|
} catch (error) { plan = decide('token', failureInput(error, larkErrMsg)) }
|
|
348
404
|
if (plan.verdict === 'stop') return 1
|
|
405
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
349
406
|
|
|
350
407
|
// Step 2b: app enabled, waiting for an organization review when there is one.
|
|
351
408
|
let waitedMs = 0
|
|
@@ -354,6 +411,7 @@ async function runConnect(argv, deps = {}) {
|
|
|
354
411
|
plan = decide('app', { observed: { enabled, pendingReview: enabled ? false : await pendingReview(client, appId), waitedMs } })
|
|
355
412
|
if (plan.verdict !== 'wait') break
|
|
356
413
|
await d.sleep(plan.retryAfterMs)
|
|
414
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
357
415
|
waitedMs += plan.retryAfterMs
|
|
358
416
|
try { identity = await feishu.fetchAppIdentity(client, appId) } catch { identity = null }
|
|
359
417
|
}
|
|
@@ -363,6 +421,7 @@ async function runConnect(argv, deps = {}) {
|
|
|
363
421
|
const precheck = await feishu.feishuPrecheck(client, { appId })
|
|
364
422
|
if (decide('precheck', { observed: { checks: precheck.checks } }).verdict === 'stop') return 1
|
|
365
423
|
const botOpenId = precheck.botOpenId || identity?.openId || ''
|
|
424
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
366
425
|
|
|
367
426
|
// Step 3: contact range, visibility and a published version. Failures only
|
|
368
427
|
// warn; --basic skips both.
|
|
@@ -383,10 +442,11 @@ async function runConnect(argv, deps = {}) {
|
|
|
383
442
|
} catch (error) { decide('publish', failureInput(error, larkErrMsg)) }
|
|
384
443
|
}
|
|
385
444
|
|
|
445
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
386
446
|
// The real configuration input, validated before anything is created.
|
|
387
447
|
Object.assign(configInput, { appId, appSecret, botOpenId, adminOpenId })
|
|
388
448
|
let built = core.connectConfig(configInput)
|
|
389
|
-
if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error }); return 1 }
|
|
449
|
+
if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error, observed: { configPath } }); return 1 }
|
|
390
450
|
|
|
391
451
|
// Step 4: the project directory is judged now and created with the
|
|
392
452
|
// configuration below, so a stopped group step leaves no folder behind.
|
|
@@ -403,36 +463,54 @@ async function runConnect(argv, deps = {}) {
|
|
|
403
463
|
const chat = await feishu.createChat(client, chatName, '', adminOpenId ? [adminOpenId] : [])
|
|
404
464
|
if (!chat.chatId) throw new Error('create chat returned no chat_id')
|
|
405
465
|
chatId = chat.chatId
|
|
406
|
-
plan = decide('chat', { observed: { appId
|
|
466
|
+
plan = decide('chat', { observed: { appId } }) // chatId in observed means a group the user supplied
|
|
407
467
|
} catch (error) { plan = decide('chat', { ...failureInput(error, larkErrMsg), observed: { appId, chatId: null } }) }
|
|
408
468
|
} else {
|
|
409
469
|
plan = decide('chat', { observed: { appId, chatId } })
|
|
410
470
|
}
|
|
411
471
|
if (plan.verdict === 'stop') return 1
|
|
472
|
+
d.onGroupReady?.({ appId, chatId })
|
|
473
|
+
if (interrupted()) return EXIT_INTERRUPTED // the group exists; the config does not, so --app-id/--chat resumes it
|
|
412
474
|
try {
|
|
413
475
|
await feishu.sendChatText(client, chatId,
|
|
414
476
|
`你好,我是 ${chatName}。这个群绑定的项目目录是 ${workdir};有任务直接 @ 我或在群里说。`)
|
|
415
477
|
} catch {}
|
|
416
478
|
|
|
479
|
+
if (interrupted()) return EXIT_INTERRUPTED
|
|
417
480
|
// Step 6: the folder, config.json (0600 in a 0700 directory) and the README.
|
|
418
481
|
built = core.connectConfig({ ...configInput, chatId })
|
|
419
|
-
if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error }); return 1 }
|
|
420
|
-
|
|
482
|
+
if (!built.ok) { decide('config', { code: LOCAL_FAILURE, msg: built.error, observed: { configPath } }); return 1 }
|
|
483
|
+
applyInitialGroupDefaults(built.config, chatId, selectedCatalog)
|
|
484
|
+
let createdDir = false, createdReadme = false, configCommitted = false
|
|
485
|
+
const readme = path.join(workdir, 'README.md')
|
|
421
486
|
try {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
createdDir = true
|
|
426
|
-
}
|
|
487
|
+
// A scan can wait for minutes. Recheck immediately before committing, under
|
|
488
|
+
// the same short-lived config lock as other writers; never replace a new
|
|
489
|
+
// installation that appeared while this page was waiting.
|
|
427
490
|
fs.mkdirSync(path.dirname(configPath), { recursive: true, mode: 0o700 })
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
491
|
+
withFileLock(configPath, () => {
|
|
492
|
+
assertReplaceable(readExistingConfig(fs, configPath), configPath, reuseAppId)
|
|
493
|
+
assertFreshState(fs, stateDir)
|
|
494
|
+
if (!observed.exists) {
|
|
495
|
+
fs.mkdirSync(path.dirname(workdir), { recursive: true, mode: 0o755 })
|
|
496
|
+
fs.mkdirSync(workdir, { mode: 0o755 })
|
|
497
|
+
createdDir = true
|
|
498
|
+
}
|
|
499
|
+
// Finish fallible project preparation before the config commit. A README
|
|
500
|
+
// failure must not leave a bound config that the retry correctly refuses.
|
|
501
|
+
if (!fs.existsSync(readme)) {
|
|
502
|
+
fs.writeFileSync(readme, built.readme, { mode: 0o644, flag: 'wx' })
|
|
503
|
+
createdReadme = true
|
|
504
|
+
}
|
|
505
|
+
if (fs.existsSync(configPath)) fs.chmodSync(configPath, 0o600)
|
|
506
|
+
atomicWriteJSON(configPath, built.config, { mode: 0o600 })
|
|
507
|
+
configCommitted = true
|
|
508
|
+
})
|
|
509
|
+
plan = decide('config', { observed: { configPath } })
|
|
433
510
|
} catch (error) {
|
|
511
|
+
if (createdReadme && !configCommitted) { try { fs.unlinkSync(readme) } catch {} }
|
|
434
512
|
if (createdDir) { try { if (!fs.readdirSync(workdir).length) fs.rmdirSync(workdir) } catch {} }
|
|
435
|
-
plan = decide('config', { code: LOCAL_FAILURE, msg: error?.message || String(error) })
|
|
513
|
+
plan = decide('config', { code: LOCAL_FAILURE, msg: error?.message || String(error), observed: { configPath } })
|
|
436
514
|
}
|
|
437
515
|
if (plan.verdict === 'stop') return 1
|
|
438
516
|
|
|
@@ -485,4 +563,4 @@ async function scannerName(client, openId, sdkName, basic, d) {
|
|
|
485
563
|
return name ? core.connectManifest(basic, name, d.version).appPreset.name : ''
|
|
486
564
|
}
|
|
487
565
|
|
|
488
|
-
module.exports = { runConnect, CONNECT_OPTIONS, sdkAddons, qrPage, failureInput }
|
|
566
|
+
module.exports = { runConnect, CONNECT_OPTIONS, sdkAddons, qrPage, failureInput, probeRunners, EXIT_INTERRUPTED, connectModelCatalog, applyInitialGroupDefaults }
|
|
@@ -70,4 +70,50 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
|
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
|
|
73
|
+
// Idle compaction (ABI 37): the same process-local arguments as a turn on this
|
|
74
|
+
// group, minus the prompt, durable hooks and follow-up channel. Tool set, MCP,
|
|
75
|
+
// system appendix, model, depth and env must match the turn exactly or the
|
|
76
|
+
// session's prompt cache is rewritten.
|
|
77
|
+
function prepareC2CompactionInput({ appendSystemPrompt, chatId, group, provider, config, brain = null,
|
|
78
|
+
sessionId, signal, extraEnv, keep, onEvent = () => {}, log = () => {} } = {}) {
|
|
79
|
+
if (typeof appendSystemPrompt !== 'string' || !appendSystemPrompt ||
|
|
80
|
+
typeof chatId !== 'string' || !chatId ||
|
|
81
|
+
!group || typeof group.workdir !== 'string' || !group.workdir ||
|
|
82
|
+
!provider || provider.runner !== 'claude' ||
|
|
83
|
+
!config || !config.agent || typeof config.agent !== 'object' ||
|
|
84
|
+
(brain !== null && (typeof brain !== 'object' || typeof brain.model !== 'string' ||
|
|
85
|
+
typeof brain.effort !== 'string')) ||
|
|
86
|
+
typeof sessionId !== 'string' || !sessionId ||
|
|
87
|
+
!signal || typeof signal.aborted !== 'boolean' ||
|
|
88
|
+
!extraEnv || typeof extraEnv !== 'object' || Array.isArray(extraEnv) ||
|
|
89
|
+
extraEnv.SBOT_CHAT_ID !== chatId ||
|
|
90
|
+
Object.values(extraEnv).some(value => typeof value !== 'string') ||
|
|
91
|
+
typeof keep !== 'string' || !keep ||
|
|
92
|
+
typeof onEvent !== 'function' || typeof log !== 'function') {
|
|
93
|
+
throw new TypeError('C2 compaction input requires an exact process-local session')
|
|
94
|
+
}
|
|
95
|
+
const rawCompact = Number(provider.autocompactTokens) || 0
|
|
96
|
+
const autocompactTokens = rawCompact > 0
|
|
97
|
+
? Math.min(1_000_000, Math.max(100_000, Math.floor(rawCompact))) : 0
|
|
98
|
+
return {
|
|
99
|
+
bin: config.agent.bin,
|
|
100
|
+
appendSystemPrompt, provider,
|
|
101
|
+
steer: false,
|
|
102
|
+
model: brain ? brain.model || undefined : group.model,
|
|
103
|
+
effort: brain ? brain.effort : '',
|
|
104
|
+
workdir: group.workdir,
|
|
105
|
+
sessionId,
|
|
106
|
+
permissionMode: group.permissionMode || 'dontAsk',
|
|
107
|
+
autocompactTokens,
|
|
108
|
+
allowedTools: group.allowedTools,
|
|
109
|
+
disallowedTools: group.disallowedTools,
|
|
110
|
+
plugins: group.plugins,
|
|
111
|
+
signal,
|
|
112
|
+
extraEnv: { ...extraEnv },
|
|
113
|
+
keep,
|
|
114
|
+
log,
|
|
115
|
+
onEvent,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = { prepareC2RunnerInput, prepareC2CompactionInput }
|