evolcore 0.0.34 → 0.0.35

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 (69) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/install-codex-managed-hooks.mjs +26 -3
  3. package/dist/agents/claude-runner.js +11 -1
  4. package/dist/agents/codex/diagnostics.js +42 -11
  5. package/dist/agents/codex-runner.js +27 -3
  6. package/dist/agents/ecagent-runner.js +17 -7
  7. package/dist/aun/aid/agentmd.js +13 -2
  8. package/dist/aun/aid/client.js +1 -1
  9. package/dist/aun/msg/managed-operation.js +16 -0
  10. package/dist/aun/msg/p2p.js +18 -2
  11. package/dist/channels/aun.js +113 -7
  12. package/dist/cli/aun-commands.js +37 -13
  13. package/dist/cli/daemon-commands.js +10 -4
  14. package/dist/cli/fs-command.js +1 -1
  15. package/dist/cli/getmeta-command.js +211 -0
  16. package/dist/cli/index.js +6 -0
  17. package/dist/cli/managed-command-guard.js +3 -0
  18. package/dist/cli/memory-command.js +46 -15
  19. package/dist/cli/thread-command.js +12 -4
  20. package/dist/config/resolved-config-op.js +25 -8
  21. package/dist/core/auth/operation-authorizer.js +21 -2
  22. package/dist/core/channel-runtime.js +1 -1
  23. package/dist/core/command/command-handler.js +7 -1
  24. package/dist/core/command/menu-handler.js +2 -0
  25. package/dist/core/command/topic-slash.js +4 -7
  26. package/dist/core/event-catalog.js +14 -0
  27. package/dist/core/ipc-registration.js +11 -1
  28. package/dist/core/memory/batch-meta.js +1 -0
  29. package/dist/core/memory/consolidation-context.js +97 -14
  30. package/dist/core/memory/consolidation-trigger.js +15 -7
  31. package/dist/core/memory/consolidation.js +55 -0
  32. package/dist/core/memory/memory-writer.js +20 -4
  33. package/dist/core/message/aun-ipc-sender.js +19 -1
  34. package/dist/core/message/message-bridge.js +3 -3
  35. package/dist/core/message/message-log.js +1 -0
  36. package/dist/core/message/peer-mode.js +17 -0
  37. package/dist/core/message/response-engine.js +15 -4
  38. package/dist/core/permission/ec-command-parser.js +76 -40
  39. package/dist/core/permission/tool-policy.js +32 -2
  40. package/dist/core/relation/peer-identity.js +87 -12
  41. package/dist/core/session/session-manager.js +56 -1
  42. package/dist/core/session/session-title.js +26 -1
  43. package/dist/core/system-session/memory.js +87 -20
  44. package/dist/core/thread/command-parser.js +6 -1
  45. package/dist/core/thread/ipc-operation.js +33 -16
  46. package/dist/core/thread/thread-operation.js +62 -24
  47. package/dist/core/thread/thread-service.js +246 -32
  48. package/dist/utils/cross-platform.js +6 -3
  49. package/kits/docs/evolcore/fs-architecture.md +5 -5
  50. package/kits/docs/evolcore/fs.md +1 -1
  51. package/kits/docs/evolcore/group-collaboration.md +1 -1
  52. package/kits/docs/evolcore/group.md +5 -3
  53. package/kits/docs/evolcore/memory-consolidation.md +1 -2
  54. package/kits/docs/evolcore/memory-tags.md +1 -1
  55. package/kits/docs/evolcore/memory.md +2 -2
  56. package/kits/docs/evolcore/stats.md +1 -1
  57. package/kits/docs/evolcore/thread-chain.md +25 -9
  58. package/kits/eck_manifest.auxiliary.json +9 -0
  59. package/kits/eck_manifest.json +20 -0
  60. package/kits/eck_manifest.memory.json +20 -0
  61. package/kits/rules/01-overview.md +3 -3
  62. package/kits/rules/07-memory-and-thread.md +1 -1
  63. package/kits/skills/ec-group/SKILL.md +6 -3
  64. package/kits/skills/thread-ops/SKILL.md +18 -18
  65. package/kits/templates/memory-consolidation/notes.md +1 -1
  66. package/kits/templates/memory-consolidation/workflow.md +36 -1
  67. package/kits/templates/system-fragments/bash-description.md +9 -0
  68. package/kits/templates/system-fragments/memory.md +2 -2
  69. package/package.json +1 -1
