evolclaw 3.1.11 → 3.2.0

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.2.0 (2026-06-05)
4
+
5
+ ### New Features
6
+
7
+ - **控制 AID(control AID)** — 进程级控制身份,启动时以 `pureIdentity` 模式连接 AUN(跳过 evolagent onboarding)。生成采用 `ec+5位数字` 候选 + PKI 权威查重 + fail-fast;缺失时进入 init(TTY 守卫,headless 仅告警)。`evolclaw status` 新增控制 AID 连接状态展示
8
+ - **Menu 协议 / agent 控制面** — `/system`、`/agent` 迁移到 owners 鉴权;trigger 接入菜单协议(直连 manager/scheduler,无文本拼装);agent 创建支持 accepted-return + 构建进度(`create-status.json` + `onPhase` 回调 + model/chatmode);新增 agent query/options 与 project 兜底
9
+ - **进程级 owners 配置层** — 新增 `evolclaw.json` 进程级配置,`config.json` 合并入 `evolclaw.json`(弃用 `ProcessConfig`);process-level owners 从 `defaults.json` 迁移到 `evolclaw.json`;新增 `isProcessLevelOwner` 鉴权辅助
10
+ - **Observer 模式重构** — AUN owner 间消息互转发;`evolclaw init aun` 简化为 owner-only 配置;`mergeForAgent` 输出补全 `dispatch`/`observable`
11
+
12
+ ### Improvements
13
+
14
+ - **统一 FileCache** — 新增 mtime-gated 统一文件缓存,迁移 relation prefs、manifest+fragment、persona/working 读取与 model-scope 缓存;新增 Cache watch 视图监控缓存命中
15
+ - **消息信封渲染补全** — @AID 列表与群名补全,修复 proximity 信息丢失
16
+ - **Idle 监控解耦** — idle notify/warn 改为事件总线发布(`runner:idle-notify`/`runner:idle-warn`,携带 idleSec/事件数/工具名),与通道发送解耦;超时诊断信息下沉到事件 payload
17
+
18
+ ### Bug Fixes
19
+
20
+ - **多 agent 群广播去重** — 消息队列改按 `sessionKey:messageId` 去重,允许同一消息广播给多个 agent
21
+ - **AUN owner 入站转发** — owner 来源的入站消息正确转发给其它 owners
22
+ - **单 agent reload 缓存失效** — reload 时失效 identity 层缓存
23
+ - **CLI /slist 过滤** — 过滤掉程序化 SDK session,不在 `/slist` 显示
24
+ - **控制 AID 查重走 GET** — 改用 `store.resolve`(GET)替代 `store.exists`(HEAD),规避部分 Gateway 对 HEAD 空响应断连导致的误判
25
+ - **启动门控修复** — gate 直接调 `initTail` 而非完整 `cmdInit`;AID 生成失败时跳过 owners 提示;抑制控制 AID gate 路径的 SDK keystore 日志
26
+
3
27
  ## v3.1.11 (2026-06-04)
4
28
 
5
29
  ### Improvements
package/README.md CHANGED
@@ -267,6 +267,32 @@ evolclaw/
267
267
  - `/restart` - 重启服务(自愈机制)
268
268
  - `/repair` - 检查并修复会话
269
269
 
