ec-web 0.0.25 → 0.0.27

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 EvolCore contributors
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 EvolCore contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,17 +1,17 @@
1
- # EC Web
2
-
3
- Web console and monitoring dashboard for EvolCore.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install -g ec-web
9
- ```
10
-
11
- ## Run
12
-
13
- ```bash
14
- ec-web --home /path/to/runtime-home
15
- ```
16
-
17
- Use `ec watch web` when EC Web is managed by the main daemon.
1
+ # EC Web
2
+
3
+ Web console and monitoring dashboard for EvolCore.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g ec-web
9
+ ```
10
+
11
+ ## Run
12
+
13
+ ```bash
14
+ ec-web --home /path/to/runtime-home
15
+ ```
16
+
17
+ Use `ec watch web` when EC Web is managed by the main daemon.
package/dist/index.js CHANGED
@@ -107,7 +107,7 @@ for (const list of Object.values(ifaces)) {
107
107
  }
108
108
  process.stdout.write(`\n${BOLD}${CYAN}EC Web — EvolCore${RST} ${DIM}(home: ${p.root})${RST}\n\n`);
109
109
  process.stdout.write(handle.pairingCode
110
- ? ` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(daemon 提供,5 分钟内有效,配对后 token 缓存 7 天,有访问自动续期)${RST}\n\n`
110
+ ? ` ${BOLD}配对码:${RST} ${GREEN}${BOLD}${handle.pairingCode}${RST} ${DIM}(daemon 提供,10 分钟内有效,仅可配对一次,token 缓存 7 天,有访问自动续期)${RST}\n\n`
111
111
  : ` ${BOLD}配对码:${RST} ${YELLOW}daemon 暂不可用,请先启动 EvolCore${RST}\n\n`);
112
112
  process.stdout.write(` ${BOLD}本机:${RST} http://localhost:${handle.port}\n`);
113
113
  for (const ip of lanIps)
package/dist/paths.js CHANGED
@@ -14,6 +14,10 @@ import os from 'os';
14
14
  import crypto from 'crypto';
15
15
  const isWindows = process.platform === 'win32';
16
16
  let _root = null;
17
+ /** Test helper: drop the cached root so a new EVOLCORE_HOME is picked up. */
18
+ export function _resetRoot() {
19
+ _root = null;
20
+ }
17
21
  export function resolveRoot() {
18
22
  if (_root)
19
23
  return _root;
@@ -48,7 +52,14 @@ function resolveInstanceSocketPath(root) {
48
52
  const hash = crypto.createHash('sha1').update(canonicalRoot).digest('hex').slice(0, 12);
49
53
  return `\\\\.\\pipe\\daemon-${hash}`;
50
54
  }
51
- return path.join(root, 'data', 'instance', 'daemon.sock');
55
+ const filesystemPath = path.join(root, 'data', 'instance', 'daemon.sock');
56
+ if (Buffer.byteLength(filesystemPath) < 100)
57
+ return filesystemPath;
58
+ const hash = crypto.createHash('sha1').update(root).digest('hex').slice(0, 20);
59
+ const shortFilesystemPath = path.join(os.tmpdir(), `.ec-${hash.slice(0, 15)}.sock`);
60
+ if (Buffer.byteLength(shortFilesystemPath) < 100)
61
+ return shortFilesystemPath;
62
+ return `\0evolcore-${hash}`;
52
63
  }