@@ -619,6 +619,10 @@ function isLiteralArgBoundary(value) {
619
619
  * 判为 `shell-composition`,拦截力度不变,只让错误归因准确。
620
620
  */
621
621
  function isPosixQuotedTokenBoundary(value) {
622
+ // 相邻引号段直接拼接(`'a''b'`、`$'a''b'`、`'a'"b"`)与 bash 一致:
623
+ // 闭合引号后紧跟另一个开引号不是错误,两个段拼进同一个 token。
624
+ if (value === "'" || value === '"')
625
+ return true;
622
626
  return value === undefined || isShellSpacing(value) || SHELL_UNQUOTED_META.has(value);
623
627
  }
624
628
  function isShellParameterExpansionStart(value) {
@@ -1657,6 +1661,54 @@ export function parseLiteralShellCommand(command, options = {}) {
1657
1661
  token += next;
1658
1662
  continue;
1659
1663
  }
1664
+ // ANSI-C quoting: $'...' allows \n \t \r \\ \' escapes, same as bash $'...'.
1665
+ // Must be checked before SHELL_UNQUOTED_META because $ is in that set.
1666
+ if (char === '$' && input[index + 1] === "'") {
1667
+ tokenStarted = true;
1668
+ index += 2; // skip $ and opening '
1669
+ let ansiClosed = false;
1670
+ while (index < input.length) {
1671
+ const c = input[index];
1672
+ if (c === "'") {
1673
+ ansiClosed = true;
1674
+ break; // leave index at closing '; for-loop increment moves past it
1675
+ }
1676
+ if (c === '\\') {
1677
+ const esc = input[++index];
1678
+ if (esc === undefined)
1679
+ break;
1680
+ switch (esc) {
1681
+ case 'n':
1682
+ token += '\n';
1683
+ break;
1684
+ case 't':
1685
+ token += '\t';
1686
+ break;
1687
+ case 'r':
1688
+ token += '\r';
1689
+ break;
1690
+ case '\\':
1691
+ token += '\\';
1692
+ break;
1693
+ case "'":
1694
+ token += "'";
1695
+ break;
1696
+ default:
1697
+ token += `\\${esc}`;
1698
+ break;
1699
+ }
1700
+ index++;
1701
+ continue;
1702
+ }
1703
+ token += c;
1704
+ index++;
1705
+ }
1706
+ if (!ansiClosed)
1707
+ return parseFailure('invalid-quote', input, index, argv.length, 'posix');
1708
+ if (!isPosixQuotedTokenBoundary(input[index + 1]))
1709
+ return parseFailure('invalid-quote', input, index, argv.length, 'posix');
1710
+ continue;
1711
+ }
1660
1712
  // `#` starts a shell comment only at the beginning of a word. Channel
1661
1713
  // peer keys such as `aun#alice` are ordinary literal arguments.
1662
1714
  if ((SHELL_UNQUOTED_META.has(char) && (char !== '#' || !tokenStarted))
@@ -1712,6 +1764,11 @@ export function parseCodexShellCommandArgv(command, options = {}) {
1712
1764
  });
1713
1765
  return parsed.ok ? canonicalizeEvolcoreCommandArgv(parsed.argv) : null;
1714
1766
  }
1767
+ const stringCarrier = resolveCodexShellCarrierString(command);
1768
+ if (stringCarrier) {
1769
+ const parsed = parseCodexCarrierCommand(stringCarrier, options);
1770
+ return parsed.ok ? normalizeEvolcoreCommandArgv(parsed.argv) : null;
1771
+ }
1715
1772
  const outer = parseLiteralShellArgv(command, options);
1716
1773
  if (outer) {
1717
1774
  const carrier = resolveCodexShellCarrierArgv(outer);
@@ -1719,20 +1776,11 @@ export function parseCodexShellCommandArgv(command, options = {}) {
1719
1776
  const parsed = parseCodexCarrierCommand(carrier, options);
1720
1777
  return parsed.ok ? parsed.argv : null;
1721
1778
  }
1722
- const stringCarrier = resolveCodexShellCarrierString(command);
1723
- if (stringCarrier) {
1724
- const parsed = parseCodexCarrierCommand(stringCarrier, options);
1725
- return parsed.ok ? parsed.argv : null;
1726
- }
1727
1779
  if (parseCodexCmdCarrierStringFailure(command, options))
1728
1780
  return null;
1729
1781
  return normalizeEvolcoreCommandArgv(outer);
1730
1782
  }
1731
- const stringCarrier = resolveCodexShellCarrierString(command);
1732
- if (!stringCarrier)
1733
- return null;
1734
- const parsed = parseCodexCarrierCommand(stringCarrier, options);
1735
- return parsed.ok ? normalizeEvolcoreCommandArgv(parsed.argv) : null;
1783
+ return null;
1736
1784
  }
