paseo-dsh-direct 0.2.4 → 0.2.6

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
@@ -15,6 +15,13 @@ In Paseo, open **Settings → Plugins → Install Plugin** and paste either the
15
15
  | `0.2.x` | `npm:paseo-dsh-direct` | `https://github.com/Jecvay/paseo-dsh-direct` |
16
16
  | `0.1.x` | `npm:paseo-dsh-direct@dsh-0.1` | CLI only: `paseo plugin install https://github.com/Jecvay/paseo-dsh-direct --ref release/0.1` |
17
17
 
18
+ To update, run on the daemon machine (the app's plugin menu has no update entry):
19
+
20
+ ```bash
21
+ paseo plugin update paseo-dsh-direct --check # see if a new version exists
22
+ paseo plugin update paseo-dsh-direct --yes # update to the latest
23
+ ```
24
+
18
25
  More: [compatibility](docs/en/compatibility.md) · [CLI and local install, environment variables](docs/en/installation.md)
19
26
 
20
27
  ## Why "Direct"
package/README.zh.md CHANGED
@@ -15,6 +15,13 @@
15
15
  | `0.2.x` | `npm:paseo-dsh-direct` | `https://github.com/Jecvay/paseo-dsh-direct` |
16
16
  | `0.1.x` | `npm:paseo-dsh-direct@dsh-0.1` | 只能用命令行:`paseo plugin install https://github.com/Jecvay/paseo-dsh-direct --ref release/0.1` |
17
17
 
18
+ 更新插件在 daemon 所在机器上运行(App 的插件菜单里没有更新入口):
19
+
20
+ ```bash
21
+ paseo plugin update paseo-dsh-direct --check # 看有没有新版本
22
+ paseo plugin update paseo-dsh-direct --yes # 更新到最新
23
+ ```
24
+
18
25
  更多:[兼容性说明](docs/compatibility.md) · [命令行安装、本地目录安装、环境变量](docs/plugin-guide.md)
19
26
 
20
27
  ## 为什么叫 Direct
package/index.server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { PluginServerContext } from "@getpaseo/plugin/server";
2
2
  import { launchDshBridge } from "./server/bridge-client.js";
3
+ import { defaultDshCommand } from "./server/dsh-spawn.js";
3
4
  import { DSH_BRIDGE_SOURCE } from "./server/generated-bridge.js";
4
5
  import { createDshProvider } from "./server/provider.js";
5
6
 
@@ -8,6 +9,7 @@ export default function contribute(server: PluginServerContext) {
8
9
  server.registerProvider(
9
10
  createDshProvider({
10
11
  executable,
12
+ command: defaultDshCommand(process.env),
11
13
  createBridge: (launch) =>
12
14
  launchDshBridge({
13
15
  ...launch,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-dsh-direct",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "DeepSeek Harness Direct Provider Plugin for Paseo",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,9 +1,10 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
2
  import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import path from "node:path";
5
5
  import { createInterface } from "node:readline";
6
6
  import { detectDshVersion } from "./dsh-version.js";
7
+ import { spawnDsh } from "./dsh-spawn.js";
7
8
  import type { ProviderLaunch } from "@getpaseo/plugin/server/provider";
8
9
  import type {
9
10
  BridgeInitializeResult,
@@ -182,10 +183,10 @@ function runProfileInit(
182
183
  timeoutMs: number,
183
184
  ): Promise<void> {
184
185
  return new Promise((resolve, reject) => {
185
- const child = spawn(
186
+ const child = spawnDsh(
186
187
  executable,
187
188
  [...leadingArgs, "--profile", profile, "--from-default-profile", DEFAULT_PROFILE_TEMPLATE, "--dump-config"],
188
- { env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true },
189
+ { env, stdio: ["ignore", "ignore", "pipe"] },
189
190
  );
190
191
  let stderr = "";
191
192
  child.stderr.setEncoding("utf8");
@@ -293,10 +294,9 @@ function dumpProfileConfig(
293
294
  env: Readonly<Record<string, string | undefined>>,
294
295
  ): Promise<string> {
295
296
  return new Promise((resolve, reject) => {
296
- const child = spawn(launch.command, [...launch.args, "--profile", profile, "--dump-config"], {
297
+ const child = spawnDsh(launch.command, [...launch.args, "--profile", profile, "--dump-config"], {
297
298
  env,
298
299
  stdio: ["ignore", "pipe", "ignore"],
299
- windowsHide: true,
300
300
  });
301
301
  let stdout = "";
302
302
  child.stdout.setEncoding("utf8");
@@ -360,10 +360,9 @@ export async function launchDshBridge(options: LaunchBridgeOptions): Promise<Dsh
360
360
  { mode: 0o600 },
361
361
  );
362
362
 
363
- const child = spawn(launch.command, [...launch.args, "--profile", profile, "--patch", patchPath], {
363
+ const child = spawnDsh(launch.command, [...launch.args, "--profile", profile, "--patch", patchPath], {
364
364
  env,
365
365
  stdio: ["pipe", "pipe", "pipe"],
366
- windowsHide: true,
367
366
  });
368
367
  const client = new JsonlBridgeClient(child, directory, launch);
369
368
  try {
@@ -0,0 +1,134 @@
1
+ import { spawn, type SpawnOptions } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import path from "node:path";
4
+
5
+ type Env = Readonly<Record<string, string | undefined>>;
6
+
7
+ /** A command line `child_process.spawn` can start without a shell. */
8
+ export interface DshSpawn {
9
+ command: string;
10
+ args: string[];
11
+ /** The environment to spawn with: the launch environment, plus what the resolved command needs. */
12
+ env: Env;
13
+ }
14
+
15
+ /** The host facts the resolution depends on, injected so the Windows branch is testable anywhere. */
16
+ export interface DshSpawnHost {
17
+ platform: NodeJS.Platform;
18
+ exists(file: string): boolean;
19
+ }
20
+
21
+ const defaultHost: DshSpawnHost = { platform: process.platform, exists: existsSync };
22
+ const DESKTOP_EXE = "DeepSeek Harness.exe";
23
+ const WRAPPER_EXTENSIONS = [".cmd", ".ps1"];
24
+ /** Extensions Node finds itself when spawning a bare name; a match means a real executable wins. */
25
+ const EXECUTABLE_EXTENSIONS = [".com", ".exe"];
26
+
27
+ /**
28
+ * Spawns dsh without a shell: `spawn` with `command` / `args` / `env` replaced
29
+ * by `resolveDshSpawn(command, args, options.env)` and `windowsHide` set. Every
30
+ * dsh process the plugin starts goes through here.
31
+ */
32
+ export const spawnDsh = ((command: string, args: readonly string[], options: SpawnOptions) => {
33
+ const target = resolveDshSpawn(command, args, options.env ?? process.env);
34
+ return spawn(target.command, target.args, { ...options, env: target.env, windowsHide: true });
35
+ }) as typeof spawn;
36
+
37
+ /**
38
+ * The spawn for a dsh command chosen by `resolveDshLaunch`, with `args` being
39
+ * the launch's leading arguments followed by dsh's own. Other platforms, real
40
+ * executables and unrecognised layouts pass through unchanged; on Windows an
41
+ * npm or DSH Desktop `dsh.cmd` / `dsh.ps1` wrapper becomes the command it runs,
42
+ * and a bare `dsh` not on `PATH` falls back to DSH Desktop's default install.
43
+ * Layouts and reasons: `docs/server-architecture.md` (dsh 的选择).
44
+ */
45
+ export function resolveDshSpawn(
46
+ command: string,
47
+ args: readonly string[],
48
+ env: Env,
49
+ host: DshSpawnHost = defaultHost,
50
+ ): DshSpawn {
51
+ const passthrough = { command, args: [...args], env };
52
+ if (host.platform !== "win32") return passthrough;
53
+ if (path.win32.basename(command).toLowerCase() === DESKTOP_EXE.toLowerCase()) {
54
+ return desktopSpawn(path.win32.dirname(command), args, env, host) ?? passthrough;
55
+ }
56
+ const found = findCommand(command, env, host);
57
+ if (found.kind === "executable") return passthrough;
58
+ if (found.kind === "missing") {
59
+ return (command.toLowerCase() === "dsh" ? desktopFallback(args, env, host) : undefined) ?? passthrough;
60
+ }
61
+ const directory = path.win32.dirname(found.path);
62
+ return (
63
+ npmSpawn(directory, args, env, host) ??
64
+ // <install>\resources\runtime\cli\bin\dsh.cmd
65
+ desktopSpawn(path.win32.resolve(directory, "..", "..", "..", ".."), args, env, host) ??
66
+ passthrough
67
+ );
68
+ }
69
+
70
+ /**
71
+ * The command the provider registers with Paseo. Paseo resolves it on `PATH`
72
+ * before the plugin sees it, so on Windows, when `PATH` has no `dsh` but DSH
73
+ * Desktop is installed in its default location, the desktop executable is
74
+ * registered instead; `resolveDshSpawn` then runs it as dsh. Otherwise `dsh`.
75
+ */
76
+ export function defaultDshCommand(env: Env, host: DshSpawnHost = defaultHost): string {
77
+ if (host.platform !== "win32" || findCommand("dsh", env, host).kind !== "missing") return "dsh";
78
+ return desktopFallback([], env, host)?.command ?? "dsh";
79
+ }
80
+
81
+ /** npm's layout: `node <wrapper dir>\node_modules\@deepseek-ai\dsh\lib\bin.js`, preferring a `node.exe` beside the wrapper. */
82
+ function npmSpawn(directory: string, args: readonly string[], env: Env, host: DshSpawnHost): DshSpawn | undefined {
83
+ const binJs = path.win32.join(directory, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js");
84
+ if (!host.exists(binJs)) return undefined;
85
+ const bundledNode = path.win32.join(directory, "node.exe");
86
+ return { command: host.exists(bundledNode) ? bundledNode : "node", args: [binJs, ...args], env };
87
+ }
88
+
89
+ /** DSH Desktop's default per-user install, `%LOCALAPPDATA%\Programs\DeepSeek Harness`, when present. */
90
+ function desktopFallback(args: readonly string[], env: Env, host: DshSpawnHost): DshSpawn | undefined {
91
+ const local = windowsEnv(env, "LOCALAPPDATA");
92
+ return local ? desktopSpawn(path.win32.join(local, "Programs", "DeepSeek Harness"), args, env, host) : undefined;
93
+ }
94
+
95
+ /** DSH Desktop's own CLI, as its `dsh.cmd` runs it, when `install` holds a desktop install. */
96
+ function desktopSpawn(install: string, args: readonly string[], env: Env, host: DshSpawnHost): DshSpawn | undefined {
97
+ const exe = path.win32.join(install, DESKTOP_EXE);
98
+ const asar = path.win32.join(install, "resources", "app.asar");
99
+ if (!host.exists(exe) || !host.exists(asar)) return undefined;
100
+ const cli = path.win32.join(asar, "dsh", "node_modules", "@deepseek-ai", "dsh-desktop-host", "lib", "cli.js");
101
+ return { command: exe, args: ["--expose-internals", cli, ...args], env: { ...env, ELECTRON_RUN_AS_NODE: "1" } };
102
+ }
103
+
104
+ type Found = { kind: "executable" } | { kind: "wrapper"; path: string } | { kind: "missing" };
105
+
106
+ /** What `command` names: a wrapper script, a real executable Node finds first, or nothing. */
107
+ function findCommand(command: string, env: Env, host: DshSpawnHost): Found {
108
+ const extension = path.win32.extname(command).toLowerCase();
109
+ if (extension !== "" && !WRAPPER_EXTENSIONS.includes(extension)) return { kind: "executable" };
110
+ const candidates = (base: string) => (extension ? [base] : WRAPPER_EXTENSIONS.map((suffix) => base + suffix));
111
+ const wrapperAt = (base: string): Found => {
112
+ const wrapper = candidates(base).find((candidate) => host.exists(candidate));
113
+ return wrapper === undefined ? { kind: "missing" } : { kind: "wrapper", path: wrapper };
114
+ };
115
+ if (/[\\/]/.test(command)) return wrapperAt(command);
116
+ for (const directory of (windowsEnv(env, "PATH") ?? "").split(";")) {
117
+ if (!directory.trim()) continue;
118
+ const base = path.win32.join(directory.trim().replace(/^"|"$/g, ""), command);
119
+ if (extension === "" && EXECUTABLE_EXTENSIONS.some((suffix) => host.exists(base + suffix))) {
120
+ return { kind: "executable" };
121
+ }
122
+ const found = wrapperAt(base);
123
+ if (found.kind === "wrapper") return found;
124
+ }
125
+ return { kind: "missing" };
126
+ }
127
+
128
+ /** Windows environment names are case-insensitive (`Path` as well as `PATH`). */
129
+ function windowsEnv(env: Env, key: string): string | undefined {
130
+ for (const [name, value] of Object.entries(env)) {
131
+ if (name.toUpperCase() === key && value) return value;
132
+ }
133
+ return undefined;
134
+ }
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawnDsh } from "./dsh-spawn.js";
2
2
 
3
3
  const DEFAULT_TIMEOUT_MS = 3_000;
4
4
 
@@ -41,10 +41,9 @@ function runVersionProbe(executable: string, options: DetectDshVersionOptions):
41
41
 
42
42
  let child;
43
43
  try {
44
- child = spawn(executable, [...(options.args ?? []), "--version"], {
44
+ child = spawnDsh(executable, [...(options.args ?? []), "--version"], {
45
45
  env: options.env ?? process.env,
46
46
  stdio: ["ignore", "pipe", "ignore"],
47
- windowsHide: true,
48
47
  });
49
48
  } catch {
50
49
  // Synchronous spawn failure (e.g. the executable does not exist).
@@ -63,6 +63,8 @@ interface DshProviderOptions {
63
63
  createBridge(launch?: SessionLaunch): Promise<DshBridge>;
64
64
  /** The `PASEO_DSH_EXECUTABLE` value; it outranks the `launch` Paseo resolves. `status()` and `createBridge` use the same precedence. */
65
65
  executable?: string;
66
+ /** The command registered with Paseo; defaults to `dsh` (see `defaultDshCommand`). */
67
+ command?: string;
66
68
  /** Detects the local dsh version for the startup compatibility check; defaults to spawning `<exe> --version`. */
67
69
  detectDshVersion?: DetectVersion;
68
70
  }
@@ -103,7 +105,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
103
105
  icon: "dsh.svg",
104
106
  getCatalogCacheKey: async (options) =>
105
107
  options.scope === "workspace" ? `dsh-pi:${options.cwd}` : "dsh-pi:global",
106
- command: ["dsh"],
108
+ command: [options.command ?? "dsh"],
107
109
  async status(request) {
108
110
  return dshStatus(
109
111
  resolveDshLaunch(options.executable, request?.launch),
@@ -263,20 +263,8 @@ export class TimelineProjector {
263
263
  projected.push(timeline(this.paseoSessionId, item, timestamp));
264
264
  }
265
265
  });
266
- const usage = objectValue(data.usage) ?? objectValue(message.usage);
267
- if (usage) {
268
- projected.push({
269
- type: "session.usage",
270
- sessionId: this.paseoSessionId,
271
- usage: {
272
- inputTokens: numberValue(usage.inputTokens) ?? numberValue(usage.input_tokens),
273
- cachedInputTokens:
274
- numberValue(usage.cachedInputTokens) ?? numberValue(usage.cached_input_tokens),
275
- outputTokens: numberValue(usage.outputTokens) ?? numberValue(usage.output_tokens),
276
- totalCostUsd: numberValue(usage.totalCostUsd) ?? numberValue(usage.total_cost_usd),
277
- },
278
- });
279
- }
266
+ // No session.usage here: Paseo replaces `lastUsage` whole, and a per-message
267
+ // reading would overwrite the bridge's complete one with fewer fields.
280
268
  return projected;
281
269
  }
282
270