evolcore 0.0.12 → 0.0.13

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 (104) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/ec-safe-output.js +26 -7
  3. package/dist/agents/claude-runner.js +53 -7
  4. package/dist/agents/codex-app-server-client.js +53 -5
  5. package/dist/agents/codex-runner.js +104 -59
  6. package/dist/agents/ecagent-runner.js +4 -1
  7. package/dist/aun/aid/control-aid.js +36 -13
  8. package/dist/aun/aid/store.js +13 -7
  9. package/dist/aun/group-fs-download.js +56 -0
  10. package/dist/aun/group-identity.js +1 -2
  11. package/dist/aun/msg/group.js +11 -33
  12. package/dist/aun/msg/index.js +1 -2
  13. package/dist/aun/msg/managed-operation.js +116 -55
  14. package/dist/aun/msg/upload.js +16 -1
  15. package/dist/aun/outbox.js +31 -2
  16. package/dist/aun/rpc/client.js +66 -0
  17. package/dist/aun/rpc/index.js +1 -2
  18. package/dist/aun/storage/{upload.js → client.js} +11 -2
  19. package/dist/aun/storage/index.js +1 -2
  20. package/dist/channels/aun.js +371 -136
  21. package/dist/cli/agent.js +9 -96
  22. package/dist/cli/cli-argv.js +10 -0
  23. package/dist/cli/config.js +9 -17
  24. package/dist/cli/ctl-command.js +1 -1
  25. package/dist/cli/daemon-commands.js +39 -24
  26. package/dist/cli/fs-command.js +19 -10
  27. package/dist/cli/init.js +77 -8
  28. package/dist/cli/queue-command.js +42 -0
  29. package/dist/cli/restart-monitor.js +3 -14
  30. package/dist/cli/trigger-command.js +7 -0
  31. package/dist/config/access-policy.js +1 -2
  32. package/dist/config/builtin-roles.js +5 -0
  33. package/dist/config/config-manager.js +21 -1
  34. package/dist/config/peer-role-resolver.js +6 -2
  35. package/dist/config/role-migration-startup.js +53 -0
  36. package/dist/config/role-store.js +1 -1
  37. package/dist/core/auth/agent-delegation.js +1 -1
  38. package/dist/core/auth/auth-gateway.js +29 -1
  39. package/dist/core/auth/authorization-audit.js +15 -0
  40. package/dist/core/auth/operation-authorizer.js +1 -0
  41. package/dist/core/auth/operation-catalog.js +9 -0
  42. package/dist/core/bootstrap-messages.js +9 -2
  43. package/dist/core/bootstrap-service.js +1 -0
  44. package/dist/core/command/command-handler.js +198 -51
  45. package/dist/core/command/menu-catalog.js +4 -0
  46. package/dist/core/command/menu-handler.js +85 -18
  47. package/dist/core/command/menu-protocol.js +23 -2
  48. package/dist/core/command/slash-handler.js +58 -10
  49. package/dist/core/event-catalog.js +6 -0
  50. package/dist/core/handoff/runtime.js +12 -1
  51. package/dist/core/handoff/store.js +5 -1
  52. package/dist/core/interaction-router.js +10 -0
  53. package/dist/core/message/im-renderer.js +19 -0
  54. package/dist/core/message/message-bridge.js +63 -11
  55. package/dist/core/message/message-log.js +2 -0
  56. package/dist/core/message/message-queue.js +267 -39
  57. package/dist/core/message/message-utils.js +81 -4
  58. package/dist/core/message/response-engine.js +107 -41
  59. package/dist/core/message/stream-debouncer.js +10 -2
  60. package/dist/core/permission/approval-gateway.js +1 -0
  61. package/dist/core/permission/ec-command-parser.js +93 -14
  62. package/dist/core/permission/index.js +1 -1
  63. package/dist/core/permission/mode.js +15 -0
  64. package/dist/core/permission/readonly-shell-query.js +148 -8
  65. package/dist/core/permission/sandbox-runtime.js +21 -0
  66. package/dist/core/permission/tool-policy.js +249 -27
  67. package/dist/core/protected-paths.js +107 -2
  68. package/dist/core/session/session-manager.js +21 -2
  69. package/dist/core/session/session-mapper.js +20 -1
  70. package/dist/eck/detect.js +18 -1
  71. package/dist/eck/group-rules-sync.js +15 -44
  72. package/dist/index.js +161 -86
  73. package/dist/ipc.js +16 -3
  74. package/dist/paths.js +3 -0
  75. package/dist/trigger/feedback.js +64 -11
  76. package/dist/trigger/manager.js +21 -1
  77. package/dist/trigger/parser.js +14 -2
  78. package/dist/trigger/scheduler.js +1 -0
  79. package/dist/trigger/validation.js +67 -6
  80. package/dist/utils/ecweb-supervisor.js +332 -0
  81. package/dist/utils/error-utils.js +10 -0
  82. package/kits/docs/evolcore/trigger.md +17 -1
  83. package/kits/migrations/migrate-contact-book-v2.mjs +1 -2
  84. package/kits/schemas/role-config.schema.1.json +1 -0
  85. package/kits/templates/roles/admin.json +5 -0
  86. package/kits/templates/roles/member.json +7 -1
  87. package/kits/templates/roles/visitor.json +2 -0
  88. package/package.json +1 -1
  89. package/dist/aun/msg/payload-type.js +0 -27
  90. package/dist/aun/rpc/caller.js +0 -42
  91. package/dist/aun/rpc/connection.js +0 -25
  92. package/dist/aun/storage/manage.js +0 -10
  93. package/dist/config/access-policy-domain.js +0 -18
  94. package/dist/config/config-batch-get.js +0 -11
  95. package/dist/config/owner-policy.js +0 -4
  96. package/dist/config/role-config-v4-startup.js +0 -32
  97. package/dist/config/role-config-v5-startup.js +0 -27
  98. package/dist/core/auth/trigger-authorization.js +0 -15
  99. package/dist/core/interaction-registration.js +0 -10
  100. package/dist/core/permission/execution-sandbox.js +0 -16
  101. package/dist/core/relation/peer-key.js +0 -1
  102. package/dist/core/session/session-key.js +0 -24
  103. package/dist/eck/baseagent-caps.js +0 -18
  104. package/dist/eck/rules-loader.js +0 -28
