sintbot 1.4.10 → 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/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',
@@ -18,7 +18,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
18
18
  '/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
19
19
  '/api/groups', '/api/groups/provider', '/api/fs/mkdir',
20
20
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
21
- '/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
21
+ '/api/groups/codex-steer', '/api/groups/steer', '/api/groups/pause', '/api/groups/stop',
22
22
  // A fresh session only for an idle, settled group (same rule as /new).
23
23
  '/api/groups/new', '/api/groups/interrupt',
24
24
  '/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
@@ -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 }
package/src/c2install.js CHANGED
@@ -123,6 +123,7 @@ function resolveInstallation(cfg, configFile, stateDir, { allowAddedGroups = fal
123
123
  if (group.paused !== undefined && typeof group.paused !== 'boolean') {
124
124
  throw new Error('Group paused must be a boolean')
125
125
  }
126
+ if (group.steer !== undefined && typeof group.steer !== 'boolean') throw new Error('Group steer must be a boolean')
126
127
  if (group.codexSteer !== undefined && typeof group.codexSteer !== 'boolean') {
127
128
  throw new Error('Group codexSteer must be a boolean')
128
129
  }
package/src/config.js CHANGED
@@ -40,8 +40,8 @@ const DEFAULTS = {
40
40
  // 写死会**覆盖 CLI 的默认**,于是每次 Anthropic 出新一代,sbot 的默认就悄悄落后一代
41
41
  // (2026-08-03 核对时正卡在 opus-4-8,而 CLI 默认已是 Opus 5)。要钉死型号在控制台选。
42
42
  providers: { claude: {} },
43
- // "<chatId>": { workdir, provider?, model?, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
- // codexSteer defaults on for Codex groups; explicit false opts out.
43
+ // "<chatId>": { workdir, provider?, model?, steer?: boolean, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
+ // steer defaults on for both executors; explicit false opts out. Legacy codexSteer applies only to Codex.
45
45
  // — one project per group; permissions are scoped to that group's own workdir.
46
46
  groups: {},
47
47
  }
@@ -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,
@@ -1,4 +1,5 @@
1
1
  'use strict'
2
+ const { followupEnabled } = require('../agent/privatecore')
2
3
 
3
4
  // Assemble the same user-facing runner inputs as Engine._runTurn after the
4
5
  // host has prepared its timestamped prompt and manifest. This only assembles
@@ -25,12 +26,13 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
25
26
  throw new TypeError('C2 runner input requires an exact process-local turn')
26
27
  }
27
28
  const isCodex = provider.runner === 'codex'
28
- const codexSteer = isCodex && group.codexSteer !== false
29
+ const enabled = followupEnabled(group, provider.runner)
30
+ const codexSteer = isCodex && enabled
29
31
  if ((onSteerReady !== undefined && typeof onSteerReady !== 'function') ||
30
32
  (onSteerLanded !== undefined && typeof onSteerLanded !== 'function') ||
31
- (codexSteer && (typeof onSteerReady !== 'function' ||
33
+ (enabled && (typeof onSteerReady !== 'function' ||
32
34
  typeof onSteerLanded !== 'function'))) {
33
- throw new TypeError('C2 Codex follow-up needs durable control callbacks')
35
+ throw new TypeError('C2 follow-up needs durable control callbacks')
34
36
  }
35
37
  const rawCompact = Number(config.agent.autocompactTokens) || 0
36
38
  const autocompactTokens = rawCompact > 0
@@ -40,6 +42,7 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
40
42
  prompt, appendSystemPrompt, provider,
41
43
  codexApp: isCodex && group.codexApp === true,
42
44
  codexSteer,
45
+ steer: !isCodex && enabled,
43
46
  codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
44
47
  onSteerReady, onSteerLanded,
45
48
  model: group.model,
@@ -24,6 +24,8 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
24
24
  }
25
25
  let bufferText = ''
26
26
  let answerText = ''
27
+ let keptText = ''
28
+ let segmentBreak = false
27
29
  let fullText = ''
28
30
  let sessionId = null
29
31
  let contextTokens = 0
@@ -68,6 +70,11 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
68
70
  throw new TypeError('C2 projection tool names invalid')
69
71
  }
70
72
  if (event.text) {
73
+ if (segmentBreak) {
74
+ segmentBreak = false
75
+ if (bufferText) bufferText += '\n\n'
76
+ if (fullText) fullText += '\n\n'
77
+ }
71
78
  fullText = append(fullText, event.text)
72
79
  bufferText = append(bufferText, event.text)
73
80
  answerText = append(answerText, event.text)
@@ -78,6 +85,13 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
78
85
  // Claude can report text and tool use in one assistant event. Text before
79
86
  // the tool is progress, while the final answer begins after that tool.
80
87
  if (tools.length) answerText = ''
88
+ } else if (event.type === 'turn_continue') {
89
+ if (event.text !== undefined && typeof event.text !== 'string') throw new TypeError('C2 segment text invalid')
90
+ const text = (answerText.trim() ? answerText : event.text || '').trim()
91
+ if (text && text !== NO_REPLY) keptText += (keptText ? '\n\n' : '') + text
92
+ if (bufferText.trim() === NO_REPLY) bufferText = ''
93
+ if (fullText.trim() === NO_REPLY) fullText = ''
94
+ answerText = ''; segmentBreak = true
81
95
  } else if (event.type === 'result') {
82
96
  if (event.text !== undefined && typeof event.text !== 'string') {
83
97
  throw new TypeError('C2 projection result text invalid')
@@ -138,7 +152,11 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
138
152
  }
139
153
  completed = true
140
154
  const runnerExit = requireResult && !observedResult && !runnerResult.aborted
141
- const resultText = observedResult?.text || ''
155
+ const rawResultText = observedResult?.text || ''
156
+ const tail = (answerText.trim() ? answerText : rawResultText).trim()
157
+ const joined = keptText ? keptText + (tail && tail !== NO_REPLY ? '\n\n' + tail : '') : ''
158
+ const resultText = joined || rawResultText
159
+ if (joined) answerText = joined
142
160
  const payload = Object.freeze({
143
161
  type: 'finalizeEffectCommit', receiptId, turnToken, quiet,
144
162
  source: observedResult ? 'result' : runnerExit ? 'runner-exit' : 'close',
@@ -9,7 +9,7 @@ const { isDeepStrictEqual } = require('node:util')
9
9
  const { redact } = require('./redact')
10
10
  const { fmtStamp, normalizeTz } = require('./time')
11
11
  const { settleUsage, manifestPolicy, manifestHeader, manifestContext, manifestFeatures, manifestSkills,
12
- systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
12
+ followupEnabled, systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
13
13
  const { taskId } = require('./enginecheckpoint')
14
14
  const { prepareCardTerminalPath } = require('./c2cardterminalpath')
15
15
  const { terminalReplyText } = require('./c2terminaleffectbridge')
@@ -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
@@ -468,7 +475,7 @@ async _durableV2Command(msg, cmd, rest) {
468
475
  group.allowedTools, group.disallowedTools).tier)
469
476
  } catch {}
470
477
  return reject('help', T.help({ project: projectName(group), model: label(name, provider),
471
- steer: isCodex && group.codexSteer !== false, readOnly }))
478
+ steer: followupEnabled(group, provider.runner), readOnly }))
472
479
  }
