sintbot 1.4.11 → 1.4.13

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.
@@ -0,0 +1,194 @@
1
+ 'use strict'
2
+
3
+ // An observational read model, never an execution/recovery authority. All
4
+ // terminal labels are supplied by the existing acknowledged engine paths.
5
+ const fs = require('node:fs')
6
+ const path = require('node:path')
7
+ const { randomUUID } = require('node:crypto')
8
+ const { redact } = require('../core/redact')
9
+ const LIMIT = 256, TEXT = 16384, FILE_LIMIT = 12 * 1024 * 1024
10
+ const CLOSED = new Set(['answered', 'silent', 'stopped', 'recalled', 'timeout', 'error', 'not-started', 'unknown'])
11
+ const clean = (s, n = TEXT) => redact(String(s || ''), { pii: false }).slice(0, n)
12
+ const finite = n => Number.isFinite(n) && n >= 0
13
+
14
+ function createLiveActivity({ file, now = Date.now, log = () => {}, canWrite = () => true } = {}) {
15
+ const epoch = randomUUID(), rows = new Map(), aliases = new Map(), contexts = new Map(), listeners = new Set()
16
+ let revision = 0, timer, saveTimer, saving = Promise.resolve(), writing = false, dirty = false, storageError = false
17
+ const key = (chatId, id) => chatId + ':' + id
18
+ const locate = (chatId, id) => rows.get(aliases.get(key(chatId, id)) || key(chatId, id))
19
+ function changed(urgent = false) {
20
+ revision++; dirty = true
21
+ if (!timer) timer = setTimeout(() => {
22
+ timer = null
23
+ for (const fn of listeners) { try { fn() } catch {} }
24
+ }, urgent ? 0 : 150).unref()
25
+ if (!saveTimer && file) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
26
+ }
27
+ function touch(row, urgent = false, details = true) {
28
+ row.updatedAt = now(); row.revision = revision + 1
29
+ if (details) row.detailRevision = row.revision
30
+ changed(urgent)
31
+ while (rows.size > LIMIT) {
32
+ const oldest = [...rows.values()].sort((a, b) => Number(!CLOSED.has(a.status)) - Number(!CLOSED.has(b.status)) || a.updatedAt - b.updatedAt)[0]
33
+ rows.delete(key(oldest.chatId, oldest.id))
34
+ for (const id of oldest.receiptIds) aliases.delete(key(oldest.chatId, id))
35
+ }
36
+ }
37
+ function receive({ chatId, receiptId, sourceMessageId, text, sender, acceptedAt }) {
38
+ if (locate(chatId, receiptId)) return
39
+ const row = { id: receiptId, chatId, receiptIds: [receiptId], ts: acceptedAt || now(),
40
+ status: 'queued', text: clean(text, 160), sender: clean(sender, 120),
41
+ messages: [{ id: receiptId, sourceMessageId: typeof sourceMessageId === 'string' ? sourceMessageId : null,
42
+ text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
43
+ truncated: String(text || '').length > TEXT, reply: '', tools: [], milestones: [] }
44
+ rows.set(key(chatId, receiptId), row); touch(row, true)
45
+ }
46
+ function merge(chatId, parentId, ids, state) {
47
+ const row = locate(chatId, parentId)
48
+ if (!row) return
49
+ for (const id of ids) {
50
+ const child = locate(chatId, id)
51
+ if (child && child !== row) {
52
+ row.messages.push(...child.messages)
53
+ row.receiptIds.push(...child.receiptIds)
54
+ rows.delete(key(chatId, child.id))
55
+ }
56
+ if (!row.receiptIds.includes(id)) row.receiptIds.push(id)
57
+ aliases.set(key(chatId, id), key(chatId, row.id))
58
+ const msg = row.messages.find(m => m.id === id)
59
+ if (msg) msg.state = state
60
+ }
61
+ // Bound the entire message body, not only each message, and disclose loss.
62
+ let budget = TEXT
63
+ if (row.messages.length > 64) row.truncated = true
64
+ row.messages = row.messages.slice(0, 64).map(m => {
65
+ const text = m.text.slice(0, budget); budget -= text.length
66
+ if (text !== m.text) row.truncated = true
67
+ return { ...m, text }
68
+ })
69
+ touch(row, true)
70
+ return row
71
+ }
72
+ function start({ chatId, receiptId, receiptIds, generation, sessionId, model, runner, project }) {
73
+ const row = merge(chatId, receiptId, receiptIds || [receiptId], 'landed')
74
+ if (!row) return
75
+ row.status = 'running'; row.generation = generation
76
+ row.startedAt ||= now(); row.sessionId = sessionId || null
77
+ Object.assign(row, { model: clean(model, 120), runner, project: clean(project, 120) })
78
+ const prior = contexts.get(chatId)
79
+ if (!prior || prior.sessionId !== sessionId) contexts.delete(chatId)
80
+ else contexts.set(chatId, { ...prior, generation, quality: 'previous' })
81
+ touch(row, true)
82
+ }
83
+ function observe({ chatId, receiptId, generation, event, doing, milestones }) {
84
+ const row = locate(chatId, receiptId)
85
+ if (!row || row.generation !== generation || CLOSED.has(row.status)) return
86
+ if (event.sessionId) row.sessionId = event.sessionId
87
+ const compact = event.type === 'system' && event.subtype === 'compact' || event.contextCompacted && !event.contextTokens
88
+ const measured = finite(event.contextTokens) && event.contextSampled !== false
89
+ if (compact || measured) {
90
+ const prior = contexts.get(chatId)
91
+ contexts.set(chatId, { sessionId: row.sessionId, generation,
92
+ usedTokens: compact ? null : event.contextTokens,
93
+ windowTokens: finite(event.contextWindowTokens) && event.contextWindowTokens > 0 ? event.contextWindowTokens : prior?.generation === generation ? prior.windowTokens : null,
94
+ measuredAt: now(), quality: compact ? 'compacted' : 'reported', source: row.runner })
95
+ }
96
+ if (event.tools) {
97
+ for (const tool of event.tools) {
98
+ const name = clean(tool.name, 100), found = row.tools.find(t => t.name === name)
99
+ if (found) found.count++
100
+ else if (row.tools.length < 50) row.tools.push({ name, count: 1 })
101
+ }
102
+ }
103
+ if (doing) row.doing = doing.map(x => clean(x, 160)).slice(-3)
104
+ if (milestones) row.milestones = milestones.map(x => clean(x, 200)).slice(-12)
105
+ // No model text deltas: only user-facing milestones and tool descriptions.
106
+ if (compact || measured || event.tools?.length || milestones)
107
+ touch(row, compact, !!event.tools?.length || !!milestones)
108
+ }
109
+ function finish({ chatId, receiptId, status, reply, err, tokens, sessionId }) {
110
+ const row = locate(chatId, receiptId)
111
+ if (!row || CLOSED.has(row.status)) return
112
+ row.status = status; row.endedAt = now(); row.ms = row.startedAt ? row.endedAt - row.startedAt : 0
113
+ row.reply = clean(reply); row.err = clean(err, 1000); row.tokens = tokens || null
114
+ row.truncated ||= String(reply || '').length > TEXT
115
+ if (sessionId) row.sessionId = sessionId
116
+ const ctx = contexts.get(chatId)
117
+ if (ctx && ctx.generation === row.generation) {
118
+ ctx.sessionId = row.sessionId; ctx.quality = ctx.quality === 'compacted' ? 'compacted' : 'previous'
119
+ }
120
+ touch(row, true)
121
+ }
122
+ function sending({ chatId, receiptId }) {
123
+ const row = locate(chatId, receiptId)
124
+ if (row && !CLOSED.has(row.status)) { row.status = 'sending'; touch(row, true) }
125
+ }
126
+ function unknown({ chatId }) {
127
+ for (const row of rows.values()) if (row.chatId === chatId && !CLOSED.has(row.status)) {
128
+ finish({ chatId, receiptId: row.id, status: 'unknown', err: '结果尚未确认,请查看群状态' })
129
+ }
130
+ }
131
+ const summary = row => {
132
+ const { messages, reply, tools, milestones, receiptIds, generation, sessionId, ...rest } = row
133
+ return { ...rest, preview: (row.err || reply || '').slice(0, 160), messageCount: messages.length,
134
+ kind: CLOSED.has(row.status) ? 'done' : 'start', details: true }
135
+ }
136
+ function list({ chatId, status, offset = 0, limit = 30 } = {}) {
137
+ const all = [...rows.values()].filter(r => (!chatId || r.chatId === chatId) && (!status || r.status === status))
138
+ .sort((a, b) => Number(CLOSED.has(a.status)) - Number(CLOSED.has(b.status)) || b.ts - a.ts)
139
+ offset = Math.max(0, Math.min(LIMIT, Number(offset) || 0)); limit = Math.max(1, Math.min(100, Number(limit) || 30))
140
+ return { epoch, revision, storageError, total: all.length, activity: all.slice(offset, offset + limit).map(summary) }
141
+ }
142
+ function detail(chatId, id) {
143
+ const row = locate(chatId, id)
144
+ return row ? { epoch, revision: row.revision, ...structuredClone(row) } : null
145
+ }
146
+ function context(chatId, sessionId, busy = false, generation = null) {
147
+ const c = contexts.get(chatId)
148
+ if (!c || !(busy && generation && c.generation === generation) && (!sessionId || c.sessionId !== sessionId)) return null
149
+ return { ...c, quality: busy ? c.quality : c.quality === 'reported' ? 'previous' : c.quality }
150
+ }
151
+ function flush() {
152
+ clearTimeout(saveTimer); saveTimer = null
153
+ if (writing) return saving
154
+ if (!file || !dirty || !canWrite()) return saving
155
+ dirty = false
156
+ const data = JSON.stringify({ version: 1, rows: [...rows.values()], contexts: [...contexts] })
157
+ writing = true
158
+ saving = saving.then(async () => {
159
+ if (!canWrite()) return
160
+ if (Buffer.byteLength(data) > FILE_LIMIT) throw new Error('activity size limit')
161
+ await fs.promises.mkdir(path.dirname(file), { recursive: true })
162
+ const tmp = file + '.tmp'
163
+ await fs.promises.writeFile(tmp, data, { mode: 0o600 })
164
+ if (canWrite()) await fs.promises.rename(tmp, file)
165
+ storageError = false
166
+ }).catch(() => { storageError = true; log('Activity history could not be saved') })
167
+ .finally(() => {
168
+ writing = false
169
+ if (dirty && !saveTimer) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
170
+ })
171
+ return saving
172
+ }
173
+ if (file) {
174
+ try {
175
+ if (fs.statSync(file).size > FILE_LIMIT) throw new Error('activity size limit')
176
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'))
177
+ if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > LIMIT) throw new Error('activity format')
178
+ for (const row of data.rows) {
179
+ if (!row || typeof row.id !== 'string' || typeof row.chatId !== 'string' || !Array.isArray(row.receiptIds) || !Array.isArray(row.messages)) continue
180
+ if (!CLOSED.has(row.status)) { row.status = 'unknown'; row.err = '服务重启,结果需要核实' }
181
+ rows.set(key(row.chatId, row.id), row)
182
+ revision = Math.max(revision, Number(row.revision) || 0)
183
+ for (const id of row.receiptIds) aliases.set(key(row.chatId, id), key(row.chatId, row.id))
184
+ }
185
+ for (const [id, value] of (data.contexts || []).slice(0, 32)) contexts.set(id, { ...value, quality: value.quality === 'compacted' ? 'compacted' : 'historical' })
186
+ } catch (error) { if (error.code !== 'ENOENT') { storageError = true; log('Activity history could not be loaded') } }
187
+ }
188
+ return { receive, start, observe, finish, sending, unknown, merge, list, detail, context, flush,
189
+ has: (chatId, id) => !!locate(chatId, id),
190
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
191
+ async close() { clearTimeout(timer); listeners.clear(); await flush(); await flush(); clearTimeout(saveTimer) } }
192
+ }
193
+
194
+ module.exports = { createLiveActivity }
@@ -18,7 +18,7 @@ const { createLedger } = require('../core/ledger')
18
18
  const { normalizeTz, isValidTz, tzOffsetMin, tzParts } = require('../core/time')
19
19
  // Runner dispatch (Claude vs Codex by provider.runner) — so 测试/试跑 run codex
20
20
  // providers on the Codex CLI, not Claude. Same module the daemon/CLI use.
21
- const { runTurn, probeVersion } = require('../agent/runner')
21
+ const { runTurn, probeVersion, isClosedRunnerResult, isClosedRunnerError } = require('../agent/runner')
22
22
  const codexstatus = require('../agent/codexstatus') // codex 订阅登录态 + 用量(卡片「验证登录」)
23
23
  const claudestatus = require('../agent/claudestatus') // Claude 订阅登录态(卡片「检测登录」· doctor 同源)
24
24
  const claudemodels = require('../agent/claudemodels') // Claude 模型候选(仅登录后给)
@@ -49,6 +49,7 @@ const DEFAULT_ISSUES_FILE = () => path.join(os.homedir(), '.sbot', 'issues.jsonl
49
49
  const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-sessions.json')
50
50
 
51
51
  const CONSOLE_HTML = path.join(__dirname, 'console.html')
52
+ const LIVE_ACTIVITY_JS = fs.readFileSync(path.join(__dirname, 'liveactivity-client.js'), 'utf8')
52
53
  const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
53
54
  const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
54
55
  const dashboard = require('./dashboard') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
@@ -404,9 +405,9 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
404
405
  // each click doesn't spawn a `claude --version` child.
405
406
  let probeCache = null // { val, ts }
406
407
  const probeCached = async (bin) => {
407
- if (probeCache && Date.now() - probeCache.ts < 60_000) return probeCache.val
408
+ if (probeCache && probeCache.bin === bin && Date.now() - probeCache.ts < 60_000) return probeCache.val
408
409
  const val = await probeVersion(bin)
409
- probeCache = { val, ts: Date.now() }
410
+ probeCache = { val, bin, ts: Date.now() }
410
411
  return val
411
412
  }
412
413
  // Same 60s cache for the codex CLI probe — only run when a codex provider exists.
@@ -521,7 +522,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
521
522
  icons: g.workdir ? design.projectIconCard(g.workdir) : null, crons: (Array.isArray(g.crons) ? g.crons : []).map((c) => ({ ...c, human: describeTask(c, nowTzStr), expired: cronExpired(c.at) })), exists: g.workdir ? fs.existsSync(g.workdir) : false }))
