evolclaw 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +7 -4
  3. package/dist/agents/{resolve.js → baseagent.js} +34 -5
  4. package/dist/agents/claude-runner.js +120 -31
  5. package/dist/agents/codex-app-server-client.js +364 -0
  6. package/dist/agents/codex-runner.js +1152 -140
  7. package/dist/agents/gemini-runner.js +2 -2
  8. package/dist/agents/runner-types.js +58 -0
  9. package/dist/aun/aid/store.js +1 -1
  10. package/dist/aun/outbox.js +14 -2
  11. package/dist/aun/storage/download.js +1 -1
  12. package/dist/aun/storage/upload.js +13 -1
  13. package/dist/channels/aun.js +869 -358
  14. package/dist/channels/dingtalk.js +77 -140
  15. package/dist/channels/feishu.js +125 -154
  16. package/dist/channels/qqbot.js +75 -138
  17. package/dist/channels/wechat.js +75 -136
  18. package/dist/channels/wecom.js +75 -138
  19. package/dist/cli/agent-command.js +591 -0
  20. package/dist/cli/agent.js +23 -8
  21. package/dist/cli/aun-commands.js +1444 -0
  22. package/dist/cli/ctl-command.js +78 -0
  23. package/dist/cli/daemon-commands.js +2707 -0
  24. package/dist/cli/index.js +23 -4905
  25. package/dist/cli/init.js +33 -6
  26. package/dist/cli/model.js +1 -1
  27. package/dist/cli/restart-monitor.js +539 -0
  28. package/dist/cli/stats.js +558 -0
  29. package/dist/cli/version.js +87 -0
  30. package/dist/cli/watch-logs.js +33 -0
  31. package/dist/cli/watch-msg.js +5 -2
  32. package/dist/config-store.js +12 -6
  33. package/dist/core/channel-loader.js +88 -83
  34. package/dist/core/command/command-handler.js +1189 -0
  35. package/dist/core/command/menu-handler.js +1478 -0
  36. package/dist/core/command/slash-gate.js +142 -0
  37. package/dist/core/command/slash-handler.js +2090 -0
  38. package/dist/core/evolagent-registry.js +82 -0
  39. package/dist/core/evolagent.js +17 -1
  40. package/dist/core/interaction-router.js +8 -0
  41. package/dist/core/message/command-handler-agent-control.js +63 -1
  42. package/dist/core/message/im-renderer.js +91 -51
  43. package/dist/core/message/items-formatter.js +9 -1
  44. package/dist/core/message/message-bridge.js +73 -24
  45. package/dist/core/message/message-log.js +1 -0
  46. package/dist/core/message/message-processor.js +432 -94
  47. package/dist/core/message/message-queue.js +70 -2
  48. package/dist/core/message/pending-hints.js +232 -0
  49. package/dist/core/model/model-catalog.js +1 -1
  50. package/dist/core/model/model-scope.js +2 -2
  51. package/dist/core/permission.js +25 -12
  52. package/dist/core/relation/peer-identity.js +16 -1
  53. package/dist/core/session/adapters/codex-session-file-adapter.js +4 -2
  54. package/dist/core/session/session-manager.js +86 -26
  55. package/dist/core/session/session-title.js +26 -0
  56. package/dist/core/stats/billing.js +151 -0
  57. package/dist/core/stats/budget.js +93 -0
  58. package/dist/core/stats/db.js +334 -0
  59. package/dist/core/stats/eck-vars.js +84 -0
  60. package/dist/core/stats/index.js +10 -0
  61. package/dist/core/stats/normalizer.js +78 -0
  62. package/dist/core/stats/query.js +760 -0
  63. package/dist/core/stats/writer.js +115 -0
  64. package/dist/core/trigger/manager.js +34 -0
  65. package/dist/core/trigger/parser.js +9 -3
  66. package/dist/core/trigger/scheduler.js +20 -17
  67. package/dist/data/error-dict.json +7 -0
  68. package/dist/{agents → eck}/manifest-engine.js +20 -1
  69. package/dist/{agents → eck}/message-renderer.js +24 -1
  70. package/dist/index.js +174 -9
  71. package/dist/ipc.js +116 -1
  72. package/dist/utils/cross-platform.js +58 -5
  73. package/dist/utils/ecweb-launch.js +49 -0
  74. package/dist/utils/ecweb-pair.js +20 -0
  75. package/dist/utils/error-utils.js +18 -5
  76. package/dist/utils/npm-ops.js +38 -8
  77. package/dist/utils/stats.js +77 -6
  78. package/kits/docs/evolclaw/INDEX.md +3 -1
  79. package/kits/docs/evolclaw/fs-architecture.md +1215 -0
  80. package/kits/docs/evolclaw/fs.md +131 -0
  81. package/kits/docs/evolclaw/group-fs.md +209 -0
  82. package/kits/docs/evolclaw/stats.md +70 -0
  83. package/kits/docs/venues/aun-group.md +29 -6
  84. package/kits/docs/venues/group.md +5 -4
  85. package/kits/eck_message_manifest.json +30 -3
  86. package/kits/rules/05-venue.md +1 -1
  87. package/kits/templates/message-fragments/inject-default.md +2 -0
  88. package/package.json +5 -6
  89. package/dist/agents/baseagent-normalize.js +0 -19
  90. package/dist/core/command-handler.js +0 -3876
  91. package/dist/core/relation/peer-key.js +0 -16
  92. package/dist/evolclaw-config.js +0 -11
  93. package/dist/utils/channel-helpers.js +0 -46
  94. /package/dist/core/{cache/file-cache.js → daemon-file-cache.js} +0 -0
  95. /package/dist/{agents → eck}/kit-renderer.js +0 -0