473
480
  if (cmd === '/status') {
474
481
  const rt = this.rts.get(msg.chatId)
@@ -651,7 +658,7 @@ async runDurableV2Message(msg, platform) {
651
658
  lane?.handoff?.[msg.chatId]?.phase === 'running' ||
652
659
  Array.isArray(queue) && queue.length > 0 ||
653
660
  rt.v2RunningJobs || rt.v2QueueReceiptIds?.length
654
- if (busy && provider.runner === 'codex' && group.codexSteer !== false &&
661
+ if (busy && rt.v2FollowupEnabled &&
655
662
  rt.v2SteerControl && rt.v2NormalRouteActive && !rt.v2RunnerStopVerified &&
656
663
  !rt.v2TerminalStarted && !rt.v2StreamQuiescing && rt.v2Controller &&
657
664
  !rt.v2Controller.signal.aborted && !rt.v2SteerClosing) {
@@ -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,10 @@ 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) })
1557
+ rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1537
1558
  rt.v2SteerControl = null
1538
1559
  rt.v2SteerEntries = new Map()
1539
1560
  rt.v2SteerClosing = false
@@ -1578,6 +1599,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1578
1599
  if (rt.v2CardBinding?.opened) await rt.v2CardBinding.path.flushForTerminal()
1579
1600
  if (rt.v2RunnerGeneration !== generation) return
1580
1601
  authority.commitSteerObservation({ ...steerIdentity, receiptId: entry.identity.receiptId, type: 'landed' })
1602
+ try { this.activity?.merge(chatId, receiptId, [entry.identity.receiptId], 'landed') } catch {}
1581
1603
  await rt.v2MoveCard?.(entry)
1582
1604
  })
