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
@@ -11,9 +11,13 @@
11
11
  // confirmed. The event mapping mirrors the samples captured there.
12
12
 
13
13
  const { spawn } = require('node:child_process')
14
+ const closedRunnerResults = new WeakSet()
15
+ const isClosedRunnerResult = result => !!result && typeof result === 'object' && closedRunnerResults.has(result)
16
+ const { StringDecoder } = require('node:string_decoder')
14
17
  const { IS_WIN, resolveSpawn } = require('./winexec')
15
- const { classifyTransient } = require('./transienterr') // 瞬时错(过载 / 限流)判据,与 claude runner 共用
16
- const { classifyQuota } = require('./quotaerr') // 额度 / 账号类判据(切备用模型的依据),与 claude runner 共用
18
+ const { tagError } = require('./errtag') // 出错原话 → quota / sessionMissing / transient 之一(优先级只在那一处定),与 claude runner 共用
19
+ const { sandboxTier, codexBuildPrompt, codexBuildArgs, codexNormalizeEvent, codexParseError, codexEffortRejection } = require('./privatecore')
20
+ const { coreFailedResult } = require('./errtag')
17
21
 
18
22
  // Warn only when the answer→close (rollout-flush) lag is abnormally high. Real-machine
19
23
  // data (Windows, codex 0.142.5) clustered at ~2.0–3.2s, so healthy turns stay silent and
@@ -30,16 +34,7 @@ const CLOSE_LAG_WARN_MS = 8000
30
34
  // approval_policy is ALWAYS "never": `codex exec` has no approval IPC, so any other
31
35
  // value would block on a TTY prompt that never comes.
32
36
  function sandboxMode(permissionMode) {
33
- return permissionMode === 'bypassPermissions' ? 'danger-full-access' : 'read-only'
34
- }
35
-
36
- // Codex item.type → a friendly tool name for sbot's activity/card (mirrors Claude's
37
- // tool names where sensible). Unknown types pass through under their raw name.
38
- const CODEX_TOOL_NAMES = {
39
- command_execution: 'Bash',
40
- web_search: 'WebSearch',
41
- file_search: 'FileSearch',
42
- mcp_tool_call: 'MCP',
37
+ return sandboxTier(permissionMode)
43
38
  }
44
39
 
45
40
  // A codex error message is often a raw JSON blob, e.g.
