paseo-dsh-direct 0.2.12 → 0.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -14,6 +14,7 @@ Runs DeepSeek Harness (`dsh`) as a native Paseo agent provider. Paseo clients (d
14
14
  - Create, resume, cancel and close sessions; model list and mode switching from the Paseo UI.
15
15
  - Token usage and context-window fill in Paseo's usage meter.
16
16
  - DSH branding (name and icon) in the provider list.
17
+ - On Paseo 0.14, answers the host's idle runtime cleanup query and refuses cleanup while supported DSH checks find pending work (agents, inboxes, maintenance, background jobs, subagent completion delivery, goals, reminders). After the host releases a persisted session runtime, the next message resumes the same native DSH session.
17
18
 
18
19
  ## Processes and environment
19
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-dsh-direct",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
4
4
  "description": "DeepSeek Harness Direct Provider Plugin for Paseo",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,8 +33,8 @@
33
33
  "!server/generated-version.ts"
34
34
  ],
35
35
  "peerDependencies": {
36
- "@getpaseo/client": "0.12.0",
37
- "@getpaseo/protocol": "0.12.0",
36
+ "@getpaseo/client": "0.14.0",
37
+ "@getpaseo/protocol": "0.14.0",
38
38
  "react": "~19.1.0",
39
39
  "react-native": ">=0.81.5",
40
40
  "zod": "^4.4.3"
@@ -57,7 +57,7 @@
57
57
  }
58
58
  },
