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.
Files changed (84) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +3 -1
  3. package/bin/codex-managed-acl.ps1 +7 -2
  4. package/bin/codex-managed-hook.mjs +127 -10
  5. package/bin/ec-safe-output.js +85 -3
  6. package/bin/install-codex-managed-hooks.mjs +17 -6
  7. package/dist/agents/baseagent-availability.js +4 -4
  8. package/dist/agents/baseagent.js +29 -1
  9. package/dist/agents/claude-runner.js +148 -13
  10. package/dist/agents/codex/app-server-client.js +18 -8
  11. package/dist/agents/codex/catalog.js +1 -1
  12. package/dist/agents/codex/diagnostics.js +215 -0
  13. package/dist/agents/codex-runner.js +438 -59
  14. package/dist/agents/ecagent-runner.js +17 -3
  15. package/dist/agents/gemini-runner.js +60 -2
  16. package/dist/agents/runner-types.js +1 -1
  17. package/dist/aun/aid/managed-operation.js +28 -6
  18. package/dist/aun/aid/store.js +41 -0
  19. package/dist/aun/msg/managed-operation.js +6 -1
  20. package/dist/aun/outbox.js +56 -18
  21. package/dist/aun/rpc/client.js +4 -5
  22. package/dist/channels/aun.js +147 -76
  23. package/dist/channels/daemon.js +7 -1
  24. package/dist/channels/dingtalk.js +1 -0
  25. package/dist/channels/feishu.js +10 -4
  26. package/dist/channels/wecom.js +2 -1
  27. package/dist/cli/agent-command.js +29 -3
  28. package/dist/cli/agent-create.js +538 -0
  29. package/dist/cli/agent.js +26 -675
  30. package/dist/cli/aun-commands.js +60 -20
  31. package/dist/cli/ctl-command.js +2 -0
  32. package/dist/cli/daemon-commands.js +148 -17
  33. package/dist/cli/index.js +2 -2
  34. package/dist/cli/managed-command-guard.js +3 -0
  35. package/dist/cli/restart-monitor.js +432 -8
  36. package/dist/cli/stats.js +110 -0
  37. package/dist/config/builtin-roles.js +2 -0
  38. package/dist/core/auth/auth-gateway.js +3 -2
  39. package/dist/core/auth/authorization-audit.js +72 -19
  40. package/dist/core/auth/operation-authorizer.js +63 -3
  41. package/dist/core/auth/operation-catalog.js +10 -2
  42. package/dist/core/auth/remote-access-store.js +3 -2
  43. package/dist/core/baseagent-loader.js +4 -4
  44. package/dist/core/bootstrap-messages.js +39 -18
  45. package/dist/core/bootstrap-service.js +32 -26
  46. package/dist/core/capability/capability-manager.js +8 -4
  47. package/dist/core/capability/providers/codex-capability-provider.js +1 -1
  48. package/dist/core/command/agent-control.js +7 -2
  49. package/dist/core/command/command-handler.js +87 -25
  50. package/dist/core/command/menu-catalog.js +3 -1
  51. package/dist/core/command/menu-handler.js +274 -70
  52. package/dist/core/command/menu-protocol.js +6 -2
  53. package/dist/core/command/slash-gate.js +1 -3
  54. package/dist/core/command/slash-handler.js +157 -48
  55. package/dist/core/data-migration.js +5 -15
  56. package/dist/core/event-catalog.js +3 -0
  57. package/dist/core/handoff/runtime.js +3 -1
  58. package/dist/core/handoff/store.js +7 -1
  59. package/dist/core/message/im-renderer.js +13 -6
  60. package/dist/core/message/message-bridge.js +5 -36
  61. package/dist/core/message/message-utils.js +6 -3
  62. package/dist/core/message/response-engine.js +73 -21
  63. package/dist/core/model/catalog-service.js +3 -1
  64. package/dist/core/model/model-catalog.js +10 -0
  65. package/dist/core/model/model-permission.js +4 -1
  66. package/dist/core/permission/approval-gateway.js +169 -33
  67. package/dist/core/permission/tool-policy.js +13 -6
  68. package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
  69. package/dist/core/session/session-manager.js +49 -13
  70. package/dist/core/session/session-renew.js +1 -0
  71. package/dist/index.js +174 -28
  72. package/dist/ipc.js +68 -21
  73. package/dist/paths.js +23 -1
  74. package/dist/stats/billing.js +18 -4
  75. package/dist/utils/atomic-write.js +26 -2
  76. package/dist/utils/cross-platform.js +436 -1
  77. package/dist/utils/error-utils.js +11 -4
  78. package/dist/utils/model-prices.jsonl +23 -20
  79. package/dist/utils/npm-ops.js +24 -1
  80. package/dist/utils/windows-autostart.js +1 -1
  81. package/kits/templates/roles/admin.json +2 -0
  82. package/package.json +7 -3
  83. package/AGENTS.md +0 -62
  84. 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', () => removeRestartMonitor());
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(getPackageRoot()));
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
- await notifyDaemonOwners(p, `📦 已升级 ${upgrade.from} → ${upgrade.to}`, log);
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
- log('✓ Service restarted successfully');
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(getPackageRoot(), 'dist', 'index.js');
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 = getPackageRoot();
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';