evolcore 0.0.20 → 0.0.22

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 (147) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +58 -9
  3. package/bin/codex-managed-hook.mjs +3 -0
  4. package/bin/install-codex-managed-hooks.mjs +3 -1
  5. package/dist/agents/baseagent.js +10 -6
  6. package/dist/agents/claude-runner.js +393 -108
  7. package/dist/agents/codex-app-server-client.js +41 -7
  8. package/dist/agents/codex-runner.js +1292 -220
  9. package/dist/agents/ecagent-runner.js +171 -61
  10. package/dist/agents/gemini-runner.js +130 -30
  11. package/dist/agents/request-identity.js +25 -0
  12. package/dist/agents/runner-types.js +19 -0
  13. package/dist/aun/aid/agentmd.js +59 -2
  14. package/dist/aun/aid/identity.js +4 -1
  15. package/dist/aun/aid/index.js +1 -1
  16. package/dist/aun/msg/group.js +72 -6
  17. package/dist/aun/msg/history.js +213 -36
  18. package/dist/aun/msg/managed-operation.js +58 -9
  19. package/dist/aun/msg/p2p.js +5 -0
  20. package/dist/aun/outbox.js +189 -80
  21. package/dist/aun/service-proxy.js +43 -25
  22. package/dist/channels/aun.js +618 -123
  23. package/dist/channels/daemon.js +6 -1
  24. package/dist/cli/agent-command.js +4 -3
  25. package/dist/cli/agent.js +66 -56
  26. package/dist/cli/aun-commands.js +177 -42
  27. package/dist/cli/command-log.js +10 -11
  28. package/dist/cli/contact.js +1 -0
  29. package/dist/cli/daemon-commands.js +98 -123
  30. package/dist/cli/init.js +27 -15
  31. package/dist/cli/task-context.js +50 -0
  32. package/dist/cli/trigger-command.js +14 -5
  33. package/dist/cli/watch-logs.js +10 -3
  34. package/dist/config/builtin-roles.js +1 -0
  35. package/dist/config/config-field-policy.js +19 -5
  36. package/dist/config/config-manager.js +167 -22
  37. package/dist/config/contact-book-store.js +25 -3
  38. package/dist/config/contact-operation-service.js +32 -1
  39. package/dist/config/contact-request-service.js +44 -0
  40. package/dist/config/daemon-services.js +186 -0
  41. package/dist/config/gateway-config.js +20 -9
  42. package/dist/config/role-service.js +54 -3
  43. package/dist/config/schema-migration.js +550 -0
  44. package/dist/config-store.js +151 -9
  45. package/dist/core/agent-application-service.js +279 -0
  46. package/dist/core/audit/log-integrity.js +102 -0
  47. package/dist/core/auth/agent-delegation.js +43 -1
  48. package/dist/core/auth/auth-gateway.js +41 -4
  49. package/dist/core/auth/authorization-audit.js +216 -8
  50. package/dist/core/auth/operation-authorizer.js +41 -1
  51. package/dist/core/auth/operation-catalog.js +9 -1
  52. package/dist/core/bootstrap-messages.js +8 -0
  53. package/dist/core/bootstrap-service.js +99 -27
  54. package/dist/core/causation/aun-association.js +7 -4
  55. package/dist/core/command/agent-control.js +56 -16
  56. package/dist/core/command/command-handler.js +311 -44
  57. package/dist/core/command/connect-menu.js +3 -4
  58. package/dist/core/command/group-menu.js +5 -7
  59. package/dist/core/command/menu-handler.js +288 -80
  60. package/dist/core/command/menu-protocol.js +1 -1
  61. package/dist/core/command/role-menu.js +21 -11
  62. package/dist/core/command/slash-gate.js +85 -18
  63. package/dist/core/command/slash-handler.js +377 -36
  64. package/dist/core/data-migration.js +11 -1
  65. package/dist/core/event-catalog.js +37 -0
  66. package/dist/core/evolagent.js +4 -0
  67. package/dist/core/handoff/dispatcher.js +4 -0
  68. package/dist/core/handoff/runtime.js +33 -3
  69. package/dist/core/handoff/store.js +32 -9
  70. package/dist/core/inference/text-inference.js +7 -15
  71. package/dist/core/message/im-renderer.js +90 -87
  72. package/dist/core/message/{inbound-admission.js → message-admission.js} +51 -1
  73. package/dist/core/message/message-bridge.js +184 -12
  74. package/dist/core/message/message-log.js +47 -7
  75. package/dist/core/message/message-queue.js +227 -16
  76. package/dist/core/message/message-utils.js +12 -5
  77. package/dist/core/message/response-engine.js +658 -109
  78. package/dist/core/message/send-receipt.js +1 -0
  79. package/dist/core/message/stream-debouncer.js +9 -2
  80. package/dist/core/model/model-catalog.js +23 -15
  81. package/dist/core/model/model-diagnostics.js +28 -10
  82. package/dist/core/permission/approval-gateway.js +180 -6
  83. package/dist/core/permission/ec-command-parser.js +627 -69
  84. package/dist/core/permission/mode.js +18 -3
  85. package/dist/core/{protected-paths.js → permission/protected-paths.js} +27 -14
  86. package/dist/core/permission/readonly-shell-query.js +263 -9
  87. package/dist/core/permission/sandbox-runtime.js +159 -1
  88. package/dist/core/permission/tool-error-code.js +12 -0
  89. package/dist/core/permission/tool-policy.js +618 -23
  90. package/dist/core/session/session-fs-store.js +154 -5
  91. package/dist/core/session/session-manager.js +329 -30
  92. package/dist/core/session/session-renew.js +37 -13
  93. package/dist/core/session/session-turn-coordinator.js +16 -5
  94. package/dist/eck/kit-renderer.js +1 -1
  95. package/dist/index.js +316 -50
  96. package/dist/ipc.js +459 -29
  97. package/dist/paths.js +82 -7
  98. package/dist/response-system/context-builder.js +1 -7
  99. package/dist/response-system/engines/v1/proactive-flow.js +7 -2
  100. package/dist/stats/price-resolver.js +4 -0
  101. package/dist/trigger/anomaly-store.js +1 -0
  102. package/dist/trigger/feedback.js +70 -7
  103. package/dist/trigger/history.js +79 -4
  104. package/dist/trigger/legacy-session-history.js +2 -2
  105. package/dist/trigger/parser.js +13 -3
  106. package/dist/trigger/scheduler.js +20 -3
  107. package/dist/trigger/validation.js +6 -1
  108. package/dist/utils/atomic-write.js +27 -0
  109. package/dist/utils/ecweb-utils.js +16 -2
  110. package/dist/utils/error-utils.js +4 -1
  111. package/dist/utils/logger.js +30 -6
  112. package/dist/utils/process-tree-stats.js +24 -4
  113. package/dist/utils/process-tree-worker.js +31 -0
  114. package/dist/utils/project-path.js +1 -2
  115. package/dist/utils/tool-summary.js +59 -0
  116. package/dist/utils/windows-shell-trust.js +201 -0
  117. package/kits/docs/INDEX.md +1 -1
  118. package/kits/docs/evolcore/INDEX.md +3 -3
  119. package/kits/docs/evolcore/agent-create.md +146 -0
  120. package/kits/docs/evolcore/agent.md +6 -0
  121. package/kits/docs/evolcore/contact.md +7 -1
  122. package/kits/docs/evolcore/group-collaboration.md +251 -0
  123. package/kits/docs/evolcore/group-rules.md +1 -19
  124. package/kits/docs/evolcore/group.md +3 -1
  125. package/kits/docs/evolcore/msg.md +16 -0
  126. package/kits/docs/evolcore/trigger.md +6 -3
  127. package/kits/docs/prompt-loading-architecture.md +6 -0
  128. package/kits/eck_message_manifest.json +6 -6
  129. package/kits/schemas/_meta.json +7 -4
  130. package/kits/schemas/agent-config.schema.11.json +13 -0
  131. package/kits/schemas/agent-config.schema.12.json +427 -0
  132. package/kits/schemas/daemon.schema.5.json +0 -1
  133. package/kits/schemas/daemon.schema.6.json +131 -0
  134. package/kits/schemas/defaults.schema.5.json +15 -3
  135. package/kits/schemas/migrations/README.md +3 -1
  136. package/kits/schemas/relation-config.schema.8.json +13 -0
  137. package/kits/schemas/role-config.schema.1.json +1 -2
  138. package/kits/schemas/single-session.schema.3.json +32 -0
  139. package/kits/templates/message-fragments/item.md +1 -1
  140. package/kits/templates/roles/admin.json +1 -0
  141. package/kits/templates/roles/member.json +1 -0
  142. package/kits/templates/roles/visitor.json +1 -0
  143. package/kits/templates/system-fragments/bootstrap.md +2 -1
  144. package/kits/templates/system-fragments/commands.md +2 -2
  145. package/package.json +6 -3
  146. package/skills/eclink/SKILL.md +2 -0
  147. package/dist/config/aun-gateway-config.js +0 -2
