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.
@@ -1,5 +1,9 @@
1
+ import path from 'node:path';
2
+ import { createHash } from 'node:crypto';
1
3
  import { isValidAid } from '../../aun/aid/validation.js';
4
+ import { atomicReadJson, atomicWriteJson } from '../../utils/atomic-write.js';
2
5
  import { remoteAccessService } from '../auth/remote-access-store.js';
6
+ import { KeyedFairMutex } from '../handoff/mutex.js';
3
7
  export const DIAGNOSTIC_AGENT_AID = 'diag.agentid.cn';
4
8
  /** IPC verifies local control authority; managed calls must independently prove their actor. */
5
9
  export async function executeDiagIpc(service, registry, owners, cmd) {
@@ -55,11 +59,17 @@ export class DiagService {
55
59
  context;
56
60
  send;
57
61
  tokens;
62
+ sessionControlTail = Promise.resolve();
58
63
  constructor(context, send, tokens = remoteAccessService) {
59
64
  this.context = context;
60
65
  this.send = send;
61
66
  this.tokens = tokens;
62
67
  }
68
+ withSessionControl(operation) {
69
+ const pending = this.sessionControlTail.then(operation, operation);
70
+ this.sessionControlTail = pending.then(() => undefined, () => undefined);
71
+ return pending;
72
+ }
63
73
  /** Local CLI authority is checked by IPC; slash identity is authenticated AUN peerId. */
64
74
  async execute(command, actorAid) {
65
75
  const { controlAid, owners } = this.context();
@@ -72,47 +82,302 @@ export class DiagService {
72
82
  if (!previous)
73
83
  return { ok: true, message: '当前没有诊断授权' };
74
84
  const diag = previous.diagnostic;
75
- try {
76
- await this.send(diag.agentAid, `诊断授权已取消。目标 daemon:${controlAid};case:${diag.caseId};沟通 Owner:${diag.ownerAid}。请停止这次诊断的后续访问,并告知 Owner。`);
77
- return { ok: true, message: '诊断授权已撤销,已通知诊断 Agent' };
78
- }
79
- catch {
80
- return { ok: true, code: 'DIAG_STOP_NOTICE_FAILED', message: '诊断授权已撤销;取消通知发送失败,诊断 Agent 后续访问将被拒绝' };
81
- }
85
+ return this.withSessionControl(async () => {
86
+ try {
87
+ await this.send(diag.agentAid, [
88
+ '诊断事件:cancel',
89
+ '诊断授权已取消。',
90
+ `目标 daemon:${controlAid}`,
91
+ `case:${diag.caseId}`,
92
+ `沟通 Owner:${diag.ownerAid}`,
93
+ '请停止该 case 的后续 Menu 访问,并告知 Owner 该 case 已取消。',
94
+ '不要把本通知当作新的诊断请求;请向 Owner 发送取消确认。',
95
+ ].join('\n'));
96
+ }
97
+ catch {
98
+ return { ok: true, code: 'DIAG_STOP_NOTICE_FAILED', message: '诊断授权已撤销;取消通知发送失败,诊断 Agent 后续访问将被拒绝' };
99
+ }
100
+ return { ok: true, message: '诊断授权已撤销,已通知诊断 Agent 停止该 case;远端执行状态未确认' };
101
+ });
82
102
  }
83
103
  const ownerAid = actorAid ?? command.ownerAid ?? owners[0];
84
104
  if (!ownerAid || !isValidAid(ownerAid) || !owners.includes(ownerAid))
85
105
  return { ok: false, code: 'DAEMON_OWNER_REQUIRED', message: '请配置本机 owners,或指定其中一个有效 Owner AID' };
86
106
  if (!command.problem?.trim() || command.problem.length > 16_000)
87
107
  return { ok: false, code: 'INVALID_ARGUMENT', message: '请提供 1 到 16000 字符的问题描述' };
108
+ const appendExisting = async (existing) => {
109
+ if (!existing.diagnostic)
110
+ return { ok: false, code: 'DIAG_AUTH_FAILED', message: '当前诊断授权记录无效' };
111
+ if (existing.diagnostic.ownerAid !== ownerAid) {
112
+ return { ok: false, code: 'DIAG_OWNER_MISMATCH', message: `当前诊断已绑定沟通 Owner ${existing.diagnostic.ownerAid},不能由 ${ownerAid} 追加信息` };
113
+ }
114
+ const { caseId } = existing.diagnostic;
115
+ const append = [
116
+ '诊断事件:append',
117
+ '当前诊断 case 的追加信息(不要新建诊断授权或 case):',
118
+ `目标 daemon:${controlAid}`,
119
+ `沟通 Owner:${ownerAid}`,
120
+ `case:${caseId}`,
121
+ `问题补充:\n${command.problem.trim()}`,
122
+ '请在现有诊断上下文中处理;继续使用原有授权,必要时向 Owner 汇报新增影响。',
123
+ ].join('\n');
124
+ return this.withSessionControl(async () => {
125
+ if (this.tokens.getDiagnosticToken(controlAid)?.token !== existing.token) {
126
+ return { ok: false, code: 'DIAG_CANCELLED', caseId, ownerAid, agentAid: existing.diagnostic.agentAid, message: '追加信息发送前诊断授权已取消' };
127
+ }
128
+ try {
129
+ await this.send(existing.diagnostic.agentAid, append);
130
+ }
131
+ catch (error) {
132
+ const reason = error instanceof Error ? error.message : String(error);
133
+ return { ok: false, code: 'DIAG_APPEND_FAILED', caseId, ownerAid, agentAid: existing.diagnostic.agentAid,
134
+ message: `诊断追加信息发送失败:${reason};原诊断授权仍保持有效` };
135
+ }
136
+ if (this.tokens.getDiagnosticToken(controlAid)?.token !== existing.token) {
137
+ return { ok: false, code: 'DIAG_CANCELLED', caseId, ownerAid, agentAid: existing.diagnostic.agentAid, message: '追加信息发送期间诊断授权已取消' };
138
+ }
139
+ return { ok: true, code: 'DIAG_APPENDED', caseId, ownerAid, agentAid: existing.diagnostic.agentAid,
140
+ message: `已将补充信息追加到现有诊断 case ${caseId},未创建新的诊断授权` };
141
+ });
142
+ };
143
+ const existing = this.tokens.getDiagnosticToken(controlAid);
144
+ if (existing?.diagnostic)
145
+ return appendExisting(existing);
88
146
  let record;
89
147
  try {
90
148
  record = this.tokens.issueDiagnosticToken(controlAid, DIAGNOSTIC_AGENT_AID, ownerAid);
91
149
  }
92
150
  catch (error) {
151
+ // Another request may have reserved the grant after the inspection above.
152
+ // Treat that reservation as the same case and append instead of surfacing a duplicate error.
153
+ const current = error?.code === 'DIAG_ALREADY_ACTIVE' ? this.tokens.getDiagnosticToken(controlAid) : undefined;
154
+ if (current?.diagnostic)
155
+ return appendExisting(current);
93
156
  return { ok: false, code: error.code ?? 'DIAG_AUTH_FAILED', message: error.message };
94
157
  }
95
158
  const caseId = record.diagnostic.caseId;
96
- const invitation = [
97
- '请协助诊断以下主机,并主动联系指定 Owner 展示进展、追问和报告。',
98
- `目标 daemon Control AID:${controlAid}`, `沟通 Owner AID:${ownerAid}`, `case:${caseId}`,
99
- `远程访问 Token:${record.token}`,
100
- '请通过标准 Menu 调用诊断;Token 放入 E2EE 受保护头。权限复用该主机既有 Control Menu,包含 restart。有效访问滑动续期,空闲 60 分钟失效。',
101
- '先给 Owner 发开始消息,使用通用 handoff 保留目标、Token、问题和发现;无需等待 Owner 回复即可开始。授权失效后停止访问。',
102
- `问题描述:\n${command.problem.trim()}`,
103
- ].join('\n');
104
- try {
105
- await this.send(DIAGNOSTIC_AGENT_AID, invitation);
106
- }
107
- catch (error) {
108
- this.tokens.revokeDiagnosticToken(controlAid, record.token);
109
- const reason = error instanceof Error ? error.message : String(error);
110
- return { ok: false, code: 'DIAG_INVITATION_FAILED', message: `未能确认诊断邀请发送成功:${reason}。本次授权已撤销,请重试` };
159
+ const sessionName = `diag-${caseId.slice(0, 8)}`;
160
+ return this.withSessionControl(async () => {
161
+ if (this.tokens.getDiagnosticToken(controlAid)?.token !== record.token) {
162
+ return { ok: false, code: 'DIAG_CANCELLED', message: '邀请发送前授权已撤销,本次诊断已取消' };
163
+ }
164
+ const invitation = [
165
+ '诊断事件:new',
166
+ '请协助诊断以下主机,并主动联系指定 Owner 展示进展、追问和报告。',
167
+ `目标 daemon Control AID:${controlAid}`, `沟通 Owner AID:${ownerAid}`, `case:${caseId}`,
168
+ `诊断创建时间(Unix毫秒):${record.createdAt}`,
169
+ `远程访问 Token:${record.token}`,
170
+ '请通过标准 Menu 调用诊断;Token 放入 E2EE 受保护头。权限复用该主机既有 Control Menu,包含 restart。有效访问滑动续期,空闲 60 分钟失效。',
171
+ `诊断端应在处理前按 case 建立或选择独立 EvolCore 会话「${sessionName}」,不得恢复或复用其他 case 的 Codex 执行会话。`,
172
+ '先给 Owner 发开始消息,使用通用 handoff 保留目标、Token、问题和发现;无需等待 Owner 回复即可开始。授权失效后停止访问。若会话隔离失败,先告知 Owner 并暂停 Menu 访问。',
173
+ `问题描述:\n${command.problem.trim()}`,
174
+ ].join('\n');
175
+ try {
176
+ await this.send(DIAGNOSTIC_AGENT_AID, invitation);
177
+ }
178
+ catch (error) {
179
+ this.tokens.revokeDiagnosticToken(controlAid, record.token);
180
+ const reason = error instanceof Error ? error.message : String(error);
181
+ return { ok: false, code: 'DIAG_INVITATION_FAILED', message: `未能确认诊断邀请发送成功:${reason}。本次授权已撤销,请重试` };
182
+ }
183
+ // A stop may race with delivery; never report an already revoked grant as active.
184
+ if (this.tokens.getDiagnosticToken(controlAid)?.token !== record.token)
185
+ return { ok: false, code: 'DIAG_CANCELLED', message: '邀请发送期间授权已撤销,本次诊断已取消' };
186
+ return { ok: true, caseId, ownerAid, agentAid: DIAGNOSTIC_AGENT_AID,
187
+ message: `诊断邀请已发出;诊断 Agent 收到后会按 case 建立独立执行会话,并联系 ${ownerAid}。` };
188
+ });
189
+ }
190
+ }
191
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
192
+ const INVITATION = '请协助诊断以下主机,并主动联系指定 Owner 展示进展、追问和报告。';
193
+ const APPEND = '当前诊断 case 的追加信息(不要新建诊断授权或 case):';
194
+ const diagnosticLocks = new KeyedFairMutex();
195
+ const diagnosticLedgerPath = (chatDir) => path.join(chatDir, 'diagnostic-cases.json');
196
+ export const diagnosticTokenHash = (token) => createHash('sha256').update(token).digest('hex');
197
+ /** Parse only the generated header, never quoted text or the user's problem. */
198
+ export function parseDiagnosticEvent(content) {
199
+ const text = content.trim().replace(/\r\n/g, '\n');
200
+ const first = text.split('\n', 1)[0];
201
+ const explicit = /^诊断事件[::]\s*(new|append|cancel)$/.exec(first)?.[1];
202
+ if (first.startsWith('诊断事件') && !explicit)
203
+ throw new Error('未知诊断事件,未选择执行会话');
204
+ // Existing released senders need no new slash command or protocol support.
205
+ const kind = explicit ?? (first === INVITATION ? 'new' : first === APPEND ? 'append'
206
+ : text.startsWith('诊断授权已取消。') ? 'cancel' : undefined);
207
+ if (!kind)
208
+ return undefined;
209
+ const header = text.split(/^问题(?:描述|补充)[::]/m, 1)[0];
210
+ const legacyCancel = !explicit && kind === 'cancel';
211
+ const fields = legacyCancel ? header.replace(/^诊断授权已取消。/, '').replace(/;/g, '\n') : header;
212
+ const field = (label) => {
213
+ const matches = [...fields.matchAll(new RegExp(`^${label}[::]([^\\n]*)$`, 'gm'))];
214
+ return matches.length === 1 ? matches[0][1].trim() : undefined;
215
+ };
216
+ const caseId = field('case')?.toLowerCase();
217
+ const controlAid = field('目标 daemon(?: Control AID)?');
218
+ const ownerAid = legacyCancel
219
+ ? field('沟通 Owner')?.split('。', 1)[0]
220
+ : field('沟通 Owner(?: AID)?');
221
+ if (!caseId || !UUID.test(caseId) || !controlAid || !isValidAid(controlAid) || !ownerAid || !isValidAid(ownerAid)) {
222
+ throw new Error('诊断通知字段无效,未选择执行会话');
223
+ }
224
+ if (kind !== 'new')
225
+ return { kind, caseId, controlAid, ownerAid };
226
+ const token = field('远程访问 Token');
227
+ if (!token || !/^[0-9a-f]{64}$/i.test(token))
228
+ throw new Error('诊断邀请缺少有效 Token,未选择执行会话');
229
+ const created = field('诊断创建时间(Unix毫秒)');
230
+ const createdAt = created === undefined ? undefined : Number(created);
231
+ if (createdAt !== undefined && (!Number.isSafeInteger(createdAt) || createdAt <= 0))
232
+ throw new Error('诊断创建时间无效');
233
+ return { kind, caseId, controlAid, ownerAid, tokenHash: diagnosticTokenHash(token), createdAt };
234
+ }
235
+ function readDiagnosticLedger(chatDir) {
236
+ const value = atomicReadJson(diagnosticLedgerPath(chatDir));
237
+ if (!value)
238
+ return { version: 1, cases: {} };
239
+ if (value.version !== 1 || !value.cases || typeof value.cases !== 'object' || Array.isArray(value.cases)
240
+ || Object.entries(value.cases).some(([key, record]) => !UUID.test(key) || !record
241
+ || !['active', 'closed'].includes(record.state) || !isValidAid(record.ownerAid))) {
242
+ throw new Error('诊断会话记录损坏,已阻止恢复旧会话');
243
+ }
244
+ return value;
245
+ }
246
+ /** Called again at execution and Menu boundaries, including delayed/recovered work. */
247
+ export function isDiagnosticSessionActive(session, chatDir) {
248
+ if (session.metadata?.diagnosticStatus === 'cancelled')
249
+ return false;
250
+ const binding = session.metadata?.diagnostic;
251
+ if (!binding)
252
+ return true;
253
+ const ledger = readDiagnosticLedger(chatDir);
254
+ const record = ledger.cases[binding.caseId];
255
+ return ledger.activeCaseId === binding.caseId && record?.state === 'active'
256
+ && record.sessionId === session.id && record.tokenHash === binding.tokenHash
257
+ && record.ownerAid === binding.ownerAid && session.channelId === binding.controlAid;
258
+ }
259
+ /** Invalid authorization is terminal; it must not be revived by a retry/append. */
260
+ export function closeDiagnosticSession(session, chatDir) {
261
+ const binding = session.metadata?.diagnostic;
262
+ if (!binding)
263
+ return;
264
+ const ledger = readDiagnosticLedger(chatDir);
265
+ const record = ledger.cases[binding.caseId];
266
+ if (record?.sessionId !== session.id)
267
+ return;
268
+ record.state = 'closed';
269
+ atomicWriteJson(diagnosticLedgerPath(chatDir), ledger);
270
+ }
271
+ /** The bridge already applies normal authentication/admission before calling this. */
272
+ export class DiagnosticSessionRouter {
273
+ sessions;
274
+ stopSession;
275
+ constructor(sessions, stopSession) {
276
+ this.sessions = sessions;
277
+ this.stopSession = stopSession;
278
+ }
279
+ async route(input) {
280
+ const { event, message, channel, channelType, selfAid, chatDir } = input;
281
+ if (channelType !== 'aun' || selfAid !== DIAGNOSTIC_AGENT_AID || message.chatType !== 'private'
282
+ || message.threadId || (message.source !== undefined && message.source !== 'user')
283
+ || !(message.encrypted ?? message.replyContext?.metadata?.encrypted)
284
+ || message.peerId !== event.controlAid || message.channelId !== event.controlAid || !message.messageId) {
285
+ throw new Error('诊断通知来源或会话不匹配,未执行会话切换');
111
286
  }
112
- // A stop may race with delivery; never report an already revoked grant as active.
113
- if (this.tokens.getDiagnosticToken(controlAid)?.token !== record.token)
114
- return { ok: false, code: 'DIAG_CANCELLED', message: '邀请发送期间授权已撤销,本次诊断已取消' };
115
- return { ok: true, caseId, ownerAid, agentAid: DIAGNOSTIC_AGENT_AID,
116
- message: `诊断邀请已发出,远程诊断 Agent 将联系 ${ownerAid},请在与 ${DIAGNOSTIC_AGENT_AID} 的会话中查看处理过程。` };
287
+ return diagnosticLocks.forKey(diagnosticLedgerPath(chatDir)).runExclusive(async () => {
288
+ const all = await this.sessions.listSessions(channel, message.channelId, { archiveFilter: 'all' });
289
+ const ledger = readDiagnosticLedger(chatDir);
290
+ const find = (caseId) => all.find(s => !s.threadId && s.selfAID === selfAid
291
+ && s.metadata?.diagnostic?.caseId === caseId && s.metadata.diagnostic.controlAid === event.controlAid);
292
+ let record = ledger.cases[event.caseId];
293
+ if (record && record.ownerAid !== event.ownerAid)
294
+ throw new Error('诊断通知 Owner 与原授权不一致');
295
+ if (event.kind === 'cancel') {
296
+ let session = find(event.caseId);
297
+ if (!session) {
298
+ const legacy = this.sessions.getActiveSessionSync(channel, message.channelId, channelType, selfAid);
299
+ if (legacy && !legacy.threadId && !legacy.metadata?.diagnostic) {
300
+ await this.sessions.patchSessionMetadata(legacy.id, { diagnosticStatus: 'cancelled' });
301
+ session = legacy;
302
+ }
303
+ }
304
+ // Keep a tombstone even if cancellation overtook the first invitation.
305
+ ledger.cases[event.caseId] = { ...record, ownerAid: event.ownerAid, state: 'closed', sessionId: record?.sessionId ?? session?.id };
306
+ atomicWriteJson(diagnosticLedgerPath(chatDir), ledger);
307
+ if (session)
308
+ await this.stopSession(session.id);
309
+ return { notice: '已关闭该诊断并停止其待处理任务。' };
310
+ }
311
+ if (record?.state === 'closed')
312
+ return { notice: '该诊断已结束,旧邀请和追加消息不会重新执行。' };
313
+ if (event.kind === 'append' && !record)
314
+ throw new Error('未找到该诊断的独立会话,请停止诊断后重新发起');
315
+ if (record && event.kind === 'new' && record.tokenHash !== event.tokenHash)
316
+ throw new Error('相同诊断的授权不一致,未恢复会话');
317
+ if (!record) {
318
+ if (find(event.caseId))
319
+ throw new Error('诊断生命周期记录缺失,拒绝恢复旧会话');
320
+ const sequence = Number.isSafeInteger(message.gatewaySeq) ? message.gatewaySeq : undefined;
321
+ const last = ledger.activeCaseId ? ledger.cases[ledger.activeCaseId] : undefined;
322
+ const stale = last && (sequence !== undefined && last.sequence !== undefined
323
+ ? sequence <= last.sequence
324
+ : event.createdAt !== undefined && last.createdAt !== undefined
325
+ ? event.createdAt <= last.createdAt : true);
326
+ if (stale)
327
+ return { notice: '邀请顺序已过期或无法确认,未恢复旧诊断。' };
328
+ // Persist all revocations before interruption/creation. A crash here
329
+ // cannot let a restored old task use its previous authorization.
330
+ for (const previous of Object.values(ledger.cases))
331
+ previous.state = 'closed';
332
+ record = { state: 'active', ownerAid: event.ownerAid, tokenHash: event.tokenHash, sequence, createdAt: event.createdAt };
333
+ ledger.cases[event.caseId] = record;
334
+ ledger.activeCaseId = event.caseId;
335
+ atomicWriteJson(diagnosticLedgerPath(chatDir), ledger);
336
+ }
337
+ if (ledger.activeCaseId !== event.caseId)
338
+ return { notice: '该诊断已被新诊断替代。' };
339
+ const oldActive = this.sessions.getActiveSessionSync(channel, message.channelId, channelType, selfAid);
340
+ if (!record.cleanupComplete && oldActive && !oldActive.threadId && !oldActive.metadata?.diagnostic) {
341
+ // Migration from the historical shared Control conversation. Owner
342
+ // conversations and sessions belonging to other hosts are untouched.
343
+ await this.sessions.patchSessionMetadata(oldActive.id, { diagnosticStatus: 'cancelled' });
344
+ await this.stopSession(oldActive.id);
345
+ }
346
+ // Retry interruption too after an interrupted creation attempt.
347
+ for (const previous of record.cleanupComplete ? [] : all) {
348
+ if (!previous.threadId && previous.metadata?.diagnostic?.controlAid === event.controlAid
349
+ && previous.metadata.diagnostic.caseId !== event.caseId)
350
+ await this.stopSession(previous.id);
351
+ }
352
+ let session = find(event.caseId);
353
+ if (record.sessionId && (!session || record.sessionId !== session.id))
354
+ throw new Error('诊断会话绑定缺失,拒绝回退到历史会话');
355
+ if (!session) {
356
+ if (event.kind !== 'new' || !input.baseagent)
357
+ throw new Error('无法创建诊断执行会话');
358
+ const metadata = {
359
+ channelKey: channel, peerId: event.controlAid, peerName: message.peerName,
360
+ diagnostic: { caseId: event.caseId, controlAid: event.controlAid, ownerAid: event.ownerAid, tokenHash: record.tokenHash },
361
+ };
362
+ // The case binding is committed in the first session snapshot. Replay
363
+ // can find it even if the process died before saving the ledger pointer.
364
+ session = await this.sessions.createNewSession(channel, message.channelId, input.projectPath, `diag-${event.caseId.slice(0, 8)}`, input.baseagent, metadata);
365
+ if (session.agentSessionId || session.metadata?.agentSessions || session.metadata?.resumeAt) {
366
+ throw new Error('诊断会话错误继承了旧执行上下文');
367
+ }
368
+ }
369
+ if (session.metadata?.diagnostic?.tokenHash !== record.tokenHash)
370
+ throw new Error('诊断会话授权绑定不一致');
371
+ // A concurrent Menu response can close this case while we await runner
372
+ // interruption/session creation. Never overwrite that terminal state.
373
+ const latest = readDiagnosticLedger(chatDir);
374
+ const current = latest.cases[event.caseId];
375
+ if (latest.activeCaseId !== event.caseId || current?.state !== 'active')
376
+ return { notice: '该诊断已结束。' };
377
+ current.sessionId = session.id;
378
+ current.cleanupComplete = true;
379
+ atomicWriteJson(diagnosticLedgerPath(chatDir), latest);
380
+ return { session };
381
+ });
117
382
  }
118
383
  }
@@ -13,7 +13,8 @@ import os from 'os';
13
13
  import crypto from 'crypto';
14
14
  import { CronExpressionParser } from 'cron-parser';
15
15
  import { parseDuration } from '../../trigger/parser.js';
16
- import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions, resolveGlobalPkg } from '../../utils/npm-ops.js';
16
+ import { isLinkedInstall, resolveGlobalPkg } from '../../utils/npm-ops.js';
17
+ import { refreshManagedPackageVersions, VERSION_INFO_PACKAGES } from '../../utils/package-version-info.js';
17
18
  import { resolveCliEntry, launchMonitorAndConfirm } from '../../utils/cross-platform.js';
18
19
  import { cachedCliVersion } from '../../utils/cross-platform.js';
19
20
  import { scanInstances } from '../../utils/instance-registry.js';
@@ -4115,27 +4116,23 @@ export async function execMenuAction(cmd, action, args, channel, channelId, user
4115
4116
  }
4116
4117
  if (action === 'upgrade') {
4117
4118
  const devMode = isLinkedInstall();
4118
- const localEvolcore = getLocalVersion();
4119
- // fastaun 本地版本:从 node_modules 读取(与 menu.query name=system 一致)
4120
- let localFastaun = null;
4121
- try {
4122
- const fp = path.join(getPackageRoot(), 'node_modules', '@agentunion', 'fastaun', 'package.json');
4123
- localFastaun = JSON.parse(fs.readFileSync(fp, 'utf-8'))?.version ?? null;
4124
- }
4125
- catch { }
4126
- const [evolcoreRemote, fastaunRemote, ecwebRemote] = await Promise.all([
4127
- checkLatestVersion('evolcore'),
4128
- checkLatestVersion('@agentunion/fastaun'),
4129
- checkLatestVersion(WEB_PACKAGE_NAME),
4130
- ]);
4131
- const cmp = (local, remote) => !!(local && remote && compareVersions(local, remote) < 0);
4119
+ const results = await refreshManagedPackageVersions(VERSION_INFO_PACKAGES, { source: 'menu-upgrade' });
4120
+ const [evolcore, fastaunWeb, web] = [results[0], results[2], results[1]];
4121
+ const item = (result) => ({
4122
+ local: result.entry.currentVersion,
4123
+ remote: result.status === 'ok' ? result.entry.latestVersion ?? null : null,
4124
+ hasUpdate: result.status === 'ok' && result.hasUpdate,
4125
+ status: result.status,
4126
+ checkedAt: result.status === 'ok' ? result.entry.checkedAt ?? null : null,
4127
+ lastCheckedAt: result.entry.checkedAt ?? null,
4128
+ ...(result.error ? { error: result.error } : {}),
4129
+ });
4132
4130
  return {
4133
4131
  data: {
4134
4132
  devMode,
4135
- evolcore: { local: localEvolcore, remote: evolcoreRemote, hasUpdate: cmp(localEvolcore, evolcoreRemote) },
4136
- fastaun: { local: localFastaun, remote: fastaunRemote, hasUpdate: cmp(localFastaun, fastaunRemote), managedBy: 'evolcore' },
4137
- // ecweb 本地版本由 ECWeb 进程自身注入(data.ecwebVersion),此处仅给 remote
4138
- ecweb: { remote: ecwebRemote },
4133
+ evolcore: item(evolcore),
4134
+ fastaun: { ...item(fastaunWeb), managedBy: 'evolcore' },
4135
+ ecweb: item(web),
4139
4136
  },
4140
4137
  };
4141
4138
  }
@@ -315,6 +315,9 @@ export function validateMenuRequest(request) {
315
315
  if (request.args !== undefined && (!request.args || typeof request.args !== 'object' || Array.isArray(request.args))) {
316
316
  return { code: 'INVALID_ARGUMENT', message: 'Menu request args must be an object' };
317
317
  }
318
+ if (type === 'menu.list' && Object.hasOwn(request, 'context')) {
319
+ return { code: 'INVALID_ARGUMENT', message: 'menu.list context must be inside args.context (for a daemon catalog, use args.context.kind=process)' };
320
+ }
318
321
  if (type === 'menu.update') {
319
322
  const args = request.args;
320
323
  const scopeError = validateConfigWriteScope(request.name, args)
@@ -7,7 +7,7 @@ import { shouldSuppressRealRestart } from '../../utils/restart-safety.js';
7
7
  import crypto from 'crypto';
8
8
  import path from 'path';
9
9
  import fs from 'fs';
10
- import { checkLatestVersion, getLocalVersion, isLinkedInstall, compareVersions } from '../../utils/npm-ops.js';
10
+ import { ensureFreshPackageVersionInfo, refreshManagedPackageVersions } from '../../utils/package-version-info.js';
11
11
  import { loadDaemonConfig } from '../../config-store.js';
12
12
  import { remoteAccessService } from '../auth/remote-access-store.js';
13
13
  import { read as cfgRead, resolveEffective, routeFieldPath, write as cfgWrite, } from '../../config/config-manager.js';
@@ -667,7 +667,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
667
667
  const appendProcessCommands = (lines) => {
668
668
  if (!isDaemonOwner)
669
669
  return;
670
- lines.push('', '🛠️ 进程级运维:', ' /restart - 重启服务', ' /upgrade - 检查版本更新', ' /reload [aid] - 热重载 Agent 配置', ' /fa <提示词> - 单次使用宿主级 fullaccess 执行(别名: /fullaccess)', ' /pair - 获取远程控制配对码', ' /diag <问题描述> - 邀请远程诊断;/diag stop 撤销授权');
670
+ lines.push('', '🛠️ 进程级运维:', ' /restart - 重启服务', ' /upgrade - 检查版本更新', ' /reload [aid] - 热重载 Agent 配置', ' /fa <提示词> - 单次使用宿主级 fullaccess 执行(别名: /fullaccess)', ' /pair - 获取远程控制配对码', ' /diag <问题描述> - 邀请或追加远程诊断;/diag stop 撤销授权');
671
671
  };
672
672
  if (!isAdmin && activeChatType === 'group') {
673
673
  const lines = [
@@ -913,7 +913,7 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
913
913
  cmds.push({ command: '/upgrade', description: '检查版本更新', category: '运维', roles: ['daemon-owner'] });
914
914
  cmds.push({ command: '/reload', args: '[aid]', description: '热重载 Agent 配置', category: '运维', roles: ['daemon-owner'] });
915
915
  cmds.push({ command: '/pair', description: '获取远程控制配对码', category: '运维', roles: ['daemon-owner'] });
916
- cmds.push({ command: '/diag', args: '<问题描述>|stop', description: '邀请远程诊断或撤销授权', category: '运维', roles: ['daemon-owner'] });
916
+ cmds.push({ command: '/diag', args: '<问题描述>|stop', description: '邀请/追加远程诊断或撤销授权', category: '运维', roles: ['daemon-owner'] });
917
917
  }
918
918
  if (!isDaemonOwner && isAdmin) {
919
919
  cmds.push({ command: '/reload', description: '热重载当前 Agent 配置', category: '运维', roles: ['admin', 'owner'] });
@@ -2997,6 +2997,12 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
2997
2997
  logger.warn(`[System] Refusing restart while data migration is pending: operations=${migrationRequirement.operationCount}`);
2998
2998
  return { code: 'DATA_MIGRATION_REQUIRED', operationCount: migrationRequirement.operationCount };
2999
2999
  }
3000
+ if (!suppressRealRestart) {
3001
+ await ensureFreshPackageVersionInfo(undefined, {
3002
+ source: 'slash-restart',
3003
+ log: message => logger.info(`[System] ${message}`),
3004
+ });
3005
+ }
3000
3006
  const restartInfo = {
3001
3007
  channel,
3002
3008
  channelId,
@@ -3140,18 +3146,21 @@ export async function handleSlashCommand(content, channel, channelId, sendMessag
3140
3146
  });
3141
3147
  if (upgradeAuthDenied)
3142
3148
  return upgradeAuthDenied;
3143
- if (isLinkedInstall()) {
3144
- return { kind: 'command.result', text: '⏭ 开发模式,跳过升级检查' };
3145
- }
3146
- const localVer = getLocalVersion();
3147
- const remoteVer = await checkLatestVersion();
3148
- if (!remoteVer) {
3149
- return { kind: 'command.result', text: `⚠️ 无法连接 npm registry(当前版本 ${localVer})` };
3150
- }
3151
- if (compareVersions(localVer, remoteVer) >= 0) {
3152
- return { kind: 'command.result', text: `✓ 已是最新版本 (${localVer})` };
3153
- }
3154
- return { kind: 'command.result', text: `📦 发现新版本 ${localVer} → ${remoteVer}\n执行 /restart 升级` };
3149
+ const results = await refreshManagedPackageVersions(undefined, { source: 'slash-upgrade' });
3150
+ const lines = results.filter(result => result.status !== 'not-installed').map(result => {
3151
+ const { packageName, status, entry } = result;
3152
+ if (status === 'skipped')
3153
+ return `⏭ ${packageName}:开发模式,跳过升级检查`;
3154
+ if (status === 'unavailable') {
3155
+ return `⚠️ ${packageName}:本次检查失败(当前 ${entry.currentVersion ?? '未知'});`
3156
+ + (entry.checkedAt ? `最近成功检查:${entry.checkedAt}` : '尚无成功检查记录');
3157
+ }
3158
+ return `${result.hasUpdate ? '📦' : '✓'} ${packageName}:当前 ${entry.currentVersion},最新 ${entry.latestVersion}`
3159
+ + `;检查时间 ${entry.checkedAt}${result.hasUpdate ? '' : ',无需升级'}`;
3160
+ });
3161
+ if (results.some(result => result.hasUpdate))
3162
+ lines.push('执行 /restart 应用升级');
3163
+ return { kind: 'command.result', text: lines.join('\n') };
3155
3164
  }
3156
3165
  // /pwd 命令:显示当前项目路径
3157
3166
  if (normalizedContent === '/pwd') {
@@ -21,6 +21,7 @@ export class IMRenderer {
21
21
  /** 自增 callId 兜底(runner 没提供时用) */
22
22
  syntheticCallSeq = 0;
23
23
  currentAssistantUuid;
24
+ currentTextPhase;
24
25
  constructor(opts) {
25
26
  this.opts = opts;
26
27
  }
@@ -175,14 +176,24 @@ export class IMRenderer {
175
176
  }
176
177
  // ── 文本/活动注入(替代 StreamFlusher.addText/addActivity)──
177
178
  /** 添加文本片段(流式 text) */
178
- async addText(text, outputTokens, turn, assistantUuid) {
179
+ async addText(text, outputTokens, turn, assistantUuid, phase) {
179
180
  if (this.opts.envelope.chatmode === 'proactive')
180
181
  return;
181
- if (this.textBuffer && this.currentAssistantUuid !== assistantUuid && !this.suppressIntermediateText()) {
182
+ const sameAssistant = this.currentAssistantUuid === assistantUuid;
183
+ // In deferred AUN private delivery, only explicit text_start/tool_use
184
+ // handlers are semantic boundaries. assistantUuid is correlation data and
185
+ // may be absent or change without proving that the prior text is final.
186
+ const hadPendingText = this.textBuffer.length > 0;
187
+ if (hadPendingText && !this.opts.deferTextUntilBoundary && !sameAssistant
188
+ && !this.suppressIntermediateText()) {
182
189
  await this.flushText();
183
190
  }
184
191
  // Do not carry the previous item's anchor onto unanchored output.
185
192
  this.currentAssistantUuid = assistantUuid;
193
+ // A mixed buffer has no trustworthy phase, even when its latest chunk is commentary.
194
+ this.currentTextPhase = hadPendingText && this.opts.deferTextUntilBoundary
195
+ ? this.currentTextPhase === phase ? phase : undefined
196
+ : phase;
186
197
  this.emitProgress('text', outputTokens, turn);
187
198
  if (!text)
188
199
  return;
@@ -276,6 +287,8 @@ export class IMRenderer {
276
287
  }
277
288
  /** Notify the client that a new model text block has started. */
278
289
  async sendTextStart(contentIndex, turn) {
290
+ if (this.opts.deferTextUntilBoundary)
291
+ await this.flushText();
279
292
  const payload = {
280
293
  kind: 'status.progress',
281
294
  metadata: { activityType: 'text', kind: 'text_start', contentIndex, turn },
@@ -453,7 +466,7 @@ export class IMRenderer {
453
466
  const elapsed = Date.now() - this.lastFlush;
454
467
  const delay = Math.max(0, targetDelay - elapsed);
455
468
  this.timer = setTimeout(() => {
456
- void this.flushInternal().catch(error => {
469
+ void this.flushInternal(undefined, undefined, true).catch(error => {
457
470
  logger.warn(`[IMRenderer] scheduled flush failed: ${error instanceof Error ? error.message : String(error)}`);
458
471
  });
459
472
  }, delay);
@@ -501,7 +514,7 @@ export class IMRenderer {
501
514
  * 完整 flush:把 itemsQueue 里所有 items 打包成 activity.batch 发送。
502
515
  * 如果 isFinal=true,还会在 batch 之后单独发一条 result.text 作为最终回复。
503
516
  */
504
- async flushInternal(isFinal, attention) {
517
+ async flushInternal(isFinal, attention, scheduled = false) {
505
518
  if (this.timer) {
506
519
  clearTimeout(this.timer);
507
520
  this.timer = undefined;
@@ -562,6 +575,10 @@ export class IMRenderer {
562
575
  if (!isFinal && this.textBuffer.length > 0) {
563
576
  if (this.suppressIntermediateText())
564
577
  return;
578
+ // A timer cannot decide whether this is the final answer. Keep it for
579
+ // an explicit boundary; known commentary can still be sent immediately.
580
+ if (scheduled && this.opts.deferTextUntilBoundary && this.currentTextPhase !== 'commentary')
581
+ return;
565
582
  const text = this.textBuffer;
566
583
  this.textBuffer = '';
567
584
  const payload = {