270
+ ### ⚠️ 进程级 menu 操作鉴权(v3.2 Breaking)
271
+
272
+ 进程级 menu 操作(`/system restart/upgrade`、`/agent` agent 生命周期管理)的鉴权已迁移到
273
+ `evolclaw.json` 顶层 `owners` 字段(v3.2 起,不再读 `agents/defaults.json`)。
274
+ 升级后**必须**在 `evolclaw.json` 配置 `owners`,否则这些操作一律返回 `FORBIDDEN`(daemon 启动时也会 warn 提示)。
275
+
276
+ ```json
277
+ {
278
+ "owners": ["eleans-2022.agentid.pub"]
279
+ }
280
+ ```
281
+
282
+ `evolclaw init` 交互流程会在生成控制 AID 后提示录入 owners(可跳过后手动编辑)。
283
+
284
+ - **`owners`**:进程级管理者 AID 名单。可执行 `/system`(重启/升级)与 `/agent`
285
+ (create / delete / enable / disable / list / show)。
286
+ - 关系级的 `/trigger`(set / cancel / update / list)仍走 channel 角色(owner/admin)+ scoped 鉴权,**不**受 `owners` 影响。
287
+ - `/agent create` 为「受理即返回」:立即回 `{ accepted: true, aid }`,后台跑完整创建流程并把各
288
+ 环节写入 `agents/<aid>/create-status.json`;客户端用 `menu.query name=agent args={aid}` 轮询
289
+ `createProgress.status` 直到 `ready` / `failed`。
290
+
291
+ ### 控制 AID(control AID)
292
+
293
+ v3.2 新增进程级身份标识。启动时自动生成 `ec+5位数字.agentid.pub` 格式的控制 AID,以
294
+ `pureIdentity` 模式接入 AUN 网络(跳过 evolagent onboarding)。`evolclaw status` 可查看控制 AID 连接状态。
295
+
270
296
  ## 技术栈
271
297
 
272
298
  - **运行时**:Node.js >= 22 + TypeScript(ES modules)
@@ -61,7 +61,11 @@ export function renderKitSections(ctx) {
61
61
  }
62
62
  const files = loadSectionFiles(section, ctx.vars, sessionCache);
63
63
  diag.fileCount = files.length;
64
+ // 路径解析成功但读出 0 文件 → 文件/目录不存在(存在性不再单独 syscall,
65
+ // 由内容读取顺带得到;详见 manifest-engine.resolvePathWithDiag)。
64
66
  if (files.length === 0) {
67
+ if (diag.resolveStatus === 'ok')
68
+ diag.resolveStatus = 'not-exist';
65
69
  diagnostics.push(diag);
66
70
  continue;
67
71
  }
