ec-web 0.0.21 → 0.0.22

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.
@@ -12,7 +12,13 @@ import path from 'path';
12
12
  // Keep this list aligned with evolcore's local-control IPC gate. `evolagent.list`
13
13
  // is used by the Agents view and is read-only from EC Web's perspective, but the
14
14
  // daemon still requires the local control token before returning its registry.
15
- const LOCAL_CONTROL_COMMANDS = new Set(['evolagent.list', 'menu.exec', 'monitor-snapshot']);
15
+ const LOCAL_CONTROL_COMMANDS = new Set([
16
+ 'evolagent.list',
17
+ 'menu.exec',
18
+ 'monitor-snapshot',
19
+ 'monitor-subscribe',
20
+ 'monitor-unsubscribe',
21
+ ]);
16
22
  export function ipcQuery(socketPath, cmd, timeoutMs = 3000) {
17
23
  let request = cmd;
18
24
  if (LOCAL_CONTROL_COMMANDS.has(cmd.type) && typeof cmd.controlToken !== 'string') {
package/dist/paths.js CHANGED
@@ -25,10 +25,27 @@ export function resolveRoot() {
25
25
  }
26
26
  return _root;
27
27
  }
28
+ function stripTrailingWindowsSeparators(value) {
29
+ const stripped = value.replace(/[\\/]+$/g, '');
30
+ // Keep a drive-root separator. `C:` is a drive-relative path on Windows.
31
+ if (/^[a-zA-Z]:$/.test(stripped) && /^[a-zA-Z]:[\\/]+$/.test(value)) {
32
+ return `${stripped}\\`;
33
+ }
34
+ return stripped;
35
+ }
36
+ /**
37
+ * Canonicalize EVOLCORE_HOME exactly like the main EvolCore package does.
38
+ * The CLI and EC Web may receive the same Windows directory with different
39
+ * casing, slash styles, or a trailing separator; all must select one pipe.
40
+ */
41
+ export function normalizeWindowsSocketRoot(root) {
42
+ return stripTrailingWindowsSeparators(path.win32.resolve(root)).toLowerCase();
43
+ }
28
44
  /** socket 路径派生:与 evolcore resolveInstanceSocketPath 完全一致 */
29
45
  function resolveInstanceSocketPath(root) {
30
46
  if (isWindows) {
31
- const hash = crypto.createHash('sha1').update(root).digest('hex').slice(0, 12);
47
+ const canonicalRoot = normalizeWindowsSocketRoot(root);
48
+ const hash = crypto.createHash('sha1').update(canonicalRoot).digest('hex').slice(0, 12);
32
49
  return `\\\\.\\pipe\\daemon-${hash}`;
33
50
  }
34
51
  return path.join(root, 'data', 'instance', 'daemon.sock');
package/dist/server.js CHANGED
@@ -555,7 +555,30 @@ function handleConnection(ws, req, log, tokenRecord) {
555
555
  send({ type: 'error', message: `unknown view: ${view}` });
556
556
  return;
557
557
  }
558
- const sourceParams = { ...params, __menuAuth: daemonOwnerMenuAuth(localDirect, actorAid) };
558
+ const sourceParams = {
559
+ ...params,
560
+ __menuAuth: daemonOwnerMenuAuth(localDirect, actorAid),
561
+ __ecwebRequestId: crypto.randomUUID(),
562
+ __ecwebEndpoint: `WS snapshot:${view}`,
563
+ };
564
+ let deactivate = null;
565
+ if (source.activate) {
566
+ try {
567
+ deactivate = await source.activate(sourceParams);
568
+ }
569
+ catch (e) {
570
+ if (generation === subscriptionGeneration)
571
+ send({ type: 'error', message: `subscription failed: ${e?.message || e}` });
572
+ return;
573
+ }
574
+ if (generation !== subscriptionGeneration) {
575
+ deactivate();
576
+ return;
577
+ }
578
+ // Install cleanup before awaiting the initial snapshot, so a view switch
579
+ // or socket close releases the monitor lease immediately.
580
+ unsubscribe = deactivate;
581
+ }
559
582
  // System 视图:把 ECWeb 自身版本合并进快照(前端版本卡用)
560
583
  const decorate = (data) => (view === 'system' && data ? { ...data, ecwebVersion: ECWEB_VERSION } : data);
561
584
  let initialSnapshot;
@@ -570,16 +593,30 @@ function handleConnection(ws, req, log, tokenRecord) {
570
593
  return;
571
594
  send({ type: 'error', message: `snapshot failed: ${e?.message || e}` });
572
595
  }
573
- const nextUnsubscribe = source.subscribe(sourceParams, (data) => {
574
- if (generation === subscriptionGeneration && currentView === view) {
575
- send({ type: 'delta', view, data: decorate(data) });
596
+ let nextUnsubscribe;
597
+ try {
598
+ nextUnsubscribe = source.subscribe(sourceParams, (data) => {
599
+ if (generation === subscriptionGeneration && currentView === view) {
600
+ send({ type: 'delta', view, data: decorate(data) });
601
+ }
602
+ });
603
+ }
604
+ catch (e) {
605
+ deactivate?.();
606
+ if (generation === subscriptionGeneration) {
607
+ unsubscribe = null;
608
+ send({ type: 'error', message: `subscription failed: ${e?.message || e}` });
576
609
  }
577
- });
610
+ return;
611
+ }
578
612
  if (generation !== subscriptionGeneration) {
579
613
  nextUnsubscribe();
580
614
  return;
581
615
  }
582
- unsubscribe = nextUnsubscribe;
616
+ unsubscribe = () => {
617
+ nextUnsubscribe();
618
+ deactivate?.();
619
+ };
583
620
  };
584
621
  ws.on('message', async (raw) => {
585
622
  let msg;
@@ -14,6 +14,7 @@
14
14
  import fs from 'node:fs';
15
15
  import { resolvePaths } from '../paths.js';
16
16
  import { ipcQuery } from '../ipc-client.js';
17
+ import crypto from 'node:crypto';
17
18
  function processGroupKey(process) {
18
19
  return `${process.kind}\u0000${process.name}`;
19
20
  }
@@ -192,9 +193,9 @@ function sampleNetworkRate(ts) {
192
193
  previousNetworkSample = { ...totals, ts };
193
194
  return latestNetworkRate;
194
195
  }
195
- async function fetchSnapshot() {
196
+ async function fetchSnapshot(leaseId) {
196
197
  const p = resolvePaths();
197
- const resp = await ipcQuery(p.socket, { type: 'monitor-snapshot' }, 3000);
198
+ const resp = await ipcQuery(p.socket, { type: 'monitor-snapshot', ...(leaseId ? { leaseId } : {}) }, 3000);
198
199
  // resp === null → daemon 离线或 socket 不可达;resp.ok === false → 命令不受支持(旧 daemon)
199
200
  if (!resp?.ok) {
200
201
  return { daemonRunning: resp !== null, snapshot: null, history: { fine: [], mid: [], coarse: [] } };
@@ -237,8 +238,24 @@ async function fetchSnapshot() {
237
238
  }
238
239
  export const monitorSource = {
239
240
  kind: 'monitor',
240
- async snapshot() {
241
- return fetchSnapshot();
241
+ async activate(params) {
242
+ const leaseId = crypto.randomUUID();
243
+ const p = resolvePaths();
244
+ const response = await ipcQuery(p.socket, { type: 'monitor-subscribe', leaseId }, 3000);
245
+ if (!response?.ok)
246
+ throw new Error('daemon rejected monitor subscription');
247
+ params.__monitorLeaseId = leaseId;
248
+ let released = false;
249
+ return () => {
250
+ if (released)
251
+ return;
252
+ released = true;
253
+ delete params.__monitorLeaseId;
254
+ void ipcQuery(p.socket, { type: 'monitor-unsubscribe', leaseId }, 3000);
255
+ };
256
+ },
257
+ async snapshot(params) {
258
+ return fetchSnapshot(params?.__monitorLeaseId);
242
259
  },
243
260
  subscribe(_params, push) {
244
261
  let lastJson = '';
@@ -247,7 +264,7 @@ export const monitorSource = {
247
264
  if (stopped)
248
265
  return;
249
266
  try {
250
- const snap = await fetchSnapshot();
267
+ const snap = await fetchSnapshot(_params.__monitorLeaseId);
251
268
  const json = JSON.stringify(snap);
252
269
  if (json !== lastJson) {
253
270
  lastJson = json;
@@ -1,8 +1,17 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { resolvePaths } from '../paths.js';
4
- import { getSessionsAunDir, listPeers, readJsonFile, readMessages, } from '../fs-utils.js';
4
+ import { getSessionsAunDir, isTransientProtocolMessage, readJsonFile, readAllJsonlLines, scanChatDirs, } from '../fs-utils.js';
5
5
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
6
+ import crypto from 'node:crypto';
7
+ function ecwebAuditContext(auth, aid, operation) {
8
+ return {
9
+ ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
10
+ targetAgentAid: aid,
11
+ endpoint: auth.endpoint ?? 'ECWeb relation roles',
12
+ logicalToolName: operation,
13
+ };
14
+ }
6
15
  async function getParentModules() {
7
16
  const configPath = resolveParentDistModule('config', 'config-manager.js');
8
17
  const rolesPath = resolveParentDistModule('config', 'roles.js');
@@ -116,6 +125,7 @@ async function authorizeRoleAssignmentRead(modules, aid, auth) {
116
125
  reason: decision.allow ? undefined : decision.reason,
117
126
  matchedRule: decision.matchedRule,
118
127
  argsSummary: { self: aid },
128
+ ...ecwebAuditContext(auth, aid, 'role.relation.read'),
119
129
  });
120
130
  if (decision.allow)
121
131
  return { allow: true, actorRole: actor.actorRole };
@@ -150,6 +160,7 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
150
160
  code: 'IMMUTABLE_STATIC_OWNER',
151
161
  reason,
152
162
  argsSummary: { ...args, targetRole },
163
+ ...ecwebAuditContext(auth, aid, operation),
153
164
  });
154
165
  return {
155
166
  allow: false,
@@ -194,6 +205,7 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
194
205
  reason: decision.reason,
195
206
  matchedRule: decision.matchedRule,
196
207
  argsSummary: { ...args, targetRole },
208
+ ...ecwebAuditContext(auth, aid, operation),
197
209
  });
198
210
  return {
199
211
  allow: false,
@@ -227,6 +239,7 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
227
239
  reason,
228
240
  matchedRule: decision.matchedRule,
229
241
  argsSummary: { ...args, targetRole },
242
+ ...ecwebAuditContext(auth, aid, operation),
230
243
  });
231
244
  return {
232
245
  allow: false,
@@ -251,6 +264,7 @@ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targe
251
264
  decision: 'allow',
252
265
  matchedRule: decision.matchedRule,
253
266
  argsSummary: { ...args, targetRole },
267
+ ...ecwebAuditContext(auth, aid, operation),
254
268
  });
255
269
  return { allow: true, actorRole };
256
270
  }
@@ -323,12 +337,6 @@ function relationTargetFromId(raw) {
323
337
  }
324
338
  return { peerKey: formatPeerKey('aun', value), channelId: value };
325
339
  }
326
- function readActive(aunDir, aid, conversationId) {
327
- return readJsonFile(path.join(aunDir, encodeSegmentForRoleSource(aid), encodeSegmentForRoleSource(conversationId), 'active.json'));
328
- }
329
- function encodeSegmentForRoleSource(s) {
330
- return s.replace(/[<>:"/\\|?*\x00-\x1F%]/g, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
331
- }
332
340
  function frontmatterValue(frontmatter, key) {
333
341
  const re = new RegExp(`^${key}:\\s*(?:"([^"]*)"|'([^']*)'|([^\\r\\n#]*))\\s*(?:#.*)?$`, 'm');
334
342
  const match = frontmatter.match(re);
@@ -380,6 +388,20 @@ function createLocalAgentMdLookup() {
380
388
  return info;
381
389
  };
382
390
  }
391
+ function readPeerVerificationStatus(selfAid, peerId) {
392
+ if (!selfAid || !peerId)
393
+ return 'unknown';
394
+ const peerKey = formatPeerKey('aun', peerId);
395
+ const identity = readJsonFile(path.join(resolvePaths().root, 'agents', selfAid, 'relations', peerKey, 'peer-identity.json'));
396
+ const raw = String(identity?.source ?? identity?.verifyStatus ?? identity?.verify_status ?? '').trim().toLowerCase();
397
+ if (raw === 'agentmd' || raw === 'verified')
398
+ return 'agentmd';
399
+ if (raw === 'agentmd-unverified' || raw === 'unverified')
400
+ return 'agentmd-unverified';
401
+ if (raw === 'invalid')
402
+ return 'invalid';
403
+ return 'unknown';
404
+ }
383
405
  function latestMessageAt(messages) {
384
406
  let lastAt = 0;
385
407
  for (const message of messages) {
@@ -418,7 +440,7 @@ function firstMessagePeerType(messages, peerId) {
418
440
  function conversationSortName(seed) {
419
441
  return (seed.name || seed.peerAid || seed.groupId || seed.conversationId).toLowerCase();
420
442
  }
421
- function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
443
+ function groupMembersFromMessages(selfAid, messages, localAgentMdLookup, peerVerificationLookup) {
422
444
  const members = new Map();
423
445
  for (const message of messages) {
424
446
  const sender = message.from;
@@ -448,6 +470,7 @@ function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
448
470
  const localInfo = localAgentMdLookup(member.peerId);
449
471
  member.peerName = localInfo?.name ?? member.peerName;
450
472
  member.peerType = localInfo?.peerType ?? member.peerType;
473
+ member.verifyStatus = peerVerificationLookup(selfAid, member.peerId);
451
474
  }
452
475
  return [...members.values()].sort((a, b) => {
453
476
  const lastDiff = (b.lastAt ?? 0) - (a.lastAt ?? 0);
@@ -456,7 +479,7 @@ function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
456
479
  return (a.peerName || a.peerAid).localeCompare(b.peerName || b.peerAid);
457
480
  });
458
481
  }
459
- export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () => undefined) {
482
+ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () => undefined, peerVerificationLookup = readPeerVerificationStatus) {
460
483
  const seeds = [];
461
484
  for (const peer of peerInfos) {
462
485
  const messages = peer.messages || [];
@@ -478,7 +501,7 @@ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () =
478
501
  lastAt,
479
502
  inbound: counts.inbound,
480
503
  outbound: counts.outbound,
481
- members: groupMembersFromMessages(aid, messages, localAgentMdLookup),
504
+ members: groupMembersFromMessages(aid, messages, localAgentMdLookup, peerVerificationLookup),
482
505
  });
483
506
  continue;
484
507
  }
@@ -499,6 +522,7 @@ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () =
499
522
  peerKey: peerId,
500
523
  peerName,
501
524
  peerType,
525
+ verifyStatus: peerVerificationLookup(aid, peerId),
502
526
  lastAt,
503
527
  inbound: counts.inbound,
504
528
  outbound: counts.outbound,
@@ -515,13 +539,20 @@ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () =
515
539
  }
516
540
  function loadConversationSeeds(aid) {
517
541
  const aunDir = getSessionsAunDir();
542
+ const peerInfoById = new Map();
543
+ for (const chat of scanChatDirs(aunDir).filter(item => item.channelType === 'aun' && item.selfAID === aid)) {
544
+ const current = peerInfoById.get(chat.channelId) ?? { conversationId: chat.channelId, messages: [] };
545
+ const active = readJsonFile(path.join(chat.dirPath, 'active.json'));
546
+ if (active && (!current.active || Number(active.updatedAt || 0) >= Number(current.active.updatedAt || 0))) {
547
+ current.active = active;
548
+ }
549
+ current.messages.push(...readAllJsonlLines(path.join(chat.dirPath, 'messages.jsonl'))
550
+ .filter(message => !isTransientProtocolMessage(message)));
551
+ peerInfoById.set(chat.channelId, current);
552
+ }
518
553
  const localAgentMdLookup = createLocalAgentMdLookup();
519
- const peerInfos = listPeers(aunDir, aid).map(conversationId => ({
520
- conversationId,
521
- active: readActive(aunDir, aid, conversationId),
522
- messages: readMessages(aunDir, aid, conversationId),
523
- }));
524
- return buildConversationSeeds(aid, peerInfos, localAgentMdLookup);
554
+ const peerInfos = [...peerInfoById.values()];
555
+ return buildConversationSeeds(aid, peerInfos, localAgentMdLookup, readPeerVerificationStatus);
525
556
  }
526
557
  function readRelationConfig(config, aid, peerKey) {
527
558
  return config.read(config.ConfigTarget.Relation, { self: aid, peerKey }, { cache: true }) || {};
@@ -648,6 +679,11 @@ function listRelationRoleAssignments(config, aid) {
648
679
  return out;
649
680
  }
650
681
  async function buildSnapshot(auth = {}) {
682
+ auth = {
683
+ ...auth,
684
+ ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
685
+ endpoint: auth.endpoint ?? 'WS snapshot:roles',
686
+ };
651
687
  const modules = await getParentModules();
652
688
  const agents = await getAgentsFromIpc();
653
689
  const snapshotAgents = [];
@@ -678,7 +714,11 @@ export const relationRolesSource = {
678
714
  kind: 'roles',
679
715
  async snapshot(params = {}) {
680
716
  try {
681
- return await buildSnapshot(params.__menuAuth ?? {});
717
+ return await buildSnapshot({
718
+ ...(params.__menuAuth ?? {}),
719
+ ecwebRequestId: params.__ecwebRequestId,
720
+ endpoint: params.__ecwebEndpoint,
721
+ });
682
722
  }
683
723
  catch (err) {
684
724
  return { agents: [], conversations: [], error: String(err) };
@@ -765,6 +805,11 @@ function deleteScopedAssignment(configMod, aid, body, fallbackTargetId) {
765
805
  export async function handleRelationRolesApi(req, res, auth = {}) {
766
806
  try {
767
807
  const urlPath = (req.url || '').split('?')[0];
808
+ auth = {
809
+ ...auth,
810
+ ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
811
+ endpoint: auth.endpoint ?? `${req.method ?? 'UNKNOWN'} ${urlPath}`,
812
+ };
768
813
  const modules = await getParentModules();
769
814
  if (req.method === 'GET' && urlPath.startsWith('/api/roles/agent/')) {
770
815
  const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
@@ -802,6 +847,11 @@ export async function handleRelationRolesApi(req, res, auth = {}) {
802
847
  export async function handlePeerRoleApi(req, res, auth = {}) {
803
848
  try {
804
849
  const urlPath = (req.url || '').split('?')[0];
850
+ auth = {
851
+ ...auth,
852
+ ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
853
+ endpoint: auth.endpoint ?? `${req.method ?? 'UNKNOWN'} ${urlPath}`,
854
+ };
805
855
  const target = assignmentFromPath(urlPath);
806
856
  if (!target)
807
857
  return sendJson(res, 400, { error: 'missing aid or target id' });
@@ -14,6 +14,7 @@
14
14
  import { getModelCatalogSnapshot } from './models.js';
15
15
  import { resolveParentDistModule, toFileUrl } from './parent-package.js';
16
16
  import { resolvePaths } from '../paths.js';
17
+ import crypto from 'node:crypto';
17
18
  // 动态导入 evolcore 主项目的角色配置模块
18
19
  async function getRolesModule() {
19
20
  const rolesPath = resolveParentDistModule('config', 'roles.js');
@@ -82,6 +83,14 @@ async function getOperationRegistryModule() {
82
83
  listOperations: mod.listOperations,
83
84
  };
84
85
  }
86
+ function ecwebAuditContext(auth, aid, operation) {
87
+ return {
88
+ ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
89
+ targetAgentAid: aid,
90
+ endpoint: auth.endpoint ?? 'ECWeb role policy',
91
+ logicalToolName: operation,
92
+ };
93
+ }
85
94
  export function visibleRoleDefinitionOptions(roleOptions) {
86
95
  return roleOptions
87
96
  .filter(item => item?.kind === 'user' || item?.value === 'admin')
@@ -142,6 +151,7 @@ async function authorizeRolePolicyOperation(modules, processConfig, auth, aid, o
142
151
  ...args,
143
152
  },
144
153
  },
154
+ auditMetadata: ecwebAuditContext(auth, aid, operation),
145
155
  });
146
156
  }
147
157
  function isDaemonOwnerAuth(processConfig, auth) {
@@ -574,6 +584,11 @@ async function buildModelPermissionResponse(roleName, roleDef, override) {
574
584
  }
575
585
  async function buildSnapshot(params = {}, auth = {}) {
576
586
  try {
587
+ auth = {
588
+ ...auth,
589
+ ecwebRequestId: params.__ecwebRequestId ?? auth.ecwebRequestId ?? crypto.randomUUID(),
590
+ endpoint: params.__ecwebEndpoint ?? auth.endpoint ?? 'WS snapshot:roleDefinitions',
591
+ };
577
592
  const agents = await getAgentsFromIpc();
578
593
  const aid = resolveRequestedAid(params.aid, agents);
579
594
  if (!aid)
@@ -614,7 +629,11 @@ async function buildSnapshot(params = {}, auth = {}) {
614
629
  export const roleDefinitionsSource = {
615
630
  kind: 'roleDefinitions',
616
631
  async snapshot(params = {}) {
617
- return buildSnapshot(params, params.__menuAuth ?? {});
632
+ return buildSnapshot(params, {
633
+ ...(params.__menuAuth ?? {}),
634
+ ecwebRequestId: params.__ecwebRequestId,
635
+ endpoint: params.__ecwebEndpoint,
636
+ });
618
637
  },
619
638
  subscribe(_params, _push) {
620
639
  return () => { };
@@ -625,6 +644,11 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
625
644
  try {
626
645
  const parsedUrl = parseUrl(req.url || '');
627
646
  const urlPath = parsedUrl.path;
647
+ auth = {
648
+ ...auth,
649
+ ecwebRequestId: auth.ecwebRequestId ?? crypto.randomUUID(),
650
+ endpoint: auth.endpoint ?? `${req.method ?? 'UNKNOWN'} ${urlPath}`,
651
+ };
628
652
  const agents = await getAgentsFromIpc();
629
653
  const aid = resolveRequestedAid(parsedUrl.query.aid, agents);
630
654
  const modules = await getRolesModule();
@@ -693,7 +717,7 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
693
717
  }
694
718
  }
695
719
  if (req.method === 'GET' && urlPath === '/api/role-definitions') {
696
- sendJson(res, 200, await buildSnapshot({ aid }, auth));
720
+ sendJson(res, 200, await buildSnapshot({ aid, __ecwebRequestId: auth.ecwebRequestId, __ecwebEndpoint: auth.endpoint }, auth));
697
721
  return;
698
722
  }
699
723
  if (req.method === 'PUT' && urlPath === '/api/role-definitions') {
@@ -755,7 +779,7 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
755
779
  }
756
780
  writeRoleRegistry(modules, aid, nextDefaults);
757
781
  }
758
- sendJson(res, 200, { ok: true, data: await buildSnapshot({ aid }, auth) });
782
+ sendJson(res, 200, { ok: true, data: await buildSnapshot({ aid, __ecwebRequestId: auth.ecwebRequestId, __ecwebEndpoint: auth.endpoint }, auth) });
759
783
  }
760
784
  catch (err) {
761
785
  console.error('[role-definitions] Failed to update agent role config:', err);
@@ -581,20 +581,32 @@ function approvalAuditFromPayload(payload) {
581
581
  }
582
582
  function classifyCodexToolRejection(text, payload) {
583
583
  const normalized = text.replace(/\\(["'])/g, '$1');
584
- if (!/Rejected\(\s*["']rejected by user["']\s*\)/i.test(normalized))
584
+ const hookMatch = normalized.match(/(?:Command|Tool call) blocked by PreToolUse hook:\s*(.+?)(?:\.\s+(?:Command|Tool):|$)/is);
585
+ const genericRejected = /Rejected\(\s*["']rejected by user["']\s*\)/i.test(normalized);
586
+ if (!hookMatch && !genericRejected)
585
587
  return undefined;
586
588
  const audit = approvalAuditFromPayload(payload);
587
- const source = audit.policyCode || audit.decisionSource?.toLowerCase().includes('policy')
589
+ const source = hookMatch || audit.policyCode || audit.decisionSource?.toLowerCase().includes('policy')
588
590
  ? 'evolcore-policy'
589
591
  : 'codex-approval';
592
+ const backendReason = hookMatch?.[1]?.trim()
593
+ || 'Rejected("rejected by user")';
594
+ const inferredPolicyCode = audit.policyCode
595
+ || (hookMatch && /工具调用已达到\s*\d+\s*次/.test(backendReason)
596
+ ? 'proactive_tool_report_required'
597
+ : hookMatch && /请先用\s+ec\s+(?:group\s+send|msg\s+send)/.test(backendReason)
598
+ ? 'proactive_first_send_required'
599
+ : undefined);
590
600
  return {
591
- kind: 'approval_rejected',
601
+ kind: source === 'evolcore-policy' ? 'policy_blocked' : 'approval_rejected',
592
602
  source,
593
- reason: 'rejected_by_user',
603
+ reason: source === 'evolcore-policy' ? 'policy_denied' : 'rejected_by_user',
594
604
  executed: false,
595
605
  evidence: audit.available ? 'structured_metadata' : 'backend_text',
596
- backendReason: 'Rejected("rejected by user")',
597
- audit,
606
+ backendReason,
607
+ audit: inferredPolicyCode && !audit.policyCode
608
+ ? { ...audit, policyCode: inferredPolicyCode }
609
+ : audit,
598
610
  };
599
611
  }
600
612
  export function parseCodexToolEvent(event) {
@@ -60,6 +60,63 @@ function normalizeAgent(item) {
60
60
  baseagent: typeof item?.baseagent === 'string' ? item.baseagent : undefined,
61
61
  };
62
62
  }
63
+ function channelTypeFromKey(channelKey) {
64
+ const value = typeof channelKey === 'string' ? channelKey.trim() : '';
65
+ if (!value)
66
+ return '';
67
+ const separator = value.indexOf('#');
68
+ return separator > 0 ? value.slice(0, separator) : value;
69
+ }
70
+ function triggerScheduleView(definition, fallback) {
71
+ const source = definition?.source ?? fallback?.source;
72
+ if (source && typeof source === 'object') {
73
+ switch (source.type) {
74
+ case 'once': return { scheduleType: 'once', scheduleValue: '' };
75
+ case 'delay': return { scheduleType: 'delay', scheduleValue: String(source.afterMs ?? '') };
76
+ case 'at': return { scheduleType: 'at', scheduleValue: String(source.at ?? '') };
77
+ case 'cron': return { scheduleType: 'cron', scheduleValue: String(source.expression ?? '') };
78
+ case 'interval': return { scheduleType: 'interval', scheduleValue: String(source.everyMs ?? '') };
79
+ case 'event': return { scheduleType: 'event', scheduleValue: String(source.eventPattern ?? '') };
80
+ default: break;
81
+ }
82
+ }
83
+ return {
84
+ scheduleType: String(fallback?.scheduleType ?? ''),
85
+ scheduleValue: String(fallback?.scheduleValue ?? ''),
86
+ };
87
+ }
88
+ function triggerDisplayFields(definition, fallback) {
89
+ const feedback = definition?.feedback ?? fallback?.feedback;
90
+ const origin = definition?.origin;
91
+ const target = feedback?.strategy === 'target'
92
+ ? (feedback.target ?? fallback?.feedback?.target)
93
+ : feedback?.strategy === 'origin'
94
+ ? {
95
+ channelKey: origin?.channelKey,
96
+ channelId: origin?.channelId ?? origin?.peerId,
97
+ channelType: origin?.channelType,
98
+ }
99
+ : null;
100
+ const schedule = triggerScheduleView(definition, fallback);
101
+ const targetChannel = target?.channelKey ?? fallback?.targetChannel ?? '';
102
+ const targetChannelType = target?.channelType
103
+ ?? fallback?.targetChannelType
104
+ ?? (feedback?.strategy === 'target'
105
+ ? channelTypeFromKey(targetChannel)
106
+ : (origin?.channelType ?? channelTypeFromKey(targetChannel)));
107
+ return {
108
+ scheduleType: schedule.scheduleType,
109
+ scheduleValue: schedule.scheduleValue,
110
+ targetChannel,
111
+ targetChannelName: targetChannel || fallback?.targetChannelName || '',
112
+ targetChannelId: target?.channelId ?? fallback?.targetChannelId ?? '',
113
+ targetChannelType,
114
+ createdByPeerId: origin?.peerId ?? fallback?.createdByPeerId ?? '',
115
+ createdByChannel: origin?.channelKey ?? fallback?.createdByChannel ?? '',
116
+ createdAt: definition?.createdAt ?? fallback?.createdAt,
117
+ updatedAt: definition?.updatedAt ?? fallback?.updatedAt,
118
+ };
119
+ }
63
120
  async function listTriggersForAgent(agentAid, agentLabel) {
64
121
  const p = resolvePaths();
65
122
  const controlToken = readTriggerControlToken(p.instanceDir);
@@ -106,6 +163,7 @@ async function listTriggersForAgent(agentAid, agentLabel) {
106
163
  revision,
107
164
  definitionError,
108
165
  subscription,
166
+ ...triggerDisplayFields(definition, trigger),
109
167
  };
110
168
  }));
111
169
  }
@@ -2256,6 +2256,12 @@ function subscribeRoleManagementSubview() {
2256
2256
  function setRoleManagementSubview(subview, options = {}) {
2257
2257
  roleManagementSubview = subview === 'assignments' ? 'assignments' : 'definitions';
2258
2258
  localStorage.setItem(ROLE_SUBVIEW_KEY, roleManagementSubview);
2259
+ const newRoleButton = $('#btn-new-role');
2260
+ if (newRoleButton) {
2261
+ const visible = roleManagementSubview === 'definitions';
2262
+ newRoleButton.hidden = !visible;
2263
+ newRoleButton.setAttribute('aria-hidden', String(!visible));
2264
+ }
2259
2265
  document.querySelectorAll('[data-role-subview]').forEach(tab => {
2260
2266
  const active = tab.dataset.roleSubview === roleManagementSubview;
2261
2267
  tab.classList.toggle('active', active);
@@ -3815,7 +3821,7 @@ function renderBlocks(blocks, options = {}) {
3815
3821
  : '';
3816
3822
  out += `<details class="blk blk-tool" open><summary class="tool-head">${icon} <span class="tool-name">${esc(tool)}</span></summary>${toolParams}</details>`;
3817
3823
  } else if (b.kind === 'tool_result') {
3818
- const rejection = b.rejection?.kind === 'approval_rejected' ? b.rejection : undefined;
3824
+ const rejection = b.rejection && (b.rejection.kind === 'approval_rejected' || b.rejection.kind === 'policy_blocked') ? b.rejection : undefined;
3819
3825
  const cls = b.isError ? `blk-result err${rejection ? ' rejection' : ''}` : 'blk-result';
3820
3826
  const loadFull = b.truncated && b.resultKey
3821
3827
  ? `<button class="ctrl-btn result-load-full" type="button" data-result-key="${esc(b.resultKey)}">${t('sessions.loadFullResult') || '加载完整结果'}</button>`
@@ -3839,7 +3845,7 @@ function renderBlocks(blocks, options = {}) {
3839
3845
  : '';
3840
3846
  return '<div class="result-rejection-meta">' +
3841
3847
  `<span class="result-rejection-badge">⚠ ${t('sessions.block.rejection.title')}</span>` +
3842
- `<span>${rejection.source === 'evolcore-policy' ? t('sessions.block.rejection.source.policy') : t('sessions.block.rejection.source.approval')}</span>` +
3848
+ `<span>${rejection.kind === 'policy_blocked' || rejection.source === 'evolcore-policy' ? t('sessions.block.rejection.source.policy') : t('sessions.block.rejection.source.approval')}</span>` +
3843
3849
  `<span>${t('sessions.block.rejection.notExecuted')}</span>` +
3844
3850
  `<span>${t(`sessions.block.rejection.evidence.${evidenceKey}`)}</span>` +
3845
3851
  backendReason + auditText +
@@ -9092,11 +9098,39 @@ function monDualLine(elId, varKey, times, isDark, title, series, fmtY, yRange) {
9092
9098
  }
9093
9099
 
9094
9100
  function renderRoles(data) {
9095
- if (!data) return;
9096
-
9097
9101
  const agentSelect = $('#roles-agent-select');
9102
+ const tbody = document.querySelector('#relations-table tbody');
9103
+ if (!data) {
9104
+ if (tbody) {
9105
+ tbody.innerHTML = '';
9106
+ const row = tbody.insertRow();
9107
+ const cell = row.insertCell();
9108
+ cell.colSpan = 7;
9109
+ cell.textContent = t('common.loading');
9110
+ cell.style.textAlign = 'center';
9111
+ cell.style.color = 'var(--dim)';
9112
+ cell.style.padding = '40px';
9113
+ }
9114
+ return;
9115
+ }
9098
9116
  if (!agentSelect) return;
9099
9117
 
9118
+ if (data.error) {
9119
+ agentSelect.innerHTML = `<option value="">${esc(t('roles.selectAgentPlaceholder'))}</option>`;
9120
+ rolesCurrentAgent = null;
9121
+ if (tbody) {
9122
+ tbody.innerHTML = '';
9123
+ const row = tbody.insertRow();
9124
+ const cell = row.insertCell();
9125
+ cell.colSpan = 7;
9126
+ cell.textContent = `${t('common.error')}: ${String(data.error)}`;
9127
+ cell.style.textAlign = 'center';
9128
+ cell.style.color = 'var(--red)';
9129
+ cell.style.padding = '40px';
9130
+ }
9131
+ return;
9132
+ }
9133
+
9100
9134
  agentSelect.innerHTML = '';
9101
9135
  const placeholderOption = document.createElement('option');
9102
9136
  placeholderOption.value = '';
@@ -9152,6 +9186,34 @@ function peerTypeText(peerType) {
9152
9186
  return '-';
9153
9187
  }
9154
9188
 
9189
+ function roleVerificationStatus(item) {
9190
+ const status = String(item?.verifyStatus || 'unknown').toLowerCase();
9191
+ if (status === 'agentmd' || status === 'verified') return 'verified';
9192
+ if (status === 'agentmd-unverified' || status === 'unverified') return 'agentmd-unverified';
9193
+ if (status === 'invalid') return 'invalid';
9194
+ return 'unknown';
9195
+ }
9196
+
9197
+ function roleVerificationMatches(item, filter) {
9198
+ if (!filter) return true;
9199
+ const status = roleVerificationStatus(item);
9200
+ return filter === 'verified' ? status === 'verified' : status === filter;
9201
+ }
9202
+
9203
+ function roleVerificationBadge(item) {
9204
+ const status = roleVerificationStatus(item);
9205
+ if (status === 'invalid') {
9206
+ return ` <span class="verify-badge verify-invalid" title="${esc(t('roles.verify.invalid'))}">⚠️ ${esc(t('roles.verify.invalidShort'))}</span>`;
9207
+ }
9208
+ if (status === 'agentmd-unverified') {
9209
+ return ` <span class="verify-badge verify-unverified" title="${esc(t('roles.verify.unverified'))}">⚠️</span>`;
9210
+ }
9211
+ if (status === 'unknown') {
9212
+ return ` <span class="verify-badge verify-unknown" title="${esc(t('roles.verify.unknown'))}">❓</span>`;
9213
+ }
9214
+ return '';
9215
+ }
9216
+
9155
9217
  function roleClass(role) {
9156
9218
  return String(role || 'none').replace(/[^a-z0-9_-]/gi, '-');
9157
9219
  }
@@ -9188,6 +9250,16 @@ function renderRelationsTable(data, filterAid = null) {
9188
9250
  if (!tbody) return;
9189
9251
 
9190
9252
  tbody.innerHTML = '';
9253
+ if (data?.error) {
9254
+ const row = tbody.insertRow();
9255
+ const cell = row.insertCell();
9256
+ cell.colSpan = 7;
9257
+ cell.textContent = `${t('common.error')}: ${String(data.error)}`;
9258
+ cell.style.textAlign = 'center';
9259
+ cell.style.color = 'var(--red)';
9260
+ cell.style.padding = '40px';
9261
+ return;
9262
+ }
9191
9263
  let conversations = data.conversations || [];
9192
9264
 
9193
9265
  if (filterAid) {
@@ -9215,6 +9287,16 @@ function renderRelationsTable(data, filterAid = null) {
9215
9287
  conversations = conversations.filter(conv => conv.chatType === rolesChatTypeFilter);
9216
9288
  }
9217
9289
 
9290
+ if (rolesVerifyFilter) {
9291
+ conversations = conversations
9292
+ .map(conv => conv.chatType === 'group'
9293
+ ? { ...conv, members: (conv.members || []).filter(member => roleVerificationMatches(member, rolesVerifyFilter)) }
9294
+ : conv)
9295
+ .filter(conv => conv.chatType === 'group'
9296
+ ? (conv.members || []).length > 0
9297
+ : roleVerificationMatches(conv, rolesVerifyFilter));
9298
+ }
9299
+
9218
9300
  conversations.sort((a, b) => {
9219
9301
  const lastDiff = (b.lastAt || 0) - (a.lastAt || 0);
9220
9302
  if (lastDiff !== 0) return lastDiff;
@@ -9244,10 +9326,12 @@ function renderPrivateConversationRow(tbody, conv) {
9244
9326
  const peerId = conv.peerId || conv.peerAid || conv.peerKey || conv.conversationId;
9245
9327
  const name = conv.peerName || conv.name || shortAid(peerId);
9246
9328
  const assignedRole = assignedRoleFor('private', conv);
9329
+ const verifyBadge = roleVerificationBadge(conv);
9330
+ const verifyClass = roleVerificationStatus(conv) === 'verified' ? '' : ` peer-${roleVerificationStatus(conv) === 'unknown' ? 'unknown' : 'unverified'}`;
9247
9331
  const row = tbody.insertRow();
9248
9332
  row.innerHTML = `
9249
9333
  <td><span class="chat-tag chat-tag-private">${esc(t('roles.chatType.private') || 'Private')}</span></td>
9250
- <td><strong>${esc(name)}</strong></td>
9334
+ <td class="${verifyClass.trim()}"><strong>${esc(name)}</strong>${verifyBadge}</td>
9251
9335
  <td><code>${esc(peerId)}</code></td>
9252
9336
  <td>${esc(peerTypeText(conv.peerType))}</td>
9253
9337
  <td><span class="role-badge role-${esc(roleClass(conv.role))}">${esc(displayRole(conv.role))}</span></td>
@@ -9312,11 +9396,13 @@ function renderGroupMemberRow(tbody, conv, member) {
9312
9396
  const peerId = member.peerId || member.peerAid || member.peerKey;
9313
9397
  const name = member.peerName || shortAid(peerId);
9314
9398
  const assignedRole = assignedRoleFor('group-member', member);
9399
+ const verifyBadge = roleVerificationBadge(member);
9400
+ const verifyClass = roleVerificationStatus(member) === 'verified' ? '' : ` peer-${roleVerificationStatus(member) === 'unknown' ? 'unknown' : 'unverified'}`;
9315
9401
  const row = tbody.insertRow();
9316
9402
  row.className = 'group-member-row';
9317
9403
  row.innerHTML = `
9318
9404
  <td></td>
9319
- <td><span class="group-member-indent">${esc(name)}</span></td>
9405
+ <td class="${verifyClass.trim()}"><span class="group-member-indent">${esc(name)}</span>${verifyBadge}</td>
9320
9406
  <td><code>${esc(peerId)}</code></td>
9321
9407
  <td>${esc(peerTypeText(member.peerType))}</td>
9322
9408
  <td><span class="role-badge role-${esc(roleClass(member.role))}">${esc(displayRole(member.role))}</span></td>
@@ -49,7 +49,7 @@
49
49
 
50
50
  </nav>
51
51
  <span style="flex:1"></span>
52
- 2026-09-03 11:55
52
+ 2026-09-05 04:51
53
53
  <span id="conn-status" class="conn-status" data-i18n="status.connecting">连接中…</span>
54
54
  <button id="lang-btn" class="theme-btn" data-i18n-title="language.buttonTitle" title="切换语言">🌐</button>
55
55
  <button id="theme-btn" class="theme-btn" data-i18n-title="theme.buttonTitle" title="主题">🌗</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ec-web",
3
- "version": "0.0.21",
3
+ "version": "0.0.22",
4
4
  "description": "Web console and monitoring dashboard for EvolCore",
5
5
  "type": "module",
6
6
  "bin": {