sintbot 1.0.16 → 1.3.0

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 (137) hide show
  1. package/CHANGELOG.en.json +99 -0
  2. package/CHANGELOG.json +99 -0
  3. package/README.md +20 -158
  4. package/README.zh-CN.md +20 -166
  5. package/THIRD-PARTY-NOTICES.md +394 -0
  6. package/bin/sintbot-v2.js +363 -0
  7. package/docs/AI-ASSISTED-INSTALL.md +11 -35
  8. package/docs/install.md +61 -167
  9. package/docs/zh-CN/AI-ASSISTED-INSTALL.md +11 -35
  10. package/docs/zh-CN/install.md +61 -165
  11. package/install.sh +84 -0
  12. package/package.json +73 -37
  13. package/src/agent/claudecode.js +149 -108
  14. package/src/agent/codexapp.js +458 -0
  15. package/src/agent/codexcode.js +210 -107
  16. package/src/agent/errtag.js +34 -0
  17. package/src/agent/privatecore.js +790 -0
  18. package/src/agent/privatecore.wasm +0 -0
  19. package/src/agent/runner.js +10 -8
  20. package/src/agent/winexec.js +35 -5
  21. package/src/c2install.js +117 -0
  22. package/src/config.js +2 -1
  23. package/src/core/c2cardeffectbridge.js +51 -0
  24. package/src/core/c2cardterminalpath.js +216 -0
  25. package/src/core/c2durableingress.js +148 -0
  26. package/src/core/c2durablejournal.js +1560 -0
  27. package/src/core/c2durableruntime.js +2361 -0
  28. package/src/core/c2eventidentity.js +90 -0
  29. package/src/core/c2globalfault.js +401 -0
  30. package/src/core/c2localeffectexecutor.js +193 -0
  31. package/src/core/c2payloadhandles.js +333 -0
  32. package/src/core/c2persistrelation.js +179 -0
  33. package/src/core/c2persiststate.js +342 -0
  34. package/src/core/c2platformrecoverysender.js +76 -0
  35. package/src/core/c2recoverygate.js +385 -0
  36. package/src/core/c2runnerinput.js +63 -0
  37. package/src/core/c2servicestartgate.js +103 -0
  38. package/src/core/c2sessionbridge.js +72 -0
  39. package/src/core/c2sourcequarantine.js +30 -0
  40. package/src/core/c2startuprecovery.js +105 -0
  41. package/src/core/c2successorbridge.js +151 -0
  42. package/src/core/c2terminaleffectbridge.js +154 -0
  43. package/src/core/c2terminalprune.js +108 -0
  44. package/src/core/c2turnprojection.js +177 -0
  45. package/src/core/enginecheckpoint.js +211 -0
  46. package/src/core/engineeffectcursor.js +394 -0
  47. package/src/core/enginestepcoordinator.js +183 -0
  48. package/src/core/store.js +52 -8
  49. package/src/core/v2engine.js +1469 -0
  50. package/src/platform/feishu.js +324 -67
  51. package/src/plugins.js +1 -9
  52. package/assets/design-styles/acid-pop.md +0 -155
  53. package/assets/design-styles/archive-index.md +0 -161
  54. package/assets/design-styles/art-deco.md +0 -164
  55. package/assets/design-styles/cinematic-noir.md +0 -178
  56. package/assets/design-styles/editorial.md +0 -121
  57. package/assets/design-styles/extra/README.md +0 -23
  58. package/assets/design-styles/extra/constructivism.md +0 -146
  59. package/assets/design-styles/extra/neo-brutalism.md +0 -175
  60. package/assets/design-styles/extra/new-wave.md +0 -166
  61. package/assets/design-styles/extra/nordic-material.md +0 -162
  62. package/assets/design-styles/extra/paris-couture.md +0 -162
  63. package/assets/design-styles/extra/tropical-modern.md +0 -168
  64. package/assets/design-styles/extra/ulm-functional.md +0 -182
  65. package/assets/design-styles/extra/wa-shikimen.md +0 -160
  66. package/assets/design-styles/extra/y2k-chrome.md +0 -172
  67. package/assets/design-styles/geometric-pop.md +0 -122
  68. package/assets/design-styles/hand-drawn.md +0 -117
  69. package/assets/design-styles/islamic-geometry.md +0 -155
  70. package/assets/design-styles/liquid-glass.md +0 -183
  71. package/assets/design-styles/neo-chinese.md +0 -136
  72. package/assets/design-styles/precision-mono.md +0 -156
  73. package/assets/design-styles/quiet-luxury.md +0 -163
  74. package/assets/design-styles/riso-print.md +0 -116
  75. package/assets/design-styles/soft-friendly.md +0 -174
  76. package/assets/design-styles/swiss-grid.md +0 -156
  77. package/assets/design-styles/tech-dark.md +0 -182
  78. package/assets/favicon.svg +0 -6
  79. package/assets/logo.svg +0 -24
  80. package/bin/sbot.js +0 -4716
  81. package/docs/slack-app-manifest.yaml +0 -104
  82. package/sbot.config.example.json +0 -235
  83. package/src/agent/askonce.js +0 -76
  84. package/src/agent/claudemodels.js +0 -60
  85. package/src/agent/claudestatus.js +0 -74
  86. package/src/agent/claudeusage.js +0 -200
  87. package/src/agent/codexstatus.js +0 -181
  88. package/src/agent/imagen.js +0 -458
  89. package/src/agent/quotaerr.js +0 -44
  90. package/src/agent/transienterr.js +0 -32
  91. package/src/cdp.js +0 -254
  92. package/src/cli-secret.js +0 -36
  93. package/src/clilang.js +0 -31
  94. package/src/core/cron.js +0 -182
  95. package/src/core/cronfire.js +0 -120
  96. package/src/core/cronrun.js +0 -116
  97. package/src/core/engine.js +0 -2758
  98. package/src/core/interfaces.js +0 -114
  99. package/src/core/issues.js +0 -185
  100. package/src/core/ledger.js +0 -30
  101. package/src/core/opsalert.js +0 -214
  102. package/src/core/pending.js +0 -123
  103. package/src/core/proxy.js +0 -424
  104. package/src/core/quotaalert.js +0 -79
  105. package/src/core/quotaerr.js +0 -55
  106. package/src/core/scheduler.js +0 -59
  107. package/src/design.js +0 -5161
  108. package/src/gitrepo.js +0 -150
  109. package/src/health.js +0 -353
  110. package/src/installdeps.js +0 -72
  111. package/src/leftover.js +0 -174
  112. package/src/platform/boardnotice.js +0 -77
  113. package/src/platform/boardops.js +0 -36
  114. package/src/platform/boardwatch.js +0 -447
  115. package/src/platform/cli.js +0 -45
  116. package/src/platform/slack.js +0 -1289
  117. package/src/platform/takeovercard.js +0 -37
  118. package/src/platform/wecom.js +0 -938
  119. package/src/platform/wecomcli.js +0 -128
  120. package/src/platform/wecomgate.js +0 -108
  121. package/src/platform/wecomname.js +0 -43
  122. package/src/platform/wecompoll.js +0 -241
  123. package/src/preview.js +0 -581
  124. package/src/projectmemory.js +0 -175
  125. package/src/service.js +0 -317
  126. package/src/ui/auth.js +0 -31
  127. package/src/ui/browser.html +0 -392
  128. package/src/ui/browserroutes.js +0 -234
  129. package/src/ui/console.html +0 -5526
  130. package/src/ui/dashboard.js +0 -168
  131. package/src/ui/feishulogin.js +0 -136
  132. package/src/ui/server.js +0 -2314
  133. package/src/ui/sessions.js +0 -78
  134. package/src/ui/takeover.js +0 -108
  135. package/src/updater.js +0 -243
  136. package/src/upgrade.js +0 -35
  137. package/src/usage.js +0 -144
