evolcore 0.0.2 → 0.0.3

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 (124) hide show
  1. package/CHANGELOG.md +44 -793
  2. package/dist/agents/claude-runner.js +197 -17
  3. package/dist/agents/codex-runner.js +46 -3
  4. package/dist/aun/outbox.js +8 -0
  5. package/dist/channels/aun.js +21 -4
  6. package/dist/channels/contact-bind-code.js +134 -0
  7. package/dist/channels/dingtalk.js +979 -149
  8. package/dist/channels/feishu.js +130 -54
  9. package/dist/channels/wecom-card.js +101 -0
  10. package/dist/channels/wecom-onboarding.js +82 -0
  11. package/dist/channels/wecom-state.js +191 -0
  12. package/dist/channels/wecom.js +755 -163
  13. package/dist/cli/agent-command.js +2 -1
  14. package/dist/cli/aun-commands.js +88 -33
  15. package/dist/cli/bench.js +2 -2
  16. package/dist/cli/contact.js +71 -0
  17. package/dist/cli/ctl-command.js +2 -2
  18. package/dist/cli/daemon-commands.js +77 -214
  19. package/dist/cli/handoff-command.js +2 -2
  20. package/dist/cli/help.js +9 -5
  21. package/dist/cli/index.js +132 -116
  22. package/dist/cli/init-channel.js +92 -97
  23. package/dist/cli/init.js +63 -26
  24. package/dist/cli/model.js +2 -1
  25. package/dist/cli/net-check.js +2 -2
  26. package/dist/cli/queue-command.js +30 -6
  27. package/dist/cli/raw-key-input.js +25 -0
  28. package/dist/cli/response.js +5 -6
  29. package/dist/cli/restart-monitor.js +25 -1
  30. package/dist/cli/stats.js +6 -4
  31. package/dist/cli/trigger-command.js +55 -15
  32. package/dist/cli/version.js +6 -1
  33. package/dist/config/builtin-role-templates.js +22 -10
  34. package/dist/config/builtin-roles.js +7 -1
  35. package/dist/config/config-manager.js +221 -17
  36. package/dist/config/contact-alias.js +68 -0
  37. package/dist/config/contact-book-store.js +454 -0
  38. package/dist/config/contact-book-v2-startup.js +35 -0
  39. package/dist/config/contact-book.js +156 -303
  40. package/dist/config/contact-operation-service.js +110 -0
  41. package/dist/config/peer-role-resolver.js +133 -54
  42. package/dist/config/role-ranks.js +18 -0
  43. package/dist/config/role-service.js +16 -19
  44. package/dist/config/role-store.js +16 -5
  45. package/dist/config/roles.js +10 -1
  46. package/dist/config-store.js +0 -2
  47. package/dist/core/auth/authorization-audit.js +10 -1
  48. package/dist/core/auth/operation-authorizer.js +2 -0
  49. package/dist/core/auth/operation-catalog.js +56 -0
  50. package/dist/core/command/command-handler.js +105 -10
  51. package/dist/core/command/connect-menu.js +374 -0
  52. package/dist/core/command/menu-handler.js +114 -16
  53. package/dist/core/command/role-menu.js +128 -29
  54. package/dist/core/command/slash-handler.js +28 -18
  55. package/dist/core/daemon-file-cache.js +12 -6
  56. package/dist/core/event-catalog.js +70 -0
  57. package/dist/core/evolagent-registry.js +0 -1
  58. package/dist/core/evolagent.js +20 -9
  59. package/dist/core/message/im-renderer.js +2 -0
  60. package/dist/core/message/message-bridge.js +79 -8
  61. package/dist/core/message/message-queue.js +10 -0
  62. package/dist/core/message/message-utils.js +8 -2
  63. package/dist/core/message/response-engine.js +269 -25
  64. package/dist/core/message/send-receipt.js +24 -0
  65. package/dist/core/message/stream-debouncer.js +11 -2
  66. package/dist/core/permission/tool-policy.js +47 -15
  67. package/dist/core/protected-paths.js +2 -0
  68. package/dist/core/session/session-fs-store.js +44 -3
  69. package/dist/core/session/session-manager.js +61 -5
  70. package/dist/index.js +62 -6
  71. package/dist/ipc.js +34 -5
  72. package/dist/stats/billing.js +20 -8
  73. package/dist/trigger/manager.js +3 -0
  74. package/dist/trigger/parser.js +77 -2
  75. package/dist/trigger/patch.js +8 -1
  76. package/dist/trigger/scheduler.js +3 -1
  77. package/dist/trigger/validation.js +13 -0
  78. package/dist/utils/aid-bind.js +43 -29
  79. package/dist/utils/instance-registry.js +14 -7
  80. package/dist/utils/log-writer.js +46 -0
  81. package/dist/utils/media-cache.js +4 -1
  82. package/dist/utils/model-prices.jsonl +6 -3
  83. package/dist/utils/restart-safety.js +31 -0
  84. package/dist/utils/system-memory.js +62 -0
  85. package/kits/docs/INDEX.md +2 -1
  86. package/kits/docs/evolcore/INDEX.md +5 -3
  87. package/kits/docs/evolcore/agent.md +9 -1
  88. package/kits/docs/evolcore/aid.md +5 -2
  89. package/kits/docs/evolcore/contact.md +57 -0
  90. package/kits/docs/evolcore/fs.md +9 -0
  91. package/kits/docs/evolcore/group.md +12 -3
  92. package/kits/docs/evolcore/model.md +4 -1
  93. package/kits/docs/evolcore/msg.md +9 -3
  94. package/kits/docs/evolcore/response.md +16 -21
  95. package/kits/docs/evolcore/rpc.md +2 -0
  96. package/kits/docs/evolcore/stats.md +15 -2
  97. package/kits/docs/evolcore/storage.md +1 -0
  98. package/kits/docs/evolcore/trigger.md +17 -2
  99. package/kits/eck_manifest.json +12 -0
  100. package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
  101. package/kits/schemas/_meta.json +7 -4
  102. package/kits/schemas/agent-config.schema.6.json +322 -0
  103. package/kits/schemas/contact-book.schema.2.json +43 -0
  104. package/kits/schemas/relation-config.schema.5.json +47 -0
  105. package/kits/schemas/role-config.schema.1.json +1 -0
  106. package/kits/schemas/role-registry.schema.1.json +2 -2
  107. package/kits/templates/roles/admin.json +1 -0
  108. package/kits/templates/roles/member.json +1 -0
  109. package/kits/templates/roles/owner.json +1 -0
  110. package/kits/templates/roles/visitor.json +1 -0
  111. package/kits/templates/system-fragments/commands.md +3 -1
  112. package/package.json +4 -4
  113. package/assets/brand/evolcore/README.md +0 -19
  114. package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
  115. package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
  116. package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
  117. package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
  118. package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
  119. package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
  120. package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
  121. package/assets/brand/evolcore/evolcore-logo.png +0 -0
  122. package/assets/brand/evolcore/evolcore-logo.svg +0 -14
  123. package/assets/brand/evolcore/evolcore-mark.png +0 -0
  124. package/assets/brand/evolcore/evolcore-mark.svg +0 -10