53
64
  export function resolvePaths() {
54
65
  const root = resolveRoot();
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * - HTTP: 静态资源 + 配对 API
5
5
  * - WebSocket: 订阅式实时推送(aid / msg / session)
6
- * - 鉴权: daemon 通过本地 IPC 提供 6 位配对码(5 分钟有效)和远程访问 token(7 天滑动续期)
6
+ * - 鉴权: daemon 通过本地 IPC 提供 6 位一次性配对码(10 分钟有效)和远程访问 token(7 天滑动续期)
7
7
  * - 安全: 绑定 0.0.0.0(支持远程访问);本地自动登录,远程配对后使用 token,登录后权限一致
8
8
  *
9
9
  * 与 evolcore daemon 通过 IPC socket(状态、Menu 和远程访问凭据)+ 文件系统(历史数据)旁路通信。
@@ -20,7 +20,7 @@ import { ipcQuery } from './ipc-client.js';
20
20
  import { setDebugLog, dlog } from './debug-log.js';
21
21
  import { aidSource } from './sources/aid.js';
22
22
  import { msgSource } from './sources/msg.js';
23
- import { sessionSource, buildBindMap, readClaudeSessionResult } from './sources/session.js';
23
+ import { sessionSource, buildBindMap, readClaudeSessionResult, resolveSessionBaseagent, refreshConfiguredBaseagent } from './sources/session.js';
24
24
  import { cacheSource } from './sources/cache.js';
25
25
  import { systemSource } from './sources/system.js';
26
26
  import { triggersSource } from './sources/triggers.js';
@@ -33,13 +33,14 @@ import { ccProjectsDir } from './paths.js';
33
33
  import { relationRolesSource, handleRelationRolesApi, handlePeerRoleApi } from './sources/relation-roles.js';
34
34
  import { roleDefinitionsSource, handleRoleDefinitionsApi } from './sources/role-definitions.js';
35
35
  import { handleModelsApi } from './sources/models.js';
36
- import { detectBaseAgentsAsync } from './sources/baseagent-detector.js';
37
36
  import { readCodexSessionResult } from './sources/session-codex.js';
37
+ import { readEcagentSessionResult } from './sources/session-ecagent.js';
38
38
  import { handleBasicSettingsApi } from './sources/basic-settings.js';
39
39
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
40
40
  const STATIC_DIR = path.join(__dirname, 'static');
41
41
  const DEFAULT_PORT = 42705;
42
42
  const PROTOCOL_VERSION = 1; // 与 evolcore ping response 对齐的软校验版本
43
+ const ECWEB_TRIGGER_IPC_TYPES = new Set(['trigger.history', 'trigger.eventCatalog']);
43
44
  function daemonOwnerMenuAuth(localDirect, actorAid) {
44
45
  return {
45
46
  localDirect,
@@ -48,6 +49,7 @@ function daemonOwnerMenuAuth(localDirect, actorAid) {
48
49
  // authentication gate (local-direct exemption or a valid bearer token).
49
50
  // ECWeb is the daemon-owner management surface, so remote tokens receive
50
51
  // the same persistent fullaccess approval capability as local requests.
52
+ daemonOwner: true,
51
53
  canApprovePersistentFullAccess: true,
52
54
  };
53
55
  }
@@ -583,16 +585,30 @@ function handleConnection(ws, req, log, tokenRecord) {
583
585
  params.source = msg.source;
584
586
  if (msg.limit)
585
587
  params.limit = msg.limit;
588
+ if (msg.archived !== undefined)
589
+ params.archived = msg.archived;
586
590
  dlog(`▸ 订阅 ${msg.view}${msg.aid ? ` aid=${String(msg.aid).split('.')[0]}` : ''}${msg.peer ? ` peer=${String(msg.peer).split('.')[0]}` : ''}${msg.sessionId ? ` session=${String(msg.sessionId).slice(0, 8)}` : ''}${msg.baseagent ? ` baseagent=${msg.baseagent}` : ''} from ${ip}`);
587
591
  await switchSubscription(msg.view, params);
588
592
  return;
589
593
  }
590
594
  if (msg.type === 'session.result') {
591
595
  const params = msg.params && typeof msg.params === 'object' ? msg.params : {};
592
- const result = params.baseagent === 'codex'
593
- ? readCodexSessionResult(params)
594
- : readClaudeSessionResult(params);
595
- send({ type: 'session.result', requestId: msg.requestId, data: result == null ? null : { text: result } });
596
+ if (!params.baseagent)
597
+ await refreshConfiguredBaseagent();
598
+ const baseagent = resolveSessionBaseagent(params);
599
+ const result = baseagent === 'codex'
600
+ ? readCodexSessionResult({ ...params, baseagent })
601
+ : baseagent === 'ecagent'
602
+ ? readEcagentSessionResult({ ...params, baseagent })
603
+ : baseagent
604
+ ? readClaudeSessionResult({ ...params, baseagent })
605
+ : Promise.resolve(null);
606
+ void Promise.resolve(result)
607
+ .then(text => send({ type: 'session.result', requestId: msg.requestId, data: text == null ? null : { text } }))
608
+ .catch(error => {
609
+ dlog(`session.result failed: ${error instanceof Error ? error.message : String(error)}`);
610
+ send({ type: 'session.result', requestId: msg.requestId, data: null });
611
+ });
596
612
  return;
597
613
  }
598
614
  if (msg.type === 'menu' && msg.payload) {
@@ -614,7 +630,7 @@ function handleConnection(ws, req, log, tokenRecord) {
614
630
  return;
615
631
  }
616
632
  if (msg.type === 'ipc' && msg.payload) {
617
- if (typeof msg.payload.type !== 'string' || !msg.payload.type.startsWith('trigger.')) {
633
+ if (typeof msg.payload.type !== 'string' || !ECWEB_TRIGGER_IPC_TYPES.has(msg.payload.type)) {
618
634
  send({ type: 'ipc.response', requestId: msg.requestId, data: { ok: false, error: 'unsupported ipc payload' } });
619
635
  return;
620
636
  }
@@ -622,9 +638,7 @@ function handleConnection(ws, req, log, tokenRecord) {
622
638
  const timeoutMs = Number.isFinite(Number(msg.timeoutMs)) && Number(msg.timeoutMs) > 0
623
639
  ? Math.min(Number(msg.timeoutMs), 120_000)
624
640
  : 10000;
625
- const payload = msg.payload.type === 'trigger.history' || msg.payload.type === 'trigger.eventCatalog'
626
- ? { ...msg.payload, controlToken: daemonTriggerControlToken() }
627
- : msg.payload;
641
+ const payload = { ...msg.payload, controlToken: daemonTriggerControlToken() };
628
642
  const resp = await ipcQuery(p.socket, payload, timeoutMs);
629
643
  send({ type: 'ipc.response', requestId: msg.requestId, data: resp ?? { ok: false, error: 'daemon unreachable' } });
630
644
  return;
@@ -731,6 +745,8 @@ function bindPort(server, preferred, log) {
731
745
  export async function startWatchWebServer(opts = {}) {
732
746
  const log = opts.log || (() => { });
733
747
  setDebugLog(log);
748
+ // 会话读取器默认后端来自 defaults.json;启动时同步一次。
749
+ await refreshConfiguredBaseagent();
734
750
  // soft 版本校验:ping daemon 检查 protocolVersion
735
751
  const p = resolvePaths();
736
752
  const pingResp = await ipcQuery(p.socket, { type: 'ping' }, 1000);
@@ -740,17 +756,12 @@ export async function startWatchWebServer(opts = {}) {
740
756
  const server = http.createServer(async (req, res) => {
741
757
  const pathname = requestPath(req.url || '');
742
758
  const localDirect = isLocalDirect(req);
743
- if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
744
- // Base agent 可用性检测 API
745
- try {
746
- const available = await detectBaseAgentsAsync();
747
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
748
- res.end(JSON.stringify(available));
749
- }
750
- catch (error) {
751
- res.writeHead(500, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
752
- res.end(JSON.stringify({ success: false, error: error?.message || String(error) }));
753
- }
759
+ if (req.method === 'GET' && (req.url || '') === '/api/health') {
760
+ // Keep readiness checks independent from daemon auth and pairing state.
761
+ // The endpoint is intentionally small so supervisors and proxies can
762
+ // distinguish ECWeb from an unrelated process on the configured port.
763
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
764
+ res.end(JSON.stringify({ ok: true, service: 'ecweb', version: ECWEB_VERSION }));
754
765
  }
755
766
  else if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
756
767
  // 取码 API:仅真本地直连可用(socket 回环 + 无 x-aun-provider-aid)。
@@ -761,7 +772,9 @@ export async function startWatchWebServer(opts = {}) {
761
772
  res.end(JSON.stringify({ error: 'forbidden' }));
762
773
  return;
763
774
  }
764
- const pair = await remoteAuth('remote-auth.pair-code');
775
+ const pair = await remoteAuth('remote-auth.pair-code', {
776
+ source: 'ecweb-local',
777
+ });
765
778
  if (!pair?.ok || typeof pair.code !== 'string' || typeof pair.expiresAt !== 'number') {
766
779
  res.writeHead(503, { 'Content-Type': 'application/json' });
767
780
  res.end(JSON.stringify({ error: 'daemon unavailable' }));
@@ -796,7 +809,9 @@ export async function startWatchWebServer(opts = {}) {
796
809
  res.end(JSON.stringify({ success: false, error: 'unauthorized' }));
797
810
  return;
798
811
  }
799
- void handleBasicSettingsApi(req, res, { redactConnectionMaps: !isLocalDirect(req) });
812
+ // Pairing grants daemon-owner management authority, just like local
813
+ // access. Authorize above; do not downgrade settings by network origin.
814
+ void handleBasicSettingsApi(req, res);
800
815
  }
801
816
  else if (req.method === 'GET' && (req.url || '').startsWith('/api/stats/')) {
802
817
  // Stats API — 本地直连自动发 token(免鉴权),远程需鉴权
@@ -910,7 +925,9 @@ export async function startWatchWebServer(opts = {}) {
910
925
  });
911
926
  });
912
927
  const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
913
- const initialPair = await remoteAuth('remote-auth.pair-code');
928
+ const initialPair = await remoteAuth('remote-auth.pair-code', {
929
+ source: 'ecweb-local',
930
+ });
914
931
  return {
915
932
  url: `http://0.0.0.0:${port}`,
916
933
  port,
@@ -65,28 +65,43 @@ function scanAidActivity() {
65
65
  }
66
66
  return result;
67
67
  }
68
- // evolcore 版本:与 watch aid 状态栏一致。daemon 进程级、整轮 watch 不变,
69
- // 故缓存一次(首次 menu.query name=system 拿到后复用),避免每秒多发一次 IPC。
68
+ // evolcore 版本:使用 /system 的轻量 version 视图。daemon 进程级、整轮 watch 不变,
69
+ // 故缓存一次并合并 in-flight 请求,避免每秒重复发 IPC。
70
70
  const versionCache = new Map();
71
+ const versionInFlight = new Map();
71
72
  function authContextKey(params = {}) {
72
73
  const auth = watchMenuRequestAuth(params);
73
74
  return `${auth.localDirect ? 'local' : 'remote'}:${auth.actorAid || ''}`;
74
75
  }
75
76
  async function fetchVersion(socket, params = {}) {
76
- const key = authContextKey(params);
77
+ const key = `${socket}\0${authContextKey(params)}`;
77
78
  const cached = versionCache.get(key);
78
79
  if (cached)
79
80
  return cached;
80
- try {
81
- const r = await ipcQuery(socket, { type: 'menu.exec', payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system' }, auth: watchMenuRequestAuth(params) }, 4000);
82
- const v = r?.ok ? r.response?.data?.version : null;
83
- if (v)
84
- versionCache.set(key, v);
85
- return v ?? null;
86
- }
87
- catch {
88
- return null;
89
- }
81
+ const pending = versionInFlight.get(key);
82
+ if (pending)
83
+ return pending;
84
+ const request = (async () => {
85
+ try {
86
+ const r = await ipcQuery(socket, {
87
+ type: 'menu.exec',
88
+ payload: { type: 'menu.query', id: 'aid-sys-q', name: 'system', args: { view: 'version' } },
89
+ auth: watchMenuRequestAuth(params),
90
+ }, 4000);
91
+ const v = r?.ok ? r.response?.data?.version : null;
92
+ if (v)
93
+ versionCache.set(key, v);
94
+ return v ?? null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ finally {
100
+ versionInFlight.delete(key);
101
+ }
102
+ })();
103
+ versionInFlight.set(key, request);
104
+ return request;
90
105
  }
91
106
  async function buildSnapshot(fallback = null, params = {}) {
92
107
  const p = resolvePaths();
@@ -580,7 +580,7 @@ export function restartRequiredForChanges(file, changes) {
580
580
  const prefixes = file === 'daemon' ? RESTART_DAEMON_PREFIXES : RESTART_DEFAULTS_PREFIXES;
581
581
  return changes.map(change => change.path).filter(fieldPath => prefixes.some(prefix => fieldPath === prefix || fieldPath.startsWith(prefix)));
582
582
  }
583
- function redactSecrets(config, file, secrets, secretRefs, options = {}) {
583
+ function redactSecrets(config, file, secrets, secretRefs) {
584
584
  const redacted = clone(config);
585
585
  if (file !== 'defaults' || !isRecord(redacted.baseagents))
586
586
  return redacted;
@@ -600,26 +600,6 @@ function redactSecrets(config, file, secrets, secretRefs, options = {}) {
600
600
  raw.apiKey = '******';
601
601
  }
602
602
  }
603
- // Headers and query parameters are also common credential channels. They
604
- // remain visible to local callers for editing, but remote snapshots must
605
- // not disclose any map value, including values under custom key names.
606
- if (options.redactConnectionMaps) {
607
- for (const field of ['headers', 'queryParams']) {
608
- if (!Object.prototype.hasOwnProperty.call(raw, field))
609
- continue;
610
- const fieldPath = `defaults.baseagents.${name}.${field}`;
611
- if (isRecord(raw[field])) {
612
- raw[field] = Object.fromEntries(Object.keys(raw[field]).map(key => {
613
- secrets[`${fieldPath}.${key}`] = true;
614
- return [key, '******'];
615
- }));
616
- }
617
- else {
618
- secrets[fieldPath] = true;
619
- raw[field] = '******';
620
- }
621
- }
622
- }
623
603
  }
624
604
  return redacted;
625
605
  }
@@ -670,7 +650,7 @@ export function collectLogTypeStats(configured, logsDir = resolvePaths().logs) {
670
650
  export function collectLogTypes(configured, logsDir = resolvePaths().logs) {
671
651
  return collectLogTypeStats(configured, logsDir).types;
672
652
  }
673
- async function readSnapshot(options = {}) {
653
+ async function readSnapshot() {
674
654
  const modules = await getConfigManager();
675
655
  const daemonFile = modules.read(modules.ConfigTarget.Process, undefined, { cache: false, expand: false });
676
656
  const defaultsFile = modules.read(modules.ConfigTarget.Defaults, undefined, { cache: false, expand: false });
@@ -682,8 +662,8 @@ async function readSnapshot(options = {}) {
682
662
  const secretRefs = {};
683
663
  const logTypeStats = collectLogTypeStats(rawDaemon?.watch?.logTypes);
684
664
  return {
685
- daemon: redactSecrets(rawDaemon, 'daemon', secrets, secretRefs, options),
686
- defaults: redactSecrets(rawDefaults, 'defaults', secrets, secretRefs, options),
665
+ daemon: redactSecrets(rawDaemon, 'daemon', secrets, secretRefs),
666
+ defaults: redactSecrets(rawDefaults, 'defaults', secrets, secretRefs),
687
667
  revisions: {
688
668
  daemon: basicSettingsRevision(rawDaemon),
689
669
  defaults: basicSettingsRevision(rawDefaults),
@@ -758,12 +738,12 @@ function basicSettingsPath(rawUrl) {
758
738
  return rawUrl.split('?')[0].replace(/\/+$/, '') || '/';
759
739
  }
760
740
  }
761
- export async function handleBasicSettingsApi(req, res, options = {}) {
741
+ export async function handleBasicSettingsApi(req, res) {
762
742
  try {
763
743
  const url = new URL(req.url || '', 'http://localhost');
764
744
  const pathname = basicSettingsPath(req.url || '');
765
745
  if (req.method === 'GET' && pathname === '/api/basic-settings') {
766
- sendJson(res, 200, { success: true, data: await readSnapshot(options) });
746
+ sendJson(res, 200, { success: true, data: await readSnapshot() });
767
747
  return;
768
748
  }
769
749
  if (req.method === 'GET' && pathname === '/api/basic-settings/secret') {
@@ -816,7 +796,7 @@ export async function handleBasicSettingsApi(req, res, options = {}) {
816
796
  changed: mutation.changed,
817
797
  restartRequired: mutation.changed && restartPaths.length > 0,
818
798
  restartPaths: mutation.changed ? restartPaths : [],
819
- data: await readSnapshot(options),
799
+ data: await readSnapshot(),
820
800
  });
821
801
  return;
822
802
  }
@@ -10,6 +10,13 @@ import { ipcQuery } from '../ipc-client.js';
10
10
  import { resolvePaths } from '../paths.js';
11
11
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
12
12
  import { decodeDirSegment, isTransientProtocolMessage, readAllJsonlLines, readJsonFile, scanChatDirs, tryParseChannelSessionKey, } from '../fs-utils.js';
13
+ // ECWeb builds as a separate package. Keep this wire contract aligned with session-mapper.ts.
14
+ function topicMatchesArchiveFilter(chat, archived) {
15
+ if (archived === 'all')
16
+ return true;
17
+ const isArchived = chat.partition === 'thread' && chat.lable === '归档';
18
+ return archived === true ? isArchived : !isArchived;
19
+ }
13
20
  let contextModulesPromise = null;
14
21
  const sessionAdapterPromises = new Map();
15
22
  let peerDisplayResolverPromise = null;
@@ -96,6 +103,7 @@ function getSessionAdapter(baseagent) {
96
103
  claude: 'ClaudeSessionFileAdapter',
97
104
  codex: 'CodexSessionFileAdapter',
98
105
  gemini: 'GeminiSessionFileAdapter',
106
+ ecagent: 'EcagentSessionFileAdapter',
99
107
  };
100
108
  const className = classNames[baseagent];
101
109
  const promise = !className
@@ -581,6 +589,8 @@ function loadChats(peerDisplayResolver, metrics) {
581
589
  threadName: item.partition === 'thread'
582
590
  ? String(partitionActive.name || partitionActive.metadata?.topicName || item.threadId)
583
591
  : null,
592
+ lable: item.partition === 'thread' && typeof partitionActive.lable === 'string'
593
+ ? partitionActive.lable || null : null,
584
594
  updatedAt,
585
595
  messages: item.messages,
586
596
  active: partitionActive,
@@ -690,6 +700,7 @@ function mergePeerInfo(chats) {
690
700
  partition: chat.partition,
691
701
  threadId: chat.threadId,
692
702
  threadName: chat.threadName,
703
+ lable: chat.lable,
693
704
  inbound: 0,
694
705
  outbound: 0,
695
706
  lastAt: 0,
@@ -766,7 +777,7 @@ async function sessionTurns(active, messages) {
766
777
  if (baseagent && agentSessionId && projectPath) {
767
778
  try {
768
779
  const adapter = await getSessionAdapter(baseagent);
769
- const turns = Number(adapter?.getFileInfo(projectPath, agentSessionId)?.turns);
780
+ const turns = Number(adapter?.getFileInfo(projectPath, agentSessionId, active.selfAID)?.turns);
770
781
  if (Number.isFinite(turns) && turns > 0)
771
782
  return turns;
772
783
  }
@@ -820,6 +831,7 @@ async function buildSessionContext(chat) {
820
831
  sessionId: active.id || null,
821
832
  evolcoreSessionId: active.id || null,
822
833
  name: active.name || null,
834
+ lable: chat.lable,
823
835
  baseagent: active.baseagent || active.agentType || null,
824
836
  chatMode,
825
837
  permissionMode,
@@ -830,6 +842,10 @@ async function buildSessionContext(chat) {
830
842
  };
831
843
  }
832
844
  async function buildSnapshot(params) {
845
+ const archived = params.archived;
846
+ if (archived !== undefined && archived !== false && archived !== true && archived !== 'all') {
847
+ throw new Error('archived 必须是布尔值或 "all"');
848
+ }
833
849
  const startedAt = Date.now();
834
850
  const metrics = {
835
851
  files: 0,
@@ -853,13 +869,14 @@ async function buildSnapshot(params) {
853
869
  : requestedScope;
854
870
  const peer = params.peer || null;
855
871
  const inScope = selectedChats(chats, scope);
856
- const peers = scope ? mergePeerInfo(inScope) : [];
872
+ const visibleChats = inScope.filter(chat => topicMatchesArchiveFilter(chat, archived));
873
+ const peers = scope ? mergePeerInfo(visibleChats) : [];
857
874
  const requestedLimit = Number(params.limit);
858
875
  const limit = Number.isFinite(requestedLimit) ? Math.min(Math.max(Math.trunc(requestedLimit), 20), 200) : 100;
859
876
  const requestedBefore = params.before == null ? Number.NaN : Number(params.before);
860
877
  const before = Number.isFinite(requestedBefore) && requestedBefore >= 0 ? Math.trunc(requestedBefore) : null;
861
- const page = scope && peer ? messagesFor(inScope, peer, before, limit) : { messages: [], nextBefore: null, hasMore: false };
862
- const currentChat = peer ? inScope.find(chat => chatKey(chat) === peer) : undefined;
878
+ const page = scope && peer ? messagesFor(visibleChats, peer, before, limit) : { messages: [], nextBefore: null, hasMore: false };
879
+ const currentChat = peer ? visibleChats.find(chat => chatKey(chat) === peer) : undefined;
863
880
  const sessionContext = await buildSessionContext(currentChat);
864
881
  metrics.totalMs = Date.now() - startedAt;
865
882
  return {
@@ -24,9 +24,11 @@ async function getParentModules() {
24
24
  async function getCommandModules() {
25
25
  const permissionPath = resolveParentDistModule('core', 'auth', 'operation-authorizer.js');
26
26
  const auditPath = resolveParentDistModule('core', 'auth', 'authorization-audit.js');
27
+ const runtimePolicyPath = resolveParentDistModule('core', 'role', 'runtime-policy.js');
27
28
  const permission = await import(toFileUrl(permissionPath));
28
29
  const audit = await import(toFileUrl(auditPath));
29
- return { permission, audit };
30
+ const runtimePolicy = await import(toFileUrl(runtimePolicyPath));
31
+ return { permission, audit, runtimePolicy };
30
32
  }
31
33
  export const ROLE_ASSIGNMENT_POLICY = {
32
34
  owner: {
@@ -38,15 +40,17 @@ export const ROLE_ASSIGNMENT_POLICY = {
38
40
  revoke: 'user-roles',
39
41
  },
40
42
  };
41
- function isManagementRoleName(role) {
42
- return role === 'owner' || role === 'admin';
43
- }
44
43
  function isAssignableUserRole(role) {
45
- return typeof role === 'string' && !!role && !isManagementRoleName(role);
44
+ // `admin` is a management role, but it is still a valid relation role.
45
+ // `owner` and `daemon-owner` remain reserved for process/Agent config and
46
+ // can never be assigned through a relation.
47
+ return typeof role === 'string' && !!role && role !== 'owner' && role !== 'daemon-owner';
46
48
  }
47
49
  export function canAssignRole(actorRole, targetRole) {
48
50
  if (!isAssignableUserRole(targetRole))
49
51
  return false;
52
+ if (targetRole === 'admin')
53
+ return actorRole === 'owner';
50
54
  return actorRole === 'owner' || actorRole === 'admin';
51
55
  }
52
56
  export function canRevokeRole(actorRole, targetRole) {
@@ -54,6 +58,8 @@ export function canRevokeRole(actorRole, targetRole) {
54
58
  return true;
55
59
  if (actorRole !== 'admin' || !targetRole)
56
60
  return false;
61
+ if (targetRole === 'admin')
62
+ return false;
57
63
  return isAssignableUserRole(targetRole);
58
64
  }
59
65
  async function getAgentsFromIpc() {
@@ -74,7 +80,8 @@ function resolveEcwebActor(modules, aid, auth) {
74
80
  const processOwners = Array.isArray(processConfig?.owners)
75
81
  ? processConfig.owners.map((value) => String(value || '').trim()).filter(Boolean)
76
82
  : [];
77
- const daemonOwner = !!auth.localDirect || (!!auth.actorAid && processOwners.includes(auth.actorAid));
83
+ const daemonOwner = auth.daemonOwner === true || !!auth.localDirect
84
+ || (!!auth.actorAid && processOwners.includes(auth.actorAid));
78
85
  const actorRole = daemonOwner
79
86
  ? 'owner'
80
87
  : auth.actorAid
@@ -89,11 +96,11 @@ function resolveEcwebActor(modules, aid, auth) {
89
96
  return {
90
97
  daemonOwner,
91
98
  actorRole,
92
- actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
99
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || (daemonOwner ? 'ecweb-remote' : undefined),
93
100
  };
94
101
  }
95
102
  async function authorizeRoleAssignmentRead(modules, aid, auth) {
96
- const { permission, audit } = await getCommandModules();
103
+ const { permission, audit, runtimePolicy } = await getCommandModules();
97
104
  const actor = resolveEcwebActor(modules, aid, auth);
98
105
  const decision = permission.authorizeCommand({
99
106
  intent: {
@@ -118,6 +125,7 @@ async function authorizeRoleAssignmentRead(modules, aid, auth) {
118
125
  actorId: actor.actorId,
119
126
  selfAid: aid,
120
127
  role: actor.actorRole,
128
+ permissionMode: runtimePolicy.resolveRuntimePermissionMode({ selfAid: aid, role: actor.actorRole }).effectiveValue,
121
129
  processRole: actor.daemonOwner ? 'daemon-owner' : 'none',
122
130
  isDaemonOwner: actor.daemonOwner,
123
131
  decision: decision.allow ? 'allow' : 'deny',
@@ -696,11 +704,17 @@ async function buildSnapshot(auth = {}) {
696
704
  if (!readDecision.allow)
697
705
  continue;
698
706
  const roleConfig = modules.roles.readRolesConfig(aid);
707
+ const availableRoles = Object.keys(roleConfig.roles || {});
708
+ // Management roles are resolved separately from user-role definitions.
709
+ // Include the assignable management role in the ECWeb snapshot so relation
710
+ // editors can offer it; owner remains reserved for config.json owners[].
711
+ if (roleExists(modules.roles, aid, 'admin'))
712
+ availableRoles.push('admin');
699
713
  snapshotAgents.push({
700
714
  aid,
701
715
  displayName: agent.displayName ?? agent.personalName,
702
716
  name: agent.name,
703
- roles: Object.keys(roleConfig.roles || {}).filter(role => isAssignableUserRole(role)),
717
+ roles: [...new Set(availableRoles)].filter(role => isAssignableUserRole(role)),
704
718
  defaultRoles: roleConfig.defaultRoles ?? { private: null, group: null },
705
719
  assignments: listRelationRoleAssignments(modules.config, aid),
706
720
  });