@toddzheng024/dscode-desktop 0.7.38 → 0.7.40

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/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  DSCODE adds a coding preset, browser workflows, cross-session collaboration,
4
4
  memory and task management to the official DeepSeek Harness Desktop application.
5
5
 
6
- **Version:** 0.7.38 · **Harness runtime:** 0.2.0-rc.2
6
+ **Version:** 0.7.40 · **Harness runtime:** 0.2.0-rc.2
7
7
 
8
8
  Preview release candidate; publication is a separate step.
9
9
 
@@ -48,7 +48,7 @@ public release; a candidate build alone does not publish a package.
48
48
  ## Install a published version
49
49
 
50
50
  After this version is available on npm, enter
51
- `@toddzheng024/dscode-desktop@0.7.38` in **Plugins → Add plugin**,
51
+ `@toddzheng024/dscode-desktop@0.7.40` in **Plugins → Add plugin**,
52
52
  review the installation, install, then choose **Enable now**. Using the exact
53
53
  version keeps the installation tied to these instructions and compatibility
54
54
  requirements. The `preview` tag tracks the current Desktop preview release.
package/docs/CHANGELOG.md CHANGED
@@ -6,6 +6,33 @@ Per-release notes live in [`docs/releases/`](releases/); the entries below summa
6
6
 
7
7
  <!-- Add upcoming changes under Unreleased. -->
8
8
 
9
+ ## [Unreleased]
10
+
11
+ ## [0.7.40] - 2026-10-12
12
+
13
+ ### Added
14
+
15
+ - `/status` in the TUI and ACP reports the selected provider, model, currency and a fresh balance without model inference. DeepSeek and OpenRouter report explicit unavailable, unsupported, timeout and failure states; account funds and key spending limits remain distinct.
16
+
17
+ ### Fixed
18
+
19
+ - Invalidate cached footer and Desktop balances when credentials change or are removed, and reject obsolete query completions. Resolve the credential service at refresh time, including when it becomes available after plugin startup. Native credential updates clear old readings immediately; cached amounts retain their currency and distinguish account balances from fallback key spending limits. Malformed amounts remain unknown instead of becoming zero. `/status` continues to query fresh data independently.
20
+ - Send a labeled ACP failure notice before the failed prompt response, with bounded error data and retry guidance. Preserve cancellation and normal completion semantics. Include recognized network error codes and elapsed time in DeepSeek transport summaries without copying raw error causes.
21
+ - Guide model output to use correct Markdown delimiters for code containing backticks and literal backslashes, and concise labels for long file links. This guidance does not rewrite generated code or guarantee that every model response is valid Markdown.
22
+
23
+ ### Changed
24
+
25
+ - Filter ACP model choices to providers with configured credentials or account login and saved custom services with no authentication. Retain the current model when credentials are missing, preserve exact routing and the TUI catalog, and check the Harness 0.2 DeepSeek account and API-key routes separately. This checks local configuration, not endpoint reachability, key validity or per-model access; reconnect after credential changes to refresh the picker.
26
+
27
+ ## [0.7.39] - 2026-10-10
28
+
29
+ ### Fixed
30
+
31
+ - Load the native DeepSeek API-key and Account plugins from the terminal bundle, with both importing its patched shared adapter. Harness 0.2 split the adapter library from its plugin entry points; the 0.7.38 bundle incorrectly mounted the library as a plugin, leaving the API-key route unavailable. Preserve existing provider settings row IDs.
32
+ - Require installed-bundle local diagnostics to have no plugin activation failures, and verify both native DeepSeek provider registrations and model resolution before any fixture adapter is introduced. These checks make provider startup part of the release gate without sending model requests.
33
+
34
+ Version 0.7.38 reached npm and Hub before this fault was found in a fresh public install; its remaining release workflow was cancelled. Upgrade to 0.7.39.
35
+
9
36
  ## [0.7.38] - 2026-10-10
10
37
 
11
38
  The 0.7.37 candidate was cancelled before publication after its Node 22.19.0 coverage check failed. This release includes that upgrade and the test-fixture fix below.
