evolcore 0.0.22 → 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 (117) hide show
  1. package/CHANGELOG.md +74 -14
  2. package/README.md +4 -0
  3. package/bin/codex-managed-acl.ps1 +160 -0
  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 +384 -95
  7. package/dist/agents/baseagent-availability.js +48 -0
  8. package/dist/agents/baseagent.js +30 -2
  9. package/dist/agents/claude-runner.js +179 -240
  10. package/dist/agents/{codex-app-server-client.js → codex/app-server-client.js} +90 -20
  11. package/dist/{utils/codex-app-server-registry.js → agents/codex/app-server-registry.js} +2 -2
  12. package/dist/agents/codex/catalog.js +70 -0
  13. package/dist/agents/codex/diagnostics.js +369 -0
  14. package/dist/agents/codex-runner.js +839 -192
  15. package/dist/agents/ecagent-runner.js +27 -34
  16. package/dist/agents/gemini-runner.js +62 -8
  17. package/dist/agents/runner-types.js +1 -1
  18. package/dist/aun/aid/managed-operation.js +28 -6
  19. package/dist/aun/aid/store.js +43 -0
  20. package/dist/aun/menu-client.js +272 -0
  21. package/dist/aun/msg/group.js +65 -4
  22. package/dist/aun/msg/index.js +1 -1
  23. package/dist/aun/msg/managed-operation.js +57 -5
  24. package/dist/aun/outbox.js +56 -18
  25. package/dist/aun/rpc/client.js +7 -1
  26. package/dist/channels/aun.js +178 -77
  27. package/dist/channels/daemon.js +7 -1
  28. package/dist/channels/dingtalk.js +1 -0
  29. package/dist/channels/feishu.js +10 -4
  30. package/dist/channels/wechat.js +81 -10
  31. package/dist/channels/wecom.js +2 -1
  32. package/dist/cli/agent-command.js +29 -3
  33. package/dist/cli/agent-create.js +538 -0
  34. package/dist/cli/agent.js +26 -675
  35. package/dist/cli/aun-commands.js +108 -24
  36. package/dist/cli/command-log.js +21 -2
  37. package/dist/cli/ctl-command.js +2 -0
  38. package/dist/cli/daemon-commands.js +289 -61
  39. package/dist/cli/index.js +22 -2
  40. package/dist/cli/init.js +2 -10
  41. package/dist/cli/managed-command-guard.js +3 -0
  42. package/dist/cli/menu-client.js +95 -0
  43. package/dist/cli/model.js +23 -8
  44. package/dist/cli/restart-monitor.js +432 -8
  45. package/dist/cli/stats.js +110 -0
  46. package/dist/cli/task-context.js +12 -2
  47. package/dist/cli/trigger-command.js +9 -1
  48. package/dist/config/builtin-roles.js +2 -0
  49. package/dist/config/schema-migration.js +14 -2
  50. package/dist/core/auth/agent-delegation.js +10 -7
  51. package/dist/core/auth/auth-gateway.js +3 -2
  52. package/dist/core/auth/authorization-audit.js +75 -18
  53. package/dist/core/auth/operation-authorizer.js +63 -3
  54. package/dist/core/auth/operation-catalog.js +14 -2
  55. package/dist/core/auth/remote-access-store.js +136 -0
  56. package/dist/core/baseagent-loader.js +4 -4
  57. package/dist/core/bootstrap-messages.js +39 -18
  58. package/dist/core/bootstrap-service.js +32 -26
  59. package/dist/core/capability/capability-manager.js +8 -4
  60. package/dist/core/capability/providers/codex-capability-provider.js +1 -1
  61. package/dist/core/command/agent-control.js +7 -2
  62. package/dist/core/command/command-handler.js +128 -30
  63. package/dist/core/command/daemon-diagnostics.js +448 -0
  64. package/dist/core/command/menu-catalog.js +6 -1
  65. package/dist/core/command/menu-handler.js +410 -98
  66. package/dist/core/command/menu-protocol.js +123 -0
  67. package/dist/core/command/slash-gate.js +4 -6
  68. package/dist/core/command/slash-handler.js +199 -70
  69. package/dist/core/data-migration.js +5 -15
  70. package/dist/core/event-catalog.js +3 -0
  71. package/dist/core/handoff/runtime.js +3 -1
  72. package/dist/core/handoff/store.js +7 -1
  73. package/dist/core/message/im-renderer.js +13 -6
  74. package/dist/core/message/message-bridge.js +5 -36
  75. package/dist/core/message/message-queue.js +20 -15
  76. package/dist/core/message/message-utils.js +6 -3
  77. package/dist/core/message/response-engine.js +147 -94
  78. package/dist/core/model/catalog-service.js +310 -0
  79. package/dist/core/model/model-catalog.js +134 -334
  80. package/dist/core/model/model-diagnostics.js +4 -5
  81. package/dist/core/model/model-fallback.js +9 -16
  82. package/dist/core/model/model-permission.js +5 -2
  83. package/dist/core/permission/approval-gateway.js +183 -32
  84. package/dist/core/permission/protected-paths.js +63 -4
  85. package/dist/core/permission/sandbox-runtime.js +4 -2
  86. package/dist/core/permission/tool-policy.js +257 -31
  87. package/dist/core/runtime-lock.js +13 -8
  88. package/dist/core/session/adapters/codex-session-file-adapter.js +71 -3
  89. package/dist/core/session/session-manager.js +49 -13
  90. package/dist/core/session/session-renew.js +1 -0
  91. package/dist/index.js +214 -57
  92. package/dist/ipc.js +191 -102
  93. package/dist/paths.js +23 -1
  94. package/dist/stats/billing.js +18 -4
  95. package/dist/trigger/manager.js +3 -1
  96. package/dist/trigger/parser.js +77 -13
  97. package/dist/trigger/patch.js +104 -1
  98. package/dist/utils/atomic-write.js +26 -2
  99. package/dist/utils/cross-platform.js +436 -1
  100. package/dist/utils/ecweb-supervisor.js +11 -2
  101. package/dist/utils/ecweb-utils.js +8 -26
  102. package/dist/utils/error-utils.js +16 -2
  103. package/dist/utils/model-prices.jsonl +23 -20
  104. package/dist/utils/npm-ops.js +70 -15
  105. package/dist/utils/windows-autostart.js +1 -1
  106. package/kits/docs/evolcore/INDEX.md +1 -0
  107. package/kits/docs/evolcore/agent-create.md +13 -0
  108. package/kits/docs/evolcore/group-collaboration.md +7 -2
  109. package/kits/docs/evolcore/menu-client.md +34 -0
  110. package/kits/migrations/migrate-contact-book-v2.mjs +9 -1
  111. package/kits/migrations/migrate-role-config-v4.mjs +11 -5
  112. package/kits/migrations/migrate-role-config-v5.mjs +8 -2
  113. package/kits/templates/roles/admin.json +2 -0
  114. package/kits/templates/roles/visitor.json +1 -0
  115. package/kits/templates/system-fragments/bootstrap.md +8 -1
  116. package/package.json +16 -4
  117. package/dist/utils/codex-cli.js +0 -43