522
523
  // Console overview health — deliberately minimal: backend CLI 探针 + "有无可用模型/群"。
523
524
  // Codex CLI 行仅在配了 codex provider 时出现(与 doctor 一致);`sbot doctor` 保留全量。
524
- const v = runtimePolicy ? null : await probeCached(cfg.agent.bin)
525
+ const hasClaude = providers.some((p) => !p.hidden && p.runner !== 'codex')
526
+ const v = !runtimePolicy || hasClaude ? await probeCached(cfg.agent.bin) : null
525
527
  const hasCodex = providers.some((p) => !p.hidden && p.runner === 'codex')
526
528
  const cv = hasCodex ? await probeCodexCached() : null
527
529
  // 「可用模型」= 能用的,不只是配了的(1.0.3;此前没登录的 Claude 订阅、试跑失败的 key 都算在内)。
@@ -683,14 +685,18 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
683
685
  const cfg = loadConfig(configPath); applyProxyEnv(cfg)
684
686
  const provider = cfg.providers[name]
685
687
  if (!provider) return { ok: false, error: `unknown provider '${name}'` }
688
+ if (provider.hidden) return { ok: false, error: '模型已隐藏,不能试运行' }
686
689
  if (provider.baseUrl && !provider.apiKey) return { ok: false, error: 'no apiKey' }
