sintbot 1.4.4 → 1.4.6

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.
@@ -5,12 +5,24 @@ const path = require('node:path')
5
5
  const { isDeepStrictEqual } = require('node:util')
6
6
  const { atomicWriteJSON } = require('./core/store')
7
7
  const { loadInstallationFromRaw, assertJournalScope, assertRunnerExecutable,
8
- MANIFEST, VALIDATORS } = require('./c2install')
8
+ MANIFEST, VALIDATORS, SUPPORTED_RUNNERS } = require('./c2install')
9
9
  const { C2DurableJournal } = require('./core/c2durablejournal')
10
10
  const { assertNoLegacyGroupState } = require('./c2migration')
11
11
 
12
- const GLOBAL_TASK_KEYS = ['providers', 'agent', 'defaultProvider', 'timezone', 'brand']
12
+ const GLOBAL_TASK_KEYS = ['agent', 'timezone', 'brand']
13
13
  const RESET_KEYS = ['provider', 'model', 'workdir', 'codexApp']
14
+ const EFFORT_ENV = 'CLAUDE_CODE_EFFORT_LEVEL'
15
+
16
+ // What a group's session belongs to: backend, executable, endpoint, model and
17
+ // the environment the backend starts with. Effort, reasoning effort, tool
18
+ // quality, key, image support, visibility and order apply from the next turn.
19
+ function sessionIdentity(provider, group) {
20
+ if (!provider || typeof provider !== 'object') return null
21
+ const env = { ...(provider.env && typeof provider.env === 'object' ? provider.env : {}) }
22
+ delete env[EFFORT_ENV]
23
+ return { runner: provider.runner, bin: provider.bin || '', baseUrl: provider.baseUrl || '',
24
+ model: group?.model || provider.model || '', env }
25
+ }
14
26
 
15
27
  // Synchronous validation/application around one atomic configuration write.
16
28
  // A group-scoped edit waits only for that group to settle. Changing a group's
@@ -30,22 +42,55 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
30
42
  }
31
43
  const { cfg, routes, stateDir } = installation
32
44
  let prepared = null
45
+ let appliedResets = []
33
46
 
34
47
  function held() {
35
48
  if (assertWriterLock() !== true) throw new Error('Service writer authority is unavailable')
36
49
  }