@@ -48,11 +43,7 @@ const CODEX_TOOL_NAMES = {
48
43
  // Extract the human message (so a group never sees raw JSON) and the offending param
49
44
  // (so the runner can recover from a rejected reasoning effort — see isEffortRejection).
50
45
  function parseCodexError(rawMsg) {
51
- const s = String(rawMsg || '').trim()
52
- if (s.startsWith('{')) {
53
- try { const j = JSON.parse(s); if (j && j.error) return { text: String(j.error.message || s), param: String(j.error.param || '') } } catch { /* not JSON — fall through */ }
54
- }
55
- return { text: s, param: '' }
46
+ return codexParseError(rawMsg)
56
47
  }
57
48
 
58
49
  // Map one raw `codex exec --json` event to a normalized AgentEvent (interfaces.js),
@@ -60,68 +51,27 @@ function parseCodexError(rawMsg) {
60
51
  // the final text — Codex streams the answer as `agent_message` items, so the caller
61
52
  // fills result.text from the last one.
62
53
  function normalizeEvent(ev) {
63
- switch (ev.type) {
64
- case 'thread.started':
65
- return { type: 'system', subtype: 'init', sessionId: ev.thread_id }
66
-
67
- case 'item.started':
68
- case 'item.completed': {
69
- const item = ev.item || {}
70
- const done = ev.type === 'item.completed'
71
- switch (item.type) {
72
- case 'agent_message': // the assistant's reply text (may arrive as several items)
73
- return done ? { type: 'assistant', text: item.text || '' } : null
74
- case 'reasoning':
75
- return done ? { type: 'assistant', thinking: item.text || '' } : null
76
- case 'command_execution': // shell — surface as a Bash tool_use, then its result
77
- return done
78
- ? { type: 'tool_result', results: [{ type: 'tool_result', content: item.aggregated_output || '', is_error: item.exit_code != null && item.exit_code !== 0 }] }
79
- : { type: 'assistant', tools: [{ name: 'Bash', input: { command: item.command } }] }
80
- case 'file_change': // one or more path edits — surface each as a Write/Delete
81
- return done ? null : { type: 'assistant', tools: (item.changes || []).map((c) => ({ name: c.kind === 'delete' ? 'Delete' : 'Write', input: { path: c.path } })) }
82
- default: {
83
- const name = CODEX_TOOL_NAMES[item.type] || item.type
84
- return done ? null : { type: 'assistant', tools: [{ name, input: item }] }
85
- }
86
- }
87
- }
88
-
89
- case 'turn.completed':
90
- // ⚠ codex 的 usage 是**会话累计值**不是本轮增量(2026-08-09 对照 rollout 的
91
- // total_token_usage 坐实;resume 的轮把历史轮全算在内)。标 usageCumulative,
92
- // engine 侧经 store 基线换算成本轮增量 —— 直接当单轮记会把账越记越大。
93
- return { type: 'result', subtype: 'success', usage: ev.usage, usageCumulative: true } // text filled by caller
94
-
95
- case 'turn.failed': {
96
- const { text, param } = parseCodexError(ev.error && ev.error.message)
97
- const r = { type: 'result', subtype: 'error', isError: true, text: text || 'codex turn failed' }
98
- if (param === 'reasoning.effort') r.effortRejected = true
99
- // 瞬时错(服务端过载 / 限流)给群面一句人话;text 仍是原话,留给账本(判据在 transienterr,两 runner 共用)
100
- const t = classifyTransient(text); if (t) r.transient = t
101
- const q = classifyQuota(text); if (q) r.quota = q // 额度 / 账号类:引擎据此切备用模型
102
- return r
103
- }
104
-
105
- case 'error': {
106
- const { text, param } = parseCodexError(ev.message)
107
- const r = { type: 'result', subtype: 'error', isError: true, text: text || 'codex error', raw: ev }
108
- if (param === 'reasoning.effort') r.effortRejected = true
109
- const t = classifyTransient(text); if (t) r.transient = t
110
- const q = classifyQuota(text); if (q) r.quota = q
111
- return r
112
- }
113
-
114
- default:
115
- return null // turn.started and other bookkeeping — ignore
54
+ const result = codexNormalizeEvent(ev)
55
+ if (result === null) return null
56
+ // JSON cannot carry undefined. Preserve the frozen runner's public shape.
57
+ if (ev.type === 'thread.started') result.sessionId = ev.thread_id
58
+ else if (ev.type === 'turn.completed') result.usage = ev.usage
59
+ else if (ev.type === 'error') result.raw = ev
60
+ else if (ev.type === 'item.started' && result.type === 'assistant' && result.tools) {
61
+ const item = ev.item || {}
62
+ if (item.type === 'command_execution') result.tools[0].input.command = item.command
63
+ else if (item.type === 'file_change') (item.changes || []).forEach((change, i) => { result.tools[i].input.path = change.path })
64
+ else if (!item.type) result.tools[0].name = item.type
116
65
  }
66
+ if (result.type === 'result' && result.isError) tagError(result, parseCodexError(ev.type === 'error' ? ev.message : ev.error && ev.error.message).text)
67
+ return result
117
68
  }
118
69
 
119
70
  // Prepend sbot's system prompt / conventions into the user prompt — FIRST TURN ONLY.
120
71
  // `codex exec` has no --append-system-prompt; the prompt text is the only channel.
121
72
  // On resume the convention persists in-session, so it isn't re-sent.
122
73
  function buildPrompt(prompt, appendSystemPrompt, isResume) {
123
- if (isResume || !appendSystemPrompt) return prompt
124
- return `Before answering, follow these project-level instructions for this session. They are not user content.\n\n${appendSystemPrompt}\n\n---\n\nUser message:\n${prompt}`
74
+ return codexBuildPrompt(prompt, appendSystemPrompt, isResume)
125
75
  }
126
76
 
127
77
  // Build the argv for `codex exec`. Fresh and resume differ in verified ways (Windows
@@ -129,24 +79,7 @@ function buildPrompt(prompt, appendSystemPrompt, isResume) {
129
79
  // and `--cd` (relies on process cwd); fresh accepts `--sandbox` + `--cd`. Prompt rides
130
80
  // on stdin (trailing `-`), dodging Windows argv mangling of arbitrary chat text.
131
81
  function buildArgs({ sessionId, model, workdir, permissionMode, reasoningEffort, autocompactTokens }) {
132
- const tier = sandboxMode(permissionMode)
133
- const args = ['exec']
134
- if (sessionId) args.push('resume', sessionId)
135
- args.push('--skip-git-repo-check')
136
- if (sessionId) args.push('-c', `sandbox_mode=${tier}`)
137
- else args.push('--sandbox', tier)
138
- args.push('-c', 'approval_policy=never')
139
- // Per-provider reasoning-effort tuning (minimal/low/medium/high). A `-c` override,
140
- // valid on both fresh and resume; omitted ⇒ codex uses the model's default.
141
- if (reasoningEffort) args.push('-c', `model_reasoning_effort=${reasoningEffort}`)
142
- // 长会话上下文上限:超过即让 codex 原地压缩(键名 0.144 二进制核对)。不传 ⇒ codex 只在
143
- // 模型窗口边缘才压,多天群会话的历史一直全量驮着。`-c` 逐次覆盖,不动机器全局 config.toml。
144
- if (autocompactTokens > 0) args.push('-c', `model_auto_compact_token_limit=${autocompactTokens}`)
145
- if (model) args.push('--model', model)
146
- args.push('--json')
147
- if (!sessionId && workdir) args.push('--cd', workdir)
148
- args.push('-') // prompt on stdin
149
- return args
82
+ return codexBuildArgs({ sessionId, model, workdir, permissionMode, reasoningEffort, autocompactTokens })
150
83
  }
151
84
 
152
85
  // A codex turn.failed / error result whose offending param is the reasoning effort —
@@ -154,7 +87,7 @@ function buildArgs({ sessionId, model, workdir, permissionMode, reasoningEffort,
154
87
  // (model/list advertises a model's full effort catalog, but the runtime API narrows it by
155
88
  // plan: e.g. a Plus account's gpt-5.6-terra rejects max/ultra though model/list lists them.)
156
89
  function isEffortRejection(result) {
157
- return !!(result && result.isError && (result.effortRejected || /reasoning\.effort/.test(String(result.text || ''))))
90
+ return codexEffortRejection(result)
158
91
  }
159
92
 
160
93
  /**
@@ -175,13 +108,32 @@ function runTurn(opts = {}) {
175
108
  // the first result event, idempotently — an early error would swallow the retry's
176
109
  // success). SUCCESS results pass through immediately, preserving prompt finalize.
177
110
  let heldErr = null
111
+ let retrySafe = true
112
+ // A rejected request is safe to resubmit only before Codex emits any item or
113
+ // successful turn event. Inspect raw events because some completed items do not
114
+ // produce a normalized event (for example, file_change).
115
+ const observeFirstAttempt = (raw) => {
116
+ // Once the durable runner-start receipt is acknowledged, even a later
117
+ // request-validation error cannot launch a second process under that receipt.
118
+ if ((opts.onTurnAccepted || opts.onDurableEvent) && raw.type === 'turn.started') retrySafe = false
119
+ if (!['thread.started', 'turn.started', 'turn.failed', 'error'].includes(raw.type)) retrySafe = false
120
+ }
178
121
  const proxy = opts.onEvent
179
122
  ? (ev) => { if (ev && ev.type === 'result' && ev.isError) { heldErr = ev; return } opts.onEvent(ev) }
180
123
  : undefined
181
- return runOnce({ ...opts, onEvent: proxy }, wantEffort).then((res) => {
182
- if (heldErr && isEffortRejection(res.result) && !res.aborted) {
124
+ return runOnce({ ...opts, onEvent: proxy, onRawEvent: observeFirstAttempt }, wantEffort).then((res) => {
125
+ if (retrySafe && heldErr && isEffortRejection(res.result) && !res.aborted) {
183
126
  if (opts.log) opts.log(`codex 推理强度 '${wantEffort}' 不被当前模型/套餐接受(model/list 报模型上限,运行时按套餐收窄)— 已回退模型默认重试一轮`)
184
- return runOnce(opts, '').then((r2) => ({ ...r2, effortFellBack: wantEffort }))
127
+ // 引擎在 result 事件到达时就定稿卡片(不等进程关闭);回退身份必须随事件走,
128
+ // 只在 Promise 返回值上加 effortFellBack 会让页脚误报刚被拒绝的强度。
129
+ const retryOpts = opts.onEvent
130
+ ? { ...opts, onEvent: (ev) => opts.onEvent(ev && ev.type === 'result' ? { ...ev, effortFellBack: wantEffort } : ev) }
131
+ : opts
132
+ return runOnce(retryOpts, '').then((r2) => {
133
+ const retried = { ...r2, result: r2.result ? { ...r2.result, effortFellBack: wantEffort } : r2.result, effortFellBack: wantEffort }
134
+ if (isClosedRunnerResult(r2)) closedRunnerResults.add(retried)
135
+ return retried
136
+ })
185
137
  }
186
138
  // Not recoverable: the resolved result carries the (readable) error; the engine's
187
139
  // post-await finalizeTurn fallback reports it (attempt 1 never emitted it via onEvent).
@@ -207,6 +159,10 @@ function runOnce(opts, effort) {
207
159
  appendSystemPrompt,
208
160
  extraEnv,
209
161
  onEvent,
162
+ onDurableEvent,
163
+ onRawEvent,
164
+ onTurnAccepted,
165
+ onSpawnFailed,
210
166
  signal,
211
167
  softStopGraceMs = 10000,
212
168
  log = () => {}, // daemon logger — warns when the answer→close (rollout-flush) lag is high
@@ -215,6 +171,9 @@ function runOnce(opts, effort) {
215
171
  } = opts || {}
216
172
  return new Promise((resolve, reject) => {
217
173
  if (!prompt) return reject(new Error('runTurn: prompt required'))
174
+ if (onTurnAccepted !== undefined && typeof onTurnAccepted !== 'function') return reject(new TypeError('onTurnAccepted must be a function'))
175
+ if (onDurableEvent !== undefined && typeof onDurableEvent !== 'function') return reject(new TypeError('onDurableEvent must be a function'))
176
+ if (onSpawnFailed !== undefined && typeof onSpawnFailed !== 'function') return reject(new TypeError('onSpawnFailed must be a function'))
218
177
 
219
178
  const env = { ...process.env }
220
179
  delete env.CLAUDECODE // don't let a nested Claude Code session confuse the child
@@ -228,7 +187,8 @@ function runOnce(opts, effort) {
228
187
  if (extraEnv) Object.assign(env, extraEnv)
229
188
 
230
189
  const args = buildArgs({ sessionId, model: model || provider.model, workdir, permissionMode, reasoningEffort: effort, autocompactTokens })
231
- const { file, spawnArgs } = resolveSpawn(bin, args)
190
+ const { file, spawnArgs, fallback } = resolveSpawn(bin, args)
191
+ if (fallback) log(`配置的 agent 路径 '${bin}' 不存在,本轮改用 ${fallback} —— 请把配置改回正确路径`) // 与 claude runner 同一条兜底
232
192
  const child = spawnFn(file, spawnArgs, {
233
193
  env,
234
194
  cwd: workdir || process.cwd(),
@@ -251,6 +211,12 @@ function runOnce(opts, effort) {
251
211
  // safe: force-releasing before the rollout is durable breaks the next resume. Logged on
252
212
  // close so a real-machine run surfaces it; not fired on abort (rollout is forfeited then).
253
213
  let answerAtMs = 0
214
+ const durableStart = typeof onTurnAccepted === 'function'
215
+ let acceptedStart = false
216
+ let acceptanceFailed = false
217
+ const heldEvents = []
218
+ let durableWork = Promise.resolve()
219
+ let durableFailed = false
254
220
 
255
221
  // /stop and the wall-clock cap abort via this signal. On Windows the codex.exe under
256
222
  // the cmd.exe shim can SURVIVE a tree-kill and keep the inherited stdout pipe open, so
@@ -260,8 +226,14 @@ function runOnce(opts, effort) {
260
226
  // (sbot's never-hang invariant) even if a grandchild clings to the pipe.
261
227
  let aborted = false
262
228
  let settled = false
229
+ let childCloseObserved = false
230
+ let childOutputObserved = false
263
231
  let graceTimer = null
264
232
  const done = (fn) => { if (settled) return; settled = true; if (graceTimer) clearTimeout(graceTimer); if (signal) signal.removeEventListener('abort', onAbort); fn() }
233
+ const resolveRun = result => {
234
+ if (childCloseObserved) closedRunnerResults.add(result)
235
+ resolve(result)
236
+ }
265
237
  const killChild = () => {
266
238
  if (IS_WIN && child.pid) {
267
239
  try { spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }) } catch {}
@@ -269,7 +241,8 @@ function runOnce(opts, effort) {
269
241
  return
270
242
  }
271
243
  try { child.kill('SIGTERM') } catch { /* already gone */ }
272
- setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
244
+ const reapTimer = setTimeout(() => { try { child.kill('SIGKILL') } catch {} }, softStopGraceMs)
245
+ reapTimer.unref?.()
273
246
  }
274
247
  // Resolve on the process 'close' — NOT on `turn.completed`. Codex finalizes its session
275
248
  // rollout on CLEAN EXIT (writes then commits the file), so hard-killing a still-running
@@ -284,15 +257,66 @@ function runOnce(opts, effort) {
284
257
  const onAbort = () => {
285
258
  aborted = true
286
259
  killChild()
287
- graceTimer = setTimeout(() => done(() => resolve({ sessionId: capturedSessionId, result: lastResult, code: null, aborted: true })), softStopGraceMs)
260
+ graceTimer = setTimeout(() => {
261
+ const complete = () => done(() => resolveRun({ sessionId: capturedSessionId, result: lastResult, code: null, aborted: true }))
262
+ if (onDurableEvent) durableWork.then(complete, failDurableEvent)
263
+ else complete()
264
+ }, softStopGraceMs)
288
265
  }
289
266
  if (signal) {
290
267
  if (signal.aborted) onAbort()
291
268
  else signal.addEventListener('abort', onAbort, { once: true })
292
269
  }
293
270
 
294
- child.stdout.on('data', (chunk) => {
295
- buf += chunk.toString('utf8') // Node reads bytes → UTF-8, no PowerShell GBK pipe issue
271
+ // 这段回调不在任何 Promise 链里,抛错会直接带走整个进程 ⇒ 核心失败只结束当前轮。
272
+ // 已经拿到结果时不杀进程:codex 要干净退出才会提交可续接的会话文件。
273
+ // 强杀后这个新 thread id 没有可续接的文件,所以交回传入的旧 id(语义同下面的崩溃退出)。
274
+ let coreFailed = false
275
+ const failCore = (error) => {
276
+ if (onDurableEvent) { failDurableEvent(error); return }
277
+ if (durableStart && !acceptedStart) { failTurnAcceptance(error); return }
278
+ coreFailed = true
279
+ buf = ''
280
+ log(`binary core failed in codex stream: ${error && error.message}`)
281
+ if (lastResult) return
282
+ capturedSessionId = sessionId || null
283
+ lastResult = coreFailedResult(capturedSessionId)
284
+ if (onEvent) { try { onEvent(lastResult) } catch { /* renderer must not kill the turn */ } }
285
+ killChild()
286
+ }
287
+ const failTurnAcceptance = (error) => {
288
+ if (acceptanceFailed || settled) return
289
+ acceptanceFailed = true
290
+ buf = ''
291
+ heldEvents.length = 0
292
+ killChild()
293
+ done(() => reject(error))
294
+ }
295
+ const failDurableEvent = (error) => {
296
+ if (durableFailed || settled) return
297
+ durableFailed = true
298
+ buf = ''
299
+ heldEvents.length = 0
300
+ killChild()
301
+ done(() => reject(error))
302
+ }
303
+ const emitEvent = (ev) => { if (onEvent) { try { onEvent(ev) } catch { /* renderer must not kill the turn */ } } }
304
+ const deliverEvent = async (ev) => {
305
+ if (onDurableEvent) {
306
+ let timer
307
+ try {
308
+ await Promise.race([
309
+ Promise.resolve().then(() => onDurableEvent(ev)),
310
+ new Promise((_, fail) => { timer = setTimeout(() => fail(new Error('Codex durable event hook timed out')), 10000) }),
311
+ ])
312
+ } finally { clearTimeout(timer) }
313
+ }
314
+ if (!durableFailed && !settled) emitEvent(ev)
315
+ }
316
+ const stdoutDecoder = new StringDecoder('utf8') // 一个汉字可能被拆在两个分块里,逐块 toString 会变成乱码
317
+ const processChunk = async (chunk) => {
318
+ if (coreFailed || acceptanceFailed || durableFailed || ((durableStart || onDurableEvent) && (aborted || settled))) return
319
+ buf += stdoutDecoder.write(chunk) // Node reads bytes → UTF-8, no PowerShell GBK pipe issue
296
320
  let nl
297
321
  while ((nl = buf.indexOf('\n')) >= 0) {
298
322
  const line = buf.slice(0, nl).trim()
@@ -300,23 +324,87 @@ function runOnce(opts, effort) {
300
324
  if (!line) continue
301
325
  let raw
302
326
  try { raw = JSON.parse(line) } catch { continue } // skip non-JSON noise
327
+ if (onRawEvent) { try { onRawEvent(raw) } catch { /* observer must not kill the turn */ } }
303
328
  if (raw.type === 'thread.started' && raw.thread_id) capturedSessionId = raw.thread_id
304
- const ev = normalizeEvent(raw)
329
+ if (durableStart && !acceptedStart) {
330
+ if (raw.type === 'turn.started') {
331
+ try {
332
+ const answer = onTurnAccepted({
333
+ channel: 'codex-exec',
334
+ sessionId: capturedSessionId,
335
+ turnId: typeof raw.turn_id === 'string' ? raw.turn_id : typeof raw.turn?.id === 'string' ? raw.turn.id : null,
336
+ })
337
+ if (answer && typeof answer.then === 'function') throw new TypeError('onTurnAccepted must complete synchronously')
338
+ acceptedStart = true
339
+ for (const held of heldEvents) {
340
+ if (onDurableEvent) {
341
+ const event = normalizeEvent(held.raw)
342
+ if (event) await deliverEvent(event)
343
+ } else emitEvent(held)
344
+ }
345
+ heldEvents.length = 0
346
+ } catch (error) { failTurnAcceptance(error); return }
347
+ } else if (!['thread.started', 'turn.failed', 'error'].includes(raw.type)) {
348
+ failTurnAcceptance(new Error('Codex emitted turn effects before turn.started'))
349
+ return
350
+ }
351
+ }
352
+ // The pre-start thread identity is control data. Delay its event
353
+ // normalization until the synchronous acceptance proof succeeds.
354
+ if (onDurableEvent && durableStart && !acceptedStart) {
355
+ heldEvents.push({ raw })
356
+ continue
357
+ }
358
+ let ev
359
+ try { ev = normalizeEvent(raw) } catch (error) { failCore(error); return }
305
360
  if (!ev) continue
306
361
  if (ev.type === 'assistant' && ev.text) lastAgentText = ev.text // last agent_message = final answer
307
362
  if (ev.type === 'result') { if (ev.subtype !== 'error' && !ev.text) ev.text = lastAgentText; lastResult = ev; if (!answerAtMs) answerAtMs = now() }
308
- if (onEvent) { try { onEvent(ev) } catch { /* renderer must not kill the turn */ } }
363
+ if (durableStart && !acceptedStart) heldEvents.push(ev)
364
+ else if (onDurableEvent) await deliverEvent(ev)
365
+ else emitEvent(ev)
309
366
  }
367
+ }
368
+ child.stdout.on('data', (chunk) => {
369
+ childOutputObserved = true
370
+ if (!onDurableEvent) { processChunk(chunk).catch(failDurableEvent); return }
371
+ child.stdout.pause?.()
372
+ durableWork = durableWork.then(() => processChunk(chunk)).catch(failDurableEvent).finally(() => {
373
+ if (!durableFailed && !settled) child.stdout.resume?.()
374
+ })
310
375
  })
311
376
 
312
- child.stderr.on('data', (c) => { stderr += c.toString('utf8') })
377
+ child.stderr.on('data', (c) => { childOutputObserved = true; stderr += c.toString('utf8') })
313
378
 
314
379
  child.on('error', (err) => done(() => {
315
- if (err.code === 'ENOENT') return reject(new Error(`cannot find agent binary '${bin}' on PATH — is Codex CLI installed? (npm i -g @openai/codex)`))
380
+ // 人话进群、原话进日志、code 给引擎发告警 —— 与 claude runner 同口径
381
+ if (err.code === 'ENOENT') {
382
+ // Node emits this only when spawning the child failed. The callback
383
+ // is a narrow host witness, not a model error classifier: a PID,
384
+ // accepted turn, output, abort, or earlier settlement disqualifies it.
385
+ if (onSpawnFailed && child.pid == null && !acceptedStart &&
386
+ !childOutputObserved && !aborted && !acceptanceFailed && !durableFailed) {
387
+ try {
388
+ const acknowledged = onSpawnFailed({ backend: 'codex-exec',
389
+ code: 'ENOENT', pid: null })
390
+ if (acknowledged !== true) throw new Error('Codex spawn-failure witness was not confirmed synchronously')
391
+ } catch (witnessError) { reject(witnessError); return }
392
+ }
393
+ log(`cannot find agent binary '${bin}' (and no fallback copy) — is Codex CLI installed? (npm i -g @openai/codex)`)
394
+ return reject(Object.assign(new Error('模型程序在这台机器上找不到了,需要管理员处理'), { code: 'AGENT_BIN_MISSING' }))
395
+ }
316
396
  reject(err)
317
397
  }))
318
398
 
319
- child.on('close', (code) => {
399
+ const completeClose = (code) => {
400
+ if (onDurableEvent && code !== 0 && !lastResult && !aborted) {
401
+ done(() => reject(new Error(`codex exited ${code} before a durable result`)))
402
+ return
403
+ }
404
+ if (durableStart && !acceptedStart && !aborted && !acceptanceFailed) {
405
+ done(() => reject(new Error('Codex exited before turn.started acceptance')))
406
+ return
407
+ }
320
408
  // Watch the answer→close (rollout-flush) lag. Healthy = ~2–3s (real-machine verified),
321
409
  // so stay silent then and only warn on a regression — early notice before the engine's
322
410
  // 20s hardReleaseMs backstop would force-release and forfeit the rollout. Not on abort
@@ -326,11 +414,26 @@ function runOnce(opts, effort) {
326
414
  if (lagMs > CLOSE_LAG_WARN_MS) log(`codex 收尾偏慢: 答案→close ${lagMs}ms(rollout 落盘, code=${code})— 正常约 2–3s;若持续接近 20s 兜底请调大 agent.hardReleaseMs`)
327
415
  }
328
416
  done(() => {
417
+ // 只有 stderr、没有任何 result 事件就非零退出(真实案例:登录令牌被吊销 ⇒ 刷模型清单 401 后直接退出)。
418
+ // 此前这里 reject ⇒ 引擎走「执行失败」裸抛路:英文原话 + 网址原样进群、不过额度 / 账号判据、零告警。
419
+ // 现在合成一条 error result,与 turn.failed 同一条路:瞬时 / 账号判据 → 人话 / 切备用 / 告警;原话仍留给账本与日志。
420
+ // 退出码放文案头,排障时一眼分得清是进程崩了还是服务端拒了。引擎零改动。
329
421
  if (code !== 0 && !lastResult && !aborted) {
330
- return reject(new Error(`codex exited ${code}: ${stderr.trim().slice(0, 800) || '(no stderr)'}`))
422
+ // 取尾不取头:致命的那行通常在最后,前面可能是一大段 WARN;截了头判据会漏、原话进群零告警(审查提醒)
423
+ try { lastResult = normalizeEvent({ type: 'error', message: `codex exited ${code}: ${stderr.trim().slice(-800) || '(no stderr)'}` }) }
424
+ catch (error) { log(`binary core failed on codex exit: ${error && error.message}`); lastResult = coreFailedResult(sessionId || null) }
425
+ // 崩着退出时 codex 多半还没提交可续接的会话文件 ⇒ 不把本轮刚拿到的新 thread id 交回去:引擎会照常落盘,
426
+ // 下一轮 resume 它就报「no rollout found」,而引擎对这种失败没有自愈路(只有空轮 / 被掐断的会话会换新会话)。
427
+ // 续接的轮仍交回传入的旧 id(它的文件本来就在)。与此前 reject 路「不落盘」同一语义。
428
+ capturedSessionId = sessionId || null
331
429
  }
332
- resolve({ sessionId: capturedSessionId, result: lastResult, code, aborted })
430
+ resolveRun({ sessionId: capturedSessionId, result: lastResult, code, aborted })
333
431
  })
432
+ }
433
+ child.on('close', (code) => {
434
+ childCloseObserved = true
435
+ if (onDurableEvent) durableWork.then(() => { if (!durableFailed) completeClose(code) })
436
+ else completeClose(code)
334
437
  })
335
438
  })
336
439
  }
@@ -347,4 +450,4 @@ function probeVersion(bin = 'codex') {
347
450
  })
348
451
  }
349
452
 
350
- module.exports = { runTurn, runOnce, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
453
+ module.exports = { runTurn, runOnce, isClosedRunnerResult, probeVersion, normalizeEvent, parseCodexError, isEffortRejection, sandboxMode, buildArgs, buildPrompt }
@@ -0,0 +1,34 @@
1
+ // 出错原话 → 结果上的结构化标记:两个 runner 与 App Server 共用的**唯一入口**。
2
+ //
3
+ // 三类判据各住各的文件(签名表是 runner 层的知识),但**谁优先只在这里定一次**,第一个命中即止:
4
+ // ① 额度 / 账号(quota)—— 要人处理或等重置,切备用模型、发告警;
5
+ // ② 续接的会话已不存在(sessionMissing)—— 清死 id、新会话重做;
6
+ // ③ 瞬时错(transient + transientKind:过载 / 限流 / 网关 / 网络)—— 群里说「稍后接着做」,网络档计连续失败。
7
+ // 之前三处各自往结果上挂字段,同一句原话可能两样都挂上(如「503 … your subscription has expired」):群里说
8
+ // 「稍后再试」、网络计数加一,同时又切模型、发账号告警 —— 互相矛盾。排他次序必须只有一份,所以收在这里。
9
+ // ⚠ 只挂标记,不改 r.text:账本的失败原因要留服务端原话。
10
+ const { classifyError } = require('./privatecore')
11
+
12
+ /** 按优先级给出错结果挂上 quota / sessionMissing / transient(+transientKind) 之一;返回 r。 */
13
+ function tagError(r, text) {
14
+ const decision = classifyError(text)
15
+ if (decision?.quota) { r.quota = decision.quota; return r }
16
+ if (decision?.sessionMissing) { r.sessionMissing = true; return r }
17
+ const t = decision?.transient
18
+ if (t) { r.transient = t.human; r.transientKind = t.kind }
19
+ return r
20
+ }
21
+
22
+ // 二进制核心失败的统一收尾:这一轮按出错结束、进程不受影响。文案进群,原因只进日志。
23
+ const CORE_FAILED_TEXT = '这一轮内部处理失败,已结束,请重新发送。'
24
+
25
+ function coreFailedResult(sessionId) {
26
+ return { type: 'result', subtype: 'core_failed', text: CORE_FAILED_TEXT, isError: true, coreFailed: true, sessionId }
27
+ }
28
+
29
+ function isCoreFailure(error) {
30
+ return error instanceof RangeError || error instanceof WebAssembly.RuntimeError ||
31
+ String(error && error.message).startsWith('SintBot binary core')
32
+ }
33
+
34
+ module.exports = { tagError, CORE_FAILED_TEXT, coreFailedResult, isCoreFailure }