@foxden-app/foxclaw 0.13.1 → 0.13.2

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 CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.13.2 - 2026-10-06
6
+
7
+ ### 中文
8
+
9
+ - Telegram 后端列表新增“添加后端”,首次支持接入 DSH。本机 CLI、源码路径及官方安装包均可从面板操作,也可发送 `/backend add dsh`。
10
+ - DSH 启动检查通过后保存配置并直接接入本机所有统一 Bot,保留当前任务和会话;明确选择后才切换。安装与配置操作统一归属服务,覆盖跨 Bot 并发、失败、过期按钮及关闭时取消。
11
+
12
+ ### English
13
+
14
+ - Added Telegram backend setup, initially supporting DSH through a local CLI, source path or official package. Open “Add backend” or send `/backend add dsh`.
15
+ - Verify startup before saving configuration and registering DSH on every unified bot without restarting or switching sessions. One service owns provisioning, including cross-bot concurrency, failures, expired actions and shutdown cancellation.
16
+
5
17
  ## 0.13.1 - 2026-10-06
6
18
 
7
19
  ### 中文
@@ -1,4 +1,5 @@
1
1
  import type { ChannelPort } from '../core/channel_port.js';
2
+ import { BackendSetupManager } from '../service/backend_setup.js';
2
3
  import type { AppConfig } from '../config.js';
3
4
  import type { Logger } from '../logger.js';
4
5
  import type { BridgeStore } from '../store/database.js';
@@ -27,6 +28,7 @@ export interface UnifiedBridgeRuntimeStatus {
27
28
  lastError?: string | null | undefined;
28
29
  }
