sintbot 1.0.7 → 1.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,165 @@
1
+ 'use strict'
2
+ // 控制台「需要你处理」收件箱 + 实时活动的账本回填(纯逻辑,可测;读写盘只在 readLedgerTail / createDismissStore)。
3
+ //
4
+ // 为什么要有收件箱:agent 跑得越久、群越多,「哪里在等我」就越难从聊天记录和概览各区里拼出来 ——
5
+ // 失败的轮次、没人认领的浏览器接管、没处理的反馈、残留进程,此前散在群消息和概览的四个地方。
6
+ // 这里只**汇总**,不新增通知渠道:群里该说的照旧在群里说,控制台是人主动来看的地方。
7
+ //
8
+ // 为什么要回填:活动流是内存环形缓冲,daemon 一重启(含每次发版)概览就是空的,而账本一直在盘上。
9
+ // 回填的事件 id 与引擎同形(`chatId:ts`),之后 SSE 重放同一轮也只会原地覆盖,不会重复。
10
+
11
+ const fs = require('node:fs')
12
+ const path = require('node:path')
13
+
14
+ const FAIL_STATUSES = new Set(['error', 'failed']) // stopped 是人按的、no-reply 是设计如此、empty 会自己重试一次 —— 都不算「要人管」
15
+ const FAIL_WINDOW_MS = 72 * 3600_000 // 三天前的失败,人早就在群里重说过或者放弃了
16
+ const MAX_FAILS = 20
17
+ const DISMISS_TTL_MS = 7 * 24 * 3600_000
18
+ const DISMISS_MAX = 200
19
+
20
+ const groupLabel = (groups, chatId) => {
21
+ const g = groups && groups[chatId]
22
+ return (g && g.name) || String(chatId || '').slice(-8)
23
+ }
24
+
25
+ // 只读账本末尾一段:账本一轮一行、只增不减(本机 30 天约 4,600 行),整本读进来没必要。
26
+ // 从中间切开时丢掉第一行残片;坏行跳过(账本是 best-effort 追加,半行是可能的)。
27
+ function readLedgerTail(file, maxBytes = 512 * 1024) {
28
+ let fd
29
+ try {
30
+ fd = fs.openSync(file, 'r')
31
+ const size = fs.fstatSync(fd).size
32
+ const start = Math.max(0, size - maxBytes)
33
+ const buf = Buffer.alloc(size - start)
34
+ fs.readSync(fd, buf, 0, buf.length, start)
35
+ const lines = buf.toString('utf8').split('\n')
36
+ if (start > 0) lines.shift()
37
+ const rows = []
38
+ for (const l of lines) {
39
+ if (!l.trim()) continue
40
+ try { const r = JSON.parse(l); if (r && typeof r === 'object') rows.push(r) } catch { /* 半行 / 坏行 */ }
41
+ }
42
+ return rows
43
+ } catch { return [] } finally { if (fd !== undefined) try { fs.closeSync(fd) } catch {} }
44
+ }
45
+
46
+ // 账本尾巴按 (大小, 修改时刻) 缓存:收件箱每次刷新都要读,账本却只在一轮结束时才变 ——
47
+ // 本机尾部 512 KB 约 2,400 行,没变也每次解析一遍是白干。返回的数组是共享的,调用方别改它。
48
+ function createLedgerTail(file, maxBytes) {
49
+ let key = null
50
+ let rows = []
51
+ return {
52
+ rows() {
53
+ let st
54
+ try { st = fs.statSync(file) } catch { key = null; rows = []; return rows }
55
+ const k = `${st.size}:${st.mtimeMs}`
56
+ if (k !== key) { rows = readLedgerTail(file, maxBytes); key = k }
57
+ return rows
58
+ },
59
+ }
60
+ }
61
+
62
+ // 账本行 → 活动事件(形状对齐引擎的 done 事件)。定时脚本行跳过:它没有模型、没有 token,
63
+ // 本机 30 天里占了 93%,混进来活动流就只剩脚本了。返回从旧到新(与活动缓冲同序)。
64
+ function ledgerActivity(rows, groups, n = 20) {
65
+ const turns = (rows || []).filter((r) => r && !r.script && r.ts && r.chatId)
66
+ return turns.slice(-n).map((r) => ({
67
+ id: `${r.chatId}:${r.ts}`,
68
+ ts: r.ts,
69
+ chatId: r.chatId,
70
+ group: String(r.chatId).slice(-8), // 与引擎 meta.group 同口径;群名由控制台按 chatId 换
71
+ name: groupLabel(groups, r.chatId),
72
+ sender: r.sender || '',
73
+ text: r.text || '',
74
+ kind: 'done',
75
+ status: r.status || '',
76
+ ms: r.ms || 0,
77
+ tokens: r.tokens || null,
78
+ ...(r.err ? { err: String(r.err) } : {}),
79
+ fromLedger: true,
80
+ }))
81
+ }
82
+
83
+ // 失败轮次:同群同一个人之后又有 ok 轮 ⇒ 当作已经重说过、自动消失 —— 否则一次网络抖动的失败
84
+ // 要在收件箱里挂三天,人只能一条条手动忽略。
85
+ function failedTurns(rows, now) {
86
+ const recent = (rows || []).filter((r) => r && !r.script && r.ts > now - FAIL_WINDOW_MS)
87
+ const out = []
88
+ for (let i = 0; i < recent.length; i++) {
89
+ const f = recent[i]
90
+ if (!FAIL_STATUSES.has(f.status)) continue
91
+ const retried = recent.slice(i + 1).some((r) => r.chatId === f.chatId && r.sender === f.sender && r.status === 'ok')
92
+ if (!retried) out.push(f)
93
+ }
94
+ return out.slice(-MAX_FAILS)
95
+ }
96
+
97
+ // 汇总成一张单子。文案不在这里拼:控制台有中英两套界面,这里只给事实(kind + 字段),由前端成句。
98
+ // failed 失败的轮次 · 可忽略
99
+ // takeover agent 在群里发了接管链接、还没人认领(只算挂了群卡的票;管理员自己在控制台签的不算)
100
+ // issues 反馈本里没处理的(一行汇总,点了滚到反馈本)
101
+ // leftovers 残留的后台进程(同上)
102
+ // preview 服务整个项目目录的预览(全部代码都在局域网上可读)
103
+ // health 健康检查没通过的项(一行汇总,排最前:它是「现在就坏着」,没有发生时刻可排)
104
+ function buildInbox({ ledgerRows = [], issues = null, previews = [], leftovers = null, health = null, takeovers = [], groups = {}, dismissed = new Set(), now = Date.now() } = {}) {
105
+ const items = []
106
+ for (const f of failedTurns(ledgerRows, now)) {
107
+ items.push({ key: `failed:${f.chatId}:${f.ts}`, kind: 'failed', chatId: f.chatId, group: groupLabel(groups, f.chatId), ts: f.ts,
108
+ sender: f.sender || '', text: f.text || '', err: f.err ? String(f.err) : '', dismissable: true })
109
+ }
110
+ for (const t of takeovers || []) {
111
+ if (!t || !t.chatId) continue
112
+ items.push({ key: `takeover:${t.id}`, kind: 'takeover', chatId: t.chatId, group: groupLabel(groups, t.chatId), ts: t.at, exp: t.exp,
113
+ pageTitle: t.pageTitle || '', pageUrl: t.pageUrl || '' })
114
+ }
115
+ if (issues && issues.openCount > 0) {
116
+ const latest = (issues.open || [])[0]
117
+ items.push({ key: 'issues', kind: 'issues', count: issues.openCount, ts: latest ? latest.ts : null })
118
+ }
119
+ const lo = leftovers && Array.isArray(leftovers.rows) ? leftovers.rows : []
120
+ if (lo.length) {
121
+ const oldest = Math.max(...lo.map((x) => Number(x.ageSec) || 0))
122
+ items.push({ key: 'leftovers', kind: 'leftovers', count: lo.length, ts: now - oldest * 1000 })
123
+ }
124
+ for (const p of previews || []) {
125
+ if (!p || !p.projectRoot) continue
126
+ items.push({ key: `preview:${p.id}`, kind: 'preview', chatId: p.chatId, group: p.name || groupLabel(groups, p.chatId), ts: now - (Number(p.upMs) || 0), port: p.port })
127
+ }
128
+ const sorted = items.filter((it) => !dismissed.has(it.key)).sort((a, b) => (b.ts || 0) - (a.ts || 0))
129
+ const bad = health && Array.isArray(health.rows) ? health.rows.filter((r) => r && r.status === 'fail') : [] // warn 不算:那是「可以更好」,不是「在等你」
130
+ return bad.length ? [{ key: 'health', kind: 'health', count: bad.length, labels: bad.map((r) => String(r.label || '')) }, ...sorted] : sorted
131
+ }
132
+
133
+ // 「忽略」记在盘上(daemon 重启不复活),7 天后清、最多 200 条 —— 失败轮次本身 72 小时就出窗口了,
134
+ // 留 7 天只是余量。文件读坏就当空:最坏是几条已忽略的失败重新出现,不阻断任何东西。
135
+ function createDismissStore({ file, now = Date.now, log = () => {} } = {}) {
136
+ let keys = null // key -> 忽略时刻
137
+ const load = () => {
138
+ if (keys) return
139
+ keys = new Map()
140
+ try {
141
+ const d = JSON.parse(fs.readFileSync(file, 'utf8'))
142
+ for (const [k, t] of Object.entries((d && d.keys) || {})) if (typeof t === 'number') keys.set(k, t)
143
+ } catch { /* 没有 / 读坏 ⇒ 空 */ }
144
+ }
145
+ const prune = () => {
146
+ const t = now()
147
+ for (const [k, at] of keys) if (!(at > t - DISMISS_TTL_MS)) keys.delete(k)
148
+ const extra = keys.size - DISMISS_MAX
149
+ if (extra > 0) for (const k of [...keys.keys()].slice(0, extra)) keys.delete(k) // Map 按插入序 ⇒ 先丢最老的
150
+ }
151
+ return {
152
+ set() { load(); prune(); return new Set(keys.keys()) },
153
+ add(key) {
154
+ load(); keys.delete(key); keys.set(String(key), now()); prune()
155
+ try {
156
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 })
157
+ const tmp = `${file}.${process.pid}.tmp`
158
+ fs.writeFileSync(tmp, JSON.stringify({ v: 1, keys: Object.fromEntries(keys) }) + '\n', { mode: 0o600 })
159
+ fs.renameSync(tmp, file)
160
+ } catch (e) { log(`inbox: 写不进 ${path.basename(file)}(${e.message})—— 这次忽略只在内存里`) }
161
+ },
162
+ }
163
+ }
164
+
165
+ module.exports = { FAIL_WINDOW_MS, readLedgerTail, createLedgerTail, ledgerActivity, failedTurns, buildInbox, createDismissStore }
package/src/ui/server.js CHANGED
@@ -51,6 +51,7 @@ const DEFAULT_SESSIONS_FILE = () => path.join(os.homedir(), '.sbot', 'ui-session
51
51
  const CONSOLE_HTML = path.join(__dirname, 'console.html')
52
52
  const BROWSER_HTML = path.join(__dirname, 'browser.html') // 接管浏览器页(自包含单文件,群成员凭一次性链接也能进)
53
53
  const takeover = require('./takeover') // 接管链接的票据与 cookie(纯逻辑)
54
+ const dashboard = require('./dashboard') // 「需要你处理」收件箱 + 活动流的账本回填(纯逻辑)
54
55
  const { createBrowserRoutes } = require('./browserroutes') // /api/browser/* 的实现;这里只裁决谁能进
55
56
  const PKG_VERSION = (() => { try { return 'v' + require('../../package.json').version } catch { return '' } })() // 顶栏版本徽章
56
57
  let changelogCache = null // 随包分发的版本历史,进程内读一次
@@ -207,7 +208,7 @@ function bindNoticeText(workdir, platform = 'feishu', cmd = '/help') {
207
208
  return `✅ 本群已绑定项目「${path.basename(String(workdir || '')) || '(未命名)'}」\n${platform === 'wecom' ? '@我 派单(只有 @ 我的消息我才收得到)' : '直接发消息或 @我 派单'};${cmd} 查看命令`
208
209
  }
209
210
 
210
- function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null } = {}) {
211
+ function createUiServer({ configPath, getLiveState = null, getConnHealth = null, onConfigChange = null, control = null, host = '127.0.0.1', log = () => {}, issuesPath = null, sessionsPath = null, auditPath = null, ledgerPath = null, inboxDismissPath = null, fetchImpl = null, cdpImpl = null, onTakeoverClaim = null, onTakeoverExpire = null, onTakeoverEnd = null, takeoverNow = null, wecomExec = null, verifyFile = null } = {}) {
211
212
  // wecomExec:企业微信命令行工具的执行器注入(只为可测;生产走默认 execFile)
212
213
  // verifyFile:凭据验证记录的落点注入(只为可测;生产 = ~/.sbot/verify.json)
213
214
  const vopts = verifyFile ? { file: verifyFile } : undefined
@@ -313,6 +314,30 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
313
314
  else { activityBuf.push(ev); if (activityBuf.length > ACT_MAX) activityBuf.shift() }
314
315
  }
315
316
  const recentActivity = () => activityBuf.slice().reverse() // newest-first for the console
317
+ // 账本落点注入**只为可测**(同反馈本):单测写默认路径会读到本机真实账本。
318
+ const ledgerFile = () => ledgerPath || path.join(os.homedir(), '.sbot', 'turns.jsonl')
319
+ const ledgerTail = dashboard.createLedgerTail(ledgerFile()) // 按文件大小与修改时刻缓存:账本只在一轮结束时才变
320
+ // 启动即用账本最近的轮次垫底:此前 daemon 一重启(含每次发版)概览的活动流就是空的,
321
+ // 要等下一轮才有东西看。id 与引擎同形,之后真实事件进来只会原地覆盖、不会重复。
322
+ try {
323
+ let groupsRaw = {}
324
+ try { groupsRaw = readRaw().groups || {} } catch {}
325
+ for (const ev of dashboard.ledgerActivity(ledgerTail.rows(), groupsRaw, 20)) recordActivity(ev)
326
+ } catch { /* 回填是锦上添花,读不了就空着 */ }
327
+ const inboxDismiss = dashboard.createDismissStore({ file: inboxDismissPath || path.join(os.homedir(), '.sbot', 'inbox-dismissed.json'), log })
328
+ let lastHealth = null // 最近一次 buildState 的健康检查,给只算收件箱的 /api/inbox 用(那里不重跑探针)
329
+ // 「需要你处理」:从 state 各区取数(反馈本 / 预览 / 残留进程 / 健康)+ 账本 + 接管票,不再各算一遍
330
+ const buildInboxFor = ({ issues, previews, leftovers, health }, raw) => {
331
+ try {
332
+ return dashboard.buildInbox({
333
+ ledgerRows: ledgerTail.rows(),
334
+ issues, previews, leftovers, health,
335
+ takeovers: takeoverStore.pending(),
336
+ groups: (raw && raw.groups) || {},
337
+ dismissed: inboxDismiss.set(),
338
+ })
339
+ } catch (e) { log('inbox: 汇总失败(忽略): ' + (e && e.message)); return [] }
340
+ }
316
341
 
317
342
  // Disband hardening: server-enforced two-phase with a one-time
318
343
  // token — a bare &confirm=1 must never skip the in-group notice/cool-down.
@@ -344,6 +369,62 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
344
369
  return val
345
370
  }
