evolcore 0.0.24 → 0.0.26

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 (112) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/bin/ec.js +12 -1
  3. package/bin/install-codex-managed-hooks.mjs +28 -1
  4. package/dist/agents/baseagent.js +96 -46
  5. package/dist/agents/claude-runner.js +54 -13
  6. package/dist/agents/codex/app-server-client.js +13 -5
  7. package/dist/agents/codex/catalog.js +1 -1
  8. package/dist/agents/codex/diagnostics.js +34 -1
  9. package/dist/agents/codex-runner.js +128 -42
  10. package/dist/agents/ecagent-runner.js +14 -18
  11. package/dist/agents/gemini-runner.js +24 -9
  12. package/dist/agents/runner-types.js +25 -17
  13. package/dist/aun/aid/agentmd.js +135 -10
  14. package/dist/aun/aid/client.js +1 -1
  15. package/dist/aun/aid/identity.js +1 -1
  16. package/dist/aun/aid/index.js +2 -1
  17. package/dist/aun/aid/managed-operation.js +53 -1
  18. package/dist/aun/aid/store.js +17 -13
  19. package/dist/aun/menu-client.js +74 -0
  20. package/dist/aun/msg/group.js +1 -1
  21. package/dist/aun/msg/managed-operation.js +1 -1
  22. package/dist/aun/msg/p2p.js +6 -1
  23. package/dist/aun/msg/upload.js +30 -22
  24. package/dist/aun/storage/client.js +4 -1
  25. package/dist/aun/storage/index.js +1 -1
  26. package/dist/channels/aun.js +329 -94
  27. package/dist/cli/agent-command.js +125 -5
  28. package/dist/cli/agent-create.js +151 -11
  29. package/dist/cli/agent.js +119 -6
  30. package/dist/cli/aun-commands.js +74 -7
  31. package/dist/cli/config.js +27 -8
  32. package/dist/cli/contact.js +63 -6
  33. package/dist/cli/daemon-commands.js +90 -25
  34. package/dist/cli/diag.js +31 -0
  35. package/dist/cli/handoff-command.js +35 -3
  36. package/dist/cli/index.js +7 -1
  37. package/dist/cli/managed-command-guard.js +3 -0
  38. package/dist/cli/menu-client.js +38 -23
  39. package/dist/cli/restart-monitor.js +57 -10
  40. package/dist/config/builtin-roles.js +2 -0
  41. package/dist/config/config-manager.js +15 -0
  42. package/dist/config/config-operation-service.js +165 -3
  43. package/dist/config/contact-operation-service.js +96 -7
  44. package/dist/config/resolved-config-op.js +66 -8
  45. package/dist/core/agent-application-service.js +2 -1
  46. package/dist/core/auth/authorization-audit.js +20 -12
  47. package/dist/core/auth/operation-authorizer.js +87 -8
  48. package/dist/core/auth/operation-catalog.js +51 -4
  49. package/dist/core/auth/remote-access-store.js +40 -1
  50. package/dist/core/bootstrap-service.js +1 -1
  51. package/dist/core/command/agent-control.js +54 -25
  52. package/dist/core/command/cli-intent-parser.js +18 -0
  53. package/dist/core/command/command-handler.js +152 -20
  54. package/dist/core/command/diag-service.js +118 -0
  55. package/dist/core/command/menu-catalog.js +13 -3
  56. package/dist/core/command/menu-handler.js +88 -31
  57. package/dist/core/command/menu-protocol.js +6 -0
  58. package/dist/core/command/menu-token-store.js +41 -8
  59. package/dist/core/command/slash-gate.js +34 -4
  60. package/dist/core/command/slash-handler.js +77 -12
  61. package/dist/core/event-catalog.js +5 -0
  62. package/dist/core/evolagent.js +3 -3
  63. package/dist/core/handoff/runtime.js +205 -27
  64. package/dist/core/handoff/store.js +193 -4
  65. package/dist/core/message/im-renderer.js +72 -21
  66. package/dist/core/message/message-bridge.js +69 -9
  67. package/dist/core/message/message-queue.js +26 -0
  68. package/dist/core/message/message-utils.js +1 -0
  69. package/dist/core/message/response-engine.js +48 -23
  70. package/dist/core/model/catalog-service.js +79 -49
  71. package/dist/core/model/context-window-suffix.js +33 -0
  72. package/dist/core/model/model-catalog.js +1 -1
  73. package/dist/core/model/model-permission.js +12 -3
  74. package/dist/core/permission/ec-command-parser.js +51 -12
  75. package/dist/core/permission/sandbox-runtime.js +36 -14
  76. package/dist/core/permission/tool-policy.js +47 -7
  77. package/dist/index.js +195 -31
  78. package/dist/ipc.js +54 -2
  79. package/dist/response-system/engines/v1/proactive-flow.js +59 -2
  80. package/dist/stats/billing.js +41 -15
  81. package/dist/stats/price-resolver.js +28 -10
  82. package/dist/utils/avatar-upload.js +282 -23
  83. package/dist/utils/cross-platform.js +30 -1
  84. package/dist/utils/error-utils.js +21 -0
  85. package/dist/utils/evolcore-version.js +129 -0
  86. package/dist/utils/logger.js +3 -1
  87. package/dist/utils/model-prices.jsonl +3 -3
  88. package/dist/utils/npm-ops.js +176 -35
  89. package/kits/docs/context-assembly.md +1 -0
  90. package/kits/docs/evolcore/INDEX.md +3 -1
  91. package/kits/docs/evolcore/agent-create.md +30 -11
  92. package/kits/docs/evolcore/agent.md +18 -8
  93. package/kits/docs/evolcore/aid.md +6 -4
  94. package/kits/docs/evolcore/codex-windows-sandbox.md +146 -0
  95. package/kits/docs/evolcore/config.md +7 -0
  96. package/kits/docs/evolcore/contact.md +12 -5
  97. package/kits/docs/evolcore/menu-client.md +11 -3
  98. package/kits/docs/evolcore/msg.md +1 -0
  99. package/kits/docs/prompt-loading-architecture.md +1 -0
  100. package/kits/docs/venues/group.md +12 -0
  101. package/kits/eck_manifest.json +12 -0
  102. package/kits/rules/03-identity.md +3 -1
  103. package/kits/templates/message-fragments/handoff-request-to-target.md +3 -3
  104. package/kits/templates/roles/admin.json +14 -2
  105. package/kits/templates/roles/member.json +3 -2
  106. package/kits/templates/roles/visitor.json +4 -3
  107. package/kits/templates/system-fragments/bootstrap.md +16 -5
  108. package/kits/templates/system-fragments/channel.md +1 -1
  109. package/kits/templates/system-fragments/session.md +1 -1
  110. package/package.json +6 -4
  111. package/skills/eclink/SKILL.md +0 -84
  112. package/skills/eclink/agents/openai.yaml +0 -4