@@ -269,6 +269,9 @@ export class MenuRequestDeduper {
269
269
  ttlMs;
270
270
  maxEntries;
271
271
  entries = new Map();
272
+ /** Retain failed request identities briefly so a subsequent identical
273
+ * request is observable as a retry instead of an unrelated first attempt. */
274
+ failures = new Map();
272
275
  constructor(ttlMs = 30_000, maxEntries = 2_048) {
273
276
  this.ttlMs = ttlMs;
274
277
  this.maxEntries = maxEntries;
@@ -279,7 +282,13 @@ export class MenuRequestDeduper {
279
282
  if (existing) {
280
283
  if (existing.fingerprint !== fingerprint)
281
284
  return { conflict: true };
282
- return { value: await existing.promise, replayed: true };
285
+ return { value: await existing.promise, replayed: true, retry: false };
286
+ }
287
+ const previousFailure = this.failures.get(key);
288
+ if (previousFailure) {
289
+ if (previousFailure.fingerprint !== fingerprint)
290
+ return { conflict: true };
291
+ this.failures.delete(key);
283
292
  }
284
293
  const promise = operation();
285
294
  const entry = { fingerprint, promise, expiresAt: Date.now() + this.ttlMs };
@@ -288,10 +297,12 @@ export class MenuRequestDeduper {
288
297
  try {
289
298
  const value = await promise;
290
299
  entry.expiresAt = Date.now() + this.ttlMs;
291
- return { value, replayed: false };
300
+ return { value, replayed: false, retry: !!previousFailure };
292
301
  }
293
302
  catch (error) {
294
303
  this.entries.delete(key);
304
+ this.failures.set(key, { fingerprint, expiresAt: Date.now() + this.ttlMs });
305
+ this.enforceLimit();
295
306
  throw error;
296
307
  }
297
308
  }
@@ -301,6 +312,10 @@ export class MenuRequestDeduper {
301
312
  if (entry.expiresAt <= now)
302
313
  this.entries.delete(key);
303
314
  }
315
+ for (const [key, entry] of this.failures) {
316
+ if (entry.expiresAt <= now)
317
+ this.failures.delete(key);
318
+ }
304
319
  }
305
320
  enforceLimit() {
306
321
  while (this.entries.size > this.maxEntries) {
@@ -309,6 +324,12 @@ export class MenuRequestDeduper {
309
324
  break;
310
325
  this.entries.delete(oldest);
311
326
  }
327
+ while (this.failures.size > this.maxEntries) {
328
+ const oldest = this.failures.keys().next().value;
329
+ if (!oldest)
330
+ break;
331
+ this.failures.delete(oldest);
332
+ }
312
333
  }
313
334
  }
314
335
  export class MenuDiagnosticLimiter {
@@ -1,6 +1,6 @@
1
1
  import { hasModelSwitcher, hasPermissionController } from '../../agents/runner-types.js';
2
2
  import { getCodexEfforts } from '../../agents/codex-runner.js';
3
- import { buildEnvelope } from '../message/message-utils.js';
3
+ import { buildEnvelope, isDeliveryTargetForChannel } from '../message/message-utils.js';
4
4
  import { resolvePaths, getPackageRoot, daemonControlDir } from '../../paths.js';
5
5
  import { logger } from '../../utils/logger.js';
6
6
  import { shouldSuppressRealRestart } from '../../utils/restart-safety.js';
@@ -2492,17 +2492,50 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
2492
2492
  // 执行重启逻辑(共用于卡片回调和文本确认)
2493
2493
  const executeRestart = async () => {
2494
2494
  const suppressRealRestart = shouldSuppressRealRestart();
2495
- let replyContext;
2496
- if (threadId) {
2497
- const threadSession = await this.sessionManager.getThreadSession(channel, channelId, threadId);
2498
- if (threadSession)
2499
- replyContext = this.getReplyContext(threadSession);
2495
+ let restartReplyContext = replyContext;
2496
+ try {
2497
+ if (!restartReplyContext?.delivery && threadId) {
2498
+ const threadSession = await this.sessionManager.getThreadSession(channel, channelId, threadId);
2499
+ if (threadSession)
2500
+ restartReplyContext = this.getReplyContext(threadSession);
2501
+ }
2502
+ else if (!restartReplyContext?.delivery && activeSession) {
2503
+ restartReplyContext = this.getReplyContext(activeSession);
2504
+ }
2505
+ }
2506
+ catch (error) {
2507
+ const code = error?.code;
2508
+ if (channel.split('#', 1)[0].toLowerCase() !== 'aun'
2509
+ || (code !== 'AUN_OUTBOUND_ROUTE_REQUIRED' && code !== 'AUN_OUTBOUND_ROUTE_MISMATCH')) {
2510
+ throw error;
2511
+ }
2512
+ logger.warn(`[System] Refusing restart from invalid persisted AUN session route: channel=${channel} channelId=${channelId} code=${code}`);
2513
+ return false;
2514
+ }
2515
+ // The authenticated inbound transport context takes precedence over a
2516
+ // potentially stale session snapshot. When no session exists, the
2517
+ // explicit command chat type is the only acceptable local fallback.
2518
+ if (restartReplyContext?.delivery === undefined) {
2519
+ restartReplyContext = {
2520
+ ...(restartReplyContext ?? {}),
2521
+ ...(chatType === 'group'
2522
+ ? { delivery: { chatType: 'group', groupId: channelId } }
2523
+ : chatType === 'private'
2524
+ ? { delivery: { chatType: 'private' } }
2525
+ : {}),
2526
+ };
2527
+ }
2528
+ if (channel.split('#', 1)[0].toLowerCase() === 'aun'
2529
+ && !isDeliveryTargetForChannel(restartReplyContext?.delivery, channelId)) {
2530
+ logger.warn(`[System] Refusing restart without a valid explicit delivery route: channel=${channel} channelId=${channelId}`);
2531
+ return false;
2500
2532
  }
2501
2533
  const restartInfo = {
2502
2534
  channel,
2503
2535
  channelId,
2504
2536
  timestamp: Date.now(),
2505
- ...(replyContext?.replyToMessageId ? { rootId: replyContext.replyToMessageId } : {}),
2537
+ ...(restartReplyContext?.delivery ? { delivery: restartReplyContext.delivery } : {}),
2538
+ ...(restartReplyContext?.replyToMessageId ? { rootId: restartReplyContext.replyToMessageId } : {}),
2506
2539
  };
2507
2540
  if (!suppressRealRestart) {
2508
2541
  const controlDir = daemonControlDir();
@@ -2527,7 +2560,11 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
2527
2560
  channelId,
2528
2561
  agentName: 'system',
2529
2562
  chatmode: 'interactive',
2530
- replyContext,
2563
+ replyContext: restartReplyContext ?? (chatType === 'group'
2564
+ ? { delivery: { chatType: 'group', groupId: channelId } }
2565
+ : chatType === 'private'
2566
+ ? { delivery: { chatType: 'private' } }
2567
+ : undefined),
2531
2568
  });
2532
2569
  await adapter.send(envelope, { kind: 'command.result', text: '🔄 服务正在重启,请稍候...(约 5 秒后恢复)' });
2533
2570
  // 等待消息发送完成后再延迟 kill
@@ -2570,7 +2607,10 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
2570
2607
  return { kind: 'command.result', text: sessionsWithMessages.join('\n') + '\n再次输入 /restart 将强制重启。' };
2571
2608
  }
2572
2609
  }
2573
- await executeRestart();
2610
+ const restarted = await executeRestart();
2611
+ if (!restarted) {
2612
+ return { kind: 'command.error', text: '❌ 无法确定重启通知的出站路由,请从可信会话上下文重试' };
2613
+ }
2574
2614
  // executeRestart 内部已经发送了反馈消息,这里返回 null 避免重复发送
2575
2615
  return null;
2576
2616
  }
@@ -2728,7 +2768,15 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
2728
2768
  }
