@foxden-app/foxclaw 0.13.3 → 0.13.5

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,30 @@
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.5 - 2026-10-06
6
+
7
+ ### 中文
8
+
9
+ - 修复 DSH `/threads` 只显示会话 ID:补充读取 DSH 原生持久化标题,旧会话无标题时使用首条用户消息;空会话显示未命名、工作目录和短编号。
10
+ - 标题读取不恢复会话、不调用模型,单个历史会话读取失败不会影响列表;保留分页及原会话 ID 的打开行为。
11
+
12
+ ### English
13
+
14
+ - Fixed DSH `/threads` displaying session IDs instead of readable names. Read native persisted titles, derive legacy names from the first human message, and show a workspace and short identifier for empty sessions.
15
+ - Title reads do not resume sessions or invoke a model. Unreadable histories cannot break the list; pagination and opening by the original session ID are preserved.
16
+
17
+ ## 0.13.4 - 2026-10-06
18
+
19
+ ### 中文
20
+
21
+ - DSH 模型面板新增 API Key 配置入口,支持私聊添加、更新和查看配置状态,通过 DSH 原生凭据服务保存并热生效。环境凭据显示为只读;读取模型列表失败时仍提供配置和重试入口。
22
+ - 敏感输入在持久收件前脱敏,不进入模型或任务记录;输入消息尝试删除,跨重启只恢复配置意图。配置后端缺失时仍阻止输入落入其他模型,凭据操作支持关闭取消并阻止并行自升级。
23
+
24
+ ### English
25
+
26
+ - Added DeepSeek API key configuration to the DSH model panel: add/update keys in private chat and inspect status using native DSH credential storage without restarting. Environment credentials are read-only; configuration and retry actions remain available if model discovery fails.
27
+ - Redact sensitive input before durable intake, keep it out of models and task records, and attempt to delete its message. Restarts retain only configuration intent. Missing backends cannot route credential input to another model; credential operations cancel on shutdown and block concurrent service updates.
28
+
5
29
  ## 0.13.3 - 2026-10-06
6
30
 
7
31
  ### 中文
@@ -1,5 +1,6 @@
1
1
  import type { BackendDescriptor } from './engine_spi.js';
2
2
  import type { BackendUi, BackendUiHost } from './backend_ui.js';
3
+ import type { ChannelTextEvent } from './channel_events.js';
3
4
  /** Owns backend definitions and their panel lifecycle. Discovery may refresh metadata, never execution identity. */