37
- function assertSettled(chatId, binding) {
50
+ function assertSettled(chatId, binding,
51
+ message = 'Wait until the group task is settled before changing its task settings') {
38
52
  if (!binding) return
39
53
  if (binding.gate.blocked || binding.runtime.blocked ||
40
54
  [...binding.engine.rts.values()].some(rt => rt.v2NormalRouteActive ||
41
55
  rt.v2AdmissionPending || rt.v2Controller || rt.v2QueueReceiptIds?.length)) {
42
- throw new Error('Wait until the group task is settled before changing its task settings')
56
+ throw new Error(message)
43
57
  }
44
58
  assertJournalScope(binding.journal, chatId, { requireSettled: true })
45
59
  }
46
60
 
61
+ // Model library edits. A provider that is added or edited must be one the
62
+ // durable route can run; the default must stay a visible runnable model;
63
+ // a provider still used by an active group or as the default stays listed.
64
+ function assertProviderEdits(next, effective) {
65
+ const before = cfg.providers || {}, after = next.cfg.providers || {}
66
+ for (const [name, provider] of Object.entries(after)) {
67
+ if (isDeepStrictEqual(before[name], provider)) continue
68
+ if (!provider || typeof provider !== 'object' || !SUPPORTED_RUNNERS.includes(provider.runner)) {
69
+ throw new Error(`模型「${name}」的后端不受支持;只能添加或修改 Codex 或 Claude Code 模型`)
70
+ }
71
+ }
72
+ if (cfg.defaultProvider !== next.cfg.defaultProvider) {
73
+ const chosen = after[next.cfg.defaultProvider]
74
+ if (!chosen || typeof chosen !== 'object' || chosen.hidden ||
75
+ !SUPPORTED_RUNNERS.includes(chosen.runner)) {
76
+ throw new Error('默认模型须是列表中未隐藏的 Codex 或 Claude Code 模型')
77
+ }
78
+ }
79
+ for (const [name, provider] of Object.entries(before)) {
80
+ if (!provider || provider.hidden || (after[name] && !after[name].hidden)) continue
81
+ const action = after[name] ? '隐藏' : '移除'
82
+ if (name === cfg.defaultProvider) {
83
+ throw new Error(`「${name}」是默认模型,先把默认模型换成别的再${action}`)
84
+ }
85
+ const users = next.routes.filter(route => !route.paused &&
86
+ effective(route.chatId, route.group, next.cfg) === name).length
87
+ if (users) throw new Error(`「${name}」正被 ${users} 个群使用,先把这些群切到其它模型再${action}`)
88
+ }
89
+ }
90
+
47
91
  function validateConfig(raw) {
48
92
  prepared = null
93
+ appliedResets = []
49
94
  held()
50
95
  if (isFrozen()) throw new Error('SintBot is upgrading; settings cannot change until it restarts')
51
96
  const next = loadInstallationFromRaw(raw, cfg._path, { dataDir: stateDir,
@@ -67,30 +112,48 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
67
112
  if (!isDeepStrictEqual(connectionOnly(cfg.feishu), connectionOnly(next.cfg.feishu))) {
68
113
  throw new Error('Connection settings require a service restart')
69
114
  }
70
- for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser', 'providers', 'defaultProvider']) {
115
+ for (const key of ['wecom', 'slack', 'proxy', 'projectsRoots', 'browser']) {
71
116
  if (!isDeepStrictEqual(cfg[key], next.cfg[key])) throw new Error('Connection and filesystem settings require a service restart')
72
117
  }
73
118
  if (cfg.agent.maxConcurrent !== next.cfg.agent.maxConcurrent) {
74
119
  throw new Error('Changing global concurrency requires a service restart')
75
120
  }
76
- const resets = [], activations = [], changed = []
121
+ // The model a group runs: a runtime override first (as the engine
122
+ // resolves it), then the group's own provider, then the default.
123
+ const sessions = chatId => store || bindings.get(chatId)?.engine?.store || null
124
+ const effective = (chatId, group, config) =>
125
+ sessions(chatId)?.getProvider?.(chatId) || group.provider || config.defaultProvider
126
+ assertProviderEdits(next, effective)
127
+ const resets = [], activations = [], changed = [], moved = []
77
128
  const heldGroups = getHeld()
78
129
  for (const route of routes) {
79
130
  const target = next.routes.find(item => item.chatId === route.chatId)
80
131
  if (route.group.platform !== target.group.platform) {
81
132
  throw new Error('Moving a group to another platform requires an explicit state migration')
82
133
  }
134
+ // A model library or default change reaches every group that runs
135
+ // the edited provider, even though its own settings stay the same.
136
+ const providerMoved = !isDeepStrictEqual(
137
+ sessionIdentity(cfg.providers?.[effective(route.chatId, route.group, cfg)], route.group),
138
+ sessionIdentity(next.cfg.providers?.[effective(route.chatId, target.group, next.cfg)], target.group))
83
139
  if (heldGroups.has(route.chatId)) {
84
140
  // Edits elsewhere proceed; the held group keeps its exact settings.
85
141
  if (!isDeepStrictEqual(route.group, target.group)) {
86
142
  throw new Error('This group is held after an unresolved fault; seal its incident and restart before changing it')
87
143
  }
144
+ if (providerMoved) {
145
+ throw new Error('有群因未解决的故障暂停处理,它用的模型、后端或端点要等处理完并重启后才能改')
146
+ }
88
147
  continue
89
148
  }
90
149
  if (!isDeepStrictEqual(route.group, target.group)) changed.push(route.chatId)
91
- if (RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))) {
150
+ const groupReset = RESET_KEYS.some(key => !isDeepStrictEqual(route.group[key], target.group[key]))
151
+ if (groupReset || providerMoved) {
92
152
  if (!store) throw new Error('Changing model or project requires session reset authority')
93
153
  resets.push(route.chatId)
154
+ if (!groupReset) moved.push(route.chatId)
155
+ // A reset also clears any runtime override: the group then runs
156
+ // its configured provider, which is the one probed here.
94
157
  if (!target.paused) probeRunner(target.provider, next.cfg)
95
158
  }
96
159
  if (!bindings.has(route.chatId)) {
@@ -115,6 +178,11 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
115
178
  const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
116
179
  for (const [chatId, binding] of bindings) {
117
180
  if (global || changed.includes(chatId)) assertSettled(chatId, binding)
181
+ else if (moved.includes(chatId)) {
182
+ const name = next.cfg.groups?.[chatId]?.name || chatId.slice(-8)
183
+ assertSettled(chatId, binding,
184
+ `「${name}」有任务进行中或记录未结账,等它结束后再改它用的模型、后端或端点`)
185
+ }
118
186
  }
119
187
  prepared = { raw: JSON.stringify(raw), next, resets, activations, added }
120
188
  return true
@@ -155,9 +223,15 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
155
223
  if (assertWriterLock() !== true || !prepared || prepared.raw !== JSON.stringify(raw)) {
156
224
  throw new Error('Configuration was not validated against the current service')
157
225
  }
158
- const { next, activations, added } = prepared
226
+ const { next, activations, added, resets } = prepared
159
227
  const global = GLOBAL_TASK_KEYS.some(key => !isDeepStrictEqual(cfg[key], next.cfg[key]))
228
+ // Every engine copies the model library at construction and reads it
229
+ // when a turn starts: a library edit must reach all of them, including
230
+ // busy ones (their running turn keeps the settings it started with).
231
+ const library = !isDeepStrictEqual(cfg.providers, next.cfg.providers) ||
232
+ cfg.defaultProvider !== next.cfg.defaultProvider
160
233
  prepared = null
234
+ appliedResets = [...resets]
161
235
  for (const key of Object.keys(cfg)) delete cfg[key]
162
236
  Object.assign(cfg, next.cfg)
163
237
  const bindings = getBindings()
@@ -166,7 +240,7 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
166
240
  const groupChanged = !isDeepStrictEqual(route.group, target.group)
167
241
  Object.assign(route, target)
168
242
  const engine = bindings.get(route.chatId)?.engine
169
- if (engine && (global || groupChanged)) {
243
+ if (engine && (global || groupChanged || library)) {
170
244
  engine.cfg = { ...cfg, groups: { [route.chatId]: route.group } }
171
245
  engine.tz = cfg.timezone
172
246
  }
@@ -209,7 +283,9 @@ function createC2ConfigControl({ installation, getBindings, getHeld = () => new
209
283
  })
210
284
  }
211
285
 
212
- return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider }
286
+ return { validateConfig, beforeWrite, applyConfig, writeConfig, switchGroupProvider,
287
+ // Groups whose session the last applied write reset (for the console).
288
+ appliedResets: () => [...appliedResets] }
213
289
  }
214
290
 
215
291
  module.exports = { createC2ConfigControl }
package/src/c2console.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const path = require('node:path')
4
4
  const { createUiServer } = require('./ui/serverbase')
5
5
  const { START_PATH, CALLBACK_PATH } = require('./ui/feishulogin')
6
+ const { assertJournalScope } = require('./c2install')
6
7
 
7
8
  // A host facade over existing v2 authorities, not a second execution engine.
8
9
  // The allowlist applies after the existing console's authentication boundary.
@@ -10,13 +11,23 @@ const READ_PATHS = ['/api/state', '/api/events', '/api/inbox', '/api/activity',
10
11
  '/api/changelog', '/api/changelog-en', '/api/codex/models', '/api/usage',
11
12
  '/api/plugins', '/api/design/styles', '/api/design/icons', '/api/fs/listdir',
12
13
  '/api/feishu/admin-candidates', '/api/feishu/org-users', '/api/feishu/resolve-contact',
13
- '/api/feishu/chats', '/api/feishu/chat-members', '/api/update', START_PATH, CALLBACK_PATH]
14
+ '/api/feishu/chats', '/api/feishu/chat-members', '/api/update', '/api/claude/models',
15
+ START_PATH, CALLBACK_PATH]
14
16
  const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
15
17
  '/api/ui/network', '/api/ui/public-url', '/api/ui/update-settings', '/api/feishu/admins',
16
18
  '/api/groups', '/api/groups/provider', '/api/fs/mkdir',
17
19
  '/api/groups/order', '/api/groups/policy', '/api/groups/redact',
18
20
  '/api/groups/codex-steer', '/api/groups/pause', '/api/groups/stop',
21
+ // A fresh session only for an idle, settled group (same rule as /new).
22
+ '/api/groups/new', '/api/groups/interrupt',
19
23
  '/api/agent', '/api/provider-settings', '/api/codex/verify', '/api/usage/refresh',
24
+ // The model library goes through the same validated writer: an edit that
25
+ // changes what a group runs waits for that group and starts a new session.
26
+ '/api/providers/add', '/api/provider-key', '/api/providers/hidden', '/api/providers/order',
27
+ '/api/default-provider', '/api/claude/verify',
28
+ // One headless trial reply outside every journal and session (like a
29
+ // one-off agent run); it does not take a group concurrency slot.
30
+ '/api/test',
20
31
  '/api/inbox/dismiss', '/api/issues/status',
21
32
  // Software updates: check and ignore anywhere; install only when launchd
22
33
  // manages this exact entry (the updater refuses every other run mode).
@@ -24,9 +35,10 @@ const WRITE_PATHS = ['/api/login', '/api/logout', '/api/logout/all',
24
35
  const SUPPORTED_RUNNERS = ['claude', 'codex']
25
36
 
26
37
  function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld = () => new Map(),
27
- assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true, now = Date.now } = {}) {
38
+ assertWriterLock, validateConfig, applyConfig, beforeWrite = () => true,
39
+ appliedResets = () => [], now = Date.now } = {}) {
28
40
  for (const callback of [getBindings, getCapacity, getConfig, getHeld, assertWriterLock,
29
- validateConfig, applyConfig, beforeWrite, now]) {
41
+ validateConfig, applyConfig, beforeWrite, appliedResets, now]) {
30
42
  if (typeof callback !== 'function') throw new TypeError('C2 console needs live authority and configuration hooks')
31
43
  }