29
30
  export interface UnifiedBridgeCoreOptions {
31
+ backendSetup?: BackendSetupManager | undefined;
30
32
  codexApp?: CodexAppClient | undefined;
31
33
  codexAdapter?: CodexEngineAdapter | undefined;
32
34
  codexCore?: BridgeSessionCore | undefined;
@@ -41,6 +43,9 @@ export interface UnifiedBridgeCoreOptions {
41
43
  ownsScope?: (scopeId: string) => boolean;
42
44
  }
43
45
  export declare class UnifiedBridgeCore {
46
+ private readonly backendSetup?;
47
+ private readonly backendSetupUi?;
48
+ private readonly detachBackendSetup?;
44
49
  private readonly conversationUi;
45
50
  private readonly accountUi;
46
51
  private readonly config;
@@ -1,6 +1,7 @@
1
1
  import { AntigravityConversationUi } from '../antigravity/conversation_ui.js';
2
2
  import { AntigravityAccountUi } from '../antigravity/account_ui.js';
3
3
  import { createCodexBackendUi } from '../codex_app/backend_ui.js';
4
+ import { BackendSetupUi, safeSetupError } from '../service/backend_setup.js';
4
5
  import fsPromises from 'node:fs/promises';
5
6
  import path from 'node:path';
6
7
  import os from 'node:os';
@@ -40,6 +41,9 @@ function formatAccountExpiry(expiryMs, locale, isActive = false) {
40
41
  }
41
42
  import { AGY_MODEL_CALLBACK_PREFIX, AGY_AUTH_CALLBACK_PREFIX, AGY_EFFORT_CALLBACK_PREFIX, AGY_OPEN_CALLBACK_PREFIX, AGY_SETUP_CALLBACK_PREFIX, AGY_WATCH_CALLBACK_PREFIX, } from '../antigravity/ui_callbacks.js';
42
43
  export class UnifiedBridgeCore {
44
+ backendSetup;
45
+ backendSetupUi;
46
+ detachBackendSetup;
43
47
  conversationUi;
44
48
  accountUi;
45
49
  config;
@@ -74,6 +78,7 @@ export class UnifiedBridgeCore {
74
78
  this.codexApp = options?.codexApp;
75
79
  this.defaultBackendId = options?.defaultBackendId ?? 'antigravity';
76
80
  this.selfUpdater = options?.selfUpdater;
81
+ this.backendSetup = options?.backendSetup;
77
82
  this.codexCore = options?.codexCore;
78
83
  if (!this.codexCore && options?.codexApp) {
79
84
  try {
@@ -198,11 +203,50 @@ export class UnifiedBridgeCore {
198
203
  }),
199
204
  backendProvider: () => this.getBackendDescriptors(),
200
205
  messaging: this.messaging,
201
- serviceUi: { handleCustomCommand: (scopeId, cmd, args, locale, event) => this.handleServiceCommand(scopeId, cmd, args, locale, event) },
206
+ serviceUi: {
207
+ handleCustomCommand: (scopeId, cmd, args, locale, event) => this.handleServiceCommand(scopeId, cmd, args, locale, event),
208
+ renderBackendMenuRows: async (_scopeId, locale) => this.backendSetupUi?.rows(locale) ?? [],
209
+ handleCustomInbound: (event, locale) => this.backendSetupUi?.inbound(event, locale) ?? false,
210
+ handleCustomCallback: async (scopeId, data, locale, messageId, event) => {
211
+ if (!this.backendSetupUi)
212
+ return false;
213
+ if (!data.startsWith('backend-setup:'))
214
+ return this.backendSetupUi.callback(scopeId, data, locale, messageId);
215
+ if (event)
216
+ await this.messaging.answerCallback(event.callbackQueryId, '');
217
+ try {
218
+ await this.backendSetupUi.callback(scopeId, data, locale, messageId);
219
+ }
220
+ catch (error) {
221
+ await this.sendMessage(scopeId, `❌ ${safeSetupError(error)}`);
222
+ }
223
+ return true;
224
+ },
225
+ },
202
226
  });
203
227
  const panels = { sendMessage: this.sendMessage.bind(this), editMessage: this.editMessage.bind(this), scheduleStalePanelDeletion: this.scheduleStalePanelDeletion.bind(this) };
204
228
  this.conversationUi = new AntigravityConversationUi(config, store, logger, this.conversations, panels, this.orchestrator);
205
229
  this.accountUi = new AntigravityAccountUi(config, store, logger, this.auth, panels, this.orchestrator);
230
+ if (this.backendSetup) {
231
+ this.backendSetupUi = new BackendSetupUi(this.backendSetup, {
232
+ listBackends: () => this.orchestrator.listBackends(),
233
+ readPathState: scopeId => this.store.getServiceInteraction(scopeId, 'backend-setup:path'),
234
+ writePathState: (scopeId, state) => this.store.setServiceInteraction(scopeId, 'backend-setup:path', state),
235
+ send: async (scopeId, text, keyboard, messageId) => {
236
+ if (messageId)
237
+ await this.editMessage(scopeId, messageId, text, keyboard);
238
+ else
239
+ await this.sendMessage(scopeId, text, keyboard);
240
+ },
241
+ });
242
+ this.detachBackendSetup = this.backendSetup.attach(factory => {
243
+ const backend = factory({ config: this.config, store: this.store, logger: this.logger, messaging: this.messaging });
244
+ if (this.backendDefinitions.some(existing => existing.id === backend.id))
245
+ return;
246
+ this.orchestrator.registerBackend(backend);
247
+ this.backendDefinitions.push(backend);
248
+ });
249
+ }
206
250
  }
207
251
  backendDefinitions;
208
252
  async getBackendDescriptors() {
@@ -325,6 +369,8 @@ export class UnifiedBridgeCore {
325
369
  }
326
370
  async stop() {
327
371
  this.clearSelfUpdateStatusPoll();
372
+ this.detachBackendSetup?.();
373
+ this.backendSetupUi?.stop();
328
374
  try {
329
375
  await this.orchestrator.stop();
330
376
  }
@@ -421,7 +467,7 @@ export class UnifiedBridgeCore {
421
467
  };
422
468
  }
423
469
  isIdleForServiceUpdate() {
424
- return this.orchestrator.isIdleForServiceUpdate();
470
+ return !this.backendSetup?.busy && this.orchestrator.isIdleForServiceUpdate();
425
471
  }
426
472
  async getCurrentAuthLabel() {
427
473
  const codexHome = this.config.codexAuthDir ?? this.config.codexHome ?? path.join(os.homedir(), '.codex');
@@ -703,7 +749,13 @@ export class UnifiedBridgeCore {
703
749
  return false;
704
750
  }
705
751
  async handleServiceCommand(scopeId, cmd, _args, locale, _event) {
752
+ if (await this.backendSetupUi?.command(scopeId, cmd, _args, locale))
753
+ return true;
706
754
  if (cmd.toLowerCase() === 'update') {
755
+ if (this.backendSetup?.busy) {
756
+ await this.sendMessage(scopeId, locale === 'zh' ? '⏳ 正在添加后端,请完成后再升级。' : '⏳ Backend setup is running. Update after it finishes.');
757
+ return true;
758
+ }
707
759
  if (this.selfUpdater) {
708
760
  const status = await this.selfUpdater.readStatus();
709
761
  if (status?.state === 'pending') {
@@ -16,9 +16,10 @@ export interface EngineCustomUiHook {
16
16
  renderModelsMenu?(scopeId: string, locale: AppLocale, messageId?: number): Promise<boolean>;
17
17
  renderCustomStatus?(scopeId: string, locale: AppLocale): Promise<string | null>;
18
18
  renderCustomSetupRows?(scopeId: string, locale: AppLocale): Promise<ChannelInlineKeyboard>;
19
+ renderBackendMenuRows?(scopeId: string, locale: AppLocale): Promise<ChannelInlineKeyboard>;
19
20
  handleCustomCallback?(scopeId: string, data: string, locale: AppLocale, messageId?: number, event?: ChannelCallbackEvent): Promise<boolean>;
20
21
  handleCustomCommand?(scopeId: string, command: string, args: string, locale: AppLocale, event?: ChannelTextEvent): Promise<boolean>;
21
- handleCustomInbound?(event: ChannelTextEvent, locale: AppLocale): Promise<boolean>;
22
+ handleCustomInbound?(event: ChannelTextEvent, locale: AppLocale): boolean | Promise<boolean>;
22
23
  }
23
24
  export interface BackendUi extends EngineCustomUiHook {
24
25
  /** Recognize owned buttons even after the user switches to another backend (e.g. pending approvals). */
@@ -660,6 +660,9 @@ export class UnifiedChannelOrchestrator {
660
660
  event.text.trim() || event.attachments.length ? { kind: 'prompt', text: event.text } : { kind: 'ignore' });
661
661
  if (addressing.kind === 'ignore')
662
662
  return;
663
+ const serviceInbound = this.serviceUi?.handleCustomInbound?.(event, locale);
664
+ if (serviceInbound && await serviceInbound)
665
+ return;
663
666
  if (addressing.kind === 'command') {
664
667
  const name = addressing.command.name.toLowerCase();
665
668
  const args = addressing.command.args.join(' ').trim();
@@ -766,6 +769,8 @@ export class UnifiedChannelOrchestrator {
766
769
  const locale = this.store.getChatSettings(scopeId)?.locale ?? 'zh';
767
770
  const data = event.data || '';
768
771
  const messageId = event.messageId;
772
+ if (this.serviceUi?.handleCustomCallback && await this.serviceUi.handleCustomCallback(scopeId, data, locale, messageId, event))
773
+ return;
769
774
  if (data.startsWith('engine:stop:')) {
770
775
  const status = await this.stopTask(scopeId, data.slice('engine:stop:'.length));
771
776
  await this.messaging.answerCallback(event.callbackQueryId, status ? (locale === 'zh' ? '已停止,排队任务保留' : 'Stopped; queued tasks kept') : (locale === 'zh' ? '此任务已结束' : 'This task has ended'));
@@ -1907,6 +1912,9 @@ export class UnifiedChannelOrchestrator {
1907
1912
  },
1908
1913
  ]);
1909
1914
  });
1915
+ if (this.serviceUi?.renderBackendMenuRows) {
1916
+ keyboard.push(...await this.serviceUi.renderBackendMenuRows(scopeId, locale));
1917
+ }
1910
1918
  keyboard.push([{ text: '◀️ 返回控制面板', callback_data: 'engine:setup:main' }]);
1911
1919
  const text = lines.join('\n');
1912
1920
  if (editMessageId) {
@@ -1947,7 +1955,7 @@ export class UnifiedChannelOrchestrator {
1947
1955
  if (!targetDesc) {
1948
1956
  await this.sendMessage(scopeId, locale === 'zh'
1949
1957
  ? `❌ 未找到后端: \`${argsString}\`。可用列表: ${backends.map((b) => `\`${b.id}\``).join(', ')}`
1950
- : `❌ Backend not found: \`${argsString}\`. Available: ${backends.map((b) => `\`${b.id}\``).join(', ')}`);
1958
+ : `❌ Backend not found: \`${argsString}\`. Available: ${backends.map((b) => `\`${b.id}\``).join(', ')}`, await this.serviceUi?.renderBackendMenuRows?.(scopeId, locale));
1951
1959
  return;
1952
1960
  }
1953
1961
  await this.switchBackend(scopeId, targetId, locale);
@@ -30,7 +30,7 @@ export declare class DshClient extends EventEmitter {
30
30
  constructor(options: DshClientOptions, scopeId: string, logger: Logger);
31
31
  setAccess(preset: AccessPresetValue): Promise<void>;
32
32
  private writeAccess;
33
- start(): Promise<void>;
33
+ start(signal?: AbortSignal): Promise<void>;
34
34
  private launch;
35
35
  get connected(): boolean;
36
36
  get imageSupported(): boolean;
@@ -41,13 +41,14 @@ export class DshClient extends EventEmitter {
41
41
  await fs.writeFile(temporary, preset === 'full-access' ? 'danger-full-access' : preset === 'read-only' ? 'read-only' : 'workspace-write', { mode: 0o600 });
42
42
  await fs.rename(temporary, this.policyPath);
43
43
  }
44
- start() {
44
+ start(signal) {
45
+ signal?.throwIfAborted();
45
46
  if (this.connection && !this.connection.signal.aborted)
46
47
  return Promise.resolve();
47
- this.starting ??= this.launch().finally(() => { this.starting = null; });
48
+ this.starting ??= this.launch(signal).finally(() => { this.starting = null; });
48
49
  return this.starting;
49
50
  }
50
- async launch() {
51
+ async launch(signal) {
51
52
  if (this.stopping)
52
53
  await this.stopping;
53
54
  if (this.process)
@@ -75,6 +76,7 @@ export class DshClient extends EventEmitter {
75
76
  args.unshift(path.resolve(command));
76
77
  command = process.execPath;
77
78
  }
79
+ signal?.throwIfAborted();
78
80
  const child = spawn(command, args, { cwd, env: { ...process.env, ...(this.options.home ? { DSH_HOME: this.options.home } : {}) }, stdio: 'pipe', windowsHide: true });
79
81
  this.process = child;
80
82
  this.stderr = '';
@@ -98,12 +100,17 @@ export class DshClient extends EventEmitter {
98
100
  this.emit('disconnected');
99
101
  });
100
102
  const controller = new AbortController();
103
+ const abortInitialization = () => connection.close(new Error('DSH startup cancelled'));
104
+ signal?.addEventListener('abort', abortInitialization, { once: true });
105
+ if (signal?.aborted)
106
+ abortInitialization();
101
107
  let timer;
102
108
  const timeout = new Promise((_resolve, reject) => {
103
109
  timer = setTimeout(() => { controller.abort(); reject(new Error('DSH initialization timed out')); }, this.options.startupTimeoutMs);
104
110
  });
105
111
  try {
106
- const initialized = await Promise.race([connection.agent.request('initialize', { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, clientInfo: { name: 'foxclaw', version: '1' } }, { cancellationSignal: controller.signal }), timeout]);
112
+ const cancellationSignal = signal ? AbortSignal.any([controller.signal, signal]) : controller.signal;
113
+ const initialized = await Promise.race([connection.agent.request('initialize', { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, clientInfo: { name: 'foxclaw', version: '1' } }, { cancellationSignal }), timeout]);
107
114
  this.capabilities = { image: initialized.agentCapabilities?.promptCapabilities?.image === true, resume: initialized.agentCapabilities?.sessionCapabilities?.resume != null, list: initialized.agentCapabilities?.sessionCapabilities?.list != null };
108
115
  }
109
116
  catch (error) {
@@ -112,6 +119,7 @@ export class DshClient extends EventEmitter {
112
119
  }
113
120
  finally {
114
121
  clearTimeout(timer);
122
+ signal?.removeEventListener('abort', abortInitialization);
115
123
  }
116
124
  }
117
125
  get connected() { return this.connection !== null && !this.connection.signal.aborted; }
@@ -0,0 +1,3 @@
1
+ import { type BackendSetupDefinition } from '../service/backend_setup.js';
2
+ import type { Logger } from '../logger.js';
3
+ export declare function createDshSetupDefinition(logger: Logger, env?: NodeJS.ProcessEnv, installDir?: string): BackendSetupDefinition;
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { valid } from 'semver';
6
+ import { APP_HOME, parseDshPatches } from '../config.js';
7
+ import { DshClient } from './client.js';
8
+ import { createDshBackend } from './backend.js';
9
+ import { runBackendCommand } from '../service/backend_setup.js';
10
+ export function createDshSetupDefinition(logger, env = process.env, installDir = path.join(APP_HOME, 'backends', 'dsh')) {
11
+ const managedCli = path.join(installDir, 'node_modules', '@deepseek-ai', 'dsh', 'lib', 'bin.js');
12
+ const options = (candidate) => {
13
+ const startupTimeoutMs = Number(env.DSH_STARTUP_TIMEOUT_MS || 60000);
14
+ if (!Number.isSafeInteger(startupTimeoutMs) || startupTimeoutMs <= 0)
15
+ throw new Error('DSH_STARTUP_TIMEOUT_MS must be a positive integer');
16
+ return {
17
+ cliBin: candidate.updates.DSH_CLI_BIN || 'dsh', sourceDir: candidate.updates.DSH_SOURCE_DIR || null,
18
+ home: env.DSH_HOME || null, profile: env.DSH_PROFILE || 'acp', patches: parseDshPatches(env.DSH_PATCHES),
19
+ runtimeDir: path.join(APP_HOME, 'runtime', 'dsh'), startupTimeoutMs,
20
+ };
21
+ };
22
+ const fromPath = async (location) => {
23
+ if (!path.isAbsolute(location) || /[\r\n\0]/.test(location))
24
+ throw new Error('请提供本机绝对路径 / An absolute local path is required');
25
+ const target = await fs.realpath(location);
26
+ const stat = await fs.stat(target);
27
+ if (stat.isDirectory()) {
28
+ await fs.access(path.join(target, 'apps', 'cli', 'src', 'bin.ts'));
29
+ return { label: 'DSH 源码 / source', location: target,
30
+ updates: { DSH_ENABLED: 'true', DSH_SOURCE_DIR: target, DSH_CLI_BIN: '' } };
31
+ }
32
+ if (!stat.isFile())
33
+ throw new Error('Not a CLI file or source directory');
34
+ // Keep stable launchers and node_modules links; pnpm's real target embeds a version.
35
+ const cli = path.resolve(location);
36
+ await fs.access(cli, /\.[cm]?js$/.test(cli) ? fs.constants.R_OK : fs.constants.X_OK);
37
+ return { label: 'DSH CLI', location: cli,
38
+ updates: { DSH_ENABLED: 'true', DSH_SOURCE_DIR: '', DSH_CLI_BIN: cli } };
39
+ };
40
+ return {
41
+ id: 'dsh', name: 'DeepSeek Harness (DSH)', fromPath,
42
+ async discover() {
43
+ const commands = (env.PATH || '').split(path.delimiter).filter(Boolean).map(directory => path.join(directory, process.platform === 'win32' ? 'dsh.cmd' : 'dsh'));
44
+ const locations = [env.DSH_SOURCE_DIR, env.DSH_CLI_BIN, managedCli, ...commands,
45
+ env.DEFAULT_CWD ? path.join(path.dirname(env.DEFAULT_CWD), 'deepseek-harness') : null,
46
+ path.join(os.homedir(), 'git', 'deepseek-harness')].filter((location) => Boolean(location));
47
+ const found = [];
48
+ for (const location of locations) {
49
+ try {
50
+ const candidate = await fromPath(location);
51
+ if (!found.some(previous => previous.location === candidate.location))
52
+ found.push(candidate);
53
+ }
54
+ catch { /* Keep unavailable paths out of the selectable list. */ }
55
+ }
56
+ return found;
57
+ },
58
+ async validate(candidate, signal) {
59
+ const clientOptions = options(candidate);
60
+ const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'foxclaw-dsh-check-'));
61
+ const client = new DshClient({ ...clientOptions, runtimeDir }, `setup:${randomUUID()}`, logger);
62
+ try {
63
+ signal.throwIfAborted();
64
+ await client.setAccess('read-only');
65
+ signal.throwIfAborted();
66
+ await client.start(signal);
67
+ signal.throwIfAborted();
68
+ }
69
+ finally {
70
+ await client.stop();
71
+ await fs.rm(runtimeDir, { recursive: true, force: true });
72
+ }
73
+ },
74
+ factory: candidate => host => createDshBackend({ ...host.config, dsh: options(candidate) }, host.store, host.logger, host.messaging),
75
+ async install(signal) {
76
+ await fs.mkdir(installDir, { recursive: true, mode: 0o700 });
77
+ const packageFile = path.join(installDir, 'package.json');
78
+ try {
79
+ await fs.access(packageFile);
80
+ }
81
+ catch {
82
+ await fs.writeFile(packageFile, JSON.stringify({ name: 'foxclaw-dsh-runtime', private: true }), { mode: 0o600 });
83
+ }
84
+ const npm = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
85
+ const npmCli = process.platform === 'win32'
86
+ ? path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js')
87
+ : await fs.realpath(npm);
88
+ const installEnv = { ...env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${env.PATH || ''}` };
89
+ const result = await runBackendCommand(process.execPath, [npmCli, 'view', '@deepseek-ai/dsh@latest', 'version', '--json', '--registry=https://registry.npmjs.org', '--prefer-online'], installDir, installEnv, signal, 30000);
90
+ const version = JSON.parse(result);
91
+ if (typeof version !== 'string' || !valid(version))
92
+ throw new Error('Cannot verify official DSH version');
93
+ await runBackendCommand(process.execPath, [npmCli, 'exec', '--yes', '--package=pnpm@10', '--', 'pnpm', '--config.minimum-release-age=0',
94
+ 'add', '--ignore-scripts', '--registry=https://registry.npmjs.org', `@deepseek-ai/dsh@${version}`], installDir, installEnv, signal);
95
+ return fromPath(managedCli);
96
+ },
97
+ };
98
+ }
package/dist/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { RuntimeSupervisor } from './core/runtime_supervisor.js';
3
+ import { BackendSetupManager } from './service/backend_setup.js';
3
4
  import crypto from 'node:crypto';
4
5
  import fs from 'node:fs';
5
6
  import os from 'node:os';
@@ -606,7 +607,7 @@ function truncateStatusLine(value, maxLength) {
606
607
  return normalized.length <= maxLength ? normalized : `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
607
608
  }
608
609
  async function runServeCli() {
609
- const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync }, { OpencodeTelegramRuntime }, { DshTelegramRuntime }, { createDshBackend }, { UnifiedBridgeCore }, { AntigravityAppClient }, { AntigravityAuthManager, linkAntigravityAuthTokens },] = await Promise.all([
610
+ const [{ BridgeMessagingRouter }, { TelegramMessagingPort }, { WeixinChannelAdapter }, { WeixinMessagingPort }, { attachIlinkRuntimeFromBridgeLogger }, { loadWeixinAccount }, { Logger }, { BridgeStore }, { TelegramGateway }, { CodexAppClient }, { BridgeSessionCore }, { TelegramChannelAdapter }, { AuthCandidateMirror }, { CrossNodeAuthSync }, { OpencodeTelegramRuntime }, { DshTelegramRuntime }, { createDshBackend }, { createDshSetupDefinition }, { UnifiedBridgeCore }, { AntigravityAppClient }, { AntigravityAuthManager, linkAntigravityAuthTokens },] = await Promise.all([
610
611
  import('./channels/bridge_messaging_router.js'),
611
612
  import('./channels/telegram/telegram_messaging_port.js'),
612
613
  import('./channels/weixin/weixin_channel_adapter.js'),
@@ -624,6 +625,7 @@ async function runServeCli() {
624
625
  import('./opencode/runtime.js'),
625
626
  import('./dsh/runtime.js'),
626
627
  import('./dsh/backend.js'),
628
+ import('./dsh/setup.js'),
627
629
  import('./bridge/unified_bridge.js'),
628
630
  import('./antigravity/client.js'),
629
631
  import('./antigravity/auth.js'),
@@ -718,6 +720,8 @@ async function runServeCli() {
718
720
  codexCliBin: config.codexCliBin,
719
721
  agyCliBin: config.antigravityCliBin,
720
722
  });
723
+ const backendSetup = new BackendSetupManager([createDshSetupDefinition(logger)], config.envPath ?? DEFAULT_ENV_PATH, async () => (await selfUpdater.readStatus())?.state !== 'pending');
724
+ supervisor.register('backend-setup', { stop: () => backendSetup.stop() });
721
725
  sharedAntigravityAuth = new AntigravityAuthManager(config.antigravityAuthDir, logger);
722
726
  sharedAntigravityApp = new AntigravityAppClient(config.antigravityCliBin, logger);
723
727
  if (config.codexCliBin) {
@@ -1001,6 +1005,7 @@ async function runServeCli() {
1001
1005
  antigravityApp: seed.antigravityApp,
1002
1006
  antigravityAuth: seed.antigravityAuth,
1003
1007
  defaultBackendId: seed.defaultBackend,
1008
+ backendSetup,
1004
1009
  backends: seed.config.dsh ? [createDshBackend(seed.config, store, logger, telegramMessaging)] : [],
1005
1010
  selfUpdater,
1006
1011
  });
@@ -0,0 +1,72 @@
1
+ import type { AppConfig } from '../config.js';
2
+ import type { BridgeStore } from '../store/database.js';
3
+ import type { Logger } from '../logger.js';
4
+ import type { AppLocale } from '../types.js';
5
+ import type { ChannelPort, ChannelInlineKeyboard } from '../core/channel_port.js';
6
+ import type { ChannelTextEvent } from '../core/channel_events.js';
7
+ import type { BackendDescriptor } from '../core/engine_spi.js';
8
+ export interface BackendSetupHost {
9
+ config: AppConfig;
10
+ store: BridgeStore;
11
+ logger: Logger;
12
+ messaging: ChannelPort;
13
+ }
14
+ export type BackendFactory = (host: BackendSetupHost) => BackendDescriptor;
15
+ export interface BackendCandidate {
16
+ label: string;
17
+ location: string;
18
+ updates: Record<string, string>;
19
+ }
20
+ export interface BackendSetupDefinition {
21
+ id: string;
22
+ name: string;
23
+ discover(): Promise<BackendCandidate[]>;
24
+ fromPath(location: string): Promise<BackendCandidate>;
25
+ validate(candidate: BackendCandidate, signal: AbortSignal): Promise<void>;
26
+ factory(candidate: BackendCandidate): BackendFactory;
27
+ install?(signal: AbortSignal): Promise<BackendCandidate>;
28
+ }
29
+ /** One owner for provisioning jobs and config writes across all bots on this service. */
30
+ export declare class BackendSetupManager {
31
+ private readonly envPath;
32
+ private readonly canConfigure;
33
+ readonly definitions: ReadonlyMap<string, BackendSetupDefinition>;
34
+ private readonly targets;
35
+ private active;
36
+ private stopped;
37
+ constructor(definitions: BackendSetupDefinition[], envPath: string, canConfigure?: () => Promise<boolean>);
38
+ get busy(): boolean;
39
+ attach(target: (factory: BackendFactory) => void): () => void;
40
+ enable(id: string, candidate?: BackendCandidate): Promise<void>;
41
+ stop(): Promise<void>;
42
+ }
43
+ export declare function writeBackendEnv(envPath: string, updates: Record<string, string>): void;
44
+ /** Fixed executable/argument vectors only; bounded lifetime and process-tree cleanup. */
45
+ export declare function runBackendCommand(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv, signal: AbortSignal, timeoutMs?: number): Promise<string>;
46
+ interface SetupPanelHost {
47
+ listBackends(): Promise<BackendDescriptor[]>;
48
+ send(scopeId: string, text: string, keyboard: ChannelInlineKeyboard, messageId?: number): Promise<void>;
49
+ readPathState?(scopeId: string): string | null;
50
+ writePathState?(scopeId: string, state: string | null): void;
51
+ }
52
+ /** Per-bot, per-scope menus. Choices never select an execution backend implicitly. */
53
+ export declare class BackendSetupUi {
54
+ private readonly manager;
55
+ private readonly host;
56
+ private readonly choices;
57
+ private readonly pendingPaths;
58
+ constructor(manager: BackendSetupManager, host: SetupPanelHost);
59
+ private clearScope;
60
+ private choice;
61
+ rows(locale: AppLocale): ChannelInlineKeyboard;
62
+ menu(scopeId: string, locale: AppLocale, messageId?: number): Promise<void>;
63
+ select(scopeId: string, id: string, locale: AppLocale, messageId?: number): Promise<void>;
64
+ callback(scopeId: string, data: string, locale: AppLocale, messageId?: number): Promise<boolean>;
65
+ command(scopeId: string, command: string, args: string, locale: AppLocale): Promise<boolean>;
66
+ inbound(event: ChannelTextEvent, locale: AppLocale): boolean | Promise<boolean>;
67
+ private consumePath;
68
+ private enable;
69
+ stop(): void;
70
+ }
71
+ export declare function safeSetupError(error: unknown): string;
72
+ export {};
@@ -0,0 +1,319 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { spawn } from 'node:child_process';
5
+ /** One owner for provisioning jobs and config writes across all bots on this service. */
6
+ export class BackendSetupManager {
7
+ envPath;
8
+ canConfigure;
9
+ definitions;
10
+ targets = new Set();
11
+ active = null;
12
+ stopped = false;
13
+ constructor(definitions, envPath, canConfigure = async () => true) {
14
+ this.envPath = envPath;
15
+ this.canConfigure = canConfigure;
16
+ this.definitions = new Map(definitions.map(definition => [definition.id, definition]));
17
+ }
18
+ get busy() { return this.active !== null; }
19
+ attach(target) {
20
+ if (this.stopped)
21
+ throw new Error('Backend setup is stopped');
22
+ this.targets.add(target);
23
+ return () => this.targets.delete(target);
24
+ }
25
+ async enable(id, candidate) {
26
+ if (this.stopped)
27
+ throw new Error('Backend setup is stopped');
28
+ if (this.active)
29
+ throw new Error('Another backend is being configured');
30
+ const definition = this.definitions.get(id);
31
+ if (!definition)
32
+ throw new Error('Unknown backend');
33
+ const controller = new AbortController();
34
+ // Claim before the first await: two bots cannot start overlapping installations.
35
+ let finish;
36
+ const done = new Promise(resolve => { finish = resolve; });
37
+ this.active = { controller, done };
38
+ try {
39
+ if (!await this.canConfigure())
40
+ throw new Error('A service update is running; try again when it finishes');
41
+ controller.signal.throwIfAborted();
42
+ const selected = candidate ?? await definition.install?.(controller.signal);
43
+ if (!selected)
44
+ throw new Error('This backend requires a local installation');
45
+ await definition.validate(selected, controller.signal);
46
+ controller.signal.throwIfAborted();
47
+ const factory = definition.factory(selected);
48
+ writeBackendEnv(this.envPath, selected.updates);
49
+ for (const target of this.targets)
50
+ target(factory);
51
+ }
52
+ finally {
53
+ this.active = null;
54
+ finish();
55
+ }
56
+ }
57
+ async stop() {
58
+ this.stopped = true;
59
+ this.active?.controller.abort();
60
+ await this.active?.done;
61
+ this.targets.clear();
62
+ }
63
+ }
64
+ export function writeBackendEnv(envPath, updates) {
65
+ // Preserve symlink-backed configuration and every unrelated setting/comment.
66
+ const target = fs.existsSync(envPath) ? fs.realpathSync(envPath) : envPath;
67
+ let text = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : '';
68
+ for (const [key, value] of Object.entries(updates)) {
69
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || /[\r\n\0]/.test(value))
70
+ throw new Error('Invalid backend configuration');
71
+ const line = `${key}=${JSON.stringify(value)}`;
72
+ const pattern = new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=.*$`, 'gm');
73
+ if (pattern.test(text))
74
+ text = text.replace(pattern, () => line);
75
+ else
76
+ text = `${text.trimEnd()}\n${line}\n`;
77
+ }
78
+ fs.mkdirSync(path.dirname(target), { recursive: true });
79
+ const temporary = `${target}.${randomUUID()}.tmp`;
80
+ try {
81
+ fs.writeFileSync(temporary, text, { mode: 0o600 });
82
+ fs.renameSync(temporary, target);
83
+ }
84
+ finally {
85
+ fs.rmSync(temporary, { force: true });
86
+ }
87
+ }
88
+ /** Fixed executable/argument vectors only; bounded lifetime and process-tree cleanup. */
89
+ export async function runBackendCommand(command, args, cwd, env, signal, timeoutMs = 5 * 60_000) {
90
+ signal.throwIfAborted();
91
+ return new Promise((resolve, reject) => {
92
+ const child = spawn(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true,
93
+ detached: process.platform !== 'win32' });
94
+ let stdout = '';
95
+ let stderr = '';
96
+ let cancelled = false;
97
+ let escalation;
98
+ const kill = (force) => {
99
+ if (!child.pid)
100
+ return;
101
+ try {
102
+ if (process.platform === 'win32') {
103
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on('error', () => { });
104
+ }
105
+ else
106
+ process.kill(-child.pid, force ? 'SIGKILL' : 'SIGTERM');
107
+ }
108
+ catch { /* Already exited. */ }
109
+ };
110
+ const cancel = () => {
111
+ if (cancelled)
112
+ return;
113
+ cancelled = true;
114
+ kill(false);
115
+ escalation = setTimeout(() => kill(true), 3000);
116
+ };
117
+ const timeout = setTimeout(cancel, timeoutMs);
118
+ signal.addEventListener('abort', cancel, { once: true });
119
+ child.stdout.on('data', (data) => { stdout = (stdout + data.toString()).slice(-8000); });
120
+ child.stderr.on('data', (data) => { stderr = (stderr + data.toString()).slice(-8000); });
121
+ const cleanup = () => {
122
+ clearTimeout(timeout);
123
+ clearTimeout(escalation);
124
+ signal.removeEventListener('abort', cancel);
125
+ };
126
+ child.once('error', error => { cleanup(); reject(error); });
127
+ child.once('close', code => {
128
+ if (cancelled)
129
+ kill(true);
130
+ cleanup();
131
+ if (cancelled)
132
+ reject(new Error(signal.aborted ? 'Backend setup cancelled' : 'Backend setup timed out'));
133
+ else if (code !== 0)
134
+ reject(new Error(`Backend command failed (${code}): ${(stderr || stdout).slice(-2000)}`));
135
+ else
136
+ resolve(stdout.trim());
137
+ });
138
+ });
139
+ }
140
+ /** Per-bot, per-scope menus. Choices never select an execution backend implicitly. */
141
+ export class BackendSetupUi {
142
+ manager;
143
+ host;
144
+ choices = new Map();
145
+ pendingPaths = new Map();
146
+ constructor(manager, host) {
147
+ this.manager = manager;
148
+ this.host = host;
149
+ }
150
+ clearScope(scopeId) {
151
+ this.pendingPaths.delete(scopeId);
152
+ this.host.writePathState?.(scopeId, null);
153
+ for (const [key, choice] of this.choices)
154
+ if (choice.scopeId === scopeId)
155
+ this.choices.delete(key);
156
+ }
157
+ choice(scopeId, backendId, candidate) {
158
+ for (const [key, choice] of this.choices)
159
+ if (choice.expires < Date.now())
160
+ this.choices.delete(key);
161
+ const key = randomUUID();
162
+ this.choices.set(key, { scopeId, backendId, candidate, expires: Date.now() + 5 * 60_000 });
163
+ return key;
164
+ }
165
+ rows(locale) {
166
+ return [[{ text: locale === 'zh' ? '➕ 添加后端' : '➕ Add backend', callback_data: 'backend-setup:list' }]];
167
+ }
168
+ async menu(scopeId, locale, messageId) {
169
+ this.clearScope(scopeId);
170
+ const installed = new Set((await this.host.listBackends()).map(backend => backend.engineType));
171
+ const definitions = [...this.manager.definitions.values()].filter(definition => !installed.has(definition.id));
172
+ const keyboard = definitions.map(definition => [{ text: definition.name, callback_data: `backend-setup:select:${definition.id}` }]);
173
+ keyboard.push([{ text: locale === 'zh' ? '◀️ 返回后端列表' : '◀️ Back to backends', callback_data: 'engine:setup:backend' }]);
174
+ await this.host.send(scopeId, locale === 'zh'
175
+ ? `➕ **添加后端**\n\n${definitions.length ? '选择要接入的后端。会保留当前会话,接入后可自行切换。' : '支持从面板接入的后端均已添加。'}`
176
+ : `➕ **Add backend**\n\n${definitions.length ? 'Choose a backend. Your current session stays selected; switch after setup.' : 'All backends supported by this setup panel are already added.'}`, keyboard, messageId);
177
+ }
178
+ async select(scopeId, id, locale, messageId) {
179
+ const definition = this.manager.definitions.get(id);
180
+ if (!definition)
181
+ throw new Error('Unknown backend');
182
+ this.clearScope(scopeId);
183
+ const candidates = await definition.discover();
184
+ const keyboard = candidates.map(candidate => [{
185
+ text: `${locale === 'zh' ? '接入' : 'Use'} · ${candidate.label}`,
186
+ callback_data: `backend-setup:use:${this.choice(scopeId, id, candidate)}`,
187
+ }]);
188
+ if (definition.install)
189
+ keyboard.push([{ text: locale === 'zh' ? '下载官方 CLI 并接入' : 'Install official CLI and enable',
190
+ callback_data: `backend-setup:install:${this.choice(scopeId, id)}` }]);
191
+ keyboard.push([{ text: locale === 'zh' ? '指定 CLI / 源码路径' : 'Specify CLI / source path', callback_data: `backend-setup:path:${id}` }]);
192
+ keyboard.push(...this.rows(locale));
193
+ const locations = candidates.map(candidate => `• ${candidate.label}: \`${candidate.location}\``).join('\n');
194
+ await this.host.send(scopeId, `➕ **${definition.name}**\n\n${locations || (locale === 'zh' ? '尚未找到本机安装。' : 'No local installation found.')}\n\n${locale === 'zh' ? '接入前检查启动能力;官方 CLI 安装在 FoxClaw 的独立目录,沿用该后端的本机凭据。无需新增 Bot,也不会自动切换当前会话。' : 'Startup is checked before enabling. Official CLIs use a dedicated FoxClaw directory and existing backend credentials. No extra bot or automatic session switch.'}`, keyboard, messageId);
195
+ }
196
+ async callback(scopeId, data, locale, messageId) {
197
+ if (!data.startsWith('backend-setup:')) {
198
+ this.clearScope(scopeId);
199
+ return false;
200
+ }
201
+ const [, action, value] = data.split(':');
202
+ if (action === 'list')
203
+ await this.menu(scopeId, locale, messageId);
204
+ else if (action === 'select')
205
+ await this.select(scopeId, value, locale, messageId);
206
+ else if (action === 'path') {
207
+ if (!this.manager.definitions.has(value))
208
+ throw new Error('Unknown backend');
209
+ this.clearScope(scopeId);
210
+ const pending = { backendId: value, expires: Date.now() + 5 * 60_000 };
211
+ this.pendingPaths.set(scopeId, pending);
212
+ this.host.writePathState?.(scopeId, JSON.stringify(pending));
213
+ await this.host.send(scopeId, locale === 'zh' ? '发送本机 CLI 文件或源码目录的绝对路径。\n/backend cancel 取消' : 'Send an absolute local CLI or source path.\n/backend cancel to cancel.', this.rows(locale), messageId);
214
+ }
215
+ else if (action === 'use' || action === 'install') {
216
+ const choice = this.choices.get(value);
217
+ if (!choice || choice.scopeId !== scopeId || choice.expires < Date.now() || (action === 'use') !== Boolean(choice.candidate)) {
218
+ await this.host.send(scopeId, locale === 'zh' ? '此操作已过期,请重新选择后端。' : 'This choice expired. Select the backend again.', this.rows(locale));
219
+ return true;
220
+ }
221
+ this.clearScope(scopeId);
222
+ await this.enable(scopeId, choice.backendId, choice.candidate, locale, messageId);
223
+ }
224
+ return true;
225
+ }
226
+ async command(scopeId, command, args, locale) {
227
+ this.clearScope(scopeId);
228
+ if (!['backend', 'backends', 'engine', 'engines'].includes(command))
229
+ return false;
230
+ if (args === 'cancel') {
231
+ this.pendingPaths.delete(scopeId);
232
+ await this.menu(scopeId, locale);
233
+ return true;
234
+ }
235
+ const match = /^add(?:\s+([a-z][a-z0-9-]*))?$/.exec(args);
236
+ if (!match)
237
+ return false;
238
+ try {
239
+ if (match[1])
240
+ await this.select(scopeId, match[1], locale);
241
+ else
242
+ await this.menu(scopeId, locale);
243
+ }
244
+ catch (error) {
245
+ await this.host.send(scopeId, `❌ ${safeSetupError(error)}`, this.rows(locale));
246
+ }
247
+ return true;
248
+ }
249
+ inbound(event, locale) {
250
+ let pending = this.pendingPaths.get(event.scopeId);
251
+ if (!pending) {
252
+ const saved = this.host.readPathState?.(event.scopeId);
253
+ if (saved) {
254
+ try {
255
+ const parsed = JSON.parse(saved);
256
+ if (typeof parsed.backendId === 'string' && typeof parsed.expires === 'number' && this.manager.definitions.has(parsed.backendId)) {
257
+ pending = { backendId: parsed.backendId, expires: parsed.expires };
258
+ this.pendingPaths.set(event.scopeId, pending);
259
+ }
260
+ }
261
+ catch { /* Invalid saved interaction is not used as an executable choice. */ }
262
+ }
263
+ }
264
+ if (!pending)
265
+ return false;
266
+ // POSIX paths resemble slash commands. Consume them before command dispatch,
267
+ // while actual commands such as /new and /backend cancel still work normally.
268
+ if (/^\/[\w]+(?:@[\w]+)?(?:\s|$)/.test(event.text.trim())) {
269
+ this.clearScope(event.scopeId);
270
+ return false;
271
+ }
272
+ return this.consumePath(event, pending, locale);
273
+ }
274
+ async consumePath(event, pending, locale) {
275
+ if (pending.expires < Date.now()) {
276
+ await this.host.send(event.scopeId, locale === 'zh' ? '路径输入已过期,请重新打开添加面板,或发送 /backend cancel 返回聊天。' : 'Path input expired. Reopen setup or send /backend cancel to return to chat.', this.rows(locale));
277
+ return true;
278
+ }
279
+ try {
280
+ const definition = this.manager.definitions.get(pending.backendId);
281
+ const candidate = await definition.fromPath(event.text.trim());
282
+ if (this.pendingPaths.get(event.scopeId) !== pending)
283
+ return true;
284
+ const key = this.choice(event.scopeId, pending.backendId, candidate);
285
+ await this.host.send(event.scopeId, `${definition.name}\n\`${candidate.location}\``, [[{
286
+ text: locale === 'zh' ? '检查并接入' : 'Validate and enable', callback_data: `backend-setup:use:${key}`,
287
+ }], ...this.rows(locale)]);
288
+ }
289
+ catch (error) {
290
+ await this.host.send(event.scopeId, safeSetupError(error), this.rows(locale));
291
+ }
292
+ return true;
293
+ }
294
+ async enable(scopeId, id, candidate, locale, messageId) {
295
+ if ((await this.host.listBackends()).some(backend => backend.engineType === id)) {
296
+ await this.menu(scopeId, locale, messageId);
297
+ return;
298
+ }
299
+ await this.host.send(scopeId, locale === 'zh' ? '⏳ 正在准备后端并检查启动能力…' : '⏳ Preparing backend and checking startup…', [], messageId);
300
+ try {
301
+ await this.manager.enable(id, candidate);
302
+ await this.host.send(scopeId, locale === 'zh' ? '✅ 后端已接入,配置已保存。当前会话继续使用原后端。' : '✅ Backend enabled and configuration saved. Your current session keeps its backend.', [[{
303
+ text: locale === 'zh' ? '切换至新后端' : 'Switch to this backend', callback_data: `engine:backend:${id}`,
304
+ }], ...this.rows(locale)], messageId);
305
+ }
306
+ catch (error) {
307
+ await this.host.send(scopeId, `❌ ${safeSetupError(error)}`, this.rows(locale), messageId);
308
+ }
309
+ }
310
+ stop() { this.choices.clear(); this.pendingPaths.clear(); }
311
+ }
312
+ export function safeSetupError(error) {
313
+ let message = error instanceof Error ? error.message : String(error);
314
+ for (const [key, value] of Object.entries(process.env)) {
315
+ if (value && value.length >= 8 && /KEY|TOKEN|SECRET|PASSWORD/.test(key))
316
+ message = message.split(value).join('[redacted]');
317
+ }
318
+ return message.slice(-1800);
319
+ }
@@ -161,6 +161,8 @@ export declare class BridgeStore {
161
161
  updateGuidedPlanSessionMessage(sessionId: string, messageId: number): void;
162
162
  updateGuidedPlanSessionState(sessionId: string, state: GuidedPlanSessionState): void;
163
163
  insertAudit(direction: 'inbound' | 'outbound', chatId: string, eventType: string, summary: string): void;
164
+ getServiceInteraction(scopeId: string, namespace: string): string | null;
165
+ setServiceInteraction(scopeId: string, namespace: string, payload: string | null): void;
164
166
  close(): void;
165
167
  private mapApproval;
166
168
  private mapPendingUserInput;
@@ -14,6 +14,12 @@ export class BridgeStore {
14
14
  this.taskJournal = new TaskJournal(this.db);
15
15
  this.channelInbox = new ChannelInbox(this.db);
16
16
  this.db.exec(`
17
+ CREATE TABLE IF NOT EXISTS service_interactions (
18
+ scope_id TEXT NOT NULL,
19
+ namespace TEXT NOT NULL,
20
+ payload TEXT NOT NULL,
21
+ PRIMARY KEY (scope_id, namespace)
22
+ );
17
23
  CREATE TABLE IF NOT EXISTS telegram_offsets (
18
24
  bot_key TEXT PRIMARY KEY,
19
25
  update_id INTEGER NOT NULL
@@ -893,6 +899,18 @@ export class BridgeStore {
893
899
  insertAudit(direction, chatId, eventType, summary) {
894
900
  this.db.prepare('INSERT INTO audit_logs (direction, chat_id, event_type, summary, created_at) VALUES (?, ?, ?, ?, ?)').run(direction, chatId, eventType, summary, Date.now());
895
901
  }
902
+ getServiceInteraction(scopeId, namespace) {
903
+ const row = this.db.prepare('SELECT payload FROM service_interactions WHERE scope_id = ? AND namespace = ?').get(scopeId, namespace);
904
+ return row ? String(row.payload) : null;
905
+ }
906
+ setServiceInteraction(scopeId, namespace, payload) {
907
+ if (payload === null) {
908
+ this.db.prepare('DELETE FROM service_interactions WHERE scope_id = ? AND namespace = ?').run(scopeId, namespace);
909
+ }
910
+ else {
911
+ this.db.prepare('INSERT INTO service_interactions (scope_id, namespace, payload) VALUES (?, ?, ?) ON CONFLICT(scope_id, namespace) DO UPDATE SET payload = excluded.payload').run(scopeId, namespace, payload);
912
+ }
913
+ }
896
914
  close() {
897
915
  this.db.close();
898
916
  }
package/docs/dsh.md CHANGED
@@ -4,6 +4,14 @@ FoxClaw launches DSH's shipped `acp` profile and reuses Telegram progress, tool
4
4
 
5
5
  ## Existing bot
6
6
 
7
+ Open Telegram `/setup` → backend list → **Add backend** and select DSH, or send `/backend add dsh`:
8
+
9
+ - Use a discovered local CLI / source tree, or supply an absolute local path.
10
+ - If none is installed, choose “Install official CLI and enable”. FoxClaw uses pnpm to install official `@deepseek-ai/dsh` under `~/.foxclaw/backends/dsh`, leaving global CLIs unchanged.
11
+ - After checking startup, save configuration and register DSH on all unified Codex / Antigravity bots on this machine without restarting. Existing tasks, sessions and selected backends stay unchanged. Switch explicitly using the completion button.
12
+
13
+ Model accounts and API keys use DSH's existing local configuration. Failed installation or startup checks do not enable the backend; the panel shows the error and lets you retry or choose another local path. `/backend cancel` or another command exits path input.
14
+
7
15
  Add to FoxClaw's `.env`:
8
16
 
9
17
  ```dotenv
@@ -80,4 +88,4 @@ DSH_TELEMETRY_DISABLED=1 node --test --import tsx src/dsh/source.integration.tes
80
88
 
81
89
  This uses temporary DSH home/session directories and verifies model/reasoning selection, native permission logs, resume, tools, and cancellation without paid model calls. It is skipped when the source path is unset.
82
90
 
83
- Run `npm run build` first and add `FOXCLAW_DSH_TEST_BUILT=1` to verify the compiled backend and packaged permission plugin.
91
+ Run `pnpm build` first and add `FOXCLAW_DSH_TEST_BUILT=1` to verify the compiled backend and packaged permission plugin.
package/docs/zh/dsh.md CHANGED
@@ -4,6 +4,14 @@ FoxClaw 直接启动 DSH 的 `acp` profile,复用 Telegram 的任务进度、
4
4
 
5
5
  ## 接入现有 Bot
6
6
 
7
+ 也可以直接在 Telegram 的 `/setup` → 后端列表 → **添加后端** 中选择 DSH,或发送 `/backend add dsh`:
8
+
9
+ - 接入检测到的本机 CLI / 源码,或发送它的绝对路径。
10
+ - 没有本机安装时,点击“下载官方 CLI 并接入”。FoxClaw 使用 pnpm 在 `~/.foxclaw/backends/dsh` 安装官方 `@deepseek-ai/dsh`,不改动全局 CLI。
11
+ - 启动检查通过后保存配置,并直接接入本机所有统一 Codex / Antigravity Bot,无需重启。当前任务、会话和后端保持原状;点击“切换至新后端”后才切换。
12
+
13
+ DSH 模型账号和 API Key 沿用其本机配置。安装或启动检查失败时不会启用后端;面板显示原因,可以重新选择本机路径或重试。`/backend cancel` 取消路径输入,其他命令也会退出路径输入。
14
+
7
15
  在 FoxClaw 的 `.env` 中增加:
8
16
 
9
17
  ```dotenv
@@ -80,4 +88,4 @@ DSH_TELEMETRY_DISABLED=1 node --test --import tsx src/dsh/source.integration.tes
80
88
 
81
89
  联调使用临时 home 和会话目录,验证模型/推理设置、原生权限日志、恢复、工具事件和取消。未配置源码路径时跳过此项。
82
90
 
83
- 先执行 `npm run build`,再追加 `FOXCLAW_DSH_TEST_BUILT=1` 可验证编译后的后端和随包发布的权限插件。
91
+ 先执行 `pnpm build`,再追加 `FOXCLAW_DSH_TEST_BUILT=1` 可验证编译后的后端和随包发布的权限插件。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.13.1",
3
+ "version": "0.13.2",
4
4
  "description": "Foxden local execution claw for controlling Codex, OpenCode, Antigravity, and DeepSeek Harness from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",