1737
1785
  /** Parse a Codex tool input while preserving safe syntax failure metadata. */
1738
1786
  export function parseCodexToolCommand(input, options = {}) {
@@ -1776,22 +1824,20 @@ export function parseCodexToolCommand(input, options = {}) {
1776
1824
  ? { ...parsed, argv: normalizeEvolcoreCommandArgv(parsed.argv) }
1777
1825
  : parsed;
1778
1826
  }
1827
+ const stringCarrier = resolveCodexShellCarrierString(command);
1828
+ if (stringCarrier) {
1829
+ return parseCodexCarrierCommand(stringCarrier, { allowManagedTmpDir: true, ...options });
1830
+ }
1779
1831
  const outer = parseLiteralShellCommand(command, { allowManagedTmpDir: true });
1780
1832
  if (outer.ok) {
1781
1833
  const carrier = resolveCodexShellCarrierArgv(outer.argv);
1782
1834
  if (carrier !== undefined) {
1783
1835
  return parseCodexCarrierCommand(carrier, { allowManagedTmpDir: true, ...options });
1784
1836
  }
1785
- const stringCarrier = resolveCodexShellCarrierString(command);
1786
- return stringCarrier
1787
- ? parseCodexCarrierCommand(stringCarrier, { allowManagedTmpDir: true, ...options })
1788
- : parseCodexCmdCarrierStringFailure(command, { allowManagedTmpDir: true })
1789
- ?? { ...outer, argv: normalizeEvolcoreCommandArgv(outer.argv) };
1837
+ return parseCodexCmdCarrierStringFailure(command, { allowManagedTmpDir: true })
1838
+ ?? { ...outer, argv: normalizeEvolcoreCommandArgv(outer.argv) };
1790
1839
  }
1791
- const stringCarrier = resolveCodexShellCarrierString(command);
1792
- return stringCarrier
1793
- ? parseCodexCarrierCommand(stringCarrier, { allowManagedTmpDir: true, ...options })
1794
- : parseCodexCmdCarrierStringFailure(command, { allowManagedTmpDir: true }) ?? outer;
1840
+ return parseCodexCmdCarrierStringFailure(command, { allowManagedTmpDir: true }) ?? outer;
1795
1841
  }
1796
1842
  function isApplyPatchExecutable(value) {
1797
1843
  const normalized = value.replaceAll('\\', '/').replace(/\/+$/u, '');
@@ -2747,6 +2793,17 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
2747
2793
  });
2748
2794
  return { kind: 'composite', issue: parsed.ok ? 'shell-composition' : parsed.issue };
2749
2795
  }
2796
+ const stringCarrier = resolveCodexShellCarrierString(stripped);
2797
+ if (stringCarrier) {
2798
+ const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
2799
+ if (nested.ok && nested.argv[0] === 'ec')
2800
+ return { kind: 'literal' };
2801
+ if (!nested.ok && (hasCarrierEcCommandPrefix(stringCarrier.command, stringCarrier.dialect)
2802
+ || (stringCarrier.dialect === 'powershell' && hasPowerShellCredentialAssignment(stringCarrier.command)))) {
2803
+ return { kind: 'composite', issue: nested.issue };
2804
+ }
2805
+ return { kind: 'none' };
2806
+ }
2750
2807
  const literal = parseLiteralShellCommand(stripped, parseOptions);
2751
2808
  if (literal.ok) {
2752
2809
  if (normalizeEvolcoreCommandArgv(literal.argv)[0] === 'ec')
@@ -2761,16 +2818,6 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
2761
2818
  return { kind: 'composite', issue: nested.issue };
2762
2819
  }
2763
2820
  }
2764
- const stringCarrier = resolveCodexShellCarrierString(stripped);
2765
- if (stringCarrier) {
2766
- const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
2767
- if (nested.ok && nested.argv[0] === 'ec')
2768
- return { kind: 'literal' };
2769
- if (!nested.ok && (hasCarrierEcCommandPrefix(stringCarrier.command, stringCarrier.dialect)
2770
- || (stringCarrier.dialect === 'powershell' && hasPowerShellCredentialAssignment(stringCarrier.command)))) {
2771
- return { kind: 'composite', issue: nested.issue };
2772
- }
2773
- }
2774
2821
  const malformedCmdBody = extractCodexCmdCarrierBody(stripped);