1583
1605
  rt.v2SteerLandedChain = observation
@@ -1776,6 +1798,12 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1776
1798
  onDurableEvent: event => {
1777
1799
  projection.accept(event)
1778
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 })
1779
1807
  },
1780
1808
  onTurnAccepted: value => {
1781
1809
  const ack = capability.onTurnAccepted(value)
@@ -2048,8 +2076,14 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
2048
2076
  ...progress.milestonesOf(payload.bufferText)]
2049
2077
  .filter((m, i, a) => a.indexOf(m) === i).map(redactMilestone)
2050
2078
  .filter(m => typeof m === 'string' && m.trim())
2079
+ this._observeActivity('sending', { chatId, receiptId })
2051
2080
  const completed = await path.complete({ payload, replyText, paused,
2052
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 })
2053
2087
  const after = authority.snapshot()
2054
2088
  const queue = after.state?.lanes?.queue?.[chatId]?.queue
2055
2089
  let successor = null
@@ -2268,6 +2302,7 @@ requestDurableV2Stop(chatId) {
2268
2302
  payloadHandles: this.durableV2Payloads,
2269
2303
  assertWriterLock: this.durableV2AssertWriterLock })
2270
2304
  rt.v2QueueReceiptIds = rt.v2QueueReceiptIds.filter(id => id !== receiptId)
2305
+ this._observeActivity('finish', { chatId, receiptId, status: 'recalled' })
2271
2306
  return true
2272
2307
  } catch (error) { authority?.close(); this.log(error.message); return false }