346
371
 
372
+ // 运行中的群预览服务(`sbot preview`)。**必须在概览里看得见** —— v0.8.1 那个 agent 私装的
373
+ // 静态服务在局域网上挂了一周才被人肉发现,「operator 看不见」正是本技能要治的三个软肋之一。
374
+ // 纯读 ~/.sbot/preview/ 的记录文件(不 spawn、不探网),每个 API 响应都跑得起。
375
+ const previewsState = (raw) => {
376
+ try {
377
+ const t = Date.now()
378
+ // 只列 **running**:'starting' 期间端口还没绑定,列出来等于给一个可能打不开的 URL
379
+ return preview.listRecords(preview.previewDir()).filter((r) => r.status === 'running' && preview.isLive(r)).map((r) => ({
380
+ id: r.id, chatId: r.chatId, name: (raw.groups && raw.groups[r.chatId] && raw.groups[r.chatId].name) || r.chatId,
381
+ label: r.label || r.entry, port: r.port, entry: r.entry,
382
+ serveRoot: preview.displayRoot(r), // 展示用相对路径(与 CLI/doctor 同一份实现)
383
+ single: !!r.single, // 只服务 entry 这一个文件(文件形态)
384
+ served: preview.displayServed(r), // 实际服务到的位置:single ⇒ 到文件,目录 ⇒ 到目录
385
+ projectRoot: !!r.projectRoot, // 服务整个项目目录 ⇒ 前端标风险色
386
+ upMs: t - (r.startedAt || t), lastReqMs: r.lastReqAt ? t - r.lastReqAt : null, reqCount: r.reqCount || 0,
387
+ idleLeftMs: r.idleMs > 0 ? Math.max(0, r.idleMs - (t - (r.lastReqAt || r.startedAt || t))) : null,
388
+ url: preview.buildUrl(r, preview.hostForUrl(r, lanIPv4())),
389
+ }))
390
+ } catch { return [] }
391
+ }
392
+
393
+ // 群内反馈本(群里 /issue 提的)。**未处理的必须在概览里看得见** —— 与上面的预览服务
394
+ // 同一条道理:这本账存在的全部意义是「反馈不会不声不响地消失」,而它要是只躺在本机
395
+ // 文件里没人看,和消失没区别。纯读一个小 JSONL(不 spawn、不探网),与 previews 同量级。
396
+ // ⚠ open 封顶 50 条:正常一天几条,封顶是防「这里有一天变成拖垮每个 API 响应的地方」。
397
+ const issuesState = (raw) => {
398
+ try {
399
+ const book = createIssues(issuesFile(), { host: raw.instance || undefined })
400
+ const all = book.list({ all: true })
401
+ const open = all.filter((i) => i.status === 'open').reverse() // 新的在上:反馈按「最近发生什么」读
402
+ const closed = all.filter((i) => i.status === 'closed').reverse()
403
+ const trim = (i) => ({ id: i.id, ts: i.ts, chatId: i.chatId, group: i.group, sender: i.sender, text: i.text, version: i.version, note: i.note, fp: fingerprint(i.turn) })
404
+ return { host: book.host, open: open.slice(0, 50).map(trim), closed: closed.slice(0, 5).map(trim), openCount: open.length, closedCount: closed.length }
405
+ } catch { return { host: '', open: [], closed: [], openCount: 0, closedCount: 0 } }
406
+ }
407
+
408
+ // 遗留进程(v0.11.108):agent 在对话轮次里用后台方式起的 shell,轮次结束后挂到 pid 1 下继续跑。
409
+ // **必须在概览里看得见** —— 与预览服务 / 反馈本同一条道理:2026-10-09 在生产机翻出一条每 5 秒
410
+ // 打线上云函数的循环,零 CPU、零报警地跑了 35 天。走 30 秒 TTL 缓存(每次 /api/state 都问,ps 一次
411
+ // 几十毫秒,不必每次都跑)。⚠ **不下发完整 command**:它带 shell 快照路径、/tmp 落点等机器布局
412
+ // (零工具名约束),页面只需要 summary(agent 真正写的那条命令)+ 年龄 + pid。
413
+ // supported:false(Windows 没有 ps 也没有这套快照)⇒ rows 为空,前端一个字不出现。
414
+ const leftoversState = async () => {
415
+ try {
416
+ const r = await leftover.cachedList()
417
+ return { supported: !!r.supported, rows: (r.rows || []).map((x) => ({ pid: x.pid, ageSec: x.ageSec, age: x.age, summary: x.summary })), error: r.error || null }
418
+ } catch { return { supported: false, rows: [], error: null } }
419
+ }
420
+
421
+ // 只算收件箱(轮次结束、忽略之后控制台来取这一块):不走 buildState —— 那里有 CLI 探针、
422
+ // 逐群读项目插件与设计卡,一轮一次全量太重。健康检查借上一次 buildState 的结果(概览每分钟刷新一次)。
423
+ const inboxOnly = async () => {
424
+ const raw = readRaw()
425
+ return buildInboxFor({ issues: issuesState(raw), previews: previewsState(raw), leftovers: await leftoversState(), health: lastHealth }, raw)
426
+ }
427
+
347
428
  async function buildState() {
348
429
  const cfg = loadConfig(configPath)
349
430
  const raw = readRaw()
@@ -424,6 +505,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
424
505
  }),