4
5
  export declare class BackendRegistry {
5
6
  private readonly host;
@@ -16,7 +17,10 @@ export declare class BackendRegistry {
16
17
  list(): BackendDescriptor[];
17
18
  ui(id: string): BackendUi | undefined;
18
19
  callbackOwner(data: string): BackendUi | undefined;
20
+ sensitiveInboundOwner(event: ChannelTextEvent): BackendUi | undefined;
19
21
  getPendingApprovals(): number;
22
+ getPendingOperations(): number;
23
+ stopPendingOperations(): Promise<void>;
20
24
  stop(): Promise<void>;
21
25
  private stopInterfaces;
22
26
  }
@@ -35,9 +35,18 @@ export class BackendRegistry {
35
35
  callbackOwner(data) {
36
36
  return [...this.interfaces.values()].find(ui => ui.ownsCallback?.(data));
37
37
  }
38
+ sensitiveInboundOwner(event) {
39
+ return [...this.interfaces.values()].find(ui => ui.isSensitiveInbound?.(event));
40
+ }
38
41
  getPendingApprovals() {
39
42
  return [...this.interfaces.values()].reduce((sum, ui) => sum + (ui.getPendingApprovals?.() ?? 0), 0);
40
43
  }
44
+ getPendingOperations() {
45
+ return [...this.interfaces.values()].reduce((sum, ui) => sum + (ui.getPendingOperations?.() ?? 0), 0);
46
+ }
47
+ async stopPendingOperations() {
48
+ await Promise.allSettled([...this.interfaces.values()].map(ui => ui.stopPendingOperations?.()));
49
+ }
41
50
  stop() {
42
51
  return this.stopping ??= this.stopInterfaces();
43
52
  }
@@ -20,10 +20,13 @@ export interface EngineCustomUiHook {
20
20
  handleCustomCallback?(scopeId: string, data: string, locale: AppLocale, messageId?: number, event?: ChannelCallbackEvent): Promise<boolean>;
21
21
  handleCustomCommand?(scopeId: string, command: string, args: string, locale: AppLocale, event?: ChannelTextEvent): Promise<boolean>;
22
22
  handleCustomInbound?(event: ChannelTextEvent, locale: AppLocale): boolean | Promise<boolean>;
23
+ isSensitiveInbound?(event: ChannelTextEvent): boolean;
23
24
  }
24
25
  export interface BackendUi extends EngineCustomUiHook {
25
26
  /** Recognize owned buttons even after the user switches to another backend (e.g. pending approvals). */
26
27
  ownsCallback?(data: string): boolean;
27
28
  getPendingApprovals?(): number;
29
+ getPendingOperations?(): number;
30
+ stopPendingOperations?(): Promise<void>;
28
31
  stop?(): Promise<void>;
29
32
  }
@@ -14,6 +14,8 @@ export interface ChannelTextEvent {
14
14
  chatType: string;
15
15
  userId: string;
16
16
  text: string;
17
+ /** Durable replay marker for administrative input whose secret was not retained. */
18
+ redacted?: boolean;
17
19
  messageId: number;
18
20
  mediaGroupId?: string | null;
19
21
  attachments: InboundAttachment[];
@@ -282,12 +282,15 @@ export class UnifiedChannelOrchestrator {
282
282
  async receiveInbound(id, inbound) {
283
283
  if (this.stopped || !this.ownsScope(inbound.event.scopeId))
284
284
  return;
285
- if (!this.store.channelInbox.accept(id, inbound))
285
+ const retained = inbound.kind === 'text' && (this.backends.sensitiveInboundOwner(inbound.event)
286
+ || (this.store.getServiceInteraction(inbound.event.scopeId, 'sensitive-input') && !parseCommand(inbound.event.text)))
287
+ ? { kind: 'text', event: { ...inbound.event, text: '', attachments: [], entities: [], redacted: true } } : inbound;
288
+ if (!this.store.channelInbox.accept(id, retained))
286
289
  return;
287
290
  await this.intakeOperations.run(id, async () => {
288
291
  if (this.stopped)
289
292
  return;
290
- if (!this.store.channelInbox.accept(id, inbound))
293
+ if (!this.store.channelInbox.accept(id, retained))
291
294
  return;
292
295
  if (inbound.kind === 'text')
293
296
  await this.handleText(inbound.event);
@@ -562,6 +565,7 @@ export class UnifiedChannelOrchestrator {
562
565
  await this.bot.stop();
563
566
  this.removeInboundConsumer?.();
564
567
  this.removeInboundConsumer = null;
568
+ await this.backends.stopPendingOperations();
565
569
  await this.intakeOperations.idle();
566
570
  for (const timer of this.stalePanelDeleteTimers.values())
567
571
  clearTimeout(timer);
@@ -577,7 +581,7 @@ export class UnifiedChannelOrchestrator {
577
581
  }
578
582
  isIdleForServiceUpdate() {
579
583
  return !this.store.taskJournal.listUnfinished().some(task => this.ownsScope(task.event.scopeId)) && this.activeTurns.size === 0 && this.settlements.size === 0 && this.scopeOperations.isIdle() && this.messageOperations.isIdle() && this.getPendingApprovals() === 0 &&
580
- !this.store.listQueuedTurnInputs().some(input => this.ownsScope(input.scopeId));
584
+ this.backends.getPendingOperations() === 0 && !this.store.listQueuedTurnInputs().some(input => this.ownsScope(input.scopeId));
581
585
  }
582
586
  getPendingApprovals() { return this.backends.getPendingApprovals(); }
583
587
  getActiveTurnsCount() {
@@ -647,13 +651,32 @@ export class UnifiedChannelOrchestrator {
647
651
  async handleText(event) {
648
652
  if (this.stopped || !this.ownsScope(event.scopeId))
649
653
  return;
654
+ const scopeId = event.scopeId;
655
+ const locale = this.store.getChatSettings(scopeId)?.locale ?? 'zh';
656
+ const sensitiveState = this.store.getServiceInteraction(scopeId, 'sensitive-input');
657
+ if (sensitiveState && parseCommand(event.text)) {
658
+ this.store.setServiceInteraction(scopeId, 'sensitive-input', null);
659
+ }
660
+ if (event.redacted) {
661
+ await this.messaging.deleteMessage(scopeId, event.messageId).catch(() => { });
662
+ await this.sendMessage(scopeId, locale === 'zh' ? '上次配置输入的敏感内容未保存。请重新打开配置面板并再次输入。' : 'The sensitive configuration input was not retained. Reopen its setup panel and enter it again.');
663
+ return;
664
+ }
665
+ const sensitiveOwner = this.backends.sensitiveInboundOwner(event);
666
+ if (sensitiveOwner) {
667
+ await sensitiveOwner.handleCustomInbound?.(event, locale);
668
+ return;
669
+ }
670
+ if (sensitiveState && !parseCommand(event.text)) {
671
+ await this.messaging.deleteMessage(scopeId, event.messageId).catch(() => { });
672
+ await this.sendMessage(scopeId, locale === 'zh' ? '对应配置入口当前不可用,此输入未交给模型。请重新打开配置面板,或发送 /cancel 取消。' : 'The configuration handler is unavailable. This input was not sent to a model. Reopen configuration or send /cancel.');
673
+ return;
674
+ }
650
675
  const action = this.messaging.resolveAction?.(event);
651
676
  if (action) {
652
677
  await this.handleCallback(action);
653
678
  return;
654
679
  }
655
- const scopeId = event.scopeId;
656
- const locale = this.store.getChatSettings(scopeId)?.locale ?? 'zh';
657
680
  const parsedCommand = parseCommand(event.text);
658
681
  const addressing = this.messaging.resolveIncoming?.(event, this.config, this.bot.username) ??
659
682
  (parsedCommand ? { kind: 'command', command: parsedCommand } :
@@ -688,8 +711,8 @@ export class UnifiedChannelOrchestrator {
688
711
  }
689
712
  const ui = this.backends.ui(this.getBackendDescriptorForScope(scopeId).id) ?? this.customUi;
690
713
  if (ui?.handleCustomInbound) {
691
- const handled = await ui.handleCustomInbound(event, locale);
692
- if (handled)
714
+ const handled = ui.handleCustomInbound(event, locale);
715
+ if (handled && await handled)
693
716
  return;
694
717
  }
695
718
  if (addressing.kind === 'command') {
@@ -40,7 +40,22 @@ export declare class DshEngineAdapter implements IEngineAdapter {
40
40
  listEfforts(scopeId: string): Promise<SessionConfigSelectOption[]>;
41
41
  selectEffort(scopeId: string, effort: string | null): Promise<void>;
42
42
  setAccess(scopeId: string, access: AccessPresetValue): Promise<void>;
43
- listSessions(scopeId: string, cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse>;
43
+ listSessions(scopeId: string, cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse | {
44
+ sessions: {
45
+ title: string | undefined;
46
+ sessionId: import("@agentclientprotocol/sdk").SessionId;
47
+ cwd: string;
48
+ additionalDirectories?: Array<string>;
49
+ updatedAt?: string | null;
50
+ _meta?: {
51
+ [key: string]: unknown;
52
+ } | null;
53
+ }[];
54
+ nextCursor?: string | null;
55
+ _meta?: {
56
+ [key: string]: unknown;
57
+ } | null;
58
+ }>;
44
59
  isBusy(scopeId: string): boolean;
45
60
  executeTurn(request: EngineTurnRequest): EngineTurnExecution;
46
61
  stop(): Promise<void>;
@@ -10,11 +10,15 @@ export function createDshBackend(config, store, logger, messaging) {
10
10
  createUi: host => ({
11
11
  ownsCallback: data => data.startsWith('dsh:'),
12
12
  getPendingApprovals: () => ui.pendingApprovals,
13
+ getPendingOperations: () => ui.pendingOperations,
14
+ stopPendingOperations: () => ui.stopPendingOperations(),
13
15
  stop: () => ui.stop(),
14
16
  renderCustomStatus: async (scopeId, locale) => ui.status(scopeId, locale),
15
17
  renderSetupMenu: async (scopeId, locale, messageId) => { await ui.setup(scopeId, locale, host, messageId); return true; },
16
18
  renderModelsMenu: async (scopeId, locale, messageId) => { await ui.models(scopeId, locale, host, messageId); return true; },
17
19
  handleCustomCommand: (scopeId, command, args, locale) => ui.command(scopeId, command, args, locale, host),
20
+ isSensitiveInbound: event => ui.isSensitiveInbound(event),
21
+ handleCustomInbound: (event, locale) => ui.inbound(event, locale, host),
18
22
  handleCustomCallback: (scopeId, data, locale, _messageId, event) => ui.callback(scopeId, data, locale, host, event),
19
23
  }),
20
24
  };
@@ -10,6 +10,10 @@ export interface DshClientOptions {
10
10
  patches: string[];
11
11
  runtimeDir: string;
12
12
  startupTimeoutMs: number;
13
+ credentialControl?: {
14
+ requestPath: string;
15
+ resultPath: string;
16
+ };
13
17
  }
14
18
  /** An owned ACP process; protocol stdout stays separate from diagnostics. */
15
19
  export declare class DshClient extends EventEmitter {
@@ -25,6 +29,7 @@ export declare class DshClient extends EventEmitter {
25
29
  private policyPath;
26
30
  private policyWrite;
27
31
  private stderr;
32
+ private titlesDirectory;
28
33
  permissionHandler?: (request: RequestPermissionRequest, signal: AbortSignal) => Promise<RequestPermissionResponse>;
29
34
  cancelPermissions?: () => void;
30
35
  constructor(options: DshClientOptions, scopeId: string, logger: Logger);
@@ -38,7 +43,23 @@ export declare class DshClient extends EventEmitter {
38
43
  session(cwd: string, sessionId?: string | null): Promise<string>;
39
44
  configOptions(sessionId: string): SessionConfigOption[];
40
45
  setConfig(sessionId: string, configId: string, value: string): Promise<void>;
41
- listSessions(cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse>;
46
+ listSessions(cursor?: string): Promise<import("@agentclientprotocol/sdk").ListSessionsResponse | {
47
+ sessions: {
48
+ title: string | undefined;
49
+ sessionId: import("@agentclientprotocol/sdk").SessionId;
50
+ cwd: string;
51
+ additionalDirectories?: Array<string>;
52
+ updatedAt?: string | null;
53
+ _meta?: {
54
+ [key: string]: unknown;
55
+ } | null;
56
+ }[];
57
+ nextCursor?: string | null;
58
+ _meta?: {
59
+ [key: string]: unknown;
60
+ } | null;
61
+ }>;
62
+ private readSessionTitles;
42
63
  closeSession(sessionId: string): Promise<void>;
43
64
  stop(): Promise<void>;
44
65
  private dispose;
@@ -4,7 +4,7 @@ import { EventEmitter } from 'node:events';
4
4
  import fs from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
- import { createHash } from 'node:crypto';
7
+ import { createHash, randomUUID } from 'node:crypto';
8
8
  import { createRequire } from 'node:module';
9
9
  import { ClientApp, ndJsonStream, PROTOCOL_VERSION } from '@agentclientprotocol/sdk';
10
10
  /** An owned ACP process; protocol stdout stays separate from diagnostics. */
@@ -21,6 +21,7 @@ export class DshClient extends EventEmitter {
21
21
  policyPath = '';
22
22
  policyWrite = Promise.resolve();
23
23
  stderr = '';
24
+ titlesDirectory = '';
24
25
  permissionHandler;
25
26
  cancelPermissions;
26
27
  constructor(options, scopeId, logger) {
@@ -65,6 +66,25 @@ export class DshClient extends EventEmitter {
65
66
  pluginPath = pluginPath.replace(/\.js$/, '.ts');
66
67
  }
67
68
  await fs.writeFile(patchPath, `- insert:\n - id: foxclaw-permissions\n name: ${JSON.stringify(pathToFileURL(pluginPath).href)}\n config:\n policyPath: ${JSON.stringify(this.policyPath)}\n`, { mode: 0o600 });
69
+ this.titlesDirectory = path.join(path.dirname(this.policyPath), `titles-${randomUUID()}`);
70
+ let titlesPlugin = fileURLToPath(new URL('./session_titles_plugin.js', import.meta.url));
71
+ try {
72
+ await fs.access(titlesPlugin);
73
+ }
74
+ catch {
75
+ titlesPlugin = titlesPlugin.replace(/\.js$/, '.ts');
76
+ }
77
+ await fs.appendFile(patchPath, `- insert:\n - id: foxclaw-session-titles\n name: ${JSON.stringify(pathToFileURL(titlesPlugin).href)}\n config:\n directory: ${JSON.stringify(this.titlesDirectory)}\n`);
78
+ if (this.options.credentialControl) {
79
+ let credentialsPlugin = fileURLToPath(new URL('./credentials_plugin.js', import.meta.url));
80
+ try {
81
+ await fs.access(credentialsPlugin);
82
+ }
83
+ catch {
84
+ credentialsPlugin = credentialsPlugin.replace(/\.js$/, '.ts');
85
+ }
86
+ await fs.appendFile(patchPath, `- insert:\n - id: foxclaw-credentials\n name: ${JSON.stringify(pathToFileURL(credentialsPlugin).href)}\n config:\n requestPath: ${JSON.stringify(this.options.credentialControl.requestPath)}\n resultPath: ${JSON.stringify(this.options.credentialControl.resultPath)}\n`);
87
+ }
68
88
  const args = ['--profile', this.options.profile, ...this.options.patches.flatMap(p => ['--patch', path.resolve(p)]), '--patch', patchPath];
69
89
  const env = { ...process.env, ...(this.options.home ? { DSH_HOME: this.options.home } : {}) };
70
90
  let command = this.options.cliBin;
@@ -162,7 +182,50 @@ export class DshClient extends EventEmitter {
162
182
  await this.start();
163
183
  if (!this.capabilities.list)
164
184
  throw new Error('This DSH profile cannot list sessions');
165
- return this.agent.request('session/list', cursor ? { cursor } : {});
185
+ const page = await this.agent.request('session/list', cursor ? { cursor } : {});
186
+ const unnamed = page.sessions.filter(session => !session.title?.trim());
187
+ if (unnamed.length === 0)
188
+ return page;
189
+ const titles = await this.readSessionTitles(unnamed.map(session => session.sessionId));
190
+ return { ...page, sessions: page.sessions.map(session => ({ ...session, title: session.title?.trim() ? session.title : titles[session.sessionId] })) };
191
+ }
192
+ async readSessionTitles(ids) {
193
+ const directory = this.titlesDirectory;
194
+ try {
195
+ await fs.access(path.join(directory, 'ready'));
196
+ }
197
+ catch {
198
+ return {};
199
+ }
200
+ const requestPath = path.join(directory, `${randomUUID()}.request`);
201
+ const responsePath = requestPath.replace(/\.request$/, '.response');
202
+ try {
203
+ await fs.writeFile(`${requestPath}.tmp`, JSON.stringify(ids), { mode: 0o600 });
204
+ await fs.rename(`${requestPath}.tmp`, requestPath);
205
+ const deadline = Date.now() + 5000;
206
+ while (this.connected && Date.now() < deadline) {
207
+ try {
208
+ const result = JSON.parse(await fs.readFile(responsePath, 'utf8'));
209
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
210
+ return Object.fromEntries(Object.entries(result).filter(([id, title]) => ids.includes(id) && typeof title === 'string'));
211
+ }
212
+ return {};
213
+ }
214
+ catch (error) {
215
+ if (error.code !== 'ENOENT')
216
+ throw error;
217
+ }
218
+ await new Promise(resolve => setTimeout(resolve, 50));
219
+ }
220
+ this.logger.warn('dsh.session_titles.timeout', { scopeId: this.scopeId });
221
+ }
222
+ catch {
223
+ this.logger.warn('dsh.session_titles.unavailable', { scopeId: this.scopeId });
224
+ }
225
+ finally {
226
+ await Promise.all([requestPath, responsePath, `${requestPath}.tmp`].map(file => fs.rm(file, { force: true }).catch(() => { })));
227
+ }
228
+ return {};
166
229
  }
167
230
  async closeSession(sessionId) {
168
231
  await this.agent.request('session/close', { sessionId });
@@ -180,6 +243,8 @@ export class DshClient extends EventEmitter {
180
243
  this.optionsBySession.clear();
181
244
  if (!child || !child.pid) {
182
245
  connection?.close();
246
+ if (this.titlesDirectory)
247
+ await fs.rm(this.titlesDirectory, { recursive: true, force: true });
183
248
  return;
184
249
  }
185
250
  const exited = new Promise(resolve => child.once('exit', () => resolve()));
@@ -197,5 +262,7 @@ export class DshClient extends EventEmitter {
197
262
  connection?.close();
198
263
  if (this.process === child)
199
264
  this.process = null;
265
+ if (this.titlesDirectory)
266
+ await fs.rm(this.titlesDirectory, { recursive: true, force: true });
200
267
  }
201
268
  }
@@ -0,0 +1,20 @@
1
+ import { type DshClientOptions } from './client.js';
2
+ import type { Logger } from '../logger.js';
3
+ export interface DshCredentialInfo {
4
+ configured: boolean;
5
+ writable: boolean;
6
+ }
7
+ /** Owns ephemeral native credential control processes; no model/session requests. */
8
+ export declare class DshCredentials {
9
+ private readonly options;
10
+ private readonly logger;
11
+ private readonly jobs;
12
+ private stopped;
13
+ constructor(options: DshClientOptions, logger: Logger);
14
+ get pendingOperations(): number;
15
+ describe(): Promise<DshCredentialInfo>;
16
+ set(value: string): Promise<DshCredentialInfo>;
17
+ private run;
18
+ private execute;
19
+ stop(): Promise<void>;
20
+ }
@@ -0,0 +1,72 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { setTimeout as delay } from 'node:timers/promises';
5
+ import { DshClient } from './client.js';
6
+ /** Owns ephemeral native credential control processes; no model/session requests. */
7
+ export class DshCredentials {
8
+ options;
9
+ logger;
10
+ jobs = new Set();
11
+ stopped = false;
12
+ constructor(options, logger) {
13
+ this.options = options;
14
+ this.logger = logger;
15
+ }
16
+ get pendingOperations() { return this.jobs.size; }
17
+ describe() { return this.run('describe'); }
18
+ set(value) { return this.run('set', value); }
19
+ run(operation, value) {
20
+ if (this.stopped)
21
+ return Promise.reject(new Error('DSH credentials stopped'));
22
+ const controller = new AbortController();
23
+ const done = this.execute(operation, value, controller.signal);
24
+ const job = { controller, done };
25
+ this.jobs.add(job);
26
+ void done.finally(() => this.jobs.delete(job)).catch(() => { });
27
+ return done;
28
+ }
29
+ async execute(operation, value, signal) {
30
+ const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'foxclaw-dsh-credential-'));
31
+ const control = { requestPath: path.join(runtimeDir, 'request.json'), resultPath: path.join(runtimeDir, 'result.json') };
32
+ const client = new DshClient({ ...this.options, runtimeDir, credentialControl: control }, 'credentials', this.logger);
33
+ try {
34
+ signal.throwIfAborted();
35
+ await fs.writeFile(control.requestPath, JSON.stringify({ operation, value }), { mode: 0o600, flag: 'wx' });
36
+ await client.setAccess('read-only');
37
+ await client.start(signal);
38
+ signal.throwIfAborted();
39
+ const deadline = Date.now() + this.options.startupTimeoutMs;
40
+ let text;
41
+ for (;;) {
42
+ signal.throwIfAborted();
43
+ try {
44
+ text = await fs.readFile(control.resultPath, 'utf8');
45
+ break;
46
+ }
47
+ catch (error) {
48
+ if (error.code !== 'ENOENT' || Date.now() >= deadline)
49
+ throw error;
50
+ await delay(25, undefined, { signal });
51
+ }
52
+ }
53
+ const result = JSON.parse(text);
54
+ if (!result.ok || !result.info)
55
+ throw new Error(result.error || 'DSH credential operation unavailable');
56
+ return result.info;
57
+ }
58
+ catch {
59
+ throw new Error('DSH 凭据操作未完成,请检查本机凭据配置、权限及环境覆盖后重试。 / DSH credential operation failed; check configuration, permissions and environment overrides.');
60
+ }
61
+ finally {
62
+ await client.stop();
63
+ await fs.rm(runtimeDir, { recursive: true, force: true });
64
+ }
65
+ }
66
+ async stop() {
67
+ this.stopped = true;
68
+ for (const job of this.jobs)
69
+ job.controller.abort();
70
+ await Promise.allSettled([...this.jobs].map(job => job.done));
71
+ }
72
+ }
@@ -0,0 +1,19 @@
1
+ interface CredentialInfo {
2
+ configured: boolean;
3
+ writable: boolean;
4
+ source?: unknown;
5
+ }
6
+ interface Context {
7
+ credentials: {
8
+ describe(ref: string): Promise<CredentialInfo>;
9
+ set(ref: string, value: string): Promise<void>;
10
+ };
11
+ }
12
+ export declare const name = "foxclaw-credentials";
13
+ export declare const inject: string[];
14
+ /** One private control request. Use DSH's native locking, validation and storage. */
15
+ export declare function apply(ctx: Context, config: {
16
+ requestPath: string;
17
+ resultPath: string;
18
+ }): Promise<void>;
19
+ export {};
@@ -0,0 +1,30 @@
1
+ import fs from 'node:fs/promises';
2
+ export const name = 'foxclaw-credentials';
3
+ export const inject = ['credentials'];
4
+ /** One private control request. Use DSH's native locking, validation and storage. */
5
+ export async function apply(ctx, config) {
6
+ let result;
7
+ try {
8
+ const request = JSON.parse(await fs.readFile(config.requestPath, 'utf8'));
9
+ const ref = 'DEEPSEEK_API_KEY';
10
+ if (request.operation === 'set') {
11
+ if (typeof request.value !== 'string' || !/^[!-~]+$/.test(request.value))
12
+ throw new Error('Invalid key');
13
+ await ctx.credentials.set(ref, request.value);
14
+ }
15
+ else if (request.operation !== 'describe')
16
+ throw new Error('Unknown credential operation');
17
+ const info = await ctx.credentials.describe(ref);
18
+ result = { ok: true, info: { configured: info.configured, writable: info.writable } };
19
+ }
20
+ catch {
21
+ // Native errors and parser diagnostics must never propagate a key value.
22
+ result = { ok: false, error: 'DSH 无法保存或读取凭据,请检查其凭据配置及文件权限;环境提供的密钥不能在面板中覆盖。 / DSH credential operation failed. Check configuration, file permissions and environment overrides.' };
23
+ }
24
+ finally {
25
+ await fs.rm(config.requestPath, { force: true });
26
+ }
27
+ const temporary = `${config.resultPath}.tmp`;
28
+ await fs.writeFile(temporary, JSON.stringify(result), { mode: 0o600, flag: 'wx' });
29
+ await fs.rename(temporary, config.resultPath);
30
+ }
@@ -0,0 +1,32 @@
1
+ interface SessionQuery {
2
+ readTitle(id: string): Promise<{
3
+ title: string;
4
+ } | undefined>;
5
+ readSession(id: string): Promise<{
6
+ events: Array<{
7
+ type: string;
8
+ data: {
9
+ source?: {
10
+ kind: string;
11
+ };
12
+ content?: Array<{
13
+ type: string;
14
+ text?: string;
15
+ }>;
16
+ };
17
+ }>;
18
+ }>;
19
+ }
20
+ interface Context {
21
+ sessionQuery: SessionQuery;
22
+ effect(callback: () => () => Promise<void>): void;
23
+ }
24
+ export declare const name = "foxclaw-session-titles";
25
+ export declare const inject: string[];
26
+ /** Read native log-backed names without resuming sessions or invoking a model. */
27
+ export declare function readSessionTitle(query: SessionQuery, id: string): Promise<string | undefined>;
28
+ /** A private, per-process mailbox supplements ACP versions that omit titles. */
29
+ export declare function apply(ctx: Context, config: {
30
+ directory: string;
31
+ }): Promise<void>;
32
+ export {};
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ export const name = 'foxclaw-session-titles';
4
+ export const inject = ['sessionQuery'];
5
+ /** Read native log-backed names without resuming sessions or invoking a model. */
6
+ export async function readSessionTitle(query, id) {
7
+ const title = (await query.readTitle(id))?.title;
8
+ if (title?.trim())
9
+ return title;
10
+ const { events } = await query.readSession(id);
11
+ for (const event of events) {
12
+ if (event.type !== 'user/message' || event.data.source?.kind !== 'user')
13
+ continue;
14
+ const text = event.data.content?.filter(block => block.type === 'text').map(block => block.text ?? '').join(' ');
15
+ if (text?.trim())
16
+ return Array.from(text.replace(/\s+/gu, ' ').trim()).slice(0, 120).join('');
17
+ }
18
+ return undefined;
19
+ }
20
+ /** A private, per-process mailbox supplements ACP versions that omit titles. */
21
+ export async function apply(ctx, config) {
22
+ await fs.mkdir(config.directory, { recursive: true, mode: 0o700 });
23
+ let active;
24
+ let stopped = false;
25
+ const poll = async () => {
26
+ for (const file of await fs.readdir(config.directory)) {
27
+ if (!/^[a-f0-9-]+\.request$/.test(file))
28
+ continue;
29
+ const requestPath = path.join(config.directory, file);
30
+ const responsePath = requestPath.replace(/\.request$/, '.response');
31
+ let titles = Object.create(null);
32
+ try {
33
+ const ids = JSON.parse(await fs.readFile(requestPath, 'utf8'));
34
+ if (!Array.isArray(ids) || ids.length > 1000 || !ids.every(id => typeof id === 'string'))
35
+ throw new Error('Invalid session title request');
36
+ const results = await Promise.allSettled(ids.map(async (id) => ({ id, title: await readSessionTitle(ctx.sessionQuery, id) })));
37
+ titles = Object.create(null);
38
+ for (const result of results) {
39
+ if (result.status === 'fulfilled' && result.value.title)
40
+ titles[result.value.id] = result.value.title;
41
+ }
42
+ }
43
+ catch { /* Metadata failure must not break native session listing. */ }
44
+ if (!stopped) {
45
+ await fs.writeFile(`${responsePath}.tmp`, JSON.stringify(titles), { mode: 0o600 });
46
+ await fs.rename(`${responsePath}.tmp`, responsePath);
47
+ }
48
+ await fs.rm(requestPath, { force: true });
49
+ }
50
+ };
51
+ const timer = setInterval(() => {
52
+ if (!active)
53
+ active = poll().catch(() => { }).finally(() => { active = undefined; });
54
+ }, 50);
55
+ ctx.effect(() => async () => {
56
+ stopped = true;
57
+ clearInterval(timer);
58
+ await active;
59
+ await fs.rm(path.join(config.directory, 'ready'), { force: true });
60
+ });
61
+ await fs.writeFile(path.join(config.directory, 'ready'), '', { mode: 0o600 });
62
+ }
package/dist/dsh/ui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AppConfig } from '../config.js';
1
+ import { type AppConfig } from '../config.js';
2
2
  import type { BridgeStore } from '../store/database.js';
3
3
  import type { Logger } from '../logger.js';
4
4
  import type { ChannelPort } from '../core/channel_port.js';
@@ -6,6 +6,7 @@ import type { AppLocale } from '../types.js';
6
6
  import type { ChannelCallbackEvent } from '../core/channel_events.js';
7
7
  import type { BackendUiHost } from '../core/backend_ui.js';
8
8
  import { DshEngineAdapter } from './adapter.js';
9
+ import type { ChannelTextEvent } from '../core/channel_events.js';
9
10
  /** DSH controls shared by the unified bot and the optional dedicated bot. */
10
11
  export declare class DshUi {
11
12
  private readonly config;
@@ -15,7 +16,10 @@ export declare class DshUi {
15
16
  private readonly actions;
16
17
  private readonly approvals;
17
18
  readonly adapter: DshEngineAdapter;
19
+ private readonly credentials;
18
20
  get pendingApprovals(): number;
21
+ get pendingOperations(): number;
22
+ stopPendingOperations(): Promise<void>;
19
23
  constructor(config: AppConfig, store: BridgeStore, logger: Logger, messaging: ChannelPort);
20
24
  private defaultAccess;
21
25
  private copy;
@@ -35,4 +39,9 @@ export declare class DshUi {
35
39
  callback(scopeId: string, data: string, locale: AppLocale, orchestrator: BackendUiHost, event?: ChannelCallbackEvent): Promise<boolean>;
36
40
  private requestPermission;
37
41
  stop(): Promise<void>;
42
+ isSensitiveInbound(event: ChannelTextEvent): boolean;
43
+ inbound(event: ChannelTextEvent, locale: AppLocale, host: BackendUiHost): boolean | Promise<boolean>;
44
+ private saveKey;
45
+ private clearKeyInput;
46
+ private keyInput;
38
47
  }
package/dist/dsh/ui.js CHANGED
@@ -2,8 +2,10 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { randomBytes } from 'node:crypto';
5
+ import { DEFAULT_ENV_PATH } from '../config.js';
5
6
  import { DshClient } from './client.js';
6
7
  import { DshEngineAdapter } from './adapter.js';
8
+ import { DshCredentials } from './credentials.js';
7
9
  /** DSH controls shared by the unified bot and the optional dedicated bot. */
8
10
  export class DshUi {
9
11
  config;
@@ -13,7 +15,10 @@ export class DshUi {
13
15
  actions = new Map();
14
16
  approvals = new Map();
15
17
  adapter;
18
+ credentials;
16
19
  get pendingApprovals() { return this.approvals.size; }
20
+ get pendingOperations() { return this.credentials.pendingOperations; }
21
+ stopPendingOperations() { return this.credentials.stop(); }
17
22
  constructor(config, store, logger, messaging) {
18
23
  this.config = config;
19
24
  this.store = store;
@@ -22,6 +27,7 @@ export class DshUi {
22
27
  if (!config.dsh)
23
28
  throw new Error('DSH backend is not configured');
24
29
  const dsh = config.dsh;
30
+ this.credentials = new DshCredentials(dsh, logger);
25
31
  this.adapter = new DshEngineAdapter({
26
32
  createClient: scopeId => {
27
33
  const client = new DshClient(dsh, scopeId, logger);
@@ -82,14 +88,27 @@ export class DshUi {
82
88
  await this.panel(scopeId, `⚙️ **DeepSeek Harness**\n\n${this.copy(locale, '模型', 'Model')}: \`${settings?.model ?? 'default'}\`\n${this.copy(locale, '推理档位', 'Reasoning')}: \`${settings?.reasoningEffort ?? 'default'}\`\n${this.copy(locale, '权限', 'Access')}: \`${access}\`\n${this.copy(locale, '会话', 'Session')}: \`${binding?.threadId || 'new'}\`\n${this.copy(locale, '工作目录', 'Directory')}: \`${binding?.cwd || this.config.defaultCwd}\``, rows, orchestrator, messageId);
83
89
  }
84
90
  async models(scopeId, locale, orchestrator, messageId) {
85
- const models = await this.adapter.listModels(scopeId);
91
+ const envPath = this.config.envPath || DEFAULT_ENV_PATH;
92
+ const guidance = this.copy(locale, `首次使用 DeepSeek:点击“配置 API Key”,在与 Bot 的私聊中添加密钥。保存到 DSH 原生凭据存储,已有凭据直接复用。\n\n如果 \`${envPath}\` 中已设置 DEEPSEEK_API_KEY,则优先使用该环境配置,面板不能覆盖;其他供应商沿用 DSH 原生配置。`, `First-time DeepSeek setup: select “Configure API Key” and add your key in a private chat with the bot. Keys are saved in DSH's native credential store; existing credentials are reused.\n\nDEEPSEEK_API_KEY in \`${envPath}\` takes precedence and cannot be overwritten here. Configure other providers through DSH.`);
93
+ const title = this.copy(locale, '🎯 **DSH 模型**', '🎯 **DSH models**');
94
+ const models = await this.adapter.listModels(scopeId).catch(async () => {
95
+ await this.panel(scopeId, `${title}\n\n${this.copy(locale, '暂时无法读取模型列表,请检查 DSH 配置后重试。', 'Model list unavailable. Check DSH configuration and retry.')}\n\n${guidance}`, [
96
+ [{ text: this.copy(locale, '🔑 配置 API Key', '🔑 Configure API Key'), callback_data: 'dsh:credentials' }],
97
+ [{ text: this.copy(locale, '🔄 重试', '🔄 Retry'), callback_data: 'dsh:models' }],
98
+ [{ text: this.copy(locale, '返回设置', 'Back'), callback_data: 'engine:setup:main' }],
99
+ ], orchestrator, messageId);
100
+ return null;
101
+ });
102
+ if (!models)
103
+ return;
86
104
  const current = this.store.getChatSettings(scopeId)?.model;
87
105
  const buttons = [this.button(scopeId, 'model', '', this.copy(locale, '默认模型', 'Default model')), ...models.map(model => this.button(scopeId, 'model', model.id, `${model.id === current ? '✓ ' : ''}${model.name}`))];
88
106
  const rows = [];
89
107
  for (let index = 0; index < buttons.length; index += 2)
90
108
  rows.push(buttons.slice(index, index + 2));
109
+ rows.push([{ text: this.copy(locale, '🔑 配置 API Key', '🔑 Configure API Key'), callback_data: 'dsh:credentials' }]);
91
110
  rows.push([{ text: this.copy(locale, '返回设置', 'Back'), callback_data: 'engine:setup:main' }]);
92
- await this.panel(scopeId, this.copy(locale, '🎯 **DSH 模型**', '🎯 **DSH models**'), rows, orchestrator, messageId);
111
+ await this.panel(scopeId, `${title}\n\n${guidance}`, rows, orchestrator, messageId);
93
112
  }
94
113
  async efforts(scopeId, locale, orchestrator, messageId) {
95
114
  const choices = await this.adapter.listEfforts(scopeId);
@@ -122,7 +141,12 @@ export class DshUi {
122
141
  }
123
142
  async threads(scopeId, locale, orchestrator, messageId, cursor) {
124
143
  const page = await this.adapter.listSessions(scopeId, cursor);
125
- const rows = page.sessions.map(session => [this.button(scopeId, 'open', session.sessionId, (session.title || session.sessionId).slice(0, 60), session.cwd)]);
144
+ const rows = page.sessions.map(session => {
145
+ const workspace = session.cwd.split(/[\\/]/).filter(Boolean).at(-1) || session.cwd;
146
+ const fallback = `${this.copy(locale, '未命名会话', 'Untitled session')} · ${workspace} · ${session.sessionId.slice(-8)}`;
147
+ const title = session.title?.replace(/\s+/gu, ' ').trim() || fallback;
148
+ return [this.button(scopeId, 'open', session.sessionId, Array.from(title).slice(0, 60).join(''), session.cwd)];
149
+ });
126
150
  if (page.nextCursor)
127
151
  rows.push([this.button(scopeId, 'page', page.nextCursor, this.copy(locale, '下一页', 'Next page'))]);
128
152
  rows.push([{ text: this.copy(locale, '返回设置', 'Back'), callback_data: 'engine:setup:main' }]);
@@ -172,6 +196,9 @@ export class DshUi {
172
196
  async command(scopeId, command, args, locale, orchestrator) {
173
197
  try {
174
198
  switch (command) {
199
+ case 'cancel':
200
+ await orchestrator.sendMessage(scopeId, this.copy(locale, '配置输入已取消。', 'Configuration input cancelled.'));
201
+ return true;
175
202
  case 'setup':
176
203
  await this.setup(scopeId, locale, orchestrator);
177
204
  return true;
@@ -249,6 +276,7 @@ export class DshUi {
249
276
  }
250
277
  }
251
278
  async callback(scopeId, data, locale, orchestrator, event) {
279
+ this.clearKeyInput(scopeId);
252
280
  const answer = (text = '') => this.messaging.answerCallback(event?.callbackQueryId ?? '', text);
253
281
  if (!data.startsWith('dsh:')) {
254
282
  if (orchestrator.getBackendDescriptorForScope(scopeId).engineType === 'dsh' && data.startsWith('engine:') && !['engine:setup:main', 'engine:setup:models', 'engine:setup:active_mode', 'engine:setup:new', 'engine:setup:backend'].includes(data) && !data.startsWith('engine:backend:')) {
@@ -275,7 +303,25 @@ export class DshUi {
275
303
  }
276
304
  await answer();
277
305
  try {
278
- if (data === 'dsh:models')
306
+ if (data === 'dsh:credentials' || data === 'dsh:credentials:add') {
307
+ if (!event || event.chatId !== event.userId) {
308
+ await orchestrator.sendMessage(scopeId, this.copy(locale, '请在与 Bot 的私聊中配置 API Key。', 'Configure API keys in a private chat with the bot.'));
309
+ return true;
310
+ }
311
+ const info = await this.credentials.describe();
312
+ if (data === 'dsh:credentials:add' && info.writable) {
313
+ this.store.setServiceInteraction(scopeId, 'sensitive-input', JSON.stringify({ owner: 'dsh:api-key', expires: Date.now() + 5 * 60_000 }));
314
+ await this.panel(scopeId, this.copy(locale, '🔑 发送 DeepSeek API Key(仅密钥本身)。\n输入消息会尝试删除,密钥不会进入模型或任务记录。保存后本机所有 DSH 会话共用;不会发起付费请求验证。\n发送 /cancel 或点击返回取消。', '🔑 Send your DeepSeek API Key only.\nWe will attempt to delete the input message; it will not enter models or task records. Local DSH sessions share the stored key. No paid validation request is made.\nSend /cancel or select Back to cancel.'), [[{ text: this.copy(locale, '返回模型', 'Back to models'), callback_data: 'dsh:models' }]], orchestrator, event.messageId);
315
+ }
316
+ else {
317
+ const keyboard = [];
318
+ if (info.writable)
319
+ keyboard.push([{ text: this.copy(locale, info.configured ? '更新 API Key' : '添加 API Key', info.configured ? 'Update API Key' : 'Add API Key'), callback_data: 'dsh:credentials:add' }]);
320
+ keyboard.push([{ text: this.copy(locale, '返回模型', 'Back to models'), callback_data: 'dsh:models' }]);
321
+ await this.panel(scopeId, `🔑 **DeepSeek API Key**\n\n${this.copy(locale, info.configured ? '状态:已配置' : '状态:未配置', info.configured ? 'Status: configured' : 'Status: not configured')}\n${info.writable ? this.copy(locale, '由 DSH 保存,可从此面板添加或更新。', 'DSH stores the key; add or update it here.') : this.copy(locale, '来自启动环境,只读。请移除对应环境变量并重启后再使用面板管理。', 'Supplied by the launch environment, read-only. Remove that variable and restart to manage the key here.')}`, keyboard, orchestrator, event.messageId);
322
+ }
323
+ }
324
+ else if (data === 'dsh:models')
279
325
  await this.models(scopeId, locale, orchestrator, event?.messageId);
280
326
  else if (data === 'dsh:efforts')
281
327
  await this.efforts(scopeId, locale, orchestrator, event?.messageId);
@@ -337,6 +383,62 @@ export class DshUi {
337
383
  for (const pending of this.approvals.values())
338
384
  pending.resolve({ outcome: { outcome: 'cancelled' } });
339
385
  this.actions.clear();
386
+ await this.credentials.stop();
340
387
  await this.adapter.stop();
341
388
  }
389
+ isSensitiveInbound(event) {
390
+ if (!this.keyInput(event.scopeId))
391
+ return false;
392
+ if (event.text.trim().startsWith('/')) {
393
+ this.clearKeyInput(event.scopeId);
394
+ return false;
395
+ }
396
+ return true;
397
+ }
398
+ inbound(event, locale, host) {
399
+ if (!this.isSensitiveInbound(event))
400
+ return false;
401
+ return this.saveKey(event, locale, host);
402
+ }
403
+ async saveKey(event, locale, host) {
404
+ const saved = this.keyInput(event.scopeId);
405
+ this.clearKeyInput(event.scopeId);
406
+ await this.messaging.deleteMessage(event.scopeId, event.messageId).catch(() => { });
407
+ const key = event.text.trim();
408
+ const expires = saved?.expires ?? 0;
409
+ if (event.chatType !== 'private' || expires < Date.now() || event.attachments.length || key.length < 8 || !/^[!-~]+$/.test(key)) {
410
+ await host.sendMessage(event.scopeId, this.copy(locale, '输入已过期或格式不正确,请从模型面板重新添加 API Key。', 'Input expired or invalid. Reopen the model panel to add a key.'), [[{ text: this.copy(locale, '配置 API Key', 'Configure API Key'), callback_data: 'dsh:credentials' }]]);
411
+ return true;
412
+ }
413
+ try {
414
+ await this.credentials.set(key);
415
+ await host.sendMessage(event.scopeId, this.copy(locale, '✅ API Key 已保存到 DSH。无需重启,下一次模型请求使用新凭据;尚未验证密钥是否有效。', '✅ API Key saved in DSH. No restart is needed; the next model request uses it. Key validity has not been tested.'), [[{ text: this.copy(locale, '返回模型选择', 'Choose model'), callback_data: 'dsh:models' }]]);
416
+ }
417
+ catch {
418
+ await host.sendMessage(event.scopeId, this.copy(locale, '无法保存 API Key,请检查 DSH 凭据存储及环境覆盖后重试。', 'Cannot save the key. Check DSH credential storage and environment overrides, then retry.'), [[{ text: this.copy(locale, '配置 API Key', 'Configure API Key'), callback_data: 'dsh:credentials' }]]);
419
+ }
420
+ return true;
421
+ }
422
+ clearKeyInput(scopeId) {
423
+ const state = this.store.getServiceInteraction(scopeId, 'sensitive-input');
424
+ if (state) {
425
+ try {
426
+ if (JSON.parse(state).owner === 'dsh:api-key')
427
+ this.store.setServiceInteraction(scopeId, 'sensitive-input', null);
428
+ }
429
+ catch { /* Another administrative interaction owns its own marker. */ }
430
+ }
431
+ }
432
+ keyInput(scopeId) {
433
+ const text = this.store.getServiceInteraction(scopeId, 'sensitive-input');
434
+ if (!text)
435
+ return null;
436
+ try {
437
+ const value = JSON.parse(text);
438
+ return value.owner === 'dsh:api-key' && typeof value.expires === 'number' && Number.isFinite(value.expires) ? { expires: value.expires } : null;
439
+ }
440
+ catch {
441
+ return null;
442
+ }
443
+ }
342
444
  }
package/docs/dsh.md CHANGED
@@ -49,6 +49,8 @@ DEFAULT_CWD=/absolute/path/to/workspace
49
49
 
50
50
  Provider credentials remain managed by DSH. Provider environment variables such as `DEEPSEEK_API_KEY` can be placed in FoxClaw's `.env` and are inherited by DSH. Codex/AGY account rotation and auth synchronization are not used by this backend.
51
51
 
52
+ Use `/models` → **Configure API Key** to add or update a DeepSeek key in a private chat with the bot. FoxClaw attempts to delete the input message and saves the key through DSH's native credential service. The next request uses it without restarting. Keys never enter model prompts, task records or the durable inbox; a restart retains only the input intent, requiring any unsaved key to be entered again. Saving does not make a paid request or verify key validity. Local DSH sessions share credentials. Launch-environment keys take precedence and are read-only; other providers continue using native DSH configuration.
53
+
52
54
  | Control | Purpose |
53
55
  | --- | --- |
54
56
  | `/setup` | Models, reasoning, permissions, queue/interrupt mode, sessions, and plugin configuration |
package/docs/zh/dsh.md CHANGED
@@ -49,6 +49,8 @@ DEFAULT_CWD=/absolute/path/to/workspace
49
49
 
50
50
  Provider 与 API key 沿用 DSH 配置。使用环境变量的 provider,可将相应变量(如 `DEEPSEEK_API_KEY`)加入 FoxClaw `.env`,DSH 子进程会继承。这里不使用 Codex/AGY 的账号轮转或认证同步。
51
51
 
52
+ 也可在 `/models` → **配置 API Key** 中添加或更新 DeepSeek 密钥:在与 Bot 的私聊中输入,FoxClaw 尝试删除输入消息,通过 DSH 原生凭据服务保存,下一次请求生效,无需重启。密钥不进入模型、任务记录或持久收件箱;服务重启后只恢复输入意图,未保存的密钥需要重新输入。保存确认不代表已经调用模型验证密钥。凭据在本机 DSH 会话间共用;启动环境提供的密钥优先且只读,其他供应商继续沿用 DSH 原生配置。
53
+
52
54
  | 操作 | 用途 |
53
55
  | --- | --- |
54
56
  | `/setup` | 模型、推理档位、权限、排队/中断接管、会话与插件配置面板 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
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",