@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 +12 -0
- package/dist/bridge/unified_bridge.d.ts +5 -0
- package/dist/bridge/unified_bridge.js +54 -2
- package/dist/core/backend_ui.d.ts +2 -1
- package/dist/core/orchestrator.js +9 -1
- package/dist/dsh/client.d.ts +1 -1
- package/dist/dsh/client.js +12 -4
- package/dist/dsh/setup.d.ts +3 -0
- package/dist/dsh/setup.js +98 -0
- package/dist/main.js +6 -1
- package/dist/service/backend_setup.d.ts +72 -0
- package/dist/service/backend_setup.js +319 -0
- package/dist/store/database.d.ts +2 -0
- package/dist/store/database.js +18 -0
- package/docs/dsh.md +9 -1
- package/docs/zh/dsh.md +9 -1
- package/package.json +1 -1
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: {
|
|
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);
|
package/dist/dsh/client.d.ts
CHANGED
|
@@ -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;
|
package/dist/dsh/client.js
CHANGED
|
@@ -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
|
|
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,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
|
+
}
|
package/dist/store/database.d.ts
CHANGED
|
@@ -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;
|
package/dist/store/database.js
CHANGED
|
@@ -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 `
|
|
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
|
-
先执行 `
|
|
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.
|
|
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",
|