evolcore 0.0.22 → 0.0.23

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 (75) hide show
  1. package/AGENTS.md +62 -0
  2. package/CHANGELOG.md +24 -14
  3. package/README.md +2 -0
  4. package/bin/codex-managed-acl.ps1 +155 -0
  5. package/bin/install-codex-managed-hooks.mjs +373 -95
  6. package/dist/agents/baseagent-availability.js +48 -0
  7. package/dist/agents/baseagent.js +1 -1
  8. package/dist/agents/claude-runner.js +30 -226
  9. package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +73 -13
  10. package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
  11. package/dist/agents/codex/catalog.js +70 -0
  12. package/dist/agents/codex/diagnostics.js +154 -0
  13. package/dist/agents/codex-runner.js +407 -139
  14. package/dist/agents/ecagent-runner.js +10 -31
  15. package/dist/agents/gemini-runner.js +2 -6
  16. package/dist/aun/aid/store.js +2 -0
  17. package/dist/aun/menu-client.js +272 -0
  18. package/dist/aun/msg/group.js +65 -4
  19. package/dist/aun/msg/index.js +1 -1
  20. package/dist/aun/msg/managed-operation.js +51 -4
  21. package/dist/aun/rpc/client.js +8 -1
  22. package/dist/channels/aun.js +31 -1
  23. package/dist/channels/wechat.js +81 -10
  24. package/dist/cli/aun-commands.js +48 -4
  25. package/dist/cli/command-log.js +21 -2
  26. package/dist/cli/daemon-commands.js +142 -45
  27. package/dist/cli/index.js +20 -0
  28. package/dist/cli/init.js +2 -10
  29. package/dist/cli/menu-client.js +95 -0
  30. package/dist/cli/model.js +23 -8
  31. package/dist/cli/task-context.js +12 -2
  32. package/dist/cli/trigger-command.js +9 -1
  33. package/dist/config/schema-migration.js +14 -2
  34. package/dist/core/auth/agent-delegation.js +10 -7
  35. package/dist/core/auth/authorization-audit.js +4 -0
  36. package/dist/core/auth/operation-catalog.js +4 -0
  37. package/dist/core/auth/remote-access-store.js +135 -0
  38. package/dist/core/command/command-handler.js +41 -5
  39. package/dist/core/command/daemon-diagnostics.js +448 -0
  40. package/dist/core/command/menu-catalog.js +3 -0
  41. package/dist/core/command/menu-handler.js +141 -33
  42. package/dist/core/command/menu-protocol.js +119 -0
  43. package/dist/core/command/slash-gate.js +3 -3
  44. package/dist/core/command/slash-handler.js +42 -22
  45. package/dist/core/message/message-queue.js +20 -15
  46. package/dist/core/message/response-engine.js +74 -73
  47. package/dist/core/model/catalog-service.js +308 -0
  48. package/dist/core/model/model-catalog.js +124 -334
  49. package/dist/core/model/model-diagnostics.js +4 -5
  50. package/dist/core/model/model-fallback.js +9 -16
  51. package/dist/core/model/model-permission.js +1 -1
  52. package/dist/core/permission/approval-gateway.js +16 -1
  53. package/dist/core/permission/protected-paths.js +63 -4
  54. package/dist/core/permission/sandbox-runtime.js +4 -2
  55. package/dist/core/permission/tool-policy.js +244 -25
  56. package/dist/core/runtime-lock.js +13 -8
  57. package/dist/index.js +40 -29
  58. package/dist/ipc.js +123 -81
  59. package/dist/trigger/manager.js +3 -1
  60. package/dist/trigger/parser.js +77 -13
  61. package/dist/trigger/patch.js +104 -1
  62. package/dist/utils/ecweb-supervisor.js +11 -2
  63. package/dist/utils/ecweb-utils.js +8 -26
  64. package/dist/utils/error-utils.js +7 -0
  65. package/dist/utils/npm-ops.js +47 -15
  66. package/kits/docs/evolcore/INDEX.md +1 -0
  67. package/kits/docs/evolcore/agent-create.md +13 -0
  68. package/kits/docs/evolcore/group-collaboration.md +7 -2
  69. package/kits/docs/evolcore/menu-client.md +34 -0
  70. package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
  71. package/kits/migrations/migrate-role-config-v4.mjs +11 -5
  72. package/kits/migrations/migrate-role-config-v5.mjs +8 -2
  73. package/kits/templates/roles/visitor.json +1 -0
  74. package/kits/templates/system-fragments/bootstrap.md +8 -1
  75. package/package.json +11 -3
@@ -1,5 +1,4 @@
1
1
  import { hasModelSwitcher, hasPermissionController } from '../../agents/runner-types.js';
2
- import { getCodexEfforts } from '../../agents/codex-runner.js';
3
2
  import { normalizeBaseagent } from '../../agents/baseagent.js';
