paseo-dsh-direct 0.1.4
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/LICENSE +21 -0
- package/README.md +64 -0
- package/README.zh.md +64 -0
- package/dsh.svg +11 -0
- package/index.client.tsx +6 -0
- package/index.server.ts +19 -0
- package/package.json +80 -0
- package/paseo-plugin.json +10 -0
- package/scripts/build.mjs +31 -0
- package/scripts/install-lefthook.mjs +743 -0
- package/server/bridge-client.ts +416 -0
- package/server/dsh/bridge.ts +176 -0
- package/server/dsh/host.ts +204 -0
- package/server/dsh/service.ts +890 -0
- package/server/dsh-version.ts +71 -0
- package/server/plugin-version.ts +7 -0
- package/server/prompt-content.ts +120 -0
- package/server/provider.ts +1101 -0
- package/server/timeline.ts +487 -0
- package/server/turn-end.ts +41 -0
- package/server/values.ts +20 -0
- package/server/version-line.ts +50 -0
- package/shared/bridge-protocol.ts +232 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createInterface } from "node:readline";
|
|
6
|
+
import type {
|
|
7
|
+
BridgeInitializeResult,
|
|
8
|
+
BridgeMethods,
|
|
9
|
+
BridgeNotifications,
|
|
10
|
+
RpcNotification,
|
|
11
|
+
RpcResponse,
|
|
12
|
+
} from "../shared/bridge-protocol.js";
|
|
13
|
+
|
|
14
|
+
type Method = keyof BridgeMethods;
|
|
15
|
+
type Notification = keyof BridgeNotifications;
|
|
16
|
+
|
|
17
|
+
export interface DshBridge {
|
|
18
|
+
readonly initialized: BridgeInitializeResult;
|
|
19
|
+
/** The resolved `dsh` executable this bridge was launched with (see `PASEO_DSH_EXECUTABLE`). */
|
|
20
|
+
readonly executable: string;
|
|
21
|
+
request<Name extends Method>(
|
|
22
|
+
method: Name,
|
|
23
|
+
params: BridgeMethods[Name]["params"],
|
|
24
|
+
): Promise<BridgeMethods[Name]["result"]>;
|
|
25
|
+
on<Name extends Notification>(
|
|
26
|
+
method: Name,
|
|
27
|
+
listener: (params: BridgeNotifications[Name]) => void,
|
|
28
|
+
): () => void;
|
|
29
|
+
onFailure(listener: (error: Error) => void): () => void;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** An MCP server mounted through `@deepseek-ai/dsh-mcp-client` for one bridge process. */
|
|
34
|
+
export type BridgeMcpServer =
|
|
35
|
+
| {
|
|
36
|
+
serverName: string;
|
|
37
|
+
transport: "stdio";
|
|
38
|
+
command: string;
|
|
39
|
+
args?: string[];
|
|
40
|
+
env?: Record<string, string>;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
serverName: string;
|
|
44
|
+
transport: "streamable-http";
|
|
45
|
+
url: string;
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** Per-session launch choices; each Paseo session owns its bridge process. */
|
|
50
|
+
export interface SessionLaunch {
|
|
51
|
+
env?: Readonly<Record<string, string>>;
|
|
52
|
+
/** Store sessions under the bridge's temporary directory, removed on close. */
|
|
53
|
+
ephemeral?: boolean;
|
|
54
|
+
mcpServers?: readonly BridgeMcpServer[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface LaunchBridgeOptions extends SessionLaunch {
|
|
58
|
+
executable?: string;
|
|
59
|
+
profile?: string;
|
|
60
|
+
bridgeSource: string;
|
|
61
|
+
startupTimeoutMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PendingRequest {
|
|
65
|
+
resolve(value: unknown): void;
|
|
66
|
+
reject(error: Error): void;
|
|
67
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const MAX_STDERR = 16_384;
|
|
71
|
+
|
|
72
|
+
/** The profile the plugin launches unless `PASEO_DSH_PROFILE` names another one. */
|
|
73
|
+
export const DEFAULT_PROFILE = "paseo";
|
|
74
|
+
/** The official DSH profile template the default profile is created from. */
|
|
75
|
+
export const DEFAULT_PROFILE_TEMPLATE = "web";
|
|
76
|
+
/**
|
|
77
|
+
* Rows of the web template that serve a browser-facing surface. The bridge is
|
|
78
|
+
* this profile's surface, so the launcher disables them in every bridge patch.
|
|
79
|
+
* `api-remotes` forwards `approval/request` and `user-questions/request` to
|
|
80
|
+
* browser clients ahead of the bridge; with no browser attached those requests
|
|
81
|
+
* would wait forever, so it is disabled with the rest.
|
|
82
|
+
*/
|
|
83
|
+
export const DISABLED_SURFACE_ROWS = ["web-startup", "webserver", "web-runtime", "connection", "api-remotes"] as const;
|
|
84
|
+
|
|
85
|
+
/** The DSH home, resolved the same way as `@deepseek-ai/dsh-home-paths`. */
|
|
86
|
+
export function resolveDshHome(env: Readonly<Record<string, string | undefined>> = process.env): string {
|
|
87
|
+
const configured = env.DSH_HOME;
|
|
88
|
+
const home = configured !== undefined && configured.trim().length > 0 ? configured : path.join(homedir(), ".dsh");
|
|
89
|
+
if (home === "~") return homedir();
|
|
90
|
+
if (home.startsWith("~/") || home.startsWith("~\\")) return path.resolve(homedir(), home.slice(2));
|
|
91
|
+
return path.resolve(home);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The bridge patch: surface rows disabled, session overrides, then the bridge itself. */
|
|
95
|
+
export function createBridgePatch(options: {
|
|
96
|
+
profile: string;
|
|
97
|
+
bridgePath: string;
|
|
98
|
+
sessionsRoot?: string;
|
|
99
|
+
mcpServers?: readonly BridgeMcpServer[];
|
|
100
|
+
}): unknown[] {
|
|
101
|
+
return [
|
|
102
|
+
...DISABLED_SURFACE_ROWS.map((id) => ({ id, disabled: true })),
|
|
103
|
+
...(options.sessionsRoot
|
|
104
|
+
? [{ id: "session-persistence-jsonl", config: { root: options.sessionsRoot } }]
|
|
105
|
+
: []),
|
|
106
|
+
{
|
|
107
|
+
insert: [
|
|
108
|
+
...(options.mcpServers ?? []).map((server) => ({
|
|
109
|
+
id: `paseo-mcp-${server.serverName}`,
|
|
110
|
+
name: "@deepseek-ai/dsh-mcp-client",
|
|
111
|
+
config: server,
|
|
112
|
+
})),
|
|
113
|
+
{ id: "paseo-dsh-bridge", name: options.bridgePath, config: { profile: options.profile } },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Make sure the profile directory exists before DSH is started with it. The
|
|
121
|
+
* default profile is created once from the official web template; a missing
|
|
122
|
+
* profile chosen through `PASEO_DSH_PROFILE` is reported instead.
|
|
123
|
+
*/
|
|
124
|
+
export async function ensureDshProfile(options: {
|
|
125
|
+
executable: string;
|
|
126
|
+
profile: string;
|
|
127
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
128
|
+
timeoutMs?: number;
|
|
129
|
+
}): Promise<void> {
|
|
130
|
+
const directory = path.join(resolveDshHome(options.env), "profiles", options.profile);
|
|
131
|
+
if (await isDirectory(directory)) return;
|
|
132
|
+
if (options.profile !== DEFAULT_PROFILE) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`DSH profile "${options.profile}" does not exist at ${directory}. ` +
|
|
135
|
+
`Create it with \`dsh --profile ${options.profile} --from-default-profile ${DEFAULT_PROFILE_TEMPLATE} --dump-config\`, ` +
|
|
136
|
+
`or unset PASEO_DSH_PROFILE to use the "${DEFAULT_PROFILE}" profile.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// Concurrent first launches share one creation run.
|
|
140
|
+
let creation = profileCreations.get(directory);
|
|
141
|
+
if (!creation) {
|
|
142
|
+
creation = runProfileInit(options.executable, options.profile, options.env, options.timeoutMs ?? 60_000).finally(
|
|
143
|
+
() => profileCreations.delete(directory),
|
|
144
|
+
);
|
|
145
|
+
profileCreations.set(directory, creation);
|
|
146
|
+
}
|
|
147
|
+
await creation;
|
|
148
|
+
if (!(await isDirectory(directory))) {
|
|
149
|
+
throw new Error(`DSH did not create the "${options.profile}" profile at ${directory}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const profileCreations = new Map<string, Promise<void>>();
|
|
154
|
+
|
|
155
|
+
async function isDirectory(directory: string): Promise<boolean> {
|
|
156
|
+
try {
|
|
157
|
+
return (await stat(directory)).isDirectory();
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function runProfileInit(
|
|
164
|
+
executable: string,
|
|
165
|
+
profile: string,
|
|
166
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
167
|
+
timeoutMs: number,
|
|
168
|
+
): Promise<void> {
|
|
169
|
+
return new Promise((resolve, reject) => {
|
|
170
|
+
const child = spawn(
|
|
171
|
+
executable,
|
|
172
|
+
["--profile", profile, "--from-default-profile", DEFAULT_PROFILE_TEMPLATE, "--dump-config"],
|
|
173
|
+
{ env, stdio: ["ignore", "ignore", "pipe"], windowsHide: true },
|
|
174
|
+
);
|
|
175
|
+
let stderr = "";
|
|
176
|
+
child.stderr.setEncoding("utf8");
|
|
177
|
+
child.stderr.on("data", (chunk: string) => {
|
|
178
|
+
stderr = (stderr + chunk).slice(-MAX_STDERR);
|
|
179
|
+
});
|
|
180
|
+
const timeout = setTimeout(() => {
|
|
181
|
+
child.kill("SIGKILL");
|
|
182
|
+
reject(new Error(`Creating the DSH "${profile}" profile timed out after ${timeoutMs}ms`));
|
|
183
|
+
}, timeoutMs);
|
|
184
|
+
child.once("error", (error) => {
|
|
185
|
+
clearTimeout(timeout);
|
|
186
|
+
reject(error);
|
|
187
|
+
});
|
|
188
|
+
child.once("exit", (code, signal) => {
|
|
189
|
+
clearTimeout(timeout);
|
|
190
|
+
if (code === 0) resolve();
|
|
191
|
+
else
|
|
192
|
+
reject(
|
|
193
|
+
new Error(
|
|
194
|
+
`Creating the DSH "${profile}" profile failed ${signal ? `from ${signal}` : `with code ${String(code)}`}` +
|
|
195
|
+
(stderr.trim() ? `: ${stderr.trim()}` : ""),
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function launchDshBridge(options: LaunchBridgeOptions): Promise<DshBridge> {
|
|
203
|
+
const profile = options.profile?.trim() || DEFAULT_PROFILE;
|
|
204
|
+
const executable = options.executable?.trim() || "dsh";
|
|
205
|
+
const env = { ...process.env, ...options.env };
|
|
206
|
+
await ensureDshProfile({ executable, profile, env });
|
|
207
|
+
const directory = await mkdtemp(path.join(tmpdir(), "paseo-dsh-direct-"));
|
|
208
|
+
const bridgePath = path.join(directory, "dsh-bridge.mjs");
|
|
209
|
+
const patchPath = path.join(directory, "patch.json");
|
|
210
|
+
await writeFile(bridgePath, options.bridgeSource, { mode: 0o600 });
|
|
211
|
+
await writeFile(
|
|
212
|
+
patchPath,
|
|
213
|
+
JSON.stringify(
|
|
214
|
+
createBridgePatch({
|
|
215
|
+
profile,
|
|
216
|
+
bridgePath,
|
|
217
|
+
sessionsRoot: options.ephemeral ? path.join(directory, "sessions") : undefined,
|
|
218
|
+
mcpServers: options.mcpServers,
|
|
219
|
+
}),
|
|
220
|
+
),
|
|
221
|
+
{ mode: 0o600 },
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const child = spawn(executable, ["--profile", profile, "--patch", patchPath], {
|
|
225
|
+
env,
|
|
226
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
227
|
+
windowsHide: true,
|
|
228
|
+
});
|
|
229
|
+
const client = new JsonlBridgeClient(child, directory, executable);
|
|
230
|
+
try {
|
|
231
|
+
await client.waitUntilReady(options.startupTimeoutMs ?? 15_000);
|
|
232
|
+
client.initialized = await client.request("bridge.initialize", {});
|
|
233
|
+
if (client.initialized.protocolVersion !== 1) {
|
|
234
|
+
throw new Error(`Unsupported DSH bridge protocol ${client.initialized.protocolVersion}`);
|
|
235
|
+
}
|
|
236
|
+
return client;
|
|
237
|
+
} catch (error) {
|
|
238
|
+
await client.close();
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
class JsonlBridgeClient implements DshBridge {
|
|
244
|
+
initialized!: BridgeInitializeResult;
|
|
245
|
+
readonly executable: string;
|
|
246
|
+
private nextId = 1;
|
|
247
|
+
private readonly pending = new Map<string | number, PendingRequest>();
|
|
248
|
+
private readonly notifications = new Map<string, Set<(params: never) => void>>();
|
|
249
|
+
private readonly failures = new Set<(error: Error) => void>();
|
|
250
|
+
private ready = false;
|
|
251
|
+
private terminalError: Error | null = null;
|
|
252
|
+
private stderr = "";
|
|
253
|
+
private closing = false;
|
|
254
|
+
private closePromise: Promise<void> | null = null;
|
|
255
|
+
private readonly exited: Promise<void>;
|
|
256
|
+
private resolveExited!: () => void;
|
|
257
|
+
|
|
258
|
+
constructor(
|
|
259
|
+
private readonly child: ChildProcessWithoutNullStreams,
|
|
260
|
+
private readonly temporaryDirectory: string,
|
|
261
|
+
executable: string,
|
|
262
|
+
) {
|
|
263
|
+
this.executable = executable;
|
|
264
|
+
this.exited = new Promise((resolve) => {
|
|
265
|
+
this.resolveExited = resolve;
|
|
266
|
+
});
|
|
267
|
+
createInterface({ input: child.stdout }).on("line", (line) => this.acceptLine(line));
|
|
268
|
+
child.stderr.setEncoding("utf8");
|
|
269
|
+
child.stderr.on("data", (chunk: string) => {
|
|
270
|
+
this.stderr = (this.stderr + chunk).slice(-MAX_STDERR);
|
|
271
|
+
});
|
|
272
|
+
child.once("error", (error) => {
|
|
273
|
+
this.fail(error);
|
|
274
|
+
this.resolveExited();
|
|
275
|
+
});
|
|
276
|
+
child.once("exit", (code, signal) => {
|
|
277
|
+
const suffix = this.stderr.trim() ? `: ${this.stderr.trim()}` : "";
|
|
278
|
+
this.fail(
|
|
279
|
+
new Error(
|
|
280
|
+
`DSH bridge exited ${signal ? `from ${signal}` : `with code ${String(code)}`}${suffix}`,
|
|
281
|
+
),
|
|
282
|
+
);
|
|
283
|
+
this.resolveExited();
|
|
284
|
+
});
|
|
285
|
+
child.once("close", () => this.resolveExited());
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async waitUntilReady(timeoutMs: number): Promise<void> {
|
|
289
|
+
if (this.ready) return;
|
|
290
|
+
if (this.terminalError) throw this.terminalError;
|
|
291
|
+
await new Promise<void>((resolve, reject) => {
|
|
292
|
+
const timeout = setTimeout(() => {
|
|
293
|
+
releaseReady();
|
|
294
|
+
releaseFailure();
|
|
295
|
+
reject(new Error(`DSH bridge did not become ready within ${timeoutMs}ms`));
|
|
296
|
+
}, timeoutMs);
|
|
297
|
+
const releaseReady = this.on("bridge.ready", () => {
|
|
298
|
+
clearTimeout(timeout);
|
|
299
|
+
releaseReady();
|
|
300
|
+
releaseFailure();
|
|
301
|
+
resolve();
|
|
302
|
+
});
|
|
303
|
+
const releaseFailure = this.onFailure((error) => {
|
|
304
|
+
clearTimeout(timeout);
|
|
305
|
+
releaseReady();
|
|
306
|
+
releaseFailure();
|
|
307
|
+
reject(error);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
request<Name extends Method>(
|
|
313
|
+
method: Name,
|
|
314
|
+
params: BridgeMethods[Name]["params"],
|
|
315
|
+
): Promise<BridgeMethods[Name]["result"]> {
|
|
316
|
+
if (this.terminalError) return Promise.reject(this.terminalError);
|
|
317
|
+
if (this.closing && method !== "bridge.shutdown") {
|
|
318
|
+
return Promise.reject(new Error("DSH bridge is closing"));
|
|
319
|
+
}
|
|
320
|
+
const id = this.nextId++;
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
const timeout = setTimeout(() => {
|
|
323
|
+
this.pending.delete(id);
|
|
324
|
+
reject(new Error(`DSH bridge request timed out: ${method}`));
|
|
325
|
+
}, 30_000);
|
|
326
|
+
this.pending.set(id, { resolve, reject, timeout });
|
|
327
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
|
328
|
+
this.child.stdin.write(payload, (error) => {
|
|
329
|
+
if (!error) return;
|
|
330
|
+
const pending = this.pending.get(id);
|
|
331
|
+
if (pending) clearTimeout(pending.timeout);
|
|
332
|
+
this.pending.delete(id);
|
|
333
|
+
reject(error);
|
|
334
|
+
});
|
|
335
|
+
}) as Promise<BridgeMethods[Name]["result"]>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
on<Name extends Notification>(
|
|
339
|
+
method: Name,
|
|
340
|
+
listener: (params: BridgeNotifications[Name]) => void,
|
|
341
|
+
): () => void {
|
|
342
|
+
const listeners = this.notifications.get(method) ?? new Set();
|
|
343
|
+
listeners.add(listener as (params: never) => void);
|
|
344
|
+
this.notifications.set(method, listeners);
|
|
345
|
+
return () => listeners.delete(listener as (params: never) => void);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
onFailure(listener: (error: Error) => void): () => void {
|
|
349
|
+
this.failures.add(listener);
|
|
350
|
+
if (this.terminalError) queueMicrotask(() => listener(this.terminalError!));
|
|
351
|
+
return () => this.failures.delete(listener);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
close(): Promise<void> {
|
|
355
|
+
if (this.closePromise) return this.closePromise;
|
|
356
|
+
this.closing = true;
|
|
357
|
+
this.closePromise = (async () => {
|
|
358
|
+
if (!this.terminalError) {
|
|
359
|
+
await Promise.race([
|
|
360
|
+
this.request("bridge.shutdown", {}).catch(() => undefined),
|
|
361
|
+
delay(500),
|
|
362
|
+
]);
|
|
363
|
+
}
|
|
364
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
365
|
+
this.child.kill("SIGTERM");
|
|
366
|
+
await Promise.race([this.exited, delay(1_000)]);
|
|
367
|
+
}
|
|
368
|
+
if (this.child.exitCode === null && this.child.signalCode === null) {
|
|
369
|
+
this.child.kill("SIGKILL");
|
|
370
|
+
await Promise.race([this.exited, delay(1_000)]);
|
|
371
|
+
}
|
|
372
|
+
await rm(this.temporaryDirectory, { recursive: true, force: true });
|
|
373
|
+
})();
|
|
374
|
+
return this.closePromise;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private acceptLine(line: string): void {
|
|
378
|
+
if (!line.trim()) return;
|
|
379
|
+
let message: RpcResponse | RpcNotification;
|
|
380
|
+
try {
|
|
381
|
+
message = JSON.parse(line) as RpcResponse | RpcNotification;
|
|
382
|
+
} catch {
|
|
383
|
+
this.fail(new Error(`DSH bridge wrote invalid JSON: ${line.slice(0, 200)}`));
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if ("id" in message) {
|
|
387
|
+
if (message.id === null) return;
|
|
388
|
+
const pending = this.pending.get(message.id);
|
|
389
|
+
if (!pending) return;
|
|
390
|
+
this.pending.delete(message.id);
|
|
391
|
+
clearTimeout(pending.timeout);
|
|
392
|
+
if ("error" in message) pending.reject(new Error(message.error.message));
|
|
393
|
+
else pending.resolve(message.result);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
if (message.method === "bridge.ready") this.ready = true;
|
|
397
|
+
for (const listener of this.notifications.get(message.method) ?? []) {
|
|
398
|
+
listener(message.params as never);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private fail(error: Error): void {
|
|
403
|
+
if (this.terminalError) return;
|
|
404
|
+
this.terminalError = error;
|
|
405
|
+
for (const pending of this.pending.values()) {
|
|
406
|
+
clearTimeout(pending.timeout);
|
|
407
|
+
pending.reject(error);
|
|
408
|
+
}
|
|
409
|
+
this.pending.clear();
|
|
410
|
+
if (!this.closing) for (const listener of this.failures) listener(error);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function delay(milliseconds: number): Promise<void> {
|
|
415
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
416
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import type {
|
|
6
|
+
BridgeMethods,
|
|
7
|
+
RpcRequest,
|
|
8
|
+
} from "../../shared/bridge-protocol.js";
|
|
9
|
+
import type { HostContext, HostHelpers } from "./host.js";
|
|
10
|
+
import { BridgeService } from "./service.js";
|
|
11
|
+
|
|
12
|
+
export const name = "paseo-dsh-bridge";
|
|
13
|
+
export const inject = [
|
|
14
|
+
"agents",
|
|
15
|
+
"sessionQuery",
|
|
16
|
+
"agentPresets",
|
|
17
|
+
"llm",
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
/** Keep stack traces off the wire and redact common credential representations. */
|
|
21
|
+
export function safeError(error: unknown): string {
|
|
22
|
+
const raw = error instanceof Error ? error.message : "DSH operation failed";
|
|
23
|
+
return raw
|
|
24
|
+
.replace(/\b(?:sk-|Bearer\s+)[A-Za-z0-9_.\-]+/gi, "[redacted]")
|
|
25
|
+
.replace(
|
|
26
|
+
/((?:api[_-]?key|authorization|token|secret|password)\s*[:=]\s*)[^\s,;]+/gi,
|
|
27
|
+
"$1[redacted]",
|
|
28
|
+
)
|
|
29
|
+
.slice(0, 1000);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const METHODS = new Set<keyof BridgeMethods>([
|
|
33
|
+
"bridge.initialize",
|
|
34
|
+
"bridge.shutdown",
|
|
35
|
+
"session.list",
|
|
36
|
+
"session.read",
|
|
37
|
+
"session.open",
|
|
38
|
+
"session.close",
|
|
39
|
+
"session.prompt",
|
|
40
|
+
"session.steer",
|
|
41
|
+
"session.configure",
|
|
42
|
+
"session.rename",
|
|
43
|
+
"session.commands",
|
|
44
|
+
"session.command",
|
|
45
|
+
"session.cancel",
|
|
46
|
+
"interaction.respond",
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
export function parseRequest(line: string): RpcRequest {
|
|
50
|
+
// Prompts carry base64 images.
|
|
51
|
+
if (line.length > 64 * 1024 * 1024) throw new Error("Request too large");
|
|
52
|
+
const value: unknown = JSON.parse(line);
|
|
53
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
54
|
+
throw new Error("Invalid JSON-RPC request");
|
|
55
|
+
}
|
|
56
|
+
const request = value as Record<string, unknown>;
|
|
57
|
+
if (
|
|
58
|
+
request.jsonrpc !== "2.0" ||
|
|
59
|
+
(typeof request.id !== "number" && typeof request.id !== "string") ||
|
|
60
|
+
(typeof request.id === "number" && !Number.isSafeInteger(request.id)) ||
|
|
61
|
+
typeof request.method !== "string" ||
|
|
62
|
+
!METHODS.has(request.method as keyof BridgeMethods) ||
|
|
63
|
+
(request.params !== undefined &&
|
|
64
|
+
(!request.params ||
|
|
65
|
+
typeof request.params !== "object" ||
|
|
66
|
+
Array.isArray(request.params)))
|
|
67
|
+
) {
|
|
68
|
+
throw new Error("Invalid or unsupported JSON-RPC request");
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
jsonrpc: "2.0",
|
|
72
|
+
id: request.id,
|
|
73
|
+
method: request.method as keyof BridgeMethods,
|
|
74
|
+
...(request.params === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { params: request.params as Record<string, unknown> }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function apply(
|
|
81
|
+
ctx: HostContext,
|
|
82
|
+
config: { profile?: string } = {},
|
|
83
|
+
): Promise<void> {
|
|
84
|
+
// Resolve only inside the DSH child, against its own installed closure.
|
|
85
|
+
// Paseo never imports or bundles a second copy of these runtime services.
|
|
86
|
+
const bin = process.argv[1];
|
|
87
|
+
let helpers: HostHelpers | undefined;
|
|
88
|
+
if (bin) {
|
|
89
|
+
const requireHost = createRequire(realpathSync(bin));
|
|
90
|
+
const module: unknown = await import(
|
|
91
|
+
pathToFileURL(requireHost.resolve("@deepseek-ai/dsh-agent")).href
|
|
92
|
+
);
|
|
93
|
+
if (
|
|
94
|
+
typeof module === "object" &&
|
|
95
|
+
module !== null &&
|
|
96
|
+
"installModelSelection" in module &&
|
|
97
|
+
typeof module.installModelSelection === "function"
|
|
98
|
+
)
|
|
99
|
+
helpers = module as HostHelpers;
|
|
100
|
+
}
|
|
101
|
+
const send = (value: unknown, flushed?: () => void) => {
|
|
102
|
+
process.stdout.write(`${JSON.stringify(value)}\n`, flushed);
|
|
103
|
+
};
|
|
104
|
+
const service = new BridgeService(
|
|
105
|
+
ctx,
|
|
106
|
+
(method, params) => send({ jsonrpc: "2.0", method, params }),
|
|
107
|
+
config.profile,
|
|
108
|
+
helpers,
|
|
109
|
+
);
|
|
110
|
+
let input: ReturnType<typeof createInterface> | undefined;
|
|
111
|
+
let disposed = false;
|
|
112
|
+
const activeIds = new Set<string | number>();
|
|
113
|
+
const exit = (code: number) => ctx.get("appExit")?.(code);
|
|
114
|
+
const onReady = ctx.get("appReady");
|
|
115
|
+
if (!onReady || !ctx.get("appExit"))
|
|
116
|
+
throw new Error("Paseo bridge requires the DSH profile launcher");
|
|
117
|
+
const removeReady = onReady.onReady(() => {
|
|
118
|
+
if (disposed) return;
|
|
119
|
+
input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
120
|
+
input.on("line", (line) => {
|
|
121
|
+
if (!line.trim()) return;
|
|
122
|
+
let request: RpcRequest;
|
|
123
|
+
try {
|
|
124
|
+
request = parseRequest(line);
|
|
125
|
+
if (activeIds.has(request.id))
|
|
126
|
+
throw new Error("Request id is already active");
|
|
127
|
+
} catch (error) {
|
|
128
|
+
send({
|
|
129
|
+
jsonrpc: "2.0",
|
|
130
|
+
id: null,
|
|
131
|
+
error: { code: -32600, message: safeError(error) },
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
activeIds.add(request.id);
|
|
136
|
+
void service
|
|
137
|
+
.handle(request.method, request.params)
|
|
138
|
+
.then(
|
|
139
|
+
(result) => {
|
|
140
|
+
send(
|
|
141
|
+
{ jsonrpc: "2.0", id: request.id, result },
|
|
142
|
+
request.method === "bridge.shutdown" ? () => exit(0) : undefined,
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
(error) =>
|
|
146
|
+
send({
|
|
147
|
+
jsonrpc: "2.0",
|
|
148
|
+
id: request.id,
|
|
149
|
+
error: { code: -32000, message: safeError(error) },
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
.finally(() => activeIds.delete(request.id));
|
|
153
|
+
});
|
|
154
|
+
input.on("close", () => {
|
|
155
|
+
if (!disposed)
|
|
156
|
+
void service.shutdown().then(
|
|
157
|
+
() => exit(0),
|
|
158
|
+
() => exit(1),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
send({
|
|
162
|
+
jsonrpc: "2.0",
|
|
163
|
+
method: "bridge.ready",
|
|
164
|
+
params: { protocolVersion: 1 },
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
ctx.effect(
|
|
168
|
+
() => async () => {
|
|
169
|
+
disposed = true;
|
|
170
|
+
removeReady();
|
|
171
|
+
input?.close();
|
|
172
|
+
await service.shutdown();
|
|
173
|
+
},
|
|
174
|
+
"paseo-dsh-bridge",
|
|
175
|
+
);
|
|
176
|
+
}
|