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.
- package/CHANGELOG.md +26 -15
- package/dist/agents/baseagent.js +10 -5
- package/dist/agents/codex/app-server-client.js +7 -0
- package/dist/agents/codex/catalog.js +7 -1
- package/dist/agents/codex-runner.js +48 -11
- package/dist/aun/menu/client.js +1 -0
- package/dist/channels/aun.js +23 -7
- package/dist/cli/daemon-commands.js +77 -140
- package/dist/cli/diag.js +1 -1
- package/dist/cli/menu-client.js +4 -0
- package/dist/cli/restart-monitor.js +65 -17
- package/dist/core/command/command-handler.js +29 -1
- package/dist/core/command/diag-service.js +292 -27
- package/dist/core/command/menu-handler.js +16 -19
- package/dist/core/command/menu-protocol.js +3 -0
- package/dist/core/command/slash-handler.js +24 -15
- package/dist/core/message/im-renderer.js +21 -4
- package/dist/core/message/message-bridge.js +47 -3
- package/dist/core/message/response-engine.js +29 -10
- package/dist/core/permission/ec-command-parser.js +15 -7
- package/dist/core/permission/tool-policy.js +1 -1
- package/dist/core/session/session-manager.js +4 -0
- package/dist/index.js +14 -153
- package/dist/product.js +1 -0
- package/dist/utils/atomic-write.js +98 -0
- package/dist/utils/cross-platform.js +87 -16
- package/dist/utils/npm-ops.js +119 -4
- package/dist/utils/package-version-info.js +244 -0
- package/dist/utils/windows-shell-trust.js +37 -0
- package/kits/docs/channels/aun.md +8 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 {
|
|
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
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
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:
|
|
4136
|
-
fastaun: {
|
|
4137
|
-
|
|
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 {
|
|
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 <问题描述> -
|
|
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: '
|
|
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
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
return {
|
|
3153
|
-
|
|
3154
|
-
|
|
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
|
-
|
|
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 = {
|