evolcore 0.0.26 → 0.0.28

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 (177) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bin/codex-managed-acl.ps1 +43 -0
  3. package/dist/agents/baseagent-availability.js +68 -0
  4. package/dist/agents/baseagent.js +10 -5
  5. package/dist/agents/claude-runner.js +123 -22
  6. package/dist/agents/codex/app-server-client.js +70 -26
  7. package/dist/agents/codex/catalog.js +7 -1
  8. package/dist/agents/codex-runner.js +363 -421
  9. package/dist/agents/ecagent-runner.js +170 -25
  10. package/dist/agents/gemini-runner.js +6 -0
  11. package/dist/agents/runner-types.js +50 -2
  12. package/dist/aun/aid/agentmd.js +73 -31
  13. package/dist/aun/aid/client.js +1 -1
  14. package/dist/aun/aid/control-aid.js +180 -1
  15. package/dist/aun/aid/identity.js +3 -3
  16. package/dist/aun/group/history.js +303 -0
  17. package/dist/aun/group/identity.js +43 -0
  18. package/dist/aun/group/index.js +5 -0
  19. package/dist/aun/{msg/group.js → group/operations.js} +161 -38
  20. package/dist/aun/{menu-client.js → menu/client.js} +13 -7
  21. package/dist/aun/menu/index.js +2 -0
  22. package/dist/aun/menu/transport.js +49 -0
  23. package/dist/aun/msg/index.js +2 -1
  24. package/dist/aun/msg/managed-operation.js +84 -18
  25. package/dist/aun/msg/mention-schema.js +51 -0
  26. package/dist/aun/msg/p2p.js +13 -8
  27. package/dist/aun/msg/upload.js +43 -88
  28. package/dist/aun/outbox.js +29 -7
  29. package/dist/aun/rpc/client.js +1 -0
  30. package/dist/aun/storage/client.js +39 -50
  31. package/dist/channels/aun.js +544 -239
  32. package/dist/channels/daemon.js +10 -6
  33. package/dist/cli/agent-command.js +108 -6
  34. package/dist/cli/agent-create.js +515 -2
  35. package/dist/cli/agent.js +109 -8
  36. package/dist/cli/aun-commands.js +191 -42
  37. package/dist/cli/config.js +8 -5
  38. package/dist/cli/contact.js +2 -2
  39. package/dist/cli/ctl-command.js +2 -2
  40. package/dist/cli/daemon-commands.js +111 -162
  41. package/dist/cli/diag.js +3 -3
  42. package/dist/cli/fs-command.js +3 -3
  43. package/dist/cli/handoff-command.js +6 -6
  44. package/dist/cli/index.js +8 -2
  45. package/dist/cli/init.js +34 -1
  46. package/dist/cli/managed-command-guard.js +2 -1
  47. package/dist/cli/menu-client.js +13 -5
  48. package/dist/cli/model.js +3 -3
  49. package/dist/cli/queue-command.js +2 -2
  50. package/dist/cli/response.js +2 -2
  51. package/dist/cli/restart-monitor.js +81 -19
  52. package/dist/cli/task-command.js +59 -0
  53. package/dist/cli/task-context.js +97 -49
  54. package/dist/cli/trigger-command.js +20 -10
  55. package/dist/config/builtin-roles.js +10 -0
  56. package/dist/config/config-field-policy.js +11 -0
  57. package/dist/config/config-manager.js +32 -4
  58. package/dist/config/config-operation-service.js +20 -5
  59. package/dist/config/contact-book-store.js +1 -1
  60. package/dist/config/contact-book.js +1 -1
  61. package/dist/config/contact-operation-service.js +1 -1
  62. package/dist/config/peer-role-resolver.js +1 -1
  63. package/dist/config/resolved-config-op.js +3 -0
  64. package/dist/config-store.js +1 -1
  65. package/dist/core/agent-application-service.js +5 -0
  66. package/dist/core/audit/event-key.js +17 -2
  67. package/dist/core/audit/log-integrity.js +34 -8
  68. package/dist/core/auth/agent-delegation.js +48 -14
  69. package/dist/core/auth/auth-gateway.js +2 -0
  70. package/dist/core/auth/authorization-audit.js +34 -38
  71. package/dist/core/auth/operation-authorizer.js +67 -5
  72. package/dist/core/auth/operation-catalog.js +110 -0
  73. package/dist/core/auth/remote-access-store.js +115 -9
  74. package/dist/core/bootstrap-messages.js +66 -7
  75. package/dist/core/bootstrap-service.js +110 -19
  76. package/dist/core/command/agent-control.js +88 -7
  77. package/dist/core/command/agent-storage-menu.js +443 -0
  78. package/dist/core/command/cli-intent-parser.js +19 -0
  79. package/dist/core/command/command-handler.js +105 -40
  80. package/dist/core/command/data-root-resolver.js +273 -0
  81. package/dist/core/command/diag-service.js +292 -27
  82. package/dist/core/command/group-menu.js +7 -2
  83. package/dist/core/command/menu-catalog.js +34 -4
  84. package/dist/core/command/menu-handler.js +364 -96
  85. package/dist/core/command/menu-protocol.js +43 -6
  86. package/dist/core/command/role-menu.js +2 -2
  87. package/dist/core/command/slash-handler.js +61 -37
  88. package/dist/core/event-catalog.js +4 -0
  89. package/dist/core/evolagent-registry.js +28 -2
  90. package/dist/core/group/invite-service.js +924 -0
  91. package/dist/core/interaction-router.js +22 -8
  92. package/dist/core/message/im-renderer.js +94 -16
  93. package/dist/core/message/message-bridge.js +140 -13
  94. package/dist/core/message/message-log.js +5 -3
  95. package/dist/core/message/message-queue.js +73 -41
  96. package/dist/core/message/response-engine.js +729 -118
  97. package/dist/core/permission/approval-gateway.js +36 -3
  98. package/dist/core/permission/ec-command-parser.js +76 -36
  99. package/dist/core/permission/protected-paths.js +134 -16
  100. package/dist/core/permission/sandbox-runtime.js +72 -3
  101. package/dist/core/permission/tool-error-code.js +15 -0
  102. package/dist/core/permission/tool-policy.js +8 -8
  103. package/dist/core/relation/peer-identity.js +57 -6
  104. package/dist/core/relation/shared-data.js +106 -0
  105. package/dist/core/role/runtime-policy.js +64 -0
  106. package/dist/core/runtime-lock.js +10 -26
  107. package/dist/core/session/runner-task-history.js +170 -0
  108. package/dist/core/session/session-manager.js +38 -6
  109. package/dist/core/session/session-mapper.js +41 -0
  110. package/dist/core/session/session-turn-coordinator.js +37 -7
  111. package/dist/core/session/session-turns.js +48 -7
  112. package/dist/core/task/task-recovery-service.js +176 -0
  113. package/dist/core/task/task-resume-operation.js +32 -0
  114. package/dist/eck/group-rules-sync.js +5 -3
  115. package/dist/eck/kit-renderer.js +1 -0
  116. package/dist/index.js +322 -207
  117. package/dist/ipc.js +141 -15
  118. package/dist/paths.js +17 -4
  119. package/dist/product.js +3 -0
  120. package/dist/trigger/event-source.js +19 -0
  121. package/dist/trigger/feedback.js +233 -40
  122. package/dist/trigger/parser.js +55 -5
  123. package/dist/trigger/patch.js +9 -0
  124. package/dist/trigger/scheduler.js +30 -4
  125. package/dist/trigger/validation.js +20 -3
  126. package/dist/utils/atomic-write.js +98 -0
  127. package/dist/utils/cross-platform.js +87 -16
  128. package/dist/utils/ecweb-utils.js +1 -0
  129. package/dist/utils/error-utils.js +40 -2
  130. package/dist/utils/logger.js +23 -13
  131. package/dist/utils/npm-ops.js +119 -4
  132. package/dist/utils/package-version-info.js +244 -0
  133. package/dist/utils/tool-summary.js +59 -3
  134. package/dist/utils/welcome.js +174 -1
  135. package/dist/utils/windows-shell-trust.js +37 -0
  136. package/ecagent/dist/harness/agent-harness.js +3 -0
  137. package/ecagent/dist/harness/session/jsonl-storage.js +21 -2
  138. package/kits/docs/INDEX.md +0 -6
  139. package/kits/docs/channels/aun.md +23 -2
  140. package/kits/docs/channels/feishu.md +1 -1
  141. package/kits/docs/context-assembly.md +29 -17
  142. package/kits/docs/evolcore/INDEX.md +8 -3
  143. package/kits/docs/evolcore/agent-create.md +39 -7
  144. package/kits/docs/evolcore/agent.md +33 -1
  145. package/kits/docs/evolcore/aid.md +9 -1
  146. package/kits/docs/evolcore/config.md +40 -1
  147. package/kits/docs/evolcore/group-collaboration.md +1 -1
  148. package/kits/docs/evolcore/group.md +117 -2
  149. package/kits/docs/evolcore/menu-client.md +16 -0
  150. package/kits/docs/evolcore/msg.md +3 -0
  151. package/kits/docs/evolcore/storage.md +3 -0
  152. package/kits/docs/evolcore/task.md +14 -0
  153. package/kits/docs/evolcore/trigger.md +51 -3
  154. package/kits/docs/venues/group.md +5 -0
  155. package/kits/eck_manifest.json +27 -0
  156. package/kits/rules/01-overview.md +11 -7
  157. package/kits/rules/04-relation.md +2 -1
  158. package/kits/rules/05-venue.md +1 -1
  159. package/kits/rules/06-channel.md +6 -4
  160. package/kits/schemas/agent-config.schema.12.json +25 -1
  161. package/kits/schemas/daemon.schema.6.json +2 -2
  162. package/kits/schemas/defaults.schema.5.json +1 -1
  163. package/kits/schemas/relation-config.schema.1.json +1 -1
  164. package/kits/schemas/relation-config.schema.5.json +1 -1
  165. package/kits/schemas/role-config.schema.1.json +14 -0
  166. package/kits/templates/roles/member.json +5 -0
  167. package/kits/templates/roles/visitor.json +5 -0
  168. package/kits/templates/system-fragments/baseagent.md +1 -1
  169. package/kits/templates/system-fragments/bootstrap.md +26 -9
  170. package/kits/templates/system-fragments/channel.md +8 -44
  171. package/kits/templates/system-fragments/commands.md +10 -2
  172. package/kits/templates/system-fragments/group-unread-history.md +7 -0
  173. package/kits/templates/system-fragments/reply.md +56 -0
  174. package/package.json +7 -3
  175. package/dist/aun/group-identity.js +0 -9
  176. /package/dist/aun/{group-fs-download.js → group/fs-download.js} +0 -0
  177. /package/dist/aun/{msg/group-index.js → group/index-rpc.js} +0 -0
@@ -1,8 +1,13 @@
1
+ import { initializationBlocksTasks } from '../../cli/agent-create.js';
1
2
  import path from 'path';
2
3
  import fs from 'fs';
3
4
  import os from 'os';
4
5
  import crypto from 'crypto';
