@toddzheng024/dscode-desktop 0.7.39 → 0.7.41

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.
Files changed (38) hide show
  1. package/README.md +2 -2
  2. package/docs/CHANGELOG.md +33 -5
  3. package/docs/account-login.md +3 -1
  4. package/docs/acp.md +17 -3
  5. package/docs/browser-use.md +1 -1
  6. package/docs/custom-providers.md +3 -1
  7. package/docs/distribution.md +61 -33
  8. package/docs/email.md +4 -4
  9. package/docs/hub-distribution.md +3 -1
  10. package/docs/opencode-go.md +4 -2
  11. package/docs/releases/0.7.40.md +13 -0
  12. package/docs/releases/0.7.41.md +25 -0
  13. package/docs/session-metrics.md +9 -0
  14. package/docs/tui-commands.md +10 -7
  15. package/package.json +1 -1
  16. package/plugins/acp/index.mjs +3 -1
  17. package/plugins/acp/models.mjs +47 -0
  18. package/plugins/custom/config.mjs +1 -1
  19. package/plugins/custom/index.mjs +1 -1
  20. package/plugins/desktop/client.mjs +2 -2
  21. package/plugins/dscode/index.mjs +5 -1
  22. package/plugins/email/inbox.mjs +1 -1
  23. package/plugins/i18n/messages.d.mts +2 -0
  24. package/plugins/i18n/messages.mjs +16 -12
  25. package/plugins/providers/openrouter-account.mjs +4 -5
  26. package/plugins/session-metrics/balance.mjs +45 -28
  27. package/plugins/session-metrics/desktop-client.mjs +2 -2
  28. package/plugins/session-metrics/desktop-host.mjs +3 -2
  29. package/plugins/session-metrics/index.mjs +17 -4
  30. package/plugins/session-metrics/view.mjs +3 -3
  31. package/plugins/status/index.mjs +127 -0
  32. package/plugins/status/selection.mjs +6 -0
  33. package/plugins/triggers/scheduler-service.mjs +1 -1
  34. package/plugins/triggers/scheduler.mjs +2 -1
  35. package/plugins/tui-tools/hook-sources.mjs +2 -1
  36. package/plugins/tui-tools/index.mjs +2 -0
  37. package/plugins/tui-tools/update.mjs +6 -4
  38. package/plugins/tui-tools/workspace-discovery.mjs +17 -3
