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,1101 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
negotiateProviderCapabilities,
|
|
4
|
+
requireProviderCapabilities,
|
|
5
|
+
type ProviderCatalog,
|
|
6
|
+
type ProviderConfigState,
|
|
7
|
+
type ProviderConnection,
|
|
8
|
+
type ProviderEvent,
|
|
9
|
+
type ProviderInput,
|
|
10
|
+
type ProviderPermissionRequest,
|
|
11
|
+
type ProviderPermissionResponse,
|
|
12
|
+
type ProviderRegistration,
|
|
13
|
+
type ProviderSessionConfig,
|
|
14
|
+
} from "@getpaseo/plugin/server/provider";
|
|
15
|
+
import type {
|
|
16
|
+
BridgeCatalog,
|
|
17
|
+
InteractionRequest,
|
|
18
|
+
ModelSelection,
|
|
19
|
+
QuestionAnswer,
|
|
20
|
+
QuestionItem,
|
|
21
|
+
SessionOpenResult,
|
|
22
|
+
PromptPart,
|
|
23
|
+
SlashCommand,
|
|
24
|
+
} from "../shared/bridge-protocol.js";
|
|
25
|
+
import { slashLine } from "../shared/bridge-protocol.js";
|
|
26
|
+
import type { BridgeMcpServer, DshBridge, SessionLaunch } from "./bridge-client.js";
|
|
27
|
+
import { detectDshVersion } from "./dsh-version.js";
|
|
28
|
+
import { PLUGIN_VERSION } from "./plugin-version.js";
|
|
29
|
+
import { displayText, toPromptParts } from "./prompt-content.js";
|
|
30
|
+
import { TimelineProjector } from "./timeline.js";
|
|
31
|
+
import { classifyTurnEnd, type TurnTerminal } from "./turn-end.js";
|
|
32
|
+
import { objectValue } from "./values.js";
|
|
33
|
+
import { matchVersionLine, versionWarningMessage } from "./version-line.js";
|
|
34
|
+
|
|
35
|
+
const CAPABILITIES = [
|
|
36
|
+
"prompt.message",
|
|
37
|
+
"prompt.command",
|
|
38
|
+
"prompt.image",
|
|
39
|
+
"prompt.steer",
|
|
40
|
+
"session.configure",
|
|
41
|
+
"session.list",
|
|
42
|
+
"session.persistence",
|
|
43
|
+
"permission",
|
|
44
|
+
"permission.tool_policy",
|
|
45
|
+
] as const;
|
|
46
|
+
const PERMISSION_PRESET_SETTING = "permissionPreset";
|
|
47
|
+
const PLAN_MODE_SETTING = "planMode";
|
|
48
|
+
|
|
49
|
+
interface DshProviderOptions {
|
|
50
|
+
createBridge(launch?: SessionLaunch): Promise<DshBridge>;
|
|
51
|
+
/** Detects the local dsh version for the startup compatibility check; defaults to spawning `<exe> --version`. */
|
|
52
|
+
detectDshVersion?: (executable: string) => Promise<string>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A prompt or command RPC in flight; it owns any turn DSH starts before the RPC returns. */
|
|
56
|
+
interface Dispatch {
|
|
57
|
+
kind: "prompt" | "command";
|
|
58
|
+
clientMessageId: string;
|
|
59
|
+
sawRunning: boolean;
|
|
60
|
+
/** A terminal DSH reported before the RPC returned. */
|
|
61
|
+
deferred: TurnTerminal | null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface SessionState {
|
|
65
|
+
paseoId: string;
|
|
66
|
+
nativeId: string;
|
|
67
|
+
config: ProviderSessionConfig;
|
|
68
|
+
open: SessionOpenResult;
|
|
69
|
+
bridge: DshBridge;
|
|
70
|
+
releaseBridgeListeners: Array<() => void>;
|
|
71
|
+
projector: TimelineProjector;
|
|
72
|
+
activeTurnId: string | null;
|
|
73
|
+
cancelRequested: boolean;
|
|
74
|
+
dispatch: Dispatch | null;
|
|
75
|
+
commands: SlashCommand[];
|
|
76
|
+
/** DSH plan mode, from the latest `plan/mode` event. */
|
|
77
|
+
planMode: boolean;
|
|
78
|
+
pendingInteractions: Map<string, InteractionRequest>;
|
|
79
|
+
lane: Promise<void>;
|
|
80
|
+
closed: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createDshProvider(options: DshProviderOptions): ProviderRegistration {
|
|
84
|
+
return {
|
|
85
|
+
id: "dsh-pi",
|
|
86
|
+
label: "DeepSeek Harness",
|
|
87
|
+
description: "Direct connection to the local DeepSeek Harness (dsh)",
|
|
88
|
+
icon: "dsh.svg",
|
|
89
|
+
getCatalogCacheKey: async (options) =>
|
|
90
|
+
options.scope === "workspace" ? `dsh-pi:${options.cwd}` : "dsh-pi:global",
|
|
91
|
+
async connect(request) {
|
|
92
|
+
if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required");
|
|
93
|
+
const bridge = await options.createBridge();
|
|
94
|
+
const supported = CAPABILITIES.filter(
|
|
95
|
+
(capability) =>
|
|
96
|
+
capability !== "permission" ||
|
|
97
|
+
bridge.initialized.capabilities.approvals ||
|
|
98
|
+
bridge.initialized.capabilities.questions,
|
|
99
|
+
);
|
|
100
|
+
return createConnection(
|
|
101
|
+
bridge,
|
|
102
|
+
options.createBridge,
|
|
103
|
+
negotiateProviderCapabilities(request.capabilities, supported),
|
|
104
|
+
options.detectDshVersion ?? detectDshVersion,
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createConnection(
|
|
111
|
+
discoveryBridge: DshBridge,
|
|
112
|
+
createBridge: DshProviderOptions["createBridge"],
|
|
113
|
+
capabilities: readonly string[],
|
|
114
|
+
detectVersion: (executable: string) => Promise<string>,
|
|
115
|
+
): ProviderConnection {
|
|
116
|
+
const listeners = new Set<(event: ProviderEvent) => void>();
|
|
117
|
+
const sessions = new Map<string, SessionState>();
|
|
118
|
+
let closed = false;
|
|
119
|
+
const emit = (event: ProviderEvent) => {
|
|
120
|
+
if (closed) return;
|
|
121
|
+
for (const listener of listeners) listener(event);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
version: 1,
|
|
126
|
+
capabilities,
|
|
127
|
+
async send(input) {
|
|
128
|
+
if (closed) throw new Error("Provider connection is closed");
|
|
129
|
+
validateAdmission(input, sessions, capabilities);
|
|
130
|
+
queueMicrotask(() => {
|
|
131
|
+
void dispatch(input, { discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion }).catch(
|
|
132
|
+
(error) => failInput(input, error, emit),
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
onEvent(listener) {
|
|
137
|
+
listeners.add(listener);
|
|
138
|
+
return () => listeners.delete(listener);
|
|
139
|
+
},
|
|
140
|
+
async close() {
|
|
141
|
+
if (closed) return;
|
|
142
|
+
closed = true;
|
|
143
|
+
const bridges = [discoveryBridge];
|
|
144
|
+
for (const session of sessions.values()) {
|
|
145
|
+
session.closed = true;
|
|
146
|
+
session.releaseBridgeListeners.forEach((release) => release());
|
|
147
|
+
bridges.push(session.bridge);
|
|
148
|
+
}
|
|
149
|
+
sessions.clear();
|
|
150
|
+
listeners.clear();
|
|
151
|
+
await Promise.all(bridges.map((bridge) => bridge.close()));
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function listenToSessionBridge(
|
|
157
|
+
session: SessionState,
|
|
158
|
+
emit: (event: ProviderEvent) => void,
|
|
159
|
+
): Array<() => void> {
|
|
160
|
+
const isCurrentSession = (nativeId: string) =>
|
|
161
|
+
!session.closed && nativeId === session.nativeId;
|
|
162
|
+
|
|
163
|
+
return [
|
|
164
|
+
session.bridge.on("session.event", ({ sessionId, event }) => {
|
|
165
|
+
if (!isCurrentSession(sessionId)) return;
|
|
166
|
+
// The prompt path emits its own bubble with the clientMessageId.
|
|
167
|
+
if (event.type === "user/message" && session.dispatch?.kind === "prompt") return;
|
|
168
|
+
if (event.type === "turn/start") {
|
|
169
|
+
// A turn nobody dispatched from Paseo: a /goal continuation or a queued
|
|
170
|
+
// follow-up that DSH drains without going idle.
|
|
171
|
+
if (!session.activeTurnId && !session.dispatch) {
|
|
172
|
+
startTurn(session, `dsh-auto-${randomUUID()}`, emit);
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
if (event.type === "turn/end") {
|
|
177
|
+
const terminal = classifyTurnEnd(event.data);
|
|
178
|
+
// Failures reach Paseo through session.turn; other abnormal ends become notices.
|
|
179
|
+
if (terminal.state !== "failed") {
|
|
180
|
+
for (const projected of session.projector.projectEvent(event)) emit(projected);
|
|
181
|
+
}
|
|
182
|
+
// DSH can finish before the prompt or command RPC returns.
|
|
183
|
+
if (!session.activeTurnId && session.dispatch) session.dispatch.deferred = terminal;
|
|
184
|
+
else finishTurn(session, terminal, emit);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (event.type === "plan/mode" && typeof event.data.active === "boolean") {
|
|
188
|
+
if (session.planMode !== event.data.active) {
|
|
189
|
+
session.planMode = event.data.active;
|
|
190
|
+
emitConfig(session, emit);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (event.type === "permission/preset" && typeof event.data.preset === "string") {
|
|
194
|
+
// Covers /permission and any other DSH-side preset change.
|
|
195
|
+
if (session.open.permissionPreset !== event.data.preset) {
|
|
196
|
+
session.open = { ...session.open, permissionPreset: event.data.preset };
|
|
197
|
+
emitConfig(session, emit);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const clientMessageId =
|
|
201
|
+
event.type === "command/run" && session.dispatch?.kind === "command"
|
|
202
|
+
? session.dispatch.clientMessageId
|
|
203
|
+
: undefined;
|
|
204
|
+
for (const projected of session.projector.projectEvent(event, { clientMessageId })) {
|
|
205
|
+
emit(projected);
|
|
206
|
+
}
|
|
207
|
+
}),
|
|
208
|
+
session.bridge.on("session.stream", ({ sessionId, frame }) => {
|
|
209
|
+
if (!isCurrentSession(sessionId)) return;
|
|
210
|
+
for (const projected of session.projector.projectStream(frame)) emit(projected);
|
|
211
|
+
}),
|
|
212
|
+
session.bridge.on("session.status", ({ sessionId, status }) => {
|
|
213
|
+
if (!isCurrentSession(sessionId)) return;
|
|
214
|
+
if (status === "running") {
|
|
215
|
+
if (session.dispatch) session.dispatch.sawRunning = true;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
const terminal: TurnTerminal = { state: session.cancelRequested ? "canceled" : "completed" };
|
|
219
|
+
if (session.activeTurnId) finishTurn(session, terminal, emit);
|
|
220
|
+
else if (session.dispatch?.sawRunning && !session.dispatch.deferred) {
|
|
221
|
+
session.dispatch.deferred = terminal;
|
|
222
|
+
}
|
|
223
|
+
}),
|
|
224
|
+
session.bridge.on("interaction.request", (interaction) => {
|
|
225
|
+
if (!isCurrentSession(interaction.sessionId)) return;
|
|
226
|
+
session.pendingInteractions.set(interaction.requestId, interaction);
|
|
227
|
+
emit({
|
|
228
|
+
type: "session.permission",
|
|
229
|
+
sessionId: session.paseoId,
|
|
230
|
+
request: permissionRequest(interaction),
|
|
231
|
+
});
|
|
232
|
+
}),
|
|
233
|
+
session.bridge.on("interaction.closed", ({ requestId, sessionId }) => {
|
|
234
|
+
if (!isCurrentSession(sessionId) || !session.pendingInteractions.delete(requestId)) return;
|
|
235
|
+
emit({
|
|
236
|
+
type: "session.permission_resolved",
|
|
237
|
+
sessionId: session.paseoId,
|
|
238
|
+
permissionId: requestId,
|
|
239
|
+
});
|
|
240
|
+
}),
|
|
241
|
+
session.bridge.onFailure((error) => failSession(session, error, emit)),
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function failSession(
|
|
246
|
+
session: SessionState,
|
|
247
|
+
error: Error,
|
|
248
|
+
emit: (event: ProviderEvent) => void,
|
|
249
|
+
): void {
|
|
250
|
+
if (session.closed) return;
|
|
251
|
+
session.closed = true;
|
|
252
|
+
session.releaseBridgeListeners.forEach((release) => release());
|
|
253
|
+
for (const permissionId of session.pendingInteractions.keys()) {
|
|
254
|
+
emit({ type: "session.permission_resolved", sessionId: session.paseoId, permissionId });
|
|
255
|
+
}
|
|
256
|
+
session.pendingInteractions.clear();
|
|
257
|
+
for (const projected of session.projector.failRunningTools(error.message)) emit(projected);
|
|
258
|
+
if (session.activeTurnId) {
|
|
259
|
+
emit({
|
|
260
|
+
type: "session.turn",
|
|
261
|
+
sessionId: session.paseoId,
|
|
262
|
+
turnId: session.activeTurnId,
|
|
263
|
+
state: "failed",
|
|
264
|
+
error: { message: error.message },
|
|
265
|
+
});
|
|
266
|
+
session.activeTurnId = null;
|
|
267
|
+
}
|
|
268
|
+
emit({
|
|
269
|
+
type: "session.runtime_failed",
|
|
270
|
+
sessionId: session.paseoId,
|
|
271
|
+
error: { message: error.message },
|
|
272
|
+
});
|
|
273
|
+
void session.bridge.close().catch(() => undefined);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
interface DispatchState {
|
|
277
|
+
discoveryBridge: DshBridge;
|
|
278
|
+
createBridge: DshProviderOptions["createBridge"];
|
|
279
|
+
sessions: Map<string, SessionState>;
|
|
280
|
+
emit(event: ProviderEvent): void;
|
|
281
|
+
capabilities: readonly string[];
|
|
282
|
+
detectVersion: (executable: string) => Promise<string>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function dispatch(input: ProviderInput, state: DispatchState): Promise<void> {
|
|
286
|
+
switch (input.type) {
|
|
287
|
+
case "catalog":
|
|
288
|
+
state.emit({
|
|
289
|
+
type: "catalog",
|
|
290
|
+
requestId: input.requestId,
|
|
291
|
+
catalog: catalog(state.discoveryBridge.initialized.catalog),
|
|
292
|
+
});
|
|
293
|
+
return;
|
|
294
|
+
case "sessions":
|
|
295
|
+
await listSessions(input, state);
|
|
296
|
+
return;
|
|
297
|
+
case "session.open":
|
|
298
|
+
await openSession(input, state);
|
|
299
|
+
return;
|
|
300
|
+
case "session.prompt":
|
|
301
|
+
await enqueue(requireSession(state.sessions, input.sessionId), () => promptSession(input, state));
|
|
302
|
+
return;
|
|
303
|
+
case "session.interrupt":
|
|
304
|
+
await interruptSession(input, state);
|
|
305
|
+
return;
|
|
306
|
+
case "session.permission":
|
|
307
|
+
await respondToPermission(input, state);
|
|
308
|
+
return;
|
|
309
|
+
case "session.configure":
|
|
310
|
+
await enqueue(requireSession(state.sessions, input.sessionId), () => configureSession(input, state));
|
|
311
|
+
return;
|
|
312
|
+
case "session.close":
|
|
313
|
+
await closeSession(input, state);
|
|
314
|
+
return;
|
|
315
|
+
case "session.archive":
|
|
316
|
+
case "session.unarchive":
|
|
317
|
+
case "session.revert":
|
|
318
|
+
state.emit({
|
|
319
|
+
type: "request.failed",
|
|
320
|
+
requestId: input.requestId,
|
|
321
|
+
error: { message: `${input.type} is not supported` },
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function listSessions(
|
|
327
|
+
input: Extract<ProviderInput, { type: "sessions" }>,
|
|
328
|
+
state: DispatchState,
|
|
329
|
+
): Promise<void> {
|
|
330
|
+
const result = await state.discoveryBridge.request("session.list", {});
|
|
331
|
+
const query = input.query?.trim().toLowerCase();
|
|
332
|
+
const rows = result.sessions
|
|
333
|
+
.filter((session): session is typeof session & { cwd: string } => Boolean(session.cwd))
|
|
334
|
+
.filter((session) => !input.cwd || session.cwd === input.cwd)
|
|
335
|
+
.filter(
|
|
336
|
+
(session) =>
|
|
337
|
+
!query || session.title?.toLowerCase().includes(query) || session.id.toLowerCase().includes(query),
|
|
338
|
+
)
|
|
339
|
+
.sort((left, right) => right.createdAt - left.createdAt);
|
|
340
|
+
const sessions = (input.limit === undefined ? rows : rows.slice(0, input.limit))
|
|
341
|
+
.map((session) => ({
|
|
342
|
+
persistence: persistence(session.id),
|
|
343
|
+
cwd: session.cwd ?? "",
|
|
344
|
+
title: session.title,
|
|
345
|
+
description: session.agentPreset ? `preset: ${session.agentPreset}` : undefined,
|
|
346
|
+
updatedAt: timestamp(session.createdAt),
|
|
347
|
+
}));
|
|
348
|
+
state.emit({ type: "sessions", requestId: input.requestId, sessions });
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function openSession(
|
|
352
|
+
input: Extract<ProviderInput, { type: "session.open" }>,
|
|
353
|
+
state: DispatchState,
|
|
354
|
+
): Promise<void> {
|
|
355
|
+
validateSessionConfig(input.config);
|
|
356
|
+
const nativeId = persistenceId(input.persistence);
|
|
357
|
+
const bridge = await state.createBridge({
|
|
358
|
+
env: input.config.env,
|
|
359
|
+
ephemeral: !input.config.persist,
|
|
360
|
+
mcpServers: bridgeMcpServers(input.config.mcpServers),
|
|
361
|
+
});
|
|
362
|
+
let session: SessionState | undefined;
|
|
363
|
+
try {
|
|
364
|
+
// Resume from DSH's durable model and preset. Paseo fills catalog defaults into
|
|
365
|
+
// open config, so forwarding them would accidentally overwrite native history.
|
|
366
|
+
const selection = nativeId
|
|
367
|
+
? undefined
|
|
368
|
+
: selectModel(bridge.initialized.catalog, input.config.model, input.config.thinkingOption);
|
|
369
|
+
const permissionPreset = nativeId
|
|
370
|
+
? undefined
|
|
371
|
+
: selectedPermissionPreset(input.config.settings, bridge.initialized.catalog);
|
|
372
|
+
const opened = await bridge.request("session.open", {
|
|
373
|
+
...(nativeId ? { sessionId: nativeId, resume: true } : {}),
|
|
374
|
+
cwd: input.config.cwd,
|
|
375
|
+
...(selection ? { model: selection } : {}),
|
|
376
|
+
...(!nativeId && input.config.mode ? { preset: input.config.mode } : {}),
|
|
377
|
+
...(permissionPreset ? { permissionPreset } : {}),
|
|
378
|
+
...(input.config.systemPrompt?.trim() ? { systemPrompt: input.config.systemPrompt } : {}),
|
|
379
|
+
...(input.config.toolPolicy
|
|
380
|
+
? { preapprovedTools: preapprovedTools(input.config) }
|
|
381
|
+
: {}),
|
|
382
|
+
});
|
|
383
|
+
const title = nativeId ? undefined : input.config.title?.trim();
|
|
384
|
+
const [commands] = await Promise.all([
|
|
385
|
+
fetchCommands(bridge, opened.sessionId),
|
|
386
|
+
title ? bridge.request("session.rename", { sessionId: opened.sessionId, title }) : undefined,
|
|
387
|
+
]);
|
|
388
|
+
session = {
|
|
389
|
+
paseoId: input.sessionId,
|
|
390
|
+
nativeId: opened.sessionId,
|
|
391
|
+
config: input.config,
|
|
392
|
+
open: opened,
|
|
393
|
+
bridge,
|
|
394
|
+
releaseBridgeListeners: [],
|
|
395
|
+
projector: new TimelineProjector(input.sessionId),
|
|
396
|
+
activeTurnId: null,
|
|
397
|
+
cancelRequested: false,
|
|
398
|
+
dispatch: null,
|
|
399
|
+
commands,
|
|
400
|
+
planMode: latestPlanMode(opened.events),
|
|
401
|
+
pendingInteractions: new Map(),
|
|
402
|
+
lane: Promise.resolve(),
|
|
403
|
+
closed: false,
|
|
404
|
+
};
|
|
405
|
+
state.sessions.set(input.sessionId, session);
|
|
406
|
+
session.releaseBridgeListeners = listenToSessionBridge(session, state.emit);
|
|
407
|
+
if (!nativeId && input.config.settings[PLAN_MODE_SETTING] === true) {
|
|
408
|
+
await setPlanMode(session, true);
|
|
409
|
+
}
|
|
410
|
+
} catch (error) {
|
|
411
|
+
if (session) state.sessions.delete(input.sessionId);
|
|
412
|
+
await bridge.close();
|
|
413
|
+
throw error;
|
|
414
|
+
}
|
|
415
|
+
state.emit({
|
|
416
|
+
type: "session.opened",
|
|
417
|
+
requestId: input.requestId,
|
|
418
|
+
sessionId: input.sessionId,
|
|
419
|
+
capabilities: state.capabilities,
|
|
420
|
+
restoration: "core",
|
|
421
|
+
// A non-persistent session lives in the bridge's temporary store.
|
|
422
|
+
...(input.config.persist ? { persistence: persistence(session.nativeId) } : {}),
|
|
423
|
+
cwd: session.open.cwd,
|
|
424
|
+
});
|
|
425
|
+
emitConfig(session, state.emit);
|
|
426
|
+
emitCommands(session, state.emit);
|
|
427
|
+
if (input.history === "replay") {
|
|
428
|
+
for (const event of session.open.events) {
|
|
429
|
+
for (const projected of session.projector.projectEvent(event)) state.emit(projected);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
await warnOnDshVersionMismatch(session, state);
|
|
433
|
+
state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Compares the detected local dsh version against the plugin's own line
|
|
438
|
+
* (`PLUGIN_VERSION`'s major.minor) and, on a mismatch or an unconfirmed
|
|
439
|
+
* result, posts a timeline warning and logs the same message to stderr.
|
|
440
|
+
* Never blocks the session: DSH's own handshake is what can fail startup.
|
|
441
|
+
*/
|
|
442
|
+
async function warnOnDshVersionMismatch(session: SessionState, state: DispatchState): Promise<void> {
|
|
443
|
+
const dshVersion = await state.detectVersion(session.bridge.executable).catch(() => "");
|
|
444
|
+
const status = matchVersionLine(PLUGIN_VERSION, dshVersion);
|
|
445
|
+
if (status === "match") return;
|
|
446
|
+
const message = versionWarningMessage(status, PLUGIN_VERSION, dshVersion);
|
|
447
|
+
console.error(`[paseo-dsh-direct] ${message}`);
|
|
448
|
+
state.emit({
|
|
449
|
+
type: "timeline.item",
|
|
450
|
+
sessionId: session.paseoId,
|
|
451
|
+
item: { type: "notification", id: `dsh-version:${session.paseoId}`, level: "warning", message },
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function promptSession(
|
|
456
|
+
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
|
457
|
+
state: DispatchState,
|
|
458
|
+
): Promise<void> {
|
|
459
|
+
const session = requireSession(state.sessions, input.sessionId);
|
|
460
|
+
if (input.prompt.delivery === "steer") {
|
|
461
|
+
if (!session.activeTurnId) {
|
|
462
|
+
emitPromptFailure(input, "There is no active DSH turn to steer", state.emit);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (input.prompt.input.type !== "message") {
|
|
466
|
+
emitPromptFailure(input, "DSH cannot steer with a command", state.emit);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
await session.bridge.request("session.steer", {
|
|
470
|
+
sessionId: session.nativeId,
|
|
471
|
+
content: toPromptParts(input.prompt.input.content),
|
|
472
|
+
});
|
|
473
|
+
emitPromptResult(input, { type: "steer", turnId: session.activeTurnId }, state.emit);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (session.activeTurnId || session.dispatch) {
|
|
477
|
+
emitPromptFailure(input, "A DSH turn is already running", state.emit);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
const prompt = input.prompt.input;
|
|
481
|
+
let content: PromptPart[];
|
|
482
|
+
if (prompt.type === "command") {
|
|
483
|
+
const kind = session.commands.find((candidate) => candidate.name === prompt.name)?.kind;
|
|
484
|
+
if (kind !== "skill") return runCommand(session, input, prompt, state.emit);
|
|
485
|
+
// DSH loads a skill body when a user message starts with `/<skill>`.
|
|
486
|
+
content = [{ type: "text", text: slashLine(prompt.name, prompt.arguments) }];
|
|
487
|
+
} else {
|
|
488
|
+
content = toPromptParts(prompt.content);
|
|
489
|
+
}
|
|
490
|
+
const dispatch = beginDispatch(session, "prompt", input.prompt.clientMessageId);
|
|
491
|
+
try {
|
|
492
|
+
const result = await session.bridge.request("session.prompt", { sessionId: session.nativeId, content });
|
|
493
|
+
state.emit({
|
|
494
|
+
type: "timeline.item",
|
|
495
|
+
sessionId: session.paseoId,
|
|
496
|
+
item: {
|
|
497
|
+
type: "user_message",
|
|
498
|
+
id: result.messageId,
|
|
499
|
+
messageId: result.messageId,
|
|
500
|
+
clientMessageId: input.prompt.clientMessageId,
|
|
501
|
+
text: displayText(content),
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
acceptTurn(session, input, dispatch, result.messageId, state.emit);
|
|
505
|
+
} catch (error) {
|
|
506
|
+
emitPromptFailure(input, errorMessage(error), state.emit);
|
|
507
|
+
} finally {
|
|
508
|
+
session.dispatch = null;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function runCommand(
|
|
513
|
+
session: SessionState,
|
|
514
|
+
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
|
515
|
+
command: { name: string; arguments: string },
|
|
516
|
+
emit: (event: ProviderEvent) => void,
|
|
517
|
+
): Promise<void> {
|
|
518
|
+
const dispatch = beginDispatch(session, "command", input.prompt.clientMessageId);
|
|
519
|
+
try {
|
|
520
|
+
// DSH records command/run and command/done; the projector renders both.
|
|
521
|
+
const result = await session.bridge.request("session.command", {
|
|
522
|
+
sessionId: session.nativeId,
|
|
523
|
+
name: command.name,
|
|
524
|
+
arguments: command.arguments,
|
|
525
|
+
});
|
|
526
|
+
if (result.running || dispatch.sawRunning) {
|
|
527
|
+
// The command woke the agent (/goal, /plan): report it as this prompt's turn;
|
|
528
|
+
// finishing the turn refreshes the command list.
|
|
529
|
+
acceptTurn(session, input, dispatch, `dsh-command-${input.prompt.clientMessageId}`, emit);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
emitPromptResult(input, { type: "completed" }, emit);
|
|
533
|
+
} catch (error) {
|
|
534
|
+
emitPromptFailure(input, errorMessage(error), emit);
|
|
535
|
+
return;
|
|
536
|
+
} finally {
|
|
537
|
+
session.dispatch = null;
|
|
538
|
+
}
|
|
539
|
+
await refreshCommands(session, emit);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function beginDispatch(session: SessionState, kind: Dispatch["kind"], clientMessageId: string): Dispatch {
|
|
543
|
+
session.dispatch = { kind, clientMessageId, sawRunning: false, deferred: null };
|
|
544
|
+
return session.dispatch;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Report the dispatch as a Paseo turn, settling a terminal DSH sent before the RPC returned. */
|
|
548
|
+
function acceptTurn(
|
|
549
|
+
session: SessionState,
|
|
550
|
+
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
|
551
|
+
dispatch: Dispatch,
|
|
552
|
+
turnId: string,
|
|
553
|
+
emit: (event: ProviderEvent) => void,
|
|
554
|
+
): void {
|
|
555
|
+
emitPromptResult(input, { type: "turn", turnId }, emit);
|
|
556
|
+
startTurn(session, turnId, emit);
|
|
557
|
+
if (dispatch.deferred) finishTurn(session, dispatch.deferred, emit);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function fetchCommands(bridge: DshBridge, sessionId: string): Promise<SlashCommand[]> {
|
|
561
|
+
try {
|
|
562
|
+
const { commands } = await bridge.request("session.commands", { sessionId });
|
|
563
|
+
return Array.isArray(commands) ? commands : [];
|
|
564
|
+
} catch {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function refreshCommands(
|
|
570
|
+
session: SessionState,
|
|
571
|
+
emit: (event: ProviderEvent) => void,
|
|
572
|
+
): Promise<void> {
|
|
573
|
+
const commands = await fetchCommands(session.bridge, session.nativeId);
|
|
574
|
+
if (session.closed || JSON.stringify(commands) === JSON.stringify(session.commands)) return;
|
|
575
|
+
const hadPlan = hasPlanCommand(session);
|
|
576
|
+
session.commands = commands;
|
|
577
|
+
if (hadPlan !== hasPlanCommand(session)) emitConfig(session, emit);
|
|
578
|
+
emitCommands(session, emit);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function emitCommands(session: SessionState, emit: (event: ProviderEvent) => void): void {
|
|
582
|
+
emit({
|
|
583
|
+
type: "session.commands",
|
|
584
|
+
sessionId: session.paseoId,
|
|
585
|
+
commands: session.commands.map((command) => ({
|
|
586
|
+
name: command.name,
|
|
587
|
+
description: command.description,
|
|
588
|
+
...(command.argumentHint ? { argumentHint: command.argumentHint } : {}),
|
|
589
|
+
})),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function interruptSession(
|
|
594
|
+
input: Extract<ProviderInput, { type: "session.interrupt" }>,
|
|
595
|
+
state: DispatchState,
|
|
596
|
+
): Promise<void> {
|
|
597
|
+
const session = requireSession(state.sessions, input.sessionId);
|
|
598
|
+
session.cancelRequested = true;
|
|
599
|
+
await session.bridge.request("session.cancel", { sessionId: session.nativeId });
|
|
600
|
+
state.emit({ type: "request.completed", requestId: input.requestId });
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function configureSession(
|
|
604
|
+
input: Extract<ProviderInput, { type: "session.configure" }>,
|
|
605
|
+
state: DispatchState,
|
|
606
|
+
): Promise<void> {
|
|
607
|
+
const session = requireSession(state.sessions, input.sessionId);
|
|
608
|
+
if (session.activeTurnId || session.dispatch) {
|
|
609
|
+
state.emit({
|
|
610
|
+
type: "request.failed",
|
|
611
|
+
requestId: input.requestId,
|
|
612
|
+
error: { message: "Wait for the active DSH turn before changing configuration" },
|
|
613
|
+
});
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
validateSettings(input.changes.settings ?? {});
|
|
617
|
+
const source = session.bridge.initialized.catalog;
|
|
618
|
+
const currentModel = modelId(source, session.open.model) ?? session.config.model;
|
|
619
|
+
const currentMode = session.open.preset ?? session.config.mode;
|
|
620
|
+
const currentThinking = session.open.model?.reasoningEffort ?? session.config.thinkingOption;
|
|
621
|
+
const next: ProviderSessionConfig = {
|
|
622
|
+
...session.config,
|
|
623
|
+
model: nullableChange(input.changes.model, currentModel),
|
|
624
|
+
mode: nullableChange(input.changes.mode, currentMode),
|
|
625
|
+
thinkingOption: nullableChange(input.changes.thinkingOption, currentThinking),
|
|
626
|
+
settings: input.changes.settings
|
|
627
|
+
? { ...session.config.settings, ...input.changes.settings }
|
|
628
|
+
: session.config.settings,
|
|
629
|
+
};
|
|
630
|
+
const modelChanged =
|
|
631
|
+
input.changes.model !== undefined || input.changes.thinkingOption !== undefined;
|
|
632
|
+
const selection = modelChanged
|
|
633
|
+
? selectModel(source, next.model, next.thinkingOption)
|
|
634
|
+
: undefined;
|
|
635
|
+
const preset = input.changes.mode === null
|
|
636
|
+
? source.defaultPreset
|
|
637
|
+
: input.changes.mode;
|
|
638
|
+
const permissionPreset = selectedPermissionPreset(
|
|
639
|
+
input.changes.settings ?? {},
|
|
640
|
+
source,
|
|
641
|
+
);
|
|
642
|
+
const planMode = input.changes.settings?.[PLAN_MODE_SETTING];
|
|
643
|
+
if (typeof planMode === "boolean" && planMode !== session.planMode) {
|
|
644
|
+
await setPlanMode(session, planMode);
|
|
645
|
+
}
|
|
646
|
+
const configured = await session.bridge.request("session.configure", {
|
|
647
|
+
sessionId: session.nativeId,
|
|
648
|
+
...(selection ? { model: selection } : {}),
|
|
649
|
+
...(preset ? { preset } : {}),
|
|
650
|
+
...(permissionPreset ? { permissionPreset } : {}),
|
|
651
|
+
});
|
|
652
|
+
session.config = next;
|
|
653
|
+
session.open = {
|
|
654
|
+
...session.open,
|
|
655
|
+
model: configured.model,
|
|
656
|
+
preset: configured.preset,
|
|
657
|
+
permissionPreset: configured.permissionPreset,
|
|
658
|
+
};
|
|
659
|
+
emitConfig(session, state.emit);
|
|
660
|
+
state.emit({ type: "request.completed", requestId: input.requestId });
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function respondToPermission(
|
|
664
|
+
input: Extract<ProviderInput, { type: "session.permission" }>,
|
|
665
|
+
state: DispatchState,
|
|
666
|
+
): Promise<void> {
|
|
667
|
+
const session = requireSession(state.sessions, input.sessionId);
|
|
668
|
+
const interaction = session.pendingInteractions.get(input.permissionId);
|
|
669
|
+
if (!interaction) throw new Error(`Unknown DSH interaction: ${input.permissionId}`);
|
|
670
|
+
const params = interaction.kind === "questions" && input.response.behavior === "allow"
|
|
671
|
+
? { requestId: input.permissionId, answers: questionAnswers(interaction.questions ?? [], input.response) }
|
|
672
|
+
: {
|
|
673
|
+
requestId: input.permissionId,
|
|
674
|
+
outcome: input.response.behavior === "allow" ? "allowed-once" as const : "rejected" as const,
|
|
675
|
+
};
|
|
676
|
+
await session.bridge.request("interaction.respond", params);
|
|
677
|
+
if (session.pendingInteractions.delete(input.permissionId)) {
|
|
678
|
+
state.emit({
|
|
679
|
+
type: "session.permission_resolved",
|
|
680
|
+
sessionId: session.paseoId,
|
|
681
|
+
permissionId: input.permissionId,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function closeSession(
|
|
687
|
+
input: Extract<ProviderInput, { type: "session.close" }>,
|
|
688
|
+
state: DispatchState,
|
|
689
|
+
): Promise<void> {
|
|
690
|
+
const session = requireSession(state.sessions, input.sessionId);
|
|
691
|
+
for (const permissionId of session.pendingInteractions.keys()) {
|
|
692
|
+
state.emit({ type: "session.permission_resolved", sessionId: session.paseoId, permissionId });
|
|
693
|
+
}
|
|
694
|
+
session.pendingInteractions.clear();
|
|
695
|
+
session.closed = true;
|
|
696
|
+
session.releaseBridgeListeners.forEach((release) => release());
|
|
697
|
+
try {
|
|
698
|
+
await session.bridge.request("session.close", { sessionId: session.nativeId });
|
|
699
|
+
} finally {
|
|
700
|
+
try {
|
|
701
|
+
await session.bridge.close();
|
|
702
|
+
} finally {
|
|
703
|
+
state.sessions.delete(input.sessionId);
|
|
704
|
+
if (session.activeTurnId) finishTurn(session, { state: "canceled" }, state.emit);
|
|
705
|
+
state.emit({ type: "session.closed", sessionId: input.sessionId });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
state.emit({ type: "request.completed", requestId: input.requestId });
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function validateAdmission(
|
|
712
|
+
input: ProviderInput,
|
|
713
|
+
sessions: Map<string, SessionState>,
|
|
714
|
+
capabilities: readonly string[],
|
|
715
|
+
): void {
|
|
716
|
+
requireProviderCapabilities(capabilities, input);
|
|
717
|
+
if (input.type === "session.open") {
|
|
718
|
+
if (sessions.has(input.sessionId)) throw new Error(`Session already exists: ${input.sessionId}`);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if ("sessionId" in input && !sessions.has(input.sessionId)) {
|
|
722
|
+
throw new Error(`Unknown session: ${input.sessionId}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function validateSessionConfig(config: ProviderSessionConfig): void {
|
|
727
|
+
validateSettings(config.settings);
|
|
728
|
+
const unsupported: string[] = [];
|
|
729
|
+
if (config.providerOptions && Object.keys(config.providerOptions).length > 0) {
|
|
730
|
+
unsupported.push("providerOptions");
|
|
731
|
+
}
|
|
732
|
+
if (unsupported.length > 0) {
|
|
733
|
+
throw new Error(`DSH does not support session config: ${unsupported.join(", ")}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function emitConfig(session: SessionState, emit: (event: ProviderEvent) => void): void {
|
|
738
|
+
emit({
|
|
739
|
+
type: "session.config",
|
|
740
|
+
sessionId: session.paseoId,
|
|
741
|
+
config: configState(session, session.bridge.initialized.catalog),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function hasPlanCommand(session: SessionState): boolean {
|
|
746
|
+
return session.commands.some((command) => command.name === "plan" && command.kind === "command");
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function latestPlanMode(events: readonly { type: string; data: Record<string, unknown> }[]): boolean {
|
|
750
|
+
const last = events.findLast((event) => event.type === "plan/mode");
|
|
751
|
+
return last?.data.active === true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** Toggle plan mode through DSH's own `/plan` command, which records `plan/mode`. */
|
|
755
|
+
async function setPlanMode(session: SessionState, active: boolean): Promise<void> {
|
|
756
|
+
const result = await session.bridge.request("session.command", {
|
|
757
|
+
sessionId: session.nativeId,
|
|
758
|
+
name: "plan",
|
|
759
|
+
arguments: active ? "" : "off",
|
|
760
|
+
});
|
|
761
|
+
if (result.kind === "error") throw new Error(result.text ?? "DSH /plan failed");
|
|
762
|
+
// Entry can be queued until the next step; reflect the requested state now.
|
|
763
|
+
session.planMode = active;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const MCP_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/u;
|
|
767
|
+
|
|
768
|
+
/** Map Paseo MCP servers to dsh-mcp-client entries. */
|
|
769
|
+
function bridgeMcpServers(servers: ProviderSessionConfig["mcpServers"]): BridgeMcpServer[] {
|
|
770
|
+
return Object.entries(servers).map(([name, server]): BridgeMcpServer => {
|
|
771
|
+
if (!MCP_SERVER_NAME.test(name)) {
|
|
772
|
+
throw new Error(`DSH MCP server names must match ${String(MCP_SERVER_NAME)}: ${name}`);
|
|
773
|
+
}
|
|
774
|
+
if (server.type === "sse") {
|
|
775
|
+
throw new Error(`DSH supports stdio and streamable HTTP MCP servers, not SSE: ${name}`);
|
|
776
|
+
}
|
|
777
|
+
return server.type === "stdio"
|
|
778
|
+
? {
|
|
779
|
+
serverName: name,
|
|
780
|
+
transport: "stdio",
|
|
781
|
+
command: server.command,
|
|
782
|
+
...(server.args ? { args: server.args } : {}),
|
|
783
|
+
...(server.env ? { env: server.env } : {}),
|
|
784
|
+
}
|
|
785
|
+
: {
|
|
786
|
+
serverName: name,
|
|
787
|
+
transport: "streamable-http",
|
|
788
|
+
url: server.url,
|
|
789
|
+
...(server.headers ? { headers: server.headers } : {}),
|
|
790
|
+
};
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** DSH names MCP tools `mcp__<server>__<tool>`. */
|
|
795
|
+
function preapprovedTools(config: ProviderSessionConfig): string[] {
|
|
796
|
+
return (config.toolPolicy?.preapproved ?? []).map((grant) => {
|
|
797
|
+
if (!Object.hasOwn(config.mcpServers, grant.server)) {
|
|
798
|
+
throw new Error(`Tool policy names MCP server '${grant.server}' that is not configured`);
|
|
799
|
+
}
|
|
800
|
+
return `mcp__${grant.server}__${grant.tool}`;
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
const catalogs = new WeakMap<BridgeCatalog, ProviderCatalog>();
|
|
805
|
+
|
|
806
|
+
/** Paseo view of a bridge catalog; a bridge's catalog never changes, so it is built once. */
|
|
807
|
+
function catalog(source: BridgeCatalog): ProviderCatalog {
|
|
808
|
+
let mapped = catalogs.get(source);
|
|
809
|
+
if (!mapped) catalogs.set(source, (mapped = buildCatalog(source)));
|
|
810
|
+
return mapped;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function buildCatalog(source: BridgeCatalog): ProviderCatalog {
|
|
814
|
+
const nameCounts = new Map<string, number>();
|
|
815
|
+
for (const model of source.models) nameCounts.set(model.name, (nameCounts.get(model.name) ?? 0) + 1);
|
|
816
|
+
return {
|
|
817
|
+
models: source.models.map((model) => ({
|
|
818
|
+
id: model.id,
|
|
819
|
+
// The provider goes in the description; the label names it only to tell same-named models apart.
|
|
820
|
+
label: nameCounts.get(model.name)! > 1 ? `${model.name} (${model.providerName})` : model.name,
|
|
821
|
+
description: model.providerName,
|
|
822
|
+
metadata: { provider: model.provider, model: model.model },
|
|
823
|
+
thinkingOptions: model.reasoningEfforts?.map((effort) => ({
|
|
824
|
+
id: effort.id,
|
|
825
|
+
label: effort.name,
|
|
826
|
+
description: effort.description,
|
|
827
|
+
isDefault: effort.id === source.defaultModel?.reasoningEffort,
|
|
828
|
+
})),
|
|
829
|
+
defaultThinkingOptionId:
|
|
830
|
+
model.provider === source.defaultModel?.provider && model.model === source.defaultModel.model
|
|
831
|
+
? source.defaultModel.reasoningEffort
|
|
832
|
+
: undefined,
|
|
833
|
+
})),
|
|
834
|
+
modes: source.presets.map((preset) => ({
|
|
835
|
+
id: preset.id,
|
|
836
|
+
label: preset.name ?? preset.id,
|
|
837
|
+
description: preset.description,
|
|
838
|
+
})),
|
|
839
|
+
defaultModel: source.models.find(
|
|
840
|
+
(model) =>
|
|
841
|
+
source.defaultModel &&
|
|
842
|
+
model.provider === source.defaultModel.provider &&
|
|
843
|
+
model.model === source.defaultModel.model,
|
|
844
|
+
)?.id,
|
|
845
|
+
defaultMode: source.defaultPreset,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function configState(session: SessionState, source: BridgeCatalog): ProviderConfigState {
|
|
850
|
+
const available = catalog(source);
|
|
851
|
+
const thinking = session.open.model?.reasoningEffort ?? session.config.thinkingOption;
|
|
852
|
+
const selectedModelId = modelId(source, session.open.model) ?? session.config.model;
|
|
853
|
+
return {
|
|
854
|
+
model: selectedModelId,
|
|
855
|
+
mode: session.open.preset ?? session.config.mode,
|
|
856
|
+
thinkingOption: thinking,
|
|
857
|
+
models: available.models,
|
|
858
|
+
modes: available.modes,
|
|
859
|
+
thinkingOptions:
|
|
860
|
+
source.models
|
|
861
|
+
.find((model) => model.id === selectedModelId)
|
|
862
|
+
?.reasoningEfforts?.map((effort) => ({
|
|
863
|
+
id: effort.id,
|
|
864
|
+
label: effort.name,
|
|
865
|
+
description: effort.description,
|
|
866
|
+
isDefault: effort.id === thinking,
|
|
867
|
+
})) ?? [],
|
|
868
|
+
settings: [
|
|
869
|
+
...(source.permissionPresets?.length
|
|
870
|
+
? [
|
|
871
|
+
{
|
|
872
|
+
type: "select" as const,
|
|
873
|
+
id: PERMISSION_PRESET_SETTING,
|
|
874
|
+
label: "Permissions",
|
|
875
|
+
description: "DSH permission and sandbox preset for this session",
|
|
876
|
+
value: session.open.permissionPreset ?? source.defaultPermissionPreset ?? null,
|
|
877
|
+
options: source.permissionPresets.map((preset) => ({
|
|
878
|
+
label: preset.name,
|
|
879
|
+
value: preset.id,
|
|
880
|
+
})),
|
|
881
|
+
},
|
|
882
|
+
]
|
|
883
|
+
: []),
|
|
884
|
+
...(hasPlanCommand(session)
|
|
885
|
+
? [
|
|
886
|
+
{
|
|
887
|
+
type: "toggle" as const,
|
|
888
|
+
id: PLAN_MODE_SETTING,
|
|
889
|
+
label: "Plan mode",
|
|
890
|
+
description: "DSH plan mode: explore and propose a plan before editing",
|
|
891
|
+
value: session.planMode,
|
|
892
|
+
},
|
|
893
|
+
]
|
|
894
|
+
: []),
|
|
895
|
+
],
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function modelId(source: BridgeCatalog, selection?: ModelSelection): string | undefined {
|
|
900
|
+
return source.models.find(
|
|
901
|
+
(model) =>
|
|
902
|
+
selection &&
|
|
903
|
+
model.provider === selection.provider &&
|
|
904
|
+
model.model === selection.model,
|
|
905
|
+
)?.id;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function validateSettings(settings: Readonly<Record<string, unknown>>): void {
|
|
909
|
+
const unsupported = Object.keys(settings).filter(
|
|
910
|
+
(key) => key !== PERMISSION_PRESET_SETTING && key !== PLAN_MODE_SETTING,
|
|
911
|
+
);
|
|
912
|
+
if (unsupported.length > 0) {
|
|
913
|
+
throw new Error(`DSH does not support session settings: ${unsupported.join(", ")}`);
|
|
914
|
+
}
|
|
915
|
+
const value = settings[PERMISSION_PRESET_SETTING];
|
|
916
|
+
if (value !== undefined && typeof value !== "string") {
|
|
917
|
+
throw new Error("DSH permissionPreset must be a string");
|
|
918
|
+
}
|
|
919
|
+
const plan = settings[PLAN_MODE_SETTING];
|
|
920
|
+
if (plan !== undefined && typeof plan !== "boolean") {
|
|
921
|
+
throw new Error("DSH planMode must be a boolean");
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function selectedPermissionPreset(
|
|
926
|
+
settings: Readonly<Record<string, unknown>>,
|
|
927
|
+
source: BridgeCatalog,
|
|
928
|
+
): string | undefined {
|
|
929
|
+
const value = settings[PERMISSION_PRESET_SETTING];
|
|
930
|
+
if (value === undefined) return undefined;
|
|
931
|
+
if (typeof value !== "string") throw new Error("DSH permissionPreset must be a string");
|
|
932
|
+
if (!source.permissionPresets?.some((preset) => preset.id === value)) {
|
|
933
|
+
throw new Error(`Unknown DSH permission preset: ${value}`);
|
|
934
|
+
}
|
|
935
|
+
return value;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function selectModel(
|
|
939
|
+
source: BridgeCatalog,
|
|
940
|
+
modelId?: string,
|
|
941
|
+
reasoningEffort?: string,
|
|
942
|
+
): ModelSelection | undefined {
|
|
943
|
+
const model = source.models.find((candidate) => candidate.id === modelId);
|
|
944
|
+
const base = model
|
|
945
|
+
? { provider: model.provider, model: model.model }
|
|
946
|
+
: source.defaultModel;
|
|
947
|
+
return base ? { ...base, ...(reasoningEffort ? { reasoningEffort } : {}) } : undefined;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function permissionRequest(interaction: InteractionRequest): ProviderPermissionRequest {
|
|
951
|
+
if (interaction.kind === "questions") {
|
|
952
|
+
return {
|
|
953
|
+
id: interaction.requestId,
|
|
954
|
+
name: "question",
|
|
955
|
+
kind: "question" as const,
|
|
956
|
+
title: "DeepSeek Harness question",
|
|
957
|
+
input: {
|
|
958
|
+
questions: (interaction.questions ?? []).map((question) => ({
|
|
959
|
+
question: question.question,
|
|
960
|
+
header: question.header ?? question.id,
|
|
961
|
+
options: question.options ?? [],
|
|
962
|
+
multiSelect: question.multiSelect ?? false,
|
|
963
|
+
allowOther: true,
|
|
964
|
+
})),
|
|
965
|
+
},
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
id: interaction.requestId,
|
|
970
|
+
name: interaction.toolName ?? "tool",
|
|
971
|
+
kind: "tool" as const,
|
|
972
|
+
title: interaction.toolName ? `Allow ${interaction.toolName}?` : "Allow tool?",
|
|
973
|
+
description: interaction.reason,
|
|
974
|
+
input: { callId: interaction.callId ?? "" },
|
|
975
|
+
actions: [
|
|
976
|
+
{ id: "allow-once", label: "Allow once", behavior: "allow" as const, variant: "primary" as const },
|
|
977
|
+
{ id: "reject", label: "Reject", behavior: "deny" as const, variant: "secondary" as const },
|
|
978
|
+
],
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
function questionAnswers(
|
|
983
|
+
questions: QuestionItem[],
|
|
984
|
+
response: Extract<ProviderPermissionResponse, { behavior: "allow" }>,
|
|
985
|
+
): QuestionAnswer[] {
|
|
986
|
+
const answers = objectValue(response.updatedInput?.answers);
|
|
987
|
+
return questions.map((question) => {
|
|
988
|
+
const key = question.header ?? question.id;
|
|
989
|
+
const raw = answers?.[key];
|
|
990
|
+
const selected = typeof raw === "string" ? raw.split(",").map((value) => value.trim()).filter(Boolean) : [];
|
|
991
|
+
const known = new Set((question.options ?? []).map((option) => option.label));
|
|
992
|
+
const custom = selected.find((value) => !known.has(value));
|
|
993
|
+
return {
|
|
994
|
+
id: question.id,
|
|
995
|
+
selected: selected.filter((value) => known.has(value)),
|
|
996
|
+
...(custom ? { custom } : {}),
|
|
997
|
+
};
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function startTurn(
|
|
1002
|
+
session: SessionState,
|
|
1003
|
+
turnId: string,
|
|
1004
|
+
emit: (event: ProviderEvent) => void,
|
|
1005
|
+
): void {
|
|
1006
|
+
session.activeTurnId = turnId;
|
|
1007
|
+
session.cancelRequested = false;
|
|
1008
|
+
emit({ type: "session.turn", sessionId: session.paseoId, turnId, state: "started" });
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function finishTurn(
|
|
1012
|
+
session: SessionState,
|
|
1013
|
+
terminal: TurnTerminal,
|
|
1014
|
+
emit: (event: ProviderEvent) => void,
|
|
1015
|
+
): void {
|
|
1016
|
+
const turnId = session.activeTurnId;
|
|
1017
|
+
if (!turnId) return;
|
|
1018
|
+
emit({
|
|
1019
|
+
type: "session.turn",
|
|
1020
|
+
sessionId: session.paseoId,
|
|
1021
|
+
turnId,
|
|
1022
|
+
state: terminal.state,
|
|
1023
|
+
...(terminal.state === "failed"
|
|
1024
|
+
? { error: terminal.error ?? { message: "DSH turn failed" } }
|
|
1025
|
+
: {}),
|
|
1026
|
+
});
|
|
1027
|
+
session.activeTurnId = null;
|
|
1028
|
+
session.cancelRequested = false;
|
|
1029
|
+
void refreshCommands(session, emit);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function persistence(sessionId: string) {
|
|
1033
|
+
return { version: 1, data: { dshSessionId: sessionId } } as const;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function persistenceId(
|
|
1037
|
+
value: Extract<ProviderInput, { type: "session.open" }>["persistence"],
|
|
1038
|
+
): string | undefined {
|
|
1039
|
+
if (!value) return undefined;
|
|
1040
|
+
if (value.version !== 1) throw new Error(`Unsupported DSH persistence version ${value.version}`);
|
|
1041
|
+
const data = objectValue(value.data);
|
|
1042
|
+
const id = data?.dshSessionId;
|
|
1043
|
+
if (typeof id !== "string" || !id) throw new Error("Invalid DSH persistence handle");
|
|
1044
|
+
return id;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function enqueue(session: SessionState, operation: () => Promise<void>): Promise<void> {
|
|
1048
|
+
session.lane = session.lane.then(operation, operation);
|
|
1049
|
+
return session.lane;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function requireSession(sessions: Map<string, SessionState>, sessionId: string): SessionState {
|
|
1053
|
+
const session = sessions.get(sessionId);
|
|
1054
|
+
if (!session) throw new Error(`Unknown session: ${sessionId}`);
|
|
1055
|
+
return session;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function failInput(input: ProviderInput, error: unknown, emit: (event: ProviderEvent) => void): void {
|
|
1059
|
+
if (input.type === "session.prompt") {
|
|
1060
|
+
emitPromptFailure(input, errorMessage(error), emit);
|
|
1061
|
+
} else if ("requestId" in input) {
|
|
1062
|
+
emit({
|
|
1063
|
+
type: "request.failed",
|
|
1064
|
+
requestId: input.requestId,
|
|
1065
|
+
error: { message: errorMessage(error) },
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function emitPromptResult(
|
|
1071
|
+
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
|
1072
|
+
result: Extract<ProviderEvent, { type: "session.prompt_result" }>["result"],
|
|
1073
|
+
emit: (event: ProviderEvent) => void,
|
|
1074
|
+
): void {
|
|
1075
|
+
emit({
|
|
1076
|
+
type: "session.prompt_result",
|
|
1077
|
+
sessionId: input.sessionId,
|
|
1078
|
+
clientMessageId: input.prompt.clientMessageId,
|
|
1079
|
+
result,
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function emitPromptFailure(
|
|
1084
|
+
input: Extract<ProviderInput, { type: "session.prompt" }>,
|
|
1085
|
+
message: string,
|
|
1086
|
+
emit: (event: ProviderEvent) => void,
|
|
1087
|
+
): void {
|
|
1088
|
+
emitPromptResult(input, { type: "failed", error: { message } }, emit);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function nullableChange(value: string | null | undefined, current: string | undefined): string | undefined {
|
|
1092
|
+
return value === null ? undefined : value ?? current;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
function errorMessage(error: unknown): string {
|
|
1096
|
+
return error instanceof Error ? error.message : String(error);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function timestamp(value: number): string {
|
|
1100
|
+
return new Date(value < 10_000_000_000 ? value * 1_000 : value).toISOString();
|
|
1101
|
+
}
|