sintbot 1.3.0 → 1.4.1
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 +50 -0
- package/CHANGELOG.json +50 -0
- package/README.md +138 -20
- package/README.zh-CN.md +144 -20
- package/assets/design-styles/acid-pop.md +155 -0
- package/assets/design-styles/archive-index.md +161 -0
- package/assets/design-styles/art-deco.md +164 -0
- package/assets/design-styles/cinematic-noir.md +178 -0
- package/assets/design-styles/editorial.md +121 -0
- package/assets/design-styles/geometric-pop.md +122 -0
- package/assets/design-styles/hand-drawn.md +117 -0
- package/assets/design-styles/islamic-geometry.md +155 -0
- package/assets/design-styles/liquid-glass.md +183 -0
- package/assets/design-styles/neo-chinese.md +136 -0
- package/assets/design-styles/precision-mono.md +156 -0
- package/assets/design-styles/quiet-luxury.md +163 -0
- package/assets/design-styles/riso-print.md +116 -0
- package/assets/design-styles/soft-friendly.md +174 -0
- package/assets/design-styles/swiss-grid.md +156 -0
- package/assets/design-styles/tech-dark.md +182 -0
- package/bin/sintbot-v2.js +291 -263
- package/docs/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/install.md +80 -48
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
- package/docs/zh-CN/install.md +80 -48
- package/install.sh +34 -81
- package/package.json +60 -5
- package/src/agent/claudecode.js +32 -3
- package/src/agent/claudemodels.js +60 -0
- package/src/agent/claudestatus.js +74 -0
- package/src/agent/claudeusage.js +200 -0
- package/src/agent/codexapp.js +21 -4
- package/src/agent/codexcode.js +8 -3
- package/src/agent/codexstatus.js +181 -0
- package/src/agent/privatecore.js +1 -1
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2configcontrol.js +214 -0
- package/src/c2console.js +149 -0
- package/src/c2incident.js +125 -0
- package/src/c2install.js +247 -37
- package/src/c2migration.js +100 -0
- package/src/c2multigroupcapacity.js +103 -0
- package/src/c2multigroupservice.js +418 -0
- package/src/cdp.js +254 -0
- package/src/config.js +13 -2
- package/src/core/c2durablejournal.js +26 -9
- package/src/core/c2durableruntime.js +104 -1
- package/src/core/c2inputrejection.js +45 -0
- package/src/core/c2localeffectexecutor.js +12 -1
- package/src/core/c2payloadhandles.js +11 -0
- package/src/core/c2persistrelation.js +20 -0
- package/src/core/c2persiststate.js +28 -2
- package/src/core/c2platformrecoverysender.js +1 -1
- package/src/core/c2sourcequarantine.js +56 -1
- package/src/core/c2terminalprune.js +2 -1
- package/src/core/engineslotsadapter.js +108 -0
- package/src/core/issues.js +185 -0
- package/src/core/ledger.js +30 -0
- package/src/core/proxy.js +424 -0
- package/src/core/store.js +12 -0
- package/src/core/v2engine.js +427 -31
- package/src/design.js +5161 -0
- package/src/gitrepo.js +150 -0
- package/src/leftover.js +174 -0
- package/src/platform/slack.js +1294 -0
- package/src/platform/wecom.js +961 -0
- package/src/platform/wecomcli.js +128 -0
- package/src/platform/wecomname.js +43 -0
- package/src/platform/wecompoll.js +241 -0
- package/src/preview.js +581 -0
- package/src/projectmemory.js +175 -0
- package/src/ui/auth.js +31 -0
- package/src/ui/browser.html +392 -0
- package/src/ui/browserroutes.js +234 -0
- package/src/ui/console.html +5719 -0
- package/src/ui/dashboard.js +168 -0
- package/src/ui/feishulogin.js +136 -0
- package/src/ui/serverbase.js +2371 -0
- package/src/ui/sessions.js +78 -0
- package/src/ui/takeover.js +108 -0
- package/src/usage.js +144 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Codex subscription introspection — login status + rate-limit (quota) usage, for the
|
|
2
|
+
// console's codex model card. Two headless probes, neither needs an API key:
|
|
3
|
+
// loginStatus() → `codex login status` (exit 0 = logged in; prints the auth mode)
|
|
4
|
+
// rateLimits() → short-lived `codex app-server` JSON-RPC (newline-framed over stdio):
|
|
5
|
+
// initialize → initialized → account/rateLimits/read → quit.
|
|
6
|
+
// The exec event stream can't carry rate limits (exec responses lack the x-codex-*
|
|
7
|
+
// headers, openai/codex#14728), so app-server is the only headless source. Protocol
|
|
8
|
+
// shape confirmed against a working driver; exact behaviour is verified on the target
|
|
9
|
+
// machine. Both return best-effort and never throw.
|
|
10
|
+
|
|
11
|
+
const { spawn } = require('node:child_process')
|
|
12
|
+
const { IS_WIN, resolveSpawn } = require('./winexec')
|
|
13
|
+
|
|
14
|
+
// `codex login status` output → { loggedIn, mode }. The line typically reads
|
|
15
|
+
// "Logged in using ChatGPT" (subscription) or mentions an API key.
|
|
16
|
+
function parseLoginStatus(stdout, code) {
|
|
17
|
+
const text = (stdout || '').trim()
|
|
18
|
+
let mode = ''
|
|
19
|
+
if (/chatgpt/i.test(text)) mode = 'chatgpt'
|
|
20
|
+
else if (/api[\s-]?key/i.test(text)) mode = 'apikey'
|
|
21
|
+
return { loggedIn: code === 0, mode, text: text.slice(0, 200) }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function loginStatus(bin = 'codex', timeoutMs = 8000) {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const { file, spawnArgs } = resolveSpawn(bin, ['login', 'status'])
|
|
27
|
+
let out = '', err = '', settled = false
|
|
28
|
+
const done = (v) => { if (settled) return; settled = true; clearTimeout(timer); resolve(v) }
|
|
29
|
+
const child = spawn(file, spawnArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
30
|
+
const timer = setTimeout(() => { try { child.kill() } catch {} done({ ok: false, error: 'timeout' }) }, timeoutMs)
|
|
31
|
+
child.stdout.on('data', (c) => { out += c.toString('utf8') })
|
|
32
|
+
child.stderr.on('data', (c) => { err += c.toString('utf8') })
|
|
33
|
+
child.on('error', (e) => done({ ok: false, error: e.code === 'ENOENT' ? "'codex' not found on PATH" : e.message }))
|
|
34
|
+
child.on('close', (code) => done({ ok: true, ...parseLoginStatus(out || err, code) }))
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// TTL 缓存(与 claudestatus 同款):doctor 是一次性的,但控制台 /api/state 每次动作 + 60s 自动刷新
|
|
39
|
+
// 都会重建健康行,每次都 spawn 一个进程没必要。
|
|
40
|
+
const LOGIN_CACHE = new Map() // bin -> { at, val }
|
|
41
|
+
async function cachedLoginStatus(bin = 'codex', ttl = 60_000) {
|
|
42
|
+
const c = LOGIN_CACHE.get(bin)
|
|
43
|
+
if (c && Date.now() - c.at < ttl) return c.val
|
|
44
|
+
const val = await loginStatus(bin)
|
|
45
|
+
LOGIN_CACHE.set(bin, { at: Date.now(), val })
|
|
46
|
+
return val
|
|
47
|
+
}
|
|
48
|
+
function clearLoginCache() { LOGIN_CACHE.clear() }
|
|
49
|
+
|
|
50
|
+
// account/rateLimits/read response → compact usage. Windows: primary = 5h rolling
|
|
51
|
+
// window, secondary = weekly (see docs/CODEX-RUNNER.md). resetsAt is an epoch (secs).
|
|
52
|
+
function mapRateLimits(result) {
|
|
53
|
+
const rl = result && result.rateLimits
|
|
54
|
+
if (!rl) return null
|
|
55
|
+
const w = (x) => (x && typeof x.usedPercent === 'number')
|
|
56
|
+
? { usedPercent: x.usedPercent, windowMins: x.windowDurationMins, resetsAt: x.resetsAt } : null
|
|
57
|
+
const out = { primary: w(rl.primary), secondary: w(rl.secondary), planType: rl.planType || '' }
|
|
58
|
+
if (rl.credits) out.credits = { balance: rl.credits.balance, hasCredits: !!rl.credits.hasCredits, unlimited: !!rl.credits.unlimited }
|
|
59
|
+
return (out.primary || out.secondary || out.credits) ? out : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function killTree(child) {
|
|
63
|
+
if (!child || !child.pid) return
|
|
64
|
+
if (IS_WIN) { try { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }) } catch {} }
|
|
65
|
+
else { try { child.kill('SIGKILL') } catch {} }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Generic short-lived `codex app-server` JSON-RPC call: initialize → initialized →
|
|
69
|
+
// <method> → map → quit. One JSON object per line. Returns mapped value or null
|
|
70
|
+
// (never throws). Cache results upstream — each call spawns a process and may hit
|
|
71
|
+
// the ChatGPT backend.
|
|
72
|
+
function appServerCall(bin, method, params, mapFn, timeoutMs = 12000) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
let child
|
|
75
|
+
try {
|
|
76
|
+
const { file, spawnArgs } = resolveSpawn(bin, ['app-server'])
|
|
77
|
+
child = spawn(file, spawnArgs, { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true })
|
|
78
|
+
} catch { return resolve(null) }
|
|
79
|
+
|
|
80
|
+
let settled = false
|
|
81
|
+
const finish = (v) => { if (settled) return; settled = true; clearTimeout(timer); killTree(child); resolve(v) }
|
|
82
|
+
const timer = setTimeout(() => finish(null), timeoutMs)
|
|
83
|
+
child.on('error', () => finish(null))
|
|
84
|
+
child.stdin.on('error', () => {})
|
|
85
|
+
|
|
86
|
+
const send = (obj) => { try { child.stdin.write(JSON.stringify(obj) + '\n') } catch {} }
|
|
87
|
+
const INIT_ID = 1, CALL_ID = 2
|
|
88
|
+
send({ jsonrpc: '2.0', id: INIT_ID, method: 'initialize', params: { clientInfo: { name: 'sbot', title: 'sbot', version: '0.1.0' }, capabilities: { experimentalApi: true } } })
|
|
89
|
+
|
|
90
|
+
let buf = ''
|
|
91
|
+
child.stdout.on('data', (chunk) => {
|
|
92
|
+
buf += chunk.toString('utf8')
|
|
93
|
+
let nl
|
|
94
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
95
|
+
const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1)
|
|
96
|
+
if (!line) continue
|
|
97
|
+
let msg; try { msg = JSON.parse(line) } catch { continue }
|
|
98
|
+
// Responses carry id + result/error and NO method; ignore notifications /
|
|
99
|
+
// server→client requests (none expected without a running turn).
|
|
100
|
+
if (msg.method) continue
|
|
101
|
+
if (msg.id === INIT_ID) { send({ jsonrpc: '2.0', method: 'initialized' }); send({ jsonrpc: '2.0', id: CALL_ID, method, params: params || {} }) }
|
|
102
|
+
else if (msg.id === CALL_ID) return finish(msg.error ? null : mapFn(msg.result))
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Subscription rate limits (5h/weekly windows) — see mapRateLimits.
|
|
109
|
+
function rateLimits(bin = 'codex', timeoutMs = 12000) {
|
|
110
|
+
return appServerCall(bin, 'account/rateLimits/read', {}, mapRateLimits, timeoutMs)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// model/list 响应 → 精简模型清单(过滤 hidden;efforts 兼容字符串/对象两形)。
|
|
114
|
+
// 空/坏返回 → null(上游回退内置预设)。协议 v2,codex 0.144.3 真机验证(2026-07)。
|
|
115
|
+
function mapModels(result) {
|
|
116
|
+
const data = (result && result.data) || []
|
|
117
|
+
const out = []
|
|
118
|
+
for (const x of data) {
|
|
119
|
+
if (!x || x.hidden || !(x.model || x.id)) continue
|
|
120
|
+
out.push({
|
|
121
|
+
model: x.model || x.id,
|
|
122
|
+
name: x.displayName || x.model || x.id,
|
|
123
|
+
desc: x.description || '',
|
|
124
|
+
isDefault: !!x.isDefault,
|
|
125
|
+
efforts: Array.isArray(x.supportedReasoningEfforts)
|
|
126
|
+
? x.supportedReasoningEfforts.map((e) => (e && e.reasoningEffort) || e).filter((e) => typeof e === 'string')
|
|
127
|
+
: [],
|
|
128
|
+
defEffort: x.defaultReasoningEffort || '',
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return out.length ? out : null
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// 当前账号可用的 codex 模型(控制台 codex 面板下拉的数据源)。
|
|
135
|
+
function listModels(bin = 'codex', timeoutMs = 12000) {
|
|
136
|
+
return appServerCall(bin, 'model/list', {}, mapModels, timeoutMs)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 探测不到(未登录/老版本/超时)时的内置预设 —— 2026-07 实测自 codex 0.144.3 的
|
|
140
|
+
// model/list 真实返回;随 sbot 版本按需更新。UI 恒留「自定义」逃生口。
|
|
141
|
+
// ⚠ efforts 已剔除 'ultra':model/list 把它列进 sol/terra,但服务端不认 —— 2026-07-20
|
|
142
|
+
// 真机用非法值触发参数校验拿到权威合法集:'none','minimal','low','medium','high','xhigh','max'。
|
|
143
|
+
// 前端另有 RE_LEGAL 兜底过滤(live 探测结果同样会带 ultra,源头改不了)。
|
|
144
|
+
const PRESET_MODELS = [
|
|
145
|
+
{ model: 'gpt-5.6-sol', name: 'GPT-5.6-Sol', desc: 'Latest frontier agentic coding model.', isDefault: true, efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defEffort: 'low' },
|
|
146
|
+
{ model: 'gpt-5.6-terra', name: 'GPT-5.6-Terra', desc: 'Balanced agentic coding model for everyday work.', isDefault: false, efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defEffort: 'medium' },
|
|
147
|
+
{ model: 'gpt-5.6-luna', name: 'GPT-5.6-Luna', desc: 'Fast and affordable agentic coding model.', isDefault: false, efforts: ['low', 'medium', 'high', 'xhigh', 'max'], defEffort: 'medium' },
|
|
148
|
+
{ model: 'gpt-5.5', name: 'GPT-5.5', desc: 'Frontier model for complex coding and research.', isDefault: false, efforts: ['low', 'medium', 'high', 'xhigh'], defEffort: 'medium' },
|
|
149
|
+
{ model: 'gpt-5.4', name: 'GPT-5.4', desc: 'Strong model for everyday coding.', isDefault: false, efforts: ['low', 'medium', 'high', 'xhigh'], defEffort: 'medium' },
|
|
150
|
+
{ model: 'gpt-5.4-mini', name: 'GPT-5.4-Mini', desc: 'Small, fast, and cost-efficient.', isDefault: false, efforts: ['low', 'medium', 'high', 'xhigh'], defEffort: 'medium' },
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
// A window's label DERIVED from its duration, not its position. Codex changed the shape:
|
|
154
|
+
// a window that used to sit in `primary` as 5h (≈300min) now arrives as the weekly window
|
|
155
|
+
// (10080min) with `secondary: null`, so the old positional `primary='5h'` mislabeled a
|
|
156
|
+
// weekly window as "5h". Read windowMins instead: ~300 ⇒ "5h", 10080 ⇒ "周".
|
|
157
|
+
// 窗口名(v0.11.8 起用于紧凑格式 `7d:12%`,故周窗口标 7d 而非「周」,与 Claude 卡一致)
|
|
158
|
+
function windowLabel(mins) {
|
|
159
|
+
if (!mins) return '窗口'
|
|
160
|
+
if (mins >= 1440) return `${Math.round(mins / 1440)}d`
|
|
161
|
+
return `${Math.round(mins / 60)}h`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Mapped rate-limits → a compact one-line string for the group's /hud reply.
|
|
165
|
+
// Mirrors the console's fmtUsage (client-side) but runs in the daemon.
|
|
166
|
+
// Date.now() is real here (Node), so the reset countdown is live.
|
|
167
|
+
// 紧凑一行(v0.11.8 与 Claude 卡同口径):`5h:98% (1.8h) │ 7d:12% (6.1d)`。
|
|
168
|
+
// ⚠ **百分比是「已用」不是「剩余」** —— 这里是群内 /hud 对 codex 群的实际渲染,与
|
|
169
|
+
// claudeusage.formatClaudeUsage / console 的 fmtUsage / usage.js 的 minimax 行必须同口径,
|
|
170
|
+
// 漏改任何一处就会出现同一个数字两种含义(98% 既可能是快满也可能是还剩很多)。
|
|
171
|
+
function formatUsage(u) {
|
|
172
|
+
if (!u) return null
|
|
173
|
+
const dur = (ms) => { const trim = (v) => (v < 10 ? String(Number(v.toFixed(1))) : String(Math.round(v))); const h = ms / 3600e3; if (h >= 24) return `${trim(h / 24)}d`; if (h >= 1) return `${trim(h)}h`; return `${Math.max(1, Math.round(ms / 60e3))}m` }
|
|
174
|
+
const reset = (w) => { if (!w || w.resetsAt == null) return ''; const ms = w.resetsAt > 1e9 ? w.resetsAt * 1000 - Date.now() : w.resetsAt * 1000; return (ms > 0 && ms < 9 * 864e5) ? ` (${dur(ms)})` : '' }
|
|
175
|
+
const win = (w) => w ? `${windowLabel(w.windowMins)}:${Math.min(100, Math.max(0, Math.round(w.usedPercent || 0)))}%${reset(w)}` : ''
|
|
176
|
+
const parts = [win(u.primary), win(u.secondary)].filter(Boolean)
|
|
177
|
+
if (u.credits && u.credits.hasCredits && u.credits.balance != null) parts.push(`credits ${u.credits.balance}`)
|
|
178
|
+
return parts.join(' │ ') || null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { loginStatus, cachedLoginStatus, clearLoginCache, rateLimits, listModels, parseLoginStatus, mapRateLimits, mapModels, formatUsage, windowLabel, PRESET_MODELS }
|
package/src/agent/privatecore.js
CHANGED
|
@@ -3,7 +3,7 @@ const fs = require('node:fs')
|
|
|
3
3
|
const path = require('node:path')
|
|
4
4
|
const { createHash } = require('node:crypto')
|
|
5
5
|
|
|
6
|
-
const EXPECTED_SHA256 = '
|
|
6
|
+
const EXPECTED_SHA256 = '1ab99efd538588accb90756aff9843063337cf666a679b2897630477c912bdf0'
|
|
7
7
|
let bytes
|
|
8
8
|
try {
|
|
9
9
|
bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
|
|
Binary file
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs')
|
|
4
|
+
const path = require('node:path')
|
|
5
|
+
const { isDeepStrictEqual } = require('node:util')
|
|
6
|
+
const { atomicWriteJSON } = require('./core/store')
|
|
7
|
+
const { loadInstallationFromRaw, assertJournalScope, assertRunnerExecutable,
|
|
8
|
+
MANIFEST, VALIDATORS } = require('./c2install')
|
|
9
|
+
const { C2DurableJournal } = require('./core/c2durablejournal')
|
|
10
|
+
const { assertNoLegacyGroupState } = require('./c2migration')
|
|
11
|
+
|
|
12
|
+
const GLOBAL_TASK_KEYS = ['providers', 'agent', 'defaultProvider', 'timezone', 'brand']
|
|
13
|
+
const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
|
|
14
|
+
|
|
15
|
+
// Synchronous validation/application around one atomic configuration write.
|
|
16
|
+
// A group-scoped edit waits only for that group to settle. Changing a group's
|
|
17
|
+
// model, project or backend clears its session before the new configuration
|
|
18
|
+
// is written, so no later turn can resume a session from another backend.
|
|
19
|
+
// It never moves a journal, settles a receipt or reconnects the platform.
|
|
20
|
+
function createC2ConfigControl({ installation, getBindings, getHeld = () => new Map(),
|
|
21
|
+
assertWriterLock, store = null, activateRoute = null, probeRunner = assertRunnerExecutable,
|
|
22
|
+
log = () => {} } = {}) {
|
|
23
|
+
if (!installation?.cfg?._path || typeof getBindings !== 'function' ||
|
|
24
|
+
typeof getHeld !== 'function' ||
|
|
25
|
+
typeof assertWriterLock !== 'function' ||
|
|
26
|
+
store !== null && typeof store.resetGroupSession !== 'function' ||
|
|
27
|
+
activateRoute !== null && typeof activateRoute !== 'function' ||
|
|
28
|
+
typeof probeRunner !== 'function') {
|
|
29
|
+
throw new TypeError('Configuration control needs live installation authority')
|
|
30
|
+
}
|
|
31
|
+
const { cfg, routes, stateDir } = installation
|
|
32
|
+
let prepared = null
|
|
33
|
+
|
|
34
|
+
function held() {
|
|
35
|
+
if (assertWriterLock() !== true) throw new Error('Service writer authority is unavailable')
|
|
36
|
+
}
|
|
37
|
+
function assertSettled(chatId, binding) {
|
|
38
|
+
if (!binding) return
|
|
39
|
+
if (binding.gate.blocked || binding.runtime.blocked ||
|
|
40
|
+
[...binding.engine.rts.values()].some(rt => rt.v2NormalRouteActive ||
|
|
41
|
+
rt.v2AdmissionPending || rt.v2Controller || rt.v2QueueReceiptIds?.length)) {
|
|
42
|
+
throw new Error('Wait until the group task is settled before changing its task settings')
|
|
43
|
+
}
|
|
44
|
+
assertJournalScope(binding.journal, chatId, { requireSettled: true })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateConfig(raw) {
|
|
48
|
+
prepared = null
|
|
49
|
+
held()
|
|
50
|
+
const next = loadInstallationFromRaw(raw, cfg._path, { dataDir: stateDir,
|
|
51
|
+
allowAddedGroups: !!activateRoute && !!store })
|
|
52
|
+
const bindings = getBindings()
|
|
53
|
+
const current = new Set(routes.map(route => route.chatId))
|
|
54
|
+
if (routes.some(route => !next.routes.some(item => item.chatId === route.chatId))) {
|
|
55
|
+
throw new Error('Removing a configured group requires a stopped migration')
|
|
56
|
+
}
|
|
57
|
+
const added = next.routes.filter(route => !current.has(route.chatId))
|
|
58
|
+
if (added.length && (!activateRoute || !store)) {
|
|
59
|
+
throw new Error('Adding a group requires a service that can open it online')
|
|
60
|
+
}
|
|
61
|
+
const connectionOnly = value => {
|
|
62
|
+
const copy = { ...value }
|
|
63
|
+
delete copy.admins; delete copy.adminOpenId; delete copy.adminName
|
|
64
|
+
return copy
|
|
65
|
+
}
|
|
66
|
+
if (!isDeepStrictEqual(connectionOnly(cfg.feishu), connectionOnly(next.cfg.feishu))) {
|
|
67
|
+
throw new Error('Connection settings require a service restart')
|
|
68
|
+
}
|
|
69
|
+
for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser', 'providers', 'defaultProvider']) {
|
|
70
|
+
if (!isDeepStrictEqual(cfg[key], next.cfg[key])) throw new Error('Connection and filesystem settings require a service restart')
|
|
71
|
+
}
|
|
72
|
+
if (cfg.agent.maxConcurrent !== next.cfg.agent.maxConcurrent) {
|
|
73
|
+
throw new Error('Changing global concurrency requires a service restart')
|
|
74
|
+
}
|
|
75
|
+
const resets = [], activations = [], changed = []
|
|
76
|
+
const heldGroups = getHeld()
|
|
77
|
+
for (const route of routes) {
|
|
78
|
+
const target = next.routes.find(item => item.chatId === route.chatId)
|
|
79
|
+
if (route.group.platform !== target.group.platform) {
|
|
80
|
+
throw new Error('Moving a group to another platform requires an explicit state migration')
|
|
81
|
+
}
|
|
82
|
+
if (heldGroups.has(route.chatId)) {
|
|
83
|
+
// Edits elsewhere proceed; the held group keeps its exact settings.
|
|
84
|
+
if (!isDeepStrictEqual(route.group, target.group)) {
|
|
85
|
+
throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
|
|
86
|
+
}
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
|
|
90
|
+
if (RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))) {
|
|
91
|
+
if (!store) throw new Error('Changing model or project requires session reset authority')
|
|
92
|
+
resets.push(route.chatId)
|
|
93
|
+
if (!target.paused) probeRunner(target.provider, next.cfg)
|
|
94
|
+
}
|
|
95
|
+
if (!bindings.has(route.chatId)) {
|
|
96
|
+
// Only a route paused since startup lacks a binding once serving.
|
|
97
|
+
if (!route.paused) throw new Error('The group is not open yet; wait for the service to finish starting')
|
|
98
|
+
if (!target.paused) {
|
|
99
|
+
if (!activateRoute) throw new Error('An initially paused group must be activated during a service restart')
|
|
100
|
+
activations.push(route.chatId)
|
|
101
|
+
probeRunner(target.provider, next.cfg)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
for (const route of added) {
|
|
106
|
+
assertNoLegacyGroupState({ stateDir, chatId: route.chatId })
|
|
107
|
+
if (fs.existsSync(route.file)) {
|
|
108
|
+
throw new Error('A journal already exists for the new group; preserve it and review it before binding')
|
|
109
|
+
}
|
|
110
|
+
if (!route.paused) probeRunner(route.provider, next.cfg)
|
|
111
|
+
}
|
|
112
|
+
// Global task settings affect every group; a group edit affects only that
|
|
113
|
+
// group. Authentication/profile-only edits never wait for running turns.
|
|
114
|
+
const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
|
|
115
|
+
for (const [chatId, binding] of bindings) {
|
|
116
|
+
if (global || changed.includes(chatId)) assertSettled(chatId, binding)
|
|
117
|
+
}
|
|
118
|
+
prepared = { raw: JSON.stringify(raw), next, resets, activations, added }
|
|
119
|
+
return true
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Runs after validation and before the configuration file is replaced.
|
|
123
|
+
// Every step is idempotent or fails closed; a crash leaves either the old
|
|
124
|
+
// configuration with a fresh session, or a mapped empty journal for review.
|
|
125
|
+
function beforeWrite(raw) {
|
|
126
|
+
held()
|
|
127
|
+
if (!prepared || prepared.raw !== JSON.stringify(raw)) {
|
|
128
|
+
throw new Error('Configuration was not validated against the current service')
|
|
129
|
+
}
|
|
130
|
+
for (const chatId of prepared.resets) store.resetGroupSession(chatId)
|
|
131
|
+
if (prepared.added.length) {
|
|
132
|
+
for (const route of prepared.added) {
|
|
133
|
+
C2DurableJournal.initialize(route.file, {
|
|
134
|
+
state: { version: 2, seq: 0, lanes: {} },
|
|
135
|
+
receipts: { pending: [], completed: [] },
|
|
136
|
+
}, VALIDATORS)
|
|
137
|
+
const journal = C2DurableJournal.open(route.file, VALIDATORS)
|
|
138
|
+
const snapshot = assertJournalScope(journal, route.chatId, { requireSettled: true })
|
|
139
|
+
if (journal.blocked || snapshot.cursor !== 0 || snapshot.eventCount !== 0) {
|
|
140
|
+
throw new Error('New group journal verification failed; preserve the file for review')
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
const manifestFile = path.join(stateDir, MANIFEST)
|
|
144
|
+
const persisted = JSON.parse(fs.readFileSync(manifestFile, 'utf8'))
|
|
145
|
+
const manifest = { version: 1,
|
|
146
|
+
groups: prepared.next.routes.map(({ chatId, file }) => ({ chatId, journal: path.basename(file) })) }
|
|
147
|
+
if (persisted.migrationSha256 !== undefined) manifest.migrationSha256 = persisted.migrationSha256
|
|
148
|
+
atomicWriteJSON(manifestFile, manifest, { mode: 0o600 })
|
|
149
|
+
}
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function applyConfig(raw) {
|
|
154
|
+
if (assertWriterLock() !== true || !prepared || prepared.raw !== JSON.stringify(raw)) {
|
|
155
|
+
throw new Error('Configuration was not validated against the current service')
|
|
156
|
+
}
|
|
157
|
+
const { next, activations, added } = prepared
|
|
158
|
+
const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
|
|
159
|
+
prepared = null
|
|
160
|
+
for (const key of Object.keys(cfg)) delete cfg[key]
|
|
161
|
+
Object.assign(cfg, next.cfg)
|
|
162
|
+
const bindings = getBindings()
|
|
163
|
+
for (const route of routes) {
|
|
164
|
+
const target = next.routes.find(item => item.chatId === route.chatId)
|
|
165
|
+
const groupChanged = !isDeepStrictEqual(route.group, target.group)
|
|
166
|
+
Object.assign(route, target)
|
|
167
|
+
const engine = bindings.get(route.chatId)?.engine
|
|
168
|
+
if (engine && (global || groupChanged)) {
|
|
169
|
+
engine.cfg = { ...cfg, groups: { [route.chatId]: route.group } }
|
|
170
|
+
engine.tz = cfg.timezone
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const route of added) routes.push(route)
|
|
174
|
+
routes.sort((a, b) => a.chatId < b.chatId ? -1 : a.chatId > b.chatId ? 1 : 0)
|
|
175
|
+
for (const route of [...routes.filter(item => activations.includes(item.chatId)),
|
|
176
|
+
...added.filter(item => !item.paused)]) {
|
|
177
|
+
// Opening runs the group's normal recovery gate. Until it finishes the
|
|
178
|
+
// service ignores that group's messages instead of admitting receipts.
|
|
179
|
+
Promise.resolve().then(() => activateRoute(route)).catch(error => {
|
|
180
|
+
log(`group ${route.chatId}: online activation failed: ${error?.message || String(error)}`)
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
return true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// One writer for the console and chat commands: read the file, change it,
|
|
187
|
+
// validate, reset affected sessions, replace atomically, then go live.
|
|
188
|
+
function writeConfig(mutate) {
|
|
189
|
+
held()
|
|
190
|
+
if (typeof mutate !== 'function') throw new TypeError('Configuration change needs a mutation')
|
|
191
|
+
const raw = JSON.parse(fs.readFileSync(cfg._path, 'utf8'))
|
|
192
|
+
mutate(raw)
|
|
193
|
+
validateConfig(raw)
|
|
194
|
+
beforeWrite(raw)
|
|
195
|
+
atomicWriteJSON(cfg._path, raw, { mode: 0o600 })
|
|
196
|
+
applyConfig(raw)
|
|
197
|
+
return raw
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function switchGroupProvider(chatId, name) {
|
|
201
|
+
return writeConfig(raw => {
|
|
202
|
+
const group = raw.groups?.[chatId]
|
|
203
|
+
if (!group) throw new Error('Unknown group')
|
|
204
|
+
if (!cfg.providers?.[name]) throw new Error('Unknown model')
|
|
205
|
+
group.provider = name
|
|
206
|
+
// A pinned model id belongs to the previous provider.
|
|
207
|
+
delete group.model
|
|
208
|
+
})
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = { createC2ConfigControl }
|
package/src/c2console.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const path = require('node:path')
|
|
4
|
+
const { createUiServer } = require('./ui/serverbase')
|
|
5
|
+
const { START_PATH, CALLBACK_PATH } = require('./ui/feishulogin')
|
|
6
|
+
|
|
7
|
+
// A host facade over existing v2 authorities, not a second execution engine.
|
|
8
|
+
// The allowlist applies after the existing console's authentication boundary.
|
|
9
|
+
const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
|
|
10
|
+
'/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
|
|
11
|
+
'/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
|
|
12
|
+
'/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
|
|
13
|
+
'/api/feishu/chats', '/api/feishu/chat-members', START_PATH, CALLBACK_PATH]
|
|
14
|
+
const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
|
|
15
|
+
'/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
|
|
16
|
+
'/api/groups', '/api/groups/provider', '/api/fs/mkdir',
|
|
17
|
+
'/api/groups/order', '/api/groups/policy', '/api/groups/redact',
|
|
18
|
+
'/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
|
|
19
|
+
'/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
|
|
20
|
+
'/api/inbox/dismiss', '/api/issues/status']
|
|
21
|
+
const SUPPORTED_RUNNERS = ['claude', 'codex']
|
|
22
|
+
|
|
23
|
+
function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
|
|
24
|
+
assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
|
|
25
|
+
for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
|
|
26
|
+
validateConfig, applyConfig, beforeWrite, now]) {
|
|
27
|
+
if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
|
|
28
|
+
}
|
|
29
|
+
if ([validateConfig, applyConfig, beforeWrite].some(callback => callback.constructor?.name === 'AsyncFunction')) {
|
|
30
|
+
throw new TypeError('C2 console configuration hooks must be synchronous')
|
|
31
|
+
}
|
|
32
|
+
const startedAt = now()
|
|
33
|
+
const capabilities = Object.freeze({ mode: 'durable-v2',
|
|
34
|
+
reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
|
|
35
|
+
summary: '实时状态、认证管理、停止任务、绑定新群,以及空闲群的模型切换(Codex / Claude Code)和项目目录修改已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务、画布和升级暂不可用。',
|
|
36
|
+
allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
|
|
37
|
+
function held() {
|
|
38
|
+
if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
|
|
39
|
+
}
|
|
40
|
+
const bindings = () => {
|
|
41
|
+
const value = getBindings()
|
|
42
|
+
if (!(value instanceof Map)) throw new TypeError('C2 console bindings must be a Map')
|
|
43
|
+
return value
|
|
44
|
+
}
|
|
45
|
+
function getLiveState() {
|
|
46
|
+
const cfg = getConfig()
|
|
47
|
+
const capacity = getCapacity()
|
|
48
|
+
const heldGroups = getHeld()
|
|
49
|
+
const groups = {}
|
|
50
|
+
let queue = 0
|
|
51
|
+
for (const [chatId, group] of Object.entries(cfg.groups || {})) {
|
|
52
|
+
const binding = bindings().get(chatId)
|
|
53
|
+
const hold = heldGroups.get(chatId)
|
|
54
|
+
const rt = binding?.engine?.rts.get(chatId)
|
|
55
|
+
const snapshot = binding?.journal?.snapshot()
|
|
56
|
+
const pending = snapshot?.receipts?.pending || []
|
|
57
|
+
const queued = snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length || 0
|
|
58
|
+
queue += queued
|
|
59
|
+
groups[chatId] = {
|
|
60
|
+
busy: !!rt?.v2NormalRouteActive,
|
|
61
|
+
finalizing: !!rt?.v2TerminalStarted,
|
|
62
|
+
followups: !!rt?.v2SteerControl && !rt?.v2SteerClosing && !rt?.v2TerminalStarted,
|
|
63
|
+
queue: queued, pending: pending.length,
|
|
64
|
+
queueFaulted: !group.paused && (!binding || (!rt?.v2NormalRouteActive && !!binding.gate?.blocked)),
|
|
65
|
+
waitingForCapacity: !!rt?.v2AdmissionPending,
|
|
66
|
+
sinceMs: rt?.v2StartedAt ? Math.max(0, now() - rt.v2StartedAt) : 0,
|
|
67
|
+
sessionId: binding?.engine?.store?.getSession(chatId) || '',
|
|
68
|
+
// A held group needs the operator: its last result is unknown.
|
|
69
|
+
...(hold ? { held: true, heldReason: hold.reason, heldSinceMs: Math.max(0, now() - hold.since) } : {}),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const activeBindingsReady = Object.entries(cfg.groups || {})
|
|
73
|
+
.filter(([chatId, group]) => !group.paused && !heldGroups.has(chatId))
|
|
74
|
+
.every(([chatId]) => bindings().has(chatId))
|
|
75
|
+
return { running: assertWriterLock() === true && !capacity.closed && activeBindingsReady,
|
|
76
|
+
uptimeMs: Math.max(0, now() - startedAt),
|
|
77
|
+
concurrency: { active: capacity.active, max: capacity.maxConcurrent },
|
|
78
|
+
queue, waiting: capacity.waiting, held: heldGroups.size, groups }
|
|
79
|
+
}
|
|
80
|
+
function getActivity() {
|
|
81
|
+
const cfg = getConfig(), items = []
|
|
82
|
+
for (const [chatId, binding] of bindings()) {
|
|
83
|
+
const snap = binding.journal.snapshot()
|
|
84
|
+
const rt = binding.engine.rts.get(chatId)
|
|
85
|
+
const group = cfg.groups[chatId]?.name || chatId.slice(-8)
|
|
86
|
+
for (const receipt of snap.receipts.pending) {
|
|
87
|
+
const running = rt?.v2RunningJobs?.some(job => job.msg?._c2ReceiptId === receipt.id)
|
|
88
|
+
items.push({ id: receipt.id, chatId, group, ts: receipt.acceptedAt,
|
|
89
|
+
kind: running ? 'start' : 'done', status: running ? 'running' : '等待处理',
|
|
90
|
+
text: running ? '正在处理群消息' : '消息已登记,等待处理', sender: '' })
|
|
91
|
+
}
|
|
92
|
+
for (const receipt of snap.receipts.completed.slice(-30)) {
|
|
93
|
+
// A settled receipt alone does not prove successful answer delivery.
|
|
94
|
+
items.push({ id: receipt.id, chatId, group, ts: receipt.settledAt,
|
|
95
|
+
kind: 'done', status: '已结账', text: '消息处理记录已结账', sender: '' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return items.sort((a, b) => b.ts - a.ts).slice(0, 30)
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
capabilities,
|
|
102
|
+
allows: (method, pathname) => (capabilities.allowed[method] || []).includes(pathname),
|
|
103
|
+
// A provider without a runner field is Claude Code, as everywhere else.
|
|
104
|
+
providerSupported: provider => SUPPORTED_RUNNERS.includes(provider?.runner || 'claude'),
|
|
105
|
+
getLiveState, getActivity,
|
|
106
|
+
validateConfig(raw) { held(); validateConfig(raw) },
|
|
107
|
+
beforeWrite(raw) { held(); beforeWrite(raw) },
|
|
108
|
+
applyConfig(raw) { held(); applyConfig(raw) },
|
|
109
|
+
decorateState(state) {
|
|
110
|
+
state.runtimeCapabilities = capabilities
|
|
111
|
+
state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
|
|
112
|
+
state.leftovers = { supported: false, rows: [], error: null }
|
|
113
|
+
},
|
|
114
|
+
control: {
|
|
115
|
+
providerOf(chatId) {
|
|
116
|
+
const binding = bindings().get(chatId)
|
|
117
|
+
const cfg = getConfig(), group = cfg.groups?.[chatId]
|
|
118
|
+
return binding?.engine?.store?.getProvider(chatId) || group?.provider || cfg.defaultProvider
|
|
119
|
+
},
|
|
120
|
+
stop(chatId) {
|
|
121
|
+
held()
|
|
122
|
+
const binding = bindings().get(chatId)
|
|
123
|
+
if (!binding) throw new Error('此群未运行,未执行停止')
|
|
124
|
+
return binding.engine.stop(chatId)
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function createC2Console(options = {}) {
|
|
131
|
+
const { stateDir, configPath, getConnHealth, host = '127.0.0.1', log = () => {} } = options
|
|
132
|
+
if (!path.isAbsolute(stateDir || '') || !path.isAbsolute(configPath || '')) {
|
|
133
|
+
throw new TypeError('C2 console requires explicit installation paths')
|
|
134
|
+
}
|
|
135
|
+
const facade = createC2ConsoleFacade(options)
|
|
136
|
+
const ui = createUiServer({ configPath, host, log, getConnHealth,
|
|
137
|
+
onHostChange: options.onHostChange,
|
|
138
|
+
getLiveState: facade.getLiveState, control: facade.control, runtimePolicy: facade,
|
|
139
|
+
issuesPath: path.join(stateDir, 'issues.jsonl'),
|
|
140
|
+
sessionsPath: path.join(stateDir, 'ui-sessions.json'),
|
|
141
|
+
auditPath: path.join(stateDir, 'audit.jsonl'),
|
|
142
|
+
ledgerPath: path.join(stateDir, 'turns.jsonl'),
|
|
143
|
+
inboxDismissPath: path.join(stateDir, 'inbox-dismissed.json'),
|
|
144
|
+
verifyFile: path.join(stateDir, 'verify.json'),
|
|
145
|
+
})
|
|
146
|
+
return { ...ui, facade }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = { createC2Console, createC2ConsoleFacade }
|