@@ -1,6 +1,7 @@
1
1
  import { normalizeTriggerDefinition, parseDurationMs, TRIGGER_SCHEMA_VERSION } from './validation.js';
2
2
  const PATCH_KEYS = new Set([
3
3
  'name',
4
+ 'description',
4
5
  'scheduleType',
5
6
  'scheduleValue',
6
7
  'timezone',
@@ -12,6 +13,21 @@ const PATCH_KEYS = new Set([
12
13
  'maxDuration',
13
14
  'concurrency',
14
15
  'missedPolicy',
16
+ 'feedback',
17
+ 'targetChannel',
18
+ 'targetChannelId',
19
+ 'targetChatType',
20
+ 'targetSession',
21
+ 'targetThreadId',
22
+ 'baseagent',
23
+ 'triggerThread',
24
+ 'onError',
25
+ 'noopSentinel',
26
+ 'retryAttempts',
27
+ 'retryBackoff',
28
+ 'scriptRuntime',
29
+ 'scriptArgs',
30
+ 'scriptTimeout',
15
31
  ]);
16
32
  export function applyTriggerPatch(definition, input, opts = {}) {
17
33
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
@@ -48,6 +64,12 @@ export function applyTriggerPatch(definition, input, opts = {}) {
48
64
  updated.reliability.concurrency = patch.concurrency;
49
65
  if (patch.missedPolicy !== undefined)
50
66
  updated.reliability.missedPolicy = patch.missedPolicy;
67
+ if (patch.description !== undefined) {
68
+ if (patch.description === null || patch.description === '')
69
+ delete updated.description;
70
+ else
71
+ updated.description = String(patch.description);
72
+ }
51
73
  if (patch.maxRuns !== undefined || patch.maxDuration !== undefined) {
52
74
  const limits = updated.limits ? { ...updated.limits } : {};
53
75
  if (patch.maxRuns !== undefined) {
@@ -64,6 +86,85 @@ export function applyTriggerPatch(definition, input, opts = {}) {
64
86
  }
65
87
  updated.limits = Object.keys(limits).length > 0 ? limits : undefined;
66
88
  }
89
+ // feedback strategy
90
+ if (patch.feedback !== undefined) {
91
+ updated.feedback.strategy = patch.feedback;
92
+ if (patch.feedback !== 'target')
93
+ delete updated.feedback.target;
94
+ }
95
+ // feedback target fields
96
+ const hasTargetPatch = patch.targetChannel !== undefined
97
+ || patch.targetChannelId !== undefined
98
+ || patch.targetChatType !== undefined
99
+ || patch.targetSession !== undefined
100
+ || patch.targetThreadId !== undefined;
101
+ if (hasTargetPatch) {
102
+ if ((patch.targetChannel === undefined) !== (patch.targetChannelId === undefined)) {
103
+ throw new Error('targetChannel 与 targetChannelId 必须同时指定');
104
+ }
105
+ const prev = updated.feedback.target;
106
+ const next = {
107
+ channelKey: patch.targetChannel ?? prev?.channelKey ?? '',
108
+ channelId: patch.targetChannelId ?? prev?.channelId ?? '',
109
+ session: patch.targetSession ?? prev?.session ?? 'main',
110
+ threadId: patch.targetThreadId === null || patch.targetThreadId === ''
111
+ ? undefined
112
+ : (patch.targetThreadId ?? prev?.threadId),
113
+ chatType: patch.targetChatType === null
114
+ ? undefined
115
+ : (patch.targetChatType ?? prev?.chatType),
116
+ };
117
+ if (!next.channelKey || !next.channelId) {
118
+ throw new Error('target 修改需要提供有效的 channelKey 和 channelId');
119
+ }
120
+ updated.feedback.target = next;
121
+ if (updated.feedback.strategy !== 'target')
122
+ updated.feedback.strategy = 'target';
123
+ }
124
+ // execution fields
125
+ if (patch.baseagent !== undefined) {
126
+ if (updated.execution.type !== 'trigger_session') {
127
+ throw new Error('baseagent 仅适用于 trigger_session 类型');
128
+ }
129
+ setOptional(updated.execution, 'baseagent', patch.baseagent);
130
+ }
131
+ if (patch.triggerThread !== undefined)
132
+ setOptional(updated.execution, 'thread', patch.triggerThread);
133
+ if (patch.onError !== undefined)
134
+ updated.execution.onError = patch.onError;
135
+ if (patch.noopSentinel !== undefined) {
136
+ if (patch.noopSentinel === null || patch.noopSentinel === '')
137
+ updated.execution.noopSentinel = '[[NOOP]]';
138
+ else
139
+ updated.execution.noopSentinel = patch.noopSentinel;
140
+ }
141
+ // reliability retry
142
+ if (patch.retryAttempts !== undefined || patch.retryBackoff !== undefined) {
143
+ const retry = { ...updated.reliability.retry };
144
+ if (patch.retryAttempts !== undefined)
145
+ retry.maxAttempts = patch.retryAttempts ?? 0;
146
+ if (patch.retryBackoff !== undefined)
147
+ retry.backoffMs = patch.retryBackoff ?? 30_000;
148
+ updated.reliability = { ...updated.reliability, retry };
149
+ }
150
+ // script fields
151
+ if (patch.scriptRuntime !== undefined || patch.scriptArgs !== undefined || patch.scriptTimeout !== undefined) {
152
+ if (updated.execution.type !== 'script' || !updated.execution.script) {
153
+ throw new Error('scriptRuntime/scriptArgs/scriptTimeout 仅适用于 script 类型 trigger');
154
+ }
155
+ const script = { ...updated.execution.script };
156
+ if (patch.scriptRuntime !== undefined)
157
+ script.runtime = patch.scriptRuntime;
158
+ if (patch.scriptArgs !== undefined) {
159
+ if (patch.scriptArgs === null)
160
+ delete script.args;
161
+ else
162
+ script.args = patch.scriptArgs;
163
+ }
164
+ if (patch.scriptTimeout !== undefined)
165
+ script.timeoutMs = patch.scriptTimeout ?? 30_000;
166
+ updated.execution.script = script;
167
+ }
67
168
  return normalizeTriggerDefinition({
68
169
  ...updated,
69
170
  id: definition.id,
@@ -98,7 +199,9 @@ export function replaceEditableTriggerDefinition(existing, input) {
98
199
  }
99
200
  function assertBaseagentImmutable(existing, updated) {
100
201
  if (existing.execution.baseagent !== updated.execution.baseagent) {
101
- throw new Error('execution.baseagent is immutable; create a new Trigger to use another baseagent');
202
+ if (existing.execution.type !== 'trigger_session' || updated.execution.type !== 'trigger_session') {
203
+ throw new Error('execution.baseagent is only mutable between trigger_session Triggers');
204
+ }
102
205
  }
103
206
  }
104
207
  function applySourcePatch(updated, patch) {
@@ -69,8 +69,32 @@ export function atomicWrite(filePath, content) {
69
69
  }
70
70
  }
71
71
  // The final rename atomically replaces the old target without an absent-path
72
- // window.
73
- fs.renameSync(tmp, filePath);
72
+ // window. Windows H-class projections can keep the destination pathname
73
+ // open while a sandbox is starting; replacing that directory entry then
74
+ // fails with EPERM. In that narrow case, preserve the target inode and write
75
+ // in place. The hot backup above still provides the previous full version.
76
+ try {
77
+ fs.renameSync(tmp, filePath);
78
+ }
79
+ catch (error) {
80
+ const code = error.code;
81
+ if (process.platform !== 'win32' || code !== 'EPERM')
82
+ throw error;
83
+ const detachedHot = `${hot}.${process.pid}.fallback.tmp`;
84
+ try {
85
+ // refreshHotBackup may have used a hard link. Detach that backup before
86
+ // the in-place write, otherwise updating the live inode would update the
87
+ // hot backup as well.
88
+ fs.copyFileSync(filePath, detachedHot);
89
+ fs.rmSync(hot, { force: true });
90
+ fs.renameSync(detachedHot, hot);
91
+ stableWrite(filePath, content);
92
+ }
93
+ finally {
94
+ fs.rmSync(detachedHot, { force: true });
95
+ fs.rmSync(tmp, { force: true });
96
+ }
97
+ }
74
98
  }
75
99
  export function atomicWriteJson(filePath, value) {
76
100
  atomicWrite(filePath, JSON.stringify(value, null, 2) + '\n');
@@ -1,12 +1,197 @@
1
1
  import path from 'path';
2
- import { fileURLToPath } from 'url';
2
+ import { fileURLToPath, pathToFileURL } from 'url';
3
+ import { createRequire } from 'module';
3
4
  import { execFileSync, execFile, spawn, spawnSync } from 'child_process';
4
5
  import { promisify } from 'util';
5
6
  import fs from 'fs';
6
7
  import { getProcessStartTime, parseCimDate } from './process-introspect.js';
7
8
  import { decodeWindowsOutput } from './windows-output.js';
8
9
  const execFileAsync = promisify(execFile);
10
+ const CLI_PROBE_FAILURE_BACKOFF_MS = 5_000;
11
+ const CLI_PROBE_PACKAGES = {
12
+ codex: '@openai/codex',
13
+ claude: '@anthropic-ai/claude-code',
14
+ gemini: '@google/gemini-cli',
15
+ };
16
+ const cliPathCache = new Map();
17
+ const cliInstallationCache = new Map();
18
+ const cliProbeCache = new Map();
9
19
  export const isWindows = process.platform === 'win32';
20
+ /** Resolve a CLI executable without spawning where/which. */
21
+ export function resolveProbePath(command) {
22
+ const pathKey = `${command}\0${process.cwd()}\0${process.env.PATH ?? ''}\0${process.env.PATHEXT ?? ''}`;
23
+ const cachedPath = cliPathCache.get(pathKey);
24
+ if (cachedPath && fs.existsSync(cachedPath))
25
+ return cachedPath;
26
+ cliPathCache.delete(pathKey);
27
+ const directories = (process.env.PATH ?? '').split(path.delimiter);
28
+ if (isWindows)
29
+ directories.unshift(process.cwd());
30
+ const extensions = isWindows
31
+ ? (process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(ext => /\.(com|exe|bat|cmd)$/i.test(ext))
32
+ : [''];
33
+ for (const directory of directories) {
34
+ for (const extension of extensions) {
35
+ const candidate = path.resolve(directory.replace(/^"|"$/g, ''), command + extension);
36
+ try {
37
+ if (!fs.statSync(candidate).isFile())
38
+ continue;
39
+ fs.accessSync(candidate, isWindows ? fs.constants.F_OK : fs.constants.X_OK);
40
+ cliPathCache.set(pathKey, candidate);
41
+ return candidate;
42
+ }
43
+ catch { /* Continue PATH lookup. */ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ function cliFileIdentity(file) {
49
+ try {
50
+ const real = fs.realpathSync(file);
51
+ const stat = fs.statSync(real);
52
+ return `${real}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
53
+ }
54
+ catch {
55
+ return `${file}:missing`;
56
+ }
57
+ }
58
+ /** Return an installation fingerprint so probes refresh after CLI upgrades. */
59
+ export function probeInstallationIdentity(command, executable) {
60
+ const cacheKey = `${command}\0${executable}`;
61
+ const cached = cliInstallationCache.get(cacheKey);
62
+ if (cached && cached.files.map(cliFileIdentity).join('|') === cached.identity)
63
+ return cached.identity;
64
+ const files = [executable];
65
+ const packageName = CLI_PROBE_PACKAGES[command];
66
+ if (packageName) {
67
+ files.push(path.join(path.dirname(executable), 'node_modules', packageName, 'package.json'));
68
+ try {
69
+ const real = fs.realpathSync(executable);
70
+ const require = createRequire(real);
71
+ const packageFile = require.resolve(`${packageName}/package.json`);
72
+ files.push(packageFile);
73
+ if (command === 'codex') {
74
+ const platformPackage = `@openai/codex-${process.platform}-${process.arch}`;
75
+ const platformFile = createRequire(packageFile).resolve(`${platformPackage}/package.json`);
76
+ files.push(platformFile);
77
+ const arch = process.arch === 'x64' ? 'x86_64' : 'aarch64';
78
+ const target = isWindows ? `${arch}-pc-windows-msvc`
79
+ : process.platform === 'darwin' ? `${arch}-apple-darwin` : `${arch}-unknown-linux-musl`;
80
+ for (const root of [path.dirname(platformFile), path.dirname(packageFile)]) {
81
+ files.push(path.join(root, 'vendor', target, 'bin', isWindows ? 'codex.exe' : 'codex'));
82
+ }
83
+ }
84
+ }
85
+ catch { /* Standalone executables have no npm package. */ }
86
+ }
87
+ const uniqueFiles = [...new Set(files)];
88
+ const identity = uniqueFiles.map(cliFileIdentity).join('|');
89
+ cliInstallationCache.set(cacheKey, { files: uniqueFiles, identity });
90
+ return identity;
91
+ }
92
+ /** Clear CLI probe caches after an in-process CLI installation. */
93
+ export function invalidateCliProbes() {
94
+ cliPathCache.clear();
95
+ cliInstallationCache.clear();
96
+ cliProbeCache.clear();
97
+ }
98
+ function cliProbeTimeouts(command, options) {
99
+ return options.timeouts?.length ? options.timeouts : command === 'codex' ? [3_000, 15_000] : [3_000];
100
+ }
101
+ function prepareCliProbe(command, args) {
102
+ const executable = resolveProbePath(command);
103
+ if (!executable)
104
+ throw Object.assign(new Error(`${command} CLI not found`), { code: 'ENOENT' });
105
+ const identity = `${probeInstallationIdentity(command, executable)}|${process.env.ComSpec ?? ''}`;
106
+ const key = JSON.stringify([command, args]);
107
+ let entry = cliProbeCache.get(key);
108
+ if (!entry || entry.identity !== identity || (entry.retryAt !== undefined && Date.now() >= entry.retryAt)) {
109
+ entry = { identity };
110
+ cliProbeCache.set(key, entry);
111
+ }
112
+ const shim = isWindows && /\.(cmd|bat)$/i.test(executable);
113
+ return { entry, command: shim ? process.env.ComSpec ?? 'cmd.exe' : executable,
114
+ args: shim ? ['/d', '/s', '/c', executable, ...args] : args };
115
+ }
116
+ function failCliProbe(entry, error) {
117
+ entry.error = error;
118
+ entry.retryAt = Date.now() + CLI_PROBE_FAILURE_BACKOFF_MS;
119
+ throw error;
120
+ }
121
+ export function probeCliSync(command, args, options = {}) {
122
+ const request = prepareCliProbe(command, args);
123
+ if (request.entry.output !== undefined)
124
+ return request.entry.output;
125
+ if (request.entry.error !== undefined)
126
+ throw request.entry.error;
127
+ for (const [index, timeout] of cliProbeTimeouts(command, options).entries()) {
128
+ const startedAt = Date.now();
129
+ try {
130
+ return request.entry.output = execFileSync(request.command, request.args, { encoding: 'utf8', timeout, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
131
+ }
132
+ catch (error) {
133
+ options.onFailure?.(args, startedAt, error, timeout);
134
+ if (error.code !== 'ETIMEDOUT' || index === cliProbeTimeouts(command, options).length - 1)
135
+ return failCliProbe(request.entry, error);
136
+ }
137
+ }
138
+ throw new Error('CLI probe exhausted');
139
+ }
140
+ export async function probeCli(command, args, options = {}) {
141
+ const request = prepareCliProbe(command, args);
142
+ if (request.entry.output !== undefined)
143
+ return request.entry.output;
144
+ if (request.entry.error !== undefined)
145
+ throw request.entry.error;
146
+ if (request.entry.pending)
147
+ return request.entry.pending;
148
+ const run = async () => {
149
+ const timeouts = cliProbeTimeouts(command, options);
150
+ for (const [index, timeout] of timeouts.entries()) {
151
+ const startedAt = Date.now();
152
+ try {
153
+ const output = await new Promise((resolve, reject) => {
154
+ const child = execFile(request.command, request.args, { encoding: 'utf8', timeout, windowsHide: true }, (error, stdout, stderr) => {
155
+ if (error) {
156
+ if (error.killed && error.signal === 'SIGTERM' && error.code == null)
157
+ error.code = 'ETIMEDOUT';
158
+ Object.assign(error, { stdout, stderr, path: request.command, spawnargs: request.args });
159
+ reject(error);
160
+ }
161
+ else
162
+ resolve(stdout);
163
+ });
164
+ child.stdin?.end();
165
+ });
166
+ return request.entry.output = output;
167
+ }
168
+ catch (error) {
169
+ options.onFailure?.(args, startedAt, error, timeout);
170
+ if (error.code !== 'ETIMEDOUT' || index === timeouts.length - 1)
171
+ return failCliProbe(request.entry, error);
172
+ }
173
+ }
174
+ throw new Error('CLI probe exhausted');
175
+ };
176
+ request.entry.pending = run();
177
+ try {
178
+ return await request.entry.pending;
179
+ }
180
+ finally {
181
+ request.entry.pending = undefined;
182
+ }
183
+ }
184
+ /** Non-blocking version lookup used by status menus. */
185
+ export function cachedCliVersion(command) {
186
+ try {
187
+ const { entry } = prepareCliProbe(command, ['--version']);
188
+ if (entry.output !== undefined)
189
+ return entry.output.match(/\b\d+\.\d+\.\d+(?:[-+][\w.-]+)?/)?.[0] ?? null;
190
+ void probeCli(command, ['--version']).catch(() => { });
191
+ }
192
+ catch { /* Missing CLI. */ }
193
+ return null;
194
+ }
10
195
  /**
11
196
  * Start a detached EvolCore Node process without creating a console window on
12
197
  * Windows. The child is unref'd here so callers cannot accidentally keep the
@@ -22,6 +207,256 @@ export function spawnDetachedNode(args, env = {}) {
22
207
  child.unref();
23
208
  return child;
24
209
  }
210
+ /**
211
+ * Spawn the restart monitor and wait until it proves it is running.
212
+ *
213
+ * Every caller stops (or kills) the daemon right after this returns, so a
214
+ * monitor that dies while loading leaves nothing running and nobody watching.
215
+ * In a checkout the monitor is executed through tsx, where a syntax error
216
+ * anywhere in its import graph is fatal at load time — and the usual
217
+ * `stdio: 'ignore'` would throw that diagnostic away. So stderr is captured,
218
+ * and success requires the child to still be alive after it had a chance to
219
+ * fail, not merely to have been spawned.
220
+ *
221
+ * `isAliveInRegistry` lets the caller define proof-of-life without dragging the
222
+ * instance registry into this module.
223
+ */
224
+ export async function launchMonitorAndConfirm(args, env, isAliveInRegistry, hasOtherLiveMonitor, timeoutMs = 15_000) {
225
+ const child = spawn(process.execPath, [...args], {
226
+ detached: true,
227
+ stdio: ['ignore', 'ignore', 'pipe'],
228
+ windowsHide: isWindows,
229
+ env: { ...process.env, ...env },
230
+ });
231
+ let stderrTail = '';
232
+ child.stderr?.on('data', (chunk) => {
233
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000);
234
+ });
235
+ // Capturing stderr must not defeat detached-process semantics. A referenced
236
+ // pipe keeps the caller's event loop alive until the monitor exits, which
237
+ // makes restart commands wait for the monitor's full lifetime.
238
+ child.stderr?.unref?.();
239
+ let exitCode = null;
240
+ let spawnError = null;
241
+ child.on('exit', code => { exitCode = code; });
242
+ child.on('error', err => { spawnError = err.message; });
243
+ const childPid = child.pid;
244
+ child.unref();
245
+ if (!childPid)
246
+ return { ok: false, reason: 'spawn 未返回 PID' };
247
+ const deadline = Date.now() + timeoutMs;
248
+ while (Date.now() < deadline) {
249
+ await new Promise(resolve => setTimeout(resolve, 300));
250
+ if (spawnError)
251
+ return { ok: false, reason: spawnError, stderrTail };
252
+ if (exitCode !== null) {
253
+ // Exiting cleanly is legitimate when another monitor already owns this
254
+ // restart (single-instance election) — that one is alive and in charge.
255
+ if (exitCode === 0 && hasOtherLiveMonitor(childPid))
256
+ return { ok: true };
257
+ return { ok: false, reason: `进程已退出,exit=${exitCode}`, stderrTail };
258
+ }
259
+ // Registering means the module graph loaded and the monitor started work.
260
+ if (isAliveInRegistry(childPid))
261
+ return { ok: true };
262
+ }
263
+ // Still running but not yet registered: a slow start is not a failure.
264
+ if (isProcessRunning(childPid))
265
+ return { ok: true };
266
+ return { ok: false, reason: '进程未登记且已消失', stderrTail };
267
+ }
268
+ /**
269
+ * Copy the monitor out of the package directory so an upgrade cannot replace it
270
+ * mid-flight.
271
+ *
272
+ * This is the installed-package counterpart of running the monitor from `src/`
273
+ * in a checkout: `npm install -g` swaps the whole package directory, and the
274
+ * monitor has ~170 lazy `await import()` sites, so a module it has not loaded
275
+ * yet would come from the new version while the rest is still the old one.
276
+ * Staging `dist/` elsewhere keeps its module graph on one consistent version.
277
+ *
278
+ * Only `dist/` and `package.json` are copied (a few MB). `node_modules` is
279
+ * linked, because copying it is hundreds of MB. The link degrades in steps so
280
+ * that no platform is left without a path forward:
281
+ *
282
+ * 1. symlink — `junction` on Windows (works without elevation, unlike a
283
+ * `dir` symlink which needs admin or developer mode), plain dir symlink
284
+ * elsewhere. Junctions cannot span volumes, hence the fallbacks.
285
+ * 2. full copy — slow but always permitted.
286
+ * 3. skip staging — caller runs the monitor in place, exactly as before.
287
+ * Losing the upgrade isolation is much better than losing the restart.
288
+ */
289
+ export function prepareMonitorStage(packageRoot, stageParent) {
290
+ const distSource = path.join(packageRoot, 'dist');
291
+ const modulesSource = path.join(packageRoot, 'node_modules');
292
+ if (!fs.existsSync(distSource)) {
293
+ return { stageDir: null, linkMode: 'none', note: 'dist/ 不存在,跳过 stage' };
294
+ }
295
+ // Clear stages left behind by a monitor that was killed before self-cleanup.
296
+ try {
297
+ const cutoff = Date.now() - 60 * 60 * 1000;
298
+ for (const entry of fs.readdirSync(stageParent, { withFileTypes: true })) {
299
+ if (!entry.isDirectory())
300
+ continue;
301
+ const full = path.join(stageParent, entry.name);
302
+ if (fs.statSync(full).mtimeMs < cutoff)
303
+ fs.rmSync(full, { recursive: true, force: true });
304
+ }
305
+ }
306
+ catch { /* stageParent may not exist yet */ }
307
+ const stageDir = path.join(stageParent, `${process.pid}-${Date.now()}`);
308
+ try {
309
+ fs.mkdirSync(stageDir, { recursive: true });
310
+ fs.cpSync(distSource, path.join(stageDir, 'dist'), { recursive: true });
311
+ const pkgJson = path.join(packageRoot, 'package.json');
312
+ if (fs.existsSync(pkgJson))
313
+ fs.copyFileSync(pkgJson, path.join(stageDir, 'package.json'));
314
+ }
315
+ catch (error) {
316
+ try {
317
+ fs.rmSync(stageDir, { recursive: true, force: true });
318
+ }
319
+ catch { }
320
+ return { stageDir: null, linkMode: 'none', note: `复制 dist/ 失败:${error.message}` };
321
+ }
322
+ if (!fs.existsSync(modulesSource)) {
323
+ // Dependencies hoisted elsewhere; nothing to link, node resolves upward.
324
+ return { stageDir, linkMode: 'none' };
325
+ }
326
+ const stageModules = path.join(stageDir, 'node_modules');
327
+ try {
328
+ fs.symlinkSync(modulesSource, stageModules, isWindows ? 'junction' : 'dir');
329
+ // A junction to another volume is created but unusable, so prove it reads.
330
+ fs.readdirSync(stageModules);
331
+ return { stageDir, linkMode: 'symlink' };
332
+ }
333
+ catch (linkError) {
334
+ try {
335
+ fs.rmSync(stageModules, { recursive: true, force: true });
336
+ }
337
+ catch { }
338
+ try {
339
+ fs.cpSync(modulesSource, stageModules, { recursive: true });
340
+ return {
341
+ stageDir,
342
+ linkMode: 'copy',
343
+ note: `链接 node_modules 失败(${linkError.code ?? 'unknown'}),已改为完整复制`,
344
+ };
345
+ }
346
+ catch (copyError) {
347
+ try {
348
+ fs.rmSync(stageDir, { recursive: true, force: true });
349
+ }
350
+ catch { }
351
+ return {
352
+ stageDir: null,
353
+ linkMode: 'none',
354
+ note: `无法准备 stage(link: ${linkError.message}; copy: ${copyError.message})`,
355
+ };
356
+ }
357
+ }
358
+ }
359
+ /** Remove a staging directory created by prepareMonitorStage. */
360
+ export function cleanupMonitorStage(stageDir) {
361
+ try {
362
+ // Remove the link itself, never what it points at.
363
+ const modules = path.join(stageDir, 'node_modules');
364
+ const stat = fs.lstatSync(modules, { throwIfNoEntry: false });
365
+ if (stat?.isSymbolicLink())
366
+ fs.unlinkSync(modules);
367
+ }
368
+ catch { /* fall through to the recursive removal */ }
369
+ try {
370
+ fs.rmSync(stageDir, { recursive: true, force: true });
371
+ }
372
+ catch { }
373
+ }
374
+ /** True when the current process may write into `dir`. */
375
+ export function isDirWritable(dir) {
376
+ try {
377
+ fs.accessSync(dir, fs.constants.W_OK);
378
+ return true;
379
+ }
380
+ catch {
381
+ return false;
382
+ }
383
+ }
384
+ /**
385
+ * Resolve the launch argv for the restart monitor, staging it out of the
386
+ * package directory when that package can be replaced underneath it.
387
+ *
388
+ * Checkout: `resolveCliEntry` already points at `src/` via tsx, which no build
389
+ * can delete — nothing to stage.
390
+ *
391
+ * Installed: the monitor lives in the very directory `npm install -g` swaps,
392
+ * so it is copied to `stageParent` first and told where the real package is
393
+ * through `EVOLCORE_TARGET_PACKAGE_ROOT`. If staging cannot be done we fall
394
+ * back to launching in place: losing upgrade isolation is far better than
395
+ * losing the ability to restart.
396
+ */
397
+ export function resolveMonitorLaunch(packageRoot, stageParent) {
398
+ const resolved = resolveCliEntry(packageRoot);
399
+ if (resolved.usingSource) {
400
+ return { args: resolved.args, entry: resolved.entry, env: {}, stageDir: null };
401
+ }
402
+ const stage = prepareMonitorStage(packageRoot, stageParent);
403
+ if (!stage.stageDir) {
404
+ return {
405
+ args: resolved.args,
406
+ entry: resolved.entry,
407
+ env: {},
408
+ stageDir: null,
409
+ note: stage.note ? `${stage.note};本次未启用 stage 隔离` : undefined,
410
+ };
411
+ }
412
+ const stagedEntry = path.join(stage.stageDir, 'dist', 'cli', 'index.js');
413
+ if (!fs.existsSync(stagedEntry)) {
414
+ cleanupMonitorStage(stage.stageDir);
415
+ return { args: resolved.args, entry: resolved.entry, env: {}, stageDir: null, note: 'stage 缺少入口,已退回原地启动' };
416
+ }
417
+ return {
418
+ args: [stagedEntry],
419
+ entry: stagedEntry,
420
+ env: {
421
+ EVOLCORE_TARGET_PACKAGE_ROOT: packageRoot,
422
+ EVOLCORE_MONITOR_STAGE: stage.stageDir,
423
+ },
424
+ stageDir: stage.stageDir,
425
+ note: stage.note,
426
+ };
427
+ }
428
+ /**
429
+ * Resolve how to launch the EvolCore CLI (`restart`, `restart-monitor`, ...).
430
+ *
431
+ * In a development checkout the compiled `dist/` is deleted and rebuilt by
432
+ * `npm run build`, so anything spawned from `dist/cli/index.js` disappears
433
+ * exactly when a build is in flight. The restart monitor is the watchdog that
434
+ * has to outlive that window, so it must not live inside the artifact it
435
+ * supervises: when sources and tsx are both available we run the monitor
436
+ * straight from `src/`, mirroring what `bin/ec.js` already does for the CLI.
437
+ *
438
+ * An npm-installed package ships `dist/` without `src/` and without tsx (a
439
+ * devDependency), so both checks fail and the caller keeps the previous
440
+ * behaviour byte for byte.
441
+ */
442
+ export function resolveCliEntry(packageRoot) {
443
+ const sourceEntry = path.join(packageRoot, 'src', 'cli', 'index.ts');
444
+ const distEntry = path.join(packageRoot, 'dist', 'cli', 'index.js');
445
+ if (fs.existsSync(sourceEntry)) {
446
+ // tsx is a devDependency: present in a checkout, absent from the published
447
+ // package. Resolve it relative to the target root so `ec mode` switching
448
+ // between roots cannot borrow the wrong copy.
449
+ try {
450
+ const require = createRequire(path.join(packageRoot, 'package.json'));
451
+ const tsxUrl = pathToFileURL(require.resolve('tsx')).href;
452
+ return { args: ['--import', tsxUrl, sourceEntry], usingSource: true, entry: sourceEntry };
453
+ }
454
+ catch {
455
+ // tsx missing (e.g. `npm prune --production`): fall through to dist.
456
+ }
457
+ }
458
+ return { args: [distEntry], usingSource: false, entry: distEntry };
459
+ }
25
460
  const ENCODE_PATH_MAX = 200;
26
461
  function encodePathHash(s) {
27
462
  let h = 0;
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { spawn } from 'child_process';
4
- import { resolvePaths } from '../paths.js';
4
+ import { getPackageRoot, resolvePaths } from '../paths.js';
5
5
  import { WEB_PACKAGE_NAME } from '../product.js';
6
6
  import { resolveGlobalPkg } from './npm-ops.js';
7
7
  import { ecwebVersionRequirementError, resolveEcwebLaunchCommand } from './ecweb-utils.js';
@@ -218,7 +218,16 @@ export function createEcwebSupervisor(options) {
218
218
  detached: true,
219
219
  stdio: ['ignore', childLogFd, childLogFd],
220
220
  windowsHide: platform.isWindows,
221
- env: { ...process.env, EVOLCORE_HOME: root },
221
+ // ECWeb dynamically imports EvolCore's dist modules for config,
222
+ // roles, authorization and session compatibility. Pin that lookup
223
+ // to the same package root as this daemon; otherwise a separately
224
+ // installed ECWeb can resolve its peer dependency and silently load
225
+ // a different (possibly stale) EvolCore copy from node_modules.
226
+ env: {
227
+ ...process.env,
228
+ EVOLCORE_HOME: root,
229
+ EVOLCORE_PACKAGE_ROOT: getPackageRoot(),
230
+ },
222
231
  });
223
232
  }
224
233
  catch (error) {