687
690
  const ac = new AbortController()
691
+ let lease, closed = false
692
+ try { lease = runtimePolicy ? await runtimePolicy.beginTrial(ac) : null }
693
+ catch (error) { return { ok: false, error: error.message } }
688
694
  // codex 跑整轮 GPT(冷启动 + reasoning)远慢于 Anthropic 的快问快答;给更长的测试超时。
689
695
  // 中止现在真生效(codexcode 强制收尾),超时到点能掐断,不会再干等到自然结束。
690
696
  const timer = setTimeout(() => ac.abort(), provider.runner === 'codex' ? 180000 : 60000)
691
697
  const t0 = Date.now()
692
698
  try {
693
- const { result } = await runTurn({
699
+ const execution = await runTurn({
694
700
  bin: cfg.agent.bin,
695
701
  prompt: prompt || verify.TRIAL_PROMPT,
696
702
  provider,
@@ -698,13 +704,22 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
698
704
  permissionMode: cfg.agent.permissionMode,
699
705
  signal: ac.signal, // a one-line reply — needs no tools, so no allow list
700
706
  })
707
+ closed = isClosedRunnerResult(execution)
708
+ const { result } = execution
709
+ if (lease && !closed) throw new Error('试运行进程关闭状态未知,结果未保存,并发名额已保留')
710
+ lease?.assertWritable()
701
711
  const ok = !(result && result.isError)
702
712
  verify.recordTurn(provider, ok, (result && result.text) || '', vopts) // doctor / 概览据此报「能用」或「key 不对」
703
713
  return { ok, model: provider.model, reply: (result && result.text) || '(no text)', ms: Date.now() - t0 }
704
714
  } catch (e) {
705
- verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
715
+ closed ||= isClosedRunnerError(e)
716
+ if (!lease) verify.recordTurn(provider, false, (e && e.message) || String(e), vopts)
717
+ else if (closed) {
718
+ try { lease.assertWritable(); verify.recordTurn(provider, false, e.message || String(e), vopts) }
719
+ catch { /* lease loss must never write verification evidence */ }
720
+ }
706
721
  return { ok: false, error: (e && e.message) || String(e), ms: Date.now() - t0 }
707
- } finally { clearTimeout(timer) }
722
+ } finally { clearTimeout(timer); lease?.finish(closed) }
708
723
  }
