evolcore 0.0.23 → 0.0.24

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 (84) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +3 -1
  3. package/bin/codex-managed-acl.ps1 +7 -2
  4. package/bin/codex-managed-hook.mjs +127 -10
  5. package/bin/ec-safe-output.js +85 -3
  6. package/bin/install-codex-managed-hooks.mjs +17 -6
  7. package/dist/agents/baseagent-availability.js +4 -4
  8. package/dist/agents/baseagent.js +29 -1
  9. package/dist/agents/claude-runner.js +148 -13
  10. package/dist/agents/codex/app-server-client.js +18 -8
  11. package/dist/agents/codex/catalog.js +1 -1
  12. package/dist/agents/codex/diagnostics.js +215 -0
  13. package/dist/agents/codex-runner.js +438 -59
  14. package/dist/agents/ecagent-runner.js +17 -3
  15. package/dist/agents/gemini-runner.js +60 -2
  16. package/dist/agents/runner-types.js +1 -1
  17. package/dist/aun/aid/managed-operation.js +28 -6
  18. package/dist/aun/aid/store.js +41 -0
  19. package/dist/aun/msg/managed-operation.js +6 -1
  20. package/dist/aun/outbox.js +56 -18
  21. package/dist/aun/rpc/client.js +4 -5
  22. package/dist/channels/aun.js +147 -76
  23. package/dist/channels/daemon.js +7 -1
  24. package/dist/channels/dingtalk.js +1 -0
  25. package/dist/channels/feishu.js +10 -4
  26. package/dist/channels/wecom.js +2 -1
  27. package/dist/cli/agent-command.js +29 -3
  28. package/dist/cli/agent-create.js +538 -0
  29. package/dist/cli/agent.js +26 -675
  30. package/dist/cli/aun-commands.js +60 -20
  31. package/dist/cli/ctl-command.js +2 -0
  32. package/dist/cli/daemon-commands.js +148 -17
  33. package/dist/cli/index.js +2 -2
  34. package/dist/cli/managed-command-guard.js +3 -0
  35. package/dist/cli/restart-monitor.js +432 -8
  36. package/dist/cli/stats.js +110 -0
  37. package/dist/config/builtin-roles.js +2 -0
  38. package/dist/core/auth/auth-gateway.js +3 -2
  39. package/dist/core/auth/authorization-audit.js +72 -19
  40. package/dist/core/auth/operation-authorizer.js +63 -3
  41. package/dist/core/auth/operation-catalog.js +10 -2
  42. package/dist/core/auth/remote-access-store.js +3 -2
  43. package/dist/core/baseagent-loader.js +4 -4
  44. package/dist/core/bootstrap-messages.js +39 -18
  45. package/dist/core/bootstrap-service.js +32 -26
  46. package/dist/core/capability/capability-manager.js +8 -4
  47. package/dist/core/capability/providers/codex-capability-provider.js +1 -1
  48. package/dist/core/command/agent-control.js +7 -2
  49. package/dist/core/command/command-handler.js +87 -25
  50. package/dist/core/command/menu-catalog.js +3 -1
  51. package/dist/core/command/menu-handler.js +274 -70
  52. package/dist/core/command/menu-protocol.js +6 -2
  53. package/dist/core/command/slash-gate.js +1 -3
  54. package/dist/core/command/slash-handler.js +157 -48
  55. package/dist/core/data-migration.js +5 -15
  56. package/dist/core/event-catalog.js +3 -0
  57. package/dist/core/handoff/runtime.js +3 -1
  58. package/dist/core/handoff/store.js +7 -1
  59. package/dist/core/message/im-renderer.js +13 -6
  60. package/dist/core/message/message-bridge.js +5 -36
  61. package/dist/core/message/message-utils.js +6 -3
  62. package/dist/core/message/response-engine.js +73 -21
  63. package/dist/core/model/catalog-service.js +3 -1
  64. package/dist/core/model/model-catalog.js +10 -0
  65. package/dist/core/model/model-permission.js +4 -1
  66. package/dist/core/permission/approval-gateway.js +169 -33
  67. package/dist/core/permission/tool-policy.js +13 -6
  68. package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
  69. package/dist/core/session/session-manager.js +49 -13
  70. package/dist/core/session/session-renew.js +1 -0
  71. package/dist/index.js +174 -28
  72. package/dist/ipc.js +68 -21
  73. package/dist/paths.js +23 -1
  74. package/dist/stats/billing.js +18 -4
  75. package/dist/utils/atomic-write.js +26 -2
  76. package/dist/utils/cross-platform.js +436 -1
  77. package/dist/utils/error-utils.js +11 -4
  78. package/dist/utils/model-prices.jsonl +23 -20
  79. package/dist/utils/npm-ops.js +24 -1
  80. package/dist/utils/windows-autostart.js +1 -1
  81. package/kits/templates/roles/admin.json +2 -0
  82. package/package.json +7 -3
  83. package/AGENTS.md +0 -62
  84. package/dist/utils/codex-cli.js +0 -43
@@ -1,9 +1,8 @@
1
1
  import { normalizePeer, writeScope } from '../model/config-scope.js';
2
2
  import { formatPeerKey } from '../relation/peer-identity.js';
3
- import { modelMatches, getCatalog, resolveCatalogModel, assertCatalogSelectable } from '../model/model-catalog.js';
3
+ import { modelMatches, getCatalog, getBaseagentCatalog, resolveCatalogModel, assertCatalogSelectable, TRANSPORT_EFFORTS } from '../model/model-catalog.js';
4
4
  import { constrainResolvedModelForRole, filterModelsForRole, validateModelSelectionForRole } from '../model/model-permission.js';
5
5
  import { hasModelSwitcher } from '../../agents/runner-types.js';
6
- import { execCodexCliSync, resolveCodexCliPath } from '../../utils/codex-cli.js';
7
6
  import { resolvePaths, getPackageRoot, daemonControlDir } from '../../paths.js';
8
7
  import { inspectDataMigrationRequirement } from '../data-migration.js';
