ec-web 0.0.26 → 0.0.28

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/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;
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,8 +33,8 @@ 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');
@@ -585,16 +585,30 @@ function handleConnection(ws, req, log, tokenRecord) {
585
585
  params.source = msg.source;
586
586
  if (msg.limit)
587
587
  params.limit = msg.limit;
588
+ if (msg.archived !== undefined)
589
+ params.archived = msg.archived;
588
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}`);
589
591
  await switchSubscription(msg.view, params);
590
592
  return;
591
593
  }
592
594
  if (msg.type === 'session.result') {
593
595
  const params = msg.params && typeof msg.params === 'object' ? msg.params : {};
594
- const result = params.baseagent === 'codex'
595
- ? readCodexSessionResult(params)
596
- : readClaudeSessionResult(params);
597
- 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
+ });
598
612
  return;
599
613
  }
600
614
  if (msg.type === 'menu' && msg.payload) {
@@ -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' }));
@@ -912,7 +925,9 @@ export async function startWatchWebServer(opts = {}) {
912
925
  });
913
926
  });
914
927
  const { port, displaced } = await bindPort(server, opts.port ?? DEFAULT_PORT, log);
915
- const initialPair = await remoteAuth('remote-auth.pair-code');
928
+ const initialPair = await remoteAuth('remote-auth.pair-code', {
929
+ source: 'ecweb-local',
930
+ });
916
931
  return {
917
932
  url: `http://0.0.0.0:${port}`,
918
933
  port,
@@ -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 {
@@ -29,6 +29,7 @@ async function getRolesModule() {
29
29
  const resolverMod = await import(toFileUrl(resolverPath));
30
30
  const roleServiceMod = await import(toFileUrl(roleServicePath));
31
31
  const authMod = await import(toFileUrl(authPath));
32
+ const schemaMod = await import(toFileUrl(resolveParentDistModule('config', 'role-schema.js')));
32
33
  if (!rolesMod.readRolesConfig)
33
34
  throw new Error('readRolesConfig not found in roles.js');
34
35
  if (!rolesMod.roleExists)
@@ -54,6 +55,8 @@ async function getRolesModule() {
54
55
  read: cmMod.read,
55
56
  write: cmMod.write,
56
57
  ConfigTarget: cmMod.ConfigTarget,
58
+ resolveEffective: cmMod.resolveEffective,
59
+ rolePermissionFields: schemaMod.rolePermissionFields,
57
60
  resolver: resolverMod,
58
61
  buildAuthSubject: authMod.buildAuthSubject,
59
62
  authorizeOperation: authMod.authorizeOperation,
@@ -66,10 +69,10 @@ async function getRolesModule() {
66
69
  }
67
70
  }
68
71
  async function getRoleConstraintsModule() {
69
- const constraintsPath = resolveParentDistModule('config', 'role-constraints.js');
72
+ const constraintsPath = resolveParentDistModule('core', 'model', 'model-permission.js');
70
73
  const mod = await import(toFileUrl(constraintsPath));
71
74
  if (!mod.isModelAllowedByPatterns)
72
- throw new Error('isModelAllowedByPatterns not found in role-constraints.js');
75
+ throw new Error('isModelAllowedByPatterns not found in model-permission.js');
73
76
  return {
74
77
  isModelAllowedByPatterns: mod.isModelAllowedByPatterns,
75
78
  };
@@ -164,8 +167,16 @@ function isDaemonOwnerAuth(processConfig, auth) {
164
167
  : [];
165
168
  return !!actor && processOwners.includes(actor);
166
169
  }
167
- const MODEL_PERMISSION_FIELD = 'baseagents.claude.model';
168
- const MODEL_PATTERN_OPTIONS = ['*', 'claude-opus-*', 'claude-sonnet-*', 'claude-haiku-*'];
170
+ function roleModelContext(modules, aid, requested) {
171
+ const effective = modules.resolveEffective({ self: aid });
172
+ const baseagent = requested ?? effective.active_baseagent;
173
+ const field = `baseagents.${baseagent}.model`;
174
+ const descriptor = modules.rolePermissionFields().find((item) => item.value === field);
175
+ if (!descriptor) {
176
+ throw Object.assign(new Error(`Unsupported role model backend: ${String(baseagent)}`), { statusCode: 400 });
177
+ }
178
+ return { baseagent, field, patternOptions: descriptor.patterns?.map((item) => item.value) ?? ['*'] };
179
+ }
169
180
  const COMMAND_PERMISSION_SCOPES = new Set(['relation', 'role', 'agent', 'process', 'filesystem', 'control', 'raw-cli']);
170
181
  const COMMAND_CONSTRAINT_KEYS = new Set([
171
182
  'ownPeerOnly',
@@ -495,11 +506,11 @@ function normalizeRoleDefinitionForSave(roleDef) {
495
506
  return roleDef;
496
507
  return { ...roleDef, usageLimits: normalizeRoleUsageLimits(roleDef.usageLimits) };
497
508
  }
498
- function getModelPermission(roleDef) {
509
+ function getModelPermission(roleDef, field) {
499
510
  const permissions = roleDef?.permissions;
500
511
  if (!permissions || typeof permissions !== 'object')
501
512
  return {};
502
- const perm = permissions[MODEL_PERMISSION_FIELD];
513
+ const perm = permissions[field];
503
514
  return perm && typeof perm === 'object' ? perm : {};
504
515
  }
505
516
  function stripSelectionModeFromPermission(perm) {
@@ -544,38 +555,42 @@ async function validateRoleModelPermission(roleDef) {
544
555
  const permissions = roleDef?.permissions;
545
556
  if (!permissions || typeof permissions !== 'object')
546
557
  return { ok: true, errors: [] };
547
- const perm = permissions[MODEL_PERMISSION_FIELD];
548
- if (!perm || typeof perm !== 'object')
549
- return { ok: true, errors: [] };
550
- permissions[MODEL_PERMISSION_FIELD] = stripSelectionModeFromPermission(perm);
551
- const result = await validateModelPermissionInput({
552
- defaultModel: permissions[MODEL_PERMISSION_FIELD].default,
553
- allowOverride: permissions[MODEL_PERMISSION_FIELD].allowOverride,
554
- allowedModels: permissions[MODEL_PERMISSION_FIELD].allowedModels,
555
- });
556
- return { ok: result.ok, errors: result.errors };
558
+ const errors = [];
559
+ for (const [field, perm] of Object.entries(permissions)) {
560
+ if (!/^baseagents\.[a-z0-9_-]+\.model$/.test(field))
561
+ continue;
562
+ permissions[field] = stripSelectionModeFromPermission(perm);
563
+ const result = await validateModelPermissionInput({
564
+ defaultModel: permissions[field]?.default,
565
+ allowOverride: permissions[field]?.allowOverride,
566
+ allowedModels: permissions[field]?.allowedModels,
567
+ });
568
+ errors.push(...result.errors.map(error => `${field}: ${error}`));
569
+ }
570
+ return { ok: errors.length === 0, errors };
557
571
  }
558
572
  async function filterAllowedCatalogModels(models, allowedModels) {
559
573
  const { isModelAllowedByPatterns } = await getRoleConstraintsModule();
560
574
  return models.filter(model => !model.isAlias && isModelAllowedByPatterns(model.id, allowedModels));
561
575
  }
562
- async function buildModelPermissionResponse(roleName, roleDef, override, aid) {
563
- const currentPerm = getModelPermission(roleDef);
576
+ async function buildModelPermissionResponse(roleName, roleDef, modelContext, override, aid) {
577
+ const currentPerm = getModelPermission(roleDef, modelContext.field);
564
578
  const defaultModel = override?.defaultModel ?? currentPerm.default ?? '';
565
579
  const allowOverride = override?.allowOverride ?? currentPerm.allowOverride ?? false;
566
580
  const allowedModels = override?.allowedModels ?? (Array.isArray(currentPerm.allowedModels) ? currentPerm.allowedModels : []);
567
- const catalog = await getModelCatalogSnapshot('claude', aid);
581
+ const catalog = await getModelCatalogSnapshot(modelContext.baseagent, aid);
568
582
  const matchingModels = await filterAllowedCatalogModels(catalog.models, allowedModels);
569
583
  return {
570
584
  role: roleName,
571
- field: MODEL_PERMISSION_FIELD,
585
+ baseagent: modelContext.baseagent,
586
+ field: modelContext.field,
572
587
  permission: {
573
588
  default: defaultModel,
574
589
  allowOverride,
575
590
  allowedModels,
576
591
  },
577
592
  selectionMode: inferSelectionMode(allowedModels),
578
- patternOptions: MODEL_PATTERN_OPTIONS,
593
+ patternOptions: modelContext.patternOptions,
579
594
  catalog,
580
595
  matchingModels,
581
596
  stats: {
@@ -613,10 +628,16 @@ async function buildSnapshot(params = {}, auth = {}) {
613
628
  };
614
629
  }
615
630
  const config = modules.readRolesConfig(aid);
631
+ const activeBaseagent = modules.resolveEffective({ self: aid }).active_baseagent;
632
+ const modelContext = activeBaseagent ? roleModelContext(modules, aid, activeBaseagent) : undefined;
616
633
  return {
617
634
  $schema_version: 1,
618
635
  aid,
619
636
  self: aid,
637
+ baseagent: modelContext?.baseagent ?? null,
638
+ modelPermissionField: modelContext?.field ?? null,
639
+ modelPatternOptions: modelContext?.patternOptions ?? [],
640
+ permissionFields: modules.rolePermissionFields(),
620
641
  agents: summarizeAgents(agents),
621
642
  defaultRoles: config.defaultRoles ?? { private: null, group: null },
622
643
  policyRevision: JSON.stringify(config),
@@ -866,7 +887,8 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
866
887
  sendJson(res, 404, { success: false, error: 'Role not found' });
867
888
  return;
868
889
  }
869
- const data = await buildModelPermissionResponse(roleName, roleDef, undefined, aid || undefined);
890
+ const modelContext = roleModelContext(modules, aid, parsedUrl.query.baseagent);
891
+ const data = await buildModelPermissionResponse(roleName, roleDef, modelContext, undefined, aid || undefined);
870
892
  sendJson(res, 200, { success: true, data });
871
893
  return;
872
894
  }
@@ -879,13 +901,14 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
879
901
  return;
880
902
  }
881
903
  const payload = await readJsonBody(req);
882
- const currentPerm = getModelPermission(roleDef);
904
+ const modelContext = roleModelContext(modules, aid, parsedUrl.query.baseagent);
905
+ const currentPerm = getModelPermission(roleDef, modelContext.field);
883
906
  const validation = await validateModelPermissionInput(extractModelPermissionPayload(payload, currentPerm));
884
907
  if (!validation.ok || !validation.value) {
885
908
  sendJson(res, 400, { success: false, error: 'Invalid model permissions', errors: validation.errors });
886
909
  return;
887
910
  }
888
- const data = await buildModelPermissionResponse(roleName, roleDef, validation.value, aid || undefined);
911
+ const data = await buildModelPermissionResponse(roleName, roleDef, modelContext, validation.value, aid || undefined);
889
912
  sendJson(res, 200, { success: true, data });
890
913
  return;
891
914
  }
@@ -898,7 +921,8 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
898
921
  return;
899
922
  }
900
923
  const payload = await readJsonBody(req);
901
- const currentPerm = getModelPermission(roleDef);
924
+ const modelContext = roleModelContext(modules, aid, parsedUrl.query.baseagent);
925
+ const currentPerm = getModelPermission(roleDef, modelContext.field);
902
926
  const validation = await validateModelPermissionInput(extractModelPermissionPayload(payload, currentPerm));
903
927
  if (!validation.ok || !validation.value) {
904
928
  sendJson(res, 400, { success: false, error: 'Invalid model permissions', errors: validation.errors });
@@ -907,15 +931,15 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
907
931
  roleDef.permissions = roleDef.permissions && typeof roleDef.permissions === 'object'
908
932
  ? roleDef.permissions
909
933
  : {};
910
- const existingPerm = stripSelectionModeFromPermission(getModelPermission(roleDef));
911
- roleDef.permissions[MODEL_PERMISSION_FIELD] = {
934
+ const existingPerm = stripSelectionModeFromPermission(getModelPermission(roleDef, modelContext.field));
935
+ roleDef.permissions[modelContext.field] = {
912
936
  ...existingPerm,
913
937
  default: validation.value.defaultModel,
914
938
  allowOverride: validation.value.allowOverride,
915
939
  allowedModels: validation.value.allowedModels,
916
940
  };
917
941
  modules.roleService.writeRoleDefinition(aid, roleName, roleDef);
918
- const data = await buildModelPermissionResponse(roleName, roleDef, validation.value, aid || undefined);
942
+ const data = await buildModelPermissionResponse(roleName, roleDef, modelContext, validation.value, aid || undefined);
919
943
  sendJson(res, 200, { success: true, data });
920
944
  return;
921
945
  }
@@ -1029,6 +1053,6 @@ export async function handleRoleDefinitionsApi(req, res, auth = {}) {
1029
1053
  }
1030
1054
  catch (err) {
1031
1055
  console.error('[role-definitions] API error:', err);
1032
- sendJson(res, 500, { error: err.message });
1056
+ sendJson(res, err.statusCode ?? 500, { error: err.message });
1033
1057
  }
1034
1058
  }
@@ -15,7 +15,7 @@ import { resolvePaths } from '../paths.js';
15
15
  import { encodePath } from '../fs-utils.js';
16
16
  import { getAgentSelfNames } from './msg.js';
17
17
  import { parseTranscriptResultKey, previewLargeToolResults, windowTranscriptTurns } from './transcript-window.js';
18
- import { buildSessionBindMap, classifyTranscriptSource, isEvolcoreRoutedMessage, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
18
+ import { buildSessionBindMap, readRunnerTaskTurns, classifyTranscriptSource, isEvolcoreRoutedMessage, isSyntheticCliPrompt, resolveSessionBindSelfName, } from './session-origin.js';
19
19
  const requireFromHere = createRequire(import.meta.url);
20
20
  let _dlog = null;
21
21
  export function setDebugLog(log) { _dlog = log; }
@@ -817,7 +817,7 @@ function readTranscriptFile(threadId, cwd, readStats) {
817
817
  cacheTranscriptDetail(rolloutPath, stat.mtimeMs, stat.size, detail);
818
818
  return detail;
819
819
  }
820
- export function readCodexSessionResult(params) {
820
+ export async function readCodexSessionResult(params) {
821
821
  const projects = listProjects();
822
822
  const project = resolveProject(params, projects);
823
823
  const sessionId = typeof params.sessionId === 'string' ? params.sessionId : '';
@@ -825,7 +825,10 @@ export function readCodexSessionResult(params) {
825
825
  if (!project || !sessionId || !key || !/^[A-Za-z0-9-]+$/.test(sessionId))
826
826
  return null;
827
827
  const detail = readTranscriptFile(sessionId, project.cwd);
828
- const block = detail.turns[key[0]]?.blocks?.[key[1]];
828
+ const bind = buildBindMap().get(sessionId);
829
+ const taskTurns = bind?.evolSessionId ? await readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
830
+ const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
831
+ const block = turns[key[0]]?.blocks?.[key[1]];
829
832
  return block?.kind === 'tool_result' && typeof block.text === 'string' ? block.text : null;
830
833
  }
831
834
  // ── Snapshot ──
@@ -899,8 +902,11 @@ async function buildSnapshot(params) {
899
902
  const detailStartedAt = Date.now();
900
903
  const detail = readTranscriptFile(sessionId, project.cwd, metrics);
901
904
  metrics.detailMs = Date.now() - detailStartedAt;
902
- const page = windowTranscriptTurns(detail.turns, params.turnLimit, params.turnBefore);
903
905
  const bind = bindMap.get(sessionId);
906
+ const taskTurns = bind?.evolSessionId ? await readRunnerTaskTurns(resolvePaths().logs, bind.evolSessionId) : [];
907
+ const turns = detail.turns.concat(taskTurns).sort((a, b) => (a.ts || 0) - (b.ts || 0));
908
+ const page = windowTranscriptTurns(turns, params.turnLimit, params.turnBefore);
909
+ const counts = { ...detail.counts, subtask: taskTurns.length };
904
910
  const transcript = metas.find(meta => meta.id === sessionId);
905
911
  const header = {
906
912
  sessionId,
@@ -912,7 +918,7 @@ async function buildSnapshot(params) {
912
918
  totalTurns: detail.totalTurns,
913
919
  userMsgs: detail.userMsgs,
914
920
  totalMsgs: detail.totalMsgs,
915
- counts: detail.counts,
921
+ counts,
916
922
  inputTokens: detail.inputTokens,
917
923
  outputTokens: detail.outputTokens,
918
924
  contextTokens: detail.contextTokens,
@@ -944,6 +950,7 @@ export function subscribeCodex(params, push) {
944
950
  const p = resolvePaths();
945
951
  let dbWatcher = null;
946
952
  let sessionWatcher = null;
953
+ let logWatcher = null;
947
954
  let debounce = null;
948
955
  const fire = () => {
949
956
  if (debounce)
@@ -971,11 +978,22 @@ export function subscribeCodex(params, push) {
971
978
  });
972
979
  }
973
980
  catch { }
981
+ if (params.sessionId) {
982
+ try {
983
+ logWatcher = fs.watch(p.logs, (_evt, filename) => {
984
+ if (!filename || /^events(?:-.+)?\.log$/.test(String(filename)))
985
+ fire();
986
+ });
987
+ }
988
+ catch { }
989
+ }
974
990
  return () => {
975
991
  if (dbWatcher)
976
992
  dbWatcher.close();
977
993
  if (sessionWatcher)
978
994
  sessionWatcher.close();
995
+ if (logWatcher)
996
+ logWatcher.close();
979
997
  if (debounce)
980
998
  clearTimeout(debounce);
981
999
  };