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.
@@ -0,0 +1,890 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { basename, isAbsolute } from "node:path";
4
+ import type {
5
+ BridgeCatalog,
6
+ BridgeInitializeResult,
7
+ BridgeMethods,
8
+ BridgeNotifications,
9
+ DshSessionEvent,
10
+ InteractionRequest,
11
+ ModelSelection,
12
+ QuestionAnswer,
13
+ QuestionItem,
14
+ SessionOpenParams,
15
+ SessionOpenResult,
16
+ SessionSummary,
17
+ PromptPart,
18
+ SlashCommand,
19
+ } from "../../shared/bridge-protocol.js";
20
+ import { slashLine } from "../../shared/bridge-protocol.js";
21
+ import type {
22
+ ApprovalOutcome,
23
+ HostAgent,
24
+ HostContext,
25
+ HostHandle,
26
+ HostHelpers,
27
+ ModelSelectionRef,
28
+ } from "./host.js";
29
+
30
+ type Notify = <K extends keyof BridgeNotifications>(
31
+ method: K,
32
+ params: BridgeNotifications[K],
33
+ ) => void;
34
+ interface PendingInteraction {
35
+ request: InteractionRequest;
36
+ finish(value?: ApprovalOutcome | { answers: QuestionAnswer[] }): void;
37
+ }
38
+ interface RecordEntry {
39
+ handle: HostHandle;
40
+ model?: ModelSelection;
41
+ preset?: string;
42
+ selection: ModelSelectionRef;
43
+ }
44
+
45
+ function nonempty(value: unknown, field: string): string {
46
+ if (typeof value !== "string" || !value.trim())
47
+ throw new Error(`${field} must be a nonempty string`);
48
+ return value;
49
+ }
50
+ function record(value: unknown): Record<string, unknown> {
51
+ return value !== null && typeof value === "object" && !Array.isArray(value)
52
+ ? (value as Record<string, unknown>)
53
+ : {};
54
+ }
55
+ function selection(input: unknown): ModelSelection | undefined {
56
+ const value = record(input);
57
+ if (
58
+ typeof value.provider !== "string" ||
59
+ !value.provider ||
60
+ typeof value.model !== "string" ||
61
+ !value.model
62
+ )
63
+ return;
64
+ return {
65
+ provider: value.provider,
66
+ model: value.model,
67
+ ...(typeof value.reasoningEffort === "string"
68
+ ? { reasoningEffort: value.reasoningEffort }
69
+ : {}),
70
+ };
71
+ }
72
+ /** Restore the durable pending selection, falling back to the last request route. */
73
+ export function savedModel(
74
+ events: DshSessionEvent[],
75
+ ): ModelSelection | undefined {
76
+ let pending: ModelSelection | undefined;
77
+ let used: ModelSelection | undefined;
78
+ for (const event of events) {
79
+ if (event.type === "model/selection")
80
+ pending = selection(event.data) ?? pending;
81
+ if (event.type !== "request/header") continue;
82
+ const header = record(event.data.header);
83
+ const raw = selection(header.config);
84
+ if (raw) {
85
+ used =
86
+ record(header.adapterDefaults).reasoningEffort === true
87
+ ? { provider: raw.provider, model: raw.model }
88
+ : raw;
89
+ if (pending && JSON.stringify(raw) === JSON.stringify(pending))
90
+ pending = undefined;
91
+ }
92
+ }
93
+ return pending ?? used;
94
+ }
95
+
96
+ export class BridgeService {
97
+ private readonly owned = new Map<string, RecordEntry>();
98
+ private readonly opening = new Map<string, Promise<SessionOpenResult>>();
99
+ private readonly closing = new Map<string, Promise<void>>();
100
+ private readonly tracked = new Set<string>();
101
+ private readonly parent = new Map<string, string>();
102
+ private readonly commandAborts = new Map<string, AbortController>();
103
+ private readonly preapproved = new Map<string, ReadonlySet<string>>();
104
+ private readonly interactions = new Map<string, PendingInteraction>();
105
+ private readonly subscriptions: Array<() => void> = [];
106
+ private stopping = false;
107
+ private stopTask?: Promise<{}>;
108
+
109
+ constructor(
110
+ private readonly ctx: HostContext,
111
+ private readonly notify: Notify,
112
+ private readonly profile = "paseo",
113
+ private readonly helpers?: HostHelpers,
114
+ ) {
115
+ this.subscriptions.push(
116
+ ctx.on("session/created", (session) => {
117
+ if (session.header.parentSession)
118
+ this.parent.set(
119
+ String(session.id),
120
+ String(session.header.parentSession),
121
+ );
122
+ }),
123
+ );
124
+ this.subscriptions.push(
125
+ ctx.on("session/event", (session, event) => {
126
+ if (this.tracked.has(String(session.id)))
127
+ notify("session.event", { sessionId: String(session.id), event });
128
+ }),
129
+ );
130
+ this.subscriptions.push(
131
+ ctx.on("agent/assistant-stream", ({ agent, frame }) => {
132
+ if (this.tracked.has(String(agent.id)))
133
+ notify("session.stream", { sessionId: String(agent.id), frame });
134
+ }),
135
+ );
136
+ this.subscriptions.push(
137
+ ctx.on("agent/status", ({ agent, status }) => {
138
+ if (this.tracked.has(String(agent.id)))
139
+ notify("session.status", { sessionId: String(agent.id), status });
140
+ }),
141
+ );
142
+ this.subscriptions.push(
143
+ ctx.on("approval/request", (request, next) => {
144
+ const sessionId = this.rootFor(request.agent);
145
+ if (!sessionId) return next();
146
+ if (this.preapproved.get(sessionId)?.has(request.toolName))
147
+ return Promise.resolve("allowed-once" as const);
148
+ return this.ask(
149
+ {
150
+ sessionId,
151
+ requestId: randomUUID(),
152
+ kind: "approval",
153
+ toolName: request.toolName,
154
+ callId: request.callId,
155
+ reason: request.reason,
156
+ },
157
+ request.signal,
158
+ ).then((value) => {
159
+ if (typeof value !== "string")
160
+ throw new Error("Unexpected approval response");
161
+ return value;
162
+ });
163
+ }),
164
+ );
165
+ this.subscriptions.push(
166
+ ctx.on("user-questions/request", (request, next) => {
167
+ const sessionId = this.rootFor(request.agent);
168
+ if (!sessionId) return next();
169
+ return this.ask(
170
+ {
171
+ sessionId,
172
+ requestId: randomUUID(),
173
+ kind: "questions",
174
+ questions: request.questions,
175
+ },
176
+ request.signal,
177
+ ).then((value) => {
178
+ if (typeof value === "string")
179
+ throw new Error("Unexpected question response");
180
+ return value;
181
+ });
182
+ }),
183
+ );
184
+ }
185
+
186
+ private rootFor(agent: HostAgent | undefined): string | undefined {
187
+ let id = agent?.id && String(agent.id);
188
+ const seen = new Set<string>();
189
+ while (id && !seen.has(id)) {
190
+ if (this.tracked.has(id)) return id;
191
+ seen.add(id);
192
+ id =
193
+ this.parent.get(id) ??
194
+ (id === agent?.id ? agent.session.header.parentSession : undefined);
195
+ }
196
+ return undefined;
197
+ }
198
+
199
+ private ask(
200
+ request: InteractionRequest,
201
+ signal?: AbortSignal,
202
+ ): Promise<ApprovalOutcome | { answers: QuestionAnswer[] }> {
203
+ if (
204
+ signal?.aborted ||
205
+ this.stopping ||
206
+ this.closing.has(request.sessionId)
207
+ ) {
208
+ return request.kind === "approval"
209
+ ? Promise.resolve("cancelled")
210
+ : Promise.reject(new Error("Question cancelled"));
211
+ }
212
+ return new Promise((resolve, reject) => {
213
+ const abort = () => finish();
214
+ const finish = (
215
+ value?: ApprovalOutcome | { answers: QuestionAnswer[] },
216
+ ) => {
217
+ if (!this.interactions.delete(request.requestId)) return;
218
+ signal?.removeEventListener("abort", abort);
219
+ this.notify("interaction.closed", {
220
+ requestId: request.requestId,
221
+ sessionId: request.sessionId,
222
+ });
223
+ if (value !== undefined) resolve(value);
224
+ else if (request.kind === "approval") resolve("cancelled");
225
+ else reject(new Error("Question cancelled"));
226
+ };
227
+ this.interactions.set(request.requestId, { request, finish });
228
+ signal?.addEventListener("abort", abort, { once: true });
229
+ this.notify("interaction.request", request);
230
+ });
231
+ }
232
+
233
+ async catalog(): Promise<BridgeCatalog> {
234
+ const llm = this.ctx.get("llm");
235
+ const presets = this.ctx.get("agentPresets");
236
+ const permission = this.ctx.get("permissionPresets");
237
+ const models: BridgeCatalog["models"] = [];
238
+ const defaultModel = selection(
239
+ this.ctx.get("agentDefaultModel")?.currentSelection(),
240
+ );
241
+ for (const provider of llm.listProviders()) {
242
+ try {
243
+ for (const model of await llm.listModels(provider.id)) {
244
+ let reasoningEfforts: BridgeCatalog["models"][number]["reasoningEfforts"];
245
+ try {
246
+ reasoningEfforts = (
247
+ await llm.resolveModelInfo?.(provider.id, model.id)
248
+ )?.reasoning?.efforts;
249
+ } catch {
250
+ /* Optional capability discovery may be unavailable for this route. */
251
+ }
252
+ models.push({
253
+ id: `${provider.id}/${model.id}`,
254
+ provider: provider.id,
255
+ model: model.id,
256
+ name: model.name ?? model.id,
257
+ providerName: provider.name ?? provider.id,
258
+ reasoningEfforts,
259
+ });
260
+ }
261
+ } catch {
262
+ /* One unconfigured provider must not hide the other configured routes. */
263
+ }
264
+ }
265
+ if (
266
+ defaultModel &&
267
+ !models.some(
268
+ (model) =>
269
+ model.provider === defaultModel.provider &&
270
+ model.model === defaultModel.model,
271
+ )
272
+ ) {
273
+ models.push({
274
+ id: `${defaultModel.provider}/${defaultModel.model}`,
275
+ ...defaultModel,
276
+ name: defaultModel.model,
277
+ providerName: defaultModel.provider,
278
+ });
279
+ }
280
+ return {
281
+ models,
282
+ presets: (await presets.list())
283
+ .filter((preset) => !preset.broken)
284
+ .map((preset) => ({
285
+ id: preset.id,
286
+ name: preset.name,
287
+ description: preset.description,
288
+ })),
289
+ defaultModel,
290
+ defaultPreset: presets.defaultId,
291
+ ...(permission && this.ctx.get("commands")
292
+ ? {
293
+ permissionPresets: permission.names.map((id) => {
294
+ const option = permission.optionOf(id);
295
+ return {
296
+ id,
297
+ name: option.name,
298
+ ...(option.description
299
+ ? { description: option.description }
300
+ : {}),
301
+ };
302
+ }),
303
+ defaultPermissionPreset: permission.defaultPreset,
304
+ }
305
+ : {}),
306
+ };
307
+ }
308
+
309
+ async handle<K extends keyof BridgeMethods>(
310
+ method: K,
311
+ input: unknown = {},
312
+ ): Promise<BridgeMethods[K]["result"]> {
313
+ if (this.stopping && method !== "bridge.shutdown")
314
+ throw new Error("Bridge is shutting down");
315
+ if (!input || typeof input !== "object" || Array.isArray(input))
316
+ throw new Error("Method params must be an object");
317
+ const params = record(input);
318
+ let result: unknown;
319
+ switch (method) {
320
+ case "bridge.initialize":
321
+ result = {
322
+ protocolVersion: 1,
323
+ profile: this.profile,
324
+ capabilities: {
325
+ stream: true,
326
+ approvals: Boolean(this.ctx.get("approval")),
327
+ questions: Boolean(this.ctx.get("userQuestions")),
328
+ history: true,
329
+ cancel: true,
330
+ },
331
+ catalog: await this.catalog(),
332
+ } satisfies BridgeInitializeResult;
333
+ break;
334
+ case "session.list": {
335
+ const query = this.ctx.get("sessionQuery");
336
+ const records = await query.listSessions();
337
+ const sessions: SessionSummary[] = records.map((record) => ({
338
+ ...record.header,
339
+ }));
340
+ // The official bulk title read owns live/cold source and validation.
341
+ if (typeof query.readTitleSnapshots === "function") {
342
+ const titles = await query.readTitleSnapshots(
343
+ sessions.map((session) => session.id),
344
+ );
345
+ for (let i = 0; i < sessions.length; i++) {
346
+ const result = titles[i];
347
+ const title =
348
+ result?.status === "fulfilled"
349
+ ? result.value.title?.title
350
+ : undefined;
351
+ if (typeof title === "string") sessions[i]!.title = title;
352
+ }
353
+ }
354
+ result = { sessions };
355
+ break;
356
+ }
357
+ case "session.read":
358
+ result = await this.ctx
359
+ .get("sessionQuery")
360
+ .readSession(nonempty(params.sessionId, "sessionId"));
361
+ break;
362
+ case "session.open":
363
+ result = await this.open(validateOpen(params));
364
+ break;
365
+ case "session.prompt":
366
+ case "session.steer": {
367
+ const agent = this.agent(params.sessionId);
368
+ const content = await this.admitContent(agent, validateContent(params.content));
369
+ const id = randomUUID();
370
+ const message = Object.freeze({
371
+ id,
372
+ role: "user",
373
+ source: Object.freeze({ kind: "user" }),
374
+ content: Object.freeze(content),
375
+ });
376
+ if (method === "session.steer") agent.steer(message);
377
+ else agent.followup(message);
378
+ result = { messageId: id };
379
+ break;
380
+ }
381
+ case "session.configure": {
382
+ const agent = this.agent(params.sessionId);
383
+ const entry = this.owned.get(String(agent.id))!;
384
+ if (agent.status !== "idle")
385
+ throw new Error("Configuration requires an idle session");
386
+ const model =
387
+ params.model === undefined ? undefined : validateModel(params.model);
388
+ const preset =
389
+ params.preset === undefined
390
+ ? undefined
391
+ : nonempty(params.preset, "preset");
392
+ const permissionPreset =
393
+ params.permissionPreset === undefined
394
+ ? undefined
395
+ : nonempty(params.permissionPreset, "permissionPreset");
396
+ if (model && !this.helpers)
397
+ throw new Error(
398
+ "Model switching is unavailable in this Harness runtime",
399
+ );
400
+ if (preset)
401
+ entry.preset = await this.ctx
402
+ .get("agentPresets")
403
+ .select(agent, preset);
404
+ if (model) {
405
+ agent.session.append("model/selection", model);
406
+ entry.selection.current = model;
407
+ entry.model = model;
408
+ }
409
+ if (permissionPreset)
410
+ await this.setPermissionPreset(agent, permissionPreset);
411
+ result = {
412
+ model: entry.model,
413
+ preset: entry.preset,
414
+ permissionPreset: this.ctx
415
+ .get("permissionPresets")
416
+ ?.current(agent.session),
417
+ };
418
+ break;
419
+ }
420
+ case "session.commands":
421
+ result = { commands: await this.slashCommands(this.agent(params.sessionId)) };
422
+ break;
423
+ case "session.command":
424
+ result = await this.runCommand(
425
+ this.agent(params.sessionId),
426
+ nonempty(params.name, "name"),
427
+ typeof params.arguments === "string" ? params.arguments : "",
428
+ );
429
+ break;
430
+ case "session.rename": {
431
+ const agent = this.agent(params.sessionId);
432
+ const titles = this.ctx.get("sessionTitle");
433
+ if (!titles)
434
+ throw new Error("Session titles are unavailable in this profile");
435
+ titles.rename(agent.session, nonempty(params.title, "title"));
436
+ result = {};
437
+ break;
438
+ }
439
+ case "session.cancel":
440
+ this.commandAborts.get(nonempty(params.sessionId, "sessionId"))?.abort();
441
+ this.agent(params.sessionId).cancel(
442
+ { kind: "user" },
443
+ { keepInbox: false },
444
+ );
445
+ result = {};
446
+ break;
447
+ case "session.close":
448
+ await this.close(nonempty(params.sessionId, "sessionId"));
449
+ result = {};
450
+ break;
451
+ case "interaction.respond":
452
+ this.respond(params);
453
+ result = {};
454
+ break;
455
+ case "bridge.shutdown":
456
+ result = await this.shutdown();
457
+ break;
458
+ default:
459
+ throw new Error(`Unsupported bridge method: ${String(method)}`);
460
+ }
461
+ return result as BridgeMethods[K]["result"];
462
+ }
463
+
464
+ /** Store images and files as durable DSH attachments, in prompt order. */
465
+ private async admitContent(
466
+ agent: HostAgent,
467
+ parts: PromptPart[],
468
+ ): Promise<object[]> {
469
+ if (parts.some((part) => part.type === "image")) {
470
+ const model = this.owned.get(String(agent.id))?.model;
471
+ const info = model
472
+ ? await this.ctx.get("llm").resolveModelInfo?.(model.provider, model.model)
473
+ : undefined;
474
+ if (info?.inputModalities && !info.inputModalities.includes("image"))
475
+ throw new Error(`Model "${model!.model}" does not support image input`);
476
+ }
477
+ const attachments = () => {
478
+ const service = this.ctx.get("attachments");
479
+ if (!service) throw new Error("Attachments are unavailable in this profile");
480
+ return service;
481
+ };
482
+ // Stores are independent; Promise.all keeps prompt order.
483
+ return Promise.all(
484
+ parts.map(async (part) => {
485
+ if (part.type === "text") return Object.freeze({ type: "text", text: part.text });
486
+ if (part.type === "image") {
487
+ const attachment = await attachments().saveImage({
488
+ data: Buffer.from(part.data, "base64"),
489
+ mediaType: part.mediaType,
490
+ ...(part.name ? { name: part.name } : {}),
491
+ });
492
+ return Object.freeze({ type: "image", attachment });
493
+ }
494
+ const attachment = await attachments().saveFile({
495
+ data: await readFile(part.path),
496
+ name: part.name ?? basename(part.path),
497
+ });
498
+ return Object.freeze({ type: "file", attachment });
499
+ }),
500
+ );
501
+ }
502
+
503
+ /** Registered commands first; a skill never shadows a command of the same name. */
504
+ private async slashCommands(agent: HostAgent): Promise<SlashCommand[]> {
505
+ const commands: SlashCommand[] = (this.ctx.get("commands")?.list(agent) ?? []).map(
506
+ (command) => ({
507
+ name: command.name,
508
+ description: command.description,
509
+ ...(command.input?.hint ? { argumentHint: command.input.hint } : {}),
510
+ kind: "command" as const,
511
+ }),
512
+ );
513
+ const taken = new Set(commands.map((command) => command.name));
514
+ const registry =
515
+ this.ctx.get("agentPresets").serviceFor?.(agent, "skills") ??
516
+ this.ctx.get("skills");
517
+ let skills: SlashCommand[] = [];
518
+ try {
519
+ skills = (
520
+ (await registry?.list({
521
+ cwd: agent.session.header.cwd,
522
+ scope: agent,
523
+ })) ?? []
524
+ )
525
+ .filter(
526
+ (skill) =>
527
+ skill.invocation?.userInvocable === true &&
528
+ typeof skill.name === "string" &&
529
+ !taken.has(skill.name),
530
+ )
531
+ .map((skill) => ({
532
+ name: skill.name,
533
+ description: skill.description,
534
+ kind: "skill" as const,
535
+ }));
536
+ } catch {
537
+ // A failed skill read degrades to commands only.
538
+ }
539
+ return [...commands, ...skills].sort((left, right) =>
540
+ left.name < right.name ? -1 : 1,
541
+ );
542
+ }
543
+
544
+ private async runCommand(
545
+ agent: HostAgent,
546
+ name: string,
547
+ args: string,
548
+ ): Promise<BridgeMethods["session.command"]["result"]> {
549
+ const commands = this.ctx.get("commands");
550
+ if (!commands) throw new Error("Commands are unavailable in this profile");
551
+ const sessionId = String(agent.id);
552
+ if (this.commandAborts.has(sessionId))
553
+ throw new Error("A DSH command is already running");
554
+ const abort = new AbortController();
555
+ this.commandAborts.set(sessionId, abort);
556
+ try {
557
+ const execution = await commands.execute(
558
+ agent,
559
+ slashLine(name, args),
560
+ [],
561
+ abort.signal,
562
+ );
563
+ if (!execution) throw new Error(`Unknown DSH command: /${name}`);
564
+ // Handlers such as /goal and /plan wake the agent; let that settle first.
565
+ await new Promise((resolve) => setImmediate(resolve));
566
+ return {
567
+ kind: execution.result.kind === "success" ? "success" : "error",
568
+ ...(execution.result.text ? { text: execution.result.text } : {}),
569
+ running: agent.status === "running",
570
+ };
571
+ } finally {
572
+ this.commandAborts.delete(sessionId);
573
+ }
574
+ }
575
+
576
+ private agent(id: unknown): HostAgent {
577
+ const sessionId = nonempty(id, "sessionId");
578
+ const agent = this.owned.get(sessionId)?.handle.agent;
579
+ if (
580
+ !agent ||
581
+ this.closing.has(sessionId) ||
582
+ this.ctx.get("agents").get(sessionId) !== agent
583
+ )
584
+ throw new Error("Session is not open in this bridge");
585
+ return agent;
586
+ }
587
+
588
+ private open(params: SessionOpenParams): Promise<SessionOpenResult> {
589
+ const id =
590
+ params.sessionId === undefined
591
+ ? randomUUID()
592
+ : nonempty(params.sessionId, "sessionId");
593
+ if (params.resume && params.sessionId === undefined)
594
+ return Promise.reject(new Error("Resume requires sessionId"));
595
+ if (this.owned.has(id) || this.opening.has(id) || this.closing.has(id))
596
+ return Promise.reject(
597
+ new Error("Session already open or changing ownership"),
598
+ );
599
+ if (!isAbsolute(nonempty(params.cwd, "cwd")))
600
+ return Promise.reject(new Error("cwd must be absolute"));
601
+ if (params.model !== undefined && selection(params.model) === undefined)
602
+ return Promise.reject(new Error("Invalid model selection"));
603
+ this.tracked.add(id);
604
+ if (params.preapprovedTools?.length)
605
+ this.preapproved.set(id, new Set(params.preapprovedTools));
606
+ const task = this.performOpen(id, params);
607
+ this.opening.set(id, task);
608
+ void task.then(
609
+ () => this.opening.delete(id),
610
+ () => {
611
+ this.opening.delete(id);
612
+ this.tracked.delete(id);
613
+ this.preapproved.delete(id);
614
+ },
615
+ );
616
+ return task;
617
+ }
618
+
619
+ private async performOpen(
620
+ id: string,
621
+ params: SessionOpenParams,
622
+ ): Promise<SessionOpenResult> {
623
+ const query = this.ctx.get("sessionQuery");
624
+ const presets = this.ctx.get("agentPresets");
625
+ let savedPreset: string | undefined;
626
+ let historical: DshSessionEvent[] = [];
627
+ if (params.resume) {
628
+ // Reading neither acquires a writer nor drives a model request.
629
+ const observation = await query.observeSession(id, {
630
+ projectionMode: "all",
631
+ });
632
+ try {
633
+ savedPreset = observation.projections?.values?.agentPreset ?? undefined;
634
+ } finally {
635
+ observation[Symbol.dispose]();
636
+ }
637
+ historical = (await query.readSession(id)).events;
638
+ if (params.preset && params.preset !== savedPreset)
639
+ throw new Error("A resumed session must retain its recorded preset");
640
+ }
641
+ const preset = await presets.resolve(savedPreset ?? params.preset);
642
+ const model =
643
+ selection(params.model) ??
644
+ savedModel(historical) ??
645
+ selection(this.ctx.get("agentDefaultModel")?.currentSelection());
646
+ if (!model) throw new Error("Profile has no configured default model");
647
+ const modelRef: ModelSelectionRef = {
648
+ current: model,
649
+ assembled: undefined,
650
+ };
651
+ const setup = async (agentCtx: HostContext) => {
652
+ this.helpers?.installModelSelection(agentCtx, modelRef);
653
+ await presets.mount(agentCtx, preset.id);
654
+ if (params.systemPrompt) {
655
+ const systemPrompt = agentCtx.get("systemPrompt");
656
+ if (!systemPrompt)
657
+ throw new Error("System prompt sections are unavailable in this profile");
658
+ // Agent-scoped, after first-party guidance and before the persona suffix (10200).
659
+ systemPrompt.section({ name: "paseo:system-prompt", order: 10100, text: params.systemPrompt });
660
+ }
661
+ };
662
+ // The official factory owns cross-process SessionWriteLease flock arbitration.
663
+ const handle: HostHandle = params.resume
664
+ ? await this.ctx
665
+ .get("agents")
666
+ .resume({ resumeSessionId: id, agentOptions: model, setup })
667
+ : await this.ctx.get("agents").create({
668
+ sessionId: id,
669
+ meta: { cwd: params.cwd, agentPreset: preset.id },
670
+ agentOptions: model,
671
+ setup,
672
+ });
673
+ try {
674
+ if (this.stopping) throw new Error("Bridge is shutting down");
675
+ if (params.model) handle.agent.session.append("model/selection", model);
676
+ if (params.permissionPreset)
677
+ await this.setPermissionPreset(handle.agent, params.permissionPreset);
678
+ this.owned.set(id, {
679
+ handle,
680
+ model,
681
+ preset: preset.id,
682
+ selection: modelRef,
683
+ });
684
+ return {
685
+ sessionId: id,
686
+ cwd: handle.agent.session.header.cwd ?? params.cwd,
687
+ model,
688
+ preset: preset.id,
689
+ permissionPreset: this.ctx
690
+ .get("permissionPresets")
691
+ ?.current(handle.agent.session),
692
+ events: handle.agent.session.snapshotEvents(),
693
+ };
694
+ } catch (error) {
695
+ await handle.dispose();
696
+ throw error;
697
+ }
698
+ }
699
+
700
+ private async setPermissionPreset(
701
+ agent: HostAgent,
702
+ preset: string,
703
+ ): Promise<void> {
704
+ const permission = this.ctx.get("permissionPresets");
705
+ const commands = this.ctx.get("commands");
706
+ if (!permission || !commands)
707
+ throw new Error("Permission presets are unavailable in this profile");
708
+ if (!permission.names.includes(preset))
709
+ throw new Error("Unknown permission preset");
710
+
711
+ // Use the official live /permission command, as DSH's own surfaces do. It writes session-owned
712
+ // permission/sandbox/approval events and queues the policy-change notice;
713
+ // the settings namespace and future-session default remain untouched.
714
+ const execution = await commands.execute(
715
+ agent,
716
+ `/permission ${preset}`,
717
+ [],
718
+ new AbortController().signal,
719
+ );
720
+ if (!execution || execution.result.kind !== "success") {
721
+ throw new Error(
722
+ execution?.result.text ?? "Permission preset command is unavailable",
723
+ );
724
+ }
725
+ if (permission.current(agent.session) !== preset)
726
+ throw new Error("Permission preset was not applied");
727
+ }
728
+
729
+ private respond(params: Record<string, unknown>): void {
730
+ const pending = this.interactions.get(
731
+ nonempty(params.requestId, "requestId"),
732
+ );
733
+ if (!pending) throw new Error("Interaction is no longer pending");
734
+ if (pending.request.kind === "approval") {
735
+ if (params.outcome !== "allowed-once" && params.outcome !== "rejected")
736
+ throw new Error("Invalid approval outcome");
737
+ pending.finish(params.outcome);
738
+ return;
739
+ }
740
+ if (params.outcome === "rejected") {
741
+ pending.finish();
742
+ return;
743
+ }
744
+ const questions = pending.request.questions ?? [];
745
+ validateAnswers(questions, params.answers);
746
+ pending.finish({ answers: params.answers });
747
+ }
748
+
749
+ private close(id: string): Promise<void> {
750
+ const previous = this.closing.get(id);
751
+ if (previous) return previous;
752
+ const task = (async () => {
753
+ await this.opening.get(id)?.catch(() => undefined);
754
+ this.commandAborts.get(id)?.abort();
755
+ for (const pending of this.interactions.values())
756
+ if (pending.request.sessionId === id) pending.finish();
757
+ const record = this.owned.get(id);
758
+ if (record) await record.handle.dispose();
759
+ this.owned.delete(id);
760
+ this.tracked.delete(id);
761
+ this.preapproved.delete(id);
762
+ })();
763
+ this.closing.set(id, task);
764
+ void task.finally(() => this.closing.delete(id)).catch(() => undefined);
765
+ return task;
766
+ }
767
+
768
+ shutdown(): Promise<{}> {
769
+ this.stopping = true;
770
+ return (this.stopTask ??= (async () => {
771
+ for (const pending of this.interactions.values()) pending.finish();
772
+ await Promise.allSettled(this.opening.values());
773
+ const results = await Promise.allSettled(
774
+ [...this.owned.keys()].map((id) => this.close(id)),
775
+ );
776
+ for (const dispose of this.subscriptions.splice(0)) dispose();
777
+ if (results.some((result) => result.status === "rejected"))
778
+ throw new Error("One or more DSH sessions failed to close");
779
+ return {};
780
+ })());
781
+ }
782
+ }
783
+
784
+ export function validateAnswers(
785
+ questions: QuestionItem[],
786
+ answers: unknown,
787
+ ): asserts answers is QuestionAnswer[] {
788
+ if (!Array.isArray(answers) || answers.length !== questions.length)
789
+ throw new Error("Every question requires one answer");
790
+ const seen = new Set<string>();
791
+ for (const candidate of answers) {
792
+ const answer = record(candidate);
793
+ const question = questions.find((item) => item.id === answer.id);
794
+ if (
795
+ !question ||
796
+ typeof answer.id !== "string" ||
797
+ seen.has(answer.id) ||
798
+ !Array.isArray(answer.selected) ||
799
+ answer.selected.some(
800
+ (label: unknown) =>
801
+ typeof label !== "string" ||
802
+ !question.options?.some((option) => option.label === label),
803
+ ) ||
804
+ new Set(answer.selected).size !== answer.selected.length ||
805
+ (!question.multiSelect && answer.selected.length > 1) ||
806
+ (answer.custom !== undefined && typeof answer.custom !== "string") ||
807
+ (!answer.selected.length &&
808
+ !(typeof answer.custom === "string" && answer.custom.trim()))
809
+ )
810
+ throw new Error("Invalid question answer");
811
+ seen.add(answer.id);
812
+ }
813
+ }
814
+
815
+ const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
816
+
817
+ function validateContent(value: unknown): PromptPart[] {
818
+ if (!Array.isArray(value) || value.length === 0)
819
+ throw new Error("content must be a nonempty array");
820
+ const parts = value.map((raw): PromptPart => {
821
+ const part = record(raw);
822
+ if (part.type === "text" && typeof part.text === "string")
823
+ return { type: "text", text: part.text };
824
+ if (part.type === "image") {
825
+ if (typeof part.mediaType !== "string" || !IMAGE_TYPES.has(part.mediaType))
826
+ throw new Error(`Unsupported image type: ${String(part.mediaType)}`);
827
+ return {
828
+ type: "image",
829
+ mediaType: part.mediaType,
830
+ data: nonempty(part.data, "image data"),
831
+ ...(typeof part.name === "string" ? { name: part.name } : {}),
832
+ };
833
+ }
834
+ if (part.type === "file") {
835
+ const path = nonempty(part.path, "file path");
836
+ if (!isAbsolute(path)) throw new Error("file path must be absolute");
837
+ return {
838
+ type: "file",
839
+ path,
840
+ ...(typeof part.name === "string" ? { name: part.name } : {}),
841
+ };
842
+ }
843
+ throw new Error("Invalid prompt content");
844
+ });
845
+ if (!parts.some((part) => part.type !== "text" || part.text.trim()))
846
+ throw new Error("Prompt content is empty");
847
+ return parts;
848
+ }
849
+
850
+ function validateOpen(params: Record<string, unknown>): SessionOpenParams {
851
+ const result: SessionOpenParams = { cwd: nonempty(params.cwd, "cwd") };
852
+ if (params.sessionId !== undefined)
853
+ result.sessionId = nonempty(params.sessionId, "sessionId");
854
+ if (params.preset !== undefined)
855
+ result.preset = nonempty(params.preset, "preset");
856
+ if (params.permissionPreset !== undefined)
857
+ result.permissionPreset = nonempty(
858
+ params.permissionPreset,
859
+ "permissionPreset",
860
+ );
861
+ if (params.resume !== undefined) {
862
+ if (typeof params.resume !== "boolean")
863
+ throw new Error("resume must be boolean");
864
+ result.resume = params.resume;
865
+ }
866
+ if (params.model !== undefined) {
867
+ result.model = validateModel(params.model);
868
+ }
869
+ if (params.systemPrompt !== undefined)
870
+ result.systemPrompt = nonempty(params.systemPrompt, "systemPrompt");
871
+ if (params.preapprovedTools !== undefined) {
872
+ if (
873
+ !Array.isArray(params.preapprovedTools) ||
874
+ params.preapprovedTools.some((tool) => typeof tool !== "string" || !tool)
875
+ )
876
+ throw new Error("preapprovedTools must be an array of tool names");
877
+ result.preapprovedTools = params.preapprovedTools as string[];
878
+ }
879
+ return result;
880
+ }
881
+ function validateModel(value: unknown): ModelSelection {
882
+ const model = selection(value);
883
+ const effort = record(value).reasoningEffort;
884
+ if (
885
+ !model ||
886
+ (effort !== undefined && (typeof effort !== "string" || !effort))
887
+ )
888
+ throw new Error("Invalid model selection");
889
+ return model;
890
+ }