2273
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": "当前不执行定时任务;已有定义仅供查看。",
@@ -2619,7 +2621,7 @@ const RUNTIME_UI_TEXT={
2619
2621
  "paused": "已暂停:不接收本群新消息,配置与会话保留。",
2620
2622
  "resume": "恢复本群:重新接收本群消息,配置与会话保留。",
2621
2623
  "resumeLabel": "恢复",
2622
- "steer": "群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。",
2624
+ "steer": "Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。",
2623
2625
  "preview": "当前版本未接入预览服务。",
2624
2626
  "providers": "可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。",
2625
2627
  "providersView": "当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。",
@@ -2631,7 +2633,7 @@ function renderGroups(){
2631
2633
  // Codex(GPT)跑自己的 agent 框架:只吃 permissionMode→沙箱层级,allow/deny 与 MCP 门控
2632
2634
  // 都是 Claude 专属(codexcode 根本不接收这些字段)。所以 codex 群不渲染那些无效控件。
2633
2635
  const pr=provOf(g), isCodex=!!pr&&pr.runner==='codex';
2634
- const showCodexSteer=runsCodex(g);
2636
+
2635
2637
  // 群属于哪个平台(服务端按配置 / id 形态算好):企微群没有画布、没有解散、基础技能文案不同;徽标只在配了企微 / Slack 时出(只配飞书的机器零变化)
2636
2638
  const isWe=g.platform==='wecom', isSl=g.platform==='slack', showPlat=!!(S.platforms&&(S.platforms.wecom||S.platforms.slack));
2637
2639
  const platTag=showPlat?`<span class="tag tan" title="${isWe?'企业微信群:只收 @ 机器人的消息;没有画布、表情回应与撤回即停':isSl?'Slack 频道:没有画布与表情回应,群内收发文件未接;控制面用 !命令 或 /sint':'飞书群'}">${isWe?'企业微信':isSl?'Slack':'飞书'}</span>`:'';
@@ -2845,13 +2847,13 @@ function renderGroups(){
2845
2847
  <select class="gprov" data-g="${esc(g.chatId)}" style="width:auto;min-width:110px;padding:5px 30px 5px 10px;font-size:11.5px">
2846
2848
  ${S.providers.filter(pp=>!pp.hidden&&pp.configured).map(pp=>`<option value="${esc(pp.name)}" ${pp.name===(g.provider||S.defaultProvider)?'selected':''}>${esc(pp.name)}${pp.model?' · '+esc(pp.model):''}</option>`).join('')}
2847
2849
  </select></div>
2848
- ${showCodexSteer?`<div class="kv" style="align-items:flex-start"><span class="kk">忙时跟进</span><span class="vv" style="min-width:0">
2849
- <label class="inline" style="gap:6px;cursor:pointer" title="${esc(S.runtimeCapabilities?RUNTIME_UI_TEXT.steer:'开启后,Codex 处理任务期间:群成员的后续消息、控制台勾了「回复」的插话,都会加入当前任务 —— 相关要求及时吸收,独立请求按先后回应。定时任务和不回复的插话仍排在后面。')}">
2850
- <input type="checkbox" class="gsteer" data-g="${esc(g.chatId)}"${g.codexSteer?' checked':''} style="width:auto;margin:0"/>
2850
+ <div class="kv" style="align-items:flex-start"><span class="kk">忙时跟进</span><span class="vv" style="min-width:0">
2851
+ <label class="inline" style="gap:6px;cursor:pointer" title="${esc(S.runtimeCapabilities?RUNTIME_UI_TEXT.steer:'开启后,处理任务期间:群成员的后续消息、控制台勾了「回复」的插话,都会加入当前任务 —— 相关要求及时吸收,独立请求按先后回应。定时任务和不回复的插话仍排在后面。')}">
2852
+ <input type="checkbox" class="gsteer" data-g="${esc(g.chatId)}"${g.followupEnabled?' checked':''} style="width:auto;margin:0"/>
2851
2853
  <span style="font-size:11.5px;color:var(--dim)">后续消息加入当前处理</span>
2852
2854
  </label>
2853
- <span class="faint" style="font-size:11px">仅 Codex · 默认开启 · 下轮生效</span>
2854
- </span></div>`:''}
2855
+ <span class="faint" style="font-size:11px">默认开启 · 下轮生效</span>
2856
+ </span></div>
2855
2857
  <div class="kv"><span class="kk">执行模式</span>${modeControl}</div>
2856
2858
  ${permPlug}
2857
2859
  <div class="spacer"></div>
@@ -2890,7 +2892,7 @@ function renderGroups(){
2890
2892
  try{S=await api('/api/groups/pause','POST',{chatId:b.dataset.gpause,paused:on});renderGroups();renderOverview();toast(S.runtimeCapabilities?(on?'已暂停':'已恢复'):(on?'已暂停 · 群内已通知':'已恢复 · 群内已通知'),'ok');}catch(e){toast(e.message,'err');}});
2891
2893
  $$('#groups .gprov').forEach(sel=>sel.onchange=async()=>{try{const r=await api('/api/groups/provider','POST',{chatId:sel.dataset.g,provider:sel.value});S=r;renderOverview();renderGroups();toast(r.switched&&r.switched.live?('已切到 '+sel.value+' · 会话已重置,下条消息生效'):('已保存 '+sel.value+' · daemon 未运行下次生效'),'ok');}catch(e){toast(e.message,'err');}});
2892
2894
  $$('#groups .gsteer').forEach(c=>c.onchange=async()=>{const on=c.checked;
2893
- try{S=await api('/api/groups/codex-steer','POST',{chatId:c.dataset.g,enabled:on});renderGroups();toast(on?'已开启 · 下轮生效':'已关闭 · 下轮恢复排队','ok');}
2895
+ try{S=await api('/api/groups/steer','POST',{chatId:c.dataset.g,enabled:on});renderGroups();toast(on?'已开启 · 下轮生效':'已关闭 · 下轮恢复排队','ok');}
2894
2896
  catch(e){c.checked=!on;toast(e.message,'err');}});
2895
2897
  $$('#groups [data-pol]').forEach(b=>b.onclick=async()=>{const g=b.dataset.pol,allow=splitList($('.gallow[data-g="'+g+'"]').value),deny=splitList($('.gdeny[data-g="'+g+'"]').value);try{S=await api('/api/groups/policy','POST',{chatId:g,allowedTools:allow,disallowedTools:deny});toast('权限已保存 · 下轮生效','ok');}catch(e){toast(e.message,'err');}});
2896
2898
  // 仓库地址:保存 = 校验 + git init(若非仓库)+ 设 origin(幂等,不 clone/不提交);留空保存 = 解除关联。
@@ -3330,7 +3332,7 @@ function sayPlan(chatId,reply){
3330
3332
  if(lv.finalizing) return {busy:true,join:false,why:'closing',lv};
3331
3333
  if(lv.followups) return reply?{busy:true,join:true,lv}:{busy:true,join:false,why:'quiet',lv};
3332
3334
  const g=(S.groups||[]).find(x=>x.chatId===chatId)||{};
3333
- return {busy:true,join:false,why:!runsCodex(g)?'other':(!g.codexSteer?'off':'turn'),lv};
3335
+ return {busy:true,join:false,why:!g.followupEnabled?'off':'turn',lv};
3334
3336
  }
3335
3337
  /* 这个群眼下实际跑的是不是 Codex(按生效中的模型,含临时切换)—— 群卡「忙时跟进」开关显不显示、插话框说不说
3336
3338
  「去打开忙时跟进」,都按这一个判。两处各写一份,开关和说明就会对不上。 */
@@ -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."],
@@ -4311,10 +4311,11 @@ const I18N_EN=[
4311
4311
  ["已暂停:不接收本群新消息,配置与会话保留。", "Paused: no new group messages are accepted; configuration and sessions are retained."],
4312
4312
  ["恢复本群:重新接收本群消息,配置与会话保留。", "Resume this group: its messages are accepted again; configuration and sessions are retained."],
4313
4313
  ["恢复", "Resume"],
4314
- ["群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。", "Followup text messages from group members can join the current task. Delivery is confirmed only when the model receives the message. Console messages work only while the group is idle and never join the current task; schedules are unavailable."],
4314
+ ["Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。", "Busy-turn followups are on by default for Codex and Claude Code. Followups are read between tool steps; a long command must finish first. Joined means the model has read the message. Unsupported clients queue followups. Console messages require an idle group."],
4315
4315
  ["当前版本未接入预览服务。", "Preview services are unavailable in this version."],
4316
4316
  ["当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。", "Model configuration and login status are shown here. Switch each group's model under Groups. Adding or changing models is unavailable."],
4317
4317
  ["可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。", "Add models; change their model id, endpoint, key, thinking depth and reasoning effort; test, hide or drag to reorder them. Switch each group's model under Groups. Changing a model id, backend or endpoint needs the groups using it to be idle and starts a new conversation for them. The default model and models in use by a group cannot be hidden. Removing models is unavailable."],
4318
+ ["默认开启 · 下轮生效", "On by default · Applies next turn"],
4318
4319
  ["当前只能添加或修改 Codex 或 Claude Code 模型", "Only Codex or Claude Code models can be added or changed right now."],
4319
4320
  ["默认模型须是列表中未隐藏的 Codex 或 Claude Code 模型", "The default model must be a visible Codex or Claude Code model from the list."],
4320
4321
  ["有群因未解决的故障暂停处理,它用的模型、后端或端点要等处理完并重启后才能改", "A group is held after an unresolved fault; the model, backend or endpoint it uses can change only after the fault is handled and the service restarts."],
@@ -5964,10 +5965,7 @@ function disableRuntimeControl(el,reason){
5964
5965
  }
5965
5966
  }
5966
5967
  function applyRuntimeCapabilities(){
5967
- const banner=document.getElementById('runtimeCapabilitiesNotice');
5968
- if(banner)banner.hidden=!(S&&S.runtimeCapabilities);
5969
5968
  if(!S||!S.runtimeCapabilities)return;
5970
- if(banner){const body=banner.querySelector('[data-runtime-summary]'),text=trText(RUNTIME_UI_TEXT.summary);if(body&&body.textContent!==text)body.textContent=text;}
5971
5969
  document.querySelectorAll(RUNTIME_DISABLED_SELECTORS).forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
5972
5970
  // 插话照服务端白名单放行(第 1 版只接空闲群,忙不忙在浮层里按实时状态说)
5973
5971
  if(!runtimeAllows('POST','/api/groups/say'))document.querySelectorAll('[data-osay]').forEach(el=>disableRuntimeControl(el,RUNTIME_UI_TEXT.unavailable));
@@ -6001,6 +5999,7 @@ function applyTurnEvent(a){
6001
5999
  // reconnect drops, and rows the self-heal resolved to a bare '✓' without their real done
6002
6000
  // data. Idempotent (id-keyed merge). Called on load + on every SSE (re)connect.
6003
6001
  async function syncActivity(repaint){
6002
+ if(S?.runtimeCapabilities&&typeof activityPanel!=='undefined')return activityPanel.sync();
6004
6003
  try{const d=await api('/api/activity'); if(d&&d.activity){d.activity.forEach(applyTurnEvent); if(repaint&&$('#overview').innerHTML)updateOverviewLive();}}catch(e){}
6005
6004
  }
6006
6005
 
@@ -6011,10 +6010,11 @@ function startLive(){
6011
6010
  if(esRef){try{esRef.close();}catch{}}
6012
6011
  helloN=0;
6013
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{}});
6014
6014
  // hello fires on first connect AND every native reconnect → replay the buffer to
6015
6015
  // recover whatever SSE dropped while we were away (SSE has no replay of its own);
6016
6016
  // 重连(非首连)还拉一次全量 state —— 断流期间的 live/conn 变化就地补齐,不整页重渲染。
6017
- 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);
6018
6018
  if(++helloN>1)api('/api/state').then(r=>{S=r;updateDaemon();updateTopUpdateBadge();updateOverviewLive();updateConnLive();startClock();}).catch(()=>{});});
6019
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{}});
6020
6020
  startAutoRefresh();
@@ -6022,7 +6022,7 @@ function startLive(){
6022
6022
  es.addEventListener('turn',ev=>{try{const d=JSON.parse(ev.data);applyTurnEvent(d);updateOverviewLive();
6023
6023
  // 收件箱跟着轮次走:刚失败的要尽快出现,重说成功的要尽快消失 —— 不等 60 秒的例行刷新;只取这一块,不拉整份 state
6024
6024
  if(d.kind==='done')refreshInbox();}catch(e){}});
6025
- es.onerror=()=>{$('#conn').classList.remove('live');checkStaleAuth();};
6025
+ es.onerror=()=>{$('#conn').classList.remove('live');if(typeof activityPanel!=='undefined')activityPanel.connected(false);checkStaleAuth();};
6026
6026
  }catch(e){}
6027
6027
  }
6028
6028
  /* SSE 断流自愈:daemon 重启会作废内存里的 token(页面 token / 登录 session 都是),