2729
2769
  // 发送文件
2730
2770
  try {
2731
- const replyCtx = !isCrossChannel && sendSession ? this.getReplyContext(sendSession) : undefined;
2771
+ const replyCtx = isCrossChannel
2772
+ ? { delivery: { chatType: 'private' } }
2773
+ : sendSession
2774
+ ? this.getReplyContext(sendSession)
2775
+ : chatType === 'group'
2776
+ ? { delivery: { chatType: 'group', groupId: channelId } }
2777
+ : chatType === 'private'
2778
+ ? { delivery: { chatType: 'private' } }
2779
+ : undefined;
2732
2780
  await targetAdapter.send(buildEnvelope({ channel: targetAdapter.channelName, channelId: targetChannelId, replyContext: replyCtx }), { kind: 'result.file', filePath: realPath });
2733
2781
  const sizeStr = stat.size < 1024 ? `${stat.size} B`
2734
2782
  : stat.size < 1024 * 1024 ? `${(stat.size / 1024).toFixed(1)} KB`
@@ -313,6 +313,9 @@ const CATALOG = [
313
313
  { path: 'sessionId', type: 'string' },
314
314
  { path: 'toolName', type: 'string' },
315
315
  { path: 'input', type: 'object' },
316
+ { path: 'callId', type: 'string', optional: true },
317
+ { path: 'agentAid', type: 'string', optional: true },
318
+ { path: 'permissionMode', type: 'string', optional: true },
316
319
  { path: 'timestamp', type: 'number', optional: true },
317
320
  ],
318
321
  },