709
724
 
710
725
  // ── request handling ──────────────────────────────────────────────────────
@@ -840,7 +855,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
840
855
  // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
841
856
  const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
842
857
  const injectTok = (authConfigured() && !localBypass) ? '' : token
843
- return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : ''))))
858
+ return fs.readFile(CONSOLE_HTML, 'utf8', (e, html) => e ? (res.writeHead(500), res.end('console.html missing')) : (res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }), res.end(html.replace('__SBOT_TOKEN__', injectTok).replace('__SBOT_VERSION__', PKG_VERSION).replace('__SBOT_FEISHU_LOGIN__', feishuLoginInfo().ready ? '1' : '').replace('/*__LIVE_ACTIVITY__*/', () => LIVE_ACTIVITY_JS))))
844
859
  }
845
860
  // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
846
861
  // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
@@ -867,7 +882,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
867
882
  if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
868
883
  // Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
869
884
  // Lightweight + kept off /api/state so mutations don't re-ship it every write.
870
- if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, { activity: recentActivity() })
885
+ if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, runtimePolicy?.activityPage
886
+ ? runtimePolicy.activityPage(Object.fromEntries(url.searchParams)) : { activity: recentActivity() })
887
+ if (p === '/api/activity/detail' && req.method === 'GET') {
888
+ const detail = runtimePolicy?.activityDetail?.(url.searchParams.get('chatId'), url.searchParams.get('id'))
889
+ return sendJSON(res, detail ? 200 : 404, detail || { error: '活动详情未记录' })
890
+ }
871
891
  // 版本历史(设置页「版本历史」卡):CHANGELOG.json 随包分发 = 单一事实源,发版时更新