package/docs/acp.md CHANGED
@@ -14,18 +14,32 @@ dscode acp --model custom-your-provider-id/your-model-id
14
14
 
15
15
  Without `--model`, the bridge uses DSCODE's saved default provider and model. Set up credentials and custom services in the normal TUI first with `/login` or `/provider custom`. The stable custom provider ID is recorded in `~/.dscode/providers.yaml`; it is not the service's display name. API keys stay in the existing credential store. Clients supporting ACP configuration options can select an advertised model and reasoning effort after creating a session.
16
16
 
17
+ The ACP model picker lists models from providers with configured credentials or account login, plus saved custom services using no authentication. Credential checks follow each provider's configured reference, including environment variables and saved keys. DeepSeek Account and DeepSeek API-key routes are checked separately. Providers with no recognized configuration metadata are omitted.
18
+
19
+ The current model remains listed even when its credentials are missing, so an explicit selection or resumed session is never silently switched to another model. This exception retains only the current model, not that provider's entire catalog. Configured credentials do not prove that a key is valid, credits remain, the endpoint is reachable, or every advertised model is permitted; filtering makes no inference requests. After changing credentials, reconnect the ACP client to refresh its picker. Saved custom service changes also refresh options in connected sessions.
20
+
21
+ ## Current credential balance
22
+
23
+ Send `/status` as a standalone text message. The command is advertised to clients and returns the selected provider, model, balance information and query time without a model turn. It also works after switching models or resuming a session. Attachments and ordinary text mentioning `/status` remain normal model input. Arguments are rejected with usage guidance.
24
+
25
+ DeepSeek API keys show the official account balance and currency; DeepSeek account login shows recharge and bonus wallets separately. OpenRouter shows account credits and the current key's remaining spending limit separately, plus the effective budget when provided. A key without a spending cap does **not** mean unlimited account funds. If OpenRouter denies account-credit access but permits key inspection, the key information remains available. Only the active inference credential is used, never a separate management key.
26
+
27
+ Each invocation makes a fresh query using the selected route's configured credential reference. Keys and raw provider errors are not printed. Missing credentials, failed queries and timeouts are reported explicitly; they are not shown as zero. The lookup waits at most eight seconds and supports cancellation. Other providers, including custom API endpoints and subscription routes, currently report that balance lookup is unsupported. An overridden DeepSeek/OpenRouter endpoint is not queried through the official billing API. The result is sent to the client without adding a model-history turn; replay of that display depends on the client.
28
+
17
29
  For a source checkout, the equivalent executable is `node` with arguments `["/absolute/path/to/dscode/bin/dscode.mjs", "acp"]`. Source and npm installations use their existing, separate state directories. `--patch FILE` adds a trusted composition overlay and can be repeated. `dscode acp --help` prints usage without starting the runtime or installing a profile.
18
30
 
19
31
  ## Behavior and limits
20
32
 
33
+ - A failed model turn sends a clearly labeled `DSCODE — Request failed` text update before returning JSON-RPC error `-32603`. Error data includes the session ID, a bounded failure code and explanation, and HTTP status when available. The update is a transport diagnostic, not a model-generated answer or a successful completion. Clients that persist text updates can retain it in their history. User cancellation keeps the native `cancelled` result and does not emit a failure notice.
34
+ - DeepSeek transport failures retain recognized low-level network error codes and request elapsed time in their summary. Raw error chains, credentials and response bodies are not added to ACP failure output. A repeated duration alone does not identify the network cause. This is bounded diagnostic feedback, not a complete network trace.
21
35
  - New and resumed ACP agents mount the `dscode` preset, including its tools and provider adapters. The bridge reuses DSH's native ACP implementation rather than translating terminal output.
22
36
  - ACP sessions use the `ask` permission preset. Tool requests requiring approval are sent to the ACP client; the client can allow once, reject, or cancel. Normal sandbox rules still apply. The terminal's automatic model review is not the approval decision maker for these sessions.
