evolcore 0.0.22 → 0.0.24

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 (117) hide show
  1. package/CHANGELOG.md +74 -14
  2. package/README.md +4 -0
  3. package/bin/codex-managed-acl.ps1 +160 -0
  4. package/bin/codex-managed-hook.mjs +127 -10
  5. package/bin/ec-safe-output.js +85 -3
  6. package/bin/install-codex-managed-hooks.mjs +384 -95
  7. package/dist/agents/baseagent-availability.js +48 -0
  8. package/dist/agents/baseagent.js +30 -2
  9. package/dist/agents/claude-runner.js +179 -240
  10. package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +90 -20
  11. package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
  12. package/dist/agents/codex/catalog.js +70 -0
  13. package/dist/agents/codex/diagnostics.js +369 -0
  14. package/dist/agents/codex-runner.js +839 -192
  15. package/dist/agents/ecagent-runner.js +27 -34
  16. package/dist/agents/gemini-runner.js +62 -8
  17. package/dist/agents/runner-types.js +1 -1
  18. package/dist/aun/aid/managed-operation.js +28 -6
  19. package/dist/aun/aid/store.js +43 -0
  20. package/dist/aun/menu-client.js +272 -0
  21. package/dist/aun/msg/group.js +65 -4
  22. package/dist/aun/msg/index.js +1 -1
  23. package/dist/aun/msg/managed-operation.js +57 -5
  24. package/dist/aun/outbox.js +56 -18
  25. package/dist/aun/rpc/client.js +7 -1
  26. package/dist/channels/aun.js +178 -77
  27. package/dist/channels/daemon.js +7 -1
  28. package/dist/channels/dingtalk.js +1 -0
  29. package/dist/channels/feishu.js +10 -4
  30. package/dist/channels/wechat.js +81 -10
  31. package/dist/channels/wecom.js +2 -1
  32. package/dist/cli/agent-command.js +29 -3
  33. package/dist/cli/agent-create.js +538 -0
  34. package/dist/cli/agent.js +26 -675
  35. package/dist/cli/aun-commands.js +108 -24
  36. package/dist/cli/command-log.js +21 -2
  37. package/dist/cli/ctl-command.js +2 -0
  38. package/dist/cli/daemon-commands.js +289 -61
  39. package/dist/cli/index.js +22 -2
  40. package/dist/cli/init.js +2 -10
  41. package/dist/cli/managed-command-guard.js +3 -0
  42. package/dist/cli/menu-client.js +95 -0
  43. package/dist/cli/model.js +23 -8
  44. package/dist/cli/restart-monitor.js +432 -8
  45. package/dist/cli/stats.js +110 -0
  46. package/dist/cli/task-context.js +12 -2
  47. package/dist/cli/trigger-command.js +9 -1
  48. package/dist/config/builtin-roles.js +2 -0
  49. package/dist/config/schema-migration.js +14 -2
  50. package/dist/core/auth/agent-delegation.js +10 -7
  51. package/dist/core/auth/auth-gateway.js +3 -2
  52. package/dist/core/auth/authorization-audit.js +75 -18
  53. package/dist/core/auth/operation-authorizer.js +63 -3
  54. package/dist/core/auth/operation-catalog.js +14 -2
  55. package/dist/core/auth/remote-access-store.js +136 -0
  56. package/dist/core/baseagent-loader.js +4 -4
  57. package/dist/core/bootstrap-messages.js +39 -18
  58. package/dist/core/bootstrap-service.js +32 -26
  59. package/dist/core/capability/capability-manager.js +8 -4
  60. package/dist/core/capability/providers/codex-capability-provider.js +1 -1
  61. package/dist/core/command/agent-control.js +7 -2
  62. package/dist/core/command/command-handler.js +128 -30
  63. package/dist/core/command/daemon-diagnostics.js +448 -0
  64. package/dist/core/command/menu-catalog.js +6 -1
  65. package/dist/core/command/menu-handler.js +410 -98
  66. package/dist/core/command/menu-protocol.js +123 -0
  67. package/dist/core/command/slash-gate.js +4 -6
  68. package/dist/core/command/slash-handler.js +199 -70
  69. package/dist/core/data-migration.js +5 -15
  70. package/dist/core/event-catalog.js +3 -0
  71. package/dist/core/handoff/runtime.js +3 -1
  72. package/dist/core/handoff/store.js +7 -1
  73. package/dist/core/message/im-renderer.js +13 -6
  74. package/dist/core/message/message-bridge.js +5 -36
  75. package/dist/core/message/message-queue.js +20 -15
  76. package/dist/core/message/message-utils.js +6 -3
  77. package/dist/core/message/response-engine.js +147 -94
  78. package/dist/core/model/catalog-service.js +310 -0
  79. package/dist/core/model/model-catalog.js +134 -334
  80. package/dist/core/model/model-diagnostics.js +4 -5
  81. package/dist/core/model/model-fallback.js +9 -16
  82. package/dist/core/model/model-permission.js +5 -2
  83. package/dist/core/permission/approval-gateway.js +183 -32
  84. package/dist/core/permission/protected-paths.js +63 -4
  85. package/dist/core/permission/sandbox-runtime.js +4 -2
  86. package/dist/core/permission/tool-policy.js +257 -31
  87. package/dist/core/runtime-lock.js +13 -8
  88. package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
  89. package/dist/core/session/session-manager.js +49 -13
  90. package/dist/core/session/session-renew.js +1 -0
  91. package/dist/index.js +214 -57
  92. package/dist/ipc.js +191 -102
  93. package/dist/paths.js +23 -1
  94. package/dist/stats/billing.js +18 -4
  95. package/dist/trigger/manager.js +3 -1
  96. package/dist/trigger/parser.js +77 -13
  97. package/dist/trigger/patch.js +104 -1
  98. package/dist/utils/atomic-write.js +26 -2
  99. package/dist/utils/cross-platform.js +436 -1
  100. package/dist/utils/ecweb-supervisor.js +11 -2
  101. package/dist/utils/ecweb-utils.js +8 -26
  102. package/dist/utils/error-utils.js +16 -2
  103. package/dist/utils/model-prices.jsonl +23 -20
  104. package/dist/utils/npm-ops.js +70 -15
  105. package/dist/utils/windows-autostart.js +1 -1
  106. package/kits/docs/evolcore/INDEX.md +1 -0
  107. package/kits/docs/evolcore/agent-create.md +13 -0
  108. package/kits/docs/evolcore/group-collaboration.md +7 -2
  109. package/kits/docs/evolcore/menu-client.md +34 -0
  110. package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
  111. package/kits/migrations/migrate-role-config-v4.mjs +11 -5
  112. package/kits/migrations/migrate-role-config-v5.mjs +8 -2
  113. package/kits/templates/roles/admin.json +2 -0
  114. package/kits/templates/roles/visitor.json +1 -0
  115. package/kits/templates/system-fragments/bootstrap.md +8 -1
  116. package/package.json +16 -4
  117. package/dist/utils/codex-cli.js +0 -43
