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.
- package/CHANGELOG.en.json +22 -313
- package/CHANGELOG.json +22 -320
- package/docs/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/install.md +5 -5
- package/docs/zh-CN/AI-ASSISTED-INSTALL.md +2 -2
- package/docs/zh-CN/install.md +5 -5
- package/install.sh +1 -1
- package/package.json +2 -1
- package/src/agent/claudecode.js +39 -5
- package/src/agent/claudefollowup.js +92 -0
- package/src/agent/codexapp.js +6 -0
- package/src/agent/codexcode.js +4 -0
- package/src/agent/privatecore.js +18 -4
- package/src/agent/privatecore.wasm +0 -0
- package/src/c2console.js +1 -1
- package/src/c2install.js +1 -0
- package/src/config.js +2 -2
- package/src/core/c2cardterminalpath.js +6 -4
- package/src/core/c2runnerinput.js +6 -3
- package/src/core/c2terminaleffectbridge.js +4 -2
- package/src/core/c2turnprojection.js +28 -7
- package/src/core/v2engine.js +10 -8
- package/src/ui/console.html +11 -10
- package/src/ui/serverbase.js +6 -5
|
@@ -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 }
|
package/src/agent/codexapp.js
CHANGED
|
@@ -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
|
}
|
package/src/agent/codexcode.js
CHANGED
|
@@ -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
|
package/src/agent/privatecore.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// ABI
|
|
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 = '
|
|
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() !==
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
-
(
|
|
33
|
+
(enabled && (typeof onSteerReady !== 'function' ||
|
|
32
34
|
typeof onSteerLanded !== 'function'))) {
|
|
33
|
-
throw new TypeError('C2
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
package/src/core/v2engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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 &&
|
|
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)) ||
|
package/src/ui/console.html
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
|
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/
|
|
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:!
|
|
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
|
-
["
|
|
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."],
|
package/src/ui/serverbase.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
1806
|
-
|
|
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
|
}
|