23
37
  - Standard session creation, prompts, cancellation, model configuration, listing, resume, close and MCP attachment are inherited from the pinned DSH bridge. Resume reconnects a session without replaying its transcript.
24
- - The inherited bridge identifies itself as `deepseek-harness-acp`. It does not advertise `session/load`, modes, slash commands, terminal delegation, client filesystem operations or interactive question forms. Clients requiring those features need additional integration; a successful protocol handshake does not establish full editor compatibility.
38
+ - The inherited bridge identifies itself as `deepseek-harness-acp`. It advertises `/status`; other terminal slash commands are not exposed. It does not advertise `session/load`, modes, terminal delegation, client filesystem operations or interactive question forms. Clients requiring those features need additional integration; a successful protocol handshake does not establish full editor compatibility.
25
39
  - Title generation, session-card generation and memory generation are disabled in this transport. Configured providers, tools and other integrations remain available; ACP is not an offline mode.
26
40
  - Stdout is reserved for protocol frames. First-install progress and launcher diagnostics go to stderr. Trusted custom patches must also respect this rule.
27
41
  - Closing stdin shuts down the runtime. SIGTERM is forwarded by the launcher. Sessions persist in the same installation's state directory, and a running ACP process holds the normal installation lock against upgrades.
28
42
 
29
43
  ## Verification
30
44
 
31
- `node scripts/verify-acp.mjs` exercises the source CLI with a fixture model and custom API, without paid model requests. Unit tests cover argument validation, preset composition, source and npm launcher routing, cleanup and signal forwarding. Client-specific UI behavior must be verified in the intended editor.
45
+ `node scripts/verify-acp.mjs` exercises the source CLI with a fixture model and custom API, without paid model requests. It checks configured model filtering on creation, selection and resume, rejects unadvertised model selections, and verifies current-model retention after a custom service starts requiring credentials. It also checks that a failure after a tool call sends exactly one diagnostic before the structured prompt error, and that cancellation does not emit a failure notice. Unit tests also cover credential references, account and login records, lookup failures, argument validation, preset composition, source and npm launcher routing, cleanup and signal forwarding. Client-specific UI behavior must be verified in the intended editor.
@@ -1,5 +1,7 @@
1
1
  # DSCODE 0.7.38
2
2
 
3
+ Status: superseded by 0.7.39. This version reached npm and Hub, but a fresh public install exposed a broken DeepSeek provider entry point and the remaining release workflow was cancelled. Use 0.7.39 or later.
4
+
3
5
  This release upgrades the terminal to Harness 0.2.0-rc.2, restores native reminder integration, and documents optional Atomic Mail MCP support.
4
6
 
5
7
  The 0.7.37 candidate was cancelled before npm or Hub publication when its Node 22.19.0 coverage check failed. This version retains the upgrade and removes a stale source-map reference from the in-memory Ink test fixture. The failure was reproduced locally on Node 22.19.0; all 1,091 unit/component tests and coverage checks passed on that exact runtime after the fix. The original tag remains unchanged.