59
59
  "devDependencies": {
60
- "@getpaseo/plugin": "0.12.0",
60
+ "@getpaseo/plugin": "0.14.0",
61
61
  "@tanstack/react-query": "^5.105.0",
62
62
  "@types/mdast": "^4.0.4",
63
63
  "@types/node": "^24.0.0",
@@ -45,6 +45,7 @@ const METHODS = new Set<keyof BridgeMethods>([
45
45
  "session.commands",
46
46
  "session.command",
47
47
  "session.cancel",
48
+ "session.can_stop_runtime",
48
49
  "interaction.respond",
49
50
  ]);
50
51
 
@@ -30,6 +30,10 @@ export interface HostAgent {
30
30
  id: string;
31
31
  ctx: HostContext;
32
32
  session: HostSession;
33
+ inbox?: {
34
+ readonly nextTurn: readonly unknown[];
35
+ readonly nextStep: readonly unknown[];
36
+ };
33
37
  options: ModelSelection;
34
38
  status: "idle" | "running";
35
39
  followup(message: unknown): void;
@@ -92,6 +96,8 @@ export interface HostServices {
92
96
  };
93
97
  agents: {
94
98
  get(id: string): HostAgent | undefined;
99
+ list?(): HostAgent[];
100
+ isOwnedBy?(id: string, owner: HostAgent): boolean;
95
101
  create(options: AgentOptions): Promise<HostHandle>;
96
102
  resume(options: AgentOptions): Promise<HostHandle>;
97
103
  };
@@ -199,6 +205,28 @@ export interface HostServices {
199
205
  | undefined
200
206
  >;
201
207
  };
208
+ jobs?: {
209
+ list(
210
+ caller?: string,
211
+ ): Array<{
212
+ id: string;
213
+ owner?: string;
214
+ status: "running" | "stopping" | "completed" | "killed" | "failed";
215
+ }>;
216
+ };
217
+ goals?: {
218
+ get(
219
+ agent: HostAgent,
220
+ ):
221
+ | {
222
+ phase?: string;
223
+ activation?: string;
224
+ }
225
+ | undefined;
226
+ };
227
+ schedule?: {
228
+ list(request: { sessionId: string }): Promise<readonly unknown[]>;
229
+ };
202
230
  userQuestions: object | undefined;
203
231
  appReady: { onReady(listener: () => void): () => void } | undefined;
204
232
  appExit: ((code: number) => void) | undefined;
@@ -216,6 +244,7 @@ export interface HostHelpers {
216
244
  export type ApprovalOutcome =
217
245
  "allowed-once" | "rejected" | "cancelled" | "unavailable";
218
246
  export interface HostEvents {
247
+ "schedule/changed": () => void;
219
248
  "session/created": (session: HostSession) => void;
220
249
  "session/event": (session: HostSession, event: DshSessionEvent) => void;
221
250
  "agent/assistant-stream": (payload: {
@@ -244,4 +273,16 @@ export interface HostEvents {
244
273
  },
245
274
  next: () => Promise<{ answers: QuestionAnswer[] }>,
246
275
  ) => Promise<{ answers: QuestionAnswer[] }>;
276
+ "subagent/start": (info: {
277
+ runId: string;
278
+ id: string;
279
+ provider?: string;
280
+ local?: boolean;
281
+ }) => void;
282
+ "subagent/end": (info: {
283
+ runId: string;
284
+ id: string;
285
+ provider?: string;
286
+ local?: boolean;
287
+ }) => void;
247
288
  }
@@ -0,0 +1,244 @@
1
+ import type { HostAgent, HostContext } from "./host.js";
2
+
3
+ /**
4
+ * Tracks published subagent runs between `subagent/start` and `subagent/end`.
5
+ *
6
+ * The subagent manager emits `subagent/end` only after an activation's
7
+ * teardown finishes, its completion notice is delivered to the parent inbox,
8
+ * and parent ownership is released. While a run is in this table, stopping the
9
+ * bridge would lose an external child or a local child's settlement notice.
10
+ */
11
+ export class SubagentRunTracker {
12
+ private readonly activeByRoot = new Map<string, Set<string>>();
13
+ private readonly rootByRun = new Map<string, string | undefined>();
14
+
15
+ /**
16
+ * Subscribe inside a root session's `setup(agentCtx)` so direct delegations
17
+ * of that root always resolve to `rootId`, even for external backends whose
18
+ * child id is not in `ctx.agents` or `session/created`.
19
+ */
20
+ observeRootScope(rootId: string, agentCtx: HostContext): void {
21
+ agentCtx.on("subagent/start", (info) => {
22
+ if (typeof info?.runId === "string" && info.runId) {
23
+ this.startRun(rootId, info.runId);
24
+ }
25
+ });
26
+ agentCtx.on("subagent/end", (info) => {
27
+ if (typeof info?.runId === "string" && info.runId) {
28
+ this.endRun(info.runId);
29
+ }
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Observe every process-level run, including external children whose parent
35
+ * is outside the bridge-owned session tree.
36
+ */
37
+ observeHost(
38
+ ctx: HostContext,
39
+ resolveRoot: (childId: string) => string | undefined,
40
+ ): Array<() => void> {
41
+ return [
42
+ ctx.on("subagent/start", (info) => {
43
+ if (typeof info?.runId !== "string" || !info.runId) return;
44
+ if (this.rootByRun.has(info.runId)) return;
45
+ const rootId =
46
+ typeof info.id === "string" ? resolveRoot(info.id) : undefined;
47
+ this.startRun(rootId, info.runId);
48
+ }),
49
+ ctx.on("subagent/end", (info) => {
50
+ if (typeof info?.runId === "string" && info.runId) {
51
+ this.endRun(info.runId);
52
+ }
53
+ }),
54
+ ];
55
+ }
56
+
57
+ hasActiveRuns(rootId?: string): boolean {
58
+ return rootId === undefined
59
+ ? this.rootByRun.size > 0
60
+ : (this.activeByRoot.get(rootId)?.size ?? 0) > 0;
61
+ }
62
+
63
+ clearRoot(rootId: string): void {
64
+ const runs = this.activeByRoot.get(rootId);
65
+ if (runs) {
66
+ for (const runId of runs) this.rootByRun.delete(runId);
67
+ this.activeByRoot.delete(rootId);
68
+ }
69
+ }
70
+
71
+ clearAll(): void {
72
+ this.activeByRoot.clear();
73
+ this.rootByRun.clear();
74
+ }
75
+
76
+ private startRun(rootId: string | undefined, runId: string): void {
77
+ const existing = this.rootByRun.get(runId);
78
+ if (existing && existing !== rootId) {
79
+ this.activeByRoot.get(existing)?.delete(runId);
80
+ }
81
+ this.rootByRun.set(runId, rootId);
82
+ if (rootId === undefined) return;
83
+ let set = this.activeByRoot.get(rootId);
84
+ if (!set) {
85
+ set = new Set();
86
+ this.activeByRoot.set(rootId, set);
87
+ }
88
+ set.add(runId);
89
+ }
90
+
91
+ private endRun(runId: string): void {
92
+ const rootId = this.rootByRun.get(runId);
93
+ this.rootByRun.delete(runId);
94
+ if (!rootId) return;
95
+ const set = this.activeByRoot.get(rootId);
96
+ if (!set) return;
97
+ set.delete(runId);
98
+ if (set.size === 0) this.activeByRoot.delete(rootId);
99
+ }
100
+ }
101
+
102
+ export interface RuntimeSafetyInput {
103
+ ctx: HostContext;
104
+ sessionId: string;
105
+ rootAgent: HostAgent | undefined;
106
+ hasBridgeActivity: () => boolean;
107
+ hasActiveSubagentRuns: () => boolean;
108
+ }
109
+
110
+ /**
111
+ * Decide whether releasing this DSH session runtime loses in-flight work.
112
+ *
113
+ * This query is strictly read-only: it never cancels, disposes, mutates, or
114
+ * leaves behind uncancelled listeners. Any missing required inspection surface
115
+ * or thrown error fails closed (`false`).
116
+ */
117
+ export async function canStopSessionRuntime(
118
+ input: RuntimeSafetyInput,
119
+ ): Promise<boolean> {
120
+ let disposeScheduleObserver: (() => void) | undefined;
121
+ try {
122
+ if (input.hasBridgeActivity() || input.hasActiveSubagentRuns()) return false;
123
+ const root = input.rootAgent;
124
+ if (!root || String(root.id) !== input.sessionId) return false;
125
+ const agents = input.ctx.get("agents");
126
+ const jobs = input.ctx.get("jobs");
127
+ const goals = input.ctx.get("goals");
128
+ const schedule = input.ctx.get("schedule");
129
+ if (
130
+ !agents ||
131
+ typeof agents.get !== "function" ||
132
+ typeof agents.list !== "function" ||
133
+ !jobs ||
134
+ typeof jobs.list !== "function" ||
135
+ (goals && typeof goals.get !== "function") ||
136
+ (schedule && typeof schedule.list !== "function")
137
+ ) {
138
+ return false;
139
+ }
140
+
141
+ let scheduleChanged = false;
142
+ if (schedule) {
143
+ disposeScheduleObserver = input.ctx.on("schedule/changed", () => {
144
+ scheduleChanged = true;
145
+ });
146
+ }
147
+ // Stopping the bridge exits the entire DSH process, including agents
148
+ // started by the profile or schedule runtime outside this session's tree.
149
+ const readAgents = (): HostAgent[] => {
150
+ if (agents.get(input.sessionId) !== root) return [];
151
+ const live = agents.list!();
152
+ return Array.isArray(live) && live.includes(root) ? live : [];
153
+ };
154
+ const liveAgents = readAgents();
155
+ if (liveAgents.length === 0 || !(await areAgentsQuiescent(liveAgents))) return false;
156
+ if (schedule) {
157
+ for (const agent of liveAgents) {
158
+ const active = await schedule.list({ sessionId: String(agent.id) });
159
+ if (!Array.isArray(active) || active.length > 0) return false;
160
+ }
161
+ }
162
+
163
+ // Schedule reads yield to arbitrary runtime work. Probe maintenance again,
164
+ // then re-read every synchronous veto before returning the decision.
165
+ if (!(await areAgentsQuiescent(liveAgents))) return false;
166
+ const currentAgents = readAgents();
167
+ if (
168
+ scheduleChanged ||
169
+ input.hasBridgeActivity() ||
170
+ input.hasActiveSubagentRuns() ||
171
+ currentAgents.length !== liveAgents.length ||
172
+ currentAgents.some((agent) => !liveAgents.includes(agent))
173
+ ) {
174
+ return false;
175
+ }
176
+ for (const agent of liveAgents) {
177
+ if (!isAgentSynchronouslyIdle(agent)) return false;
178
+ const goal = goals?.get(agent);
179
+ if (goal?.phase === "active" && goal.activation === "armed") return false;
180
+ }
181
+ // No caller filter: jobs can outlive their owner agent's registry entry.
182
+ const views = jobs.list();
183
+ if (
184
+ !Array.isArray(views) || views.some(
185
+ (job) => job?.status === "running" || job?.status === "stopping",
186
+ )
187
+ ) return false;
188
+ return true;
189
+ } catch {
190
+ return false;
191
+ } finally {
192
+ try {
193
+ disposeScheduleObserver?.();
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+ }
199
+
200
+ function isAgentSynchronouslyIdle(agent: HostAgent): boolean {
201
+ if (agent.status !== "idle") return false;
202
+ const inbox = agent.inbox;
203
+ if (
204
+ !inbox ||
205
+ !Array.isArray(inbox.nextTurn) ||
206
+ !Array.isArray(inbox.nextStep)
207
+ ) {
208
+ return false;
209
+ }
210
+ return inbox.nextTurn.length === 0 && inbox.nextStep.length === 0;
211
+ }
212
+
213
+ // Repeated safety queries during a long maintenance job must not attach another
214
+ // whenIdle continuation to the same unfinished activity on every request.
215
+ const idleObservations = new WeakMap<
216
+ HostAgent,
217
+ { state: "pending" | "idle" | "failed" }
218
+ >();
219
+
220
+ async function areAgentsQuiescent(agents: readonly HostAgent[]): Promise<boolean> {
221
+ const observations = [];
222
+ for (const agent of agents) {
223
+ if (!isAgentSynchronouslyIdle(agent) || typeof agent.whenIdle !== "function") {
224
+ return false;
225
+ }
226
+ let observation = idleObservations.get(agent);
227
+ if (!observation || observation.state !== "pending") {
228
+ observation = { state: "pending" };
229
+ idleObservations.set(agent, observation);
230
+ const current = observation;
231
+ void Promise.resolve(agent.whenIdle()).then(
232
+ () => { current.state = "idle"; },
233
+ () => { current.state = "failed"; },
234
+ );
235
+ }
236
+ observations.push(observation);
237
+ }
238
+ // DSH's async whenIdle awaits activityDone and checks that its identity has
239
+ // not changed. Allow that resolved-promise path to finish without waiting for
240
+ // pending maintenance, timers, or attaching listeners to a long-lived race.
241
+ await Promise.resolve().then(() => undefined).then(() => undefined);
242
+ return observations.every((observation) => observation.state === "idle") &&
243
+ agents.every(isAgentSynchronouslyIdle);
244
+ }
@@ -19,6 +19,7 @@ import type {
19
19
  SlashCommand,
20
20
  } from "../../shared/bridge-protocol.js";
21
21
  import { SubagentSettings } from "./subagent-settings.js";
22
+ import { canStopSessionRuntime, SubagentRunTracker } from "./runtime-safety.js";
22
23
  import { slashLine } from "../../shared/bridge-protocol.js";
23
24
  import type {
24
25
  ApprovalOutcome,
@@ -147,6 +148,7 @@ export class BridgeService {
147
148
  private stopTask?: Promise<{}>;
148
149
  private readonly subagentSettings: SubagentSettings;
149
150
  private subagentSettingsInitializationError?: Error;
151
+ private readonly subagentRuns = new SubagentRunTracker();
150
152
 
151
153
  constructor(
152
154
  private readonly ctx: HostContext,
@@ -157,11 +159,12 @@ export class BridgeService {
157
159
  this.subagentSettings = new SubagentSettings(ctx, profile);
158
160
  this.subscriptions.push(
159
161
  ctx.on("session/created", (session) => {
160
- if (session.header.parentSession)
162
+ if (session.header.parentSession) {
161
163
  this.parent.set(
162
164
  String(session.id),
163
165
  String(session.header.parentSession),
164
166
  );
167
+ }
165
168
  }),
166
169
  );
167
170
  this.subscriptions.push(
@@ -237,6 +240,11 @@ export class BridgeService {
237
240
  });
238
241
  }),
239
242
  );
243
+ this.subscriptions.push(
244
+ ...this.subagentRuns.observeHost(ctx, (childId) =>
245
+ this.rootForId(childId),
246
+ ),
247
+ );
240
248
  }
241
249
 
242
250
  /**
@@ -316,13 +324,19 @@ export class BridgeService {
316
324
  }
317
325
 
318
326
  private rootFor(agent: HostAgent | undefined): string | undefined {
319
- let id = agent?.id && String(agent.id);
327
+ return agent && this.rootForId(String(agent.id), agent);
328
+ }
329
+
330
+ private rootForId(
331
+ sessionId: string,
332
+ agent = this.ctx.get("agents")?.get(sessionId),
333
+ ): string | undefined {
334
+ let id: string | undefined = sessionId;
320
335
  const seen = new Set<string>();
321
336
  while (id && !seen.has(id)) {
322
337
  if (this.tracked.has(id)) return id;
323
338
  seen.add(id);
324
- id =
325
- this.parent.get(id) ??
339
+ id = this.parent.get(id) ??
326
340
  (id === agent?.id ? agent.session.header.parentSession : undefined);
327
341
  }
328
342
  return undefined;
@@ -581,6 +595,13 @@ export class BridgeService {
581
595
  );
582
596
  result = {};
583
597
  break;
598
+ case "session.can_stop_runtime":
599
+ result = {
600
+ canStop: await this.canStopRuntime(
601
+ nonempty(params.sessionId, "sessionId"),
602
+ ),
603
+ };
604
+ break;
584
605
  case "session.close":
585
606
  await this.close(nonempty(params.sessionId, "sessionId"));
586
607
  result = {};
@@ -815,6 +836,7 @@ export class BridgeService {
815
836
  this.tracked.delete(id);
816
837
  this.preapproved.delete(id);
817
838
  this.lastUsage.delete(id);
839
+ this.subagentRuns.clearRoot(id);
818
840
  },
819
841
  );
820
842
  return task;
@@ -853,6 +875,7 @@ export class BridgeService {
853
875
  assembled: undefined,
854
876
  };
855
877
  const setup = async (agentCtx: HostContext) => {
878
+ this.subagentRuns.observeRootScope(id, agentCtx);
856
879
  this.helpers?.installModelSelection(agentCtx, modelRef);
857
880
  await presets.mount(agentCtx, preset.id);
858
881
  if (params.systemPrompt) {
@@ -950,6 +973,23 @@ export class BridgeService {
950
973
  pending.finish({ answers: params.answers });
951
974
  }
952
975
 
976
+ private canStopRuntime(sessionId: string): Promise<boolean> {
977
+ return canStopSessionRuntime({
978
+ ctx: this.ctx,
979
+ sessionId,
980
+ rootAgent: this.owned.get(sessionId)?.handle.agent,
981
+ hasBridgeActivity: () =>
982
+ this.stopping ||
983
+ this.opening.has(sessionId) ||
984
+ this.closing.has(sessionId) ||
985
+ this.commandAborts.has(sessionId) ||
986
+ [...this.interactions.values()].some(
987
+ (pending) => pending.request.sessionId === sessionId,
988
+ ),
989
+ hasActiveSubagentRuns: () => this.subagentRuns.hasActiveRuns(),
990
+ });
991
+ }
992
+
953
993
  private close(id: string): Promise<void> {
954
994
  const previous = this.closing.get(id);
955
995
  if (previous) return previous;
@@ -964,6 +1004,7 @@ export class BridgeService {
964
1004
  this.tracked.delete(id);
965
1005
  this.preapproved.delete(id);
966
1006
  this.lastUsage.delete(id);
1007
+ this.subagentRuns.clearRoot(id);
967
1008
  })();
968
1009
  this.closing.set(id, task);
969
1010
  void task.finally(() => this.closing.delete(id)).catch(() => undefined);
@@ -978,6 +1019,7 @@ export class BridgeService {
978
1019
  const results = await Promise.allSettled(
979
1020
  [...this.owned.keys()].map((id) => this.close(id)),
980
1021
  );
1022
+ this.subagentRuns.clearAll();
981
1023
  for (const dispose of this.subscriptions.splice(0)) dispose();
982
1024
  if (results.some((result) => result.status === "rejected"))
983
1025
  throw new Error("One or more DSH sessions failed to close");
@@ -48,6 +48,7 @@ const CAPABILITIES = [
48
48
  "prompt.command",
49
49
  "prompt.image",
50
50
  "prompt.steer",
51
+ "session.can_stop_runtime",
51
52
  "session.configure",
52
53
  "session.list",
53
54
  "session.persistence",
@@ -57,6 +58,8 @@ const CAPABILITIES = [
57
58
  ] as const;
58
59
  const PERMISSION_PRESET_SETTING = "permissionPreset";
59
60
  const PLAN_MODE_SETTING = "planMode";
61
+ // Leave room for the host's ten-second safety-query deadline and IPC overhead.
62
+ const RUNTIME_SAFETY_TIMEOUT_MS = 5_000;
60
63
 
61
64
  type DetectVersion = (
62
65
  executable: string,
@@ -104,6 +107,11 @@ interface SessionState {
104
107
  planMode: boolean;
105
108
  pendingInteractions: Map<string, InteractionRequest>;
106
109
  lane: Promise<void>;
110
+ /** Includes admitted input still waiting for its dispatch microtask or lane. */
111
+ pendingOperations: number;
112
+ /** Reject a safety snapshot if any input or native activity happened meanwhile. */
113
+ activityRevision: number;
114
+ ready: boolean;
107
115
  closed: boolean;
108
116
  }
109
117
 
@@ -188,7 +196,23 @@ function createConnection(
188
196
  ): ProviderConnection {
189
197
  const listeners = new Set<(event: ProviderEvent) => void>();
190
198
  const sessions = new Map<string, SessionState>();
199
+ const pendingOpens = new Set<Promise<void>>();
200
+ const openingBridges = new Set<DshBridge>();
201
+ const bridgeClosures = new WeakMap<DshBridge, Promise<void>>();
202
+ const cleanupErrors: unknown[] = [];
191
203
  let closed = false;
204
+ let closePromise: Promise<void> | undefined;
205
+ const closeBridge = (bridge: DshBridge): Promise<void> => {
206
+ let closing = bridgeClosures.get(bridge);
207
+ if (!closing) {
208
+ closing = Promise.resolve().then(() => bridge.close()).catch((error) => {
209
+ cleanupErrors.push(error);
210
+ throw error;
211
+ });
212
+ bridgeClosures.set(bridge, closing);
213
+ }
214
+ return closing;
215
+ };
192
216
  const emit = (event: ProviderEvent) => {
193
217
  if (closed) return;
194
218
  for (const listener of listeners) listener(event);
@@ -200,28 +224,50 @@ function createConnection(
200
224
  async send(input) {
201
225
  if (closed) throw new Error("Provider connection is closed");
202
226
  validateAdmission(input, sessions, capabilities);
203
- queueMicrotask(() => {
204
- void dispatch(input, { discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion, launchEnv }).catch(
205
- (error) => failInput(input, error, emit),
206
- );
227
+ const operationSession = input.type !== "session.can_stop_runtime" && "sessionId" in input
228
+ ? sessions.get(input.sessionId)
229
+ : undefined;
230
+ if (operationSession) {
231
+ operationSession.pendingOperations += 1;
232
+ operationSession.activityRevision += 1;
233
+ }
234
+ const pending = Promise.resolve().then(async () => {
235
+ if (closed) return;
236
+ await dispatch(input, {
237
+ discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion, launchEnv,
238
+ openingBridges, closeBridge, isClosed: () => closed,
239
+ });
240
+ }).catch((error) => failInput(input, error, emit)).finally(() => {
241
+ if (operationSession) operationSession.pendingOperations -= 1;
207
242
  });
243
+ if (input.type === "session.open") {
244
+ pendingOpens.add(pending);
245
+ void pending.then(() => pendingOpens.delete(pending));
246
+ }
208
247
  },
209
248
  onEvent(listener) {
210
249
  listeners.add(listener);
211
250
  return () => listeners.delete(listener);
212
251
  },
213
- async close() {
214
- if (closed) return;
252
+ close() {
253
+ if (closePromise) return closePromise;
215
254
  closed = true;
216
- const bridges = [discoveryBridge];
255
+ const bridges = new Set([discoveryBridge, ...openingBridges]);
217
256
  for (const session of sessions.values()) {
218
257
  session.closed = true;
219
258
  session.releaseBridgeListeners.forEach((release) => release());
220
- bridges.push(session.bridge);
259
+ bridges.add(session.bridge);
221
260
  }
222
261
  sessions.clear();
223
262
  listeners.clear();
224
- await Promise.all(bridges.map((bridge) => bridge.close()));
263
+ closePromise = (async () => {
264
+ await Promise.allSettled([
265
+ ...Array.from(bridges, closeBridge),
266
+ ...pendingOpens,
267
+ ]);
268
+ if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Could not close DSH bridges");
269
+ })();
270
+ return closePromise;
225
271
  },
226
272
  };
227
273
  }
@@ -236,6 +282,7 @@ function listenToSessionBridge(
236
282
  return [
237
283
  session.bridge.on("session.event", ({ sessionId, event }) => {
238
284
  if (!isCurrentSession(sessionId)) return;
285
+ session.activityRevision += 1;
239
286
  // The prompt path emits its own bubble with the clientMessageId.
240
287
  if (event.type === "user/message" && session.dispatch?.kind === "prompt") return;
241
288
  if (event.type === "turn/start") {
@@ -280,10 +327,12 @@ function listenToSessionBridge(
280
327
  }),
281
328
  session.bridge.on("session.stream", ({ sessionId, frame }) => {
282
329
  if (!isCurrentSession(sessionId)) return;
330
+ session.activityRevision += 1;
283
331
  for (const projected of session.projector.projectStream(frame)) emit(projected);
284
332
  }),
285
333
  session.bridge.on("session.status", ({ sessionId, status }) => {
286
334
  if (!isCurrentSession(sessionId)) return;
335
+ session.activityRevision += 1;
287
336
  if (status === "running") {
288
337
  if (session.dispatch) session.dispatch.sawRunning = true;
289
338
  return;
@@ -296,6 +345,7 @@ function listenToSessionBridge(
296
345
  }),
297
346
  session.bridge.on("session.usage", ({ sessionId, usage }) => {
298
347
  if (!isCurrentSession(sessionId)) return;
348
+ session.activityRevision += 1;
299
349
  emit({
300
350
  type: "session.usage",
301
351
  sessionId: session.paseoId,
@@ -305,6 +355,7 @@ function listenToSessionBridge(
305
355
  }),
306
356
  session.bridge.on("interaction.request", (interaction) => {
307
357
  if (!isCurrentSession(interaction.sessionId)) return;
358
+ session.activityRevision += 1;
308
359
  session.pendingInteractions.set(interaction.requestId, interaction);
309
360
  emit({
310
361
  type: "session.permission",
@@ -314,6 +365,7 @@ function listenToSessionBridge(
314
365
  }),
315
366
  session.bridge.on("interaction.closed", ({ requestId, sessionId }) => {
316
367
  if (!isCurrentSession(sessionId) || !session.pendingInteractions.delete(requestId)) return;
368
+ session.activityRevision += 1;
317
369
  emit({
318
370
  type: "session.permission_resolved",
319
371
  sessionId: session.paseoId,
@@ -363,6 +415,9 @@ interface DispatchState {
363
415
  capabilities: readonly string[];
364
416
  detectVersion: DetectVersion;
365
417
  launchEnv: Readonly<Record<string, string>> | undefined;
418
+ openingBridges: Set<DshBridge>;
419
+ closeBridge(bridge: DshBridge): Promise<void>;
420
+ isClosed(): boolean;
366
421
  }
367
422
 
368
423
  async function dispatch(input: ProviderInput, state: DispatchState): Promise<void> {
@@ -392,6 +447,9 @@ async function dispatch(input: ProviderInput, state: DispatchState): Promise<voi
392
447
  case "session.configure":
393
448
  await enqueue(requireSession(state.sessions, input.sessionId), () => configureSession(input, state));
394
449
  return;
450
+ case "session.can_stop_runtime":
451
+ await canStopRuntime(input, state);
452
+ return;
395
453
  case "session.close":
396
454
  await closeSession(input, state);
397
455
  return;
@@ -452,8 +510,10 @@ async function openSession(
452
510
  ephemeral: !input.config.persist,
453
511
  mcpServers: bridgeMcpServers(input.config.mcpServers),
454
512
  });
513
+ state.openingBridges.add(bridge);
455
514
  let session: SessionState | undefined;
456
515
  try {
516
+ if (state.isClosed()) return;
457
517
  // Resume from DSH's durable model and preset. Paseo fills catalog defaults into
458
518
  // open config, so forwarding them would accidentally overwrite native history.
459
519
  const selection = nativeId
@@ -473,11 +533,13 @@ async function openSession(
473
533
  ? { preapprovedTools: preapprovedTools(input.config) }
474
534
  : {}),
475
535
  });
536
+ if (state.isClosed()) return;
476
537
  const title = nativeId ? undefined : input.config.title?.trim();
477
538
  const [commands] = await Promise.all([
478
539
  fetchCommands(bridge, opened.sessionId),
479
540
  title ? bridge.request("session.rename", { sessionId: opened.sessionId, title }) : undefined,
480
541
  ]);
542
+ if (state.isClosed()) return;
481
543
  session = {
482
544
  paseoId: input.sessionId,
483
545
  nativeId: opened.sessionId,
@@ -493,6 +555,9 @@ async function openSession(
493
555
  planMode: latestPlanMode(opened.events),
494
556
  pendingInteractions: new Map(),
495
557
  lane: Promise.resolve(),
558
+ pendingOperations: 0,
559
+ activityRevision: 0,
560
+ ready: false,
496
561
  closed: false,
497
562
  };
498
563
  state.sessions.set(input.sessionId, session);
@@ -501,10 +566,18 @@ async function openSession(
501
566
  await setPlanMode(session, true);
502
567
  }
503
568
  } catch (error) {
504
- if (session) state.sessions.delete(input.sessionId);
505
- await bridge.close();
569
+ if (session) {
570
+ session.closed = true;
571
+ session.releaseBridgeListeners.forEach((release) => release());
572
+ state.sessions.delete(input.sessionId);
573
+ }
574
+ await state.closeBridge(bridge);
506
575
  throw error;
576
+ } finally {
577
+ state.openingBridges.delete(bridge);
578
+ if (state.isClosed()) await state.closeBridge(bridge);
507
579
  }
580
+ if (state.isClosed()) return;
508
581
  state.emit({
509
582
  type: "session.opened",
510
583
  requestId: input.requestId,
@@ -525,6 +598,8 @@ async function openSession(
525
598
  session.projector.seed(session.open.events);
526
599
  }
527
600
  await warnOnDshVersionMismatch(session, state);
601
+ if (state.isClosed() || session.closed) return;
602
+ session.ready = true;
528
603
  state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
529
604
  }
530
605
 
@@ -781,6 +856,54 @@ async function respondToPermission(
781
856
  }
782
857
  }
783
858
 
859
+ async function canStopRuntime(
860
+ input: Extract<ProviderInput, { type: "session.can_stop_runtime" }>,
861
+ state: DispatchState,
862
+ ): Promise<void> {
863
+ const session = requireSession(state.sessions, input.sessionId);
864
+ if (!isProviderSessionIdle(session)) {
865
+ state.emit({
866
+ type: "session.can_stop_runtime",
867
+ requestId: input.requestId,
868
+ sessionId: input.sessionId,
869
+ canStop: false,
870
+ });
871
+ return;
872
+ }
873
+ const revision = session.activityRevision;
874
+ const result = await session.bridge.request(
875
+ "session.can_stop_runtime",
876
+ { sessionId: session.nativeId },
877
+ RUNTIME_SAFETY_TIMEOUT_MS,
878
+ );
879
+ if (typeof result?.canStop !== "boolean") {
880
+ throw new Error("Invalid DSH runtime safety response");
881
+ }
882
+ const canStop =
883
+ result.canStop &&
884
+ state.sessions.get(input.sessionId) === session &&
885
+ session.activityRevision === revision &&
886
+ isProviderSessionIdle(session);
887
+ state.emit({
888
+ type: "session.can_stop_runtime",
889
+ requestId: input.requestId,
890
+ sessionId: input.sessionId,
891
+ canStop,
892
+ });
893
+ }
894
+
895
+ function isProviderSessionIdle(session: SessionState): boolean {
896
+ return (
897
+ session.ready &&
898
+ !session.closed &&
899
+ session.config.persist &&
900
+ !session.activeTurnId &&
901
+ !session.dispatch &&
902
+ session.pendingInteractions.size === 0 &&
903
+ session.pendingOperations === 0
904
+ );
905
+ }
906
+
784
907
  async function closeSession(
785
908
  input: Extract<ProviderInput, { type: "session.close" }>,
786
909
  state: DispatchState,
@@ -816,6 +939,7 @@ function validateAdmission(
816
939
  if (sessions.has(input.sessionId)) throw new Error(`Session already exists: ${input.sessionId}`);
817
940
  return;
818
941
  }
942
+ if (input.type === "session.can_stop_runtime") return;
819
943
  if ("sessionId" in input && !sessions.has(input.sessionId)) {
820
944
  throw new Error(`Unknown session: ${input.sessionId}`);
821
945
  }
@@ -1085,12 +1209,28 @@ function questionAnswers(
1085
1209
  return questions.map((question) => {
1086
1210
  const key = question.header ?? question.id;
1087
1211
  const raw = answers?.[key];
1088
- const selected = typeof raw === "string" ? raw.split(",").map((value) => value.trim()).filter(Boolean) : [];
1089
- const known = new Set((question.options ?? []).map((option) => option.label));
1090
- const custom = selected.find((value) => !known.has(value));
1212
+ if (typeof raw !== "string" || raw.length === 0) return { id: question.id, selected: [] };
1213
+ const labels = (question.options ?? []).map((option) => option.label);
1214
+ if (!question.multiSelect) {
1215
+ return labels.includes(raw)
1216
+ ? { id: question.id, selected: [raw] }
1217
+ : { id: question.id, selected: [], custom: raw };
1218
+ }
1219
+ // Paseo joins checked labels and the optional typed answer with ", ".
1220
+ // Match complete labels (longest first), keeping the remaining text intact.
1221
+ const candidates = labels.filter(Boolean).sort((left, right) => right.length - left.length);
1222
+ const selected: string[] = [];
1223
+ let custom = raw;
1224
+ while (custom.length > 0) {
1225
+ const label = candidates.find((value) =>
1226
+ !selected.includes(value) && (custom === value || custom.startsWith(`${value}, `)));
1227
+ if (label === undefined) break;
1228
+ selected.push(label);
1229
+ custom = custom === label ? "" : custom.slice(label.length + 2);
1230
+ }
1091
1231
  return {
1092
1232
  id: question.id,
1093
- selected: selected.filter((value) => known.has(value)),
1233
+ selected,
1094
1234
  ...(custom ? { custom } : {}),
1095
1235
  };
1096
1236
  });
@@ -480,32 +480,55 @@ function timeline(
480
480
 
481
481
  function toolDetail(name: string, args: JsonObject): ProviderToolCallDetail {
482
482
  const normalized = name.toLowerCase();
483
- if (/shell|bash|exec|command/u.test(normalized)) {
483
+
484
+ // DSH's built-in tools are matched by exact name. Substring patterns get the
485
+ // common ones wrong (`todo_write` is not a file write, `read_image` is not a
486
+ // text read), and they would also claim unrelated MCP tools.
487
+ if (normalized === "bash" || normalized === "pwsh") {
488
+ // An empty command during streaming keeps the card from showing the raw
489
+ // half-parsed JSON; the final frame fills it in.
484
490
  return {
485
491
  type: "shell",
486
- command: stringValue(args.command) ?? stringValue(args.cmd) ?? stringifyValue(args),
487
- cwd: stringValue(args.cwd),
492
+ command: stringValue(args.command) ?? "",
493
+ cwd: stringValue(args.workdir),
494
+ };
495
+ }
496
+ if (normalized === "read" || normalized === "read_image") {
497
+ return {
498
+ type: "read",
499
+ filePath: stringValue(args.file_path) ?? "",
500
+ offset: numberValue(args.offset),
501
+ limit: numberValue(args.limit),
488
502
  };
489
503
  }
490
- const filePath = stringValue(args.filePath) ?? stringValue(args.path) ?? "";
491
- if (/read/u.test(normalized)) {
492
- return { type: "read", filePath, offset: numberValue(args.offset), limit: numberValue(args.limit) };
504
+ if (normalized === "write") {
505
+ return {
506
+ type: "write",
507
+ filePath: stringValue(args.file_path) ?? "",
508
+ content: stringValue(args.content),
509
+ };
493
510
  }
494
- if (/edit|patch/u.test(normalized)) {
511
+ if (normalized === "edit") {
495
512
  return {
496
513
  type: "edit",
497
- filePath,
498
- oldString: stringValue(args.oldString) ?? stringValue(args.old_string),
499
- newString: stringValue(args.newString) ?? stringValue(args.new_string),
500
- unifiedDiff: stringValue(args.patch) ?? stringValue(args.diff),
514
+ filePath: stringValue(args.file_path) ?? "",
515
+ oldString: stringValue(args.old_string),
516
+ newString: stringValue(args.new_string),
501
517
  };
502
518
  }
503
- if (/write/u.test(normalized)) {
504
- return { type: "write", filePath, content: stringValue(args.content) };
519
+ if (normalized === "grep") {
520
+ return { type: "search", query: stringValue(args.pattern) ?? "", toolName: "grep" };
505
521
  }
506
- if (/search|grep|glob/u.test(normalized)) {
507
- return { type: "search", query: stringValue(args.query) ?? stringValue(args.pattern) ?? "" };
522
+ if (normalized === "glob") {
523
+ return { type: "search", query: stringValue(args.pattern) ?? "", toolName: "glob" };
508
524
  }
525
+ if (normalized === "web_fetch") {
526
+ return { type: "fetch", url: stringValue(args.url) ?? "" };
527
+ }
528
+
529
+ // Everything else, including MCP tools, keeps a generic detail carrying the
530
+ // raw arguments. Guessing a card type from the name would render MCP tools as
531
+ // file operations.
509
532
  return { type: "unknown", input: { raw: stringifyValue(args) }, output: {} };
510
533
  }
511
534
 
@@ -204,6 +204,10 @@ export interface BridgeMethods {
204
204
  result: {};
205
205
  };
206
206
  "session.cancel": { params: { sessionId: string }; result: {} };
207
+ "session.can_stop_runtime": {
208
+ params: { sessionId: string };
209
+ result: { canStop: boolean };
210
+ };
207
211
  "session.close": { params: { sessionId: string }; result: {} };
208
212
  "interaction.respond": {
209
213
  params: {