paseo-dsh-direct 0.2.13 → 0.2.15
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 +1 -0
- package/client/settings-screen.tsx +18 -19
- package/client/settings-section.tsx +22 -0
- package/client/subagent-settings.tsx +98 -63
- package/package.json +5 -4
- package/server/dsh/bridge.ts +1 -0
- package/server/dsh/host.ts +41 -0
- package/server/dsh/runtime-safety.ts +244 -0
- package/server/dsh/service.ts +46 -4
- package/server/provider.ts +81 -1
- package/shared/bridge-protocol.ts +4 -0
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
|
|
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import { useSettings } from "@getpaseo/plugin/client";
|
|
1
|
+
import { useSettings, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
2
|
import { ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
3
|
-
import { SettingsAction, SettingsInput, SettingsRow
|
|
3
|
+
import { SettingsAction, SettingsInput, SettingsRow } from "@getpaseo/plugin/client/ui";
|
|
4
|
+
import SettingsSection from "./settings-section.js";
|
|
4
5
|
import { useRef } from "react";
|
|
5
6
|
import { DSH_SETTINGS } from "../shared/settings.js";
|
|
6
7
|
import SubagentSettings from "./subagent-settings.js";
|
|
7
8
|
|
|
8
|
-
const EXECUTABLE_HINT =
|
|
9
|
-
|
|
10
|
-
const PROFILE_HINT =
|
|
11
|
-
'Name of the dsh profile to launch. PASEO_DSH_PROFILE overrides this; leave it empty to use the "paseo" profile.';
|
|
9
|
+
const EXECUTABLE_HINT = "DSH command or path; environment takes priority";
|
|
10
|
+
const PROFILE_HINT = "Default: paseo, unless set by environment";
|
|
12
11
|
|
|
13
12
|
/** Edits the dsh executable and profile that the server reads when it launches a bridge. */
|
|
14
|
-
export default function DshSettingsScreen() {
|
|
13
|
+
export default function DshSettingsScreen({ theme }: PluginSurfaceProps) {
|
|
15
14
|
const settings = useSettings(DSH_SETTINGS);
|
|
16
15
|
const draftState = useRef<{ revision: string | undefined; edits: { executable?: string; profile?: string } }>({
|
|
17
16
|
revision: undefined,
|
|
@@ -21,7 +20,7 @@ export default function DshSettingsScreen() {
|
|
|
21
20
|
if (settings.status === "loading") {
|
|
22
21
|
return (
|
|
23
22
|
<ScrollView>
|
|
24
|
-
<SettingsSection title="
|
|
23
|
+
<SettingsSection title="Connection" theme={theme}>
|
|
25
24
|
<SettingsRow label="Loading settings" />
|
|
26
25
|
</SettingsSection>
|
|
27
26
|
</ScrollView>
|
|
@@ -30,9 +29,9 @@ export default function DshSettingsScreen() {
|
|
|
30
29
|
if (settings.status === "error") {
|
|
31
30
|
return (
|
|
32
31
|
<ScrollView>
|
|
33
|
-
<SettingsSection title="
|
|
32
|
+
<SettingsSection title="Connection" theme={theme}>
|
|
34
33
|
<SettingsRow label="Settings unavailable" error={settings.error} />
|
|
35
|
-
<SettingsAction label="
|
|
34
|
+
<SettingsAction label="Load settings" actionLabel="Retry" onPress={() => void settings.reload()} />
|
|
36
35
|
</SettingsSection>
|
|
37
36
|
</ScrollView>
|
|
38
37
|
);
|
|
@@ -40,9 +39,9 @@ export default function DshSettingsScreen() {
|
|
|
40
39
|
if (settings.status === "invalid") {
|
|
41
40
|
return (
|
|
42
41
|
<ScrollView>
|
|
43
|
-
<SettingsSection title="
|
|
44
|
-
<SettingsRow label="
|
|
45
|
-
<SettingsAction label="
|
|
42
|
+
<SettingsSection title="Connection" theme={theme}>
|
|
43
|
+
<SettingsRow label="Saved settings are invalid" error={settings.error} hint="Reset to use default settings" />
|
|
44
|
+
<SettingsAction label="Restore defaults" actionLabel="Reset" onPress={() => void settings.reset()} disabled={settings.saving} />
|
|
46
45
|
</SettingsSection>
|
|
47
46
|
</ScrollView>
|
|
48
47
|
);
|
|
@@ -65,10 +64,10 @@ export default function DshSettingsScreen() {
|
|
|
65
64
|
|
|
66
65
|
return (
|
|
67
66
|
<ScrollView>
|
|
68
|
-
<SettingsSection title="
|
|
67
|
+
<SettingsSection title="Connection" theme={theme}>
|
|
69
68
|
<SettingsInput
|
|
70
69
|
key={`executable-${revision}`}
|
|
71
|
-
label="
|
|
70
|
+
label="Executable"
|
|
72
71
|
hint={EXECUTABLE_HINT}
|
|
73
72
|
placeholder="dsh"
|
|
74
73
|
initialValue={values.executable ?? ""}
|
|
@@ -79,7 +78,7 @@ export default function DshSettingsScreen() {
|
|
|
79
78
|
/>
|
|
80
79
|
<SettingsInput
|
|
81
80
|
key={`profile-${revision}`}
|
|
82
|
-
label="
|
|
81
|
+
label="Profile"
|
|
83
82
|
hint={PROFILE_HINT}
|
|
84
83
|
placeholder="paseo"
|
|
85
84
|
initialValue={values.profile ?? ""}
|
|
@@ -89,15 +88,15 @@ export default function DshSettingsScreen() {
|
|
|
89
88
|
disabled={settings.saving}
|
|
90
89
|
/>
|
|
91
90
|
<SettingsAction
|
|
92
|
-
label="
|
|
93
|
-
hint=
|
|
91
|
+
label="Save settings"
|
|
92
|
+
hint="Applies to new sessions"
|
|
94
93
|
error={settings.saveError}
|
|
95
94
|
actionLabel={settings.saving ? "Saving" : "Save"}
|
|
96
95
|
onPress={save}
|
|
97
96
|
disabled={settings.saving}
|
|
98
97
|
/>
|
|
99
98
|
</SettingsSection>
|
|
100
|
-
<SubagentSettings launchRevision={revision} launchSaving={settings.saving} />
|
|
99
|
+
<SubagentSettings theme={theme} launchRevision={revision} launchSaving={settings.saving} />
|
|
101
100
|
</ScrollView>
|
|
102
101
|
);
|
|
103
102
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
import { Text, View } from "react-native";
|
|
4
|
+
|
|
5
|
+
/** Keep section titles more prominent than field labels in every host theme. */
|
|
6
|
+
export default function SettingsSection({ title, theme, children }: {
|
|
7
|
+
title: string;
|
|
8
|
+
theme: PluginSurfaceProps["theme"];
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<View style={{ marginBottom: 24 }}>
|
|
13
|
+
<Text
|
|
14
|
+
accessibilityRole="header"
|
|
15
|
+
style={{ color: theme.colors.foreground, fontSize: 18, fontWeight: "600", marginHorizontal: 16, marginBottom: 8 }}
|
|
16
|
+
>
|
|
17
|
+
{title}
|
|
18
|
+
</Text>
|
|
19
|
+
<View>{children}</View>
|
|
20
|
+
</View>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
-
import { SettingsAction, SettingsInput, SettingsRow,
|
|
1
|
+
import { useRpc, type PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import { Modal, SettingsAction, SettingsInput, SettingsRow, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
+
import SettingsSection from "./settings-section.js";
|
|
5
6
|
import {
|
|
6
7
|
readSubagentSettings,
|
|
7
8
|
saveSubagentSettings,
|
|
@@ -30,7 +31,8 @@ function errorMessage(error: unknown): string {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/** Keeps edits tied to the exact profile and revision returned by the daemon. */
|
|
33
|
-
export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
34
|
+
export default function SubagentSettings({ theme, launchRevision, launchSaving }: {
|
|
35
|
+
theme: PluginSurfaceProps["theme"];
|
|
34
36
|
launchRevision: string;
|
|
35
37
|
launchSaving: boolean;
|
|
36
38
|
}) {
|
|
@@ -47,6 +49,7 @@ export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
|
47
49
|
});
|
|
48
50
|
const [draft, setDraft] = useState<Draft | null>(null);
|
|
49
51
|
const [search, setSearch] = useState("");
|
|
52
|
+
const [editorOpen, setEditorOpen] = useState(false);
|
|
50
53
|
const dirty = draft !== null && !sameSelection(draft.selection, draft.view.selection);
|
|
51
54
|
// A background read or a saved launch-setting change must never replace pending edits.
|
|
52
55
|
const activeDraft = dirty ? draft : null;
|
|
@@ -88,13 +91,12 @@ export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
|
88
91
|
|
|
89
92
|
if (!view || !selection) {
|
|
90
93
|
return (
|
|
91
|
-
<SettingsSection title="
|
|
94
|
+
<SettingsSection theme={theme} title="Subagents">
|
|
92
95
|
<SettingsRow
|
|
93
96
|
label={query.isError ? "Could not load subagent models" : "Loading subagent models"}
|
|
94
97
|
error={query.isError ? errorMessage(query.error) : undefined}
|
|
95
|
-
hint="Models and saved choices come from the active dsh profile."
|
|
96
98
|
/>
|
|
97
|
-
{query.isError && <SettingsAction label="Load models
|
|
99
|
+
{query.isError && <SettingsAction label="Load models" actionLabel="Retry" onPress={refresh} disabled={busy} />}
|
|
98
100
|
</SettingsSection>
|
|
99
101
|
);
|
|
100
102
|
}
|
|
@@ -134,70 +136,66 @@ export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
|
134
136
|
edit({ ...selection, allowedModels: [...routes.values()] });
|
|
135
137
|
};
|
|
136
138
|
|
|
137
|
-
|
|
139
|
+
// Older clients expose the same UI module without Modal; keep their inline editor.
|
|
140
|
+
const hasModal = typeof Modal !== "undefined" && Modal.Content != null;
|
|
141
|
+
const feedback = (
|
|
138
142
|
<>
|
|
139
|
-
|
|
143
|
+
{conflict && (
|
|
144
|
+
<SettingsRow label="Settings changed elsewhere" hint="Discard your edits and reload the saved settings" />
|
|
145
|
+
)}
|
|
146
|
+
{query.isError && (
|
|
147
|
+
<SettingsRow label="Could not refresh models" error={errorMessage(query.error)} hint={dirty ? "Your edits are kept; discard and refresh to retry" : "The previous list is shown; refresh to retry"} />
|
|
148
|
+
)}
|
|
149
|
+
{launchChanged && (
|
|
150
|
+
<SettingsRow label="Connection settings changed" error="These edits belong to the previous connection. Discard and refresh before saving." />
|
|
151
|
+
)}
|
|
152
|
+
{!view.supported && (
|
|
153
|
+
<SettingsRow
|
|
154
|
+
label="Model selection unavailable"
|
|
155
|
+
hint={view.reason ?? "Choose a profile that supports model selection, then refresh."}
|
|
156
|
+
/>
|
|
157
|
+
)}
|
|
158
|
+
{view.failedProviders.length > 0 && (
|
|
140
159
|
<SettingsRow
|
|
141
|
-
label=
|
|
142
|
-
hint="
|
|
160
|
+
label="Some providers could not load models"
|
|
161
|
+
hint={`${view.failedProviders.join(", ")} · Saved choices kept; refresh to retry`}
|
|
143
162
|
/>
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
)}
|
|
163
|
+
)}
|
|
164
|
+
{view.supported && !view.initialized && (
|
|
165
|
+
<SettingsRow label="No saved selection" hint="Choose models and save, or check your providers if the list is empty" />
|
|
166
|
+
)}
|
|
167
|
+
{missingCount > 0 && (
|
|
168
|
+
<SettingsRow label={`${missingCount} selected ${missingCount === 1 ? "model is" : "models are"} missing from this catalog`} hint="Saved choices are kept unless you remove them" />
|
|
169
|
+
)}
|
|
170
|
+
</>
|
|
171
|
+
);
|
|
172
|
+
const editor = (
|
|
173
|
+
<>
|
|
174
|
+
<SettingsSection theme={theme} title="Selection">
|
|
175
|
+
<SettingsRow
|
|
176
|
+
label={`${selection.allowedModels.length} selected · ${view.models.length} available · Profile: ${view.profile}`}
|
|
177
|
+
hint="Closing keeps your unsaved changes"
|
|
178
|
+
error={emptyEnabled ? "Select at least one model, or turn off Use selected models." : undefined}
|
|
179
|
+
/>
|
|
180
|
+
{feedback}
|
|
181
|
+
</SettingsSection>
|
|
182
|
+
<SettingsSection theme={theme} title="Model selection">
|
|
165
183
|
<SettingsSwitch
|
|
166
|
-
label="
|
|
167
|
-
hint="
|
|
184
|
+
label="Use selected models"
|
|
185
|
+
hint="Turn off to use the profile’s default behavior"
|
|
168
186
|
value={selection.enabled}
|
|
169
187
|
onValueChange={(enabled) => edit({ ...selection, enabled })}
|
|
170
188
|
disabled={busy || !view.supported}
|
|
171
189
|
/>
|
|
172
|
-
<
|
|
173
|
-
label={`${selection.allowedModels.length} selected · ${view.models.length} available`}
|
|
174
|
-
hint={missingCount > 0 ? `${missingCount} selected model(s) are not in the current catalog. You can keep or remove them below.` : "New models are never added to your saved selection automatically."}
|
|
175
|
-
error={emptyEnabled ? "Select at least one model, or turn off model selection before saving." : undefined}
|
|
176
|
-
/>
|
|
177
|
-
<SettingsInput label="Search models" placeholder="Name, model ID, or provider" initialValue={search} onChangeText={setSearch} />
|
|
178
|
-
<SettingsAction
|
|
179
|
-
label="Select all available models"
|
|
180
|
-
hint="Includes all providers, even models hidden by search. Keeps saved models that are currently unavailable."
|
|
181
|
-
actionLabel="Select all"
|
|
182
|
-
onPress={selectAvailable}
|
|
183
|
-
disabled={busy || !view.supported || view.models.length === 0 || view.models.every((model) => selectedKeys.has(subagentRouteKey(model)))}
|
|
184
|
-
/>
|
|
185
|
-
<SettingsAction label="Clear model selection" hint="Clears every selection, including models hidden by search." actionLabel="Clear" onPress={() => edit({ ...selection, allowedModels: [] })} disabled={busy || !view.supported || selectedKeys.size === 0} />
|
|
186
|
-
<SettingsAction
|
|
187
|
-
label={query.isFetching ? "Refreshing models" : "Refresh models"}
|
|
188
|
-
hint={dirty ? "Save or discard your changes before refreshing." : "Reload the catalog and saved selection for the active profile."}
|
|
189
|
-
actionLabel={query.isError ? "Retry" : "Refresh"}
|
|
190
|
-
onPress={refresh}
|
|
191
|
-
disabled={busy || dirty}
|
|
192
|
-
/>
|
|
190
|
+
<SettingsInput label="Search" placeholder="Name, ID or provider" initialValue={search} onChangeText={setSearch} />
|
|
193
191
|
</SettingsSection>
|
|
194
192
|
{[...groups].sort(([left], [right]) => left.localeCompare(right)).map(([provider, group]) => (
|
|
195
|
-
<SettingsSection key={provider} title={`${group.name} (${group.rows.filter((model) => selectedKeys.has(subagentRouteKey(model))).length}/${group.rows.length}${term ? " matching" : ""})`}>
|
|
193
|
+
<SettingsSection theme={theme} key={provider} title={`${group.name} (${group.rows.filter((model) => selectedKeys.has(subagentRouteKey(model))).length}/${group.rows.length}${term ? " matching" : ""})`}>
|
|
196
194
|
{group.rows.sort((left, right) => left.name.localeCompare(right.name) || left.model.localeCompare(right.model)).map((model) => (
|
|
197
195
|
<SettingsSwitch
|
|
198
196
|
key={subagentRouteKey(model)}
|
|
199
197
|
label={model.name}
|
|
200
|
-
hint={
|
|
198
|
+
hint={model.name === model.model ? (model.available ? undefined : "Not in this catalog") : `${model.model}${model.available ? "" : " · Not in this catalog"}`}
|
|
201
199
|
value={selectedKeys.has(subagentRouteKey(model))}
|
|
202
200
|
onValueChange={(checked) => toggle(model, checked)}
|
|
203
201
|
disabled={busy || !view.supported}
|
|
@@ -205,15 +203,32 @@ export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
|
205
203
|
))}
|
|
206
204
|
</SettingsSection>
|
|
207
205
|
))}
|
|
208
|
-
{groups.size === 0 && (
|
|
209
|
-
<SettingsSection title="Models">
|
|
210
|
-
<SettingsRow label={term ? "No models
|
|
206
|
+
{view.supported && groups.size === 0 && (
|
|
207
|
+
<SettingsSection theme={theme} title="Models">
|
|
208
|
+
<SettingsRow label={term ? "No matching models" : "No models available"} hint={term ? "Try a different name, ID or provider" : "Check your providers, then refresh"} />
|
|
211
209
|
</SettingsSection>
|
|
212
210
|
)}
|
|
213
|
-
<SettingsSection title="
|
|
211
|
+
<SettingsSection theme={theme} title="All models">
|
|
212
|
+
<SettingsAction
|
|
213
|
+
label="Available models"
|
|
214
|
+
hint="Includes models hidden by your search"
|
|
215
|
+
actionLabel="Select all"
|
|
216
|
+
onPress={selectAvailable}
|
|
217
|
+
disabled={busy || !view.supported || view.models.length === 0 || view.models.every((model) => selectedKeys.has(subagentRouteKey(model)))}
|
|
218
|
+
/>
|
|
219
|
+
<SettingsAction label="Selected models" hint="Clears every choice, including models not shown" actionLabel="Clear" onPress={() => edit({ ...selection, allowedModels: [] })} disabled={busy || !view.supported || selectedKeys.size === 0} />
|
|
220
|
+
<SettingsAction
|
|
221
|
+
label={query.isFetching ? "Refreshing models" : "Refresh models"}
|
|
222
|
+
hint={dirty ? "Save or discard edits first" : undefined}
|
|
223
|
+
actionLabel={query.isError ? "Retry" : "Refresh"}
|
|
224
|
+
onPress={refresh}
|
|
225
|
+
disabled={busy || dirty}
|
|
226
|
+
/>
|
|
227
|
+
</SettingsSection>
|
|
228
|
+
<SettingsSection theme={theme} title="Changes">
|
|
214
229
|
<SettingsAction
|
|
215
|
-
label={save.isSuccess ? "
|
|
216
|
-
hint=
|
|
230
|
+
label={save.isSuccess ? "Settings saved" : "Save selection"}
|
|
231
|
+
hint="Applies to new sessions"
|
|
217
232
|
error={saveError}
|
|
218
233
|
actionLabel={save.isPending ? "Saving" : "Save"}
|
|
219
234
|
onPress={() => {
|
|
@@ -222,8 +237,28 @@ export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
|
222
237
|
}}
|
|
223
238
|
disabled={!dirty || busy || emptyEnabled || conflict || launchChanged || !view.supported || query.isError}
|
|
224
239
|
/>
|
|
225
|
-
<SettingsAction label="
|
|
240
|
+
<SettingsAction label="Reload saved settings" hint="Discards your unsaved changes" actionLabel="Discard and refresh" onPress={discardAndRefresh} disabled={busy || (!dirty && !save.isError)} />
|
|
241
|
+
</SettingsSection>
|
|
242
|
+
</>
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return (
|
|
246
|
+
<>
|
|
247
|
+
<SettingsSection theme={theme} title="Subagents">
|
|
248
|
+
<SettingsAction
|
|
249
|
+
label={`Model selection: ${selection.enabled ? "On" : "Off"}${save.isPending ? " · Saving…" : dirty ? " · Unsaved changes" : ""}`}
|
|
250
|
+
hint={`${selection.allowedModels.length} selected · ${view.models.length} available`}
|
|
251
|
+
actionLabel={!hasModal && editorOpen ? "Close editor" : "Edit models"}
|
|
252
|
+
onPress={() => setEditorOpen((value) => hasModal || !value)}
|
|
253
|
+
error={emptyEnabled ? "Select at least one model, or turn off Use selected models." : saveError}
|
|
254
|
+
/>
|
|
255
|
+
{feedback}
|
|
226
256
|
</SettingsSection>
|
|
257
|
+
{hasModal ? (
|
|
258
|
+
<Modal title="Subagent models" open={editorOpen} onOpenChange={setEditorOpen}>
|
|
259
|
+
<Modal.Content>{editor}</Modal.Content>
|
|
260
|
+
</Modal>
|
|
261
|
+
) : editorOpen ? editor : null}
|
|
227
262
|
</>
|
|
228
263
|
);
|
|
229
264
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-dsh-direct",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.15",
|
|
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.
|
|
37
|
-
"@getpaseo/protocol": "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.
|
|
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",
|
|
@@ -67,6 +67,7 @@
|
|
|
67
67
|
"mdast-util-from-markdown": "^2.0.3",
|
|
68
68
|
"mdast-util-gfm": "^3.1.0",
|
|
69
69
|
"micromark-extension-gfm": "^3.0.0",
|
|
70
|
+
"react-native": "0.81.5",
|
|
70
71
|
"tsx": "^4.22.4",
|
|
71
72
|
"typescript": "^5.9.3",
|
|
72
73
|
"zod": "^4.4.3"
|
package/server/dsh/bridge.ts
CHANGED
package/server/dsh/host.ts
CHANGED
|
@@ -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
|
+
}
|
package/server/dsh/service.ts
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/server/provider.ts
CHANGED
|
@@ -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
|
|
|
@@ -216,13 +224,22 @@ function createConnection(
|
|
|
216
224
|
async send(input) {
|
|
217
225
|
if (closed) throw new Error("Provider connection is closed");
|
|
218
226
|
validateAdmission(input, sessions, capabilities);
|
|
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
|
+
}
|
|
219
234
|
const pending = Promise.resolve().then(async () => {
|
|
220
235
|
if (closed) return;
|
|
221
236
|
await dispatch(input, {
|
|
222
237
|
discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion, launchEnv,
|
|
223
238
|
openingBridges, closeBridge, isClosed: () => closed,
|
|
224
239
|
});
|
|
225
|
-
}).catch((error) => failInput(input, error, emit))
|
|
240
|
+
}).catch((error) => failInput(input, error, emit)).finally(() => {
|
|
241
|
+
if (operationSession) operationSession.pendingOperations -= 1;
|
|
242
|
+
});
|
|
226
243
|
if (input.type === "session.open") {
|
|
227
244
|
pendingOpens.add(pending);
|
|
228
245
|
void pending.then(() => pendingOpens.delete(pending));
|
|
@@ -265,6 +282,7 @@ function listenToSessionBridge(
|
|
|
265
282
|
return [
|
|
266
283
|
session.bridge.on("session.event", ({ sessionId, event }) => {
|
|
267
284
|
if (!isCurrentSession(sessionId)) return;
|
|
285
|
+
session.activityRevision += 1;
|
|
268
286
|
// The prompt path emits its own bubble with the clientMessageId.
|
|
269
287
|
if (event.type === "user/message" && session.dispatch?.kind === "prompt") return;
|
|
270
288
|
if (event.type === "turn/start") {
|
|
@@ -309,10 +327,12 @@ function listenToSessionBridge(
|
|
|
309
327
|
}),
|
|
310
328
|
session.bridge.on("session.stream", ({ sessionId, frame }) => {
|
|
311
329
|
if (!isCurrentSession(sessionId)) return;
|
|
330
|
+
session.activityRevision += 1;
|
|
312
331
|
for (const projected of session.projector.projectStream(frame)) emit(projected);
|
|
313
332
|
}),
|
|
314
333
|
session.bridge.on("session.status", ({ sessionId, status }) => {
|
|
315
334
|
if (!isCurrentSession(sessionId)) return;
|
|
335
|
+
session.activityRevision += 1;
|
|
316
336
|
if (status === "running") {
|
|
317
337
|
if (session.dispatch) session.dispatch.sawRunning = true;
|
|
318
338
|
return;
|
|
@@ -325,6 +345,7 @@ function listenToSessionBridge(
|
|
|
325
345
|
}),
|
|
326
346
|
session.bridge.on("session.usage", ({ sessionId, usage }) => {
|
|
327
347
|
if (!isCurrentSession(sessionId)) return;
|
|
348
|
+
session.activityRevision += 1;
|
|
328
349
|
emit({
|
|
329
350
|
type: "session.usage",
|
|
330
351
|
sessionId: session.paseoId,
|
|
@@ -334,6 +355,7 @@ function listenToSessionBridge(
|
|
|
334
355
|
}),
|
|
335
356
|
session.bridge.on("interaction.request", (interaction) => {
|
|
336
357
|
if (!isCurrentSession(interaction.sessionId)) return;
|
|
358
|
+
session.activityRevision += 1;
|
|
337
359
|
session.pendingInteractions.set(interaction.requestId, interaction);
|
|
338
360
|
emit({
|
|
339
361
|
type: "session.permission",
|
|
@@ -343,6 +365,7 @@ function listenToSessionBridge(
|
|
|
343
365
|
}),
|
|
344
366
|
session.bridge.on("interaction.closed", ({ requestId, sessionId }) => {
|
|
345
367
|
if (!isCurrentSession(sessionId) || !session.pendingInteractions.delete(requestId)) return;
|
|
368
|
+
session.activityRevision += 1;
|
|
346
369
|
emit({
|
|
347
370
|
type: "session.permission_resolved",
|
|
348
371
|
sessionId: session.paseoId,
|
|
@@ -424,6 +447,9 @@ async function dispatch(input: ProviderInput, state: DispatchState): Promise<voi
|
|
|
424
447
|
case "session.configure":
|
|
425
448
|
await enqueue(requireSession(state.sessions, input.sessionId), () => configureSession(input, state));
|
|
426
449
|
return;
|
|
450
|
+
case "session.can_stop_runtime":
|
|
451
|
+
await canStopRuntime(input, state);
|
|
452
|
+
return;
|
|
427
453
|
case "session.close":
|
|
428
454
|
await closeSession(input, state);
|
|
429
455
|
return;
|
|
@@ -529,6 +555,9 @@ async function openSession(
|
|
|
529
555
|
planMode: latestPlanMode(opened.events),
|
|
530
556
|
pendingInteractions: new Map(),
|
|
531
557
|
lane: Promise.resolve(),
|
|
558
|
+
pendingOperations: 0,
|
|
559
|
+
activityRevision: 0,
|
|
560
|
+
ready: false,
|
|
532
561
|
closed: false,
|
|
533
562
|
};
|
|
534
563
|
state.sessions.set(input.sessionId, session);
|
|
@@ -569,6 +598,8 @@ async function openSession(
|
|
|
569
598
|
session.projector.seed(session.open.events);
|
|
570
599
|
}
|
|
571
600
|
await warnOnDshVersionMismatch(session, state);
|
|
601
|
+
if (state.isClosed() || session.closed) return;
|
|
602
|
+
session.ready = true;
|
|
572
603
|
state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
|
573
604
|
}
|
|
574
605
|
|
|
@@ -825,6 +856,54 @@ async function respondToPermission(
|
|
|
825
856
|
}
|
|
826
857
|
}
|
|
827
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
|
+
|
|
828
907
|
async function closeSession(
|
|
829
908
|
input: Extract<ProviderInput, { type: "session.close" }>,
|
|
830
909
|
state: DispatchState,
|
|
@@ -860,6 +939,7 @@ function validateAdmission(
|
|
|
860
939
|
if (sessions.has(input.sessionId)) throw new Error(`Session already exists: ${input.sessionId}`);
|
|
861
940
|
return;
|
|
862
941
|
}
|
|
942
|
+
if (input.type === "session.can_stop_runtime") return;
|
|
863
943
|
if ("sessionId" in input && !sessions.has(input.sessionId)) {
|
|
864
944
|
throw new Error(`Unknown session: ${input.sessionId}`);
|
|
865
945
|
}
|
|
@@ -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: {
|