9
8
  import { buildEnvelope, isDeliveryTarget, isDeliveryTargetForChannel, replyContextFromSession } from '../message/message-utils.js';
@@ -11,11 +10,12 @@ import path from 'path';
11
10
  import fs from 'fs';
12
11
  import os from 'os';
13
12
  import crypto from 'crypto';
14
- import { execFileSync } from 'child_process';
15
13
  import { CronExpressionParser } from 'cron-parser';
16
14
  import { parseDuration } from '../../trigger/parser.js';
17
15
  import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions, resolveGlobalPkg } from '../../utils/npm-ops.js';
18
- import { commandExists, spawnDetachedNode } from '../../utils/cross-platform.js';
16
+ import { commandExists, resolveCliEntry, launchMonitorAndConfirm } from '../../utils/cross-platform.js';
17
+ import { cachedCliVersion } from '../../utils/cross-platform.js';
18
+ import { scanInstances } from '../../utils/instance-registry.js';
19
19
  import { isFullAccessEnabled, loadDefaults, loadDaemonConfig } from '../../config-store.js';
20
20
  import { WEB_PACKAGE_NAME } from '../../product.js';
21
21
  import { read as cfgRead, resolveEffective, resolveEffectiveFieldWithSource, routeFieldPath, write as cfgWrite, ConfigTarget, } from '../../config/config-manager.js';
@@ -52,32 +52,7 @@ import { probeSessionActivity } from './slash-gate.js';
52
52
  * 失败返回 null(命令不存在或执行失败)。
53
53
  */
54
54
  function getBaseagentVersion(cmd) {
55
- try {
56
- const executable = cmd === 'codex' ? resolveCodexCliPath() : cmd;
57
- if (!executable)
58
- return null;
59
- const output = cmd === 'codex'
60
- ? execCodexCliSync(['--version'], {
61
- encoding: 'utf-8',
62
- timeout: 3000,
63
- stdio: ['ignore', 'pipe', 'pipe'],
64
- })
65
- : execFileSync(executable, ['--version'], {
66
- encoding: 'utf-8',
67
- timeout: 3000,
68
- stdio: ['ignore', 'pipe', 'pipe'],
69
- windowsHide: process.platform === 'win32',
70
- });
71
- const versionOutput = String(output).trim();
72
- // claude: "2.1.187 (Claude Code)" → 提取 "2.1.187"
73
- // gemini: "0.38.0" → 直接返回
74
- // codex: "codex-cli 0.142.0" → 提取 "0.142.0"
75
- const match = versionOutput.match(/(\d+\.\d+\.\d+)/);
76
- return match ? match[1] : null;
77
- }
78
- catch {
79
- return null;
80
- }
55
+ return cachedCliVersion(cmd);
81
56
  }
82
57
  const allEfforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
83
58
  const PERMISSION_MODE_KEYS = PUBLIC_PERMISSION_MODES;
@@ -397,7 +372,14 @@ function tokenizeArgv(line) {
397
372
  return out;
398
373
  }
399
374
  async function getAvailableEfforts(agent, model) {
400
- return (await agent.getAvailableEfforts?.(model) ?? []);
375
+ const dynamic = await agent.getAvailableEfforts?.(model);
376
+ if (Array.isArray(dynamic))
377
+ return dynamic;
378
+ // Keep Menu usable with legacy/embedded runner implementations that do not
379
+ // expose the optional catalog method. Full runners still take the dynamic
380
+ // path above, so this fallback cannot broaden their advertised capabilities.
381
+ const baseagent = agent?.baseagent || agent?.name;
382
+ return (TRANSPORT_EFFORTS[baseagent] ?? []);
401
383
  }
