evolcore 0.0.23 → 0.0.24
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/CHANGELOG.md +50 -0
- package/README.md +3 -1
- package/bin/codex-managed-acl.ps1 +7 -2
- package/bin/codex-managed-hook.mjs +127 -10
- package/bin/ec-safe-output.js +85 -3
- package/bin/install-codex-managed-hooks.mjs +17 -6
- package/dist/agents/baseagent-availability.js +4 -4
- package/dist/agents/baseagent.js +29 -1
- package/dist/agents/claude-runner.js +148 -13
- package/dist/agents/codex/app-server-client.js +18 -8
- package/dist/agents/codex/catalog.js +1 -1
- package/dist/agents/codex/diagnostics.js +215 -0
- package/dist/agents/codex-runner.js +438 -59
- package/dist/agents/ecagent-runner.js +17 -3
- package/dist/agents/gemini-runner.js +60 -2
- package/dist/agents/runner-types.js +1 -1
- package/dist/aun/aid/managed-operation.js +28 -6
- package/dist/aun/aid/store.js +41 -0
- package/dist/aun/msg/managed-operation.js +6 -1
- package/dist/aun/outbox.js +56 -18
- package/dist/aun/rpc/client.js +4 -5
- package/dist/channels/aun.js +147 -76
- package/dist/channels/daemon.js +7 -1
- package/dist/channels/dingtalk.js +1 -0
- package/dist/channels/feishu.js +10 -4
- package/dist/channels/wecom.js +2 -1
- package/dist/cli/agent-command.js +29 -3
- package/dist/cli/agent-create.js +538 -0
- package/dist/cli/agent.js +26 -675
- package/dist/cli/aun-commands.js +60 -20
- package/dist/cli/ctl-command.js +2 -0
- package/dist/cli/daemon-commands.js +148 -17
- package/dist/cli/index.js +2 -2
- package/dist/cli/managed-command-guard.js +3 -0
- package/dist/cli/restart-monitor.js +432 -8
- package/dist/cli/stats.js +110 -0
- package/dist/config/builtin-roles.js +2 -0
- package/dist/core/auth/auth-gateway.js +3 -2
- package/dist/core/auth/authorization-audit.js +72 -19
- package/dist/core/auth/operation-authorizer.js +63 -3
- package/dist/core/auth/operation-catalog.js +10 -2
- package/dist/core/auth/remote-access-store.js +3 -2
- package/dist/core/baseagent-loader.js +4 -4
- package/dist/core/bootstrap-messages.js +39 -18
- package/dist/core/bootstrap-service.js +32 -26
- package/dist/core/capability/capability-manager.js +8 -4
- package/dist/core/capability/providers/codex-capability-provider.js +1 -1
- package/dist/core/command/agent-control.js +7 -2
- package/dist/core/command/command-handler.js +87 -25
- package/dist/core/command/menu-catalog.js +3 -1
- package/dist/core/command/menu-handler.js +274 -70
- package/dist/core/command/menu-protocol.js +6 -2
- package/dist/core/command/slash-gate.js +1 -3
- package/dist/core/command/slash-handler.js +157 -48
- package/dist/core/data-migration.js +5 -15
- package/dist/core/event-catalog.js +3 -0
- package/dist/core/handoff/runtime.js +3 -1
- package/dist/core/handoff/store.js +7 -1
- package/dist/core/message/im-renderer.js +13 -6
- package/dist/core/message/message-bridge.js +5 -36
- package/dist/core/message/message-utils.js +6 -3
- package/dist/core/message/response-engine.js +73 -21
- package/dist/core/model/catalog-service.js +3 -1
- package/dist/core/model/model-catalog.js +10 -0
- package/dist/core/model/model-permission.js +4 -1
- package/dist/core/permission/approval-gateway.js +169 -33
- package/dist/core/permission/tool-policy.js +13 -6
- package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
- package/dist/core/session/session-manager.js +49 -13
- package/dist/core/session/session-renew.js +1 -0
- package/dist/index.js +174 -28
- package/dist/ipc.js +68 -21
- package/dist/paths.js +23 -1
- package/dist/stats/billing.js +18 -4
- package/dist/utils/atomic-write.js +26 -2
- package/dist/utils/cross-platform.js +436 -1
- package/dist/utils/error-utils.js +11 -4
- package/dist/utils/model-prices.jsonl +23 -20
- package/dist/utils/npm-ops.js +24 -1
- package/dist/utils/windows-autostart.js +1 -1
- package/kits/templates/roles/admin.json +2 -0
- package/package.json +7 -3
- package/AGENTS.md +0 -62
- package/dist/utils/codex-cli.js +0 -43
|
@@ -16,6 +16,23 @@ import { rotateStdoutLog } from '../utils/log-writer.js';
|
|
|
16
16
|
import { inspectDataMigrationRequirement } from '../core/data-migration.js';
|
|
17
17
|
import { DAEMON_SHUTDOWN_TIMEOUT_MS, requestDaemonShutdown } from './daemon-commands.js';
|
|
18
18
|
const execFileAsync = promisify(execFile);
|
|
19
|
+
/**
|
|
20
|
+
* The package this monitor operates on, which is not always the one it runs
|
|
21
|
+
* from.
|
|
22
|
+
*
|
|
23
|
+
* In an installed setup the monitor is copied to a staging directory before an
|
|
24
|
+
* upgrade replaces the real package (see `prepareMonitorStage`), so
|
|
25
|
+
* `getPackageRoot()` — derived from `import.meta.dirname` — would point at that
|
|
26
|
+
* throwaway copy. Every path the monitor acts on (the daemon entry it spawns,
|
|
27
|
+
* the artifact it backs up, the build it runs) must instead resolve against the
|
|
28
|
+
* package being serviced.
|
|
29
|
+
*
|
|
30
|
+
* In a checkout no staging happens and the variable is unset, so this is
|
|
31
|
+
* exactly `getPackageRoot()` and behaviour is unchanged.
|
|
32
|
+
*/
|
|
33
|
+
function targetPackageRoot() {
|
|
34
|
+
return process.env.EVOLCORE_TARGET_PACKAGE_ROOT || getPackageRoot();
|
|
35
|
+
}
|
|
19
36
|
// 清理 Claude Code 环境变量,防止 SDK 认为是嵌套会话
|
|
20
37
|
function cleanEnv() {
|
|
21
38
|
for (const key of [
|
|
@@ -55,7 +72,18 @@ export async function cmdRestartMonitor() {
|
|
|
55
72
|
}
|
|
56
73
|
// 立即登记自己;exit 路径上自动清理 record
|
|
57
74
|
writeRestartMonitor();
|
|
58
|
-
process.on('exit', () =>
|
|
75
|
+
process.on('exit', () => {
|
|
76
|
+
removeRestartMonitor();
|
|
77
|
+
// 安装包模式下本进程跑在 stage 副本里。cwd 不在其中(继承自调用方),
|
|
78
|
+
// 所以可以安全自删;留下来只会占磁盘。
|
|
79
|
+
const stage = process.env.EVOLCORE_MONITOR_STAGE;
|
|
80
|
+
if (stage) {
|
|
81
|
+
try {
|
|
82
|
+
platform.cleanupMonitorStage(stage);
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
}
|
|
86
|
+
});
|
|
59
87
|
// post-write 自检:(startedAt, pid) 选最早赢家
|
|
60
88
|
{
|
|
61
89
|
const verdict = isRestartMonitorWinner();
|
|
@@ -73,11 +101,17 @@ export async function cmdRestartMonitor() {
|
|
|
73
101
|
return s.mains.some(m => m.alive);
|
|
74
102
|
};
|
|
75
103
|
log('Restart monitor started');
|
|
104
|
+
// Keep this path available for a failed read-only preflight so a stale
|
|
105
|
+
// restart request cannot be replayed by a later daemon startup.
|
|
106
|
+
const pendingFile = path.join(daemonControlDir(), 'restart-pending.json');
|
|
107
|
+
// 升级成功时记下版本变化,回执里要告诉 agent 实际跑的是哪个版本。
|
|
108
|
+
let upgradeVersions;
|
|
76
109
|
const migrationRequirement = inspectDataMigrationRequirement(p.root);
|
|
77
110
|
if (migrationRequirement.required) {
|
|
78
111
|
const message = `Pending user-data migration (${migrationRequirement.operationCount} operations); leaving the current service running. Run ec data migrate --dry-run, then ec data migrate --apply.`;
|
|
79
112
|
log(message);
|
|
80
113
|
console.error(`❌ ${message}`);
|
|
114
|
+
cleanupPendingFile(pendingFile, log);
|
|
81
115
|
process.exitCode = 2;
|
|
82
116
|
return;
|
|
83
117
|
}
|
|
@@ -100,7 +134,7 @@ export async function cmdRestartMonitor() {
|
|
|
100
134
|
if (o.confirmedTestDaemon)
|
|
101
135
|
return false;
|
|
102
136
|
const homeMatches = o.evolcoreHome && normalizePath(o.evolcoreHome) === normalizePath(p.root);
|
|
103
|
-
const packageMatches = !o.evolcoreHome && normalizePath(o.cmdline).includes(normalizePath(
|
|
137
|
+
const packageMatches = !o.evolcoreHome && normalizePath(o.cmdline).includes(normalizePath(targetPackageRoot()));
|
|
104
138
|
return Boolean(homeMatches || packageMatches);
|
|
105
139
|
});
|
|
106
140
|
if (runtimeOrphans.length > 0) {
|
|
@@ -126,8 +160,6 @@ export async function cmdRestartMonitor() {
|
|
|
126
160
|
}
|
|
127
161
|
cleanupInstances();
|
|
128
162
|
}
|
|
129
|
-
// restart-pending.json 只留给新主进程发送发起会话内的“重启成功”回执。
|
|
130
|
-
const pendingFile = path.join(daemonControlDir(), 'restart-pending.json');
|
|
131
163
|
// 等待所有活 main 进程退出(可能不止一个)
|
|
132
164
|
const oldStatus = scanInstances();
|
|
133
165
|
const aliveMains = oldStatus.mains.filter(m => m.alive);
|
|
@@ -167,11 +199,29 @@ export async function cmdRestartMonitor() {
|
|
|
167
199
|
}
|
|
168
200
|
// 版本检查与自动升级
|
|
169
201
|
log('Checking for updates...');
|
|
202
|
+
// 升级会原地替换整包,先留一个可回退的产物副本。备份放 EVOLCORE_HOME 而非包目录:
|
|
203
|
+
// Linux/macOS 的全局目录常常不可写,而 HOME 一定可写。
|
|
204
|
+
const upgradeBackup = await backupDistForUpgrade(p, log);
|
|
170
205
|
const upgrade = await tryUpgrade();
|
|
206
|
+
let upgradeRolledBack;
|
|
171
207
|
switch (upgrade.status) {
|
|
172
208
|
case 'upgraded':
|
|
173
209
|
log(`✅ Upgraded: ${upgrade.from} → ${upgrade.to}`);
|
|
174
|
-
|
|
210
|
+
if (!distArtifactIsUsable(path.join(targetPackageRoot(), 'dist'))) {
|
|
211
|
+
// npm 报成功但产物不完整:当作升级失败处理。
|
|
212
|
+
log('❌ Upgrade left an incomplete dist/');
|
|
213
|
+
if (await restoreDistBackup(upgradeBackup, log)) {
|
|
214
|
+
upgradeRolledBack = `升级到 ${upgrade.to} 后产物不完整,已回滚到 ${upgrade.from}`;
|
|
215
|
+
await notifyDaemonOwners(p, `⚠️ ${upgradeRolledBack}`, log);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
await notifyDaemonOwners(p, `❌ 升级到 ${upgrade.to} 后产物不完整,且回滚失败,请人工检查`, log);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
upgradeVersions = { from: upgrade.from, to: upgrade.to };
|
|
223
|
+
await notifyDaemonOwners(p, `📦 已升级 ${upgrade.from} → ${upgrade.to}`, log);
|
|
224
|
+
}
|
|
175
225
|
break;
|
|
176
226
|
case 'no-update':
|
|
177
227
|
log(`Already up to date (${upgrade.from})`);
|
|
@@ -183,6 +233,13 @@ export async function cmdRestartMonitor() {
|
|
|
183
233
|
break;
|
|
184
234
|
case 'failed':
|
|
185
235
|
log(`⚠ Upgrade failed (${upgrade.from} → ${upgrade.to}): ${upgrade.error}`);
|
|
236
|
+
// 安装可能中途失败并留下残缺目录,此时必须用备份恢复才敢继续启动。
|
|
237
|
+
if (!distArtifactIsUsable(path.join(targetPackageRoot(), 'dist'))) {
|
|
238
|
+
log('❌ Failed upgrade left an unusable dist/');
|
|
239
|
+
if (await restoreDistBackup(upgradeBackup, log)) {
|
|
240
|
+
upgradeRolledBack = `升级失败且产物受损,已回滚到 ${upgrade.from}`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
186
243
|
await notifyDaemonOwners(p, `⚠️ evolcore 升级失败,使用当前版本继续\n${upgrade.error ?? ''}`.trim(), log);
|
|
187
244
|
break;
|
|
188
245
|
}
|
|
@@ -200,17 +257,81 @@ export async function cmdRestartMonitor() {
|
|
|
200
257
|
await notifyDaemonOwners(p, `⚠️ ${WEB_PACKAGE_NAME} 升级失败,使用当前版本继续\n${ecwebUpgrade.error ?? ''}`.trim(), log);
|
|
201
258
|
break;
|
|
202
259
|
}
|
|
260
|
+
// 编译在 daemon 停止之后、拉起之前:此时没有活进程占着 dist/,
|
|
261
|
+
// 失败还能回滚到上一份可用产物,因此这一步整体是可撤销的。
|
|
262
|
+
const buildOutcome = await buildSourcesTransactionally(log);
|
|
263
|
+
if (buildOutcome.status === 'failed') {
|
|
264
|
+
const message = `服务未重启:${buildOutcome.reason ?? '构建失败'}`;
|
|
265
|
+
log(`❌ ${message}`);
|
|
266
|
+
await notifyDaemonOwners(p, `❌ ${message}`, log);
|
|
267
|
+
markRestartOutcome(pendingFile, 'build-failed', buildOutcome.reason, log);
|
|
268
|
+
process.exit(1);
|
|
269
|
+
}
|
|
270
|
+
if (buildOutcome.status === 'rolled-back') {
|
|
271
|
+
// 回滚后仍然继续启动:旧产物是可用的,服务恢复比停在这里更重要。
|
|
272
|
+
log(`↩ ${buildOutcome.reason ?? '已回滚'};继续以上一版本启动`);
|
|
273
|
+
await notifyDaemonOwners(p, `⚠️ ${buildOutcome.reason ?? '已回滚'},正在以上一版本启动服务`, log);
|
|
274
|
+
}
|
|
203
275
|
// ECWeb is owned by the daemon. Starting it here used to race with the
|
|
204
276
|
// daemon's own launcher and ECWeb's stale-instance cleanup, which could
|
|
205
277
|
// leave ServiceProxy pointed at a dead PID.
|
|
206
278
|
// 启动并检测 ready signal
|
|
207
279
|
let started = await spawnAndWaitReady(p, log, READY_TIMEOUT);
|
|
208
280
|
if (started) {
|
|
209
|
-
|
|
281
|
+
if (upgradeRolledBack) {
|
|
282
|
+
markRestartOutcome(pendingFile, 'rolled-back', upgradeRolledBack, log);
|
|
283
|
+
log(`✓ Service restarted after upgrade rollback`);
|
|
284
|
+
}
|
|
285
|
+
else if (buildOutcome.status === 'rolled-back') {
|
|
286
|
+
// 让发起方的 agent 知道改动没生效,而不是以为重启成功了。
|
|
287
|
+
markRestartOutcome(pendingFile, 'rolled-back', buildOutcome.reason, log);
|
|
288
|
+
log('✓ Service restarted on the previous build');
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
dropStaleBackup(log);
|
|
292
|
+
dropUpgradeBackup(upgradeBackup, log);
|
|
293
|
+
if (upgradeVersions?.to) {
|
|
294
|
+
markRestartOutcome(pendingFile, 'upgraded', `已升级 ${upgradeVersions.from} → ${upgradeVersions.to}`, log);
|
|
295
|
+
}
|
|
296
|
+
log('✓ Service restarted successfully');
|
|
297
|
+
}
|
|
210
298
|
archiveSelfHealLog(p, log);
|
|
211
299
|
// 发起会话内的“重启成功”通知由新进程自行发送,此处只负责失败/自愈告警。
|
|
212
300
|
process.exit(0);
|
|
213
301
|
}
|
|
302
|
+
// 升级后的新版本起不来:回退到升级前的产物再试一次。
|
|
303
|
+
if (upgradeVersions?.to && !upgradeRolledBack) {
|
|
304
|
+
if (await restoreDistBackup(upgradeBackup, log)) {
|
|
305
|
+
await notifyDaemonOwners(p, `⚠️ 升级到 ${upgradeVersions.to} 后启动失败,正在回滚到 ${upgradeVersions.from} 重试...`, log);
|
|
306
|
+
started = await spawnAndWaitReady(p, log, READY_TIMEOUT);
|
|
307
|
+
if (started) {
|
|
308
|
+
const reason = `升级到 ${upgradeVersions.to} 后启动失败,已回滚到 ${upgradeVersions.from}`;
|
|
309
|
+
log(`✓ ${reason}`);
|
|
310
|
+
markRestartOutcome(pendingFile, 'rolled-back', reason, log);
|
|
311
|
+
await notifyDaemonOwners(p, `⚠️ ${reason}。当前运行旧版本,请检查后重试。`, log);
|
|
312
|
+
archiveSelfHealLog(p, log);
|
|
313
|
+
process.exit(0);
|
|
314
|
+
}
|
|
315
|
+
log('❌ Rolled-back version also failed to start');
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// 新产物起不来:回退到上一份可用产物再试一次,比直接进 self-heal 快且确定。
|
|
319
|
+
if (buildOutcome.status === 'built') {
|
|
320
|
+
const reverted = await revertToPreviousBuild(log);
|
|
321
|
+
if (reverted) {
|
|
322
|
+
await notifyDaemonOwners(p, '⚠️ 新编译产物启动失败,正在回滚到上一版本重试...', log);
|
|
323
|
+
started = await spawnAndWaitReady(p, log, READY_TIMEOUT);
|
|
324
|
+
if (started) {
|
|
325
|
+
const reason = '新编译产物启动失败,已回滚到上一个可用版本';
|
|
326
|
+
log(`✓ ${reason}`);
|
|
327
|
+
markRestartOutcome(pendingFile, 'rolled-back', reason, log);
|
|
328
|
+
await notifyDaemonOwners(p, `⚠️ ${reason}。改动未生效,请检查代码后重试。`, log);
|
|
329
|
+
archiveSelfHealLog(p, log);
|
|
330
|
+
process.exit(0);
|
|
331
|
+
}
|
|
332
|
+
log('❌ Rolled-back build also failed to start');
|
|
333
|
+
}
|
|
334
|
+
}
|
|
214
335
|
// 启动失败 — 测试环境下跳过 self-heal(避免 claude -p 污染会话列表、误杀生产进程)
|
|
215
336
|
if (p.root.startsWith('/tmp/') || process.env.EVOLCORE_TEST === '1') {
|
|
216
337
|
log('❌ Service failed to start (test environment detected, skipping self-heal)');
|
|
@@ -320,6 +441,49 @@ function sleep(ms) {
|
|
|
320
441
|
function normalizePath(value) {
|
|
321
442
|
return value.replace(/[\\/]+/g, '/').toLowerCase();
|
|
322
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* Move a directory, retrying briefly before falling back to copy+delete.
|
|
446
|
+
*
|
|
447
|
+
* Windows fails `rename` with EPERM/EBUSY/EACCES while any handle inside the
|
|
448
|
+
* tree is still open — an antivirus scan or a not-quite-dead child is enough,
|
|
449
|
+
* and those clear within a second or two. When the handle does not clear we
|
|
450
|
+
* still have to make progress, so the fallback copies the tree and deletes the
|
|
451
|
+
* source afterwards: slower, but it does not need exclusive access. A failure
|
|
452
|
+
* to delete the source is not fatal, the copy already succeeded.
|
|
453
|
+
*/
|
|
454
|
+
async function moveDirWithFallback(from, to, log) {
|
|
455
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
456
|
+
try {
|
|
457
|
+
fs.renameSync(from, to);
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
const code = error.code;
|
|
462
|
+
if (attempt < 3 && (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES')) {
|
|
463
|
+
log(`rename ${path.basename(from)} -> ${path.basename(to)} failed (${code}), retry ${attempt}/2`);
|
|
464
|
+
await sleep(attempt * 700);
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
log(`rename failed (${code ?? 'unknown'}), falling back to copy: ${path.basename(from)}`);
|
|
468
|
+
try {
|
|
469
|
+
fs.cpSync(from, to, { recursive: true });
|
|
470
|
+
try {
|
|
471
|
+
fs.rmSync(from, { recursive: true, force: true });
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
// Copy is what matters; a leftover source only wastes disk.
|
|
475
|
+
log(`WARN: copied ${path.basename(from)} but could not remove the original`);
|
|
476
|
+
}
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
catch (copyError) {
|
|
480
|
+
log(`ERROR: copy fallback failed: ${copyError.message}`);
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
323
487
|
function hasStartupResourceConflict(p) {
|
|
324
488
|
let daemonLog = null;
|
|
325
489
|
try {
|
|
@@ -341,6 +505,266 @@ function hasStartupResourceConflict(p) {
|
|
|
341
505
|
}
|
|
342
506
|
});
|
|
343
507
|
}
|
|
508
|
+
/** Entry files that must exist for a build artifact to be considered usable. */
|
|
509
|
+
function distArtifactIsUsable(distDir) {
|
|
510
|
+
return ['index.js', path.join('cli', 'index.js')].every(rel => {
|
|
511
|
+
try {
|
|
512
|
+
return fs.statSync(path.join(distDir, rel)).size > 0;
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Rebuild `dist/` transactionally when sources are newer than the artifact.
|
|
521
|
+
*
|
|
522
|
+
* The daemon is already stopped when this runs, so the build cannot pull the
|
|
523
|
+
* rug from under a live process — but a failed build still leaves `dist/`
|
|
524
|
+
* half-deleted, and that is the state that used to make the service
|
|
525
|
+
* unrecoverable. So the previous artifact is moved aside first and restored
|
|
526
|
+
* whenever the build fails or produces something unusable: the caller either
|
|
527
|
+
* gets a fresh `dist/` or exactly the one it had before.
|
|
528
|
+
*
|
|
529
|
+
* Installed packages ship `dist/` without `src/`, so they skip all of this and
|
|
530
|
+
* keep using the artifact npm delivered.
|
|
531
|
+
*/
|
|
532
|
+
async function buildSourcesTransactionally(log) {
|
|
533
|
+
const packageRoot = targetPackageRoot();
|
|
534
|
+
const sourceCli = path.join(packageRoot, 'src', 'cli', 'index.ts');
|
|
535
|
+
if (!fs.existsSync(sourceCli))
|
|
536
|
+
return { status: 'skipped' };
|
|
537
|
+
const distDir = path.join(packageRoot, 'dist');
|
|
538
|
+
const backupDir = path.join(packageRoot, 'dist.prev');
|
|
539
|
+
const daemonEntry = path.join(distDir, 'index.js');
|
|
540
|
+
const latestSource = Math.max(latestMtimeIn(path.join(packageRoot, 'src')), latestMtimeIn(path.join(packageRoot, 'ecagent', 'src')));
|
|
541
|
+
const distIsUsable = distArtifactIsUsable(distDir);
|
|
542
|
+
if (distIsUsable) {
|
|
543
|
+
let distMtime = 0;
|
|
544
|
+
try {
|
|
545
|
+
distMtime = fs.statSync(daemonEntry).mtimeMs;
|
|
546
|
+
}
|
|
547
|
+
catch { }
|
|
548
|
+
if (latestSource <= distMtime)
|
|
549
|
+
return { status: 'skipped' };
|
|
550
|
+
}
|
|
551
|
+
// Clear a leftover backup from an interrupted run before creating a new one.
|
|
552
|
+
if (fs.existsSync(backupDir)) {
|
|
553
|
+
try {
|
|
554
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
555
|
+
}
|
|
556
|
+
catch { }
|
|
557
|
+
}
|
|
558
|
+
let haveBackup = false;
|
|
559
|
+
if (distIsUsable) {
|
|
560
|
+
haveBackup = await moveDirWithFallback(distDir, backupDir, log);
|
|
561
|
+
if (!haveBackup) {
|
|
562
|
+
// Without a rollback point a failed build would strand the service.
|
|
563
|
+
log('❌ Could not set aside the current dist/; skipping rebuild to keep the working artifact');
|
|
564
|
+
return { status: 'failed', reason: '无法备份当前 dist/,已跳过重建以保住可用产物' };
|
|
565
|
+
}
|
|
566
|
+
log('Backed up dist/ -> dist.prev');
|
|
567
|
+
}
|
|
568
|
+
const restoreBackup = async () => {
|
|
569
|
+
if (!haveBackup)
|
|
570
|
+
return false;
|
|
571
|
+
try {
|
|
572
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
573
|
+
}
|
|
574
|
+
catch { }
|
|
575
|
+
const restored = await moveDirWithFallback(backupDir, distDir, log);
|
|
576
|
+
log(restored ? '↩ Rolled back to the previous dist/' : '❌ Rollback failed: dist.prev could not be restored');
|
|
577
|
+
return restored;
|
|
578
|
+
};
|
|
579
|
+
log('🔧 Sources are newer than dist/, rebuilding...');
|
|
580
|
+
const built = await runBuildCommand(log);
|
|
581
|
+
if (!built) {
|
|
582
|
+
const rolledBack = await restoreBackup();
|
|
583
|
+
return rolledBack
|
|
584
|
+
? { status: 'rolled-back', reason: '编译失败,已回滚到上一个可用产物' }
|
|
585
|
+
: { status: 'failed', reason: '编译失败,且回滚未成功' };
|
|
586
|
+
}
|
|
587
|
+
if (!distArtifactIsUsable(distDir)) {
|
|
588
|
+
log('❌ Build reported success but dist/ is missing an entry file');
|
|
589
|
+
const rolledBack = await restoreBackup();
|
|
590
|
+
return rolledBack
|
|
591
|
+
? { status: 'rolled-back', reason: '编译产物不完整,已回滚到上一个可用产物' }
|
|
592
|
+
: { status: 'failed', reason: '编译产物不完整,且回滚未成功' };
|
|
593
|
+
}
|
|
594
|
+
// Keep dist.prev until the new artifact has actually served a ready signal;
|
|
595
|
+
// the caller decides when to drop it.
|
|
596
|
+
return { status: 'built' };
|
|
597
|
+
}
|
|
598
|
+
/** Latest mtime under a directory tree, 0 when unreadable. */
|
|
599
|
+
function latestMtimeIn(dir) {
|
|
600
|
+
let latest = 0;
|
|
601
|
+
try {
|
|
602
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
603
|
+
const full = path.join(dir, entry.name);
|
|
604
|
+
latest = entry.isDirectory()
|
|
605
|
+
? Math.max(latest, latestMtimeIn(full))
|
|
606
|
+
: Math.max(latest, fs.statSync(full).mtimeMs);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
catch { }
|
|
610
|
+
return latest;
|
|
611
|
+
}
|
|
612
|
+
/** Run `npm run build` in the package root, resolving to false on any failure. */
|
|
613
|
+
function runBuildCommand(log) {
|
|
614
|
+
return new Promise(resolve => {
|
|
615
|
+
// Windows cannot spawn the npm .cmd shim directly with shell:false on
|
|
616
|
+
// recent Node versions; invoke cmd.exe explicitly instead.
|
|
617
|
+
const command = platform.isWindows ? (process.env.ComSpec ?? process.env.COMSPEC ?? 'cmd.exe') : 'npm';
|
|
618
|
+
const args = platform.isWindows ? ['/d', '/c', 'npm.cmd', 'run', 'build'] : ['run', 'build'];
|
|
619
|
+
const child = spawn(command, args, {
|
|
620
|
+
cwd: targetPackageRoot(),
|
|
621
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
622
|
+
windowsHide: true,
|
|
623
|
+
});
|
|
624
|
+
let tail = '';
|
|
625
|
+
const capture = (chunk) => {
|
|
626
|
+
tail = (tail + chunk.toString()).slice(-4000);
|
|
627
|
+
};
|
|
628
|
+
child.stdout?.on('data', capture);
|
|
629
|
+
child.stderr?.on('data', capture);
|
|
630
|
+
child.on('error', err => {
|
|
631
|
+
log(`❌ Failed to launch the build: ${err.message}`);
|
|
632
|
+
resolve(false);
|
|
633
|
+
});
|
|
634
|
+
child.on('exit', code => {
|
|
635
|
+
if (code === 0) {
|
|
636
|
+
log('✅ Build finished');
|
|
637
|
+
resolve(true);
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
log(`❌ Build failed (exit ${code})`);
|
|
641
|
+
for (const line of tail.split(/\r?\n/).filter(Boolean).slice(-12))
|
|
642
|
+
log(` | ${line}`);
|
|
643
|
+
resolve(false);
|
|
644
|
+
});
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Annotate restart-pending.json with how the restart actually ended.
|
|
649
|
+
*
|
|
650
|
+
* The new daemon reads this file to send its "restart done" receipt into the
|
|
651
|
+
* originating session. Without an outcome the agent cannot tell a clean restart
|
|
652
|
+
* from one that silently fell back to the previous build and would assume its
|
|
653
|
+
* code change took effect. Older daemons ignore unknown fields, so writing this
|
|
654
|
+
* stays safe when the rolled-back artifact predates the field.
|
|
655
|
+
*/
|
|
656
|
+
function markRestartOutcome(pendingFile, outcome, reason, log) {
|
|
657
|
+
try {
|
|
658
|
+
if (!fs.existsSync(pendingFile))
|
|
659
|
+
return;
|
|
660
|
+
const info = JSON.parse(fs.readFileSync(pendingFile, 'utf-8'));
|
|
661
|
+
info.outcome = outcome;
|
|
662
|
+
if (reason)
|
|
663
|
+
info.outcomeReason = reason;
|
|
664
|
+
fs.writeFileSync(pendingFile, JSON.stringify(info));
|
|
665
|
+
log(`Marked restart-pending.json outcome=${outcome}`);
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
log(`WARN: could not annotate restart-pending.json: ${error.message}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
/** Drop the rollback point once the freshly built artifact is serving. */
|
|
672
|
+
function dropStaleBackup(log) {
|
|
673
|
+
const backupDir = path.join(targetPackageRoot(), 'dist.prev');
|
|
674
|
+
if (!fs.existsSync(backupDir))
|
|
675
|
+
return;
|
|
676
|
+
try {
|
|
677
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
678
|
+
log('Removed dist.prev (new build verified)');
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// Harmless: the next build clears it before creating its own backup.
|
|
682
|
+
log('WARN: could not remove dist.prev');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/** Swap the freshly built dist/ back to the previous artifact after a failed start. */
|
|
686
|
+
async function revertToPreviousBuild(log) {
|
|
687
|
+
const packageRoot = targetPackageRoot();
|
|
688
|
+
const distDir = path.join(packageRoot, 'dist');
|
|
689
|
+
const backupDir = path.join(packageRoot, 'dist.prev');
|
|
690
|
+
if (!fs.existsSync(backupDir))
|
|
691
|
+
return false;
|
|
692
|
+
try {
|
|
693
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
694
|
+
}
|
|
695
|
+
catch { }
|
|
696
|
+
const restored = await moveDirWithFallback(backupDir, distDir, log);
|
|
697
|
+
log(restored ? '↩ Restored the previous dist/' : '❌ Could not restore dist.prev');
|
|
698
|
+
return restored;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Copy the current dist/ aside so a bad upgrade can be undone.
|
|
702
|
+
*
|
|
703
|
+
* Unlike the build path — where dist/ is *moved* to dist.prev because it is
|
|
704
|
+
* about to be deleted anyway — an upgrade replaces dist/ in place, so the
|
|
705
|
+
* original must stay usable until npm succeeds. Hence a copy.
|
|
706
|
+
*
|
|
707
|
+
* The backup lives under EVOLCORE_HOME rather than in the package directory:
|
|
708
|
+
* on Linux/macOS the global prefix is frequently root-owned, and a rollback
|
|
709
|
+
* that cannot be written is worthless. Returns null when there is nothing to
|
|
710
|
+
* back up or the copy failed — the caller then knows rollback is unavailable.
|
|
711
|
+
*/
|
|
712
|
+
async function backupDistForUpgrade(p, log) {
|
|
713
|
+
const distDir = path.join(targetPackageRoot(), 'dist');
|
|
714
|
+
if (!distArtifactIsUsable(distDir))
|
|
715
|
+
return null;
|
|
716
|
+
const backupDir = path.join(p.root, 'data', 'package-backup', 'dist.prev');
|
|
717
|
+
try {
|
|
718
|
+
fs.mkdirSync(path.dirname(backupDir), { recursive: true });
|
|
719
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
720
|
+
fs.cpSync(distDir, backupDir, { recursive: true });
|
|
721
|
+
log(`Backed up dist/ -> ${backupDir}`);
|
|
722
|
+
return backupDir;
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
log(`WARN: could not back up dist/ before upgrade: ${error.message}`);
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
/** Restore a backup produced by backupDistForUpgrade. */
|
|
730
|
+
async function restoreDistBackup(backupDir, log) {
|
|
731
|
+
if (!backupDir || !fs.existsSync(backupDir)) {
|
|
732
|
+
log('❌ No usable dist backup to restore from');
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
const distDir = path.join(targetPackageRoot(), 'dist');
|
|
736
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
737
|
+
try {
|
|
738
|
+
fs.rmSync(distDir, { recursive: true, force: true });
|
|
739
|
+
fs.cpSync(backupDir, distDir, { recursive: true });
|
|
740
|
+
log('↩ Restored dist/ from backup');
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
catch (error) {
|
|
744
|
+
const code = error.code;
|
|
745
|
+
if (attempt < 3 && (code === 'EPERM' || code === 'EBUSY' || code === 'EACCES')) {
|
|
746
|
+
log(`restore failed (${code}), retry ${attempt}/2`);
|
|
747
|
+
await sleep(attempt * 700);
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
log(`❌ Could not restore dist/: ${error.message}`);
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
/** Drop the upgrade rollback point once the new version is confirmed serving. */
|
|
757
|
+
function dropUpgradeBackup(backupDir, log) {
|
|
758
|
+
if (!backupDir || !fs.existsSync(backupDir))
|
|
759
|
+
return;
|
|
760
|
+
try {
|
|
761
|
+
fs.rmSync(backupDir, { recursive: true, force: true });
|
|
762
|
+
log('Removed upgrade backup (new version verified)');
|
|
763
|
+
}
|
|
764
|
+
catch {
|
|
765
|
+
log('WARN: could not remove upgrade backup');
|
|
766
|
+
}
|
|
767
|
+
}
|
|
344
768
|
function cleanupPendingFile(filePath, log) {
|
|
345
769
|
try {
|
|
346
770
|
if (fs.existsSync(filePath)) {
|
|
@@ -372,7 +796,7 @@ async function spawnAndWaitReady(p, log, timeout) {
|
|
|
372
796
|
const stdoutLog = path.join(p.logs, 'stdout.log');
|
|
373
797
|
const out = fs.openSync(stdoutLog, 'a');
|
|
374
798
|
const err = fs.openSync(stdoutLog, 'a');
|
|
375
|
-
const appMain = path.join(
|
|
799
|
+
const appMain = path.join(targetPackageRoot(), 'dist', 'index.js');
|
|
376
800
|
const child = spawn(process.execPath, ['--no-warnings=ExperimentalWarning', appMain], {
|
|
377
801
|
detached: true,
|
|
378
802
|
stdio: ['ignore', out, err],
|
|
@@ -423,7 +847,7 @@ async function spawnAndWaitReady(p, log, timeout) {
|
|
|
423
847
|
* 调用 claude CLI 进行自动修复
|
|
424
848
|
*/
|
|
425
849
|
async function invokeClaude(claudePath, p, attempt, maxAttempts, timeout, log) {
|
|
426
|
-
const projectDir =
|
|
850
|
+
const projectDir = targetPackageRoot();
|
|
427
851
|
const selfHealLog = p.selfHealLog;
|
|
428
852
|
const stdoutLog = path.join(p.logs, 'stdout.log');
|
|
429
853
|
const selfHealExists = fs.existsSync(selfHealLog) ? '存在,请先阅读之前的修复记录' : '不存在(首次修复)';
|
package/dist/cli/stats.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { resolveRoot } from '../paths.js';
|
|
5
5
|
import { wantsHelp } from './help.js';
|
|
6
|
+
import { readBestTaskRuntimeContext } from './task-context.js';
|
|
6
7
|
import { queryAggregated, querySessionTurns, queryContextBreakdown, queryTopPeers, queryTopModels, queryMessageAggregated, queryPeerList, querySummary, queryPeerDaily, queryTaskModelCalls, querySessionModelCalls } from '../stats/query.js';
|
|
7
8
|
import { getBudgetStatus } from '../stats/budget.js';
|
|
8
9
|
const HELP = `ec stats — Token 用量与费用统计
|
|
@@ -102,6 +103,98 @@ function parseDateArg(s) {
|
|
|
102
103
|
throw new Error(`Invalid date: ${s}`);
|
|
103
104
|
return d.getTime();
|
|
104
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Views that read the whole stats database rather than one authorized scope.
|
|
108
|
+
* `--sql` is a read-only escape hatch over every table, and `--rebuild` writes.
|
|
109
|
+
* Neither can be narrowed to a relation, so both stay outside managed tasks.
|
|
110
|
+
*/
|
|
111
|
+
const MANAGED_FORBIDDEN_FLAGS = ['sql', 'rebuild'];
|
|
112
|
+
/**
|
|
113
|
+
* Views that enumerate peers other than the caller. They are meaningful only
|
|
114
|
+
* for an agent-wide scope, so a relation-scoped caller cannot use them.
|
|
115
|
+
*/
|
|
116
|
+
const AGENT_SCOPE_FLAGS = ['peers', 'groups', 'top-peers'];
|
|
117
|
+
/** Relation roles allowed to read usage across the whole agent. */
|
|
118
|
+
const AGENT_SCOPE_ROLES = new Set(['owner', 'admin']);
|
|
119
|
+
/**
|
|
120
|
+
* Resolve the authorized stats scope for a managed Agent task.
|
|
121
|
+
*
|
|
122
|
+
* `ec stats` reads the usage database directly, so authorization has to happen
|
|
123
|
+
* here: owner and admin may read the whole agent, and every other relation role
|
|
124
|
+
* is confined to its own relation and session. Returns undefined outside a
|
|
125
|
+
* managed task, leaving direct terminal usage unchanged.
|
|
126
|
+
*/
|
|
127
|
+
async function resolveManagedStatsScope(flags, isJson) {
|
|
128
|
+
if (!process.env.EVOLCORE_SESSION_ID)
|
|
129
|
+
return undefined;
|
|
130
|
+
const runtime = await readBestTaskRuntimeContext();
|
|
131
|
+
const role = runtime?.peerRole?.trim();
|
|
132
|
+
const selfAid = runtime?.selfAid?.trim();
|
|
133
|
+
// Fail closed: without a resolved identity and role there is no scope to
|
|
134
|
+
// authorize against, and a managed caller must never fall back to full access.
|
|
135
|
+
if (!selfAid || !role || role === 'none') {
|
|
136
|
+
fail(isJson, 'NOT_ALLOWED', 'stats 在托管任务中需要已解析的关系身份与角色');
|
|
137
|
+
}
|
|
138
|
+
for (const flag of MANAGED_FORBIDDEN_FLAGS) {
|
|
139
|
+
if (flags[flag])
|
|
140
|
+
fail(isJson, 'NOT_ALLOWED', `--${flag} 在托管任务中不可用`);
|
|
141
|
+
}
|
|
142
|
+
if (AGENT_SCOPE_ROLES.has(role))
|
|
143
|
+
return { agentWide: true, agentAid: selfAid, role };
|
|
144
|
+
for (const flag of AGENT_SCOPE_FLAGS) {
|
|
145
|
+
if (flags[flag])
|
|
146
|
+
fail(isJson, 'NOT_ALLOWED', `--${flag} 仅 owner/admin 可用`);
|
|
147
|
+
}
|
|
148
|
+
const channel = runtime?.channel?.trim();
|
|
149
|
+
const peerId = runtime?.peerId?.trim();
|
|
150
|
+
if (!channel || !peerId) {
|
|
151
|
+
fail(isJson, 'NOT_ALLOWED', `角色 ${role} 只能查看自己的关系用量,但当前对端标识不完整`);
|
|
152
|
+
}
|
|
153
|
+
const sessionId = process.env.EVOLCORE_SESSION_ID;
|
|
154
|
+
return {
|
|
155
|
+
agentWide: false,
|
|
156
|
+
agentAid: selfAid,
|
|
157
|
+
peerKey: `${channel}#${encodeURIComponent(peerId)}`,
|
|
158
|
+
sessionId,
|
|
159
|
+
role,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Reject an explicit selector that points outside the authorized scope instead
|
|
164
|
+
* of silently narrowing it, so a caller never reads a value as their own.
|
|
165
|
+
*/
|
|
166
|
+
function enforceManagedSelector(scope, flags, isJson) {
|
|
167
|
+
// owner/admin read across the host, so an explicit --agent stays available to
|
|
168
|
+
// them. Every other role is pinned to the agent that owns this task.
|
|
169
|
+
if (scope.agentWide)
|
|
170
|
+
return;
|
|
171
|
+
const requestedAgent = typeof flags.agent === 'string' ? flags.agent.trim() : undefined;
|
|
172
|
+
if (requestedAgent && requestedAgent !== scope.agentAid) {
|
|
173
|
+
fail(isJson, 'NOT_ALLOWED', `角色 ${scope.role} 只能查看 ${scope.agentAid} 的用量`);
|
|
174
|
+
}
|
|
175
|
+
const normalizePeer = (value) => value.includes('#') ? value : `aun#${encodeURIComponent(value)}`;
|
|
176
|
+
for (const flag of ['peer', 'peer-detail']) {
|
|
177
|
+
const raw = flags[flag];
|
|
178
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
179
|
+
continue;
|
|
180
|
+
if (normalizePeer(raw.trim()) !== scope.peerKey) {
|
|
181
|
+
fail(isJson, 'NOT_ALLOWED', `角色 ${scope.role} 只能查看自己的关系用量`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
for (const flag of ['session', 'context', 'session-calls']) {
|
|
185
|
+
const raw = flags[flag];
|
|
186
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
187
|
+
continue;
|
|
188
|
+
if (raw.trim() !== scope.sessionId) {
|
|
189
|
+
fail(isJson, 'NOT_ALLOWED', `角色 ${scope.role} 只能查看当前会话的用量`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// A task id cannot be checked against this relation without a second lookup,
|
|
193
|
+
// and it addresses one runQuery across the whole agent. Keep it privileged.
|
|
194
|
+
if (typeof flags['task-calls'] === 'string' && flags['task-calls'].trim()) {
|
|
195
|
+
fail(isJson, 'NOT_ALLOWED', '--task-calls 仅 owner/admin 可用');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
105
198
|
export async function handleStats(args) {
|
|
106
199
|
// Help
|
|
107
200
|
if (wantsHelp(args)) {
|
|
@@ -130,6 +223,11 @@ export async function handleStats(args) {
|
|
|
130
223
|
}
|
|
131
224
|
}
|
|
132
225
|
const isJson = flags.format === 'json';
|
|
226
|
+
// Authorize before any branch runs: `--rebuild` writes, and every read view
|
|
227
|
+
// below would otherwise see the whole database.
|
|
228
|
+
const managedScope = await resolveManagedStatsScope(flags, isJson);
|
|
229
|
+
if (managedScope)
|
|
230
|
+
enforceManagedSelector(managedScope, flags, isJson);
|
|
133
231
|
// 全量重建日聚合表(运维兜底/排查),不依赖时间范围。
|
|
134
232
|
if (flags.rebuild) {
|
|
135
233
|
const { rebuildDailyRollup, getDb } = await import('../stats/db.js');
|
|
@@ -223,6 +321,18 @@ export async function handleStats(args) {
|
|
|
223
321
|
filter.model = flags.model;
|
|
224
322
|
if (flags.session && typeof flags.session === 'string')
|
|
225
323
|
filter.session_id = flags.session;
|
|
324
|
+
// Pin the authorized scope into the filter itself. Rejecting an out-of-scope
|
|
325
|
+
// selector is not enough: a relation-scoped caller that passes no selector at
|
|
326
|
+
// all would otherwise aggregate across every agent and peer in the database.
|
|
327
|
+
if (managedScope && !managedScope.agentWide) {
|
|
328
|
+
filter.agent_aid = managedScope.agentAid;
|
|
329
|
+
filter.peer_key = managedScope.peerKey;
|
|
330
|
+
// A bare peer id would take the LIKE branch below and could also match the
|
|
331
|
+
// same id on another channel. Pin the exact key that was authorized.
|
|
332
|
+
if (typeof flags['peer-detail'] === 'string' && managedScope.peerKey) {
|
|
333
|
+
flags['peer-detail'] = managedScope.peerKey;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
226
336
|
// ── 结构化查询:私聊/群聊列表、总消耗汇总、对端按天明细 ──────────────────
|
|
227
337
|
if (flags.peers || flags.groups) {
|
|
228
338
|
const peerType = flags.peers ? 'private' : 'group';
|