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.
Files changed (81) hide show
  1. package/CHANGELOG.en.json +50 -0
  2. package/CHANGELOG.json +50 -0
  3. package/README.md +138 -20
  4. package/README.zh-CN.md +144 -20
  5. package/assets/design-styles/acid-pop.md +155 -0
  6. package/assets/design-styles/archive-index.md +161 -0
  7. package/assets/design-styles/art-deco.md +164 -0
  8. package/assets/design-styles/cinematic-noir.md +178 -0
  9. package/assets/design-styles/editorial.md +121 -0
  10. package/assets/design-styles/geometric-pop.md +122 -0
  11. package/assets/design-styles/hand-drawn.md +117 -0
  12. package/assets/design-styles/islamic-geometry.md +155 -0
  13. package/assets/design-styles/liquid-glass.md +183 -0
  14. package/assets/design-styles/neo-chinese.md +136 -0
  15. package/assets/design-styles/precision-mono.md +156 -0
  16. package/assets/design-styles/quiet-luxury.md +163 -0
  17. package/assets/design-styles/riso-print.md +116 -0
  18. package/assets/design-styles/soft-friendly.md +174 -0
  19. package/assets/design-styles/swiss-grid.md +156 -0
  20. package/assets/design-styles/tech-dark.md +182 -0
  21. package/bin/sintbot-v2.js +291 -263
  22. package/docs/AI-ASSISTED-INSTALL.md +8 -8
  23. package/docs/install.md +80 -48
  24. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +8 -8
  25. package/docs/zh-CN/install.md +80 -48
  26. package/install.sh +34 -81
  27. package/package.json +60 -5
  28. package/src/agent/claudecode.js +32 -3
  29. package/src/agent/claudemodels.js +60 -0
  30. package/src/agent/claudestatus.js +74 -0
  31. package/src/agent/claudeusage.js +200 -0
  32. package/src/agent/codexapp.js +21 -4
  33. package/src/agent/codexcode.js +8 -3
  34. package/src/agent/codexstatus.js +181 -0
  35. package/src/agent/privatecore.js +1 -1
  36. package/src/agent/privatecore.wasm +0 -0
  37. package/src/c2configcontrol.js +214 -0
  38. package/src/c2console.js +149 -0
  39. package/src/c2incident.js +125 -0
  40. package/src/c2install.js +247 -37
  41. package/src/c2migration.js +100 -0
  42. package/src/c2multigroupcapacity.js +103 -0
  43. package/src/c2multigroupservice.js +418 -0
  44. package/src/cdp.js +254 -0
  45. package/src/config.js +13 -2
  46. package/src/core/c2durablejournal.js +26 -9
  47. package/src/core/c2durableruntime.js +104 -1
  48. package/src/core/c2inputrejection.js +45 -0
  49. package/src/core/c2localeffectexecutor.js +12 -1
  50. package/src/core/c2payloadhandles.js +11 -0
  51. package/src/core/c2persistrelation.js +20 -0
  52. package/src/core/c2persiststate.js +28 -2
  53. package/src/core/c2platformrecoverysender.js +1 -1
  54. package/src/core/c2sourcequarantine.js +56 -1
  55. package/src/core/c2terminalprune.js +2 -1
  56. package/src/core/engineslotsadapter.js +108 -0
  57. package/src/core/issues.js +185 -0
  58. package/src/core/ledger.js +30 -0
  59. package/src/core/proxy.js +424 -0
  60. package/src/core/store.js +12 -0
  61. package/src/core/v2engine.js +427 -31
  62. package/src/design.js +5161 -0
  63. package/src/gitrepo.js +150 -0
  64. package/src/leftover.js +174 -0
  65. package/src/platform/slack.js +1294 -0
  66. package/src/platform/wecom.js +961 -0
  67. package/src/platform/wecomcli.js +128 -0
  68. package/src/platform/wecomname.js +43 -0
  69. package/src/platform/wecompoll.js +241 -0
  70. package/src/preview.js +581 -0
  71. package/src/projectmemory.js +175 -0
  72. package/src/ui/auth.js +31 -0
  73. package/src/ui/browser.html +392 -0
  74. package/src/ui/browserroutes.js +234 -0
  75. package/src/ui/console.html +5719 -0
  76. package/src/ui/dashboard.js +168 -0
  77. package/src/ui/feishulogin.js +136 -0
  78. package/src/ui/serverbase.js +2371 -0
  79. package/src/ui/sessions.js +78 -0
  80. package/src/ui/takeover.js +108 -0
  81. package/src/usage.js +144 -0
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sintbot",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "private": false,
5
- "description": "A built-in agent core for one Feishu group on macOS, powered by Codex.",
5
+ "description": "Drive your Mac projects from Feishu groups with Codex or Claude Code, using a built-in agent core.",
6
6
  "bin": {
7
7
  "sbot": "bin/sintbot-v2.js",
8
8
  "sintbot": "bin/sintbot-v2.js"
@@ -21,6 +21,22 @@
21
21
  "README.md",
22
22
  "README.zh-CN.md",
23
23
  "THIRD-PARTY-NOTICES.md",
24
+ "assets/design-styles/acid-pop.md",
25
+ "assets/design-styles/archive-index.md",
26
+ "assets/design-styles/art-deco.md",
27
+ "assets/design-styles/cinematic-noir.md",
28
+ "assets/design-styles/editorial.md",
29
+ "assets/design-styles/geometric-pop.md",
30
+ "assets/design-styles/hand-drawn.md",
31
+ "assets/design-styles/islamic-geometry.md",
32
+ "assets/design-styles/liquid-glass.md",
33
+ "assets/design-styles/neo-chinese.md",
34
+ "assets/design-styles/precision-mono.md",
35
+ "assets/design-styles/quiet-luxury.md",
36
+ "assets/design-styles/riso-print.md",
37
+ "assets/design-styles/soft-friendly.md",
38
+ "assets/design-styles/swiss-grid.md",
39
+ "assets/design-styles/tech-dark.md",
24
40
  "bin/sintbot-v2.js",
25
41
  "docs/AI-ASSISTED-INSTALL.md",
26
42
  "docs/install.md",
@@ -28,15 +44,26 @@
28
44
  "docs/zh-CN/install.md",
29
45
  "install.sh",
30
46
  "src/agent/claudecode.js",
47
+ "src/agent/claudemodels.js",
48
+ "src/agent/claudestatus.js",
49
+ "src/agent/claudeusage.js",
31
50
  "src/agent/codexapp.js",
32
51
  "src/agent/codexcode.js",
52
+ "src/agent/codexstatus.js",
33
53
  "src/agent/errtag.js",
34
54
  "src/agent/privatecore.js",
35
55
  "src/agent/privatecore.wasm",
36
56
  "src/agent/runner.js",
37
57
  "src/agent/visionproxy.js",
38
58
  "src/agent/winexec.js",
59
+ "src/c2configcontrol.js",
60
+ "src/c2console.js",
61
+ "src/c2incident.js",
39
62
  "src/c2install.js",
63
+ "src/c2migration.js",
64
+ "src/c2multigroupcapacity.js",
65
+ "src/c2multigroupservice.js",
66
+ "src/cdp.js",
40
67
  "src/config.js",
41
68
  "src/core/c2cardeffectbridge.js",
42
69
  "src/core/c2cardterminalpath.js",
@@ -45,6 +72,7 @@
45
72
  "src/core/c2durableruntime.js",
46
73
  "src/core/c2eventidentity.js",
47
74
  "src/core/c2globalfault.js",
75
+ "src/core/c2inputrejection.js",
48
76
  "src/core/c2localeffectexecutor.js",
49
77
  "src/core/c2payloadhandles.js",
50
78
  "src/core/c2persistrelation.js",
@@ -63,31 +91,57 @@
63
91
  "src/core/datadir.js",
64
92
  "src/core/enginecheckpoint.js",
65
93
  "src/core/engineeffectcursor.js",
94
+ "src/core/engineslotsadapter.js",
66
95
  "src/core/enginestepcoordinator.js",
67
96
  "src/core/instancelock.js",
97
+ "src/core/issues.js",
98
+ "src/core/ledger.js",
68
99
  "src/core/mdtable.js",
100
+ "src/core/proxy.js",
69
101
  "src/core/redact.js",
70
102
  "src/core/store.js",
71
103
  "src/core/time.js",
72
104
  "src/core/v2engine.js",
73
105
  "src/core/verify.js",
106
+ "src/design.js",
107
+ "src/gitrepo.js",
108
+ "src/leftover.js",
74
109
  "src/platform/board.js",
75
110
  "src/platform/feishu.js",
76
111
  "src/platform/multi.js",
112
+ "src/platform/slack.js",
113
+ "src/platform/wecom.js",
114
+ "src/platform/wecomcli.js",
115
+ "src/platform/wecomname.js",
116
+ "src/platform/wecompoll.js",
77
117
  "src/platform/wswatchdog.js",
78
118
  "src/plugins.js",
119
+ "src/preview.js",
120
+ "src/projectmemory.js",
79
121
  "src/projectsroot.js",
80
- "src/setupmodels.js"
122
+ "src/setupmodels.js",
123
+ "src/ui/auth.js",
124
+ "src/ui/browser.html",
125
+ "src/ui/browserroutes.js",
126
+ "src/ui/console.html",
127
+ "src/ui/dashboard.js",
128
+ "src/ui/feishulogin.js",
129
+ "src/ui/serverbase.js",
130
+ "src/ui/sessions.js",
131
+ "src/ui/takeover.js",
132
+ "src/usage.js"
81
133
  ],