@@ -0,0 +1,11 @@
1
+ # DSCODE 0.7.39
2
+
3
+ This release repairs DeepSeek provider startup in the published terminal bundle and retains the Harness 0.2.0-rc.2 upgrade, native reminders, Computer Use 0.3.3 and optional Atomic Mail integration from 0.7.38.
4
+
5
+ - **Working DeepSeek entries:** Harness 0.2 separates its shared adapter library from the API-key and Account plugins. The bundle now ships both native plugin entries and points them at its patched adapter, retaining DSCODE effort policies. Existing API-key and Account settings row IDs remain unchanged. This fixes the `invalid plugin` activation error and missing API-key provider seen in a fresh 0.7.38 installation.
6
+ - **Installed provider verification:** the release gate now boots the installed launcher's local doctor and rejects plugin activation failures. The real Harness probe checks that both `deepseek-official` and `deepseek-account` register and resolve model information before it adds its offline fixture adapter. Fixture agent success can no longer hide this missing provider.
7
+ - **Publication boundary:** 0.7.38 reached npm and Hub before the public installation check found this problem; its remaining workflow was cancelled. The existing version and tag are retained. Install 0.7.39 instead.
8
+
9
+ Update the launcher with `npm install -g @toddzheng024/dscode@latest`, then run `dscode update` for an existing Hub profile. The Desktop extension remains qualified separately for official Harness 0.2.0-rc.2 on macOS Apple Silicon.
10
+
11
+ Verification passed `npm run check`: lint, both TypeScript checks, 1,091 unit/component tests, native integration, package checks and 63 offline evaluations; measured first-party `.mjs` coverage was 80.35%. The exact candidate also passed `verify:hub` in a clean temporary workspace: native locked installation, installed local diagnostics, both DeepSeek provider registrations and model resolution, ACP, agent execution, session messaging, cards, memory, failed-upgrade preservation and successful upgrade/rollback. Running that verification directly from the developer checkout was blocked by its local `.env` bootstrap setting; no local credentials were copied to the clean workspace. Documentation and tag-version checks passed before tagging. Real provider inference, live OAuth login, interactive desktop actions and real mail delivery have no automated case. Computer Use retains the upstream saved-skill restoration limitation documented in [0.7.38](0.7.38.md).
@@ -0,0 +1,13 @@
1
+ # DSCODE 0.7.40
2
+
3
+ Credential-scoped balance snapshots, fresh status queries and clearer ACP failures.
4
+
5
+ - **Cached balance identity:** footer and Desktop balances clear immediately when credentials change or are removed. Late responses from an obsolete request cannot restore the old reading, including during credential resolution or response-body parsing. Refreshes resolve the native credential service when it becomes available after plugin startup. Transient failures retain a previous reading only for the same credential and source; authentication refusal clears it. Billing fixtures cover these transitions without real credentials or paid inference.
6
+ - **Currency and source:** cached amounts retain the reported currency and identify account funds versus a fallback key spending limit. CNY is displayed as CNY, malformed amounts remain unknown, and a valid zero remains zero. The public numeric balance API remains compatible; Desktop adds currency and source metadata. This does not change provider billing or currency conversion.
7
+ - **Fresh `/status`:** the TUI and ACP expose the selected provider and model plus a fresh DeepSeek or OpenRouter balance query, independently of the footer cache. Account funds and key limits remain distinct, credentials are not printed, and unsupported providers, unavailable data, timeouts and request failures have explicit states. The command does not send a model request or change the selected model.
8
+ - **ACP model choices and failures:** the picker lists providers with configured credentials or account login, plus saved custom services that require no authentication, while retaining the current model. Reconnect after credential changes to refresh the picker; local configuration does not prove endpoint reachability or model access. Failed prompts send a labeled notice before the failed response, with bounded transport details and retry guidance. Cancellation and successful completion keep their existing semantics.
9
+ - **Markdown guidance:** model instructions explain delimiters for code containing backticks and literal backslashes, and concise labels for long file links. They do not rewrite generated code or guarantee valid Markdown in every response.
10
+
11
+ Update the launcher with `npm install -g @toddzheng024/dscode@latest`, then run `dscode update` for an existing Hub profile. For official Harness Desktop 0.2.0-rc.2 on macOS Apple Silicon, install `@toddzheng024/dscode-desktop@0.7.40`, enable it, then fully quit and reopen Desktop.
12
+
13
+ Verification of the code passed `npm run check`: lint, both TypeScript gates, 1,129 unit/component tests, native integration, package checks and 63 offline evaluations, with 80.55% first-party `.mjs` coverage. PR CI passed on Node 22 and Node 24, plus Linux e2e. An independent Harness 0.2.0-rc.2 native Host passed packed Desktop usage checks across generation, resume and unload, including temporary native credential updates, removal, obsolete responses, currency metadata and key-limit fallback. Billing and model responses in these probes are synthetic. Public release installation is checked after publication and recorded in issue #16. Real billing, paid model inference, signed Desktop GUI interaction and real mail delivery have no automated case in this change.
@@ -12,6 +12,15 @@ work. Cost outside recorded turn windows is shown separately. Known cached
12
12
  provider balance or subscription figures are included; opening or refreshing the