425
506
  ]
426
507
  const health = { rows, fails: rows.filter((r) => r.status === 'fail').length, warns: rows.filter((r) => r.status === 'warn').length }
508
+ lastHealth = health
427
509
  const feishu = raw.feishu ? {
428
510
  appId: isEnvRef(raw.feishu.appId) ? '' : (raw.feishu.appId || ''),
429
511
  appIdRef: isEnvRef(raw.feishu.appId) ? raw.feishu.appId.slice(4) : '',
@@ -462,7 +544,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
462
544
  replyInThread: raw.slack.replyInThread !== false,
463
545
  commandAlias: raw.slack.commandAlias || '!',
464
546
  } : null
465
- return {
547
+ const state = {
466
548
  configPath: cfg._path,
467
549
  health,
468
550
  providers,
@@ -495,54 +577,11 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
495
577
  proxy: raw.proxy && raw.proxy.http ? { http: raw.proxy.http, noProxy: raw.proxy.noProxy || '' } : null,
496
578
  live: getLiveState ? getLiveState() : { running: false },
497
579
  conn, // 各平台连接健康度字典 { feishu, wecom }(daemon 运行时);未运行为 null
498
- // 运行中的群预览服务(`sbot preview`)。**必须在概览里看得见** —— v0.8.1 那个 agent 私装的
499
- // 静态服务在局域网上挂了一周才被人肉发现,「operator 看不见」正是本技能要治的三个软肋之一。
500
- // 纯读 ~/.sbot/preview/ 的记录文件(不 spawn、不探网),每个 API 响应都跑得起。
501
- previews: (() => {
502
- try {
503
- const t = Date.now()
504
- // 只列 **running**:'starting' 期间端口还没绑定,列出来等于给一个可能打不开的 URL
505
- return preview.listRecords(preview.previewDir()).filter((r) => r.status === 'running' && preview.isLive(r)).map((r) => ({
506
- id: r.id, chatId: r.chatId, name: (raw.groups && raw.groups[r.chatId] && raw.groups[r.chatId].name) || r.chatId,
507
- label: r.label || r.entry, port: r.port, entry: r.entry,
508
- serveRoot: preview.displayRoot(r), // 展示用相对路径(与 CLI/doctor 同一份实现)
509
- single: !!r.single, // 只服务 entry 这一个文件(文件形态)
510
- served: preview.displayServed(r), // 实际服务到的位置:single ⇒ 到文件,目录 ⇒ 到目录
511
- projectRoot: !!r.projectRoot, // 服务整个项目目录 ⇒ 前端标风险色
512
- upMs: t - (r.startedAt || t), lastReqMs: r.lastReqAt ? t - r.lastReqAt : null, reqCount: r.reqCount || 0,
513
- idleLeftMs: r.idleMs > 0 ? Math.max(0, r.idleMs - (t - (r.lastReqAt || r.startedAt || t))) : null,
514
- url: preview.buildUrl(r, preview.hostForUrl(r, lanIPv4())),
515
- }))
516
- } catch { return [] }
517
- })(),
580
+ previews: previewsState(raw),
518
581
 
519
- // 群内反馈本(群里 /issue 提的)。**未处理的必须在概览里看得见** —— 与上面的预览服务
520
- // 同一条道理:这本账存在的全部意义是「反馈不会不声不响地消失」,而它要是只躺在本机
521
- // 文件里没人看,和消失没区别。纯读一个小 JSONL(不 spawn、不探网),与 previews 同量级。
522
- // ⚠ open 封顶 50 条:正常一天几条,封顶是防「这里有一天变成拖垮每个 API 响应的地方」。
523
- issues: (() => {
524
- try {
525
- const book = createIssues(issuesFile(), { host: raw.instance || undefined })
526
- const all = book.list({ all: true })
527
- const open = all.filter((i) => i.status === 'open').reverse() // 新的在上:反馈按「最近发生什么」读
528
- const closed = all.filter((i) => i.status === 'closed').reverse()
529
- const trim = (i) => ({ id: i.id, ts: i.ts, chatId: i.chatId, group: i.group, sender: i.sender, text: i.text, version: i.version, note: i.note, fp: fingerprint(i.turn) })
530
- return { host: book.host, open: open.slice(0, 50).map(trim), closed: closed.slice(0, 5).map(trim), openCount: open.length, closedCount: closed.length }
531
- } catch { return { host: '', open: [], closed: [], openCount: 0, closedCount: 0 } }
532
- })(),
582
+ issues: issuesState(raw),
533
583
 
534
- // 遗留进程(v0.11.108):agent 在对话轮次里用后台方式起的 shell,轮次结束后挂到 pid 1 下继续跑。
535
- // **必须在概览里看得见** —— 与预览服务 / 反馈本同一条道理:2026-10-09 在生产机翻出一条每 5 秒
536
- // 打线上云函数的循环,零 CPU、零报警地跑了 35 天。走 30 秒 TTL 缓存(每次 /api/state 都问,ps 一次
537
- // 几十毫秒,不必每次都跑)。⚠ **不下发完整 command**:它带 shell 快照路径、/tmp 落点等机器布局
538
- // (零工具名约束),页面只需要 summary(agent 真正写的那条命令)+ 年龄 + pid。
539
- // supported:false(Windows 没有 ps 也没有这套快照)⇒ rows 为空,前端一个字不出现。
540
- leftovers: await (async () => {
541
- try {
542
- const r = await leftover.cachedList()
543
- return { supported: !!r.supported, rows: (r.rows || []).map((x) => ({ pid: x.pid, ageSec: x.ageSec, age: x.age, summary: x.summary })), error: r.error || null }
544
- } catch { return { supported: false, rows: [], error: null } }
545
- })(),
584
+ leftovers: await leftoversState(),
546
585
 
547
586
  codexStatus: codexStatusCache ? { login: codexStatusCache.login, usage: codexStatusCache.usage } : null, // 前端加载回填,codex 卡片刷新不丢
548
587
  claudeStatus: claudeStatusCache ? claudeStatusCache.login : null, // 同上,Claude 订阅登录态
@@ -571,6 +610,8 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
571
610
  }
572
611
  })(),