872
892
  if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
873
893
  // 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
@@ -1113,7 +1133,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1113
1133
  const why = { 'no-daemon': '守护进程没在跑(纯配置模式)—— 插话需要它在线', paused: '这个群项目已暂停,先恢复再插话', unbound: '没有这个群项目', 'inject-failed': '注入失败(守护进程侧报错,看日志)',
1114
1134
  // 持久处理流程(第 1 版插话)的拒绝原因。shown-failed 之前的几条在发群消息之前就拒,群里没有任何字;
1115
1135
  // 从 shown-failed 起,群里可能已经有那行「【管理员】…」
1116
- busy: '该群有任务进行中,当前版本的插话不排队也不加入当前任务,等它结束再插',
1136
+ busy: '该群正在停止、跳过或处理未结记录,请稍后再插话',
1137
+ 'queue-full': '该群等待处理的消息已满,请稍后再插话',
1117
1138
  held: '该群上一个任务结果不明,已暂停处理、等待人工确认;插话未执行',
1118
1139
  recovering: '该群上一个任务出错,正在收尾;插话未执行,请稍后再试',
1119
1140
  upgrading: '正在升级,插话未执行,请一分钟后再试',
@@ -1124,7 +1145,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1124
1145
  'request-id': '这次提交缺少有效的提交编号,请刷新控制台后再试',
1125
1146
  empty: '内容不能为空',
1126
1147
  'shown-failed': '群里那行「【管理员】…」没能确认发出(失败或超时),插话未执行;即使群里随后出现这行,它也不会被处理',
1127
- 'busy-after-post': '这行字发进群时该群刚好开始了别的任务,这句没有处理;等它结束后原样再点一次插入,会沿用群里已有的那行',
1148
+ 'busy-after-post': '这行字发进群时该群正在停止或处理未结记录,这句没有处理;稍后原样再点一次插入,会沿用群里已有的那行',
1128
1149
  refused: '该群拒收了这句(原因已回复在群里那行下面);插话未执行',
1129
1150
  quarantined: '这句的来源已被隔离,未执行',
1130
1151
  unconfirmed: '这行字已发进群,但没在 5 秒内确认登记,结果以群里和「实时活动」为准;原样再点一次插入不会重复处理' }[r.reason] || '插话失败'