@@ -33,6 +33,9 @@ function normalizeReturnContent(content) {
33
33
  function contentHash(content) {
34
34
  return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
35
35
  }
36
+ function newReturnResultId() {
37
+ return `hr-${crypto.randomBytes(8).toString('hex')}`;
38
+ }
36
39
  function boundedQueryLimit(limit) {
37
40
  if (typeof limit !== 'number' || !Number.isInteger(limit))
38
41
  return HANDOFF_QUERY_DEFAULT_LIMIT;
@@ -111,6 +114,9 @@ export class HandoffStore {
111
114
  fs.renameSync(pendingPath, filePath);
112
115
  }
113
116
  create(input) {
117
+ if (input.handoffContext !== undefined && (typeof input.handoffContext !== 'string'
118
+ || !input.handoffContext.trim() || input.handoffContext.length > 64 * 1024))
119
+ throw new Error('invalid handoff context');
114
120
  const now = input.now ?? Date.now();
115
121
  const handoffId = newHandoffId();
116
122
  const instance = {
@@ -123,6 +129,7 @@ export class HandoffStore {
123
129
  payload: clonePayload(input.payload),
124
130
  encrypt: input.encrypt,
125
131
  ...(input.contextOnly ? { contextOnly: true } : {}),
132
+ ...(input.handoffContext ? { context: input.handoffContext } : {}),
126
133
  ...(input.delivery ? { delivery: input.delivery } : {}),
127
134
  },
128
135
  ...(input.originAuthorization
@@ -143,6 +150,7 @@ export class HandoffStore {
143
150
  return_content: null,
144
151
  return_content_hash: null,
145
152
  origin_delivery_message_id: null,
153
+ ...((input.returnPolicy ?? 'required') === 'required' && !input.contextOnly ? { return_results: [] } : {}),
146
154
  attention_required: false,
147
155
  attention_reason: null,
148
156
  version: 1,
@@ -401,7 +409,12 @@ export class HandoffStore {
401
409
  if ('ok' in selected)
402
410
  return selected;
403
411
  const { instance, selectedBy } = selected;
404
- if (instance.target_session_id !== input.currentSessionId || instance.consumed_target_session_id !== input.currentSessionId) {
412
+ const directSessionMatch = instance.target_session_id === input.currentSessionId
413
+ && instance.consumed_target_session_id === input.currentSessionId;
414
+ const recoveryAuthorized = !!input.recovery
415
+ && input.recovery.recoverySessionId === input.currentSessionId
416
+ && input.recovery.actorId === instance.origin_authorization?.actorId;
417
+ if (!directSessionMatch && !recoveryAuthorized) {
405
418
  return {
406
419
  ok: false,
407
420
  code: 'HANDOFF_TARGET_SESSION_MISMATCH',
@@ -430,6 +443,110 @@ export class HandoffStore {
430
443
  };
431
444
  }
432
445
  const hash = contentHash(normalized);
446
+ const previousState = instance.state;
447
+ // New required handoffs expose an append-only result stream. The parent
448
+ // remains return_pending while each result is delivered and consumed.
449
+ if (instance.return_policy === 'required' && Array.isArray(instance.return_results)) {
450
+ if (instance.state !== 'return_pending' && instance.state !== 'completed') {
451
+ const existing = instance.return_results.find(result => result.content_hash === hash);
452
+ if (existing) {
453
+ return {
454
+ ok: true,
455
+ code: 'HANDOFF_RETURN_ALREADY_APPLIED',
456
+ handoff_id: instance.handoff_id,
457
+ selected_by: selectedBy,
458
+ previous_state: instance.state,
459
+ state: instance.state,
460
+ idempotent: true,
461
+ result_id: existing.result_id,
462
+ };
463
+ }
464
+ return {
465
+ ok: false,
466
+ code: 'HANDOFF_NOT_RETURNABLE',
467
+ error: 'handoff is not return_pending',
468
+ handoff_id: instance.handoff_id,
469
+ state: instance.state,
470
+ };
471
+ }
472
+ if (!normalized.trim()) {
473
+ return {
474
+ ok: false,
475
+ code: 'HANDOFF_RETURN_CONTENT_REQUIRED',
476
+ error: 'return content is required',
477
+ handoff_id: instance.handoff_id,
478
+ state: instance.state,
479
+ };
480
+ }
481
+ const existing = instance.return_results.find(result => result.content_hash === hash);
482
+ if (existing) {
483
+ return {
484
+ ok: true,
485
+ code: 'HANDOFF_RETURN_ALREADY_APPLIED',
486
+ handoff_id: instance.handoff_id,
487
+ selected_by: selectedBy,
488
+ previous_state: instance.state,
489
+ state: instance.state,
490
+ idempotent: true,
491
+ result_id: existing.result_id,
492
+ };
493
+ }
494
+ const now = input.now ?? Date.now();
495
+ const previousVersion = instance.version;
496
+ const result = {
497
+ result_id: newReturnResultId(),
498
+ seq: instance.return_results.reduce((max, item) => Math.max(max, item.seq), 0) + 1,
499
+ content: normalized,
500
+ content_hash: hash,
501
+ state: 'origin_queued',
502
+ origin_delivery_message_id: null,
503
+ return_causation: normalizeCausation(input.returnCausation),
504
+ created_at: now,
505
+ updated_at: now,
506
+ completed_at: null,
507
+ };
508
+ instance.return_results.push(result);
509
+ instance.return_causation = result.return_causation;
510
+ instance.version++;
511
+ instance.updated_at = now;
512
+ try {
513
+ this.appendEvent(input.selfAid, 'return_accepted', instance.handoff_id, {
514
+ operationKey: `handoff-return:${instance.handoff_id}:${result.result_id}`,
515
+ fromVersion: previousVersion,
516
+ toVersion: instance.version,
517
+ details: {
518
+ return_policy: instance.return_policy,
519
+ result_id: result.result_id,
520
+ seq: result.seq,
521
+ ...(recoveryAuthorized
522
+ ? { recovery: true, recovery_session_id: input.currentSessionId, recovery_actor_id: input.recovery?.actorId }
523
+ : {}),
524
+ },
525
+ now,
526
+ });
527
+ this.write(input.selfAid, instance);
528
+ }
529
+ catch {
530
+ return {
531
+ ok: false,
532
+ code: 'HANDOFF_STORE_WRITE_FAILED',
533
+ error: 'failed to persist handoff return',
534
+ handoff_id: instance.handoff_id,
535
+ state: previousState,
536
+ retryable: true,
537
+ };
538
+ }
539
+ return {
540
+ ok: true,
541
+ code: 'HANDOFF_RETURN_ACCEPTED',
542
+ handoff_id: instance.handoff_id,
543
+ selected_by: selectedBy,
544
+ previous_state: previousState,
545
+ state: instance.state,
546
+ idempotent: false,
547
+ result_id: result.result_id,
548
+ };
549
+ }
433
550
  if (instance.state !== 'return_pending') {
434
551
  if (instance.return_content_hash) {
435
552
  if (instance.return_content_hash === hash) {
@@ -469,7 +586,6 @@ export class HandoffStore {
469
586
  };
470
587
  }
471
588
  const now = input.now ?? Date.now();
472
- const previousState = instance.state;
473
589
  const previousVersion = instance.version;
474
590
  instance.state = instance.return_policy === 'none' ? 'completed' : 'origin_queued';
475
591
  instance.return_content = instance.return_policy === 'none' ? null : normalized;
@@ -483,7 +599,12 @@ export class HandoffStore {
483
599
  operationKey: `handoff-return:${instance.handoff_id}`,
484
600
  fromVersion: previousVersion,
485
601
  toVersion: instance.version,
486
- details: { return_policy: instance.return_policy },
602
+ details: {
603
+ return_policy: instance.return_policy,
604
+ ...(recoveryAuthorized
605
+ ? { recovery: true, recovery_session_id: input.currentSessionId, recovery_actor_id: input.recovery?.actorId }
606
+ : {}),
607
+ },
487
608
  now,
488
609
  });
489
610
  this.write(input.selfAid, instance);
@@ -524,6 +645,31 @@ export class HandoffStore {
524
645
  this.write(selfAid, instance);
525
646
  return instance;
526
647
  }
648
+ recordOriginResultEnqueued(selfAid, handoffId, resultId, messageId, now = Date.now()) {
649
+ const instance = this.get(selfAid, handoffId);
650
+ if (!instance)
651
+ throw new Error('handoff not found');
652
+ const result = instance.return_results?.find(item => item.result_id === resultId);
653
+ if (!result)
654
+ throw new Error('handoff result not found');
655
+ if (result.state !== 'origin_queued')
656
+ throw new Error(`handoff result state ${result.state}, expected origin_queued`);
657
+ const previous = instance.version;
658
+ result.state = 'origin_delivered';
659
+ result.origin_delivery_message_id = messageId;
660
+ result.updated_at = now;
661
+ instance.version++;
662
+ instance.updated_at = now;
663
+ this.appendEvent(selfAid, 'origin_enqueue_succeeded', handoffId, {
664
+ operationKey: messageId,
665
+ fromVersion: previous,
666
+ toVersion: instance.version,
667
+ details: { result_id: resultId },
668
+ now,
669
+ });
670
+ this.write(selfAid, instance);
671
+ return instance;
672
+ }
527
673
  recordOriginEnqueueFailed(selfAid, handoffId, error, now = Date.now()) {
528
674
  const instance = this.requireState(selfAid, handoffId, 'origin_queued');
529
675
  this.appendEvent(selfAid, 'origin_enqueue_failed', handoffId, {
@@ -532,6 +678,19 @@ export class HandoffStore {
532
678
  now,
533
679
  });
534
680
  }
681
+ recordOriginResultEnqueueFailed(selfAid, handoffId, resultId, error, now = Date.now()) {
682
+ const instance = this.get(selfAid, handoffId);
683
+ if (!instance)
684
+ throw new Error('handoff not found');
685
+ if (!instance.return_results?.some(item => item.result_id === resultId)) {
686
+ throw new Error('handoff result not found');
687
+ }
688
+ this.appendEvent(selfAid, 'origin_enqueue_failed', handoffId, {
689
+ operationKey: `origin-deliver:${handoffId}:${resultId}:${instance.origin_session_id}`,
690
+ details: { error, result_id: resultId },
691
+ now,
692
+ });
693
+ }
535
694
  completeOriginContext(selfAid, handoffId, now = Date.now()) {
536
695
  const instance = this.requireState(selfAid, handoffId, 'origin_delivered');
537
696
  const previous = instance.version;
@@ -548,6 +707,35 @@ export class HandoffStore {
548
707
  this.write(selfAid, instance);
549
708
  return instance;
550
709
  }
710
+ completeOriginResult(selfAid, handoffId, resultId, now = Date.now()) {
711
+ const instance = this.get(selfAid, handoffId);
712
+ if (!instance)
713
+ throw new Error('handoff not found');
714
+ const result = instance.return_results?.find(item => item.result_id === resultId);
715
+ if (!result)
716
+ throw new Error('handoff result not found');
717
+ if (result.state !== 'origin_delivered')
718
+ throw new Error(`handoff result state ${result.state}, expected origin_delivered`);
719
+ const previous = instance.version;
720
+ result.state = 'completed';
721
+ result.updated_at = now;
722
+ result.completed_at = now;
723
+ if (instance.return_results.every(item => item.state === 'completed')) {
724
+ instance.state = 'completed';
725
+ instance.completed_at = now;
726
+ }
727
+ instance.version++;
728
+ instance.updated_at = now;
729
+ this.appendEvent(selfAid, 'origin_context_consumed', handoffId, {
730
+ operationKey: result.origin_delivery_message_id ?? undefined,
731
+ fromVersion: previous,
732
+ toVersion: instance.version,
733
+ details: { result_id: resultId },
734
+ now,
735
+ });
736
+ this.write(selfAid, instance);
737
+ return instance;
738
+ }
551
739
  discard(selfAid, handoffId, reason, details = {}, now = Date.now()) {
552
740
  const instance = this.get(selfAid, handoffId);
553
741
  if (!instance)
@@ -617,7 +805,8 @@ export class HandoffStore {
617
805
  };
618
806
  }
619
807
  requestSummary(instance) {
620
- return classifyAunPayloadForLog(instance.request.payload).content || '[payload]';
808
+ const sent = classifyAunPayloadForLog(instance.request.payload).content || '[payload]';
809
+ return instance.request.context ? `${sent}\n\n[本地交接上下文]\n${instance.request.context}` : sent;
621
810
  }
622
811
  selectReturnCandidate(input) {
623
812
  if (input.handoffId) {
@@ -1,6 +1,7 @@
1
1
  import { logger } from '../../utils/logger.js';
2
2
  import { summarizeToolInput, toolInputForDisplay } from '../../utils/tool-summary.js';
3
3
  import { getErrorMessage, isContextTooLongText } from '../../utils/error-utils.js';
4
+ import { tagErrorTextWithEvolcoreVersion } from '../../utils/evolcore-version.js';
4
5
  import { DEFAULT_FLUSH_DELAY_MS } from '../../types.js';
5
6
  export class IMRenderer {
6
7
  opts;
@@ -79,9 +80,9 @@ export class IMRenderer {
79
80
  }
80
81
  /** 是否有会抑制兜底错误消息的正文/过程内容;lifecycle 边界不算。 */
81
82
  hasNonLifecycleContent() {
82
- return this.textBuffer.length > 0 || this.itemsQueue.some(it => {
83
+ return this.textBuffer.trim().length > 0 || this.itemsQueue.some(it => {
83
84
  if (it.kind === 'text')
84
- return it.text.length > 0;
85
+ return it.text.trim().length > 0;
85
86
  return it.kind !== 'started' && it.kind !== 'completed';
86
87
  });
87
88
  }
@@ -95,6 +96,21 @@ export class IMRenderer {
95
96
  return;
96
97
  if (this.textBuffer.length === 0)
97
98
  return;
99
+ // 文件标记过滤后若只剩空白,应立即消费并告警;尤其避免 final-only
100
+ // 模式把工具调用前的空白继续带到最终回复。
101
+ const rawText = this.opts.fileMarkerPattern
102
+ ? this.textBuffer.replace(this.opts.fileMarkerPattern, '')
103
+ : this.textBuffer;
104
+ if (!rawText.trim()) {
105
+ if (this.timer) {
106
+ clearTimeout(this.timer);
107
+ this.timer = undefined;
108
+ }
109
+ this.textBuffer = '';
110
+ this.itemsQueue = this.itemsQueue.filter(it => it.kind !== 'text');
111
+ this.hasSendableText(rawText, 'flushText');
112
+ return;
113
+ }
98
114
  if (this.suppressIntermediateText()) {
99
115
  this.itemsQueue = this.itemsQueue.filter(it => it.kind !== 'text');
100
116
  return;
@@ -103,14 +119,8 @@ export class IMRenderer {
103
119
  clearTimeout(this.timer);
104
120
  this.timer = undefined;
105
121
  }
106
- // 文件标记过滤:tool_use 前的文本块也不能把 marker 暴露给用户
107
- const rawText = this.opts.fileMarkerPattern
108
- ? this.textBuffer.replace(this.opts.fileMarkerPattern, '')
109
- : this.textBuffer;
110
122
  this.textBuffer = '';
111
- if (!rawText)
112
- return;
113
- // 清掉 itemsQueue 中的 text items(已发出)
123
+ // 清掉已消费的 text items,包括被拦截的纯空白正文。
114
124
  this.itemsQueue = this.itemsQueue.filter(it => it.kind !== 'text');
115
125
  const payload = { kind: 'result.text', text: rawText, isFinal: false };
116
126
  this.sentContent = true;
@@ -164,6 +174,7 @@ export class IMRenderer {
164
174
  this.emitProgress('text', outputTokens, turn);
165
175
  if (!text)
166
176
  return;
177
+ // 保留流式片段之间的空格/换行;仅在发送完整正文时判定是否纯空白。
167
178
  // 同一窗口内连续 text delta 合并到最后一个 text item
168
179
  const last = this.itemsQueue[this.itemsQueue.length - 1];
169
180
  if (last && last.kind === 'text') {
@@ -302,6 +313,8 @@ export class IMRenderer {
302
313
  async sendOperationalNoticeAsText(text) {
303
314
  if (!this.opts.operationalNoticesAsText || this.opts.envelope.chatmode === 'proactive' || !text)
304
315
  return false;
316
+ if (!this.hasSendableText(text, 'operational-notice'))
317
+ return false;
305
318
  // 保持与流式正文的到达顺序:先发出已缓冲的正文,再投影运行状态。
306
319
  await this.flushText();
307
320
  const payload = { kind: 'result.text', text, isFinal: false };
@@ -315,6 +328,17 @@ export class IMRenderer {
315
328
  synthCallId() {
316
329
  return `synth-${this.opts.envelope.taskId}-${++this.syntheticCallSeq}`;
317
330
  }
331
+ /** Reject blank bodies before sending, without trimming meaningful formatting. */
332
+ hasSendableText(text, source) {
333
+ if (text.trim())
334
+ return true;
335
+ // An unused buffer is normal. A nonempty whitespace body is diagnostic evidence.
336
+ if (text.length > 0) {
337
+ const { envelope, agentAid } = this.opts;
338
+ logger.warn(`[IMRenderer] Attempted to send empty message, skipping: code=EMPTY_MESSAGE source=${source} task=${envelope.taskId} session=${envelope.sessionId ?? '<none>'} agent=${agentAid ?? envelope.agentName} textLength=${text.length} text=${JSON.stringify(text.slice(0, 80))}`);
339
+ }
340
+ return false;
341
+ }
318
342
  /** Convert a structured failed receipt into the same error shape as a rejected sender. */
319
343
  receiptError(receipt) {
320
344
  return Object.assign(new Error(receipt.error), {
@@ -331,6 +355,18 @@ export class IMRenderer {
331
355
  : undefined;
332
356
  return value.code === 'OUTBOX_FULL' || cause?.code === 'OUTBOX_FULL';
333
357
  }
358
+ isPermanentActivityError(error) {
359
+ const value = error && typeof error === 'object' ? error : {};
360
+ const cause = value.cause && typeof value.cause === 'object'
361
+ ? value.cause
362
+ : undefined;
363
+ const code = String(value.code ?? cause?.code ?? '');
364
+ return value.permanent === true
365
+ || value.status === 'failed'
366
+ || code === 'AUN_SEND_FAILED'
367
+ || code === '-1'
368
+ || /payload is too large/i.test(String(value.message ?? value.error ?? ''));
369
+ }
334
370
  /**
335
371
  * Serialize durable sends while keeping the chain usable after a rejected
336
372
  * operation. Activity queue saturation is best-effort: it is logged, but
@@ -344,9 +380,19 @@ export class IMRenderer {
344
380
  }
345
381
  });
346
382
  const handled = operation.catch(error => {
347
- if (payload.kind === 'activity.batch' && this.isOutboxFull(error)) {
348
- logger.warn(`[IMRenderer] activity outbox full; continuing without activity batch: ${error instanceof Error ? error.message : String(error)}`);
349
- return;
383
+ if (payload.kind === 'activity.batch') {
384
+ const detail = error instanceof Error ? error.message : String(error);
385
+ if (this.isOutboxFull(error)) {
386
+ logger.warn(`[IMRenderer] activity outbox full; continuing without activity batch: ${detail}`);
387
+ return;
388
+ }
389
+ if (this.isPermanentActivityError(error)) {
390
+ // Activity is best-effort. A permanent structured-send failure (for
391
+ // example an AUN payload-size rejection) must not requeue the same
392
+ // item indefinitely or prevent the final result.text from flushing.
393
+ logger.warn(`[IMRenderer] activity delivery failed; dropping activity batch and continuing: ${detail}`);
394
+ return;
395
+ }
350
396
  }
351
397
  throw error;
352
398
  });
@@ -432,22 +478,25 @@ export class IMRenderer {
432
478
  // 文件标记过滤:marker 任何时候都不该出现在用户文本里,必须在 *每次* flush
433
479
  // (含非最终的定时 flush)执行。否则文本块若滞留 buffer 后被定时器触发,会走
434
480
  // 下方非-final 的 result.text 路径,把 [SEND_FILE:...] 原样发给用户。
435
- // 非最终 flush 不做 trim,避免 trim 掉 Markdown 块级换行。
481
+ // 先保留空白,以便空正文告警仍能记录原长度。
436
482
  if (this.opts.fileMarkerPattern) {
437
- const strip = (s) => {
438
- const replaced = s.replace(this.opts.fileMarkerPattern, '');
439
- return isFinal ? replaced.trim() : replaced;
440
- };
441
- this.textBuffer = strip(this.textBuffer);
483
+ this.textBuffer = this.textBuffer.replace(this.opts.fileMarkerPattern, '');
442
484
  for (const item of this.itemsQueue) {
443
485
  if (item.kind === 'text')
444
486
  item.text = item.text.replace(this.opts.fileMarkerPattern, '');
445
487
  }
446
488
  }
489
+ if (!this.hasSendableText(this.textBuffer, isFinal ? 'final' : 'flush')) {
490
+ this.textBuffer = '';
491
+ }
492
+ else if (isFinal && this.opts.fileMarkerPattern) {
493
+ // 保持既有最终文件标记过滤语义;中间文本的格式原样保留。
494
+ this.textBuffer = this.textBuffer.trim();
495
+ }
447
496
  // 清掉空 text items
448
497
  const items = this.itemsQueue.filter(it => {
449
498
  if (it.kind === 'text')
450
- return it.text.length > 0;
499
+ return it.text.trim().length > 0;
451
500
  return true;
452
501
  });
453
502
  this.itemsQueue = [];
@@ -544,6 +593,8 @@ export class IMRenderer {
544
593
  if (!item)
545
594
  return;
546
595
  if (item.kind === 'text') {
596
+ if (!this.hasSendableText(item.text, 'proactive'))
597
+ return;
547
598
  this.hasEmittedText = true;
548
599
  this.allText += item.text;
549
600
  }
@@ -633,7 +684,7 @@ export class IMRenderer {
633
684
  if (isContextTooLongText(event.error || ''))
634
685
  return null;
635
686
  const text = getErrorMessage(new Error(event.error || '任务执行失败'), undefined, false);
636
- return { kind: 'notice', text, severity: 'warn' };
687
+ return { kind: 'notice', text: tagErrorTextWithEvolcoreVersion(text), severity: 'warn' };
637
688
  }
638
689
  case 'complete': {
639
690
  if (event.queryFinal === false)
@@ -650,7 +701,7 @@ export class IMRenderer {
650
701
  const text = getErrorMessage(new Error(errText), event.terminalReason, false);
651
702
  const item = {
652
703
  kind: 'summary',
653
- text,
704
+ text: tagErrorTextWithEvolcoreVersion(text),
654
705
  is_error: true,
655
706
  };
656
707
  if (event.subtype !== undefined)
@@ -25,7 +25,7 @@ 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 { parseFullAccessCommand } from '../command/slash-gate.js';
28
+ import { guardGroupSlashCommand, parseFullAccessCommand } from '../command/slash-gate.js';
29
29
  import { isFullAccessEnabled } from '../../config-store.js';
30
30
  import { auditFullAccessEvent } from '../auth/authorization-audit.js';
31
31
  import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './message-admission.js';
@@ -500,9 +500,31 @@ export class MessageBridge {
500
500
  async sendAdmissionControlReply(context, msg, sendReply, text) {
501
501
  if (!text || !consumeAdmissionReplyBudget(context))
502
502
  return false;
503
- await sendReply(msg.channelId, text, msg.replyContext);
503
+ if (context.chatType === 'group' && /^\s*\/(?:bind|request)(?:\s|$)/i.test(msg.content)) {
504
+ await this.sendSlashControlReply(context.channelKey, msg, sendReply, { kind: 'command.error', text });
505
+ }
506
+ else {
507
+ await sendReply(msg.channelId, text, msg.replyContext);
508
+ }
504
509
  return true;
505
510
  }
511
+ /** Controls handled before the normal slash handler still need structured group feedback. */
512
+ async sendSlashControlReply(channel, msg, sendReply, payload, adapter = this.processor.getChannelInfo?.(channel)?.adapter) {
513
+ if (msg.chatType === 'group' && adapter?.send) {
514
+ await adapter.send(buildEnvelope({
515
+ taskId: `cmd-${randomBytes(5).toString('hex')}`,
516
+ channel,
517
+ channelId: msg.channelId,
518
+ chatmode: 'interactive',
519
+ replyContext: {
520
+ ...msg.replyContext,
521
+ ...(msg.threadId ? { threadId: msg.threadId } : {}),
522
+ },
523
+ }), payload);
524
+ return;
525
+ }
526
+ await sendReply(msg.channelId, payload.text, msg.replyContext);
527
+ }
506
528
  admissionDeniedNotice(context, channelType) {
507
529
  if (context.contactStatus === 'pending') {
508
530
  return '联系人申请审核中;可再次发送 /request 更新说明。';
@@ -801,6 +823,16 @@ export class MessageBridge {
801
823
  : { isMenu: false };
802
824
  let menuResponseWarning;
803
825
  if (menuControl.isMenu) {
826
+ // Menu is an AUN private control plane. Group messages must never
827
+ // execute Menu commands or participate in token issuance/rotation.
828
+ if (msg.chatType === 'group') {
829
+ this.logMenuDiagnostic('unsupported-chat-type', channelName, msg, menuControl, 'MENU_PRIVATE_ONLY');
830
+ logMenuRequestCompleted(menuControl.request, undefined, this.menuFlowContext(channelName, msg), {
831
+ delivery: 'dropped',
832
+ reason: 'unsupported-chat-type',
833
+ });
834
+ return;
835
+ }
804
836
  // AUN captures this mark at its adapter boundary. Legacy/test
805
837
  // adapters fall back to the bridge entry mark captured above.
806
838
  Object.assign(msg, menuTiming);
@@ -837,7 +869,10 @@ export class MessageBridge {
837
869
  const tokenSupplied = Object.prototype.hasOwnProperty.call(menuControl.request, 'menu_token');
838
870
  const tokenValid = (menuTokenRequired || tokenSupplied)
839
871
  && !!tokenOwnerAid
840
- && this.menuTokenStore.use(tokenOwnerAid, msg.peerId, menuControl.request.menu_token);
872
+ && this.menuTokenStore.use(tokenOwnerAid, msg.peerId, menuControl.request.menu_token, {
873
+ deviceId: msg.peerDeviceId,
874
+ slotId: msg.peerSlotId,
875
+ });
841
876
  if (menuTokenRequired && !tokenValid) {
842
877
  this.logMenuDiagnostic('token-required', channelName, msg, menuControl, 'MENU_TOKEN_REQUIRED');
843
878
  await this.sendMenuResponse(adapter, channelName, msg, menuFailure(header, {
@@ -915,6 +950,11 @@ export class MessageBridge {
915
950
  // internal per-call authorization carried into ResponseEngine.
916
951
  const fullAccessCommand = parseFullAccessCommand(content);
917
952
  if (fullAccessCommand.matched) {
953
+ const groupGuard = guardGroupSlashCommand(content, chatType);
954
+ if (groupGuard?.kind === 'command.error') {
955
+ await this.sendSlashControlReply(channelName, msg, sendReply, groupGuard, adapter);
956
+ return;
957
+ }
918
958
  const reject = async (text) => {
919
959
  logger.warn(`[MessageBridge] fullaccess command rejected: channel=${channelName} `
920
960
  + `actor=${authSubject.principalId ?? authSubject.actorId ?? '<none>'} reason=${text}`);
@@ -1002,6 +1042,13 @@ export class MessageBridge {
1002
1042
  // 2. 命令快速路径(去除引用前缀后检查,兼容话题中引用上文的情况)
1003
1043
  const contentForCmd = content.replace(/^(>[^\n]*\n)+\n?/, '').trim();
1004
1044
  const cmdContent = contentForCmd || content;
1045
+ if (msg.source !== 'handoff' && !executionPermissionOverride) {
1046
+ const groupGuard = guardGroupSlashCommand(cmdContent, chatType);
1047
+ if (groupGuard?.kind === 'command.error') {
1048
+ await this.sendSlashControlReply(channelName, msg, sendReply, groupGuard, adapter);
1049
+ return;
1050
+ }
1051
+ }
1005
1052
  const isCmd = msg.source !== 'handoff'
1006
1053
  // Once /fa has been accepted, its remainder is always model input.
1007
1054
  // A prompt such as `/fa /status` must not be re-routed as the
@@ -1411,7 +1458,10 @@ export class MessageBridge {
1411
1458
  }
1412
1459
  }
1413
1460
  const scope = msg.chatType === 'group' ? (msg.groupId || msg.channelId) : msg.peerId;
1414
- const dedupKey = [msg.selfAID || channel, channelType, msg.chatType || 'private', scope, msg.peerId, msg.threadId || '', parsed.id].join('\u001f');
1461
+ // Separate installations can generate identical request IDs. Token
1462
+ // issuance and replay must remain scoped to the authenticated requester.
1463
+ const dedupKey = [msg.selfAID || channel, channelType, msg.chatType || 'private', scope, msg.peerId,
1464
+ msg.peerDeviceId || '', msg.peerSlotId || '', msg.threadId || '', parsed.id].join('\u001f');
1415
1465
  let afterResponse;
1416
1466
  const deduped = await this.menuDeduper.execute(dedupKey, menuPayloadFingerprint(parsed.raw), () => isTokenRequest
1417
1467
  ? this.issueMenuTokenResponse(header, channel, msg)
@@ -1452,7 +1502,9 @@ export class MessageBridge {
1452
1502
  case 'menu.list':
1453
1503
  if (args?.view === 'catalog') {
1454
1504
  validateMenuCatalogArgs(args);
1455
- const data = await this.cmdHandler.getMenuCatalog(channel, msg.channelId, authSubject, args.context, msg.isControlChannel ? 'control' : 'agent');
1505
+ const data = await this.cmdHandler.getMenuCatalog(channel, msg.channelId, authSubject, args.context, msg.isControlChannel ? 'control' : 'agent', !msg.isControlChannel || (msg.encrypted === true
1506
+ && msg.protectedHeaders?.auth_method !== 'remote-access-token'
1507
+ && msg.protectedHeaders?.auth_method !== 'ecweb-token'));
1456
1508
  return menuSuccess(header, data);
1457
1509
  }
1458
1510
  if (args?.view !== undefined) {
@@ -1615,6 +1667,9 @@ export class MessageBridge {
1615
1667
  clientName: this.menuTokenHeaderValue(headers.client_name ?? headers.app_name),
1616
1668
  clientVersion: this.menuTokenHeaderValue(headers.client_version ?? headers.app_version),
1617
1669
  evolVersion: this.menuTokenHeaderValue(headers.evol_version),
1670
+ }, {
1671
+ deviceId: msg.peerDeviceId,
1672
+ slotId: msg.peerSlotId,
1618
1673
  });
1619
1674
  if (!issued) {
1620
1675
  return menuFailure(header, { code: 'MENU_TOKEN_REJECTED', message: '授权申请未通过' });
@@ -1732,20 +1787,25 @@ export class MessageBridge {
1732
1787
  logger.info(`[${channel}] ${channelId}: ${content}${source === 'card-trigger' ? ' [card]' : ''}`);
1733
1788
  const adapter = this.processor.getChannelInfo?.(channel)?.adapter;
1734
1789
  const cmdResult = await this.cmdHandler.handle(content, channel, channelId, async (_cid, text, opts, updatePayload) => {
1735
- if (updatePayload && adapter?.send) {
1790
+ const payload = updatePayload ?? (chatType === 'group' ? { kind: 'command.result', text } : undefined);
1791
+ if (payload && adapter?.send) {
1736
1792
  const envelope = buildEnvelope({
1737
1793
  taskId: `cmd-update-${randomBytes(5).toString('hex')}`,
1738
1794
  channel,
1739
1795
  channelId,
1740
1796
  agentName: this.agentRegistry?.resolveByChannel(channel)?.name ?? '<unknown>',
1741
1797
  chatmode: 'interactive',
1742
- replyContext: opts ?? replyContext,
1798
+ replyContext: {
1799
+ ...(opts ?? {}),
1800
+ ...(replyContext ?? {}),
1801
+ metadata: { ...opts?.metadata, ...replyContext?.metadata },
1802
+ },
1743
1803
  });
1744
- await adapter.send(envelope, updatePayload);
1804
+ await adapter.send(envelope, payload);
1745
1805
  return;
1746
1806
  }
1747
1807
  await sendReply(text, opts, updatePayload);
1748
- }, userId, threadId, chatType, source, messageId, selfAID, authSubject?.identity, authSubject, replyContext);
1808
+ }, userId, threadId, chatType, source, messageId, selfAID, authSubject?.identity, authSubject, replyContext, true);
1749
1809
  logger.debug(`[MessageBridge] handleCommand: result type=${typeof cmdResult}`);
1750
1810
  if (cmdResult === undefined)
1751
1811
  return false;