@@ -1,3 +1,4 @@
1
+ import { getRunnerCatalog, catalogEfforts, assertCatalogSelectable } from '../core/model/model-catalog.js';
1
2
  import fs from 'fs';
2
3
  import path from 'path';
3
4
  import { AgentHarness, JsonlSessionRepo, NodeExecutionEnv, } from '../../ecagent/dist/node.js';
@@ -20,13 +21,18 @@ const PROVIDER_ID = 'evolcore-gateway';
20
21
  const DEFAULT_SYSTEM_PROMPT = `You are ecagent, the coding agent built into EvolCore.
21
22
  Work directly in the current project. Inspect relevant files before changing them, keep edits scoped, and verify the result.
22
23
  Use the provided tools for filesystem and shell work. Never claim a tool action succeeded unless its result confirms success.`;
23
- const MODEL_CACHE_TTL_MS = 5 * 60 * 1000;
24
- const MODEL_LIST_TIMEOUT_MS = 8_000;
25
24
  const MAX_READ_BYTES = 1024 * 1024;
26
25
  const MAX_TOOL_OUTPUT = 200_000;
27
26
  const MAX_SCAN_FILES = 5_000;
28
27
  const MAX_SEARCH_RESULTS = 500;
29
28
  const DEFAULT_BASH_TIMEOUT_MS = 120_000;