573
612
  }
613
+ state.inbox = buildInboxFor(state, raw)
614
+ return state
574
615
  }
575
616
 
576
617
  async function testProvider(name, prompt) {
@@ -752,6 +793,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
752
793
  return
753
794
  }
754
795
  if (p === '/api/state' && req.method === 'GET') return sendJSON(res, 200, await buildState())
796
+ if (p === '/api/inbox' && req.method === 'GET') return sendJSON(res, 200, { inbox: await inboxOnly() })
755
797
  // Recent activity for the console to seed/merge on load + SSE reconnect (下一步 ②).
756
798
  // Lightweight + kept off /api/state so mutations don't re-ship it every write.
757
799
  if (p === '/api/activity' && req.method === 'GET') return sendJSON(res, 200, { activity: recentActivity() })
@@ -1606,6 +1648,14 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1606
1648
  : '🔒 本群已恢复数据遮盖:回复里的手机号/邮箱将显示为遮盖标记')
1607
1649
  return sendJSON(res, 200, await buildState())
1608
1650
  }
1651
+ // 「需要你处理」里忽略一条。只有失败轮次能忽略:其余几类各有自己的处理路径(反馈本标已处理、
1652
+ // 结束进程、停预览),在这里「忽略」只会把还在的问题藏起来。只回收件箱,前端只重画这一块。
1653
+ if (p === '/api/inbox/dismiss' && req.method === 'POST') {
1654
+ const { key } = await body(req)
1655
+ if (typeof key !== 'string' || !key.startsWith('failed:') || key.length > 300) return sendJSON(res, 400, { error: '这一条不能忽略' })
1656
+ inboxDismiss.add(key)
1657
+ return sendJSON(res, 200, { inbox: await inboxOnly() })
1658
+ }
1609
1659
  // 群预览服务:手动停一个(概览「预览服务」区的停止按钮)。控制台停 ⇒ **发群一行告知**