@@ -1,458 +0,0 @@
1
- // 生图基础技能(`sbot imagen`)— 任何模型的群借机器的 codex 订阅生图(零 API key)。
2
- //
3
- // 形态 = 异步 detached worker(2026-07-22 拍板):受理进程写任务文件、spawn 脱离进程树的
4
- // worker 后**立即返回** ⇒ agent 的 Bash 调用秒回、群锁不被生图占用(主对话可并行);
5
- // worker spawn `codex exec --json` 用**固定生图模板**(非自由指令 —— 这不是嵌套 agent,
6
- // 是确定性专用任务,与「群内多 agent 并行不做」的拍板不冲突),成图后自动上板+发群通知,
7
- // 全程确定性零模型成本(「按这张改」的下一步烧额度决定留给群里的人,boardwatch 同哲学)。
8
- //
9
- // 本模块只放可单测的纯逻辑(prompt 模板 / 任务文件 / 并发计数 / 产物校验 / 通知文案);
10
- // spawn codex、上板、发群等接线在 bin(项目纪律:src 可测,bin 接线)。
11
- const fs = require('fs')
12
- const os = require('os')
13
- const path = require('path')
14
- const crypto = require('crypto')
15
- const { atomicWriteJSON } = require('../core/store')
16
-
17
- const IMAGEN_MAX_CONCURRENT = 2 // 全局并发上限(2026-07-22 拍板)— 烧的是与交互式共享的订阅额度
18
- const IMAGEN_WALL_MS = 10 * 60_000 // worker 墙钟:超时树杀 codex,任务标失败
19
-
20
- function imagenDir() {
21
- return path.join(os.homedir(), '.sbot', 'imagen')
22
- }
23
-
24
- // codex 收到的固定模板 —— 把自由发挥空间压到「画什么」一项,其余(存哪、只做这件事、
25
- // 画得好的底线)钉死。设计底线管的是生成图像最常见的「一眼 AI」通病(套路渐变、假数据、
26
- // 乱码文字、无层级堆砌),与项目风格档(DESIGN.md)互补:那管系列风格一致,这管单张质量;
27
- // 用户明确要求的风格永远优先(底线只在「满足图片要求的前提下」生效)。
28
- // styleAbs(可选)= 项目风格档绝对路径:给路径不给内容 —— 生图后端自己读文件,省得受理侧
29
- // 把整份风格档塞进 prompt;冲突时以本轮图片要求优先(风格档是缺省,不是枷锁)。
30
- // opts(v0.11.27,设计图→网页资产管线):
31
- // refs 输入图绝对路径数组 —— 有它就从「生成」切到「编辑」语义(codex 内置 image_gen
32
- // 本就支持编辑/多参考图,此前桥这边没通道喂给它,能力等于埋着)
33
- // mask 遮罩绝对路径:透明处重绘、不透明处逐像素保原样(局部重绘/补全被遮挡部分)
34
- // transparent 要求输出真 alpha 的透明底 PNG(抠素材给网页用的关键)
35
- // ⚠ 意图不设 --mode 而由参数推断(参数即意图):无 refs = 生成 · 有 refs = 编辑 · 再有 mask
36
- // = 局部重绘。少一个要记的模式名,也少一处「模式与参数打架」的状态。
37
- // ⚠ 无 refs 时整段文案**逐字节沿用旧版** —— 老的纯生图路径零回归(单测钉死)。
38
- function buildImagenPrompt(userPrompt, outAbs, styleAbs, opts = {}) {
39
- const style = String(styleAbs || '').trim()
40
- const refs = (opts && Array.isArray(opts.refs) ? opts.refs : []).filter(Boolean)
41
- const mask = String((opts && opts.mask) || '').trim()
42
- const transparent = !!(opts && opts.transparent)
43
- const editing = refs.length > 0
44
- const L = []
45
-
46
- if (editing) {
47
- L.push('使用你的图像编辑能力处理下面的输入图片,把结果保存为这个文件(绝对路径,格式按扩展名):', outAbs, '',
48
- '输入图片(已按顺序编号,下面的要求按编号指代):',
49
- ...refs.map((p, i) => ` [${i + 1}] ${p}`), '')
50
- if (mask) {
51
- L.push('遮罩(mask):' + mask,
52
- '- 只重绘遮罩中**透明**的区域;不透明区域必须**逐像素保持原样**,不要顺手美化、不要重新构图。', '')
53
- }
54
- } else {
55
- L.push('使用你的图像生成能力生成一张图片,并保存为这个文件(绝对路径,格式按扩展名):', outAbs, '')
56
- }
57
-
58
- L.push(editing ? '处理要求:' : '图片要求:', String(userPrompt || '').trim(), '')
59
- if (transparent) {
60
- L.push('输出必须是**背景真透明**的 PNG(保留 alpha 通道)——',
61
- '不要用白底、纯色底或棋盘格图案冒充透明;边缘要干净,不留原背景的残色描边。', '')
62
- }
63
- // ⚠ 措辞按路径分叉:生成路径这一行**逐字节沿用旧版**(「图片要求」是它上面那个小标题的原词)。
64
- // 我曾图省事把两条路统一成「上述要求」—— 既有单测当场报红,那正是「纯生图零回归」的守卫。
65
- if (style) L.push(editing ? '项目风格档(先读并遵循;与上述处理要求冲突时以处理要求优先):' : '项目风格档(先读并遵循;与图片要求冲突时以图片要求优先):', style, '')
66
-
67
- if (editing) {
68
- // 编辑的底线与生成完全不同:生成图求「好看」,编辑求「除了要改的地方一模一样」——
69
- // 把生成那套「构图有主体/配色克制」套到抠素材上,会诱导它把素材重新画一遍。
70
- L.push('编辑底线(比画得好看更重要):',
71
- '- 除了要求改的部分,其余内容与输入图**保持一致**:不要改变主体的形状、比例、颜色、光影和风格',
72
- '- 不要自作主张裁剪、缩放、旋转或补画要求之外的元素',
73
- '- 需要补全被遮挡的部分时,依据周围可见部分合理续接,材质与光影要接得上',
74
- '- 图中文字保持原样,除非要求明确说了要改',
75
- // ⚠ 2026-09-05 实测:不写死这条,它会在自评「达不到逐像素一致」后**改用 Swift/CoreGraphics
76
- // 手工补像素**,反复迭代到撞墙钟,产物是旧指针残影 + 灰色补丁块。
77
- '- **必须用你自己的图像生成/编辑能力出图**:不要改用脚本逐像素修补或手工重画' +
78
- '(Swift、Python、ImageMagick、canvas 一律不行)—— 那样出来的边缘、光影、质感接不上,一眼就看得出。' +
79
- '图像能力用不了就直接说明原因结束,不要绕路自己画。')
80
- // 达标口径只在**没有遮罩**时给:有遮罩时「不透明处逐像素保原样」由遮罩机制保证、本来就做得到,
81
- // 再说一句「不必逐像素相同」反而自相矛盾。
82
- if (!mask) {
83
- L.push('- 达标看的是**看不出被动过**,不是与输入图逐像素相同:生成式编辑必然带来细微的像素差异,' +
84
- '只要主体、构图、配色、光影、质感没变就算达标。处理要求里即使写了「逐像素一致」「完全不变」' +
85
- '也按这把尺子量 —— **不要因为做不到字面的逐像素一致就放弃图像编辑能力**。')
86
- }
87
- L.push('')
88
- L.push('保存前自查一遍:该改的改了吗?不该动的地方看上去还和输入图一样吗?' +
89
- (transparent ? '背景是真透明吗(不是白底)?边缘干净吗?' : '') +
90
- (style ? '与风格档一致吗?' : '') +
91
- // ⚠ 旧文案是「不满足就重做」无上限 —— 配上一个达不到的验收条件就是死循环。给上限并要求保底交付:
92
- // 一版可用的图 + 一句「还差在哪」,比反复重做到超时什么都交不出好。
93
- '不满足可以重做,但**最多重做两次**;到上限仍不完美就保存当前最好的一版,并说明还差在哪。')
94
- } else {
95
- L.push('设计底线(在满足上述图片要求的前提下遵守):',
96
- '- 构图有明确主体与视觉层级,不要把元素无重点地平铺堆砌',
97
- '- 配色克制:一个主色加少量辅助色;除非明确要求,避免俗套的紫蓝渐变',
98
- '- 图中文字宁少勿多、必须拼写正确,禁止乱码感文字;界面/图表类的数据要像真实业务(量级合理、有单位),不用占位假词假指标',
99
- '- 有参照图或既定系列风格时与之保持一致', '')
100
- // 自查行按有无风格档分叉:给了风格档就必须查到风格档上,否则「先读并遵循」只是句空话
101
- // (无风格档时逐字节沿用老文案 —— 生图对无风格档的项目零回归)
102
- L.push('保存前自查一遍:主体清晰?层级分明?配色克制?文字正确?' + (style ? "与风格档的色板/字体一致?没踩它的 Don'ts?" : '') + '不满足就先重新生成,满意再保存。')
103
- }
104
-
105
- L.push('', `只做「${editing ? '处理图片并保存' : '生成图片并保存'}到上述路径」这一件事,不要执行任何其他任务、不要修改其他文件。`,
106
- editing ? '若无法完成,直接说明原因后结束。' : '若无法生成图片,直接说明原因后结束。')
107
- return L.join('\n')
108
- }
109
-
110
- function newTask({ chatId, workdir, out, outRel, label, prompt, configPath, model, beside, deliver, noBoard, styleDoc, refs, mask, transparent }) {
111
- return {
112
- id: crypto.randomBytes(5).toString('hex'),
113
- chatId, workdir, out, outRel,
114
- label: label || '', prompt, configPath: configPath || null, model: model || null,
115
- // 设计图→网页资产管线(v0.11.27):输入图/遮罩落**绝对路径** —— worker 是另一个进程、
116
- // cwd 与受理侧不同,存相对路径到那边会解析到别的地方(风格档选档踩过同款)。
117
- refs: Array.isArray(refs) && refs.length ? refs.slice() : null,
118
- mask: mask || null,
119
- transparent: !!transparent,
120
- styleDoc: styleDoc || null, // 受理侧定下的风格档(多份档时不让 worker 再猜一次)
121
- beside: beside || null, // 参照节点 id:上板放它右侧(改图场景,透传 board-add-image --beside)
122
- deliver: !!deliver, // 按标注改图交付:worker 上板带 --deliver(结果回填进播报卡),done 兜底让位给 worker
123
- // 画布是**设计演变档案**(原图→标注→新版的链条),不是图片收纳箱。与设计/产品流程无关的
124
- // 单张图(「给我画只猫」)堆上去只会稀释那条链、还占着要长期保留的版面 ⇒ 只发群。
125
- // ⚠ --beside/--deliver 本身就是「这张图在设计流程里」的显式信号,与它们同时给没有意义,
126
- // 由调用侧判为矛盾并忽略 noBoard(见 bin 的解析):留着更容易让改图交付悄悄丢板。
127
- noBoard: !!noBoard,
128
- createdAt: Date.now(), status: 'running', pid: null,
129
- }
130
- }
131
-
132
- const taskFile = (dir, id) => path.join(dir, id + '.json')
133
-
134
- function writeTask(dir, task) {
135
- fs.mkdirSync(dir, { recursive: true })
136
- atomicWriteJSON(taskFile(dir, task.id), task)
137
- }
138
-
139
- function readTask(file) {
140
- return JSON.parse(fs.readFileSync(file, 'utf8'))
141
- }
142
-
143
- function listTasks(dir) {
144
- let names
145
- try { names = fs.readdirSync(dir) } catch { return [] }
146
- const out = []
147
- for (const n of names) {
148
- if (!/^[0-9a-f]+\.json$/.test(n)) continue // 跳过 .stream.jsonl / .log / 锁文件
149
- try { out.push(readTask(path.join(dir, n))) } catch { /* 损坏条目跳过 */ }
150
- }
151
- return out.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))
152
- }
153
-
154
- function pidAlive(pid) {
155
- if (!pid) return false
156
- try { process.kill(pid, 0); return true } catch { return false }
157
- }
158
-
159
- // 「这条任务此刻真的在跑吗」—— 并发计数与「同一张图是否已在生成」共用的唯一判据。
160
- // 三种不算在跑的死态:①标了非 running;②worker 已起(有 pid)但进程已死(崩溃没来得及写
161
- // 状态);③超龄(受理后 worker 从未起来,pid 恒 null,永远占位 → 按墙钟+宽限判过期)。
162
- // ⚠ 收成一个函数而不是两处各写一遍:两处判据分叉的后果是**僵尸任务能挡住新任务**
163
- // (占并发位那边认它死了、去重那边认它活着),而僵尸恰恰是最需要让路的那种。
164
- function isLiveTask(t, { isAlive = pidAlive, now = Date.now } = {}) {
165
- if (!t || t.status !== 'running') return false
166
- if (now() - (t.createdAt || 0) > IMAGEN_WALL_MS + 60_000) return false // 超龄僵尸不算在跑
167
- if (t.pid != null) return isAlive(t.pid)
168
- return true // 刚受理、worker 未接手 —— 算在跑(防受理竞态超卖)
169
- }
170
-
171
- // 并发计数(受理时挡上限)。pid 探活与时钟可注入(单测)。
172
- function countRunning(dir, { isAlive = pidAlive, now = Date.now } = {}) {
173
- return listTasks(dir).filter((t) => isLiveTask(t, { isAlive, now })).length
174
- }
175
-
176
- // 同一张图是不是已经在生成了(受理侧去重的判据)。out 用**绝对路径**比 —— 受理侧 cwd 与
177
- // worker 不同,拿 outRel 比会把两个项目里的同名文件判成同一张。
178
- // ⚠ 2026-09-05 事故:群里的 agent 见任务失败就原样重投,22 个任务里同一个 outRel 最多投了
179
- // 3 次(每次烧满 10 分钟墙钟),而并发上限只有 2 ⇒ 队列被同一张图占死,别的图一张都排不进来。
180
- // 全局并发上限拦不住这种「重复的同一件事」—— 它只数个数,不问是不是同一张图。
181
- function findRunningForOut(tasks, out, { chatId = null, isAlive = pidAlive, now = Date.now } = {}) {
182
- if (!out) return null
183
- return (tasks || []).find((t) => t && t.out === out && (!chatId || t.chatId === chatId) && isLiveTask(t, { isAlive, now })) || null
184
- }
185
-
186
- // 撞上「同一张图正在生成」时给受理方的话。⚠ 这段是 **CLI stdout/stderr**,读它的是 agent 不是
187
- // 群成员 ⇒ 按项目纪律的例外条款保留命令名(不保留它就不会去查 --list)。
188
- // 三件事必须说全:①它在跑、跑了多久 ②别重投、为什么重投更糟 ③还能做什么(查进度 / 换路径)。
189
- function buildDupMessage(dup, { now = Date.now } = {}) {
190
- const sec = Math.max(0, Math.round((now() - ((dup && dup.createdAt) || 0)) / 1000))
191
- return `这张图正在生成中(任务 ${dup && dup.id},已跑 ${sec}s)—— 同一个输出路径同时只允许一个任务。\n` +
192
- `⚠ 不要重投:重投不会更快,只会把生图队列(同时最多 ${IMAGEN_MAX_CONCURRENT} 个)占死;上一次报「失败」也可能只是超时,重投同样的要求大概率还是超时。\n` +
193
- `查进度:sbot imagen --list(完成会自动发群)。真要改需求,等这一张结束再提,或换一个 --out 路径。`
194
- }
195
-
196
- // 僵尸判定:任务标着 running,但它其实早就没了。两种死法 ——
197
- // ①worker 起来过(有 pid)而进程已不在:被杀/崩溃,没来得及落状态
198
- // ②受理后 worker 从未接手(pid 恒 null)且已超龄
199
- // ⚠ 这两条与 isLiveTask 的判据同源(那边判「算不算在跑」,这边判「要不要落成失败」)。
200
- // 2026-08-14 真机:一条 `--sync` 任务的进程被杀,状态永远停在 running —— `--list` 显示
201
- // 「已中断」却没人给它收尾,而轮询等待的一方会一直等一个永远不会变的状态。
202
- function staleReason(task, { isAlive = pidAlive, now = Date.now } = {}) {
203
- if (!task || task.status !== 'running') return null
204
- if (now() - (task.createdAt || 0) > IMAGEN_WALL_MS + 60_000) return '任务超时未完成(worker 可能被中断)'
205
- if (task.pid != null && !isAlive(task.pid)) return '生成进程已中断(未留下结果)'
206
- return null
207
- }
208
-
209
- // 把僵尸任务落成失败态。读侧调用(--list / 受理 / 轮询)⇒ 自愈,不需要额外的清理进程。
210
- // 返回被收尾的任务 id 列表(调用方决定要不要说出来)。
211
- function reapStale(dir, { isAlive = pidAlive, now = Date.now, write = writeTask } = {}) {
212
- const done = []
213
- for (const t of listTasks(dir)) {
214
- const why = staleReason(t, { isAlive, now })
215
- if (!why) continue
216
- try { write(dir, { ...t, status: 'fail', error: why, finishedAt: now() }); done.push(t.id) } catch { /* 单条失败不拖累其余 */ }
217
- }
218
- return done
219
- }
220
-
221
- // 产物校验:worker 等 codex 退出后凭这个判成败 —— 文件存在、非空、魔数是可识别图片格式。
222
- // imageSizeFn = platform/board.js 的 imageSize(注入免循环依赖,单测可换)。
223
- function checkImageOut(absPath, imageSizeFn) {
224
- let buf
225
- try { buf = fs.readFileSync(absPath) } catch { return { ok: false, reason: '未生成图片文件' } }
226
- if (!buf.length) return { ok: false, reason: '生成的文件是空的' }
227
- try {
228
- const { width, height } = imageSizeFn(buf)
229
- return { ok: true, width, height }
230
- } catch {
231
- return { ok: false, reason: '生成的文件不是可识别的图片格式(PNG/JPEG/WebP)' }
232
- }
233
- }
234
-
235
- // ── 失败时把半成品挪走 ───────────────────────────────────────────────────────────────
236
- // 半成品旁路的落点:`design/clocks/clock-01.png` → `design/clocks/clock-01.partial.png`。
237
- // 相对路径与绝对路径都能算(群消息里报相对、真正搬文件用绝对,两边同一套命名)。
238
- function partialPath(p) {
239
- const s = String(p || '')
240
- const ext = path.extname(s)
241
- return (ext ? s.slice(0, s.length - ext.length) : s) + '.partial' + ext
242
- }
243
-
244
- // 起跑前给 out 拍个快照。**这是「旧文件」和「本次半成品」唯一的分界线** —— 失败时光看
245
- // 「out 上有没有文件」是分不清的:那可能是上一版成功的图,一直好端端待在那儿。
246
- function statOut(absPath, { stat = fs.statSync } = {}) {
247
- try { const s = stat(absPath); return { exists: true, size: s.size, mtimeMs: s.mtimeMs } }
248
- catch { return { exists: false, size: 0, mtimeMs: 0 } }
249
- }
250
-
251
- // 失败(含墙钟超时)时把 out 上的那一版挪到 .partial —— **正式路径必须保持「不存在」**。
252
- // ⚠ 2026-09-05 实测:超时被树杀那一刻盘上常常已经落了一版中间产物(9 张改图全在盘上、群里
253
- // 却报失败)。留在正式路径上的后果不是「多一个文件」,是**下一轮 agent 一看文件在就当图做好了**,
254
- // 拿一张 codex 自己都没验收通过的半成品去交付(那版是旧指针残影 + 灰色补丁块)。
255
- // ⚠ before 快照说「存在且 size/mtime 一个字节没变」⇒ 这一版根本不是本次任务写的,是上一版
256
- // 成功的图 —— 挪走它等于因为这次失败把上次的成果弄丢。
257
- // 只在失败路径调用;成功路径一个字节都不碰(调用方保证)。
258
- function quarantinePartial(absPath, before, { stat = fs.statSync, rename = fs.renameSync } = {}) {
259
- let after
260
- try { after = stat(absPath) } catch { return { moved: false, to: null, reason: 'none' } } // 盘上什么都没有
261
- if (before && before.exists && after.size === before.size && after.mtimeMs === before.mtimeMs) {
262
- return { moved: false, to: null, reason: 'untouched' } // 本次一个字节没动过 = 上一版的图,留着
263
- }
264
- const to = partialPath(absPath)
265
- // 同名 .partial 已存在时直接覆盖:它本身就是上一次失败的残留,留两份没有意义(POSIX rename 语义)。
266
- try { rename(absPath, to) } catch { return { moved: false, to: null, reason: 'error' } }
267
- return { moved: true, to, reason: 'moved' }
268
- }
269
-
270
- // 失败原因里把半成品的去处说清楚 —— 不说,查的人就会对着「报失败但文件在」一脸问号(这正是
271
- // 本次事故里发生的事);说了却不挪,下一轮 agent 会拿它当正式产物。两件事必须同时做。
272
- function withPartialNote(reason, partialRel) {
273
- return partialRel ? `${reason}(盘上那版没画完的中间产物已挪到 ${partialRel},正式路径保持不存在)` : String(reason || '')
274
- }
275
-
276
- // 群通知文案(调用方过 redact 后出站)。boardUrl:画板入口直达(收到结果
277
- // 不用翻群标签页,点链接即到)。
278
- // opts.boards:false = 这个群的平台没有画布(企微),收尾那句别提「在画布标注」
279
- function buildDoneMessage(task, boardOk, boardUrl, { boards = true } = {}) {
280
- const name = task.label || task.outRel
281
- return `✅ 生图完成:${name}` + (boardOk ? '(已上画布)' : '') + `\n文件:${task.outRel}` +
282
- (boardUrl ? `\n画板:${boardUrl}` : '') + (boards ? `\n要调整的话在画布标注或群里说一声即可` : `\n要调整的话在群里说一声即可`)
283
- }
284
-
285
- // 失败后该怎么办,按原因分叉 —— 超时那一族**原样重试必然再超时**(2026-09-05:同一张图连试三次、
286
- // 每次烧满 10 分钟额度),给「原样重试」以外的出路才拦得住这种空转。
287
- function failHint(reason) {
288
- return /超时/.test(String(reason || ''))
289
- ? '超时多半是要求里带了做不到的硬条件(例如「逐像素完全一致」「一模一样」)—— 换成「主体和配色不变」这类可达的说法再试;原样重试大概率还是超时'
290
- : '可以换个描述再试;连续失败请检查机器的 codex 登录态/网络'
291
- }
292
-
293
- function buildFailMessage(task, reason) {
294
- const name = task.label || task.outRel
295
- return `❌ 生图失败:${name} — ${reason}\n${failHint(reason)}`
296
- }
297
-
298
- // 画布交付卡(Card JSON 2.0 富卡;docs/BOARD.md)
299
- // —— 图在卡内(合并卡,2026-07-22 用户选)+ 元信息 + open_url「打开画布」按钮,一张卡收拢原来
300
- // 的「图 + 文」两条消息。imgKey = image_type:message 上传返回的 key(与原生图片消息同源,复用
301
- // uploadImage);open_url 无回调无 3s 约束 ⇒ 零新基础设施。纯函数注入 redact(label/文件名是
302
- // agent/用户输入,与文本交付同款脱敏 —— 卡是 JSON 无法整体 redact,故逐字段脱敏)。
303
- function buildDoneCard(task, { boardOk = false, boardUrl = null, imgKey = null, elapsedMs = null, redact = (s) => s } = {}) {
304
- const name = redact(task.label || task.outRel)
305
- const meta = `📄 ${redact(task.outRel)}` + (elapsedMs ? ` · ⏱ ${Math.round(elapsedMs / 1000)}s` : '')
306
- const elements = []
307
- if (imgKey) elements.push({ tag: 'img', img_key: imgKey, scale_type: 'fit_horizontal', corner_radius: '8px', preview: true, alt: { tag: 'plain_text', content: name } })
308
- elements.push({ tag: 'markdown', text_size: 'notation', content: `<font color='grey'>${meta}</font>` })
309
- // 引导语按群有无画布门控 —— imagen 对无画布群也可用,不能把人指去不存在的画布(审查修正)
310
- elements.push({ tag: 'markdown', content: (boardOk || boardUrl)
311
- ? '想调整?在画布上标注,再点播报卡的「按标注改图」按钮 —— 新图会放在原图右侧方便对比。'
312
- : '想调整?在群里说一声要改哪里即可。' })
313
- // ⚠ 2.0 button:样式字段是 type(非 button_style),文字是 text 对象(非 content 字符串)—— 写成
314
- // content 会渲染成空白按钮(v0.6.8 真机踩过)。
315
- if (boardUrl) elements.push({ tag: 'hr' }, { tag: 'button', type: 'primary', width: 'fill', text: { tag: 'plain_text', content: '📋 打开设计画布' }, behaviors: [{ type: 'open_url', default_url: boardUrl }] })
316
- const header = { title: { tag: 'plain_text', content: `🎨 生图完成 · ${name}` }, template: 'green' }
317
- if (boardOk) header.text_tag_list = [{ tag: 'text_tag', text: { tag: 'plain_text', content: '已上画布' }, color: 'green' }]
318
- return { schema: '2.0', config: { width_mode: 'default' }, header, body: { padding: '16px', vertical_spacing: '12px', elements } }
319
- }
320
-
321
- function buildFailCard(task, reason, { redact = (s) => s } = {}) {
322
- const name = redact(task.label || task.outRel)
323
- return {
324
- schema: '2.0', config: { width_mode: 'default' },
325
- header: { title: { tag: 'plain_text', content: `⚠️ 生图失败 · ${name}` }, template: 'red' },
326
- body: { padding: '12px', vertical_spacing: '8px', elements: [
327
- { tag: 'markdown', content: `**原因**:${redact(String(reason || '未知'))}` },
328
- { tag: 'markdown', content: failHint(reason) + '。' },
329
- ] },
330
- }
331
- }
332
-
333
- // ── 生图进度(群里那条会自己更新的消息)────────────────────────────────────────────────
334
- // 背景(实测):生图一跑 10 分钟,群里只看得到
335
- // 「受理」和最后的「失败」,中间完全静默 —— 群成员分不清是在跑还是死了。
336
- // ⚠ 只能做**粗阶段**:codex 生图走内部 imagegen skill,`--json` 流里它是普通 `command_execution`,
337
- // 没有专门的生图 item、没有百分比、没有 partial 预览。硬凑百分比就是编数字。
338
- const IMAGEN_STAGE_ORDER = ['start', 'prep', 'gen', 'save', 'wrap', 'ended']
339
- const IMAGEN_STAGE_TEXT = {
340
- start: '正在启动绘图',
341
- prep: '正在读取绘图规范',
342
- gen: '绘制中 —— 一般 1~3 分钟,改图或复杂画面会更久',
343
- save: '画面已出,正在保存并自查',
344
- wrap: '收尾中 —— 校验产物、上画布',
345
- ended: '本次生成已结束,结果见下面的消息',
346
- }
347
-
348
- // codex 的 `--json` 流里混着非 JSON 的诊断行(真机抓到过 rmcp transport 的 ERROR 行)——
349
- // 解析不了就跳过,一行脏数据不该把整条进度链拖垮。
350
- function parseStreamLine(line) {
351
- const s = String(line || '').trim()
352
- if (!s || s[0] !== '{') return null
353
- try { const o = JSON.parse(s); return o && typeof o === 'object' && !Array.isArray(o) ? o : null } catch { return null }
354
- }
355
-
356
- // 一条流事件对应哪个阶段(认不出返回 null = 不推进)。判据取自真机流(见 docs/BOARD.md):
357
- // ⭐ `generated_images` 是**唯一能确认「图真的画出来了」的信号** —— 内置 image_gen 把图落在
358
- // `~/.codex/generated_images/…`,codex 随后 `cp` 到 out;流里没有别的东西能证明这件事。
359
- function stageOfEvent(ev) {
360
- if (!ev || typeof ev !== 'object') return null
361
- if (ev.type === 'thread.started' || ev.type === 'turn.started') return 'prep'
362
- if (ev.type === 'turn.completed' || ev.type === 'turn.failed') return 'wrap'
363
- const item = ev.item
364
- if (!item || typeof item !== 'object') return null
365
- if (item.type === 'command_execution') {
366
- const cmd = String(item.command || '')
367
- if (cmd.includes('generated_images')) return 'save' // 图已生成,正在落到 out
368
- if (cmd.includes('imagegen')) return 'prep' // 在读生图技能说明书 = 还在准备
369
- return 'gen' // 其余 shell 动作(探环境、查尺寸)一律算「在干活」
370
- }
371
- if (item.type === 'agent_message' || item.type === 'mcp_tool_call') return 'gen'
372
- return null
373
- }
374
-
375
- // 阶段只进不退:图落盘之后 codex 还会 `sips` 自查(那是 command_execution ⇒ 认成 'gen'),
376
- // 不钉死单调就会出现「保存中 → 绘制中」来回跳,群里看着像出了问题。
377
- function nextStage(cur, ev) {
378
- const s = stageOfEvent(ev)
379
- if (!s) return cur
380
- const a = IMAGEN_STAGE_ORDER.indexOf(cur)
381
- const b = IMAGEN_STAGE_ORDER.indexOf(s)
382
- return b > a ? s : cur
383
- }
384
-
385
- // 进度更新节流。⚠ 飞书接口是**租户级 100 万次/月**的共享额度(这台机器画布轮询也开着,单个
386
- // 有画布的群就 ≈4.3 万/月)⇒ 每个流事件 edit 一次是不能接受的,而进度本来也不需要精确到秒。
387
- // 两条闸各治一半:
388
- // ① 两次更新最少隔 MIN —— 阶段连着变(prep→gen→save 可能只差几秒)也不刷屏;
389
- // ② 阶段没变也每 HEARTBEAT 刷一次耗时 —— 不刷就又变回「一声不吭」,这正是要修的毛病。
390
- // 账:一次跑满 10 分钟的任务最坏 = 1 次发卡 + 阶段变更 ≤4 次 + 心跳 ≤600/90≈6 次 + 1 次收尾
391
- // ≈ 12 次调用;并发上限 2、一天几十张图 ⇒ 月增量千级,与画布轮询不是一个量级。
392
- const IMAGEN_PROGRESS_MIN_MS = 15_000
393
- const IMAGEN_PROGRESS_HEARTBEAT_MS = 90_000
394
- const IMAGEN_PROGRESS_TICK_MS = 5_000 // 心跳靠定时器驱动 —— 事件本身很稀(10 分钟才十来条),等事件等不来
395
-
396
- function shouldPushProgress({ stage, pushedStage, lastPushAt } = {}, now = Date.now()) {
397
- if (lastPushAt == null) return true // 还没发过第一条
398
- const since = now - lastPushAt
399
- return stage !== pushedStage ? since >= IMAGEN_PROGRESS_MIN_MS : since >= IMAGEN_PROGRESS_HEARTBEAT_MS
400
- }
401
-
402
- function fmtElapsed(ms) {
403
- const s = Math.max(0, Math.round((Number(ms) || 0) / 1000))
404
- return s < 60 ? `${s} 秒` : `${Math.floor(s / 60)} 分 ${s % 60} 秒`
405
- }
406
-
407
- // 进度卡(会被原地更新的那一张)。⚠ 群成员看得到 ⇒ **零工具名、零机器内部路径**:只出现
408
- // 项目内的相对路径(与完成卡同口径)。「不用重复提交」写在卡上是故意的 —— 事故里正是「见失败
409
- // 就原样重投」把队列占死,受理侧的挡板只拦得住 agent,群里的人也该看得见这句。
410
- function buildProgressCard(task, { stage = 'start', elapsedMs = 0, redact = (s) => s } = {}) {
411
- const name = redact(task.label || task.outRel)
412
- return {
413
- schema: '2.0', config: { width_mode: 'default' },
414
- header: { title: { tag: 'plain_text', content: `🎨 生成中 · ${name}` }, template: 'blue' },
415
- body: { padding: '12px', vertical_spacing: '8px', elements: [
416
- { tag: 'markdown', content: `**${IMAGEN_STAGE_TEXT[stage] || IMAGEN_STAGE_TEXT.start}**` },
417
- { tag: 'markdown', text_size: 'notation', content: `<font color='grey'>⏱ 已用 ${fmtElapsed(elapsedMs)} · 📄 ${redact(task.outRel)}</font>` },
418
- { tag: 'markdown', text_size: 'notation', content: "<font color='grey'>生成期间不用重复提交,好了会在这里出图。</font>" },
419
- ] },
420
- }
421
- }
422
-
423
- // 等图的三态文案(`--wait`)。⚠ 「还没好」**不是失败** —— worker 是 detached 的,受理进程
424
- // 就算被 agent 的 Bash 超时杀掉,图照样会生成、照样发群里。文案必须把这件事说死,否则
425
- // agent 拿到非零退出码会以为要重试,白烧一次额度。
426
- const WAIT_TEXT = {
427
- ok: (rel) => `✅ 图已生成:${rel}\n可以引用这个文件了。`,
428
- fail: (rel, why) => `❌ 生图失败:${rel} — ${why}\n换个描述再试;连续失败请检查机器的 codex 登录态/网络。`,
429
- pending: (rel, sec) => `⏳ 等了 ${sec}s 还没好 —— **任务仍在后台跑,没有失败,也不用重试**。\n` +
430
- `⚠ 此刻 ${rel} 还不存在,先别把它写进页面/引用它。\n` +
431
- `生成完会自动发到群里(要查进度:sbot imagen --list)。需要图才能继续的活,等图到了下一轮再做。`,
432
- }
433
-
434
- // 等图轮询(`--wait`)的状态机。**抽到这里纯粹为了可测** —— 留在 bin 里就只能靠真跑一次
435
- // 生图来验,而那要烧额度、要等 90s+,结果就是「只验得起超时那一态」。三态各自的下一步差别
436
- // 极大(能不能引用文件 / 要不要重试),恰恰是最该被钉死的地方。
437
- // 依赖全注入(read/reap/now/sleep),不碰 fs 不碰时钟。
438
- async function waitForTask({ read, reap = () => {}, now = Date.now, sleep, deadlineMs, pollMs = 2000 } = {}) {
439
- for (;;) {
440
- reap() // 僵尸任务当场落成失败,否则这里会一直等一个永远不会变的 running
441
- let cur = null
442
- try { cur = read() } catch { /* 写入竞态:下一轮再读 */ }
443
- if (cur && cur.status === 'ok') return { status: 'ok' }
444
- if (cur && cur.status === 'fail') return { status: 'fail', error: cur.error || '未知原因' }
445
- if (now() >= deadlineMs) return { status: 'pending' }
446
- await sleep(pollMs)
447
- }
448
- }
449
-
450
- module.exports = {
451
- IMAGEN_MAX_CONCURRENT, IMAGEN_WALL_MS, WAIT_TEXT, waitForTask,
452
- imagenDir, buildImagenPrompt, newTask, taskFile, writeTask, readTask, listTasks,
453
- pidAlive, isLiveTask, countRunning, findRunningForOut, buildDupMessage,
454
- staleReason, reapStale, checkImageOut, buildDoneMessage, buildFailMessage, buildDoneCard, buildFailCard, failHint,
455
- partialPath, statOut, quarantinePartial, withPartialNote,
456
- IMAGEN_STAGE_ORDER, IMAGEN_STAGE_TEXT, IMAGEN_PROGRESS_MIN_MS, IMAGEN_PROGRESS_HEARTBEAT_MS, IMAGEN_PROGRESS_TICK_MS,
457
- parseStreamLine, stageOfEvent, nextStage, shouldPushProgress, fmtElapsed, buildProgressCard,
458
- }
@@ -1,44 +0,0 @@
1
- // 额度 / 账号类错误判据 —— 「等一会儿没用,得换模型或换账号」的那类错(两个 runner 共用;与 ./transienterr 对称)。
2
- //
3
- // 为什么住 src/agent 而不是 src/core:签名表全是具体后端的原话形状,是 runner 层的知识;core 只认 runner 挂上的结构化
4
- // 字段 result.quota,不碰原话。「几点重置」的解析(parseResetAt)依赖产品时区,留在 src/core/quotaerr.js。
5
- //
6
- // 与 transienterr(瞬时错:过载 / 限流 / 网关错)是两类事,那边显式把额度类排除了;这里接住它们:
7
- // · quota —— 订阅额度用尽(5 小时 / 一周窗口)。服务端原话形如
8
- // 「You've hit your weekly limit · resets Oct 1 at 9am (Asia/Shanghai)」(claude 2.1.260 真机)
9
- // 「You've hit your usage limit. Upgrade to Pro (…) or try again at 2:30 PM.」(codex 0.144 真机)
10
- // · account —— 账号层面不可用:被停用 / 登录态过期刷新不了。原话形如
11
- // 「Your account is on hold and can't use Claude Code. …」(2026-09-27 真机)
12
- // 「Failed to authenticate: OAuth session expired and could not be refreshed」(2026-09-25 真机,6 轮)
13
- //
14
- // 为什么要分这两档:额度类有确定的重置时刻,切到备用模型后到点就该切回来;账号类没有重置时刻,
15
- // 要人去处理(重新登录 / 申诉),切过去之后只能靠探针确认恢复才切回。两档的「下一步」不同,混成一档就
16
- // 会在错的时候切回去。
17
- //
18
- // 两个 runner 在出错结果上标 `quota`(与 `transient` 同一形状:runner 判、引擎只认字段);重置时刻的
19
- // 解析放在这里(core)而不是 runner:它依赖产品时区,而 runner 不知道时区。
20
- // ⚠ 判据宁漏不误:只认明确的额度 / 认证措辞;裸的「limit」「403」不认(正文里出现「limit」太常见)。
21
-
22
-
23
- // 额度用尽的签名(窗口能分出来就分,分不出来为 null —— 切换逻辑不依赖窗口,只有文案用)
24
- const QUOTA_SIGNATURES = [
25
- [/hit your weekly limit|weekly (?:usage )?limit (?:reached|exceeded)/i, 'weekly'],
26
- [/hit your (?:session|5-hour|five-hour) limit/i, 'session'],
27
- [/hit your (?:usage |daily |monthly )?limit|usage[ _]limit[ _](?:reached|exceeded)|usage_limit|out of (?:extra )?usage|quota (?:exceeded|exhausted|reached)|额度(?:用尽|已用完|用完)/i, null],
28
- ]
29
- // 账号 / 登录态不可用的签名
30
- const ACCOUNT_RE = /account is on hold|account (?:has been |is )?(?:suspended|restricted|disabled|deactivated|locked)|could not be refreshed|oauth session expired|oauth token (?:expired|revoked)|invalid[_ ]grant|failed to authenticate|authentication[_ ]error|not logged in|please (?:run )?\/?login/i
31
-
32
- /**
33
- * 判一段出错原话是不是额度 / 账号类。
34
- * @returns {{kind:'quota'|'account', window:'weekly'|'session'|null}|null}
35
- */
36
- function classifyQuota(text) {
37
- const s = String(text || '')
38
- if (!s.trim()) return null
39
- for (const [re, window] of QUOTA_SIGNATURES) if (re.test(s)) return { kind: 'quota', window }
40
- if (ACCOUNT_RE.test(s)) return { kind: 'account', window: null }
41
- return null
42
- }
43
-
44
- module.exports = { classifyQuota, QUOTA_SIGNATURES, ACCOUNT_RE }
@@ -1,32 +0,0 @@
1
- // 瞬时错判据 —— 服务端过载 / 限流 / 网关错这类「过一会儿再来就好」的错,与配置错、权限错、
2
- // 额度用尽是两类事:前者群里该说「排不上队,稍后接着做」,后者要人去改配置或等额度重置。
3
- //
4
- // 由来(2026-09-08 测试群现场):codex 在工具调用之后的下一次请求上收到
5
- // 「Selected model is at capacity. Please try a different model.」,群里落的是这句英文原话 ——
6
- // 「换个模型」是给 ChatGPT 用户的建议,群成员照做不了(群里 /model 会清会话,照做反而更糟)。
7
- //
8
- // 两个 runner 共用这一份签名表(codex 的 turn.failed / error,claude 的 result is_error),
9
- // 分开写必漂;引擎只认结果上的 `transient` 字段(一句人话),不认签名 —— core 不碰错误文本。
10
- // ⚠ 只给群面那句人话,**不改 result.text**:账本的失败原因要留服务端原话,查的人靠它对号。
11
- // ⚠ 判据宁漏不误:额度类(usage limit / quota / billing)显式排除 —— 那种等十秒没用,
12
- // 而且额度告警另有一条线在管;裸数字状态码不认(「exit code 500」这种正文会误中)。
13
-
14
- const NOT_TRANSIENT = /usage limit|usage_limit|quota|额度|insufficient|billing|credit/i
15
-
16
- // [签名, 群面人话] —— 顺序即优先级;人话里零工具名(群成员看得到)
17
- const SIGNATURES = [
18
- [/at capacity/i, '模型这会儿排不上队(服务端过载)'],
19
- [/overloaded/i, '模型这会儿排不上队(服务端过载)'],
20
- [/rate[ _-]?limit|too many requests/i, '请求太密,被暂时限流了'],
21
- [/bad gateway|service unavailable|gateway time-?out|internal server error|server_error|upstream connect error/i, '模型服务暂时不可用'],
22
- ]
23
-
24
- /** 命中瞬时错签名 ⇒ 返回群面那句人话;否则空串。 */
25
- function classifyTransient(text) {
26
- const s = String(text || '')
27
- if (!s.trim() || NOT_TRANSIENT.test(s)) return ''
28
- for (const [re, human] of SIGNATURES) if (re.test(s)) return human
29
- return ''
30
- }
31
-
32
- module.exports = { classifyTransient, SIGNATURES, NOT_TRANSIENT }