@@ -428,14 +428,47 @@ export class ResponseEngine {
428
428
  if (!session)
429
429
  return;
430
430
  this.turnCoordinator.hydrate(session);
431
- this.turnCoordinator.invalidate(sessionId, reason);
431
+ const interruptedTurn = this.turnCoordinator.invalidate(sessionId, reason);
432
+ const interruptedTaskId = interruptedTurn?.active?.taskId;
432
433
  this.interruptedSessions.set(sessionId, reason);
433
434
  await this.interactionRouter?.cancelAll(sessionId, reason);
434
- const agent = this.getAgent(session.metadata?.channelKey || session.channel, session.baseagent, session.selfAID);
435
- await agent.interrupt(sessionId).catch(error => {
435
+ const channelKey = session.metadata?.channelKey || session.channel;
436
+ const agent = this.getAgent(channelKey, session.baseagent, session.selfAID);
437
+ const terminalStatus = (() => {
438
+ // TimeoutController owns timeout delivery (including user-facing timeout
439
+ // details) and already guarantees a single terminal status. Sending it
440
+ // here as well would race that barrier and duplicate status.timeout.
441
+ if (!interruptedTaskId || reason === 'timeout')
442
+ return Promise.resolve();
443
+ const channelInfo = this.resolveChannelInfo(channelKey);
444
+ if (!channelInfo)
445
+ return Promise.resolve();
446
+ const envelope = buildEnvelope({
447
+ taskId: interruptedTaskId,
448
+ sessionId: session.id,
449
+ channel: channelInfo.adapter.channelName,
450
+ channelId: session.channelId,
451
+ agentName: agent.name,
452
+ chatmode: this.resolveEffectiveChatmodeForSession(session, channelKey, session.channelId),
453
+ replyContext: {
454
+ ...(session.metadata?.replyContext ?? {}),
455
+ sessionId: session.id,
456
+ threadId: session.threadId || session.metadata?.replyContext?.threadId,
457
+ },
458
+ });
459
+ const payload = {
460
+ kind: 'status.interrupted',
461
+ metadata: { reason },
462
+ };
463
+ return channelInfo.adapter.send(envelope, payload).then(() => undefined).catch(error => {
464
+ logger.debug(`[ResponseEngine] interrupt terminal status failure: session=${sessionId} task=${interruptedTaskId} error=${error instanceof Error ? error.message : String(error)}`);
465
+ });
466
+ })();
467
+ const runnerInterrupt = agent.interrupt(sessionId).catch(error => {
436
468
  logger.debug(`[ResponseEngine] interrupt barrier runner failure: session=${sessionId} error=${error instanceof Error ? error.message : String(error)}`);
437
469
  });
438
- const taskId = await this.turnCoordinator.completeInterrupt(session, reason);
470
+ await Promise.all([terminalStatus, runnerInterrupt]);
471
+ const taskId = (await this.turnCoordinator.completeInterrupt(session, reason)) ?? interruptedTaskId;
439
472
  if (taskId) {
440
473
  this.sessionManager.clearProcessingIfTask(sessionId, taskId);
441
474
  if (this.activeTaskRuntimeContexts.get(sessionId)?.taskId === taskId) {
@@ -986,19 +1019,34 @@ export class ResponseEngine {
986
1019
  let rejectFn;
987
1020
  let lastIdleSec = 0;
988
1021
  let timeoutTriggered = false;
1022
+ const timeoutControl = {
1023
+ claimedByInternal: false,
1024
+ terminalDelivered: false,
1025
+ claim: () => {
1026
+ if (!timeoutControl.error || this.interruptedSessions.get(session.id) !== 'timeout')
1027
+ return undefined;
1028
+ timeoutControl.claimedByInternal = true;
1029
+ return timeoutControl.error;
1030
+ },
1031
+ };
989
1032
  const rejectAfterInterruptBarrier = (error) => {
990
1033
  if (timeoutTriggered)
991
1034
  return;
992
1035
  timeoutTriggered = true;
1036
+ timeoutControl.error = error;
993
1037
  this.timeoutErrors.set(session.id, error);
994
1038
  this.turnCoordinator.hydrate(session);
995
1039
  this.turnCoordinator.invalidate(session.id, 'timeout');
996
1040
  this.interruptedSessions.set(session.id, 'timeout');
997
- void this.interruptSession(session.id, 'timeout')
1041
+ const barrier = this.interruptSession(session.id, 'timeout')
998
1042
  .catch(interruptError => {
999
1043
  logger.debug(`[ResponseEngine] Timeout interrupt barrier failed: ${interruptError instanceof Error ? interruptError.message : String(interruptError)}`);
1000
- })
1001
- .finally(() => rejectFn(error));
1044
+ });
1045
+ timeoutControl.barrier = barrier;
1046
+ void barrier.finally(() => {
1047
+ if (!timeoutControl.claimedByInternal)
1048
+ rejectFn(error);
1049
+ });
1002
1050
  };
1003
1051
  const triggerAttemptId = message.replyContext?.metadata?.triggerAttemptId;
1004
1052
  const resetTimer = (eventType, toolName) => {
@@ -1052,13 +1100,27 @@ export class ResponseEngine {
1052
1100
  totalExecutionTimer.unref?.();
1053
1101
  });
1054
1102
  try {
1103
+ const processingPromise = this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec, outputState, timeoutControl);
1104
+ const guardedProcessingPromise = processingPromise.then(async () => {
1105
+ if (!timeoutControl.error)
1106
+ return;
1107
+ await timeoutControl.barrier;
1108
+ throw timeoutControl.error;
1109
+ });
1055
1110
  await Promise.race([
1056
- this._processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, () => lastIdleSec, outputState),
1111
+ guardedProcessingPromise,
1057
1112
  timeoutPromise,
1058
1113
  totalExecutionPromise,
1059
1114
  ]);
1060
1115
  }
1061
1116
  catch (error) {
1117
+ if (error instanceof Error && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
1118
+ await timeoutControl.barrier;
1119
+ if (!timeoutControl.terminalDelivered) {
1120
+ await timeoutControl.deliverUnhandled?.(error);
1121
+ }
1122
+ this.timeoutErrors.delete(session.id);
1123
+ }
1062
1124
  // 超时错误:kill 级别已发送诊断信息,无需再发
1063
1125
  // 非超时错误走通用处理
1064
1126
  // 记录错误到健康状态(复用已有 session)
@@ -1112,7 +1174,7 @@ export class ResponseEngine {
1112
1174
  : 60 * 60 * 1000;
1113
1175
  }
1114
1176
  /** 自动安全模式已禁用:仅保留错误计数,不再自动切换状态 */
1115
- async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, outputState = { middleOutputMode: 'all' }) {
1177
+ async _processMessageInternal(message, session, absoluteProjectPath, resetTimer, shouldSuppress, getLastIdleSec, outputState = { middleOutputMode: 'all' }, timeoutControl) {
1116
1178
  const messageId = `${message.channel}_${message.channelId}_${message.timestamp || Date.now()}`;
1117
1179
  const channelKey = this.messageChannelKey(message, session);
1118
1180
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -1386,6 +1448,71 @@ export class ResponseEngine {
1386
1448
  replyContext: taskReplyContext(),
1387
1449
  causation: taskCausation,
1388
1450
  });
1451
+ if (timeoutControl) {
1452
+ timeoutControl.deliverUnhandled = async (timeoutError) => {
1453
+ if (timeoutControl.terminalDelivered)
1454
+ return;
1455
+ timeoutControl.terminalDelivered = true;
1456
+ const isTotalExecutionTimeout = timeoutError.message === 'TOTAL_EXECUTION_TIMEOUT';
1457
+ const totalExecutionMs = this.totalExecutionLimitMs();
1458
+ const statusPayload = {
1459
+ kind: 'status.timeout',
1460
+ metadata: isTotalExecutionTimeout
1461
+ ? { totalExecutionMs }
1462
+ : { idleSec: getLastIdleSec?.() || undefined },
1463
+ };
1464
+ const idleSec = getLastIdleSec?.() || 0;
1465
+ const userMessage = isTotalExecutionTimeout
1466
+ ? `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`
1467
+ : idleSec > 0
1468
+ ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断`
1469
+ : '⚠️ 任务超时,已自动中断';
1470
+ agent.cleanupStream(streamKey);
1471
+ this.sessionManager.clearProcessingIfTask(session.id, taskId);
1472
+ if (this.activeTaskRuntimeContexts.get(session.id)?.taskId === taskId) {
1473
+ this.activeTaskRuntimeContexts.delete(session.id);
1474
+ }
1475
+ this.agentDelegationRegistry?.revokeTask(session.id, taskId);
1476
+ const daemonTrigger = this.isTrustedDaemonTrigger(message);
1477
+ if (!daemonTrigger) {
1478
+ await adapter.send(envelope, statusPayload).catch(() => { });
1479
+ }
1480
+ await adapter.send(envelope, {
1481
+ kind: 'result.error',
1482
+ text: userMessage,
1483
+ reason: isTotalExecutionTimeout ? 'total_execution_timeout' : 'timeout',
1484
+ }).catch(() => { });
1485
+ if (daemonTrigger) {
1486
+ await adapter.send(envelope, statusPayload).catch(() => { });
1487
+ }
1488
+ this.touchAgentActivity(channelKey);
1489
+ this.publishTriggerExecutionFailure(message, timeoutError.message, {
1490
+ messageId,
1491
+ causation: taskCausation,
1492
+ });
1493
+ const errorType = prefixErrorType(ERROR_PREFIX.INFRA, classifyError(timeoutError));
1494
+ this.eventBus.publish({
1495
+ type: 'task:error',
1496
+ sessionId: session.id,
1497
+ error: timeoutError.message,
1498
+ errorType,
1499
+ agentName: agentNameForStats,
1500
+ causation: taskCausation,
1501
+ });
1502
+ recordCausationSpan(taskCausation, 'task.run', {
1503
+ status: 'failed',
1504
+ refs: { taskId, sessionId: session.id, messageId },
1505
+ reason: errorType,
1506
+ });
1507
+ logger.message({
1508
+ msgId: messageId,
1509
+ sessionId: session.id,
1510
+ dir: 'inbound',
1511
+ status: 'failed',
1512
+ error: timeoutError.message,
1513
+ });
1514
+ };
1515
+ }
1389
1516
  try {
1390
1517
  const isBackground = this.isBackgroundSession(session, message.channel, message.channelId);
1391
1518
  // 记录收到消息
@@ -1474,10 +1601,24 @@ export class ResponseEngine {
1474
1601
  const peerShort = peerId ? peerId.split('.')[0].split(':')[0] : '?';
1475
1602
  const peerLabel = peerName && peerName !== peerShort ? `${peerShort}(${peerName})` : peerShort;
1476
1603
  logger.info(`[ResponseEngine] session=${session.id} task=${taskId} peer=${peerLabel} chatType=${session.chatType} chatMode=${effectiveChatMode} baseagent=${session.baseagent} msgChatType=${message.chatType ?? 'n/a'}`);
1604
+ // Establish the durable turn before exposing any processing state. This
1605
+ // lets an immediate /stop or replacement message close the same task and
1606
+ // prevents a BusinessTrip reaction from becoming orphaned during setup.
1607
+ turnLease = await this.turnCoordinator.begin(session, taskId);
1477
1608
  // 记录开始处理
1478
1609
  const taskEncrypt = message.replyContext?.metadata?.encrypted != null ? !!(message.replyContext.metadata.encrypted) : undefined;
1479
1610
  this.eventBus.publish({ type: 'task:started', sessionId: session.id, agentName: agentNameForStats, encrypt: taskEncrypt, chatmode, causation: taskCausation });
1480
1611
  this.touchAgentActivity(channelKey);
1612
+ // Upgrade the channel acknowledgement at task start, before compaction and
1613
+ // runner setup, so processing feedback remains visible for the whole run.
1614
+ // Trigger messageId is an internal runId and has no channel reaction.
1615
+ if (message.messageId && message.source !== 'trigger') {
1616
+ adapter.promoteAck?.(message.messageId, {
1617
+ taskId,
1618
+ channelId: message.channelId,
1619
+ messageIds: message.sourceMessageIds,
1620
+ }).catch(() => { });
1621
+ }
1481
1622
  adapter.send(envelope, { kind: 'status.started' }).catch(() => { });
1482
1623
  await this.runPendingAutoCompactAtTaskStart(session, agent, absoluteProjectPath, adapter, envelope, false);
1483
1624
  logger.message({
@@ -1659,7 +1800,6 @@ export class ResponseEngine {
1659
1800
  recordTriggerExecutionAnomaly(triggerRunId, anomaly);
1660
1801
  }
1661
1802
  : undefined;
1662
- turnLease = await this.turnCoordinator.begin(session, taskId);
1663
1803
  // 设置权限审批的交互上下文(支持交互卡片)
1664
1804
  agent.setPermissionContext?.(session.id, {
1665
1805
  sendPrompt: permissionPromptForOrigin,
@@ -2270,11 +2410,6 @@ export class ResponseEngine {
2270
2410
  logger.error('[ResponseEngine] Failed to record retry health status:', statusError);
2271
2411
  }
2272
2412
  };
2273
- // Runner 开始执行前:将 Pin 升级为 CheckMark(表示"正在处理")。
2274
- // Trigger messageId is an internal runId, so there is no channel ack to promote.
2275
- if (message.messageId && message.source !== 'trigger') {
2276
- adapter.promoteAck?.(message.messageId).catch(() => { });
2277
- }
2278
2413
  while (true) {
2279
2414
  let streamRegistered = false;
2280
2415
  try {
@@ -2383,6 +2518,10 @@ export class ResponseEngine {
2383
2518
  throw error;
2384
2519
  }
2385
2520
  }
2521
+ const pendingTimeoutError = timeoutControl?.claim();
2522
+ if (pendingTimeoutError) {
2523
+ throw pendingTimeoutError;
2524
+ }
2386
2525
  if (permissionTerminalFailure) {
2387
2526
  renderer.discardPending();
2388
2527
  streamResult = {
@@ -2734,6 +2873,12 @@ export class ResponseEngine {
2734
2873
  return;
2735
2874
  }
2736
2875
  const durationMs = Date.now() - startTime;
2876
+ const statsModel = streamResult.lastModelCall?.model
2877
+ || streamResult.model
2878
+ || streamResult.contextUsage?.model
2879
+ || modelOverride?.model
2880
+ || agentModel
2881
+ || 'unknown';
2737
2882
  // ── Stats: 写入 usage_events(在 status.completed 之前,以便带上 cost) ──
2738
2883
  // cost 含原价(official)与网关实际价(gateway)两套,由 insertUsageEvent 写库时一并算出。
2739
2884
  let turnCost = { official: null, gateway: null };
@@ -2745,7 +2890,6 @@ export class ResponseEngine {
2745
2890
  || (configActorId
2746
2891
  ? formatPeerKey(configChannelType, configActorId)
2747
2892
  : formatPeerKey(configChannelType, message.channelId));
2748
- const statsModel = streamResult.contextUsage?.model || 'unknown';
2749
2893
  const ctxPct = streamResult.contextUsage?.percentage;
2750
2894
  const event = normalizeUsage(streamResult.tokenUsage, {
2751
2895
  ts: Date.now(),
@@ -2803,7 +2947,6 @@ export class ResponseEngine {
2803
2947
  try {
2804
2948
  const { resolveModelSpec } = await import('../../stats/billing.js');
2805
2949
  const { querySessionSummary } = await import('../../stats/query.js');
2806
- const statsModel = streamResult.contextUsage?.model || 'unknown';
2807
2950
  modelSpec = resolveModelSpec(resolveRoot(), statsModel);
2808
2951
  const sum = querySessionSummary(resolveRoot(), session.id);
2809
2952
  if (sum.calls > 0) {
@@ -2848,7 +2991,7 @@ export class ResponseEngine {
2848
2991
  let lastModelCall = streamResult.lastModelCall;
2849
2992
  if (lastModelCall?.tokenUsage) {
2850
2993
  try {
2851
- const lastModel = lastModelCall.model || streamResult.contextUsage?.model || 'unknown';
2994
+ const lastModel = lastModelCall.model || statsModel;
2852
2995
  const lastEvent = normalizeUsage(lastModelCall.tokenUsage, {
2853
2996
  ts: Date.now(), agent_aid: '', peer_key: '', session_id: session.id,
2854
2997
  model: lastModel, turns: 1,
@@ -2950,7 +3093,14 @@ export class ResponseEngine {
2950
3093
  });
2951
3094
  }
2952
3095
  catch (error) {
2953
- if (error instanceof Error && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
3096
+ const authoritativeTimeoutError = timeoutControl?.claim();
3097
+ if (authoritativeTimeoutError) {
3098
+ error = authoritativeTimeoutError;
3099
+ await timeoutControl?.barrier;
3100
+ }
3101
+ const handlingTimeout = error instanceof Error
3102
+ && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT');
3103
+ if (handlingTimeout) {
2954
3104
  this.timeoutErrors.delete(session.id);
2955
3105
  }
2956
3106
  const leaseWasCurrent = !!turnLease && this.turnCoordinator.isCurrent(turnLease);
@@ -3055,7 +3205,7 @@ export class ResponseEngine {
3055
3205
  if (isUserInterrupt) {
3056
3206
  logger.info(`[ResponseEngine] User interrupt by new_message, skip sending error message`);
3057
3207
  }
3058
- else if (error?._errorAlreadySent && !retryExhaustedCount) {
3208
+ else if (error?._errorAlreadySent && !retryExhaustedCount && !isTimeout && !isTotalExecutionTimeout) {
3059
3209
  logger.info(`[ResponseEngine] Error already sent via renderer, skip sending duplicate message`);
3060
3210
  }
3061
3211
  else {
@@ -3103,6 +3253,9 @@ export class ResponseEngine {
3103
3253
  }).catch(() => { });
3104
3254
  }
3105
3255
  }
3256
+ if (handlingTimeout && timeoutControl) {
3257
+ timeoutControl.terminalDelivered = true;
3258
+ }
3106
3259
  }
3107
3260
  // [迁移探针] 任务收尾:记录工具提醒最终状态并落盘(防线 1)
3108
3261
  if (snapshot.isEnabled()) {
@@ -3374,7 +3527,7 @@ export class ResponseEngine {
3374
3527
  assistantUuid = event.assistantUuid ?? assistantUuid;
3375
3528
  protocolIncompleteReason = event.protocolIncompleteReason ?? protocolIncompleteReason;
3376
3529
  }
3377
- // 记录事件类型:高价值事件(text/tool_use/tool_result/complete/error/compact/task_progress)INFO,
3530
+ // 记录事件类型:高价值事件(含子任务生命周期)INFO,
3378
3531
  // 框架事件(session_id/state_changed/status)DEBUG
3379
3532
  let eventDetail = '';
3380
3533
  if (event.type === 'text' && event.text) {
@@ -3429,6 +3582,97 @@ export class ResponseEngine {
3429
3582
  });
3430
3583
  continue;
3431
3584
  }
3585
+ if (event.type === 'task_started') {
3586
+ this.eventBus.publish({
3587
+ type: 'runner:task-started',
3588
+ sessionId: session.id,
3589
+ taskId: event.taskId,
3590
+ taskKind: event.taskKind,
3591
+ toolUseId: event.toolUseId,
3592
+ description: event.description,
3593
+ subagentType: event.subagentType,
3594
+ taskType: event.taskType,
3595
+ workflowName: event.workflowName,
3596
+ prompt: event.prompt,
3597
+ skipTranscript: event.skipTranscript,
3598
+ timestamp: Date.now(),
3599
+ causation,
3600
+ });
3601
+ if (!event.skipTranscript) {
3602
+ renderer.addLifecycle('started', {
3603
+ scope: 'runner_task',
3604
+ taskId: event.taskId,
3605
+ taskKind: event.taskKind,
3606
+ toolUseId: event.toolUseId,
3607
+ description: event.description,
3608
+ subagentType: event.subagentType,
3609
+ taskType: event.taskType,
3610
+ }, `${event.taskKind === 'agent' ? 'Agent' : 'Background'} task started: ${event.description}`);
3611
+ }
3612
+ continue;
3613
+ }
3614
+ if (event.type === 'background_tasks_changed') {
3615
+ this.eventBus.publish({
3616
+ type: 'runner:background-tasks-changed',
3617
+ sessionId: session.id,
3618
+ tasks: event.tasks,
3619
+ timestamp: Date.now(),
3620
+ causation,
3621
+ });
3622
+ continue;
3623
+ }
3624
+ if (event.type === 'task_notification') {
3625
+ this.eventBus.publish({
3626
+ type: 'runner:task-notification',
3627
+ sessionId: session.id,
3628
+ taskId: event.taskId,
3629
+ taskKind: event.taskKind,
3630
+ toolUseId: event.toolUseId,
3631
+ description: event.description,
3632
+ subagentType: event.subagentType,
3633
+ status: event.status,
3634
+ outputFile: event.outputFile,
3635
+ summary: event.summary,
3636
+ usage: event.usage,
3637
+ skipTranscript: event.skipTranscript,
3638
+ timestamp: Date.now(),
3639
+ causation,
3640
+ });
3641
+ if (!event.skipTranscript) {
3642
+ const label = event.description || event.taskId;
3643
+ renderer.addLifecycle('completed', {
3644
+ scope: 'runner_task',
3645
+ taskId: event.taskId,
3646
+ taskKind: event.taskKind,
3647
+ toolUseId: event.toolUseId,
3648
+ description: event.description,
3649
+ subagentType: event.subagentType,
3650
+ status: event.status,
3651
+ outputFile: event.outputFile,
3652
+ summary: event.summary,
3653
+ usage: event.usage,
3654
+ }, `${event.taskKind === 'agent' ? 'Agent' : 'Background'} task ${event.status}: ${label}`);
3655
+ }
3656
+ continue;
3657
+ }
3658
+ if (event.type === 'task_progress') {
3659
+ this.eventBus.publish({
3660
+ type: 'runner:task-progress',
3661
+ sessionId: session.id,
3662
+ taskId: event.taskId,
3663
+ taskKind: event.taskKind,
3664
+ toolUseId: event.toolUseId,
3665
+ description: event.description,
3666
+ subagentType: event.subagentType,
3667
+ summary: event.summary,
3668
+ lastToolName: event.lastToolName,
3669
+ totalTokens: event.totalTokens,
3670
+ toolUses: event.toolUses,
3671
+ durationMs: event.durationMs,
3672
+ timestamp: Date.now(),
3673
+ causation,
3674
+ });
3675
+ }
3432
3676
  const isCurrentlyBackground = this.isBackgroundSession(session, session.channel, session.channelId);
3433
3677
  // === 前台任务:正常处理所有事件 ===
3434
3678
  if (!isCurrentlyBackground) {
@@ -3570,14 +3814,14 @@ export class ResponseEngine {
3570
3814
  // 仅记录状态,最终 flush(true) 在流结束后统一执行
3571
3815
  if (event.type === 'complete') {
3572
3816
  const isAbort = event.terminalReason === 'aborted_streaming' || event.terminalReason === 'aborted_tools';
3573
- logger.info(`[ResponseEngine] ${isAbort ? 'task interrupted' : 'complete event'}: isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
3817
+ logger.info(`[ResponseEngine] ${isAbort ? 'task interrupted' : 'complete event'}: queryFinal=${event.queryFinal !== false} isError=${event.isError} terminalReason=${event.terminalReason ?? 'none'} subtype=${event.subtype ?? 'none'} hasReceivedText=${hasReceivedText}`);
3574
3818
  // 自动回填会话名称
3575
3819
  if (event.sessionTitle && shouldAutoFillSessionTitle(session.name, session.threadId)) {
3576
3820
  await this.sessionManager.renameSession(session.id, event.sessionTitle);
3577
3821
  logger.info(`[ResponseEngine] Auto-filled session name: ${event.sessionTitle}`);
3578
3822
  }
3579
3823
  // 记录完成状态 + 最后一轮回复文本(后续 complete 覆盖前序)
3580
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall, modelCalls: event.modelCalls };
3824
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, model: event.model, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall, modelCalls: event.modelCalls };
3581
3825
  if (!event.isError)
3582
3826
  madeProgress = true;
3583
3827
  // thought jsonl 写入已下沉到 aun.ts:sendThought 成功后,
@@ -3613,7 +3857,7 @@ export class ResponseEngine {
3613
3857
  }
3614
3858
  // 检测 proactive 标志位,设置 lastProactiveFlag 供模式切换提示使用
3615
3859
  // [迁移点5] 标志位检查:由 ProactiveMode.onComplete 实现
3616
- if (modeHooks?.mode?.onComplete && lastReplyText) {
3860
+ if (event.queryFinal !== false && modeHooks?.mode?.onComplete && lastReplyText) {
3617
3861
  await modeHooks.mode.onComplete({
3618
3862
  session,
3619
3863
  state: modeHooks.state,
@@ -3647,7 +3891,7 @@ export class ResponseEngine {
3647
3891
  logger.info(`[ResponseEngine] Auto-filled session name: ${event.sessionTitle}`);
3648
3892
  }
3649
3893
  // 记录完成状态
3650
- completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall };
3894
+ completeResult = { isError: !!event.isError, subtype: event.subtype, errors: event.errors, terminalReason: event.terminalReason, lastReplyText, fullText: event.result || '', hasReceivedText, numTurns: event.numTurns, ttftMs: event.ttftMs, model: event.model, tokenUsage: event.tokenUsage, contextUsage: event.contextUsage, lastModelCall: event.lastModelCall };
3651
3895
  if (event.subtype === 'success')
3652
3896
  madeProgress = true;
3653
3897
  }
@@ -0,0 +1,24 @@
1
+ export function sendOperationId(envelope) {
2
+ return envelope.operationId || envelope.taskId;
3
+ }
4
+ export function sentReceipt(envelope, messageIds, threadId) {
5
+ const messages = messageIds
6
+ .filter((messageId) => typeof messageId === 'string' && messageId.length > 0)
7
+ .map((messageId, partIndex) => ({ messageId, partIndex, threadId }));
8
+ if (messages.length === 0) {
9
+ throw new Error(`MISSING_MESSAGE_ID: operation=${sendOperationId(envelope)}`);
10
+ }
11
+ return {
12
+ status: 'sent',
13
+ operationId: sendOperationId(envelope),
14
+ messages: messages,
15
+ };
16
+ }
17
+ export function suppressedReceipt(envelope, reason) {
18
+ return {
19
+ status: 'suppressed',
20
+ operationId: sendOperationId(envelope),
21
+ messages: [],
22
+ reason,
23
+ };
24
+ }
@@ -27,9 +27,9 @@ export class StreamDebouncer {
27
27
  * 否则新建窗口。timer 到期后自动 flush。
28
28
  */
29
29
  submit(key, message, enqueue) {
30
- const { content, images, mentions, messageId, replyContext, ...rest } = message;
30
+ const { content, images, mentions, messageId, sourceMessageIds, replyContext, ...rest } = message;
31
31
  return new Promise((resolve, reject) => {
32
- const entry = { messageId, content, images, mentions, replyContext, rest, resolve, reject };
32
+ const entry = { messageId, sourceMessageIds, content, images, mentions, replyContext, rest, resolve, reject };
33
33
  const win = this.pending.get(key);
34
34
  if (win) {
35
35
  clearTimeout(win.timer);
@@ -87,12 +87,20 @@ export class StreamDebouncer {
87
87
  const allImages = [];
88
88
  const allMentions = [];
89
89
  const contents = [];
90
+ const sourceMessageIds = [];
91
+ const seenSourceMessageIds = new Set();
90
92
  for (const e of entries) {
91
93
  contents.push(e.content);
92
94
  if (e.images)
93
95
  allImages.push(...e.images);
94
96
  if (e.mentions)
95
97
  allMentions.push(...e.mentions);
98
+ for (const id of [...(e.sourceMessageIds ?? []), ...(e.messageId ? [e.messageId] : [])]) {
99
+ if (!seenSourceMessageIds.has(id)) {
100
+ seenSourceMessageIds.add(id);
101
+ sourceMessageIds.push(id);
102
+ }
103
+ }
96
104
  }
97
105
  const last = entries[entries.length - 1];
98
106
  // 合并后保留最新一条的 messageId(用于 thought 锚定与中断追踪)
@@ -110,6 +118,7 @@ export class StreamDebouncer {
110
118
  mentions: allMentions.length > 0 ? allMentions : undefined,
111
119
  replyContext: last.replyContext,
112
120
  messageId: latestMessageId,
121
+ sourceMessageIds: sourceMessageIds.length > 0 ? sourceMessageIds : undefined,
113
122
  };
114
123
  const resolves = entries.map(e => e.resolve);
115
124
  const rejects = entries.map(e => e.reject);
@@ -347,25 +347,45 @@ export function checkReadonly(toolName, input, projectPath, context) {
347
347
  logger.warn(`[ReadonlyCheck] 🔒 Unknown tool blocked: tool=${toolName} session=${context?.sessionId} channel=${context?.channel} peer=${context?.peerId} role=${context?.role}`);
348
348
  return { behavior: 'deny', message: `🔒 只读模式:未声明为只读的工具 ${toolName} 已拒绝执行` };
349
349
  }
350
- /**
351
- * H 类文件保护(用于 PreToolUse hook)
352
- * H 类文件只能由受控的人类/daemon 路径处理,agent 不可直接访问
353
- */
354
- function collectStringValues(value, output) {
350
+ const NON_FILESYSTEM_METADATA_TOOLS = new Set([
351
+ 'Agent',
352
+ 'AskUserQuestion',
353
+ 'CronCreate',
354
+ 'CronDelete',
355
+ 'CronList',
356
+ 'EnterPlanMode',
357
+ 'ExitPlanMode',
358
+ 'SendMessage',
359
+ 'Skill',
360
+ 'Task',
361
+ 'TaskCreate',
362
+ 'TaskGet',
363
+ 'TaskList',
364
+ 'TaskOutput',
365
+ 'TaskStop',
366
+ 'TaskUpdate',
367
+ 'TeamCreate',
368
+ 'TeamDelete',
369
+ 'TodoWrite',
370
+ 'ToolSearch',
371
+ 'WebFetch',
372
+ 'WebSearch',
373
+ ]);
374
+ function collectOpaqueToolStrings(value, output) {
355
375
  if (typeof value === 'string') {
356
- output.push(value);
376
+ if (value.trim())
377
+ output.push(value);
357
378
  return;
358
379
  }
359
380
  if (Array.isArray(value)) {
360
381
  for (const entry of value)
361
- collectStringValues(entry, output);
382
+ collectOpaqueToolStrings(entry, output);
362
383
  return;
363
384
  }
364
385
  if (!value || typeof value !== 'object')
365
386
  return;
366
- for (const [key, entry] of Object.entries(value)) {
367
- output.push(key);
368
- collectStringValues(entry, output);
387
+ for (const entry of Object.values(value)) {
388
+ collectOpaqueToolStrings(entry, output);
369
389
  }
370
390
  }
371
391
  function collectPermissionPathSpec(value, output, projectRootSubpaths) {
@@ -604,9 +624,18 @@ export function checkHClassWrite(toolName, input, context) {
604
624
  collectFilesystemGrantPaths(input.additionalPermissions, grantPaths, projectRootGrantSubpaths);
605
625
  collectFilesystemGrantPaths(input.permissions, grantPaths, projectRootGrantSubpaths);
606
626
  }
607
- else {
608
- collectStringValues(input, paths);
609
- }
627
+ else if (!NON_FILESYSTEM_METADATA_TOOLS.has(toolName)) {
628
+ // Opaque external tools may run outside the Claude/Codex filesystem
629
+ // sandbox. Retain conservative string inspection for those tools, while
630
+ // excluding known orchestration tools whose payload is only metadata.
631
+ collectOpaqueToolStrings(input, paths);
632
+ }
633
+ // Agent/Skill/Task and other orchestration tools carry natural-language
634
+ // metadata, not direct filesystem operands. Treating every string in their
635
+ // payload as a path makes a prompt that merely mentions daemon.json or a
636
+ // *.json_ backup indistinguishable from an actual file access. Any nested
637
+ // filesystem operation is checked again when it reaches Read/Write/Bash,
638
+ // FileChange, or PermissionGrant above.
610
639
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
611
640
  if (requestsFilesystemRoot(input.permissions) || requestsFilesystemRoot(input.additionalPermissions)) {
612
641
  logger.warn(`[H-Class Protection] 🔒 Permission grant covers filesystem root: tool=${toolName} ` +
@@ -729,9 +758,12 @@ export function checkLClassWrite(toolName, input, context) {
729
758
  collectFilesystemWriteGrantPaths(input.additionalPermissions, writeGrantPaths, projectRootWriteSubpaths);
730
759
  collectFilesystemWriteGrantPaths(input.permissions, writeGrantPaths, projectRootWriteSubpaths);
731
760
  }
732
- else {
733
- collectStringValues(input, paths);
761
+ else if (!NON_FILESYSTEM_METADATA_TOOLS.has(toolName)) {
762
+ collectOpaqueToolStrings(input, paths);
734
763
  }
764
+ // Do not infer mutation targets from arbitrary orchestration-tool strings.
765
+ // Skills commonly pass args: "" and delegation prompts may discuss
766
+ // protected paths; neither is itself a filesystem mutation.
735
767
  const pathOptions = { cwd: context?.projectPath, root: context?.root };
736
768
  for (const grantPath of writeGrantPaths) {
737
769
  if (grantPath && lClassGrantIncludesProtectedPath(grantPath, pathOptions)) {
@@ -306,6 +306,8 @@ export function hClassGrantIncludesProtectedPath(value, options = {}) {
306
306
  return true;
307
307
  }
308
308
  export function lClassGrantIncludesProtectedPath(value, options = {}) {
309
+ if (!value.trim())
310
+ return false;
309
311
  const root = resolveThroughExistingAncestor(options.root ?? resolveRoot());
310
312
  const protectedRoot = path.join(root, 'data', 'triggers');
311
313
  if (isLClassPath(value, { cwd: options.cwd, root }))