402
384
  function modelDisplayLabel(agent, model) {
403
385
  const full = agent.resolveModelId?.(model);
@@ -410,6 +392,29 @@ function menuStringArg(args, key) {
410
392
  const trimmed = value.trim();
411
393
  return trimmed || undefined;
412
394
  }
395
+ function hostCatalogArgs(args = {}) {
396
+ const invalid = (message) => { throw { code: 'INVALID_ARGUMENT', message }; };
397
+ if (!args || typeof args !== 'object' || Array.isArray(args))
398
+ invalid('Baseagent catalog args must be an object');
399
+ if (Object.keys(args).some(key => !['baseagent', 'ba', 'refresh', 'scope', 'context'].includes(key))) {
400
+ invalid('Baseagent catalog only accepts baseagent, ba, refresh and process context');
401
+ }
402
+ if (args.scope !== undefined && args.scope !== 'process')
403
+ invalid('Baseagent catalog requires process scope');
404
+ if (args.context !== undefined && (!args.context || typeof args.context !== 'object'
405
+ || Array.isArray(args.context) || args.context.kind !== 'process'
406
+ || Object.keys(args.context).some(key => key !== 'kind'))) {
407
+ invalid('Baseagent catalog requires process context');
408
+ }
409
+ for (const key of ['baseagent', 'ba']) {
410
+ if (args[key] !== undefined && (typeof args[key] !== 'string' || !args[key].trim())) {
411
+ invalid(`${key} must be a non-empty string`);
412
+ }
413
+ }
414
+ if (args.refresh !== undefined && typeof args.refresh !== 'boolean')
415
+ invalid('refresh must be a boolean');
416
+ return { baseagent: menuStringArg(args, 'baseagent') ?? menuStringArg(args, 'ba') ?? 'claude', refresh: args.refresh === true };
417
+ }
413
418
  const LEGACY_SESSION_CONFIG_ARG_KEYS = [
414
419
  'session',
415
420
  'sessionId',
@@ -852,6 +857,8 @@ export const SELF_MANAGE_AGENT_ACTIONS = new Set(['update', 'reload']);
852
857
  /** 判断 (cmdBase, action) 是否为进程级操作(仅控制 channel 可执行)。
853
858
  * /system 全部进程级;/agent 仅 create/delete/enable/disable 进程级;其余关系级。 */
854
859
  export function isProcessLevelAction(cmdBase, action) {
860
+ if (cmdBase === '/baseagent-models')
861
+ return true;
855
862
  if (cmdBase === '/system')
856
863
  return true;
857
864
  if (cmdBase === '/gateway')
@@ -900,6 +907,8 @@ function buildMenuIntent(verb, cmdBase, args, action, value, fromControlChannel
900
907
  });
901
908
  const modelScope = args?.scope === 'agent' ? 'agent' : 'relation';
902
909
  if (verb === 'query') {
910
+ if (cmdBase === '/baseagent-models')
911
+ return intent('baseagent.models.list', 'process');
903
912
  if (cmdBase === '/pwd')
904
913
  return intent('project.current', 'relation');
905
914
  if (cmdBase === '/session' || cmdBase === '/s')
@@ -1107,6 +1116,7 @@ async function authorizeMenuIntent(params) {
1107
1116
  intent.source = source;
1108
1117
  const decision = await authorizeOperation({ source, intent, subject });
1109
1118
  if (!decision.allow) {
1119
+ logger.warn(`[MenuAuth] denied operation=${intent.operation} scope=${intent.scope} source=${source} selfAid=${subject.selfAid ?? '<none>'} actor=${subject.actorId ?? '<none>'} role=${subject.role} processRole=${subject.processRole} dataScope=${subject.dataScope} daemonOwner=${subject.isDaemonOwner} control=${subject.fromControlChannel} reason=${decision.reason}`);
1110
1120
  if (intent.operation.startsWith('role.') || intent.operation.startsWith('connect.')) {
1111
1121
  const namespace = intent.operation.split('.', 1)[0];
1112
1122
  return {
@@ -1510,6 +1520,8 @@ export async function getSubMenuItems(cmd, channel, channelId, userId, args, ove
1510
1520
  const gated0 = gateControlScope.call(this, { cmdBase: cmdBase0, args, channel, fromControlChannel });
1511
1521
  if (gated0)
1512
1522
  throw { code: gated0.code, message: gated0.error };
1523
+ if (cmdBase0 === '/baseagent-models')
1524
+ throw { code: 'NOT_SUPPORTED', message: 'Baseagent catalog only supports menu.query' };
1513
1525
  if (cmdBase0 === '/role') {
1514
1526
  const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId, ...menuIdentityArgs(session));
1515
1527
  const authorized = await authorizeRoleMenu(this, {
@@ -1628,7 +1640,7 @@ export async function getSubMenuItems(cmd, channel, channelId, userId, args, ove
1628
1640
  const triggerScheduler = this.getTriggerSchedulerForChannel?.(channel);
1629
1641
  const scope = args?.options === 'all' ? 'all' : 'enabled';
1630
1642
  const role = optionsIdentity.role;
1631
- const isAdmin = isManagementRole(role) || subject.isDaemonOwner;
1643
+ const isAdmin = isManagementRole(role) || subject.isDaemonOwner || subject.processRole === 'daemon-service';
1632
1644
  if (!triggerScheduler)
1633
1645
  return [];
1634
1646
  const list = triggerScheduler.list({ all: scope === 'all' });
@@ -1731,7 +1743,16 @@ export async function getSubMenuItems(cmd, channel, channelId, userId, args, ove
1731
1743
  });
1732
1744
  if ('error' in target)
1733
1745
  throw { code: target.code, message: target.error };
1734
- const agent = this.getAgent(channel, target.baseagent);
1746
+ let agent;
1747
+ try {
1748
+ agent = this.getAgent(channel, target.baseagent);
1749
+ }
1750
+ catch (error) {
1751
+ if (subject.processRole === 'daemon-service') {
1752
+ return [{ value: target.baseagent, label: target.baseagent, selected: true }];
1753
+ }
1754
+ throw error;
1755
+ }
1735
1756
  if (hasModelSwitcher(agent)) {
1736
1757
  const catalog = await getCatalog(target.sel.self, target.baseagent);
1737
1758
  assertCatalogSelectable(catalog);
@@ -1793,7 +1814,16 @@ export async function getSubMenuItems(cmd, channel, channelId, userId, args, ove
1793
1814
  });
1794
1815
  if ('error' in target)
1795
1816
  throw { code: target.code, message: target.error };
1796
- const agent = this.getAgent(channel, target.baseagent);
1817
+ let agent;
1818
+ try {
1819
+ agent = this.getAgent(channel, target.baseagent);
1820
+ }
1821
+ catch (error) {
1822
+ if (subject.processRole === 'daemon-service') {
1823
+ return ['low', 'medium', 'high', 'auto'].map(e => ({ value: e, label: e === 'auto' ? 'auto (SDK默认)' : e }));
1824
+ }
1825
+ throw error;
1826
+ }
1797
1827
  const requestedModel = menuStringArg(args, 'model') ?? menuStringArg(args, 'currentModel');
1798
1828
  const modelTarget = { ...target, field: 'model', fieldPath: `baseagents.${target.baseagent}.model` };
1799
1829
  const currentModel = requestedModel || (hasModelSwitcher(agent) ? (readMenuModel(modelTarget, agent).value || agent.getModel()) : agent.name);
@@ -1947,6 +1977,18 @@ export async function execMenuQuery(cmd, channel, channelId, userId, args, expli
1947
1977
  });
1948
1978
  if (authDenied)
1949
1979
  return authDenied;
1980
+ if (cmdBase === '/baseagent-models') {
1981
+ try {
1982
+ const query = hostCatalogArgs(args);
1983
+ return { data: await getBaseagentCatalog(query.baseagent, { refresh: query.refresh }) };
1984
+ }
1985
+ catch (error) {
1986
+ if (error?.code === 'INVALID_ARGUMENT')
1987
+ return menuResultFailure(error);
1988
+ logger.error('Failed to query host baseagent catalog');
1989
+ return { error: 'Unable to query host baseagent catalog', code: 'INTERNAL_ERROR' };
1990
+ }
1991
+ }
1950
1992
  // ── /agent 查询(只读) ──
1951
1993
  // 授权由 agent.list/agent.show operation 决定;channel 闸门只负责目标范围。
1952
1994
  if (cmdBase === '/agent') {
@@ -2408,7 +2450,7 @@ export async function execMenuQuery(cmd, channel, channelId, userId, args, expli
2408
2450
  return { error: `不支持 query: ${cmdBase}`, code: 'NOT_SUPPORTED' };
2409
2451
  }
2410
2452
  /** menu.update — 写入新值。 */
2411
- export async function execMenuUpdate(cmd, value, channel, channelId, userId, overrideIdentity, fromControlChannel = false, args, authSubject, source = 'menu') {
2453
+ export async function execMenuUpdate(cmd, value, channel, channelId, userId, overrideIdentity, fromControlChannel = false, args, authSubject, source = 'menu', transportReplyContext) {
2412
2454
  const cmdBase = cmd.trim().split(' ')[0];
2413
2455
  if (!cmdBase)
2414
2456
  return { error: '缺少命令', code: 'MISSING_CMD' };
@@ -2424,10 +2466,27 @@ export async function execMenuUpdate(cmd, value, channel, channelId, userId, ove
2424
2466
  const gated = gateControlScope.call(this, { cmdBase, args, channel, fromControlChannel });
2425
2467
  if (gated)
2426
2468
  return gated;
2469
+ const loadedContext = await this.loadMenuContext(channel, channelId);
2470
+ let session = loadedContext.session;
2471
+ const evolagent = loadedContext.evolagent;
2472
+ if (transportReplyContext?.threadId && typeof this.sessionManager.getThreadSession === 'function') {
2473
+ try {
2474
+ const topicSession = await this.sessionManager.getThreadSession(channel, channelId, transportReplyContext.threadId);
2475
+ if (!topicSession) {
2476
+ return { error: '话题会话不存在,拒绝回退到主会话', code: 'TOPIC_SESSION_NOT_FOUND' };
2477
+ }
2478
+ session = topicSession;
2479
+ }
2480
+ catch (error) {
2481
+ logger.debug('[MenuHandler] topic session lookup failed for menu update: thread=' + transportReplyContext.threadId + ': ' + (error instanceof Error ? error.message : String(error)));
2482
+ return { error: '无法读取话题会话,拒绝回退到主会话', code: 'TOPIC_SESSION_LOOKUP_FAILED' };
2483
+ }
2484
+ }
2485
+ if (cmdBase === '/baseagent-models')
2486
+ return { error: 'Baseagent catalog only supports menu.query', code: 'NOT_SUPPORTED' };
2427
2487
  const arg = value.trim();
2428
2488
  if (!arg)
2429
2489
  return { error: '缺少 value 参数', code: 'MISSING_VALUE' };
2430
- const { session, evolagent } = await this.loadMenuContext(channel, channelId);
2431
2490
  const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId, ...menuIdentityArgs(session));
2432
2491
  const isAdmin = isManagementRole(identity.role);
2433
2492
  const subject = buildMenuAuthSubject(this, {
@@ -2516,7 +2575,7 @@ export async function execMenuUpdate(cmd, value, channel, channelId, userId, ove
2516
2575
  }
2517
2576
  if (!patch?.nameOrId)
2518
2577
  return { error: '缺少 nameOrId', code: 'INVALID_ARGS' };
2519
- const isAdmin = isManagementRole(identity.role) || subject.isDaemonOwner;
2578
+ const isAdmin = isManagementRole(identity.role) || subject.isDaemonOwner || subject.processRole === 'daemon-service';
2520
2579
  if (!isAdmin && !userId)
2521
2580
  return { error: '无法确认身份,请确保渠道提供发送者 ID', code: 'FORBIDDEN' };
2522
2581
  const triggerScheduler = this.getTriggerSchedulerForChannel?.(channel);
@@ -2578,21 +2637,112 @@ export async function execMenuUpdate(cmd, value, channel, channelId, userId, ove
2578
2637
  return { error: `无效 baseagent: ${arg},可选: ${valid.join(' / ')}`, code: 'INVALID_VALUE' };
2579
2638
  }
2580
2639
  const previousBaseagent = evolagent.baseagent;
2640
+ if (session?.threadId) {
2641
+ if (transportReplyContext?.threadId !== session.threadId) {
2642
+ return { error: '话题路由上下文不匹配,拒绝切换', code: 'THREAD_CONTEXT_MISMATCH' };
2643
+ }
2644
+ if (typeof this.messageQueue.withSessionBarrier !== 'function') {
2645
+ return { error: '当前运行环境不支持安全话题切换', code: 'NOT_SUPPORTED' };
2646
+ }
2647
+ const sessionKey = this.getQueueKey(session, channel, channelId);
2648
+ const currentAgent = this.getAgent(channel, session.baseagent);
2649
+ const outcome = await this.messageQueue.withSessionBarrier(sessionKey, async (snapshot) => {
2650
+ const latest = await this.sessionManager.getSessionById?.(session.id);
2651
+ if (!latest || latest.threadId !== session.threadId) {
2652
+ return { error: '话题会话不存在或归属已变化', code: 'NOT_FOUND' };
2653
+ }
2654
+ const busy = !!latest.processingState
2655
+ || !!latest.metadata?.turnState?.active
2656
+ || snapshot.active
2657
+ || snapshot.queued > 0
2658
+ || snapshot.dispatching
2659
+ || this.messageQueue.isProcessing?.(sessionKey)
2660
+ || (this.messageQueue.getQueueLength?.(sessionKey) ?? 0) > 0
2661
+ || currentAgent.hasActiveStream(sessionKey);
2662
+ if (busy) {
2663
+ return { error: '当前话题会话忙碌,无法切换 baseagent', code: 'BUSY' };
2664
+ }
2665
+ try {
2666
+ evolagent.setActiveBaseagent(arg);
2667
+ await this.sessionManager.rotateTopicBackend({
2668
+ sessionId: latest.id,
2669
+ expectedThreadId: latest.threadId,
2670
+ baseagent: arg,
2671
+ });
2672
+ return { ok: true };
2673
+ }
2674
+ catch (error) {
2675
+ try {
2676
+ evolagent.setActiveBaseagent(previousBaseagent);
2677
+ }
2678
+ catch { }
2679
+ return { error: error instanceof Error ? error.message : String(error), code: 'EXEC_FAILED' };
2680
+ }
2681
+ });
2682
+ if ('error' in outcome)
2683
+ return outcome;
2684
+ this.eventBus.publish({
2685
+ type: 'agent:baseagent-changed',
2686
+ aid: evolagent.aid,
2687
+ baseagent: arg,
2688
+ previousBaseagent,
2689
+ scope: 'agent',
2690
+ timestamp: Date.now(),
2691
+ });
2692
+ return { data: {
2693
+ baseagent: arg,
2694
+ previousBaseagent,
2695
+ baseagentChanged: previousBaseagent !== arg,
2696
+ scope: 'agent',
2697
+ sessionId: session.id,
2698
+ threadId: session.threadId,
2699
+ sessionIdChanged: false,
2700
+ notice: '已切换 Baseagent: ' + previousBaseagent + ' → ' + arg + ',话题会话 ID 保持不变',
2701
+ } };
2702
+ }
2581
2703
  try {
2582
2704
  evolagent.setActiveBaseagent(arg);
2583
2705
  }
2584
2706
  catch (error) {
2585
2707
  return { error: error instanceof Error ? error.message : String(error), code: 'INVALID_VALUE' };
2586
2708
  }
2587
- this.eventBus.publish({
2588
- type: 'agent:baseagent-changed',
2589
- aid: evolagent.aid,
2590
- baseagent: arg,
2591
- previousBaseagent,
2592
- scope: 'agent',
2593
- timestamp: Date.now(),
2594
- });
2595
- return { data: { baseagent: arg, scope: 'agent' } };
2709
+ try {
2710
+ const newSession = await this.sessionManager.createNewSession(channel, channelId, evolagent.projectPath, undefined, arg, session?.metadata, { previousBaseagent, baseagentChanged: previousBaseagent !== arg });
2711
+ if (session) {
2712
+ try {
2713
+ const previousAgent = this.getAgent(channel, session.baseagent);
2714
+ await previousAgent.clearSession(session.id, session.agentSessionId || '', session.projectPath);
2715
+ await previousAgent.closeSession(session.id);
2716
+ }
2717
+ catch (error) {
2718
+ // The new Evolcore session is already active; backend cleanup is best effort.
2719
+ logger.warn(`[Menu] baseagent switch cleanup failed: session=${session.id}: ${error instanceof Error ? error.message : String(error)}`);
2720
+ }
2721
+ }
2722
+ this.eventBus.publish({
2723
+ type: 'agent:baseagent-changed',
2724
+ aid: evolagent.aid,
2725
+ baseagent: arg,
2726
+ previousBaseagent,
2727
+ scope: 'agent',
2728
+ timestamp: Date.now(),
2729
+ });
2730
+ return { data: {
2731
+ baseagent: arg,
2732
+ previousBaseagent,
2733
+ baseagentChanged: previousBaseagent !== arg,
2734
+ scope: 'agent',
2735
+ sessionId: newSession.id,
2736
+ notice: `已切换 Baseagent: ${previousBaseagent} → ${arg},已创建新的主会话`,
2737
+ } };
2738
+ }
2739
+ catch (error) {
2740
+ try {
2741
+ evolagent.setActiveBaseagent(previousBaseagent);
2742
+ }
2743
+ catch { }
2744
+ return { error: `创建新会话失败:${error instanceof Error ? error.message : String(error)}`, code: 'EXEC_FAILED' };
2745
+ }
2596
2746
  }
2597
2747
  if (cmdBase === '/model') {
2598
2748
  const target = resolveMenuModelTarget.call(this, {
@@ -2816,8 +2966,9 @@ export async function execMenuUpdate(cmd, value, channel, channelId, userId, ove
2816
2966
  };
2817
2967
  }
2818
2968
  if (cmdBase === '/observable') {
2819
- if (identity.role !== 'owner')
2969
+ if (identity.role !== 'owner' && subject.processRole !== 'daemon-service' && !subject.isDaemonOwner) {
2820
2970
  return { error: '观察者模式仅限 owner 开关', code: 'NO_PERMISSION' };
2971
+ }
2821
2972
  if (arg !== 'true' && arg !== 'false')
2822
2973
  return { error: `无效值: ${arg},可选: true / false`, code: 'INVALID_VALUE' };
2823
2974
  if (!evolagent)
@@ -2837,8 +2988,25 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
2837
2988
  const gated = gateControlScope.call(this, { cmdBase, action, args, channel, fromControlChannel });
2838
2989
  if (gated)
2839
2990
  return gated;
2840
- const { session: authSession } = await this.loadMenuContext(channel, channelId);
2991
+ const loadedContext = await this.loadMenuContext(channel, channelId);
2992
+ // Menu callbacks can arrive from a topic while the channel's active
2993
+ // session still points at the main conversation. Prefer the explicitly
2994
+ // transported topic route for session-scoped actions and authorization.
2995
+ let authSession = loadedContext.session;
2996
+ if (transportReplyContext?.threadId && typeof this.sessionManager.getThreadSession === 'function') {
2997
+ try {
2998
+ authSession = await this.sessionManager.getThreadSession(channel, channelId, transportReplyContext.threadId) ?? authSession;
2999
+ }
3000
+ catch (error) {
3001
+ logger.debug(`[MenuHandler] topic session lookup failed for menu action: thread=${transportReplyContext.threadId}: ${error instanceof Error ? error.message : String(error)}`);
3002
+ }
3003
+ }
3004
+ const session = authSession;
3005
+ const evolagent = loadedContext.evolagent;
3006
+ if (cmdBase === '/baseagent-models')
3007
+ return { error: 'Baseagent catalog only supports menu.query', code: 'NOT_SUPPORTED' };
2841
3008
  const authIdentity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId, ...menuIdentityArgs(authSession));
3009
+ const identity = authIdentity;
2842
3010
  const subject = buildMenuAuthSubject(this, {
2843
3011
  identity: authIdentity,
2844
3012
  session: authSession,
@@ -2850,7 +3018,7 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
2850
3018
  fromControlChannel,
2851
3019
  subject: authSubject,
2852
3020
  });
2853
- if (cmdBase === '/system' && fromControlChannel && !subject.isDaemonOwner) {
3021
+ if (cmdBase === '/system' && fromControlChannel && !subject.isDaemonOwner && subject.processRole !== 'daemon-service') {
2854
3022
  return {
2855
3023
  error: formatAuthorizationDenial('This daemon-level operation requires DaemonOwner.', 'DAEMON_OWNER_REQUIRED'),
2856
3024
  code: 'NOT_ALLOWED',
@@ -2927,15 +3095,13 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
2927
3095
  return await gatewaySyncEnv(args);
2928
3096
  return { error: `不支持 gateway action: ${action}`, code: 'NOT_SUPPORTED' };
2929
3097
  }
2930
- const { session, evolagent } = await this.loadMenuContext(channel, channelId);
2931
- const identity = overrideIdentity ?? this.sessionManager.resolveIdentity(channel, userId, ...menuIdentityArgs(session));
2932
- const isMenuAdmin = isManagementRole(identity.role);
3098
+ const isMenuAdmin = isManagementRole(authIdentity.role);
2933
3099
  // ── /agent action ──
2934
3100
  // 控制 channel:验 evolcore.owners,可执行进程级(create/delete/enable/disable)+ 自管理(update/reload)。
2935
3101
  // agent channel:闸门已挡掉进程级 + 跨 agent,仅 update/reload 能到此;reload 允许 owner/admin,update 仅 owner。
2936
3102
  if (cmdBase === '/agent') {
2937
3103
  if (fromControlChannel) {
2938
- if (!subject.isDaemonOwner) {
3104
+ if (!subject.isDaemonOwner && subject.processRole !== 'daemon-service') {
2939
3105
  return {
2940
3106
  error: formatAuthorizationDenial('This Agent lifecycle operation requires DaemonOwner.', 'DAEMON_OWNER_REQUIRED'),
2941
3107
  code: 'NOT_ALLOWED',
@@ -3054,7 +3220,7 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
3054
3220
  // ── 关系级 /trigger(不走 owners;复用 isAdmin + scoped 逻辑,D4 直调底层) ──
3055
3221
  if (cmdBase === '/trigger') {
3056
3222
  const role = identity.role;
3057
- const isAdmin = isManagementRole(role) || subject.isDaemonOwner;
3223
+ const isAdmin = isManagementRole(role) || subject.isDaemonOwner || subject.processRole === 'daemon-service';
3058
3224
  const triggerScheduler = this.getTriggerSchedulerForChannel?.(channel);
3059
3225
  if (!triggerScheduler)
3060
3226
  return { error: '触发器功能未启用', code: 'NOT_SUPPORTED' };
@@ -3135,7 +3301,7 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
3135
3301
  if (action === 'enable' && trigger.execution.permissionMode === 'fullaccess') {
3136
3302
  if (!isFullAccessEnabled())
3137
3303
  return { error: 'fullaccess 功能未启用', code: 'NOT_SUPPORTED' };
3138
- if (!subject.isDaemonOwner)
3304
+ if (!subject.isDaemonOwner && subject.processRole !== 'daemon-service')
3139
3305
  return { error: '只有 daemon-owner 可以启用 fullaccess Trigger', code: 'FORBIDDEN' };
3140
3306
  if (subject.canApprovePersistentFullAccess === false) {
3141
3307
  return { error: '当前认证通道不能批准持久 fullaccess Trigger', code: 'FORBIDDEN' };
@@ -3513,7 +3679,7 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
3513
3679
  }
3514
3680
  if (action === 'new') {
3515
3681
  const name = (args?.name ?? '').toString().trim();
3516
- return await this.delegateAsAction(action, name ? `/new ${name}` : '/new', channel, channelId, userId, { enrichSession: true, authSubject: subject });
3682
+ return await this.delegateAsAction(action, name ? `/new ${name}` : '/new', channel, channelId, userId, { enrichSession: true, authSubject: subject, threadId: transportReplyContext?.threadId, chatType: transportReplyContext?.delivery?.chatType, replyContext: transportReplyContext });
3517
3683
  }
3518
3684
  if (action === 'rename') {
3519
3685
  const newName = getRenameName(args);
@@ -3556,33 +3722,33 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
3556
3722
  const target = (args?.target ?? '').toString().trim();
3557
3723
  if (!target)
3558
3724
  return { error: '缺少 args.target', code: 'MISSING_VALUE' };
3559
- return await this.delegateAsAction(action, `/del ${target}`, channel, channelId, userId, { authSubject: subject });
3725
+ return await this.delegateAsAction(action, `/del ${target}`, channel, channelId, userId, { authSubject: subject, threadId: transportReplyContext?.threadId, chatType: transportReplyContext?.delivery?.chatType, replyContext: transportReplyContext });
3560
3726
  }
3561
3727
  if (action === 'switch') {
3562
3728
  const target = (args?.target ?? '').toString().trim();
3563
3729
  if (!target)
3564
3730
  return { error: '缺少 args.target', code: 'MISSING_VALUE' };
3565
- return await this.delegateAsAction(action, `/s ${target}`, channel, channelId, userId, { enrichSession: true, authSubject: subject });
3731
+ return await this.delegateAsAction(action, `/s ${target}`, channel, channelId, userId, { enrichSession: true, authSubject: subject, threadId: transportReplyContext?.threadId, chatType: transportReplyContext?.delivery?.chatType, replyContext: transportReplyContext });
3566
3732
  }
3567
3733
  if (action === 'compact') {
3568
3734
  const need = this.requireSession(session);
3569
3735
  if (need)
3570
3736
  return need;
3571
- return await this.delegateAsAction(action, '/compact', channel, channelId, userId, { authSubject: subject });
3737
+ return await this.delegateAsAction(action, '/compact', channel, channelId, userId, { authSubject: subject, threadId: transportReplyContext?.threadId, chatType: transportReplyContext?.delivery?.chatType, replyContext: transportReplyContext });
3572
3738
  }
3573
3739
  if (action === 'fork') {
3574
3740
  const need = this.requireSession(session);
3575
3741
  if (need)
3576
3742
  return need;
3577
3743
  const name = (args?.name ?? '').toString().trim();
3578
- return await this.delegateAsAction(action, name ? `/fork ${name}` : '/fork', channel, channelId, userId, { enrichSession: true, authSubject: subject });
3744
+ return await this.delegateAsAction(action, name ? `/fork ${name}` : '/fork', channel, channelId, userId, { enrichSession: true, authSubject: subject, threadId: transportReplyContext?.threadId, chatType: transportReplyContext?.delivery?.chatType, replyContext: transportReplyContext });
3579
3745
  }
3580
3746
  return { error: `不支持的 session action: ${action}`, code: 'NOT_SUPPORTED' };
3581
3747
  }
3582
3748
  // ── /system 系列 ──
3583
3749
  if (cmdBase === '/system') {
3584
3750
  // D1 迁移:进程级鉴权统一查 daemon.json owners,替代各 action 内联的 identity.role 判断
3585
- if (!subject.isDaemonOwner) {
3751
+ if (!subject.isDaemonOwner && subject.processRole !== 'daemon-service') {
3586
3752
  return {
3587
3753
  error: formatAuthorizationDenial('This daemon-level operation requires DaemonOwner.', 'DAEMON_OWNER_REQUIRED'),
3588
3754
  code: 'NOT_ALLOWED',
@@ -3626,17 +3792,28 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
3626
3792
  const migrationRequirement = inspectDataMigrationRequirement(resolvePaths().root);
3627
3793
  if (migrationRequirement.required) {
3628
3794
  return {
3629
- error: `待处理的用户数据迁移(${migrationRequirement.operationCount} 项)阻止重启。请先运行 ec data migrate --dry-run,然后运行 ec data migrate --apply。`,
3795
+ error: `待处理的用户数据迁移(${migrationRequirement.operationCount} 项)阻止重启;本次仅检查,未停止当前服务。请先运行 ec data migrate --dry-run,然后运行 ec data migrate --apply。`,
3630
3796
  code: 'DATA_MIGRATION_REQUIRED',
3631
3797
  data: { reasonCode: 'DATA_MIGRATION_REQUIRED', operationCount: migrationRequirement.operationCount },
3632
3798
  };
3633
3799
  }
3634
3800
  if (!suppressRealRestart) {
3801
+ // 这条路径同样以 daemon 自杀收尾,入口缺失必须在写 pending 和自杀之前拦下。
3802
+ const restartEntry = resolveCliEntry(getPackageRoot());
3803
+ if (!fs.existsSync(restartEntry.entry)) {
3804
+ return {
3805
+ error: `找不到重启入口:${restartEntry.entry}。请先执行 npm run build 重建产物。`,
3806
+ code: 'RESTART_ENTRY_MISSING',
3807
+ data: { reasonCode: 'RESTART_ENTRY_MISSING', entry: restartEntry.entry },
3808
+ };
3809
+ }
3635
3810
  const restartInfo = {
3636
3811
  channel,
3637
3812
  channelId,
3638
3813
  timestamp: Date.now(),
3814
+ ...(restartReplyContext?.threadId ? { threadId: restartReplyContext.threadId } : {}),
3639
3815
  ...(restartDelivery ? { delivery: restartDelivery } : {}),
3816
+ ...(restartReplyContext?.replyToMessageId ? { rootId: restartReplyContext.replyToMessageId } : {}),
3640
3817
  };
3641
3818
  const controlDir = daemonControlDir();
3642
3819
  fs.mkdirSync(controlDir, { recursive: true });
@@ -3645,23 +3822,41 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
3645
3822
  // the version check and automatic upgrades before replacing the daemon.
3646
3823
  // Do not inherit managed-session credentials: otherwise cmdRestart can
3647
3824
  // delegate back to this daemon and recursively enqueue another restart.
3648
- spawnDetachedNode([path.join(getPackageRoot(), 'dist', 'cli', 'index.js'), 'restart'], {
3825
+ //
3826
+ // `ec restart` owns the monitor liveness check, but it can itself die on
3827
+ // load (tsx + a broken source tree), and this path kills the daemon
3828
+ // afterwards — so confirm the CLI process survived before proceeding.
3829
+ const restartLaunch = await launchMonitorAndConfirm([...restartEntry.args, 'restart'], {
3649
3830
  EVOLCORE_HOME: resolvePaths().root,
3650
3831
  EVOLCORE_SESSION_ID: '',
3651
3832
  [AGENT_DELEGATION_TOKEN_ENV]: '',
3652
- });
3833
+ },
3834
+ // `ec restart` does not register itself; staying alive is the signal.
3835
+ () => false, pid => scanInstances().restartMonitors.some(m => m.alive && m.record.pid !== pid), 8_000);
3836
+ if (!restartLaunch.ok) {
3837
+ try {
3838
+ fs.rmSync(path.join(controlDir, 'restart-pending.json'), { force: true });
3839
+ }
3840
+ catch { }
3841
+ logger.error(`[System] Refusing menu restart: launcher did not survive (${restartLaunch.reason})`);
3842
+ for (const line of (restartLaunch.stderrTail ?? '').split(/\r?\n/).filter(Boolean).slice(-8)) {
3843
+ logger.error(`[System] | ${line}`);
3844
+ }
3845
+ return {
3846
+ error: `重启已中止:启动器未能运行(${restartLaunch.reason})。服务未停止,仍在运行当前版本。`,
3847
+ code: 'RESTART_LAUNCH_FAILED',
3848
+ data: { reasonCode: 'RESTART_LAUNCH_FAILED', reason: restartLaunch.reason },
3849
+ };
3850
+ }
3653
3851
  }
3654
3852
  else {
3655
3853
  logger.info('[System] Suppressed real menu restart in test runtime');
3656
3854
  }
3657
3855
  this.eventBus.publish({ type: 'system:restart', channel, channelId });
3658
- if (!suppressRealRestart) {
3659
- setTimeout(() => { process.kill(process.pid, 'SIGTERM'); }, 1000);
3660
- }
3661
3856
  return { data: { action: 'restart', success: true } };
3662
3857
  }
3663
3858
  if (action === 'check') {
3664
- const r = await this.delegateAsAction(action, '/check', channel, channelId, userId, { overrideIdentity, authSubject: subject });
3859
+ const r = await this.delegateAsAction(action, '/check', channel, channelId, userId, { overrideIdentity, authSubject: subject, threadId: transportReplyContext?.threadId, chatType: transportReplyContext?.delivery?.chatType, replyContext: transportReplyContext });
3665
3860
  // delegateAsAction 会把 structured 展开到 data 顶层,并保留 data.structured 兼容旧客户端。
3666
3861
  return r;
3667
3862
  }
@@ -3927,8 +4122,8 @@ function menuCommandToken(cmd) {
3927
4122
  }
3928
4123
  function isProcessLevelMenu(name, cmd) {
3929
4124
  const cmdToken = menuCommandToken(cmd);
3930
- return name === 'system' || name === 'agent' || name === 'gateway' || name === 'config'
3931
- || cmdToken === '/system' || cmdToken === '/agent' || cmdToken === '/gateway' || cmdToken === '/config';
4125
+ return name === 'system' || name === 'agent' || name === 'gateway' || name === 'config' || name === 'baseagent-models'
4126
+ || cmdToken === '/system' || cmdToken === '/agent' || cmdToken === '/gateway' || cmdToken === '/config' || cmdToken === '/baseagent-models';
3932
4127
  }
3933
4128
  function isProcessContextMenu(payload) {
3934
4129
  const isCatalog = payload?.type === 'menu.list' && payload?.args?.view === 'catalog';
@@ -3986,10 +4181,18 @@ async function execMenuForSystemControl(payload, context) {
3986
4181
  processOwners: context.owners,
3987
4182
  fromControlChannel: true,
3988
4183
  canApprovePersistentFullAccess: context.canApprovePersistentFullAccess,
4184
+ trustedProcessRole: context.source === 'ecweb' ? 'daemon-owner' : undefined,
3989
4185
  });
3990
4186
  const trustedIdentity = trustedSubject.identity;
3991
4187
  const cmd = name ? (menuCommandForName(name) ?? payload.cmd) : payload.cmd;
3992
4188
  try {
4189
+ if (menuCommandToken(cmd) === '/baseagent-models') {
4190
+ if (payload.type !== 'menu.query')
4191
+ return ecwebErr(id, name, 'NOT_SUPPORTED', 'Baseagent catalog only supports menu.query');
4192
+ if (payload.agent !== undefined)
4193
+ return ecwebErr(id, name, 'INVALID_ARGUMENT', 'Baseagent catalog does not accept an Agent target');
4194
+ hostCatalogArgs(payload.args);
4195
+ }
3993
4196
  switch (payload?.type) {
3994
4197
  case 'menu.list':
3995
4198
  if (payload?.args?.view === 'catalog') {
@@ -4093,6 +4296,7 @@ async function execMenuForSystemControl(payload, context) {
4093
4296
  }
4094
4297
  }
4095
4298
  catch (e) {
4299
+ logger.error(`[ECWebMenu] failed type=${payload?.type ?? '<none>'} name=${name ?? '<none>'} cmd=${cmd ?? '<none>'} agent=${payload?.agent ?? '<none>'} actor=${context.actorAid ?? '<none>'}: ${e?.stack || e?.message || String(e)}`);
4096
4300
  return menuFailure({ id, ...(name ? { name } : {}) }, normalizeMenuError(e));
4097
4301
  }
4098
4302
  }
@@ -25,8 +25,11 @@ export function buildMenuClientProtectedHeaders(input) {
25
25
  ...(input.remoteAccessToken || input.ecwebToken ? { auth_method: 'remote-access-token', remote_access_token: input.remoteAccessToken ?? input.ecwebToken } : {}),
26
26
  };
27
27
  }
28
+ const appClientVersion = input.clientVersion?.trim();
28
29
  return {
30
+ ...(clientName === 'Evol App' ? { client_name: clientName } : {}),
29
31
  ...(input.evolVersion ? { evol_version: input.evolVersion } : {}),
32
+ ...(clientName === 'Evol App' && appClientVersion ? { client_version: appClientVersion } : {}),
30
33
  ...(input.remoteAccessToken || input.ecwebToken ? { auth_method: 'remote-access-token', remote_access_token: input.remoteAccessToken ?? input.ecwebToken } : {}),
31
34
  };
32
35
  }
@@ -40,7 +43,7 @@ export function parseMenuClientProtectedHeaders(value) {
40
43
  normalized[key.trim().toLowerCase()] = raw;
41
44
  }
42
45
  const result = {};
43
- if (normalized.client_name === 'evol' || normalized.client_name === 'ec-menu-client')
46
+ if (normalized.client_name === 'evol' || normalized.client_name === 'Evol App' || normalized.client_name === 'ec-menu-client')
44
47
  result.client_name = normalized.client_name;
45
48
  else if (normalized.client_name !== undefined)
46
49
  result.client_name = normalized.client_name;
@@ -71,7 +74,7 @@ export function evaluateMenuClientCompatibility(input) {
71
74
  }
72
75
  return null;
73
76
  }
74
- if (clientName !== 'evol') {
77
+ if (clientName !== 'evol' && clientName !== 'Evol App') {
75
78
  return { code: 'INVALID_CLIENT', message: `Unsupported Menu client: ${String(clientName)}` };
76
79
  }
77
80
  return evaluateEvolMenuVersionGate(input);
@@ -104,6 +107,7 @@ export const MENU_NAME_COMMANDS = Object.freeze({
104
107
  session: '/session',
105
108
  topic: '/topic',
106
109
  baseagent: '/baseagent',
110
+ 'baseagent-models': '/baseagent-models',
107
111
  model: '/model',
108
112
  effort: '/effort',
109
113
  chatmode: '/chatmode',