@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.
- package/README.md +2 -2
- package/docs/CHANGELOG.md +33 -5
- package/docs/account-login.md +3 -1
- package/docs/acp.md +17 -3
- package/docs/browser-use.md +1 -1
- package/docs/custom-providers.md +3 -1
- package/docs/distribution.md +61 -33
- package/docs/email.md +4 -4
- package/docs/hub-distribution.md +3 -1
- package/docs/opencode-go.md +4 -2
- package/docs/releases/0.7.40.md +13 -0
- package/docs/releases/0.7.41.md +25 -0
- package/docs/session-metrics.md +9 -0
- package/docs/tui-commands.md +10 -7
- package/package.json +1 -1
- package/plugins/acp/index.mjs +3 -1
- package/plugins/acp/models.mjs +47 -0
- package/plugins/custom/config.mjs +1 -1
- package/plugins/custom/index.mjs +1 -1
- package/plugins/desktop/client.mjs +2 -2
- package/plugins/dscode/index.mjs +5 -1
- package/plugins/email/inbox.mjs +1 -1
- package/plugins/i18n/messages.d.mts +2 -0
- package/plugins/i18n/messages.mjs +16 -12
- package/plugins/providers/openrouter-account.mjs +4 -5
- package/plugins/session-metrics/balance.mjs +45 -28
- package/plugins/session-metrics/desktop-client.mjs +2 -2
- package/plugins/session-metrics/desktop-host.mjs +3 -2
- package/plugins/session-metrics/index.mjs +17 -4
- package/plugins/session-metrics/view.mjs +3 -3
- package/plugins/status/index.mjs +127 -0
- package/plugins/status/selection.mjs +6 -0
- package/plugins/triggers/scheduler-service.mjs +1 -1
- package/plugins/triggers/scheduler.mjs +2 -1
- package/plugins/tui-tools/hook-sources.mjs +2 -1
- package/plugins/tui-tools/index.mjs +2 -0
- package/plugins/tui-tools/update.mjs +6 -4
- package/plugins/tui-tools/workspace-discovery.mjs +17 -3
|
@@ -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 };
|
|
@@ -8,7 +8,7 @@ export const schedulerLabel = home => `ai.dscode.scheduler.${createHash('sha256'
|
|
|
8
8
|
export const schedulerPath = (home, directory = join(homedir(), 'Library', 'LaunchAgents')) => join(directory, `${schedulerLabel(home)}.plist`);
|
|
9
9
|
|
|
10
10
|
export function schedulerPlist(home, dscodePath) {
|
|
11
|
-
const args = [process.execPath, dscodePath, 'trigger', 'scheduler', 'start'];
|
|
11
|
+
const args = [...(/\.[cm]?js$/.test(dscodePath) ? [process.execPath, dscodePath] : [dscodePath]), 'trigger', 'scheduler', 'start'];
|
|
12
12
|
const log = join(home, 'triggers', 'scheduler.log');
|
|
13
13
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
14
14
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -15,7 +15,8 @@ export function launchJobWorker({ home, dscodePath, job }) {
|
|
|
15
15
|
const fd = openSync(join(directory, `${job.id}.log`), 'a', 0o600);
|
|
16
16
|
let child;
|
|
17
17
|
try {
|
|
18
|
-
|
|
18
|
+
const script = /\.[cm]?js$/.test(dscodePath);
|
|
19
|
+
child = spawn(script ? process.execPath : dscodePath, [...(script ? [dscodePath] : []), 'trigger', 'run-job', job.id], {
|
|
19
20
|
cwd: home, env: { ...process.env, DSH_HOME: home, DSCODE_HOME: home },
|
|
20
21
|
stdio: ['ignore', fd, fd],
|
|
21
22
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { hookEvents, validateHooks } from './hooks.mjs';
|
|
4
|
-
import { enabledFlag } from './workspace-discovery.mjs';
|
|
4
|
+
import { enabledFlag, isolatedDshPath } from './workspace-discovery.mjs';
|
|
5
5
|
|
|
6
6
|
// Project hook files layer on top of the installation file by default. They run as
|
|
7
7
|
// the OS user outside tool approval, so a cloned repository can install gates;
|
|
@@ -67,6 +67,7 @@ export function resolveHookSources({ root, cwd = root, env = process.env }) {
|
|
|
67
67
|
if (!projectHooksEnabled(env)) return { sources, skipped };
|
|
68
68
|
for (const file of projectHookFiles) {
|
|
69
69
|
const path = join(cwd, file.path);
|
|
70
|
+
if (isolatedDshPath(path, env)) continue;
|
|
70
71
|
if (!existsSync(path)) continue;
|
|
71
72
|
let parsed;
|
|
72
73
|
try {
|
|
@@ -12,6 +12,7 @@ import { hookEvents, validateHooks } from './hooks.mjs';
|
|
|
12
12
|
import { redact } from '../auto-review/policy.mjs';
|
|
13
13
|
import { plainConfig } from '../cordis-config/plain.mjs';
|
|
14
14
|
import { analyzeDoctorEvidence, collectDoctorEvidence, recordRuntimeLog } from './doctor.mjs';
|
|
15
|
+
import { isolatedDshPath } from './workspace-discovery.mjs';
|
|
15
16
|
|
|
16
17
|
export const name = 'dscode-tui-tools';
|
|
17
18
|
export const inject = ['commands', 'agents', 'tools', 'skills', 'permissionPresets', 'sessionPersistence', 'llm'];
|
|
@@ -53,6 +54,7 @@ export async function findConflicts(cwd, configs, winners, env = process.env) {
|
|
|
53
54
|
const candidates = new Map();
|
|
54
55
|
const errors = [];
|
|
55
56
|
for (const root of new Set(roots)) {
|
|
57
|
+
if (isolatedDshPath(root, env)) continue;
|
|
56
58
|
let entries;
|
|
57
59
|
try { entries = await readdir(root, { withFileTypes: true }); }
|
|
58
60
|
catch (e) { if (e.code !== 'ENOENT') errors.push(`${root}: ${e.code}`); continue; }
|
|
@@ -29,16 +29,17 @@ export function newerVersion(candidate, current) {
|
|
|
29
29
|
return false;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
/** Newest
|
|
33
|
-
export async function fetchLatestVersion({ fetchImpl = fetch, timeoutMs = 8000 } = {}) {
|
|
32
|
+
/** Newest GitHub application or legacy npm launcher; undefined on lookup failure. */
|
|
33
|
+
export async function fetchLatestVersion({ fetchImpl = fetch, timeoutMs = 8000, standalone = !!process.env.DSCODE_STANDALONE_ROOT } = {}) {
|
|
34
34
|
const controller = new AbortController();
|
|
35
35
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
36
36
|
try {
|
|
37
37
|
// Plain JSON: the registry answers 406 for the abbreviated metadata type on the
|
|
38
38
|
// `/latest` dist-tag endpoint.
|
|
39
|
-
const response = await fetchImpl(REGISTRY_URL, { headers: { accept: 'application/json' }, signal: controller.signal });
|
|
39
|
+
const response = await fetchImpl(standalone ? 'https://api.github.com/repos/qiz029/dscode/releases/latest' : REGISTRY_URL, { headers: { accept: 'application/json' }, signal: controller.signal });
|
|
40
40
|
if (!response.ok) return undefined;
|
|
41
|
-
const
|
|
41
|
+
const body = await response.json();
|
|
42
|
+
const version = standalone ? (body.draft || body.prerelease ? undefined : body.tag_name?.replace(/^v/, '')) : body.version;
|
|
42
43
|
return typeof version === 'string' && VERSION_PATTERN.test(version) ? version : undefined;
|
|
43
44
|
} catch { return undefined; }
|
|
44
45
|
finally { clearTimeout(timer); }
|
|
@@ -55,6 +56,7 @@ export function stateHome(env = process.env, home = process.env.HOME) {
|
|
|
55
56
|
*/
|
|
56
57
|
export function planUpdate({ moduleUrl, version = 'latest', execPath = process.execPath } = {}) {
|
|
57
58
|
const root = resolve(dirname(fileURLToPath(moduleUrl)), '..', '..');
|
|
59
|
+
if (existsSync(join(root, '.dscode-standalone.json'))) return { label: `self-contained ${root}`, command: join(root, 'dscode'), args: ['update', version] };
|
|
58
60
|
const entry = join(root, 'bin/dscode.mjs');
|
|
59
61
|
if (existsSync(entry)) return { label: `tar/source ${root}`, command: execPath, args: [entry, 'update', version] };
|
|
60
62
|
if (existsSync(join(root, 'node_modules', LAUNCHER_PACKAGE, 'cli.mjs'))) return { label: 'npm launcher (bundle tree)', command: execPath, args: [join(root, 'node_modules', LAUNCHER_PACKAGE, 'cli.mjs'), 'update', version] };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
|
|
4
5
|
// Discovery above the project root, bounded at the home directory rather than the
|
|
5
6
|
// filesystem root: a shared workspace directory such as ~/Workspace can contribute
|
|
@@ -25,7 +26,20 @@ export const skillAncestorsEnabled = (env = process.env) => !disabledFlag(env.DS
|
|
|
25
26
|
// whatever $HOME says, so the comparison canonicalizes both sides. The chain itself
|
|
26
27
|
// keeps the caller's logical paths, which is what the skill and instruction probes
|
|
27
28
|
// then stat.
|
|
28
|
-
const canonical = path => {
|
|
29
|
+
const canonical = path => {
|
|
30
|
+
try { return realpathSync(path); } catch {
|
|
31
|
+
const absolute = resolve(path), parent = dirname(absolute);
|
|
32
|
+
return parent === absolute ? absolute : join(canonical(parent), basename(absolute));
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Even when ~ is the working directory, its .dsh is another application's home. */
|
|
37
|
+
export function isolatedDshPath(path, env = process.env) {
|
|
38
|
+
if (!env.DSCODE_STANDALONE_ROOT) return false;
|
|
39
|
+
const foreign = canonical(join(env.HOME || homedir(), '.dsh'));
|
|
40
|
+
const target = canonical(path);
|
|
41
|
+
return target === foreign || target.startsWith(foreign + sep);
|
|
42
|
+
}
|
|
29
43
|
|
|
30
44
|
// Every directory from the working directory up to home, nearest first. An empty
|
|
31
45
|
// list means home is not an ancestor, and the mode then contributes nothing.
|
|
@@ -68,7 +82,7 @@ export function ancestorSkillDirs({ cwd, home, env = process.env }) {
|
|
|
68
82
|
// The provider already scans these two at rank 100/200 for the owning project.
|
|
69
83
|
if (dir === projectRoot && (root === '.dsh/skills' || root === '.agents/skills')) continue;
|
|
70
84
|
const path = join(dir, root);
|
|
71
|
-
if (existsSync(path) && !dirs.includes(path)) dirs.push(path);
|
|
85
|
+
if (existsSync(path) && !isolatedDshPath(path, env) && !dirs.includes(path)) dirs.push(path);
|
|
72
86
|
}
|
|
73
87
|
}
|
|
74
88
|
return dirs;
|