82
134
  "scripts": {
83
135
  "start": "node bin/sintbot-v2.js start"
84
136
  },
85
137
  "keywords": [
86
138
  "codex",
139
+ "claude-code",
87
140
  "feishu",
88
141
  "group-chat",
89
142
  "chatops",
90
- "self-hosted"
143
+ "self-hosted",
144
+ "agent"
91
145
  ],
92
146
  "author": "WaterTian",
93
147
  "license": "SEE LICENSE IN LICENSE",
@@ -100,6 +154,7 @@
100
154
  "url": "https://github.com/sintlab/sintbot/issues"
101
155
  },
102
156
  "dependencies": {
103
- "@larksuiteoapi/node-sdk": "^1.76.0"
157
+ "@larksuiteoapi/node-sdk": "^1.76.0",
158
+ "ws": "^8.21.3"
104
159
  }
105
160
  }
@@ -29,6 +29,26 @@ const { IS_WIN, resolveSpawn } = require('./winexec')
29
29
  const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 codex runner 共用
30
30
  const closedRunnerResults = new WeakSet()
31
31
  const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
32
+ // A rejection raised from the child's own 'close' event: the process has
33
+ // exited even though the turn has no result. It never proves the turn's outcome.
34
+ const closedRunnerErrors = new WeakSet()
35
+ const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
36
+ const closedError = error => { closedRunnerErrors.add(error); return error }
37
+ const PRE_INIT_HOOK_EVENTS = new Set(['hook_started', 'hook_response'])
38
+ const DURABLE_EVENT_TYPES = new Set(['system', 'assistant', 'user', 'result', 'tool_result'])
39
+ // Tool output is completion evidence only, never answer text.
40
+ function durableToolResults(ev) {
41
+ const results = Array.isArray(ev.results) ? ev.results : []
42
+ return results.filter(item => item && item.type === 'tool_result').map(item => {
43
+ const content = typeof item.content === 'string' ? item.content
44
+ : Array.isArray(item.content) ? item.content
45
+ .filter(part => part && part.type === 'text' && typeof part.text === 'string')
46
+ .map(part => part.text).join('\n')
47
+ : ''
48
+ return { type: 'tool_result', ...(ev.sessionId ? { sessionId: ev.sessionId } : {}),
49
+ results: [{ type: 'tool_result', content, is_error: item.is_error === true }] }
50
+ })
51
+ }
32
52
 