@@ -1,5 +1,55 @@
1
1
  import { AccessPolicyError, readAccessPolicy, } from '../../config/access-policy.js';
2
- import { isAcceptedContact, resolveContactView, resolvePrincipal, } from '../../config/contact-book.js';
2
+ import { getContactSnapshot, isAcceptedContact, resolveContactView, resolvePrincipal, } from '../../config/contact-book.js';
3
+ import { isValidAid } from '../../aun/aid/validation.js';
4
+ import { expirePendingContactRequests } from '../../config/contact-request-service.js';
5
+ /** Validate managed private AUN sends against the sender's local contact policy. */
6
+ export async function evaluateOutboundContactAdmission(input) {
7
+ const selfAid = String(input.selfAid || '').trim();
8
+ const targetAid = String(input.targetAid || '').trim();
9
+ if (!isValidAid(selfAid) || !isValidAid(targetAid)) {
10
+ return { allow: false, code: 'CONTACT_POLICY_INVALID', status: 'absent', error: 'A valid Agent AID is required for outbound contact admission' };
11
+ }
12
+ let policy;
13
+ try {
14
+ policy = readAccessPolicy(selfAid);
15
+ }
16
+ catch (error) {
17
+ return { allow: false, code: 'CONTACT_POLICY_INVALID', status: 'absent', error: error instanceof Error ? error.message : String(error) };
18
+ }
19
+ if (policy.policyMode !== 'contacts') {
20
+ try {
21
+ if (getContactSnapshot(selfAid).contacts.get(targetAid)?.status === 'blocked') {
22
+ return { allow: false, code: 'CONTACT_BLOCKED', status: 'blocked', error: `目标联系人 ${targetAid} 已被拉黑。请先解除拉黑:ec contact unblock ${targetAid} --self ${selfAid}` };
23
+ }
24
+ }
25
+ catch (error) {
26
+ return { allow: false, code: 'CONTACT_POLICY_INVALID', status: 'absent', error: error instanceof Error ? error.message : String(error) };
27
+ }
28
+ return { allow: true, status: policy.policyMode };
29
+ }
30
+ try {
31
+ await expirePendingContactRequests(selfAid);
32
+ const snapshot = getContactSnapshot(selfAid);
33
+ const entry = snapshot.contacts.get(targetAid);
34
+ const view = resolveContactView(selfAid, targetAid);
35
+ if (view.isOwner)
36
+ return { allow: true, status: 'owner' };
37
+ if (entry && (entry.status === undefined || entry.status === 'active'))
38
+ return { allow: true, status: 'active' };
39
+ const status = entry?.status ?? 'absent';
40
+ if (status === 'blocked')
41
+ return { allow: false, code: 'CONTACT_BLOCKED', status, error: `目标联系人 ${targetAid} 已被拉黑。请先解除拉黑:ec contact unblock ${targetAid} --self ${selfAid}` };
42
+ const addCommand = `ec contact add ${targetAid} --self ${selfAid}`;
43
+ if (status === 'pending')
44
+ return { allow: false, code: 'CONTACT_PENDING', status, error: `目标联系人 ${targetAid} 的联系人申请正在审核中,请等待 Owner 通过;如需重新提交,执行:${addCommand}` };
45
+ if (status === 'declined')
46
+ return { allow: false, code: 'CONTACT_DECLINED', status, error: `目标联系人 ${targetAid} 的联系人申请已拒绝或过期,请重新申请:${addCommand}` };
47
+ return { allow: false, code: 'CONTACT_NOT_ACTIVE', status: 'absent', error: `目标 ${targetAid} 不是联系人,请先申请添加:${addCommand}` };
48
+ }
49
+ catch (error) {
50
+ return { allow: false, code: 'CONTACT_POLICY_INVALID', status: 'absent', error: error instanceof Error ? error.message : String(error) };
51
+ }
52
+ }
3
53
  export function evaluateInboundAdmissionPreflight(input) {
4
54
  const selfAid = String(input.selfAid || '').trim();
5
55
  const channelType = String(input.channelType || '').trim().toLowerCase();
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from 'crypto';
2
2
  import { logger } from '../../utils/logger.js';
3
3
  import { StreamDebouncer } from './stream-debouncer.js';
4
4
  import { appendMessageLog, appendMessageLogStrict, buildInboundEntry, isTransientProtocolMessage } from './message-log.js';
5
- import { buildEnvelope, sendInteractionPayload } from './message-utils.js';
5
+ import { buildEnvelope, isInteractionSendAccepted, sendInteractionPayload } from './message-utils.js';
6
6
  import { chatDirPath } from '../session/session-fs-store.js';
7
7
  import { formatChannelKey, tryParseChannelKey } from '../channel-loader.js';
8
8
  import { agentDir, resolvePaths } from '../../paths.js';
@@ -25,7 +25,10 @@ import { createRootCausation, deriveCausation, normalizeCausation } from '../cau
25
25
  import { recordCausationSpan } from '../causation/audit.js';
26
26
  import { renderActionAsText } from '../interaction-router.js';
27
27
  import { planApprovalRoute } from '../permission/approval-gateway.js';
28
- import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './inbound-admission.js';
28
+ import { parseFullAccessCommand } from '../command/slash-gate.js';
29
+ import { isFullAccessEnabled } from '../../config-store.js';
30
+ import { auditFullAccessEvent } from '../auth/authorization-audit.js';
31
+ import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './message-admission.js';
29
32
  const CONTACT_REJECTION_NOTICE = '[rejection] Not accepting messages from you.';
30
33
  const REJECTABLE_PEER_TYPES = new Set(['ai', 'bot', 'agent', 'service']);
31
34
  const REJECTION_MAX = 3;
@@ -98,6 +101,8 @@ export class MessageBridge {
98
101
  processOwnersProvider;
99
102
  debouncers = new Map();
100
103
  defaultDebounce;
104
+ /** Serializes session selection/renewal with inbound log admission per route. */
105
+ admissionLocks = new Map();
101
106
  agentRegistry;
102
107
  bootstrapService;
103
108
  menuDeduper = new MenuRequestDeduper();
@@ -431,6 +436,27 @@ export class MessageBridge {
431
436
  await this.sendContactRequestProtocolResponse(adapter, msg, result, owningAgent?.name);
432
437
  return true;
433
438
  }
439
+ /**
440
+ * Agent self-service contact add: create/update a pending request and send
441
+ * the same Owner action card used by inbound `/request`.
442
+ */
443
+ async submitAgentContactRequest(input) {
444
+ const owningAgent = this.agentRegistry?.get(input.selfAid) ?? null;
445
+ const submission = await submitContactRequest({
446
+ selfAid: input.selfAid,
447
+ applicantAid: input.applicantAid,
448
+ sourceChannelKey: input.sourceChannelKey,
449
+ note: '通过 ec contact add 发起的联系人申请',
450
+ });
451
+ if ((submission.code !== 'submitted' && submission.code !== 'resubmitted') || !submission.requestId) {
452
+ return { ...submission, reviewCardSent: false };
453
+ }
454
+ const reviewCardSent = await this.deliverContactReviewCard(input.selfAid, input.applicantAid, submission, owningAgent);
455
+ if (!reviewCardSent) {
456
+ logger.warn(`[MessageBridge] Agent contact add request persisted without review card: self=${input.selfAid} applicant=${input.applicantAid} request=${submission.requestId}`);
457
+ }
458
+ return { ...submission, reviewCardSent };
459
+ }
434
460
  contactRequestReply(result) {
435
461
  switch (result.code) {
436
462
  case 'submitted': return '联系人申请已提交,等待管理员审核。';
@@ -507,14 +533,17 @@ export class MessageBridge {
507
533
  toolInput: { applicantAid, requestId: submission.requestId },
508
534
  summary: `Review contact request from ${applicantAid}`,
509
535
  grantable: true,
510
- approverPolicy: 'agent_manager',
536
+ // Contact membership is an Agent-owner decision. Do not use the broader
537
+ // agent_manager policy here: that policy intentionally prefers an Admin,
538
+ // which made request cards invisible to the configured Owner.
539
+ approverPolicy: 'agent_owner',
511
540
  createdAt: Date.parse(submission.submittedAt),
512
541
  }, {
513
542
  userId: applicantAid,
514
543
  chatType: 'private',
515
544
  selfAid,
516
545
  approvalRouting: {
517
- approverPolicy: 'agent_manager',
546
+ approverPolicy: 'agent_owner',
518
547
  owners,
519
548
  admins,
520
549
  resolveApproverCandidates,
@@ -602,10 +631,11 @@ export class MessageBridge {
602
631
  chatmode: 'interactive',
603
632
  replyContext: { delivery: { chatType: 'private' }, metadata: { source: 'handoff', chatmode: 'interactive' } },
604
633
  });
605
- const sent = await sendInteractionPayload(route.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
606
- if (!sent)
634
+ const receipt = await sendInteractionPayload(route.adapter, envelope, interaction, renderActionAsText(interaction), envelope.replyContext);
635
+ const accepted = isInteractionSendAccepted(receipt);
636
+ if (!accepted)
607
637
  await this.interactionRouter.cancel(submission.requestId, 'delivery_failed');
608
- return !!sent;
638
+ return accepted;
609
639
  }
610
640
  async sendContactReviewOutcome(adapter, ownerAid, applicantAid, code, agentName) {
611
641
  const text = code === 'approved' ? `已通过 ${applicantAid} 的联系人申请。`
@@ -666,12 +696,23 @@ export class MessageBridge {
666
696
  const available = this.availableBaseagentsFor(channelName);
667
697
  if (available.length === 0 || available.includes(session.baseagent))
668
698
  return session;
699
+ // A topic's backend binding is fenced by SessionManager and cannot be
700
+ // cleared as a side effect of routing alignment. Reassigning the
701
+ // baseagent while leaving the old provider's agentSessionId persisted
702
+ // would make the new runner resume an incompatible backend. Keep the
703
+ // binding intact and let normal baseagent-mismatch handling report it;
704
+ // an explicit boundary operation must perform any future migration.
705
+ if (session.threadId) {
706
+ logger.error(`[MessageBridge] Cannot align topic to another baseagent: `
707
+ + `session=${session.id} thread=${session.threadId} current=${session.baseagent} preferred=${owningAgent.baseagent}`);
708
+ return session;
709
+ }
669
710
  const preferred = available.includes(owningAgent.baseagent)
670
711
  ? owningAgent.baseagent
671
712
  : available[0];
672
713
  logger.warn(`[MessageBridge] Aligning unavailable session baseagent: session=${session.id} ${session.baseagent} -> ${preferred} agent=${owningAgent.name} available=${available.join(',')}`);
673
- await this.sessionManager.updateSession(session.id, { baseagent: preferred, agentSessionId: null });
674
- return { ...session, baseagent: preferred, agentSessionId: undefined };
714
+ await this.sessionManager.updateSession(session.id, { baseagent: preferred });
715
+ return { ...session, baseagent: preferred };
675
716
  }
676
717
  /**
677
718
  * 为渠道注册消息桥梁:入站处理管线 + 出站命令响应
@@ -685,8 +726,15 @@ export class MessageBridge {
685
726
  register(channelName, onMessage, sendReply, adapter, channelType) {
686
727
  const effectiveChannelType = channelType || channelName;
687
728
  onMessage(async (msg) => {
729
+ let releaseAdmission;
730
+ let releaseSessionIngress;
731
+ const releaseIngress = () => {
732
+ releaseSessionIngress?.();
733
+ releaseSessionIngress = undefined;
734
+ };
688
735
  try {
689
736
  let content = msg.content.trim();
737
+ let executionPermissionOverride;
690
738
  // Channels that expose adapter receive time (notably AUN) must keep
691
739
  // that timestamp through session resolution and queue processing.
692
740
  // The fallback is only for legacy adapters that do not provide it.
@@ -709,6 +757,12 @@ export class MessageBridge {
709
757
  const conversationId = chatType === 'group' ? (msg.groupId || msg.channelId) : msg.peerId;
710
758
  const selfAid = msg.selfAID || owningAgent?.aid || parsedChannelKey?.selfAID;
711
759
  const resolvedChannelType = msg.channelType || parsedChannelKey?.type || effectiveChannelType;
760
+ const admissionRoute = [
761
+ resolvedChannelType,
762
+ selfAid || '',
763
+ msg.channelId,
764
+ msg.threadId || '',
765
+ ].join('#');
712
766
  const trustedHandoffEcho = msg.source === 'handoff'
713
767
  && resolvedChannelType === 'aun'
714
768
  && !!selfAid
@@ -890,6 +944,61 @@ export class MessageBridge {
890
944
  await this.sendAccessDenied(adapter, channelName, msg, sendReply);
891
945
  return;
892
946
  }
947
+ // /fa is an execution prefix, not a session-mode switch or a normal
948
+ // quick command. Only this trusted bridge path can construct the
949
+ // internal per-call authorization carried into ResponseEngine.
950
+ const fullAccessCommand = parseFullAccessCommand(content);
951
+ if (fullAccessCommand.matched) {
952
+ const reject = async (text) => {
953
+ logger.warn(`[MessageBridge] fullaccess command rejected: channel=${channelName} `
954
+ + `actor=${authSubject.principalId ?? authSubject.actorId ?? '<none>'} reason=${text}`);
955
+ auditFullAccessEvent({
956
+ event: 'fullaccess.command.rejected',
957
+ source: 'fullaccess-command',
958
+ decision: 'deny',
959
+ actorId: authSubject.principalId ?? authSubject.actorId,
960
+ processRole: authSubject.processRole,
961
+ messageId: msg.messageId,
962
+ reason: text,
963
+ });
964
+ await sendReply(msg.channelId, `❌ ${text}`, msg.replyContext);
965
+ };
966
+ if (msg.source !== undefined && msg.source !== 'user') {
967
+ await reject('fullaccess 只能由用户直接发送的 /fa 或 /fullaccess 命令发起');
968
+ return;
969
+ }
970
+ if (!fullAccessCommand.prompt) {
971
+ await reject('用法:/fa <提示词> 或 /fullaccess <提示词>');
972
+ return;
973
+ }
974
+ if (!isFullAccessEnabled()) {
975
+ await reject('fullaccess 功能未启用,请先设置 daemon.json.fullaccess.enabled=true');
976
+ return;
977
+ }
978
+ if (!authSubject.isDaemonOwner || !authSubject.principalId) {
979
+ await reject('仅可信 daemon-owner 可以使用 fullaccess');
980
+ return;
981
+ }
982
+ if (!msg.messageLogContent)
983
+ msg.messageLogContent = content;
984
+ content = fullAccessCommand.prompt;
985
+ executionPermissionOverride = {
986
+ permissionMode: 'fullaccess',
987
+ processRole: 'fullaccess-run',
988
+ dataScope: 'daemon',
989
+ source: 'fullaccess-command',
990
+ authorizedBy: authSubject.principalId,
991
+ };
992
+ logger.warn(`[MessageBridge] fullaccess command accepted: channel=${channelName} `
993
+ + `actor=${authSubject.principalId} message=${msg.messageId ?? '<none>'}`);
994
+ auditFullAccessEvent({
995
+ event: 'fullaccess.command.accepted',
996
+ source: 'fullaccess-command',
997
+ actorId: authSubject.principalId,
998
+ processRole: authSubject.processRole,
999
+ messageId: msg.messageId,
1000
+ });
1001
+ }
893
1002
  // 0. 仅阻止协议遥测进入 session / model / messages.jsonl;协议本身已在
894
1003
  // channel/main/events/AUN/handoff 专用链路中处理和记录。
895
1004
  // 注:menu.* 自定义消息已由上方 handleMenuControl 快速路径处理。
@@ -928,7 +1037,18 @@ export class MessageBridge {
928
1037
  const contentForCmd = content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
929
1038
  const cmdContent = contentForCmd || content;
930
1039
  const isCmd = msg.source !== 'handoff'
1040
+ // Once /fa has been accepted, its remainder is always model input.
1041
+ // A prompt such as `/fa /status` must not be re-routed as the
1042
+ // ordinary `/status` quick command after the prefix is removed.
1043
+ && !executionPermissionOverride
1044
+ && !parseFullAccessCommand(cmdContent).matched
931
1045
  && this.cmdHandler.isCommand(cmdContent);
1046
+ if (isCmd && /^\/new(?:\s|$)/i.test(cmdContent)) {
1047
+ // /new changes the active main-session boundary. Serialize it with
1048
+ // ordinary session admission so an in-flight renew cannot append or
1049
+ // enqueue against a superseded session after /new commits.
1050
+ releaseAdmission = await this.acquireAdmission(admissionRoute);
1051
+ }
932
1052
  if (isCmd) {
933
1053
  logger.debug(`[MessageBridge] Command detected: "${cmdContent}", routing to handler`);
934
1054
  // 命令也要记录入方向 jsonl(不创建 session,直接用 chatDirPath 计算路径)
@@ -966,6 +1086,7 @@ export class MessageBridge {
966
1086
  }
967
1087
  }
968
1088
  if (msg.source !== 'handoff'
1089
+ && !executionPermissionOverride
969
1090
  && await this.handleCommand(cmdContent, channelName, msg.channelId, (text) => {
970
1091
  const taskId = `cmd-${msg.messageId || Date.now()}`;
971
1092
  logger.channelOut({ channel: channelName, channelId: msg.channelId, taskId, correlationId: taskId, sessionId: msg.replyContext?.sessionId, agentAid: msg.selfAID, payload: { kind: 'command.result', text } });
@@ -1009,8 +1130,16 @@ export class MessageBridge {
1009
1130
  // 通道找不到归属时退回到 globalConfig(一般是测试场景)
1010
1131
  const effectiveProjectPath = owningAgent?.projectPath
1011
1132
  ?? this.defaultProjectPath;
1133
+ // Session renewal and inbound history append form one admission
1134
+ // transaction. Without this per-route single-flight, a second message
1135
+ // can observe the just-renewed empty session before the first message
1136
+ // has been appended and create another renewed session.
1137
+ releaseAdmission = await this.acquireAdmission(admissionRoute);
1012
1138
  const hadMainSession = msg.threadId ? true : this.sessionManager.hasMainSession(channelName, msg.channelId, msg.channelType || effectiveChannelType, msg.selfAID || selfAid);
1013
1139
  let session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, owningAgent?.baseagent, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType, identity);
1140
+ if (session.threadId && typeof this.messageQueue.reserveSessionIngress === 'function') {
1141
+ releaseSessionIngress = this.messageQueue.reserveSessionIngress(session.id);
1142
+ }
1014
1143
  session = await this.alignSessionBaseagent(channelName, session);
1015
1144
  const replyToMessageId = typeof msg.replyContext?.metadata?.refMessageId === 'string'
1016
1145
  ? msg.replyContext.metadata.refMessageId
@@ -1065,6 +1194,7 @@ export class MessageBridge {
1065
1194
  topicName: this.extractTopicName(msg),
1066
1195
  replyContext: msg.replyContext,
1067
1196
  source: msg.source,
1197
+ ...(executionPermissionOverride ? { executionPermissionOverride } : {}),
1068
1198
  boundSessionId: session.id,
1069
1199
  resolvedIdentity: identity,
1070
1200
  mentionMode: msg.mentionMode,
@@ -1134,6 +1264,12 @@ export class MessageBridge {
1134
1264
  }
1135
1265
  if (!handoffCandidate)
1136
1266
  appendMessageLog(inboundEntry.chatDir, inboundEntry.entry);
1267
+ // The authoritative session choice is now paired with its first
1268
+ // conversation record. Release before waiting for queue completion so
1269
+ // later group messages can still exercise the normal interrupt/FIFO
1270
+ // policy instead of being serialized behind the whole model turn.
1271
+ releaseAdmission?.();
1272
+ releaseAdmission = undefined;
1137
1273
  // 6. ACK + debounce/enqueue
1138
1274
  // ACK 在到达时立即做(每条独立 ACK),不等合并
1139
1275
  // Interrupt 模式(单聊)→ 入队前 debounce 合并
@@ -1158,6 +1294,7 @@ export class MessageBridge {
1158
1294
  role: !isInterrupt ? (session.identity?.role ?? 'none') : undefined,
1159
1295
  sessionKeyField: session.sessionKey,
1160
1296
  selfAID, // ← 传递 selfAID,用于队列隔离
1297
+ onPersisted: releaseIngress,
1161
1298
  });
1162
1299
  }
1163
1300
  catch (error) {
@@ -1193,8 +1330,29 @@ export class MessageBridge {
1193
1330
  catch (error) {
1194
1331
  logger.error(`[MessageBridge] Error in onMessage handler for ${channelName}:`, error);
1195
1332
  }
1333
+ finally {
1334
+ releaseAdmission?.();
1335
+ releaseIngress();
1336
+ }
1196
1337
  });
1197
1338
  }
1339
+ async acquireAdmission(key) {
1340
+ const previous = this.admissionLocks.get(key) ?? Promise.resolve();
1341
+ let release;
1342
+ const current = new Promise(resolve => { release = resolve; });
1343
+ const tail = previous.then(() => current);
1344
+ this.admissionLocks.set(key, tail);
1345
+ await previous;
1346
+ let released = false;
1347
+ return () => {
1348
+ if (released)
1349
+ return;
1350
+ released = true;
1351
+ release();
1352
+ if (this.admissionLocks.get(key) === tail)
1353
+ this.admissionLocks.delete(key);
1354
+ };
1355
+ }
1198
1356
  // ── Menu Protocol ──
1199
1357
  extractTopicName(msg) {
1200
1358
  const raw = msg.topicName
@@ -1541,7 +1699,7 @@ export class MessageBridge {
1541
1699
  return;
1542
1700
  logger.warn(`[MenuControl] category=${category} request=${parsed.id || '<missing>'}`
1543
1701
  + ` type=${parsed.type} name=${parsed.name || '<none>'} action=${parsed.action || '<none>'}`
1544
- + ` transport=${channel} scope=${this.shortHash(scope)} sender=${this.shortHash(msg.peerId)}`
1702
+ + ` transport=${channel} scope=${scope || 'none'} sender=${msg.peerId || 'none'}`
1545
1703
  + (category === 'request-replay' ? ' replay=true' : '')
1546
1704
  + (category === 'request-retry' ? ' retry=true' : '')
1547
1705
  + `${code ? ` code=${code}` : ''}${decision.suppressed ? ` suppressed=${decision.suppressed}` : ''}`);
@@ -1603,7 +1761,22 @@ export class MessageBridge {
1603
1761
  if (!this.cmdHandler.isCommand(content))
1604
1762
  return false;
1605
1763
  logger.info(`[${channel}] ${channelId}: ${content}${source === 'card-trigger' ? ' [card]' : ''}`);
1606
- const cmdResult = await this.cmdHandler.handle(content, channel, channelId, (_cid, text, opts) => sendReply(text), userId, threadId, chatType, source, messageId, selfAID, authSubject?.identity, authSubject, replyContext);
1764
+ const adapter = this.processor.getChannelInfo?.(channel)?.adapter;
1765
+ const cmdResult = await this.cmdHandler.handle(content, channel, channelId, async (_cid, text, opts, updatePayload) => {
1766
+ if (updatePayload && adapter?.send) {
1767
+ const envelope = buildEnvelope({
1768
+ taskId: `cmd-update-${randomBytes(5).toString('hex')}`,
1769
+ channel,
1770
+ channelId,
1771
+ agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',
1772
+ chatmode: 'interactive',
1773
+ replyContext: opts ?? replyContext,
1774
+ });
1775
+ await adapter.send(envelope, updatePayload);
1776
+ return;
1777
+ }
1778
+ await sendReply(text, opts, updatePayload);
1779
+ }, userId, threadId, chatType, source, messageId, selfAID, authSubject?.identity, authSubject, replyContext);
1607
1780
  logger.debug(`[MessageBridge] handleCommand: result type=${typeof cmdResult}`);
1608
1781
  if (cmdResult === undefined)
1609
1782
  return false;
@@ -1621,7 +1794,6 @@ export class MessageBridge {
1621
1794
  return true;
1622
1795
  }
1623
1796
  // 出站走 adapter.send 统一入口
1624
- const adapter = this.processor.getChannelInfo?.(channel)?.adapter;
1625
1797
  let responseSessionId;
1626
1798
  try {
1627
1799
  const responseSession = threadId
@@ -231,24 +231,64 @@ export function messageLogPath(chatDir) {
231
231
  return path.join(chatDir, MESSAGE_LOG_FILE);
232
232
  }
233
233
  export function hasMessageLogOperation(chatDir, operationId) {
234
+ return findMessageLogOperationMessageId(chatDir, operationId) !== undefined;
235
+ }
236
+ /** Find the newest completed outbound operation whose ID starts with prefix. */
237
+ export function findLatestMessageLogOperation(chatDir, operationPrefix) {
234
238
  const file = messageLogPath(chatDir);
235
239
  if (!fs.existsSync(file))
236
- return false;
240
+ return undefined;
241
+ let latest;
237
242
  try {
238
- return fs.readFileSync(file, 'utf-8').split('\n').some(line => {
243
+ for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
239
244
  if (!line.trim())
240
- return false;
245
+ continue;
241
246
  try {
242
247
  const entry = JSON.parse(line);
243
- return entry?.dir === 'out' && entry.operationId === operationId;
248
+ if (entry?.dir !== 'out'
249
+ || typeof entry.operationId !== 'string'
250
+ || (!entry.operationId.startsWith(operationPrefix))
251
+ || typeof entry.msgId !== 'string'
252
+ || !entry.msgId)
253
+ continue;
254
+ const ts = typeof entry.ts === 'number' && Number.isFinite(entry.ts) ? entry.ts : 0;
255
+ if (!latest || ts >= latest.ts) {
256
+ latest = { messageId: entry.msgId, operationId: entry.operationId, ts };
257
+ }
244
258
  }
245
259
  catch {
246
- return false;
260
+ // skip malformed message log lines
247
261
  }
248
- });
262
+ }
249
263
  }
250
264
  catch {
251
- return false;
265
+ return undefined;
266
+ }
267
+ return latest;
268
+ }
269
+ export function findMessageLogOperationMessageId(chatDir, operationId) {
270
+ const file = messageLogPath(chatDir);
271
+ if (!fs.existsSync(file))
272
+ return undefined;
273
+ try {
274
+ for (const line of fs.readFileSync(file, 'utf-8').split('\n')) {
275
+ if (!line.trim())
276
+ continue;
277
+ try {
278
+ const entry = JSON.parse(line);
279
+ if (entry?.dir === 'out' && entry.operationId === operationId) {
280
+ if (typeof entry.msgId === 'string' && entry.msgId)
281
+ return entry.msgId;
282
+ }
283
+ }
284
+ catch {
285
+ // skip malformed message log lines
286
+ }
287
+ }
288
+ return undefined;
289
+ }
290
+ catch {
291
+ return undefined;
252
292
  }
253
293
  }
254
294
  export function resolveChatDir(sessionsDir, channelType, channelId, selfAID) {