@@ -125,7 +129,7 @@ const PARAM_DESCRIPTIONS = {
125
129
  peerName: '对端显示名',
126
130
  peerRole: '对端角色(owner/admin/guest/anonymous)',
127
131
  peerType: '对端类型(human/agent)',
128
- sameDevice: '对端与本端同一物理设备(E2EE 消息 proximity,仅加密消息有值)',
132
+ sameDevice: '对端与本端同一物理设备(SDK 0.4.9 起明文/密文消息均可携带,具体字段以网关下发为准)',
129
133
  sameNetwork: '对端与本端在同一网络内',
130
134
  sameEgressIp: '对端与本端共享同一出口 IP',
131
135
  groupId: '群组 ID(群聊时)',
@@ -5,24 +5,25 @@ import fs from 'fs';
5
5
  import path from 'path';
6
6
  import { kitsDir, resolveRoot, getPackageRoot } from '../paths.js';
7
7
  import { logger } from '../utils/logger.js';
8
- // ── Manifest loading / cache (keyed by filename) ──
9
- const _manifestCache = new Map();
10
- /** 清空所有 manifest 缓存(manifest 结构变更后调用)。 */
8
+ import { fileCache } from '../core/cache/file-cache.js';
9
+ // ── Manifest loading / cache ──
10
+ // manifest 定义随包发布、运行期靠 reload/重启刷新 → on-reload(group 'kits')。
11
+ // base + eck override 合成结果以 base 文件路径为键缓存;loader 内读两个文件。
12
+ /** 清空所有 manifest 缓存(manifest 结构变更后调用,由 invalidateKitCache 串联)。 */
11
13
  export function invalidateManifestCache() {
12
- _manifestCache.clear();
14
+ fileCache.invalidateGroup('kits');
13
15
  }
14
16
  /**
15
17
  * 加载并合并 manifest。基础文件在 $KITS/<filename>,
16
18
  * 覆盖文件在 $EVOLCLAW_HOME/eck/<filename>(可选)。结果按 order 升序缓存。
17
19
  */
18
20
  export function loadManifest(filename) {
19
- const cached = _manifestCache.get(filename);
20
- if (cached)
21
- return cached;
22
- const sections = loadAndMergeManifest(filename);
23
- _manifestCache.set(filename, sections);
24
- logger.info(`[ManifestEngine] Loaded ${filename}: ${sections.length} sections`);
25
- return sections;
21
+ const kitsPath = path.join(kitsDir(), filename);
22
+ return fileCache.get(kitsPath, () => {
23
+ const sections = loadAndMergeManifest(filename);
24
+ logger.info(`[ManifestEngine] Loaded ${filename}: ${sections.length} sections`);
25
+ return sections;
26
+ }, { policy: 'on-reload', group: 'kits' });
26
27
  }
27
28
  function loadAndMergeManifest(filename) {
28
29
  const kitsPath = path.join(kitsDir(), filename);
@@ -86,9 +87,72 @@ export function evaluateWhen(when, vars) {
86
87
  return true;
87
88
  }
88
89
  export function isTruthy(val) {
90
+ if (Array.isArray(val))
91
+ return val.length > 0; // 空数组视为假,使 {{?arr}} / {{#each}} 落空
89
92
  return val !== undefined && val !== null && val !== false && val !== '' && val !== 0;
90
93
  }
91
94
  // ── Template rendering ──
95
+ /**
96
+ * 展开 {{#each KEY}}BODY{{/each}} 循环块(在条件/变量替换之前跑)。
97
+ * - vars[KEY] 为非空数组才展开;每个元素构造子作用域:
98
+ * 对象元素 → { ...vars, ...el }(字段可用 {{field}} 访问)
99
+ * 标量元素 → { ...vars, '.': el }({{.}} 访问当前元素)
100
+ * 另注入 {{@index}}(0 基序号)。
101
+ * - body 经完整 renderTemplate 递归渲染,天然支持嵌套 each / 条件。
102
+ * - 非数组或空数组 → 整块渲染为空串。
103
+ * 用深度扫描定位**最外层** each 块(正则无法平衡嵌套),从外向内展开。
104
+ */
105
+ function resolveEach(template, vars, stripBlankLines) {
106
+ const OPEN = /\{\{#each\s+([A-Za-z_]\w*)\}\}/g;
107
+ let result = '';
108
+ let cursor = 0;
109
+ OPEN.lastIndex = 0;
110
+ let m;
111
+ while ((m = OPEN.exec(template)) !== null) {
112
+ const blockStart = m.index;
113
+ const key = m[1];
114
+ const bodyStart = OPEN.lastIndex;
115
+ // 从 bodyStart 起按深度找配对的 {{/each}}
116
+ const TOKEN = /\{\{#each\s+[A-Za-z_]\w*\}\}|\{\{\/each\}\}/g;
117
+ TOKEN.lastIndex = bodyStart;
118
+ let depth = 1;
119
+ let bodyEnd = -1;
120
+ let blockEnd = -1;
121
+ let t;
122
+ while ((t = TOKEN.exec(template)) !== null) {
123
+ if (t[0].startsWith('{{#each'))
124
+ depth++;
125
+ else {
126
+ depth--;
127
+ if (depth === 0) {
128
+ bodyEnd = t.index;
129
+ blockEnd = TOKEN.lastIndex;
130
+ break;
131
+ }
132
+ }
133
+ }
134
+ if (bodyEnd === -1)
135
+ break; // 无配对,剩余原样输出
136
+ // 输出块前的原文
137
+ result += template.slice(cursor, blockStart);
138
+ const body = template.slice(bodyStart, bodyEnd);
139
+ const arr = vars[key];
140
+ if (Array.isArray(arr)) {
141
+ for (let i = 0; i < arr.length; i++) {
142
+ const el = arr[i];
143
+ const scope = (el && typeof el === 'object' && !Array.isArray(el))
144
+ ? { ...vars, ...el, '@index': i }
145
+ : { ...vars, '.': el, '@index': i };
146
+ result += renderTemplate(body, scope, stripBlankLines);
147
+ }
148
+ }
149
+ // 数组以外(含 undefined / 非数组)→ 整块跳过(不输出)
150
+ cursor = blockEnd;
151
+ OPEN.lastIndex = blockEnd;
152
+ }
153
+ result += template.slice(cursor);
154
+ return result;
155
+ }
92
156
  function resolveConditions(template, vars) {
93
157
  // 只匹配**最内层** {{?...}}...{{/}} 块(逐字符负向前瞻排除嵌套),do/while 由内向外消解。
94
158
  const inner = /\{\{\?(\w+)(?:(!=|=)([^}]*))?\}\}((?:(?!\{\{\?)[^])*?)\{\{\/\}\}/;
@@ -111,11 +175,15 @@ function resolveConditions(template, vars) {
111
175
  * 紧凑);false 时保留空行(消息正文用,正文多段结构不能被压扁)。
112
176
  */
113
177
  export function renderTemplate(template, vars, stripBlankLines = true) {
114
- let result = resolveConditions(template, vars);
115
- result = result.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
178
+ let result = resolveEach(template, vars, stripBlankLines);
179
+ result = resolveConditions(result, vars);
180
+ // 变量替换:支持普通名、当前元素 {{.}}、循环序号 {{@index}}。
181
+ result = result.replace(/\{\{(\.|@index|\w+)\}\}/g, (_match, key) => {
116
182
  const val = vars[key];
117
- if (!isTruthy(val))
118
- return '';
183
+ if (!isTruthy(val) && val !== 0)
184
+ return ''; // 0 是有效序号/值,保留
185
+ if (val === 0)
186
+ return '0';
119
187
  return String(val);
120
188
  });
121
189
  if (stripBlankLines)
@@ -146,11 +214,10 @@ export function resolvePathWithDiag(rawPath, vars) {
146
214
  if (unresolved.length > 0) {
147
215
  return { resolved, status: 'unresolved-vars', unresolvedTokens: unresolved };
148
216
  }
149
- // 路径规范化:模板里 ../ 等相对片段折叠成真实路径
217
+ // 路径规范化:模板里 ../ 等相对片段折叠成真实路径。
218
+ // 不再在此 existsSync——存在性由随后经 fileCache 的内容读取顺带得到(file
219
+ // section 读出 null 即不存在),避免每 section 每消息一次 syscall。
150
220
  resolved = path.normalize(resolved);
151
- if (!fs.existsSync(resolved)) {
152
- return { resolved, status: 'not-exist', unresolvedTokens: unresolved };
153
- }
154
221
  return { resolved, status: 'ok', unresolvedTokens: unresolved };
155
222
  }
156
223
  function resolvePath(rawPath, vars) {
@@ -174,29 +241,35 @@ export function loadSectionFiles(section, vars, sessionCache) {
174
241
  return [];
175
242
  }
176
243
  function loadFileSection(filePath, vars, sessionCache) {
244
+ void sessionCache; // 内容跨 session 共享,改走全局 fileCache(on-reload)
177
245
  const resolved = resolvePath(filePath, vars);
178
246
  if (!resolved)
179
247
  return null;
180
- if (sessionCache.has(resolved))
181
- return [resolved, sessionCache.get(resolved)];
182
- try {
183
- const content = fs.readFileSync(resolved, 'utf-8');
184
- sessionCache.set(resolved, content);
185
- return [resolved, content];
186
- }
187
- catch {
188
- return null;
189
- }
248
+ // 内容跨 session 共享:用全局 fileCache(on-reload,reload/重启时失效),
249
+ // 不再按 session 重复缓存同一文件内容。
250
+ const content = fileCache.getText(resolved, { policy: 'on-reload', group: 'kits' });
251
+ return content === null ? null : [resolved, content];
190
252
  }
191
253
  function readDirectoryFiles(dirPath, pattern) {
192
254
  const glob = pattern || '*.md';
193
- try {
194
- const files = fs.readdirSync(dirPath).filter(f => matchGlob(f, glob)).sort();
195
- return files.map(f => [f, fs.readFileSync(path.join(dirPath, f), 'utf-8')]);
196
- }
197
- catch {
198
- return [];
255
+ // 目录列表 + 各文件内容均走 fileCache(on-reload)。目录列表以 "<dir>|<glob>"
256
+ // 为键缓存文件名数组;各文件内容走 fileCache.getText 共享。
257
+ const names = fileCache.get(`${dirPath} ${glob}`, () => {
258
+ try {
259
+ return fs.readdirSync(dirPath).filter(f => matchGlob(f, glob)).sort();
260
+ }
261
+ catch {
262
+ return [];
263
+ }
264
+ }, { policy: 'on-reload', group: 'kits' });
265
+ const out = [];
266
+ for (const f of names) {
267
+ const fp = path.join(dirPath, f);
268
+ const content = fileCache.getText(fp, { policy: 'on-reload', group: 'kits' });
269
+ if (content !== null)
270
+ out.push([f, content]);
199
271
  }
272
+ return out;
200
273
  }
201
274
  function matchGlob(filename, pattern) {
202
275
  const regex = pattern
@@ -44,6 +44,8 @@ function renderOneItem(item, sessionVars, sessionCache, contentSentinel) {
44
44
  sameDevice: item.sameDevice ?? sessionVars.sameDevice,
45
45
  sameNetwork: item.sameNetwork ?? sessionVars.sameNetwork,
46
46
  sameEgressIp: item.sameEgressIp ?? sessionVars.sameEgressIp,
47
+ // 模板引擎不支持数组循环:被 @ 的 AID 预先 join 成串,空则 undefined 使 {{?mentionAids}} 落空。
48
+ mentionAids: (item.mentionAids && item.mentionAids.length > 0) ? item.mentionAids.join(',') : undefined,
47
49
  now: formatLocalTime(item.timestamp ?? Date.now(), sessionVars.timezone ? String(sessionVars.timezone) : undefined),
48
50
  // content held as a per-call random sentinel, swapped back post-render.
49
51
  // Using a UUID means no real message can collide with it.
@@ -0,0 +1,67 @@
1
+ import crypto from 'crypto';
2
+ import { aidCreate } from './index.js';
3
+ import { getAidStore, SLOT } from './store.js';
4
+ import { logger } from '../../utils/logger.js';
5
+ const MAX_ATTEMPTS = 5;
6
+ /** 生成候选控制 AID:ec + 5位随机数字 + .agentid.pub */
7
+ export function candidateAid() {
8
+ const n = crypto.randomInt(10000, 100000); // 5 位:10000-99999
9
+ return `ec${n}.agentid.pub`;
10
+ }
11
+ /**
12
+ * 候选 AID 是否已在 PKI 注册。
13
+ *
14
+ * 不用 store.exists(它走 HTTP HEAD /pki/cert/<aid>)——部分 Gateway 实现
15
+ * (Python websockets HTTP 处理)对 HEAD 直接空响应断连(curl 52 / socket hang up),
16
+ * 导致 exists 误报"网关不可达"。改用 store.resolve(走 GET),语义等价且 GET 正常返回:
17
+ * - resolve ok → 证书存在(HTTP 200)→ 已注册
18
+ * - CERT_NOT_FOUND → 404 → 未注册
19
+ * - 其它 error → 真·网络错误,向上抛出供 fail-fast
20
+ * skipAgentMd:true 避免多拉一次 agent.md(控制 AID 本就不传 agent.md)。
21
+ */
22
+ async function candidateExists(store, candidate) {
23
+ const r = await store.resolve(candidate, { skipAgentMd: true });
24
+ if (r.ok)
25
+ return true;
26
+ if (r.error?.code === 'CERT_NOT_FOUND')
27
+ return false;
28
+ throw new Error(`Gateway 不可达,无法查重控制 AID:${r.error?.message ?? 'unknown'}`);
29
+ }
30
+ /**
31
+ * 生成控制 AID:循环候选 → candidateExists 查重(权威 PKI 判据)→ 不冲突则 aidCreate。
32
+ * - 查重走 GET 证书(见 candidateExists;不拉 agent.md,控制 AID 本就不传 agent.md)
33
+ * - fail-fast:查重探测失败(网关不可达)立即抛错,不掩盖成"均冲突"
34
+ * - agent.md 不上传:aidCreate 仅注册身份 + 写私钥,不调 agentmdPut
35
+ */
36
+ export async function generateControlAid() {
37
+ const store = await getAidStore({ slotId: SLOT.cli });
38
+ try {
39
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
40
+ const candidate = candidateAid();
41
+ if (await candidateExists(store, candidate)) {
42
+ logger.info(`[control-aid] ${candidate} 已注册,重试 (${i + 1}/${MAX_ATTEMPTS})`);
43
+ continue;
44
+ }
45
+ const created = await aidCreate(candidate);
46
+ // 清理 aidCreate 内部另开的 client/store——关闭失败不可丢弃已注册的 AID(否则下次 init
47
+ // 会把它当冲突,白白消耗一次重试)。close 异常降级为 warn。
48
+ try {
49
+ await created.client?.close?.();
50
+ }
51
+ catch (e) {
52
+ logger.warn(`[control-aid] client.close() 失败(非致命): ${e}`);
53
+ }
54
+ try {
55
+ await created.store?.close?.();
56
+ }
57
+ catch (e) {
58
+ logger.warn(`[control-aid] store.close() 失败(非致命): ${e}`);
59
+ }
60
+ return { aid: created.aid, gateway: created.gateway };
61
+ }
62
+ throw new Error(`无法生成控制 AID:连续 ${MAX_ATTEMPTS} 次候选均冲突`);
63
+ }
64
+ finally {
65
+ store.close();
66
+ }
67
+ }
@@ -443,6 +443,7 @@ export async function probePkiRecoverability(aid, opts) {
443
443
  }
444
444
  // ==================== Lookup ====================
445
445
  export async function aidLookup(aid) {
446
+ // gateway:well-known 探测(保留,供 aid lookup 命令展示)
446
447
  let gateway = '';
447
448
  try {
448
449
  const gwResp = await fetch(`https://${aid}/.well-known/aun-gateway`, { redirect: 'follow' });
@@ -464,16 +465,28 @@ export async function aidLookup(aid) {
464
465
  }
465
466
  }
466
467
  catch { /* ignore */ }
468
+ const { agentmdGet } = await import('./agentmd.js');
469
+ const store = await getAidStore({ slotId: SLOT.cli });
467
470
  try {
468
- const resp = await fetch(`https://${aid}/agent.md`, { redirect: 'follow' });
469
- if (resp.ok) {
470
- const content = await resp.text();
471
- return { exists: true, aid, gateway, content };
471
+ // 权威注册判据:PKI 证书 HEAD(与 agent.md 无关)
472
+ const existsResult = await store.exists(aid);
473
+ if (!existsResult.ok) {
474
+ return { exists: false, aid, gateway, error: existsResult.error?.message ?? 'exists check failed' };
472
475
  }
473
- return { exists: false, aid, gateway, error: `agent_md_not_found` };
476
+ const exists = existsResult.data.exists;
477
+ if (!exists) {
478
+ return { exists: false, aid, gateway };
479
+ }
480
+ // 已注册:尽力拉 agent.md content(无 agent.md 不影响 exists)
481
+ let content;
482
+ try {
483
+ content = await agentmdGet(aid, { store });
484
+ }
485
+ catch { /* registered but no agent.md — content stays undefined */ }
486
+ return { exists, aid, gateway, content };
474
487
  }
475
- catch (e) {
476
- return { exists: false, aid, gateway, error: String(e.message || e) };
488
+ finally {
489
+ store.close();
477
490
  }
478
491
  }
479
492
  function lifecycleLogPath(aid) {
@@ -39,10 +39,10 @@ export class AidLoadError extends Error {
39
39
  */
40
40
  export async function getAidStore(opts) {
41
41
  const { aunPath: defaultAunPath } = await import('../../paths.js');
42
- const { loadProcessConfig } = await import('../../config-store.js');
42
+ const { loadEvolclawConfig } = await import('../../evolclaw-config.js');
43
43
  const { AIDStore } = await import('@agentunion/fastaun');
44
44
  const aunPath = opts.aunPath ?? defaultAunPath();
45
- const encryptionSeed = loadProcessConfig().aun?.encryptionSeed
45
+ const encryptionSeed = loadEvolclawConfig().aun?.encryptionSeed
46
46
  ?? process.env.AUN_ENCRYPTION_SEED
47
47
  ?? 'evol';
48
48
  const caCertPath = path.join(aunPath, 'CA', 'root', 'root.crt');