evolcore 0.0.27 → 0.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,6 +33,7 @@ import { guardGroupSlashCommand, parseFullAccessCommand } from '../command/slash
33
33
  import { isFullAccessEnabled } from '../../config-store.js';
34
34
  import { createAgentPolicyAuthorization } from '../role/runtime-policy.js';
35
35
  import { auditFullAccessEvent } from '../auth/authorization-audit.js';
36
+ import { DIAGNOSTIC_AGENT_AID, DiagnosticSessionRouter, parseDiagnosticEvent, isDiagnosticSessionActive } from '../command/diag-service.js';
36
37
  import { consumeAdmissionReplyBudget, clearAdmissionReplyBudgets, evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission, } from './message-admission.js';
37
38
  const CONTACT_REJECTION_NOTICE = '[rejection] Not accepting messages from you.';
38
39
  const REJECTABLE_PEER_TYPES = new Set(['ai', 'bot', 'agent', 'service']);
@@ -115,6 +116,7 @@ export class MessageBridge {
115
116
  menuTokenStore = new MenuTokenStore();
116
117
  handoffRuntime;
117
118
  sessionRenewService;
119
+ diagnosticRouter;
118
120
  aidStatsCollector;
119
121
  contactBindRuntimeChecker;
120
122
  interactionRouter;
@@ -129,6 +131,13 @@ export class MessageBridge {
129
131
  this.processOwnersProvider = processOwnersProvider;
130
132
  this.defaultDebounce = defaultDebounce ?? 0;
131
133
  this.sessionRenewService = new SessionRenewService(sessionManager, processor, { eventBus });
134
+ this.diagnosticRouter = new DiagnosticSessionRouter(sessionManager, async (sessionId) => {
135
+ // Revoke in the router first, then clear pending work before awaiting the
136
+ // runner interruption. Delayed debounce and recovery are fenced below.
137
+ this.messageQueue.clearBySession(sessionId);
138
+ this.messageQueue.markActiveTerminal(sessionId, 'stop');
139
+ await this.processor.interruptSession(sessionId, 'stop');
140
+ });
132
141
  }
133
142
  /** Inject EvolAgentRegistry so owner lookups/writes route to agent.json for agent-owned channels. */
134
143
  setAgentRegistry(registry) {
@@ -971,6 +980,37 @@ export class MessageBridge {
971
980
  await this.sendAccessDenied(adapter, channelName, msg, sendReply);
972
981
  return;
973
982
  }
983
+ // Diagnostic invitations carry their own execution boundary. Resolve
984
+ // that boundary before ordinary command/session routing so a new case
985
+ // cannot inherit an older Codex thread.
986
+ let diagnosticSession;
987
+ if (selfAid === DIAGNOSTIC_AGENT_AID && resolvedChannelType === 'aun'
988
+ && chatType === 'private' && msg.source !== 'handoff') {
989
+ try {
990
+ const diagnosticEvent = parseDiagnosticEvent(content);
991
+ if (diagnosticEvent) {
992
+ releaseAdmission = await this.acquireAdmission(admissionRoute);
993
+ const routed = await this.diagnosticRouter.route({
994
+ event: diagnosticEvent, message: msg, channel: channelName,
995
+ channelType: resolvedChannelType, selfAid,
996
+ chatDir: chatDirPath(resolvePaths().sessionsDir, resolvedChannelType, msg.channelId, selfAid, channelName),
997
+ projectPath: owningAgent?.projectPath ?? this.defaultProjectPath,
998
+ baseagent: owningAgent?.baseagent,
999
+ });
1000
+ if ('notice' in routed) {
1001
+ await sendReply(msg.channelId, routed.notice, msg.replyContext);
1002
+ return;
1003
+ }
1004
+ diagnosticSession = routed.session;
1005
+ diagnosticSession.identity = identity;
1006
+ }
1007
+ }
1008
+ catch (error) {
1009
+ logger.warn(`[MessageBridge] Diagnostic admission failed: ${error instanceof Error ? error.message : 'unknown error'}`);
1010
+ await sendReply(msg.channelId, '诊断会话隔离未完成,本次未执行诊断。请检查诊断端日志后重试。', msg.replyContext);
1011
+ return;
1012
+ }
1013
+ }
974
1014
  // /fa is an execution prefix, not a session-mode switch or a normal
975
1015
  // quick command. Only this trusted bridge path can construct the
976
1016
  // internal per-call authorization carried into ResponseEngine.
@@ -1186,9 +1226,10 @@ export class MessageBridge {
1186
1226
  // transaction. Without this per-route single-flight, a second message
1187
1227
  // can observe the just-renewed empty session before the first message
1188
1228
  // has been appended and create another renewed session.
1189
- releaseAdmission = await this.acquireAdmission(admissionRoute);
1229
+ if (!releaseAdmission)
1230
+ releaseAdmission = await this.acquireAdmission(admissionRoute);
1190
1231
  const hadMainSession = msg.threadId ? true : this.sessionManager.hasMainSession(channelName, msg.channelId, msg.channelType || effectiveChannelType, msg.selfAID || selfAid);
1191
- let session = await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, owningAgent?.baseagent, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType, identity, readTopicLable(msg) ?? readTopicLable(msg.replyContext?.metadata));
1232
+ let session = diagnosticSession ?? await this.sessionManager.getOrCreateSession(channelName, msg.channelId, effectiveProjectPath, msg.threadId, Object.keys(metadata).length ? metadata : undefined, this.extractTopicName(msg), msg.peerId, chatType, owningAgent?.baseagent, msg.selfAID, msg.channelType || effectiveChannelType, msg.peerType, identity, readTopicLable(msg) ?? readTopicLable(msg.replyContext?.metadata));
1192
1233
  if (session.threadId && typeof this.messageQueue.reserveSessionIngress === 'function') {
1193
1234
  releaseSessionIngress = this.messageQueue.reserveSessionIngress(session.id);
1194
1235
  }
@@ -1201,7 +1242,7 @@ export class MessageBridge {
1201
1242
  const inboundCausation = restoredCausation
1202
1243
  ? deriveCausation(restoredCausation)
1203
1244
  : createRootCausation();
1204
- const renewResult = await this.sessionRenewService.resolve({
1245
+ const renewResult = session.metadata?.diagnostic ? { session } : await this.sessionRenewService.resolve({
1205
1246
  session,
1206
1247
  channelName,
1207
1248
  channelType: msg.channelType || effectiveChannelType,
@@ -1338,6 +1379,9 @@ export class MessageBridge {
1338
1379
  const enqueueAgentName = owningAgent?.name ?? '<unknown>';
1339
1380
  const selfAID = session.selfAID;
1340
1381
  const doEnqueue = async (m) => {
1382
+ // A cancellation can arrive while this message is being debounced.
1383
+ if (!isDiagnosticSessionActive(session, this.sessionManager.getChatDir(session)))
1384
+ return;
1341
1385
  const enqueue = handoffCandidate ? this.messageQueue.enqueuePersisted.bind(this.messageQueue) : this.messageQueue.enqueue.bind(this.messageQueue);
1342
1386
  try {
1343
1387
  return await enqueue(session.id, m, session.projectPath, {
@@ -5,6 +5,7 @@ import os from 'os';
5
5
  import crypto from 'crypto';
6
6
  import { BaseagentRunnerUnavailableError, hasCompact, autoCompactWindowForModel, isClaudeContextUsageModel, numericToken, formatAsyncQuestionAnswer, parseAsyncUserQuestions, } from '../../agents/runner-types.js';
7
7
  import { loadedBaseagentIds } from '../../agents/baseagent-availability.js';
8
+ import { isDiagnosticSessionActive } from '../command/diag-service.js';
8
9
  import { evaluateInboundAdmissionPreflight, evaluateOrdinaryInboundAdmission } from './message-admission.js';
9
10
  import { authorizeAccess, authorizeOperation, buildAuthSubject } from '../auth/auth-gateway.js';
10
11
  import { buildTaskRuntimeEnv, ensureRuntimeLockDir, ensureSessionRuntimeDir } from '../../cli/task-context.js';
@@ -1878,6 +1879,28 @@ export class ResponseEngine {
1878
1879
  async processMessageWithMonitors(message) {
1879
1880
  const idleMs = (this.globalSettings.idleMonitor?.timeout ?? 120) * 1000;
1880
1881
  const totalExecutionMs = this.totalExecutionLimitMs();
1882
+ // A discarded handoff must be dropped before any bound-session lookup.
1883
+ // Restored origin deliveries may point at a session that has already been
1884
+ // replaced; resolving it first would reactivate the stale session path.
1885
+ if (message.handoffDelivery
1886
+ && this.handoffRuntime
1887
+ && this.handoffRuntime.isDiscardedDelivery(message)) {
1888
+ logger.info(`[ResponseEngine] Skip stale handoff delivery before session resolution:`
1889
+ + ` direction=${message.handoffDelivery.direction}`
1890
+ + ` handoff=${message.handoffDelivery.handoffId}`);
1891
+ return;
1892
+ }
1893
+ // Includes persisted queues, recovery triggers and delayed owner replies.
1894
+ // A closed diagnostic must not activate its old main session again.
1895
+ const diagnosticBoundId = message.boundSessionId ?? message.triggerMeta?.boundSessionId;
1896
+ if (diagnosticBoundId) {
1897
+ const bound = await this.sessionManager.getSessionById(diagnosticBoundId);
1898
+ if (bound && (bound.metadata?.diagnostic || bound.metadata?.diagnosticStatus === 'cancelled')
1899
+ && !isDiagnosticSessionActive(bound, this.sessionManager.getChatDir(bound))) {
1900
+ logger.info(`[ResponseEngine] Skip closed diagnostic session ${bound.id}`);
1901
+ return;
1902
+ }
1903
+ }
1881
1904
  if (message.taskRecovery) {
1882
1905
  const current = message.boundSessionId
1883
1906
  ? await this.sessionManager.getSessionById(message.boundSessionId)
@@ -1887,17 +1910,12 @@ export class ResponseEngine {
1887
1910
  return;
1888
1911
  }
1889
1912
  }
1890
- if (message.handoffDelivery
1891
- && this.handoffRuntime
1892
- && this.handoffRuntime.isDiscardedDelivery(message)) {
1893
- logger.info(`[ResponseEngine] Skip stale handoff delivery before session resolution:`
1894
- + ` direction=${message.handoffDelivery.direction}`
1895
- + ` handoff=${message.handoffDelivery.handoffId}`);
1896
- return;
1897
- }
1898
1913
  // 先解析会话,再优先用 session.metadata.channelKey 精确定位实例级 adapter
1899
1914
  // message.channel 现在存实例名(channelName),可直接用于精确路由
1900
1915
  const { session, absoluteProjectPath } = await this.resolveSession(message);
1916
+ if ((session.metadata?.diagnostic || session.metadata?.diagnosticStatus === 'cancelled')
1917
+ && !isDiagnosticSessionActive(session, this.sessionManager.getChatDir(session)))
1918
+ return;
1901
1919
  const accessChannelKey = this.messageChannelKey(message, session);
1902
1920
  const accessChannelInfo = this.resolveChannelInfo(accessChannelKey);
1903
1921
  const accessAdapter = accessChannelInfo?.adapter;
@@ -2874,6 +2892,7 @@ export class ResponseEngine {
2874
2892
  flushDelay: (options?.flushDelay ?? this.agentRegistry?.resolveByChannel(channelKey)?.config?.flush_delay ?? 3) * 1000,
2875
2893
  suppressActivityItems: isProactive ? false : middleOutputMode !== 'all',
2876
2894
  suppressIntermediateText: isProactive ? false : middleOutputMode === 'none',
2895
+ deferTextUntilBoundary: (message.channelType ?? session.channelType) === 'aun' && session.chatType === 'private',
2877
2896
  operationalNoticesAsText: !isProactive && middleOutputMode !== 'none',
2878
2897
  fileMarkerPattern: options?.fileMarkerPattern,
2879
2898
  send: async (payload) => {
@@ -5245,7 +5264,7 @@ export class ResponseEngine {
5245
5264
  // valid mapped owner/admin to role=none at the execution boundary.
5246
5265
  if (resolvedIdentity)
5247
5266
  bound.identity = resolvedIdentity;
5248
- if (bound.threadId) {
5267
+ if (bound.threadId || bound.metadata?.diagnostic) {
5249
5268
  const absoluteProjectPath = path.isAbsolute(bound.projectPath)
5250
5269
  ? bound.projectPath : path.resolve(process.cwd(), bound.projectPath);
5251
5270
  return { session: bound, absoluteProjectPath };
@@ -5705,7 +5724,7 @@ export class ResponseEngine {
5705
5724
  this.eventBus.publish({ type: 'message:text', sessionId: session.id, text: visibleText, isFinal: false });
5706
5725
  }
5707
5726
  if (visibleText && !shouldSuppress()) {
5708
- await renderer.addText(visibleText, event.outputTokens, event.turn, event.assistantUuid);
5727
+ await renderer.addText(visibleText, event.outputTokens, event.turn, event.assistantUuid, event.phase);
5709
5728
  hasProjectedCurrentReplyText = true;
5710
5729
  }
5711
5730
  }
@@ -376,6 +376,14 @@ function parseFailure(issue, input, offset, tokenIndex, dialect) {
376
376
  inputLength: input.length,
377
377
  };
378
378
  }
379
+ // PowerShell uses smart quotes as delimiters, including when the opening
380
+ // quote was ASCII. Treating them as text changes argv and delegation hashes.
381
+ function isPowerShellSingleQuote(char) {
382
+ return char !== undefined && /['\u2018\u2019\u201a\u201b]/u.test(char);
383
+ }
384
+ function isPowerShellDoubleQuote(char) {
385
+ return char !== undefined && /["\u201c\u201d\u201e]/u.test(char);
386
+ }
379
387
  function parseLiteralPowerShellCommand(command, options = {}) {
380
388
  const input = trimShellSpacing(command);
381
389
  if (!input)
@@ -399,11 +407,11 @@ function parseLiteralPowerShellCommand(command, options = {}) {
399
407
  for (let index = 0; index < input.length; index++) {
400
408
  const char = input[index];
401
409
  if (quote === 'single') {
402
- if (char === "'" && input[index + 1] === "'") {
403
- token += "'";
410
+ if (isPowerShellSingleQuote(char) && isPowerShellSingleQuote(input[index + 1])) {
411
+ token += input[index + 1];
404
412
  index++;
405
413
  }
406
- else if (char === "'") {
414
+ else if (isPowerShellSingleQuote(char)) {
407
415
  if (!isLiteralArgBoundary(input[index + 1]))
408
416
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
409
417
  quote = null;
@@ -420,7 +428,7 @@ function parseLiteralPowerShellCommand(command, options = {}) {
420
428
  return parseFailure('unsafe-expansion', input, index, argv.length, 'powershell');
421
429
  }
422
430
  if (!options.allowManagedTmpDir || !options.managedTempDir
423
- || (next !== undefined && !/[\\/\s"']/u.test(next))) {
431
+ || (next !== undefined && !/[\\/\s"']/u.test(next) && !isPowerShellSingleQuote(next) && !isPowerShellDoubleQuote(next))) {
424
432
  return parseFailure('unsafe-expansion', input, index, argv.length, 'powershell');
425
433
  }
426
434
  // Append the registered value directly: spaces remain in this argument,
@@ -431,7 +439,7 @@ function parseLiteralPowerShellCommand(command, options = {}) {
431
439
  continue;
432
440
  }
433
441
  if (quote === 'double') {
434
- if (char === '"') {
442
+ if (isPowerShellDoubleQuote(char)) {
435
443
  if (!isLiteralArgBoundary(input[index + 1]))
436
444
  return parseFailure('invalid-quote', input, index, argv.length, 'powershell');
437
445
  quote = null;
@@ -458,12 +466,12 @@ function parseLiteralPowerShellCommand(command, options = {}) {
458
466
  if (/\s/u.test(char) || char.charCodeAt(0) < 0x20 || char === '\x7f') {
459
467
  return parseFailure('invalid-control-char', input, index, argv.length, 'powershell');
460
468
  }
461
- if (char === "'") {
469
+ if (isPowerShellSingleQuote(char)) {
462
470
  tokenStarted = true;
463
471
  quote = 'single';
464
472
  continue;
465
473
  }
466
- if (char === '"') {
474
+ if (isPowerShellDoubleQuote(char)) {
467
475
  tokenStarted = true;
468
476
  quote = 'double';
469
477
  continue;
@@ -2334,7 +2334,7 @@ export function evaluateToolPreflight(toolName, input, context) {
2334
2334
  }
2335
2335
  if (ecCommand.issue === 'invalid-quote') {
2336
2336
  const message = shellDialect === 'powershell'
2337
- ? '🔒 EC 命令引号未闭合或存在错误嵌套;PowerShell 正文内的双引号请用反引号转义,或改用单引号包裹纯文字'
2337
+ ? '🔒 EC 命令引号未闭合或存在错误嵌套;PowerShell 也会解释中文弯引号。请按 PowerShell 规则转义,或把消息正文写入当前 $TMPDIR 后使用 --text-from-file'
2338
2338
  : shellDialect === 'cmd'
2339
2339
  ? '🔒 EC 命令引号未闭合或存在错误嵌套;cmd.exe carrier 的命令正文请保持一个完整参数,不要用未配对的双引号'
2340
2340
  : '🔒 EC 命令引号未闭合或存在错误嵌套;正文中的双引号请写成 \\"';
@@ -1501,6 +1501,10 @@ export class SessionManager {
1501
1501
  if (identityMetadata?.sessionBoundaryOperationId) {
1502
1502
  metadata.sessionBoundaryOperationId = identityMetadata.sessionBoundaryOperationId;
1503
1503
  }
1504
+ // Only an explicit caller may bind a diagnostic case. Never inherit a
1505
+ // previous case (or any provider thread binding) from the active session.
1506
+ if (identityMetadata?.diagnostic)
1507
+ metadata.diagnostic = { ...identityMetadata.diagnostic };
1504
1508
  const session = {
1505
1509
  id: generateSessionId(),
1506
1510
  channel,
package/dist/index.js CHANGED
@@ -103,12 +103,10 @@ import { atomicWriteJson } from './core/session/session-fs-store.js';
103
103
  import { DAEMON_RUNTIME_EPOCH_ENV, ensureProcessManagedTempDir } from './cli/task-context.js';
104
104
  import { appendMessageLog, buildOutboundEntry, classifyAunPayloadForLog } from './core/message/message-log.js';
105
105
  import { normalizeAunMentionEntries } from './aun/msg/mention-schema.js';
106
- import { MAIN_PACKAGE_NAME } from './product.js';
107
106
  import { sanitizeMenuLogValue } from './core/command/menu-protocol.js';
108
107
  import fs from 'fs';
109
108
  import crypto from 'crypto';
110
109
  import { fileURLToPath } from 'url';
111
- import * as platform from './utils/cross-platform.js';
112
110
  const controlMenuDeduper = new MenuRequestDeduper();
113
111
  /** 出站 payload 摘要(用于 channel-out.log) */
114
112
  function summarizeOutboundPayload(payload) {
@@ -372,160 +370,23 @@ function seedUpgradeCheckTrigger(manager, owner) {
372
370
  fs.mkdirSync(dir, { recursive: true });
373
371
  const scriptPath = path.join(dir, scriptName);
374
372
  const packageRoot = getPackageRoot();
375
- const cliPath = path.join(packageRoot, 'dist', 'cli', 'index.js');
376
- const packageJsonPath = path.join(packageRoot, 'package.json');
377
- const devMode = fs.existsSync(path.join(packageRoot, 'src', 'index.ts'));
378
- // Resolve the launch argv at generation time: in a checkout this points the
379
- // monitor at src/ through tsx, so a `npm run build` that wipes dist/ cannot
380
- // take the watchdog down with it.
381
- const monitorEntry = platform.resolveCliEntry(packageRoot);
382
- const stageParent = path.join(resolvePaths().root, 'data', 'monitor-stage');
373
+ // Keep this generated script check-only. It never starts a monitor or runs npm install.
383
374
  fs.writeFileSync(scriptPath, `#!/usr/bin/env node
384
- const fs = require('node:fs');
385
- const path = require('node:path');
386
- const { existsSync, readFileSync } = fs;
387
- const { spawn } = require('node:child_process');
388
-
389
- const CLI_PATH = ${JSON.stringify(monitorEntry.entry)};
390
- const CLI_ARGS = ${JSON.stringify(monitorEntry.args)};
391
- const PACKAGE_ROOT = ${JSON.stringify(packageRoot)};
392
- const STAGE_PARENT = ${JSON.stringify(stageParent)};
393
- const PACKAGE_JSON = ${JSON.stringify(packageJsonPath)};
394
- const PACKAGE_NAME = ${JSON.stringify(MAIN_PACKAGE_NAME)};
395
- const DEV_MODE = ${devMode ? 'true' : 'false'};
396
-
397
- function emit(outcome, text) {
398
- process.stdout.write(JSON.stringify({ outcome, text, files: [] }));
399
- }
400
-
401
- function compareVersions(a, b) {
402
- const pa = a.split('-')[0].split('.').map(Number);
403
- const pb = b.split('-')[0].split('.').map(Number);
404
- const count = Math.max(pa.length, pb.length);
405
- for (let i = 0; i < count; i += 1) {
406
- const av = Number.isFinite(pa[i]) ? pa[i] : 0;
407
- const bv = Number.isFinite(pb[i]) ? pb[i] : 0;
408
- if (av < bv) return -1;
409
- if (av > bv) return 1;
410
- }
411
- return 0;
412
- }
413
-
414
- async function latestVersion() {
415
- const controller = new AbortController();
416
- const timer = setTimeout(() => controller.abort(), 10_000);
417
- try {
418
- const response = await fetch('https://registry.npmjs.org/' + encodeURIComponent(PACKAGE_NAME) + '/latest', {
419
- signal: controller.signal,
420
- headers: { Accept: 'application/json' },
421
- });
422
- if (!response.ok) return null;
423
- const data = await response.json();
424
- return typeof data.version === 'string' ? data.version : null;
425
- } finally {
426
- clearTimeout(timer);
427
- }
428
- }
429
-
375
+ const { pathToFileURL } = require('node:url');
376
+ const entry = ${JSON.stringify(path.join(packageRoot, 'dist', 'utils', 'package-version-info.js'))};
430
377
  async function main() {
431
- if (DEV_MODE) {
432
- emit('noop', 'upgrade check skipped in dev mode');
433
- return;
434
- }
435
-
436
- let localVersion;
437
- try {
438
- localVersion = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')).version;
439
- } catch {
440
- emit('error', 'failed to read local ec version');
441
- return;
442
- }
443
- if (typeof localVersion !== 'string' || !localVersion) {
444
- emit('error', 'failed to read local ec version');
445
- return;
446
- }
447
-
448
- let remoteVersion;
449
- try {
450
- remoteVersion = await latestVersion();
451
- } catch {
452
- remoteVersion = null;
453
- }
454
- if (!remoteVersion) {
455
- emit('error', 'failed to check evolcore latest version');
456
- return;
457
- }
458
- if (compareVersions(localVersion, remoteVersion) >= 0) {
459
- emit('noop', 'evolcore is already up to date (' + localVersion + '; latest ' + remoteVersion + ')');
460
- return;
461
- }
462
- if (!existsSync(CLI_PATH)) {
463
- emit('error', 'restart-monitor entry not found: ' + CLI_PATH);
464
- return;
465
- }
466
-
467
- // Installed mode: copy the monitor out of the package before it upgrades that
468
- // same package. Mirrors prepareMonitorStage(); kept inline because this file
469
- // is generated standalone CJS. Any failure degrades to launching in place —
470
- // losing upgrade isolation beats losing the upgrade.
471
- let launchArgs = CLI_ARGS;
472
- const launchEnv = { ...process.env };
473
- if (!DEV_MODE && STAGE_PARENT) {
474
- try {
475
- const stageDir = path.join(STAGE_PARENT, 'upgrade-' + process.pid + '-' + Date.now());
476
- fs.mkdirSync(stageDir, { recursive: true });
477
- fs.cpSync(path.join(PACKAGE_ROOT, 'dist'), path.join(stageDir, 'dist'), { recursive: true });
478
- if (existsSync(PACKAGE_JSON)) fs.copyFileSync(PACKAGE_JSON, path.join(stageDir, 'package.json'));
479
- const modulesSource = path.join(PACKAGE_ROOT, 'node_modules');
480
- if (existsSync(modulesSource)) {
481
- const staged = path.join(stageDir, 'node_modules');
482
- try {
483
- // junction works unelevated on Windows; dir symlink elsewhere.
484
- fs.symlinkSync(modulesSource, staged, process.platform === 'win32' ? 'junction' : 'dir');
485
- fs.readdirSync(staged);
486
- } catch (linkError) {
487
- fs.rmSync(staged, { recursive: true, force: true });
488
- fs.cpSync(modulesSource, staged, { recursive: true });
489
- }
490
- }
491
- const stagedEntry = path.join(stageDir, 'dist', 'cli', 'index.js');
492
- if (existsSync(stagedEntry)) {
493
- launchArgs = [stagedEntry];
494
- launchEnv.EVOLCORE_TARGET_PACKAGE_ROOT = PACKAGE_ROOT;
495
- launchEnv.EVOLCORE_MONITOR_STAGE = stageDir;
496
- }
497
- } catch (stageError) {
498
- // Fall through with the in-place entry.
499
- }
500
- }
501
-
502
- try {
503
- const child = spawn(process.execPath, [...launchArgs, 'restart-monitor'], {
504
- detached: true,
505
- stdio: 'ignore',
506
- windowsHide: process.platform === 'win32',
507
- env: launchEnv,
508
- });
509
- child.unref();
510
- const pid = child.pid;
511
- // The daemon is still running here — the monitor is what stops it. So a
512
- // monitor that dies on load costs the upgrade, not the service; still,
513
- // report it accurately instead of claiming success.
514
- await new Promise(resolve => setTimeout(resolve, 3000));
515
- let alive = false;
516
- try { process.kill(pid, 0); alive = true; } catch (e) { alive = false; }
517
- if (!alive) {
518
- emit('error', 'restart-monitor exited during startup; upgrade to ' + remoteVersion + ' not applied');
519
- return;
520
- }
521
- emit('success', 'evolcore upgrade available: ' + localVersion + ' -> ' + remoteVersion + '; restart-monitor started');
522
- } catch (error) {
523
- emit('error', 'failed to start restart-monitor: ' + (error && error.message ? error.message : String(error)));
524
- }
378
+ const { refreshManagedPackageVersions } = await import(pathToFileURL(entry).href);
379
+ const results = await refreshManagedPackageVersions(undefined, { source: 'daily-check' });
380
+ const failed = results.some(result => result.status === 'unavailable');
381
+ process.stdout.write(JSON.stringify({
382
+ outcome: failed ? 'error' : 'success',
383
+ text: results.map(result => result.packageName + ': ' + result.status).join('; '),
384
+ files: [],
385
+ }));
525
386
  }
526
-
527
- main().catch((error) => {
528
- emit('error', 'upgrade check failed: ' + (error && error.message ? error.message : String(error)));
387
+ main().catch(() => {
388
+ process.stdout.write(JSON.stringify({ outcome: 'error', text: 'Version information refresh failed', files: [] }));
389
+ process.exitCode = 1;
529
390
  });
530
391
  `);
531
392
  // Remove the pre-Node script left by older installations so it cannot be
package/dist/product.js CHANGED
@@ -14,6 +14,7 @@ export const MIN_ECWEB_VERSION = '0.0.8';
14
14
  export const DAEMON_CONFIG_FILENAME = 'daemon.json';
15
15
  export const DAEMON_SCHEMA_NAME = 'daemon';
16
16
  export const RUNTIME_HOME_ENV = 'EVOLCORE_HOME';
17
+ export const TARGET_PACKAGE_ROOT_ENV = 'EVOLCORE_TARGET_PACKAGE_ROOT';
17
18
  export const SESSION_ID_ENV = 'EVOLCORE_SESSION_ID';
18
19
  export const SELF_AID_ENV = 'EVOLCORE_SELF_AID';
19
20
  export const DEFAULT_RUNTIME_HOME_DIRNAME = '.evolcore';
@@ -15,6 +15,9 @@
15
15
  */
16
16
  import fs from 'fs';
17
17
  import path from 'path';
18
+ import os from 'os';
19
+ import { randomUUID } from 'crypto';
20
+ import { isProcessRunning } from './cross-platform.js';
18
21
  const HOT = '_'; // foo.json_ 旧版热备
19
22
  const TMP = '__'; // foo.json__ 写入中
20
23
  const HOT_BACKUP_FALLBACK_CODES = new Set(['EACCES', 'ENOSYS', 'ENOTSUP', 'EPERM', 'EXDEV']);
@@ -178,3 +181,98 @@ export function atomicWriteBytes(filePath, content) {
178
181
  fs.writeFileSync(tmp, content);
179
182
  fs.renameSync(tmp, filePath);
180
183
  }
184
+ const pauseFileWrite = (ms) => new Promise(resolve => setTimeout(resolve, ms));
185
+ /** Cross-process lock for package state. A complete owner file is linked into place atomically. */
186
+ export async function withFileLock(file, action, timeoutMs = 30000) {
187
+ fs.mkdirSync(path.dirname(file), { recursive: true });
188
+ const token = randomUUID();
189
+ const candidate = `${file}.${process.pid}.${token}.candidate`;
190
+ const owner = JSON.stringify({ token, pid: process.pid, host: os.hostname() });
191
+ fs.writeFileSync(candidate, owner, { flag: 'wx', mode: 0o600 });
192
+ const deadline = Date.now() + timeoutMs;
193
+ try {
194
+ while (true) {
195
+ try {
196
+ fs.linkSync(candidate, file);
197
+ break;
198
+ }
199
+ catch (error) {
200
+ if (error.code !== 'EEXIST')
201
+ throw error;
202
+ }
203
+ try {
204
+ const raw = fs.readFileSync(file, 'utf8');
205
+ const existing = JSON.parse(raw);
206
+ if (typeof existing.token === 'string' && /^[a-f0-9-]{36}$/.test(existing.token)
207
+ && existing.host === os.hostname() && existing.pid !== process.pid
208
+ && Number.isSafeInteger(existing.pid) && existing.pid > 0 && !isProcessRunning(existing.pid)
209
+ && fs.readFileSync(file, 'utf8') === raw) {
210
+ fs.unlinkSync(file);
211
+ continue;
212
+ }
213
+ }
214
+ catch { /* Unknown or concurrently released owner: wait. */ }
215
+ if (Date.now() >= deadline)
216
+ throw new Error('Package state lock timed out');
217
+ await pauseFileWrite(40 + Math.floor(Math.random() * 20));
218
+ }
219
+ const lockIdentity = fs.statSync(file);
220
+ try {
221
+ return await action();
222
+ }
223
+ finally {
224
+ const releaseDeadline = Date.now() + 5000;
225
+ while (true) {
226
+ try {
227
+ const current = fs.statSync(file);
228
+ if (current.dev !== lockIdentity.dev || current.ino !== lockIdentity.ino) {
229
+ throw new Error('Package lock ownership changed');
230
+ }
231
+ fs.unlinkSync(file);
232
+ break;
233
+ }
234
+ catch (error) {
235
+ if (error.code === 'ENOENT')
236
+ break;
237
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code ?? '')
238
+ || Date.now() >= releaseDeadline)
239
+ throw error;
240
+ await pauseFileWrite(50);
241
+ }
242
+ }
243
+ }
244
+ }
245
+ finally {
246
+ fs.rmSync(candidate, { force: true });
247
+ }
248
+ }
249
+ /** Atomic replacement without the in-place Windows fallback used by protected config files. */
250
+ export async function atomicReplaceJson(file, value) {
251
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
252
+ try {
253
+ const fd = fs.openSync(temporary, 'wx', 0o600);
254
+ try {
255
+ fs.writeFileSync(fd, JSON.stringify(value, null, 2) + '\n', 'utf8');
256
+ fs.fsyncSync(fd);
257
+ }
258
+ finally {
259
+ fs.closeSync(fd);
260
+ }
261
+ const deadline = Date.now() + 5000;
262
+ for (let attempt = 0;; attempt++) {
263
+ try {
264
+ fs.renameSync(temporary, file);
265
+ return;
266
+ }
267
+ catch (error) {
268
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code ?? '')
269
+ || Date.now() >= deadline)
270
+ throw error;
271
+ await pauseFileWrite(Math.min(200, 25 * (attempt + 1)) + Math.floor(Math.random() * 25));
272
+ }
273
+ }
274
+ }
275
+ finally {
276
+ fs.rmSync(temporary, { force: true });
277
+ }
278
+ }