@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.
@@ -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
- `• **引擎类型**: ${isCodex ? 'OpenAI Codex (App Server)' : 'Google Antigravity (AGY)'}\n` +
234
- `• **绑定账号**: \`${targetDesc.account || '默认账号'}\`${targetDesc.details ? ` (${targetDesc.details})` : ''}\n` +
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
- `• **专属指令**: \`/models\` (${isCodex ? 'Codex模型' : 'Gemini模型'}), \`/auth\` (${isCodex ? 'OpenAI账号池' : 'Google授权'}), \`/threads\` (${isCodex ? 'Codex历史' : 'AGY历史'})\n\n` +
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**: ${isCodex ? 'OpenAI Codex (App Server)' : 'Google Antigravity (AGY)'}\n` +
241
- `• **Account**: \`${targetDesc.account || 'default'}\`${targetDesc.details ? ` (${targetDesc.details})` : ''}\n` +
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
- `⚡ **Telegram Bot is now fully operated by ${isCodex ? 'OpenAI Codex' : 'Google Antigravity'}!**`;
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 requeued = this.store.requeueInterruptedQueuedTurnInputs();
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
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
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
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
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
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
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
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
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
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
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
- await this.drainNextQueuedTurn(event, locale).catch(() => { });
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
- `${tokenLine}${breakdown}\n` +
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
- `${tokenLine}${breakdown}\n` +
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 };