4
3
  import { buildEnvelope, isDeliveryTargetForChannel } from '../message/message-utils.js';
5
4
  import { resolvePaths, getPackageRoot, daemonControlDir } from '../../paths.js';
@@ -10,13 +9,14 @@ import path from 'path';
10
9
  import fs from 'fs';
11
10
  import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions } from '../../utils/npm-ops.js';
12
11
  import { loadDaemonConfig } from '../../config-store.js';
12
+ import { remoteAccessService } from '../auth/remote-access-store.js';
13
13
  import { read as cfgRead, resolveEffective, routeFieldPath, write as cfgWrite, } from '../../config/config-manager.js';
14
14
  import { execAgentAction } from './agent-control.js';
15
15
  import { authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
16
16
  import { authorizationNextStep, formatAuthorizationDenial } from '../auth/authorization-denial.js';
17
17
  import { resolvePermissionMode, writeScope } from '../model/config-scope.js';
18
18
  import { formatPeerKey } from '../relation/peer-identity.js';
19
- import { modelMatches } from '../model/model-catalog.js';
19
+ import { modelMatches, getCatalog, assertCatalogSelectable, resolveCatalogModel } from '../model/model-catalog.js';
20
20
  import { formatModelCheck, runModelCheck } from '../model/model-diagnostics.js';
21
21
  import { constrainResolvedModelForRole, filterModelsForRole, validateModelSelectionForRole } from '../model/model-permission.js';
22
22
  import { resolveRuntimeStringField } from '../role/runtime-policy.js';
@@ -28,7 +28,7 @@ import { isSystemControlChannel } from '../system-channels.js';
28
28
  import { spawnDetachedNode } from '../../utils/cross-platform.js';
29
29
  import { inspectDataMigrationRequirement } from '../data-migration.js';
30
30
  import { guardIdleCommand, guardKnownCommand, guardRoleCommand, guardThreadCommand, isRecognizedSlashCommand, normalizeSlashContent, probeSessionActivity, } from './slash-gate.js';
31
- const allEfforts = ['low', 'medium', 'high', 'xhigh', 'max'];
31
+ const allEfforts = ['none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'];
32
32
  const PERMISSION_MODE_KEYS = PUBLIC_PERMISSION_MODES;
33
33
  function defaultSlashChatmode(field) {
34
34
  return field === 'private' ? 'interactive' : 'proactive';
@@ -127,12 +127,8 @@ function resolveSlashRelationTarget(params) {
127
127
  role: params.role,
128
128
  };
129
129
  }
130
- function getAvailableEfforts(agent, model) {
131
- if (agent.name === 'claude')
132
- return allEfforts;
133
- if (agent.name === 'codex')
134
- return getCodexEfforts(model);
135
- return [];
130
+ async function getAvailableEfforts(agent, model) {
131
+ return (await agent.getAvailableEfforts?.(model) ?? []);
136
132
  }
137
133
  function modelDisplayLabel(agent, model) {
138
134
  const full = agent.resolveModelId?.(model);
@@ -487,7 +483,8 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
487
483
  const isProcessLevelSlash = normalizedContent === '/restart'
488
484
  || normalizedContent === '/upgrade'
489
485
  || normalizedContent === '/reload'
490
- || normalizedContent.startsWith('/reload ');
486
+ || normalizedContent.startsWith('/reload ')
487
+ || normalizedContent === '/pair';
491
488
  const roleGuard = guardRoleCommand(normalizedContent, activeChatType, isAdmin || (isDaemonOwner && isProcessLevelSlash));
492
489
  if (roleGuard)
493
490
  return roleGuard;
@@ -635,7 +632,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
635
632
  const appendProcessCommands = (lines) => {
636
633
  if (!isDaemonOwner)
637
634
  return;
638
- lines.push('', '🛠️ 进程级运维:', ' /restart - 重启服务', ' /upgrade - 检查版本更新', ' /reload [aid] - 热重载 Agent 配置', ' /fa <提示词> - 单次使用宿主级 fullaccess 执行(别名: /fullaccess)');
635
+ lines.push('', '🛠️ 进程级运维:', ' /restart - 重启服务', ' /upgrade - 检查版本更新', ' /reload [aid] - 热重载 Agent 配置', ' /fa <提示词> - 单次使用宿主级 fullaccess 执行(别名: /fullaccess)', ' /pair - 获取远程控制配对码');
639
636
  };
640
637
  if (!isAdmin && activeChatType === 'group') {
641
638
  const lines = [
@@ -775,6 +772,23 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
775
772
  ];
776
773
  return { kind: 'command.result', text: lines.join('\n') };
777
774
  }
775
+ // /pair is also recognized on an Agent channel so it cannot fall through
776
+ // into model input. The daemon control channel has its own fast path; this
777
+ // branch preserves the same daemon-owner boundary for ordinary AUN chats.
778
+ if (normalizedContent === '/pair') {
779
+ if (!isDaemonOwner) {
780
+ return {
781
+ kind: 'command.error',
782
+ text: '❌ /pair 仅限 daemon-owner 使用;请向 daemon 控制 AID 发送 /pair。',
783
+ };
784
+ }
785
+ const pair = remoteAccessService.getPairingCode(userId);
786
+ const mins = Math.max(0, Math.round((pair.expiresAt - Date.now()) / 60000));
787
+ return {
788
+ kind: 'command.result',
789
+ text: `远程控制配对码:${pair.code}(约 ${mins} 分钟内有效)\n可用于 ECWeb 登录或远程 Menu 客户端授权`,
790
+ };
791
+ }
778
792
  // /evolhelp 命令:返回 JSON 格式的命令列表(供程序解析)
779
793
  if (normalizedContent === '/evolhelp') {
780
794
  const cmds = [];
@@ -835,6 +849,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
835
849
  cmds.push({ command: '/restart', description: '重启服务', category: '运维', roles: ['daemon-owner'] });
836
850
  cmds.push({ command: '/upgrade', description: '检查版本更新', category: '运维', roles: ['daemon-owner'] });
837
851
  cmds.push({ command: '/reload', args: '[aid]', description: '热重载 Agent 配置', category: '运维', roles: ['daemon-owner'] });
852
+ cmds.push({ command: '/pair', description: '获取远程控制配对码', category: '运维', roles: ['daemon-owner'] });
838
853
  }
839
854
  if (!isDaemonOwner && isAdmin) {
840
855
  cmds.push({ command: '/reload', description: '热重载当前 Agent 配置', category: '运维', roles: ['admin', 'owner'] });
@@ -1260,12 +1275,14 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1260
1275
  catch { }
1261
1276
  const configuredModel = modelConfig.model;
1262
1277
  const configuredEffort = modelConfig.effort;
1263
- const rawModels = hasModelSwitcher(modelAgent) ? await modelAgent.listModels() : [];
1278
+ const catalog = await getCatalog(modelSelector.self, fallbackBaseagent);
1279
+ assertCatalogSelectable(catalog);
1280
+ const rawModels = catalog.models.map(m => m.id);
1264
1281
  const models = hasModelSwitcher(modelAgent)
1265
- ? filterModelsForRole(identity.role, fallbackBaseagent, rawModels, modelAgent.resolveModelId?.bind(modelAgent))
1282
+ ? filterModelsForRole(identity.role, fallbackBaseagent, rawModels, model => resolveCatalogModel(catalog, model), modelSelector.self)
1266
1283
  : rawModels;
1267
1284
  const currentModel = hasModelSwitcher(modelAgent) ? (configuredModel || modelAgent.getModel()) : modelAgent.name;
1268
- const efforts = getAvailableEfforts(modelAgent, currentModel);
1285
+ const efforts = await getAvailableEfforts(modelAgent, currentModel);
1269
1286
  const currentEffort = configuredEffort ?? modelAgent.getEffort?.() ?? 'auto';
1270
1287
  const canReadModelList = await canReadSlashModelList({
1271
1288
  subject: authSubject,
@@ -1290,7 +1307,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1290
1307
  kind: 'command-card',
1291
1308
  title: '🤖 切换模型',
1292
1309
  buttons: models.map((m) => {
1293
- const display = modelDisplayLabel(modelAgent, m);
1310
+ const display = modelDisplayLabel(modelAgent, m) + (catalog.models.find(e => e.id === m)?.verification === 'unverified' ? ' [unverified]' : '');
1294
1311
  return {
1295
1312
  label: modelMatches(modelAgent, m, currentModel) ? `✓ ${display}` : display,
1296
1313
  command: `/model ${m}`,
@@ -1307,6 +1324,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1307
1324
  return { kind: 'command.result', text: cardResult };
1308
1325
  }
1309
1326
  // 降级:文本
1327
+ const catalogHint = `[${catalog.source}/${catalog.freshness}] ${catalog.diagnostics.map(d => d.message).join('; ')}`;
1310
1328
  const modelList = models.map((m) => ` ${modelMatches(modelAgent, m, currentModel) ? '✓' : ' '} ${modelDisplayLabel(modelAgent, m)}`).join('\n');
1311
1329
  const effortHint = efforts.length > 0
1312
1330
  ? `\n推理强度: ${currentEffort === 'auto' ? 'auto (SDK默认)' : currentEffort} (使用 /effort 调整)`
@@ -1322,8 +1340,8 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1322
1340
  const usage = !args && isAdmin ? '\n\n用法: /model <模型>' : '';
1323
1341
  return {
1324
1342
  kind: 'command.result',
1325
- text: `当前模型: ${modelDisplayLabel(modelAgent, currentModel)}${effortHint}\n\n可用模型:\n${modelList}${usage}`,
1326
- ...(args === 'list' ? { structured: { ok: true, model: currentModel, effort: currentEffort, baseagent: fallbackBaseagent, models } } : {}),
1343
+ text: `当前模型: ${modelDisplayLabel(modelAgent, currentModel)}${effortHint}${rawModels.includes(currentModel) ? '' : ' [当前配置,未列入目录]'}\n${catalogHint}\n\n可用模型:\n${modelList}${usage}`,
1344
+ ...(args === 'list' ? { structured: { ok: true, model: currentModel, effort: currentEffort, baseagent: fallbackBaseagent, models, revision: catalog.revision, source: catalog.source, freshness: catalog.freshness, diagnostics: catalog.diagnostics } } : {}),
1327
1345
  };
1328
1346
  }
1329
1347
  return { kind: 'command.result', text: `当前模型: ${modelDisplayLabel(modelAgent, currentModel)}${effortHint}` };
@@ -1384,7 +1402,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1384
1402
  catch { }
1385
1403
  const models = hasModelSwitcher(modelAgent) ? await modelAgent.listModels() : [];
1386
1404
  const allowedModels = hasModelSwitcher(modelAgent)
1387
- ? filterModelsForRole(identity.role, fallbackBaseagent, models, modelAgent.resolveModelId?.bind(modelAgent))
1405
+ ? filterModelsForRole(identity.role, fallbackBaseagent, models, modelAgent.resolveModelId?.bind(modelAgent), selfAID ?? this.resolveSelfAID(channel))
1388
1406
  : models;
1389
1407
  const parts = args.split(/\s+/);
1390
1408
  let newModel;
@@ -1392,7 +1410,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1392
1410
  if (parts.length === 1) {
1393
1411
  const arg = parts[0];
1394
1412
  const currentModel = hasModelSwitcher(modelAgent) ? (configuredModel || modelAgent.getModel()) : modelAgent.name;
1395
- const efforts = getAvailableEfforts(modelAgent, currentModel);
1413
+ const efforts = await getAvailableEfforts(modelAgent, currentModel);
1396
1414
  // effort 相关参数统一转发到 /effort
1397
1415
  if (efforts.includes(arg) || arg === 'auto') {
1398
1416
  const delegated = await this.handle(`/effort ${arg}`, channel, channelId, undefined, userId, threadId);
@@ -1425,7 +1443,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1425
1443
  if (!models.includes(modelArg)) {
1426
1444
  return { kind: 'command.error', text: `❌ 无效的模型ID: ${modelArgRaw}` };
1427
1445
  }
1428
- const targetEfforts = getAvailableEfforts(modelAgent, modelArg);
1446
+ const targetEfforts = await getAvailableEfforts(modelAgent, modelArg);
1429
1447
  if (targetEfforts.length === 0) {
1430
1448
  return { kind: 'command.error', text: `⚠️ ${modelArg} 不支持推理强度设置` };
1431
1449
  }
@@ -1583,7 +1601,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1583
1601
  const configuredModel = modelConfig.model;
1584
1602
  const configuredEffort = modelConfig.effort;
1585
1603
  const currentModel = hasModelSwitcher(effortAgent) ? (configuredModel || effortAgent.getModel()) : effortAgent.name;
1586
- const efforts = getAvailableEfforts(effortAgent, currentModel);
1604
+ const efforts = await getAvailableEfforts(effortAgent, currentModel);
1587
1605
  const currentEffort = configuredEffort ?? effortAgent.getEffort?.() ?? 'auto';
1588
1606
  if (efforts.length === 0) {
1589
1607
  return { kind: 'command.error', text: '⚠️ 当前模型不支持推理强度设置' };
@@ -1645,7 +1663,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
1645
1663
  const configuredModel = modelConfig.model;
1646
1664
  const configuredEffort = modelConfig.effort;
1647
1665
  const currentModel = hasModelSwitcher(effortAgent) ? (configuredModel || effortAgent.getModel()) : effortAgent.name;
1648
- const efforts = getAvailableEfforts(effortAgent, currentModel);
1666
+ const efforts = await getAvailableEfforts(effortAgent, currentModel);
1649
1667
  const currentEffort = configuredEffort ?? effortAgent.getEffort?.() ?? 'auto';
1650
1668
  if (efforts.length === 0) {
1651
1669
  return { kind: 'command.error', text: '⚠️ 当前模型不支持推理强度设置' };
@@ -2502,6 +2520,8 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
2502
2520
  if (session && previousAgent) {
2503
2521
  // Reset agent backend state so the new
2504
2522
  // session starts with a fresh conversation history
2523
+ // Clearing is a pure backend lifecycle operation; model selection is
2524
+ // resolved only when the new session performs its next turn.
2505
2525
  await previousAgent.clearSession(session.id, session.agentSessionId || '', session.projectPath);
2506
2526
  await previousAgent.closeSession(session.id);
2507
2527
  }
@@ -234,6 +234,7 @@ export class MessageQueue {
234
234
  }
235
235
  const tmp = `${this.dedupPath}.tmp-${process.pid}-${Date.now()}-${this.enqueueSequence}`;
236
236
  let fd;
237
+ let renamed = false;
237
238
  try {
238
239
  fs.mkdirSync(path.dirname(this.dedupPath), { recursive: true });
239
240
  const content = Buffer.from(JSON.stringify({
@@ -253,20 +254,22 @@ export class MessageQueue {
253
254
  fs.closeSync(fd);
254
255
  fd = undefined;
255
256
  fs.renameSync(tmp, this.dedupPath);
256
- const persistedFd = fs.openSync(this.dedupPath, 'r');
257
- try {
258
- fs.fsyncSync(persistedFd);
259
- }
260
- finally {
261
- fs.closeSync(persistedFd);
262
- }
257
+ renamed = true;
263
258
  if (process.platform !== 'win32') {
264
- const directoryFd = fs.openSync(path.dirname(this.dedupPath), 'r');
265
259
  try {
266
- fs.fsyncSync(directoryFd);
260
+ const directoryFd = fs.openSync(path.dirname(this.dedupPath), 'r');
261
+ try {
262
+ fs.fsyncSync(directoryFd);
263
+ }
264
+ finally {
265
+ fs.closeSync(directoryFd);
266
+ }
267
267
  }
268
- finally {
269
- fs.closeSync(directoryFd);
268
+ catch (error) {
269
+ // The file contents were already fsynced before rename. A platform
270
+ // that cannot fsync directory metadata must not make a committed
271
+ // dedup claim look uncommitted to the caller.
272
+ logger.warn(`[Queue] Durable dedup ledger directory fsync unavailable after commit: ${error instanceof Error ? error.message : String(error)}`);
270
273
  }
271
274
  }
272
275
  return true;
@@ -279,11 +282,13 @@ export class MessageQueue {
279
282
  }
280
283
  catch { }
281
284
  }
282
- try {
283
- fs.unlinkSync(tmp);
285
+ if (!renamed) {
286
+ try {
287
+ fs.unlinkSync(tmp);
288
+ }
289
+ catch { }
284
290
  }
285
- catch { }
286
- return false;
291
+ return renamed;
287
292
  }
288
293
  }
289
294
  hasEnqueuedMessage(sessionKey, messageId, selfAID) {
@@ -325,44 +325,71 @@ function withFallbackModel(override, candidate) {
325
325
  ...(candidate.effort ? { effort: candidate.effort } : { effortMode: 'model_default' }),
326
326
  };
327
327
  }
328
+ /**
329
+ * Keep the per-task fullaccess fact in the user-message prompt rather than in
330
+ * the long-lived system/developer prompt. This avoids changing a runner's
331
+ * system-prompt cache while still giving the model an explicit, auditable
332
+ * execution-mode signal for the privileged task.
333
+ */
334
+ const FULL_ACCESS_MESSAGE_PROMPT_CONTEXT = '【当前权限模式提升为fullaccess,可不受限制执行命令,仅当前轮 task 有效】';
335
+ /**
336
+ * Put the one-task permission notice between the rendered message envelope and
337
+ * the user's literal input. The normal message fragment ends its envelope in
338
+ * `›`; matching the dated normal-message form avoids placing the notice inside
339
+ * an owner-hint envelope when one is present before the real inbound message.
340
+ */
341
+ function insertMessagePromptContext(prompt, context, userContent) {
342
+ if (!context)
343
+ return prompt;
344
+ const headers = [...prompt.matchAll(/‹\d{4}-\d{2}-\d{2} [^\n]*· from:[^\n]*›/gu)];
345
+ const renderedHeader = headers[0];
346
+ const headerEnd = renderedHeader
347
+ ? (renderedHeader.index ?? 0) + renderedHeader[0].length
348
+ : -1;
349
+ if (headerEnd >= 0) {
350
+ const remainder = prompt.slice(headerEnd).replace(/^\n+/u, '');
351
+ return `${prompt.slice(0, headerEnd)}\n${context}${remainder ? `\n${remainder}` : ''}`;
352
+ }
353
+ if (userContent?.trim()) {
354
+ const contentStart = prompt.indexOf(userContent);
355
+ if (contentStart >= 0) {
356
+ return `${prompt.slice(0, contentStart)}${context}\n${prompt.slice(contentStart)}`;
357
+ }
358
+ }
359
+ // Internal recovery prompts have no message envelope; keep their context at
360
+ // the front so the runner still sees it for the same task.
361
+ return `${context}\n\n${prompt}`;
362
+ }
328
363
  function getStreamErrorText(result) {
329
- return [
364
+ const parts = [
330
365
  result.errors?.join('\n'),
331
- result.lastReplyText,
332
- result.fullText,
333
366
  result.subtype,
334
367
  result.terminalReason,
335
368
  ]
336
- .filter((part) => typeof part === 'string' && part.trim().length > 0)
337
- .join('\n');
369
+ .filter((part) => typeof part === 'string' && part.trim().length > 0);
370
+ // Some older runners put the terminal diagnostic in `result`, but never
371
+ // treat streamed assistant text as an error merely because the turn failed.
372
+ const fullText = result.fullText.trim();
373
+ const fullTextLooksLikeDiagnostic = fullText.length > 0 && (isRetryableError(new Error(fullText))
374
+ || isContextTooLongText(fullText));
375
+ if (result.isError && !result.hasReceivedText && fullText
376
+ && (parts.length === 0 || fullTextLooksLikeDiagnostic)) {
377
+ parts.unshift(fullText);
378
+ }
379
+ return parts.join('\n');
338
380
  }
339
381
  function getStreamErrorMessage(result, includeEmoji = false) {
340
382
  const raw = getStreamErrorText(result) || '任务执行失败';
341
383
  const mapped = getErrorMessage(new Error(raw), result.terminalReason, includeEmoji);
342
384
  const generic = includeEmoji ? '❌ 处理消息时出错,请稍后重试' : '处理消息时出错,请稍后重试';
343
- if (!result.terminalReason && raw && mapped === generic) {
385
+ // Unknown runner-specific terminal reasons do not have a friendly mapping.
386
+ // Preserve their diagnostic text instead of collapsing it to the generic
387
+ // "处理消息时出错" message.
388
+ if (raw && mapped === generic) {
344
389
  return includeEmoji ? `❌ ${raw}` : raw;
345
390
  }
346
391
  return mapped;
347
392
  }
348
- function getRuntimeErrorMessage(raw, includeEmoji = false) {
349
- const fallback = raw || '任务执行失败';
350
- const mapped = getErrorMessage(new Error(fallback), undefined, includeEmoji);
351
- const generic = includeEmoji ? '❌ 处理消息时出错,请稍后重试' : '处理消息时出错,请稍后重试';
352
- if (mapped === generic)
353
- return includeEmoji ? `❌ ${fallback}` : fallback;
354
- return mapped;
355
- }
356
- function normalizeComparableText(text) {
357
- return text.replace(/\s+/g, ' ').trim();
358
- }
359
- function isPendingTextSameAsStreamError(pendingText, errorText) {
360
- const pending = normalizeComparableText(pendingText);
361
- const error = normalizeComparableText(errorText);
362
- if (!pending || !error || pending.length < 20)
363
- return false;
364
- return pending === error || pending.includes(error) || error.includes(pending);
365
- }
366
393
  function streamHitContextLimit(result) {
367
394
  return result.terminalReason === 'prompt_too_long' ||
368
395
  isContextTooLongText(result.lastReplyText) ||
@@ -446,12 +473,6 @@ function getContextTooLongHint(agent) {
446
473
  }
447
474
  return '上下文过长,请精简提问,或使用 /new 新建会话后继续';
448
475
  }
449
- function getContextCompactFailedHint(agent) {
450
- if (canCompactAgent(agent)) {
451
- return '上下文过长,自动压缩失败,请手动输入 /compact 重试';
452
- }
453
- return '上下文过长,请精简提问,或使用 /new 新建会话后继续';
454
- }
455
476
  function canCompactAgent(agent) {
456
477
  return hasCompact(agent) && agent.capabilities?.compact !== false;
457
478
  }
@@ -1042,7 +1063,7 @@ export class ResponseEngine {
1042
1063
  return true;
1043
1064
  }
1044
1065
  async retryAfterContextRecovery(prompt, opts) {
1045
- const { streamKey, renderer, agent, session, absoluteProjectPath, effectiveSystemPrompt, modelOverride, runtimeEnv, resetTimer, shouldSuppress, proactive, turnLease, beforeRunQuery, } = opts;
1066
+ const { streamKey, renderer, agent, session, absoluteProjectPath, effectiveSystemPrompt, modelOverride, runtimeEnv, resetTimer, shouldSuppress, proactive, turnLease, beforeRunQuery, messagePromptContext, } = opts;
1046
1067
  if (!session.agentSessionId || !canCompactAgent(agent)) {
1047
1068
  throw new Error('CONTEXT_COMPACT_FAILED');
1048
1069
  }
@@ -1081,7 +1102,7 @@ export class ResponseEngine {
1081
1102
  await this.emitOperationalNotice(renderer, '✅ 压缩完成,继续处理...', 'info', 'compact-retry');
1082
1103
  await renderer.flush();
1083
1104
  beforeRunQuery?.();
1084
- const retryStream = await agent.runQuery(session.id, prompt, absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager, recoveryModelOverride, runtimeEnv);
1105
+ const retryStream = await agent.runQuery(session.id, insertMessagePromptContext(prompt, messagePromptContext), absoluteProjectPath, session.agentSessionId, undefined, effectiveSystemPrompt, this.sessionManager, recoveryModelOverride, runtimeEnv);
1085
1106
  if (await this.cancelStaleRunnerStart(agent, session.id, streamKey, turnLease, turnLease.taskId)) {
1086
1107
  throw new Error('RUNNER_START_SUPERSEDED');
1087
1108
  }
@@ -1365,11 +1386,11 @@ export class ResponseEngine {
1365
1386
  '/model', '/effort', '/agent', '/slist', '/session', '/rename', '/repair', '/fork',
1366
1387
  '/stop', '/pause', '/resume', '/clear', '/compact', '/renew', '/del', '/perm', '/file', '/check',
1367
1388
  '/s ', '/name ', '/rewind', '/rw', '/rw ', '/activity', '/observable', '/chatmode',
1368
- '/aid', '/upgrade', '/evolagent',
1389
+ '/aid', '/upgrade', '/evolagent', '/pair ',
1369
1390
  ];
1370
1391
  /** 判断消息内容是否为已知命令 */
1371
1392
  isKnownCommand(content) {
1372
- return content === '/s' ||
1393
+ return content === '/s' || content === '/pair' ||
1373
1394
  ResponseEngine.COMMAND_PREFIXES.some(cmd => content.startsWith(cmd));
1374
1395
  }
1375
1396
  /**
@@ -2689,6 +2710,7 @@ export class ResponseEngine {
2689
2710
  : body;
2690
2711
  // 先用裸文本兜底;vars 构造完成后用消息渲染层重算(见下方 effectivePrompt 重赋值)。
2691
2712
  let effectivePrompt = wrapPrompt(message.content);
2713
+ let fullAccessMessagePromptContext;
2692
2714
  let renderResult;
2693
2715
  let effectiveSystemPrompt;
2694
2716
  let modelOverride;
@@ -2784,6 +2806,9 @@ export class ResponseEngine {
2784
2806
  // records all observe the same normalized value.
2785
2807
  permissionContext.permissionMode = effectivePermissionMode;
2786
2808
  permissionContext.executionPermission = fullAccessAuthorization;
2809
+ fullAccessMessagePromptContext = fullAccessAuthorization
2810
+ ? FULL_ACCESS_MESSAGE_PROMPT_CONTEXT
2811
+ : undefined;
2787
2812
  // 按 关系级 > agent级 > 全局 解析本次调用的模型/强度,作为 per-call 入参传入 runQuery。
2788
2813
  // 不缓存、不绑会话——改关系级/agent级后该范围所有会话的下条消息即时生效;
2789
2814
  // 多对端并发各自独立解析、各自传参,无共享状态可被污染。
@@ -2878,7 +2903,9 @@ export class ResponseEngine {
2878
2903
  agentModel = (typeof agent.getModel === 'function') ? agent.getModel() : undefined;
2879
2904
  primaryModel = modelOverride?.model || agentModel;
2880
2905
  if (!hasExplicitTriggerModel && primaryModel) {
2881
- const fallbackResolution = eligibleFallbackModels({
2906
+ const fallbackResolution = await eligibleFallbackModels({
2907
+ selfAid: selfAid || undefined,
2908
+ catalog: agent.getModelCatalog?.bind(agent),
2882
2909
  baseagent: normalizedBaseagent.canonical,
2883
2910
  currentModel: primaryModel,
2884
2911
  resolveModelId: typeof agent.resolveModelId === 'function'
@@ -3218,6 +3245,12 @@ export class ResponseEngine {
3218
3245
  if (v2HandoffIds.length > 0 && renderResult?.body.trim())
3219
3246
  handoffPromptRendered = true;
3220
3247
  }
3248
+ // Keep this notice in the per-turn message prompt. Injecting it into
3249
+ // the system prompt would alter long-lived runner prompt caches and
3250
+ // could make a one-turn grant look persistent.
3251
+ if (fullAccessMessagePromptContext && effectivePrompt.trim()) {
3252
+ effectivePrompt = insertMessagePromptContext(effectivePrompt, fullAccessMessagePromptContext, message.content);
3253
+ }
3221
3254
  // 空消息防护:在 agent 调用之前检查 prompt 是否为空
3222
3255
  // 防止空消息(或纯空格消息)浪费 API 调用
3223
3256
  if (!effectivePrompt.trim()) {
@@ -3527,6 +3560,7 @@ export class ResponseEngine {
3527
3560
  proactiveSelfAid: selfAid,
3528
3561
  permissionMode: effectivePermissionMode,
3529
3562
  turnLease,
3563
+ messagePromptContext: fullAccessMessagePromptContext,
3530
3564
  beforeRunQuery: fullAccessAuthorization
3531
3565
  ? () => this.assertCurrentFullAccessExecutionAuthorization(fullAccessAuthorization, agent)
3532
3566
  : undefined,
@@ -3573,6 +3607,7 @@ export class ResponseEngine {
3573
3607
  proactiveSelfAid: selfAid,
3574
3608
  permissionMode: effectivePermissionMode,
3575
3609
  turnLease,
3610
+ messagePromptContext: fullAccessMessagePromptContext,
3576
3611
  beforeRunQuery: fullAccessAuthorization
3577
3612
  ? () => this.assertCurrentFullAccessExecutionAuthorization(fullAccessAuthorization, agent)
3578
3613
  : undefined,
@@ -5254,8 +5289,10 @@ export class ResponseEngine {
5254
5289
  // 运行时错误(Codex: turn.failed / item error)
5255
5290
  if (event.type === 'error') {
5256
5291
  logger.warn(`[ResponseEngine] error event: ${event.errorType}: ${event.error}`);
5257
- // 记录错误文本到 lastReplyText,供后续 isPromptTooLong 检测
5258
- lastReplyText += event.error || '';
5292
+ // Keep runtime diagnostics separate from assistant prose. Appending
5293
+ // an error to lastReplyText makes the terminal flush treat the error
5294
+ // as user-visible model output and can also make it look like a
5295
+ // duplicate of a pending text event.
5259
5296
  completeResult = {
5260
5297
  ...completeResult,
5261
5298
  isError: true,
@@ -5265,14 +5302,6 @@ export class ResponseEngine {
5265
5302
  lastReplyText,
5266
5303
  fullText: '',
5267
5304
  };
5268
- // 上下文过长的错误不在此处输出 notice,留给外层 isPromptTooLong 触发 auto-compact
5269
- const isContextError = isContextTooLongText(event.error || '');
5270
- const isRetryableRuntimeError = isRetryableError(new Error(event.error || ''));
5271
- const isRecoverableModelError = classifyError(new Error(event.error || '')) === ErrorType.MODEL_UNAVAILABLE;
5272
- if (!isContextError && !isRetryableRuntimeError && !isRecoverableModelError && !hasErrorResult && !shouldSuppress()) {
5273
- hasErrorResult = true;
5274
- renderer.addNotice(getRuntimeErrorMessage(event.error || '任务执行失败', false), 'warn', 'runtime-error', true);
5275
- }
5276
5305
  }
5277
5306
  // 完成事件
5278
5307
  // SDK 可能产生多个 complete 事件(如 subagent 或 auto-compact 二次查询),
@@ -5291,34 +5320,6 @@ export class ResponseEngine {
5291
5320
  madeProgress = true;
5292
5321
  // thought jsonl 写入已下沉到 aun.ts:sendThought 成功后,
5293
5322
  // 由那里按 LLM 输出的每个 text item 单独写一条,此处不再写。
5294
- // 失败且无前置错误输出:显示 errors 摘要
5295
- // 但用户主动中断(新消息打断 或 /stop 命令)时不显示错误提示
5296
- // 上下文过长的错误留给外层 isPromptTooLong 触发 auto-compact,不在此处输出
5297
- const interruptReason = this.interruptedSessions.get(session.id);
5298
- const isUserInterrupt = isNonErrorInterruptReason(interruptReason)
5299
- || isExpectedTerminalInterrupt(event.terminalReason);
5300
- const isContextTooLong = event.terminalReason === 'prompt_too_long'
5301
- || isContextTooLongText(event.errors?.join(' ') || '')
5302
- || isContextTooLongText(lastReplyText);
5303
- const completeErrorText = getStreamErrorText(completeResult);
5304
- const isRetryableCompleteError = event.isError && !isContextTooLong && completeErrorText
5305
- ? isRetryableError(new Error(completeErrorText))
5306
- : false;
5307
- const isRecoverableModelError = event.isError && !isContextTooLong && completeErrorText
5308
- ? classifyError(new Error(completeErrorText)) === ErrorType.MODEL_UNAVAILABLE
5309
- : false;
5310
- if (event.isError && completeErrorText && isPendingTextSameAsStreamError(renderer.getRemainingText(), completeErrorText)) {
5311
- renderer.discardPendingText();
5312
- }
5313
- if (event.isError && !hasErrorResult && !shouldSuppress() && !isUserInterrupt && !isContextTooLong && !isRetryableCompleteError && !isRecoverableModelError) {
5314
- // 使用 terminalReason 提供更友好的错误提示(不带 emoji,由 formatter 统一加)
5315
- const userFriendlyMessage = event.terminalReason === 'prompt_too_long'
5316
- ? getContextTooLongHint(agent)
5317
- : event.terminalReason === 'context_compact_failed'
5318
- ? getContextCompactFailedHint(agent)
5319
- : getStreamErrorMessage(completeResult, false);
5320
- renderer.addNotice(userFriendlyMessage, 'warn', 'task-error', true);
5321
- }
5322
5323
  // 中间 complete:flush 掉已有 activities(不带 isFinal),让中间结果及时显示
5323
5324
  // 最终文本留给流结束后的统一 flush(true)
5324
5325
  if (renderer.hasContent()) {