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.
@@ -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 }
@@ -1,9 +1,9 @@
1
- // ABI v36 adapter for the private binary decision core. Manifest assembly still stays in JS.
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 = 'c79f12ceb1ef79aceb0ab54cf87052127325a1d8fdfd5c02e3509fef39f6f511'
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() !== 36) throw new Error('SintBot binary core ABI mismatch')
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
@@ -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 }
@@ -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, chatId } })
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
- let createdDir = false
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
- if (!observed.exists) {
423
- fs.mkdirSync(path.dirname(workdir), { recursive: true, mode: 0o755 })
424
- fs.mkdirSync(workdir, { mode: 0o755 })
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
- atomicWriteJSON(configPath, built.config, { mode: 0o600 })
429
- fs.chmodSync(configPath, 0o600)
430
- const readme = path.join(workdir, 'README.md')
431
- if (!fs.existsSync(readme)) fs.writeFileSync(readme, built.readme, { mode: 0o644 })
432
- plan = decide('config', {})
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
- module.exports = { prepareC2RunnerInput }
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 }