@@ -0,0 +1,47 @@
1
+ import { credentialKeyId, credentialKeyScope } from '@deepseek-ai/dsh-credentials';
2
+ import { isCustomId, keyRef } from '../custom/config.mjs';
3
+ import { providerSpec } from '../providers/catalog.mjs';
4
+
5
+ function credentialReference(ctx, provider) {
6
+ const entry = ctx.llm.listConfigurableProviders?.().find(entry => entry.provider === provider);
7
+ if (!entry) return providerSpec(provider)?.credentialRef;
8
+ const descriptor = ctx.get('settings')?.describe({ redactSecrets: true }).find(item => item.ns === entry.settingsNs);
9
+ const profile = entry.settingsPath.reduce((value, key) => value?.[key], descriptor?.value);
10
+ return typeof profile?.apiKeyEnv === 'string' ? profile.apiKeyEnv : undefined;
11
+ }
12
+
13
+ /** Local configuration facts only: no secret values or inference requests. */
14
+ export async function configuredProvider(ctx, provider) {
15
+ try {
16
+ const credentials = ctx.get('credentials');
17
+ if (isCustomId(provider)) {
18
+ const profile = ctx.get('dscodeCustom')?.providers.find(profile => profile.id === provider);
19
+ return !!profile && (profile.auth === 'none' || (await credentials?.describe(keyRef(provider)))?.configured === true);
20
+ }
21
+ // Harness 0.2 separates the account route from the API-key route.
22
+ if (provider === 'deepseek-account') {
23
+ return (await ctx.get('deepseekAccount')?.getState())?.status === 'credential-stored';
24
+ }
25
+ const ref = credentialReference(ctx, provider);
26
+ if (ref && (await credentials?.describe(ref))?.configured === true) return true;
27
+ const entries = ctx.get('authorization')?.list() ?? [];
28
+ for (const { key } of entries) {
29
+ if (credentialKeyScope(key) === 'llm-pi-ai' && credentialKeyId(key) === provider
30
+ && (await credentials?.describeRecord(key))?.configured === true) return true;
31
+ }
32
+ } catch {
33
+ // One unavailable configuration source must not break the ACP picker.
34
+ }
35
+ return false;
36
+ }
37
+
38
+ /** Scope discovery to ACP; native routing and the shared TUI catalog stay intact. */
39
+ export function configuredModels(ctx) {
40
+ return new Proxy(ctx.llm, {
41
+ get(target, key) {
42
+ if (key === 'listModels') return async provider => await configuredProvider(ctx, provider) ? target.listModels(provider) : [];
43
+ const value = Reflect.get(target, key, target);
44
+ return typeof value === 'function' ? value.bind(target) : value;
45
+ },
46
+ });
47
+ }
@@ -52,7 +52,7 @@ export function validateProfile(input) {
52
52
 
53
53
  /** Whole-file revision + cross-process lock: stale editors never overwrite another Host. */
54
54
  export class CustomStore {
55
- constructor(path = join(homedir(), '.dscode', 'providers.yaml')) { this.path = path; }
55
+ constructor(path = join(process.env.DSCODE_STANDALONE_ROOT && process.env.DSCODE_HOME ? process.env.DSCODE_HOME : join(homedir(), '.dscode'), 'providers.yaml')) { this.path = path; }
56
56
  async read() {
57
57
  let raw;
58
58
  try { raw = await readFile(this.path, 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; raw = ''; }
@@ -138,7 +138,7 @@ export async function apply(ctx, config = {}) {
138
138
  else if (ids.length) registration = ctx.llm.registerAdapter(ids, service.adapter);
139
139
  } });
140
140
  ctx.provide('dscodeCustom', service);
141
- try { await service.refresh(); } catch { ctx.logger.warn('Could not read custom providers; repair ~/.dscode/providers.yaml'); }
141
+ try { await service.refresh(); } catch { ctx.logger.warn('Could not read custom providers; repair %s', service.store.path); }
142
142
  const refresh = () => { void service.refresh().catch(() => ctx.logger.warn('Invalid custom provider edit; retaining the last valid configuration')); };
143
143
  watchFile(service.store.path, { persistent: false, interval: 1000 }, refresh);
144
144
  ctx.effect(() => () => unwatchFile(service.store.path, refresh));
@@ -827,7 +827,7 @@ components.push({
827
827
  id: '@toddzheng024/dscode-desktop',
828
828
  factory: require => {
829
829
  const { createElement: h, useState, useEffect, useRef } = require('react');
830
- const money = (cost, partial = false) => cost === null || partial && cost === 0 ? 'Unknown' : `$${cost.toFixed(4)}${partial ? '+' : ''}`;
830
+ const money = (cost, partial = false, currency = 'USD') => cost === null || partial && cost === 0 ? 'Unknown' : (currency === 'USD' ? `$${cost.toFixed(4)}` : `${cost.toFixed(4)} ${currency}`) + (partial ? '+' : '');
831
831
  const percent = value => value === null ? 'Unknown' : `${value.toFixed(1)}%`;
832
832
  const speed = value => value === null ? 'Unknown' : `${value.toFixed(1)} tokens/s`;
833
833
  const control = { padding: '7px 10px', border: '.5px solid var(--dsw-alias-border-l3, #8886)', borderRadius: 'var(--dsw-radius-md, 6px)', background: 'transparent', color: 'inherit', font: 'inherit' };
@@ -858,7 +858,7 @@ components.push({
858
858
  fact('Pending calls', state.pending ?? 'Unknown'), fact('Context estimate', percent(state.contextPercent)),
859
859
  fact('Input cache share', percent(state.cachePercent)), fact('Smoothed request speed', speed(state.currentTps)),
860
860
  fact('Session average speed', speed(state.averageTps)),
861
- state.balance !== null && fact('Cached provider balance', money(state.balance)),
861
+ state.balance !== null && state.balanceCurrency && fact(state.balanceSource === 'key-limit' ? 'Cached key spending limit' : 'Cached account balance', money(state.balance, false, state.balanceCurrency)),
862
862
  state.subscription && fact('Cached subscription usage', state.subscription),
863
863
  state.budget && fact('Configured cost budget', `${money(state.budget.spent)} / ${money(state.budget.limit)} · ${state.budget.state}`)),
864
864
  h('p', { style: { fontSize: 12 } }, 'Unknown means a reading or price is unavailable. + marks a partial cost. Pending calls have not settled. Cached account figures may lag the provider.'),
@@ -9,8 +9,9 @@ import { boardSummary, DelegateBoard, setBoardSource } from './board.mjs';
9
9
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
10
10
  import { worktreeBase } from '../worktree-subagent/worktree.mjs';
11
11
  import { withChildTarget } from './child-target.mjs';
12
+ import { registerStatus } from '../status/index.mjs';
12
13
  export const name = 'dscode-execution-policy';
13
- export const inject = ['systemPrompt', 'tools', 'agents', 'commands', 'terminals', 'subagents'];
14
+ export const inject = ['systemPrompt', 'tools', 'agents', 'commands', 'terminals', 'subagents', 'llm'];
14
15
  export const SHELL_POLICY = `Use bash as the persistent shell for reading, searching and modifying files. Prefer rg/rg --files, sed and standard CLI tools. Use apply_patch with a standard unified diff on stdin (git apply format, a/ and b/ paths); apply_patch --check validates before writing. It is not the *** Begin Patch format. Quote heredoc delimiters to avoid shell interpolation.
15
16
  Each agent has its own persistent shell, initially in the session workspace. cd, exported variables, functions and background jobs persist only while this shell lives. Timeout, cancellation, exit, /shell reset and process restart discard shell state; resume restores conversation, not an OS process. Never assume an environment from a past session still exists. Inspect pwd when paths matter. Keep long-running processes controlled and clean them up when done.
16
17
  Normal bash remains confined by the active sandbox. After a genuine sandbox denial, shell_retry provides a fresh, one-shot shell with the existing approval/escalation mechanism. Set an explicit absolute workdir and reconstruct needed non-secret setup; it does not inherit the persistent shell's cd, exports, functions or jobs. Use existing credential-aware CLIs; never paste secrets into arguments. Approval rejection is final for that action; do not work around it.
@@ -21,6 +22,7 @@ Delegation to child agents (subagent, subagent_fork) is available at every effor
21
22
  // unrequested commits, invented test suites). Kept separate from the persona so
22
23
  // it stays one reviewable unit and does not lengthen the deployment block.
23
24
  export const CODE_DISCIPLINE = `Code discipline. Fix the problem at its root cause rather than with a surface patch, and keep the change inside the requested scope: do not fix unrelated bugs or failing tests, do not reformat or rename what the task did not ask for, and mention adjacent problems instead of taking them on. Match the surrounding code's style, naming and comment density; do not add inline comments, license or copyright headers unless the task or the neighbouring code requires it. Do not commit, create branches or rewrite history unless the user asks. Do not introduce a test suite to a repository that has none; where tests exist, extend the nearest relevant pattern. When the repository's own history would settle a question, read it with git log or git blame before guessing.`;
25
+ export const MARKDOWN_POLICY = 'Markdown output. Inside a code span, backslashes are literal and cannot escape a backtick. Wrap code containing backticks in a longer backtick delimiter (for example, two backticks around a template literal containing single backticks); add one padding space at both ends when the content starts or ends with a backtick. Use a fenced code block for multiline code, with a fence longer than any matching run inside it. Preserve literal backslashes and code text. Keep prose and punctuation outside code spans. For long file paths in explanations, prefer a short filename label with the full path as the link target instead of repeating the full path inline.';
24
26
 
25
27
  // Claude-Code-derived working discipline: instruction precedence, acting instead
26
28
  // of re-deriving, and correction economy. The memory and session sections already
@@ -61,10 +63,12 @@ const DOCS_REPO = 'https://github.com/qiz029/dscode/blob/main/docs/';
61
63
  export const DOCS_SECTION = `To answer questions about DSCODE itself - what it supports, how a feature is configured, what a command does - treat the bundled user guides as the source of truth instead of guessing: ${existsSync(DOCS_DIR) ? `${DOCS_DIR} plus the repository root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, custom-providers.md, demo.md, triggers.md)` : `the repository documentation at ${DOCS_REPO} plus the root README.md (session-bridge.md, session-communication.md, session-cards.md, memory.md, exec.md, acp.md, email.md, browser-use.md, plugin-hub.md, computer-use.md, skills.md, tui-commands.md, session-metrics.md, dscode-ultra.md, auto-review.md, account-login.md, opencode-go.md, custom-providers.md, demo.md, triggers.md)`}. Read the relevant guide before answering rather than relying on memory of an earlier session. docs/CONTEXT-HANDOFF.md, docs/session-messaging-design.md, docs/cloud-webapp-host.md, docs/triggers-design.md, docs/verification.md, docs/maintainability.md and docs/vendored-tui-upgrade.md are internal development records, not user documentation: never quote them to a user or treat them as a specification. When the guides do not cover something, describe only what the running installation actually does and say plainly that it is not documented.`;
62
64
 
63
65
  export function apply(ctx, config = {}) {
66
+ registerStatus(ctx);
64
67
  const viewer = config.desktop ? 'Delegation board in the right sidebar' : '/delegate-dashboard';
65
68
  ctx.systemPrompt.section({ name: 'dscode:shell-policy', order: 1050, text: SHELL_POLICY });
66
69
  ctx.systemPrompt.section({ name: 'dscode:docs', order: 1052, text: DOCS_SECTION });
67
70
  ctx.systemPrompt.section({ name: 'dscode:code-discipline', order: 1053, text: CODE_DISCIPLINE });
71
+ ctx.systemPrompt.section({ name: 'dscode:markdown', order: 1054, text: MARKDOWN_POLICY });
68
72
  ctx.systemPrompt.section({ name: 'dscode:working-discipline', order: 1054, text: WORKING_DISCIPLINE });
69
73
  ctx.systemPrompt.section({ name: 'dscode:child-policy', order: 1051, text: ({ scope }) => scope?.session?.header?.origin === 'subagent' && scope.session.header.agentPreset === 'dscode'
70
74
  ? 'You are a delegated worker. Complete your assigned task yourself and return a concise result to the parent. You cannot start or wake another agent; ask the parent to make any new delegation decision. Your parent is addressed as / in send_message.' : '' });
@@ -39,7 +39,7 @@ export function emailPrompt(mail) {
39
39
  }, null, 2);
40
40
  }
41
41
 
42
- export function createEmailInbox({ directory = process.env.DSCODE_EMAIL_DIR || join(homedir(), '.dscode', 'email') } = {}) {
42
+ export function createEmailInbox({ directory = process.env.DSCODE_EMAIL_DIR || join(process.env.DSCODE_STANDALONE_ROOT && process.env.DSCODE_HOME ? process.env.DSCODE_HOME : join(homedir(), '.dscode'), 'email') } = {}) {
43
43
  return {
44
44
  directory,
45
45
  receive(value) {
@@ -19,3 +19,5 @@ export function normalizeLanguage(value: unknown): string | null;
19
19
  export function languageName(code: string): string;
20
20
  /** One message in one locale, with `{name}` parameters interpolated. */
21
21
  export function t(locale: string, key: string, params?: Readonly<Record<string, unknown>>): string;
22
+ /** Package-local user preferences; legacy installs retain their existing location. */
23
+ export function terminalStateDirectory(home?: string, env?: NodeJS.ProcessEnv): string;
@@ -41,7 +41,7 @@ export const MESSAGES = {
41
41
  'language.set': 'language → {name}', 'language.title': '/language — interface language', 'language.currentMark': 'current', 'language.unknown': 'Unknown language "{value}". Choose en, zh-CN, zh-TW, ja, ko or es.',
42
42
  'language.saveFailed': 'language save failed: {error}',
43
43
  'doctor.logs.new': 'Only warnings/errors since this TUI version started are recorded.',
44
- 'footer.average': 'avg', 'footer.context': 'ctx', 'footer.cache': 'cache', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'footer.goLimited': 'limited until', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
44
+ 'footer.average': 'avg', 'footer.context': 'ctx', 'footer.cache': 'cache', 'footer.grokUsed': 'used', 'footer.grokResets': 'resets', 'footer.goLimited': 'limited until', 'footer.keyLimit': 'key', 'composer.placeholder': 'type a message', 'composer.shellMode': '! shell mode · esc exits',
45
45
  'compaction.running': 'Compacting context, please wait', 'compaction.confirm.title': 'Switching to {model} will compact the conversation',
46
46
  'compaction.confirm.usage': 'context {used} ≥ compaction threshold {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'the next step compacts older history first',
47
47
  'compaction.confirm.overflow': 'it already exceeds the {window} window: the next request compacts and retries', 'compaction.confirm.hint': 'y switch · n/esc back',
@@ -119,7 +119,7 @@ export const MESSAGES = {
119
119
  'language.set': '语言 → {name}', 'language.title': '/language — 界面语言', 'language.currentMark': '当前', 'language.unknown': '未知语言 "{value}"。可选 en、zh-CN、zh-TW、ja、ko、es。',
120
120
  'language.saveFailed': '语言设置保存失败:{error}',
121
121
  'doctor.logs.new': '仅记录新版 TUI 启动后的 warning/error。',
122
- 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '缓存', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
122
+ 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '缓存', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'footer.keyLimit': '额度', 'composer.placeholder': '输入消息', 'composer.shellMode': '! shell 模式 · esc 退出',
123
123
  'compaction.running': '正在压缩上下文,请稍后', 'compaction.confirm.title': '切换到 {model} 会触发自动压缩',
124
124
  'compaction.confirm.usage': '当前上下文 {used} ≥ 压缩阈值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步开始前会先压缩较早的对话',
125
125
  'compaction.confirm.overflow': '已超出新模型的 {window} 上下文窗口:下一次请求会先压缩再重试', 'compaction.confirm.hint': 'y 确认切换 · n/esc 返回',
@@ -197,7 +197,7 @@ export const MESSAGES = {
197
197
  'language.set': '語言 → {name}', 'language.title': '/language — 介面語言', 'language.currentMark': '目前', 'language.unknown': '未知語言 "{value}"。可選 en、zh-CN、zh-TW、ja、ko、es。',
198
198
  'language.saveFailed': '語言設定儲存失敗:{error}',
199
199
  'doctor.logs.new': '僅記錄新版 TUI 啟動後的 warning/error。',
200
- 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '快取', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
200
+ 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': '快取', 'footer.grokUsed': '已用', 'footer.grokResets': '重置', 'footer.goLimited': '受限至', 'footer.keyLimit': '額度', 'composer.placeholder': '輸入訊息', 'composer.shellMode': '! shell 模式 · esc 離開',
201
201
  'compaction.running': '正在壓縮上下文,請稍候', 'compaction.confirm.title': '切換到 {model} 會觸發自動壓縮',
202
202
  'compaction.confirm.usage': '目前上下文 {used} ≥ 壓縮閾值 {threshold}({window} × {ratio})', 'compaction.confirm.next': '下一步開始前會先壓縮較早的對話',
203
203
  'compaction.confirm.overflow': '已超出新模型的 {window} 上下文視窗:下一次請求會先壓縮再重試', 'compaction.confirm.hint': 'y 確認切換 · n/esc 返回',
@@ -275,7 +275,7 @@ export const MESSAGES = {
275
275
  'language.set': '言語 → {name}', 'language.title': '/language — 表示言語', 'language.currentMark': '現在', 'language.unknown': '不明な言語 "{value}"。en、zh-CN、zh-TW、ja、ko、es から選んでください。',
276
276
  'language.saveFailed': '言語設定の保存に失敗しました:{error}',
277
277
  'doctor.logs.new': 'この TUI バージョンの起動以降の warning/error のみ記録されています。',
278
- 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': 'キャッシュ', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'footer.goLimited': '制限解除', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
278
+ 'footer.average': '平均', 'footer.context': 'ctx', 'footer.cache': 'キャッシュ', 'footer.grokUsed': '使用', 'footer.grokResets': 'リセット', 'footer.goLimited': '制限解除', 'footer.keyLimit': 'キー上限', 'composer.placeholder': 'メッセージを入力', 'composer.shellMode': '! shell モード · esc で終了',
279
279
  'compaction.running': 'コンテキストを圧縮しています。しばらくお待ちください', 'compaction.confirm.title': '{model} に切り替えると自動圧縮が実行されます',
280
280
  'compaction.confirm.usage': '現在のコンテキスト {used} ≥ 圧縮しきい値 {threshold}({window} × {ratio})', 'compaction.confirm.next': '次のステップの前に古い履歴を圧縮します',
281
281
  'compaction.confirm.overflow': '新しいモデルの {window} ウィンドウを超えています。次のリクエストで圧縮して再試行します', 'compaction.confirm.hint': 'y 切り替え · n/esc 戻る',
@@ -353,7 +353,7 @@ export const MESSAGES = {
353
353
  'language.set': '언어 → {name}', 'language.title': '/language — 인터페이스 언어', 'language.currentMark': '현재', 'language.unknown': '알 수 없는 언어 "{value}". en, zh-CN, zh-TW, ja, ko, es 중에서 선택하세요.',
354
354
  'language.saveFailed': '언어 설정 저장 실패: {error}',
355
355
  'doctor.logs.new': '이 TUI 버전 시작 이후의 warning/error만 기록됩니다.',
356
- 'footer.average': '평균', 'footer.context': 'ctx', 'footer.cache': '캐시', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'footer.goLimited': '제한 해제', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
356
+ 'footer.average': '평균', 'footer.context': 'ctx', 'footer.cache': '캐시', 'footer.grokUsed': '사용', 'footer.grokResets': '재설정', 'footer.goLimited': '제한 해제', 'footer.keyLimit': '키 한도', 'composer.placeholder': '메시지를 입력하세요', 'composer.shellMode': '! shell 모드 · esc 종료',
357
357
  'compaction.running': '컨텍스트를 압축하는 중입니다. 잠시만 기다려 주세요', 'compaction.confirm.title': '{model}(으)로 전환하면 자동 압축이 실행됩니다',
358
358
  'compaction.confirm.usage': '현재 컨텍스트 {used} ≥ 압축 임계값 {threshold} ({window} × {ratio})', 'compaction.confirm.next': '다음 단계 전에 이전 대화를 먼저 압축합니다',
359
359
  'compaction.confirm.overflow': '새 모델의 {window} 창을 이미 넘었습니다. 다음 요청에서 압축한 뒤 재시도합니다', 'compaction.confirm.hint': 'y 전환 · n/esc 뒤로',
@@ -431,7 +431,7 @@ export const MESSAGES = {
431
431
  'language.set': 'idioma → {name}', 'language.title': '/language — idioma de la interfaz', 'language.currentMark': 'actual', 'language.unknown': 'Idioma desconocido "{value}". Elige en, zh-CN, zh-TW, ja, ko o es.',
432
432
  'language.saveFailed': 'no se pudo guardar el idioma: {error}',
433
433
  'doctor.logs.new': 'Solo se registran los warnings/errores desde que arrancó esta versión del TUI.',
434
- 'footer.average': 'prom', 'footer.context': 'ctx', 'footer.cache': 'caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'footer.goLimited': 'limitado hasta', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
434
+ 'footer.average': 'prom', 'footer.context': 'ctx', 'footer.cache': 'caché', 'footer.grokUsed': 'usado', 'footer.grokResets': 'reinicia', 'footer.goLimited': 'limitado hasta', 'footer.keyLimit': 'límite key', 'composer.placeholder': 'escribe un mensaje', 'composer.shellMode': '! modo shell · esc sale',
435
435
  'compaction.running': 'Compactando el contexto, espera un momento', 'compaction.confirm.title': 'Cambiar a {model} compactará la conversación',
436
436
  'compaction.confirm.usage': 'contexto {used} ≥ umbral de compactación {threshold} ({window} × {ratio})', 'compaction.confirm.next': 'el siguiente paso compacta antes el historial antiguo',
437
437
  'compaction.confirm.overflow': 'ya supera la ventana de {window}: la siguiente petición compacta y reintenta', 'compaction.confirm.hint': 'y cambiar · n/esc volver',
@@ -512,8 +512,12 @@ export function t(locale, key, params) {
512
512
  return text;
513
513
  }
514
514
 
515
- export function languageFile(home = homedir()) {
516
- return join(home, '.dsh', 'dsh-code', 'language.json');
515
+ export function terminalStateDirectory(home = homedir(), env = process.env) {
516
+ return env.DSCODE_STANDALONE_ROOT && env.DSCODE_HOME ? join(env.DSCODE_HOME, 'tui') : join(home, '.dsh', 'dsh-code');
517
+ }
518
+
519
+ export function languageFile(home = homedir(), env = process.env) {
520
+ return join(terminalStateDirectory(home, env), 'language.json');
517
521
  }
518
522
 
519
523
  /** The stored language, or English; DSCODE_LANGUAGE overrides the file for one process. */
@@ -521,17 +525,17 @@ export function readLanguage({ home = homedir(), env = process.env } = {}) {
521
525
  const override = normalizeLanguage(env.DSCODE_LANGUAGE);
522
526
  if (override) return override;
523
527
  try {
524
- const file = languageFile(home);
528
+ const file = languageFile(home, env);
525
529
  if (!existsSync(file)) return 'en';
526
530
  return normalizeLanguage(JSON.parse(readFileSync(file, 'utf8')).language) ?? 'en';
527
531
  } catch { return 'en'; }
528
532
  }
529
533
 
530
- export function saveLanguage(code, { home = homedir() } = {}) {
534
+ export function saveLanguage(code, { home = homedir(), env = process.env } = {}) {
531
535
  const normalized = normalizeLanguage(code);
532
536
  if (!normalized) throw new Error(`Unknown language: ${code}`);
533
- const file = languageFile(home);
534
- mkdirSync(join(home, '.dsh', 'dsh-code'), { recursive: true });
537
+ const file = languageFile(home, env);
538
+ mkdirSync(terminalStateDirectory(home, env), { recursive: true });
535
539
  writeFileSync(file, JSON.stringify({ language: normalized }, null, 2) + '\n');
536
540
  return normalized;
537
541
  }
@@ -6,8 +6,9 @@ export const OPENROUTER_API = 'https://openrouter.ai/api/v1';
6
6
  export const MANAGEMENT_REF = 'OPENROUTER_MANAGEMENT_KEY';
7
7
 
8
8
  const finite = value => {
9
+ if (!(typeof value === 'number' || typeof value === 'string' && value.trim())) return undefined;
9
10
  const number = Number(value);
10
- return value !== null && value !== undefined && value !== '' && Number.isFinite(number) ? number : undefined;
11
+ return Number.isFinite(number) ? number : undefined;
11
12
  };
12
13
 
13
14
  export class OpenRouterAccountError extends Error {
@@ -45,10 +46,8 @@ export function parseOpenRouterCredits(body) {
45
46
 
46
47
  /** Remaining USD credit limit from a `/key` body; null when the key has no limit. */
47
48
  export function parseOpenRouterKeyRemaining(body) {
48
- const remaining = body?.data?.limit_remaining;
49
- if (remaining == null) return null;
50
- const value = Number(remaining);
51
- return Number.isFinite(value) ? Math.max(0, value) : null;
49
+ const value = finite(body?.data?.limit_remaining);
50
+ return value === undefined ? null : Math.max(0, value);
52
51
  }
53
52
 
54
53
  function keyOf(raw) {
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { OPENROUTER_API, parseOpenRouterCredits, parseOpenRouterKeyRemaining } from '../providers/openrouter-account.mjs';
2
3
 
3
4
  export { parseOpenRouterCredits, parseOpenRouterKeyRemaining };
@@ -8,10 +9,10 @@ export { parseOpenRouterCredits, parseOpenRouterKeyRemaining };
8
9
  // credits (`/credits`) are documented as management-only but are served to
9
10
  // inference keys too; a key refused them falls back to its own remaining limit (`/key`).
10
11
  const SOURCES = {
11
- 'deepseek-official': { env: 'DEEPSEEK_API_KEY', clock: true, requests: ({ key }) => [{ url: 'https://api.deepseek.com/user/balance', key, parse: body => parseBalance(body) }] },
12
+ 'deepseek-official': { env: 'DEEPSEEK_API_KEY', clock: true, requests: ({ key }) => [{ url: 'https://api.deepseek.com/user/balance', key, source: 'account', parse: parseBalanceInfo }] },
12
13
  openrouter: { env: 'OPENROUTER_API_KEY', managementEnv: 'OPENROUTER_MANAGEMENT_KEY', clock: false, requests: ({ key, managementKey }) => [
13
- { url: `${OPENROUTER_API}/credits`, key: managementKey ?? key, parse: parseOpenRouterCredits },
14
- { url: `${OPENROUTER_API}/key`, key, parse: parseOpenRouterKeyRemaining },
14
+ { url: `${OPENROUTER_API}/credits`, key: managementKey || key, source: 'account', parse: body => usdBalance(parseOpenRouterCredits(body), 'account') },
15
+ { url: `${OPENROUTER_API}/key`, key, source: 'key-limit', parse: body => usdBalance(parseOpenRouterKeyRemaining(body), 'key-limit') },
15
16
  ] },
16
17
  };
17
18
  export const BALANCE_PROVIDERS = Object.freeze(Object.keys(SOURCES));
@@ -21,16 +22,29 @@ const snapshots = new Map();
21
22
  let clock = { anchor: null, skewMs: 0 };
22
23
  const snapshotOf = provider => snapshots.get(provider) ?? { balance: null, fetchedAt: 0, pending: false };
23
24
 
24
- /** Best-effort positive USD balance from a `/user/balance` body. */
25
- export function parseBalance(body) {
25
+ const usdBalance = (amount, source) => amount === null ? null : Object.freeze({ amount, currency: 'USD', source });
26
+
27
+ function parseBalanceInfo(body) {
28
+ if (body?.is_available === false) return null;
26
29
  const infos = Array.isArray(body?.balance_infos) ? body.balance_infos : [];
27
- const usd = infos.find(info => String(info?.currency ?? '').toUpperCase() === 'USD') ?? infos[0];
28
- const total = Number(usd?.total_balance);
29
- if (!Number.isFinite(total) || total < 0) return null;
30
- return body?.is_available === false ? null : total;
30
+ const balances = infos.flatMap(info => {
31
+ const value = info?.total_balance, currency = String(info?.currency ?? '').toUpperCase();
32
+ if (!(typeof value === 'number' || typeof value === 'string' && value.trim()) || !/^[A-Z]{3}$/.test(currency)) return [];
33
+ const amount = Number(value);
34
+ return Number.isFinite(amount) && amount >= 0 ? [Object.freeze({ amount, currency, source: 'account' })] : [];
35
+ });
36
+ return balances.find(info => info.currency === 'USD') ?? balances[0] ?? null;
31
37
  }
32
38
 
33
- export function balanceNow(provider = 'deepseek-official') { return snapshotOf(provider).balance; }
39
+ /** Numeric projection retained for callers that do not render currency or source. */
40
+ export function parseBalance(body) { return parseBalanceInfo(body)?.amount ?? null; }
41
+ export function balanceNow(provider = 'deepseek-official') { return balanceInfoNow(provider)?.amount ?? null; }
42
+ export function balanceInfoNow(provider = 'deepseek-official') { return snapshotOf(provider).balance; }
43
+ export function invalidateBalance(provider) { snapshots.delete(provider); }
44
+ export function formatBalance(balance, digits = 2) {
45
+ if (!balance) return '';
46
+ return balance.currency === 'USD' ? `$${balance.amount.toFixed(digits)}` : `${balance.amount.toFixed(digits)} ${balance.currency}`;
47
+ }
34
48
  /** Clock anchored to the last DeepSeek balance response's `Date` header, else the local one. */
35
49
  export function trustedNow() {
36
50
  const local = Date.now();
@@ -42,40 +56,43 @@ export async function refreshBalance(options = {}) {
42
56
  const provider = options.provider ?? 'deepseek-official';
43
57
  const source = SOURCES[provider];
44
58
  if (!source) return null;
45
- const snapshot = snapshotOf(provider);
46
- const key = options.key ?? process.env[source.env];
47
- const managementKey = options.managementKey ?? (source.managementEnv ? process.env[source.managementEnv] : undefined);
59
+ const key = Object.hasOwn(options, 'key') ? options.key : process.env[source.env];
60
+ const managementKey = Object.hasOwn(options, 'managementKey') ? options.managementKey : source.managementEnv ? process.env[source.managementEnv] : undefined;
48
61
  const requests = source.requests({ key, managementKey }).filter(request => request.key);
49
- const mode = managementKey ? 'management' : 'key';
62
+ const identity = createHash('sha256').update(JSON.stringify(requests.map(({ url, key }) => [url, key]))).digest('hex');
63
+ let snapshot = snapshots.get(provider);
64
+ if (!snapshot || snapshot.identity !== identity) {
65
+ snapshot = { balance: null, fetchedAt: 0, pending: false, identity };
66
+ snapshots.set(provider, snapshot);
67
+ }
50
68
  const now = Date.now();
51
- // A newly added (or removed) management key changes the source; it is read at once.
52
- if (snapshot.pending || snapshot.mode === mode && now - snapshot.fetchedAt < CACHE_MS) return snapshot.balance;
69
+ if (requests.length === 0) return null;
70
+ if (snapshot.pending || snapshot.fetchedAt && now - snapshot.fetchedAt < CACHE_MS) return balanceNow(provider);
53
71
  const retrySoon = () => Date.now() - (CACHE_MS - RETRY_MS);
54
- if (requests.length === 0) { snapshots.set(provider, { ...snapshot, mode, fetchedAt: retrySoon(), pending: false }); return snapshot.balance; }
55
72
  const fetchImpl = options.fetch ?? globalThis.fetch;
56
- if (typeof fetchImpl !== 'function') return snapshot.balance;
57
- snapshots.set(provider, { ...snapshot, pending: true });
73
+ if (typeof fetchImpl !== 'function') return balanceNow(provider);
74
+ snapshot.pending = true;
58
75
  try {
59
76
  let response, body, request;
60
77
  for (request of requests) {
61
78
  response = await fetchImpl(request.url, { headers: { Authorization: `Bearer ${request.key}`, Accept: 'application/json' } });
62
- body = await response.json();
79
+ try { body = await response.json(); } catch { body = undefined; }
80
+ if (snapshots.get(provider) !== snapshot) return balanceNow(provider);
63
81
  // A key refused this reading falls through to the next source.
64
82
  if (response.ok || (response.status !== 401 && response.status !== 403)) break;
83
+ if (snapshot.balance?.source === request.source) snapshot.balance = null;
65
84
  }
66
85
  const header = source.clock ? response.headers?.get?.('date') : null;
67
86
  const anchor = header ? Date.parse(header) : NaN;
68
87
  const parsed = response.ok ? request.parse(body) : null;
69
88
  if (Number.isFinite(anchor)) clock = { anchor, skewMs: anchor - Date.now() };
70
- snapshots.set(provider, {
71
- mode,
72
- balance: parsed === null && response.ok ? null : parsed ?? snapshot.balance,
73
- fetchedAt: parsed === null ? retrySoon() : Date.now(),
74
- pending: false,
75
- });
89
+ snapshot.balance = response.ok ? parsed : snapshot.balance?.source === request.source ? snapshot.balance : null;
90
+ snapshot.fetchedAt = parsed === null ? retrySoon() : Date.now();
76
91
  } catch {
77
92
  // A transient failure keeps the last known balance and retries sooner.
78
- snapshots.set(provider, { ...snapshot, mode, fetchedAt: retrySoon(), pending: false });
93
+ if (snapshots.get(provider) === snapshot) snapshot.fetchedAt = retrySoon();
94
+ } finally {
95
+ snapshot.pending = false;
79
96
  }
80
- return snapshotOf(provider).balance;
97
+ return balanceNow(provider);
81
98
  }
@@ -2,7 +2,7 @@ globalThis.__ModuleLoader__.load({
2
2
  id: '@toddzheng024/dscode-desktop',
3
3
  factory: require => {
4
4
  const { createElement: h, useState, useEffect, useRef } = require('react');
5
- const money = (cost, partial = false) => cost === null || partial && cost === 0 ? 'Unknown' : `$${cost.toFixed(4)}${partial ? '+' : ''}`;
5
+ const money = (cost, partial = false, currency = 'USD') => cost === null || partial && cost === 0 ? 'Unknown' : (currency === 'USD' ? `$${cost.toFixed(4)}` : `${cost.toFixed(4)} ${currency}`) + (partial ? '+' : '');
6
6
  const percent = value => value === null ? 'Unknown' : `${value.toFixed(1)}%`;
7
7
  const speed = value => value === null ? 'Unknown' : `${value.toFixed(1)} tokens/s`;
8
8
  const control = { padding: '7px 10px', border: '.5px solid var(--dsw-alias-border-l3, #8886)', borderRadius: 'var(--dsw-radius-md, 6px)', background: 'transparent', color: 'inherit', font: 'inherit' };
@@ -33,7 +33,7 @@ globalThis.__ModuleLoader__.load({
33
33
  fact('Pending calls', state.pending ?? 'Unknown'), fact('Context estimate', percent(state.contextPercent)),
34
34
  fact('Input cache share', percent(state.cachePercent)), fact('Smoothed request speed', speed(state.currentTps)),
35
35
  fact('Session average speed', speed(state.averageTps)),
36
- state.balance !== null && fact('Cached provider balance', money(state.balance)),
36
+ state.balance !== null && state.balanceCurrency && fact(state.balanceSource === 'key-limit' ? 'Cached key spending limit' : 'Cached account balance', money(state.balance, false, state.balanceCurrency)),
37
37
  state.subscription && fact('Cached subscription usage', state.subscription),
38
38
  state.budget && fact('Configured cost budget', `${money(state.budget.spent)} / ${money(state.budget.limit)} · ${state.budget.state}`)),
39
39
  h('p', { style: { fontSize: 12 } }, 'Unknown means a reading or price is unavailable. + marks a partial cost. Pending calls have not settled. Cached account figures may lag the provider.'),
@@ -1,6 +1,6 @@
1
1
  import { clientRequestSchema } from '@deepseek-ai/dsh-client-connection';
2
2
  import { footerFor, goFooterFact, grokFooterFact } from './view.mjs';
3
- import { balanceNow } from './balance.mjs';
3
+ import { balanceInfoNow } from './balance.mjs';
4
4
  import { canonicalProvider } from '../providers/catalog.mjs';
5
5
  import { grokSubscriptionNow } from '../grok/billing.mjs';
6
6
  import { goUsageNow } from '../opencode-go/usage.mjs';
@@ -13,12 +13,13 @@ export function desktopMetricsRequest(ctx, payload) {
13
13
  if (!agent || ctx.agentPresets.composedPreset(agent.ctx) !== 'dscode') throw Error('Usage is available for an open DSCODE session.');
14
14
  const route = agent.session.requestHeader()?.config ?? agent.options;
15
15
  const provider = canonicalProvider(route.provider);
16
+ const balance = balanceInfoNow(provider);
16
17
  return footerFor(agent.id, {}, 0, provider, 'en', (summary, context, _columns, rates) => ({
17
18
  sessionId: agent.id, provider: route.provider, model: route.model,
18
19
  cost: finite(summary.cost), partial: summary.unknown === true, calls: finite(summary.calls), pending: finite(summary.pending),
19
20
  contextPercent: finite(context), cachePercent: finite(summary.cache),
20
21
  currentTps: finite(rates.current), averageTps: finite(rates.average), requestActive: rates.active === true,
21
- balance: finite(balanceNow(provider)),
22
+ balance: finite(balance?.amount), balanceCurrency: balance?.currency ?? null, balanceSource: balance?.source ?? null,
22
23
  subscription: provider === 'grok' ? grokFooterFact(grokSubscriptionNow()) : provider === 'opencode-go' ? goFooterFact(goUsageNow()) : null,
23
24
  turns: (summary.turns ?? []).slice(-50).reverse().map(turn => ({ turn: turn.turn, cost: finite(turn.cost), calls: turn.calls, partial: turn.unknown })),
24
25
  totalTurns: summary.turns?.length ?? 0, unattributed: finite(summary.unattributed),
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
3
3
  import { appendMetric } from './store.mjs';
4
4
  import { estimateCost, priceVersionFor } from './pricing.mjs';
5
5
  import { setMetricSource } from './view.mjs';
6
- import { BALANCE_PROVIDERS, refreshBalance } from './balance.mjs';
6
+ import { BALANCE_PROVIDERS, invalidateBalance, refreshBalance } from './balance.mjs';
7
7
  import { refreshOpenRouterModels } from '../openrouter/models.mjs';
8
8
  import { REPLAY_KIND } from '../openrouter/wire.mjs';
9
9
  import { providerSpec } from '../providers/catalog.mjs';
@@ -35,17 +35,22 @@ export function apply(ctx) {
35
35
  // Remaining balance is best-effort decoration: resolve the key lazily (never
36
36
  // inject the credentials service, so a missing one cannot fail startup), keep
37
37
  // the request on a five-minute cache, and never let it reach the render path.
38
- const credentials = ctx.get?.('credentials');
39
- const refresh = () => Promise.all(BALANCE_PROVIDERS.map(async provider => {
38
+ const generations = new Map();
39
+ let closed = false;
40
+ const refresh = (providers = BALANCE_PROVIDERS) => Promise.all(providers.map(async provider => {
41
+ const generation = (generations.get(provider) ?? 0) + 1;
42
+ generations.set(provider, generation);
40
43
  try {
41
44
  const spec = providerSpec(provider);
42
45
  const secret = async ref => {
43
46
  if (!ref) return undefined;
47
+ const credentials = ctx.get?.('credentials');
44
48
  const resolved = await credentials?.resolve?.(ref);
45
49
  return (typeof resolved === 'string' ? resolved : resolved?.value) || process.env[ref] || undefined;
46
50
  };
47
51
  // An OpenRouter management key, when stored, turns the balance into the account's credits.
48
52
  const [key, managementKey] = await Promise.all([secret(spec.credentialRef), secret(spec.managementRef)]);
53
+ if (closed || generations.get(provider) !== generation) return;
49
54
  await refreshBalance({ provider, key, managementKey });
50
55
  // The listing needs no key, but only a user with an OpenRouter key uses it; the adapter waits for it before its first call.
51
56
  if (provider === 'openrouter' && key) await refreshOpenRouterModels({ home });
@@ -53,10 +58,18 @@ export function apply(ctx) {
53
58
  /* balance stays unknown */
54
59
  }
55
60
  }));
61
+ ctx.on('credentials/reference-updated', ref => {
62
+ const providers = BALANCE_PROVIDERS.filter(provider => {
63
+ const spec = providerSpec(provider);
64
+ return ref === spec.credentialRef || ref === spec.managementRef;
65
+ });
66
+ for (const provider of providers) invalidateBalance(provider);
67
+ return refresh(providers);
68
+ });
56
69
  refresh();
57
70
  const balanceTimer = setInterval(refresh, 5 * 60 * 1000);
58
71
  if (typeof balanceTimer.unref === 'function') balanceTimer.unref();
59
- ctx.effect(() => () => clearInterval(balanceTimer));
72
+ ctx.effect(() => () => { closed = true; clearInterval(balanceTimer); for (const provider of BALANCE_PROVIDERS) invalidateBalance(provider); });
60
73
  const record = (id, entry) => { try { appendMetric(home, id, entry); } catch { ctx.logger.warn('Session cost telemetry could not be saved.'); } };
61
74
  ctx.on('llm/stream', async function* (options, next) {
62
75
  // Plugin calls made for a session carry no sessionId on the wire; they are charged through the async context.
@@ -2,7 +2,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
2
2
  import { readMetrics } from './store.mjs';
3
3
  import { t } from '../i18n/messages.mjs';
4
4
  import { estimateCost, peakEmoji } from './pricing.mjs';
5
- import { balanceNow, trustedNow } from './balance.mjs';
5
+ import { balanceInfoNow, formatBalance, trustedNow } from './balance.mjs';
6
6
  import { grokSubscriptionNow } from '../grok/billing.mjs';
7
7
  import { goUsageNow } from '../opencode-go/usage.mjs';
8
8
  import { canonicalProvider } from '../providers/catalog.mjs';
@@ -166,7 +166,7 @@ function footerFigures(metrics, context, rates, locale = 'en', provider = 'deeps
166
166
  const ctx = figure(Number.isFinite(context) ? `${Math.round(context)}%` : '--', FIGURE.percent);
167
167
  const cache = figure(metrics.cache === null ? '--' : `${metrics.cache.toFixed(1)}%`, FIGURE.share);
168
168
  // The balance belongs to the provider serving the route; only DeepSeek's official one bills by a peak window.
169
- const balance = balanceNow(provider);
169
+ const balance = balanceInfoNow(provider);
170
170
  const spend = metrics.unknown && metrics.cost === 0 ? '--' : `$${metrics.cost.toFixed(2)}${metrics.unknown ? '+' : ''}${metrics.pending ? '…' : ''}`;
171
171
  // The money is the one live figure whose digits are not reserved: they cross a
172
172
  // column a handful of times per session, and reserving them would evict a
@@ -174,7 +174,7 @@ function footerFigures(metrics, context, rates, locale = 'en', provider = 'deeps
174
174
  // provider cannot report is left out instead of parked as `$--`.
175
175
  const dollars = provider === 'grok' ? grokFooterFact(grokSubscriptionNow(), locale)
176
176
  : provider === 'opencode-go' ? goFooterFact(goUsageNow(), locale)
177
- : spend + (balance === null ? '' : ' / $' + balance.toFixed(2)) + (provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : '');
177
+ : spend + (balance === null ? '' : ' / ' + (balance.source === 'key-limit' ? label('footer.keyLimit') + ' ' : '') + formatBalance(balance)) + (provider === 'deepseek-official' ? ' ' + peakEmoji(trustedNow()) : '');
178
178
  // The budget slot rides the money figure: it answers "how much of the session's
179
179
  // limit is spent", which is a fact about the cost, not a second cost. A session
180
180
  // with no limit renders exactly as before, so the footer only grows by choice.