evolclaw 3.1.10 → 3.2.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.
@@ -13,6 +13,9 @@ import { parseTriggerSet, parseTriggerUpdate } from './trigger/parser.js';
13
13
  import { calcNextFireAt } from './trigger/scheduler.js';
14
14
  import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions } from '../utils/npm-ops.js';
15
15
  import { tryParseChannelKey } from './channel-loader.js';
16
+ import { loadDefaults } from '../config-store.js';
17
+ import { loadEvolclawConfig } from '../evolclaw-config.js';
18
+ import { execAgentAction, execAgentQuery, execAgentOptions, resolveProjectPath } from './message/command-handler-agent-control.js';
16
19
  const allEfforts = ['low', 'medium', 'high', 'xhigh', 'max'];
17
20
  const nonMaxEfforts = allEfforts.filter(e => e !== 'max' && e !== 'xhigh');
18
21
  // ── CLI 透传(menu.action name=cli action=exec)─────────────────────────
@@ -573,8 +576,37 @@ export class CommandHandler {
573
576
  return items;
574
577
  }
575
578
  /** 动态子菜单:根据 cmd 路径返回选项列表(供 menu.query + cmd 使用) */
576
- async getSubMenuItems(cmd, channel, channelId, userId) {
579
+ async getSubMenuItems(cmd, channel, channelId, userId, args) {
577
580
  const session = await this.sessionManager.getActiveSession(channel, channelId);
581
+ // ── 进程级 /agent list(owners 鉴权) ──
582
+ if (cmd === '/agent') {
583
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
584
+ throw { code: 'FORBIDDEN', message: '操作需要 owner 权限' };
585
+ }
586
+ const res = await execAgentOptions(args);
587
+ if ('error' in res)
588
+ throw { code: res.code, message: res.error };
589
+ return res.data.agents.map(ag => ({ value: ag.aid, label: ag.name || ag.aid, desc: ag.status }));
590
+ }
591
+ // ── 关系级 /trigger list(每个 trigger 一个 MenuItem) ──
592
+ if (cmd === '/trigger') {
593
+ const owningAgent = this.getOwningAgent(channel);
594
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
595
+ if (!manager)
596
+ return [];
597
+ const scope = args?.options === 'all' ? 'all' : 'enabled';
598
+ const role = this.sessionManager.resolveIdentity(channel, userId).role;
599
+ const isAdmin = role === 'owner' || role === 'admin';
600
+ const all = manager.listAll();
601
+ const list = scope === 'all' ? all.active.concat(all.history) : manager.listActive();
602
+ const visible = isAdmin ? list
603
+ : list.filter((t) => t.createdByPeerId === (userId ?? '') && t.createdByChannel === channel);
604
+ return visible.map((t) => ({
605
+ value: t.id,
606
+ label: t.name,
607
+ desc: `${t.scheduleType}${t.nextFireAt ? ` | 下次 ${new Date(t.nextFireAt).toLocaleString()}` : ''}`,
608
+ }));
609
+ }
578
610
  if (cmd === '/s' || cmd === '/session' || cmd === '/del') {
579
611
  const sessions = await this.sessionManager.listSessions(channel, channelId);
580
612
  const active = cmd === '/del' ? await this.sessionManager.getActiveSession(channel, channelId) : null;
@@ -698,12 +730,18 @@ export class CommandHandler {
698
730
  return s ? null : { error: '当前无活跃会话', code: 'NO_ACTIVE_SESSION' };
699
731
  }
700
732
  /** menu.query — 查询当前值。 */
701
- async execMenuQuery(cmd, channel, channelId, userId) {
702
- void userId;
733
+ async execMenuQuery(cmd, channel, channelId, userId, args) {
703
734
  const cmdBase = cmd.trim().split(' ')[0];
704
735
  if (!cmdBase)
705
736
  return { error: '缺少命令', code: 'MISSING_CMD' };
706
737
  const { session, evolagent } = await this.loadMenuContext(channel, channelId);
738
+ // ── 进程级 /agent(owners 鉴权) ──
739
+ if (cmdBase === '/agent') {
740
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
741
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
742
+ }
743
+ return await execAgentQuery(args);
744
+ }
707
745
  if (cmdBase === '/pwd') {
708
746
  const sessPath = session?.projectPath;
709
747
  const fallbackPath = evolagent?.config?.projects?.defaultPath;
@@ -792,6 +830,9 @@ export class CommandHandler {
792
830
  const fallback = evolagent?.config?.dispatch;
793
831
  return { data: { mode: sessionMode ?? fallback ?? null } };
794
832
  }
833
+ if (cmdBase === '/observable') {
834
+ return { data: { observable: evolagent?.getObservable() ?? false } };
835
+ }
795
836
  if (cmdBase === '/perm') {
796
837
  const need = this.requireSession(session);
797
838
  if (need)
@@ -804,6 +845,9 @@ export class CommandHandler {
804
845
  return { data: { mode: currentMode } };
805
846
  }
806
847
  if (cmdBase === '/system') {
848
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
849
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
850
+ }
807
851
  const owningAgent = this.getOwningAgent(channel);
808
852
  const data = {
809
853
  agent: owningAgent?.name ?? 'DefaultAgent',
@@ -836,6 +880,56 @@ export class CommandHandler {
836
880
  return { error: '缺少 value 参数', code: 'MISSING_VALUE' };
837
881
  const { session, evolagent } = await this.loadMenuContext(channel, channelId);
838
882
  const identity = this.sessionManager.resolveIdentity(channel, userId);
883
+ // ── 关系级 /trigger update(调度参数,value 为 JSON 字符串) ──
884
+ if (cmdBase === '/trigger') {
885
+ const owningAgent = this.getOwningAgent(channel);
886
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
887
+ const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
888
+ if (!manager || !scheduler)
889
+ return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
890
+ let patch;
891
+ try {
892
+ patch = JSON.parse(arg);
893
+ }
894
+ catch {
895
+ return { error: 'value 需为 JSON', code: 'INVALID_ARGS' };
896
+ }
897
+ if (!patch?.nameOrId)
898
+ return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
899
+ const isAdmin = identity.role === 'owner' || identity.role === 'admin';
900
+ if (!isAdmin && !userId)
901
+ return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
902
+ const trigger = isAdmin
903
+ ? (manager.getByName(patch.nameOrId) ?? manager.getById(patch.nameOrId))
904
+ : (manager.getByNameScoped(patch.nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(patch.nameOrId, userId ?? '', channel));
905
+ if (!trigger)
906
+ return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
907
+ const fields = {};
908
+ if (patch.scheduleType !== undefined)
909
+ fields.scheduleType = patch.scheduleType;
910
+ if (patch.scheduleValue !== undefined)
911
+ fields.scheduleValue = String(patch.scheduleValue);
912
+ if (patch.prompt !== undefined)
913
+ fields.prompt = String(patch.prompt);
914
+ // 调度参数变化时重算 nextFireAt——先校验避免 NaN 污染 scheduler heap
915
+ if (fields.scheduleType !== undefined || fields.scheduleValue !== undefined) {
916
+ const effType = fields.scheduleType ?? trigger.scheduleType;
917
+ const effValue = fields.scheduleValue ?? trigger.scheduleValue;
918
+ const schedErr = validateScheduleParams(effType, effValue);
919
+ if (schedErr)
920
+ return { error: schedErr, code: 'INVALID_ARGS' };
921
+ fields.nextFireAt = calcNextFireAt(effType, effValue, Date.now());
922
+ }
923
+ let updated;
924
+ try {
925
+ updated = manager.update(trigger.id, fields);
926
+ }
927
+ catch (err) {
928
+ return { error: `更新失败:${err?.message || err}`, code: 'INVALID_ARGS' };
929
+ }
930
+ scheduler.update(updated);
931
+ return { data: { id: updated.id, nextFireAt: updated.nextFireAt } };
932
+ }
839
933
  if (cmdBase === '/baseagent') {
840
934
  const valid = this.getAvailableBaseagents(channel);
841
935
  if (valid.length && !valid.includes(arg)) {
@@ -945,6 +1039,16 @@ export class CommandHandler {
945
1039
  this.agentRegistry.setShowActivities(channel, newMode);
946
1040
  return { data: { mode: newMode } };
947
1041
  }
1042
+ if (cmdBase === '/observable') {
1043
+ if (identity.role !== 'owner')
1044
+ return { error: '观察者模式仅限 owner 开关', code: 'NO_PERMISSION' };
1045
+ if (arg !== 'true' && arg !== 'false')
1046
+ return { error: `无效值: ${arg},可选: true / false`, code: 'INVALID_VALUE' };
1047
+ if (!evolagent)
1048
+ return { error: '找不到通道所属 agent,无法持久化', code: 'EXEC_FAILED' };
1049
+ evolagent.setObservable(arg === 'true');
1050
+ return { data: { observable: arg === 'true' } };
1051
+ }
948
1052
  return { error: `不支持 update: ${cmdBase}`, code: 'NOT_SUPPORTED' };
949
1053
  }
950
1054
  /** menu.action — 触发动词。 */
@@ -956,6 +1060,77 @@ export class CommandHandler {
956
1060
  return { error: '缺少 action', code: 'MISSING_VALUE' };
957
1061
  const { session } = await this.loadMenuContext(channel, channelId);
958
1062
  const identity = this.sessionManager.resolveIdentity(channel, userId);
1063
+ // ── 进程级 /agent(owners 鉴权,不依赖 session/channel) ──
1064
+ // NOTE(D5): 本次进程级 /agent 仅按 evolclaw.json owners 鉴权,任意 evolagent 的 AUN
1065
+ // channel 均可作为入口。part1(daemon 控制 AID)落地后,应叠加 isControlChannel(channelId)
1066
+ // 闸:仅控制 AID channel 上的 /agent /system 生效。见 part1 计划。
1067
+ if (cmdBase === '/agent') {
1068
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
1069
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
1070
+ }
1071
+ const a = { ...(args ?? {}) };
1072
+ if (action === 'create') {
1073
+ a.project = resolveProjectPath(a.project, a.aid ?? '', loadDefaults());
1074
+ }
1075
+ return await execAgentAction(action, a, userId ?? '');
1076
+ }
1077
+ // ── 关系级 /trigger(不走 owners;复用 isAdmin + scoped 逻辑,D4 直调底层) ──
1078
+ if (cmdBase === '/trigger') {
1079
+ const role = identity.role;
1080
+ const isAdmin = role === 'owner' || role === 'admin';
1081
+ const owningAgent = this.getOwningAgent(channel);
1082
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
1083
+ const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
1084
+ if (!manager || !scheduler)
1085
+ return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
1086
+ if (action === 'set') {
1087
+ // args 结构化 → 直接组装 ParsedTriggerSet(绕过 parseTriggerSet 文本解析,无注入风险)
1088
+ if (!args?.scheduleType || !args?.scheduleValue || !args?.prompt) {
1089
+ return { error: '缺少必填参数:scheduleType / scheduleValue / prompt', code: 'INVALID_ARGS' };
1090
+ }
1091
+ // menu 路径绕过了 parseTriggerSet 的校验,必须自行校验枚举/数值,
1092
+ // 否则非法值会传到 calcNextFireAt 产出 NaN nextFireAt,污染 scheduler heap。
1093
+ const schedErr = validateScheduleParams(args.scheduleType, String(args.scheduleValue));
1094
+ if (schedErr)
1095
+ return { error: schedErr, code: 'INVALID_ARGS' };
1096
+ const strategy = args.targetSessionStrategy ?? 'latest';
1097
+ if (!['latest', 'current', 'thread'].includes(strategy)) {
1098
+ return { error: `无效 targetSessionStrategy: ${strategy}`, code: 'INVALID_ARGS' };
1099
+ }
1100
+ const parsed = {
1101
+ scheduleType: args.scheduleType,
1102
+ scheduleValue: String(args.scheduleValue),
1103
+ prompt: String(args.prompt),
1104
+ name: args.name,
1105
+ targetChannel: args.targetChannel,
1106
+ targetChannelId: args.targetChannelId,
1107
+ targetThreadId: args.targetThreadId,
1108
+ targetSessionStrategy: strategy,
1109
+ agentId: args.agentId,
1110
+ };
1111
+ const r = await this.registerTriggerFromParsed(parsed, channel, channelId, userId ?? '', undefined);
1112
+ if (!r.ok)
1113
+ return { error: r.error, code: /已存在|exists|重复/.test(r.error) ? 'CONFLICT' : 'INVALID_ARGS' };
1114
+ return { data: { id: r.trigger.id, name: r.trigger.name, nextFireAt: r.trigger.nextFireAt } };
1115
+ }
1116
+ if (action === 'cancel') {
1117
+ const nameOrId = args?.nameOrId;
1118
+ if (!nameOrId)
1119
+ return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
1120
+ if (!isAdmin && !userId)
1121
+ return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
1122
+ const trigger = isAdmin
1123
+ ? (manager.getByName(nameOrId) ?? manager.getById(nameOrId))
1124
+ : (manager.getByNameScoped(nameOrId, userId ?? '', channel) ?? manager.getByIdScoped(nameOrId, userId ?? '', channel));
1125
+ if (!trigger)
1126
+ return { error: '触发器不存在或无权限', code: 'NOT_FOUND' };
1127
+ manager.moveToDone(trigger.id, 'cancelled');
1128
+ scheduler.cancel(trigger.id);
1129
+ this.eventBus.publish({ type: 'trigger:cancelled', triggerId: trigger.id, by: userId ?? '' });
1130
+ return { data: { id: trigger.id, cancelled: true } };
1131
+ }
1132
+ return { error: `不支持的 trigger action: ${action}`, code: 'INVALID_ARGS' };
1133
+ }
959
1134
  // ── /session 系列 ──
960
1135
  if (cmdBase === '/session' || cmdBase === '/s') {
961
1136
  if (action === 'stop') {
@@ -1011,9 +1186,11 @@ export class CommandHandler {
1011
1186
  }
1012
1187
  // ── /system 系列 ──
1013
1188
  if (cmdBase === '/system') {
1189
+ // D1 迁移:进程级鉴权统一查 evolclaw.json owners,替代各 action 内联的 identity.role 判断
1190
+ if (!isProcessLevelOwner(userId, loadEvolclawConfig().owners)) {
1191
+ return { error: '操作需要 owner 权限', code: 'FORBIDDEN' };
1192
+ }
1014
1193
  if (action === 'restart') {
1015
- if (identity.role !== 'owner')
1016
- return { error: '无权限:服务重启仅限 owner 使用', code: 'NO_PERMISSION' };
1017
1194
  const restartInfo = { channel, channelId, timestamp: Date.now() };
1018
1195
  fs.writeFileSync(path.join(resolvePaths().dataDir, 'restart-pending.json'), JSON.stringify(restartInfo));
1019
1196
  const { spawn } = await import('child_process');
@@ -1030,8 +1207,6 @@ export class CommandHandler {
1030
1207
  return await this.delegateAsAction(action, '/check', channel, channelId, userId);
1031
1208
  }
1032
1209
  if (action === 'upgrade') {
1033
- if (identity.role !== 'owner')
1034
- return { error: '无权限:升级仅限 owner 使用', code: 'NO_PERMISSION' };
1035
1210
  return await this.delegateAsAction(action, '/upgrade', channel, channelId, userId);
1036
1211
  }
1037
1212
  return { error: `不支持的 system action: ${action}`, code: 'NOT_SUPPORTED' };
@@ -3254,70 +3429,83 @@ export class CommandHandler {
3254
3429
  const result = parseTriggerSet(args);
3255
3430
  if (!result.ok)
3256
3431
  return `❌ ${result.error}`;
3257
- const parsed = result.value;
3258
- const now = Date.now();
3259
- const nextFireAt = calcNextFireAt(parsed.scheduleType, parsed.scheduleValue, now);
3260
- // Auto-generate name if not provided
3261
- const name = parsed.name ?? `trigger-${Date.now().toString(36)}`;
3262
- const trigger = {
3263
- id: crypto.randomUUID(),
3264
- name,
3265
- scheduleType: parsed.scheduleType,
3266
- scheduleValue: parsed.scheduleValue,
3267
- nextFireAt,
3268
- targetChannel: parsed.targetChannel ?? channel,
3269
- targetChannelId: parsed.targetChannelId ?? channelId,
3270
- targetChannelType: this.resolveChannelType(parsed.targetChannel ?? channel),
3271
- targetThreadId: parsed.targetThreadId,
3272
- targetSessionStrategy: parsed.targetSessionStrategy,
3273
- agentId: parsed.agentId,
3274
- prompt: parsed.prompt,
3275
- createdByPeerId: peerId,
3276
- createdByChannel: channel,
3277
- fireCount: 0,
3278
- createdAt: now,
3279
- updatedAt: now,
3280
- };
3281
- try {
3282
- // Strategy-based session binding
3283
- if (parsed.targetSessionStrategy === 'current') {
3284
- const active = await this.sessionManager.getActiveSession(channel, channelId);
3285
- if (!active)
3286
- return '❌ 当前没有活跃会话,改用 --session latest 或 thread';
3287
- trigger.boundSessionId = active.id;
3288
- }
3289
- else if (parsed.targetSessionStrategy === 'thread') {
3290
- const targetAdapterName = parsed.targetChannel ?? channel;
3291
- const adapter = this.adapters.get(targetAdapterName);
3292
- if (!adapter?.capabilities.thread)
3293
- return '❌ 目标渠道不支持 thread 会话';
3294
- const channelType = adapter.channelKey.split('#')[0];
3295
- trigger.targetChannelType = channelType;
3296
- if (channelType === 'aun') {
3297
- trigger.threadKind = 'aun';
3298
- trigger.targetThreadId = `trigger-${trigger.id}`;
3299
- }
3300
- else {
3301
- if (!messageId)
3302
- return ' 飞书 thread 模式需要消息 ID,请重新发送命令';
3303
- trigger.threadKind = 'feishu';
3304
- trigger.rootMessageId = messageId;
3305
- trigger.pendingThread = true;
3306
- }
3432
+ const reg = await this.registerTriggerFromParsed(result.value, channel, channelId, peerId, messageId);
3433
+ if (!reg.ok)
3434
+ return `❌ ${reg.error}`;
3435
+ const nextStr = new Date(reg.trigger.nextFireAt).toLocaleString();
3436
+ return `✅ 触发器已注册:**${reg.trigger.name}**\n下次触发:${nextStr}`;
3437
+ }
3438
+ return `❌ 未知子命令。用法:\n/trigger — 查看活跃触发器\n/trigger list — 查看所有触发器\n/trigger set <参数> — 注册触发器\n/trigger update <名称|ID> <参数> — 修改触发器\n/trigger cancel <名称> — 取消触发器`;
3439
+ }
3440
+ /** 从已解析的 trigger 参数组装 Trigger 并注册。文本路径(handleTrigger)与 menu 路径共用。
3441
+ * parsed 形状 = parseTriggerSet 的 result.value(ParsedTriggerSet)。
3442
+ * 失败 return { ok:false, error };成功 return { ok:true, trigger }。本方法不改变原文本路径行为。 */
3443
+ async registerTriggerFromParsed(parsed, channel, channelId, peerId, messageId) {
3444
+ const owningAgent = this.getOwningAgent(channel);
3445
+ const scheduler = (owningAgent?.triggerScheduler ?? this.triggerScheduler);
3446
+ const manager = (owningAgent?.triggerManager ?? this.triggerManager);
3447
+ if (!manager || !scheduler)
3448
+ return { ok: false, error: '触发器功能未启用' };
3449
+ const now = Date.now();
3450
+ const nextFireAt = calcNextFireAt(parsed.scheduleType, parsed.scheduleValue, now);
3451
+ // Auto-generate name if not provided
3452
+ const name = parsed.name ?? `trigger-${Date.now().toString(36)}`;
3453
+ const trigger = {
3454
+ id: crypto.randomUUID(),
3455
+ name,
3456
+ scheduleType: parsed.scheduleType,
3457
+ scheduleValue: parsed.scheduleValue,
3458
+ nextFireAt,
3459
+ targetChannel: parsed.targetChannel ?? channel,
3460
+ targetChannelId: parsed.targetChannelId ?? channelId,
3461
+ targetChannelType: this.resolveChannelType(parsed.targetChannel ?? channel),
3462
+ targetThreadId: parsed.targetThreadId,
3463
+ targetSessionStrategy: parsed.targetSessionStrategy,
3464
+ agentId: parsed.agentId,
3465
+ prompt: parsed.prompt,
3466
+ createdByPeerId: peerId,
3467
+ createdByChannel: channel,
3468
+ fireCount: 0,
3469
+ createdAt: now,
3470
+ updatedAt: now,
3471
+ };
3472
+ try {
3473
+ // Strategy-based session binding
3474
+ if (parsed.targetSessionStrategy === 'current') {
3475
+ const active = await this.sessionManager.getActiveSession(channel, channelId);
3476
+ if (!active)
3477
+ return { ok: false, error: '当前没有活跃会话,改用 --session latest thread' };
3478
+ trigger.boundSessionId = active.id;
3479
+ }
3480
+ else if (parsed.targetSessionStrategy === 'thread') {
3481
+ const targetAdapterName = parsed.targetChannel ?? channel;
3482
+ const adapter = this.adapters.get(targetAdapterName);
3483
+ if (!adapter?.capabilities.thread)
3484
+ return { ok: false, error: '目标渠道不支持 thread 会话' };
3485
+ const channelType = adapter.channelKey.split('#')[0];
3486
+ trigger.targetChannelType = channelType;
3487
+ if (channelType === 'aun') {
3488
+ trigger.threadKind = 'aun';
3489
+ trigger.targetThreadId = `trigger-${trigger.id}`;
3490
+ }
3491
+ else {
3492
+ if (!messageId)
3493
+ return { ok: false, error: '飞书 thread 模式需要消息 ID,请重新发送命令' };
3494
+ trigger.threadKind = 'feishu';
3495
+ trigger.rootMessageId = messageId;
3496
+ trigger.pendingThread = true;
3307
3497
  }
3308
- // Validate name uniqueness before persisting (manager.register writes to disk)
3309
- // scheduler.register is in-memory only and cannot fail, so order is safe here.
3310
- // If manager.register throws (duplicate name/ID), nothing is persisted.
3311
- manager.register(trigger);
3312
- scheduler.register(trigger);
3313
- }
3314
- catch (err) {
3315
- return `❌ 注册失败:${err.message}`;
3316
3498
  }
3317
- const nextStr = new Date(nextFireAt).toLocaleString();
3318
- return `✅ 触发器已注册:**${name}**\n下次触发:${nextStr}`;
3499
+ // Validate name uniqueness before persisting (manager.register writes to disk)
3500
+ // scheduler.register is in-memory only and cannot fail, so order is safe here.
3501
+ // If manager.register throws (duplicate name/ID), nothing is persisted.
3502
+ manager.register(trigger);
3503
+ scheduler.register(trigger);
3319
3504
  }
3320
- return `❌ 未知子命令。用法:\n/trigger — 查看活跃触发器\n/trigger list — 查看所有触发器\n/trigger set <参数> — 注册触发器\n/trigger update <名称|ID> <参数> — 修改触发器\n/trigger cancel <名称> — 取消触发器`;
3505
+ catch (err) {
3506
+ return { ok: false, error: `注册失败:${err.message}` };
3507
+ }
3508
+ return { ok: true, trigger };
3321
3509
  }
3322
3510
  // ── /rewind helpers ──
3323
3511
  async handleRewindList(session, agent) {
@@ -3651,3 +3839,38 @@ export class CommandHandler {
3651
3839
  return text.length > 50 ? text.substring(0, 50) + '…' : text;
3652
3840
  }
3653
3841
  }
3842
+ /** 进程级 menu 操作(/agent、/system)鉴权:发送方 AID 必须在 owners 名单中。
3843
+ * owners 来自 evolclaw.json 顶层(进程级控制面配置)。纯静态名单比对。 */
3844
+ export function isProcessLevelOwner(peerId, owners) {
3845
+ if (!peerId)
3846
+ return false;
3847
+ return (owners ?? []).includes(peerId);
3848
+ }
3849
+ /** 校验 menu 路径直传的 trigger 调度参数(绕过 parseTriggerSet 文本解析后必须自校验)。
3850
+ * 返回错误字符串表示非法;返回 null 表示通过。
3851
+ * 防止非法 scheduleType/scheduleValue 传到 calcNextFireAt 产出 NaN/throw,污染 scheduler heap。 */
3852
+ export function validateScheduleParams(scheduleType, scheduleValue) {
3853
+ if (!['delay', 'at', 'cron'].includes(scheduleType)) {
3854
+ return `无效 scheduleType: ${scheduleType}(可选: delay / at / cron)`;
3855
+ }
3856
+ if (scheduleType === 'delay') {
3857
+ const ms = Number(scheduleValue);
3858
+ if (!Number.isFinite(ms) || ms <= 0)
3859
+ return `delay 的 scheduleValue 需为正整数毫秒: ${scheduleValue}`;
3860
+ }
3861
+ else if (scheduleType === 'at') {
3862
+ const ts = new Date(scheduleValue).getTime();
3863
+ if (!Number.isFinite(ts))
3864
+ return `at 的 scheduleValue 需为合法时间: ${scheduleValue}`;
3865
+ }
3866
+ else {
3867
+ // cron:交给 calcNextFireAt 内部的 CronExpressionParser 校验(会 throw,被上层 catch)
3868
+ try {
3869
+ calcNextFireAt('cron', scheduleValue, Date.now());
3870
+ }
3871
+ catch {
3872
+ return `无效 cron 表达式: ${scheduleValue}`;
3873
+ }
3874
+ }
3875
+ return null;
3876
+ }
@@ -300,6 +300,9 @@ export class EvolAgentRegistry {
300
300
  }
301
301
  // swap config 后再起新 channel —— startChannel hook 需要看到新 config
302
302
  oldAgent.swapConfig(raw, merged);
303
+ // 热重载也刷新身份层缓存(persona / working 等 fileCache 'agent-files:<aid>' 组),
304
+ // 使 personal 文件改动经 reload 即时生效,不必重启。
305
+ oldAgent.invalidatePersonaCache();
303
306
  for (const ch of toAdd) {
304
307
  await hooks.startChannel(oldAgent, ch);
305
308
  addedSuccessfully.push(ch);
@@ -1,9 +1,9 @@
1
1
  import path from 'path';
2
- import fs from 'fs';
3
2
  import { logger } from '../utils/logger.js';
4
3
  import { saveAgent } from '../config-store.js';
5
4
  import { formatChannelKey, tryParseChannelKey } from './channel-loader.js';
6
5
  import { agentPersonalDir } from '../paths.js';
6
+ import { fileCache } from './cache/file-cache.js';
7
7
  /**
8
8
  * EvolAgent —— 一个 self-agent 的运行时表示。
9
9
  *
@@ -213,40 +213,45 @@ export class EvolAgent {
213
213
  this.merged.dispatch = value;
214
214
  this.persist();
215
215
  }
216
+ /** 读取观察者模式开关(默认 false)。 */
217
+ getObservable() {
218
+ return this.merged.observable === true;
219
+ }
220
+ /** 设置观察者模式开关:开启后入站/出站消息各转发一份给 owners[]。 */
221
+ setObservable(value) {
222
+ if (value)
223
+ this.rawAgent.observable = true;
224
+ else
225
+ delete this.rawAgent.observable;
226
+ this.merged.observable = value;
227
+ this.persist();
228
+ }
216
229
  // ── Personal layer ────────────────────────────────────────────────────
217
- _personaCache = undefined;
230
+ /** agent 身份层文件在 fileCache 的组名(带 aid,避免 reload 单个 agent 误失效他人)。 */
231
+ agentFilesGroup() {
232
+ return `agent-files:${this.aid}`;
233
+ }
218
234
  /**
219
- * 读取 personal/persona.md 内容(缓存,首次调用时从磁盘读)。
220
- * 文件不存在返回 null。
235
+ * 读取 personal/persona.md 内容。走 fileCache(mtime 门控):persona 没有任何
236
+ * 写入命令、由 agent 自己带外改写,与 working memory 同样改了即应生效,故每次读
237
+ * stat 比对、变了自动重读。文件不存在返回 null。
221
238
  */
222
239
  getPersona() {
223
- if (this._personaCache !== undefined)
224
- return this._personaCache;
225
240
  const personaPath = path.join(agentPersonalDir(this.aid), 'persona.md');
226
- try {
227
- this._personaCache = fs.readFileSync(personaPath, 'utf-8').trim() || null;
228
- }
229
- catch {
230
- this._personaCache = null;
231
- }
232
- return this._personaCache;
241
+ return fileCache.get(personaPath, (raw) => (raw === null ? null : (raw.trim() || null)), { policy: 'mtime', group: this.agentFilesGroup() });
233
242
  }
234
243
  /**
235
- * 读取 personal/memory/working.md 内容(不缓存,每次会话开始时读)。
244
+ * 读取 personal/memory/working.md 内容。走 fileCache(mtime 门控):
245
+ * agent 在对话中改写 working memory、不触发 reload,故每次读 stat 比对、
246
+ * 变了自动重读,既即时反映又避免无谓重读。
236
247
  */
237
248
  getWorkingMemory() {
238
249
  const workingPath = path.join(agentPersonalDir(this.aid), 'memory', 'working.md');
239
- try {
240
- const content = fs.readFileSync(workingPath, 'utf-8').trim();
241
- return content || null;
242
- }
243
- catch {
244
- return null;
245
- }
250
+ return fileCache.get(workingPath, (raw) => (raw === null ? null : (raw.trim() || null)), { policy: 'mtime', group: this.agentFilesGroup() });
246
251
  }
247
- /** 清除 persona 缓存(reload 后重新读取) */
252
+ /** 清除本 agent 身份层缓存(reload 后重新读取)。只失效自己的文件组,不波及他人。 */
248
253
  invalidatePersonaCache() {
249
- this._personaCache = undefined;
254
+ fileCache.invalidateGroup(this.agentFilesGroup());
250
255
  }
251
256
  // ── Context(喂给 message-processor / command-handler) ──────────────
252
257
  getContext(_channelKey, chatType, globalChatmode) {