13
13
  panel does not request new account data or start a model turn.
14
14
 
15
+ Cached balances retain the provider's currency and identify account funds or a
16
+ fallback key spending limit. USD values use `$`; other reported currencies carry
17
+ their currency code. The terminal marks a fallback limit with `key` (localized),
18
+ and Desktop labels it **Cached key spending limit**. The five-minute balance
19
+ cache is invalidated on native credential updates; removing a credential clears
20
+ its reading, and obsolete queries cannot restore it. Transient lookup failures
21
+ may retain the last reading only for the same credential and balance source.
22
+ Missing or malformed data stays unknown. `/status` makes a separate fresh query.
23
+
15
24
  The panel refreshes once per second while visible and clears its state when
16
25
  hidden or switched to another session. Refresh failures clear the old figures
17
26
  and display an error. Standard sessions and closed sessions have no DSCODE usage
@@ -12,6 +12,7 @@ If a provider rejects a request for content filtering, the turn stops with a `CO
12
12
  | `/model` | Picker for the session's current provider only, listed alphabetically by the displayed model name (digits compare naturally, so GLM 5.2 precedes GLM 5.3). Typing searches as you type (BM25 over model name, id and provider; the word being typed matches prefixes) and narrows the list without re-ranking it; Enter focuses the first match, a second Enter selects it, Esc clears the search then closes, Tab opens the provider picker. |
13
13
  | `/opencode [login\|cancel\|logout]` | Sign in to OpenCode Go with an OpenCode account, the route's only credential. `login` shows a code, opens the console in the browser and waits in the background for up to ten minutes; the consent page names the OpenCode CLI, whose login client DSCODE uses. Without an argument it shows who is signed in or how the last attempt ended. The login is stored in `~/.dscode/credentials.yaml` and renews itself. See [OpenCode Go](opencode-go.md). |
14
14
  | `/openrouter` | OpenRouter account panel: the account balance, this key's limit and its spend today, this week and this month; with a management key, also every API key's usage and the last 30 days of spend by model and serving provider. `r` refreshes, `m` sets the management key (checked against OpenRouter, stored in `~/.dscode/credentials.yaml`, read-only use; `OPENROUTER_MANAGEMENT_KEY` also works). Saving the OpenRouter API key through `/provider openrouter` or `/login openrouter` offers the management key as an optional step. |
15
+ | `/status` | Query the current session's provider, model and credential balance without a model turn. Supports DeepSeek API/account login and OpenRouter; account funds and key limits are labeled separately. Uses the active credential, refreshes on every call, and reports unsupported endpoints, missing credentials and query failures explicitly. Also available in [ACP](acp.md#current-credential-balance), which documents the lookup limits. |
15
16
  | `/review` | Independent review of the current changes. `DSCODE_REVIEW_MODEL=provider/model` pins the reviewer to its own model and `DSCODE_REVIEW_EFFORT=<level>` sets its reasoning level, so a small session model cannot cap the review; the budget leaves room for reasoning plus the report. |
16
17
  | `/update [exact-version]` | Upgrade DSCODE after this session exits: the TUI starts a detached helper that waits for the session to end and then runs the same `dscode update` entry (npm/Hub launcher, or tar/source self-update). Progress and the outcome are written to `$DSH_HOME/update.log` and `$DSH_HOME/update-result.json`. |
17
18
  | `/login [deepseek\|openrouter]` | Paste an API key into the private input; it is saved to `~/.dscode/credentials.yaml` (`0600`, shared across projects and installed versions). Without an argument it targets the current provider. `DEEPSEEK_API_KEY` or `OPENROUTER_API_KEY` in the environment takes precedence and makes that key read-only here. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@toddzheng024/dscode-desktop",
3
- "version": "0.7.38",
3
+ "version": "0.7.40",
4
4
  "private": false,
5
5
  "description": "DSCODE for Harness Desktop: coding collaboration, session memory, browser workflows and task management.",
6
6
  "license": "MIT",
@@ -1,5 +1,7 @@
1
1
  import { apply as applyAcp } from '@deepseek-ai/dsh-acp';
2
2
  import z from '@deepseek-ai/schemastery';
3
+ import { configuredModels } from './models.mjs';
4
+ import { acpStatus } from '../status/index.mjs';
3
5
 
4
6
  export const name = 'dscode-acp';
5
7
  export const inject = ['acpAppStartup', 'agents', 'agentPresets', 'agentDefaultModel', 'permissionPresets', 'llm', 'sessionPersistence', 'sessions', 'dscodeCustom'];
@@ -34,7 +36,7 @@ export function startAcp(ctx, config = {}, bridge = applyAcp) {
34
36
  const selection = config.model
35
37
  ? { provider: config.model.slice(0, config.model.indexOf('/')), model: config.model.slice(config.model.indexOf('/') + 1) }
36
38
  : ctx.agentDefaultModel.currentSelection();
37
- const scoped = ctx.extend({ agents: presetAgents(ctx) });
39
+ const scoped = ctx.extend({ agents: presetAgents(ctx), llm: configuredModels(ctx), dscodeAcpStatus: (selection, prompt, signal) => acpStatus(ctx, selection, prompt, signal) });
38
40
  bridge(scoped, { provider: selection.provider, model: selection.model });
39
41
  }
40
42
 
@@ -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
+ }
@@ -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.' : '' });
@@ -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',
@@ -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.
@@ -0,0 +1,127 @@
1
+ import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
2
+ import { readLanguage } from '../i18n/messages.mjs';
3
+ import { dscodeVersion } from '../opencode-go/version.mjs';
4
+ import { canonicalProvider, providerSpec } from '../providers/catalog.mjs';
5
+ import { statusSelection } from './selection.mjs';
6
+
7
+ const words = {
8
+ en: { title: 'DSCODE status', provider: 'Provider', model: 'Model', balance: 'Account balance', key: 'This key remaining limit', unlimited: 'No key spending cap (not an unlimited account balance)', budget: 'Effective budget remaining', recharge: 'Recharge balance', bonus: 'Bonus balance', missing: 'Credentials are not configured.', unsupported: 'Balance lookup is not supported for this provider or endpoint.', failed: 'Balance unavailable. Check your connection or provider account and retry.', denied: 'The provider refused this credential for balance lookup.', timeout: 'Balance lookup timed out. Retry /status.', available: 'Account available', yes: 'yes', no: 'no', checked: 'Checked', usage: 'Usage: /status', unknown: 'unavailable' },
9
+ 'zh-CN': { title: 'DSCODE 状态', provider: '服务商', model: '模型', balance: '账户余额', key: '当前 key 剩余额度', unlimited: '此 key 未设消费上限(不代表账户余额无限)', budget: '当前有效预算剩余', recharge: '充值余额', bonus: '赠送余额', missing: '尚未配置凭据。', unsupported: '此服务商或接口暂不支持余额查询。', failed: '余额暂不可用,请检查网络或服务商账户后重试。', denied: '服务商拒绝使用当前凭据查询余额。', timeout: '余额查询超时,请重试 /status。', available: '账户可用', yes: '是', no: '否', checked: '查询时间', usage: '用法:/status', unknown: '暂不可用' },
10
+ };
11
+ const decimal = value => (typeof value === 'number' && Number.isFinite(value)) || (typeof value === 'string' && /^-?\d+(?:\.\d+)?$/.test(value));
12
+ const money = (value, currency) => decimal(value) && ['USD', 'CNY'].includes(currency) ? `${value} ${currency}` : undefined;
13
+
14
+ /** Resolve the same configured reference as the active route, never a different saved key. */
15
+ export function statusConnection(ctx, provider) {
16
+ const entry = ctx.llm.listConfigurableProviders().find(item => item.provider === provider);
17
+ if (entry) {
18
+ const descriptor = ctx.get('settings')?.describe({ redactSecrets: true }).find(item => item.ns === entry.settingsNs);
19
+ const profile = entry.settingsPath.reduce((value, key) => value?.[key], descriptor?.value);
20
+ if (!profile || typeof profile.apiKeyEnv !== 'string') return undefined;
21
+ return { ref: profile.apiKeyEnv, baseURL: profile.baseURL };
22
+ }
23
+ const ref = providerSpec(provider)?.credentialRef;
24
+ return ref ? { ref } : undefined;
25
+ }
26
+
27
+ function officialEndpoint(ctx, provider, baseURL) {
28
+ const fallback = provider === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.deepseek.com/anthropic';
29
+ const configured = baseURL ?? (provider === 'deepseek-official' ? launchEnvironmentOf(ctx).get('DEEPSEEK_BASE_URL')?.value : undefined);
30
+ const url = new URL(configured ?? fallback);
31
+ const expected = new URL(fallback);
32
+ return url.origin === expected.origin && !url.username && !url.password && !url.search && !url.hash
33
+ && (provider === 'openrouter' ? /^\/api\/v1\/?$/.test(url.pathname) : /^\/(?:anthropic\/?|v1\/?)?$/.test(url.pathname));
34
+ }
35
+
36
+ async function json(url, key, fetchImpl, signal) {
37
+ const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' }, signal, redirect: 'error' });
38
+ if (!response.ok) throw Object.assign(new Error('Balance query failed'), { status: response.status });
39
+ return response.json();
40
+ }
41
+
42
+ /** No shared balance cache: every invocation belongs to this exact route and credential. */
43
+ export async function providerStatus(ctx, selection, { signal, fetch: fetchImpl = globalThis.fetch, locale = readLanguage(), timeoutMs = 8000 } = {}) {
44
+ const w = words[locale] ?? words.en;
45
+ const provider = selection?.provider, model = selection?.model;
46
+ const lines = [`${w.title}`, `${w.provider}: ${provider ?? w.unknown}`, `${w.model}: ${model ?? w.unknown}`];
47
+ const deadline = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(timeoutMs)]);
48
+ const run = async () => {
49
+ const route = canonicalProvider(provider);
50
+ if (route === 'deepseek-account') {
51
+ const account = ctx.get('deepseekAccount');
52
+ if ((await account?.getState())?.status !== 'credential-stored') return [w.missing];
53
+ const balance = await account.getBalance({ version: dscodeVersion(), locale, timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60 });
54
+ if (balance?.status !== 'ready') return [w.failed];
55
+ const result = [];
56
+ for (const [label, wallets] of [[w.recharge, balance.value], [w.bonus, balance.bonusWallets]]) {
57
+ for (const wallet of wallets ?? []) {
58
+ const amount = money(wallet.balance, wallet.currency);
59
+ if (amount !== undefined) result.push(`${label}: ${amount}`);
60
+ }
61
+ }
62
+ return result.length ? result : [w.failed];
63
+ }
64
+ if (!['deepseek-official', 'openrouter'].includes(route)) return [w.unsupported];
65
+ const connection = statusConnection(ctx, provider);
66
+ if (!connection) return [w.failed];
67
+ if (!officialEndpoint(ctx, route, connection.baseURL)) return [w.unsupported];
68
+ const credentials = ctx.get('credentials');
69
+ const key = credentials ? (await credentials.resolve(connection.ref))?.value : launchEnvironmentOf(ctx).get(connection.ref)?.value;
70
+ if (!key) return [w.missing];
71
+ if (route === 'deepseek-official') {
72
+ const body = await json('https://api.deepseek.com/user/balance', key, fetchImpl, deadline);
73
+ const amounts = (Array.isArray(body?.balance_infos) ? body.balance_infos : []).flatMap(info => {
74
+ const amount = money(info.total_balance, info.currency);
75
+ return amount === undefined ? [] : [`${w.balance}: ${amount}`];
76
+ });
77
+ if (!amounts.length) return [w.failed];
78
+ if (typeof body.is_available === 'boolean') amounts.push(`${w.available}: ${body.is_available ? w.yes : w.no}`);
79
+ return amounts;
80
+ }
81
+ // Never borrow a management key: it may belong to a different account.
82
+ const [credits, keyInfo] = await Promise.allSettled([
83
+ json('https://openrouter.ai/api/v1/credits', key, fetchImpl, deadline),
84
+ json('https://openrouter.ai/api/v1/key', key, fetchImpl, deadline),
85
+ ]);
86
+ const result = [];
87
+ const data = credits.status === 'fulfilled' ? credits.value?.data : undefined;
88
+ const remaining = decimal(data?.total_credits) && decimal(data?.total_usage) ? Number(data.total_credits) - Number(data.total_usage) : NaN;
89
+ result.push(`${w.balance}: ${Number.isFinite(remaining) ? `${Number(remaining.toFixed(8))} USD` : w.unknown}`);
90
+ const current = keyInfo.status === 'fulfilled' ? keyInfo.value?.data : undefined;
91
+ result.push(`${w.key}: ${current?.limit === null && current?.limit_remaining === null ? w.unlimited : money(current?.limit_remaining, 'USD') ?? w.unknown}`);
92
+ const effective = money(current?.effective_budget?.remaining_usd, 'USD');
93
+ if (effective !== undefined) result.push(`${w.budget}: ${effective}`);
94
+ if (credits.status === 'rejected' && keyInfo.status === 'rejected') throw keyInfo.reason;
95
+ return result;
96
+ };
97
+ let onAbort;
98
+ try {
99
+ deadline.throwIfAborted();
100
+ const cancelled = new Promise((_, reject) => {
101
+ onAbort = () => reject(deadline.reason);
102
+ deadline.addEventListener('abort', onAbort, { once: true });
103
+ });
104
+ lines.push(...await Promise.race([run(), cancelled]));
105
+ } catch (error) {
106
+ if (signal?.aborted) throw signal.reason;
107
+ lines.push(deadline.aborted ? w.timeout : [401, 403].includes(error?.status) ? w.denied : w.failed);
108
+ } finally {
109
+ if (onAbort) deadline.removeEventListener('abort', onAbort);
110
+ }
111
+ return [...lines, `${w.checked}: ${new Date().toISOString()}`].join('\n');
112
+ }
113
+
114
+ export function registerStatus(ctx) {
115
+ ctx.commands.register({ name: 'status', description: 'Show the current provider, model and available balance', handler: async ({ agent, rawInput, signal }) => {
116
+ if (rawInput.trim()) return { kind: 'error', text: (words[readLanguage()] ?? words.en).usage };
117
+ const selection = statusSelection(agent);
118
+ return { kind: 'success', text: await providerStatus(ctx, selection, { signal }) };
119
+ } });
120
+ }
121
+
122
+ /** Only a standalone text command is intercepted; paths and attached prompts stay model input. */
123
+ export async function acpStatus(ctx, selection, prompt, signal) {
124
+ if (prompt.length !== 1 || prompt[0].type !== 'text' || !/^\/status(?:\s|$)/.test(prompt[0].text.trim())) return undefined;
125
+ if (prompt[0].text.trim() !== '/status') return (words[readLanguage()] ?? words.en).usage;
126
+ return providerStatus(ctx, selection, { signal });
127
+ }
@@ -0,0 +1,6 @@
1
+ // The TUI's explicit picker choice takes precedence over its last request header.
2
+ // Keep the live ref outside model history; WeakMap ownership follows the Agent.
3
+ const selections = new WeakMap();
4
+ export const registerStatusSelection = (agent, read) => { selections.set(agent, read); };
5
+ export const statusSelection = agent => selections.get(agent)?.()
6
+ ?? { ...agent.options, ...agent.session.requestHeader()?.config };