2775
2822
  if (malformedCmdBody && looksLikeEcCommandBody(malformedCmdBody)) {
2776
2823
  const nested = parseLiteralShellCommand(malformedCmdBody, {
@@ -2783,17 +2830,6 @@ export function classifyEvolcoreShellCommand(command, options = {}) {
2783
2830
  }
2784
2831
  return { kind: 'none' };
2785
2832
  }
2786
- const stringCarrier = resolveCodexShellCarrierString(stripped);
2787
- if (stringCarrier) {
2788
- const nested = parseCodexCarrierCommand(stringCarrier, parseOptions);
2789
- if (nested.ok && nested.argv[0] === 'ec')
2790
- return { kind: 'literal' };
2791
- if (!nested.ok && (hasCarrierEcCommandPrefix(stringCarrier.command, stringCarrier.dialect)
2792
- || (stringCarrier.dialect === 'powershell' && hasPowerShellCredentialAssignment(stringCarrier.command)))) {
2793
- return { kind: 'composite', issue: nested.issue };
2794
- }
2795
- return { kind: 'none' };
2796
- }
2797
2833
  const malformedCmdBody = extractCodexCmdCarrierBody(stripped);
2798
2834
  if (malformedCmdBody && looksLikeEcCommandBody(malformedCmdBody)) {
2799
2835
  const nested = parseLiteralShellCommand(malformedCmdBody, {
@@ -2314,6 +2314,36 @@ function prepareBoundedOutputInput(input, command, kind, context) {
2314
2314
  reason: kind === 'ec' ? 'ec-command' : 'host-process-command',
2315
2315
  };
2316
2316
  }
2317
+ const BASH_RISK_LEVELS = new Set(['低', '中', '高']);
2318
+ /**
2319
+ * Bash 调用的 description 必须是「<命令目标描述> [<风险等级>]」。
2320
+ * 只适用于 Bash 工具 schema 带 description 字段的基座(Claude Code、ecagent),
2321
+ * 是调用格式约定而非权限判定,不区分会话类型与 permissionMode。
2322
+ */
2323
+ export function checkBashDescription(toolName, input) {
2324
+ if (toolName !== 'Bash')
2325
+ return undefined;
2326
+ const FORMAT_HINT = 'description 格式为「<命令目标描述> [<风险等级>]」,风险等级只能是 [低]、[中]、[高] 之一。示例:"查看工作区未提交的改动 [低]"、"安装项目依赖 [中]"、"推送 main 分支到远端 [高]"。这是格式问题,不是权限问题,补全后重试。';
2327
+ const deny = (problem, policyCode) => ({
2328
+ behavior: 'deny',
2329
+ input,
2330
+ message: `Bash 调用被拒:${problem}。${FORMAT_HINT}`,
2331
+ policyCode,
2332
+ });
2333
+ const description = typeof input.description === 'string' ? input.description.trim() : '';
2334
+ if (!description) {
2335
+ return deny('缺少 description,既没有命令目标描述,也没有风险等级标记', 'bash_description_missing');
2336
+ }
2337
+ const tag = description.match(/\[([^[\]]*)\]$/);
2338
+ if (!tag)
2339
+ return deny('description 末尾缺少风险等级标记', 'bash_risk_level_missing');
2340
+ if (!BASH_RISK_LEVELS.has(tag[1]))
2341
+ return deny(`风险等级标记 [${tag[1]}] 无效`, 'bash_risk_level_invalid');
2342
+ if (!description.slice(0, tag.index).trim()) {
2343
+ return deny('description 缺少命令目标描述,只有风险等级标记', 'bash_description_target_missing');
2344
+ }
2345
+ return undefined;
2346
+ }
2317
2347
  /** Shared fail-closed preflight used before runner-specific mode and approval handling. */
2318
2348
  export function evaluateToolPreflight(toolName, input, context) {
2319
2349
  let applyPatchFileChanges;
@@ -2570,7 +2600,7 @@ export function evaluateToolPreflight(toolName, input, context) {
2570
2600
  ? '🔒 EC 命令包含无法安全解析的 PowerShell 展开;临时路径仅支持已登记的当前会话 $env:TMPDIR 或 ${env:TMPDIR},也可使用完整路径。纯文字请使用单引号,或用 PowerShell 反引号转义 $ 和正文内的双引号。'
2571
2601
  : shellDialect === 'cmd'
2572
2602
  ? '🔒 EC 命令包含未转义的 cmd.exe 展开;请不要使用 %VAR%、!VAR! 或 ^ 转义。cmd.exe 传给 ec 时必须保持原文字面量'
2573
- : '🔒 EC 自由文本参数(正文、--desc、--name、--keywords 等)一律用单引号包住;双引号内的反引号与 $ 会被 shell 展开。正文里不要再出现半角撇号(ASCII 0x27),需要时用 `ʼ`(U+02BC)或 `'`(U+FF07)';
2603
+ : '🔒 EC 自由文本参数(正文、--desc、--name、--keywords 等)一律用单引号包住;双引号内的反引号与 $ 会被 shell 展开。正文含单引号时改用 $\'…\' 语法(支持 \\n \\t \\\\ \\\')。';
2574
2604
  return {
2575
2605
  behavior: 'deny',
2576
2606
  input,
@@ -2583,7 +2613,7 @@ export function evaluateToolPreflight(toolName, input, context) {
2583
2613
  ? '🔒 EC 命令引号未闭合或存在错误嵌套。Note: PowerShell 把 U+2018/U+2019/U+201A/U+201B 也当引号定界符——正文里出现这四个字符同样会报此错。正文一律用半角单引号(ASCII 0x27)包住,正文内部的半角撇号用 `ʼ`(U+02BC)或 `'`(U+FF07)代替,或把消息正文写入当前 $TMPDIR 后使用 --text-from-file'
2584
2614
  : shellDialect === 'cmd'
2585
2615
  ? '🔒 EC 命令引号未闭合或存在错误嵌套;cmd.exe carrier 的命令正文请保持一个完整参数,不要用未配对的双引号'
2586
- : '🔒 EC 命令引号未闭合或存在错误嵌套。自由文本参数一律用单引号包住;正文里不要出现半角撇号(ASCII 0x27)本身,需要时用 `ʼ`(U+02BC)或 `'`(U+FF07)';
2616
+ : '🔒 EC 命令引号未闭合或存在错误嵌套。自由文本参数用单引号包住;正文含单引号时改用 $\'…\' 语法,内部用 \\\' 转义,如 $\'it\\\'s fine\'。';
2587
2617
  return {
2588
2618
  behavior: 'deny',
2589
2619
  input,
@@ -1,3 +1,54 @@
1
+ /**
2
+ * 解析 agent.md 的 YAML frontmatter。
3
+ *
4
+ * agent.md 形如:
5
+ * ---
6
+ * aid: xxx
7
+ * type: human
8
+ * ...
9
+ * ---
10
+ * <正文>
11
+ *
12
+ * 返回整份 frontmatter(不是只挑 type/name),调用方据此写完整画像。
13
+ * 文档不合法(无 frontmatter、YAML 解析失败)时返回空对象——身份缓存
14
+ * 不应因为一个可选的元数据块解析失败而整体失效。
15
+ */
16
+ export function parseAgentMdFrontmatter(content) {
17
+ const text = content.replace(/^\uFEFF/, '');
18
+ const lines = text.split(/\r\n|\n|\r/);
19
+ if (lines[0]?.trim() !== '---')
20
+ return {};
21
+ const end = lines.slice(1).findIndex(line => line.trim() === '---');
22
+ if (end < 0)
23
+ return {};
24
+ try {
25
+ const parsed = parseYaml(lines.slice(1, end + 1).join('\n'));
26
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
27
+ ? parsed
28
+ : {};
29
+ }
30
+ catch {
31
+ return {};
32
+ }
33
+ }
34
+ /**
35
+ * 兼容旧缓存文件:字段从 `isAgent` 改名为 `isMachine` 之前写下的
36
+ * peer-identity.json 只有 isAgent。两者语义相同(type !== 'human'),
37
+ * 缓存有效期长达 30 天,不能指望靠下一次刷新自然迁移——
38
+ * 读取时直接归一,缺字段就用旧字段或 type 推出。
39
+ */
40
+ function normalizeCachedIdentity(raw) {
41
+ if (!raw || typeof raw !== 'object')
42
+ return null;
43
+ const value = raw;
44
+ const type = typeof value.type === 'string' ? value.type : 'unknown';
45
+ const isMachine = typeof value.isMachine === 'boolean'
46
+ ? value.isMachine
47
+ : typeof value.isAgent === 'boolean'
48
+ ? value.isAgent
49
+ : type !== 'human';
50
+ return { ...value, type, isMachine, isAgent: isMachine };
51
+ }
1
52
  /**
2
53
  * PeerIdentityCache - 对端身份缓存管理
3
54
  *
@@ -13,6 +64,7 @@
13
64
  import * as fs from 'fs';
14
65
  import * as path from 'path';
15
66
  import * as crypto from 'crypto';
67
+ import { parse as parseYaml } from 'yaml';
16
68
  import { logger } from '../../utils/logger.js';
17
69
  import { agentMdPath } from '../../paths.js';
18
70
  /**
@@ -56,7 +108,7 @@ export class PeerIdentityCache {
56
108
  const filePath = this.getFilePath(channelType, peerId, agentDir);
57
109
  try {
58
110
  const content = fs.readFileSync(filePath, 'utf-8');
59
- return JSON.parse(content);
111
+ return normalizeCachedIdentity(JSON.parse(content));
60
112
  }
61
113
  catch {
62
114
  return null;
@@ -87,6 +139,10 @@ export class PeerIdentityCache {
87
139
  const cached = this.get(channelType, peerId, agentDir);
88
140
  if (!cached)
89
141
  return true;
142
+ // 旧快照缺 metadata(字段补全之前写入)时需要重建一次,否则 30 天内
143
+ // 都会直接命中缓存,新字段永远补不上。
144
+ if (cached.source !== 'unknown' && cached.metadata === undefined)
145
+ return true;
90
146
  const effectiveMaxAge = cached.source === 'unknown'
91
147
  ? Math.min(maxAgeMs, this.UNKNOWN_CACHE_MAX_AGE_MS)
92
148
  : maxAgeMs;
@@ -98,17 +154,28 @@ export class PeerIdentityCache {
98
154
  * @param verifiedAt 验签通过时间戳;未验签传 0
99
155
  */
100
156
  static updateFromAgentMd(channelType, peerId, agentDir, agentMd, verifiedAt, source = 'agentmd') {
101
- const typeMatch = agentMd.match(/^type:\s*["']?([^"'\n]+?)["']?\s*$/m);
102
- const nameMatch = agentMd.match(/^name:\s*["']?(.+?)["']?\s*$/m);
103
- const type = typeMatch?.[1] || 'unknown';
104
- const isAgent = type !== 'human';
105
- const name = nameMatch?.[1]?.trim();
157
+ // 保留整份 frontmatter:这份缓存是对端自我声明的完整画像,
158
+ // 只截取 type/name 会在上游新增字段(exttype/owner/tags/avatar…)时静默丢失。
159
+ const metadata = parseAgentMdFrontmatter(agentMd);
160
+ const readString = (key) => {
161
+ const value = metadata[key];
162
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
163
+ };
164
+ const type = readString('type') ?? 'unknown';
165
+ const isMachine = type !== 'human';
166
+ const name = readString('name');
167
+ const exttype = readString('exttype');
168
+ const owner = readString('owner');
106
169
  const agentMdHash = 'sha256:' + crypto.createHash('sha256').update(agentMd, 'utf-8').digest('hex');
107
170
  const now = Date.now();
108
171
  const identity = {
109
172
  aid: peerId,
110
173
  type,
111
- isAgent,
174
+ isMachine,
175
+ isAgent: isMachine,
176
+ ...(exttype ? { exttype } : {}),
177
+ ...(owner ? { owner } : {}),
178
+ metadata,
112
179
  name,
113
180
  agentMdHash,
114
181
  agentMdUpdatedAt: now,
@@ -120,7 +187,7 @@ export class PeerIdentityCache {
120
187
  try {
121
188
  fs.mkdirSync(path.join(path.dirname(filePath), 'shared'), { recursive: true });
122
189
  fs.writeFileSync(filePath, JSON.stringify(identity, null, 2), 'utf-8');
123
- logger.debug(`[PeerIdentityCache] Updated: ${channelType}#${peerId} type=${type} isAgent=${isAgent} source=${source}`);
190
+ logger.debug(`[PeerIdentityCache] Updated: ${channelType}#${peerId} type=${type} isMachine=${isMachine} source=${source}`);
124
191
  }
125
192
  catch (err) {
126
193
  logger.warn(`[PeerIdentityCache] Failed to write cache: ${filePath} err=${err}`);
@@ -146,6 +213,7 @@ export class PeerIdentityCache {
146
213
  const identity = {
147
214
  aid: peerId,
148
215
  type: 'unknown',
216
+ isMachine: true,
149
217
  isAgent: true,
150
218
  agentMdHash: '',
151
219
  agentMdUpdatedAt: 0,
@@ -188,6 +256,7 @@ export class PeerIdentityCache {
188
256
  return {
189
257
  aid: '',
190
258
  type: 'unknown',
259
+ isMachine: true,
191
260
  isAgent: true,
192
261
  agentMdHash: '',
193
262
  agentMdUpdatedAt: 0,
@@ -212,7 +281,10 @@ export class PeerIdentityCache {
212
281
  try {
213
282
  logger.debug(`[PeerIdentityCache] Syncing agent.md: ${channelType}#${peerId}`);
214
283
  const { agentmdSync } = await import('../../aun/aid/agentmd.js');
215
- const invalidated = stale?.lastCheckedAt === 0 || revision > 0;
284
+ // 缺 metadata 的旧快照(字段补全之前写入的)也要重写一次:
285
+ // 否则命中 hash 相同分支只会 touch,永远补不上 exttype/owner/metadata。
286
+ const invalidated = stale?.lastCheckedAt === 0 || revision > 0
287
+ || (!!stale && stale.source !== 'unknown' && stale.metadata === undefined);
216
288
  const result = await agentmdSync(peerId, {
217
289
  store,
218
290
  aunPath: resolvedAunPath,
@@ -222,7 +294,7 @@ export class PeerIdentityCache {
222
294
  if ((this.invalidationRevisions.get(cachePath) ?? 0) !== revision) {
223
295
  // A newer event arrived during this read. Its next lookup must refresh.
224
296
  return this.get(channelType, peerId, agentDir) ?? {
225
- aid: peerId, type: 'unknown', isAgent: true, agentMdHash: '', agentMdUpdatedAt: 0,
297
+ aid: peerId, type: 'unknown', isMachine: true, isAgent: true, agentMdHash: '', agentMdUpdatedAt: 0,
226
298
  verifiedAt: 0, lastCheckedAt: 0, source: 'unknown',
227
299
  };
228
300
  }
@@ -241,10 +313,13 @@ export class PeerIdentityCache {
241
313
  if (!verified) {
242
314
  logger.info(`[PeerIdentityCache] agent.md unverified for ${peerId}: status=${result.verification?.status ?? 'unknown'} reason=${result.verification?.reason ?? '-'} (type 仍按声明解析)`);
243
315
  }
244
- // 3. 比较 hash,内容与可信级别都未变时仅 touch
316
+ // 3. 比较 hash,内容与可信级别都未变时仅 touch。
317
+ // 例外:旧快照若缺 metadata(字段补全之前写入),必须重写一次,
318
+ // 否则这里每次都走 touch,新字段永远补不上。
245
319
  const newHash = 'sha256:' + crypto.createHash('sha256').update(content, 'utf-8').digest('hex');
246
320
  const cached = this.get(channelType, peerId, agentDir);
247
- if (cached && cached.agentMdHash === newHash && cached.source === source) {
321
+ if (cached && cached.agentMdHash === newHash && cached.source === source
322
+ && cached.metadata !== undefined) {
248
323
  return this.touchLastChecked(channelType, peerId, agentDir, cached);
249
324
  }
250
325
  return this.updateFromAgentMd(channelType, peerId, agentDir, content, verified ? Date.now() : 0, source);
@@ -1,6 +1,6 @@
1
1
  import { normalizeSessionLoopConfig, resolveSessionLoopTarget, sessionLoopError } from './session-idle-monitor.js';
2
2
  import { ensureDir } from '../../utils/atomic-write.js';
3
- import { resolvePaths } from '../../paths.js';
3
+ import { resolvePaths, agentThreadIndexPath } from '../../paths.js';
4
4
  import { logger } from '../../utils/logger.js';
5
5
  import { encodePath } from '../../utils/cross-platform.js';
6
6
  import { readRunnerEventSummary } from './runner-event-summary.js';
@@ -1555,6 +1555,20 @@ export class SessionManager {
1555
1555
  metaFile: `${session.id}.jsonl`,
1556
1556
  };
1557
1557
  writeThreadIndex(chatDir, threadIndex);
1558
+ // 被动建档(收到消息时自动创建话题会话)也必须同步到 agent 级话题索引,
1559
+ // 否则 `ec thread rename current` / list 等按索引寻址的操作会报"话题不存在"。
1560
+ this.upsertAgentThreadIndex({
1561
+ selfAid: selfAID ?? '',
1562
+ threadId,
1563
+ sessionId: session.id,
1564
+ peer: channelId,
1565
+ channelType: channelType ?? 'aun',
1566
+ peerKind: effectiveChatType === 'group' ? 'group' : 'private',
1567
+ name: session.name,
1568
+ label: normalizeTopicLabel(label) ?? null,
1569
+ topicStatus: session.topicStatus ?? 'pending',
1570
+ now: session.createdAt ?? Date.now(),
1571
+ });
1558
1572
  this.eventBus.publish({
1559
1573
  type: 'session:created',
1560
1574
  sessionId: session.id,
@@ -1567,6 +1581,47 @@ export class SessionManager {
1567
1581
  });
1568
1582
  return session;
1569
1583
  }
1584
+ /**
1585
+ * 把一条话题会话合并进 agent 级索引(`data/threads/index.json`)。
1586
+ *
1587
+ * 全局索引是 `ec thread` 各命令的寻址源;被动建档只写过 chatDir 下的
1588
+ * `_threads/thread-index.json`,导致 `rename current` 之类找不到自己。
1589
+ * 这里按 (peer, threadId) 幂等 upsert:已存在则只补缺失字段,不覆盖
1590
+ * 名称/摘要等既有属性。
1591
+ */
1592
+ upsertAgentThreadIndex(input) {
1593
+ if (!input.selfAid || !input.threadId)
1594
+ return;
1595
+ try {
1596
+ const indexPath = agentThreadIndexPath(input.selfAid);
1597
+ const raw = readJsonFile(indexPath);
1598
+ const index = raw && Array.isArray(raw.threads) ? raw : { version: 1, threads: [] };
1599
+ const existing = index.threads.find((t) => t.threadId === input.threadId
1600
+ && String(t.peer ?? '').toLowerCase() === input.peer.toLowerCase());
1601
+ if (existing) {
1602
+ existing.sessionId = existing.sessionId || input.sessionId;
1603
+ return;
1604
+ }
1605
+ index.threads.push({
1606
+ threadId: input.threadId,
1607
+ sessionId: input.sessionId,
1608
+ peer: input.peer,
1609
+ channelType: input.channelType,
1610
+ peerKind: input.peerKind,
1611
+ name: input.name || '话题会话',
1612
+ label: input.label ?? null,
1613
+ topicStatus: input.topicStatus,
1614
+ createdAt: input.now,
1615
+ lastActiveAt: input.now,
1616
+ });
1617
+ ensureDir(path.dirname(indexPath));
1618
+ atomicWriteJson(indexPath, index);
1619
+ }
1620
+ catch (error) {
1621
+ // 索引同步失败不能阻断消息处理:会话本身已落盘,rebuild-index 可补齐。
1622
+ logger.warn(`[SessionManager] agent thread index upsert failed: ${error instanceof Error ? error.message : String(error)}`);
1623
+ }
1624
+ }
1570
1625
  async switchProject(channel, channelId, newProjectPath, baseagent) {
1571
1626
  if (!baseagent) {
1572
1627
  throw new Error(`[SessionManager] switchProject: baseagent is empty for channel=${channel} channelId=${channelId}`);
@@ -94,6 +94,31 @@ export function buildCliSessionPreview(messages, backend, options = {}) {
94
94
  : null;
95
95
  return { title, hasReal: !!summary.firstRealMessage };
96
96
  }
97
+ /**
98
+ * 会话/话题名称的长度常量,自动生成与手动改名共用。
99
+ *
100
+ * - `SESSION_TITLE_MAX_LEN`(80):正文字数,超长文本截到这里再加省略号;
101
+ * - `SESSION_TITLE_STORED_MAX_LEN`(83):落盘上限 = 80 + `...`。
102
+ *
103
+ * 80~83 字符的名字本身就是合法落盘形态,不再二次截断——
104
+ * 否则「系统自动生成的名字」会变成改一次短一点。
105
+ */
106
+ export const SESSION_TITLE_MAX_LEN = 80;
107
+ export const SESSION_TITLE_ELLIPSIS = '...';
108
+ /** 落盘名称的硬上限(截断后的最长长度)。 */
109
+ export const SESSION_TITLE_STORED_MAX_LEN = SESSION_TITLE_MAX_LEN + SESSION_TITLE_ELLIPSIS.length;
110
+ /**
111
+ * 截断到落盘上限并补省略号;未超长时原样返回。
112
+ *
113
+ * 判据是 `SESSION_TITLE_STORED_MAX_LEN`(83)而不是正文上限(80):
114
+ * 80~83 字符的名称本来就是合法落盘形态(含历史遗留),截断它们会把
115
+ * 「系统生成的名字原样改回去」变成「改一次短一点」。
116
+ */
117
+ export function truncateSessionTitle(title) {
118
+ return title.length > SESSION_TITLE_STORED_MAX_LEN
119
+ ? `${title.slice(0, SESSION_TITLE_MAX_LEN)}${SESSION_TITLE_ELLIPSIS}`
120
+ : title;
121
+ }
97
122
  export function sanitizeSessionTitle(title) {
98
123
  if (typeof title !== 'string')
99
124
  return undefined;
@@ -109,7 +134,7 @@ export function sanitizeSessionTitle(title) {
109
134
  return undefined;
110
135
  if (/^\.+$/.test(cleanTitle))
111
136
  return undefined;
112
- return cleanTitle.length > 80 ? `${cleanTitle.slice(0, 80)}...` : cleanTitle;
137
+ return truncateSessionTitle(cleanTitle);
113
138
  }
114
139
  export function displaySessionTitle(title, fallback = '默认会话') {
115
140
  return sanitizeSessionTitle(title) || sanitizeSessionTitle(fallback) || fallback;