sintbot 1.4.9 → 1.4.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ 'use strict'
2
+
3
+ // Transport adapter only: the binary decides readiness, landing and segment boundaries.
4
+ const { randomUUID } = require('node:crypto')
5
+ const { claudeFollowupStep } = require('./privatecore')
6
+
7
+ function createClaudeFollowup({ write, end, onReady, onLanded, onFailure,
8
+ receiptTimeoutMs = 10000, holdMs = 20000 } = {}) {
9
+ let state = null, disposed = false, holdTimer = null, sessionId = null
10
+ const firstId = randomUUID()
11
+ const pending = new Map(), messages = new Map()
12
+ const step = event => { const result = claudeFollowupStep(state, event); state = result.state; return result }
13
+ const clearHold = () => { clearTimeout(holdTimer); holdTimer = null }
14
+ const settle = (id, error) => {
15
+ const waiter = pending.get(id)
16
+ if (!waiter) return
17
+ pending.delete(id); clearTimeout(waiter.timer)
18
+ if (error) waiter.reject(error); else waiter.resolve(true)
19
+ }
20
+ const dispose = () => {
21
+ disposed = true; clearHold()
22
+ for (const id of pending.keys()) settle(id, new Error('Follow-up closed before receipt'))
23
+ }
24
+ const fail = error => { if (disposed) return; dispose(); onFailure(error) }
25
+ const control = {
26
+ steerDurable({ msgId, text } = {}) {
27
+ if (disposed) return Promise.resolve(false)
28
+ if (typeof msgId !== 'string' || !msgId || typeof text !== 'string' || !text || text.length > 200000) {
29
+ return Promise.resolve(false)
30
+ }
31
+ if ([...messages.values()].includes(msgId)) return Promise.reject(new Error('Duplicate follow-up message'))
32
+ const id = randomUUID()
33
+ const decision = step({ type: 'write', id })
34
+ if (decision.action === 'reject') return Promise.resolve(false)
35
+ messages.set(id, msgId)
36
+ const promise = new Promise((resolve, reject) => {
37
+ const timer = setTimeout(() => fail(new Error('Follow-up receipt timed out; delivery unknown')), receiptTimeoutMs)
38
+ pending.set(id, { resolve, reject, timer })
39
+ })
40
+ // Register identity and waiter before writing: a receipt may arrive immediately.
41
+ try { write({ type: 'user', uuid: id, message: { role: 'user', content: text } }) }
42
+ catch (error) { fail(error) }
43
+ return promise
44
+ },
45
+ }
46
+ return {
47
+ start(prompt) { write({ type: 'user', uuid: firstId, message: { role: 'user', content: prompt } }) },
48
+ protocol(raw) {
49
+ if (raw.type === 'control_response') return true
50
+ if (raw.type !== 'command_lifecycle') return false
51
+ if (raw.command_uuid === firstId) return true
52
+ if (sessionId && raw.session_id && raw.session_id !== sessionId) throw new Error('Follow-up session mismatch')
53
+ if (disposed) return true
54
+ const decision = step({ type: 'lifecycle', id: raw.command_uuid, status: raw.state })
55
+ if (decision.accepted) settle(raw.command_uuid)
56
+ if (decision.landed) { clearHold(); onLanded?.(messages.get(raw.command_uuid)) }
57
+ return true
58
+ },
59
+ init(raw) {
60
+ if (sessionId) {
61
+ if (raw.session_id !== sessionId) throw new Error('Follow-up init session changed')
62
+ return 'none'
63
+ }
64
+ sessionId = raw.session_id
65
+ const decision = step({ type: 'init', capabilities: raw.capabilities })
66
+ if (decision.action === 'ready') onReady?.(control)
67
+ else end()
68
+ return decision.action
69
+ },
70
+ result(event) {
71
+ const decision = step({ type: 'result', usage: event.usage, isError: event.isError })
72
+ clearHold()
73
+ if (decision.action === 'continue') {
74
+ holdTimer = setTimeout(() => fail(new Error('Follow-up landing timed out; replay forbidden')), holdMs)
75
+ return { ...event, type: 'turn_continue', usage: decision.usage }
76
+ }
77
+ end()
78
+ return { ...event, usage: { ...event.usage, ...decision.usage } }
79
+ },
80
+ stop() {
81
+ if (!disposed) step({ type: 'stop' })
82
+ dispose()
83
+ write({ type: 'control_request', request_id: `stop-${randomUUID()}`,
84
+ request: { subtype: 'interrupt', cancel_queued: true } })
85
+ end()
86
+ },
87
+ dispose,
88
+ fail,
89
+ }
90
+ }
91
+
92
+ module.exports = { createClaudeFollowup }
@@ -486,6 +486,12 @@ function runTurn(opts = {}) {
486
486
  })
487
487
  const completeClose = (code) => settle(() => {
488
488
  if (onDurableEvent && !completed && !aborted) {
489
+ if (acceptedTurnId) {
490
+ // No synthetic result: retain the distinction between process
491
+ // closure and model completion for the durable terminal reducer.
492
+ resolveRun({ sessionId: capturedSessionId, result: null, code, aborted })
493
+ return
494
+ }
489
495
  reject(closedError(new Error(`codex app-server exited ${code} before a durable result`)))
490
496
  return
491
497
  }
@@ -402,6 +402,10 @@ function runOnce(opts, effort) {
402
402
  }))