29
+ /**
30
+ * 规范化模型 ID:去掉 Claude Code SDK 专有的上下文窗口后缀 [1m]/[200k]
31
+ * Ecagent/OpenAI API 不认识这些后缀,必须传入标准模型 ID
32
+ */
33
+ function stripContextWindowSuffix(modelId) {
34
+ return modelId.replace(/\[(?:1m|200k)\]$/i, '');
35
+ }
30
36
  class AsyncEventQueue {
31
37
  values = [];
32
38
  waiters = [];
@@ -705,7 +711,6 @@ export class EcagentRunner {
705
711
  permissionGateway;
706
712
  sendPromptFn;
707
713
  onCompactStart;
708
- modelCache;
709
714
  constructor(config, callbacks, runtime) {
710
715
  this.config = {
711
716
  ...config,
@@ -716,7 +721,7 @@ export class EcagentRunner {
716
721
  configuredHeaders: config.headers,
717
722
  }),
718
723
  };
719
- this.model = config.model;
724
+ this.model = stripContextWindowSuffix(config.model);
720
725
  this.effort = config.effort;
721
726
  this.agentAid = runtime?.agentAid;
722
727
  this.onSessionIdUpdate = callbacks?.onSessionIdUpdate;
@@ -726,7 +731,7 @@ export class EcagentRunner {
726
731
  throw new Error('ecagent requires an EvolCore Agent AID');
727
732
  return agentSessionsDir(this.agentAid);
728
733
  }
729
- setModel(model) { this.model = model; }
734
+ setModel(model) { this.model = stripContextWindowSuffix(model); }
730
735
  getModel() { return this.model; }
731
736
  resolveModelId(model) { return model; }
732
737
  setEffort(effort) { this.effort = effort; }
@@ -745,35 +750,16 @@ export class EcagentRunner {
745
750
  { key: 'bypass', nameZh: '放行', description: '普通审批清单自动执行,强危险操作仍需审批', available: true },
746
751
  ];
747
752
  }
753
+ getModelCatalog() {
754
+ return getRunnerCatalog('ecagent', this.agentAid, this.config);
755
+ }
748
756
  async listModels() {
749
- if (this.modelCache && Date.now() - this.modelCache.fetchedAt < MODEL_CACHE_TTL_MS)
750
- return [...this.modelCache.ids];
751
- const stale = this.modelCache?.ids;
752
- try {
753
- const signal = AbortSignal.timeout(MODEL_LIST_TIMEOUT_MS);
754
- const response = await fetch(appendRequestPath(this.config.baseUrl, 'models', this.config.queryParams), {
755
- headers: {
756
- authorization: `Bearer ${this.config.apiKey}`,
757
- ...this.config.headers,
758
- },
759
- signal,
760
- });
761
- if (!response.ok)
762
- throw new Error(`Failed to list ecagent models: HTTP ${response.status}`);
763
- const payload = await response.json();
764
- const ids = [...new Set((payload.data ?? [])
765
- .map(item => typeof item.id === 'string' ? item.id : '')
766
- .filter(id => id.startsWith('claude-') || (id.startsWith('gpt-') && !id.startsWith('gpt-image-'))))];
767
- if (ids.length === 0)
768
- throw new Error('Gateway returned no supported Claude or GPT models');
769
- this.modelCache = { ids, fetchedAt: Date.now() };
770
- return [...ids];
771
- }
772
- catch (error) {
773
- if (stale && stale.length > 0)
774
- return [...stale];
775
- throw error;
776
- }
757
+ const catalog = await this.getModelCatalog();
758
+ assertCatalogSelectable(catalog);
759
+ return catalog.models.map(m => m.id);
760
+ }
761
+ async getAvailableEfforts(model) {
762
+ return catalogEfforts(await this.getModelCatalog(), model, 'ecagent');
777
763
  }
778
764
  registerStream(key, stream) { this.activeStreams.set(key, stream); }
779
765
  cleanupStream(key) {
@@ -1016,6 +1002,13 @@ export class EcagentRunner {
1016
1002
  return runtime;
1017
1003
  }
1018
1004
  mapHarnessEvent(queue, event, stats, model, effort) {
1005
+ if (event.type === 'message_update'
1006
+ && (event.assistantMessageEvent.type === 'thinking_start'
1007
+ || event.assistantMessageEvent.type === 'thinking_delta'
1008
+ || event.assistantMessageEvent.type === 'thinking_end')) {
1009
+ queue.push({ type: 'heartbeat', source: 'reasoning', turn: stats.numTurns + 1 });
1010
+ return;
1011
+ }
1019
1012
  if (event.type === 'message_update' && event.assistantMessageEvent.type === 'text_delta') {
1020
1013
  if (!stats.firstTextAt)
1021
1014
  stats.firstTextAt = Date.now();
@@ -1223,7 +1216,7 @@ export class EcagentRunner {
1223
1216
  ? cached
1224
1217
  : await this.openExistingRuntime(agentSessionId, cwd);
1225
1218
  this.onCompactStart?.(sessionId);
1226
- const compactModel = modelOverride?.model || this.model;
1219
+ const compactModel = stripContextWindowSuffix(modelOverride?.model || this.model);
1227
1220
  const compactEffort = modelOverride?.effortMode === 'model_default'
1228
1221
  ? undefined
1229
1222
  : modelOverride?.effort ?? this.effort;
@@ -1,3 +1,4 @@
1
+ import { getCatalog, assertCatalogSelectable } from '../core/model/model-catalog.js';
1
2
  /**
2
3
  * Gemini Agent Runner
3
4
  *
@@ -23,6 +24,7 @@ import { normalizeExecutionPermissionMode, normalizePermissionMode } from '../co
23
24
  import { workspaceContainsHClassPaths } from '../core/permission/protected-paths.js';
24
25
  import { buildBubblewrapCommand } from '../core/permission/sandbox-runtime.js';
25
26
  import { getManagedTaskTempDir } from '../cli/task-context.js';
27
+ import { auditPermissionLifecycle } from '../core/auth/authorization-audit.js';
26
28
  // Strip ANSI escape codes from Gemini CLI text output.
27
29
  // Gemini embeds raw terminal colors from tool stdout (e.g. vitest, npm)
28
30
  // into its assistant text, unlike Claude SDK which strips them internally.
@@ -31,6 +33,13 @@ const ANSI_RE = /\x1b\[[0-9;]*[a-zA-Z]|\x1b\].*?(?:\x1b\\|\x07)/g;
31
33
  function stripAnsi(text) {
32
34
  return text.replace(ANSI_RE, '');
33
35
  }
36
+ /**
37
+ * 规范化模型 ID:去掉 Claude Code SDK 专有的上下文窗口后缀 [1m]/[200k]
38
+ * Gemini API 不认识这些后缀,必须传入标准模型 ID
39
+ */
40
+ function stripContextWindowSuffix(modelId) {
41
+ return modelId.replace(/\[(?:1m|200k)\]$/i, '');
42
+ }
34
43
  // ── MIME → 扩展名映射 ──
35
44
  const MIME_EXT = {
36
45
  'image/png': '.png',
@@ -39,11 +48,6 @@ const MIME_EXT = {
39
48
  'image/webp': '.webp',
40
49
  };
41
50
  // ── Gemini 模型列表 ──
42
- const GEMINI_MODELS = [
43
- 'gemini-2.5-pro',
44
- 'gemini-2.5-flash',
45
- 'gemini-2.5-flash-lite',
46
- ];
47
51
  const GEMINI_READONLY_TOOLS = [
48
52
  'read_file', 'list_directory', 'glob', 'grep_search', 'google_web_search',
49
53
  'codebase_investigator', 'cli_help', 'get_internal_docs', 'update_topic', 'complete_task',
@@ -290,13 +294,13 @@ export class GeminiRunner {
290
294
  permissionContexts = new Map();
291
295
  constructor(config, callbacks) {
292
296
  this.resolved = resolveGoogleConfig(config);
293
- this.model = this.resolved.model;
297
+ this.model = stripContextWindowSuffix(this.resolved.model);
294
298
  this.onSessionIdUpdate = callbacks.onSessionIdUpdate;
295
299
  }
296
300
  // ── ModelSwitcher ──
297
- setModel(model) { this.model = model; }
301
+ setModel(model) { this.model = stripContextWindowSuffix(model); }
298
302
  getModel() { return this.model; }
299
- listModels() { return GEMINI_MODELS; }
303
+ async listModels() { const catalog = await getCatalog(undefined, 'gemini'); assertCatalogSelectable(catalog); return catalog.models.map(m => m.id); }
300
304
  // ── Effort (not applicable) ──
301
305
  setEffort(_effort) { }
302
306
  getEffort() { return undefined; }
@@ -691,6 +695,56 @@ export class GeminiRunner {
691
695
  logger.info(`[GeminiRunner] interaction.auto_resolved kind=${normalizeGeminiToolName(toolName)} session=${sessionId}`);
692
696
  return;
693
697
  }
698
+ const lifecycleRequestId = requestId ?? `gemini-approval-${crypto.randomUUID()}`;
699
+ const auditSummary = `${toolName} runtime authorization denied by Gemini headless policy`;
700
+ auditPermissionLifecycle({
701
+ phase: 'request',
702
+ requestId: lifecycleRequestId,
703
+ sessionId,
704
+ toolName,
705
+ decision: 'pending',
706
+ decisionSource: 'approval',
707
+ permissionMode: context.permissionMode,
708
+ agentAid: context.selfAid,
709
+ agentName: context.agentName,
710
+ role: context.role,
711
+ taskId: context.taskId,
712
+ summary: auditSummary,
713
+ });
714
+ auditPermissionLifecycle({
715
+ phase: 'response',
716
+ requestId: lifecycleRequestId,
717
+ sessionId,
718
+ toolName,
719
+ decision: 'deny',
720
+ decisionSource: 'policy',
721
+ policyCode: 'permission_mode_denied',
722
+ reason: auditSummary,
723
+ permissionMode: context.permissionMode,
724
+ agentAid: context.selfAid,
725
+ agentName: context.agentName,
726
+ role: context.role,
727
+ taskId: context.taskId,
728
+ summary: auditSummary,
729
+ response: 'deny',
730
+ });
731
+ auditPermissionLifecycle({
732
+ phase: 'final',
733
+ requestId: lifecycleRequestId,
734
+ sessionId,
735
+ toolName,
736
+ decision: 'deny',
737
+ decisionSource: 'policy',
738
+ policyCode: 'permission_mode_denied',
739
+ reason: auditSummary,
740
+ permissionMode: context.permissionMode,
741
+ agentAid: context.selfAid,
742
+ agentName: context.agentName,
743
+ role: context.role,
744
+ taskId: context.taskId,
745
+ summary: auditSummary,
746
+ response: 'deny',
747
+ });
694
748
  try {
695
749
  await context.recordExecutionAnomaly?.({
696
750
  code: 'runtime_permission_denied',
@@ -63,7 +63,7 @@ export function usageForContext(usage) {
63
63
  return lastIteration ?? usage;
64
64
  }
65
65
  /** Models whose base ID uses a 1M context window when sent to the SDK with [1m]. */
66
- export const ONE_M_CONTEXT_MODEL_PREFIXES = ['claude-opus-4-8', 'claude-sonnet-4-6'];
66
+ export const ONE_M_CONTEXT_MODEL_PREFIXES = ['claude-opus-5', 'claude-opus-4-8', 'claude-sonnet-4-6'];
67
67
  const ONE_M_CONTEXT_MODEL_ALIASES = ['opus', 'sonnet'];
68
68
  const CLAUDE_CONTEXT_MODEL_ALIASES = ['opus', 'sonnet', 'haiku'];
69
69
  export function isClaudeContextUsageModel(model) {
@@ -29,8 +29,9 @@ function parseAid(value) {
29
29
  return { ok: true, aid };
30
30
  }
31
31
  /**
32
- * The daemon accepts only the self-scoped AID commands exposed to managed
33
- * tasks. Parsing here is deliberately narrower than the standalone CLI.
32
+ * The daemon accepts only the narrowly scoped AID commands exposed to managed
33
+ * tasks. Public lookup/get may name any valid AID; local show/publish remain
34
+ * subject to the self-AID boundary enforced by the command handler.
34
35
  */
35
36
  export function resolveManagedAidOperation(argv) {
36
37
  const args = argv[0] === 'aid' ? argv.slice(1) : argv;
@@ -67,7 +68,21 @@ export function resolveManagedAidOperation(argv) {
67
68
  return {
68
69
  ok: false,
69
70
  code: 'NOT_ALLOWED',
70
- reason: 'Managed AID operations only allow exact self-scoped show, lookup, agentmd get, or agentmd put commands with an optional --format json suffix',
71
+ reason: 'Managed AID operations only allow exact show, lookup, agentmd get, or agentmd put commands with an optional --format json suffix',
72
+ };
73
+ }
74
+ export function managedAidShowViewForRole(role) {
75
+ return role === 'owner' || role === 'admin' ? 'full' : 'redacted';
76
+ }
77
+ export function redactAidShowResult(value) {
78
+ return {
79
+ aid: value.aid,
80
+ redacted: true,
81
+ hasAgentMd: value.hasAgentMd,
82
+ certExpiresAt: value.certExpiresAt,
83
+ certSubject: value.certSubject,
84
+ certExpired: value.certExpired,
85
+ agentMdSignature: value.agentMdSignature,
71
86
  };
72
87
  }
73
88
  const defaultDependencies = () => ({
@@ -80,12 +95,19 @@ const defaultDependencies = () => ({
80
95
  readFileSync: fs.readFileSync,
81
96
  });
82
97
  /** Execute a validated AID operation from the daemon-owned host filesystem. */
83
- export async function executeManagedAidOperation(command, overrides = {}) {
98
+ export async function executeManagedAidOperation(command, options = {}) {
99
+ const { showView = 'full', ...overrides } = options;
84
100
  const deps = { ...defaultDependencies(), ...overrides };
85
101
  const aunPath = deps.root;
86
102
  switch (command.kind) {
87
- case 'show':
88
- return { kind: command.kind, aid: command.aid, value: await deps.aidShow(command.aid, { aunPath }) };
103
+ case 'show': {
104
+ const value = await deps.aidShow(command.aid, { aunPath });
105
+ return {
106
+ kind: command.kind,
107
+ aid: command.aid,
108
+ value: showView === 'redacted' ? redactAidShowResult(value) : value,
109
+ };
110
+ }
89
111
  case 'lookup':
90
112
  return { kind: command.kind, aid: command.aid, value: await deps.aidLookup(command.aid) };
91
113
  case 'agentmd.get':
@@ -7,6 +7,7 @@ import { AUN_KEYSTORE_ENCRYPTION_SEED } from './encryption-seed-policy.js';
7
7
  // pages so a large backlog is drained without monopolizing the sync loop.
8
8
  const FULL_GROUP_PULL_MAX_PAGES = 10;
9
9
  const GROUP_PULL_PAGINATION_PATCH = Symbol('evolcore.groupPullPaginationPatch');
10
+ const RELIABLE_MESSAGE_DELIVERY_PATCH = Symbol('evolcore.reliableMessageDeliveryPatch');
10
11
  /**
11
12
  * Slot 命名约定(基于 fastaun 0.4.3 隔离键语义)。
12
13
  *
@@ -24,6 +25,7 @@ const GROUP_PULL_PAGINATION_PATCH = Symbol('evolcore.groupPullPaginationPatch');
24
25
  * - CLI / ecweb 查询使用新的独立 slot,不能共享 daemon 的 token 或 seq 游标。
25
26
  * - netcheck 使用独立消费者,不得因诊断连接影响 daemon 的入站顺序。
26
27
  * - 群规则同步是短连接 RPC,也必须使用独立 slot,避免触碰 daemon 消费游标。
28
+ * - menu-client 是远程 Control AID 的短连接请求器,使用独立 slot,避免触碰 daemon 消费游标。
27
29
  * - bench 各并发单元用 evolcore-bench-<N>,各自独立隔离键,互不干扰
28
30
  */
29
31
  export const SLOT = {
@@ -32,6 +34,7 @@ export const SLOT = {
32
34
  daemon: 'evolcore daemon',
33
35
  cli: 'evolcore-cli',
34
36
  groupRules: 'evolcore-group-rules',
37
+ menuClient: 'evolcore-menu-client',
35
38
  bench: 'evolcore-bench',
36
39
  netcheck: 'evolcore-netcheck',
37
40
  };
@@ -104,6 +107,46 @@ export function enableFullGroupPullPagination(client) {
104
107
  target._pullGroupV2 = wrappedPull;
105
108
  return true;
106
109
  }
110
+ /**
111
+ * Make SDK pull delivery wait until EvolCore's application handlers finish.
112
+ *
113
+ * fastaun 0.5.13 publishes message events with dispatcher.enqueue(), then
114
+ * advances its sequence tracker immediately. That is a transport watermark,
115
+ * so a daemon restart can acknowledge a message before the application has
116
+ * durably claimed it. The SDK keeps _publishAppEvent private, but the public
117
+ * client is intentionally adapted here in one place: preserve the SDK's
118
+ * normal publication path and wait for its dispatcher queue for pulled
119
+ * message events before the pull operation commits the sequence.
120
+ */
121
+ export function enableReliableMessageDelivery(client) {
122
+ const target = client;
123
+ if (target[RELIABLE_MESSAGE_DELIVERY_PATCH])
124
+ return false;
125
+ const publish = target._publishAppEvent;
126
+ const dispatcher = target._dispatcher;
127
+ if (typeof publish !== 'function' || !dispatcher || typeof dispatcher.flush !== 'function')
128
+ return false;
129
+ const wrappedPublish = async function (event, payload, source, ...rest) {
130
+ const result = await publish.call(this, event, payload, source, ...rest);
131
+ // Pull/recovery paths must not advance SeqTracker until the application
132
+ // handlers have completed. Push delivery has no pull commit to gate and
133
+ // remains asynchronous as intended by the SDK.
134
+ const reliable = (event === 'message.received' || event === 'group.message_created')
135
+ && source !== 'push'
136
+ && source !== 'inline-push';
137
+ if (reliable)
138
+ await dispatcher.flush();
139
+ return result;
140
+ };
141
+ Object.defineProperty(target, RELIABLE_MESSAGE_DELIVERY_PATCH, {
142
+ configurable: false,
143
+ enumerable: false,
144
+ value: true,
145
+ writable: false,
146
+ });
147
+ target._publishAppEvent = wrappedPublish;
148
+ return true;
149
+ }
107
150
  /** 加载本地 AID 值对象(用于离线签名/验签,无需连接)。load 失败抛 AidLoadError。 */
108
151
  export function loadAid(store, aid) {
109
152
  const r = store.load(aid);
@@ -0,0 +1,272 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { createShortConnection } from './rpc/client.js';
5
+ import { SLOT } from './aid/store.js';
6
+ import { isValidAid } from './aid/validation.js';
7
+ import { resolveRoot } from '../paths.js';
8
+ import { readInstalledEvolcoreVersion } from '../utils/evolcore-version.js';
9
+ import { buildMenuClientProtectedHeaders, parseMenuResponse, validateMenuRequest, } from '../core/command/menu-protocol.js';
10
+ const DEFAULT_TIMEOUT_MS = 15_000;
11
+ const MAX_TIMEOUT_MS = 120_000;
12
+ const POLL_INTERVAL_MS = 200;
13
+ const PULL_LIMIT = 50;
14
+ const LOCK_STALE_MS = 5 * 60_000;
15
+ export class MenuClientError extends Error {
16
+ code;
17
+ data;
18
+ constructor(code, message, data) {
19
+ super(message);
20
+ this.code = code;
21
+ this.data = data;
22
+ this.name = 'MenuClientError';
23
+ }
24
+ }
25
+ async function callClient(conn, method, params) {
26
+ try {
27
+ return await conn.call(method, params);
28
+ }
29
+ catch (error) {
30
+ if (error instanceof MenuClientError)
31
+ throw error;
32
+ const code = String(error?.code ?? '').toLowerCase();
33
+ const message = error?.message ?? String(error);
34
+ const timedOut = code.includes('timeout') || code.includes('timed_out') || /timed?\s*out|timeout/i.test(String(message));
35
+ throw new MenuClientError(timedOut ? 'TIMEOUT' : 'RPC_FAILED', String(message));
36
+ }
37
+ }
38
+ function normalizeTimeout(value) {
39
+ const timeout = value ?? DEFAULT_TIMEOUT_MS;
40
+ if (!Number.isFinite(timeout) || timeout < 1_000 || timeout > MAX_TIMEOUT_MS) {
41
+ throw new MenuClientError('INVALID_ARGUMENT', `timeout must be between 1s and ${MAX_TIMEOUT_MS / 1000}s`);
42
+ }
43
+ return Math.round(timeout);
44
+ }
45
+ function lockPath(aunPath, fromAid) {
46
+ const identity = crypto.createHash('sha256').update(fromAid).digest('hex').slice(0, 16);
47
+ return path.join(aunPath ?? resolveRoot(), 'data', 'instance', `menu-client-${identity}.lock`);
48
+ }
49
+ function acquireClientLock(aunPath, fromAid) {
50
+ const file = lockPath(aunPath, fromAid);
51
+ fs.mkdirSync(path.dirname(file), { recursive: true });
52
+ const token = `${process.pid}:${crypto.randomUUID()}`;
53
+ try {
54
+ const stat = fs.statSync(file);
55
+ if (Date.now() - stat.mtimeMs > LOCK_STALE_MS) {
56
+ try {
57
+ fs.unlinkSync(file);
58
+ }
59
+ catch { }
60
+ }
61
+ }
62
+ catch { }
63
+ let fd;
64
+ try {
65
+ fd = fs.openSync(file, 'wx', 0o600);
66
+ fs.writeFileSync(fd, token, 'utf8');
67
+ }
68
+ catch (error) {
69
+ try {
70
+ if (fd !== undefined)
71
+ fs.closeSync(fd);
72
+ }
73
+ catch { }
74
+ try {
75
+ if (fs.existsSync(file))
76
+ fs.unlinkSync(file);
77
+ }
78
+ catch { }
79
+ if (error?.code === 'EEXIST')
80
+ throw new MenuClientError('CLIENT_BUSY', 'Another ec menu-client request is using the configured slot');
81
+ throw new MenuClientError('CLIENT_LOCK_FAILED', `Unable to acquire menu-client lock: ${error?.message ?? String(error)}`);
82
+ }
83
+ return () => {
84
+ try {
85
+ const current = fs.readFileSync(file, 'utf8');
86
+ if (current === token)
87
+ fs.unlinkSync(file);
88
+ }
89
+ catch { }
90
+ if (fd !== undefined) {
91
+ try {
92
+ fs.closeSync(fd);
93
+ }
94
+ catch { }
95
+ }
96
+ };
97
+ }
98
+ function normalizeRequest(value) {
99
+ const request = { ...value };
100
+ if (typeof request.id !== 'string' || !request.id.trim())
101
+ request.id = crypto.randomUUID();
102
+ const error = validateMenuRequest(request);
103
+ if (error)
104
+ throw new MenuClientError(error.code, error.message, error.data);
105
+ return request;
106
+ }
107
+ function responseFromMessage(message) {
108
+ const from = typeof message?.from === 'string'
109
+ ? message.from.trim()
110
+ : typeof message?.envelope?.from === 'string' ? message.envelope.from.trim() : '';
111
+ let payload = message?.payload;
112
+ if (typeof payload === 'string') {
113
+ try {
114
+ payload = JSON.parse(payload);
115
+ }
116
+ catch {
117
+ return undefined;
118
+ }
119
+ }
120
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
121
+ return undefined;
122
+ if (from && typeof payload.type === 'string' && payload.type === 'menu.response') {
123
+ try {
124
+ return parseMenuResponse(payload);
125
+ }
126
+ catch {
127
+ return undefined;
128
+ }
129
+ }
130
+ return undefined;
131
+ }
132
+ async function pollResponse(conn, targetAid, requestId, timeoutMs, initialAfterSeq) {
133
+ const deadline = Date.now() + timeoutMs;
134
+ let afterSeq = initialAfterSeq;
135
+ while (Date.now() < deadline) {
136
+ const result = await callClient(conn, 'message.pull', { after_seq: afterSeq, limit: PULL_LIMIT });
137
+ const messages = Array.isArray(result?.messages) ? result.messages : [];
138
+ let maxSeq = afterSeq;
139
+ for (const message of messages) {
140
+ const seq = Number(message?.seq);
141
+ if (Number.isSafeInteger(seq) && seq > maxSeq)
142
+ maxSeq = seq;
143
+ const response = responseFromMessage(message);
144
+ const messageFrom = typeof message?.from === 'string'
145
+ ? message.from.trim()
146
+ : typeof message?.envelope?.from === 'string' ? message.envelope.from.trim() : '';
147
+ if (response && messageFrom === targetAid && response.id === requestId) {
148
+ if (Number.isSafeInteger(maxSeq) && maxSeq > afterSeq) {
149
+ try {
150
+ await callClient(conn, 'message.ack', { seq: maxSeq });
151
+ }
152
+ catch (error) {
153
+ throw new MenuClientError('ACK_FAILED', error?.message ?? String(error));
154
+ }
155
+ }
156
+ return response;
157
+ }
158
+ }
159
+ if (Number.isSafeInteger(maxSeq) && maxSeq > afterSeq) {
160
+ afterSeq = maxSeq;
161
+ try {
162
+ await callClient(conn, 'message.ack', { seq: afterSeq });
163
+ }
164
+ catch (error) {
165
+ throw new MenuClientError('ACK_FAILED', error?.message ?? String(error));
166
+ }
167
+ }
168
+ const waitMs = Math.min(POLL_INTERVAL_MS, Math.max(1, deadline - Date.now()));
169
+ await new Promise(resolve => setTimeout(resolve, waitMs));
170
+ }
171
+ throw new MenuClientError('TIMEOUT', `Timed out waiting for Menu response ${requestId}`);
172
+ }
173
+ async function drainBaseline(conn) {
174
+ let afterSeq = 0;
175
+ for (let page = 0; page < 200; page += 1) {
176
+ const result = await callClient(conn, 'message.pull', { after_seq: afterSeq, limit: PULL_LIMIT });
177
+ const messages = Array.isArray(result?.messages) ? result.messages : [];
178
+ let maxSeq = Math.max(afterSeq, Number(result?.server_ack_seq) || 0);
179
+ for (const message of messages) {
180
+ const seq = Number(message?.seq);
181
+ if (Number.isSafeInteger(seq) && seq > maxSeq)
182
+ maxSeq = seq;
183
+ }
184
+ if (maxSeq > afterSeq) {
185
+ try {
186
+ await callClient(conn, 'message.ack', { seq: maxSeq });
187
+ }
188
+ catch (error) {
189
+ throw new MenuClientError('ACK_FAILED', error?.message ?? String(error));
190
+ }
191
+ afterSeq = maxSeq;
192
+ }
193
+ if (messages.length < PULL_LIMIT)
194
+ return afterSeq;
195
+ }
196
+ throw new MenuClientError('BASELINE_TOO_LARGE', 'menu-client backlog exceeds the bounded baseline drain');
197
+ }
198
+ async function sendOnce(options, request, timeoutMs) {
199
+ const slotId = options.slotId ?? SLOT.menuClient;
200
+ const encrypt = options.encrypt ?? true;
201
+ if (!encrypt) {
202
+ throw new MenuClientError('INVALID_CLIENT', 'ec-menu-client requires E2EE protected headers; remove --no-encrypt');
203
+ }
204
+ const headers = buildMenuClientProtectedHeaders({
205
+ clientName: 'ec-menu-client',
206
+ clientVersion: readInstalledEvolcoreVersion(),
207
+ remoteAccessToken: options.remoteAccessToken ?? options.ecwebToken,
208
+ });
209
+ const conn = await createShortConnection(options.fromAid, {
210
+ aunPath: options.aunPath,
211
+ slotId,
212
+ shortTtlMs: timeoutMs * (request.type === 'menu.query' || request.type === 'menu.options' ? 2 : 1) + 10_000,
213
+ });
214
+ try {
215
+ let baselineSeq = await drainBaseline(conn);
216
+ const attempts = request.type === 'menu.query' || request.type === 'menu.options' ? 2 : 1;
217
+ let lastError;
218
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
219
+ const sent = await callClient(conn, 'message.send', {
220
+ to: options.targetAid,
221
+ payload: request,
222
+ encrypt,
223
+ ...(encrypt ? { protected_headers: headers } : {}),
224
+ });
225
+ const messageId = sent?.message_id ?? sent?.message?.message_id;
226
+ if (typeof messageId !== 'string' || !messageId.trim()) {
227
+ throw new MenuClientError('SEND_FAILED', 'message.send returned no message_id');
228
+ }
229
+ try {
230
+ return await pollResponse(conn, options.targetAid, request.id, timeoutMs, baselineSeq);
231
+ }
232
+ catch (error) {
233
+ lastError = error;
234
+ if (attempt + 1 < attempts && error instanceof MenuClientError && error.code === 'TIMEOUT') {
235
+ baselineSeq = await drainBaseline(conn);
236
+ continue;
237
+ }
238
+ throw error;
239
+ }
240
+ }
241
+ throw lastError ?? new MenuClientError('INTERNAL_ERROR', 'Menu request failed');
242
+ }
243
+ finally {
244
+ await conn.close();
245
+ }
246
+ }
247
+ export async function executeMenuClientRequest(options) {
248
+ if (!isValidAid(options.fromAid) || !isValidAid(options.targetAid)) {
249
+ throw new MenuClientError('INVALID_ARGUMENT', 'fromAid and targetAid must be valid AIDs');
250
+ }
251
+ if (options.slotId !== undefined && (typeof options.slotId !== 'string' || !options.slotId.trim())) {
252
+ throw new MenuClientError('INVALID_ARGUMENT', 'slotId must be a non-empty string');
253
+ }
254
+ const slotId = options.slotId ?? SLOT.menuClient;
255
+ if (slotId.length > 128 || !/^[A-Za-z0-9._-][A-Za-z0-9._/ :\-]*$/.test(slotId)) {
256
+ throw new MenuClientError('INVALID_ARGUMENT', 'slotId contains unsupported characters');
257
+ }
258
+ if (options.aunPath !== undefined && (typeof options.aunPath !== 'string' || !options.aunPath.trim())) {
259
+ throw new MenuClientError('INVALID_ARGUMENT', 'aunPath must be a non-empty path');
260
+ }
261
+ const request = normalizeRequest(options.request);
262
+ const timeoutMs = normalizeTimeout(options.timeoutMs);
263
+ const release = options.slotId && options.slotId !== SLOT.menuClient
264
+ ? () => { }
265
+ : acquireClientLock(options.aunPath, options.fromAid);
266
+ try {
267
+ return await sendOnce(options, request, timeoutMs);
268
+ }
269
+ finally {
270
+ release();
271
+ }
272
+ }