sintbot 1.4.11 → 1.4.12

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 CHANGED
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "1.4.12",
4
+ "date": "2026-11-10",
5
+ "name": "Projects and live activity",
6
+ "items": [
7
+ {
8
+ "t": "Projects, concurrency and context in one panel",
9
+ "d": "Show project paths, runtime, configured reasoning and context together; restore row icon actions and display task tools, reply and compact metrics after selecting a group. Remove the top available-controls notice."
10
+ },
11
+ {
12
+ "t": "Actual task details and reported context",
13
+ "d": "Shows requests, follow-ups, progress, replies and the latest reported context. Reuses existing events without extra model calls; details load on demand and restored samples are labeled historical."
14
+ }
15
+ ]
16
+ },
2
17
  {
3
18
  "version": "1.4.11",
4
19
  "date": "2026-11-10",
package/CHANGELOG.json CHANGED
@@ -1,4 +1,19 @@
1
1
  [
2
+ {
3
+ "version": "1.4.12",
4
+ "date": "2026-11-10",
5
+ "name": "群项目与实时活动",
6
+ "items": [
7
+ {
8
+ "t": "群项目、并发与上下文集中展示",
9
+ "d": "群项目、运行耗时、项目路径、模型思考深度与上下文集中展示;恢复群行图标操作,选择群后查看任务,详情采用工具标签、回复正文和紧凑统计。 移除顶部当前可用操作说明卡片。"
10
+ },
11
+ {
12
+ "t": "真实任务详情与可信上下文",
13
+ "d": "展示请求、补话、进度、回复及最近上报的上下文。使用既有事件,不额外请求模型;详情按需加载,重启后历史采样明确标注。"
14
+ }
15
+ ]
16
+ },
2
17
  {
3
18
  "version": "1.4.11",
4
19
  "date": "2026-11-10",
package/bin/sintbot-v2.js CHANGED
@@ -114,6 +114,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
114
114
  let configControl
115
115
  let updater
116
116
  let liveTransport
117
+ let activity
117
118
  let onSigint, onSigterm
118
119
  try {
119
120
  const migrationFence = require('../src/c2migration').loadC2MigrationFence({ stateDir, routes })
@@ -139,6 +140,9 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
139
140
  // All group Engines must share this exact object: separate Store caches
140
141
  // would overwrite sibling sessions when they save the same state file.
141
142
  const store = new StateStore(path.join(stateDir, 'state.json'))
143
+ activity = require('../src/ui/liveactivity').createLiveActivity({
144
+ file: path.join(stateDir, 'activity-v1.json'), canWrite: assertWriterLock,
145
+ log: message => process.stderr.write(`Console · ${message}\n`) })
142
146
  const closeChecks = {
143
147
  claude: require('../src/agent/claudecode').isClosedRunnerResult,
144
148
  'codex-exec': require('../src/agent/codexcode').isClosedRunnerResult,
@@ -154,11 +158,12 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
154
158
  const limits = { maxEventCount: 100_000, maxJournalBytes: 64 * 1024 * 1024,
155
159
  maxCompletionEvents: 1_000, maxCompletionBytes: 8 * 1024 * 1024 }
156
160
  let finishing = false
157
- const finish = result => {
161
+ const finish = async result => {
158
162
  if (finishing) return
159
163
  finishing = true
160
164
  ui?.close()
161
165
  updater?.close()
166
+ await activity?.close()
162
167
  lock.release()
163
168
  process.stderr.write(`C2 multi-group · ${result.reason}; platform stopped=${result.platformStopped}; runner drained=${result.drained}\n`,
164
169
  () => process.exit(result.code))
@@ -230,7 +235,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
230
235
  return result
231
236
  }
232
237
  engine = new Engine({ ...cfg, groups: { [route.chatId]: route.group } }, {
233
- store, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
238
+ store, activity, log: message => process.stderr.write(`C2 v2 · ${message}\n`),
234
239
  durableV2Runtime: runtime, durableV2Payloads: handles,
235
240
  durableV2StartTurn: startTurn, durableV2ApplyLocalEffect: applyLocalEffect,
236
241
  durableV2VerifyRunnerClosed: ({ result, error }) => result
@@ -284,7 +289,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
284
289
  process.on('SIGTERM', onSigterm)
285
290
  if (uiEnabled && !preflightOnly) {
286
291
  ui = require('../src/c2console').createC2Console({
287
- configPath: cfg._path, stateDir, host: uiHost,
292
+ configPath: cfg._path, stateDir, host: uiHost, activity, store,
288
293
  getConfig: () => cfg, getBindings: () => service.bindings,
289
294
  getHeld: () => service.held, getCapacity: () => capacity.snapshot(),
290
295
  getConnHealth: () => liveTransport?.platform.connHealth?.() || {},
@@ -339,6 +344,7 @@ async function startMultiGroupService({ installation, preflightOnly, uiEnabled =
339
344
  throw error
340
345
  } finally {
341
346
  if (!running) {
347
+ await activity?.close()
342
348
  updater?.close()
343
349
  if (onSigint) process.off('SIGINT', onSigint)
344
350
  if (onSigterm) process.off('SIGTERM', onSigterm)
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.11 with an AI assistant
1
+ # Install SintBot 1.4.12 with an AI assistant
2
2
 
3
3
  English · [简体中文](./zh-CN/AI-ASSISTED-INSTALL.md)
4
4
 
@@ -15,5 +15,5 @@ Use the terminal on the Mac that will run SintBot. A cloud shell cannot install
15
15
  ## Copyable request
16
16
 
17
17
  ```text
18
- Install SintBot 1.4.11 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
18
+ Install SintBot 1.4.12 on this Mac using the one-line installation guide. Preserve existing services, configuration, state and Codex or Claude Code account data. Do not expose credentials. Verify the actual installed executable and binary core, configure the Feishu groups with their runners, and initialize only clean new state. Run the foreground preflight and an authorized read-only group test, check the result, then verify clean stop/restart. Stop temporary processes when finished, and distinguish package installation from successful group acceptance.
19
19
  ```
package/docs/install.md CHANGED
@@ -1,4 +1,4 @@
1
- # Install SintBot 1.4.11
1
+ # Install SintBot 1.4.12
2
2
 
3
3
  English · [简体中文](./zh-CN/install.md)
4
4
 
@@ -10,7 +10,7 @@ You need a Mac, Node.js 22 or newer, and projects stored on that Mac. Install an
10
10
  curl -fsSL https://sintbot.com/install.sh | bash
11
11
  ```
12
12
 
13
- The installer checks the environment, installs `sintbot@1.4.11` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.11`. No sudo is needed.
13
+ The installer checks the environment, installs `sintbot@1.4.12` into `~/.local/share/sintbot`, and verifies the entry, built-in core and Feishu module. Continue when you see `Installed and verified SintBot 1.4.12`. No sudo is needed.
14
14
 
15
15
  To preview, append `-s -- --dry-run`. To choose another location, set `SINTBOT_INSTALL_DIR` and adjust the executable paths below.
16
16
 
@@ -105,7 +105,9 @@ Busy follow-ups default on for both Codex and Claude Code. Set `steer: false` to
105
105
 
106
106
  ## 6. Console
107
107
 
108
- With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group (a paused group shows Needs attention), stops tasks, starts a new conversation for an idle group, binds new groups, switches the model or project folder of an idle group, and adds or configures models. For LAN access, set a console password in its settings first, then enable LAN access.
108
+ The overview combines group projects and concurrency. Each row shows the runner, state and elapsed time, project folder, model and configured reasoning effort, and the latest reported context usage. Select a group to view its tasks, then expand a task for tools, reply and usage. Context is a session sample, not cumulative token consumption; unknown capacity and older samples are labeled. Row icons provide console messages, stop and new-session controls according to the current group state.
109
+
110
+ With `--ui`, open `http://127.0.0.1:4600` on this Mac. The console shows each group, stops tasks, starts a new conversation for an idle group, binds new groups, switches the model or project folder of an idle group, and adds or configures models. For LAN access, set a console password in its settings first, then enable LAN access.
109
111
 
110
112
  Settings → Software updates checks for new releases. In the foreground setup above it shows the upgrade steps: press Ctrl-C and wait for exit, rerun the one-line installer, then start again with the same command. If your own launchd agent starts SintBot, Upgrade now waits until every group is idle and settled, installs the new version into `~/.local/share/sintbot/releases/<version>` and switches the agent to it; if the new version does not connect every group within 90 seconds, the previous one is restored. A window shows each step, reconnects by itself while the service restarts and can be left running in the background. On the first start after an upgrade, each serving group gets one card listing what changed; to turn this off, clear "Announce upgrades in groups" under Settings → Software updates.
111
113
 
@@ -1,4 +1,4 @@
1
- # 用 AI 助手安装 SintBot 1.4.11
1
+ # 用 AI 助手安装 SintBot 1.4.12
2
2
 
3
3
  [English](../AI-ASSISTED-INSTALL.md) · 简体中文
4
4
 
@@ -15,5 +15,5 @@
15
15
  ## 可复制的请求
16
16
 
17
17
  ```text
18
- 按一行安装指南,在这台 Mac 安装 SintBot 1.4.11。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
18
+ 按一行安装指南,在这台 Mac 安装 SintBot 1.4.12。保留已有服务、配置、状态和 Codex 或 Claude Code 账号数据,不输出凭据。验证实际入口与二进制核心,配置飞书群及各群的执行器,只对全新状态初始化。运行前台预检和已授权的只读群测试,对照结果,再验证正常停止与重启。完成后停止临时进程,并区分程序包安装与群验收结果。
19
19
  ```
@@ -1,4 +1,4 @@
1
- # 安装 SintBot 1.4.11
1
+ # 安装 SintBot 1.4.12
2
2
 
3
3
  [English](../install.md) · 简体中文
4
4
 
@@ -10,7 +10,7 @@
10
10
  curl -fsSL https://sintbot.com/install.sh | bash
11
11
  ```
12
12
 
13
- 安装器会自动检查环境,把 `sintbot@1.4.11` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.11` 就可以继续。不需要 sudo。
13
+ 安装器会自动检查环境,把 `sintbot@1.4.12` 装到 `~/.local/share/sintbot`,并验证程序入口、内置核心和飞书模块。看到 `Installed and verified SintBot 1.4.12` 就可以继续。不需要 sudo。
14
14
 
15
15
  可选预览:在命令末尾加 `-s -- --dry-run`。要自定义安装位置,设置 `SINTBOT_INSTALL_DIR` 环境变量,并同步修改后面的可执行文件路径。
16
16
 
@@ -105,7 +105,9 @@ Codex 和 Claude Code 的忙时跟进默认开启;`steer:false` 改为排队
105
105
 
106
106
  ## 6. 控制台
107
107
 
108
- 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态(暂停的群显示「需要处理」)、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
108
+ 概览合并群项目与并发信息,每行显示执行器、状态与耗时、项目目录、模型和配置的思考深度、最近上报的上下文用量。选择群后查看对应任务,展开任务查看工具、回复和用量。上下文是当前会话采样,不是累计消耗;容量未知或历史采样会明确标注。群行图标按当前状态提供插话、停止与新会话操作。
109
+
110
+ 带 `--ui` 启动后,在本机浏览器打开 `http://127.0.0.1:4600`。控制台可以查看各群状态、停止任务、给空闲的群开新会话、绑定新群、给空闲的群切换模型或项目目录、添加和设置模型。要从局域网访问,先在控制台的设置里设访问密码,再开启局域网访问。
109
111
 
110
112
  「设置 → 软件更新」会检查新版本。按上面前台方式运行时,卡片给出升级步骤:按 Ctrl-C 等待退出,重新运行一行安装命令,再用原来的命令启动。如果用你自己的 launchd 后台任务启动 SintBot,「现在升级」会等所有群空闲、记录结清后,把新版本装到 `~/.local/share/sintbot/releases/<版本>` 并切换过去;新版本 90 秒内没连上全部群会自动换回旧版。升级时窗口逐步显示进度,服务重启时页面自动重连,也可以放到后台继续。升级后首次启动会在每个在服务的群发一张版本公告卡,列出这一版的更新;不想播报就在「设置 → 软件更新」取消「升级后向群里播报」。
111
113
 
package/install.sh CHANGED
@@ -2,7 +2,7 @@
2
2
  # The function is parsed in full before installation starts, including when piped.
3
3
  sintbot_install() (
4
4
  set -eu
5
- version=1.4.11
5
+ version=1.4.12
6
6
  prefix="${SINTBOT_INSTALL_DIR:-$HOME/.local/share/sintbot}"
7
7
  fail() { printf 'SintBot: %s\n' "$*" >&2; exit 1; }
8
8
  case "${1:-}" in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sintbot",
3
- "version": "1.4.11",
3
+ "version": "1.4.12",
4
4
  "private": false,
5
5
  "description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
6
6
  "bin": {
@@ -135,6 +135,8 @@
135
135
  "src/ui/console.html",
136
136
  "src/ui/dashboard.js",
137
137
  "src/ui/feishulogin.js",
138
+ "src/ui/liveactivity-client.js",
139
+ "src/ui/liveactivity.js",
138
140
  "src/ui/serverbase.js",
139
141
  "src/ui/sessions.js",
140
142
  "src/ui/takeover.js",
@@ -439,6 +439,10 @@ function runTurn({
439
439
  ev.contextTokens = (lastMsgUsage.input_tokens || 0) + (lastMsgUsage.cache_read_input_tokens || 0) + (lastMsgUsage.cache_creation_input_tokens || 0)
440
440
  }
441
441
  if (ev.type === 'result') {
442
+ const windows = Object.values(raw.modelUsage || {}).map(value => value?.contextWindow)
443
+ // No model-name guesses; a single reported capacity is unambiguous.
444
+ if (windows.length && windows.every(value => Number.isFinite(value) && value > 0) &&
445
+ new Set(windows).size === 1) ev.contextWindowTokens = windows[0]
442
446
  // 当前上下文规模 = 最后一次 API 请求的输入侧总量(未命中 + 缓存读 + 缓存写)。
443
447
  // result.usage 是整轮跨请求聚合、不能当上下文用;逐请求数在 assistant 事件的
444
448
  // message.usage 里(2.1.222 真机核对),兜底走 usage.iterations 末元素(同一形状)。
@@ -143,6 +143,9 @@ function runTurn(opts = {}) {
143
143
  totalUsage = usageFromTotal(usage.total)
144
144
  if (Number.isFinite(usage.last && usage.last.inputTokens) && usage.last.inputTokens > 0) contextTokens = usage.last.inputTokens
145
145
  if (Number.isFinite(usage.modelContextWindow) && usage.modelContextWindow > 0) contextWindowTokens = usage.modelContextWindow
146
+ // Observational only: do not add telemetry to the durable transcript.
147
+ if (!completed && contextTokens > 0) legacyEmit({ type: 'system', subtype: 'context',
148
+ sessionId: capturedSessionId, contextTokens, contextWindowTokens })
146
149
  }
147
150
  const write = (obj) => {
148
151
  if (child.stdin.destroyed) throw new Error('codex app-server stdin closed')
@@ -402,7 +405,8 @@ function runTurn(opts = {}) {
402
405
  const next = appDeltaNext(messageDeltas.get(params.itemId) || '', delta)
403
406
  if (next !== null) {
404
407
  messageDeltas.set(params.itemId, next)
405
- emit({ type: 'assistant', text: delta, contextTokens })
408
+ emit({ type: 'assistant', text: delta,
409
+ ...(contextTokens > 0 ? { contextTokens, contextWindowTokens, contextSampled: false } : {}) })
406
410
  }
407
411
  } else if (action === 4) {
408
412
  const item = params.item
package/src/c2console.js CHANGED
@@ -8,7 +8,7 @@ const { createDurableConsoleSay } = require('./consolesay')
8
8
 
9
9
  // A host facade over existing v2 authorities, not a second execution engine.
10
10
  // The allowlist applies after the existing console's authentication boundary.
11
- const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
11
+ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity', '/api/activity/detail',
12
12
  '/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
13
13
  '/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
14
14
  '/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
@@ -39,7 +39,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
39
39
  '/api/update/check', '/api/update/start', '/api/update/ignore']
40
40
  const SUPPORTED_RUNNERS = ['claude', 'codex']
41
41
 
42
- function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
42
+ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(), activity = null, store = null,
43
43
  assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
44
44
  appliedResets = () => [], now = Date.now,
45
45
  // Console injection reaches a group only through the service's own ingress
@@ -76,7 +76,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
76
76
  // flight keeps the current session.
77
77
  function groupBusy(chatId, binding) {
78
78
  const rt = binding.engine?.rts?.get(chatId)
79
- const snapshot = binding.journal?.snapshot()
79
+ const snapshot = binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot()
80
80
  return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
81
81
  rt?.v2QueueReceiptIds?.length || snapshot?.receipts?.pending?.length ||
82
82
  snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length ||
@@ -111,7 +111,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
111
111
  if (!binding || binding.runtime?.closed === true) return 'not-serving'
112
112
  const rt = binding.engine?.rts?.get(chatId)
113
113
  if (groupBusy(chatId, binding) || rt?.v2RunningJobs || rt?.v2StopControl || rt?.v2SkipWaiter ||
114
- binding.journal?.snapshot()?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
114
+ (binding.journal?.consoleSnapshot?.() || binding.journal?.snapshot())?.state?.lanes?.stopIntent?.[chatId]) return 'busy'
115
115
  return null
116
116
  }
117
117
  function receiptState(chatId, receiptId) {
@@ -133,11 +133,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
133
133
  const binding = bindings().get(chatId)
134
134
  const hold = heldGroups.get(chatId)
135
135
  const rt = binding?.engine?.rts.get(chatId)
136
- const snapshot = binding?.journal?.snapshot()
136
+ const snapshot = binding?.journal?.consoleSnapshot?.() || binding?.journal?.snapshot()
137
137
  const pending = snapshot?.receipts?.pending || []
138
138
  const queued = snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length || 0
139
139
  queue += queued
140
140
  groups[chatId] = {
141
+ context: activity?.context(chatId, (binding?.engine?.store || store)?.getSession(chatId),
142
+ !!rt?.v2NormalRouteActive, rt?.v2RunnerGeneration) || null,
141
143
  busy: !!rt?.v2NormalRouteActive,
142
144
  finalizing: !!rt?.v2TerminalStarted,
143
145
  followups: !!rt?.v2SteerControl && !rt?.v2SteerClosing && !rt?.v2TerminalStarted,
@@ -145,7 +147,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
145
147
  queueFaulted: !group.paused && (!binding || (!rt?.v2NormalRouteActive && !!binding.gate?.blocked)),
146
148
  waitingForCapacity: !!rt?.v2AdmissionPending,
147
149
  sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
148
- sessionId: binding?.engine?.store?.getSession(chatId) || '',
150
+ sessionId: (binding?.engine?.store || store)?.getSession(chatId) || '',
149
151
  // Why a console injection would be refused right now (null = accepted).
150
152
  sayBlocked: say ? sayGate(chatId) : 'not-serving',
151
153
  // A held group needs the operator: its last result is unknown.
@@ -163,29 +165,55 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
163
165
  function getActivity() {
164
166
  const cfg = getConfig(), items = []
165
167
  for (const [chatId, binding] of bindings()) {
166
- const snap = binding.journal.snapshot()
168
+ const snap = binding.journal.consoleSnapshot?.() || binding.journal.snapshot()
167
169
  const rt = binding.engine.rts.get(chatId)
168
170
  const group = cfg.groups[chatId]?.name || chatId.slice(-8)
169
171
  for (const receipt of snap.receipts.pending) {
172
+ if (activity?.has(chatId, receipt.id)) continue
170
173
  const running = rt?.v2RunningJobs?.some(job => job.msg?._c2ReceiptId === receipt.id)
171
174
  items.push({ id: receipt.id, chatId, group, ts: receipt.acceptedAt,
172
175
  kind: running ? 'start' : 'done', status: running ? 'running' : '等待处理',
173
176
  text: running ? '正在处理群消息' : '消息已登记,等待处理', sender: '' })
174
177
  }
175
178
  for (const receipt of snap.receipts.completed.slice(-30)) {
179
+ if (activity?.has(chatId, receipt.id)) continue
176
180
  // A settled receipt alone does not prove successful answer delivery.
177
181
  items.push({ id: receipt.id, chatId, group, ts: receipt.settledAt,
178
182
  kind: 'done', status: '已结账', text: '消息处理记录已结账', sender: '' })
179
183
  }
180
184
  }
181
- return items.sort((a, b) => b.ts - a.ts).slice(0, 30)
185
+ const recent = activity?.list({ limit: 100 }).activity || []
186
+ return [...recent, ...items].filter(item => cfg.groups[item.chatId])
187
+ .sort((a, b) => Number(a.kind !== 'start') - Number(b.kind !== 'start') || b.ts - a.ts).slice(0, 100)
188
+ }
189
+ function activityPage(query = {}) {
190
+ const cfg = getConfig(), live = getLiveState()
191
+ const meta = activity?.list({ limit: 1 }) || { epoch: 'receipts', revision: 0 }
192
+ const groups = Object.entries(cfg.groups || {}).map(([chatId, group]) => {
193
+ const binding = bindings().get(chatId), groupStore = binding?.engine?.store || store
194
+ const providerName = groupStore?.getProvider?.(chatId) || group.provider || cfg.defaultProvider
195
+ const provider = cfg.providers?.[providerName] || {}
196
+ const stats = groupStore?.getSessionStats?.(chatId), session = groupStore?.getSession?.(chatId)
197
+ const context = live.groups[chatId].context || (session && stats?.sessionId === session && Number.isFinite(stats.ctx)
198
+ ? { usedTokens: stats.ctx, windowTokens: null, measuredAt: null, quality: 'historical' } : null)
199
+ return { chatId, name: group.name || chatId.slice(-8), project: path.basename(group.workdir || '') || '—',
200
+ workdir: group.workdir || '', runner: provider.runner || 'claude',
201
+ model: group.model || provider.model || provider.env?.ANTHROPIC_MODEL || providerName,
202
+ reasoningEffort: (provider.runner === 'codex' ? provider.reasoningEffort : provider.env?.CLAUDE_CODE_EFFORT_LEVEL) || '',
203
+ paused: !!group.paused, ...live.groups[chatId], context }
204
+ })
205
+ const rows = getActivity().filter(row => (!query.chatId || row.chatId === query.chatId) && (!query.status || row.status === query.status))
206
+ const offset = Math.max(0, Math.min(100, Number(query.offset) || 0)), limit = Math.max(1, Math.min(100, Number(query.limit) || 30))
207
+ return { epoch: meta.epoch, revision: meta.revision, storageError: !!meta.storageError, groups,
208
+ total: rows.length, activity: rows.slice(offset, offset + limit) }
182
209
  }
183
210
  return {
184
211
  capabilities,
185
212
  allows: (method, pathname) => (capabilities.allowed[method] || []).includes(pathname),
186
213
  // A provider without a runner field is Claude Code, as everywhere else.
187
214
  providerSupported: provider => SUPPORTED_RUNNERS.includes(provider?.runner || 'claude'),
188
- getLiveState, getActivity,
215
+ getLiveState, getActivity, activityPage,
216
+ activityDetail(chatId, id) { return getConfig().groups?.[chatId] ? activity?.detail(chatId, id) || null : null },
189
217
  validateConfig(raw) { held(); validateConfig(raw) },
190
218
  beforeWrite(raw) { held(); beforeWrite(raw) },
191
219
  applyConfig(raw) { held(); applyConfig(raw) },
@@ -241,7 +269,18 @@ function createC2Console(options = {}) {
241
269
  inboxDismissPath: path.join(stateDir, 'inbox-dismissed.json'),
242
270
  verifyFile: path.join(stateDir, 'verify.json'),
243
271
  })
244
- return { ...ui, facade }
272
+ let previous = null
273
+ const unsubscribe = options.activity?.subscribe(() => {
274
+ const page = facade.activityPage({ limit: 100 })
275
+ const before = new Map((previous?.activity || []).map(row => [row.chatId + ':' + row.id, JSON.stringify(row)]))
276
+ const current = new Set(page.activity.map(row => row.chatId + ':' + row.id))
277
+ const changed = page.activity.filter(row => before.get(row.chatId + ':' + row.id) !== JSON.stringify(row))
278
+ const groups = page.groups.filter(group => JSON.stringify(previous?.groups.find(g => g.chatId === group.chatId)) !== JSON.stringify(group))
279
+ ui.pushEvent('activity-update', { epoch: page.epoch, revision: page.revision, storageError: page.storageError,
280
+ activity: changed, groups, removed: [...before.keys()].filter(id => !current.has(id)) })
281
+ previous = page
282
+ })
283
+ return { ...ui, facade, close() { unsubscribe?.(); ui.close() } }
245
284
  }
246
285
 
247
286
  module.exports = { createC2Console, createC2ConsoleFacade }
@@ -646,6 +646,16 @@ class C2DurableJournal {
646
646
  effects: new Map(this.#effects), notices: new Map(this.#notices) }
647
647
  }
648
648
 
649
+ // Bounded diagnostics: the console must not clone all effects and history
650
+ // at progress-event frequency. This is never accepted as recovery authority.
651
+ consoleSnapshot() {
652
+ return { cursor: this.#cursor, state: { lanes: {
653
+ queue: structuredClone(this.#state.lanes?.queue || {}),
654
+ stopIntent: structuredClone(this.#state.lanes?.stopIntent || {}) } },
655
+ receipts: { pending: structuredClone(this.#receipts.pending),
656
+ completed: structuredClone(this.#receipts.completed.slice(-30)) } }
657
+ }
658
+
649
659
  #settlementAuthority() {
650
660
  return { state: this.#state, receipts: this.#receipts, effects: this.#effects,
651
661
  attempts: this.#effectAttempts, descriptors: this.#effectDescriptors,
@@ -292,10 +292,17 @@ class Engine {
292
292
  durableV2SwitchProvider, durableV2UsageProbe, durableV2Issues, durableV2Version })
293
293
  this.projectMemory = options.projectMemory || null
294
294
  this.repoInfo = options.repoInfo || null
295
+ this.activity = options.activity || null
295
296
  this.tz = normalizeTz(cfg.timezone)
296
297
  this.rts = new Map()
297
298
  }
298
299
 
300
+ // Read-only diagnostics must never change admission, delivery or settlement.
301
+ _observeActivity(method, value) {
302
+ try { this.activity?.[method]?.(value) }
303
+ catch { this.log('Activity display update unavailable') }
304
+ }
305
+
299
306
  shutdown() {
300
307
  this.durableV2Runtime.close()
301
308
  let aborted = 0
@@ -741,6 +748,7 @@ async runDurableV2Message(msg, platform) {
741
748
  } catch (error) {
742
749
  if (controller.signal.aborted && !rt.v2DispatchEffect &&
743
750
  error?.code === 'C2_CAPACITY_WAIT_CANCELLED') {
751
+ this._observeActivity('unknown', { chatId: msg.chatId })
744
752
  return { receiptId, status: 'interruptedBeforeDispatch',
745
753
  queued: true, ingressClosed: true }
746
754
  }
@@ -763,6 +771,7 @@ async runDurableV2Message(msg, platform) {
763
771
  // siblings must not dispatch in the microtask before the service sees
764
772
  // this rejection and begins its process-wide stop.
765
773
  failed = true
774
+ this._observeActivity('unknown', { chatId: msg.chatId })
766
775
  this.durableV2SealCapacity?.(error)
767
776
  throw error
768
777
  } finally {
@@ -823,6 +832,7 @@ async _runDurableV2Steer(rt, receiptId) {
823
832
  invoke: () => control.steerDurable({ msgId: job.msg.msgId, text }) })
824
833
  entry.accepted = accepted
825
834
  if (accepted) {
835
+ try { this.activity?.merge(chatId, identity.parentReceiptId, [receiptId], entry.landed ? 'landed' : 'accepted') } catch {}
826
836
  if (entry.landed) {
827
837
  authority.commitSteerObservation({ ...identity, type: 'landed' })
828
838
  }
@@ -1100,6 +1110,7 @@ async _drainDurableV2NormalChain(initial) {
1100
1110
  }
1101
1111
  const started = await this.startDurableV2Runner(identity)
1102
1112
  if (started.startFailureRecovered === true) {
1113
+ this._observeActivity('finish', { ...identity, status: 'not-started', err: '任务未启动,已通知群内' })
1103
1114
  settled.push({ receiptId: identity.receiptId, status: 'startupNotified' })
1104
1115
  return { receiptId: firstReceiptId, status: settled[0].status,
1105
1116
  settledReceiptIds: settled.map(item => item.receiptId),
@@ -1170,6 +1181,8 @@ async _drainDurableV2NormalChain(initial) {
1170
1181
  }
1171
1182
  for (const settledReceiptId of cancelled.cancelledReceiptIds ||
1172
1183
  [identity.receiptId]) {
1184
+ this._observeActivity('finish', { chatId: identity.chatId, receiptId: settledReceiptId,
1185
+ status: control === 'recall' ? 'recalled' : 'stopped' })
1173
1186
  settled.push({ receiptId: settledReceiptId,
1174
1187
  status: control === 'recall' ? 'recalled' : 'stopped',
1175
1188
  terminalEffectId: null, cancellationEventId: cancelled.eventId })
@@ -1230,6 +1243,8 @@ async _drainDurableV2NormalChain(initial) {
1230
1243
  rt.v2TurnToken = null
1231
1244
  for (const settledReceiptId of released.settledReceiptIds ||
1232
1245
  [identity.receiptId]) {
1246
+ this._observeActivity('finish', { chatId: identity.chatId, receiptId: settledReceiptId,
1247
+ status: 'timeout', err: '任务超过运行时限,已停止' })
1233
1248
  settled.push({ receiptId: settledReceiptId,
1234
1249
  status: 'timeout', terminalEffectId: null,
1235
1250
  cancellationEventId: released.eventId })
@@ -1361,6 +1376,8 @@ stageDurableV2Message(msg, platform) {
1361
1376
  const rt = this._rt(msg.chatId)
1362
1377
  if (!rt.v2QueueReceiptIds) rt.v2QueueReceiptIds = []
1363
1378
  rt.v2QueueReceiptIds.push(receipt.id)
1379
+ this._observeActivity('receive', { chatId: msg.chatId, receiptId: receipt.id,
1380
+ text: msg.text, sender: msg.senderName || '', acceptedAt })
1364
1381
  return { receiptId: receipt.id, cursor: result.cursor, staged: true,
1365
1382
  ingressClosed: !!authority.blocked }
1366
1383
  }
@@ -1534,6 +1551,9 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1534
1551
  throw new Error('C2 v2 runner provider unavailable')
1535
1552
  }
1536
1553
  rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1554
+ this._observeActivity('start', { chatId, receiptId, receiptIds: cohort.receiptIds, generation,
1555
+ sessionId: this.store.getSession(chatId), model: rt.v2CardModelInfo.model,
1556
+ runner: provider.runner, project: projectName(job.group) })
1537
1557
  rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1538
1558
  rt.v2SteerControl = null
1539
1559
  rt.v2SteerEntries = new Map()
@@ -1579,6 +1599,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1579
1599
  if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1580
1600
  if (rt.v2RunnerGeneration !== generation) return
1581
1601
  authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1602
+ try { this.activity?.merge(chatId, receiptId, [entry.identity.receiptId], 'landed') } catch {}
1582
1603
  await rt.v2MoveCard?.(entry)
1583
1604
  })
1584
1605
  rt.v2SteerLandedChain = observation
@@ -1777,6 +1798,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1777
1798
  onDurableEvent: event => {
1778
1799
  projection.accept(event)
1779
1800
  enqueueFrame(event)
1801
+ this._observeActivity('observe', { chatId, receiptId, generation, event,
1802
+ doing: rt.v2Doing, milestones: event.text?.includes('>>') ? progress.milestonesOf(projection.streamSnapshot().bufferText) : undefined })
1803
+ },
1804
+ onEvent: event => {
1805
+ if (event.type === 'system' && event.subtype === 'context')
1806
+ this._observeActivity('observe', { chatId, receiptId, generation, event })
1780
1807
  },
1781
1808
  onTurnAccepted: value => {
1782
1809
  const ack = capability.onTurnAccepted(value)
@@ -2049,8 +2076,14 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
2049
2076
  ...progress.milestonesOf(payload.bufferText)]
2050
2077
  .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
2051
2078
  .filter(m => typeof m === 'string' && m.trim())
2079
+ this._observeActivity('sending', { chatId, receiptId })
2052
2080
  const completed = await path.complete({ payload, replyText, paused,
2053
2081
  footer, footerDetail, milestones })
2082
+ this._observeActivity('finish', { chatId, receiptId,
2083
+ status: quotaFailure || closedFailure ? 'error' : payload.quiet || projected.display.silent ? 'silent' : 'answered',
2084
+ reply: redact(quotaFailure ? T.quotaFailure : replyText || '', { pii: job.group?.redactPii !== false }),
2085
+ err: quotaFailure ? T.quotaFailure : closedFailure ? terminalReplyText(payload) : '',
2086
+ tokens: rt.v2UsageDecision?.tokens || null, sessionId: projected.display.sessionId })
2054
2087
  const after = authority.snapshot()
2055
2088
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
2056
2089
  let successor = null
@@ -2269,6 +2302,7 @@ requestDurableV2Stop(chatId) {
2269
2302
  payloadHandles: this.durableV2Payloads,
2270
2303
  assertWriterLock: this.durableV2AssertWriterLock })
2271
2304
  rt.v2QueueReceiptIds = rt.v2QueueReceiptIds.filter(id => id !== receiptId)
2305
+ this._observeActivity('finish', { chatId, receiptId, status: 'recalled' })
2272
2306
  return true
2273
2307
  } catch (error) { authority?.close(); this.log(error.message); return false }
2274
2308
  }
@@ -1008,7 +1008,6 @@ d.dataset.theme=t;if(l!=='en'&&l!=='zh')l=/^zh/i.test((navigator.language||''))?
1008
1008
  <button data-tab="playground" class="navplay" title="试跑 — 从控制台直接跑一轮" aria-label="试跑"><svg class="ic ic-play" aria-hidden="true"><use href="#lu-play"/></svg></button>
1009
1009
  </nav>
1010
1010
 
1011
- <div id="runtimeCapabilitiesNotice" class="card" hidden style="margin-bottom:16px"><div class="lbl">当前可用操作</div><div class="hint" data-runtime-summary></div></div>
1012
1011
 
1013
1012
  <section id="overview" class="tab on"><div class="empty" style="padding:48px;text-align:center"><span class="spin"></span> 加载中…</div></section>
1014
1013
  <section id="providers" class="tab"></section>
@@ -1027,6 +1026,7 @@ d.dataset.theme=t;if(l!=='en'&&l!=='zh')l=/^zh/i.test((navigator.language||''))?
1027
1026
  /* 令牌来源:本机环回免密时服务端注入;设了密码时注入空串,退而用登录(密码 / 飞书)签发的会话令牌 ——
1028
1027
  记在 localStorage(服务端落盘 30 天,重启不登出);过期或被吊销的令牌只会 401 → 重新登录。 */
1029
1028
  let TOKEN='__SBOT_TOKEN__'; // 本机环回免密时服务端注入;为空 = 要登录(⚠ 这一行的形状被服务端单测当锚点抠 token,别改)
1029
+ /*__LIVE_ACTIVITY__*/
1030
1030
  const INJECTED_TOKEN=TOKEN; // 记住「是不是注入的」:注入态不显示退出登录
1031
1031
  TOKEN=TOKEN||localStorage.getItem('sbot-token')||sessionStorage.getItem('sbot-token')||''; // 登录签发的令牌记在 localStorage(30 天)
1032
1032
  const FEISHU_LOGIN='__SBOT_FEISHU_LOGIN__'; // 服务端算好:公网地址 + 凭证 + 管理员名单三样齐才是 '1'
@@ -1149,6 +1149,7 @@ function startCardHtml(){
1149
1149
  </div>`;
1150
1150
  }
1151
1151
  function renderOverview(){
1152
+ if(typeof activityPanel!=='undefined')activityPanel.preserve();
1152
1153
  const h=S.health;
1153
1154
  const cap=Math.max((S.agent&&S.agent.maxConcurrent)||0,0); // 0 = 不限(无固定槽位 → 不画 gauge)
1154
1155
  const segs=cap>0?Array.from({length:cap},()=>'<div class="seg"></div>').join(''):'';
@@ -1164,13 +1165,13 @@ function renderOverview(){
1164
1165
  ${liveOn?`<button class="iconbtn sm" data-ostop="${esc(g.chatId)}" title="停止当前任务(保留会话)" style="flex:none">${IC.square}</button>
1165
1166
  <button class="iconbtn sm" data-onew="${esc(g.chatId)}" title="${S.runtimeCapabilities?'开新会话 — 仅在该群空闲、记录全部结账后可用;运行中请先停止,结束后再开(同群内 /new)':'开新会话 — 运行中会先中断当前任务并清空队列(同群内 /new)'}" style="flex:none">${IC.rotate}</button>`:''}</div>`).join('')
1166
1167
  :'<div class="empty">还没有群项目 · 去「群·项目」配置</div>';
1167
- $('#overview').innerHTML=`${startCardHtml()}<div id="ovInboxWrap">${inboxHtml()}</div><div class="grid cols2 stagger">
1168
+ $('#overview').innerHTML=`${startCardHtml()}<div id="ovInboxWrap">${inboxHtml()}</div><div class="grid ${S.runtimeCapabilities?'':'cols2'} stagger">
1168
1169
  <div class="card" id="ovHealth"><div class="lbl">健康</div>
1169
1170
  <span class="hpill ${healthClass(h)}" id="ovHealthPill"><span class="led"></span><span id="ovHealthTxt">${healthText(h)}</span></span>
1170
1171
  <div class="hrows" id="ovHrows">${h.rows.map(r=>`<div class="hrow" data-hk="${esc(r.label)}"><span class="dot ${r.status}"></span>
1171
1172
  <span class="k">${esc(r.label)}</span><span class="v" title="${esc(r.detail)}">${esc(r.detail)}</span></div>`).join('')}</div>
1172
1173
  </div>
1173
- <div class="grid" style="gap:16px">
1174
+ ${S.runtimeCapabilities?'':`<div class="grid" style="gap:16px">
1174
1175
  <div class="card">
1175
1176
  <div class="inline" style="justify-content:space-between;align-items:baseline">
1176
1177
  <span class="lbl" style="margin:0">并发</span>
@@ -1179,14 +1180,15 @@ function renderOverview(){
1179
1180
  ${cap>0?`<div class="gauge" id="ovGauge" style="margin-top:13px">${segs}</div>`:''}
1180
1181
  </div>
1181
1182
  <div class="card ovgroups"><div class="lbl">群 · 项目</div>${gRows}</div>
1182
- </div>
1183
+ </div>`}
1183
1184
  </div>
1184
- <h2 class="sec">实时活动</h2><div class="card" id="ovActivity">${activityHtml()}</div>
1185
+ ${S.runtimeCapabilities?'<div id="liveActivity"></div>':`<h2 class="sec">实时活动</h2><div class="card" id="ovActivity">${activityHtml()}</div>`}
1185
1186
  ${issuesHtml()}
1186
1187
  ${previewsHtml()}
1187
1188
  ${leftoversHtml()}`;
1188
1189
  lastActSig='';
1189
1190
  bindActivityClicks();
1191
+ if(S.runtimeCapabilities&&typeof activityPanel!=='undefined')activityPanel.mount();
1190
1192
  bindInbox();
1191
1193
  // 插话:入口挂在「群·项目」每一行的 ✎ 上 —— 动作长在对象身上,目标群由那一行决定,不必再选。
1192
1194
  $$('#overview [data-osay]').forEach(b=>b.onclick=()=>openSay(b.dataset.osay));
@@ -1374,6 +1376,7 @@ function bindActivityClicks(){
1374
1376
  let lastActSig='';
1375
1377
  function updateOverviewLive(){
1376
1378
  if(!S) return;
1379
+ if(S.runtimeCapabilities&&typeof activityPanel!=='undefined')activityPanel.live(S.live);
1377
1380
  if(sayRefresh&&$('#sayT')) sayRefresh();
1378
1381
  const live=S.live||{};
1379
1382
  const active=(live.concurrency&&live.concurrency.active)||0;
@@ -2610,7 +2613,6 @@ function cronFullHtml(text){
2610
2613
  }).join('');
2611
2614
  }
2612
2615
  const RUNTIME_UI_TEXT={
2613
- "summary": "当前支持多群消息、流式卡片回复、忙时跟进、停止任务、空闲群开新会话、空闲群的控制台插话(这句话和回复都进群)、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换、模型的添加与设置,以及后台运行时的软件升级。群忙时的插话、静默插话、解除绑定、移除模型、定时任务、生图、画布与预览暂不可用。",
2614
2616
  "groups": "可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。",
2615
2617
  "skills": "当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。",
2616
2618
  "cron": "当前不执行定时任务;已有定义仅供查看。",
@@ -4301,8 +4303,6 @@ updateThemeBtn();
4301
4303
  ⚠ 正则一律 ^…$ 整串、不加 g 标志;更具体的条目必须排在泛化条之前。 */
4302
4304
  const I18N_EN=[
4303
4305
  ["需要处理", "Needs attention"],
4304
- ["当前可用操作", "Available controls"],
4305
- ["当前支持多群消息、流式卡片回复、忙时跟进、停止任务、空闲群开新会话、空闲群的控制台插话(这句话和回复都进群)、状态查看、认证管理、绑定新群、空闲群的模型(Codex / Claude Code)和项目目录切换、模型的添加与设置,以及后台运行时的软件升级。群忙时的插话、静默插话、解除绑定、移除模型、定时任务、生图、画布与预览暂不可用。", "Available: group messages, streaming reply cards, busy-turn followups, stopping tasks, new sessions for idle groups, console messages to idle groups (the line and the reply both appear in the group), live status, authentication, binding new groups, switching the model (Codex / Claude Code) or project of an idle group, adding and configuring models, and software upgrades in a background run. Console messages to busy groups, silent console messages, unbinding, removing models, schedules, image generation, canvas and previews are unavailable."],
4306
4306
  ["可绑定新群,修改模型、项目目录、权限、数据输出和忙时跟进;该群须没有进行中的任务和未结记录。切模型或项目会开始新的一段对话。停止任务和开新会话在「概览」,开新会话须等该群空闲。群里发 /model 也能切换模型。", "Bind new groups and change the model, project folder, permissions, data display and busy-turn followups of a group with no running task or unsettled record. Changing the model or project starts a new conversation. Stop tasks and start new sessions in Overview; a new session needs the group to be idle. Members can also switch models with /model in the group."],
4307
4307
  ["当前支持飞书文字消息、忙时跟进、任务回复和停止。定时任务、生图、群消息管理、画布与预览暂未接入。", "Feishu text messages, busy-turn followups, task replies and stopping are supported. Schedules, image generation, group message management, canvas and previews are unavailable."],
4308
4308
  ["当前不执行定时任务;已有定义仅供查看。", "Schedules are not executed. Existing definitions are shown for reference only."],
@@ -5965,10 +5965,7 @@ function disableRuntimeControl(el,reason){
5965
5965
  }
5966
5966
  }
5967
5967
  function applyRuntimeCapabilities(){
5968
- const banner=document.getElementById('runtimeCapabilitiesNotice');
5969
- if(banner)banner.hidden=!(S&&S.runtimeCapabilities);
5970
5968
  if(!S||!S.runtimeCapabilities)return;
5971
- if(banner){const body=banner.querySelector('[data-runtime-summary]'),text=trText(RUNTIME_UI_TEXT.summary);if(body&&body.textContent!==text)body.textContent=text;}
5972
5969
  document.querySelectorAll(RUNTIME_DISABLED_SELECTORS).forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
5973
5970
  // 插话照服务端白名单放行(第 1 版只接空闲群,忙不忙在浮层里按实时状态说)
5974
5971
  if(!runtimeAllows('POST','/api/groups/say'))document.querySelectorAll('[data-osay]').forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
@@ -6002,6 +5999,7 @@ function applyTurnEvent(a){
6002
5999
  // reconnect drops, and rows the self-heal resolved to a bare '✓' without their real done
6003
6000
  // data. Idempotent (id-keyed merge). Called on load + on every SSE (re)connect.
6004
6001
  async function syncActivity(repaint){
6002
+ if(S?.runtimeCapabilities&&typeof activityPanel!=='undefined')return activityPanel.sync();
6005
6003
  try{const d=await api('/api/activity'); if(d&&d.activity){d.activity.forEach(applyTurnEvent); if(repaint&&$('#overview').innerHTML)updateOverviewLive();}}catch(e){}
6006
6004
  }
6007
6005
 
@@ -6012,10 +6010,11 @@ function startLive(){
6012
6010
  if(esRef){try{esRef.close();}catch{}}
6013
6011
  helloN=0;
6014
6012
  const es=new EventSource('/api/events?token='+encodeURIComponent(TOKEN));esRef=es;
6013
+ es.addEventListener('activity-update',ev=>{try{activityPanel.receive(JSON.parse(ev.data));}catch{}});
6015
6014
  // hello fires on first connect AND every native reconnect → replay the buffer to
6016
6015
  // recover whatever SSE dropped while we were away (SSE has no replay of its own);
6017
6016
  // 重连(非首连)还拉一次全量 state —— 断流期间的 live/conn 变化就地补齐,不整页重渲染。
6018
- es.addEventListener('hello',()=>{$('#conn').classList.add('live');syncActivity(true);
6017
+ es.addEventListener('hello',()=>{$('#conn').classList.add('live');if(typeof activityPanel!=='undefined')activityPanel.connected(true);syncActivity(true);
6019
6018
  if(++helloN>1)api('/api/state').then(r=>{S=r;updateDaemon();updateTopUpdateBadge();updateOverviewLive();updateConnLive();startClock();}).catch(()=>{});});
6020
6019
  es.addEventListener('tick',ev=>{try{const d=JSON.parse(ev.data);if(S){if(d.live)S.live=d.live;if('conn' in d)S.conn=d.conn;}if(d.activity)d.activity.forEach(applyTurnEvent);updateDaemon();updateOverviewLive();updateConnLive();}catch{}});
6021
6020
  startAutoRefresh();
@@ -6023,7 +6022,7 @@ function startLive(){
6023
6022
  es.addEventListener('turn',ev=>{try{const d=JSON.parse(ev.data);applyTurnEvent(d);updateOverviewLive();
6024
6023
  // 收件箱跟着轮次走:刚失败的要尽快出现,重说成功的要尽快消失 —— 不等 60 秒的例行刷新;只取这一块,不拉整份 state
6025
6024
  if(d.kind==='done')refreshInbox();}catch(e){}});
6026
- es.onerror=()=>{$('#conn').classList.remove('live');checkStaleAuth();};
6025
+ es.onerror=()=>{$('#conn').classList.remove('live');if(typeof activityPanel!=='undefined')activityPanel.connected(false);checkStaleAuth();};
6027
6026
  }catch(e){}
6028
6027
  }
6029
6028
  /* SSE 断流自愈:daemon 重启会作废内存里的 token(页面 token / 登录 session 都是),
@@ -0,0 +1,276 @@
1
+ /* Injected into the console's existing script; no framework or extra connection. */
2
+ const activityPanel = (() => {
3
+ let root, groupBody, listBody, taskBody, selected = '', count = 5
4
+ try { selected = sessionStorage.getItem('sintbot.activity.group') || '' } catch {}
5
+ let epoch = '', revision = -1, loaded = false, pending = null, frames = [], scheduled = false
6
+ const rows = new Map(), groups = new Map(), detailCache = new Map(), detailPending = new Set(), detailErrors = new Map(), open = new Set()
7
+ const groupNodes = new Map(), rowNodes = new Map(), actionPending = new Set()
8
+ let lang = '', error = false, connected = true, focusBeforeRender = null, groupScroll = 0
9
+ const key = r => r.chatId + ':' + r.id
10
+ const en = () => langOf() === 'en'
11
+ const tr = (zh, english) => en() ? english : zh
12
+ const labels = {
13
+ queued: ['排队', 'Queued'], running: ['运行中', 'Running'], sending: ['回复发送中', 'Sending reply'],
14
+ answered: ['已回复', 'Replied'], silent: ['无需回复', 'No reply needed'], stopped: ['已停止', 'Stopped'],
15
+ recalled: ['已撤回', 'Recalled'], timeout: ['已超时', 'Timed out'], error: ['失败', 'Failed'],
16
+ 'not-started': ['未启动', 'Not started'], unknown: ['结果待核实', 'Unconfirmed'],
17
+ '已结账': ['历史记录已结账', 'Historical receipt settled'], '等待处理': ['等待处理', 'Waiting'],
18
+ }
19
+ const status = s => labels[s] ? tr(...labels[s]) : s
20
+ const active = r => ['queued', 'running', 'sending', '等待处理'].includes(r.status)
21
+ const num = n => n == null ? '—' : n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, '') + 'k' : String(n)
22
+ const stamp = ts => ts ? new Date(ts).toLocaleTimeString(en() ? 'en-GB' : 'zh-CN', { hour: '2-digit', minute: '2-digit', timeZone: S?.timezone || 'Asia/Shanghai' }) : '—'
23
+ const node = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n }
24
+ const text = (n, value) => { if (n.textContent !== value) n.textContent = value }
25
+ function schedule() {
26
+ if (scheduled || document.hidden) return
27
+ scheduled = true; requestAnimationFrame(() => { scheduled = false; paint() })
28
+ }
29
+ function setup() {
30
+ if (root) return
31
+ root = node('section', 'la'); root.setAttribute('data-i18n-skip', '')
32
+ const style = node('style'); style.textContent = `
33
+ .la{margin:20px 0}.la [hidden]{display:none!important}.la .la-panel{padding:0;overflow:hidden}
34
+ .la .la-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px 24px;border-bottom:1px solid var(--line)}
35
+ .la h2{font-size:15px;font-weight:600;letter-spacing:0;margin:0}.la .la-capacity{font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums;text-align:right}
36
+ .la .la-select,.la .la-toggle{font:inherit;width:100%;border:0;border-radius:0;background:transparent;color:inherit;text-align:left;cursor:pointer;min-height:48px}
37
+ .la .la-group{position:relative;display:flex;align-items:center;border-bottom:1px solid var(--line);transition:background .18s ease}.la .la-select{flex:1;min-width:0;display:grid;grid-template-columns:minmax(160px,1.2fr) minmax(120px,1fr) minmax(160px,1fr);gap:20px;padding:15px 20px 15px 24px}.la .la-select>div{min-width:0}
38
+ .la .la-group:last-child{border-bottom:0}.la .la-group:before{content:'';position:absolute;inset:12px auto 12px 0;width:3px;border-radius:0 3px 3px 0;background:var(--ember);opacity:0;transform:scaleY(.4);transition:opacity .18s,transform .18s}
39
+ .la .la-select strong,.la .la-select small,.la .la-model>span,.la .la-context>span{display:block;white-space:normal;overflow-wrap:anywhere}.la .la-tags{display:flex;flex-wrap:wrap;gap:5px;margin-top:7px}.la .la-tags .tag{font-size:10px;padding:2px 6px}.la .la-tags [data-status=running]{color:var(--ember);border-color:color-mix(in srgb,var(--ember) 30%,var(--line))}.la .la-tags [data-status=attention]{color:var(--fail)}.la .la-path{font-family:var(--mono);font-size:10.5px}
40
+ .la .la-group:hover,.la .la-toggle:hover{background:color-mix(in srgb,var(--ember) 4%,transparent)}
41
+ .la .la-group.selected{background:color-mix(in srgb,var(--ember) 7%,transparent)}.la .la-group.selected:before{opacity:1;transform:scaleY(1)}
42
+ .la button:focus-visible{outline:2px solid var(--ember);outline-offset:-3px}.la button:active:not(:disabled){background:color-mix(in srgb,var(--ember) 11%,transparent)}
43
+ .la small{display:block;color:var(--dim);font-size:11px;line-height:1.5;margin-top:4px}.la strong{font-size:13px;font-weight:600}.la .la-model{font-size:12px}.la .la-model small[data-status=running]{color:var(--ember)}.la .la-model small[data-status=attention]{color:var(--fail)}.la .la-context{font-size:12px;font-variant-numeric:tabular-nums}
44
+ .la .la-track{height:3px;margin-top:8px;background:var(--line);overflow:hidden;border-radius:2px}.la .la-fill{height:100%;background:var(--ember);transform-origin:left;transition:transform .3s ease-out;transform:scaleX(0)}
45
+ .la .la-groups{max-height:560px;overflow:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
46
+ .la .la-task-head{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:22px 0 12px}.la .la-task-head strong{font-size:14px;overflow-wrap:anywhere}
47
+ .la .la-actions{display:flex;gap:6px;flex-shrink:0;padding-right:24px}.la .la-actions .iconbtn{padding:0;transition:background .15s,border-color .15s,transform .15s}.la .la-actions .iconbtn:active:not(:disabled){transform:scale(.92)}.la button:disabled{cursor:default;opacity:.45}
48
+ .la .la-list{padding:0;overflow:hidden}.la .la-row{border-bottom:1px solid var(--line)}.la .la-row:last-child{border-bottom:0}
49
+ .la .la-toggle{padding:16px 20px;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:16px;transition:background .15s}
50
+ .la .la-title{font-size:13px;line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-top:5px;overflow-wrap:anywhere}
51
+ .la .la-preview{font-size:12px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-top:5px}
52
+ .la .la-state{font-size:11px;color:var(--dim);white-space:nowrap;padding-top:4px}.la .la-state:after{content:'';display:inline-block;width:5px;height:5px;border-right:1px solid currentColor;border-bottom:1px solid currentColor;transform:rotate(45deg);margin:0 2px 3px 14px;transition:transform .18s}
53
+ .la [aria-expanded=true] .la-state:after{transform:rotate(225deg)}.la .la-state[data-state=running],.la .la-state[data-state=sending]{color:var(--ember)}
54
+ .la .la-state[data-state=error],.la .la-state[data-state=unknown],.la .la-state[data-state=timeout]{color:var(--fail)}
55
+ .la .la-fold{display:grid;grid-template-rows:0fr;opacity:0;transition:grid-template-rows .2s ease,opacity .2s ease}.la .la-fold.open{grid-template-rows:1fr;opacity:1}
56
+ .la .la-fold-inner{min-height:0;overflow:hidden}.la .la-detail{padding:0 20px 18px;font-size:13px;line-height:1.7;overflow-wrap:anywhere}
57
+ .la .la-detail .aexp{margin:0;max-height:360px;white-space:normal}.la .la-detail .aereply{white-space:pre-wrap}.la .la-detail .aemeta{gap:6px 16px;white-space:normal}.la .la-detail .la-source{margin-top:10px;border-top:1px solid var(--line);padding-top:8px}.la .la-source summary{cursor:pointer;color:var(--dim);font-size:11px}.la .la-source summary:focus-visible{outline:2px solid var(--ember);outline-offset:3px}.la .la-detail .la-error{color:var(--fail)}.la .la-detail p{white-space:pre-wrap;margin:8px 0}.la .la-detail h3{font-size:11px;color:var(--dim);margin:16px 0 5px;font-weight:500}.la .la-notice{font-size:12px;color:var(--dim);margin:12px 0;line-height:1.6}
58
+ .la .la-more{margin:12px auto 0;display:block;min-height:40px;font-size:12px}
59
+ @media(max-width:640px){.la .la-head{padding:16px;gap:10px}.la .la-capacity{font-size:11px}.la .la-group{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start}.la .la-select{grid-column:1/-1;grid-row:1;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:10px 16px;padding:14px 16px}.la .la-select>div:first-child{padding-right:94px;grid-column:1/-1}.la .la-actions{grid-column:2;grid-row:1;z-index:1;padding:14px 16px 0 0}.la .la-model{grid-column:1/-1}.la .la-context{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:0 8px}.la .la-context small{margin:0;font-size:10px}.la .la-track{grid-column:1/-1;margin-top:6px}.la .la-task-head{align-items:flex-start;flex-direction:column;gap:10px}.la .la-actions{flex-wrap:wrap}.la .la-toggle{grid-template-columns:minmax(0,1fr);gap:7px;padding:14px 16px}.la .la-state{white-space:normal;padding:0}.la .la-state:after{float:right}.la .la-detail{padding:0 16px 16px}}
60
+ @media(prefers-reduced-motion:reduce){.la *{transition:none!important;animation:none!important}}`
61
+ root.append(style)
62
+ const panel = node('div', 'card la-panel'), head = node('div', 'la-head')
63
+ head.append(node('h2'), node('span', 'la-capacity')); panel.append(head)
64
+ groupBody = node('div', 'la-groups'); panel.append(groupBody); root.append(panel)
65
+ const note = node('div', 'la-notice'); note.dataset.la = 'note'; note.setAttribute('role', 'status'); root.append(note)
66
+ taskBody = node('div', 'la-task-body'); taskBody.hidden = true
67
+ const bar = node('div', 'la-task-head'), title = node('strong'); title.dataset.la = 'activity'; bar.append(title)
68
+ taskBody.append(bar)
69
+ listBody = node('div', 'card la-list'); taskBody.append(listBody)
70
+ const more = node('button', 'btn la-more'); more.dataset.la = 'more'; more.onclick = () => { count += 5; schedule() }; taskBody.append(more)
71
+ root.append(taskBody)
72
+ }
73
+ async function act(action, button, chatId) {
74
+ if (!chatId || button.disabled) return
75
+ if (action === 'say') { openSay(chatId); return }
76
+ const actionKey = chatId + ':' + action
77
+ actionPending.add(actionKey); button.disabled = true
78
+ try {
79
+ const result = await api(action === 'stop' ? '/api/groups/stop' : '/api/groups/interrupt','POST',{chatId})
80
+ toast(action === 'new' ? tr('已开新会话','New session started') : result.ok ? tr('已请求停止,等待结束确认','Stop requested; waiting for confirmation') : tr('当前没有可停止的运行任务','No running task to stop'),'ok')
81
+ await sync()
82
+ } catch (e) { toast(e.message,'err') }
83
+ finally { actionPending.delete(actionKey); schedule() }
84
+ }
85
+
86
+ function groupNode(g) {
87
+ let n = groupNodes.get(g.chatId)
88
+ if (!n) {
89
+ n = node('div', 'la-group'); n.dataset.chatId = g.chatId
90
+ const select = node('button', 'la-select'); select.type = 'button'
91
+ const a = node('div'), b = node('div', 'la-model'), c = node('div', 'la-context')
92
+ const tags = node('div','la-tags'); tags.append(node('span','tag prov'),node('span','tag'))
93
+ a.append(node('strong'), tags, node('small','la-path')); b.append(node('span'), node('small'))
94
+ const track = node('div', 'la-track'); track.append(node('div', 'la-fill'))
95
+ c.append(node('span'), node('small'), track); select.append(a, b, c); n.append(select)
96
+ const actions = node('div','la-actions')
97
+ for (const [action,icon] of [['say','say'],['stop','square'],['new','rotate']]) {
98
+ const button = node('button','iconbtn sm'); button.type = 'button'; button.dataset.action = action
99
+ button.innerHTML = IC[icon]; button.onclick = () => void act(action,button,g.chatId); actions.append(button)
100
+ }
101
+ n.append(actions)
102
+ select.onclick = () => {
103
+ if (selected === g.chatId) return
104
+ selected = g.chatId; count = 5
105
+ try { sessionStorage.setItem('sintbot.activity.group', selected) } catch {}
106
+ schedule(); requestAnimationFrame(() => {
107
+ if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
108
+ taskBody.getAnimations().forEach(animation => animation.cancel())
109
+ taskBody.animate([{opacity:0,transform:'translateY(4px)'},{opacity:1,transform:'translateY(0)'}],{duration:180,easing:'ease-out'})
110
+ }
111
+ })
112
+ }
113
+ groupNodes.set(g.chatId, n)
114
+ }
115
+ const [a,b,c] = n.children[0].children, ctx = g.context
116
+ text(a.children[0], g.name); text(a.children[2], g.workdir ? shortPath(g.workdir) : g.project)
117
+ a.children[2].title = g.workdir || g.project
118
+ text(a.children[1].children[0], g.runner === 'codex' ? 'codex' : 'claude')
119
+ const state = a.children[1].children[1]
120
+ text(state, g.held || g.queueFaulted ? tr('需要处理','Needs attention') : g.paused ? tr('已暂停','Paused') : g.finalizing ? tr('收尾中','Finishing') : g.waitingForCapacity ? tr('等待容量','Waiting for capacity') : g.busy ? 'busy' : 'idle')
121
+ state.dataset.status = g.held || g.queueFaulted ? 'attention' : g.busy ? 'running' : 'idle'
122
+ if (g.busy) text(state, state.textContent + ' ' + Math.floor((g.sinceMs || 0)/1000) + 's')
123
+ if (g.queue) text(state, state.textContent + ' · ' + tr('排队 ','Queued ') + g.queue)
124
+ text(b.children[0], g.model)
125
+ text(b.children[1], tr('思考深度 · ','Reasoning · ') + (g.reasoningEffort || tr('默认','Default')))
126
+ b.children[1].title = tr('当前模型配置;未指定时使用模型默认值','Current model configuration; unspecified uses the model default')
127
+ const blocked = !S?.live?.running || g.paused || g.held || g.queueFaulted
128
+ const busy = g.busy || g.pending > 0 || g.queue > 0 || g.waitingForCapacity || g.finalizing
129
+ for (const [action,zh,eng] of [['say','插话','Message'],['stop','停止任务','Stop task'],['new','新会话','New session']]) {
130
+ const button = n.querySelector('[data-action='+action+']')
131
+ button.disabled = actionPending.has(g.chatId + ':' + action) || blocked || (action === 'stop' ? !busy : !!busy || action === 'say' && !!g.sayBlocked)
132
+ button.setAttribute('aria-label',tr(zh,eng) + ' · ' + g.name)
133
+ button.title = tr(zh,eng) + ' · ' + (button.disabled ? tr('当前状态不可操作','Unavailable in the current state') : action === 'say' ? tr('这句话和回复都会发进该群','Your message and reply will be sent to this group') : action === 'new' ? tr('开始新会话,清除当前会话上下文','Start a new session with fresh context') : tr('停止当前任务,保留会话','Stop the task and keep the session'))
134
+ }
135
+ const valid = ctx?.usedTokens != null, capacity = ctx?.windowTokens > 0
136
+ const percent = valid && capacity ? Math.round(ctx.usedTokens / ctx.windowTokens * 100) : null
137
+ text(c.children[0], valid ? `${num(ctx.usedTokens)}${capacity ? ' / ' + num(ctx.windowTokens) + ' · ' + percent + '%' : ' · ' + tr('容量未知','capacity unknown')}` : ctx?.quality === 'compacted' ? tr('压缩后待上报','Awaiting usage after compaction') : !g.sessionId && !g.busy ? tr('未开始','No session') : tr('待上报','Awaiting usage'))
138
+ const quality = ctx?.quality === 'reported' ? tr('上下文 · 最新上报','Context · Latest report') : ctx?.quality === 'historical' ? tr('上下文 · 历史采样','Context · Historical sample') : tr('上下文 · 上次上报','Context · Last report')
139
+ text(c.children[1], ctx ? quality + ' · ' + stamp(ctx.measuredAt) : tr('上下文','Context'))
140
+ c.children[2].children[0].style.transform = 'scaleX(' + (percent == null ? 0 : Math.max(0, Math.min(100, percent)) / 100) + ')'
141
+ n.title = g.name + ' · ' + g.project + ' · ' + g.model + ' · ' + g.chatId.slice(-6)
142
+ n.classList.toggle('selected',selected === g.chatId); n.children[0].setAttribute('aria-pressed', String(selected === g.chatId)); return n
143
+ }
144
+ async function showDetail(r, n) {
145
+ const k = key(r), body = n.querySelector('.la-detail')
146
+ if (!open.has(k)) return
147
+ if (!r.details) { text(body, tr('历史记录未保存消息和回复详情。','Message and reply details were not recorded.')); return }
148
+ if (detailErrors.get(k) === r.detailRevision) return
149
+ let d = detailCache.get(k)
150
+ if (!d || d.detailRevision !== r.detailRevision) {
151
+ if (detailPending.has(k)) return
152
+ detailPending.add(k)
153
+ text(body, tr('加载详情…','Loading details…'))
154
+ try {
155
+ const value = await api('/api/activity/detail?chatId=' + encodeURIComponent(r.chatId) + '&id=' + encodeURIComponent(r.id))
156
+ if (epoch !== value.epoch) return
157
+ d = { ...value, lang: langOf() }; detailCache.set(k, d)
158
+ while (detailCache.size > 40) detailCache.delete(detailCache.keys().next().value)
159
+ } catch { detailErrors.set(k,r.detailRevision);text(body, tr('详情暂不可用,收起后可重新打开。','Details unavailable. Close and reopen to retry.')); return }
160
+ finally { detailPending.delete(k) }
161
+ }
162
+ if (!open.has(k) || !n.isConnected) return
163
+ const sig = d.detailRevision + ':' + langOf()
164
+ if (body.dataset.rev === sig) return
165
+ body.dataset.rev = sig; body.replaceChildren()
166
+ const box = node('div','aexp'); body.append(box)
167
+ if (d.tools?.length) {
168
+ const tools = node('div','aetools')
169
+ for (const tool of d.tools) {
170
+ const chip = node('span','tchip'); chip.innerHTML = IC.wrench
171
+ chip.append(document.createTextNode(' ' + tool.name + (tool.count > 1 ? ' ×' + tool.count : ''))); tools.append(chip)
172
+ }
173
+ box.append(tools)
174
+ }
175
+ box.append(node('div',d.reply ? 'aereply' : 'faint',d.reply || (active(d) ? tr('任务进行中,等待回复…','Task in progress; awaiting reply…') : tr('(无回复内容)','(No reply content)'))))
176
+ if (d.err) box.append(node('p','la-error',d.err))
177
+ const meta = node('div','aemeta')
178
+ function metric(label,value) { const item = node('span'); item.append(node('b','',label+' '),document.createTextNode(value)); meta.append(item) }
179
+ if (d.sender) metric(tr('发送者','Sender'),d.sender)
180
+ metric(tr('状态','Status'),status(d.status))
181
+ if (d.startedAt) metric(tr('排队','Queued'),(Math.max(0,d.startedAt-d.ts)/1000).toFixed(1)+'s')
182
+ if (d.ms != null) metric(tr('耗时','Duration'),(d.ms/1000).toFixed(1)+'s')
183
+ if (d.tokens) {
184
+ const cr=d.tokens.cr||0,cw=d.tokens.cw||0
185
+ metric('tokens',`↑${num((d.tokens.in||0)+cr+cw)} ↓${num(d.tokens.out||0)}` + (cr||cw ? ' · '+tr('缓存读 ','Cache read ')+num(cr)+tr(' / 写 ',' / write ')+num(cw) : ''))
186
+ }
187
+ box.append(meta)
188
+ if (d.messages?.length || d.milestones?.length) {
189
+ const source = node('details','la-source'); source.append(node('summary','',tr('请求、补充与阶段进度','Request, follow-ups and progress')))
190
+ for (const message of d.messages || []) source.append(node('p','',(message.sender || '—')+' · '+(message.state==='landed'?tr('已读到','Read'):tr('已受理','Accepted'))+'\n'+message.text))
191
+ for (const milestone of d.milestones || []) source.append(node('p','',milestone))
192
+ box.append(source)
193
+ }
194
+ if (d.truncated) box.append(node('p','faint',tr('内容较长,记录已截断。','Long content was truncated in this record.')))
195
+
196
+ }
197
+ function rowNode(r) {
198
+ const k = key(r); let n = rowNodes.get(k)
199
+ if (!n) {
200
+ n = node('article', 'la-row'); const button = node('button','la-toggle'); button.type = 'button'
201
+ const lead = node('div'); lead.append(node('small'),node('div','la-title'),node('div','la-preview'))
202
+ button.append(lead,node('span','la-state')); n.append(button)
203
+ const fold = node('div','la-fold'), inner = node('div','la-fold-inner'), detail = node('div','la-detail')
204
+ fold.id = 'la-detail-' + rowNodes.size + '-' + Math.random().toString(36).slice(2,8)
205
+ inner.append(detail); fold.append(inner); n.append(fold)
206
+ button.setAttribute('aria-controls', fold.id)
207
+ button.onclick = () => { open.has(k) ? open.delete(k) : open.add(k);detailErrors.delete(k);schedule() }
208
+ rowNodes.set(k,n)
209
+ }
210
+ const b = n.children[0], lead = b.children[0], group = groups.get(r.chatId)
211
+ text(lead.children[0], stamp(r.ts) + ' · ' + (r.sender || '—'))
212
+ text(lead.children[1], r.text || tr('内容未记录','Content not recorded'))
213
+ text(lead.children[2], active(r) ? (r.doing || []).join(' · ') : r.preview || '')
214
+ text(b.children[1], status(r.status) + (r.messageCount > 1 ? ' · ' + tr('消息 ','messages ') + r.messageCount : '') + (r.ms ? ' · ' + (r.ms/1000).toFixed(1)+'s' : ''))
215
+ b.children[1].dataset.state = r.status; b.setAttribute('aria-expanded', String(open.has(k)))
216
+ n.children[1].classList.toggle('open', open.has(k)); n.children[1].inert = !open.has(k)
217
+ if (open.has(k)) void showDetail(r,n)
218
+ return n
219
+ }
220
+ function reconcile(container, nodes) {
221
+ const wanted = new Set(nodes)
222
+ for (const child of [...container.children]) if (!wanted.has(child)) child.remove()
223
+ nodes.forEach((n,i) => { if (container.children[i] !== n) container.insertBefore(n,container.children[i] || null) })
224
+ }
225
+ function paint() {
226
+ if (!root?.isConnected) return
227
+ lang = langOf()
228
+ text(root.querySelector('h2'),tr('群 · 项目','Groups · Projects'))
229
+ const concurrency = S?.live?.concurrency, capacity = concurrency?.max ?? S?.agent?.maxConcurrent ?? 0
230
+ text(root.querySelector('.la-capacity'), tr('运行 ','Running ') + (concurrency?.active ?? [...groups.values()].filter(g=>g.busy).length) + (capacity > 0 ? ' / ' + capacity : '') + ' · ' + tr('排队 ','Queued ') + (S?.live?.queue || 0))
231
+ if (loaded && selected && !groups.has(selected)) { selected = ''; try { sessionStorage.removeItem('sintbot.activity.group') } catch {} }
232
+ reconcile(groupBody, groups.size ? [...groups.values()].map(groupNode) : [node('div','empty',loaded ? tr('暂无群项目','No projects') : tr('正在同步…','Syncing…'))])
233
+ const group = groups.get(selected)
234
+ taskBody.hidden = !group
235
+ text(root.querySelector('[data-la=activity]'), (group?.name || '') + ' · ' + tr('任务活动','Task activity'))
236
+ const all = [...rows.values()].filter(r => selected && r.chatId===selected)
237
+ .sort((a,b) => Number(!active(a))-Number(!active(b)) || b.ts-a.ts)
238
+ const shown = all.filter(active).concat(all.filter(r=>!active(r)).slice(0,count))
239
+ reconcile(listBody, shown.length ? shown.map(rowNode) : [node('div','empty',loaded ? tr('该群暂无任务活动','No task activity for this group') : tr('正在同步…','Syncing…'))])
240
+ const more = root.querySelector('[data-la=more]'); more.hidden=shown.length>=all.length;text(more,tr('查看更早的任务','Show earlier tasks'))
241
+ const note = root.querySelector('[data-la=note]')
242
+ text(note, !connected ? tr('连接中断,当前为最后收到的记录','Disconnected — showing last received records') : error ? tr('部分活动记录暂不可用','Some activity records are unavailable') : !selected ? tr('选择一个群项目,查看任务活动','Select a project to view its tasks') : '')
243
+ note.hidden = !note.textContent
244
+ for (const id of groupNodes.keys()) if (!groups.has(id)) groupNodes.delete(id)
245
+ for (const k of rowNodes.keys()) if (!rows.has(k)) { rowNodes.delete(k); open.delete(k); detailCache.delete(k);detailErrors.delete(k) }
246
+ }
247
+ function apply(data, full) {
248
+ if (!data?.epoch) return
249
+ if (loaded && data.epoch===epoch && data.revision<revision) return
250
+ if (full || data.epoch!==epoch) { rows.clear(); groups.clear() }
251
+ if (data.epoch!==epoch) { detailCache.clear(); open.clear(); revision=-1 }
252
+ epoch=data.epoch;revision=data.revision;error=!!data.storageError
253
+ for (const r of data.activity || []) rows.set(key(r),r)
254
+ for (const g of data.groups || []) groups.set(g.chatId,g)
255
+ for (const k of data.removed || []) rows.delete(k)
256
+ loaded=true;schedule()
257
+ }
258
+ function sync() {
259
+ if (pending) return pending
260
+ frames=[]
261
+ pending=api('/api/activity?limit=100').then(data=>{
262
+ apply(data,true); for(const frame of frames)apply(frame,false)
263
+ }).catch(()=>{error=true;schedule()}).finally(()=>{pending=null;frames=[]})
264
+ return pending
265
+ }
266
+ document.addEventListener('visibilitychange',()=>{if(!document.hidden){schedule();void sync()}})
267
+ new MutationObserver(schedule).observe(document.documentElement,{attributes:true,attributeFilter:['data-lang']})
268
+ return {
269
+ preserve(){focusBeforeRender=root?.contains(document.activeElement)?document.activeElement:null;groupScroll=groupBody?.scrollTop||0},
270
+ mount(){setup();const target=document.getElementById('liveActivity');if(target&&root.parentNode!==target)target.append(root);groupBody.scrollTop=groupScroll;if(focusBeforeRender?.isConnected)focusBeforeRender.focus({preventScroll:true});focusBeforeRender=null;schedule();void sync()},
271
+ sync,
272
+ receive(data){if(pending){frames.push(data);if(frames.length>200)frames.shift()}else apply(data,false)},
273
+ live(live){for(const [id,value]of Object.entries(live?.groups||{})){const g=groups.get(id);if(g)Object.assign(g,value)}schedule()},
274
+ connected(value){connected=value;schedule()},
275
+ }
276
+ })()
@@ -0,0 +1,193 @@
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, 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, text: clean(text), sender: clean(sender, 120), state: 'accepted' }],
42
+ truncated: String(text || '').length > TEXT, reply: '', tools: [], milestones: [] }
43
+ rows.set(key(chatId, receiptId), row); touch(row, true)
44
+ }
45
+ function merge(chatId, parentId, ids, state) {
46
+ const row = locate(chatId, parentId)
47
+ if (!row) return
48
+ for (const id of ids) {
49
+ const child = locate(chatId, id)
50
+ if (child && child !== row) {
51
+ row.messages.push(...child.messages)
52
+ row.receiptIds.push(...child.receiptIds)
53
+ rows.delete(key(chatId, child.id))
54
+ }
55
+ if (!row.receiptIds.includes(id)) row.receiptIds.push(id)
56
+ aliases.set(key(chatId, id), key(chatId, row.id))
57
+ const msg = row.messages.find(m => m.id === id)
58
+ if (msg) msg.state = state
59
+ }
60
+ // Bound the entire message body, not only each message, and disclose loss.
61
+ let budget = TEXT
62
+ if (row.messages.length > 64) row.truncated = true
63
+ row.messages = row.messages.slice(0, 64).map(m => {
64
+ const text = m.text.slice(0, budget); budget -= text.length
65
+ if (text !== m.text) row.truncated = true
66
+ return { ...m, text }
67
+ })
68
+ touch(row, true)
69
+ return row
70
+ }
71
+ function start({ chatId, receiptId, receiptIds, generation, sessionId, model, runner, project }) {
72
+ const row = merge(chatId, receiptId, receiptIds || [receiptId], 'landed')
73
+ if (!row) return
74
+ row.status = 'running'; row.generation = generation
75
+ row.startedAt ||= now(); row.sessionId = sessionId || null
76
+ Object.assign(row, { model: clean(model, 120), runner, project: clean(project, 120) })
77
+ const prior = contexts.get(chatId)
78
+ if (!prior || prior.sessionId !== sessionId) contexts.delete(chatId)
79
+ else contexts.set(chatId, { ...prior, generation, quality: 'previous' })
80
+ touch(row, true)
81
+ }
82
+ function observe({ chatId, receiptId, generation, event, doing, milestones }) {
83
+ const row = locate(chatId, receiptId)
84
+ if (!row || row.generation !== generation || CLOSED.has(row.status)) return
85
+ if (event.sessionId) row.sessionId = event.sessionId
86
+ const compact = event.type === 'system' && event.subtype === 'compact' || event.contextCompacted && !event.contextTokens
87
+ const measured = finite(event.contextTokens) && event.contextSampled !== false
88
+ if (compact || measured) {
89
+ const prior = contexts.get(chatId)
90
+ contexts.set(chatId, { sessionId: row.sessionId, generation,
91
+ usedTokens: compact ? null : event.contextTokens,
92
+ windowTokens: finite(event.contextWindowTokens) && event.contextWindowTokens > 0 ? event.contextWindowTokens : prior?.generation === generation ? prior.windowTokens : null,
93
+ measuredAt: now(), quality: compact ? 'compacted' : 'reported', source: row.runner })
94
+ }
95
+ if (event.tools) {
96
+ for (const tool of event.tools) {
97
+ const name = clean(tool.name, 100), found = row.tools.find(t => t.name === name)
98
+ if (found) found.count++
99
+ else if (row.tools.length < 50) row.tools.push({ name, count: 1 })
100
+ }
101
+ }
102
+ if (doing) row.doing = doing.map(x => clean(x, 160)).slice(-3)
103
+ if (milestones) row.milestones = milestones.map(x => clean(x, 200)).slice(-12)
104
+ // No model text deltas: only user-facing milestones and tool descriptions.
105
+ if (compact || measured || event.tools?.length || milestones)
106
+ touch(row, compact, !!event.tools?.length || !!milestones)
107
+ }
108
+ function finish({ chatId, receiptId, status, reply, err, tokens, sessionId }) {
109
+ const row = locate(chatId, receiptId)
110
+ if (!row || CLOSED.has(row.status)) return
111
+ row.status = status; row.endedAt = now(); row.ms = row.startedAt ? row.endedAt - row.startedAt : 0
112
+ row.reply = clean(reply); row.err = clean(err, 1000); row.tokens = tokens || null
113
+ row.truncated ||= String(reply || '').length > TEXT
114
+ if (sessionId) row.sessionId = sessionId
115
+ const ctx = contexts.get(chatId)
116
+ if (ctx && ctx.generation === row.generation) {
117
+ ctx.sessionId = row.sessionId; ctx.quality = ctx.quality === 'compacted' ? 'compacted' : 'previous'
118
+ }
119
+ touch(row, true)
120
+ }
121
+ function sending({ chatId, receiptId }) {
122
+ const row = locate(chatId, receiptId)
123
+ if (row && !CLOSED.has(row.status)) { row.status = 'sending'; touch(row, true) }
124
+ }
125
+ function unknown({ chatId }) {
126
+ for (const row of rows.values()) if (row.chatId === chatId && !CLOSED.has(row.status)) {
127
+ finish({ chatId, receiptId: row.id, status: 'unknown', err: '结果尚未确认,请查看群状态' })
128
+ }
129
+ }
130
+ const summary = row => {
131
+ const { messages, reply, tools, milestones, receiptIds, generation, sessionId, ...rest } = row
132
+ return { ...rest, preview: (row.err || reply || '').slice(0, 160), messageCount: messages.length,
133
+ kind: CLOSED.has(row.status) ? 'done' : 'start', details: true }
134
+ }
135
+ function list({ chatId, status, offset = 0, limit = 30 } = {}) {
136
+ const all = [...rows.values()].filter(r => (!chatId || r.chatId === chatId) && (!status || r.status === status))
137
+ .sort((a, b) => Number(CLOSED.has(a.status)) - Number(CLOSED.has(b.status)) || b.ts - a.ts)
138
+ offset = Math.max(0, Math.min(LIMIT, Number(offset) || 0)); limit = Math.max(1, Math.min(100, Number(limit) || 30))
139
+ return { epoch, revision, storageError, total: all.length, activity: all.slice(offset, offset + limit).map(summary) }
140
+ }
141
+ function detail(chatId, id) {
142
+ const row = locate(chatId, id)
143
+ return row ? { epoch, revision: row.revision, ...structuredClone(row) } : null
144
+ }
145
+ function context(chatId, sessionId, busy = false, generation = null) {
146
+ const c = contexts.get(chatId)
147
+ if (!c || !(busy && generation && c.generation === generation) && (!sessionId || c.sessionId !== sessionId)) return null
148
+ return { ...c, quality: busy ? c.quality : c.quality === 'reported' ? 'previous' : c.quality }
149
+ }
150
+ function flush() {
151
+ clearTimeout(saveTimer); saveTimer = null
152
+ if (writing) return saving
153
+ if (!file || !dirty || !canWrite()) return saving
154
+ dirty = false
155
+ const data = JSON.stringify({ version: 1, rows: [...rows.values()], contexts: [...contexts] })
156
+ writing = true
157
+ saving = saving.then(async () => {
158
+ if (!canWrite()) return
159
+ if (Buffer.byteLength(data) > FILE_LIMIT) throw new Error('activity size limit')
160
+ await fs.promises.mkdir(path.dirname(file), { recursive: true })
161
+ const tmp = file + '.tmp'
162
+ await fs.promises.writeFile(tmp, data, { mode: 0o600 })
163
+ if (canWrite()) await fs.promises.rename(tmp, file)
164
+ storageError = false
165
+ }).catch(() => { storageError = true; log('Activity history could not be saved') })
166
+ .finally(() => {
167
+ writing = false
168
+ if (dirty && !saveTimer) saveTimer = setTimeout(() => { saveTimer = null; void flush() }, 1000).unref()
169
+ })
170
+ return saving
171
+ }
172
+ if (file) {
173
+ try {
174
+ if (fs.statSync(file).size > FILE_LIMIT) throw new Error('activity size limit')
175
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'))
176
+ if (data.version !== 1 || !Array.isArray(data.rows) || data.rows.length > LIMIT) throw new Error('activity format')
177
+ for (const row of data.rows) {
178
+ if (!row || typeof row.id !== 'string' || typeof row.chatId !== 'string' || !Array.isArray(row.receiptIds) || !Array.isArray(row.messages)) continue
179
+ if (!CLOSED.has(row.status)) { row.status = 'unknown'; row.err = '服务重启,结果需要核实' }
180
+ rows.set(key(row.chatId, row.id), row)
181
+ revision = Math.max(revision, Number(row.revision) || 0)
182
+ for (const id of row.receiptIds) aliases.set(key(row.chatId, id), key(row.chatId, row.id))
183
+ }
184
+ for (const [id, value] of (data.contexts || []).slice(0, 32)) contexts.set(id, { ...value, quality: value.quality === 'compacted' ? 'compacted' : 'historical' })
185
+ } catch (error) { if (error.code !== 'ENOENT') { storageError = true; log('Activity history could not be loaded') } }
186
+ }
187
+ return { receive, start, observe, finish, sending, unknown, merge, list, detail, context, flush,
188
+ has: (chatId, id) => !!locate(chatId, id),
189
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn) },
190
+ async close() { clearTimeout(timer); listeners.clear(); await flush(); await flush(); clearTimeout(saveTimer) } }
191
+ }
192
+
193
+ module.exports = { createLiveActivity }
@@ -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') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
@@ -840,7 +841,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
840
841
  // 隧道对端哪天被写成 127.0.0.1:4600,也不会退化成「公网全员免密」;伪造这两个头只会让自己失去免密,方向安全。
841
842
  const localBypass = !loopbackBind && isLoopbackReq(req) && !req.headers['x-forwarded-for'] && !req.headers['x-forwarded-proto']
842
843
  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' : ''))))
844
+ 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
845
  }
845
846
  // 接管浏览器页。群成员的一次性链接是 /browser#k=<票>:票在 URL 片段里,服务端根本收不到 ⇒ 不进任何日志、链接预览的
846
847
  // 裸 GET 也抓不走;页面脚本读片段、人点「开始接管」后 POST /api/browser/claim 换 cookie。这里只分发页面:
@@ -867,7 +868,12 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
867
868
  if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
868
869
  // Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
869
870
  // 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() })
871
+ if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, runtimePolicy?.activityPage
872
+ ? runtimePolicy.activityPage(Object.fromEntries(url.searchParams)) : { activity: recentActivity() })
873
+ if (p === '/api/activity/detail' && req.method === 'GET') {
874
+ const detail = runtimePolicy?.activityDetail?.(url.searchParams.get('chatId'), url.searchParams.get('id'))
875
+ return sendJSON(res, detail ? 200 : 404, detail || { error: '活动详情未记录' })
876
+ }
871
877
  // 版本历史(设置页「版本历史」卡):CHANGELOG.json 随包分发 = 单一事实源,发版时更新
872
878
  if (p === '/api/changelog' && req.method === 'GET') return sendJSON(res, 200, { changelog: readChangelog() })
873
879
  // 同一份版本历史的英文本(界面切英文时才拉;缺的版本前端按 version 回退中文)
@@ -2417,8 +2423,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2417
2423
  const live = getLiveState ? getLiveState() : { running: false }
2418
2424
  const conn = getConnHealth ? getConnHealth() : null // 飞书 WS 健康度随心跳带上(SSE 无重放,轮询兜底)
2419
2425
  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) } }
2426
+ ...(runtimePolicy && !runtimePolicy.activityPage ? { activity: runtimePolicy.getActivity() } : {}) })}\n\n`
2427
+ for (const c of sseClients) { try {
2428
+ if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
2429
+ else c.write(msg)
2430
+ } catch { sseClients.delete(c) } }
2422
2431
  }, 3000)
2423
2432
 
2424
2433
  return {
@@ -2434,7 +2443,10 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
2434
2443
  if (type === 'turn' && getLiveState) {
2435
2444
  msg += `event: tick\ndata: ${JSON.stringify({ live: getLiveState(), conn: getConnHealth ? getConnHealth() : null, ts: Date.now() })}\n\n`
2436
2445
  }
2437
- for (const c of sseClients) { try { c.write(msg) } catch { sseClients.delete(c) } }
2446
+ for (const c of sseClients) { try {
2447
+ if (c.writableLength > 256 * 1024) { c.destroy(); sseClients.delete(c) }
2448
+ else c.write(msg)
2449
+ } catch { sseClients.delete(c) } }
2438
2450
  },
2439
2451
  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
2452
  _browserRoutes: browserRoutes, // 测试探 CDP 会话注册表用