403
403
 
404
404
  const completeClose = (code) => {
405
+ if (onDurableEvent && acceptedStart && !lastResult && !aborted) {
406
+ done(() => resolveRun({ sessionId: capturedSessionId, result: null, code, aborted }))
407
+ return
408
+ }
405
409
  if (onDurableEvent && code !== 0 && !lastResult && !aborted) {
406
410
  done(() => reject(closedError(new Error(`codex exited ${code} before a durable result`))))
407
411
  return
@@ -1,9 +1,9 @@
1
- // ABI v34 adapter for the private binary decision core. Manifest assembly still stays in JS.
1
+ // ABI v35 adapter for the private binary decision core. Manifest assembly still stays in JS.
2
2
  const fs = require('node:fs')
3
3
  const path = require('node:path')
4
4
  const { createHash } = require('node:crypto')
5
5
 
6
- const EXPECTED_SHA256 = '70bb1b33ba1d9f3249659a4721ed047350f866dd2b3601a67376df604a23b3fd'
6
+ const EXPECTED_SHA256 = '9c29c9123b38aa75b9c47066c809d844f938baca41779372b77c02fa7baa9989'
7
7
  let bytes
8
8
  try {
9
9
  bytes = fs.readFileSync(path.join(__dirname, 'privatecore.wasm'))
@@ -26,7 +26,7 @@ try {
26
26
  } catch (cause) {
27
27
  throw new Error('SintBot binary core cannot start on this runtime', { cause })
28
28
  }
29
- if (core.abi_version() !== 34) throw new Error('SintBot binary core ABI mismatch')
29
+ if (core.abi_version() !== 35) throw new Error('SintBot binary core ABI mismatch')
30
30
  const encoder = new TextEncoder()
31
31
  const decoder = new TextDecoder('utf-8', { fatal: true })
32
32
 
@@ -784,7 +784,21 @@ function guarded(fn) {
784
784
  }
785
785
  }
786
786
 
787
- const entries = { classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
787
+ function followupCall(name, value) {
788
+ let ptr = 0, size = 0
789
+ try {
790
+ ptr = callText(name, JSON.stringify(value))
791
+ if (!ptr) throw new Error('Follow-up core rejected protocol state')
792
+ size = new DataView(core.memory.buffer).getUint32(ptr, true) + 4
793
+ return JSON.parse(decoder.decode(new Uint8Array(core.memory.buffer, ptr + 4, size - 4)))
794
+ } finally {
795
+ if (ptr && size) { new Uint8Array(core.memory.buffer).fill(0, ptr, ptr + size); core.dealloc(ptr, size) }
796
+ }
797
+ }
798
+ function followupEnabled(group, runner) { return followupCall('followup_policy', { group, runner }) }
799
+ function claudeFollowupStep(state, event) { return followupCall('claude_followup_step', { state, event }) }
800
+
801
+ const entries = { followupEnabled, claudeFollowupStep, classifyError, routeBackend, sandboxTier, appErrorOverride, appNormalizeItem, appStreamTail, appNoticeAction, appDeltaNext, appLandedAction, appCompletionAction, appCompletionNoticeAction, manifestFeatures, manifestHeader, manifestContext, manifestSkills, manifestStyleBoard, manifestImagenAsset, manifestRuntime, manifestPolicy, systemAppendix, codexEffortRejection, wecomGatePrompt, imagenPrompt, turnPrompt, compactEvent, engineQueueStep, engineSlotsStep, engineTurnStep, engineSessionStep, engineHandoffStep, engineIntakeStep, engineLifecycleStep, engineTerminalStep, engineSteerStep, engineRecoveryStep, engineFailoverStep, parseResetFields, chooseResetEpoch, codexBuildPrompt, codexBuildArgs, appUsageFromTotal, settleUsage, appBuildRequest, claudeBaseSkillAllows, claudeBuildArgs, claudePluginAllowRules, claudeNormalizeEvent, codexNormalizeEvent, codexParseError }
788
802
  for (const name of Object.keys(entries)) entries[name] = guarded(entries[name])
789
803
 
790
804
  module.exports = { ...entries, engineStep, resetBinaryCore, HUMAN_BY_KIND: HUMAN }
Binary file
package/src/c2console.js CHANGED
@@ -18,7 +18,7 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
18
18
  '/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
19
19
  '/api/groups', '/api/groups/provider', '/api/fs/mkdir',
20
20
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
21
- '/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
21
+ '/api/groups/codex-steer', '/api/groups/steer', '/api/groups/pause', '/api/groups/stop',
22
22
  // A fresh session only for an idle, settled group (same rule as /new).
23
23
  '/api/groups/new', '/api/groups/interrupt',
24
24
  '/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
package/src/c2install.js CHANGED
@@ -123,6 +123,7 @@ function resolveInstallation(cfg, configFile, stateDir, { allowAddedGroups = fal
123
123
  if (group.paused !== undefined && typeof group.paused !== 'boolean') {
124
124
  throw new Error('Group paused must be a boolean')
125
125
  }
126
+ if (group.steer !== undefined && typeof group.steer !== 'boolean') throw new Error('Group steer must be a boolean')
126
127
  if (group.codexSteer !== undefined && typeof group.codexSteer !== 'boolean') {
127
128
  throw new Error('Group codexSteer must be a boolean')
128
129
  }
package/src/config.js CHANGED
@@ -40,8 +40,8 @@ const DEFAULTS = {
40
40
  // 写死会**覆盖 CLI 的默认**,于是每次 Anthropic 出新一代,sbot 的默认就悄悄落后一代
41
41
  // (2026-08-03 核对时正卡在 opus-4-8,而 CLI 默认已是 Opus 5)。要钉死型号在控制台选。
42
42
  providers: { claude: {} },
43
- // "<chatId>": { workdir, provider?, model?, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
- // codexSteer defaults on for Codex groups; explicit false opts out.
43
+ // "<chatId>": { workdir, provider?, model?, steer?: boolean, codexSteer?: false, allowedTools?: [], disallowedTools?: [] }
44
+ // steer defaults on for both executors; explicit false opts out. Legacy codexSteer applies only to Codex.
45
45
  // — one project per group; permissions are scoped to that group's own workdir.
46
46
  groups: {},
47
47
  }
@@ -7,6 +7,7 @@
7
7
  const { createC2CardEffectBoundary, confirmCardOperation } = require('./c2cardeffectbridge')
8
8
  const { prepareTerminalEffectBridge, terminalReplyText } = require('./c2terminaleffectbridge')
9
9
  const { stripMilestones } = require('./turnprogress')
10
+ const { engineTerminalStep } = require('../agent/privatecore')
10
11
 
11
12
  function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
12
13
  cardAnchor = null, preferPlain = false,
@@ -207,10 +208,11 @@ function prepareCardTerminalPath({ runtime, payloadHandles, platform, replyCtx,
207
208
  if (card && await card.flush() !== true) {
208
209
  throw new Error('C2 card prior operations lack confirmed flush')
209
210
  }
210
- // Mirrors the private reducer's silent predicate. A posted card must
211
- // be durably recalled before the zero-send quiet settlement.
212
- const quiet = receipt.quiet || terminalPayload.resultText.trim() === 'NO_REPLY' ||
213
- (!terminalPayload.resultText.trim() && terminalPayload.bufferText.trim() === 'NO_REPLY')
211
+ // Partial NO_REPLY text on a crashed turn is not a silent completion.
212
+ const terminalEvent = { ...terminalPayload, type: 'finalize' }
213
+ delete terminalEvent.receiptId; delete terminalEvent.turnToken; delete terminalEvent.quiet
214
+ const quiet = receipt.quiet || engineTerminalStep(
215
+ { finalized: false, outcome: null }, terminalEvent).state.outcome.silent
214
216
  if (suppressedCard && !quiet) {
215
217
  throw new Error('C2 suppressed card needs a silent terminal')
216
218
  }
@@ -1,4 +1,5 @@
1
1
  'use strict'
2
+ const { followupEnabled } = require('../agent/privatecore')
2
3
 
3
4
  // Assemble the same user-facing runner inputs as Engine._runTurn after the
4
5
  // host has prepared its timestamped prompt and manifest. This only assembles
@@ -25,12 +26,13 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
25
26
  throw new TypeError('C2 runner input requires an exact process-local turn')
26
27
  }
27
28
  const isCodex = provider.runner === 'codex'
28
- const codexSteer = isCodex && group.codexSteer !== false
29
+ const enabled = followupEnabled(group, provider.runner)
30
+ const codexSteer = isCodex && enabled
29
31
  if ((onSteerReady !== undefined && typeof onSteerReady !== 'function') ||
30
32
  (onSteerLanded !== undefined && typeof onSteerLanded !== 'function') ||
31
- (codexSteer && (typeof onSteerReady !== 'function' ||
33
+ (enabled && (typeof onSteerReady !== 'function' ||
32
34
  typeof onSteerLanded !== 'function'))) {
33
- throw new TypeError('C2 Codex follow-up needs durable control callbacks')
35
+ throw new TypeError('C2 follow-up needs durable control callbacks')
34
36
  }
35
37
  const rawCompact = Number(config.agent.autocompactTokens) || 0
36
38
  const autocompactTokens = rawCompact > 0
@@ -40,6 +42,7 @@ function prepareC2RunnerInput({ prompt, appendSystemPrompt, msg, group, provider
40
42
  prompt, appendSystemPrompt, provider,
41
43
  codexApp: isCodex && group.codexApp === true,
42
44
  codexSteer,
45
+ steer: !isCodex && enabled,
43
46
  codexProjectDocFallback: isCodex && group.codexProjectDocFallback === true,
44
47
  onSteerReady, onSteerLanded,
45
48
  model: group.model,
@@ -9,6 +9,7 @@ const { C2PayloadHandles } = require('./c2payloadhandles')
9
9
  const { engineTerminalStep } = require('../agent/privatecore')
10
10
 
11
11
  const FAILURE_NOTICE = '本次任务执行失败。若已执行过工具,请先核对实际结果,再决定是否重试。'
12
+ const INTERRUPTED_NOTICE = '本次任务执行中断,未收到最终结果。已执行的操作可能已经生效,请核对后再决定是否重试。'
12
13
 
13
14
  const RECEIPT_ID = /^[0-9a-f]{32}$/
14
15
  const GENERATION = /^[0-9a-f]{32}$/
@@ -28,11 +29,12 @@ function terminalReplyText(payload) {
28
29
  }
29
30
  // The core classifies the result; the host supplies a fixed display string.
30
31
  // Neither a provider error nor unfinished assistant text is a final answer.
31
- if (payload.source === 'result' && payload.isError === true) {
32
+ if (payload.source === 'runner-exit' || payload.source === 'result' && payload.isError === true) {
32
33
  const event = { ...payload, type: 'finalize' }
33
34
  delete event.receiptId; delete event.turnToken; delete event.quiet
34
35
  if (engineTerminalStep({ finalized: false, outcome: null }, event)
35
- .state.outcome.status === 'error') return FAILURE_NOTICE
36
+ .state.outcome.status === 'error') return payload.source === 'runner-exit'
37
+ ? INTERRUPTED_NOTICE : FAILURE_NOTICE
36
38
  }
37
39
  return (payload.answerText.trim() ? payload.answerText : payload.bufferText) ||
38
40
  payload.resultText
@@ -8,11 +8,11 @@ const NO_REPLY = 'NO_REPLY'
8
8
  const { isDeepStrictEqual } = require('node:util')
9
9
 
10
10
  function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
11
- freshRetried = false, batchReceiptIds } = {}) {
11
+ freshRetried = false, batchReceiptIds, requireResult = false } = {}) {
12
12
  if (!RECEIPT_ID.test(receiptId || '') ||
13
13
  !Number.isSafeInteger(turnToken) || turnToken <= 0 ||
14
14
  typeof quiet !== 'boolean' || typeof resumed !== 'boolean' ||
15
- typeof freshRetried !== 'boolean') {
15
+ typeof freshRetried !== 'boolean' || typeof requireResult !== 'boolean') {
16
16
  throw new TypeError('C2 projection needs a typed running receipt')
17
17
  }
18
18
  if (batchReceiptIds !== undefined &&
@@ -24,6 +24,8 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
24
24
  }
25
25
  let bufferText = ''
26
26
  let answerText = ''
27
+ let keptText = ''
28
+ let segmentBreak = false
27
29
  let fullText = ''
28
30
  let sessionId = null
29
31
  let contextTokens = 0
@@ -68,6 +70,11 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
68
70
  throw new TypeError('C2 projection tool names invalid')
69
71
  }
70
72
  if (event.text) {
73
+ if (segmentBreak) {
74
+ segmentBreak = false
75
+ if (bufferText) bufferText += '\n\n'
76
+ if (fullText) fullText += '\n\n'
77
+ }
71
78
  fullText = append(fullText, event.text)
72
79
  bufferText = append(bufferText, event.text)
73
80
  answerText = append(answerText, event.text)
@@ -78,6 +85,13 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
78
85
  // Claude can report text and tool use in one assistant event. Text before
79
86
  // the tool is progress, while the final answer begins after that tool.
80
87
  if (tools.length) answerText = ''
88
+ } else if (event.type === 'turn_continue') {
89
+ if (event.text !== undefined && typeof event.text !== 'string') throw new TypeError('C2 segment text invalid')
90
+ const text = (answerText.trim() ? answerText : event.text || '').trim()
91
+ if (text && text !== NO_REPLY) keptText += (keptText ? '\n\n' : '') + text
92
+ if (bufferText.trim() === NO_REPLY) bufferText = ''
93
+ if (fullText.trim() === NO_REPLY) fullText = ''
94
+ answerText = ''; segmentBreak = true
81
95
  } else if (event.type === 'result') {
82
96
  if (event.text !== undefined && typeof event.text !== 'string') {
83
97
  throw new TypeError('C2 projection result text invalid')
@@ -137,10 +151,15 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
137
151
  throw new TypeError('C2 projection current resumed session requires abort')
138
152
  }
139
153
  completed = true
140
- const resultText = observedResult?.text || ''
154
+ const runnerExit = requireResult && !observedResult && !runnerResult.aborted
155
+ const rawResultText = observedResult?.text || ''
156
+ const tail = (answerText.trim() ? answerText : rawResultText).trim()
157
+ const joined = keptText ? keptText + (tail && tail !== NO_REPLY ? '\n\n' + tail : '') : ''
158
+ const resultText = joined || rawResultText
159
+ if (joined) answerText = joined
141
160
  const payload = Object.freeze({
142
161
  type: 'finalizeEffectCommit', receiptId, turnToken, quiet,
143
- source: observedResult ? 'result' : 'close',
162
+ source: observedResult ? 'result' : runnerExit ? 'runner-exit' : 'close',
144
163
  aborted: runnerResult.aborted,
145
164
  isError: observedResult?.isError || false,
146
165
  resumed, sessionMissing: observedResult?.sessionMissing || false,
@@ -156,10 +175,12 @@ function createC2TurnProjection({ receiptId, turnToken, quiet, resumed = false,
156
175
  const display = Object.freeze({
157
176
  resultText, bufferText, answerText, fullText,
158
177
  finalAnswer: (answerText.trim() ? answerText : bufferText) || resultText,
159
- silent: quiet || resultText.trim() === NO_REPLY ||
160
- (!resultText.trim() && bufferText.trim() === NO_REPLY),
178
+ silent: quiet || !runnerExit && (resultText.trim() === NO_REPLY ||
179
+ (!resultText.trim() && bufferText.trim() === NO_REPLY)),
161
180
  assistantEvents, tools,
162
- sessionId: runnerResult.sessionId || sessionId,
181
+ // A crashed process may never have flushed its new session. Retain the
182
+ // previously stored session; never publish an unproven new session ID.
183
+ sessionId: runnerExit ? null : runnerResult.sessionId || sessionId,
163
184
  contextTokens,
164
185
  usage: result?.usage || null,
165
186
  usageCumulative: result?.usageCumulative === true,
@@ -9,7 +9,7 @@ const { isDeepStrictEqual } = require('node:util')
9
9
  const { redact } = require('./redact')
10
10
  const { fmtStamp, normalizeTz } = require('./time')
11
11
  const { settleUsage, manifestPolicy, manifestHeader, manifestContext, manifestFeatures, manifestSkills,
12
- systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
12
+ followupEnabled, systemAppendix, turnPrompt, engineTerminalStep, engineQueueStep } = require('../agent/privatecore')
13
13
  const { taskId } = require('./enginecheckpoint')
14
14
  const { prepareCardTerminalPath } = require('./c2cardterminalpath')
15
15
  const { terminalReplyText } = require('./c2terminaleffectbridge')
@@ -167,10 +167,11 @@ function supportedV2QuotaFailure(payload, runnerResult) {
167
167
  // Error classification remains in the core; close/session/effect authority is
168
168
  // checked by the terminal path before any delivery can be attempted.
169
169
  function supportedV2ClosedFailure(payload, runnerResult) {
170
- if (!payload || payload.source !== 'result' || payload.aborted ||
170
+ if (!payload || !['result', 'runner-exit'].includes(payload.source) || payload.aborted ||
171
171
  payload.sessionMissing || payload.transientNetwork || payload.hasQuota ||
172
172
  payload.freshRetried || payload.resumedSessionCurrent ||
173
- runnerResult?.aborted !== false) return false
173
+ runnerResult?.aborted !== false ||
174
+ (payload.source === 'runner-exit' && runnerResult.result != null)) return false
174
175
  const terminal = { ...payload, type: 'finalize' }
175
176
  delete terminal.receiptId; delete terminal.turnToken; delete terminal.quiet
176
177
  return engineTerminalStep({ finalized: false, outcome: null }, terminal)
@@ -467,7 +468,7 @@ async _durableV2Command(msg, cmd, rest) {
467
468
  group.allowedTools, group.disallowedTools).tier)
468
469
  } catch {}
469
470
  return reject('help', T.help({ project: projectName(group), model: label(name, provider),
470
- steer: isCodex && group.codexSteer !== false, readOnly }))
471
+ steer: followupEnabled(group, provider.runner), readOnly }))
471
472
  }
472
473
  if (cmd === '/status') {
473
474
  const rt = this.rts.get(msg.chatId)
@@ -650,7 +651,7 @@ async runDurableV2Message(msg, platform) {
650
651
  lane?.handoff?.[msg.chatId]?.phase === 'running' ||
651
652
  Array.isArray(queue) && queue.length > 0 ||
652
653
  rt.v2RunningJobs || rt.v2QueueReceiptIds?.length
653
- if (busy && provider.runner === 'codex' && group.codexSteer !== false &&
654
+ if (busy && rt.v2FollowupEnabled &&
654
655
  rt.v2SteerControl && rt.v2NormalRouteActive && !rt.v2RunnerStopVerified &&
655
656
  !rt.v2TerminalStarted && !rt.v2StreamQuiescing && rt.v2Controller &&
656
657
  !rt.v2Controller.signal.aborted && !rt.v2SteerClosing) {
@@ -1266,7 +1267,7 @@ async _drainDurableV2NormalChain(initial) {
1266
1267
  error.classificationEventId = classified.eventId
1267
1268
  throw error
1268
1269
  }
1269
- if (payload.source !== 'result' || payload.sessionMissing ||
1270
+ if ((payload.source !== 'result' && !closedFailure) || payload.sessionMissing ||
1270
1271
  (payload.hasQuota && !quotaFailure) || payload.transientNetwork || payload.freshRetried ||
1271
1272
  payload.resumedSessionCurrent ||
1272
1273
  (!['ok', 'no-reply'].includes(outcome.status) && !quotaFailure && !closedFailure)) {
@@ -1523,7 +1524,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1523
1524
  throw new Error('C2 v2 runner effect changed before start')
1524
1525
  }
1525
1526
  const projection = createC2TurnProjection({ receiptId, turnToken,
1526
- quiet: receipt.quiet,
1527
+ quiet: receipt.quiet, requireResult: true,
1527
1528
  resumed: !!this.store.getSession(chatId), freshRetried: false,
1528
1529
  ...(cohort.receiptIds.length > 1
1529
1530
  ? { batchReceiptIds: cohort.receiptIds } : {}) })
@@ -1533,6 +1534,7 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1533
1534
  throw new Error('C2 v2 runner provider unavailable')
1534
1535
  }
1535
1536
  rt.v2CardModelInfo = cardModelInfo(job.group, provider)
1537
+ rt.v2FollowupEnabled = followupEnabled(job.group, provider.runner)
1536
1538
  rt.v2SteerControl = null
1537
1539
  rt.v2SteerEntries = new Map()
1538
1540
  rt.v2SteerClosing = false
@@ -1997,7 +1999,7 @@ async completeDurableV2CardTurn({ chatId, receiptId, turnToken, effectId,
1997
1999
  !isDeepStrictEqual(payload, projected.payload) ||
1998
2000
  // A deterministic failure is delivered once, after the same durable
1999
2001
  // close checks as success. Retry and unknown outcomes stay sealed.
2000
- payload.source !== 'result' || payload.aborted || (payload.isError && !quotaFailure && !closedFailure) ||
2002
+ (payload.source !== 'result' && !closedFailure) || payload.aborted || (payload.isError && !quotaFailure && !closedFailure) ||
2001
2003
  payload.sessionMissing || (payload.hasQuota && !quotaFailure) || payload.transientNetwork ||
2002
2004
  payload.freshRetried || payload.resumedSessionCurrent ||
2003
2005
  (replyText !== undefined && replyText !== terminalReplyText(projected.payload)) ||
@@ -2619,7 +2619,7 @@ const RUNTIME_UI_TEXT={
2619
2619
  "paused": "已暂停:不接收本群新消息,配置与会话保留。",
2620
2620
  "resume": "恢复本群:重新接收本群消息,配置与会话保留。",
2621
2621
  "resumeLabel": "恢复",
2622
- "steer": "群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。",
2622
+ "steer": "Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。",
2623
2623
  "preview": "当前版本未接入预览服务。",
2624
2624
  "providers": "可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。",
2625
2625
  "providersView": "当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。",
@@ -2631,7 +2631,7 @@ function renderGroups(){
2631
2631
  // Codex(GPT)跑自己的 agent 框架:只吃 permissionMode→沙箱层级,allow/deny 与 MCP 门控
2632
2632
  // 都是 Claude 专属(codexcode 根本不接收这些字段)。所以 codex 群不渲染那些无效控件。
2633
2633
  const pr=provOf(g), isCodex=!!pr&&pr.runner==='codex';
2634
- const showCodexSteer=runsCodex(g);
2634
+
2635
2635
  // 群属于哪个平台(服务端按配置 / id 形态算好):企微群没有画布、没有解散、基础技能文案不同;徽标只在配了企微 / Slack 时出(只配飞书的机器零变化)
2636
2636
  const isWe=g.platform==='wecom', isSl=g.platform==='slack', showPlat=!!(S.platforms&&(S.platforms.wecom||S.platforms.slack));
2637
2637
  const platTag=showPlat?`<span class="tag tan" title="${isWe?'企业微信群:只收 @ 机器人的消息;没有画布、表情回应与撤回即停':isSl?'Slack 频道:没有画布与表情回应,群内收发文件未接;控制面用 !命令 或 /sint':'飞书群'}">${isWe?'企业微信':isSl?'Slack':'飞书'}</span>`:'';
@@ -2845,13 +2845,13 @@ function renderGroups(){
2845
2845
  <select class="gprov" data-g="${esc(g.chatId)}" style="width:auto;min-width:110px;padding:5px 30px 5px 10px;font-size:11.5px">
2846
2846
  ${S.providers.filter(pp=>!pp.hidden&&pp.configured).map(pp=>`<option value="${esc(pp.name)}" ${pp.name===(g.provider||S.defaultProvider)?'selected':''}>${esc(pp.name)}${pp.model?' · '+esc(pp.model):''}</option>`).join('')}
2847
2847
  </select></div>
2848
- ${showCodexSteer?`<div class="kv" style="align-items:flex-start"><span class="kk">忙时跟进</span><span class="vv" style="min-width:0">
2849
- <label class="inline" style="gap:6px;cursor:pointer" title="${esc(S.runtimeCapabilities?RUNTIME_UI_TEXT.steer:'开启后,Codex 处理任务期间:群成员的后续消息、控制台勾了「回复」的插话,都会加入当前任务 —— 相关要求及时吸收,独立请求按先后回应。定时任务和不回复的插话仍排在后面。')}">
2850
- <input type="checkbox" class="gsteer" data-g="${esc(g.chatId)}"${g.codexSteer?' checked':''} style="width:auto;margin:0"/>
2848
+ <div class="kv" style="align-items:flex-start"><span class="kk">忙时跟进</span><span class="vv" style="min-width:0">
2849
+ <label class="inline" style="gap:6px;cursor:pointer" title="${esc(S.runtimeCapabilities?RUNTIME_UI_TEXT.steer:'开启后,处理任务期间:群成员的后续消息、控制台勾了「回复」的插话,都会加入当前任务 —— 相关要求及时吸收,独立请求按先后回应。定时任务和不回复的插话仍排在后面。')}">
2850
+ <input type="checkbox" class="gsteer" data-g="${esc(g.chatId)}"${g.followupEnabled?' checked':''} style="width:auto;margin:0"/>
2851
2851
  <span style="font-size:11.5px;color:var(--dim)">后续消息加入当前处理</span>
2852
2852
  </label>
2853
- <span class="faint" style="font-size:11px">仅 Codex · 默认开启 · 下轮生效</span>
2854
- </span></div>`:''}
2853
+ <span class="faint" style="font-size:11px">默认开启 · 下轮生效</span>
2854
+ </span></div>
2855
2855
  <div class="kv"><span class="kk">执行模式</span>${modeControl}</div>
2856
2856
  ${permPlug}
2857
2857
  <div class="spacer"></div>
@@ -2890,7 +2890,7 @@ function renderGroups(){
2890
2890
  try{S=await api('/api/groups/pause','POST',{chatId:b.dataset.gpause,paused:on});renderGroups();renderOverview();toast(S.runtimeCapabilities?(on?'已暂停':'已恢复'):(on?'已暂停 · 群内已通知':'已恢复 · 群内已通知'),'ok');}catch(e){toast(e.message,'err');}});
2891
2891
  $$('#groups .gprov').forEach(sel=>sel.onchange=async()=>{try{const r=await api('/api/groups/provider','POST',{chatId:sel.dataset.g,provider:sel.value});S=r;renderOverview();renderGroups();toast(r.switched&&r.switched.live?('已切到 '+sel.value+' · 会话已重置,下条消息生效'):('已保存 '+sel.value+' · daemon 未运行下次生效'),'ok');}catch(e){toast(e.message,'err');}});
2892
2892
  $$('#groups .gsteer').forEach(c=>c.onchange=async()=>{const on=c.checked;
2893
- try{S=await api('/api/groups/codex-steer','POST',{chatId:c.dataset.g,enabled:on});renderGroups();toast(on?'已开启 · 下轮生效':'已关闭 · 下轮恢复排队','ok');}
2893
+ try{S=await api('/api/groups/steer','POST',{chatId:c.dataset.g,enabled:on});renderGroups();toast(on?'已开启 · 下轮生效':'已关闭 · 下轮恢复排队','ok');}
2894
2894
  catch(e){c.checked=!on;toast(e.message,'err');}});
2895
2895
  $$('#groups [data-pol]').forEach(b=>b.onclick=async()=>{const g=b.dataset.pol,allow=splitList($('.gallow[data-g="'+g+'"]').value),deny=splitList($('.gdeny[data-g="'+g+'"]').value);try{S=await api('/api/groups/policy','POST',{chatId:g,allowedTools:allow,disallowedTools:deny});toast('权限已保存 · 下轮生效','ok');}catch(e){toast(e.message,'err');}});
2896
2896
  // 仓库地址:保存 = 校验 + git init(若非仓库)+ 设 origin(幂等,不 clone/不提交);留空保存 = 解除关联。
@@ -3330,7 +3330,7 @@ function sayPlan(chatId,reply){
3330
3330
  if(lv.finalizing) return {busy:true,join:false,why:'closing',lv};
3331
3331
  if(lv.followups) return reply?{busy:true,join:true,lv}:{busy:true,join:false,why:'quiet',lv};
3332
3332
  const g=(S.groups||[]).find(x=>x.chatId===chatId)||{};
3333
- return {busy:true,join:false,why:!runsCodex(g)?'other':(!g.codexSteer?'off':'turn'),lv};
3333
+ return {busy:true,join:false,why:!g.followupEnabled?'off':'turn',lv};
3334
3334
  }
3335
3335
  /* 这个群眼下实际跑的是不是 Codex(按生效中的模型,含临时切换)—— 群卡「忙时跟进」开关显不显示、插话框说不说
3336
3336
  「去打开忙时跟进」,都按这一个判。两处各写一份,开关和说明就会对不上。 */
@@ -4311,10 +4311,11 @@ const I18N_EN=[
4311
4311
  ["已暂停:不接收本群新消息,配置与会话保留。", "Paused: no new group messages are accepted; configuration and sessions are retained."],
4312
4312
  ["恢复本群:重新接收本群消息,配置与会话保留。", "Resume this group: its messages are accepted again; configuration and sessions are retained."],
4313
4313
  ["恢复", "Resume"],
4314
- ["群成员的后续文字消息可加入当前任务;只有确认消息已进入模型后才算送达。控制台插话只在群空闲时可用,不加入当前任务;定时任务暂不可用。", "Followup text messages from group members can join the current task. Delivery is confirmed only when the model receives the message. Console messages work only while the group is idle and never join the current task; schedules are unavailable."],
4314
+ ["Codex 与 Claude Code 默认开启忙时跟进;长命令执行期间的补充会在工具步骤之间读取。只有确认模型已读取才显示已加入;不支持此能力时排队。控制台插话仅空闲时可用。", "Busy-turn followups are on by default for Codex and Claude Code. Followups are read between tool steps; a long command must finish first. Joined means the model has read the message. Unsupported clients queue followups. Console messages require an idle group."],
4315
4315
  ["当前版本未接入预览服务。", "Preview services are unavailable in this version."],
4316
4316
  ["当前可查看模型配置与登录状态;各群的模型在「群·项目」里切换。添加或修改模型暂不可用。", "Model configuration and login status are shown here. Switch each group's model under Groups. Adding or changing models is unavailable."],
4317
4317
  ["可添加模型,修改型号、端点、密钥、思考深度与推理强度,测试、隐藏或拖动排序;各群的模型在「群·项目」里切换。改型号、后端或端点时,用它的群须空闲,并会开始新的一段对话;默认模型和群正在用的模型不能隐藏。移除模型暂不可用。", "Add models; change their model id, endpoint, key, thinking depth and reasoning effort; test, hide or drag to reorder them. Switch each group's model under Groups. Changing a model id, backend or endpoint needs the groups using it to be idle and starts a new conversation for them. The default model and models in use by a group cannot be hidden. Removing models is unavailable."],
4318
+ ["默认开启 · 下轮生效", "On by default · Applies next turn"],
4318
4319
  ["当前只能添加或修改 Codex 或 Claude Code 模型", "Only Codex or Claude Code models can be added or changed right now."],
4319
4320
  ["默认模型须是列表中未隐藏的 Codex 或 Claude Code 模型", "The default model must be a visible Codex or Claude Code model from the list."],
4320
4321
  ["有群因未解决的故障暂停处理,它用的模型、后端或端点要等处理完并重启后才能改", "A group is held after an unresolved fault; the model, backend or endpoint it uses can change only after the fault is handled and the service restarts."],
@@ -5,6 +5,7 @@
5
5
  // two stay in sync.
6
6
 
7
7
  const http = require('node:http')
8
+ const { followupEnabled } = require('../agent/privatecore')
8
9
  const crypto = require('node:crypto')
9
10
  const fs = require('node:fs')
10
11
  const os = require('node:os')
@@ -507,7 +508,7 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
507
508
  // repo:群项目关联的 git 远端(可空)。repoState 走纯 fs 的 .git/config 读取(quickLinkState)
508
509
  // —— 群卡标签要反映真实关联(origin 被手改/删掉时不能还绿着),但这里每个 API 响应都跑一遍,
509
510
  // per-group spawn git 会把控制台拖垮,所以只读文件、不 spawn。
510
- .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: g.codexSteer !== false, model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
511
+ .map(([chatId, g]) => ({ chatId, name: g.name || '', workdir: g.workdir || '', repo: g.repo || '', platform: chatPlatformOf(raw, chatId) /* 群属于哪个平台:配置写了就认,没写按 id 形态 / 唯一平台推断(与 config.normalizeGroupPlatforms 同口径) */, repoState: gitrepo.quickLinkState(g.workdir, g.repo), board: g.board && g.board.url ? { url: g.board.url, createdAt: g.board.createdAt || null } : null, provider: g.provider || '', effectiveProvider: (control && control.providerOf && control.providerOf(chatId)) || g.provider || raw.defaultProvider || cfg.defaultProvider, codexSteer: followupEnabled(g, 'codex'), steer: g.steer ?? null, followupEnabled: followupEnabled(g, cfg.providers[(control && control.providerOf && control.providerOf(chatId)) || g.provider || cfg.defaultProvider]?.runner || 'claude'), model: g.model || '', allowedTools: g.allowedTools || [], disallowedTools: g.disallowedTools || [], permissionMode: g.permissionMode || '', paused: g.paused === true, plugins: g.plugins || null,
511
512
  // 项目级插件/技能(workdir 自带声明,随项目恒加载)—— 群卡摘要行不点开也看得见。
512
513
  // projectPlugins 纯 fs 读且各错各兜(readJson/scanSkills 都 catch),与同行的 projectStyleCard 同量级。
513
514
  // ⚠ workdir 必须先判空:空串会让 path.join 退化成 daemon cwd 相对读(实验证实能读到别的项目的
@@ -1795,15 +1796,15 @@ function createUiServer({ configPath, getLiveState = null, getConnHealth = null,
1795
1796
  }
1796
1797
  // Codex 忙时跟进默认开启;显式 false 才关闭。当前轮按启动时配置运行。
1797
1798
  // providerOf 覆盖运行时模型切换/额度回退,因此必须以实际有效 runner 校验。
1798
- if (p === '/api/groups/codex-steer' && req.method === 'POST') {
1799
+ if ((p === '/api/groups/steer' || p === '/api/groups/codex-steer') && req.method === 'POST') {
1799
1800
  const { chatId, enabled } = await body(req); const raw = readRaw()
1800
1801
  if (!chatId || !raw.groups || !raw.groups[chatId]) return sendJSON(res, 404, { error: 'no such group' })
1801
1802
  if (typeof enabled !== 'boolean') return sendJSON(res, 400, { error: 'enabled 必须为布尔值' })
1802
1803
  const name = (control && control.providerOf && control.providerOf(chatId)) || raw.groups[chatId].provider || raw.defaultProvider || loadConfig(configPath).defaultProvider
1803
1804
  const provider = { ...DEFAULTS.providers, ...(raw.providers || {}) }[name]
1804
- if (!provider || provider.runner !== 'codex') return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1805
- if (enabled) delete raw.groups[chatId].codexSteer
1806
- else raw.groups[chatId].codexSteer = false
1805
+ if (p === '/api/groups/codex-steer' && (!provider || provider.runner !== 'codex')) return sendJSON(res, 400, { error: '仅实际使用 Codex 的群项目可开启忙时跟进' })
1806
+ raw.groups[chatId].steer = enabled
1807
+ delete raw.groups[chatId].codexSteer
1807
1808
  writeRaw(raw)
1808
1809
  return sendJSON(res, 200, await buildState())
1809
1810
  }