@@ -2417,8 +2438,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2417
2438
  const live = getLiveState ? getLiveState() : { running: false }
2418
2439
  const conn = getConnHealth ? getConnHealth() : null // 飞书 WS 健康度随心跳带上(SSE 无重放,轮询兜底)
2419
2440
  const msg = `event: tick\ndata: ${JSON.stringify({ live, conn, ts: Date.now(),
2420
- ...(runtimePolicy ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
2421
- for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2441
+ ...(runtimePolicy && !runtimePolicy.activityPage ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
2442
+ for (const c of sseClients) { try {
2443
+ if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
2444
+ else c.write(msg)
2445
+ } catch { sseClients.delete(c) } }
2422
2446
  }, 3000)
2423
2447
 
2424
2448
  return {
@@ -2434,7 +2458,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2434
2458
  if (type === 'turn' && getLiveState) {
2435
2459
  msg += `event: tick\ndata: ${JSON.stringify({ live: getLiveState(), conn: getConnHealth ? getConnHealth() : null, ts: Date.now() })}\n\n`
2436
2460
  }
2437
- for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2461
+ for (const c of sseClients) { try {
2462
+ if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
2463
+ else c.write(msg)
2464
+ } catch { sseClients.delete(c) } }
2438
2465
  },
2439
2466
  close() { clearInterval(tick); for (const c of sseClients) { try { c.end() } catch {} } try { browserRoutes.close() } catch {} server.close(); if (typeof server.closeAllConnections === 'function') server.closeAllConnections(); if (updater) updater.close() },
2440
2467
  _browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用