@@ -0,0 +1,1478 @@
1
+ import { DEFAULT_PERMISSION_MODE } from '../../types.js';
2
+ import { hasModelSwitcher, hasPermissionController } from '../../agents/runner-types.js';
3
+ import { getCodexEfforts } from '../../agents/codex-runner.js';
4
+ import { resolvePaths, getPackageRoot } from '../../paths.js';
5
+ import { buildEnvelope } from '../message/message-processor.js';
6
+ import path from 'path';
7
+ import fs from 'fs';
8
+ import crypto from 'crypto';
9
+ import { calcNextFireAt } from '../trigger/scheduler.js';
10
+ import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions } from '../../utils/npm-ops.js';
11
+ import { loadDefaults, loadEvolclawConfig } from '../../config-store.js';
12
+ import { execAgentAction, execAgentQuery, execAgentOptions, resolveProjectPath } from '../message/command-handler-agent-control.js';
13
+ import { displaySessionTitle } from '../session/session-title.js';
14
+ const allEfforts = ['low', 'medium', 'high', 'xhigh', 'max'];
15
+ const PERMISSION_MODE_KEYS = ['auto', 'bypass', 'readonly', 'plan', 'edit', 'request', 'noask'];
16
+ /** menu file: fetch 文件大小上限(与 /file 一致) */
17
+ const FILE_FETCH_MAX_SIZE = 10 * 1024 * 1024;
18
+ /** menu file: query 的 sha256 仅对 ≤ 2 MB 文件计算,超过返回 null(见设计文档 §7 决策 2) */
19
+ const FILE_HASH_MAX_SIZE = 2 * 1024 * 1024;
20
+ function getRenameName(args) {
21
+ return (args?.name ?? args?.title ?? args?.value ?? '').toString().trim();
22
+ }
23
+ function buildSessionPayload(session, name) {
24
+ const payload = { id: session.id, name };
25
+ if (session.agentSessionId)
26
+ payload.agentSessionId = session.agentSessionId;
27
+ return payload;
28
+ }
29
+ async function findMainSessionTarget(sessionManager, channel, channelId, target, activeSession) {
30
+ if (!target) {
31
+ return activeSession && !activeSession.threadId ? activeSession : undefined;
32
+ }
33
+ const sessions = (await sessionManager.listSessions(channel, channelId))
34
+ .filter((s) => !s.threadId);
35
+ return sessions.find((s) => s.name === target ||
36
+ s.id === target ||
37
+ (target.length >= 8 && s.id.startsWith(target)) ||
38
+ s.agentSessionId === target ||
39
+ (!!s.agentSessionId && target.length >= 8 && s.agentSessionId.startsWith(target)));
40
+ }
41
+ /**
42
+ * 解析并校验 menu `name=file` 的目标路径(query/fetch 共用)。
43
+ *
44
+ * 与文本 `/file` 的差异(见设计文档 §6.2):
45
+ * - 接受项目内**绝对路径**(文本 /file 仍拒绝绝对路径)
46
+ * - 项目外文件仅 aid channel owner(identity.role === 'owner')可取
47
+ *
48
+ * 沿用 /file 的安全校验链:拒绝 `..` 穿越、realpathSync 后验证落点。
49
+ * 成功返回 `{ realPath, projectPath, stat }`;失败返回 `{ error, code }`。
50
+ */
51
+ function resolveMenuFilePath(input, session, role) {
52
+ if (!session?.projectPath)
53
+ return { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
54
+ const raw = (input ?? '').toString().trim();
55
+ if (!raw)
56
+ return { error: '缺少 path 参数', code: 'MISSING_VALUE' };
57
+ // 拒绝 .. 路径穿越(兼容两种分隔符)
58
+ if (raw.split(path.sep).includes('..') || raw.split('/').includes('..')) {
59
+ return { error: '不支持 .. 路径穿越', code: 'NO_PERMISSION' };
60
+ }
61
+ // 相对路径基于 projectPath 解析;绝对路径原样
62
+ const resolved = path.isAbsolute(raw) ? raw : path.resolve(session.projectPath, raw);
63
+ if (!fs.existsSync(resolved)) {
64
+ return { error: '文件不存在', code: 'NOT_FOUND' };
65
+ }
66
+ let realPath;
67
+ let realProjectPath;
68
+ try {
69
+ realPath = fs.realpathSync(resolved);
70
+ realProjectPath = fs.realpathSync(session.projectPath);
71
+ }
72
+ catch {
73
+ return { error: '文件不存在', code: 'NOT_FOUND' };
74
+ }
75
+ const inProject = realPath === realProjectPath || realPath.startsWith(realProjectPath + path.sep);
76
+ // 项目外文件:仅 aid channel owner 可取(§6.2)
77
+ if (!inProject && role !== 'owner') {
78
+ return { error: '无权限:项目外文件仅 owner 可取', code: 'NO_PERMISSION' };
79
+ }
80
+ const stat = fs.statSync(realPath);
81
+ if (stat.isDirectory()) {
82
+ return { error: '暂不支持目录', code: 'NOT_SUPPORTED' };
83
+ }
84
+ return { realPath, projectPath: realProjectPath, stat };
85
+ }
86
+ const CLI_EXEC_WHITELIST = {
87
+ status: '*',
88
+ model: '*',
89
+ stats: '*',
90
+ agent: new Set(['list', 'show', 'get']),
91
+ aid: new Set(['list', 'show', 'lookup']),
92
+ storage: new Set(['ls', 'quota']),
93
+ };
94
+ function tokenizeArgv(line) {
95
+ const out = [];
96
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
97
+ let m;
98
+ while ((m = re.exec(line)) !== null) {
99
+ out.push(m[1] ?? m[2] ?? m[3] ?? '');
100
+ }
101
+ return out;
102
+ }
103
+ function getAvailableEfforts(agent, model) {
104
+ if (agent.name === 'claude') {
105
+ return allEfforts;
106
+ }
107
+ if (agent.name === 'codex') {
108
+ return getCodexEfforts(model);
109
+ }
110
+ return [];
111
+ }
112
+ function modelDisplayLabel(agent, model) {
113
+ const full = agent.resolveModelId?.(model);
114
+ return full && full !== model ? `${model} (${full})` : model;
115
+ }
116
+ export function isProcessLevelOwner(peerId, owners) {
117
+ if (!peerId)
118
+ return false;
119
+ return (owners ?? []).includes(peerId);
120
+ }
121
+ // ── 控制面双轨鉴权(见 docs/.../2026-06-10-control-channel-auth-design.md)──
122
+ // 白名单而非黑名单:默认安全,新增进程级 action 需显式加入。
123
+ /** /agent 的进程级 action:仅控制 channel 可执行。 */
124
+ export const PROCESS_LEVEL_AGENT_ACTIONS = new Set(['create', 'delete', 'enable', 'disable']);
125
+ /** /agent 的「本 agent 自管理」action:agent channel 仅 owner/admin 可对自身 aid 执行;update 另行收紧为 owner。 */
126
+ export const SELF_MANAGE_AGENT_ACTIONS = new Set(['update', 'reload']);
127
+ /** 判断 (cmdBase, action) 是否为进程级操作(仅控制 channel 可执行)。
128
+ * /system 全部进程级;/agent 仅 create/delete/enable/disable 进程级;其余关系级。 */
129
+ export function isProcessLevelAction(cmdBase, action) {
130
+ if (cmdBase === '/system')
131
+ return true;
132
+ if (cmdBase === '/agent')
133
+ return PROCESS_LEVEL_AGENT_ACTIONS.has(action ?? '');
134
+ return false;
135
+ }
136
+ /** 控制面作用域闸门:在每个 exec 入口算出 cmdBase/action 后调用。
137
+ * 闸1 进程级 action:仅控制 channel。
138
+ * 闸2 跨 agent 寻址(args.aid ≠ 自身):仅控制 channel。
139
+ * 命中返回 FORBIDDEN 结果,否则返回 null(放行,后续走原有 owner/role 鉴权)。 */
140
+ function gateControlScope(opts) {
141
+ const { cmdBase, action, args, channel, fromControlChannel } = opts;
142
+ if (fromControlChannel)
143
+ return null;
144
+ if (isProcessLevelAction(cmdBase, action)) {
145
+ return { error: '此操作仅允许通过控制 AID channel 执行', code: 'FORBIDDEN' };
146
+ }
147
+ const targetAid = args?.aid;
148
+ if (targetAid) {
149
+ const currentAgentAid = this.getOwningAgent?.(channel)?.aid;
150
+ if (targetAid !== currentAgentAid) {
151
+ return { error: '跨 agent 操作仅允许通过控制 AID channel 执行', code: 'FORBIDDEN' };
152
+ }
153
+ }
154
+ return null;
155
+ }
156
+ function ecwebErr(id, name, code, message) {
157
+ return { type: 'menu.response', id, ...(name ? { name } : {}), error: { code, message } };
158
+ }
159
+ function ecwebResp(id, name, result) {
160
+ return 'error' in result
161
+ ? { type: 'menu.response', id, ...(name ? { name } : {}), error: { code: result.code ?? 'EXEC_FAILED', message: result.error } }
162
+ : { type: 'menu.response', id, ...(name ? { name } : {}), data: result.data };
163
+ }
164
+ export function validateScheduleParams(scheduleType, scheduleValue) {
165
+ if (!['delay', 'at', 'cron'].includes(scheduleType)) {
166
+ return `无效 scheduleType: ${scheduleType}(可选: delay / at / cron)`;
167
+ }
168
+ if (scheduleType === 'delay') {
169
+ const ms = Number(scheduleValue);
170
+ if (!Number.isFinite(ms) || ms <= 0)
171
+ return `delay 的 scheduleValue 需为正整数毫秒: ${scheduleValue}`;
172
+ }
173
+ else if (scheduleType === 'at') {
174
+ const ts = new Date(scheduleValue).getTime();
175
+ if (!Number.isFinite(ts))
176
+ return `at 的 scheduleValue 需为合法时间: ${scheduleValue}`;
177
+ }
178
+ else {
179
+ try {
180
+ calcNextFireAt('cron', scheduleValue, Date.now());
181
+ }
182
+ catch {
183
+ return `无效 cron 表达式: ${scheduleValue}`;
184
+ }
185
+ }
186
+ return null;
187
+ }
188
+ /**
189
+ * 返回结构化命令菜单(供 menu.query 使用)
190
+ * owner 看到全部命令,admin 看到管理级命令(不含 owner-only),guest 仅看到用户级命令
191
+ */
192
+ export function getMenuItems(role, chatType = 'private', scope = 'agent') {
193
+ const isOwner = role === 'owner';
194
+ const isAdmin = role === 'owner' || role === 'admin';
195
+ const isControlScope = scope === 'control';
196
+ const canReadTopic = role !== 'anonymous';
197
+ const items = [];
198
+ if (!isAdmin && chatType === 'group') {
199
+ return [
200
+ ...(canReadTopic ? [{
201
+ group: '话题管理',
202
+ commands: [
203
+ { cmd: '/topic', label: '话题管理', desc: '查看当前聊天的话题会话', next: { type: 'select', dynamic: true } },
204
+ ]
205
+ }] : []),
206
+ {
207
+ group: '其他',
208
+ commands: [
209
+ { cmd: '/status', label: '显示会话状态' },
210
+ { cmd: '/check', label: '检查渠道健康' },
211
+ { cmd: '/help', label: '显示帮助信息' },
212
+ ]
213
+ }
214
+ ];
215
+ }
216
+ items.push({
217
+ group: '会话管理',
218
+ commands: [
219
+ { cmd: '/new', label: '创建新会话', desc: '清空历史,开始全新对话', next: { type: 'text' } },
220
+ { cmd: '/s', label: '切换会话', desc: '切换到同项目下的其他会话', next: { type: 'select', dynamic: true } },
221
+ ...(canReadTopic ? [{ cmd: '/topic', label: '话题管理', desc: '查看与管理当前聊天的话题会话', next: { type: 'select', dynamic: true } }] : []),
222
+ { cmd: '/name', label: '重命名当前会话', desc: '为当前会话设置一个易识别的名称', next: { type: 'text' } },
223
+ { cmd: '/del', label: '删除指定会话', desc: '永久删除一个非活跃会话', next: { type: 'select', dynamic: true } },
224
+ ...(isAdmin ? [
225
+ { cmd: '/fork', label: '分支当前会话', desc: '基于当前会话创建独立分支', next: { type: 'text' } },
226
+ { cmd: '/rewind', label: '查看历史/撤销指定轮次', desc: '回退会话到指定轮次,可选择撤销文件改动' },
227
+ { cmd: '/compact', label: '压缩会话上下文', desc: '将长对话压缩为摘要以节省 token' },
228
+ ] : []),
229
+ ]
230
+ });
231
+ if (isAdmin) {
232
+ items.push({
233
+ group: 'Agent 与模型',
234
+ commands: [
235
+ { cmd: '/baseagent', label: '切换 Agent 后端', desc: '切换当前会话使用的 AI 后端', next: { type: 'select', dynamic: true } },
236
+ { cmd: '/model', label: '切换模型', desc: '切换当前 Agent 使用的模型版本', next: { type: 'select', dynamic: true } },
237
+ { cmd: '/effort', label: '切换推理强度', desc: '调整模型推理深度,影响响应速度与质量', next: { type: 'select', items: [
238
+ { value: 'low', label: 'Low' },
239
+ { value: 'medium', label: 'Medium' },
240
+ { value: 'high', label: 'High' },
241
+ { value: 'max', label: 'Max' },
242
+ ] } },
243
+ { cmd: '/chatmode', label: '切换会话模式', desc: '控制 Agent 主动性(被动响应或主动推进)', next: { type: 'select', items: [
244
+ { value: 'interactive', label: '交互模式', desc: '仅在收到消息时响应' },
245
+ { value: 'proactive', label: '主动模式', desc: 'Agent 可主动推进任务' },
246
+ ] } },
247
+ { cmd: '/dispatch', label: '切换分发模式', desc: '控制群聊消息过滤(仅@提及或广播响应)', next: { type: 'select', items: [
248
+ { value: 'mention', label: '@ 提及', desc: '仅在被 @ 提及时响应' },
249
+ { value: 'broadcast', label: '广播', desc: '响应群内所有消息' },
250
+ ] } },
251
+ ]
252
+ });
253
+ items.push({
254
+ group: '权限管理',
255
+ commands: [
256
+ { cmd: '/perm', label: '权限模式管理', desc: '控制工具调用的审批策略', next: { type: 'select', items: [
257
+ { value: 'auto', label: '自动模式', desc: '根据风险等级自动决定是否审批' },
258
+ { value: 'bypass', label: '免审批模式', desc: '跳过所有工具审批确认' },
259
+ { value: 'readonly', label: '只读模式', desc: '允许读取和临时目录写入,拒绝项目文件修改' },
260
+ { value: 'plan', label: '计划模式', desc: '仅允许只读操作,写操作需审批' },
261
+ { value: 'edit', label: '编辑模式', desc: '允许文件编辑,其他操作需审批' },
262
+ { value: 'request', label: '请求模式', desc: '所有操作均需审批' },
263
+ { value: 'noask', label: '静默模式', desc: '不弹出审批,自动拒绝未授权操作' },
264
+ { value: 'allow', label: '允许此操作', desc: '本次允许当前待审批操作' },
265
+ { value: 'always', label: '始终允许', desc: '永久允许同类操作' },
266
+ { value: 'deny', label: '拒绝此操作', desc: '拒绝当前待审批操作' },
267
+ ] } },
268
+ ]
269
+ });
270
+ items.push({
271
+ group: '运维',
272
+ commands: [
273
+ { cmd: '/status', label: '显示会话状态', desc: '查看当前会话、项目、Agent 的详细状态' },
274
+ { cmd: '/stop', label: '中断当前任务', desc: '立即中断正在执行的 Agent 任务' },
275
+ { cmd: '/check', label: '检查渠道状态', desc: '检查各消息渠道的连接健康状态' },
276
+ { cmd: '/activity', label: '控制中间输出显示', desc: '设置工具调用过程的可见范围', next: { type: 'select', items: [
277
+ { value: 'all', label: '全部显示', desc: '所有用户均可见中间输出' },
278
+ { value: 'dm', label: '仅私聊', desc: '仅私聊中显示中间输出' },
279
+ { value: 'owner', label: '仅 owner 私聊', desc: '仅 owner 的私聊中显示' },
280
+ { value: 'none', label: '不显示', desc: '关闭所有中间输出' },
281
+ ] } },
282
+ ...(isControlScope && isOwner ? [
283
+ { cmd: '/restart', label: '重启服务', desc: '重启整个 EvolClaw 服务进程' },
284
+ ] : []),
285
+ ...(isAdmin ? [
286
+ { cmd: '/file', label: '发送项目内文件', desc: '将项目目录内的文件发送给用户' },
287
+ ] : []),
288
+ ]
289
+ });
290
+ }
291
+ else {
292
+ items.push({
293
+ group: '其他',
294
+ commands: [
295
+ { cmd: '/status', label: '显示会话状态', desc: '查看当前会话的基本状态' },
296
+ { cmd: '/check', label: '检查渠道健康', desc: '检查消息渠道连接状态' },
297
+ ]
298
+ });
299
+ }
300
+ items.push({
301
+ group: '帮助',
302
+ commands: [
303
+ { cmd: '/help', label: '显示帮助信息', desc: '列出所有可用命令及说明' },
304
+ ]
305
+ });
306
+ return items;
307
+ }
308
+ /** 动态子菜单:根据 cmd 路径返回选项列表(供 menu.query + cmd 使用) */
309
+ export async function getSubMenuItems(cmd, channel, channelId, userId, args, overrideIdentity, _explicitChatType, fromControlChannel = false) {
310
+ const session = await this.sessionManager.getActiveSession(channel, channelId);
311
+ const cmdBase0 = cmd.trim().split(' ')[0];
312
+ const gated0 = gateControlScope.call(this, { cmdBase: cmdBase0, args, channel, fromControlChannel });
313
+ if (gated0)
314
+ throw { code: gated0.code, message: gated0.error };
315
+ // ── /agent list(只读) ──
316
+ // 控制 channel:验 evolclaw.owners,返回全量;agent channel:放行但仅返回自身单条。
317
+ if (cmd === '/agent') {
318
+ if (fromControlChannel) {
319
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
320
+ throw { code: 'FORBIDDEN', message: '操作需要 owner 权限' };
321
+ }
322
+ const res = await execAgentOptions(args);
323
+ if ('error' in res)
324
+ throw { code: res.code, message: res.error };
325
+ return res.data.agents.map(ag => ({ value: ag.aid, label: ag.name || ag.aid, desc: ag.status }));
326
+ }
327
+ // agent channel:作用域绑定自身,仅返回自身单条
328
+ const selfAid = this.getOwningAgent?.(channel)?.aid;
329
+ if (!selfAid)
330
+ throw { code: 'FORBIDDEN', message: '当前 channel 无绑定 agent' };
331
+ const res = await execAgentOptions(args);
332
+ if ('error' in res)
333
+ throw { code: res.code, message: res.error };
334
+ return res.data.agents
335
+ .filter(ag => ag.aid === selfAid)
336
+ .map(ag => ({ value: ag.aid, label: ag.name || ag.aid, desc: ag.status }));
337
+ }
338
+ // ── 关系级 /trigger list(每个 trigger 一个 MenuItem) ──
339
+ if (cmd === '/trigger') {
340
+ const owningAgent = this.getOwningAgent(channel);
341
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
342
+ if (!manager)
343
+ return [];
344
+ const scope = args?.options === 'all' ? 'all' : 'enabled';
345
+ const role = (overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId)).role;
346
+ const isAdmin = role === 'owner' || role === 'admin';
347
+ const all = manager.listAll();
348
+ const list = scope === 'all' ? all.active.concat(all.history) : manager.listActive();
349
+ const visible = isAdmin ? list
350
+ : list.filter((t) => t.createdByPeerId === (userId ?? '') && t.createdByChannel === channel);
351
+ return visible.map((t) => ({
352
+ // 透传完整 trigger 字段(ECWeb Triggers 表逐列渲染需要)
353
+ ...t,
354
+ value: t.id,
355
+ label: t.name,
356
+ desc: `${t.scheduleType}${t.nextFireAt ? ` | 下次 ${new Date(t.nextFireAt).toLocaleString()}` : ''}`,
357
+ // 状态标识:history 条目带 doneReason(fired/cancelled/expired),active 条目恒为 'active'
358
+ status: t.doneReason ?? 'active',
359
+ }));
360
+ }
361
+ if (cmd === '/topic') {
362
+ const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
363
+ if (!this.canReadTopics(identity.role)) {
364
+ throw { code: 'FORBIDDEN', message: '无权限查看话题' };
365
+ }
366
+ const sessions = await this.sessionManager.listSessions(channel, channelId);
367
+ return sessions
368
+ .filter((s) => !!s.threadId)
369
+ .map((s) => this.buildTopicMenuItem(s));
370
+ }
371
+ if (cmd === '/s' || cmd === '/session' || cmd === '/del') {
372
+ const sessions = await this.sessionManager.listSessions(channel, channelId);
373
+ const active = cmd === '/del' ? await this.sessionManager.getActiveSession(channel, channelId) : null;
374
+ const currentSession = session;
375
+ const items = sessions
376
+ .filter((s) => !s.threadId)
377
+ .filter((s) => !active || s.id !== active.id)
378
+ .map((s) => {
379
+ const displayName = displaySessionTitle(s.name, s.id.slice(0, 8));
380
+ const item = {
381
+ value: s.name || s.id.slice(0, 8),
382
+ label: displayName,
383
+ selected: currentSession ? s.id === currentSession.id : false,
384
+ };
385
+ if (s.agentSessionId) {
386
+ item.agentSessionId = s.agentSessionId;
387
+ const fileInfo = this.sessionManager.getSessionFileInfo(s.projectPath, s.agentSessionId, s.agentId);
388
+ if (fileInfo.turns)
389
+ item.turns = fileInfo.turns;
390
+ const firstMsg = this.sessionManager.readSessionFirstMessage(s.projectPath, s.agentSessionId, s.agentId);
391
+ if (firstMsg)
392
+ item.preview = firstMsg.length > 80 ? firstMsg.slice(0, 80) + '…' : firstMsg;
393
+ }
394
+ if (s.updatedAt)
395
+ item.lastActive = s.updatedAt;
396
+ return item;
397
+ });
398
+ if (cmd === '/s' || cmd === '/session') {
399
+ items.push({ value: 'cli', label: '查看 CLI 会话', desc: '列出未导入的 CLI 本地会话' });
400
+ }
401
+ return items;
402
+ }
403
+ if (cmd === '/baseagent') {
404
+ const currentAgent = session?.agentId;
405
+ return this.getAvailableBaseagents(channel).map((name) => ({ value: name, label: name, selected: name === currentAgent }));
406
+ }
407
+ if (cmd === '/model') {
408
+ const agent = this.getAgent(channel, session?.agentId);
409
+ if (hasModelSwitcher(agent) && agent.listModels) {
410
+ const models = await agent.listModels() ?? [];
411
+ const currentModel = agent.getModel();
412
+ if (models.length > 0)
413
+ return models.map((m) => ({ value: m, label: modelDisplayLabel(agent, m), selected: m === currentModel }));
414
+ }
415
+ return null;
416
+ }
417
+ // if (cmd === '/restart') {
418
+ // const isOwner = userId ? this.sessionManager.resolveIdentity(channel, userId).role === 'owner' : false;
419
+ // // 列出所有 channel type
420
+ // const visibleTypes = new Set<string>();
421
+ // for (const [name] of this.adapters) {
422
+ // const t = this.channelTypeMap.get(name);
423
+ // if (t) visibleTypes.add(t);
424
+ // }
425
+ // const channels = [...visibleTypes].map(type => ({ value: type, label: type, desc: '重连此类型所有渠道实例' }));
426
+ // if (isOwner) channels.unshift({ value: '', label: '重启服务', desc: '重启整个 EvolClaw 服务进程' });
427
+ // return channels;
428
+ // }
429
+ if (cmd === '/activity') {
430
+ const currentMode = this.agentRegistry?.getShowActivities?.(channel) ?? 'all';
431
+ return [
432
+ { value: 'all', label: '全部显示', selected: currentMode === 'all' },
433
+ { value: 'dm', label: '仅私聊显示', selected: currentMode === 'dm-only' },
434
+ { value: 'owner', label: '仅 owner 私聊显示', selected: currentMode === 'owner-dm-only' },
435
+ { value: 'none', label: '全部静默', selected: currentMode === 'none' },
436
+ ];
437
+ }
438
+ if (cmd === '/effort') {
439
+ const agent = this.getAgent(channel, session?.agentId);
440
+ const currentModel = hasModelSwitcher(agent) ? agent.getModel() : agent.name;
441
+ const efforts = getAvailableEfforts(agent, currentModel);
442
+ const currentEffort = agent.getEffort?.() || 'auto';
443
+ const allItems = [...efforts, 'auto'];
444
+ return allItems.map(e => ({ value: e, label: e === 'auto' ? 'auto (SDK默认)' : e, selected: e === currentEffort }));
445
+ }
446
+ if (cmd === '/chatmode') {
447
+ // 无活跃会话时,selected 跟随 evolagent.config.chatmode.private 默认值
448
+ let currentMode;
449
+ if (session?.sessionMode) {
450
+ currentMode = session.sessionMode;
451
+ }
452
+ else {
453
+ const evolagent = this.agentRegistry?.resolveByChannel(channel);
454
+ currentMode = evolagent?.config?.chatmode?.private || 'interactive';
455
+ }
456
+ return [
457
+ { value: 'interactive', label: '交互模式', selected: currentMode === 'interactive' },
458
+ { value: 'proactive', label: '主动模式', selected: currentMode === 'proactive' },
459
+ ];
460
+ }
461
+ if (cmd === '/dispatch') {
462
+ const currentMode = session?.metadata?.dispatchModeOverride ?? session?.metadata?.dispatchMode ?? null;
463
+ return [
464
+ { value: 'mention', label: '@提及时响应', selected: currentMode === 'mention' },
465
+ { value: 'broadcast', label: '所有消息响应', selected: currentMode === 'broadcast' },
466
+ ];
467
+ }
468
+ if (cmd === '/perm') {
469
+ const currentMode = session?.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE;
470
+ const permAgent = this.getAgent(channel, session?.agentId);
471
+ const validModes = hasPermissionController(permAgent)
472
+ ? permAgent.listModes().filter(m => m.available).map(m => m.key)
473
+ : [...PERMISSION_MODE_KEYS];
474
+ return validModes.map(m => ({ value: m, label: m, selected: m === currentMode }));
475
+ }
476
+ return null;
477
+ }
478
+ // ── Menu Protocol exec ────────────────────────────────────────────────
479
+ //
480
+ // 三个入口对应 menu.query / menu.update / menu.action:
481
+ // execMenuQuery — 查询某项当前值(无会话时多数 fallback 到 evolagent config)
482
+ // execMenuUpdate — 写入新值(持久化到 session 或 evolagent config)
483
+ // execMenuAction — 触发动词(stop/restart/new/delete/compact/fork/switch/check/upgrade)
484
+ //
485
+ // 所有方法返回 { data } 或 { error, code? }。code 是结构化错误码(NO_ACTIVE_SESSION 等),
486
+ // 客户端可据此决定降级策略。message-bridge 把 code 透传到 menu.response。
487
+ /** menu.query — 查询当前值。 */
488
+ export async function execMenuQuery(cmd, channel, channelId, userId, args, _explicitChatType, fromControlChannel = false) {
489
+ const cmdBase = cmd.trim().split(' ')[0];
490
+ if (!cmdBase)
491
+ return { error: '缺少命令', code: 'MISSING_CMD' };
492
+ const gated = gateControlScope.call(this, { cmdBase, args, channel, fromControlChannel });
493
+ if (gated)
494
+ return gated;
495
+ const { session, evolagent } = await this.loadMenuContext(channel, channelId);
496
+ // ── /agent 查询(只读) ──
497
+ // 控制 channel:验 owners,按 args.aid 查任意 agent;agent channel:强制查自身。
498
+ if (cmdBase === '/agent') {
499
+ if (fromControlChannel) {
500
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
501
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
502
+ }
503
+ return await execAgentQuery(args);
504
+ }
505
+ const selfAid = this.getOwningAgent?.(channel)?.aid;
506
+ if (!selfAid)
507
+ return { error: '当前 channel 无绑定 agent', code: 'FORBIDDEN' };
508
+ return await execAgentQuery({ ...(args ?? {}), aid: selfAid });
509
+ }
510
+ if (cmdBase === '/pwd') {
511
+ const sessPath = session?.projectPath;
512
+ const fallbackPath = evolagent?.config?.projects?.defaultPath;
513
+ const path = sessPath ?? fallbackPath ?? null;
514
+ const name = path ? this.getProjectName(path) : null;
515
+ return { data: { name, path } };
516
+ }
517
+ if (cmdBase === '/session' || cmdBase === '/s') {
518
+ if (!session) {
519
+ return { data: { status: 'no-session' } };
520
+ }
521
+ const sessionKey = this.getQueueKey(session, channel, channelId);
522
+ const sessionAgent = this.getAgent(channel, session.agentId);
523
+ const isProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
524
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
525
+ const health = await this.sessionManager.getHealthStatus(session.id);
526
+ let processingDuration;
527
+ if (isProcessing && session.processingState) {
528
+ const elapsed = Date.now() - parseInt(session.processingState, 10);
529
+ if (!isNaN(elapsed) && elapsed > 0)
530
+ processingDuration = Math.floor(elapsed / 1000);
531
+ }
532
+ let turns = 0;
533
+ if (session.agentSessionId) {
534
+ const fileInfo = this.sessionManager.getSessionFileInfo(session.projectPath, session.agentSessionId, session.agentId);
535
+ turns = fileInfo.turns;
536
+ }
537
+ const data = {
538
+ name: session.name || null,
539
+ agentSessionId: session.agentSessionId || null,
540
+ status: isProcessing ? 'processing' : 'idle',
541
+ createdAt: session.createdAt,
542
+ updatedAt: session.updatedAt,
543
+ };
544
+ if (processingDuration !== undefined)
545
+ data.processingDuration = processingDuration;
546
+ if (queueLength > 0)
547
+ data.queueLength = queueLength;
548
+ if (turns > 0)
549
+ data.turns = turns;
550
+ if (health.lastSuccessTime)
551
+ data.lastSuccess = health.lastSuccessTime;
552
+ if (health.consecutiveErrors)
553
+ data.consecutiveErrors = health.consecutiveErrors;
554
+ if (health.lastError)
555
+ data.lastError = { type: health.lastErrorType || 'unknown', message: health.lastError.substring(0, 100) };
556
+ return { data };
557
+ }
558
+ if (cmdBase === '/topic') {
559
+ const identity = this.sessionManager.resolveIdentity(channel, userId);
560
+ if (!this.canReadTopics(identity.role)) {
561
+ return { error: '无权限查看话题', code: 'FORBIDDEN' };
562
+ }
563
+ const target = (args?.target ?? '').toString().trim();
564
+ if (!target)
565
+ return { error: '缺少 args.target', code: 'MISSING_VALUE' };
566
+ const topic = await this.sessionManager.getThreadSession(channel, channelId, target);
567
+ if (!topic)
568
+ return { error: '话题不存在', code: 'NOT_FOUND' };
569
+ const sessionKey = this.getQueueKey(topic, channel, channelId);
570
+ const sessionAgent = this.getAgent(channel, topic.agentId);
571
+ const isProcessing = this.messageQueue.isProcessing(sessionKey) || sessionAgent.hasActiveStream(sessionKey);
572
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
573
+ const health = await this.sessionManager.getHealthStatus(topic.id);
574
+ let processingDuration;
575
+ if (isProcessing && topic.processingState) {
576
+ const elapsed = Date.now() - parseInt(topic.processingState, 10);
577
+ if (!isNaN(elapsed) && elapsed > 0)
578
+ processingDuration = Math.floor(elapsed / 1000);
579
+ }
580
+ let turns = 0;
581
+ if (topic.agentSessionId) {
582
+ turns = this.sessionManager.getSessionFileInfo(topic.projectPath, topic.agentSessionId, topic.agentId).turns;
583
+ }
584
+ const data = {
585
+ threadId: topic.threadId,
586
+ name: topic.name || null,
587
+ agentSessionId: topic.agentSessionId || null,
588
+ status: isProcessing ? 'processing' : 'idle',
589
+ createdAt: topic.createdAt,
590
+ updatedAt: topic.updatedAt,
591
+ };
592
+ if (processingDuration !== undefined)
593
+ data.processingDuration = processingDuration;
594
+ if (queueLength > 0)
595
+ data.queueLength = queueLength;
596
+ if (turns > 0)
597
+ data.turns = turns;
598
+ if (health.lastSuccessTime)
599
+ data.lastSuccess = health.lastSuccessTime;
600
+ if (health.consecutiveErrors)
601
+ data.consecutiveErrors = health.consecutiveErrors;
602
+ if (health.lastError)
603
+ data.lastError = { type: health.lastErrorType || 'unknown', message: health.lastError.substring(0, 100) };
604
+ return { data };
605
+ }
606
+ if (cmdBase === '/baseagent') {
607
+ const value = session?.agentId ?? evolagent?.config?.active_baseagent ?? null;
608
+ return { data: { baseagent: value } };
609
+ }
610
+ if (cmdBase === '/model') {
611
+ if (session) {
612
+ const agent = this.getAgent(channel, session.agentId);
613
+ if (hasModelSwitcher(agent))
614
+ return { data: { model: agent.getModel() ?? null } };
615
+ }
616
+ const ba = evolagent?.config?.active_baseagent;
617
+ const block = ba && evolagent ? evolagent.config.baseagents?.[ba] : undefined;
618
+ return { data: { model: block?.model ?? null } };
619
+ }
620
+ if (cmdBase === '/effort') {
621
+ if (session) {
622
+ const agent = this.getAgent(channel, session.agentId);
623
+ const e = agent.getEffort?.();
624
+ if (e !== undefined)
625
+ return { data: { effort: e } };
626
+ }
627
+ const ba = evolagent?.config?.active_baseagent;
628
+ const block = ba && evolagent ? evolagent.config.baseagents?.[ba] : undefined;
629
+ const fallbackField = ba === 'codex' ? (block?.effort ?? block?.reasoning) : block?.effort;
630
+ return { data: { effort: fallbackField ?? null } };
631
+ }
632
+ if (cmdBase === '/chatmode') {
633
+ const sessionMode = session?.sessionMode;
634
+ const fallback = evolagent?.config?.chatmode?.private;
635
+ return { data: { mode: sessionMode || fallback || 'interactive' } };
636
+ }
637
+ if (cmdBase === '/dispatch') {
638
+ const chatType = session?.chatType || 'private';
639
+ if (chatType !== 'group') {
640
+ return { error: 'dispatch 仅在群聊会话中有效', code: 'NOT_APPLICABLE' };
641
+ }
642
+ const sessionMode = session?.metadata?.dispatchModeOverride ?? session?.metadata?.dispatchMode;
643
+ const fallback = evolagent?.config?.dispatch;
644
+ return { data: { mode: sessionMode ?? fallback ?? null } };
645
+ }
646
+ if (cmdBase === '/observable') {
647
+ return { data: { observable: evolagent?.getObservable() ?? false } };
648
+ }
649
+ if (cmdBase === '/perm') {
650
+ const need = this.requireSession(session);
651
+ if (need)
652
+ return need;
653
+ const currentMode = session.metadata?.permissionMode ?? DEFAULT_PERMISSION_MODE;
654
+ return { data: { mode: currentMode } };
655
+ }
656
+ if (cmdBase === '/activity') {
657
+ const currentMode = this.agentRegistry?.getShowActivities?.(channel) ?? 'all';
658
+ return { data: { mode: currentMode } };
659
+ }
660
+ if (cmdBase === '/system') {
661
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
662
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
663
+ }
664
+ const owningAgent = this.getOwningAgent(channel);
665
+ const data = {
666
+ agent: owningAgent?.name ?? 'DefaultAgent',
667
+ channel: this.resolveChannelType(channel),
668
+ pid: process.pid,
669
+ node: process.version,
670
+ uptime: Math.floor(process.uptime()),
671
+ };
672
+ try {
673
+ const pkgPath = path.join(getPackageRoot(), 'package.json');
674
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
675
+ if (pkg?.version)
676
+ data.version = pkg.version;
677
+ }
678
+ catch { }
679
+ try {
680
+ const fp = path.join(getPackageRoot(), 'node_modules', '@agentunion', 'fastaun', 'package.json');
681
+ const fp2 = JSON.parse(fs.readFileSync(fp, 'utf-8'));
682
+ if (fp2?.version)
683
+ data.fastaunVersion = fp2.version;
684
+ }
685
+ catch { }
686
+ const channels = owningAgent?.channelInstanceNames?.() ?? [];
687
+ if (channels.length)
688
+ data.channels = channels;
689
+ return { data };
690
+ }
691
+ // ── name=file:文件元信息(§5.1) ──
692
+ // 权限(§6.3):agent owner/admin 或 aid channel owner。项目外文件仅 owner 可取(§6.2,由 resolveMenuFilePath 校验)。
693
+ if (cmdBase === '/file') {
694
+ const role = (this.sessionManager.resolveIdentity(channel, userId)).role;
695
+ if (role !== 'owner' && role !== 'admin') {
696
+ return { error: '无权限', code: 'NO_PERMISSION' };
697
+ }
698
+ const resolved = resolveMenuFilePath(args?.path, session, role);
699
+ if ('error' in resolved)
700
+ return resolved;
701
+ const { realPath, stat } = resolved;
702
+ // sha256 仅对 ≤ 2 MB 文件计算,超过返回 null,客户端降级到 size+mtime(§4、§7 决策 2)
703
+ let sha256 = null;
704
+ if (stat.size <= FILE_HASH_MAX_SIZE) {
705
+ try {
706
+ sha256 = crypto.createHash('sha256').update(fs.readFileSync(realPath)).digest('hex');
707
+ }
708
+ catch {
709
+ sha256 = null;
710
+ }
711
+ }
712
+ return {
713
+ data: {
714
+ path: (args?.path ?? '').toString(),
715
+ sha256,
716
+ size: stat.size,
717
+ mtime: stat.mtimeMs,
718
+ },
719
+ };
720
+ }
721
+ return { error: `不支持 query: ${cmdBase}`, code: 'NOT_SUPPORTED' };
722
+ }
723
+ /** menu.update — 写入新值。 */
724
+ export async function execMenuUpdate(cmd, value, channel, channelId, userId, overrideIdentity, fromControlChannel = false, args) {
725
+ const cmdBase = cmd.trim().split(' ')[0];
726
+ if (!cmdBase)
727
+ return { error: '缺少命令', code: 'MISSING_CMD' };
728
+ const gated = gateControlScope.call(this, { cmdBase, channel, fromControlChannel });
729
+ if (gated)
730
+ return gated;
731
+ const arg = value.trim();
732
+ if (!arg)
733
+ return { error: '缺少 value 参数', code: 'MISSING_VALUE' };
734
+ const { session, evolagent } = await this.loadMenuContext(channel, channelId);
735
+ const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
736
+ const isAdmin = identity.role === 'owner' || identity.role === 'admin';
737
+ // ── 关系级 /trigger update(调度参数,value 为 JSON 字符串) ──
738
+ if (cmdBase === '/trigger') {
739
+ const owningAgent = this.getOwningAgent(channel);
740
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
741
+ const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
742
+ if (!manager || !scheduler)
743
+ return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
744
+ let patch;
745
+ try {
746
+ patch = JSON.parse(arg);
747
+ }
748
+ catch {
749
+ return { error: 'value 需为 JSON', code: 'INVALID_ARGS' };
750
+ }
751
+ if (!patch?.nameOrId)
752
+ return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
753
+ const isAdmin = identity.role === 'owner' || identity.role === 'admin';
754
+ if (!isAdmin && !userId)
755
+ return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
756
+ const trigger = isAdmin
757
+ ? (manager.getByName(patch.nameOrId) ?? manager.getById(patch.nameOrId))
758
+ : (manager.getByNameScoped(patch.nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(patch.nameOrId, userId ?? '', channel));
759
+ if (!trigger)
760
+ return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
761
+ const fields = {};
762
+ if (patch.scheduleType !== undefined)
763
+ fields.scheduleType = patch.scheduleType;
764
+ if (patch.scheduleValue !== undefined)
765
+ fields.scheduleValue = String(patch.scheduleValue);
766
+ if (patch.prompt !== undefined)
767
+ fields.prompt = String(patch.prompt);
768
+ // 调度参数变化时重算 nextFireAt——先校验避免 NaN 污染 scheduler heap
769
+ if (fields.scheduleType !== undefined || fields.scheduleValue !== undefined) {
770
+ const effType = fields.scheduleType ?? trigger.scheduleType;
771
+ const effValue = fields.scheduleValue ?? trigger.scheduleValue;
772
+ const schedErr = validateScheduleParams(effType, effValue);
773
+ if (schedErr)
774
+ return { error: schedErr, code: 'INVALID_ARGS' };
775
+ fields.nextFireAt = calcNextFireAt(effType, effValue, Date.now());
776
+ }
777
+ let updated;
778
+ try {
779
+ updated = manager.update(trigger.id, fields);
780
+ }
781
+ catch (err) {
782
+ return { error: `更新失败:${err?.message || err}`, code: 'INVALID_ARGS' };
783
+ }
784
+ scheduler.update(updated);
785
+ return { data: { id: updated.id, nextFireAt: updated.nextFireAt } };
786
+ }
787
+ if (cmdBase === '/baseagent') {
788
+ if (!isAdmin)
789
+ return { error: '无权限', code: 'NO_PERMISSION' };
790
+ // scope 决定写入层级:
791
+ // 'session' — 仅切当前会话 runner(等价 /baseagent slash 命令),不改 agent 默认值
792
+ // 'default' — 仅改 agent 默认值(影响后续新会话),不触碰当前会话
793
+ // 'both'(默认,向后兼容)— 先切当前会话,成功后再写 agent 默认值
794
+ const scope = (args?.scope === 'session' || args?.scope === 'default') ? args.scope : 'both';
795
+ if (scope === 'default' && !evolagent) {
796
+ return { error: '当前 channel 无绑定 agent,无法设置默认 baseagent', code: 'EXEC_FAILED' };
797
+ }
798
+ const valid = this.getAvailableBaseagents(channel);
799
+ if (valid.length && !valid.includes(arg)) {
800
+ return { error: `无效 baseagent: ${arg},可选: ${valid.join(' / ')}`, code: 'INVALID_VALUE' };
801
+ }
802
+ // 当前会话切换走 slash 命令的完整逻辑(涉及 runner 状态、session.agentId 重新挂载等)
803
+ // 仅在 slash 命令成功后才持久化到 evolagent config,避免失败时配置已落盘
804
+ if (scope !== 'default' && session && session.agentId !== arg) {
805
+ const result = await this._handleInternal(`/baseagent ${arg}`, channel, channelId, undefined, userId);
806
+ const payload = result;
807
+ if (payload?.kind === 'command.error') {
808
+ return { error: payload.text || '切换失败', code: 'EXEC_FAILED' };
809
+ }
810
+ }
811
+ // 持久化到 evolagent config(影响后续新会话)
812
+ if (scope !== 'session' && evolagent)
813
+ evolagent.setActiveBaseagent(arg);
814
+ return { data: { baseagent: arg, scope } };
815
+ }
816
+ if (cmdBase === '/model') {
817
+ if (!isAdmin)
818
+ return { error: '无权限', code: 'NO_PERMISSION' };
819
+ const agent = this.getAgent(channel, session?.agentId);
820
+ if (hasModelSwitcher(agent)) {
821
+ const models = (await agent.listModels?.()) ?? [];
822
+ if (models.length && !models.includes(arg)) {
823
+ return { error: `无效模型: ${arg}`, code: 'INVALID_VALUE' };
824
+ }
825
+ agent.setModel(arg);
826
+ }
827
+ if (evolagent)
828
+ evolagent.setBaseagentModel(arg);
829
+ return { data: { model: arg } };
830
+ }
831
+ if (cmdBase === '/effort') {
832
+ if (!isAdmin)
833
+ return { error: '无权限', code: 'NO_PERMISSION' };
834
+ const agent = this.getAgent(channel, session?.agentId);
835
+ const currentModel = hasModelSwitcher(agent) ? agent.getModel() : agent.name;
836
+ const validEfforts = getAvailableEfforts(agent, currentModel);
837
+ const allValid = [...validEfforts, 'auto'];
838
+ if (!allValid.includes(arg)) {
839
+ return { error: `无效推理强度: ${arg},可选: ${allValid.join(' / ')}`, code: 'INVALID_VALUE' };
840
+ }
841
+ if (typeof agent.setEffort === 'function') {
842
+ agent.setEffort(arg === 'auto' ? undefined : arg);
843
+ }
844
+ if (evolagent)
845
+ evolagent.setBaseagentEffort(arg === 'auto' ? undefined : arg);
846
+ return { data: { effort: arg } };
847
+ }
848
+ if (cmdBase === '/chatmode') {
849
+ if (!isAdmin)
850
+ return { error: '无权限', code: 'NO_PERMISSION' };
851
+ if (arg !== 'interactive' && arg !== 'proactive') {
852
+ return { error: `无效模式: ${arg}`, code: 'INVALID_VALUE' };
853
+ }
854
+ if (session) {
855
+ await this.sessionManager.updateSession(session.id, { sessionMode: arg });
856
+ this.eventBus.publish({ type: 'session:chat-mode-changed', sessionId: session.id, mode: arg, timestamp: Date.now() });
857
+ }
858
+ else {
859
+ if (evolagent)
860
+ evolagent.setChatmodePrivate(arg);
861
+ }
862
+ return { data: { mode: arg } };
863
+ }
864
+ if (cmdBase === '/dispatch') {
865
+ if (!isAdmin)
866
+ return { error: '无权限', code: 'NO_PERMISSION' };
867
+ if (arg !== 'mention' && arg !== 'broadcast' && arg !== 'clear') {
868
+ return { error: `无效模式: ${arg}`, code: 'INVALID_VALUE' };
869
+ }
870
+ const chatType = session?.chatType;
871
+ if (!session || chatType !== 'group') {
872
+ return { error: 'dispatch 仅在群聊会话中有效', code: 'NOT_APPLICABLE' };
873
+ }
874
+ if (arg === 'clear') {
875
+ const { dispatchModeOverride: _, ...rest } = session.metadata || {};
876
+ await this.sessionManager.updateSession(session.id, { metadata: rest });
877
+ this.eventBus.publish({ type: 'session:dispatch-mode-changed', sessionId: session.id, mode: undefined, timestamp: Date.now() });
878
+ return { data: { mode: null } };
879
+ }
880
+ const metadata = { ...(session.metadata || {}), dispatchModeOverride: arg };
881
+ await this.sessionManager.updateSession(session.id, { metadata });
882
+ this.eventBus.publish({ type: 'session:dispatch-mode-changed', sessionId: session.id, mode: arg, timestamp: Date.now() });
883
+ return { data: { mode: arg } };
884
+ }
885
+ if (cmdBase === '/perm') {
886
+ const need = this.requireSession(session);
887
+ if (need)
888
+ return need;
889
+ if (!isAdmin)
890
+ return { error: '无权限', code: 'NO_PERMISSION' };
891
+ const permAgent = this.getAgent(channel, session.agentId);
892
+ const validModes = hasPermissionController(permAgent)
893
+ ? permAgent.listModes().filter(m => m.available).map(m => m.key)
894
+ : [...PERMISSION_MODE_KEYS];
895
+ if (!validModes.includes(arg))
896
+ return { error: `无效模式: ${arg}`, code: 'INVALID_VALUE' };
897
+ const metadata = { ...(session.metadata || {}), permissionMode: arg };
898
+ await this.sessionManager.updateSession(session.id, { metadata });
899
+ return { data: { mode: arg } };
900
+ }
901
+ if (cmdBase === '/activity') {
902
+ const modeMap = { all: 'all', dm: 'dm-only', owner: 'owner-dm-only', none: 'none' };
903
+ const newMode = modeMap[arg];
904
+ if (!newMode)
905
+ return { error: `无效模式: ${arg},可选: all / dm / owner / none`, code: 'INVALID_VALUE' };
906
+ if (identity.role !== 'owner')
907
+ return { error: '中间输出模式切换仅限 owner', code: 'NO_PERMISSION' };
908
+ if (!this.agentRegistry?.setShowActivities)
909
+ return { error: '找不到通道所属 agent,无法持久化', code: 'EXEC_FAILED' };
910
+ this.agentRegistry.setShowActivities(channel, newMode);
911
+ return { data: { mode: newMode } };
912
+ }
913
+ if (cmdBase === '/observable') {
914
+ if (identity.role !== 'owner')
915
+ return { error: '观察者模式仅限 owner 开关', code: 'NO_PERMISSION' };
916
+ if (arg !== 'true' && arg !== 'false')
917
+ return { error: `无效值: ${arg},可选: true / false`, code: 'INVALID_VALUE' };
918
+ if (!evolagent)
919
+ return { error: '找不到通道所属 agent,无法持久化', code: 'EXEC_FAILED' };
920
+ evolagent.setObservable(arg === 'true');
921
+ return { data: { observable: arg === 'true' } };
922
+ }
923
+ return { error: `不支持 update: ${cmdBase}`, code: 'NOT_SUPPORTED' };
924
+ }
925
+ /** menu.action — 触发动词。 */
926
+ export async function execMenuAction(cmd, action, args, channel, channelId, userId, overrideIdentity, explicitChatType, requestId, fromControlChannel = false) {
927
+ const cmdBase = cmd.trim().split(' ')[0];
928
+ if (!cmdBase)
929
+ return { error: '缺少命令', code: 'MISSING_CMD' };
930
+ if (!action)
931
+ return { error: '缺少 action', code: 'MISSING_VALUE' };
932
+ const gated = gateControlScope.call(this, { cmdBase, action, args, channel, fromControlChannel });
933
+ if (gated)
934
+ return gated;
935
+ const { session } = await this.loadMenuContext(channel, channelId);
936
+ const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId);
937
+ const isMenuAdmin = identity.role === 'owner' || identity.role === 'admin';
938
+ // ── /agent action ──
939
+ // 控制 channel:验 evolclaw.owners,可执行进程级(create/delete/enable/disable)+ 自管理(update/reload)。
940
+ // agent channel:闸门已挡掉进程级 + 跨 agent,仅 update/reload 能到此;reload 允许 owner/admin,update 仅 owner。
941
+ if (cmdBase === '/agent') {
942
+ if (fromControlChannel) {
943
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
944
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
945
+ }
946
+ }
947
+ else {
948
+ // 本 agent 自管理:reload 允许 owner/admin;update 会改鉴权相关元配置,仍仅 owner。
949
+ const isAgentOwner = identity.role === 'owner';
950
+ const isAgentAdmin = identity.role === 'owner' || identity.role === 'admin';
951
+ if (action === 'update' ? !isAgentOwner : !isAgentAdmin) {
952
+ return { error: action === 'update' ? '本 agent 配置更新仅 owner 可执行' : '本 agent 自管理操作仅 owner/admin 可执行', code: 'FORBIDDEN' };
953
+ }
954
+ const selfAid = this.getOwningAgent?.(channel)?.aid;
955
+ if (!selfAid)
956
+ return { error: '当前 channel 无绑定 agent', code: 'FORBIDDEN' };
957
+ args = { ...(args ?? {}), aid: selfAid };
958
+ }
959
+ const a = { ...(args ?? {}) };
960
+ if (action === 'create') {
961
+ a.project = resolveProjectPath(a.project, a.aid ?? '', loadDefaults());
962
+ }
963
+ // queue-clear:清空指定 agent 的待处理消息(不影响处理中),直接走 messageQueue。
964
+ if (action === 'queue-clear') {
965
+ if (!a.aid)
966
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
967
+ const handle = this.agentRegistry?.get(a.aid) ?? null;
968
+ const agentName = handle?.name;
969
+ if (!agentName)
970
+ return { error: `未找到 Agent: ${a.aid}`, code: 'NOT_FOUND' };
971
+ const cleared = this.messageQueue.clearByAgent(agentName);
972
+ return { data: { cleared } };
973
+ }
974
+ // mute / unmute:禁言/解禁。禁言后消息照常入队但不消费,解禁后恢复。
975
+ if (action === 'mute' || action === 'unmute') {
976
+ if (!a.aid)
977
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
978
+ const handle = this.agentRegistry?.get(a.aid) ?? null;
979
+ const agentName = handle?.name;
980
+ if (!agentName)
981
+ return { error: `未找到 Agent: ${a.aid}`, code: 'NOT_FOUND' };
982
+ if (action === 'mute')
983
+ this.messageQueue.muteAgent(agentName);
984
+ else
985
+ this.messageQueue.unmuteAgent(agentName);
986
+ return { data: { aid: a.aid, muted: action === 'mute' } };
987
+ }
988
+ // start / stop:运行时连/断渠道,不改 config.enabled。
989
+ if (action === 'start' || action === 'stop') {
990
+ if (!a.aid)
991
+ return { error: '缺少 aid', code: 'INVALID_ARGS' };
992
+ const hooks = globalThis.__evolclaw_reloadHooks;
993
+ if (!hooks)
994
+ return { error: 'Reload hooks 未初始化', code: 'INTERNAL' };
995
+ try {
996
+ if (action === 'stop') {
997
+ if (!this.agentRegistry?.stopAgent)
998
+ return { error: 'stopAgent 不可用', code: 'INTERNAL' };
999
+ await this.agentRegistry.stopAgent(a.aid, hooks);
1000
+ // 中断该 agent 正在执行的大模型调用
1001
+ const handle = this.agentRegistry.get(a.aid);
1002
+ if (handle)
1003
+ this.messageQueue.interruptByAgent(handle.name);
1004
+ }
1005
+ else {
1006
+ if (!this.agentRegistry?.startAgent)
1007
+ return { error: 'startAgent 不可用', code: 'INTERNAL' };
1008
+ await this.agentRegistry.startAgent(a.aid, hooks);
1009
+ }
1010
+ return { data: { aid: a.aid, action } };
1011
+ }
1012
+ catch (e) {
1013
+ return { error: e?.message || String(e), code: 'INTERNAL' };
1014
+ }
1015
+ }
1016
+ // reload / disable / delete 会中断 agent 正在处理的任务,执行前检查是否繁忙。
1017
+ // 队列按 agent 名计数,故先用 registry 把 aid 解析成 name;force 跳过。
1018
+ if ((action === 'reload' || action === 'disable' || action === 'delete') && a.aid && !a.force) {
1019
+ const handle = this.agentRegistry?.get(a.aid) ?? null;
1020
+ const agentName = handle?.name;
1021
+ if (agentName) {
1022
+ const busy = this.messageQueue.getProcessingCountByAgent(agentName)
1023
+ + this.messageQueue.getQueueLengthByAgent(agentName);
1024
+ if (busy > 0) {
1025
+ return { error: `该 Agent 有 ${busy} 个任务执行中`, code: 'BUSY' };
1026
+ }
1027
+ }
1028
+ }
1029
+ return await execAgentAction(action, a, userId ?? '');
1030
+ }
1031
+ // ── 关系级 /trigger(不走 owners;复用 isAdmin + scoped 逻辑,D4 直调底层) ──
1032
+ if (cmdBase === '/trigger') {
1033
+ const role = identity.role;
1034
+ const isAdmin = role === 'owner' || role === 'admin';
1035
+ const owningAgent = this.getOwningAgent(channel);
1036
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
1037
+ const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
1038
+ if (!manager || !scheduler)
1039
+ return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
1040
+ if (action === 'set') {
1041
+ // args 结构化 → 直接组装 ParsedTriggerSet(绕过 parseTriggerSet 文本解析,无注入风险)
1042
+ if (!args?.scheduleType || !args?.scheduleValue || !args?.prompt) {
1043
+ return { error: '缺少必填参数:scheduleType / scheduleValue / prompt', code: 'INVALID_ARGS' };
1044
+ }
1045
+ // menu 路径绕过了 parseTriggerSet 的校验,必须自行校验枚举/数值,
1046
+ // 否则非法值会传到 calcNextFireAt 产出 NaN nextFireAt,污染 scheduler heap。
1047
+ const schedErr = validateScheduleParams(args.scheduleType, String(args.scheduleValue));
1048
+ if (schedErr)
1049
+ return { error: schedErr, code: 'INVALID_ARGS' };
1050
+ const strategy = args.targetSessionStrategy ?? 'latest';
1051
+ if (!['latest', 'current', 'thread'].includes(strategy)) {
1052
+ return { error: `无效 targetSessionStrategy: ${strategy}`, code: 'INVALID_ARGS' };
1053
+ }
1054
+ const parsed = {
1055
+ scheduleType: args.scheduleType,
1056
+ scheduleValue: String(args.scheduleValue),
1057
+ prompt: String(args.prompt),
1058
+ name: args.name,
1059
+ targetChannel: args.targetChannel,
1060
+ targetChannelId: args.targetChannelId,
1061
+ targetThreadId: args.targetThreadId,
1062
+ targetSessionStrategy: strategy,
1063
+ agentId: args.agentId,
1064
+ };
1065
+ const r = await this.registerTriggerFromParsed(parsed, channel, channelId, userId ?? '', undefined);
1066
+ if (!r.ok)
1067
+ return { error: r.error, code: /已存在|exists|重复/.test(r.error) ? 'CONFLICT' : 'INVALID_ARGS' };
1068
+ return { data: { id: r.trigger.id, name: r.trigger.name, nextFireAt: r.trigger.nextFireAt } };
1069
+ }
1070
+ if (action === 'cancel') {
1071
+ const nameOrId = args?.nameOrId;
1072
+ if (!nameOrId)
1073
+ return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
1074
+ if (!isAdmin && !userId)
1075
+ return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
1076
+ const trigger = isAdmin
1077
+ ? (manager.getByName(nameOrId) ?? manager.getById(nameOrId))
1078
+ : (manager.getByNameScoped(nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(nameOrId, userId ?? '', channel));
1079
+ if (!trigger)
1080
+ return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
1081
+ manager.moveToDone(trigger.id, 'cancelled');
1082
+ scheduler.cancel(trigger.id);
1083
+ this.eventBus.publish({ type: 'trigger:cancelled', triggerId: trigger.id, name: trigger.name, by: userId ?? '' });
1084
+ return { data: { id: trigger.id, cancelled: true } };
1085
+ }
1086
+ return { error: `不支持的 trigger action: ${action}`, code: 'INVALID_ARGS' };
1087
+ }
1088
+ if (cmdBase === '/topic') {
1089
+ if (action !== 'delete' && action !== 'rename') {
1090
+ return { error: `不支持的 topic action: ${action}`, code: 'NOT_SUPPORTED' };
1091
+ }
1092
+ const target = (args?.target ?? '').toString().trim();
1093
+ if (!target)
1094
+ return { error: '缺少 args.target', code: 'MISSING_VALUE' };
1095
+ const renameName = action === 'rename' ? getRenameName(args) : '';
1096
+ if (action === 'rename' && !renameName)
1097
+ return { error: '缺少 args.name', code: 'MISSING_VALUE' };
1098
+ const topic = await this.sessionManager.getThreadSession(channel, channelId, target);
1099
+ if (!topic)
1100
+ return { error: '话题不存在', code: 'NOT_FOUND' };
1101
+ const chatType = topic.chatType === 'group'
1102
+ ? 'group'
1103
+ : topic.chatType === 'private'
1104
+ ? 'private'
1105
+ : this.resolveMenuChatType(channel, channelId, explicitChatType);
1106
+ if (!this.canDeleteTopic(identity.role, chatType, topic, userId)) {
1107
+ return { error: action === 'rename' ? '无权限重命名话题' : '无权限删除话题', code: 'FORBIDDEN' };
1108
+ }
1109
+ if (action === 'rename') {
1110
+ const newName = renameName;
1111
+ const existing = await this.sessionManager.getSessionByName?.(channel, channelId, newName);
1112
+ if (existing && existing.id !== topic.id) {
1113
+ return { error: `名称 "${newName}" 已存在`, code: 'CONFLICT' };
1114
+ }
1115
+ const oldName = displaySessionTitle(topic.name, topic.threadId || '(未命名)');
1116
+ const success = await this.sessionManager.renameSession(topic.id, newName);
1117
+ if (!success)
1118
+ return { error: '重命名失败', code: 'EXEC_FAILED' };
1119
+ if (topic.agentSessionId) {
1120
+ try {
1121
+ const targetAgent = this.getAgent(channel, topic.agentId);
1122
+ await targetAgent.setSessionName?.(topic.agentSessionId, newName);
1123
+ }
1124
+ catch { }
1125
+ }
1126
+ this.eventBus.publish({ type: 'session:renamed', sessionId: topic.id, oldName, newName });
1127
+ return {
1128
+ data: {
1129
+ action: 'rename',
1130
+ success: true,
1131
+ topic: {
1132
+ ...buildSessionPayload(topic, newName),
1133
+ threadId: topic.threadId,
1134
+ },
1135
+ },
1136
+ };
1137
+ }
1138
+ const success = await this.sessionManager.unbindSession(topic.id);
1139
+ if (!success)
1140
+ return { error: '删除失败', code: 'DELETE_FAILED' };
1141
+ this.eventBus.publish({ type: 'session:deleted', sessionId: topic.id });
1142
+ const targetAgent = this.getAgent(channel, topic.agentId);
1143
+ await targetAgent.closeSession?.(topic.id);
1144
+ return { data: { deleted: true } };
1145
+ }
1146
+ // ── /session 系列 ──
1147
+ if (cmdBase === '/session' || cmdBase === '/s') {
1148
+ if (action === 'stop') {
1149
+ if (!session)
1150
+ return { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
1151
+ const sessionKey = this.getQueueKey(session, channel, channelId);
1152
+ const sessionAgent = this.getAgent(channel, session.agentId);
1153
+ const hasActive = sessionAgent.hasActiveStream(sessionKey);
1154
+ const queueLength = this.messageQueue.getQueueLength(sessionKey);
1155
+ if (queueLength === 0 && !hasActive) {
1156
+ return { error: '当前没有正在处理的任务', code: 'NO_ACTIVE_TASK' };
1157
+ }
1158
+ await sessionAgent.interrupt(sessionKey);
1159
+ this.eventBus.publish({
1160
+ type: 'task:interrupted',
1161
+ sessionId: sessionKey,
1162
+ reason: 'stop',
1163
+ agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',
1164
+ });
1165
+ this.sessionManager.clearProcessing(sessionKey);
1166
+ return { data: { action: 'stop', success: true } };
1167
+ }
1168
+ if (action === 'new') {
1169
+ const name = (args?.name ?? '').toString().trim();
1170
+ return await this.delegateAsAction(action, name ? `/new ${name}` : '/new', channel, channelId, userId, { enrichSession: true });
1171
+ }
1172
+ if (action === 'rename') {
1173
+ const newName = getRenameName(args);
1174
+ if (!newName)
1175
+ return { error: '缺少 args.name', code: 'MISSING_VALUE' };
1176
+ const target = (args?.target ?? '').toString().trim();
1177
+ const targetSession = await findMainSessionTarget(this.sessionManager, channel, channelId, target, session);
1178
+ if (!targetSession) {
1179
+ return target
1180
+ ? { error: `会话不存在: ${target}`, code: 'NOT_FOUND' }
1181
+ : { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
1182
+ }
1183
+ const targetChatType = targetSession.chatType === 'group'
1184
+ ? 'group'
1185
+ : targetSession.chatType === 'private'
1186
+ ? 'private'
1187
+ : this.resolveMenuChatType(channel, channelId, explicitChatType);
1188
+ if (targetChatType === 'group' && !isMenuAdmin) {
1189
+ return { error: '无权限:群聊中仅管理员可重命名会话', code: 'NO_PERMISSION' };
1190
+ }
1191
+ const existing = await this.sessionManager.getSessionByName?.(channel, channelId, newName);
1192
+ if (existing && existing.id !== targetSession.id) {
1193
+ return { error: `名称 "${newName}" 已存在`, code: 'CONFLICT' };
1194
+ }
1195
+ const oldName = displaySessionTitle(targetSession.name, '(未命名)');
1196
+ const success = await this.sessionManager.renameSession(targetSession.id, newName);
1197
+ if (!success)
1198
+ return { error: '重命名失败', code: 'EXEC_FAILED' };
1199
+ if (targetSession.agentSessionId) {
1200
+ try {
1201
+ const targetAgent = this.getAgent(channel, targetSession.agentId);
1202
+ await targetAgent.setSessionName?.(targetSession.agentSessionId, newName);
1203
+ }
1204
+ catch { }
1205
+ }
1206
+ this.eventBus.publish({ type: 'session:renamed', sessionId: targetSession.id, oldName, newName });
1207
+ return { data: { action: 'rename', success: true, session: buildSessionPayload(targetSession, newName) } };
1208
+ }
1209
+ if (action === 'delete') {
1210
+ const target = (args?.target ?? '').toString().trim();
1211
+ if (!target)
1212
+ return { error: '缺少 args.target', code: 'MISSING_VALUE' };
1213
+ return await this.delegateAsAction(action, `/del ${target}`, channel, channelId, userId);
1214
+ }
1215
+ if (action === 'switch') {
1216
+ const target = (args?.target ?? '').toString().trim();
1217
+ if (!target)
1218
+ return { error: '缺少 args.target', code: 'MISSING_VALUE' };
1219
+ return await this.delegateAsAction(action, `/s ${target}`, channel, channelId, userId, { enrichSession: true });
1220
+ }
1221
+ if (action === 'compact') {
1222
+ const need = this.requireSession(session);
1223
+ if (need)
1224
+ return need;
1225
+ return await this.delegateAsAction(action, '/compact', channel, channelId, userId);
1226
+ }
1227
+ if (action === 'fork') {
1228
+ const need = this.requireSession(session);
1229
+ if (need)
1230
+ return need;
1231
+ const name = (args?.name ?? '').toString().trim();
1232
+ return await this.delegateAsAction(action, name ? `/fork ${name}` : '/fork', channel, channelId, userId, { enrichSession: true });
1233
+ }
1234
+ return { error: `不支持的 session action: ${action}`, code: 'NOT_SUPPORTED' };
1235
+ }
1236
+ // ── /system 系列 ──
1237
+ if (cmdBase === '/system') {
1238
+ // D1 迁移:进程级鉴权统一查 evolclaw.json owners,替代各 action 内联的 identity.role 判断
1239
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
1240
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
1241
+ }
1242
+ if (action === 'restart') {
1243
+ const restartInfo = { channel, channelId, timestamp: Date.now() };
1244
+ fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
1245
+ const { spawn } = await import('child_process');
1246
+ spawn('node', [path.join(getPackageRoot(), 'dist', 'cli', 'index.js'), 'restart-monitor'], {
1247
+ detached: true,
1248
+ stdio: 'ignore',
1249
+ env: { ...process.env, EVOLCLAW_HOME: resolvePaths().root }
1250
+ }).unref();
1251
+ this.eventBus.publish({ type: 'system:restart', channel, channelId });
1252
+ setTimeout(() => { process.kill(process.pid, 'SIGTERM'); }, 1000);
1253
+ return { data: { action: 'restart', success: true } };
1254
+ }
1255
+ if (action === 'check') {
1256
+ const r = await this.delegateAsAction(action, '/check', channel, channelId, userId, { overrideIdentity });
1257
+ const structured = r.data?.structured ?? null;
1258
+ if (structured)
1259
+ return { data: { ...r.data, ...structured } };
1260
+ return r;
1261
+ }
1262
+ if (action === 'upgrade') {
1263
+ const devMode = isLinkedInstall();
1264
+ const localEvolclaw = getLocalVersion();
1265
+ // fastaun 本地版本:从 node_modules 读取(与 menu.query name=system 一致)
1266
+ let localFastaun = null;
1267
+ try {
1268
+ const fp = path.join(getPackageRoot(), 'node_modules', '@agentunion', 'fastaun', 'package.json');
1269
+ localFastaun = JSON.parse(fs.readFileSync(fp, 'utf-8'))?.version ?? null;
1270
+ }
1271
+ catch { }
1272
+ const [evolclawRemote, fastaunRemote, ecwebRemote] = await Promise.all([
1273
+ checkLatestVersion('evolclaw'),
1274
+ checkLatestVersion('@agentunion/fastaun'),
1275
+ checkLatestVersion('evolclaw-web'),
1276
+ ]);
1277
+ const cmp = (local, remote) => !!(local && remote && compareVersions(local, remote) < 0);
1278
+ return {
1279
+ data: {
1280
+ devMode,
1281
+ evolclaw: { local: localEvolclaw, remote: evolclawRemote, hasUpdate: cmp(localEvolclaw, evolclawRemote) },
1282
+ fastaun: { local: localFastaun, remote: fastaunRemote, hasUpdate: cmp(localFastaun, fastaunRemote) },
1283
+ // ecweb 本地版本由 ECWeb 进程自身注入(data.ecwebVersion),此处仅给 remote
1284
+ ecweb: { remote: ecwebRemote },
1285
+ },
1286
+ };
1287
+ }
1288
+ return { error: `不支持的 system action: ${action}`, code: 'NOT_SUPPORTED' };
1289
+ }
1290
+ // ── /cli 透传 ──
1291
+ if (cmdBase === '/cli') {
1292
+ if (action !== 'exec')
1293
+ return { error: `不支持的 cli action: ${action}`, code: 'NOT_SUPPORTED' };
1294
+ if (identity.role !== 'owner')
1295
+ return { error: '无权限:CLI 执行仅限 owner', code: 'NO_PERMISSION' };
1296
+ const argv = Array.isArray(args?.argv) ? args.argv.map((x) => String(x))
1297
+ : typeof args?.command === 'string' ? tokenizeArgv(args.command)
1298
+ : null;
1299
+ if (!argv || argv.length === 0)
1300
+ return { error: '缺少 argv 或 command', code: 'MISSING_VALUE' };
1301
+ const allowed = CLI_EXEC_WHITELIST[argv[0]];
1302
+ if (!allowed)
1303
+ return { error: `命令不在白名单: ${argv[0]}`, code: 'NOT_ALLOWED' };
1304
+ if (allowed !== '*' && !allowed.has(argv[1] ?? '')) {
1305
+ return { error: `子命令不在白名单: ${argv[0]} ${argv[1] ?? ''}`, code: 'NOT_ALLOWED' };
1306
+ }
1307
+ return await this.execCliPassthrough(argv);
1308
+ }
1309
+ // ── name=file action=fetch:拉取文件(§5.2) ──
1310
+ // 内部等价于 /file <path>:复用 resolveMenuFilePath 校验链 + adapter.send(result.file)。
1311
+ // 文件作为独立 result.file 消息异步发回;把请求 id 作为 correlationId 透传,
1312
+ // 客户端用它把异步到达的文件消息对回这次 fetch 点击(§7.1)。
1313
+ if (cmdBase === '/file') {
1314
+ if (action !== 'fetch')
1315
+ return { error: `不支持的 file action: ${action}`, code: 'NOT_SUPPORTED' };
1316
+ // 权限(§6.3):agent owner/admin 或 aid channel owner。项目外文件仅 owner(§6.2,由 resolveMenuFilePath 校验)。
1317
+ if (identity.role !== 'owner' && identity.role !== 'admin') {
1318
+ return { error: '无权限', code: 'NO_PERMISSION' };
1319
+ }
1320
+ const resolved = resolveMenuFilePath(args?.path, session, identity.role);
1321
+ if ('error' in resolved)
1322
+ return resolved;
1323
+ const { realPath, stat } = resolved;
1324
+ if (stat.size > FILE_FETCH_MAX_SIZE) {
1325
+ return { error: `文件过大: ${(stat.size / 1024 / 1024).toFixed(1)} MB (限制 ${FILE_FETCH_MAX_SIZE / 1024 / 1024} MB)`, code: 'FILE_TOO_LARGE' };
1326
+ }
1327
+ const adapter = this.adapters.get(channel);
1328
+ if (!adapter)
1329
+ return { error: '通道不存在', code: 'EXEC_FAILED' };
1330
+ if (!adapter.capabilities?.file)
1331
+ return { error: '通道不支持文件发送', code: 'NOT_SUPPORTED' };
1332
+ try {
1333
+ const replyCtx = session ? this.getReplyContext(session) : undefined;
1334
+ await adapter.send(buildEnvelope({ channel: adapter.channelName, channelId, replyContext: replyCtx }), { kind: 'result.file', filePath: realPath, correlationId: requestId });
1335
+ return { data: { action: 'fetch', success: true, size: stat.size } };
1336
+ }
1337
+ catch (e) {
1338
+ return { error: `文件发送失败: ${e?.message ?? e}`, code: 'EXEC_FAILED' };
1339
+ }
1340
+ }
1341
+ return { error: `不支持 action: ${cmdBase}`, code: 'NOT_SUPPORTED' };
1342
+ }
1343
+ /** ECWeb 专用入口:注入 owner identity,进程级操作检查 owners 非空。不暴露 cli。 */
1344
+ export async function execMenuForEcweb(payload) {
1345
+ const id = payload?.id ?? '';
1346
+ const name = payload?.name;
1347
+ if (name === 'cli' || payload?.cmd === '/cli') {
1348
+ return { type: 'menu.response', id, name, error: { code: 'NOT_SUPPORTED', message: 'cli 不在 ECWeb 控制范围' } };
1349
+ }
1350
+ const isProcessLevel = name === 'system' || name === 'agent';
1351
+ const owners = loadEvolclawConfig().owners ?? [];
1352
+ if (isProcessLevel && owners.length === 0) {
1353
+ return { type: 'menu.response', id, name, error: { code: 'FORBIDDEN', message: '请在 evolclaw.json 配置 owners 后使用进程级操作' } };
1354
+ }
1355
+ const ECWEB_CHANNEL = '__ecweb__';
1356
+ // payload.agent(aid 或 name)时,用该 agent 的首个 channel 实例作为 channel 参数,
1357
+ // 让 execMenuQuery / execMenuUpdate 能按真实 agent 解析 model/effort/perm 等会话级配置。
1358
+ // system / agent 两个进程级 name 不走此路径,仍用 ECWEB_CHANNEL。
1359
+ const agentChannelKey = (() => {
1360
+ if (!payload?.agent || isProcessLevel)
1361
+ return ECWEB_CHANNEL;
1362
+ const handle = this.agentRegistry?.get(payload.agent) ?? null;
1363
+ return handle?.channelInstanceNames()?.[0] ?? ECWEB_CHANNEL;
1364
+ })();
1365
+ const ownerIdentity = { role: 'owner', mode: 'interactive' };
1366
+ // 进程级操作用 owners[0] 让 isProcessLevelOwner() 通过;其余传 undefined
1367
+ const userId = isProcessLevel ? (owners[0] ?? '') : undefined;
1368
+ const nameMap = {
1369
+ pwd: '/pwd', session: '/session', baseagent: '/baseagent', model: '/model',
1370
+ topic: '/topic',
1371
+ effort: '/effort', chatmode: '/chatmode', dispatch: '/dispatch',
1372
+ permission: '/perm', activity: '/activity', system: '/system',
1373
+ agent: '/agent', trigger: '/trigger', file: '/file',
1374
+ };
1375
+ const cmd = name ? (nameMap[name] ?? payload.cmd) : payload.cmd;
1376
+ try {
1377
+ switch (payload?.type) {
1378
+ case 'menu.list':
1379
+ return { type: 'menu.response', id, data: this.getMenuItems('owner', 'private', 'control') };
1380
+ case 'menu.query': {
1381
+ if (!cmd)
1382
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1383
+ const r = await this.execMenuQuery(cmd, agentChannelKey, agentChannelKey, userId, payload.args, undefined, true);
1384
+ return ecwebResp(id, name, r);
1385
+ }
1386
+ case 'menu.options': {
1387
+ if (!cmd)
1388
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1389
+ const data = await this.getSubMenuItems(cmd, agentChannelKey, agentChannelKey, userId, payload.args, ownerIdentity, undefined, true) ?? [];
1390
+ return { type: 'menu.response', id, name, data };
1391
+ }
1392
+ case 'menu.update': {
1393
+ if (!cmd)
1394
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1395
+ if (!payload.value)
1396
+ return ecwebErr(id, name, 'MISSING_VALUE', '缺少 value');
1397
+ const r = await this.execMenuUpdate(cmd, payload.value, agentChannelKey, agentChannelKey, userId, ownerIdentity, true, payload.args);
1398
+ return ecwebResp(id, name, r);
1399
+ }
1400
+ case 'menu.action': {
1401
+ if (!cmd)
1402
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1403
+ if (!payload.action)
1404
+ return ecwebErr(id, name, 'MISSING_VALUE', '缺少 action');
1405
+ const r = await this.execMenuAction(cmd, payload.action, payload.args, agentChannelKey, agentChannelKey, userId, ownerIdentity, undefined, id, true);
1406
+ return ecwebResp(id, name, r);
1407
+ }
1408
+ default:
1409
+ return ecwebErr(id, name, 'NOT_SUPPORTED', `未知类型: ${payload?.type}`);
1410
+ }
1411
+ }
1412
+ catch (e) {
1413
+ return ecwebErr(id, name, 'INTERNAL', e?.message ?? String(e));
1414
+ }
1415
+ }
1416
+ /** 控制 AID channel 专用入口:peerId 必须 ∈ evolclaw.owners。
1417
+ * 全量权限(进程级 + 跨 agent + 关系级),fromControlChannel=true 放行闸门。
1418
+ * 与 ECWeb 入口的区别:鉴权主体是真实 peerId(而非注入 owner),按 evolclaw.owners 校验。 */
1419
+ export async function execMenuForControl(payload, peerId) {
1420
+ const id = payload?.id ?? '';
1421
+ const name = payload?.name;
1422
+ const owners = loadEvolclawConfig().owners ?? [];
1423
+ if (!isProcessLevelOwner(peerId, owners)) {
1424
+ return { type: 'menu.response', id, ...(name ? { name } : {}), error: { code: 'FORBIDDEN', message: '控制 channel 操作需要 owner 权限' } };
1425
+ }
1426
+ if (name === 'cli' || payload?.cmd === '/cli') {
1427
+ return { type: 'menu.response', id, name, error: { code: 'NOT_SUPPORTED', message: 'cli 不在控制 channel 范围' } };
1428
+ }
1429
+ const CONTROL_CHANNEL = '__control__';
1430
+ const ownerIdentity = { role: 'owner', mode: 'interactive' };
1431
+ const nameMap = {
1432
+ pwd: '/pwd', session: '/session', baseagent: '/baseagent', model: '/model',
1433
+ topic: '/topic',
1434
+ effort: '/effort', chatmode: '/chatmode', dispatch: '/dispatch',
1435
+ permission: '/perm', activity: '/activity', system: '/system',
1436
+ agent: '/agent', trigger: '/trigger', file: '/file',
1437
+ };
1438
+ const cmd = name ? (nameMap[name] ?? payload.cmd) : payload.cmd;
1439
+ try {
1440
+ switch (payload?.type) {
1441
+ case 'menu.list':
1442
+ return { type: 'menu.response', id, data: this.getMenuItems('owner', 'private', 'control') };
1443
+ case 'menu.query': {
1444
+ if (!cmd)
1445
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1446
+ const r = await this.execMenuQuery(cmd, CONTROL_CHANNEL, CONTROL_CHANNEL, peerId, payload.args, undefined, true);
1447
+ return ecwebResp(id, name, r);
1448
+ }
1449
+ case 'menu.options': {
1450
+ if (!cmd)
1451
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1452
+ const data = await this.getSubMenuItems(cmd, CONTROL_CHANNEL, CONTROL_CHANNEL, peerId, payload.args, ownerIdentity, undefined, true) ?? [];
1453
+ return { type: 'menu.response', id, name, data };
1454
+ }
1455
+ case 'menu.update': {
1456
+ if (!cmd)
1457
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1458
+ if (!payload.value)
1459
+ return ecwebErr(id, name, 'MISSING_VALUE', '缺少 value');
1460
+ const r = await this.execMenuUpdate(cmd, payload.value, CONTROL_CHANNEL, CONTROL_CHANNEL, peerId, ownerIdentity, true, payload.args);
1461
+ return ecwebResp(id, name, r);
1462
+ }
1463
+ case 'menu.action': {
1464
+ if (!cmd)
1465
+ return ecwebErr(id, name, 'MISSING_CMD', '缺少 name/cmd');
1466
+ if (!payload.action)
1467
+ return ecwebErr(id, name, 'MISSING_VALUE', '缺少 action');
1468
+ const r = await this.execMenuAction(cmd, payload.action, payload.args, CONTROL_CHANNEL, CONTROL_CHANNEL, peerId, ownerIdentity, undefined, id, true);
1469
+ return ecwebResp(id, name, r);
1470
+ }
1471
+ default:
1472
+ return ecwebErr(id, name, 'NOT_SUPPORTED', `未知类型: ${payload?.type}`);
1473
+ }
1474
+ }
1475
+ catch (e) {
1476
+ return ecwebErr(id, name, 'INTERNAL', e?.message ?? String(e));
1477
+ }
1478
+ }