32
44
  if ([validateConfig, applyConfig, beforeWrite].some(callback => callback.constructor?.name === 'AsyncFunction')) {
@@ -35,7 +47,7 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
35
47
  const startedAt = now()
36
48
  const capabilities = Object.freeze({ mode: 'durable-v2',
37
49
  reason: '此操作尚未接入当前版本的持久处理流程,未执行。',
38
- summary: '实时状态、认证管理、停止任务、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改。开新会话、解除绑定、控制台插话、定时任务和画布暂不可用。',
50
+ summary: '实时状态、认证管理、停止任务、空闲群开新会话、绑定新群、空闲群的模型切换(Codex / Claude Code)和项目目录修改、模型的添加与设置,以及后台运行时的软件升级已接入;该群有任务或未结记录时不能修改,也不能开新会话;改模型的型号、后端或端点时,用它的群须空闲,并会开始新的一段对话。解除绑定、移除模型、控制台插话、定时任务和画布暂不可用。',
39
51
  allowed: { GET: READ_PATHS, POST: WRITE_PATHS } })
40
52
  function held() {
41
53
  if (assertWriterLock() !== true) throw new Error('服务写入权限已失效,操作未执行')
@@ -45,6 +57,33 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
45
57
  if (!(value instanceof Map)) throw new TypeError('C2 console bindings must be a Map')
46
58
  return value
47
59
  }
60
+ // The engine's /new guard, read from the same runtime and journal the live
61
+ // view uses: a running, admitted, controlled or queued turn, any receipt of
62
+ // this group's journal not yet settled, or an outbound effect still in
63
+ // flight keeps the current session.
64
+ function groupBusy(chatId, binding) {
65
+ const rt = binding.engine?.rts?.get(chatId)
66
+ const snapshot = binding.journal?.snapshot()
67
+ return !!(rt?.v2NormalRouteActive || rt?.v2AdmissionPending || rt?.v2Controller ||
68
+ rt?.v2QueueReceiptIds?.length || snapshot?.receipts?.pending?.length ||
69
+ snapshot?.state?.lanes?.queue?.[chatId]?.queue?.length ||
70
+ binding.gate?.blocked || binding.runtime?.blocked)
71
+ }
72
+ function clearSession(chatId) {
73
+ held()
74
+ const binding = bindings().get(chatId)
75
+ if (!binding) throw new Error('此群未运行,未开新会话')
76
+ if (binding.runtime?.closed === true) throw new Error('此群当前未开放,未开新会话')
77
+ const busy = '该群有任务进行中或记录未结账,先停止或等它结束再开新会话'
78
+ if (groupBusy(chatId, binding)) throw new Error(busy)
79
+ // The configuration writer's settled check as well: an effect not yet
80
+ // acknowledged or a notice of unknown outcome also keeps the session.
81
+ try { assertJournalScope(binding.journal, chatId, { requireSettled: true }) } catch (error) {
82
+ throw new Error(busy, { cause: error })
83
+ }
84
+ binding.engine.store.resetSessionAndStats(chatId)
85
+ return true
86
+ }
48
87
  function getLiveState() {
49
88
  const cfg = getConfig()
50
89
  const capacity = getCapacity()
@@ -109,6 +148,8 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
109
148
  validateConfig(raw) { held(); validateConfig(raw) },
110
149
  beforeWrite(raw) { held(); beforeWrite(raw) },
111
150
  applyConfig(raw) { held(); applyConfig(raw) },
151
+ // Groups whose session the last applied configuration write reset.
152
+ appliedResets: () => appliedResets(),
112
153
  decorateState(state) {
113
154
  state.runtimeCapabilities = capabilities
114
155
  state.previews = (state.previews || []).filter(item => getConfig().groups?.[item.chatId])
@@ -126,6 +167,13 @@ function createC2ConsoleFacade({ getBindings, getCapacity, getConfig, getHeld =
126
167
  if (!binding) throw new Error('此群未运行,未执行停止')
127
168
  return binding.engine.stop(chatId)
128
169
  },
170
+ clearSession,
171
+ // A durable stop settles later, so a busy group is never reset here:
172
+ // the operator stops it first and starts the new session once idle.
173
+ interrupt(chatId) {
174
+ clearSession(chatId)
175
+ return { wasBusy: false }
176
+ },
129
177
  },
130
178
  }
131
179
  }
@@ -683,9 +683,19 @@ class C2DurableRuntime {
683
683
  } catch (error) { this.close(); throw error }
684
684
 
685
685
  let phase = 'attempting'
686
+ // Two host witnesses prove that no model turn began: the exec child never
687
+ // spawned (no pid), or the App Server rejected initialize/thread before
688
+ // any turn/start and its child has already closed.
689
+ const runnerNotStartedWitness = failure => {
690
+ const keys = failure && typeof failure === 'object' ? Object.keys(failure).sort() : []
691
+ return (isDeepStrictEqual(keys, ['backend', 'code', 'pid']) &&
692
+ failure.backend === 'codex-exec' && failure.code === 'ENOENT' && failure.pid === null) ||
693
+ (isDeepStrictEqual(keys, ['backend', 'closed', 'code', 'turnStarted']) &&
694
+ failure.backend === 'codex-app-server' && failure.code === 'THREAD_REJECTED' &&
695
+ failure.turnStarted === false && failure.closed === true)
696
+ }
686
697
  const onSpawnFailed = (failure) => {
687
- if (phase !== 'attempting' || failure?.backend !== 'codex-exec' ||
688
- failure.code !== 'ENOENT' || failure.pid !== null ||
698
+ if (phase !== 'attempting' || !runnerNotStartedWitness(failure) ||
689
699
  !isDeepStrictEqual(witness.jobReceiptIds, [receiptId]) ||
690
700
  !isDeepStrictEqual(witness.batchReceiptIds, [receiptId]) ||
691
701
  verifyGeneration({ chatId, receiptId, turnToken, generation }) !== true ||
@@ -19,6 +19,10 @@ const progress = require('./turnprogress')
19
19
  const NO_REPLY = 'NO_REPLY'
20
20
  const SYSTEM_APPENDIX = systemAppendix(NO_REPLY)
21
21
  const V2_NORMAL_ROUTE_MISSING = Object.freeze(['complete durable v2 authority'])
22
+ // Permission tiers the manifest describes to the agent as read-only: 1 is
23
+ // the Codex read-only sandbox, 3 the fail-closed default. 0 is full access
24
+ // and 2 an allow-listed tool set, which may still change files.
25
+ const READ_ONLY_TIERS = Object.freeze([1, 3])
22
26
  const DURABLE_V2_COMMANDS = new Set(['/help', '/status', '/model', '/new', '/hud', '/issue'])
23
27
  function fmtWan(n) {
24
28
  n = Number(n) || 0
@@ -33,21 +37,43 @@ function humanDuration(ms) {
33
37
  if (minutes < 60) return `${minutes} 分 ${seconds % 60} 秒`
34
38
  return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
35
39
  }
40
+ // Group texts name the project by its folder only, never by a machine path.
41
+ function projectName(group) {
42
+ return String(group?.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
43
+ }
36
44
  const T = {
37
45
  queued: n => n > 1 ? `⏳ 已排队 — 当前任务结束后与前面 ${n - 1} 条一并处理` : '⏳ 已排队 — 当前任务结束后处理',
38
46
  stopped: '已停止',
39
47
  noActiveTask: '没有正在运行的任务',
40
- unsupportedCommand: '这个命令暂不支持。要停止当前任务请发 /stop,其他需求直接用文字说明。',
48
+ unsupportedCommand: '这个命令暂不支持。只放弃手头这件发 /skip,连排队的一起取消发 /stop;其他需求直接用文字说明。',
41
49
  unsupportedCommandWithModel: '这个命令暂不支持,发 /help 查看可用命令;其他需求直接用文字说明。',
42
- help: steer => ['【可用命令】',
43
- '/status 本群状态:模型、项目、是否在忙',
44
- '/model 查看模型;/model 名字 切换(会开始新的一段对话)',
45
- '/new 开始新的一段对话',
46
- '/stop 停止当前任务,连同排队的一起取消',
47
- '/hud 额度与本段对话的用量',
48
- '/issue 一句话 记下问题反馈',
49
- steer ? '直接发文字就是派任务;任务进行中再发的消息会补充进当前任务。'
50
- : '直接发文字就是派任务;任务进行中再发的消息会排队,等这一轮结束再处理。'].join('\n'),
50
+ // Plain text: 【】 sections and "· " items, each read in one glance, no
51
+ // alignment. Commands go by urgency; the three cancel commands rise in what
52
+ // they discard and describe the queue and the conversation in the same words.
53
+ help: ({ project, model, steer, readOnly }) => ['【这是什么】',
54
+ `· 本群绑着项目「${project}」,模型 ${model}`,
55
+ readOnly ? '· 直接问它,它看代码、答问题;之前聊的都记得'
56
+ : '· 直接说事它就干,做完在群里回你;之前聊的都记得',
57
+ steer ? '· 任务进行中再发的消息会补充进当前任务'
58
+ : '· 任务进行中再发的消息会排队,等这一轮结束再处理',
59
+ '',
60
+ '【命令】',
61
+ '· /status — 跑了多久、还有没有动静、排队几条',
62
+ '· /skip — 只放弃手头这件,排队的继续;之前聊的还记得',
63
+ '· /stop — 手头和排队的全取消;之前聊的还记得',
64
+ '· /new — 忘掉之前聊的,从头开始(要等手头任务结束)',
65
+ '· /model — 本群在用的模型;换:/model 名字(要等手头任务结束,换完之前聊的不再带着)',
66
+ '· /hud — 额度与本段对话的用量',
67
+ '· /issue 一句话 — 记下问题反馈,给管理员看',
68
+ '· /help — 这份说明',
69
+ // Not "trailing text is ignored": /stop with text after it is not recognised.
70
+ '⚠ 命令单独发一条,后面别跟别的字(/issue、/model 除外)',
71
+ // Same boundary as handleRecall: a queued message, or the task running on
72
+ // that message alone; a steered follow-up takes its whole task with it.
73
+ steer ? '⚠ 撤回自己发的消息 = 取消那件事;撤回补进当前任务的那条,会连当前任务一起取消'
74
+ : '⚠ 撤回自己发的消息 = 取消那件事(已和别的消息合成一轮在做的除外)',
75
+ ...(readOnly ? ['⚠ 本群为只读模式:能看代码、答问题;要改文件需管理员放开权限'] : []),
76
+ ].join('\n'),
51
77
  status: ({ model, project, state, doing = [], queue, session }) => ['【本群状态】', `· 模型:${model}`,
52
78
  `· 项目:${project}`, `· 状态:${state}`,
53
79
  ...(doing.length ? [`· 最近在做:${doing.join(' · ')}`] : []),
@@ -71,6 +97,9 @@ const T = {
71
97
  stopPending: '正在停止当前任务,这条消息没有处理,请稍后重发。',
72
98
  stopUnavailable: '当前任务正在收尾,暂时无法停止。',
73
99
  stoppedWithQueue: n => `已停止 — 连同排队的 ${n} 条一并取消`,
100
+ skipped: n => n > 0 ? `已跳过当前任务(后面还有 ${n} 条排队,接着处理)` : '已跳过当前任务',
101
+ skipUnavailable: '当前任务正在收尾,暂时无法跳过。',
102
+ skipPending: '正在跳过当前任务,这条消息没有处理,请稍后重发。',
74
103
  quotaFailure: 'Codex 订阅额度已用完,本次任务未完成。请恢复额度后重新发送;本次请求不会自动重试。',
75
104
  cardFooter: ({ ms, tokens }) => {
76
105
  const parts = [`耗时 ${Math.max(0, Math.round(ms / 1000))} 秒`]
@@ -347,10 +376,20 @@ async _durableV2Command(msg, cmd, rest) {
347
376
  const group = this._resolveGroup(msg.chatId)
348
377
  if (!group || group.paused) return reject('unsupported-command', T.unsupportedCommandWithModel)
349
378
  const { name, provider } = this._resolveProvider(msg.chatId, group)
350
- const label = (n, p) => p?.model ? `${n} · ${p.model}` : n
379
+ // A group may pin its own model; the runner uses it before the provider default.
380
+ const label = (n, p) => (group.model || p?.model) ? `${n} · ${group.model || p.model}` : n
351
381
  if (cmd === '/model') return this._durableV2ModelCommand(msg, rest, group)
352
382
  if (cmd === '/help') {
353
- return reject('help', T.help(provider?.runner === 'codex' && group.codexSteer !== false))
383
+ const isCodex = provider?.runner === 'codex'
384
+ // Read-only exactly when the manifest tells the agent so, for either
385
+ // runner; an unreadable policy reads as read-only.
386
+ let readOnly = true
387
+ try {
388
+ readOnly = READ_ONLY_TIERS.includes(manifestPolicy(group.permissionMode, isCodex,
389
+ group.allowedTools, group.disallowedTools).tier)
390
+ } catch {}
391
+ return reject('help', T.help({ project: projectName(group), model: label(name, provider),
392
+ steer: isCodex && group.codexSteer !== false, readOnly }))
354
393
  }
355
394
  if (cmd === '/status') {
356
395
  const rt = this.rts.get(msg.chatId)
@@ -360,8 +399,7 @@ async _durableV2Command(msg, cmd, rest) {
360
399
  const state = busy
361
400
  ? `运行中(已跑 ${humanDuration(Date.now() - (rt.v2StartedAt || Date.now()))}${rt.v2Steps ? ` · 已执行 ${rt.v2Steps} 步` : ''}${rt.v2AliveAt ? ` · 最近动静 ${humanDuration(Date.now() - rt.v2AliveAt)}前` : ''})`
362
401
  : rt?.v2AdmissionPending ? '消息已登记,等待并发名额' : '空闲'
363
- const project = String(group.workdir || '').split(/[\\/]/).filter(Boolean).pop() || '(未设)'
364
- return reject('status', T.status({ model: label(name, provider), project, state,
402
+ return reject('status', T.status({ model: label(name, provider), project: projectName(group), state,
365
403
  doing: busy && Array.isArray(rt.v2Doing) ? rt.v2Doing : [], queue: queued,
366
404
  session: this.store.getSession(msg.chatId) ? '进行中(记着上下文)' : '未开始' }))
367
405
  }
@@ -437,6 +475,9 @@ async runDurableV2Message(msg, platform) {
437
475
  if (msg?.text?.trim() === '/stop') {
438
476
  return this.runDurableV2Stop(msg, platform)
439
477
  }
478
+ if (typeof msg?.text === 'string' && /^\/skip(?:\s|$)/.test(msg.text.trim())) {
479
+ throw await this.runDurableV2Skip(msg)
480
+ }
440
481
  if (!this.durableV2NormalRoute) {
441
482
  const error = new Error('C2 v2 normal route is closed: runner transcript, tool/stream effects, and terminal projection lack durable evidence')
442
483
  error.code = 'C2_V2_NORMAL_ROUTE_CLOSED'
@@ -501,6 +542,12 @@ async runDurableV2Message(msg, platform) {
501
542
  throw rejectC2Input('C2 v2 ordinary route is waiting for stop acknowledgement',
502
543
  { reason: 'stop-pending', notice: T.stopPending })
503
544
  }
545
+ // Between /skip and its durable cancellation the runner may already be
546
+ // closed; refuse before receipt instead of reaching the closed busy route.
547
+ if (rt.v2SkipWaiter) {
548
+ throw rejectC2Input('C2 v2 ordinary route is waiting for /skip to settle',
549
+ { reason: 'skip-pending', notice: T.skipPending })
550
+ }
504
551
  const lane = before.state?.lanes
505
552
  const queue = lane?.queue?.[msg.chatId]?.queue
506
553
  if (rt.v2AdmissionPending) {
@@ -850,6 +897,79 @@ async runDurableV2Stop(msg, platform) {
850
897
  }
851
898
  }
852
899
 
900
+ // /skip cancels only the running turn, through the same durable cancellation
901
+ // as the console stop: queued receipts stay queued and run next, and the
902
+ // session is kept. Like the other chat commands it gets no receipt and is
903
+ // answered as an input rejection, here only after the cancellation has
904
+ // committed. That reply is best effort: the durable stop acknowledgement
905
+ // exists only for /stop, whose transition empties the queue.
906
+ async runDurableV2Skip(msg) {
907
+ const authority = this.durableV2Runtime
908
+ const chatId = msg?.chatId
909
+ if (!this.durableV2NormalRoute || !authority ||
910
+ !msg || msg.synthetic || msg.cron || msg.polled || msg.quiet ||
911
+ msg.wantsAck || msg.batch ||
912
+ typeof chatId !== 'string' || !chatId ||
913
+ typeof msg.msgId !== 'string' ||
914
+ !/^om_[A-Za-z0-9_-]{1,124}$/.test(msg.msgId) ||
915
+ msg.replyCtx?.chatId !== chatId ||
916
+ msg.replyCtx?.messageId !== msg.msgId) {
917
+ throw new Error('C2 v2 /skip is unsupported or closed')
918
+ }
919
+ const group = this._resolveGroup(chatId)
920
+ if (!group || group.paused || group.platform && group.platform !== 'feishu') {
921
+ throw new Error('C2 v2 /skip group control is not migrated')
922
+ }
923
+ // As for /stop, a /skip with nothing it may cancel is refused before receipt.
924
+ const reject = (reason, notice) => rejectC2Input(reason === 'skipped'
925
+ ? 'C2 v2 /skip answered after durable cancellation'
926
+ : 'C2 v2 /skip is unsupported or closed', { reason, notice })
927
+ // Without a receipt nothing durable dedupes this command, so a platform
928
+ // redelivery must not cancel a later task: a repeat this process has seen
929
+ // is dropped silently, and so is a /skip sent before the newest message of
930
+ // the running task (both are platform times).
931
+ const seen = this.durableV2SkipSeen || (this.durableV2SkipSeen = new Set())
932
+ const key = `${chatId}\n${msg.msgId}`
933
+ if (seen.has(key)) return reject('skip-duplicate', null)
934
+ seen.add(key)
935
+ if (seen.size > 256) seen.delete(seen.values().next().value)
936
+ const rt = this.rts.get(chatId)
937
+ if (!rt?.v2NormalRouteActive || !rt.v2RunningJobs?.length) {
938
+ return reject('skip-idle', T.noActiveTask)
939
+ }
940
+ const sentAt = Number(msg.createTimeMs)
941
+ if (Number.isSafeInteger(sentAt) && sentAt > 0 && rt.v2RunningJobs.some(job =>
942
+ Number(job?.msg?.createTimeMs) > sentAt)) {
943
+ return reject('skip-stale', null)
944
+ }
945
+ const route = rt.v2NormalRoutePromise
946
+ let settle
947
+ const settled = new Promise(resolve => { settle = resolve })
948
+ const waiter = { turnToken: rt.v2TurnToken, settle }
949
+ if (rt.v2SkipWaiter || !route || !Number.isSafeInteger(waiter.turnToken)) {
950
+ return reject('skip-unavailable', T.skipUnavailable)
951
+ }
952
+ rt.v2SkipWaiter = waiter
953
+ try {
954
+ if (!this.requestDurableV2Stop(chatId)) {
955
+ if (authority.blocked) throw new Error('C2 v2 /skip closed its durable authority')
956
+ return reject('skip-unavailable', T.skipUnavailable)
957
+ }
958
+ const queued = await Promise.race([settled, route.then(() => null)])
959
+ if (queued === null) {
960
+ // The turn ended without this cancellation, e.g. its runner was proven
961
+ // never started. Nothing was skipped and the group is not at fault;
962
+ // only a closed authority is.
963
+ if (authority.blocked) throw new Error('C2 v2 /skip cancellation was not durably settled')
964
+ return reject('skip-unavailable', T.skipUnavailable)
965
+ }
966
+ this.log(`/skip: ${chatId} (by ${msg.senderName || 'unknown'}), ${queued} queued`)
967
+ return reject('skipped', T.skipped(queued))
968
+ } finally {
969
+ if (rt.v2SkipWaiter === waiter) rt.v2SkipWaiter = null
970
+ }
971
+ }
972
+
853
973
  async _cleanupDurableV2CancelledCard(rt, identity, generation,
854
974
  verifyGeneration) {
855
975
  if (!rt.v2CardBinding?.opened) return null
@@ -963,6 +1083,13 @@ async _drainDurableV2NormalChain(initial) {
963
1083
  terminalEffectId: null, cancellationEventId: cancelled.eventId })
964
1084
  }
965
1085
  if (authority.blocked) throw new Error('C2 v2 cancellation closed ingress')
1086
+ // A chat /skip answers once its own turn is durably cancelled; what
1087
+ // stays in the queue is the work that runs next.
1088
+ const skip = rt.v2SkipWaiter
1089
+ if (!stop && skip?.turnToken === identity.turnToken) {
1090
+ rt.v2SkipWaiter = null
1091
+ skip.settle(authority.snapshot().state.lanes.queue?.[identity.chatId]?.queue?.length || 0)
1092
+ }
966
1093
  const nextReceiptId = this._nextDurableV2NormalAnchor(identity.chatId)
967
1094
  if (nextReceiptId) {
968
1095
  const next = this.commitDurableV2InitialDispatch({
@@ -1627,7 +1754,8 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1627
1754
  if (controller && rt.v2Controller === controller) rt.v2Controller = null
1628
1755
  const proofId = `sbot-c2-runner-not-started-v1:${receiptId}:${generation}`
1629
1756
  const eventId = `sbot-c2-runner-not-started-commit-v1:${receiptId}:${turnToken}:${generation}`
1630
- const provenNotStarted = !accepted && error?.code === 'AGENT_BIN_MISSING' &&
1757
+ const provenNotStarted = !accepted &&
1758
+ ['AGENT_BIN_MISSING', 'AGENT_THREAD_REJECTED'].includes(error?.code) &&
1631
1759
  this.durableV2Journal?.lookupRunnerNotStarted(proofId)?.dispatchEffectId === effectId &&
1632
1760
  this.durableV2Journal?.hasEvent(eventId) === true
1633
1761
  if (provenNotStarted) {
@@ -1638,10 +1766,44 @@ async startDurableV2Runner({ chatId, receiptId, turnToken, effectId } = {}) {
1638
1766
  authority.close()
1639
1767
  throw new Error('C2 v2 unstarted runner receipt remains unsettled')
1640
1768
  }
1769
+ // Recovery also settles messages queued behind this turn, each with its
1770
+ // own confirmed notice. Forget their bodies and the local queue so the
1771
+ // next message is admitted as idle, not as a busy follow-up. A queue
1772
+ // the recovery left in place has no dispatcher here: fail closed.
1773
+ const durableQueue = authority.snapshot().state.lanes.queue?.[chatId]?.queue || []
1774
+ if (durableQueue.length) {
1775
+ authority.close()
1776
+ throw new Error('C2 v2 unstarted runner left queued receipts unsettled')
1777
+ }
1778
+ try {
1779
+ for (const id of rt.v2QueueReceiptIds || []) this.durableV2Payloads.dropConfirmed(id)
1780
+ } catch (dropError) { authority.close(); throw dropError }
1781
+ rt.v2QueueReceiptIds = []
1782
+ // A resume the server itself rejected leaves the saved thread unusable
1783
+ // (missing rollout, unreadable history). As /new on this now idle and
1784
+ // settled group: the next message starts a fresh session instead of
1785
+ // meeting the same rejection again.
1786
+ if (error.code === 'AGENT_THREAD_REJECTED' && this.store.getSession(chatId)) {
1787
+ let locked = false
1788
+ try { locked = this.durableV2AssertWriterLock?.() === true } catch {}
1789
+ if (!locked) {
1790
+ authority.close()
1791
+ throw new Error('C2 v2 rejected-session reset lost single-writer lock')
1792
+ }
1793
+ this.store.resetSessionAndStats(chatId)
1794
+ this.log(`group ${chatId}: Codex rejected the saved session before any turn; the next message starts a new session`)
1795
+ }
1641
1796
  rt.v2RunnerGeneration = null
1642
1797
  rt.v2RunningJobs = null
1643
1798
  rt.v2DispatchEffect = null
1644
1799
  rt.v2RunnerNotStarted = true
1800
+ // A console stop or /skip asked of this turn has nothing left to
1801
+ // cancel; carried over, it would cancel the group's next turn.
1802
+ if (!rt.v2StopControl) {
1803
+ rt.v2CancelControl = null
1804
+ rt.v2StreamQuiescing = false
1805
+ rt.v2StopIntentGate = null
1806
+ }
1645
1807
  return { effectId, accepted: false, startFailureRecovered: true }
1646
1808
  }
1647
1809
  if (capability && !accepted) {