paseo-dsh-direct 0.2.1 → 0.2.3
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 +24 -0
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/dsh.png +0 -0
- package/index.server.ts +3 -1
- package/package.json +8 -6
- package/paseo-plugin.json +3 -1
- package/server/bridge-client.ts +154 -12
- package/server/dsh/service.ts +42 -1
- package/server/dsh-version.ts +6 -3
- package/server/provider.ts +68 -10
- package/shared/bridge-protocol.ts +6 -0
package/OVERVIEW.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# DeepSeek Harness for Paseo
|
|
2
|
+
|
|
3
|
+
Runs DeepSeek Harness (`dsh`) as a native Paseo agent provider. Paseo clients (desktop, web, iOS, Android) drive a local `dsh` agent with streamed responses, visible reasoning, tool approvals, user questions and session lifecycle management, without going through a generic ACP adapter.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Paseo 0.11.0 or newer.
|
|
8
|
+
- DeepSeek Harness `dsh` on the 0.2 line, installed and configured on the machine that runs the Paseo daemon (model API key and settings are read by `dsh` from its own configuration).
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Streaming assistant text and reasoning ("thinking") as Paseo timeline items.
|
|
13
|
+
- Tool calls with Paseo approval prompts, and `dsh` user questions answered from the Paseo UI.
|
|
14
|
+
- Create, resume, cancel and close sessions; model list and mode switching from the Paseo UI.
|
|
15
|
+
- DSH branding (name and icon) in the provider list.
|
|
16
|
+
|
|
17
|
+
## Processes and environment
|
|
18
|
+
|
|
19
|
+
- **Child process.** For each session the plugin starts the `dsh` executable as a child process and loads a small bridge plugin into it. The two communicate over the child's stdio.
|
|
20
|
+
- **Executable lookup.** In order: the `PASEO_DSH_EXECUTABLE` environment variable, then the Paseo setting `agents.providers.dsh-pi.command`, then `dsh` on `PATH`.
|
|
21
|
+
- **Profile.** The plugin launches `dsh --profile paseo`. If `<DSH_HOME>/profiles/paseo` does not exist, it is created once by running `dsh --profile paseo --from-default-profile web --dump-config`. `DSH_HOME` defaults to `~/.dsh`. Setting `PASEO_DSH_PROFILE` selects another existing profile instead; a missing one is reported as an error, not created.
|
|
22
|
+
- **Environment variables read by the plugin.** `PASEO_DSH_EXECUTABLE`, `PASEO_DSH_PROFILE`, and `DSH_HOME` (to locate the profile directory).
|
|
23
|
+
- **Environment passed to `dsh`.** The child inherits the Paseo daemon's environment.
|
|
24
|
+
- **Network.** The plugin makes no network requests itself. Model API calls are made by `dsh` according to its own configuration.
|
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ English | [中文](README.zh.md)
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
You need `dsh` on the machine that runs the Paseo daemon, with the same `major.minor` as the plugin line you install.
|
|
9
|
+
You need Paseo 0.11 or later and `dsh` on the machine that runs the Paseo daemon, with the same `major.minor` as the plugin line you install. On Paseo 0.8 to 0.10, install `npm:paseo-dsh-direct@0.2.1`.
|
|
10
10
|
|
|
11
11
|
In Paseo, open **Settings → Plugins → Install Plugin** and paste either the npm or the Git source:
|
|
12
12
|
|
|
@@ -36,7 +36,7 @@ Create a conversation with the **DeepSeek Harness** provider. Models and presets
|
|
|
36
36
|
|
|
37
37
|
The provider supports streamed assistant and reasoning output, tool execution and approval, user questions, model/preset configuration, interruption, and session recovery. It starts DSH with its own `paseo` profile and does not modify Paseo or DSH core.
|
|
38
38
|
|
|
39
|
-
The plugin uses the DSH profile `paseo`. On first start it creates that profile from DSH's official `web` template (`~/.dsh/profiles/paseo`, or under `$DSH_HOME` when set). Model routes, the default model, and permission presets go in `~/.dsh/profiles/paseo/cordis.patch.yml`.
|
|
39
|
+
The plugin uses the DSH profile `paseo`. On first start it creates that profile from DSH's official `web` template (`~/.dsh/profiles/paseo`, or under `$DSH_HOME` when set). Model routes, the default model, and permission presets go in `~/.dsh/profiles/paseo/cordis.patch.yml`. To use a different `dsh` executable, set `agents.providers.dsh-pi.command` in the Paseo configuration or `PASEO_DSH_EXECUTABLE` (which takes precedence); or `PASEO_DSH_PROFILE` to use another existing profile; a profile named there is not created automatically. If the Paseo Daemon has plugin loading disabled, enable `pluginsEnabled` in its configuration before installing the plugin.
|
|
40
40
|
|
|
41
41
|
If a native session is still held by another DSH client (such as a terminal UI), release it there before importing or resuming it from Paseo.
|
|
42
42
|
|
|
@@ -57,7 +57,7 @@ paseo provider models dsh-pi
|
|
|
57
57
|
The same local source can be installed directly with `paseo plugin install /absolute/path/to/paseo-dsh-direct` after building. The CLI also accepts a Git ref that the Settings UI cannot:
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
paseo plugin install
|
|
60
|
+
paseo plugin install github:Jecvay/paseo-dsh-direct --ref <tag-or-commit>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
## Development
|
package/README.zh.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
## 安装
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
需要 Paseo 0.11 及以上,并在运行 Paseo daemon 的机器上装好 `dsh`,它的 `major.minor` 要和你装的插件线一致。Paseo 0.8 至 0.10 请安装 `npm:paseo-dsh-direct@0.2.1`。
|
|
10
10
|
|
|
11
11
|
在 Paseo 里打开 **Settings → Plugins → Install Plugin**,npm 和 Git 两种来源任填一个:
|
|
12
12
|
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
Provider 支持连续正文和思考输出、工具执行与审批、用户问答、模型和 preset 配置、中断及会话恢复。它用插件专用的 `paseo` profile 启动 DSH,不修改 Paseo 或 DSH 核心。
|
|
38
38
|
|
|
39
|
-
插件使用名为 `paseo` 的 DSH profile。首次启动时,插件用 DSH 官方的 `web` 模板自动创建它(位置是 `~/.dsh/profiles/paseo`;设置了 `$DSH_HOME` 时在其下)。模型路由、默认模型和权限预设写在 `~/.dsh/profiles/paseo/cordis.patch.yml
|
|
39
|
+
插件使用名为 `paseo` 的 DSH profile。首次启动时,插件用 DSH 官方的 `web` 模板自动创建它(位置是 `~/.dsh/profiles/paseo`;设置了 `$DSH_HOME` 时在其下)。模型路由、默认模型和权限预设写在 `~/.dsh/profiles/paseo/cordis.patch.yml`。可在 Paseo 配置的 `agents.providers.dsh-pi.command` 或 `PASEO_DSH_EXECUTABLE`(后者优先)指定其他 `dsh` 可执行文件,通过 `PASEO_DSH_PROFILE` 改用另一个已存在的 profile;这样指定的 profile 不会被自动创建。如果 Paseo Daemon 默认关闭插件加载,请先在配置中启用 `pluginsEnabled`。
|
|
40
40
|
|
|
41
41
|
如果原生会话仍被另一个 DSH 客户端(如终端界面)占用,请先在那里释放,再从 Paseo 导入或恢复。
|
|
42
42
|
|
|
@@ -57,7 +57,7 @@ paseo provider models dsh-pi
|
|
|
57
57
|
构建后也可以直接安装本地源码:`paseo plugin install /absolute/path/to/paseo-dsh-direct`。命令行还能指定 Settings 界面给不了的 Git ref:
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
paseo plugin install
|
|
60
|
+
paseo plugin install github:Jecvay/paseo-dsh-direct --ref <tag-or-commit>
|
|
61
61
|
```
|
|
62
62
|
|
|
63
63
|
## 开发
|
package/dsh.png
ADDED
|
Binary file
|
package/index.server.ts
CHANGED
|
@@ -4,13 +4,15 @@ import { DSH_BRIDGE_SOURCE } from "./server/generated-bridge.js";
|
|
|
4
4
|
import { createDshProvider } from "./server/provider.js";
|
|
5
5
|
|
|
6
6
|
export default function contribute(server: PluginServerContext) {
|
|
7
|
+
const executable = process.env.PASEO_DSH_EXECUTABLE;
|
|
7
8
|
server.registerProvider(
|
|
8
9
|
createDshProvider({
|
|
10
|
+
executable,
|
|
9
11
|
createBridge: (launch) =>
|
|
10
12
|
launchDshBridge({
|
|
11
13
|
...launch,
|
|
12
14
|
bridgeSource: DSH_BRIDGE_SOURCE,
|
|
13
|
-
executable
|
|
15
|
+
executable,
|
|
14
16
|
profile: process.env.PASEO_DSH_PROFILE,
|
|
15
17
|
}),
|
|
16
18
|
}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-dsh-direct",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "DeepSeek Harness Direct Provider Plugin for Paseo",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -24,13 +24,15 @@
|
|
|
24
24
|
"scripts/install-lefthook.mjs",
|
|
25
25
|
"paseo-plugin.json",
|
|
26
26
|
"dsh.svg",
|
|
27
|
+
"dsh.png",
|
|
28
|
+
"OVERVIEW.md",
|
|
27
29
|
"!server/**/*.test.ts",
|
|
28
30
|
"!server/generated-bridge.ts",
|
|
29
31
|
"!server/generated-version.ts"
|
|
30
32
|
],
|
|
31
33
|
"peerDependencies": {
|
|
32
|
-
"@getpaseo/client": "0.
|
|
33
|
-
"@getpaseo/protocol": "0.
|
|
34
|
+
"@getpaseo/client": "0.11.2",
|
|
35
|
+
"@getpaseo/protocol": "0.11.2",
|
|
34
36
|
"react": "~19.1.0",
|
|
35
37
|
"react-native": ">=0.81.5"
|
|
36
38
|
},
|
|
@@ -49,7 +51,7 @@
|
|
|
49
51
|
}
|
|
50
52
|
},
|
|
51
53
|
"devDependencies": {
|
|
52
|
-
"@getpaseo/plugin": "0.
|
|
54
|
+
"@getpaseo/plugin": "0.11.2",
|
|
53
55
|
"@types/mdast": "^4.0.4",
|
|
54
56
|
"@types/node": "^24.0.0",
|
|
55
57
|
"esbuild": "^0.25.0",
|
|
@@ -65,13 +67,13 @@
|
|
|
65
67
|
"build": "node scripts/build.mjs",
|
|
66
68
|
"smoke:bridge": "npm run build && tsx scripts/smoke-bridge.ts",
|
|
67
69
|
"board": "tsx scripts/board.ts",
|
|
68
|
-
"
|
|
70
|
+
"agent-loop": "tsx scripts/agent-loop/main.ts",
|
|
69
71
|
"typecheck": "npm run typecheck:shared && npm run typecheck:server && npm run typecheck:client && npm run typecheck:scripts",
|
|
70
72
|
"typecheck:shared": "tsc -p tsconfig.shared.json",
|
|
71
73
|
"typecheck:server": "tsc -p tsconfig.server.json",
|
|
72
74
|
"typecheck:client": "tsc -p tsconfig.client.json",
|
|
73
75
|
"typecheck:scripts": "tsc -p tsconfig.scripts.json",
|
|
74
|
-
"test": "tsx --test \"server/**/*.test.ts\"",
|
|
76
|
+
"test": "tsx --test \"server/**/*.test.ts\" \"scripts/**/*.test.ts\"",
|
|
75
77
|
"postinstall": "node scripts/install-lefthook.mjs",
|
|
76
78
|
"verify:notes": "tsx scripts/verify-agent-note-classification.ts && tsx scripts/verify-agent-note-format.ts && tsx scripts/verify-archived-agent-notes.ts",
|
|
77
79
|
"verify:docs": "tsx scripts/verify-md-links.ts && tsx scripts/verify-doc-refs.ts",
|
package/paseo-plugin.json
CHANGED
package/server/bridge-client.ts
CHANGED
|
@@ -3,6 +3,8 @@ 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
|
+
import { detectDshVersion } from "./dsh-version.js";
|
|
7
|
+
import type { ProviderLaunch } from "@getpaseo/plugin/server/provider";
|
|
6
8
|
import type {
|
|
7
9
|
BridgeInitializeResult,
|
|
8
10
|
BridgeMethods,
|
|
@@ -16,8 +18,10 @@ type Notification = keyof BridgeNotifications;
|
|
|
16
18
|
|
|
17
19
|
export interface DshBridge {
|
|
18
20
|
readonly initialized: BridgeInitializeResult;
|
|
19
|
-
/** The resolved `dsh` executable this bridge was launched with (see `
|
|
21
|
+
/** The resolved `dsh` executable this bridge was launched with (see `resolveDshLaunch`). */
|
|
20
22
|
readonly executable: string;
|
|
23
|
+
/** Arguments that precede dsh's own arguments, from Paseo's resolved `command`. */
|
|
24
|
+
readonly args?: readonly string[];
|
|
21
25
|
request<Name extends Method>(
|
|
22
26
|
method: Name,
|
|
23
27
|
params: BridgeMethods[Name]["params"],
|
|
@@ -52,9 +56,12 @@ export interface SessionLaunch {
|
|
|
52
56
|
/** Store sessions under the bridge's temporary directory, removed on close. */
|
|
53
57
|
ephemeral?: boolean;
|
|
54
58
|
mcpServers?: readonly BridgeMcpServer[];
|
|
59
|
+
/** The `launch` Paseo resolved for the provider connection: command, leading arguments and the complete daemon environment. */
|
|
60
|
+
providerLaunch?: ProviderLaunch;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
export interface LaunchBridgeOptions extends SessionLaunch {
|
|
64
|
+
/** The `PASEO_DSH_EXECUTABLE` value; it takes precedence over `providerLaunch.command`. */
|
|
58
65
|
executable?: string;
|
|
59
66
|
profile?: string;
|
|
60
67
|
bridgeSource: string;
|
|
@@ -123,6 +130,7 @@ export function createBridgePatch(options: {
|
|
|
123
130
|
*/
|
|
124
131
|
export async function ensureDshProfile(options: {
|
|
125
132
|
executable: string;
|
|
133
|
+
args?: readonly string[];
|
|
126
134
|
profile: string;
|
|
127
135
|
env: Readonly<Record<string, string | undefined>>;
|
|
128
136
|
timeoutMs?: number;
|
|
@@ -139,7 +147,13 @@ export async function ensureDshProfile(options: {
|
|
|
139
147
|
// Concurrent first launches share one creation run.
|
|
140
148
|
let creation = profileCreations.get(directory);
|
|
141
149
|
if (!creation) {
|
|
142
|
-
creation = runProfileInit(
|
|
150
|
+
creation = runProfileInit(
|
|
151
|
+
options.executable,
|
|
152
|
+
options.args ?? [],
|
|
153
|
+
options.profile,
|
|
154
|
+
options.env,
|
|
155
|
+
options.timeoutMs ?? 60_000,
|
|
156
|
+
).finally(
|
|
143
157
|
() => profileCreations.delete(directory),
|
|
144
158
|
);
|
|
145
159
|
profileCreations.set(directory, creation);
|
|
@@ -162,6 +176,7 @@ async function isDirectory(directory: string): Promise<boolean> {
|
|
|
162
176
|
|
|
163
177
|
function runProfileInit(
|
|
164
178
|
executable: string,
|
|
179
|
+
leadingArgs: readonly string[],
|
|
165
180
|
profile: string,
|
|
166
181
|
env: Readonly<Record<string, string | undefined>>,
|
|
167
182
|
timeoutMs: number,
|
|
@@ -169,7 +184,7 @@ function runProfileInit(
|
|
|
169
184
|
return new Promise((resolve, reject) => {
|
|
170
185
|
const child = spawn(
|
|
171
186
|
executable,
|
|
172
|
-
["--profile", profile, "--from-default-profile", DEFAULT_PROFILE_TEMPLATE, "--dump-config"],
|
|
187
|
+
[...leadingArgs, "--profile", profile, "--from-default-profile", DEFAULT_PROFILE_TEMPLATE, "--dump-config"],
|
|
173
188
|
{ env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true },
|
|
174
189
|
);
|
|
175
190
|
let stderr = "";
|
|
@@ -199,11 +214,135 @@ function runProfileInit(
|
|
|
199
214
|
});
|
|
200
215
|
}
|
|
201
216
|
|
|
217
|
+
/** The command and leading arguments that start dsh. */
|
|
218
|
+
export interface DshLaunch {
|
|
219
|
+
command: string;
|
|
220
|
+
args: readonly string[];
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The dsh launch, by precedence: a non-blank `PASEO_DSH_EXECUTABLE` (the most
|
|
225
|
+
* specific signal, kept for existing installs), then the `launch` Paseo
|
|
226
|
+
* resolved from `command` / `agents.providers.dsh-pi.command`, then `dsh` from
|
|
227
|
+
* `PATH`. `launch.args` belong to `launch.command`, so they are dropped when
|
|
228
|
+
* the environment variable wins.
|
|
229
|
+
*/
|
|
230
|
+
export function resolveDshLaunch(configured: string | undefined, launch?: Pick<ProviderLaunch, "command" | "args">): DshLaunch {
|
|
231
|
+
const override = configured?.trim();
|
|
232
|
+
if (override) return { command: override, args: [] };
|
|
233
|
+
if (launch) return { command: launch.command, args: launch.args };
|
|
234
|
+
return { command: "dsh", args: [] };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The `dsh` executable chosen by `resolveDshLaunch`. */
|
|
238
|
+
export function resolveDshExecutable(configured: string | undefined, launch?: Pick<ProviderLaunch, "command" | "args">): string {
|
|
239
|
+
return resolveDshLaunch(configured, launch).command;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Text for diagnostics: the command line with its leading arguments. */
|
|
243
|
+
export function describeDshLaunch(launch: DshLaunch): string {
|
|
244
|
+
return [launch.command, ...launch.args].join(" ");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Row ids found in a dumped DSH configuration, in YAML (`id: x`) or JSON (`"id": "x"`) form. */
|
|
248
|
+
export function dumpedRowIds(dump: string): Set<string> {
|
|
249
|
+
const ids = new Set<string>();
|
|
250
|
+
for (const match of dump.matchAll(/(?:^|[\s{,\-])["']?id["']?\s*:\s*["']?([A-Za-z0-9_.@/-]+)["']?/gm)) {
|
|
251
|
+
ids.add(match[1]!);
|
|
252
|
+
}
|
|
253
|
+
return ids;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The surface rows the launcher disables that the dumped configuration does not contain. */
|
|
257
|
+
export function missingSurfaceRows(dump: string): string[] {
|
|
258
|
+
const present = dumpedRowIds(dump);
|
|
259
|
+
return DISABLED_SURFACE_ROWS.filter((row) => !present.has(row));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Fail loudly when the profile no longer has a row the launcher disables.
|
|
264
|
+
* Disabling an absent row has no effect, so a renamed row would leave the
|
|
265
|
+
* browser surface running beside the bridge. Returns normally when the dump
|
|
266
|
+
* cannot be obtained or is empty: the guard has no evidence then, and the real
|
|
267
|
+
* launch reports its own errors.
|
|
268
|
+
*/
|
|
269
|
+
export async function assertSurfaceRowsPresent(options: {
|
|
270
|
+
launch: DshLaunch;
|
|
271
|
+
profile: string;
|
|
272
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
273
|
+
dshVersionOf: (launch: DshLaunch) => Promise<string>;
|
|
274
|
+
dump?: (launch: DshLaunch, profile: string, env: Readonly<Record<string, string | undefined>>) => Promise<string>;
|
|
275
|
+
}): Promise<void> {
|
|
276
|
+
const dump = await (options.dump ?? dumpProfileConfig)(options.launch, options.profile, options.env).catch(() => "");
|
|
277
|
+
if (!dump.trim()) return;
|
|
278
|
+
const missing = missingSurfaceRows(dump);
|
|
279
|
+
if (missing.length === 0) return;
|
|
280
|
+
const version = (await options.dshVersionOf(options.launch).catch(() => "")) || "unknown";
|
|
281
|
+
throw new Error(
|
|
282
|
+
`DSH ${version} profile "${options.profile}" has no ${missing.map((row) => `"${row}"`).join(", ")} row. ` +
|
|
283
|
+
`The plugin disables ${DISABLED_SURFACE_ROWS.join(", ")} to keep the web surface from running beside the bridge, ` +
|
|
284
|
+
`so this dsh does not match the plugin. Install the dsh line this plugin release supports, ` +
|
|
285
|
+
`or recreate the profile from the web template.`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** `dsh --profile <profile> --dump-config` stdout; rejects when dsh fails or exceeds the timeout. */
|
|
290
|
+
function dumpProfileConfig(
|
|
291
|
+
launch: DshLaunch,
|
|
292
|
+
profile: string,
|
|
293
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
294
|
+
): Promise<string> {
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
const child = spawn(launch.command, [...launch.args, "--profile", profile, "--dump-config"], {
|
|
297
|
+
env,
|
|
298
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
299
|
+
windowsHide: true,
|
|
300
|
+
});
|
|
301
|
+
let stdout = "";
|
|
302
|
+
child.stdout.setEncoding("utf8");
|
|
303
|
+
child.stdout.on("data", (chunk: string) => {
|
|
304
|
+
stdout = (stdout + chunk).slice(-1_048_576);
|
|
305
|
+
});
|
|
306
|
+
const timeout = setTimeout(() => {
|
|
307
|
+
child.kill("SIGKILL");
|
|
308
|
+
reject(new Error("dsh --dump-config timed out"));
|
|
309
|
+
}, 20_000);
|
|
310
|
+
child.once("error", (error) => {
|
|
311
|
+
clearTimeout(timeout);
|
|
312
|
+
reject(error);
|
|
313
|
+
});
|
|
314
|
+
child.once("exit", (code) => {
|
|
315
|
+
clearTimeout(timeout);
|
|
316
|
+
if (code === 0) resolve(stdout);
|
|
317
|
+
else reject(new Error(`dsh --dump-config exited with code ${String(code)}`));
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Names of host services or methods the bridge needs that the handshake reported missing. */
|
|
323
|
+
export function assertBridgeHandshake(initialized: BridgeInitializeResult, dshVersion: string): void {
|
|
324
|
+
if (initialized.protocolVersion !== 1) {
|
|
325
|
+
throw new Error(`Unsupported DSH bridge protocol ${initialized.protocolVersion}`);
|
|
326
|
+
}
|
|
327
|
+
const missing = initialized.missing ?? [];
|
|
328
|
+
if (missing.length === 0) return;
|
|
329
|
+
throw new Error(
|
|
330
|
+
`DSH ${dshVersion || "unknown"} does not provide what the bridge depends on: ${missing.join(", ")}. ` +
|
|
331
|
+
`This dsh does not match the plugin; install the dsh line this plugin release supports.`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
202
335
|
export async function launchDshBridge(options: LaunchBridgeOptions): Promise<DshBridge> {
|
|
203
336
|
const profile = options.profile?.trim() || DEFAULT_PROFILE;
|
|
204
|
-
const
|
|
205
|
-
const env = { ...process.env, ...options.env };
|
|
206
|
-
await ensureDshProfile({ executable, profile, env });
|
|
337
|
+
const launch = resolveDshLaunch(options.executable, options.providerLaunch);
|
|
338
|
+
const env = { ...(options.providerLaunch?.env ?? process.env), ...options.env };
|
|
339
|
+
await ensureDshProfile({ executable: launch.command, args: launch.args, profile, env });
|
|
340
|
+
await assertSurfaceRowsPresent({
|
|
341
|
+
launch,
|
|
342
|
+
profile,
|
|
343
|
+
env,
|
|
344
|
+
dshVersionOf: (target) => detectDshVersion(target.command, { args: target.args, env }),
|
|
345
|
+
});
|
|
207
346
|
const directory = await mkdtemp(path.join(tmpdir(), "paseo-dsh-direct-"));
|
|
208
347
|
const bridgePath = path.join(directory, "dsh-bridge.mjs");
|
|
209
348
|
const patchPath = path.join(directory, "patch.json");
|
|
@@ -221,17 +360,18 @@ export async function launchDshBridge(options: LaunchBridgeOptions): Promise<Dsh
|
|
|
221
360
|
{ mode: 0o600 },
|
|
222
361
|
);
|
|
223
362
|
|
|
224
|
-
const child = spawn(
|
|
363
|
+
const child = spawn(launch.command, [...launch.args, "--profile", profile, "--patch", patchPath], {
|
|
225
364
|
env,
|
|
226
365
|
stdio: ["pipe", "pipe", "pipe"],
|
|
227
366
|
windowsHide: true,
|
|
228
367
|
});
|
|
229
|
-
const client = new JsonlBridgeClient(child, directory,
|
|
368
|
+
const client = new JsonlBridgeClient(child, directory, launch);
|
|
230
369
|
try {
|
|
231
370
|
await client.waitUntilReady(options.startupTimeoutMs ?? 15_000);
|
|
232
371
|
client.initialized = await client.request("bridge.initialize", {});
|
|
233
|
-
if (client.initialized.protocolVersion !== 1) {
|
|
234
|
-
|
|
372
|
+
if (client.initialized.protocolVersion !== 1 || (client.initialized.missing?.length ?? 0) > 0) {
|
|
373
|
+
const version = await detectDshVersion(launch.command, { args: launch.args, env }).catch(() => "");
|
|
374
|
+
assertBridgeHandshake(client.initialized, version);
|
|
235
375
|
}
|
|
236
376
|
return client;
|
|
237
377
|
} catch (error) {
|
|
@@ -243,6 +383,7 @@ export async function launchDshBridge(options: LaunchBridgeOptions): Promise<Dsh
|
|
|
243
383
|
class JsonlBridgeClient implements DshBridge {
|
|
244
384
|
initialized!: BridgeInitializeResult;
|
|
245
385
|
readonly executable: string;
|
|
386
|
+
readonly args: readonly string[];
|
|
246
387
|
private nextId = 1;
|
|
247
388
|
private readonly pending = new Map<string | number, PendingRequest>();
|
|
248
389
|
private readonly notifications = new Map<string, Set<(params: never) => void>>();
|
|
@@ -258,9 +399,10 @@ class JsonlBridgeClient implements DshBridge {
|
|
|
258
399
|
constructor(
|
|
259
400
|
private readonly child: ChildProcessWithoutNullStreams,
|
|
260
401
|
private readonly temporaryDirectory: string,
|
|
261
|
-
|
|
402
|
+
launch: DshLaunch,
|
|
262
403
|
) {
|
|
263
|
-
this.executable =
|
|
404
|
+
this.executable = launch.command;
|
|
405
|
+
this.args = launch.args;
|
|
264
406
|
this.exited = new Promise((resolve) => {
|
|
265
407
|
this.resolveExited = resolve;
|
|
266
408
|
});
|
package/server/dsh/service.ts
CHANGED
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
HostContext,
|
|
25
25
|
HostHandle,
|
|
26
26
|
HostHelpers,
|
|
27
|
+
HostServices,
|
|
27
28
|
ModelSelectionRef,
|
|
28
29
|
} from "./host.js";
|
|
29
30
|
|
|
@@ -93,6 +94,38 @@ export function savedModel(
|
|
|
93
94
|
return pending ?? used;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** Host services and methods every bridge method relies on; `service` alone means the service itself. */
|
|
98
|
+
const REQUIRED_HOST_SURFACE = {
|
|
99
|
+
agents: ["get", "create", "resume"],
|
|
100
|
+
sessionQuery: ["listSessions", "readSession", "observeSession"],
|
|
101
|
+
agentPresets: ["list", "resolve", "mount", "select"],
|
|
102
|
+
llm: ["listProviders", "listModels"],
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
/** The required host services and methods this DSH does not provide. */
|
|
106
|
+
export function missingHostSurface(ctx: HostContext): string[] {
|
|
107
|
+
const missing: string[] = [];
|
|
108
|
+
for (const [service, methods] of Object.entries(REQUIRED_HOST_SURFACE)) {
|
|
109
|
+
let provided: unknown;
|
|
110
|
+
try {
|
|
111
|
+
provided = ctx.get(service as keyof HostServices);
|
|
112
|
+
} catch {
|
|
113
|
+
// A service that is not registered may throw instead of returning undefined.
|
|
114
|
+
provided = undefined;
|
|
115
|
+
}
|
|
116
|
+
if (provided === null || typeof provided !== "object") {
|
|
117
|
+
missing.push(service);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
for (const method of methods) {
|
|
121
|
+
if (typeof (provided as Record<string, unknown>)[method] !== "function") {
|
|
122
|
+
missing.push(`${service}.${method}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return missing;
|
|
127
|
+
}
|
|
128
|
+
|
|
96
129
|
export class BridgeService {
|
|
97
130
|
private readonly owned = new Map<string, RecordEntry>();
|
|
98
131
|
private readonly opening = new Map<string, Promise<SessionOpenResult>>();
|
|
@@ -317,7 +350,14 @@ export class BridgeService {
|
|
|
317
350
|
const params = record(input);
|
|
318
351
|
let result: unknown;
|
|
319
352
|
switch (method) {
|
|
320
|
-
case "bridge.initialize":
|
|
353
|
+
case "bridge.initialize": {
|
|
354
|
+
const missing = missingHostSurface(this.ctx);
|
|
355
|
+
if (missing.length > 0) {
|
|
356
|
+
result = { protocolVersion: 1, profile: this.profile, capabilities: {
|
|
357
|
+
stream: true, approvals: false, questions: false, history: true, cancel: true,
|
|
358
|
+
}, catalog: { models: [], presets: [] }, missing } satisfies BridgeInitializeResult;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
321
361
|
result = {
|
|
322
362
|
protocolVersion: 1,
|
|
323
363
|
profile: this.profile,
|
|
@@ -331,6 +371,7 @@ export class BridgeService {
|
|
|
331
371
|
catalog: await this.catalog(),
|
|
332
372
|
} satisfies BridgeInitializeResult;
|
|
333
373
|
break;
|
|
374
|
+
}
|
|
334
375
|
case "session.list": {
|
|
335
376
|
const query = this.ctx.get("sessionQuery");
|
|
336
377
|
const records = await query.listSessions();
|
package/server/dsh-version.ts
CHANGED
|
@@ -8,6 +8,8 @@ const cache = new Map<string, Promise<string>>();
|
|
|
8
8
|
export interface DetectDshVersionOptions {
|
|
9
9
|
timeoutMs?: number;
|
|
10
10
|
env?: Readonly<Record<string, string | undefined>>;
|
|
11
|
+
/** Arguments placed before `--version`, from Paseo's resolved command. */
|
|
12
|
+
args?: readonly string[];
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -18,10 +20,11 @@ export interface DetectDshVersionOptions {
|
|
|
18
20
|
* executable path for the life of the process.
|
|
19
21
|
*/
|
|
20
22
|
export function detectDshVersion(executable: string, options: DetectDshVersionOptions = {}): Promise<string> {
|
|
21
|
-
const
|
|
23
|
+
const key = JSON.stringify([executable, ...(options.args ?? [])]);
|
|
24
|
+
const cached = cache.get(key);
|
|
22
25
|
if (cached) return cached;
|
|
23
26
|
const probe = runVersionProbe(executable, options);
|
|
24
|
-
cache.set(
|
|
27
|
+
cache.set(key, probe);
|
|
25
28
|
return probe;
|
|
26
29
|
}
|
|
27
30
|
|
|
@@ -38,7 +41,7 @@ function runVersionProbe(executable: string, options: DetectDshVersionOptions):
|
|
|
38
41
|
|
|
39
42
|
let child;
|
|
40
43
|
try {
|
|
41
|
-
child = spawn(executable, ["--version"], {
|
|
44
|
+
child = spawn(executable, [...(options.args ?? []), "--version"], {
|
|
42
45
|
env: options.env ?? process.env,
|
|
43
46
|
stdio: ["ignore", "pipe", "ignore"],
|
|
44
47
|
windowsHide: true,
|
package/server/provider.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ProviderPermissionResponse,
|
|
12
12
|
type ProviderRegistration,
|
|
13
13
|
type ProviderSessionConfig,
|
|
14
|
+
type ProviderStatus,
|
|
14
15
|
} from "@getpaseo/plugin/server/provider";
|
|
15
16
|
import type {
|
|
16
17
|
BridgeCatalog,
|
|
@@ -23,7 +24,14 @@ import type {
|
|
|
23
24
|
SlashCommand,
|
|
24
25
|
} from "../shared/bridge-protocol.js";
|
|
25
26
|
import { slashLine } from "../shared/bridge-protocol.js";
|
|
26
|
-
import
|
|
27
|
+
import {
|
|
28
|
+
describeDshLaunch,
|
|
29
|
+
resolveDshLaunch,
|
|
30
|
+
type BridgeMcpServer,
|
|
31
|
+
type DshBridge,
|
|
32
|
+
type DshLaunch,
|
|
33
|
+
type SessionLaunch,
|
|
34
|
+
} from "./bridge-client.js";
|
|
27
35
|
import { detectDshVersion } from "./dsh-version.js";
|
|
28
36
|
import { PLUGIN_VERSION } from "./plugin-version.js";
|
|
29
37
|
import { displayText, toPromptParts } from "./prompt-content.js";
|
|
@@ -46,10 +54,17 @@ const CAPABILITIES = [
|
|
|
46
54
|
const PERMISSION_PRESET_SETTING = "permissionPreset";
|
|
47
55
|
const PLAN_MODE_SETTING = "planMode";
|
|
48
56
|
|
|
57
|
+
type DetectVersion = (
|
|
58
|
+
executable: string,
|
|
59
|
+
options?: { args?: readonly string[]; env?: Readonly<Record<string, string | undefined>> },
|
|
60
|
+
) => Promise<string>;
|
|
61
|
+
|
|
49
62
|
interface DshProviderOptions {
|
|
50
63
|
createBridge(launch?: SessionLaunch): Promise<DshBridge>;
|
|
64
|
+
/** The `PASEO_DSH_EXECUTABLE` value; it outranks the `launch` Paseo resolves. `status()` and `createBridge` use the same precedence. */
|
|
65
|
+
executable?: string;
|
|
51
66
|
/** Detects the local dsh version for the startup compatibility check; defaults to spawning `<exe> --version`. */
|
|
52
|
-
detectDshVersion?:
|
|
67
|
+
detectDshVersion?: DetectVersion;
|
|
53
68
|
}
|
|
54
69
|
|
|
55
70
|
/** A prompt or command RPC in flight; it owns any turn DSH starts before the RPC returns. */
|
|
@@ -88,9 +103,20 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
|
|
|
88
103
|
icon: "dsh.svg",
|
|
89
104
|
getCatalogCacheKey: async (options) =>
|
|
90
105
|
options.scope === "workspace" ? `dsh-pi:${options.cwd}` : "dsh-pi:global",
|
|
106
|
+
command: ["dsh"],
|
|
107
|
+
async status(request) {
|
|
108
|
+
return dshStatus(
|
|
109
|
+
resolveDshLaunch(options.executable, request?.launch),
|
|
110
|
+
request?.launch?.env,
|
|
111
|
+
options.detectDshVersion ?? detectDshVersion,
|
|
112
|
+
);
|
|
113
|
+
},
|
|
91
114
|
async connect(request) {
|
|
92
115
|
if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required");
|
|
93
|
-
const
|
|
116
|
+
const providerLaunch = request.launch;
|
|
117
|
+
const createBridge = (launch?: SessionLaunch) =>
|
|
118
|
+
options.createBridge(providerLaunch ? { ...launch, providerLaunch } : launch);
|
|
119
|
+
const bridge = await createBridge();
|
|
94
120
|
const supported = CAPABILITIES.filter(
|
|
95
121
|
(capability) =>
|
|
96
122
|
capability !== "permission" ||
|
|
@@ -99,19 +125,47 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
|
|
|
99
125
|
);
|
|
100
126
|
return createConnection(
|
|
101
127
|
bridge,
|
|
102
|
-
|
|
128
|
+
createBridge,
|
|
103
129
|
negotiateProviderCapabilities(request.capabilities, supported),
|
|
104
130
|
options.detectDshVersion ?? detectDshVersion,
|
|
131
|
+
providerLaunch?.env,
|
|
105
132
|
);
|
|
106
133
|
},
|
|
107
134
|
};
|
|
108
135
|
}
|
|
109
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Availability of the local dsh for Paseo 0.11+ provider listings. A dsh that
|
|
139
|
+
* does not answer `--version` is unavailable; a version outside the plugin's
|
|
140
|
+
* major.minor line stays available with the same warning the session timeline
|
|
141
|
+
* shows, since only DSH's own handshake can reject a session.
|
|
142
|
+
*/
|
|
143
|
+
async function dshStatus(
|
|
144
|
+
launch: DshLaunch,
|
|
145
|
+
env: Readonly<Record<string, string>> | undefined,
|
|
146
|
+
detectVersion: DetectVersion,
|
|
147
|
+
): Promise<ProviderStatus> {
|
|
148
|
+
const dshVersion = await detectVersion(launch.command, { args: launch.args, ...(env ? { env } : {}) }).catch(() => "");
|
|
149
|
+
if (!dshVersion) {
|
|
150
|
+
return {
|
|
151
|
+
available: false,
|
|
152
|
+
diagnostic:
|
|
153
|
+
`Could not run "${describeDshLaunch(launch)} --version". Install dsh, ` +
|
|
154
|
+
`set agents.providers.dsh-pi.command in Paseo config, or set PASEO_DSH_EXECUTABLE, ` +
|
|
155
|
+
`then restart the Paseo daemon.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const match = matchVersionLine(PLUGIN_VERSION, dshVersion);
|
|
159
|
+
if (match === "match") return { available: true };
|
|
160
|
+
return { available: true, diagnostic: versionWarningMessage(match, PLUGIN_VERSION, dshVersion) };
|
|
161
|
+
}
|
|
162
|
+
|
|
110
163
|
function createConnection(
|
|
111
164
|
discoveryBridge: DshBridge,
|
|
112
|
-
createBridge:
|
|
165
|
+
createBridge: (launch?: SessionLaunch) => Promise<DshBridge>,
|
|
113
166
|
capabilities: readonly string[],
|
|
114
|
-
detectVersion:
|
|
167
|
+
detectVersion: DetectVersion,
|
|
168
|
+
launchEnv: Readonly<Record<string, string>> | undefined,
|
|
115
169
|
): ProviderConnection {
|
|
116
170
|
const listeners = new Set<(event: ProviderEvent) => void>();
|
|
117
171
|
const sessions = new Map<string, SessionState>();
|
|
@@ -128,7 +182,7 @@ function createConnection(
|
|
|
128
182
|
if (closed) throw new Error("Provider connection is closed");
|
|
129
183
|
validateAdmission(input, sessions, capabilities);
|
|
130
184
|
queueMicrotask(() => {
|
|
131
|
-
void dispatch(input, { discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion }).catch(
|
|
185
|
+
void dispatch(input, { discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion, launchEnv }).catch(
|
|
132
186
|
(error) => failInput(input, error, emit),
|
|
133
187
|
);
|
|
134
188
|
});
|
|
@@ -275,11 +329,12 @@ function failSession(
|
|
|
275
329
|
|
|
276
330
|
interface DispatchState {
|
|
277
331
|
discoveryBridge: DshBridge;
|
|
278
|
-
createBridge:
|
|
332
|
+
createBridge: (launch?: SessionLaunch) => Promise<DshBridge>;
|
|
279
333
|
sessions: Map<string, SessionState>;
|
|
280
334
|
emit(event: ProviderEvent): void;
|
|
281
335
|
capabilities: readonly string[];
|
|
282
|
-
detectVersion:
|
|
336
|
+
detectVersion: DetectVersion;
|
|
337
|
+
launchEnv: Readonly<Record<string, string>> | undefined;
|
|
283
338
|
}
|
|
284
339
|
|
|
285
340
|
async function dispatch(input: ProviderInput, state: DispatchState): Promise<void> {
|
|
@@ -440,7 +495,10 @@ async function openSession(
|
|
|
440
495
|
* Never blocks the session: DSH's own handshake is what can fail startup.
|
|
441
496
|
*/
|
|
442
497
|
async function warnOnDshVersionMismatch(session: SessionState, state: DispatchState): Promise<void> {
|
|
443
|
-
const dshVersion = await state.detectVersion(session.bridge.executable
|
|
498
|
+
const dshVersion = await state.detectVersion(session.bridge.executable, {
|
|
499
|
+
args: session.bridge.args ?? [],
|
|
500
|
+
...(state.launchEnv ? { env: state.launchEnv } : {}),
|
|
501
|
+
}).catch(() => "");
|
|
444
502
|
const status = matchVersionLine(PLUGIN_VERSION, dshVersion);
|
|
445
503
|
if (status === "match") return;
|
|
446
504
|
const message = versionWarningMessage(status, PLUGIN_VERSION, dshVersion);
|
|
@@ -86,6 +86,12 @@ export interface BridgeInitializeResult {
|
|
|
86
86
|
cancel: true;
|
|
87
87
|
};
|
|
88
88
|
catalog: BridgeCatalog;
|
|
89
|
+
/**
|
|
90
|
+
* DSH services or methods the bridge requires that this DSH does not provide,
|
|
91
|
+
* as `service` or `service.method`. Absent or empty when nothing is missing;
|
|
92
|
+
* the plugin refuses to connect otherwise.
|
|
93
|
+
*/
|
|
94
|
+
missing?: string[];
|
|
89
95
|
}
|
|
90
96
|
export interface SessionOpenParams {
|
|
91
97
|
sessionId?: string;
|