5
- import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken } from '../../agents/runner-types.js';
6
+ import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken, formatAsyncQuestionAnswer, parseAsyncUserQuestions, } from '../../agents/runner-types.js';
7
+ import { loadedBaseagentIds } from '../../agents/baseagent-availability.js';
8
+ import { isDiagnosticSessionActive } from '../command/diag-service.js';
9
+ import { evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission } from './message-admission.js';
10
+ import { authorizeAccess, authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
6
11
  import { buildTaskRuntimeEnv, ensureRuntimeLockDir, ensureSessionRuntimeDir } from '../../cli/task-context.js';
7
12
  import { IMRenderer } from './im-renderer.js';
8
13
  import { createSendFileMarkerPattern } from './file-markers.js';
@@ -10,11 +15,11 @@ import { createTextInferenceProvider } from '../inference/text-inference.js';
10
15
  import { StreamIdleMonitor } from './stream-idle-monitor.js';
11
16
  import { logger } from '../../utils/logger.js';
12
17
  import { isHostChinese } from '../../utils/locale.js';
13
- import { getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, isAbortTerminalReason, prefixErrorType, isRetryableError, isContextTooLongText } from '../../utils/error-utils.js';
18
+ import { ExecutionTimeoutError, OrphanedTargetSessionError, isExecutionFailure, getErrorMessage, classifyError, ErrorType, ERROR_PREFIX, isInfraError, isAbortTerminalReason, prefixErrorType, isRetryableError, isContextTooLongText } from '../../utils/error-utils.js';
14
19
  import { readEvolcoreRuntimeFlavor, tagErrorTextWithEvolcoreVersion } from '../../utils/evolcore-version.js';
15
20
  import { describeProactiveSendCommandFailure, isExactEvolcoreSendCommandForSession } from '../permission/ec-command-parser.js';
16
- import { summarizeToolInput } from '../../utils/tool-summary.js';
17
- import { agentDir as resolveAgentDir, agentMdPath, getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
21
+ import { summarizeToolInputForDisplay } from '../../utils/tool-summary.js';
22
+ import { agentDataDir, agentDir as resolveAgentDir, agentMdPath, getPackageRoot, resolveRoot, resolvePaths } from '../../paths.js';
18
23
  import { renderKitSections } from '../../eck/kit-renderer.js';
19
24
  import { renderMessageBody } from '../../eck/message-renderer.js';
20
25
  import { syncGroupRulesContext } from '../../eck/group-rules-sync.js';
@@ -22,8 +27,9 @@ import { consumeHints, hintsToSubMessages, composeHintFallback } from './pending
22
27
  import { createRootCausation, deriveCausation, normalizeCausation } from '../causation/context.js';
23
28
  import { recordCausationLink, recordCausationSpan } from '../causation/audit.js';
24
29
  import { normalizeBaseagent } from '../../agents/baseagent.js';
30
+ import { renderActionAsText } from '../interaction-router.js';
25
31
  import { formatPeerKey, parsePeerKey } from '../relation/peer-identity.js';
26
- import { buildEnvelope, replyContextFromSession, withEnvelopeReplyContext } from './message-utils.js';
32
+ import { buildEnvelope, isInteractionSendAccepted, replyContextFromSession, sendInteractionPayload, withEnvelopeReplyContext } from './message-utils.js';
27
33
  import { isSystemOrServicePeer, resolveChatMode } from './peer-mode.js';
28
34
  import { AGENT_DELEGATION_TOKEN_ENV } from '../auth/agent-delegation.js';
29
35
  import { hasTrustedPrincipal } from '../auth/authenticated-actor.js';
@@ -52,7 +58,290 @@ import { classifyToolErrorCode, normalizeToolErrorCode } from '../permission/too
52
58
  import { normalizeExecutionPermissionMode } from '../permission/mode.js';
53
59
  import { buildToolLifecycleEventKey } from '../audit/event-key.js';
54
60
  import { isFullAccessEnabled, loadDaemonConfig } from '../../config-store.js';
61
+ import { assertAgentPolicyAuthorization } from '../role/runtime-policy.js';
55
62
  import { auditFullAccessEvent } from '../auth/authorization-audit.js';
63
+ import { TaskRecoveryService, isTaskRecoveryCurrent } from '../task/task-recovery-service.js';
64
+ const QUESTION_TTL_MS = 24 * 60 * 60 * 1000;
65
+ /** Pending async questions outlive the originating turn, but never change its backend binding. */
66
+ export class AsyncQuestionService {
67
+ deps;
68
+ registered = new Map();
69
+ deliveries = new Map();
70
+ constructor(deps) {
71
+ this.deps = deps;
72
+ }
73
+ directory(selfAid) {
74
+ return this.deps.directory?.(selfAid) ?? path.join(agentDataDir(selfAid), 'async-questions');
75
+ }
76
+ file(selfAid, id) {
77
+ if (!/^aq-[a-f0-9]{40}$/.test(id))
78
+ throw new Error('Invalid async question id');
79
+ return path.join(this.directory(selfAid), `${id}.json`);
80
+ }
81
+ save(record) {
82
+ fs.mkdirSync(this.directory(record.selfAid), { recursive: true, mode: 0o700 });
83
+ const destination = this.file(record.selfAid, record.id);
84
+ const temporary = `${destination}.tmp`;
85
+ const fd = fs.openSync(temporary, 'w', 0o600);
86
+ try {
87
+ fs.writeFileSync(fd, JSON.stringify(record));
88
+ fs.fsyncSync(fd);
89
+ }
90
+ finally {
91
+ fs.closeSync(fd);
92
+ }
93
+ // Replace atomically, without unlinking the previous durable state first.
94
+ fs.renameSync(temporary, destination);
95
+ }
96
+ read(selfAid, id) {
97
+ try {
98
+ const value = JSON.parse(fs.readFileSync(this.file(selfAid, id), 'utf8'));
99
+ if (value.version !== 1 || value.selfAid !== selfAid || value.id !== id
100
+ || !value.sessionId || !value.providerThreadId || !value.itemId
101
+ || !value.origin?.peerId || !value.channelKey || !Number.isFinite(value.expiresAt))
102
+ return undefined;
103
+ parseAsyncUserQuestions([value.question]);
104
+ return value;
105
+ }
106
+ catch (error) {
107
+ if (error.code !== 'ENOENT')
108
+ throw error;
109
+ return undefined;
110
+ }
111
+ }
112
+ async present(session, request, message, adapter, envelope) {
113
+ if (!session.selfAID || !message.peerId)
114
+ throw new Error('Async question requires an authenticated route');
115
+ const questions = parseAsyncUserQuestions(request.questions);
116
+ const records = [];
117
+ for (const [index, question] of questions.entries()) {
118
+ const hash = crypto.createHash('sha256').update(JSON.stringify([
119
+ session.selfAID, session.id, request.providerThreadId, request.itemId, index,
120
+ ])).digest('hex').slice(0, 40);
121
+ const id = `aq-${hash}`;
122
+ const existing = this.read(session.selfAID, id);
123
+ if (existing && existing.status !== 'pending')
124
+ continue;
125
+ const replyContext = {
126
+ delivery: envelope.delivery ?? envelope.replyContext?.delivery,
127
+ threadId: session.threadId || undefined,
128
+ metadata: {
129
+ encrypted: message.encrypted ?? envelope.replyContext?.metadata?.encrypted,
130
+ chatmode: envelope.chatmode,
131
+ },
132
+ };
133
+ const record = existing ?? {
134
+ version: 1, id, sessionId: session.id, selfAid: session.selfAID,
135
+ providerThreadId: request.providerThreadId, providerTurnId: request.providerTurnId,
136
+ itemId: request.itemId, question,
137
+ origin: {
138
+ channel: message.channel, channelType: session.channelType,
139
+ channelId: session.channelId, peerId: message.peerId, peerType: message.peerType,
140
+ chatType: session.chatType === 'group' ? 'group' : 'private',
141
+ threadId: session.threadId || undefined, selfAID: session.selfAID,
142
+ },
143
+ envelope: { ...envelope, sessionId: session.id, replyContext, delivery: replyContext.delivery },
144
+ channelKey: adapter.channelKey, createdAt: Date.now(), expiresAt: Date.now() + QUESTION_TTL_MS,
145
+ status: 'pending',
146
+ };
147
+ if (!existing)
148
+ this.save(record);
149
+ records.push(record);
150
+ }
151
+ // Persist the complete batch before dispatch; one failed card must not lose the other questions.
152
+ const errors = [];
153
+ for (const record of records) {
154
+ try {
155
+ await this.deliver(record, adapter);
156
+ }
157
+ catch (error) {
158
+ errors.push(error);
159
+ }
160
+ }
161
+ if (errors.length)
162
+ throw new AggregateError(errors, 'Async question delivery failed; pending questions retained');
163
+ }
164
+ async restore(adapter, selfAid) {
165
+ let names;
166
+ try {
167
+ names = fs.readdirSync(this.directory(selfAid));
168
+ }
169
+ catch (error) {
170
+ if (error.code === 'ENOENT')
171
+ return;
172
+ throw error;
173
+ }
174
+ for (const name of names) {
175
+ if (!/^aq-[a-f0-9]{40}\.json$/.test(name))
176
+ continue;
177
+ try {
178
+ const record = this.read(selfAid, name.slice(0, -5));
179
+ if (!record || record.channelKey !== adapter.channelKey)
180
+ continue;
181
+ if (this.registered.get(record.id) === adapter)
182
+ continue;
183
+ if (record.expiresAt <= Date.now()) {
184
+ fs.unlinkSync(this.file(selfAid, record.id));
185
+ continue;
186
+ }
187
+ if (record.status === 'submitting') {
188
+ // A crash may have happened after Codex accepted the steer. Never replay it blindly.
189
+ record.status = 'uncertain';
190
+ record.reason = 'restart_during_submission';
191
+ this.save(record);
192
+ }
193
+ if (record.status === 'pending')
194
+ await this.deliver(record, adapter);
195
+ }
196
+ catch (error) {
197
+ logger.warn(`[AsyncQuestions] restore failed: id=${name} error=${String(error)}`);
198
+ }
199
+ }
200
+ }
201
+ async validSession(record) {
202
+ const session = await this.deps.getSession(record.sessionId);
203
+ if (!session || session.baseagent !== 'codex' || session.selfAID !== record.selfAid
204
+ || session.agentSessionId !== record.providerThreadId
205
+ || session.channel !== record.origin.channel
206
+ || session.channelType !== record.origin.channelType
207
+ || session.channelId !== record.origin.channelId
208
+ || (session.threadId || '') !== (record.origin.threadId || '')
209
+ || !(await this.deps.authorize(record, session)))
210
+ return undefined;
211
+ return session;
212
+ }
213
+ /** Recheck persisted answers after queue delays/restarts, immediately before a provider run. */
214
+ async validateQueuedAnswer(session, answer) {
215
+ if (!session.selfAID)
216
+ return false;
217
+ const record = this.read(session.selfAID, answer.requestId);
218
+ return !!record && record.sessionId === session.id
219
+ && ['submitting', 'answered', 'uncertain'].includes(record.status)
220
+ && record.providerThreadId === answer.providerThreadId
221
+ && record.itemId === answer.questionItemId && record.question.title === answer.question
222
+ && record.answer === answer.answer && !!(await this.validSession(record));
223
+ }
224
+ async deliver(record, adapter) {
225
+ if (this.registered.get(record.id) === adapter)
226
+ return;
227
+ // A channel reload replaces its callback/card registry. Rebind pending questions to the new adapter.
228
+ if (this.registered.has(record.id))
229
+ await this.deps.router.cancel(record.id, 'daemon_restart');
230
+ const previous = this.deliveries.get(record.id);
231
+ if (previous)
232
+ return previous;
233
+ const delivery = this.deliverOnce(record, adapter);
234
+ this.deliveries.set(record.id, delivery);
235
+ try {
236
+ await delivery;
237
+ }
238
+ finally {
239
+ this.deliveries.delete(record.id);
240
+ }
241
+ }
242
+ async deliverOnce(record, adapter) {
243
+ if (record.expiresAt <= Date.now() || !(await this.validSession(record))) {
244
+ record.status = 'cancelled';
245
+ record.reason = 'expired_or_binding_changed';
246
+ this.save(record);
247
+ return;
248
+ }
249
+ const options = record.question.options ?? [];
250
+ const interaction = {
251
+ type: 'interaction', id: record.id, sessionId: record.sessionId, channelId: record.origin.channelId,
252
+ initiatorId: record.origin.peerId, blocking: false, expiresAt: record.expiresAt,
253
+ kind: {
254
+ kind: 'action', title: '请补充信息', body: record.question.title, bodyFormat: 'plain',
255
+ buttons: options.map((label, index) => ({ key: `opt-${index}`, label })),
256
+ allowCustomInput: true,
257
+ },
258
+ fallback: {
259
+ command: 'ask',
260
+ buttonArgMap: Object.fromEntries(options.map((_, index) => [`opt-${index}`, `${record.id} ${index + 1}`])),
261
+ acceptFreeText: true, freeTextHint: `回复 /ask ${record.id} <回答>`,
262
+ },
263
+ };
264
+ // Register before dispatch: a fast reply must not race card delivery.
265
+ this.registered.set(record.id, adapter);
266
+ this.deps.router.register(record.id, record.sessionId, async (action, values) => {
267
+ const current = this.read(record.selfAid, record.id);
268
+ if (!current || current.status !== 'pending' || current.expiresAt <= Date.now()) {
269
+ await this.deps.router.cancel(record.id, 'expired_or_resolved');
270
+ return false;
271
+ }
272
+ const answer = this.answerText(current.question, action, values);
273
+ if (!answer)
274
+ return false;
275
+ const session = await this.validSession(current);
276
+ if (!session) {
277
+ await this.deps.router.cancel(record.id, 'binding_or_authorization_changed');
278
+ return false;
279
+ }
280
+ current.answer = answer;
281
+ current.status = 'submitting';
282
+ this.save(current);
283
+ try {
284
+ await this.deps.submit(current, session, {
285
+ requestId: current.id, providerThreadId: current.providerThreadId,
286
+ questionItemId: current.itemId, question: current.question.title, answer,
287
+ });
288
+ current.status = 'answered';
289
+ this.save(current);
290
+ this.registered.delete(record.id);
291
+ logger.info(`[AsyncQuestions] answer accepted: session=${record.sessionId} request=${record.id}`);
292
+ return true;
293
+ }
294
+ catch (error) {
295
+ current.status = 'uncertain';
296
+ current.reason = 'submission_failed';
297
+ this.save(current);
298
+ logger.warn(`[AsyncQuestions] answer submission failed: request=${record.id} error=${String(error)}`);
299
+ throw error;
300
+ }
301
+ }, {
302
+ initiatorId: record.origin.peerId, initiatorChannelKey: adapter.channelKey,
303
+ blocking: false, retainOnRejected: true, fallbackCommand: 'ask', timeoutMs: record.expiresAt - Date.now(),
304
+ onCancel: async (reason) => {
305
+ this.registered.delete(record.id);
306
+ if (reason === 'daemon_restart')
307
+ return;
308
+ const current = this.read(record.selfAid, record.id);
309
+ if (current?.status === 'pending') {
310
+ current.status = 'cancelled';
311
+ current.reason = reason;
312
+ this.save(current);
313
+ }
314
+ await adapter.invalidateInteraction?.(record.id, reason);
315
+ },
316
+ });
317
+ try {
318
+ const receipt = await sendInteractionPayload(adapter, {
319
+ ...record.envelope, operationId: `${record.id}-${Date.now()}`,
320
+ }, interaction, renderActionAsText(interaction), record.envelope.replyContext);
321
+ if (!isInteractionSendAccepted(receipt))
322
+ throw new Error('Async question card was not accepted for delivery');
323
+ }
324
+ catch (error) {
325
+ // Keep the durable pending record for a later channel reconnect/daemon restore.
326
+ await this.deps.router.cancel(record.id, 'daemon_restart');
327
+ throw error;
328
+ }
329
+ }
330
+ answerText(question, action, values) {
331
+ if (action === 'cancel' || action === 'dismiss' || values?.behavior === 'dismiss')
332
+ return undefined;
333
+ let answer;
334
+ if (action === '_custom_input')
335
+ answer = typeof values?.custom_text === 'string' ? values.custom_text.trim() : undefined;
336
+ else if (/^opt-\d+$/.test(action))
337
+ answer = question.options?.[Number(action.slice(4))];
338
+ else if (question.options?.length && /^\d+$/.test(action))
339
+ answer = question.options[Number(action) - 1];
340
+ else
341
+ answer = action.trim();
342
+ return answer && answer.length <= 65_536 ? answer : undefined;
343
+ }
344
+ }
56
345
  const INBOUND_MESSAGE_LOG_TEXT_MAX_LENGTH = 240;
57
346
  export function formatInboundMessageLogText(text) {
58
347
  const singleLine = text.replace(/\s+/gu, ' ').trim();
@@ -519,12 +808,15 @@ export class ResponseEngine {
519
808
  primaryRunnerKey;
520
809
  interruptedSessions = new Map(); // sessionId → reason ('new_message' | 'stop' | ...)
521
810
  timeoutErrors = new Map();
811
+ activeTimeoutHandlers = new Map();
522
812
  /** sessionId → 已成功的模型降级状态(进程重启清零) */
523
813
  modelFallbackMap = new Map();
524
814
  /** sessionId → interruptible task-level API retry delays. */
525
815
  retryDelayWaiters = new Map();
526
816
  interactionRouter;
817
+ asyncQuestions;
527
818
  messageQueue;
819
+ taskRecoveryService;
528
820
  handoffRuntime;
529
821
  /** sessionId → 活跃的空闲监控器,用于等待用户交互期间暂停/恢复计时 */
530
822
  activeMonitors = new Map();
@@ -598,10 +890,7 @@ export class ResponseEngine {
598
890
  return createTextInferenceProvider(baseagent, config);
599
891
  }
600
892
  getAvailableBaseagentsForOwner(evolName) {
601
- const prefix = `${evolName}::`;
602
- return [...this.agentMap.keys()]
603
- .filter(key => key.startsWith(prefix))
604
- .map(key => key.slice(prefix.length));
893
+ return loadedBaseagentIds(this.agentMap.keys(), evolName);
605
894
  }
606
895
  async sendBaseagentMismatch(channelKey, channelId, session, error, replyContext) {
607
896
  const channelInfo = this.resolveChannelInfo(channelKey);
@@ -742,6 +1031,12 @@ export class ResponseEngine {
742
1031
  }
743
1032
  setInteractionRouter(router) {
744
1033
  this.interactionRouter = router;
1034
+ this.asyncQuestions = new AsyncQuestionService({
1035
+ router,
1036
+ getSession: id => this.sessionManager.getSessionById(id),
1037
+ authorize: async (record, session) => this.authorizeAsyncQuestion(record, session),
1038
+ submit: (record, session, answer) => this.submitAsyncQuestionAnswer(record, session, answer),
1039
+ });
745
1040
  // 等待用户交互期间暂停 idle 监控,应答/取消/超时后恢复——
746
1041
  // 避免把「正在等用户点按钮」误判为「任务卡死」而中断任务。
747
1042
  router.setWaitHooks({
@@ -752,9 +1047,118 @@ export class ResponseEngine {
752
1047
  this.activeMonitors.get(sessionId)?.resume();
753
1048
  },
754
1049
  });
1050
+ for (const { adapter } of this.channels.values())
1051
+ this.restoreAsyncQuestions(adapter);
755
1052
  }
756
1053
  setMessageQueue(queue) {
757
1054
  this.messageQueue = queue;
1055
+ this.taskRecoveryService = new TaskRecoveryService({
1056
+ sessions: this.sessionManager,
1057
+ queue,
1058
+ getRunner: (session) => this.getAgent(session.metadata?.channelKey || session.channel, session.baseagent, session.selfAID),
1059
+ resumePause: (sessionId) => this.resumeSession(sessionId),
1060
+ hasChannel: (session) => !!this.resolveChannelInfo(session.metadata?.channelKey || session.channel),
1061
+ });
1062
+ }
1063
+ async recoverTask(request) {
1064
+ if (!this.taskRecoveryService) {
1065
+ return { ok: false, status: 'not_recoverable', sessionId: request.sessionId, message: '消息队列尚未初始化' };
1066
+ }
1067
+ return this.taskRecoveryService.resume(request);
1068
+ }
1069
+ authorizeAsyncQuestion(record, session) {
1070
+ const origin = record.origin;
1071
+ const owner = this.agentRegistry?.resolveByChannel(origin.channel);
1072
+ if (owner?.aid !== record.selfAid)
1073
+ return false;
1074
+ const chatType = origin.chatType === 'group' ? 'group' : 'private';
1075
+ const channelType = origin.channelType || record.channelKey.split('#')[0];
1076
+ const preflight = evaluateInboundAdmissionPreflight({
1077
+ selfAid: record.selfAid, channelKey: record.channelKey, channelType, chatType, actorId: origin.peerId,
1078
+ });
1079
+ if (!preflight.allow || !evaluateOrdinaryInboundAdmission(preflight.context).allow)
1080
+ return false;
1081
+ const identity = this.inferDirectSessionIdentity({ ...origin, content: '' }, owner);
1082
+ if (!identity || identity.role === 'none')
1083
+ return false;
1084
+ return authorizeAccess(buildAuthSubject({
1085
+ selfAid: record.selfAid, channel: record.channelKey, channelType, chatType,
1086
+ actorId: origin.peerId, channelId: session.channelId, conversationId: session.channelId, identity,
1087
+ })).allow;
1088
+ }
1089
+ async submitAsyncQuestionAnswer(record, session, answer) {
1090
+ const runner = this.getAgent(record.origin.channel, 'codex', record.selfAid);
1091
+ if (!runner.steerAsyncQuestionAnswer)
1092
+ throw new Error('Codex async input is unavailable');
1093
+ if (await runner.steerAsyncQuestionAnswer(session.id, answer) === 'accepted')
1094
+ return;
1095
+ if (!this.messageQueue)
1096
+ throw new Error('Async question resume queue is unavailable');
1097
+ // The queue owns idle-thread restoration and a fresh task/permission context.
1098
+ // Keep the exact Session/backend identity; never create a new topic for an old card.
1099
+ await this.messageQueue.enqueuePersisted(session.id, {
1100
+ ...record.origin, content: formatAsyncQuestionAnswer(answer),
1101
+ asyncQuestionAnswer: answer, boundSessionId: session.id, baseagent: 'codex',
1102
+ source: 'card-trigger', messageId: `async-answer-${record.id}`,
1103
+ timestamp: Date.now(), receivedAt: Date.now(),
1104
+ encrypted: record.envelope.replyContext?.metadata?.encrypted,
1105
+ replyContext: record.envelope.replyContext,
1106
+ }, session.projectPath, {
1107
+ interruptible: false, interruptSamePeer: false,
1108
+ agentName: this.agentRegistry?.resolveByChannel(record.origin.channel)?.name,
1109
+ selfAID: record.selfAid, sessionKeyField: session.sessionKey,
1110
+ });
1111
+ }
1112
+ /** Read the exact attempt's lifecycle without touching any newer task. */
1113
+ async inspectTargetExecution(sessionId, attemptId, selfAid) {
1114
+ const session = await this.sessionManager.getSessionById(sessionId);
1115
+ if (!session || session.deletedAt || session.selfAID !== selfAid)
1116
+ return { state: 'missing' };
1117
+ const state = this.turnCoordinator.current(session);
1118
+ const terminal = state.lastTerminal;
1119
+ const matches = (context) => (context?.triggerAttemptId ?? context?.recovery?.attemptId) === attemptId;
1120
+ if (terminal && matches(terminal.recoveryContext)) {
1121
+ return { state: 'terminal', taskId: terminal.taskId, generation: terminal.generation,
1122
+ outcome: terminal.status === 'committed' ? 'completed'
1123
+ : ['cancelled', 'superseded', 'interrupted'].includes(terminal.status) ? 'skipped' : 'failed',
1124
+ reason: terminal.reason };
1125
+ }
1126
+ const queued = this.messageQueue?.getMessageState(sessionId, attemptId, selfAid);
1127
+ if (queued?.state === 'queued')
1128
+ return { state: 'live' };
1129
+ const active = state.active;
1130
+ if (active && matches(active.recoveryContext)) {
1131
+ const identity = { taskId: active.taskId, generation: active.generation };
1132
+ if (active.status === 'waiting_interaction' || active.status === 'interrupting'
1133
+ || active.interactions.some(item => !item.settlement)
1134
+ || this.isPauseRequested(sessionId) || this.retryDelayWaiters.has(sessionId))
1135
+ return { state: 'live', ...identity };
1136
+ const runner = this.getAgent(session.metadata?.channelKey || session.channel, session.baseagent, selfAid);
1137
+ return { state: runner.hasActiveStream(sessionId) ? 'live' : 'missing', ...identity,
1138
+ hasTerminalOwner: this.activeTimeoutHandlers.has(sessionId) };
1139
+ }
1140
+ // Never adopt or interrupt a newer task to clean up an older attempt.
1141
+ if (active) {
1142
+ return { state: 'terminal', outcome: 'skipped', reason: 'stale_generation',
1143
+ taskId: active.taskId, generation: active.generation };
1144
+ }
1145
+ if (queued?.state === 'active') {
1146
+ return { state: 'missing', hasTerminalOwner: this.activeTimeoutHandlers.has(sessionId) };
1147
+ }
1148
+ if (queued?.state === 'missing' && (active || terminal)) {
1149
+ const current = active ?? terminal;
1150
+ return { state: 'terminal', outcome: 'skipped', reason: 'stale_generation',
1151
+ taskId: current.taskId, generation: current.generation };
1152
+ }
1153
+ return { state: 'missing' };
1154
+ }
1155
+ /** Outer execution deadlines use the same terminal owner as runner idle timeouts. */
1156
+ async timeoutSession(sessionId, error) {
1157
+ const timeout = this.activeTimeoutHandlers.get(sessionId);
1158
+ if (timeout)
1159
+ await timeout(error);
1160
+ else
1161
+ await this.interruptSession(sessionId, error instanceof OrphanedTargetSessionError ? 'execution_error' : 'timeout');
758
1162
  }
759
1163
  async interruptSession(sessionId, reason) {
760
1164
  this.clearPauseSession(sessionId);
@@ -808,7 +1212,7 @@ export class ResponseEngine {
808
1212
  // TimeoutController owns timeout delivery (including user-facing timeout
809
1213
  // details) and already guarantees a single terminal status. Sending it
810
1214
  // here as well would race that barrier and duplicate status.timeout.
811
- if (!interruptedTaskId || effectiveReason === 'timeout')
1215
+ if (!interruptedTaskId || effectiveReason === 'timeout' || effectiveReason === 'execution_error')
812
1216
  return Promise.resolve();
813
1217
  const channelInfo = this.resolveChannelInfo(channelKey);
814
1218
  if (!channelInfo)
@@ -930,6 +1334,8 @@ export class ResponseEngine {
930
1334
  agentRegistry;
931
1335
  setAgentRegistry(registry) {
932
1336
  this.agentRegistry = registry;
1337
+ for (const { adapter } of this.channels.values())
1338
+ this.restoreAsyncQuestions(adapter);
933
1339
  }
934
1340
  /** 更新 EvolAgent.lastActivity —— 每次发出 status.* 事件(含 progress)时调用 */
935
1341
  touchAgentActivity(channelKey) {
@@ -1026,6 +1432,15 @@ export class ResponseEngine {
1026
1432
  if (!this.channelTypeMap.has(type)) {
1027
1433
  this.channelTypeMap.set(type, adapter.channelName);
1028
1434
  }
1435
+ this.restoreAsyncQuestions(adapter);
1436
+ }
1437
+ restoreAsyncQuestions(adapter) {
1438
+ const selfAid = this.agentRegistry?.resolveByChannel(adapter.channelKey)?.aid;
1439
+ if (selfAid && this.asyncQuestions) {
1440
+ void this.asyncQuestions.restore(adapter, selfAid).catch(error => {
1441
+ logger.warn(`[AsyncQuestions] channel restore failed: ${String(error)}`);
1442
+ });
1443
+ }
1029
1444
  }
1030
1445
  /**
1031
1446
  * 注销渠道适配器(热重载断开渠道时调用,避免遗留死实例)。
@@ -1219,7 +1634,10 @@ export class ResponseEngine {
1219
1634
  && (!authorization.triggerId || !authorization.runId || !authorization.attemptId)) {
1220
1635
  throw new Error('fullaccess Trigger authorization lacks scheduler identity or approval provenance');
1221
1636
  }
1222
- if (authorization.source !== 'fullaccess-command' && authorization.source !== 'trigger') {
1637
+ if (authorization.source === 'agent-policy') {
1638
+ assertAgentPolicyAuthorization(authorization);
1639
+ }
1640
+ else if (authorization.source !== 'fullaccess-command' && authorization.source !== 'trigger') {
1223
1641
  throw new Error('fullaccess execution authorization has an unsupported source');
1224
1642
  }
1225
1643
  if (!isFullAccessEnabled()) {
@@ -1241,6 +1659,8 @@ export class ResponseEngine {
1241
1659
  const originChannelKey = message.triggerMeta.originChannelKey;
1242
1660
  if (!selfAid || !actorId || !channelType || !originChannelKey)
1243
1661
  return undefined;
1662
+ const chatType = message.triggerMeta.originChatType ?? 'private';
1663
+ const conversationId = message.triggerMeta.originConversationId ?? actorId;
1244
1664
  const control = message.triggerMeta.originControl === true;
1245
1665
  let actor;
1246
1666
  let resolvedRole = 'none';
@@ -1249,9 +1669,9 @@ export class ResponseEngine {
1249
1669
  selfAid,
1250
1670
  channelKey: originChannelKey,
1251
1671
  channelType,
1252
- chatType: 'private',
1672
+ chatType,
1253
1673
  actorId,
1254
- conversationId: actorId,
1674
+ conversationId,
1255
1675
  });
1256
1676
  actor = detail.actor;
1257
1677
  resolvedRole = detail.effectiveRole || 'none';
@@ -1271,7 +1691,7 @@ export class ResponseEngine {
1271
1691
  channelType,
1272
1692
  originChannelKey,
1273
1693
  control,
1274
- peerKey: formatPeerKey(channelType, actorId),
1694
+ peerKey: formatPeerKey(channelType, chatType === 'group' ? conversationId : actorId),
1275
1695
  };
1276
1696
  }
1277
1697
  resolveHandoffOriginExecutionIdentity(message, selfAid) {
@@ -1437,7 +1857,8 @@ export class ResponseEngine {
1437
1857
  auditFullAccessEvent({
1438
1858
  event: fallbackFullAccessAudit.authorization.source === 'trigger'
1439
1859
  ? 'fullaccess.trigger.execution.ended'
1440
- : 'fullaccess.execution.ended',
1860
+ : fallbackFullAccessAudit.authorization.source === 'agent-policy'
1861
+ ? 'fullaccess.agent-policy.execution.ended' : 'fullaccess.execution.ended',
1441
1862
  source: fallbackFullAccessAudit.authorization.source,
1442
1863
  actorId: fallbackFullAccessAudit.authorization.authorizedBy,
1443
1864
  processRole: fallbackFullAccessAudit.processRole,
@@ -1458,6 +1879,9 @@ export class ResponseEngine {
1458
1879
  async processMessageWithMonitors(message) {
1459
1880
  const idleMs = (this.globalSettings.idleMonitor?.timeout ?? 120) * 1000;
1460
1881
  const totalExecutionMs = this.totalExecutionLimitMs();
1882
+ // A discarded handoff must be dropped before any bound-session lookup.
1883
+ // Restored origin deliveries may point at a session that has already been
1884
+ // replaced; resolving it first would reactivate the stale session path.
1461
1885
  if (message.handoffDelivery
1462
1886
  && this.handoffRuntime
1463
1887
  && this.handoffRuntime.isDiscardedDelivery(message)) {
@@ -1466,9 +1890,32 @@ export class ResponseEngine {
1466
1890
  + ` handoff=${message.handoffDelivery.handoffId}`);
1467
1891
  return;
1468
1892
  }
1893
+ // Includes persisted queues, recovery triggers and delayed owner replies.
1894
+ // A closed diagnostic must not activate its old main session again.
1895
+ const diagnosticBoundId = message.boundSessionId ?? message.triggerMeta?.boundSessionId;
1896
+ if (diagnosticBoundId) {
1897
+ const bound = await this.sessionManager.getSessionById(diagnosticBoundId);
1898
+ if (bound && (bound.metadata?.diagnostic || bound.metadata?.diagnosticStatus === 'cancelled')
1899
+ && !isDiagnosticSessionActive(bound, this.sessionManager.getChatDir(bound))) {
1900
+ logger.info(`[ResponseEngine] Skip closed diagnostic session ${bound.id}`);
1901
+ return;
1902
+ }
1903
+ }
1904
+ if (message.taskRecovery) {
1905
+ const current = message.boundSessionId
1906
+ ? await this.sessionManager.getSessionById(message.boundSessionId)
1907
+ : undefined;
1908
+ if (!current || current.selfAID !== message.selfAID || !isTaskRecoveryCurrent(current, message.taskRecovery)) {
1909
+ this.publishTriggerExecutionSkipped(message, 'recovery_stale');
1910
+ return;
1911
+ }
1912
+ }
1469
1913
  // 先解析会话,再优先用 session.metadata.channelKey 精确定位实例级 adapter
1470
1914
  // message.channel 现在存实例名(channelName),可直接用于精确路由
1471
1915
  const { session, absoluteProjectPath } = await this.resolveSession(message);
1916
+ if ((session.metadata?.diagnostic || session.metadata?.diagnosticStatus === 'cancelled')
1917
+ && !isDiagnosticSessionActive(session, this.sessionManager.getChatDir(session)))
1918
+ return;
1472
1919
  const accessChannelKey = this.messageChannelKey(message, session);
1473
1920
  const accessChannelInfo = this.resolveChannelInfo(accessChannelKey);
1474
1921
  const accessAdapter = accessChannelInfo?.adapter;
@@ -1520,7 +1967,8 @@ export class ResponseEngine {
1520
1967
  const userRole = session.identity?.role || 'none';
1521
1968
  const isInternalHandoff = message.source === 'handoff';
1522
1969
  const fullAccessAuditCandidate = this.resolveFullAccessAuditCandidate(message);
1523
- const isCurrentFullAccessCommand = fullAccessAuditCandidate?.authorization.source === 'fullaccess-command'
1970
+ const isCurrentFullAccessCommand = (fullAccessAuditCandidate?.authorization.source === 'fullaccess-command'
1971
+ || fullAccessAuditCandidate?.authorization.source === 'agent-policy')
1524
1972
  && fullAccessAuditCandidate.processRole === 'fullaccess-run';
1525
1973
  const isCurrentFullAccessTrigger = fullAccessAuditCandidate?.authorization.source === 'trigger'
1526
1974
  && fullAccessAuditCandidate.processRole === 'fullaccess-run';
@@ -1609,11 +2057,17 @@ export class ResponseEngine {
1609
2057
  claimedByInternal: false,
1610
2058
  terminalDelivered: false,
1611
2059
  claim: () => {
1612
- if (!timeoutControl.error || this.interruptedSessions.get(session.id) !== 'timeout')
2060
+ if (!timeoutControl.error || !['timeout', 'execution_error'].includes(this.interruptedSessions.get(session.id) ?? ''))
1613
2061
  return undefined;
1614
2062
  timeoutControl.claimedByInternal = true;
1615
2063
  return timeoutControl.error;
1616
2064
  },
2065
+ claimTerminal: () => {
2066
+ if (timeoutControl.terminalDelivered)
2067
+ return false;
2068
+ timeoutControl.terminalDelivered = true;
2069
+ return true;
2070
+ },
1617
2071
  };
1618
2072
  const rejectAfterInterruptBarrier = (error) => {
1619
2073
  if (timeoutTriggered)
@@ -1622,9 +2076,10 @@ export class ResponseEngine {
1622
2076
  timeoutControl.error = error;
1623
2077
  this.timeoutErrors.set(session.id, error);
1624
2078
  this.turnCoordinator.hydrate(session);
1625
- this.turnCoordinator.invalidate(session.id, 'timeout');
1626
- this.interruptedSessions.set(session.id, 'timeout');
1627
- const barrier = this.interruptSession(session.id, 'timeout')
2079
+ const reason = error instanceof OrphanedTargetSessionError ? 'execution_error' : 'timeout';
2080
+ this.turnCoordinator.invalidate(session.id, reason);
2081
+ this.interruptedSessions.set(session.id, reason);
2082
+ const barrier = this.interruptSession(session.id, reason)
1628
2083
  .catch(interruptError => {
1629
2084
  logger.debug(`[ResponseEngine] Timeout interrupt barrier failed: ${interruptError instanceof Error ? interruptError.message : String(interruptError)}`);
1630
2085
  });
@@ -1635,6 +2090,11 @@ export class ResponseEngine {
1635
2090
  });
1636
2091
  };
1637
2092
  const triggerAttemptId = message.replyContext?.metadata?.triggerAttemptId;
2093
+ const externalTimeout = async (error) => {
2094
+ rejectAfterInterruptBarrier(error);
2095
+ await timeoutControl.barrier;
2096
+ };
2097
+ this.activeTimeoutHandlers.set(session.id, externalTimeout);
1638
2098
  const resetTimer = (eventType, toolName) => {
1639
2099
  monitor?.recordEvent(eventType || 'unknown', toolName);
1640
2100
  if (typeof triggerAttemptId === 'string') {
@@ -1714,11 +2174,9 @@ export class ResponseEngine {
1714
2174
  await Promise.race(processingPromises);
1715
2175
  }
1716
2176
  catch (error) {
1717
- if (error instanceof Error && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
2177
+ if (error instanceof Error && (isExecutionFailure(error) || error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT')) {
1718
2178
  await timeoutControl.barrier;
1719
- if (!timeoutControl.terminalDelivered) {
1720
- await timeoutControl.deliverUnhandled?.(error);
1721
- }
2179
+ await timeoutControl.deliverUnhandled?.(error);
1722
2180
  this.timeoutErrors.delete(session.id);
1723
2181
  }
1724
2182
  // 超时错误:kill 级别已发送诊断信息,无需再发
@@ -1759,6 +2217,8 @@ export class ResponseEngine {
1759
2217
  if (totalExecutionTimer)
1760
2218
  clearTimeout(totalExecutionTimer);
1761
2219
  this.activeMonitors.delete(streamKey);
2220
+ if (this.activeTimeoutHandlers.get(session.id) === externalTimeout)
2221
+ this.activeTimeoutHandlers.delete(session.id);
1762
2222
  }
1763
2223
  }
1764
2224
  resolveFullAccessAuditCandidate(message) {
@@ -1766,15 +2226,18 @@ export class ResponseEngine {
1766
2226
  if (commandAuthorization?.permissionMode === 'fullaccess'
1767
2227
  && commandAuthorization.processRole === 'fullaccess-run'
1768
2228
  && commandAuthorization.dataScope === 'daemon'
1769
- && commandAuthorization.source === 'fullaccess-command'
2229
+ && (commandAuthorization.source === 'fullaccess-command' || commandAuthorization.source === 'agent-policy')
1770
2230
  && typeof commandAuthorization.authorizedBy === 'string'
1771
2231
  && commandAuthorization.authorizedBy.length > 0) {
1772
2232
  let currentOwner = false;
1773
2233
  try {
1774
2234
  currentOwner = isFullAccessEnabled()
1775
2235
  && new Set(loadDaemonConfig().owners ?? []).has(commandAuthorization.authorizedBy);
2236
+ if (commandAuthorization.source === 'agent-policy')
2237
+ assertAgentPolicyAuthorization(commandAuthorization);
1776
2238
  }
1777
2239
  catch {
2240
+ currentOwner = false;
1778
2241
  // Runtime validation will fail closed; the audit must still retain the
1779
2242
  // attempted authorization without falsely labeling it active.
1780
2243
  }
@@ -1894,7 +2357,7 @@ export class ResponseEngine {
1894
2357
  // states. An untransitioned `created` agent or malformed lifecycle must
1895
2358
  // not fall through to a base runner's default prompt. Missing lifecycle
1896
2359
  // is normalized to active for legacy configurations.
1897
- if (owningAgentForTask && !isActive && !isBootstrapping) {
2360
+ if (owningAgentForTask && ((!isActive && !isBootstrapping) || initializationBlocksTasks(owningAgentForTask.config))) {
1898
2361
  const blockedLifecycle = lifecycle
1899
2362
  ?? `invalid (${String(owningAgentForTask.config.lifecycle)})`;
1900
2363
  logger.error(`[ResponseEngine] Agent lifecycle is not runnable: agent=${owningAgentForTask.aid} lifecycle=${blockedLifecycle}`);
@@ -1991,7 +2454,7 @@ export class ResponseEngine {
1991
2454
  ?? this.resolveHandoffOriginExecutionIdentity(message, selfAid);
1992
2455
  const configChannelType = authorizationIdentity?.channelType ?? currentChannelType;
1993
2456
  const configChatType = authorizationIdentity
1994
- ? (message.handoffDelivery?.originAuthorization?.chatType ?? 'private')
2457
+ ? (message.triggerMeta?.originChatType ?? message.handoffDelivery?.originAuthorization?.chatType ?? 'private')
1995
2458
  : (chatType === 'group' ? 'group' : 'private');
1996
2459
  // A group channel ID identifies the conversation, not the authenticated
1997
2460
  // operator. Keep the historical private-chat fallback (where channelId
@@ -2179,25 +2642,30 @@ export class ResponseEngine {
2179
2642
  });
2180
2643
  if (timeoutControl) {
2181
2644
  timeoutControl.deliverUnhandled = async (timeoutError) => {
2182
- if (timeoutControl.terminalDelivered)
2645
+ if (!timeoutControl.claimTerminal())
2183
2646
  return;
2184
- timeoutControl.terminalDelivered = true;
2185
2647
  const isTotalExecutionTimeout = timeoutError.message === 'TOTAL_EXECUTION_TIMEOUT';
2186
- const totalExecutionMs = this.totalExecutionLimitMs();
2187
- const statusPayload = {
2648
+ const totalExecutionMs = timeoutError instanceof ExecutionTimeoutError ? timeoutError.timeoutMs : this.totalExecutionLimitMs();
2649
+ const statusPayload = timeoutError instanceof OrphanedTargetSessionError ? {
2650
+ kind: 'status.error',
2651
+ metadata: { errorType: timeoutError.reason, message: '任务执行状态丢失,已结束本次执行。' },
2652
+ } : {
2188
2653
  kind: 'status.timeout',
2189
2654
  metadata: isTotalExecutionTimeout
2190
- ? (totalExecutionMs === undefined ? {} : { totalExecutionMs })
2655
+ ? { ...(totalExecutionMs === undefined ? {} : { totalExecutionMs }),
2656
+ ...(timeoutError instanceof ExecutionTimeoutError ? { reason: timeoutError.reason } : {}) }
2191
2657
  : { idleSec: getLastIdleSec?.() || undefined },
2192
2658
  };
2193
2659
  const idleSec = getLastIdleSec?.() || 0;
2194
- const userMessage = isTotalExecutionTimeout
2195
- ? (totalExecutionMs === undefined
2196
- ? '⚠️ 任务超过总执行时限,已自动中断'
2197
- : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
2198
- : idleSec > 0
2199
- ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断`
2200
- : '⚠️ 任务超时,已自动中断';
2660
+ const userMessage = timeoutError instanceof OrphanedTargetSessionError
2661
+ ? '⚠️ 任务执行状态丢失,已结束本次执行。'
2662
+ : isTotalExecutionTimeout
2663
+ ? (totalExecutionMs === undefined
2664
+ ? '⚠️ 任务超过总执行时限,已自动中断'
2665
+ : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
2666
+ : idleSec > 0
2667
+ ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断`
2668
+ : '⚠️ 任务超时,已自动中断';
2201
2669
  agent.cleanupStream(streamKey);
2202
2670
  this.sessionManager.clearProcessingIfTask(session.id, taskId);
2203
2671
  if (this.activeTaskRuntimeContexts.get(session.id)?.taskId === taskId) {
@@ -2211,13 +2679,13 @@ export class ResponseEngine {
2211
2679
  await adapter.send(envelope, {
2212
2680
  kind: 'result.error',
2213
2681
  text: userMessage,
2214
- reason: isTotalExecutionTimeout ? 'total_execution_timeout' : 'timeout',
2682
+ reason: isExecutionFailure(timeoutError) ? timeoutError.reason : isTotalExecutionTimeout ? 'total_execution_timeout' : 'timeout',
2215
2683
  }).catch(() => { });
2216
2684
  if (daemonTrigger) {
2217
2685
  await adapter.send(envelope, statusPayload).catch(() => { });
2218
2686
  }
2219
2687
  this.touchAgentActivity(channelKey);
2220
- this.publishTriggerExecutionFailure(message, timeoutError.message, {
2688
+ this.publishTriggerExecutionFailure(message, isExecutionFailure(timeoutError) ? timeoutError.reason : timeoutError.message, {
2221
2689
  messageId,
2222
2690
  causation: taskCausation,
2223
2691
  });
@@ -2225,9 +2693,14 @@ export class ResponseEngine {
2225
2693
  this.eventBus.publish({
2226
2694
  type: 'task:error',
2227
2695
  sessionId: session.id,
2696
+ agentAid: runnerSelfAid,
2697
+ peerId: configActorId,
2698
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
2699
+ originTriggerId: message.triggerMeta?.triggerId,
2228
2700
  taskId,
2229
2701
  generation: turnLease?.generation,
2230
2702
  error: timeoutError.message,
2703
+ terminalReason: isExecutionFailure(timeoutError) ? timeoutError.reason : undefined,
2231
2704
  errorType,
2232
2705
  agentName: agentNameForStats,
2233
2706
  causation: taskCausation,
@@ -2356,14 +2829,28 @@ export class ResponseEngine {
2356
2829
  // message is authoritative; session metadata is only a fallback for
2357
2830
  // legacy paths that do not carry a per-message display name.
2358
2831
  const peerName = message.peerName ?? session.metadata?.peerName;
2359
- const peerId = session.metadata?.peerId ?? message.peerId ?? message.channelId;
2832
+ const peerId = message.peerId ?? session.metadata?.peerId ?? message.channelId;
2360
2833
  const peerShort = peerId ? peerId.split('.')[0].split(':')[0] : '?';
2361
2834
  const peerLabel = peerName && peerName !== peerShort ? `${peerShort}(${peerName})` : peerShort;
2362
2835
  logger.info(`[ResponseEngine] session=${session.id} task=${taskId} peer=${peerLabel} chatType=${session.chatType} chatMode=${effectiveChatMode} baseagent=${session.baseagent} msgChatType=${message.chatType ?? 'n/a'}`);
2363
2836
  // Establish the durable turn before exposing any processing state. This
2364
2837
  // lets an immediate /stop or replacement message close the same task and
2365
2838
  // prevents a BusinessTrip reaction from becoming orphaned during setup.
2366
- turnLease = await this.turnCoordinator.begin(session, taskId);
2839
+ if (message.taskRecovery && !isTaskRecoveryCurrent({
2840
+ ...session, metadata: { ...session.metadata, turnState: this.turnCoordinator.current(session) },
2841
+ }, message.taskRecovery)) {
2842
+ this.publishTriggerExecutionSkipped(message, 'recovery_stale');
2843
+ return;
2844
+ }
2845
+ turnLease = await this.turnCoordinator.begin(session, taskId, undefined, configActorId ? {
2846
+ peerId: configActorId,
2847
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
2848
+ baseagent: session.baseagent,
2849
+ agentSessionId: session.agentSessionId,
2850
+ originTriggerId: message.triggerMeta?.triggerId,
2851
+ recovery: message.taskRecovery?.origin,
2852
+ triggerAttemptId: message.triggerMeta?.attemptId,
2853
+ } : undefined);
2367
2854
  // 记录开始处理
2368
2855
  const taskEncrypt = message.replyContext?.metadata?.encrypted != null ? !!(message.replyContext.metadata.encrypted) : undefined;
2369
2856
  this.eventBus.publish({
@@ -2405,6 +2892,7 @@ export class ResponseEngine {
2405
2892
  flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
2406
2893
  suppressActivityItems: isProactive ? false : middleOutputMode !== 'all',
2407
2894
  suppressIntermediateText: isProactive ? false : middleOutputMode === 'none',
2895
+ deferTextUntilBoundary: (message.channelType ?? session.channelType) === 'aun' && session.chatType === 'private',
2408
2896
  operationalNoticesAsText: !isProactive && middleOutputMode !== 'none',
2409
2897
  fileMarkerPattern: options?.fileMarkerPattern,
2410
2898
  send: async (payload) => {
@@ -2440,7 +2928,10 @@ export class ResponseEngine {
2440
2928
  if (payload.kind === 'result.text' && payload.isFinal) {
2441
2929
  opts.title = '\u2705 \u6700\u7ec8\u56de\u590d:';
2442
2930
  }
2443
- opts.metadata = withTaskMetadata(opts.metadata);
2931
+ opts.metadata = withTaskMetadata({
2932
+ ...(opts.metadata ?? {}),
2933
+ ...(payload.kind === 'result.text' ? payload.metadata : {}),
2934
+ });
2444
2935
  // status.notice is a diagnostic observation, not execution activity.
2445
2936
  if (payload.kind.startsWith('status.') && payload.kind !== 'status.notice') {
2446
2937
  this.touchAgentActivity(channelKey);
@@ -2585,10 +3076,10 @@ export class ResponseEngine {
2585
3076
  };
2586
3077
  })();
2587
3078
  let effectivePermissionMode = 'readonly';
2588
- // Allocate/register the session-owned root before any runner preflight
2589
- // context is installed. Codex may already own a stronger directory;
2590
- // other runners receive a private child of the process TMPDIR.
2591
- const sessionRuntimeDir = agent.getManagedTempDir?.(session.id) ?? ensureSessionRuntimeDir(session.id);
3079
+ // Allocate the stable session-owned root before any runner preflight
3080
+ // context is installed. Every runner for this EvolCore session receives
3081
+ // the same directory, independent of daemon or runner lifetimes.
3082
+ const sessionRuntimeDir = ensureSessionRuntimeDir(session.id);
2592
3083
  const recordExecutionAnomaly = triggerRunId
2593
3084
  ? (anomaly) => {
2594
3085
  recordTriggerExecutionAnomaly(triggerRunId, {
@@ -2636,8 +3127,8 @@ export class ResponseEngine {
2636
3127
  pauseController: taskPauseController,
2637
3128
  preToolUsePolicyHook: pureSessionPolicyHook,
2638
3129
  armApprovedDelegationCommand: this.agentDelegationRegistry
2639
- ? (carrierToken, commandHash) => this.agentDelegationRegistry.armApprovedCommand({
2640
- carrierToken,
3130
+ ? (sessionToken, commandHash) => this.agentDelegationRegistry.armApprovedCommand({
3131
+ sessionToken,
2641
3132
  sessionId: session.id,
2642
3133
  taskId,
2643
3134
  commandHash,
@@ -2812,10 +3303,19 @@ export class ResponseEngine {
2812
3303
  if (commandFullAccess.permissionMode !== 'fullaccess'
2813
3304
  || commandFullAccess.processRole !== 'fullaccess-run'
2814
3305
  || commandFullAccess.dataScope !== 'daemon'
2815
- || commandFullAccess.source !== 'fullaccess-command'
3306
+ || (commandFullAccess.source !== 'fullaccess-command' && commandFullAccess.source !== 'agent-policy')
2816
3307
  || !commandFullAccess.authorizedBy) {
2817
3308
  throw new Error('fullaccess command authorization is missing or malformed');
2818
3309
  }
3310
+ if (commandFullAccess.source === 'agent-policy'
3311
+ && (commandFullAccess.agentAid !== runnerSelfAid
3312
+ || configChatType !== 'private'
3313
+ || (message.source !== undefined && message.source !== 'user')
3314
+ || !hasTrustedPrincipal(authenticatedActor)
3315
+ || authenticatedActor.principalId !== commandFullAccess.authorizedBy
3316
+ || authenticatedActor.operatorId !== commandFullAccess.policyActor?.operatorId)) {
3317
+ throw new Error('Agent fullaccess policy authorization is missing its Agent identity');
3318
+ }
2819
3319
  fullAccessAuthorization = commandFullAccess;
2820
3320
  }
2821
3321
  else {
@@ -3030,18 +3530,25 @@ export class ResponseEngine {
3030
3530
  // happen before task-start auto compact because compact is a backend
3031
3531
  // management action just like the subsequent runQuery.
3032
3532
  let runAgentSessionId = session.agentSessionId;
3033
- if (session.threadId
3533
+ if ((session.threadId || message.taskRecovery)
3034
3534
  && typeof this.sessionManager.getSessionById === 'function') {
3035
3535
  const latestBackendSession = await this.sessionManager.getSessionById(session.id);
3036
- if (!latestBackendSession || latestBackendSession.threadId !== session.threadId) {
3536
+ if (!latestBackendSession || latestBackendSession.deletedAt || latestBackendSession.threadId !== session.threadId) {
3037
3537
  throw new Error('topic session changed before backend run started');
3038
3538
  }
3539
+ if (message.taskRecovery && (latestBackendSession.agentSessionId !== message.taskRecovery.agentSessionId
3540
+ || latestBackendSession.baseagent !== message.taskRecovery.baseagent)) {
3541
+ throw new Error('recovery backend changed before execution');
3542
+ }
3039
3543
  runAgentSessionId = latestBackendSession.agentSessionId;
3040
3544
  session.agentSessionId = runAgentSessionId;
3041
3545
  session.metadata = latestBackendSession.metadata;
3042
3546
  }
3043
3547
  modelOverride = {
3044
3548
  ...(modelOverride || {}),
3549
+ ...(message.taskRecovery
3550
+ ? { recovery: { agentSessionId: message.taskRecovery.agentSessionId } }
3551
+ : {}),
3045
3552
  ...(session.threadId
3046
3553
  ? { backend: { kind: 'topic', agentSessionId: runAgentSessionId ?? null } }
3047
3554
  : {}),
@@ -3054,13 +3561,17 @@ export class ResponseEngine {
3054
3561
  },
3055
3562
  };
3056
3563
  const refreshTopicBindingForLogicalRetry = async () => {
3057
- if (!session.threadId || typeof this.sessionManager.getSessionById !== 'function')
3564
+ if ((!session.threadId && !message.taskRecovery) || typeof this.sessionManager.getSessionById !== 'function')
3058
3565
  return;
3059
3566
  const latest = await this.sessionManager.getSessionById(session.id);
3060
3567
  if (!latest || latest.threadId !== session.threadId) {
3061
3568
  throw new Error('topic session changed during backend run');
3062
3569
  }
3063
3570
  const latestAgentSessionId = latest.agentSessionId;
3571
+ if (message.taskRecovery && (latestAgentSessionId !== message.taskRecovery.agentSessionId
3572
+ || latest.baseagent !== message.taskRecovery.baseagent)) {
3573
+ throw new Error('recovery backend changed during execution');
3574
+ }
3064
3575
  session.agentSessionId = latestAgentSessionId;
3065
3576
  session.metadata = latest.metadata;
3066
3577
  if (latestAgentSessionId === runAgentSessionId)
@@ -3097,6 +3608,26 @@ export class ResponseEngine {
3097
3608
  groupId: session.metadata?.groupId || message.channelId,
3098
3609
  channel: currentChannelType || message.channel,
3099
3610
  });
3611
+ const historyGroupId = session.metadata?.groupId || message.channelId;
3612
+ const canUseGroupHistory = (operation) => {
3613
+ if (currentChannelType !== 'aun' || session.chatType !== 'group' || !selfAid || !historyGroupId)
3614
+ return false;
3615
+ const historySubject = buildAuthSubject({
3616
+ selfAid, actorId: configActorId, channel: channelKey, channelType: configChannelType,
3617
+ chatType: configChatType, channelId: message.channelId,
3618
+ conversationId: configChatType === 'group' ? historyGroupId : configActorId,
3619
+ permissionMode: effectivePermissionMode,
3620
+ });
3621
+ return authorizeOperation({
3622
+ source: 'agent-tool', subject: historySubject, audit: false,
3623
+ intent: {
3624
+ operation, scope: ['owner', 'admin'].includes(historySubject.role) ? 'agent' : 'relation',
3625
+ source: 'agent-tool',
3626
+ args: { self: selfAid, targetId: historyGroupId, peer: historyGroupId,
3627
+ peerKey: formatPeerKey('aun', historyGroupId) },
3628
+ },
3629
+ }).allow;
3630
+ };
3100
3631
  // Kit renderer: 组装上下文
3101
3632
  const pkgRoot = getPackageRoot();
3102
3633
  const kitCtx = {
@@ -3128,11 +3659,12 @@ export class ResponseEngine {
3128
3659
  peerKey,
3129
3660
  peerName: peerName || undefined,
3130
3661
  peerRole,
3131
- peerType: message.peerType || session.metadata?.peerType || undefined,
3662
+ // Display missing identity explicitly without changing the mode resolver's legacy fallback.
3663
+ peerType: peerType || 'unknown',
3132
3664
  sameDevice: message.sameDevice ?? false,
3133
3665
  sameNetwork: message.sameNetwork ?? false,
3134
3666
  sameEgressIp: message.sameEgressIp ?? false,
3135
- groupId: session.metadata?.groupId || undefined,
3667
+ groupId: session.chatType === 'group' ? historyGroupId : undefined,
3136
3668
  groupName: session.metadata?.groupName || undefined,
3137
3669
  // 信封展示用:有群名则「名<ID>」,否则纯 ID。规避模板引擎无 not/else 的限制。
3138
3670
  groupLabel: session.metadata?.groupId
@@ -3147,6 +3679,7 @@ export class ResponseEngine {
3147
3679
  ?? session.metadata?.mentionMode
3148
3680
  ?? message.mentionMode
3149
3681
  ?? undefined,
3682
+ groupHistoryAvailable: canUseGroupHistory('ec.group.history'),
3150
3683
  clientType: message.clientType || undefined,
3151
3684
  permissionMode: effectivePermissionMode,
3152
3685
  capabilities: capParts.length > 0 ? capParts.join('、') : undefined,
@@ -3343,11 +3876,9 @@ export class ResponseEngine {
3343
3876
  }
3344
3877
  }
3345
3878
  }
3346
- // The runner may already own a stronger per-session directory (Codex
3347
- // registers one before constructing its thread). Other runners use a
3348
- // private child of the process-provided TMPDIR. Both paths are passed
3349
- // explicitly so sandboxed helper commands never fall back to a shared
3350
- // agent or project directory for transient state.
3879
+ // Pass the stable session directory explicitly so sandboxed helper
3880
+ // commands never fall back to a process, agent, or project directory
3881
+ // for transient state.
3351
3882
  const taskRuntimeContext = {
3352
3883
  taskId,
3353
3884
  sessionId: session.id,
@@ -3430,7 +3961,8 @@ export class ResponseEngine {
3430
3961
  auditFullAccessEvent({
3431
3962
  event: fullAccessAuthorization.source === 'trigger'
3432
3963
  ? 'fullaccess.trigger.execution.started'
3433
- : 'fullaccess.execution.started',
3964
+ : fullAccessAuthorization.source === 'agent-policy'
3965
+ ? 'fullaccess.agent-policy.execution.started' : 'fullaccess.execution.started',
3434
3966
  source: fullAccessAuthorization.source,
3435
3967
  actorId: fullAccessAuthorization.authorizedBy,
3436
3968
  processRole: fullAccessAuditProcessRole,
@@ -3454,6 +3986,23 @@ export class ResponseEngine {
3454
3986
  }
3455
3987
  fullAccessRunnerInvoked = true;
3456
3988
  }
3989
+ if (message.asyncQuestionAnswer) {
3990
+ if (session.baseagent !== 'codex' || runAgentSessionId !== message.asyncQuestionAnswer.providerThreadId) {
3991
+ throw new Error('Async question backend changed before queued resume');
3992
+ }
3993
+ if (!(await this.asyncQuestions?.validateQueuedAnswer(session, message.asyncQuestionAnswer))) {
3994
+ throw new Error('Async question authorization or persisted answer changed before queued resume');
3995
+ }
3996
+ // An active queue item restored after submission resumes the unfinished work.
3997
+ // Its native answer is already in Codex history and must not be appended twice.
3998
+ if (!message.restartResume?.submitted) {
3999
+ modelOverride = { ...modelOverride, asyncQuestionAnswer: message.asyncQuestionAnswer };
4000
+ }
4001
+ }
4002
+ if (session.baseagent === 'codex') {
4003
+ modelOverride = { ...modelOverride,
4004
+ enableRequestUserInput: effectiveAgentConfig?.baseagents?.codex?.enableRequestUserInput ?? true };
4005
+ }
3457
4006
  const stream = await agent.runQuery(session.id, effectivePrompt, absoluteProjectPath, runAgentSessionId, renderResult?.images.length ? renderResult.images : message.images, effectiveSystemPrompt, this.sessionManager, modelOverride, runtimeEnv);
3458
4007
  // The turn may be superseded while the runner is still creating its
3459
4008
  // backend query. Replay the interrupt after runQuery resolves, when
@@ -3470,7 +4019,23 @@ export class ResponseEngine {
3470
4019
  const processAttempt = this.processEventStream(stream, session, agent, renderer, (eventType, toolName) => {
3471
4020
  resetTimer(eventType, toolName);
3472
4021
  attemptTimeout?.reset();
3473
- }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease, effectivePermissionMode, selfAid);
4022
+ }, shouldSuppress, proactive, resolvedMode ? { mode: resolvedMode.mode, state: modeState } : undefined, taskCausation, turnLease, effectivePermissionMode, selfAid, async (request) => {
4023
+ if (!this.asyncQuestions)
4024
+ throw new Error('Async question service is unavailable');
4025
+ if (permissionContext.approvalInteractionPolicy === 'deny') {
4026
+ logger.info(`[AsyncQuestions] no interactive recipient for unattended task: session=${session.id}`);
4027
+ return;
4028
+ }
4029
+ await renderer.flush(false);
4030
+ try {
4031
+ await this.asyncQuestions.present(session, request, message, adapter, envelope);
4032
+ }
4033
+ catch (error) {
4034
+ // Card dispatch is non-blocking business input. Keep consuming the running turn;
4035
+ // pending records can be delivered after reconnect/restart.
4036
+ logger.warn(`[AsyncQuestions] presentation failed: session=${session.id} error=${String(error)}`);
4037
+ }
4038
+ });
3474
4039
  streamResult = attemptTimeout
3475
4040
  ? await Promise.race([processAttempt, attemptTimeout.promise])
3476
4041
  : await processAttempt;
@@ -3889,7 +4454,7 @@ export class ResponseEngine {
3889
4454
  // `hasReceivedText` 是任务级计数。工具调用前的文字已经显示,并不表示最后一轮
3890
4455
  // (通常来自 complete.result)的文字也已显示;用本轮投影标记避免终态丢失。
3891
4456
  if (shouldSuppress() || !streamResult.hasProjectedCurrentReplyText) {
3892
- renderer.addText(visibleFinalReplyText);
4457
+ await renderer.addText(visibleFinalReplyText, undefined, undefined, streamResult.assistantUuid);
3893
4458
  }
3894
4459
  }
3895
4460
  // 先清理流和处理中状态(保证即使 flush 卡住,session 也不会永久处于"处理中")
@@ -3908,7 +4473,7 @@ export class ResponseEngine {
3908
4473
  const suffix = showHint
3909
4474
  ? `\n\n---\n⚠️ [降级模型: ${usedFallback.model} | 可告诉我"帮我检查可用模型"来诊断]`
3910
4475
  : `\n\n---\n⚠️ [降级模型: ${usedFallback.model}]`;
3911
- renderer.addText(suffix);
4476
+ await renderer.addText(suffix, undefined, undefined, streamResult.assistantUuid);
3912
4477
  if (showHint && curFbState) {
3913
4478
  curFbState.hintShown = true;
3914
4479
  this.modelFallbackMap.set(session.id, curFbState);
@@ -3921,7 +4486,14 @@ export class ResponseEngine {
3921
4486
  }
3922
4487
  else {
3923
4488
  // Flush 剩余内容(文件标记已在 flush 时自动移除)
3924
- await renderer.flush(true);
4489
+ // A final text flush can also contain failed output. Preserve the
4490
+ // outcome so AUN does not label that text as normal completion.
4491
+ if (streamResult.isError && !isExpectedTerminalInterrupt(streamResult.terminalReason)) {
4492
+ await renderer.flush(true, 'failed');
4493
+ }
4494
+ else {
4495
+ await renderer.flush(!isExpectedTerminalInterrupt(streamResult.terminalReason));
4496
+ }
3925
4497
  }
3926
4498
  const terminalInterrupt = isExpectedTerminalInterrupt(streamResult.terminalReason);
3927
4499
  if (!streamResult.isError && !terminalInterrupt) {
@@ -3944,7 +4516,8 @@ export class ResponseEngine {
3944
4516
  }
3945
4517
  }
3946
4518
  else {
3947
- await this.turnCoordinator.finish(session, turnLease, terminalInterrupt ? 'cancelled' : 'failed', streamResult.protocolIncompleteReason || streamResult.terminalReason || streamResult.subtype);
4519
+ await this.turnCoordinator.finish(session, turnLease, terminalInterrupt ? 'cancelled' : 'failed', permissionTerminalFailure ? 'permission_denied'
4520
+ : streamResult.protocolIncompleteReason || streamResult.terminalReason || streamResult.subtype);
3948
4521
  }
3949
4522
  // 注意:不在此处清除 interruptedSessions,由下一条消息的 prompt 包装逻辑消费
3950
4523
  const interruptReason = this.interruptedSessions.get(session.id);
@@ -3978,7 +4551,7 @@ export class ResponseEngine {
3978
4551
  // DaemonChannel settles a trigger when it receives a terminal
3979
4552
  // payload. Send the metadata-bearing status first so failed runs do
3980
4553
  // not lose their tool counts and recovery diagnostics.
3981
- if (message.source === 'trigger') {
4554
+ if (this.isTrustedDaemonTrigger(message)) {
3982
4555
  await adapter.send(envelope, { kind: 'status.error', metadata: terminalMetadata }).catch(() => { });
3983
4556
  await adapter.send(envelope, { kind: 'result.error', text: userErrorSummary, reason: rawSubtype }).catch(() => { });
3984
4557
  }
@@ -4019,6 +4592,10 @@ export class ResponseEngine {
4019
4592
  this.eventBus.publish({
4020
4593
  type: 'task:error',
4021
4594
  sessionId: session.id,
4595
+ agentAid: runnerSelfAid,
4596
+ peerId: configActorId,
4597
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
4598
+ originTriggerId: message.triggerMeta?.triggerId,
4022
4599
  taskId,
4023
4600
  generation: turnLease?.generation,
4024
4601
  error: errorSummary,
@@ -4297,7 +4874,8 @@ export class ResponseEngine {
4297
4874
  await timeoutControl?.barrier;
4298
4875
  }
4299
4876
  const handlingTimeout = error instanceof Error
4300
- && (error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT');
4877
+ && (isExecutionFailure(error) || error.message === 'SDK_TIMEOUT' || error.message === 'TOTAL_EXECUTION_TIMEOUT');
4878
+ const ownsTimeoutTerminal = !handlingTimeout || !timeoutControl || timeoutControl.claimTerminal();
4301
4879
  if (handlingTimeout) {
4302
4880
  this.timeoutErrors.delete(session.id);
4303
4881
  }
@@ -4319,11 +4897,12 @@ export class ResponseEngine {
4319
4897
  const errType = classifyError(error);
4320
4898
  const interruptReason = this.interruptedSessions.get(session.id);
4321
4899
  const isTotalExecutionTimeout = error instanceof Error && error.message === 'TOTAL_EXECUTION_TIMEOUT';
4322
- const isTimeout = error instanceof Error && error.message === 'SDK_TIMEOUT';
4900
+ const isTimeout = error instanceof Error && (error.message === 'SDK_TIMEOUT' || error instanceof ExecutionTimeoutError);
4323
4901
  const canPublishTerminal = !turnLease || this.turnCoordinator.canPublish(turnLease);
4324
4902
  const staleSupersededTurn = !!turnLease
4325
4903
  && !canPublishTerminal
4326
4904
  && interruptReason !== 'timeout'
4905
+ && interruptReason !== 'execution_error'
4327
4906
  && !isTimeout
4328
4907
  && !isTotalExecutionTimeout;
4329
4908
  const isUserInterrupt = isNonErrorInterruptReason(interruptReason) || staleSupersededTurn;
@@ -4331,7 +4910,7 @@ export class ResponseEngine {
4331
4910
  if (isUserInterrupt) {
4332
4911
  await this.publishTriggerExecutionInterrupted(message, adapter, envelope, interruptReason ?? 'stale_turn', taskCausation);
4333
4912
  }
4334
- else {
4913
+ else if (ownsTimeoutTerminal) {
4335
4914
  this.publishTriggerExecutionFailure(message, errorMsg, {
4336
4915
  messageId,
4337
4916
  causation: taskCausation,
@@ -4340,24 +4919,27 @@ export class ResponseEngine {
4340
4919
  if (leaseWasCurrent && turnLease) {
4341
4920
  await this.turnCoordinator.finish(session, turnLease, 'failed', error instanceof Error ? error.message : String(error));
4342
4921
  }
4343
- const totalExecutionMs = this.totalExecutionLimitMs();
4922
+ const totalExecutionMs = error instanceof ExecutionTimeoutError ? error.timeoutMs : this.totalExecutionLimitMs();
4344
4923
  const procStatus = errType === ErrorType.SDK_TIMEOUT || isTotalExecutionTimeout ? 'timeout'
4345
4924
  : errType === ErrorType.STREAM_ERROR ? 'interrupted'
4346
4925
  : 'error';
4347
4926
  const daemonTrigger = this.isTrustedDaemonTrigger(message);
4348
- const statusPayload = procStatus === 'timeout'
4349
- ? { kind: 'status.timeout', metadata: isTotalExecutionTimeout
4350
- ? (totalExecutionMs === undefined ? {} : { totalExecutionMs })
4351
- : { idleSec: getLastIdleSec?.() || undefined } }
4352
- : procStatus === 'interrupted'
4353
- ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
4354
- : { kind: 'status.error' };
4927
+ const statusPayload = error instanceof OrphanedTargetSessionError
4928
+ ? { kind: 'status.error', metadata: { errorType: error.reason, message: '任务执行状态丢失,已结束本次执行。' } }
4929
+ : procStatus === 'timeout'
4930
+ ? { kind: 'status.timeout', metadata: isTotalExecutionTimeout
4931
+ ? { ...(totalExecutionMs === undefined ? {} : { totalExecutionMs }),
4932
+ ...(error instanceof ExecutionTimeoutError ? { reason: error.reason } : {}) }
4933
+ : { idleSec: getLastIdleSec?.() || undefined } }
4934
+ : procStatus === 'interrupted'
4935
+ ? { kind: 'status.interrupted', metadata: { reason: 'stream_error' } }
4936
+ : { kind: 'status.error' };
4355
4937
  // 用户主动中断(新消息打断 或 /stop 命令)时静默,不发送中断/错误提示
4356
- if (!isUserInterrupt && !daemonTrigger) {
4938
+ if (!isUserInterrupt && ownsTimeoutTerminal && !daemonTrigger) {
4357
4939
  await adapter.send(envelope, statusPayload).catch(() => { });
4358
4940
  this.touchAgentActivity(channelKey);
4359
4941
  }
4360
- if (!isUserInterrupt && daemonTrigger)
4942
+ if (!isUserInterrupt && ownsTimeoutTerminal && daemonTrigger)
4361
4943
  this.touchAgentActivity(channelKey);
4362
4944
  // 用户主动中断时降级日志;其余仍按 error 记录
4363
4945
  if (isUserInterrupt) {
@@ -4371,13 +4953,18 @@ export class ResponseEngine {
4371
4953
  // (message-queue / slash-handler)已发过 task:interrupted(它填充了
4372
4954
  // interruptedSessions,isUserInterrupt 才会为真),stats 侧已据此收尾任务。
4373
4955
  // 此处不再发任何事件——发 task:error 会误归类,重发 task:interrupted 会重复记账。
4374
- if (!isUserInterrupt) {
4956
+ if (!isUserInterrupt && ownsTimeoutTerminal) {
4375
4957
  this.eventBus.publish({
4376
4958
  type: 'task:error',
4377
4959
  sessionId: session.id,
4960
+ agentAid: runnerSelfAid,
4961
+ peerId: configActorId,
4962
+ encrypted: message.encrypted ?? message.replyContext?.metadata?.encrypted,
4963
+ originTriggerId: message.triggerMeta?.triggerId,
4378
4964
  taskId,
4379
4965
  generation: turnLease?.generation,
4380
4966
  error: errorMsg,
4967
+ terminalReason: isExecutionFailure(error) ? error.reason : undefined,
4381
4968
  errorType,
4382
4969
  agentName: agentNameForStats,
4383
4970
  causation: taskCausation,
@@ -4385,14 +4972,19 @@ export class ResponseEngine {
4385
4972
  recordCausationSpan(taskCausation, 'task.run', { status: 'failed', refs: { taskId, sessionId: session.id, messageId }, reason: errorType });
4386
4973
  }
4387
4974
  // 记录处理失败
4388
- logger.message({
4389
- msgId: messageId,
4390
- sessionId: session.id,
4391
- dir: 'inbound',
4392
- status: isUserInterrupt ? 'interrupted' : 'failed',
4393
- error: error instanceof Error ? error.message : String(error)
4394
- });
4395
- if (error instanceof Error && !isUserInterrupt) {
4975
+ if (ownsTimeoutTerminal) {
4976
+ logger.message({
4977
+ msgId: messageId,
4978
+ sessionId: session.id,
4979
+ dir: 'inbound',
4980
+ status: isUserInterrupt ? 'interrupted' : 'failed',
4981
+ error: error instanceof Error ? error.message : String(error)
4982
+ });
4983
+ }
4984
+ else {
4985
+ logger.info(`[ResponseEngine] Duplicate timeout terminal suppressed: session=${session.id} task=${taskId}`);
4986
+ }
4987
+ if (error instanceof Error && !isUserInterrupt && ownsTimeoutTerminal) {
4396
4988
  logger.error(`[${message.channel}] Error stack:`, error.stack);
4397
4989
  }
4398
4990
  // 发送用户友好的错误消息
@@ -4402,7 +4994,11 @@ export class ResponseEngine {
4402
4994
  const retryExhaustedCount = getRetryExhaustedCount(error);
4403
4995
  const modelFallbackExhaustedMessage = getModelFallbackExhaustedMessage(error);
4404
4996
  const retryInputAlreadySubmitted = wasRetryInputAlreadySubmitted(error);
4405
- if (isUserInterrupt) {
4997
+ if (!ownsTimeoutTerminal) {
4998
+ // The monitor path already owns delivery for this timeout. Cleanup and
4999
+ // durable turn finalization above still run, but no terminal is emitted twice.
5000
+ }
5001
+ else if (isUserInterrupt) {
4406
5002
  logger.info(`[ResponseEngine] User interrupt by new_message, skip sending error message`);
4407
5003
  }
4408
5004
  else if (error?._errorAlreadySent && !daemonTrigger && !retryExhaustedCount && !isTimeout && !isTotalExecutionTimeout) {
@@ -4411,19 +5007,21 @@ export class ResponseEngine {
4411
5007
  else {
4412
5008
  // SDK_TIMEOUT:status.timeout 已发结构化状态,此处再补一条用户可见的错误文本(result.error)
4413
5009
  const idleSec = getLastIdleSec?.() || 0;
4414
- const userMessage = retryExhaustedCount
4415
- ? formatRetryableErrorFinalMessage(error, retryExhaustedCount)
4416
- : retryInputAlreadySubmitted
4417
- ? formatRetryInputAlreadySubmittedMessage(error)
4418
- : modelFallbackExhaustedMessage
4419
- ? modelFallbackExhaustedMessage
4420
- : isTotalExecutionTimeout
4421
- ? (totalExecutionMs === undefined
4422
- ? '⚠️ 任务超过总执行时限,已自动中断'
4423
- : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
4424
- : isTimeout
4425
- ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
4426
- : getErrorMessage(error, undefined);
5010
+ const userMessage = error instanceof OrphanedTargetSessionError
5011
+ ? '⚠️ 任务执行状态丢失,已结束本次执行。'
5012
+ : retryExhaustedCount
5013
+ ? formatRetryableErrorFinalMessage(error, retryExhaustedCount)
5014
+ : retryInputAlreadySubmitted
5015
+ ? formatRetryInputAlreadySubmittedMessage(error)
5016
+ : modelFallbackExhaustedMessage
5017
+ ? modelFallbackExhaustedMessage
5018
+ : isTotalExecutionTimeout
5019
+ ? (totalExecutionMs === undefined
5020
+ ? '⚠️ 任务超过总执行时限,已自动中断'
5021
+ : `⚠️ 任务超过总执行时限(${Math.round(totalExecutionMs / 1000)}秒),已自动中断`)
5022
+ : isTimeout
5023
+ ? (idleSec > 0 ? `⚠️ 任务超时(${idleSec}秒无响应),已自动中断` : '⚠️ 任务超时,已自动中断')
5024
+ : getErrorMessage(error, undefined);
4427
5025
  // 获取 session 用于话题回复(如果 resolveSession 已执行)
4428
5026
  let sendOpts;
4429
5027
  try {
@@ -4440,7 +5038,7 @@ export class ResponseEngine {
4440
5038
  const errorPayload = {
4441
5039
  kind: 'result.error',
4442
5040
  text: userMessage,
4443
- reason: retryExhaustedCount ? 'retry_exhausted' : retryInputAlreadySubmitted ? 'input_already_submitted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
5041
+ reason: isExecutionFailure(error) ? error.reason : retryExhaustedCount ? 'retry_exhausted' : retryInputAlreadySubmitted ? 'input_already_submitted' : modelFallbackExhaustedMessage ? 'model_unavailable' : isTotalExecutionTimeout ? 'total_execution_timeout' : isTimeout ? 'timeout' : errType,
4444
5042
  };
4445
5043
  if (!isUserInterrupt && daemonTrigger && procStatus === 'error') {
4446
5044
  // DaemonChannel settles a trigger on the first terminal payload. Put
@@ -4483,9 +5081,6 @@ export class ResponseEngine {
4483
5081
  }).catch(() => { });
4484
5082
  }
4485
5083
  }
4486
- if (handlingTimeout && timeoutControl) {
4487
- timeoutControl.terminalDelivered = true;
4488
- }
4489
5084
  }
4490
5085
  finally {
4491
5086
  if (fullAccessAuthorization) {
@@ -4496,7 +5091,8 @@ export class ResponseEngine {
4496
5091
  auditFullAccessEvent({
4497
5092
  event: fullAccessAuthorization.source === 'trigger'
4498
5093
  ? 'fullaccess.trigger.execution.ended'
4499
- : 'fullaccess.execution.ended',
5094
+ : fullAccessAuthorization.source === 'agent-policy'
5095
+ ? 'fullaccess.agent-policy.execution.ended' : 'fullaccess.execution.ended',
4500
5096
  source: fullAccessAuthorization.source,
4501
5097
  actorId: fullAccessAuthorization.authorizedBy,
4502
5098
  processRole: fullAccessAuditProcessRole,
@@ -4668,7 +5264,7 @@ export class ResponseEngine {
4668
5264
  // valid mapped owner/admin to role=none at the execution boundary.
4669
5265
  if (resolvedIdentity)
4670
5266
  bound.identity = resolvedIdentity;
4671
- if (bound.threadId) {
5267
+ if (bound.threadId || bound.metadata?.diagnostic) {
4672
5268
  const absoluteProjectPath = path.isAbsolute(bound.projectPath)
4673
5269
  ? bound.projectPath : path.resolve(process.cwd(), bound.projectPath);
4674
5270
  return { session: bound, absoluteProjectPath };
@@ -4694,7 +5290,7 @@ export class ResponseEngine {
4694
5290
  logger.warn(`[ResponseEngine] Bound session ${boundSessionId} not found, falling back to latest`);
4695
5291
  }
4696
5292
  }
4697
- const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, resolvedBaseagent, message.selfAID, message.channelType, message.peerType, resolvedIdentity);
5293
+ const session = await this.sessionManager.getOrCreateSession(message.channel, message.channelId, projectPath, message.threadId, metadata, message.topicName, message.peerId, message.chatType, resolvedBaseagent, message.selfAID, message.channelType, message.peerType, resolvedIdentity, message.lable);
4698
5294
  this.ensureSessionBaseagent(session, resolvedBaseagent);
4699
5295
  // 群名解析:群会话首次取群显示名(group.get),缓存到 metadata,供信封渲染。
4700
5296
  // 渠道私有方法 getGroupName 自带进程缓存 + 容错;取不到不阻塞(groupName 保持空,模板回退 groupId)。
@@ -4752,7 +5348,7 @@ export class ResponseEngine {
4752
5348
  */
4753
5349
  async processEventStream(stream, session, agent, renderer, resetTimer, shouldSuppress, proactive,
4754
5350
  /** [迁移点4/5] 响应模式插件 + 状态,用于调 onToolUse/onComplete 钩子 */
4755
- modeHooks, causation, turnLease, permissionMode, proactiveSelfAid) {
5351
+ modeHooks, causation, turnLease, permissionMode, proactiveSelfAid, onAsyncQuestion) {
4756
5352
  // Per-session agent name for stats bucketing
4757
5353
  const statsChannelKey = session.channel === 'daemon' ? session.channel : (session.metadata?.channelKey || session.channel);
4758
5354
  const agentNameForStats = this.agentRegistry?.resolveByChannel(statsChannelKey)?.name ?? '<unknown>';
@@ -4839,6 +5435,15 @@ export class ResponseEngine {
4839
5435
  // It must never be projected as progress, activity, text, or transcript.
4840
5436
  if (event.type === 'heartbeat')
4841
5437
  continue;
5438
+ if (event.type === 'user_question') {
5439
+ if (!onAsyncQuestion)
5440
+ throw new Error('Async question has no delivery context');
5441
+ await onAsyncQuestion(event.request);
5442
+ madeProgress = true;
5443
+ // A structured question is user-visible progress without being a text reply.
5444
+ textCount++;
5445
+ continue;
5446
+ }
4842
5447
  if (event.type === 'input_accepted') {
4843
5448
  inputAccepted = !turnLease || event.inputId === turnLease.inputId;
4844
5449
  logger.debug(`[ResponseEngine] Input accepted: session=${session.id} input=${event.inputId}`);
@@ -4934,7 +5539,7 @@ export class ResponseEngine {
4934
5539
  || (typeof input?.prompt === 'string' ? input.prompt.slice(0, 80) : '')
4935
5540
  || (typeof input?.query === 'string' ? input.query.slice(0, 80) : '')
4936
5541
  || '';
4937
- eventDetail = ` tool=${event.name}${desc ? ` desc="${desc}"` : ''}`;
5542
+ eventDetail = ` tool=${event.name}${desc ? ` desc="${String(desc).replace(/\s+/g, ' ')}"` : ''}`;
4938
5543
  }
4939
5544
  else if (event.type === 'tool_result') {
4940
5545
  const decision = event.decision ?? (event.isError ? 'error' : 'allow');
@@ -4954,6 +5559,10 @@ export class ResponseEngine {
4954
5559
  }
4955
5560
  // IMRenderer 旁路:proactive 模式逐事件投影为 thought(fire-and-forget)
4956
5561
  renderer.emit(event);
5562
+ if (event.type === 'text_start') {
5563
+ await renderer.sendTextStart(event.contentIndex, event.turn);
5564
+ continue;
5565
+ }
4957
5566
  // session_id 已在 AgentRunner.transformStream 中处理,此处仅记录
4958
5567
  if (event.type === 'session_id') {
4959
5568
  logger.debug(`[ResponseEngine] Session ID updated: ${event.sessionId} for session: ${session.id}`);
@@ -5115,7 +5724,7 @@ export class ResponseEngine {
5115
5724
  this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: visibleText, isFinal: false });
5116
5725
  }
5117
5726
  if (visibleText && !shouldSuppress()) {
5118
- renderer.addText(visibleText, event.outputTokens, event.turn);
5727
+ await renderer.addText(visibleText, event.outputTokens, event.turn, event.assistantUuid, event.phase);
5119
5728
  hasProjectedCurrentReplyText = true;
5120
5729
  }
5121
5730
  }
@@ -5175,7 +5784,7 @@ export class ResponseEngine {
5175
5784
  timestamp: Date.now(),
5176
5785
  causation,
5177
5786
  });
5178
- const desc = summarizeToolInput(event.name, event.input || {});
5787
+ const desc = summarizeToolInputForDisplay(event.name, event.input || {});
5179
5788
  const pendingInput = { name: event.name, input: event.input || {} };
5180
5789
  if (event.callId)
5181
5790
  toolInputByCallId.set(event.callId, pendingInput);
@@ -5351,7 +5960,7 @@ export class ResponseEngine {
5351
5960
  let errorMsg = event.error || (typeof event.result === 'string' ? event.result : JSON.stringify(event.result)) || '\u6267\u884c\u5931\u8d25';
5352
5961
  // 移除 XML 风格的错误标签
5353
5962
  errorMsg = errorMsg.replace(/<tool_use_error>(.*?)<\/tool_use_error>/gs, '$1');
5354
- renderer.addToolResult(event.name || '\u5de5\u5177', false, undefined, errorMsg, event.callId, event.durationMs, cachedDesc);
5963
+ renderer.addToolResult(event.name || '\u5de5\u5177', false, undefined, errorMsg, event.callId, event.durationMs, cachedDesc, event);
5355
5964
  }
5356
5965
  else if (!event.isError && !shouldSuppress()) {
5357
5966
  renderer.addToolResult(event.name || '\u5de5\u5177', true, event.result, undefined, event.callId, event.durationMs, cachedDesc);
@@ -5452,7 +6061,7 @@ export class ResponseEngine {
5452
6061
  // User interrupt (AbortError) is expected, log at info level
5453
6062
  const catchInterruptReason = this.interruptedSessions.get(session.id);
5454
6063
  const catchIsUserInterrupt = isNonErrorInterruptReason(catchInterruptReason);
5455
- if (catchInterruptReason === 'timeout') {
6064
+ if (catchInterruptReason === 'timeout' || catchInterruptReason === 'execution_error') {
5456
6065
  logger.info('[ResponseEngine] Stream closed by timeout barrier');
5457
6066
  throw this.timeoutErrors.get(session.id) ?? new Error('SDK_TIMEOUT');
5458
6067
  }
@@ -5490,7 +6099,9 @@ export class ResponseEngine {
5490
6099
  if (hasErrorSuppressingContent) {
5491
6100
  let errorOutputFlushed = false;
5492
6101
  try {
5493
- await renderer.flush(true);
6102
+ // The outer retry/terminal handler has not decided the outcome yet.
6103
+ // Pending stream output must not announce successful completion.
6104
+ await renderer.flush(false);
5494
6105
  errorOutputFlushed = true;
5495
6106
  }
5496
6107
  catch (flushError) {