33
53
  // Build the child env from a resolved provider. `baseUrlOverride` (when given)
34
54
  // replaces provider.baseUrl for ANTHROPIC_BASE_URL — used to point `claude` at
@@ -351,6 +371,9 @@ function runTurn({
351
371
  let raw
352
372
  try { raw = JSON.parse(line) } catch { continue } // skip non-JSON noise
353
373
  if (onTurnAccepted && !turnAccepted) {
374
+ // Locally configured session hooks report their own lifecycle before
375
+ // init. They are not model output; skip them and keep waiting.
376
+ if (raw.type === 'system' && PRE_INIT_HOOK_EVENTS.has(raw.subtype)) continue
354
377
  if (raw.type !== 'system' || raw.subtype !== 'init' || typeof raw.session_id !== 'string' || !raw.session_id) {
355
378
  failTurnAcceptance(new Error('Claude turn emitted an event before start confirmation'))
356
379
  return
@@ -400,7 +423,13 @@ function runTurn({
400
423
  const last = lastMsgUsage || (ev.usage && Array.isArray(ev.usage.iterations) ? ev.usage.iterations[ev.usage.iterations.length - 1] : null)
401
424
  if (last) ev.contextTokens = (last.input_tokens || 0) + (last.cache_read_input_tokens || 0) + (last.cache_creation_input_tokens || 0)
402
425
  }
403
- if (onDurableEvent) await persistEvent(ev)
426
+ // Only transcript events reach the durable projection. Informational
427
+ // stream additions (for example rate_limit_event) carry no answer.
428
+ // Claude reports several tool results per event, with array content
429
+ // and an optional error flag; persist each in the shared shape.
430
+ if (onDurableEvent && ev.type === 'tool_result') {
431
+ for (const durable of durableToolResults(ev)) await persistEvent(durable)
432
+ } else if (onDurableEvent && DURABLE_EVENT_TYPES.has(ev.type)) await persistEvent(ev)
404
433
  if (ev.type === 'result') { lastResult = ev; armCloseGrace() } // answer in → bound the wait for 'close'
405
434
  if (onEvent) { try { onEvent(ev) } catch { /* renderer must not kill the turn */ } }
406
435
  }
@@ -427,7 +456,7 @@ function runTurn({
427
456
 
428
457
  const completeClose = (code) => finish(() => {
429
458
  if (code !== 0 && !lastResult && !aborted) {
430
- return reject(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`))
459
+ return reject(closedError(new Error(`claude exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`)))
431
460
  }
432
461
  resolveRun({ sessionId: capturedSessionId, result: lastResult, code, aborted })
433
462
  })
@@ -500,4 +529,4 @@ function probeVersion(bin = 'claude') {
500
529
  })
501
530
  }
502
531
 
503
- module.exports = { runTurn, isClosedRunnerResult, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
532
+ module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, buildEnv, normalizeEvent, probeVersion, supportsFlag, BASE_SKILL_ALLOWS }
@@ -0,0 +1,60 @@
1
+ // Claude 模型清单(控制台「模型」下拉用)—— **只在已登录时给**,未登录不列任何型号。
2
+ //
3
+ // ⚠ 为什么不是「登录后拉官方实时清单」:查过了,三条路都不通(2026-08-03 实测):
4
+ // 1. `GET /v1/models` + Claude Code 的登录态 token(Bearer + anthropic-beta: oauth-2025-04-20)
5
+ // —— curl 拿得到 200 与完整清单,但**同样的头从 Node 里发一律 403 `Request not allowed`**
6
+ // (node:https / fetch 都试过,补 accept、换 UA、换 HTTP/1.1 全无效)⇒ 这是按客户端指纹的
7
+ // 门禁,不是缺哪个头。它拦的是「登录态 token 被别的程序拿去用」,shell 出 curl 去绕正是
8
+ // 它要防的那件事,所以不做。
9
+ // 2. `claude` 没有 models 子命令(只有 auth / mcp / plugin / doctor …)。
10
+ // 3. `~/.claude.json` 的 `modelAccessCache` 实测是空数组、`orgModelDefaultCache` 是 null
11
+ // —— 不是清单来源。
12
+ // 所以清单 = 内置候选 + 本机账号的**额外可选项**(`additionalModelOptionsCache`,CLI 登录后
13
+ // 自己写的,合法本地读、best-effort),并在面板里说清「以 Claude Code 里 /model 为准」。
14
+ // 想用清单外的型号:面板留了「自定义…」直接填,不受这里限制。
15
+
16
+ const fs = require('node:fs')
17
+ const os = require('node:os')
18
+ const path = require('node:path')
19
+
20
+ // 当前主力档 + 上一代 Opus。刻意不铺满历史版本 —— 它是挑选快捷方式,不是权威清单;
21
+ // 4.8 留着是因为它仍在服务且额度池与 Opus 5 分开(operator 点名要能选,2026-08-05)。
22
+ // ⚠ id 是不带日期后缀的固定串(`claude-opus-4-8`,不是 `claude-opus-4-8-2026xxxx`)。
23
+ const BUILTIN = [
24
+ { id: '', name: '默认(跟随 Claude Code)', note: '推荐 · 不指定型号' },
25
+ { id: 'claude-opus-5', name: 'Claude Opus 5', note: '最强 · 复杂改码 / 长链路' },
26
+ { id: 'claude-opus-4-8', name: 'Claude Opus 4.8', note: '上一代 Opus · 额度池独立' },
27
+ { id: 'claude-sonnet-5', name: 'Claude Sonnet 5', note: '速度与智能平衡' },
28
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', note: '最快最省 · 简单任务' },
29
+ ]
30
+
31
+ // CLI 登录后写在 ~/.claude.json 的账号额外可选模型(本机这台是 Fable 5)。
32
+ // 私有字段,形状可能变 ⇒ 全程防御式读取,取不到就当没有。纯函数,便于单测。
33
+ function parseExtraModels(rawJson) {
34
+ const arr = rawJson && Array.isArray(rawJson.additionalModelOptionsCache) ? rawJson.additionalModelOptionsCache : []
35
+ return arr
36
+ .filter((x) => x && typeof x.value === 'string' && x.value)
37
+ .map((x) => ({ id: x.value, name: x.label || x.value, note: String(x.description || '').slice(0, 60) }))
38
+ }
39
+ function readExtraModels(home) {
40
+ try { return parseExtraModels(JSON.parse(fs.readFileSync(path.join(home || os.homedir(), '.claude.json'), 'utf8'))) }
41
+ catch { return [] }
42
+ }
43
+
44
+ // 合并:内置在前,账号额外项去重后追加(同 id 不重复列)。
45
+ function mergeModels(extra = []) {
46
+ const out = BUILTIN.slice()
47
+ const seen = new Set(out.map((m) => m.id))
48
+ for (const m of extra) if (m.id && !seen.has(m.id)) { seen.add(m.id); out.push({ ...m, extra: true }) }
49
+ return out
50
+ }
51
+
52
+ // { loggedIn, models, source }。未登录 ⇒ models 为空数组(面板据此整块不显示型号)。
53
+ // source: 'account' = 含本机账号的额外可选项 · 'builtin' = 只有内置候选。
54
+ function listModels({ loggedIn, home } = {}) {
55
+ if (!loggedIn) return { loggedIn: false, models: [], source: 'none' }
56
+ const extra = readExtraModels(home)
57
+ return { loggedIn: true, models: mergeModels(extra), source: extra.length ? 'account' : 'builtin' }
58
+ }
59
+
60
+ module.exports = { listModels, mergeModels, parseExtraModels, readExtraModels, BUILTIN }
@@ -0,0 +1,74 @@
1
+ // Claude 订阅登录态探测 —— 控制台 Claude 模型卡 + doctor 用。
2
+ //
3
+ // `claude auth status --json` 是**官方的非交互子命令**(0.2s、不联网收费、不吃订阅额度),
4
+ // 返回 {loggedIn, authMethod, apiProvider, email, orgId, orgName, subscriptionType}。
5
+ // 它是 Claude 这侧对应 `codex login status` 的那件东西 —— 在它之前,sbot 只能按
6
+ // 「没填 baseUrl 就当成登录态」猜(卡片写「登录态·未验证」、doctor 写「Claude 登录态(未验证)」),
7
+ // 真没登录时一样显示正常,要到群里跑一轮才炸。
8
+ //
9
+ // 一律 best-effort:失败不抛,调用方显示「未验证」而不是把整块健康检查拖垮。
10
+
11
+ const { spawn } = require('node:child_process')
12
+ const { resolveSpawn } = require('./winexec')
13
+
14
+ // `claude auth status --json` 的 stdout → 归一化结果。纯函数,便于单测钉住字段映射。
15
+ // 老版本 CLI 没有这个子命令(stderr 报 unknown command / 非 0 退出)⇒ ok:false,
16
+ // 调用方据此显示「未验证」,**不能**当成「未登录」—— 二者要分开,同飞书应用描述那条教训。
17
+ function parseAuthStatus(stdout, code) {
18
+ const text = String(stdout || '').trim()
19
+ let j = null
20
+ try { j = JSON.parse(text) } catch { /* 非 JSON:老版本或报错文本 */ }
21
+ if (!j || typeof j !== 'object') {
22
+ return { ok: false, error: text.slice(0, 200) || `exit ${code}`, loggedIn: false }
23
+ }
24
+ return {
25
+ ok: true,
26
+ loggedIn: !!j.loggedIn,
27
+ plan: j.subscriptionType || '', // max / pro / team …(未订阅时为空)
28
+ email: j.email || '',
29
+ org: j.orgName || '',
30
+ method: j.authMethod || '', // claude.ai = 订阅登录
31
+ apiProvider: j.apiProvider || '', // firstParty / bedrock / vertex
32
+ }
33
+ }
34
+
35
+ function loginStatus(bin = 'claude', timeoutMs = 8000) {
36
+ return new Promise((resolve) => {
37
+ const { file, spawnArgs } = resolveSpawn(bin, ['auth', 'status', '--json'])
38
+ let out = '', err = '', settled = false
39
+ const done = (v) => { if (settled) return; settled = true; clearTimeout(timer); resolve(v) }
40
+ let child
41
+ try { child = spawn(file, spawnArgs, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }) }
42
+ catch (e) { return resolve({ ok: false, error: e.message, loggedIn: false }) }
43
+ const timer = setTimeout(() => { try { child.kill() } catch {} done({ ok: false, error: 'timeout', loggedIn: false }) }, timeoutMs)
44
+ child.stdout.on('data', (c) => { out += c.toString('utf8') })
45
+ child.stderr.on('data', (c) => { err += c.toString('utf8') })
46
+ child.on('error', (e) => done({ ok: false, loggedIn: false, error: e.code === 'ENOENT' ? "'claude' 不在 PATH 上" : e.message }))
47
+ child.on('close', (code) => done(parseAuthStatus(out || err, code)))
48
+ })
49
+ }
50
+
51
+ // TTL 缓存:doctor 是一次性的,但控制台 /api/state 每次动作 + 60s 自动刷新都会重建健康行,
52
+ // 每次都 spawn 一个进程没必要。登录态分钟级变化足够,取 60s。
53
+ const CACHE = new Map() // bin -> { at, val }
54
+ const TTL = 60_000
55
+ async function cachedLoginStatus(bin = 'claude', ttl = TTL) {
56
+ const c = CACHE.get(bin)
57
+ if (c && Date.now() - c.at < ttl) return c.val
58
+ const val = await loginStatus(bin)
59
+ CACHE.set(bin, { at: Date.now(), val })
60
+ return val
61
+ }
62
+ function clearCache() { CACHE.clear() } // 「重新检测」按钮:绕过 TTL
63
+
64
+ // 登录态 → 一行人话(卡片 chip / doctor 详情共用,措辞单一事实源)。
65
+ function describeLogin(s) {
66
+ if (!s || !s.ok) return { level: 'warn', text: '未验证' + (s && s.error ? ' — ' + String(s.error).slice(0, 60) : '') }
67
+ if (!s.loggedIn) return { level: 'fail', text: '未登录 — 在这台机器上跑 claude auth login' }
68
+ const bits = ['订阅登录']
69
+ if (s.plan) bits.push(s.plan) // max / pro
70
+ if (s.email) bits.push(s.email)
71
+ return { level: 'ok', text: bits.join(' · ') }
72
+ }
73
+
74
+ module.exports = { loginStatus, cachedLoginStatus, clearCache, parseAuthStatus, describeLogin }
@@ -0,0 +1,200 @@
1
+ // Claude 订阅额度(v0.11.3)—— 控制台 Claude 卡「订阅额度」行 + 群内 /hud 用。
2
+ //
3
+ // 通道与 cc-hud(operator 自家 statusline 项目)同源,2026-08-03 拍板采用:
4
+ // 端点 = `https://api.anthropic.com/api/oauth/usage`(交互式 /usage 面板同一数据源),
5
+ // `Authorization: Bearer <claudeAiOauth.accessToken>` + `anthropic-beta: oauth-2025-04-20`。
6
+ // `limits[]` 三档(本机 2026-08-03 实测):kind=session(5h 窗口)/ weekly_all / weekly_scoped
7
+ // (带 scope.model.display_name,= 顶级模型周窗口,如 Fable);percent 为**已用**百分比。
8
+ //
9
+ // ⚠ 三条纪律(照 cc-hud 踩通的路,别走样):
10
+ // 1. Cloudflare WAF 对 /api/oauth/* 按 **TLS 指纹** 403 —— Node 的 fetch **恒被拒**、curl 放行
11
+ // (与网络环境无关,换机器换代理都一样)。所以这条路只走 curl,native fetch 不再试
12
+ // (那一发必 403 还白占端点的限流计数);`fetchNative` 保留不删 —— 哪天官方放行,
13
+ // 回接只是 fetchRaw 里加一行。curl 的 config 走 **stdin**,token 绝不进 argv(ps 可见)、
14
+ // 绝不落日志。
15
+ // 2. 凭据**只读不刷新**:accessToken 过期就当拿不到(返回 null 显示「未验证」),token 轮换
16
+ // 是 Claude Code 自己的事 —— 代刷会跟它抢 refresh token,两边都掉登录。
17
+ // 3. 端点又慢又娇:走本机代理的好路也要 1.1~2.7s,限流阈值低(十几分钟十几次就 429 且持续
18
+ // 数分钟)。故超时给足 5s(调用方都是异步回复,多等两秒无感)、失败隔一下重试一次、
19
+ // 失败**不长缓存**(见 fetchRaw)。
20
+ //
21
+ // 全程 best-effort:任何一步失败返回 null,调用方不显示额度行,绝不拖垮健康检查/卡片渲染。
22
+ // 但**失败要留一行痕**:此前 curl 带 `--fail` 把状态码连同 body 一起吞掉,超时/429/500 在
23
+ // 日志里长得一模一样(什么都没有),排查只能靠猜。
24
+
25
+ const fs = require('node:fs')
26
+ const os = require('node:os')
27
+ const path = require('node:path')
28
+ const { execFile, execFileSync } = require('node:child_process')
29
+
30
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
31
+ const TIMEOUT_MS = 5000 // curl 预算(纪律 3);native fetch 已不走这条路
32
+ const RETRY_WAIT_MS = 1500 // 失败后隔多久重试(一次为限)
33
+ const FAIL_TTL_MS = 10_000 // 失败缓存窗口,见 fetchRaw
34
+
35
+ // ~/.claude/.credentials.json(Windows / Linux)或 macOS Keychain 的 JSON → accessToken。
36
+ // 过期 ⇒ null(纪律 2)。纯函数,单测钉住。
37
+ function parseCredentials(raw, now = Date.now()) {
38
+ let j = null
39
+ try { j = JSON.parse(String(raw || '')) } catch { return null }
40
+ const o = j && j.claudeAiOauth
41
+ if (!o || typeof o.accessToken !== 'string' || !o.accessToken) return null
42
+ if (typeof o.expiresAt === 'number' && o.expiresAt <= now) return null
43
+ return o.accessToken
44
+ }
45
+
46
+ function readAccessToken(home = os.homedir()) {
47
+ // 凭据文件优先(win/linux;存在即终局 —— 文件过期意味着同账号同过期,不再翻 Keychain)
48
+ let raw = null
49
+ try { raw = fs.readFileSync(path.join(home, '.claude', '.credentials.json'), 'utf8') } catch { /* 无文件 */ }
50
+ if (raw !== null) return parseCredentials(raw)
51
+ if (process.platform === 'darwin') {
52
+ try {
53
+ const out = execFileSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
54
+ { encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'] })
55
+ return parseCredentials(out)
56
+ } catch { /* 无条目 / 拒绝访问 */ }
57
+ }
58
+ return null
59
+ }
60
+
61
+ // limits[] → [{kind, percent(已用), resetsAt(ms|null), scopeName}]。防御式:形状不对给 []。
62
+ function parseLimits(data) {
63
+ const arr = data && Array.isArray(data.limits) ? data.limits : []
64
+ const out = []
65
+ for (const l of arr) {
66
+ if (!l || typeof l.kind !== 'string' || typeof l.percent !== 'number') continue
67
+ const t = Date.parse(l.resets_at || '')
68
+ out.push({
69
+ kind: l.kind,
70
+ percent: l.percent,
71
+ resetsAt: Number.isFinite(t) ? t : null,
72
+ scopeName: (l.scope && l.scope.model && typeof l.scope.model.display_name === 'string') ? l.scope.model.display_name : '',
73
+ })
74
+ }
75
+ return out
76
+ }
77
+
78
+ // 紧凑一行(v0.11.8 口径改版,operator 拍板):`5h:98% (1.8h) │ 7d:12% (6.1d) │ Fable:12%`
79
+ // ⚠ **百分比是「已用」不是「剩余」** —— 与 v0.11.3~0.11.7 的「剩 x%」正好相反,四处落点
80
+ // (本函数 / console 的 codex 卡 / usage.js 的 minimax 行 / 群内 /hud)必须同版改齐,
81
+ // 否则同屏两种口径,「98%」会被读成还剩很多。窗口名前缀 + 括号内距重置,不留裸百分比。
82
+ // 分隔符 │(U+2502),两侧空格。
83
+ // 一位小数的紧凑时长(4.0h ⇒ 4h:整数不留尾零;≥10 直接取整,两位数带小数太挤)
84
+ const fmtDurDecimal = (ms) => {
85
+ const trim = (v) => (v < 10 ? String(Number(v.toFixed(1))) : String(Math.round(v)))
86
+ const h = ms / 3600e3
87
+ if (h >= 24) return `${trim(h / 24)}d`
88
+ if (h >= 1) return `${trim(h)}h`
89
+ return `${Math.max(1, Math.round(ms / 60e3))}m`
90
+ }
91
+ const QUOTA_SEP = ' │ '
92
+
93
+ function formatClaudeUsage(limits, now = Date.now()) {
94
+ if (!Array.isArray(limits) || !limits.length) return null
95
+ const reset = (l) => (l.resetsAt && l.resetsAt > now && l.resetsAt - now < 9 * 864e5) ? ` (${fmtDurDecimal(l.resetsAt - now)})` : ''
96
+ // 窗口名:session=5h(官方就是 5 小时滚动窗)· weekly_all=7d · weekly_scoped=该模型名
97
+ const label = (l) => l.kind === 'session' ? '5h' : l.kind === 'weekly_all' ? '7d' : l.kind === 'weekly_scoped' ? (l.scopeName || '顶级模型') : ''
98
+ const parts = []
99
+ for (const l of limits) {
100
+ const lb = label(l)
101
+ if (!lb) continue // 未知窗口类型不猜(官方加新档时宁缺毋滥)
102
+ parts.push(`${lb}:${Math.min(100, Math.max(0, Math.round(l.percent)))}%${reset(l)}`)
103
+ }
104
+ return parts.join(QUOTA_SEP) || null
105
+ }
106
+
107
+ // ⚠ 当前**不走**这条腿(纪律 1:Node 的 TLS 指纹被 Cloudflare 恒 403)。保留函数是为了
108
+ // 哪天官方放行时回接成本近似为零 —— 别因为「没人调」把它删掉。
109
+ async function fetchNative(token) {
110
+ const ac = new AbortController()
111
+ const timer = setTimeout(() => ac.abort(), TIMEOUT_MS)
112
+ try {
113
+ const r = await fetch(USAGE_URL, { headers: { Authorization: `Bearer ${token}`, 'anthropic-beta': 'oauth-2025-04-20' }, signal: ac.signal })
114
+ if (!r.ok) return null
115
+ return await r.json()
116
+ } catch { return null } finally { clearTimeout(timer) }
117
+ }
118
+
119
+ // curl 收尾判读(纯函数,单测钉死):(err, stdout) → { data, note }。
120
+ // stdout 的**最后一行**是 `--write-out` 写的 HTTP 状态码 —— 不再用 `--fail`,它把状态码和 body
121
+ // 一起吞了,超时/限流/服务端错在日志里长得一模一样。note = 该记的一行人话(成功给 null,
122
+ // 成功不打日志);note 里只放状态码与本地错因,绝不带 token 或响应头。
123
+ function readCurlResult(err, stdout) {
124
+ if (err) {
125
+ // 退出码 28 = 取数命令自己的 max-time 到点;killed/SIGTERM = 外层兜底超时掐的
126
+ const exit = typeof err.code === 'number' ? err.code : null
127
+ if (err.killed || err.signal === 'SIGTERM' || exit === 28 || String(err.code || '') === 'ETIMEDOUT')
128
+ return { data: null, note: `额度接口超时(${TIMEOUT_MS}ms 未返回)` }
129
+ const why = exit != null ? `退出码 ${exit}` : String((err.message || '').split('\n')[0] || '未知错误').slice(0, 120)
130
+ return { data: null, note: `额度接口调用失败(${why})` }
131
+ }
132
+ const raw = String(stdout == null ? '' : stdout)
133
+ const cut = raw.lastIndexOf('\n')
134
+ const status = Number((cut >= 0 ? raw.slice(cut + 1) : raw).trim())
135
+ const body = cut >= 0 ? raw.slice(0, cut) : ''
136
+ if (!Number.isFinite(status) || !status) return { data: null, note: '额度接口没给出状态码(响应形状不认识)' }
137
+ if (status === 429) return { data: null, note: '额度接口限流(429),等一会儿才会恢复' }
138
+ if (status !== 200) return { data: null, note: `额度接口返回 ${status}` }
139
+ try { return { data: JSON.parse(body), note: null } } catch { return { data: null, note: '额度接口返回的不是 JSON(解析失败)' } }
140
+ }
141
+
142
+ const warn = (m) => { try { process.stderr.write('· ' + m + '\n') } catch { /* stderr 关了也不能崩 */ } }
143
+
144
+ // 唯一的取数腿:config 经 stdin(token 不进 argv);token 字符集先校验,防 config 注入。
145
+ function fetchCurl(token, { log = warn, exec = execFile } = {}) {
146
+ if (!/^[\w.\-~+/=]+$/.test(token)) return Promise.resolve(null)
147
+ return new Promise((resolve) => {
148
+ try {
149
+ const child = exec('curl', ['--config', '-'], { timeout: TIMEOUT_MS + 1000, windowsHide: true }, (err, stdout) => {
150
+ const { data, note } = readCurlResult(err, stdout)
151
+ if (note) log(note)
152
+ resolve(data)
153
+ })
154
+ child.stdin.on('error', () => { /* curl 可能在 stdin 写完前退出 */ })
155
+ child.stdin.end([
156
+ `url = "${USAGE_URL}"`,
157
+ `header = "Authorization: Bearer ${token}"`,
158
+ 'header = "anthropic-beta: oauth-2025-04-20"',
159
+ 'silent', `max-time = ${Math.ceil(TIMEOUT_MS / 1000)}`,
160
+ 'write-out = "\\n%{http_code}"', // 末行状态码:失败要留痕,见 readCurlResult
161
+ ].join('\n'))
162
+ } catch (e) { log('额度接口起不来:' + ((e && e.message) || '未知错误')); resolve(null) }
163
+ })
164
+ }
165
+
166
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms))
167
+
168
+ // 原始额度 JSON。**缓存与 usageLine 共用同一份** —— 额度告警每轮结算都会问一次,不共用
169
+ // 就是每轮多打一次接口(v0.11.8 新增,给 quotaalert 判定用)。
170
+ // ⚠ **只有成功才缓存 60s;失败只压 10 秒**:失败也缓存一分钟,等于一次偶发超时就让整整一分钟
171
+ // 里所有人都查不到额度(而这接口恰恰偶发超时/限流);完全不压又让连点几下就把限流捅穿。
172
+ // 10 秒 = 人再问一次就真的重试,机器连点仍被挡住。
173
+ const RAW = { at: 0, data: null }
174
+ async function fetchRaw(token, { ttl = 60_000, now = Date.now, log = warn, curl = fetchCurl, sleep = sleepMs } = {}) {
175
+ if (RAW.at && now() - RAW.at < (RAW.data ? ttl : FAIL_TTL_MS)) return RAW.data
176
+ // 只走 curl(纪律 1);失败隔一下再问一次 —— 超时与短限流重试一次往往就通,再不通就认了。
177
+ let data = await curl(token, { log })
178
+ if (!data) { await sleep(RETRY_WAIT_MS); data = await curl(token, { log }) }
179
+ RAW.at = now(); RAW.data = data
180
+ return data
181
+ }
182
+
183
+ // 拉一次 → 一行人话;拿不到 null。走 fetchRaw ⇒ 与告警共享那份缓存。
184
+ // 缓存口径与 fetchRaw 一致:成功 60s、失败 10s(两层都压死一分钟就白搭下层的重试了)。
185
+ const CACHE = { at: 0, val: null }
186
+ const TTL = 60_000
187
+ async function usageLine({ ttl = TTL, now = Date.now, readToken = readAccessToken, ...io } = {}) {
188
+ if (CACHE.at && now() - CACHE.at < (CACHE.val ? ttl : FAIL_TTL_MS)) return CACHE.val
189
+ let val = null
190
+ const token = readToken()
191
+ if (token) {
192
+ const data = await fetchRaw(token, { ttl, now, ...io })
193
+ val = formatClaudeUsage(parseLimits(data), now())
194
+ }
195
+ CACHE.at = now(); CACHE.val = val
196
+ return val
197
+ }
198
+ function clearCache() { CACHE.at = 0; CACHE.val = null; RAW.at = 0; RAW.data = null }
199
+
200
+ module.exports = { usageLine, fetchRaw, clearCache, parseCredentials, parseLimits, formatClaudeUsage, readAccessToken, fetchCurl, fetchNative, readCurlResult, fmtDurDecimal, QUOTA_SEP, TIMEOUT_MS, FAIL_TTL_MS, RETRY_WAIT_MS }
@@ -4,6 +4,11 @@
4
4
  const { spawn } = require('node:child_process')
5
5
  const closedRunnerResults = new WeakSet()
6
6
  const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
7
+ // A rejection raised from the child's own 'close' event: the process has
8
+ // exited even though the turn has no result. It never proves the turn's outcome.
9
+ const closedRunnerErrors = new WeakSet()
10
+ const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
11
+ const closedError = error => { closedRunnerErrors.add(error); return error }
7
12
  const { IS_WIN, resolveSpawn } = require('./winexec')
8
13
  const { normalizeEvent, isEffortRejection } = require('./codexcode')
9
14
  const { coreFailedResult, isCoreFailure } = require('./errtag')
@@ -142,6 +147,18 @@ function runTurn(opts = {}) {
142
147
  })
143
148
  const deactivate = () => { activeTurnId = null } // 完成/中止后立即拒绝晚到的补充。
144
149
  const control = {
150
+ async steerDurable({ msgId, text }) {
151
+ const turnId = activeTurnId
152
+ if (!turnId || completed || aborted) return false
153
+ try {
154
+ await request('turn/steer', { threadId: capturedSessionId, expectedTurnId: turnId,
155
+ clientUserMessageId: msgId, input: [{ type: 'text', text }] })
156
+ return true
157
+ } catch (error) {
158
+ if (error?.codexRpcRejected === true) return false
159
+ throw error
160
+ }
161
+ },
145
162
  async steer({ msgId, text }) {
146
163
  const turnId = activeTurnId
147
164
  if (!turnId || completed || aborted) return false
@@ -321,7 +338,7 @@ function runTurn(opts = {}) {
321
338
  }
322
339
  if (raw.id != null) {
323
340
  const p = requests.get(raw.id)
324
- if (p) { requests.delete(raw.id); raw.error ? p.fail(new Error(raw.error.message || 'Codex RPC error')) : p.ok(raw.result) }
341
+ if (p) { requests.delete(raw.id); raw.error ? p.fail(Object.assign(new Error(raw.error.message || 'Codex RPC error'), { codexRpcRejected: [-32600, -32601, -32602].includes(raw.error.code) })) : p.ok(raw.result) }
325
342
  return
326
343
  }
327
344
  if (onTurnAccepted && pendingTurnStart && !acceptedTurnId) {
@@ -348,7 +365,7 @@ function runTurn(opts = {}) {
348
365
  }
349
366
  } else if (action === 4) {
350
367
  const item = params.item
351
- if (appLandedAction(item && item.type, raw.method === 'item/completed', !!(item && item.clientId), !!(item && landed.has(item.clientId)))) {
368
+ if (params.turnId === activeTurnId && params.threadId === capturedSessionId && appLandedAction(item && item.type, raw.method === 'item/completed', !!(item && item.clientId), !!(item && landed.has(item.clientId)))) {
352
369
  landed.add(item.clientId)
353
370
  if (onSteerLanded) { try { onSteerLanded(item.clientId) } catch {} }
354
371
  }
@@ -427,7 +444,7 @@ function runTurn(opts = {}) {
427
444
  })
428
445
  const completeClose = (code) => settle(() => {
429
446
  if (onDurableEvent && !completed && !aborted) {
430
- reject(new Error(`codex app-server exited ${code} before a durable result`))
447
+ reject(closedError(new Error(`codex app-server exited ${code} before a durable result`)))
431
448
  return
432
449
  }
433
450
  if (!completed && !aborted) {
@@ -455,4 +472,4 @@ function runTurn(opts = {}) {
455
472
  })
456
473
  }
457
474
 
458
- module.exports = { runTurn, isClosedRunnerResult, usageFromTotal, itemEvent, failedTurnEvent }
475
+ module.exports = { runTurn, isClosedRunnerResult, isClosedRunnerError, usageFromTotal, itemEvent, failedTurnEvent }
@@ -13,6 +13,11 @@
13
13
  const { spawn } = require('node:child_process')
14
14
  const closedRunnerResults = new WeakSet()
15
15
  const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
16
+ // A rejection raised from the child's own 'close' event: the process has
17
+ // exited even though the turn has no result. It never proves the turn's outcome.
18
+ const closedRunnerErrors = new WeakSet()
19
+ const isClosedRunnerError = error => !!error && typeof error === 'object' && closedRunnerErrors.has(error)
20
+ const closedError = error => { closedRunnerErrors.add(error); return error }
16
21
  const { StringDecoder } = require('node:string_decoder')
17
22
  const { IS_WIN, resolveSpawn } = require('./winexec')
18
23
  const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 claude runner 共用
@@ -398,11 +403,11 @@ function runOnce(opts, effort) {
398
403
 
399
404
  const completeClose = (code) => {
400
405
  if (onDurableEvent && code !== 0 && !lastResult && !aborted) {
401
- done(() => reject(new Error(`codex exited ${code} before a durable result`)))
406
+ done(() => reject(closedError(new Error(`codex exited ${code} before a durable result`))))
402
407
  return
403
408
  }
404
409
  if (durableStart && !acceptedStart && !aborted && !acceptanceFailed) {
405
- done(() => reject(new Error('Codex exited before turn.started acceptance')))
410
+ done(() => reject(closedError(new Error('Codex exited before turn.started acceptance'))))
406
411
  return
407
412
  }
408
413
  // Watch the answer→close (rollout-flush) lag. Healthy = ~2–3s (real-machine verified),
@@ -450,4 +455,4 @@ function probeVersion(bin = 'codex') {
450
455
  })
451
456
  }
452
457
 
453
- module.exports = { runTurn, runOnce, isClosedRunnerResult, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
458
+ module.exports = { runTurn, runOnce, isClosedRunnerResult, isClosedRunnerError, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }