ec-web 0.0.20 → 0.0.21

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.
@@ -77,6 +77,128 @@ function getDb() {
77
77
  }
78
78
  return _db;
79
79
  }
80
+ function messageContentText(content) {
81
+ if (typeof content === 'string')
82
+ return content;
83
+ if (content && typeof content === 'object' && !Array.isArray(content)) {
84
+ return typeof content.text === 'string' ? content.text.trim() : '';
85
+ }
86
+ if (!Array.isArray(content))
87
+ return '';
88
+ return content
89
+ .map((part) => {
90
+ if (typeof part === 'string')
91
+ return part;
92
+ if (!part || typeof part !== 'object')
93
+ return '';
94
+ if (typeof part.text === 'string')
95
+ return part.text;
96
+ return '';
97
+ })
98
+ .filter(Boolean)
99
+ .join('\n')
100
+ .trim();
101
+ }
102
+ function parseRolloutEvents(raw) {
103
+ const events = [];
104
+ for (const line of raw.split('\n')) {
105
+ if (!line.trim())
106
+ continue;
107
+ try {
108
+ events.push(JSON.parse(line));
109
+ }
110
+ catch { }
111
+ }
112
+ return events;
113
+ }
114
+ /**
115
+ * Normalize both Codex CLI events and Codex app-server lifecycle events.
116
+ * The app-server writes the same message twice: response_item.message and
117
+ * event_msg.item_completed. We intentionally consume the latter because it
118
+ * carries the canonical UserMessage/AgentMessage item and stable id.
119
+ */
120
+ export function parseCodexMessageEvent(event) {
121
+ if (!event || !event.payload)
122
+ return null;
123
+ const payload = event.payload;
124
+ if (event.type === 'response_item') {
125
+ if (payload.type !== 'message' || (payload.role !== 'user' && payload.role !== 'assistant'))
126
+ return null;
127
+ const text = messageContentText(payload.content ?? payload.message ?? payload.text);
128
+ if (!text)
129
+ return null;
130
+ return {
131
+ role: payload.role,
132
+ text,
133
+ id: String(payload.id || ''),
134
+ };
135
+ }
136
+ if (event.type !== 'event_msg')
137
+ return null;
138
+ if (payload.type === 'user_message' && payload.message) {
139
+ const text = messageContentText(payload.message);
140
+ if (!text)
141
+ return null;
142
+ return {
143
+ role: 'user',
144
+ text,
145
+ id: String(payload.id || ''),
146
+ };
147
+ }
148
+ if (payload.type === 'agent_message' && payload.message) {
149
+ const text = messageContentText(payload.message);
150
+ if (!text)
151
+ return null;
152
+ return {
153
+ role: 'assistant',
154
+ text,
155
+ id: String(payload.id || ''),
156
+ };
157
+ }
158
+ if (payload.type !== 'item_completed' || !payload.item)
159
+ return null;
160
+ const item = payload.item;
161
+ const role = item.type === 'UserMessage'
162
+ ? 'user'
163
+ : item.type === 'AgentMessage'
164
+ ? 'assistant'
165
+ : null;
166
+ if (!role)
167
+ return null;
168
+ const text = messageContentText(item.content ?? item.message ?? item.text);
169
+ if (!text)
170
+ return null;
171
+ return {
172
+ role,
173
+ text,
174
+ id: String(item.id || payload.id || ''),
175
+ };
176
+ }
177
+ function normalizedCodexMessages(events) {
178
+ // app-server emits response_item.message followed by item_completed for
179
+ // the same logical message; prefer lifecycle items when available. This
180
+ // also prevents injected context response items from being counted as user
181
+ // turns in an app-server rollout.
182
+ const hasCanonicalMessages = events.some(event => event?.type === 'event_msg' && parseCodexMessageEvent(event) !== null);
183
+ const out = [];
184
+ for (const event of events) {
185
+ const message = parseCodexMessageEvent(event);
186
+ if (!message)
187
+ continue;
188
+ if (event.type === 'response_item' && hasCanonicalMessages)
189
+ continue;
190
+ if (event.type === 'response_item' && message.role === 'user' && isSyntheticCodexContext(message.text))
191
+ continue;
192
+ out.push({ event, message });
193
+ }
194
+ return out;
195
+ }
196
+ function isSyntheticCodexContext(text) {
197
+ const trimmed = text.trimStart();
198
+ return trimmed.startsWith('# AGENTS.md instructions')
199
+ || trimmed.startsWith('<system-reminder>')
200
+ || trimmed.startsWith('<environment_context>');
201
+ }
80
202
  // ── 项目列表 ──
81
203
  function listProjects() {
82
204
  const db = getDb();
@@ -110,7 +232,7 @@ function listProjects() {
110
232
  }
111
233
  }
112
234
  // ── 会话列表 ──
113
- const CACHE_VERSION = 2;
235
+ const CACHE_VERSION = 4;
114
236
  const _metaCache = new Map();
115
237
  const _detailCache = new Map();
116
238
  const MAX_DETAIL_CACHE_ENTRIES = 6;
@@ -186,36 +308,29 @@ function extractMetaFromRollout(file, id) {
186
308
  catch { }
187
309
  let title = '', firstUser = '', gitBranch = '', version = '', userMsgs = 0, totalMsgs = 0;
188
310
  let hasRealUserInput = false;
189
- const lines = raw.split('\n');
190
- for (const line of lines) {
191
- if (!line.trim())
192
- continue;
193
- try {
194
- const event = JSON.parse(line);
195
- // 提取 cwd 和 version
196
- if (event.type === 'session_meta' && event.payload) {
197
- if (!gitBranch && event.payload.cwd)
198
- gitBranch = event.payload.cwd;
199
- if (!version && event.payload.cli_version)
200
- version = event.payload.cli_version;
201
- }
202
- // 提取 title(从第一个 user_message)
203
- if (event.type === 'event_msg' && event.payload?.type === 'user_message' && event.payload.message) {
204
- totalMsgs++;
205
- userMsgs++;
206
- if (!isSyntheticCliPrompt(event.payload.message, 'codex'))
207
- hasRealUserInput = true;
208
- if (!firstUser) {
209
- const text = event.payload.message.trim().replace(/\s+/g, ' ');
210
- firstUser = text.substring(0, 120);
211
- }
212
- }
213
- // 统计 assistant 消息
214
- if (event.type === 'event_msg' && event.payload?.type === 'assistant_message') {
215
- totalMsgs++;
311
+ const events = parseRolloutEvents(raw);
312
+ for (const event of events) {
313
+ // 提取 cwd 和 version
314
+ if (event.type === 'session_meta' && event.payload) {
315
+ if (!gitBranch && event.payload.cwd)
316
+ gitBranch = event.payload.cwd;
317
+ if (!version && event.payload.cli_version)
318
+ version = event.payload.cli_version;
319
+ }
320
+ }
321
+ for (const { message } of normalizedCodexMessages(events)) {
322
+ if (message.role === 'user') {
323
+ totalMsgs++;
324
+ userMsgs++;
325
+ if (!isSyntheticCliPrompt(message.text, 'codex'))
326
+ hasRealUserInput = true;
327
+ if (!firstUser) {
328
+ firstUser = message.text.replace(/\s+/g, ' ').substring(0, 120);
216
329
  }
217
330
  }
218
- catch { }
331
+ else {
332
+ totalMsgs++;
333
+ }
219
334
  }
220
335
  let stat;
221
336
  try {
@@ -241,8 +356,15 @@ function extractMetaFromRollout(file, id) {
241
356
  };
242
357
  }
243
358
  function withThreadSource(meta, row) {
359
+ const dbFirstUser = typeof row.first_user_message === 'string'
360
+ ? row.first_user_message.trim().replace(/\s+/g, ' ').substring(0, 120)
361
+ : '';
362
+ const dbTitle = typeof row.title === 'string' ? row.title.trim() : '';
244
363
  return {
245
364
  ...meta,
365
+ title: dbTitle || meta.title,
366
+ firstUser: dbFirstUser || meta.firstUser,
367
+ hasRealUserInput: meta.hasRealUserInput || (!!dbFirstUser && !isSyntheticCliPrompt(dbFirstUser, 'codex')),
246
368
  backendSource: typeof row.source === 'string' ? row.source : '',
247
369
  threadSource: typeof row.thread_source === 'string' ? row.thread_source : '',
248
370
  evolcoreRouted: isEvolcoreRoutedMessage(row.first_user_message),
@@ -584,16 +706,9 @@ function readTranscriptFile(threadId, cwd, readStats) {
584
706
  let lastUsageKey = '';
585
707
  const seenToolCalls = new Set();
586
708
  const seenToolResults = new Set();
587
- for (const line of raw.split('\n')) {
588
- if (!line.trim())
589
- continue;
590
- let event;
591
- try {
592
- event = JSON.parse(line);
593
- }
594
- catch {
595
- continue;
596
- }
709
+ const events = parseRolloutEvents(raw);
710
+ const messagesByEvent = new Map(normalizedCodexMessages(events).map(({ event, message }) => [event, message]));
711
+ for (const event of events) {
597
712
  // 提取 session_meta
598
713
  if (event.type === 'session_meta' && event.payload) {
599
714
  if (!version && event.payload.cli_version)
@@ -624,44 +739,37 @@ function readTranscriptFile(threadId, cwd, readStats) {
624
739
  }
625
740
  }
626
741
  }
627
- // 处理消息事件
628
- if (event.type === 'event_msg' && event.payload) {
629
- const payload = event.payload;
630
- if (payload.type === 'user_message' && payload.message) {
631
- totalMsgs++;
632
- userMsgs++;
633
- counts.userInput++;
634
- const text = payload.message.trim();
635
- turns.push({
636
- role: 'user',
637
- ts: event.timestamp ? Date.parse(event.timestamp) : 0,
638
- uuid: payload.id || '',
639
- category: 'user_input',
640
- blocks: [{ kind: 'text', text }],
641
- });
642
- }
643
- else if (payload.type === 'agent_message' && payload.message) {
644
- totalMsgs++;
645
- counts.modelOutput++;
646
- const blocks = [];
647
- // 提取 message
648
- if (payload.message) {
649
- blocks.push({ kind: 'text', text: payload.message });
650
- }
651
- turns.push({
652
- role: 'assistant',
653
- ts: event.timestamp ? Date.parse(event.timestamp) : 0,
654
- uuid: payload.id || '',
655
- category: 'model_output',
656
- blocks,
657
- });
658
- }
659
- else if (payload.type === 'reasoning' && payload.message) {
660
- // Codex reasoning 对应 Claude 的 thinking
661
- const lastTurn = turns[turns.length - 1];
662
- if (lastTurn && lastTurn.role === 'assistant') {
663
- lastTurn.blocks.unshift({ kind: 'thinking', text: payload.message });
664
- }
742
+ // 处理用户/助手消息。托管 app-server 使用 item_completed,旧版 CLI
743
+ // 使用 payload.type=user_message/agent_message;统一入口避免重复解析。
744
+ const message = messagesByEvent.get(event);
745
+ if (message?.role === 'user') {
746
+ totalMsgs++;
747
+ userMsgs++;
748
+ counts.userInput++;
749
+ turns.push({
750
+ role: 'user',
751
+ ts: event.timestamp ? Date.parse(event.timestamp) : 0,
752
+ uuid: message.id,
753
+ category: 'user_input',
754
+ blocks: [{ kind: 'text', text: message.text }],
755
+ });
756
+ }
757
+ else if (message?.role === 'assistant') {
758
+ totalMsgs++;
759
+ counts.modelOutput++;
760
+ turns.push({
761
+ role: 'assistant',
762
+ ts: event.timestamp ? Date.parse(event.timestamp) : 0,
763
+ uuid: message.id,
764
+ category: 'model_output',
765
+ blocks: [{ kind: 'text', text: message.text }],
766
+ });
767
+ }
768
+ else if (event.type === 'event_msg' && event.payload?.type === 'reasoning' && event.payload.message) {
769
+ // Codex reasoning 对应 Claude 的 thinking
770
+ const lastTurn = turns[turns.length - 1];
771
+ if (lastTurn && lastTurn.role === 'assistant') {
772
+ lastTurn.blocks.unshift({ kind: 'thinking', text: event.payload.message });
665
773
  }
666
774
  }
667
775
  const toolEvent = parseCodexToolEvent(event);
@@ -748,6 +856,9 @@ async function buildSnapshot(params) {
748
856
  metrics.totalMs = Date.now() - startedAt;
749
857
  return {
750
858
  ...m,
859
+ // EvolCore topics carry the user-facing name; Codex's DB title is often
860
+ // the routed envelope text for AUN-hosted sessions.
861
+ title: bind?.name?.trim() || m.title,
751
862
  sourceType: classifyTranscriptSource(bind, isCodexCliCandidate(m)),
752
863
  bound: !!bind,
753
864
  boundChannel: bind?.channelType ?? null,
@@ -781,7 +892,7 @@ async function buildSnapshot(params) {
781
892
  const transcript = metas.find(meta => meta.id === sessionId);
782
893
  const header = {
783
894
  sessionId,
784
- title: detail.title,
895
+ title: bind?.name?.trim() || detail.title,
785
896
  model: detail.model,
786
897
  gitBranch: detail.gitBranch,
787
898
  version: detail.version,
@@ -11,6 +11,7 @@ import fs from 'fs';
11
11
  import path from 'path';
12
12
  import { resolvePaths } from '../paths.js';
13
13
  import { ipcQuery } from '../ipc-client.js';
14
+ import { watchMenuRequestAuth } from './types.js';
14
15
  function readDefaultBaseagents() {
15
16
  try {
16
17
  const p = resolvePaths();
@@ -34,15 +35,15 @@ function readDefaultBaseagents() {
34
35
  return [];
35
36
  }
36
37
  }
37
- async function menuExec(payload) {
38
+ async function menuExec(payload, params) {
38
39
  const p = resolvePaths();
39
- const r = await ipcQuery(p.socket, { type: 'menu.exec', payload, auth: { source: 'ecweb', localDirect: true } }, 5000);
40
+ const r = await ipcQuery(p.socket, { type: 'menu.exec', payload, auth: watchMenuRequestAuth(params) }, 5000);
40
41
  return r?.ok ? r.response : null;
41
42
  }
42
- async function buildSnapshot() {
43
+ async function buildSnapshot(params = {}) {
43
44
  const [listResp, sysResp] = await Promise.all([
44
- menuExec({ type: 'menu.list', id: 'sys-list' }),
45
- menuExec({ type: 'menu.query', id: 'sys-q', name: 'system' }),
45
+ menuExec({ type: 'menu.list', id: 'sys-list' }, params),
46
+ menuExec({ type: 'menu.query', id: 'sys-q', name: 'system' }, params),
46
47
  ]);
47
48
  const daemonRunning = listResp !== null;
48
49
  // sysResp 形如 { type:'menu.response', id, name, data | error }
@@ -58,17 +59,17 @@ async function buildSnapshot() {
58
59
  }
59
60
  export const systemSource = {
60
61
  kind: 'system',
61
- async snapshot() {
62
- return buildSnapshot();
62
+ async snapshot(params = {}) {
63
+ return buildSnapshot(params);
63
64
  },
64
- subscribe(_params, push) {
65
+ subscribe(params, push) {
65
66
  let lastJson = '';
66
67
  let stopped = false;
67
68
  const tick = async () => {
68
69
  if (stopped)
69
70
  return;
70
71
  try {
71
- const snap = await buildSnapshot();
72
+ const snap = await buildSnapshot(params);
72
73
  const json = JSON.stringify(snap);
73
74
  if (json !== lastJson) {
74
75
  lastJson = json;
@@ -11,9 +11,10 @@ import fs from 'fs';
11
11
  import path from 'path';
12
12
  import { resolvePaths } from '../paths.js';
13
13
  import { ipcQuery } from '../ipc-client.js';
14
- async function menuExec(payload) {
14
+ import { watchMenuAuth, watchMenuRequestAuth } from './types.js';
15
+ async function menuExec(payload, params) {
15
16
  const p = resolvePaths();
16
- const r = await ipcQuery(p.socket, { type: 'menu.exec', payload, auth: { source: 'ecweb', localDirect: true } }, 5000);
17
+ const r = await ipcQuery(p.socket, { type: 'menu.exec', payload, auth: watchMenuRequestAuth(params) }, 5000);
17
18
  return r?.ok ? r.response : null;
18
19
  }
19
20
  function readTriggerControlToken(instanceDir) {
@@ -38,6 +39,15 @@ function readDaemonOwnerAid(p) {
38
39
  return null;
39
40
  }
40
41
  }
42
+ function readFullAccessEnabled(p) {
43
+ try {
44
+ const raw = JSON.parse(fs.readFileSync(p.daemonConfig, 'utf8'));
45
+ return raw?.fullaccess?.enabled === true;
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
41
51
  function normalizeAgent(item) {
42
52
  const aid = String(item?.value || item?.aid || '').trim();
43
53
  if (!aid)
@@ -53,14 +63,11 @@ function normalizeAgent(item) {
53
63
  async function listTriggersForAgent(agentAid, agentLabel) {
54
64
  const p = resolvePaths();
55
65
  const controlToken = readTriggerControlToken(p.instanceDir);
56
- const resp = await menuExec({
57
- type: 'menu.options',
58
- id: `tr-list-${agentAid}`,
59
- name: 'trigger',
60
- args: { options: 'all' },
61
- agent: agentAid,
62
- });
63
- const items = Array.isArray(resp?.data) ? resp.data : [];
66
+ // ECWeb is a management surface, so use the canonical Trigger IPC rather
67
+ // than routing through a regular Agent channel. This also covers the
68
+ // daemon-owned scheduler, which has no self-agent channel in the registry.
69
+ const resp = await ipcQuery(p.socket, { type: 'trigger.list', agentAid, all: true, controlToken }, 5000);
70
+ const items = Array.isArray(resp?.triggers) ? resp.triggers : [];
64
71
  return Promise.all(items.map(async (trigger) => {
65
72
  const effectiveAgentAid = trigger.agentAid || trigger.schedulerAid || agentAid;
66
73
  let definition = null;
@@ -104,9 +111,13 @@ async function listTriggersForAgent(agentAid, agentLabel) {
104
111
  }
105
112
  async function buildSnapshot(params) {
106
113
  const p = resolvePaths();
114
+ const fullaccessEnabled = readFullAccessEnabled(p);
115
+ // This capability is derived from the server-injected ECWeb connection
116
+ // context; browser-supplied subscription parameters are never trusted for it.
117
+ const canApprovePersistentFullAccess = watchMenuAuth(params).canApprovePersistentFullAccess;
107
118
  const agent = typeof params?.agent === 'string' && params.agent ? params.agent : null;
108
119
  const [agentsResp, runtimeAgentsResp] = await Promise.all([
109
- menuExec({ type: 'menu.options', id: 'tr-agents', name: 'agent' }),
120
+ menuExec({ type: 'menu.options', id: 'tr-agents', name: 'agent' }, params),
110
121
  ipcQuery(p.socket, { type: 'evolagent.list' }, 5000),
111
122
  ]);
112
123
  // menu.options 响应形如 { type:'menu.response', id, name, data: MenuItem[] }
@@ -132,11 +143,11 @@ async function buildSnapshot(params) {
132
143
  if (agent) {
133
144
  const found = agentInfos.find((x) => x.aid === agent);
134
145
  const triggers = await listTriggersForAgent(agent, found?.label || agent);
135
- return { agents: agentInfos, triggers, selectedAgent: agent };
146
+ return { agents: agentInfos, triggers, selectedAgent: agent, fullaccessEnabled, canApprovePersistentFullAccess };
136
147
  }
137
148
  const batches = await Promise.all(agentInfos.map((x) => listTriggersForAgent(x.aid, x.label)));
138
149
  const triggers = batches.flat();
139
- return { agents: agentInfos, triggers, selectedAgent: agent };
150
+ return { agents: agentInfos, triggers, selectedAgent: agent, fullaccessEnabled, canApprovePersistentFullAccess };
140
151
  }
141
152
  export const triggersSource = {
142
153
  kind: 'triggers',
@@ -7,4 +7,23 @@
7
7
  *
8
8
  * aid/cache 走 IPC 轮询(无推送),msg/session 走 fs.watch 文件监听。
9
9
  */
10
- export {};
10
+ /** Read only the server-injected auth marker; browser parameters never decide it. */
11
+ export function watchMenuAuth(params) {
12
+ const auth = params?.__menuAuth;
13
+ return {
14
+ localDirect: auth?.localDirect === true,
15
+ actorAid: typeof auth?.actorAid === 'string' ? auth.actorAid : null,
16
+ // This marker is injected by the ECWeb server, never read from browser
17
+ // request parameters. Authenticated ECWeb has daemon-owner authority.
18
+ canApprovePersistentFullAccess: auth?.canApprovePersistentFullAccess === true,
19
+ };
20
+ }
21
+ export function watchMenuRequestAuth(params) {
22
+ const auth = watchMenuAuth(params);
23
+ return {
24
+ source: 'ecweb',
25
+ localDirect: auth.localDirect,
26
+ actorAid: auth.actorAid ?? null,
27
+ canApprovePersistentFullAccess: auth.canApprovePersistentFullAccess,
28
+ };
29
+ }