paseo-dsh-direct 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -12,6 +12,7 @@ Runs DeepSeek Harness (`dsh`) as a native Paseo agent provider. Paseo clients (d
12
12
  - Streaming assistant text and reasoning ("thinking") as Paseo timeline items.
13
13
  - Tool calls with Paseo approval prompts, and `dsh` user questions answered from the Paseo UI.
14
14
  - Create, resume, cancel and close sessions; model list and mode switching from the Paseo UI.
15
+ - Token usage and context-window fill in Paseo's usage meter.
15
16
  - DSH branding (name and icon) in the provider list.
16
17
 
17
18
  ## Processes and environment
package/README.md CHANGED
@@ -25,6 +25,7 @@ Most dsh integrations go through ACP, a generic protocol that every agent can sp
25
25
  - Tool approvals and dsh's questions to you appear as Paseo's own approval and question cards.
26
26
  - Your existing dsh sessions can be imported and resumed in Paseo.
27
27
  - dsh's slash commands (`/compact`, `/plan`, ...) appear in Paseo's `/` menu.
28
+ - Paseo's usage meter shows dsh's token counts and how full the context window is.
28
29
 
29
30
  ## Versioning
30
31
 
package/README.zh.md CHANGED
@@ -25,6 +25,7 @@
25
25
  - 工具审批和 dsh 向你提的问题,显示成 Paseo 自带的审批卡片和问答卡片。
26
26
  - 已有的 dsh 会话可以导入 Paseo 接着用。
27
27
  - dsh 的斜杠命令(`/compact`、`/plan` 等)出现在 Paseo 的 `/` 菜单里。
28
+ - Paseo 的用量表显示 dsh 的 token 用量和上下文占用。
28
29
 
29
30
  ## 版本号
30
31
 
package/dsh.png CHANGED
Binary file
package/dsh.svg CHANGED
@@ -1,11 +1,3 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="DeepSeek Harness">
2
- <defs>
3
- <linearGradient id="wave" x1="8" y1="10" x2="56" y2="54" gradientUnits="userSpaceOnUse">
4
- <stop stop-color="#6A8DFF"/>
5
- <stop offset="1" stop-color="#3158E6"/>
6
- </linearGradient>
7
- </defs>
8
- <path fill="url(#wave)" d="M8 34c7-13 18-20 33-20 6 0 11 1 15 4-2 13-8 23-18 29-8 5-17 6-27 3 7-2 12-6 15-11-7 2-13 0-18-5Z"/>
9
- <circle cx="43" cy="25" r="3" fill="#fff"/>
10
- <path d="M13 42c8 4 17 3 25-2" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity=".9"/>
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" fill="currentColor" role="img" aria-label="DeepSeek Harness">
2
+ <path fill-rule="nonzero" d="M48.8354 10.0479C48.3232 9.79199 48.1025 10.2798 47.8032 10.5278C47.7007 10.6079 47.6143 10.7119 47.5273 10.8076C46.7793 11.624 45.9048 12.1597 44.7622 12.0957C43.0923 12 41.666 12.5356 40.4058 13.8398C40.1377 12.2319 39.2476 11.272 37.8926 10.6558C37.1836 10.3359 36.4668 10.0156 35.9702 9.31982C35.6235 8.82373 35.5293 8.27197 35.356 7.72754C35.2456 7.3999 35.1353 7.06396 34.7651 7.00781C34.3633 6.94385 34.2056 7.2876 34.0479 7.57568C33.418 8.75195 33.1733 10.0479 33.1973 11.3599C33.2524 14.312 34.4736 16.6641 36.8999 18.3359C37.1758 18.5278 37.2466 18.7197 37.1597 19C36.9946 19.5757 36.7974 20.1357 36.624 20.7119C36.5137 21.0801 36.3486 21.1597 35.9624 21C34.6309 20.4321 33.481 19.5918 32.4644 18.5757C30.7393 16.8721 29.1792 14.9917 27.2334 13.52C26.7764 13.1758 26.3193 12.856 25.8467 12.5518C23.8618 10.584 26.1069 8.96777 26.627 8.77588C27.1704 8.57568 26.8159 7.8877 25.0591 7.896C23.3022 7.90381 21.6953 8.50391 19.647 9.30371C19.3477 9.42383 19.0322 9.51172 18.7095 9.58398C16.8501 9.22363 14.9199 9.14355 12.9033 9.37598C9.10596 9.80762 6.07275 11.6396 3.84326 14.7681C1.16455 18.5278 0.53418 22.7998 1.30664 27.2559C2.11768 31.9521 4.46582 35.8398 8.07373 38.8799C11.8159 42.0322 16.1255 43.5762 21.041 43.2803C24.0269 43.104 27.3516 42.6963 31.1016 39.4561C32.0469 39.936 33.0396 40.1279 34.686 40.272C35.9546 40.3921 37.1758 40.208 38.1211 40.0078C39.6021 39.688 39.4995 38.2881 38.9639 38.0322C34.623 35.9678 35.5762 36.8081 34.71 36.1279C36.9155 33.4639 40.2402 30.6958 41.54 21.728C41.6426 21.0161 41.5557 20.5679 41.54 19.9917C41.5322 19.6396 41.6108 19.5039 42.0049 19.4639C43.0923 19.3359 44.1479 19.0317 45.1167 18.4878C47.9292 16.9199 49.064 14.3438 49.3315 11.2559C49.3711 10.7837 49.3237 10.2959 48.8354 10.0479ZM24.3262 37.8398C20.1196 34.4639 18.0791 33.3521 17.2358 33.3999C16.4482 33.4482 16.5898 34.3682 16.7632 34.9678C16.9443 35.5601 17.1812 35.9683 17.5117 36.4878C17.7402 36.832 17.8979 37.3442 17.2832 37.728C15.9282 38.584 13.5728 37.4399 13.4624 37.3838C10.7207 35.7358 8.42822 33.5601 6.81348 30.584C5.25342 27.7197 4.34766 24.6479 4.19775 21.3677C4.1582 20.5757 4.38672 20.2959 5.15869 20.1519C6.17529 19.96 7.22314 19.9199 8.23926 20.0718C12.5327 20.7119 16.1885 22.6719 19.2529 25.7759C21.002 27.5439 22.3252 29.6558 23.6885 31.7202C25.1377 33.9121 26.6978 36 28.6831 37.7119C29.3843 38.312 29.9434 38.7681 30.479 39.104C28.8643 39.2881 26.1699 39.3281 24.3262 37.8398ZM26.3433 24.6001C26.3433 24.248 26.6191 23.9678 26.9658 23.9678C27.0444 23.9678 27.1152 23.9839 27.1782 24.0078C27.2651 24.04 27.3438 24.0879 27.4067 24.1602C27.5171 24.272 27.5801 24.4321 27.5801 24.6001C27.5801 24.9521 27.3042 25.2319 26.9575 25.2319C26.6108 25.2319 26.3433 24.9521 26.3433 24.6001ZM32.6064 27.8799C32.2046 28.0479 31.8027 28.1919 31.4165 28.208C30.8179 28.2397 30.1641 27.9922 29.8096 27.688C29.2583 27.2158 28.8643 26.9521 28.6987 26.1279C28.6279 25.7759 28.6675 25.2319 28.7305 24.9199C28.8721 24.248 28.7144 23.8159 28.2495 23.4238C27.8716 23.104 27.3911 23.0161 26.8633 23.0161C26.666 23.0161 26.4849 22.9277 26.3511 22.856C26.1304 22.7441 25.9492 22.4639 26.1226 22.1201C26.1777 22.0078 26.4458 21.7358 26.5088 21.688C27.2256 21.272 28.0527 21.4077 28.8169 21.7197C29.5259 22.0161 30.0615 22.5601 30.834 23.3281C31.6216 24.2559 31.7632 24.5117 32.2124 25.208C32.5669 25.752 32.8901 26.312 33.1104 26.9521C33.2446 27.3521 33.0713 27.6802 32.6064 27.8799Z"/>
11
3
  </svg>
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.3",
3
+ "version": "0.2.5",
4
4
  "description": "DeepSeek Harness Direct Provider Plugin for Paseo",
5
5
  "repository": {
6
6
  "type": "git",
package/scripts/build.mjs CHANGED
@@ -13,7 +13,8 @@ const result = await build({
13
13
  write: false,
14
14
  })
15
15
 
16
- const bridge = result.outputFiles.find(file => file.path.endsWith('/dsh-bridge.mjs'))
16
+ // esbuild reports output paths with the platform separator (backslashes on Windows).
17
+ const bridge = result.outputFiles.find(file => file.path.replace(/\\/g, '/').endsWith('/dsh-bridge.mjs'))
17
18
  if (!bridge) throw new Error('esbuild did not produce dist/dsh-bridge.mjs')
18
19
  await mkdir('dist', { recursive: true })
19
20
  await writeFile('dist/dsh-bridge.mjs', bridge.contents)
@@ -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 {
@@ -125,6 +125,26 @@ export interface HostServices {
125
125
  }>;
126
126
  };
127
127
  agentDefaultModel: { currentSelection(): ModelSelection } | undefined;
128
+ /**
129
+ * Projection registry read cut. The `tokenUsage` and `contextPressure` units
130
+ * carry the figures Paseo renders as session usage; absent when the profile
131
+ * mounts no projection registry.
132
+ */
133
+ sessionProjections?: {
134
+ snapshot(
135
+ session: HostSession,
136
+ keys?: readonly string[],
137
+ ): { values: Partial<Record<string, unknown>> };
138
+ /** Change feed: fires once per client-visible unit whose raw view changed. */
139
+ onChanged(
140
+ listener: (
141
+ session: HostSession,
142
+ key: string,
143
+ value: unknown,
144
+ seq: number,
145
+ ) => void,
146
+ ): () => void;
147
+ };
128
148
  sessionTitle:
129
149
  { rename(session: HostSession, title: string): void } | undefined;
130
150
  approval: object | undefined;
@@ -6,6 +6,7 @@ import type {
6
6
  BridgeInitializeResult,
7
7
  BridgeMethods,
8
8
  BridgeNotifications,
9
+ BridgeUsage,
9
10
  DshSessionEvent,
10
11
  InteractionRequest,
11
12
  ModelSelection,
@@ -25,6 +26,7 @@ import type {
25
26
  HostHandle,
26
27
  HostHelpers,
27
28
  HostServices,
29
+ HostSession,
28
30
  ModelSelectionRef,
29
31
  } from "./host.js";
30
32
 
@@ -136,6 +138,8 @@ export class BridgeService {
136
138
  private readonly preapproved = new Map<string, ReadonlySet<string>>();
137
139
  private readonly interactions = new Map<string, PendingInteraction>();
138
140
  private readonly subscriptions: Array<() => void> = [];
141
+ /** Last usage payload sent per session, so an unchanged fold is not re-sent. */
142
+ private readonly lastUsage = new Map<string, string>();
139
143
  private stopping = false;
140
144
  private stopTask?: Promise<{}>;
141
145
 
@@ -172,6 +176,19 @@ export class BridgeService {
172
176
  notify("session.status", { sessionId: String(agent.id), status });
173
177
  }),
174
178
  );
179
+ // Usage rides the projection change feed rather than the session event
180
+ // stream: it fires exactly when the harness's token fold moves, so no
181
+ // session-event name has to be guessed and an idle session costs nothing.
182
+ const projections = ctx.get("sessionProjections");
183
+ if (projections) {
184
+ this.subscriptions.push(
185
+ projections.onChanged((session, key) => {
186
+ if (key === "tokenUsage" || key === "contextPressure") {
187
+ this.publishUsage(session);
188
+ }
189
+ }),
190
+ );
191
+ }
175
192
  this.subscriptions.push(
176
193
  ctx.on("approval/request", (request, next) => {
177
194
  const sessionId = this.rootFor(request.agent);
@@ -216,6 +233,70 @@ export class BridgeService {
216
233
  );
217
234
  }
218
235
 
236
+ /**
237
+ * Fold the harness's token projections into Paseo's usage vocabulary.
238
+ *
239
+ * `tokenUsage` totals and `contextPressure` carry every figure Paseo can
240
+ * render, and both are plain numbers, so no live harness object crosses the
241
+ * wire. Returns undefined when no projection registry is mounted or neither
242
+ * unit is registered: a profile without them degrades to no usage rather
243
+ * than failing the turn.
244
+ */
245
+ private readUsage(session: HostSession): BridgeUsage | undefined {
246
+ const projections = this.ctx.get("sessionProjections");
247
+ if (!projections) return undefined;
248
+ let values: Partial<Record<string, unknown>>;
249
+ try {
250
+ values = projections.snapshot(session, [
251
+ "tokenUsage",
252
+ "contextPressure",
253
+ ]).values;
254
+ } catch {
255
+ return undefined;
256
+ }
257
+ const tokens = values.tokenUsage as
258
+ | {
259
+ uncachedInputTokens?: number;
260
+ outputTokens?: number;
261
+ cacheReadTokens?: number;
262
+ }
263
+ | undefined;
264
+ const pressure = values.contextPressure as
265
+ | { contextWindow?: number; pressureTokens?: number }
266
+ | undefined;
267
+ const usage: BridgeUsage = {};
268
+ if (typeof tokens?.uncachedInputTokens === "number")
269
+ usage.inputTokens = tokens.uncachedInputTokens;
270
+ if (typeof tokens?.cacheReadTokens === "number")
271
+ usage.cachedInputTokens = tokens.cacheReadTokens;
272
+ if (typeof tokens?.outputTokens === "number")
273
+ usage.outputTokens = tokens.outputTokens;
274
+ if (typeof pressure?.pressureTokens === "number")
275
+ usage.contextWindowUsedTokens = pressure.pressureTokens;
276
+ if (typeof pressure?.contextWindow === "number")
277
+ usage.contextWindowMaxTokens = pressure.contextWindow;
278
+ // The units materialize at zero before any provider usage lands, so a
279
+ // payload carrying no measured token is warm-up noise, not a reading.
280
+ const measured =
281
+ (usage.inputTokens ?? 0) > 0 ||
282
+ (usage.cachedInputTokens ?? 0) > 0 ||
283
+ (usage.outputTokens ?? 0) > 0 ||
284
+ (usage.contextWindowUsedTokens ?? 0) > 0;
285
+ return measured ? usage : undefined;
286
+ }
287
+
288
+ /** Publish one tracked session's usage, skipping an unchanged fold. */
289
+ private publishUsage(session: HostSession): void {
290
+ const sessionId = String(session.id);
291
+ if (!this.tracked.has(sessionId)) return;
292
+ const usage = this.readUsage(session);
293
+ if (!usage) return;
294
+ const signature = JSON.stringify(usage);
295
+ if (this.lastUsage.get(sessionId) === signature) return;
296
+ this.lastUsage.set(sessionId, signature);
297
+ this.notify("session.usage", { sessionId, usage });
298
+ }
299
+
219
300
  private rootFor(agent: HostAgent | undefined): string | undefined {
220
301
  let id = agent?.id && String(agent.id);
221
302
  const seen = new Set<string>();
@@ -652,6 +733,7 @@ export class BridgeService {
652
733
  this.opening.delete(id);
653
734
  this.tracked.delete(id);
654
735
  this.preapproved.delete(id);
736
+ this.lastUsage.delete(id);
655
737
  },
656
738
  );
657
739
  return task;
@@ -800,6 +882,7 @@ export class BridgeService {
800
882
  this.owned.delete(id);
801
883
  this.tracked.delete(id);
802
884
  this.preapproved.delete(id);
885
+ this.lastUsage.delete(id);
803
886
  })();
804
887
  this.closing.set(id, task);
805
888
  void task.finally(() => this.closing.delete(id)).catch(() => undefined);
@@ -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),
@@ -275,6 +277,15 @@ function listenToSessionBridge(
275
277
  session.dispatch.deferred = terminal;
276
278
  }
277
279
  }),