@@ -326,6 +329,9 @@ const CATALOG = [
326
329
  { path: 'toolName', type: 'string' },
327
330
  { path: 'isError', type: 'boolean', optional: true },
328
331
  { path: 'agentName', type: 'string', optional: true },
332
+ { path: 'callId', type: 'string', optional: true },
333
+ { path: 'agentAid', type: 'string', optional: true },
334
+ { path: 'permissionMode', type: 'string', optional: true },
329
335
  { path: 'timestamp', type: 'number', optional: true },
330
336
  ],
331
337
  },
@@ -6,6 +6,7 @@ import { HandoffStore } from './store.js';
6
6
  import { createRootCausation, deriveCausation, normalizeCausation } from '../causation/context.js';
7
7
  import { recordCausationSpan } from '../causation/audit.js';
8
8
  import { logger } from '../../utils/logger.js';
9
+ import { replyContextFromSession } from '../message/message-utils.js';
9
10
  const HANDOFF_STALE_WARN_MS = 72 * 60 * 60 * 1000;
10
11
  const HANDOFF_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
11
12
  const HANDOFF_LARGE_AGGREGATE_COUNT = 10;
@@ -79,7 +80,12 @@ export class HandoffRuntime {
79
80
  if (!origin || origin.selfAID !== input.selfAid)
80
81
  throw new Error('origin session not found');
81
82
  const targetChannelKey = formatChannelKey({ type: 'aun', selfAID: input.selfAid, name: 'main' });
82
- const targetChatType = input.targetChatType ?? 'private';
83
+ if (!input.targetChatType) {
84
+ throw Object.assign(new Error('AUN handoff requires an explicit target chat type'), {
85
+ code: 'AUN_OUTBOUND_ROUTE_REQUIRED',
86
+ });
87
+ }
88
+ const targetChatType = input.targetChatType;
83
89
  const target = await this.sessionManager.getOrCreateSession(targetChannelKey, input.to, origin.projectPath, input.thread, {
84
90
  channelKey: targetChannelKey,
85
91
  peerId: input.to,
@@ -112,6 +118,9 @@ export class HandoffRuntime {
112
118
  targetSessionId: target.id,
113
119
  payload: input.payload,
114
120
  encrypt: input.encrypt,
121
+ delivery: targetChatType === 'group'
122
+ ? { chatType: 'group', groupId: input.to }
123
+ : { chatType: 'private' },
115
124
  returnPolicy: input.explicitReturnPolicy ?? 'required',
116
125
  originAuthorization: input.originAuthorization,
117
126
  causation: handoffCausation,
@@ -209,6 +218,8 @@ export class HandoffRuntime {
209
218
  messageId,
210
219
  timestamp: Date.now(),
211
220
  source: 'handoff',
221
+ groupId: origin.chatType === 'group' ? (origin.metadata?.groupId || origin.channelId) : undefined,
222
+ replyContext: replyContextFromSession(origin),
212
223
  boundSessionId: origin.id,
213
224
  causation: instance.return_causation
214
225
  ? deriveCausation(instance.return_causation)
@@ -115,7 +115,11 @@ export class HandoffStore {
115
115
  origin_session_id: input.originSessionId,
116
116
  origin_message_id: input.originMessageId,
117
117
  target_session_id: input.targetSessionId,
118
- request: { payload: clonePayload(input.payload), encrypt: input.encrypt },
118
+ request: {
119
+ payload: clonePayload(input.payload),
120
+ encrypt: input.encrypt,
121
+ ...(input.delivery ? { delivery: input.delivery } : {}),
122
+ },
119
123
  ...(input.originAuthorization
120
124
  ? { origin_authorization: { ...input.originAuthorization } }
121
125
  : {}),
@@ -228,6 +228,16 @@ export class InteractionRouter {
228
228
  return undefined;
229
229
  }
230
230
  }
231
+ /** Bind an adapter response to its runtime-owned channel identity domain. */
232
+ export function registerAdapterInteractions(adapter, router) {
233
+ const channelKey = adapter.channelKey;
234
+ adapter.onInteraction?.(async (response) => {
235
+ return router.handleAsync({ ...response, channelKey });
236
+ });
237
+ adapter.onInteractionInvalidated?.((interactionId, reason) => {
238
+ return router.cancelFromChannel(interactionId, reason);
239
+ });
240
+ }
231
241
  /** 把 CommandCard 渲染为文本提示,channel 不支持卡片时使用 */
232
242
  export function renderCommandCardAsText(card) {
233
243
  const lines = [card.title];
@@ -244,6 +244,25 @@ export class IMRenderer {
244
244
  durationMs: opts.durationMs,
245
245
  });
246
246
  }
247
+ /** 发送不进入 activity/thought 历史的非终态 warning status。 */
248
+ async sendWarningStatus(message, subtype, details) {
249
+ const payload = {
250
+ kind: 'status.progress',
251
+ metadata: {
252
+ activityType: 'progress',
253
+ message,
254
+ severity: 'warn',
255
+ ...(subtype !== undefined && { subtype }),
256
+ ...(details?.missingItems !== undefined && { missingItems: details.missingItems }),
257
+ },
258
+ };
259
+ try {
260
+ await this.opts.send(payload);
261
+ }
262
+ catch (error) {
263
+ logger.warn(`[IMRenderer] warning status send failed: ${error instanceof Error ? error.message : String(error)}`);
264
+ }
265
+ }
247
266
  /** 添加任务生命周期边界事件,复用 activity.batch 投影路径。 */
248
267
  addLifecycle(phase, metadata, text) {
249
268
  const item = {
@@ -600,7 +600,7 @@ export class MessageBridge {
600
600
  channelId: route.approverOperatorId,
601
601
  agentName: owningAgent?.name ?? selfAid,
602
602
  chatmode: 'interactive',
603
- replyContext: { metadata: { source: 'handoff', chatmode: 'interactive' } },
603
+ replyContext: { delivery: { chatType: 'private' }, metadata: { source: 'handoff', chatmode: 'interactive' } },
604
604
  });
605
605
  const sent = await sendInteractionPayload(route.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
606
606
  if (!sent)
@@ -615,14 +615,14 @@ export class MessageBridge {
615
615
  : code === 'request-stale' ? '申请已更新或已被处理。'
616
616
  : code === 'revision-conflict' ? '联系人列表已变化,请从联系人列表重新审核。'
617
617
  : '当前无审核权限。';
618
- await adapter.send(buildEnvelope({ channel: adapter.channelName, channelId: ownerAid, agentName: agentName ?? '<unknown>' }), { kind: 'system.notice', text, subtype: 'contact-review' });
618
+ await adapter.send(buildEnvelope({ channel: adapter.channelName, channelId: ownerAid, agentName: agentName ?? '<unknown>', delivery: { chatType: 'private' } }), { kind: 'system.notice', text, subtype: 'contact-review' });
619
619
  }
620
620
  async notifyContactApplicant(adapter, applicantAid, code, requestId, expiresAt, agentName) {
621
621
  await adapter.send(buildEnvelope({
622
622
  channel: adapter.channelName,
623
623
  channelId: applicantAid,
624
624
  agentName: agentName ?? '<unknown>',
625
- replyContext: { metadata: evolMenuResponseTransportMetadata() },
625
+ replyContext: { delivery: { chatType: 'private' }, metadata: evolMenuResponseTransportMetadata() },
626
626
  }), {
627
627
  kind: 'custom',
628
628
  channelType: 'aun',
@@ -687,11 +687,23 @@ export class MessageBridge {
687
687
  onMessage(async (msg) => {
688
688
  try {
689
689
  let content = msg.content.trim();
690
+ // Channels that expose adapter receive time (notably AUN) must keep
691
+ // that timestamp through session resolution and queue processing.
692
+ // The fallback is only for legacy adapters that do not provide it.
693
+ const receivedAt = msg.receivedAt ?? Date.now();
690
694
  const channelKey = adapter?.channelKey || channelName;
691
695
  const owningAgent = this.agentRegistry?.resolveByChannel(channelKey)
692
696
  ?? this.agentRegistry?.resolveByChannel(channelName);
693
697
  const parsedChannelKey = tryParseChannelKey(channelKey);
694
698
  const chatType = msg.chatType || 'private';
699
+ const delivery = chatType === 'group'
700
+ ? { chatType: 'group', groupId: msg.groupId || msg.channelId }
701
+ : { chatType: 'private' };
702
+ msg.groupId = delivery.chatType === 'group' ? delivery.groupId : undefined;
703
+ msg.replyContext = {
704
+ ...(msg.replyContext ?? {}),
705
+ delivery,
706
+ };
695
707
  const actorId = msg.peerId;
696
708
  const conversationId = chatType === 'group' ? (msg.groupId || msg.channelId) : msg.peerId;
697
709
  const selfAid = msg.selfAID || owningAgent?.aid || parsedChannelKey?.selfAID;
@@ -925,7 +937,9 @@ export class MessageBridge {
925
937
  content: msg.messageLogContent ?? content,
926
938
  replyTo: inboundReplyTo,
927
939
  permMode: null,
928
- timestamp: Date.now(),
940
+ timestamp: receivedAt,
941
+ receivedAt,
942
+ gatewaySeq: msg.gatewaySeq,
929
943
  encrypt: inboundEncrypt,
930
944
  chatmode: inboundChatmode,
931
945
  peerName: msg.peerName,
@@ -1017,9 +1031,12 @@ export class MessageBridge {
1017
1031
  channelType: msg.channelType || effectiveChannelType,
1018
1032
  channelId: msg.channelId, content,
1019
1033
  selfAID: msg.selfAID,
1034
+ groupId: msg.groupId,
1020
1035
  baseagent: session.baseagent,
1021
1036
  chatType,
1022
- images: msg.images, timestamp: Date.now(),
1037
+ images: msg.images, timestamp: receivedAt,
1038
+ receivedAt,
1039
+ gatewaySeq: msg.gatewaySeq,
1023
1040
  peerId: msg.peerId, peerName: msg.peerName,
1024
1041
  peerType: msg.peerType,
1025
1042
  sameDevice: msg.sameDevice,
@@ -1062,6 +1079,8 @@ export class MessageBridge {
1062
1079
  replyTo: inboundReplyTo,
1063
1080
  permMode: session.identity?.role ?? null,
1064
1081
  timestamp: fullMessage.timestamp,
1082
+ receivedAt: msg.receivedAt,
1083
+ gatewaySeq: msg.gatewaySeq,
1065
1084
  encrypt: inboundEncrypt,
1066
1085
  chatmode: inboundChatmode,
1067
1086
  peerName: msg.peerName,
@@ -1200,6 +1219,31 @@ export class MessageBridge {
1200
1219
  if (!hasValidMenuId(parsed))
1201
1220
  return;
1202
1221
  const header = { id: parsed.id, ...(parsed.name?.trim() ? { name: parsed.name } : {}) };
1222
+ // Menu IDs are the protocol's stable correlation key. Bind it, together
1223
+ // with the active EvolCore session, before authorization so every
1224
+ // CommandAudit record and denial can be joined to the same request.
1225
+ let activeSession;
1226
+ if (msg.threadId && typeof this.sessionManager.getThreadSession === 'function') {
1227
+ try {
1228
+ activeSession = await this.sessionManager.getThreadSession(channel, msg.channelId, msg.threadId);
1229
+ }
1230
+ catch {
1231
+ activeSession = undefined;
1232
+ }
1233
+ }
1234
+ if (!activeSession && typeof this.sessionManager.getActiveSession === 'function') {
1235
+ try {
1236
+ activeSession = await this.sessionManager.getActiveSession(channel, msg.channelId);
1237
+ }
1238
+ catch {
1239
+ activeSession = undefined;
1240
+ }
1241
+ }
1242
+ const menuAuthSubject = {
1243
+ ...authSubject,
1244
+ requestId: parsed.id,
1245
+ ...(activeSession?.id ? { sessionId: activeSession.id } : {}),
1246
+ };
1203
1247
  const isTokenRequest = parsed.type === 'menu.token.request';
1204
1248
  if (isTokenRequest) {
1205
1249
  if (msg.chatType !== 'private') {
@@ -1218,7 +1262,7 @@ export class MessageBridge {
1218
1262
  }
1219
1263
  }
1220
1264
  if (!isTokenRequest) {
1221
- const access = authorizeAccess(authSubject);
1265
+ const access = authorizeAccess(menuAuthSubject);
1222
1266
  if (!access.allow) {
1223
1267
  this.logMenuDiagnostic('access-denied', channel, msg, parsed, access.code);
1224
1268
  await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
@@ -1233,7 +1277,7 @@ export class MessageBridge {
1233
1277
  let afterResponse;
1234
1278
  const deduped = await this.menuDeduper.execute(dedupKey, menuPayloadFingerprint(parsed.raw), () => isTokenRequest
1235
1279
  ? this.issueMenuTokenResponse(header, channel, msg)
1236
- : this.dispatchMenuRequest(parsed.request, header, channel, msg, authSubject, effect => { afterResponse = effect; }));
1280
+ : this.dispatchMenuRequest(parsed.request, header, channel, msg, menuAuthSubject, effect => { afterResponse = effect; }));
1237
1281
  if ('conflict' in deduped) {
1238
1282
  this.logMenuDiagnostic('request-conflict', channel, msg, parsed, 'CONFLICT');
1239
1283
  await this.sendMenuResponse(adapter, channel, msg, menuFailure(header, {
@@ -1244,6 +1288,8 @@ export class MessageBridge {
1244
1288
  }
1245
1289
  if (deduped.replayed)
1246
1290
  this.logMenuDiagnostic('request-replay', channel, msg, parsed);
1291
+ if (deduped.retry)
1292
+ this.logMenuDiagnostic('request-retry', channel, msg, parsed);
1247
1293
  const response = responseWarning && 'data' in deduped.value
1248
1294
  ? { ...deduped.value, warning: responseWarning }
1249
1295
  : deduped.value;
@@ -1380,6 +1426,10 @@ export class MessageBridge {
1380
1426
  ...evolMenuResponseTransportMetadata(),
1381
1427
  },
1382
1428
  };
1429
+ if (msg.channelType === 'aun' && !replyContext.delivery) {
1430
+ logger.warn(`[MessageBridge] dropping AUN menu response without trusted delivery route: channel=${channel} channelId=${msg.channelId}`);
1431
+ return;
1432
+ }
1383
1433
  const envelope = buildEnvelope({
1384
1434
  taskId: `menu-${randomBytes(4).toString('hex')}`,
1385
1435
  channel,
@@ -1438,6 +1488,8 @@ export class MessageBridge {
1438
1488
  logger.warn(`[MenuControl] category=${category} request=${parsed.id || '<missing>'}`
1439
1489
  + ` type=${parsed.type} name=${parsed.name || '<none>'} action=${parsed.action || '<none>'}`
1440
1490
  + ` transport=${channel} scope=${this.shortHash(scope)} sender=${this.shortHash(msg.peerId)}`
1491
+ + (category === 'request-replay' ? ' replay=true' : '')
1492
+ + (category === 'request-retry' ? ' retry=true' : '')
1441
1493
  + `${code ? ` code=${code}` : ''}${decision.suppressed ? ` suppressed=${decision.suppressed}` : ''}`);
1442
1494
  }
1443
1495
  shortHash(value) {
@@ -1556,17 +1608,17 @@ export class MessageBridge {
1556
1608
  * 撤回消息:先查 debounce 窗口,再查 message queue,最后查正在执行的任务。
1557
1609
  * @returns true 如果找到并取消/中断
1558
1610
  */
1559
- cancel(messageId) {
1611
+ cancel(messageId, selfAID) {
1560
1612
  // 阶段 1: debounce 窗口(尚未入队)
1561
1613
  for (const d of this.debouncers.values()) {
1562
- if (d.cancel(messageId))
1614
+ if (d.cancel(messageId, selfAID))
1563
1615
  return true;
1564
1616
  }
1565
1617
  // 阶段 2: 已入队但未处理(合并后 messageId 可能是逗号分隔的多个 id)
1566
- if (this.messageQueue.cancel(messageId))
1618
+ if (this.messageQueue.cancel(messageId, selfAID))
1567
1619
  return true;
1568
1620
  // 阶段 3: 正在执行的任务 → 触发 interrupt
1569
- return this.messageQueue.cancelActive(messageId);
1621
+ return this.messageQueue.cancelActive(messageId, selfAID);
1570
1622
  }
1571
1623
  /** 清理资源 */
1572
1624
  dispose() {
@@ -289,6 +289,8 @@ export function buildInboundEntry(opts) {
289
289
  const isCommand = isCommandCapable && opts.content.startsWith('/');
290
290
  return {
291
291
  ts,
292
+ ...(opts.receivedAt !== undefined ? { receivedAt: opts.receivedAt } : {}),
293
+ ...(opts.gatewaySeq !== undefined ? { gatewaySeq: opts.gatewaySeq } : {}),
292
294
  time: formatTimestampMs(ts),
293
295
  sessionId: opts.sessionId,
294
296
  threadId: opts.threadId ?? null,