1610
1660
  // (群成员可能正开着那个链接,悄悄拔掉会让人以为 bot 坏了);记录由这里写 —— 「谁杀谁写」。
1611
1661
  if (p === '/api/previews/stop' && req.method === 'POST') {
@@ -73,6 +73,13 @@ function createTakeoverStore({ ttlMs = DEFAULT_TTL_MS, now = Date.now, onClaimed
73
73
  session(sid) { prune(); return sessions.get(String(sid || '')) || null },
74
74
  revokeSession(sid) { return endSession(String(sid || ''), 'ended') },
75
75
  revokeAll() { const n = sessions.size + links.size; for (const k of [...sessions.keys()]) endSession(k, 'ended'); for (const l of links.values()) if (l.timer) clearTimeout(l.timer); links.clear(); return n },
76
+ // 群里还挂着、没人来认领的票(控制台「需要你处理」用):只算挂了群卡的票 —— 那是 agent 在群里求助;
77
+ // 管理员自己在控制台签的没有卡,不算。只给元数据,不给令牌。
78
+ pending() {
79
+ prune()
80
+ return [...links.values()].filter((l) => !l.usedAt && l.meta && l.meta.chatId)
81
+ .map((l) => ({ id: l.id, at: l.at, exp: l.exp, chatId: l.meta.chatId, pageTitle: l.meta.pageTitle || '', pageUrl: l.meta.pageUrl || '' }))
82
+ },
76
83
  // 摘要(测试与排障用,不暴露令牌本身)
77
84
  stats() { prune(); return { links: links.size, unused: [...links.values()].filter((l) => !l.usedAt).length, sessions: sessions.size } },
78
85
  }