@foxden-app/foxclaw 0.11.0 → 0.12.0
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/.env.example +18 -1
- package/CHANGELOG.md +34 -0
- package/README.md +4 -3
- package/README_EN.md +4 -3
- package/dist/antigravity/controller.d.ts +7 -0
- package/dist/antigravity/controller.js +123 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +41 -5
- package/dist/core/engine_spi.d.ts +1 -1
- package/dist/core/orchestrator.d.ts +6 -0
- package/dist/core/orchestrator.js +78 -26
- package/dist/dsh/adapter.d.ts +48 -0
- package/dist/dsh/adapter.js +192 -0
- package/dist/dsh/client.d.ts +46 -0
- package/dist/dsh/client.js +184 -0
- package/dist/dsh/permissions_plugin.d.ts +30 -0
- package/dist/dsh/permissions_plugin.js +21 -0
- package/dist/dsh/runtime.d.ts +21 -0
- package/dist/dsh/runtime.js +45 -0
- package/dist/dsh/ui.d.ts +38 -0
- package/dist/dsh/ui.js +342 -0
- package/dist/i18n.d.ts +4 -0
- package/dist/i18n.js +12 -0
- package/dist/main.js +99 -39
- package/dist/store/database.d.ts +3 -1
- package/dist/store/database.js +8 -3
- package/dist/types.d.ts +1 -1
- package/docs/dsh.md +83 -0
- package/docs/zh/dsh.md +83 -0
- package/package.json +3 -2
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import { parseCommand } from '../controller/commands.js';
|
|
5
|
-
import { getTelegramCommands, getAntigravityTelegramCommands } from '../i18n.js';
|
|
5
|
+
import { getTelegramCommands, getAntigravityTelegramCommands, getDshTelegramCommands } from '../i18n.js';
|
|
6
6
|
import { isDefaultTelegramScope, resolveTelegramAddressing } from '../telegram/addressing.js';
|
|
7
7
|
import { chunkTelegramMessage } from '../telegram/text.js';
|
|
8
8
|
import { stageInboundAttachments } from './attachments.js';
|
|
@@ -27,6 +27,8 @@ export class UnifiedChannelOrchestrator {
|
|
|
27
27
|
defaultBackendId;
|
|
28
28
|
activeTurns = new Map();
|
|
29
29
|
stalePanelDeleteTimers = new Map();
|
|
30
|
+
recoveryTimers = new Set();
|
|
31
|
+
ownsScope;
|
|
30
32
|
backendProvider;
|
|
31
33
|
get adapter() {
|
|
32
34
|
return this.getAdapterForBackend(this.defaultBackendId);
|
|
@@ -40,6 +42,7 @@ export class UnifiedChannelOrchestrator {
|
|
|
40
42
|
this.queueManager = new TurnQueueManager();
|
|
41
43
|
this.customUi = options.customUi;
|
|
42
44
|
this.backendProvider = options.backendProvider;
|
|
45
|
+
this.ownsScope = options.ownsScope ?? (() => true);
|
|
43
46
|
if (options.backends) {
|
|
44
47
|
for (const b of options.backends) {
|
|
45
48
|
this.backends.set(b.id, b);
|
|
@@ -173,6 +176,9 @@ export class UnifiedChannelOrchestrator {
|
|
|
173
176
|
? `未找到目标后端: ${targetBackendId}`
|
|
174
177
|
: `Target backend not found: ${targetBackendId}`);
|
|
175
178
|
}
|
|
179
|
+
if (this.activeTurns.has(scopeId) && (targetDesc.engineType === 'dsh' || this.getBackendDescriptorForScope(scopeId).engineType === 'dsh')) {
|
|
180
|
+
throw new Error(locale === 'zh' ? '请先中断当前任务,再切换后端。' : 'Interrupt the active turn before switching backends.');
|
|
181
|
+
}
|
|
176
182
|
if (targetDesc.onSelect) {
|
|
177
183
|
await targetDesc.onSelect(scopeId);
|
|
178
184
|
}
|
|
@@ -219,29 +225,28 @@ export class UnifiedChannelOrchestrator {
|
|
|
219
225
|
}
|
|
220
226
|
else {
|
|
221
227
|
this.store.clearBinding(scopeId);
|
|
222
|
-
const defaultEffort = targetDesc.engineType === 'antigravity' ? 'high' : 'medium';
|
|
228
|
+
const defaultEffort = targetDesc.engineType === 'dsh' ? null : targetDesc.engineType === 'antigravity' ? 'high' : 'medium';
|
|
223
229
|
this.store.setChatSettings(scopeId, null, defaultEffort);
|
|
224
230
|
this.store.setChatServiceTier(scopeId, null);
|
|
225
231
|
}
|
|
226
232
|
const isCodex = targetDesc.engineType === 'codex';
|
|
227
233
|
const activeSettings = this.store.getChatSettings(scopeId);
|
|
228
234
|
const modelText = activeSettings?.model ? `\`${activeSettings.model}\`` : (locale === 'zh' ? '引擎默认' : 'default');
|
|
229
|
-
const effortText = activeSettings?.reasoningEffort ?? (isCodex ? 'medium' : 'high');
|
|
235
|
+
const effortText = activeSettings?.reasoningEffort ?? (targetDesc.engineType === 'dsh' ? 'default' : isCodex ? 'medium' : 'high');
|
|
230
236
|
const modeText = activeSettings?.activeTurnMessageMode ?? 'queue';
|
|
231
237
|
const switchMsg = locale === 'zh'
|
|
232
238
|
? `🔄 **已切换至后端: ${targetDesc.name}** (\`${targetDesc.id}\`)\n\n` +
|
|
233
|
-
`• **引擎类型**: ${
|
|
234
|
-
|
|
239
|
+
`• **引擎类型**: ${targetDesc.name}\n` +
|
|
240
|
+
(targetDesc.account ? `• **绑定账号**: \`${targetDesc.account}\`\n` : '') +
|
|
235
241
|
`• **会话状态**: ${saved?.threadId ? `已恢复历史会话 (\`${saved.threadId.slice(0, 16)}…\`)\n• **工作目录**: \`${saved.cwd ?? this.config.defaultCwd}\`` : '新会话就绪 (发送消息将开启新会话)'}\n` +
|
|
236
242
|
`• **记忆配置**: 模型 ${modelText} | 思考深度 \`${effortText}\` | 插话模式 \`${modeText}\`\n` +
|
|
237
|
-
`•
|
|
238
|
-
`⚡ **当前 Telegram Bot 已完全切换为 ${isCodex ? 'OpenAI Codex' : 'Google Antigravity'} 交互人格与执行引擎!**`
|
|
243
|
+
`• **操作**: \`/setup\` · \`/models\` · \`/threads\``
|
|
239
244
|
: `🔄 **Switched to Backend: ${targetDesc.name}** (\`${targetDesc.id}\`)\n\n` +
|
|
240
|
-
`• **Engine**: ${
|
|
241
|
-
`• **Account**: \`${targetDesc.account
|
|
245
|
+
`• **Engine**: ${targetDesc.name}\n` +
|
|
246
|
+
(targetDesc.account ? `• **Account**: \`${targetDesc.account}\`\n` : '') +
|
|
242
247
|
`• **Thread**: ${saved?.threadId ? `Restored (\`${saved.threadId.slice(0, 16)}…\`)\n• **Directory**: \`${saved.cwd ?? this.config.defaultCwd}\`` : 'Ready (next prompt starts new thread)'}\n` +
|
|
243
248
|
`• **Restored Settings**: Model ${modelText} | Effort \`${effortText}\` | Message Mode \`${modeText}\`\n\n` +
|
|
244
|
-
|
|
249
|
+
`• **Controls**: \`/setup\` · \`/models\` · \`/threads\``;
|
|
245
250
|
try {
|
|
246
251
|
await this.sendMessage(scopeId, switchMsg);
|
|
247
252
|
}
|
|
@@ -251,7 +256,7 @@ export class UnifiedChannelOrchestrator {
|
|
|
251
256
|
try {
|
|
252
257
|
if (scopeId.startsWith(BRIDGE_SCOPE_TELEGRAM_PREFIX)) {
|
|
253
258
|
const target = parseTelegramTargetFromBridgeScope(scopeId);
|
|
254
|
-
const cmds = isCodex ? getTelegramCommands(locale) : getAntigravityTelegramCommands(locale);
|
|
259
|
+
const cmds = targetDesc.engineType === 'dsh' ? getDshTelegramCommands(locale) : isCodex ? getTelegramCommands(locale) : getAntigravityTelegramCommands(locale);
|
|
255
260
|
await this.bot.setChatCommands(target.chatId, cmds);
|
|
256
261
|
}
|
|
257
262
|
}
|
|
@@ -302,7 +307,8 @@ export class UnifiedChannelOrchestrator {
|
|
|
302
307
|
});
|
|
303
308
|
// Requeue any interrupted turns from previous crash/restart
|
|
304
309
|
try {
|
|
305
|
-
const
|
|
310
|
+
const scopes = new Set(this.store.listQueuedTurnInputs().filter(input => this.ownsScope(input.scopeId)).map(input => input.scopeId));
|
|
311
|
+
const requeued = [...scopes].reduce((count, scopeId) => count + this.store.requeueInterruptedQueuedTurnInputs(scopeId), 0);
|
|
306
312
|
if (requeued > 0) {
|
|
307
313
|
this.logger.info('orchestrator.requeued_interrupted_turns', { count: requeued });
|
|
308
314
|
}
|
|
@@ -317,6 +323,8 @@ export class UnifiedChannelOrchestrator {
|
|
|
317
323
|
backendIds.add(desc.adapter.id);
|
|
318
324
|
}
|
|
319
325
|
const activePreviews = this.store.listActiveTurnPreviews().filter((p) => {
|
|
326
|
+
if (!this.ownsScope(p.scopeId))
|
|
327
|
+
return false;
|
|
320
328
|
for (const bId of backendIds) {
|
|
321
329
|
if (p.turnId.startsWith(`${bId}_`))
|
|
322
330
|
return true;
|
|
@@ -352,6 +360,7 @@ export class UnifiedChannelOrchestrator {
|
|
|
352
360
|
? (locale === 'zh' ? 'FoxClaw 服务重启中断,请在当前会话中从中断处继续完成刚才的工作。' : 'FoxClaw restarted while previous turn was running. Please resume and complete the work from where it left off.')
|
|
353
361
|
: (locale === 'zh' ? '请继续完成上一步未完成的任务。' : 'Please continue and complete the unfinished task from previous step.');
|
|
354
362
|
const resumeTimer = setTimeout(() => {
|
|
363
|
+
this.recoveryTimers.delete(resumeTimer);
|
|
355
364
|
this.startPromptTurn(fakeEvent, resumePrompt, locale, {
|
|
356
365
|
reuseMessageId: prev.messageId,
|
|
357
366
|
forcedThreadId: prev.threadId || undefined,
|
|
@@ -362,11 +371,14 @@ export class UnifiedChannelOrchestrator {
|
|
|
362
371
|
});
|
|
363
372
|
});
|
|
364
373
|
}, 1500);
|
|
374
|
+
this.recoveryTimers.add(resumeTimer);
|
|
365
375
|
resumeTimer?.unref?.();
|
|
366
376
|
}
|
|
367
377
|
// Check for queued turns on scopes without active previews
|
|
368
378
|
const queuedInputs = this.store.listQueuedTurnInputs();
|
|
369
379
|
for (const q of queuedInputs) {
|
|
380
|
+
if (!this.ownsScope(q.scopeId))
|
|
381
|
+
continue;
|
|
370
382
|
if (!recoveredScopes.has(q.scopeId) && !this.activeTurns.has(q.scopeId)) {
|
|
371
383
|
recoveredScopes.add(q.scopeId);
|
|
372
384
|
const locale = this.store.getChatSettings(q.scopeId)?.locale || 'zh';
|
|
@@ -383,10 +395,12 @@ export class UnifiedChannelOrchestrator {
|
|
|
383
395
|
replyToBot: false,
|
|
384
396
|
};
|
|
385
397
|
const drainTimer = setTimeout(() => {
|
|
398
|
+
this.recoveryTimers.delete(drainTimer);
|
|
386
399
|
this.drainNextQueuedTurn(fakeEvent, locale).catch((err) => {
|
|
387
400
|
this.logger.warn('orchestrator.auto_drain_failed', { scopeId: q.scopeId, error: String(err) });
|
|
388
401
|
});
|
|
389
402
|
}, 2000);
|
|
403
|
+
this.recoveryTimers.add(drainTimer);
|
|
390
404
|
drainTimer?.unref?.();
|
|
391
405
|
}
|
|
392
406
|
}
|
|
@@ -397,11 +411,18 @@ export class UnifiedChannelOrchestrator {
|
|
|
397
411
|
}
|
|
398
412
|
async stop() {
|
|
399
413
|
this.bot.stop();
|
|
414
|
+
for (const timer of this.stalePanelDeleteTimers.values())
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
this.stalePanelDeleteTimers.clear();
|
|
417
|
+
for (const timer of this.recoveryTimers)
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
this.recoveryTimers.clear();
|
|
400
420
|
for (const [scopeId, turn] of this.activeTurns.entries()) {
|
|
401
421
|
if (turn.flushTimer)
|
|
402
422
|
clearTimeout(turn.flushTimer);
|
|
403
423
|
if (turn.typingTimer)
|
|
404
424
|
clearInterval(turn.typingTimer);
|
|
425
|
+
turn.suppressQueueDrain = true;
|
|
405
426
|
turn.execution.cancel();
|
|
406
427
|
this.activeTurns.delete(scopeId);
|
|
407
428
|
}
|
|
@@ -656,12 +677,12 @@ export class UnifiedChannelOrchestrator {
|
|
|
656
677
|
let effectivePrompt = prompt;
|
|
657
678
|
let stagedAttachments;
|
|
658
679
|
if (event.attachments && event.attachments.length > 0) {
|
|
659
|
-
const nonDocJson = event.attachments.filter((a) => !(a.kind === 'document' && (a.fileName?.endsWith('.json') || a.mimeType?.includes('json'))));
|
|
680
|
+
const nonDocJson = this.getAdapterForScope(scopeId).id === 'dsh' ? event.attachments : event.attachments.filter((a) => !(a.kind === 'document' && (a.fileName?.endsWith('.json') || a.mimeType?.includes('json'))));
|
|
660
681
|
if (nonDocJson.length > 0) {
|
|
661
682
|
const staged = await stageInboundAttachments(this.messaging, cwd, threadId, nonDocJson, this.logger);
|
|
662
683
|
if (staged.length > 0) {
|
|
663
684
|
stagedAttachments = staged;
|
|
664
|
-
effectivePrompt = buildAttachmentPrompt(prompt, staged);
|
|
685
|
+
effectivePrompt = this.getAdapterForScope(scopeId).id === 'dsh' ? prompt : buildAttachmentPrompt(prompt, staged);
|
|
665
686
|
}
|
|
666
687
|
}
|
|
667
688
|
}
|
|
@@ -671,7 +692,11 @@ export class UnifiedChannelOrchestrator {
|
|
|
671
692
|
if (mode === 'steer') {
|
|
672
693
|
const active = this.activeTurns.get(scopeId);
|
|
673
694
|
if (active) {
|
|
695
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
696
|
+
active.suppressQueueDrain = true;
|
|
674
697
|
active.execution.cancel();
|
|
698
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
699
|
+
await active.execution.waitForResult();
|
|
675
700
|
if (active.typingTimer)
|
|
676
701
|
clearInterval(active.typingTimer);
|
|
677
702
|
if (active.flushTimer)
|
|
@@ -717,6 +742,8 @@ export class UnifiedChannelOrchestrator {
|
|
|
717
742
|
}
|
|
718
743
|
async drainNextQueuedTurn(event, locale) {
|
|
719
744
|
const scopeId = event.scopeId;
|
|
745
|
+
if (this.activeTurns.has(scopeId))
|
|
746
|
+
return;
|
|
720
747
|
try {
|
|
721
748
|
const nextQueued = this.store.peekQueuedTurnInput(scopeId);
|
|
722
749
|
if (!nextQueued)
|
|
@@ -753,7 +780,7 @@ export class UnifiedChannelOrchestrator {
|
|
|
753
780
|
const threadId = options?.forcedThreadId ?? binding?.threadId ?? null;
|
|
754
781
|
const model = settings?.model || 'default';
|
|
755
782
|
const isBoost = settings?.serviceTier === 'boost';
|
|
756
|
-
const effort = isBoost ? 'high' : (settings?.reasoningEffort ?? 'high');
|
|
783
|
+
const effort = isBoost ? 'high' : (settings?.reasoningEffort ?? (adapter.id === 'dsh' ? null : 'high'));
|
|
757
784
|
const effectivePrompt = isBoost && !prompt.startsWith('[Boost Mode:')
|
|
758
785
|
? `[Boost Mode: Proceed with deep thinking, strategic planning, multiple perspectives, and rigorous verification.]\n\n${prompt}`
|
|
759
786
|
: prompt;
|
|
@@ -937,7 +964,8 @@ export class UnifiedChannelOrchestrator {
|
|
|
937
964
|
});
|
|
938
965
|
const chunks = combineSummaryAndResponse(foldedTools, finalText || '(无输出 / No output)');
|
|
939
966
|
await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
|
|
940
|
-
|
|
967
|
+
if (!activeTurn.suppressQueueDrain)
|
|
968
|
+
await this.drainNextQueuedTurn(event, locale).catch(() => { });
|
|
941
969
|
return;
|
|
942
970
|
}
|
|
943
971
|
if (res.status === 'ERROR') {
|
|
@@ -1004,15 +1032,18 @@ export class UnifiedChannelOrchestrator {
|
|
|
1004
1032
|
: '';
|
|
1005
1033
|
const chunks = combineSummaryAndResponse(foldedTools, finalText + warningNote);
|
|
1006
1034
|
await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
|
|
1007
|
-
|
|
1035
|
+
if (!activeTurn.suppressQueueDrain)
|
|
1036
|
+
await this.drainNextQueuedTurn(event, locale).catch(() => { });
|
|
1008
1037
|
return;
|
|
1009
1038
|
}
|
|
1010
1039
|
await this.safeDeliverMessage(scopeId, activeTurn.messageId, `❌ **${adapter.name} 错误**:\n\`\`\`\n${errorText}\n\`\`\``);
|
|
1011
|
-
|
|
1040
|
+
if (!activeTurn.suppressQueueDrain)
|
|
1041
|
+
await this.drainNextQueuedTurn(event, locale).catch(() => { });
|
|
1012
1042
|
return;
|
|
1013
1043
|
}
|
|
1014
1044
|
await this.safeDeliverMessage(scopeId, activeTurn.messageId, activeTurn.accumulatedText || '⚠️ 执行结束');
|
|
1015
|
-
|
|
1045
|
+
if (!activeTurn.suppressQueueDrain)
|
|
1046
|
+
await this.drainNextQueuedTurn(event, locale).catch(() => { });
|
|
1016
1047
|
}
|
|
1017
1048
|
catch (fatalErr) {
|
|
1018
1049
|
this.logger.error('orchestrator.turn_result_unhandled', { error: String(fatalErr) });
|
|
@@ -1075,11 +1106,13 @@ export class UnifiedChannelOrchestrator {
|
|
|
1075
1106
|
`\n\n⚠️ <i>(注意:任务执行中途异常中断: ${escapeTelegramHtml(err.message.slice(0, 120))})</i>`;
|
|
1076
1107
|
const chunks = combineSummaryAndResponse(foldedTools, fullAnswer);
|
|
1077
1108
|
await this.safeDeliverChunks(scopeId, activeTurn.messageId, chunks);
|
|
1078
|
-
|
|
1109
|
+
if (!activeTurn.suppressQueueDrain)
|
|
1110
|
+
await this.drainNextQueuedTurn(event, locale).catch(() => { });
|
|
1079
1111
|
return;
|
|
1080
1112
|
}
|
|
1081
1113
|
await this.safeDeliverMessage(scopeId, activeTurn.messageId, `❌ **执行异常**:\n\`\`\`\n${err.message}\n\`\`\``);
|
|
1082
|
-
|
|
1114
|
+
if (!activeTurn.suppressQueueDrain)
|
|
1115
|
+
await this.drainNextQueuedTurn(event, locale).catch(() => { });
|
|
1083
1116
|
}
|
|
1084
1117
|
catch (fatalErr) {
|
|
1085
1118
|
this.logger.error('orchestrator.turn_error_unhandled', { error: String(fatalErr) });
|
|
@@ -1168,7 +1201,11 @@ export class UnifiedChannelOrchestrator {
|
|
|
1168
1201
|
}
|
|
1169
1202
|
const active = this.activeTurns.get(scopeId);
|
|
1170
1203
|
if (active) {
|
|
1204
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
1205
|
+
active.suppressQueueDrain = true;
|
|
1171
1206
|
active.execution.cancel();
|
|
1207
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
1208
|
+
await active.execution.waitForResult();
|
|
1172
1209
|
if (active.typingTimer)
|
|
1173
1210
|
clearInterval(active.typingTimer);
|
|
1174
1211
|
if (active.flushTimer)
|
|
@@ -1184,7 +1221,11 @@ export class UnifiedChannelOrchestrator {
|
|
|
1184
1221
|
const active = this.activeTurns.get(scopeId);
|
|
1185
1222
|
const queuedCount = this.store.countQueuedTurnInputs(scopeId);
|
|
1186
1223
|
if (active) {
|
|
1224
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
1225
|
+
active.suppressQueueDrain = true;
|
|
1187
1226
|
active.execution.cancel();
|
|
1227
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
1228
|
+
await active.execution.waitForResult();
|
|
1188
1229
|
if (active.flushTimer)
|
|
1189
1230
|
clearTimeout(active.flushTimer);
|
|
1190
1231
|
if (active.typingTimer)
|
|
@@ -1238,7 +1279,11 @@ export class UnifiedChannelOrchestrator {
|
|
|
1238
1279
|
this.store.cancelQueuedTurnInputs(scopeId);
|
|
1239
1280
|
const active = this.activeTurns.get(scopeId);
|
|
1240
1281
|
if (active) {
|
|
1282
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
1283
|
+
active.suppressQueueDrain = true;
|
|
1241
1284
|
active.execution.cancel();
|
|
1285
|
+
if (this.getAdapterForScope(scopeId).id === 'dsh')
|
|
1286
|
+
await active.execution.waitForResult();
|
|
1242
1287
|
if (active.typingTimer)
|
|
1243
1288
|
clearInterval(active.typingTimer);
|
|
1244
1289
|
if (active.flushTimer)
|
|
@@ -1264,12 +1309,15 @@ export class UnifiedChannelOrchestrator {
|
|
|
1264
1309
|
const tokenLine = formatTokenUsageSummary(totalUsage, locale);
|
|
1265
1310
|
const allUsages = this.store.getAllBackendTokenUsages();
|
|
1266
1311
|
const breakdown = formatBackendTokenUsageBreakdown(allUsages);
|
|
1312
|
+
const usageText = backendDesc.engineType === 'dsh'
|
|
1313
|
+
? (locale === 'zh' ? '• **Token 消耗**: DSH ACP 未提供逐轮统计\n' : '• **Token usage**: Per-turn counts are not exposed by DSH ACP\n')
|
|
1314
|
+
: `${tokenLine}${breakdown}\n`;
|
|
1267
1315
|
const text = locale === 'zh'
|
|
1268
1316
|
? `📊 **${adapter.name} 运行状态**\n\n` +
|
|
1269
1317
|
`• **当前引擎**: \`${backendDesc.name}\` (\`${backendDesc.id}\`)${backendDesc.account ? ` · \`${backendDesc.account}\`` : ''}\n` +
|
|
1270
1318
|
`• **状态**: ${isBusy ? '⚡ 正在执行任务' : '💤 空闲'}\n` +
|
|
1271
1319
|
`• **当前模型**: \`${settings?.model || '默认'}\`\n` +
|
|
1272
|
-
|
|
1320
|
+
usageText +
|
|
1273
1321
|
`• **绑定会话**: \`${binding?.threadId || '(新会话)'}\`\n` +
|
|
1274
1322
|
`• **工作目录**: \`${binding?.cwd || this.config.defaultCwd}\`` +
|
|
1275
1323
|
(customStatus ? `\n${customStatus}` : '')
|
|
@@ -1277,14 +1325,14 @@ export class UnifiedChannelOrchestrator {
|
|
|
1277
1325
|
`• **Engine**: \`${backendDesc.name}\` (\`${backendDesc.id}\`)${backendDesc.account ? ` · \`${backendDesc.account}\`` : ''}\n` +
|
|
1278
1326
|
`• **State**: ${isBusy ? '⚡ Executing' : '💤 Idle'}\n` +
|
|
1279
1327
|
`• **Model**: \`${settings?.model || 'default'}\`\n` +
|
|
1280
|
-
|
|
1328
|
+
usageText +
|
|
1281
1329
|
`• **Thread**: \`${binding?.threadId || '(new)'}\`\n` +
|
|
1282
1330
|
`• **Directory**: \`${binding?.cwd || this.config.defaultCwd}\`` +
|
|
1283
1331
|
(customStatus ? `\n${customStatus}` : '');
|
|
1284
1332
|
const keyboard = [
|
|
1285
1333
|
[
|
|
1286
1334
|
{ text: '⚙️ 控制面板', callback_data: 'engine:setup:main' },
|
|
1287
|
-
{ text: '🧠 切换模型', callback_data: 'engine:setup:models' },
|
|
1335
|
+
{ text: '🧠 切换模型', callback_data: backendDesc.engineType === 'dsh' ? 'dsh:models' : 'engine:setup:models' },
|
|
1288
1336
|
],
|
|
1289
1337
|
];
|
|
1290
1338
|
const allBackends = await this.listBackends();
|
|
@@ -1297,6 +1345,8 @@ export class UnifiedChannelOrchestrator {
|
|
|
1297
1345
|
this.scheduleStalePanelDeletion(scopeId, msgId);
|
|
1298
1346
|
}
|
|
1299
1347
|
async sendSetupMenu(scopeId, locale, editMessageId) {
|
|
1348
|
+
if (await this.customUi?.renderSetupMenu?.(scopeId, locale, editMessageId))
|
|
1349
|
+
return;
|
|
1300
1350
|
const settings = this.store.getChatSettings(scopeId);
|
|
1301
1351
|
const mode = settings?.activeTurnMessageMode ?? 'queue';
|
|
1302
1352
|
const isBoost = settings?.serviceTier === 'boost';
|
|
@@ -1322,7 +1372,7 @@ export class UnifiedChannelOrchestrator {
|
|
|
1322
1372
|
`Select setting to configure:`;
|
|
1323
1373
|
const keyboard = [];
|
|
1324
1374
|
// 1. Model choices (Codex style)
|
|
1325
|
-
const models = await adapter.listModels();
|
|
1375
|
+
const models = await adapter.listModels(scopeId);
|
|
1326
1376
|
const modelButtons = [
|
|
1327
1377
|
{
|
|
1328
1378
|
text: `${!currentModel || currentModel === 'default' ? '• ' : ''}${locale === 'zh' ? '默认模型' : 'Default'}`,
|
|
@@ -1396,10 +1446,12 @@ export class UnifiedChannelOrchestrator {
|
|
|
1396
1446
|
}
|
|
1397
1447
|
}
|
|
1398
1448
|
async sendModelsMenu(scopeId, locale, editMessageId) {
|
|
1449
|
+
if (await this.customUi?.renderModelsMenu?.(scopeId, locale, editMessageId))
|
|
1450
|
+
return;
|
|
1399
1451
|
const settings = this.store.getChatSettings(scopeId);
|
|
1400
1452
|
const currentModel = settings?.model;
|
|
1401
1453
|
const adapter = this.getAdapterForScope(scopeId);
|
|
1402
|
-
const models = await adapter.listModels();
|
|
1454
|
+
const models = await adapter.listModels(scopeId);
|
|
1403
1455
|
const keyboard = [];
|
|
1404
1456
|
for (let i = 0; i < models.length; i += 2) {
|
|
1405
1457
|
const row = [];
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { SessionConfigSelectOption } from '@agentclientprotocol/sdk';
|
|
2
|
+
import type { IEngineAdapter, EngineModel, EngineTurnRequest, EngineTurnExecution } from '../core/engine_spi.js';
|
|
3
|
+
import type { AccessPresetValue } from '../types.js';
|
|
4
|
+
import { DshClient, type SessionConfigOption } from './client.js';
|
|
5
|
+
export declare function optionChoices(option?: SessionConfigOption): SessionConfigSelectOption[];
|
|
6
|
+
export declare function modelId(value: string): string;
|
|
7
|
+
interface ScopePreferences {
|
|
8
|
+
cwd: string;
|
|
9
|
+
threadId: string | null;
|
|
10
|
+
model: string | null;
|
|
11
|
+
effort: string | null;
|
|
12
|
+
access: AccessPresetValue;
|
|
13
|
+
}
|
|
14
|
+
interface ScopeSession {
|
|
15
|
+
client: DshClient;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
defaultModel: string;
|
|
19
|
+
defaultEffort: string | null;
|
|
20
|
+
}
|
|
21
|
+
/** Persistent DSH sessions over the shipped ACP profile. */
|
|
22
|
+
export declare class DshEngineAdapter implements IEngineAdapter {
|
|
23
|
+
private readonly options;
|
|
24
|
+
readonly id = "dsh";
|
|
25
|
+
readonly name = "DeepSeek Harness (DSH)";
|
|
26
|
+
private readonly clients;
|
|
27
|
+
private readonly sessions;
|
|
28
|
+
private readonly pending;
|
|
29
|
+
private readonly busy;
|
|
30
|
+
private closed;
|
|
31
|
+
constructor(options: {
|
|
32
|
+
createClient: (scopeId: string) => DshClient;
|
|
33
|
+
preferences: (scopeId: string) => ScopePreferences;
|
|
34
|
+
bind: (scopeId: string, sessionId: string, cwd: string) => void;
|
|
35
|
+
});
|
|
36
|
+
sessionForScope(scopeId: string): Promise<ScopeSession>;
|
|
37
|
+
private openSession;
|
|
38
|
+
listModels(scopeId?: string): Promise<EngineModel[]>;
|
|
39
|
+
selectModel(scopeId: string, id: string | null): Promise<void>;
|
|
40
|
+
listEfforts(scopeId: string): Promise<SessionConfigSelectOption[]>;
|
|
41
|
+
selectEffort(scopeId: string, effort: string | null): Promise<void>;
|
|
42
|
+
setAccess(scopeId: string, access: AccessPresetValue): Promise<void>;
|
|
43
|
+
listSessions(scopeId: string, cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse>;
|
|
44
|
+
isBusy(scopeId: string): boolean;
|
|
45
|
+
executeTurn(request: EngineTurnRequest): EngineTurnExecution;
|
|
46
|
+
stop(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { buildAttachmentPrompt, isNativeImageAttachment } from '../telegram/media.js';
|
|
4
|
+
export function optionChoices(option) {
|
|
5
|
+
if (option?.type !== 'select')
|
|
6
|
+
return [];
|
|
7
|
+
return option.options.flatMap(item => 'options' in item ? item.options : [item]);
|
|
8
|
+
}
|
|
9
|
+
export function modelId(value) {
|
|
10
|
+
const route = JSON.parse(value);
|
|
11
|
+
if (!Array.isArray(route) || route.length !== 2 || !route.every(part => typeof part === 'string' && part.length > 0))
|
|
12
|
+
throw new Error('Invalid DSH model route');
|
|
13
|
+
return `${route[0]}/${route[1]}`;
|
|
14
|
+
}
|
|
15
|
+
/** Persistent DSH sessions over the shipped ACP profile. */
|
|
16
|
+
export class DshEngineAdapter {
|
|
17
|
+
options;
|
|
18
|
+
id = 'dsh';
|
|
19
|
+
name = 'DeepSeek Harness (DSH)';
|
|
20
|
+
clients = new Map();
|
|
21
|
+
sessions = new Map();
|
|
22
|
+
pending = new Map();
|
|
23
|
+
busy = new Set();
|
|
24
|
+
closed = false;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
this.options = options;
|
|
27
|
+
}
|
|
28
|
+
async sessionForScope(scopeId) {
|
|
29
|
+
if (this.closed)
|
|
30
|
+
throw new Error('DSH backend is stopped');
|
|
31
|
+
const existing = this.pending.get(scopeId);
|
|
32
|
+
if (existing)
|
|
33
|
+
return existing;
|
|
34
|
+
const task = this.openSession(scopeId).finally(() => this.pending.delete(scopeId));
|
|
35
|
+
this.pending.set(scopeId, task);
|
|
36
|
+
return task;
|
|
37
|
+
}
|
|
38
|
+
async openSession(scopeId) {
|
|
39
|
+
const prefs = this.options.preferences(scopeId);
|
|
40
|
+
let client = this.clients.get(scopeId);
|
|
41
|
+
if (!client) {
|
|
42
|
+
client = this.options.createClient(scopeId);
|
|
43
|
+
this.clients.set(scopeId, client);
|
|
44
|
+
}
|
|
45
|
+
await client.setAccess(prefs.access);
|
|
46
|
+
const previous = this.sessions.get(scopeId);
|
|
47
|
+
if (client.connected && previous && previous.sessionId === prefs.threadId && previous.cwd === prefs.cwd)
|
|
48
|
+
return previous;
|
|
49
|
+
if (previous && this.busy.has(scopeId))
|
|
50
|
+
throw new Error('DSH session is busy');
|
|
51
|
+
if (previous && client.connected)
|
|
52
|
+
await client.closeSession(previous.sessionId);
|
|
53
|
+
const sessionId = await client.session(prefs.cwd, prefs.threadId);
|
|
54
|
+
const config = client.configOptions(sessionId);
|
|
55
|
+
const model = config.find(item => item.id === 'model');
|
|
56
|
+
const effort = config.find(item => item.id === 'reasoning_effort');
|
|
57
|
+
const state = { client, sessionId, cwd: prefs.cwd, defaultModel: String(model?.currentValue ?? ''), defaultEffort: effort?.type === 'select' ? effort.currentValue : null };
|
|
58
|
+
this.sessions.set(scopeId, state);
|
|
59
|
+
this.options.bind(scopeId, sessionId, prefs.cwd);
|
|
60
|
+
return state;
|
|
61
|
+
}
|
|
62
|
+
async listModels(scopeId = 'dsh:catalog') {
|
|
63
|
+
const state = await this.sessionForScope(scopeId);
|
|
64
|
+
return optionChoices(state.client.configOptions(state.sessionId).find(option => option.id === 'model')).map(choice => ({ id: modelId(choice.value), name: choice.name, description: choice.description ?? undefined, isDefault: choice.value === state.defaultModel }));
|
|
65
|
+
}
|
|
66
|
+
async selectModel(scopeId, id) {
|
|
67
|
+
const state = await this.sessionForScope(scopeId);
|
|
68
|
+
const model = state.client.configOptions(state.sessionId).find(option => option.id === 'model');
|
|
69
|
+
const choice = optionChoices(model).find(choice => id === null ? choice.value === state.defaultModel : modelId(choice.value) === id);
|
|
70
|
+
if (!choice)
|
|
71
|
+
throw new Error(`DSH model unavailable: ${id}`);
|
|
72
|
+
await state.client.setConfig(state.sessionId, 'model', choice.value);
|
|
73
|
+
const effort = state.client.configOptions(state.sessionId).find(option => option.id === 'reasoning_effort');
|
|
74
|
+
state.defaultEffort = effort?.type === 'select' ? effort.currentValue : null;
|
|
75
|
+
}
|
|
76
|
+
async listEfforts(scopeId) {
|
|
77
|
+
const state = await this.sessionForScope(scopeId);
|
|
78
|
+
return optionChoices(state.client.configOptions(state.sessionId).find(option => option.id === 'reasoning_effort'));
|
|
79
|
+
}
|
|
80
|
+
async selectEffort(scopeId, effort) {
|
|
81
|
+
const state = await this.sessionForScope(scopeId);
|
|
82
|
+
const choices = await this.listEfforts(scopeId);
|
|
83
|
+
if (choices.length === 0 && effort === null)
|
|
84
|
+
return;
|
|
85
|
+
const value = effort ?? state.defaultEffort;
|
|
86
|
+
if (value === null || !choices.some(choice => choice.value === value))
|
|
87
|
+
throw new Error(`DSH reasoning effort unavailable: ${effort}`);
|
|
88
|
+
await state.client.setConfig(state.sessionId, 'reasoning_effort', value);
|
|
89
|
+
}
|
|
90
|
+
async setAccess(scopeId, access) {
|
|
91
|
+
const state = await this.sessionForScope(scopeId);
|
|
92
|
+
await state.client.setAccess(access);
|
|
93
|
+
}
|
|
94
|
+
async listSessions(scopeId, cursor) { return (await this.sessionForScope(scopeId)).client.listSessions(cursor); }
|
|
95
|
+
isBusy(scopeId) { return this.busy.has(scopeId); }
|
|
96
|
+
executeTurn(request) {
|
|
97
|
+
const emitter = new EventEmitter();
|
|
98
|
+
let cancelled = false;
|
|
99
|
+
let state;
|
|
100
|
+
let finished = false;
|
|
101
|
+
let ownsSlot = false;
|
|
102
|
+
let result = null;
|
|
103
|
+
let resolveResult;
|
|
104
|
+
const promise = new Promise(resolve => { resolveResult = resolve; });
|
|
105
|
+
let response = '';
|
|
106
|
+
let finalMessage = '';
|
|
107
|
+
let finalMessageId;
|
|
108
|
+
const tools = new Map();
|
|
109
|
+
const onUpdate = (notification) => {
|
|
110
|
+
if (notification.sessionId !== state?.sessionId)
|
|
111
|
+
return;
|
|
112
|
+
const update = notification.update;
|
|
113
|
+
if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
|
|
114
|
+
if (update.messageId && update.messageId !== finalMessageId) {
|
|
115
|
+
finalMessageId = update.messageId;
|
|
116
|
+
finalMessage = '';
|
|
117
|
+
}
|
|
118
|
+
finalMessage += update.content.text;
|
|
119
|
+
response += update.content.text;
|
|
120
|
+
emitter.emit('delta', update.content.text);
|
|
121
|
+
}
|
|
122
|
+
else if (update.sessionUpdate === 'tool_call') {
|
|
123
|
+
tools.set(update.toolCallId, update.title);
|
|
124
|
+
emitter.emit('tool', { name: update.title, args: update.rawInput == null ? undefined : JSON.stringify(update.rawInput), status: 'running' });
|
|
125
|
+
}
|
|
126
|
+
else if (update.sessionUpdate === 'tool_call_update') {
|
|
127
|
+
emitter.emit('tool', { name: tools.get(update.toolCallId) ?? update.toolCallId, status: update.status === 'failed' ? 'failed' : update.status === 'completed' ? 'completed' : 'running' });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const run = async () => {
|
|
131
|
+
try {
|
|
132
|
+
if (this.busy.has(request.scopeId))
|
|
133
|
+
throw new Error('DSH already has an active turn in this scope');
|
|
134
|
+
state = await this.sessionForScope(request.scopeId);
|
|
135
|
+
if (cancelled)
|
|
136
|
+
return;
|
|
137
|
+
if (this.busy.has(request.scopeId))
|
|
138
|
+
throw new Error('DSH already has an active turn in this scope');
|
|
139
|
+
this.busy.add(request.scopeId);
|
|
140
|
+
ownsSlot = true;
|
|
141
|
+
emitter.emit('conversation', state.sessionId);
|
|
142
|
+
await this.selectModel(request.scopeId, request.model === 'default' ? null : request.model);
|
|
143
|
+
await this.selectEffort(request.scopeId, request.effort ?? null);
|
|
144
|
+
if (cancelled)
|
|
145
|
+
return;
|
|
146
|
+
const attachments = request.stagedAttachments ?? [];
|
|
147
|
+
const images = state.client.imageSupported ? attachments.filter(isNativeImageAttachment) : [];
|
|
148
|
+
const files = attachments.filter(attachment => !images.includes(attachment));
|
|
149
|
+
const content = [{ type: 'text', text: files.length ? buildAttachmentPrompt(request.prompt, files) : request.prompt }];
|
|
150
|
+
for (const attachment of images)
|
|
151
|
+
content.push({ type: 'image', data: (await fs.readFile(attachment.localPath)).toString('base64'), mimeType: attachment.mimeType || 'image/jpeg' });
|
|
152
|
+
state.client.on('update', onUpdate);
|
|
153
|
+
if (cancelled)
|
|
154
|
+
return;
|
|
155
|
+
const value = await state.client.agent.request('session/prompt', { sessionId: state.sessionId, prompt: content });
|
|
156
|
+
result = { kind: 'result', status: cancelled || value.stopReason === 'cancelled' ? 'INTERRUPTED' : 'SUCCESS', response: finalMessage || response, conversationId: state.sessionId };
|
|
157
|
+
}
|
|
158
|
+
catch (error) {
|
|
159
|
+
result = { kind: 'result', status: cancelled ? 'INTERRUPTED' : 'ERROR', response: finalMessage || response, error: error instanceof Error ? error.message : String(error), conversationId: state?.sessionId ?? request.threadId };
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
state?.client.off('update', onUpdate);
|
|
163
|
+
if (ownsSlot)
|
|
164
|
+
this.busy.delete(request.scopeId);
|
|
165
|
+
result ??= { kind: 'result', status: 'INTERRUPTED', response: finalMessage || response, conversationId: state?.sessionId ?? request.threadId };
|
|
166
|
+
finished = true;
|
|
167
|
+
resolveResult(result);
|
|
168
|
+
emitter.emit('result', result);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
queueMicrotask(() => { void run(); });
|
|
172
|
+
return {
|
|
173
|
+
cancel: () => {
|
|
174
|
+
if (finished || cancelled)
|
|
175
|
+
return;
|
|
176
|
+
cancelled = true;
|
|
177
|
+
state?.client.cancelPermissions?.();
|
|
178
|
+
if (state?.client.connected)
|
|
179
|
+
void state.client.agent.notify('session/cancel', { sessionId: state.sessionId }).catch(() => { });
|
|
180
|
+
},
|
|
181
|
+
waitForResult: () => promise,
|
|
182
|
+
on: (event, listener) => { emitter.on(event, listener); },
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async stop() {
|
|
186
|
+
this.closed = true;
|
|
187
|
+
await Promise.allSettled([...this.pending.values()]);
|
|
188
|
+
await Promise.all([...this.clients.values()].map(client => client.stop()));
|
|
189
|
+
this.clients.clear();
|
|
190
|
+
this.sessions.clear();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { type SessionConfigOption, type SessionNotification, type RequestPermissionRequest, type RequestPermissionResponse } from '@agentclientprotocol/sdk';
|
|
3
|
+
import type { Logger } from '../logger.js';
|
|
4
|
+
import type { AccessPresetValue } from '../types.js';
|
|
5
|
+
export interface DshClientOptions {
|
|
6
|
+
cliBin: string;
|
|
7
|
+
sourceDir?: string | null;
|
|
8
|
+
home?: string | null;
|
|
9
|
+
profile: string;
|
|
10
|
+
patches: string[];
|
|
11
|
+
runtimeDir: string;
|
|
12
|
+
startupTimeoutMs: number;
|
|
13
|
+
}
|
|
14
|
+
/** An owned ACP process; protocol stdout stays separate from diagnostics. */
|
|
15
|
+
export declare class DshClient extends EventEmitter {
|
|
16
|
+
readonly options: DshClientOptions;
|
|
17
|
+
readonly scopeId: string;
|
|
18
|
+
private readonly logger;
|
|
19
|
+
private process;
|
|
20
|
+
private connection;
|
|
21
|
+
private starting;
|
|
22
|
+
private stopping;
|
|
23
|
+
private optionsBySession;
|
|
24
|
+
private capabilities;
|
|
25
|
+
private policyPath;
|
|
26
|
+
private policyWrite;
|
|
27
|
+
private stderr;
|
|
28
|
+
permissionHandler?: (request: RequestPermissionRequest, signal: AbortSignal) => Promise<RequestPermissionResponse>;
|
|
29
|
+
cancelPermissions?: () => void;
|
|
30
|
+
constructor(options: DshClientOptions, scopeId: string, logger: Logger);
|
|
31
|
+
setAccess(preset: AccessPresetValue): Promise<void>;
|
|
32
|
+
private writeAccess;
|
|
33
|
+
start(): Promise<void>;
|
|
34
|
+
private launch;
|
|
35
|
+
get connected(): boolean;
|
|
36
|
+
get imageSupported(): boolean;
|
|
37
|
+
get agent(): import("@agentclientprotocol/sdk").ClientContext;
|
|
38
|
+
session(cwd: string, sessionId?: string | null): Promise<string>;
|
|
39
|
+
configOptions(sessionId: string): SessionConfigOption[];
|
|
40
|
+
setConfig(sessionId: string, configId: string, value: string): Promise<void>;
|
|
41
|
+
listSessions(cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse>;
|
|
42
|
+
closeSession(sessionId: string): Promise<void>;
|
|
43
|
+
stop(): Promise<void>;
|
|
44
|
+
private dispose;
|
|
45
|
+
}
|
|
46
|
+
export type { SessionConfigOption, SessionNotification, RequestPermissionRequest, RequestPermissionResponse };
|