280
+ session.bridge.on("session.usage", ({ sessionId, usage }) => {
281
+ if (!isCurrentSession(sessionId)) return;
282
+ emit({
283
+ type: "session.usage",
284
+ sessionId: session.paseoId,
285
+ ...(session.activeTurnId ? { turnId: session.activeTurnId } : {}),
286
+ usage,
287
+ });
288
+ }),
278
289
  session.bridge.on("interaction.request", (interaction) => {
279
290
  if (!isCurrentSession(interaction.sessionId)) return;
280
291
  session.pendingInteractions.set(interaction.requestId, interaction);
@@ -208,11 +208,30 @@ export interface BridgeMethods {
208
208
  };
209
209
  "bridge.shutdown": { params: {}; result: {} };
210
210
  }
211
+ /**
212
+ * One session's token and context figures, in Paseo's usage vocabulary.
213
+ *
214
+ * Field names match `ProviderUsage` so the provider forwards this straight
215
+ * onto `session.usage` without a second translation table.
216
+ */
217
+ export interface BridgeUsage {
218
+ /** Uncached prompt tokens reported by the provider. */
219
+ inputTokens?: number;
220
+ /** Prompt tokens served from the provider's cache. */
221
+ cachedInputTokens?: number;
222
+ /** Completion tokens produced by the model. */
223
+ outputTokens?: number;
224
+ /** Provider-reported prompt size of the newest request. */
225
+ contextWindowUsedTokens?: number;
226
+ /** Route context capacity paired with that pressure. */
227
+ contextWindowMaxTokens?: number;
228
+ }
211
229
  export interface BridgeNotifications {
212
230
  "bridge.ready": { protocolVersion: 1 };
213
231
  "session.event": { sessionId: string; event: DshSessionEvent };
214
232
  "session.stream": { sessionId: string; frame: DshStreamFrame };
215
233
  "session.status": { sessionId: string; status: "idle" | "running" };
234
+ "session.usage": { sessionId: string; usage: BridgeUsage };
216
235
  "interaction.request": InteractionRequest;
217
236
  "interaction.closed": { requestId: string; sessionId: string };
218
237
  }