paseo-dsh-direct 0.2.11 → 0.2.13
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 -1
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/client/settings-screen.tsx +2 -0
- package/client/subagent-settings.tsx +229 -0
- package/index.server.ts +26 -1
- package/package.json +2 -1
- package/server/dsh/bridge.ts +2 -0
- package/server/dsh/host.ts +23 -0
- package/server/dsh/service.ts +21 -0
- package/server/dsh/subagent-settings.ts +179 -0
- package/server/provider.ts +81 -16
- package/server/subagent-settings.ts +66 -0
- package/server/timeline.ts +38 -15
- package/shared/bridge-protocol.ts +4 -0
- package/shared/subagent-settings.ts +60 -0
package/OVERVIEW.md
CHANGED
|
@@ -21,6 +21,6 @@ Runs DeepSeek Harness (`dsh`) as a native Paseo agent provider. Paseo clients (d
|
|
|
21
21
|
- **Executable lookup.** In order: the `PASEO_DSH_EXECUTABLE` environment variable, the executable field of the DeepSeek Harness settings screen, the Paseo setting `agents.providers.dsh-pi.command`, then `dsh` on `PATH`.
|
|
22
22
|
- **Profile.** The plugin launches `dsh --profile paseo`. If `<DSH_HOME>/profiles/paseo` does not exist, it is created once by running `dsh --profile paseo --from-default-profile web --dump-config`. `DSH_HOME` defaults to `~/.dsh`. Setting `PASEO_DSH_PROFILE`, or the profile field of the DeepSeek Harness settings screen (the variable wins), selects another existing profile instead; a missing one is reported as an error, not created.
|
|
23
23
|
- **Environment variables read by the plugin.** `PASEO_DSH_EXECUTABLE`, `PASEO_DSH_PROFILE`, and `DSH_HOME` (to locate the profile directory).
|
|
24
|
-
- **Settings screen.**
|
|
24
|
+
- **Settings screen.** Set the executable and profile, and search or select the models subagents may use. Changes apply to new sessions. When the DSH profile has no explicit subagent model settings, the plugin initially selects all currently available models and persists the choice in that profile. Existing choices are preserved; newly added models require selection. Profiles without writable subagent settings show this feature as unavailable.
|
|
25
25
|
- **Environment passed to `dsh`.** The child inherits the Paseo daemon's environment.
|
|
26
26
|
- **Network.** The plugin makes no network requests itself. Model API calls are made by `dsh` according to its own configuration.
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ In Paseo, open **Settings → Plugins → Install Plugin** and paste either the
|
|
|
13
13
|
| Your dsh | npm | Git |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `0.2.x` | `npm:paseo-dsh-direct` | `https://github.com/Jecvay/paseo-dsh-direct` |
|
|
16
|
-
| `0.1.x` | `npm:paseo-dsh-direct@dsh-0.1` | CLI only: `paseo plugin install https://github.com/Jecvay/paseo-dsh-direct --ref
|
|
16
|
+
| `0.1.x` | `npm:paseo-dsh-direct@dsh-0.1` | CLI only: `paseo plugin install https://github.com/Jecvay/paseo-dsh-direct --ref rel/0.1` |
|
|
17
17
|
|
|
18
18
|
To update, run on the daemon machine (the app's plugin menu has no update entry):
|
|
19
19
|
|
|
@@ -47,6 +47,8 @@ The provider supports streamed assistant and reasoning output, tool execution an
|
|
|
47
47
|
|
|
48
48
|
The plugin uses the DSH profile `paseo`. On first start it creates that profile from DSH's official `web` template (`~/.dsh/profiles/paseo`, or under `$DSH_HOME` when set). Model routes, the default model, and permission presets go in `~/.dsh/profiles/paseo/cordis.patch.yml`. The executable and the profile can be set on the **DeepSeek Harness** screen in Paseo's settings (fields `dsh executable` and `dsh profile`). The executable is chosen in this order: `PASEO_DSH_EXECUTABLE`, the settings screen, `agents.providers.dsh-pi.command` in the Paseo configuration, then `dsh` on `PATH`. The profile is chosen from `PASEO_DSH_PROFILE`, then the settings screen, then `paseo`; a profile named explicitly is not created automatically. Changed settings apply to new sessions; running sessions keep their `dsh`. If the Paseo Daemon has plugin loading disabled, enable `pluginsEnabled` in its configuration before installing the plugin.
|
|
49
49
|
|
|
50
|
+
The **Subagent models** section on that settings screen lets you search models by name or provider, select individual models, select all, clear the list, or disable model selection. On first initialization, the plugin selects all currently available models and saves the choice in the DSH profile. Existing choices, including an explicit disabled setting, are preserved; newly added models require selection. Changes apply to new sessions with a supported preset. See [settings behavior](docs/client-architecture.md#设置页).
|
|
51
|
+
|
|
50
52
|
If a native session is still held by another DSH client (such as a terminal UI), release it there before importing or resuming it from Paseo.
|
|
51
53
|
|
|
52
54
|
## Install from source
|
package/README.zh.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| 你的 dsh | npm | Git |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| `0.2.x` | `npm:paseo-dsh-direct` | `https://github.com/Jecvay/paseo-dsh-direct` |
|
|
16
|
-
| `0.1.x` | `npm:paseo-dsh-direct@dsh-0.1` | 只能用命令行:`paseo plugin install https://github.com/Jecvay/paseo-dsh-direct --ref
|
|
16
|
+
| `0.1.x` | `npm:paseo-dsh-direct@dsh-0.1` | 只能用命令行:`paseo plugin install https://github.com/Jecvay/paseo-dsh-direct --ref rel/0.1` |
|
|
17
17
|
|
|
18
18
|
更新插件在 daemon 所在机器上运行(App 的插件菜单里没有更新入口):
|
|
19
19
|
|
|
@@ -47,6 +47,8 @@ Provider 支持连续正文和思考输出、工具执行与审批、用户问
|
|
|
47
47
|
|
|
48
48
|
插件使用名为 `paseo` 的 DSH profile。首次启动时,插件用 DSH 官方的 `web` 模板自动创建它(位置是 `~/.dsh/profiles/paseo`;设置了 `$DSH_HOME` 时在其下)。模型路由、默认模型和权限预设写在 `~/.dsh/profiles/paseo/cordis.patch.yml`。可在 Paseo 设置里的 **DeepSeek Harness** 页(`dsh executable`、`dsh profile` 两项)指定可执行文件与 profile。可执行文件的选择顺序:`PASEO_DSH_EXECUTABLE`、设置页、Paseo 配置的 `agents.providers.dsh-pi.command`、`PATH` 里的 `dsh`。profile 的选择顺序:`PASEO_DSH_PROFILE`、设置页、默认的 `paseo`;显式指定的 profile 不会被自动创建。改动对新会话生效,已在运行的会话继续使用原来的 `dsh`。如果 Paseo Daemon 默认关闭插件加载,请先在配置中启用 `pluginsEnabled`。
|
|
49
49
|
|
|
50
|
+
同一设置页的 **Subagent models** 区域可以按名称或提供方搜索模型、逐项勾选、全选、清空或关闭选模。首次初始化会选中当前可用的全部模型并保存到 DSH profile;已有选择(包括显式关闭)会保留,后续新增模型需要手动勾选。改动对支持选模的 preset 的新会话生效,详见[设置页说明](docs/client-architecture.md#设置页)。
|
|
51
|
+
|
|
50
52
|
如果原生会话仍被另一个 DSH 客户端(如终端界面)占用,请先在那里释放,再从 Paseo 导入或恢复。
|
|
51
53
|
|
|
52
54
|
## 从源码安装
|
|
@@ -3,6 +3,7 @@ import { ScrollView } from "@getpaseo/plugin/client/react-native";
|
|
|
3
3
|
import { SettingsAction, SettingsInput, SettingsRow, SettingsSection } from "@getpaseo/plugin/client/ui";
|
|
4
4
|
import { useRef } from "react";
|
|
5
5
|
import { DSH_SETTINGS } from "../shared/settings.js";
|
|
6
|
+
import SubagentSettings from "./subagent-settings.js";
|
|
6
7
|
|
|
7
8
|
const EXECUTABLE_HINT =
|
|
8
9
|
"Path or command of dsh. PASEO_DSH_EXECUTABLE overrides this; leave it empty to use the Paseo provider command or dsh on PATH.";
|
|
@@ -96,6 +97,7 @@ export default function DshSettingsScreen() {
|
|
|
96
97
|
disabled={settings.saving}
|
|
97
98
|
/>
|
|
98
99
|
</SettingsSection>
|
|
100
|
+
<SubagentSettings launchRevision={revision} launchSaving={settings.saving} />
|
|
99
101
|
</ScrollView>
|
|
100
102
|
);
|
|
101
103
|
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { useRpc } from "@getpaseo/plugin/client";
|
|
2
|
+
import { SettingsAction, SettingsInput, SettingsRow, SettingsSection, SettingsSwitch } from "@getpaseo/plugin/client/ui";
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
readSubagentSettings,
|
|
7
|
+
saveSubagentSettings,
|
|
8
|
+
subagentRouteKey,
|
|
9
|
+
type SubagentRoute,
|
|
10
|
+
type SubagentSelection,
|
|
11
|
+
type SubagentSettingsView,
|
|
12
|
+
type SubagentSettingsSave,
|
|
13
|
+
} from "../shared/subagent-settings.js";
|
|
14
|
+
|
|
15
|
+
type Draft = {
|
|
16
|
+
view: SubagentSettingsView;
|
|
17
|
+
selection: SubagentSelection;
|
|
18
|
+
launchRevision: string;
|
|
19
|
+
};
|
|
20
|
+
type ModelRow = SubagentRoute & { name: string; providerName: string; available: boolean };
|
|
21
|
+
|
|
22
|
+
function sameSelection(left: SubagentSelection, right: SubagentSelection): boolean {
|
|
23
|
+
const keys = new Set(left.allowedModels.map(subagentRouteKey));
|
|
24
|
+
return left.enabled === right.enabled && keys.size === right.allowedModels.length &&
|
|
25
|
+
right.allowedModels.every((model) => keys.has(subagentRouteKey(model)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function errorMessage(error: unknown): string {
|
|
29
|
+
return error instanceof Error ? error.message : String(error);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Keeps edits tied to the exact profile and revision returned by the daemon. */
|
|
33
|
+
export default function SubagentSettings({ launchRevision, launchSaving }: {
|
|
34
|
+
launchRevision: string;
|
|
35
|
+
launchSaving: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const readSettings = useRpc(readSubagentSettings);
|
|
38
|
+
const saveSettings = useRpc(saveSubagentSettings);
|
|
39
|
+
const queryClient = useQueryClient();
|
|
40
|
+
const queryKey = ["dsh-subagent-settings", launchRevision];
|
|
41
|
+
const query = useQuery({
|
|
42
|
+
queryKey,
|
|
43
|
+
queryFn: () => readSettings({}),
|
|
44
|
+
refetchOnWindowFocus: false,
|
|
45
|
+
refetchOnReconnect: false,
|
|
46
|
+
retry: false,
|
|
47
|
+
});
|
|
48
|
+
const [draft, setDraft] = useState<Draft | null>(null);
|
|
49
|
+
const [search, setSearch] = useState("");
|
|
50
|
+
const dirty = draft !== null && !sameSelection(draft.selection, draft.view.selection);
|
|
51
|
+
// A background read or a saved launch-setting change must never replace pending edits.
|
|
52
|
+
const activeDraft = dirty ? draft : null;
|
|
53
|
+
const view = activeDraft?.view ?? query.data;
|
|
54
|
+
const selection = activeDraft?.selection ?? view?.selection;
|
|
55
|
+
const launchChanged = activeDraft !== null && activeDraft.launchRevision !== launchRevision;
|
|
56
|
+
const save = useMutation({
|
|
57
|
+
mutationFn: ({ input }: { input: SubagentSettingsSave; launchRevision: string }) => saveSettings(input),
|
|
58
|
+
onSuccess: (saved, submitted) => {
|
|
59
|
+
queryClient.setQueryData(["dsh-subagent-settings", submitted.launchRevision], saved);
|
|
60
|
+
setDraft(null);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
const saveError = save.error ? errorMessage(save.error) : undefined;
|
|
64
|
+
const conflict = saveError !== undefined && /changed|conflict|revision|stale/i.test(saveError);
|
|
65
|
+
const busy = save.isPending || query.isFetching || launchSaving;
|
|
66
|
+
|
|
67
|
+
const refresh = () => {
|
|
68
|
+
if (dirty || busy) return;
|
|
69
|
+
save.reset();
|
|
70
|
+
void query.refetch();
|
|
71
|
+
};
|
|
72
|
+
const discardAndRefresh = () => {
|
|
73
|
+
if (busy) return;
|
|
74
|
+
setDraft(null);
|
|
75
|
+
save.reset();
|
|
76
|
+
void query.refetch();
|
|
77
|
+
};
|
|
78
|
+
const edit = (next: SubagentSelection) => {
|
|
79
|
+
if (!view || busy) return;
|
|
80
|
+
// Keep a conflict visible until the user explicitly discards and refreshes.
|
|
81
|
+
if (!conflict) save.reset();
|
|
82
|
+
setDraft({
|
|
83
|
+
view,
|
|
84
|
+
selection: next,
|
|
85
|
+
launchRevision: activeDraft?.launchRevision ?? launchRevision,
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
if (!view || !selection) {
|
|
90
|
+
return (
|
|
91
|
+
<SettingsSection title="Subagent models">
|
|
92
|
+
<SettingsRow
|
|
93
|
+
label={query.isError ? "Could not load subagent models" : "Loading subagent models"}
|
|
94
|
+
error={query.isError ? errorMessage(query.error) : undefined}
|
|
95
|
+
hint="Models and saved choices come from the active dsh profile."
|
|
96
|
+
/>
|
|
97
|
+
{query.isError && <SettingsAction label="Load models again" actionLabel="Retry" onPress={refresh} disabled={busy} />}
|
|
98
|
+
</SettingsSection>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const selectedKeys = new Set(selection.allowedModels.map(subagentRouteKey));
|
|
103
|
+
const models = new Map<string, ModelRow>(view.models.map((model) => [
|
|
104
|
+
subagentRouteKey(model), { ...model, available: true },
|
|
105
|
+
]));
|
|
106
|
+
// Keep missing saved rows visible even after unchecking, until Save or Discard.
|
|
107
|
+
for (const route of [...view.selection.allowedModels, ...selection.allowedModels]) {
|
|
108
|
+
const key = subagentRouteKey(route);
|
|
109
|
+
if (!models.has(key)) {
|
|
110
|
+
models.set(key, { ...route, name: route.model, providerName: route.provider, available: false });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const term = search.trim().toLocaleLowerCase();
|
|
114
|
+
const groups = new Map<string, { name: string; rows: ModelRow[] }>();
|
|
115
|
+
for (const model of models.values()) {
|
|
116
|
+
if (term && ![model.name, model.model, model.provider, model.providerName]
|
|
117
|
+
.some((value) => value.toLocaleLowerCase().includes(term))) continue;
|
|
118
|
+
const group = groups.get(model.provider) ?? { name: model.providerName, rows: [] };
|
|
119
|
+
group.rows.push(model);
|
|
120
|
+
groups.set(model.provider, group);
|
|
121
|
+
}
|
|
122
|
+
const missingCount = selection.allowedModels.filter((route) => !models.get(subagentRouteKey(route))?.available).length;
|
|
123
|
+
const emptyEnabled = selection.enabled && selection.allowedModels.length === 0;
|
|
124
|
+
const toggle = (model: SubagentRoute, checked: boolean) => {
|
|
125
|
+
const key = subagentRouteKey(model);
|
|
126
|
+
const remaining = selection.allowedModels.filter((route) => subagentRouteKey(route) !== key);
|
|
127
|
+
edit({ ...selection, allowedModels: checked ? [...remaining, { provider: model.provider, model: model.model }] : remaining });
|
|
128
|
+
};
|
|
129
|
+
const selectAvailable = () => {
|
|
130
|
+
const routes = new Map(selection.allowedModels.map((route) => [subagentRouteKey(route), route]));
|
|
131
|
+
for (const model of view.models) {
|
|
132
|
+
routes.set(subagentRouteKey(model), { provider: model.provider, model: model.model });
|
|
133
|
+
}
|
|
134
|
+
edit({ ...selection, allowedModels: [...routes.values()] });
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<>
|
|
139
|
+
<SettingsSection title="Subagent models">
|
|
140
|
+
<SettingsRow
|
|
141
|
+
label={`Profile: ${view.profile}`}
|
|
142
|
+
hint="Saved changes apply only to new sessions. The first initialization selects all available models; models added later stay unselected until you choose them."
|
|
143
|
+
/>
|
|
144
|
+
{query.isError && (
|
|
145
|
+
<SettingsRow label="Could not refresh models" error={errorMessage(query.error)} hint="The last loaded list is still shown. Retry after discarding any pending edits." />
|
|
146
|
+
)}
|
|
147
|
+
{launchChanged && (
|
|
148
|
+
<SettingsRow label="dsh launch settings changed" error="Your model edits still belong to the previously loaded profile. Discard changes and refresh before saving." />
|
|
149
|
+
)}
|
|
150
|
+
{!view.supported && (
|
|
151
|
+
<SettingsRow
|
|
152
|
+
label="Subagent model selection is unavailable"
|
|
153
|
+
hint={view.reason ?? "The current dsh profile does not support subagent model selection. Choose a supported profile and refresh."}
|
|
154
|
+
/>
|
|
155
|
+
)}
|
|
156
|
+
{view.failedProviders.length > 0 && (
|
|
157
|
+
<SettingsRow
|
|
158
|
+
label="Some providers could not load models"
|
|
159
|
+
hint={`${view.failedProviders.join(", ")}. Their saved selections are preserved. Refresh to retry loading them.`}
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
{!view.initialized && (
|
|
163
|
+
<SettingsRow label="Model selection has not been initialized" hint="No initial selection has been saved. If the catalog is empty, check your providers and refresh once models are available." />
|
|
164
|
+
)}
|
|
165
|
+
<SettingsSwitch
|
|
166
|
+
label="Enable subagent model selection"
|
|
167
|
+
hint="Allow new sessions to choose subagent models from the selected list. Turning this off keeps your model choices."
|
|
168
|
+
value={selection.enabled}
|
|
169
|
+
onValueChange={(enabled) => edit({ ...selection, enabled })}
|
|
170
|
+
disabled={busy || !view.supported}
|
|
171
|
+
/>
|
|
172
|
+
<SettingsRow
|
|
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
|
+
/>
|
|
193
|
+
</SettingsSection>
|
|
194
|
+
{[...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" : ""})`}>
|
|
196
|
+
{group.rows.sort((left, right) => left.name.localeCompare(right.name) || left.model.localeCompare(right.model)).map((model) => (
|
|
197
|
+
<SettingsSwitch
|
|
198
|
+
key={subagentRouteKey(model)}
|
|
199
|
+
label={model.name}
|
|
200
|
+
hint={`${model.provider} / ${model.model}${model.available ? "" : " · Not in the current catalog; uncheck to remove from saved selection."}`}
|
|
201
|
+
value={selectedKeys.has(subagentRouteKey(model))}
|
|
202
|
+
onValueChange={(checked) => toggle(model, checked)}
|
|
203
|
+
disabled={busy || !view.supported}
|
|
204
|
+
/>
|
|
205
|
+
))}
|
|
206
|
+
</SettingsSection>
|
|
207
|
+
))}
|
|
208
|
+
{groups.size === 0 && (
|
|
209
|
+
<SettingsSection title="Models">
|
|
210
|
+
<SettingsRow label={term ? "No models match your search" : "No models are available"} hint={term ? "Try another name, model ID, or provider." : "Check your provider configuration, then refresh the catalog."} />
|
|
211
|
+
</SettingsSection>
|
|
212
|
+
)}
|
|
213
|
+
<SettingsSection title="Save subagent settings">
|
|
214
|
+
<SettingsAction
|
|
215
|
+
label={save.isSuccess ? "Subagent settings saved" : "Apply model selection"}
|
|
216
|
+
hint={conflict ? "Settings changed elsewhere. Your edits are still here; discard changes and refresh to load the latest version." : "Changes apply only to new sessions. Existing sessions keep their current model selection."}
|
|
217
|
+
error={saveError}
|
|
218
|
+
actionLabel={save.isPending ? "Saving" : "Save"}
|
|
219
|
+
onPress={() => {
|
|
220
|
+
if (!dirty || busy || emptyEnabled || conflict || launchChanged || !view.supported || query.isError) return;
|
|
221
|
+
save.mutate({ input: { profile: view.profile, revision: view.revision, selection }, launchRevision });
|
|
222
|
+
}}
|
|
223
|
+
disabled={!dirty || busy || emptyEnabled || conflict || launchChanged || !view.supported || query.isError}
|
|
224
|
+
/>
|
|
225
|
+
<SettingsAction label="Discard changes and reload saved selection" actionLabel="Discard and refresh" onPress={discardAndRefresh} disabled={busy || (!dirty && !save.isError)} />
|
|
226
|
+
</SettingsSection>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
}
|
package/index.server.ts
CHANGED
|
@@ -5,14 +5,39 @@ import { currentDshConfigured } from "./server/dsh-config.js";
|
|
|
5
5
|
import { defaultDshCommand } from "./server/dsh-spawn.js";
|
|
6
6
|
import { DSH_BRIDGE_SOURCE } from "./server/generated-bridge.js";
|
|
7
7
|
import { createDshProvider } from "./server/provider.js";
|
|
8
|
+
import type { ProviderLaunch } from "@getpaseo/plugin/server/provider";
|
|
9
|
+
import { createSubagentSettingsAccess } from "./server/subagent-settings.js";
|
|
10
|
+
import { readSubagentSettings, saveSubagentSettings } from "./shared/subagent-settings.js";
|
|
8
11
|
|
|
9
12
|
export default function contribute(server: PluginServerContext) {
|
|
10
13
|
const settings = server.registerSettings(DSH_SETTINGS);
|
|
11
14
|
// Read per call, not once: a changed setting applies to new sessions and new catalog discovery.
|
|
12
15
|
const configured = () => currentDshConfigured(process.env, settings);
|
|
16
|
+
let providerLaunch: ProviderLaunch | undefined;
|
|
17
|
+
const subagents = createSubagentSettingsAccess({
|
|
18
|
+
target: async () => ({ configured: await configured(), launch: providerLaunch }),
|
|
19
|
+
open: ({ configured: { executable, profile }, launch }) => launchDshBridge({
|
|
20
|
+
bridgeSource: DSH_BRIDGE_SOURCE, executable, profile, providerLaunch: launch,
|
|
21
|
+
}),
|
|
22
|
+
});
|
|
23
|
+
// Provider discovery supplies the same resolved command and environment used for sessions.
|
|
24
|
+
const refreshLaunch = async (paseo: import("@getpaseo/client").PaseoApi) => {
|
|
25
|
+
await paseo.providers.refresh({ providers: ["dsh-pi"] });
|
|
26
|
+
const result = await paseo.providers.listModels("dsh-pi");
|
|
27
|
+
if (result.error) throw new Error(result.error);
|
|
28
|
+
};
|
|
29
|
+
server.handle(readSubagentSettings, async (_input, { paseo }) => {
|
|
30
|
+
await refreshLaunch(paseo);
|
|
31
|
+
return subagents.read();
|
|
32
|
+
});
|
|
33
|
+
server.handle(saveSubagentSettings, async (input, { paseo }) => {
|
|
34
|
+
await refreshLaunch(paseo);
|
|
35
|
+
return subagents.save(input);
|
|
36
|
+
});
|
|
13
37
|
server.registerProvider(
|
|
14
38
|
createDshProvider({
|
|
15
39
|
configured,
|
|
40
|
+
observeLaunch: (launch) => { providerLaunch = launch; },
|
|
16
41
|
command: defaultDshCommand(process.env),
|
|
17
42
|
createBridge: async (launch) => {
|
|
18
43
|
const { executable, profile } = await configured();
|
|
@@ -20,5 +45,5 @@ export default function contribute(server: PluginServerContext) {
|
|
|
20
45
|
},
|
|
21
46
|
}),
|
|
22
47
|
);
|
|
23
|
-
return () =>
|
|
48
|
+
return () => subagents.dispose();
|
|
24
49
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paseo-dsh-direct",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "DeepSeek Harness Direct Provider Plugin for Paseo",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@getpaseo/plugin": "0.12.0",
|
|
61
|
+
"@tanstack/react-query": "^5.105.0",
|
|
61
62
|
"@types/mdast": "^4.0.4",
|
|
62
63
|
"@types/node": "^24.0.0",
|
|
63
64
|
"@types/react": "~19.2.0",
|
package/server/dsh/bridge.ts
CHANGED
|
@@ -32,6 +32,8 @@ export function safeError(error: unknown): string {
|
|
|
32
32
|
const METHODS = new Set<keyof BridgeMethods>([
|
|
33
33
|
"bridge.initialize",
|
|
34
34
|
"bridge.shutdown",
|
|
35
|
+
"subagent.settings.read",
|
|
36
|
+
"subagent.settings.save",
|
|
35
37
|
"session.list",
|
|
36
38
|
"session.read",
|
|
37
39
|
"session.open",
|
package/server/dsh/host.ts
CHANGED
|
@@ -66,7 +66,30 @@ interface AgentOptions {
|
|
|
66
66
|
agentOptions: ModelSelection;
|
|
67
67
|
setup(ctx: HostContext, agent: HostAgent): Promise<void>;
|
|
68
68
|
}
|
|
69
|
+
export interface HostSettingsDescriptor {
|
|
70
|
+
ns: string;
|
|
71
|
+
revision: number;
|
|
72
|
+
value: unknown;
|
|
73
|
+
base?: unknown;
|
|
74
|
+
user?: unknown;
|
|
75
|
+
}
|
|
76
|
+
export interface HostSettings {
|
|
77
|
+
readonly writable?: boolean;
|
|
78
|
+
readonly documentPath: string;
|
|
79
|
+
describe(options: { redactSecrets: true }): HostSettingsDescriptor[];
|
|
80
|
+
mutate(
|
|
81
|
+
ns: string,
|
|
82
|
+
ops: readonly { op: "set"; path: readonly string[]; value: unknown }[],
|
|
83
|
+
expectedRevision: number,
|
|
84
|
+
): Promise<void>;
|
|
85
|
+
}
|
|
69
86
|
export interface HostServices {
|
|
87
|
+
settings?: HostSettings;
|
|
88
|
+
configEditor?: {
|
|
89
|
+
configuration(): Array<{
|
|
90
|
+
entry: { options: { id: string; config?: unknown } };
|
|
91
|
+
}>;
|
|
92
|
+
};
|
|
70
93
|
agents: {
|
|
71
94
|
get(id: string): HostAgent | undefined;
|
|
72
95
|
create(options: AgentOptions): Promise<HostHandle>;
|
package/server/dsh/service.ts
CHANGED
|
@@ -18,6 +18,7 @@ import type {
|
|
|
18
18
|
PromptPart,
|
|
19
19
|
SlashCommand,
|
|
20
20
|
} from "../../shared/bridge-protocol.js";
|
|
21
|
+
import { SubagentSettings } from "./subagent-settings.js";
|
|
21
22
|
import { slashLine } from "../../shared/bridge-protocol.js";
|
|
22
23
|
import type {
|
|
23
24
|
ApprovalOutcome,
|
|
@@ -144,6 +145,8 @@ export class BridgeService {
|
|
|
144
145
|
private readonly titles = new Map<string, string>();
|
|
145
146
|
private stopping = false;
|
|
146
147
|
private stopTask?: Promise<{}>;
|
|
148
|
+
private readonly subagentSettings: SubagentSettings;
|
|
149
|
+
private subagentSettingsInitializationError?: Error;
|
|
147
150
|
|
|
148
151
|
constructor(
|
|
149
152
|
private readonly ctx: HostContext,
|
|
@@ -151,6 +154,7 @@ export class BridgeService {
|
|
|
151
154
|
private readonly profile = "paseo",
|
|
152
155
|
private readonly helpers?: HostHelpers,
|
|
153
156
|
) {
|
|
157
|
+
this.subagentSettings = new SubagentSettings(ctx, profile);
|
|
154
158
|
this.subscriptions.push(
|
|
155
159
|
ctx.on("session/created", (session) => {
|
|
156
160
|
if (session.header.parentSession)
|
|
@@ -453,6 +457,15 @@ export class BridgeService {
|
|
|
453
457
|
}, catalog: { models: [], presets: [] }, missing } satisfies BridgeInitializeResult;
|
|
454
458
|
break;
|
|
455
459
|
}
|
|
460
|
+
try {
|
|
461
|
+
await this.subagentSettings.initialize();
|
|
462
|
+
this.subagentSettingsInitializationError = undefined;
|
|
463
|
+
} catch (error) {
|
|
464
|
+
this.subagentSettingsInitializationError = new Error(
|
|
465
|
+
`Subagent settings initialization failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
466
|
+
);
|
|
467
|
+
console.error(this.subagentSettingsInitializationError.message);
|
|
468
|
+
}
|
|
456
469
|
result = {
|
|
457
470
|
protocolVersion: 1,
|
|
458
471
|
profile: this.profile,
|
|
@@ -467,6 +480,14 @@ export class BridgeService {
|
|
|
467
480
|
} satisfies BridgeInitializeResult;
|
|
468
481
|
break;
|
|
469
482
|
}
|
|
483
|
+
case "subagent.settings.read":
|
|
484
|
+
if (this.subagentSettingsInitializationError) throw this.subagentSettingsInitializationError;
|
|
485
|
+
result = await this.subagentSettings.read();
|
|
486
|
+
break;
|
|
487
|
+
case "subagent.settings.save":
|
|
488
|
+
result = await this.subagentSettings.save(params);
|
|
489
|
+
this.subagentSettingsInitializationError = undefined;
|
|
490
|
+
break;
|
|
470
491
|
case "session.list":
|
|
471
492
|
result = { sessions: await this.listSessions(listParams(params)) };
|
|
472
493
|
break;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
SubagentRoute,
|
|
4
|
+
SubagentSelection,
|
|
5
|
+
SubagentSettingsSave,
|
|
6
|
+
SubagentSettingsView,
|
|
7
|
+
} from "../../shared/subagent-settings.js";
|
|
8
|
+
import type { HostContext, HostServices, HostSettings, HostSettingsDescriptor } from "./host.js";
|
|
9
|
+
|
|
10
|
+
const NAMESPACE = "subagent-model-selection-settings";
|
|
11
|
+
type Catalog = Pick<SubagentSettingsView, "models" | "failedProviders">;
|
|
12
|
+
interface Snapshot {
|
|
13
|
+
settings: HostSettings;
|
|
14
|
+
descriptor: HostSettingsDescriptor;
|
|
15
|
+
initialized: boolean;
|
|
16
|
+
selection: SubagentSelection;
|
|
17
|
+
revision: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function record(value: unknown): Record<string, unknown> {
|
|
21
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
22
|
+
throw new Error("Invalid subagent settings object");
|
|
23
|
+
return value as Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
function routeKey(route: SubagentRoute): string {
|
|
26
|
+
return JSON.stringify([route.provider, route.model]);
|
|
27
|
+
}
|
|
28
|
+
function validateSelection(input: unknown): SubagentSelection {
|
|
29
|
+
const value = record(input);
|
|
30
|
+
if (typeof value.enabled !== "boolean" || !Array.isArray(value.allowedModels))
|
|
31
|
+
throw new Error("Subagent settings require enabled and allowedModels");
|
|
32
|
+
const allowedModels = value.allowedModels.map((input): SubagentRoute => {
|
|
33
|
+
const route = record(input);
|
|
34
|
+
if (typeof route.provider !== "string" || !route.provider.trim() ||
|
|
35
|
+
typeof route.model !== "string" || !route.model.trim())
|
|
36
|
+
throw new Error("Subagent model routes require nonempty provider and model ids");
|
|
37
|
+
return { provider: route.provider, model: route.model };
|
|
38
|
+
});
|
|
39
|
+
if (new Set(allowedModels.map(routeKey)).size !== allowedModels.length)
|
|
40
|
+
throw new Error("A subagent model may only be selected once");
|
|
41
|
+
if (value.enabled && allowedModels.length === 0)
|
|
42
|
+
throw new Error("Select at least one model before enabling model selection");
|
|
43
|
+
return { enabled: value.enabled, allowedModels };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Object insertion order and process-local revisions must not invalidate tokens
|
|
47
|
+
// carried between the short-lived read and save bridges. Array order is meaningful.
|
|
48
|
+
function canonical(value: unknown): unknown {
|
|
49
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
50
|
+
if (value !== null && typeof value === "object")
|
|
51
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
52
|
+
.map(([key, child]) => [key, canonical(child)]));
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Accesses only native DSH services; the shared module is a type-only boundary. */
|
|
57
|
+
export class SubagentSettings {
|
|
58
|
+
constructor(private readonly ctx: HostContext, private readonly profile: string) {}
|
|
59
|
+
|
|
60
|
+
private snapshot(): Snapshot | undefined {
|
|
61
|
+
let settings: HostSettings | undefined;
|
|
62
|
+
let editor: HostServices["configEditor"];
|
|
63
|
+
try {
|
|
64
|
+
settings = this.ctx.get("settings");
|
|
65
|
+
editor = this.ctx.get("configEditor");
|
|
66
|
+
} catch {
|
|
67
|
+
// Older profiles can omit these optional services entirely.
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
if (!settings || typeof settings.describe !== "function" ||
|
|
71
|
+
typeof settings.mutate !== "function" || settings.writable === false ||
|
|
72
|
+
!editor || !("configuration" in editor) || typeof editor.configuration !== "function")
|
|
73
|
+
return undefined;
|
|
74
|
+
const descriptor = settings.describe({ redactSecrets: true }).find(row => row.ns === NAMESPACE);
|
|
75
|
+
const entry = editor.configuration().find(row => row.entry.options.id === NAMESPACE)?.entry;
|
|
76
|
+
if (!descriptor || !entry) return undefined;
|
|
77
|
+
if (!Number.isSafeInteger(descriptor.revision) || descriptor.revision < 0)
|
|
78
|
+
throw new Error("Invalid DSH settings revision");
|
|
79
|
+
const raw = entry.options.config ?? {};
|
|
80
|
+
const config = record(raw);
|
|
81
|
+
const initialized = Object.hasOwn(config, "enabled") || Object.hasOwn(config, "allowedModels");
|
|
82
|
+
const selection = validateSelection(descriptor.value);
|
|
83
|
+
const revision = createHash("sha256").update(JSON.stringify(canonical({
|
|
84
|
+
profile: this.profile,
|
|
85
|
+
documentPath: settings.documentPath,
|
|
86
|
+
raw,
|
|
87
|
+
value: descriptor.value,
|
|
88
|
+
base: descriptor.base,
|
|
89
|
+
user: descriptor.user,
|
|
90
|
+
}))).digest("hex");
|
|
91
|
+
return { settings, descriptor, initialized, selection, revision };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private unsupported(): SubagentSettingsView {
|
|
95
|
+
return {
|
|
96
|
+
supported: false, profile: this.profile,
|
|
97
|
+
reason: "This DSH profile does not provide writable subagent model settings.",
|
|
98
|
+
revision: "", initialized: false,
|
|
99
|
+
selection: { enabled: false, allowedModels: [] }, models: [], failedProviders: [],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private async catalog(): Promise<Catalog> {
|
|
104
|
+
const llm = this.ctx.get("llm");
|
|
105
|
+
const models: Catalog["models"] = [];
|
|
106
|
+
const failedProviders: string[] = [];
|
|
107
|
+
const seen = new Set<string>();
|
|
108
|
+
for (const provider of llm.listProviders()) {
|
|
109
|
+
try {
|
|
110
|
+
for (const model of await llm.listModels(provider.id)) {
|
|
111
|
+
const route = { provider: provider.id, model: model.id };
|
|
112
|
+
const key = routeKey(route);
|
|
113
|
+
if (seen.has(key)) continue;
|
|
114
|
+
seen.add(key);
|
|
115
|
+
models.push({ ...route, name: model.name ?? model.id, providerName: provider.name ?? provider.id });
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
failedProviders.push(provider.id);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { models, failedProviders };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private view(snapshot: Snapshot, catalog: Catalog): SubagentSettingsView {
|
|
125
|
+
return {
|
|
126
|
+
supported: true, profile: this.profile,
|
|
127
|
+
revision: snapshot.revision, initialized: snapshot.initialized,
|
|
128
|
+
selection: snapshot.selection, ...catalog,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private async mutate(snapshot: Snapshot, selection: SubagentSelection): Promise<void> {
|
|
133
|
+
await snapshot.settings.mutate(NAMESPACE, [
|
|
134
|
+
{ op: "set", path: ["enabled"], value: selection.enabled },
|
|
135
|
+
{ op: "set", path: ["allowedModels"], value: selection.allowedModels },
|
|
136
|
+
], snapshot.descriptor.revision);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Capture the revision before discovery so another writer always wins. */
|
|
140
|
+
async initialize(): Promise<void> {
|
|
141
|
+
const snapshot = this.snapshot();
|
|
142
|
+
if (!snapshot || snapshot.initialized) return;
|
|
143
|
+
const catalog = await this.catalog();
|
|
144
|
+
if (catalog.models.length === 0) return;
|
|
145
|
+
try {
|
|
146
|
+
await this.mutate(snapshot, {
|
|
147
|
+
enabled: true,
|
|
148
|
+
allowedModels: catalog.models.map(({ provider, model }) => ({ provider, model })),
|
|
149
|
+
});
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error !== null && typeof error === "object" && "code" in error &&
|
|
152
|
+
error.code === "SETTINGS_CONFLICT" && this.snapshot()?.initialized) return;
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async read(): Promise<SubagentSettingsView> {
|
|
158
|
+
if (!this.snapshot()) return this.unsupported();
|
|
159
|
+
const catalog = await this.catalog();
|
|
160
|
+
const current = this.snapshot();
|
|
161
|
+
if (!current) return this.unsupported();
|
|
162
|
+
return this.view(current, catalog);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async save(input: unknown): Promise<SubagentSettingsView> {
|
|
166
|
+
const value = record(input);
|
|
167
|
+
if (value.profile !== this.profile) throw new Error("DSH profile changed; reload settings before saving");
|
|
168
|
+
if (typeof value.revision !== "string" || !value.revision)
|
|
169
|
+
throw new Error("Subagent settings revision is required");
|
|
170
|
+
const selection = validateSelection(value.selection);
|
|
171
|
+
const request: SubagentSettingsSave = { profile: this.profile, revision: value.revision, selection };
|
|
172
|
+
const snapshot = this.snapshot();
|
|
173
|
+
if (!snapshot) return this.unsupported();
|
|
174
|
+
if (request.revision !== snapshot.revision)
|
|
175
|
+
throw new Error("Subagent settings changed; reload settings before saving");
|
|
176
|
+
await this.mutate(snapshot, request.selection);
|
|
177
|
+
return this.read();
|
|
178
|
+
}
|
|
179
|
+
}
|
package/server/provider.ts
CHANGED
|
@@ -65,6 +65,8 @@ type DetectVersion = (
|
|
|
65
65
|
|
|
66
66
|
interface DshProviderOptions {
|
|
67
67
|
createBridge(launch?: SessionLaunch): Promise<DshBridge>;
|
|
68
|
+
/** Share the daemon-resolved launch with settings RPCs targeting the same provider. */
|
|
69
|
+
observeLaunch?(launch: SessionLaunch["providerLaunch"]): void;
|
|
68
70
|
/**
|
|
69
71
|
* The environment and settings-screen choices, read on every call so a changed
|
|
70
72
|
* setting applies to new sessions. The executable outranks the `launch` Paseo
|
|
@@ -112,6 +114,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
|
|
|
112
114
|
description: "Direct connection to the local DeepSeek Harness (dsh)",
|
|
113
115
|
icon: "dsh.svg",
|
|
114
116
|
getCatalogCacheKey: async (request) => {
|
|
117
|
+
options.observeLaunch?.(request.launch);
|
|
115
118
|
const configured = (await options.configured?.()) ?? {};
|
|
116
119
|
const launch = describeDshLaunch(resolveDshLaunch(configured.executable, request.launch));
|
|
117
120
|
const scope = request.scope === "workspace" ? request.cwd : "global";
|
|
@@ -119,6 +122,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
|
|
|
119
122
|
},
|
|
120
123
|
command: [options.command ?? "dsh"],
|
|
121
124
|
async status(request) {
|
|
125
|
+
options.observeLaunch?.(request?.launch);
|
|
122
126
|
return dshStatus(
|
|
123
127
|
resolveDshLaunch((await options.configured?.())?.executable, request?.launch),
|
|
124
128
|
request?.launch?.env,
|
|
@@ -128,6 +132,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
|
|
|
128
132
|
async connect(request) {
|
|
129
133
|
if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required");
|
|
130
134
|
const providerLaunch = request.launch;
|
|
135
|
+
options.observeLaunch?.(providerLaunch);
|
|
131
136
|
const createBridge = (launch?: SessionLaunch) =>
|
|
132
137
|
options.createBridge(providerLaunch ? { ...launch, providerLaunch } : launch);
|
|
133
138
|
const bridge = await createBridge();
|
|
@@ -183,7 +188,23 @@ function createConnection(
|
|
|
183
188
|
): ProviderConnection {
|
|
184
189
|
const listeners = new Set<(event: ProviderEvent) => void>();
|
|
185
190
|
const sessions = new Map<string, SessionState>();
|
|
191
|
+
const pendingOpens = new Set<Promise<void>>();
|
|
192
|
+
const openingBridges = new Set<DshBridge>();
|
|
193
|
+
const bridgeClosures = new WeakMap<DshBridge, Promise<void>>();
|
|
194
|
+
const cleanupErrors: unknown[] = [];
|
|
186
195
|
let closed = false;
|
|
196
|
+
let closePromise: Promise<void> | undefined;
|
|
197
|
+
const closeBridge = (bridge: DshBridge): Promise<void> => {
|
|
198
|
+
let closing = bridgeClosures.get(bridge);
|
|
199
|
+
if (!closing) {
|
|
200
|
+
closing = Promise.resolve().then(() => bridge.close()).catch((error) => {
|
|
201
|
+
cleanupErrors.push(error);
|
|
202
|
+
throw error;
|
|
203
|
+
});
|
|
204
|
+
bridgeClosures.set(bridge, closing);
|
|
205
|
+
}
|
|
206
|
+
return closing;
|
|
207
|
+
};
|
|
187
208
|
const emit = (event: ProviderEvent) => {
|
|
188
209
|
if (closed) return;
|
|
189
210
|
for (const listener of listeners) listener(event);
|
|
@@ -195,28 +216,41 @@ function createConnection(
|
|
|
195
216
|
async send(input) {
|
|
196
217
|
if (closed) throw new Error("Provider connection is closed");
|
|
197
218
|
validateAdmission(input, sessions, capabilities);
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
219
|
+
const pending = Promise.resolve().then(async () => {
|
|
220
|
+
if (closed) return;
|
|
221
|
+
await dispatch(input, {
|
|
222
|
+
discoveryBridge, createBridge, sessions, emit, capabilities, detectVersion, launchEnv,
|
|
223
|
+
openingBridges, closeBridge, isClosed: () => closed,
|
|
224
|
+
});
|
|
225
|
+
}).catch((error) => failInput(input, error, emit));
|
|
226
|
+
if (input.type === "session.open") {
|
|
227
|
+
pendingOpens.add(pending);
|
|
228
|
+
void pending.then(() => pendingOpens.delete(pending));
|
|
229
|
+
}
|
|
203
230
|
},
|
|
204
231
|
onEvent(listener) {
|
|
205
232
|
listeners.add(listener);
|
|
206
233
|
return () => listeners.delete(listener);
|
|
207
234
|
},
|
|
208
|
-
|
|
209
|
-
if (
|
|
235
|
+
close() {
|
|
236
|
+
if (closePromise) return closePromise;
|
|
210
237
|
closed = true;
|
|
211
|
-
const bridges = [discoveryBridge];
|
|
238
|
+
const bridges = new Set([discoveryBridge, ...openingBridges]);
|
|
212
239
|
for (const session of sessions.values()) {
|
|
213
240
|
session.closed = true;
|
|
214
241
|
session.releaseBridgeListeners.forEach((release) => release());
|
|
215
|
-
bridges.
|
|
242
|
+
bridges.add(session.bridge);
|
|
216
243
|
}
|
|
217
244
|
sessions.clear();
|
|
218
245
|
listeners.clear();
|
|
219
|
-
|
|
246
|
+
closePromise = (async () => {
|
|
247
|
+
await Promise.allSettled([
|
|
248
|
+
...Array.from(bridges, closeBridge),
|
|
249
|
+
...pendingOpens,
|
|
250
|
+
]);
|
|
251
|
+
if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Could not close DSH bridges");
|
|
252
|
+
})();
|
|
253
|
+
return closePromise;
|
|
220
254
|
},
|
|
221
255
|
};
|
|
222
256
|
}
|
|
@@ -358,6 +392,9 @@ interface DispatchState {
|
|
|
358
392
|
capabilities: readonly string[];
|
|
359
393
|
detectVersion: DetectVersion;
|
|
360
394
|
launchEnv: Readonly<Record<string, string>> | undefined;
|
|
395
|
+
openingBridges: Set<DshBridge>;
|
|
396
|
+
closeBridge(bridge: DshBridge): Promise<void>;
|
|
397
|
+
isClosed(): boolean;
|
|
361
398
|
}
|
|
362
399
|
|
|
363
400
|
async function dispatch(input: ProviderInput, state: DispatchState): Promise<void> {
|
|
@@ -447,8 +484,10 @@ async function openSession(
|
|
|
447
484
|
ephemeral: !input.config.persist,
|
|
448
485
|
mcpServers: bridgeMcpServers(input.config.mcpServers),
|
|
449
486
|
});
|
|
487
|
+
state.openingBridges.add(bridge);
|
|
450
488
|
let session: SessionState | undefined;
|
|
451
489
|
try {
|
|
490
|
+
if (state.isClosed()) return;
|
|
452
491
|
// Resume from DSH's durable model and preset. Paseo fills catalog defaults into
|
|
453
492
|
// open config, so forwarding them would accidentally overwrite native history.
|
|
454
493
|
const selection = nativeId
|
|
@@ -468,11 +507,13 @@ async function openSession(
|
|
|
468
507
|
? { preapprovedTools: preapprovedTools(input.config) }
|
|
469
508
|
: {}),
|
|
470
509
|
});
|
|
510
|
+
if (state.isClosed()) return;
|
|
471
511
|
const title = nativeId ? undefined : input.config.title?.trim();
|
|
472
512
|
const [commands] = await Promise.all([
|
|
473
513
|
fetchCommands(bridge, opened.sessionId),
|
|
474
514
|
title ? bridge.request("session.rename", { sessionId: opened.sessionId, title }) : undefined,
|
|
475
515
|
]);
|
|
516
|
+
if (state.isClosed()) return;
|
|
476
517
|
session = {
|
|
477
518
|
paseoId: input.sessionId,
|
|
478
519
|
nativeId: opened.sessionId,
|
|
@@ -496,10 +537,18 @@ async function openSession(
|
|
|
496
537
|
await setPlanMode(session, true);
|
|
497
538
|
}
|
|
498
539
|
} catch (error) {
|
|
499
|
-
if (session)
|
|
500
|
-
|
|
540
|
+
if (session) {
|
|
541
|
+
session.closed = true;
|
|
542
|
+
session.releaseBridgeListeners.forEach((release) => release());
|
|
543
|
+
state.sessions.delete(input.sessionId);
|
|
544
|
+
}
|
|
545
|
+
await state.closeBridge(bridge);
|
|
501
546
|
throw error;
|
|
547
|
+
} finally {
|
|
548
|
+
state.openingBridges.delete(bridge);
|
|
549
|
+
if (state.isClosed()) await state.closeBridge(bridge);
|
|
502
550
|
}
|
|
551
|
+
if (state.isClosed()) return;
|
|
503
552
|
state.emit({
|
|
504
553
|
type: "session.opened",
|
|
505
554
|
requestId: input.requestId,
|
|
@@ -1080,12 +1129,28 @@ function questionAnswers(
|
|
|
1080
1129
|
return questions.map((question) => {
|
|
1081
1130
|
const key = question.header ?? question.id;
|
|
1082
1131
|
const raw = answers?.[key];
|
|
1083
|
-
|
|
1084
|
-
const
|
|
1085
|
-
|
|
1132
|
+
if (typeof raw !== "string" || raw.length === 0) return { id: question.id, selected: [] };
|
|
1133
|
+
const labels = (question.options ?? []).map((option) => option.label);
|
|
1134
|
+
if (!question.multiSelect) {
|
|
1135
|
+
return labels.includes(raw)
|
|
1136
|
+
? { id: question.id, selected: [raw] }
|
|
1137
|
+
: { id: question.id, selected: [], custom: raw };
|
|
1138
|
+
}
|
|
1139
|
+
// Paseo joins checked labels and the optional typed answer with ", ".
|
|
1140
|
+
// Match complete labels (longest first), keeping the remaining text intact.
|
|
1141
|
+
const candidates = labels.filter(Boolean).sort((left, right) => right.length - left.length);
|
|
1142
|
+
const selected: string[] = [];
|
|
1143
|
+
let custom = raw;
|
|
1144
|
+
while (custom.length > 0) {
|
|
1145
|
+
const label = candidates.find((value) =>
|
|
1146
|
+
!selected.includes(value) && (custom === value || custom.startsWith(`${value}, `)));
|
|
1147
|
+
if (label === undefined) break;
|
|
1148
|
+
selected.push(label);
|
|
1149
|
+
custom = custom === label ? "" : custom.slice(label.length + 2);
|
|
1150
|
+
}
|
|
1086
1151
|
return {
|
|
1087
1152
|
id: question.id,
|
|
1088
|
-
selected
|
|
1153
|
+
selected,
|
|
1089
1154
|
...(custom ? { custom } : {}),
|
|
1090
1155
|
};
|
|
1091
1156
|
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { ProviderLaunch } from "@getpaseo/plugin/server/provider";
|
|
3
|
+
import type { SubagentSettingsSave, SubagentSettingsView } from "../shared/subagent-settings.js";
|
|
4
|
+
import type { DshConfigured } from "./dsh-config.js";
|
|
5
|
+
import { DEFAULT_PROFILE, resolveDshHome, resolveDshLaunch, type DshBridge } from "./bridge-client.js";
|
|
6
|
+
|
|
7
|
+
interface SettingsTarget {
|
|
8
|
+
configured: DshConfigured;
|
|
9
|
+
launch?: ProviderLaunch;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Bind a draft to its effective installation and profile without exposing launch credentials. */
|
|
13
|
+
export function subagentSettingsTargetKey({ configured, launch }: SettingsTarget): string {
|
|
14
|
+
const command = resolveDshLaunch(configured.executable, launch);
|
|
15
|
+
return createHash("sha256").update(JSON.stringify([
|
|
16
|
+
command,
|
|
17
|
+
configured.profile ?? DEFAULT_PROFILE,
|
|
18
|
+
resolveDshHome(launch?.env ?? process.env),
|
|
19
|
+
])).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Each settings operation owns a short-lived bridge; no agent or background worker is retained. */
|
|
23
|
+
export function createSubagentSettingsAccess(options: {
|
|
24
|
+
target(): Promise<SettingsTarget>;
|
|
25
|
+
open(target: SettingsTarget): Promise<DshBridge>;
|
|
26
|
+
}) {
|
|
27
|
+
let disposed = false;
|
|
28
|
+
const pending = new Set<Promise<unknown>>();
|
|
29
|
+
const run = (input?: SubagentSettingsSave): Promise<SubagentSettingsView> => {
|
|
30
|
+
if (disposed) return Promise.reject(new Error("Plugin is shutting down"));
|
|
31
|
+
const task = (async () => {
|
|
32
|
+
const target = await options.target();
|
|
33
|
+
const key = subagentSettingsTargetKey(target);
|
|
34
|
+
let revision: string | undefined;
|
|
35
|
+
if (input) {
|
|
36
|
+
const prefix = `${key}:`;
|
|
37
|
+
if (!input.revision.startsWith(prefix) || input.profile !== (target.configured.profile ?? DEFAULT_PROFILE)) {
|
|
38
|
+
throw new Error("DSH launch settings changed. Reload before saving.");
|
|
39
|
+
}
|
|
40
|
+
revision = input.revision.slice(prefix.length);
|
|
41
|
+
}
|
|
42
|
+
if (disposed) throw new Error("Plugin is shutting down");
|
|
43
|
+
const bridge = await options.open(target);
|
|
44
|
+
try {
|
|
45
|
+
if (disposed) throw new Error("Plugin is shutting down");
|
|
46
|
+
const view = input
|
|
47
|
+
? await bridge.request("subagent.settings.save", { ...input, revision: revision! })
|
|
48
|
+
: await bridge.request("subagent.settings.read", {});
|
|
49
|
+
return { ...view, revision: `${key}:${view.revision}` };
|
|
50
|
+
} finally {
|
|
51
|
+
await bridge.close();
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
pending.add(task);
|
|
55
|
+
void task.then(() => pending.delete(task), () => pending.delete(task));
|
|
56
|
+
return task;
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
read: () => run(),
|
|
60
|
+
save: (input: SubagentSettingsSave) => run(input),
|
|
61
|
+
async dispose() {
|
|
62
|
+
disposed = true;
|
|
63
|
+
await Promise.allSettled(pending);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
package/server/timeline.ts
CHANGED
|
@@ -480,32 +480,55 @@ function timeline(
|
|
|
480
480
|
|
|
481
481
|
function toolDetail(name: string, args: JsonObject): ProviderToolCallDetail {
|
|
482
482
|
const normalized = name.toLowerCase();
|
|
483
|
-
|
|
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) ??
|
|
487
|
-
cwd: stringValue(args.
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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 (
|
|
511
|
+
if (normalized === "edit") {
|
|
495
512
|
return {
|
|
496
513
|
type: "edit",
|
|
497
|
-
filePath,
|
|
498
|
-
oldString: stringValue(args.
|
|
499
|
-
newString: stringValue(args.
|
|
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 (
|
|
504
|
-
return { type: "
|
|
519
|
+
if (normalized === "grep") {
|
|
520
|
+
return { type: "search", query: stringValue(args.pattern) ?? "", toolName: "grep" };
|
|
505
521
|
}
|
|
506
|
-
if (
|
|
507
|
-
return { type: "search", query: stringValue(args.
|
|
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
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SubagentSettingsSave, SubagentSettingsView } from "./subagent-settings.js";
|
|
2
|
+
|
|
1
3
|
/** The package-owned JSONL boundary. No DSH or Node runtime imports. */
|
|
2
4
|
export const BRIDGE_PROTOCOL_VERSION = 1 as const;
|
|
3
5
|
export type JsonObject = Record<string, unknown>;
|
|
@@ -160,6 +162,8 @@ export interface CommandResult {
|
|
|
160
162
|
}
|
|
161
163
|
export interface BridgeMethods {
|
|
162
164
|
"bridge.initialize": { params: {}; result: BridgeInitializeResult };
|
|
165
|
+
"subagent.settings.read": { params: {}; result: SubagentSettingsView };
|
|
166
|
+
"subagent.settings.save": { params: SubagentSettingsSave; result: SubagentSettingsView };
|
|
163
167
|
"session.list": {
|
|
164
168
|
params: { cwd?: string; query?: string; limit?: number };
|
|
165
169
|
result: { sessions: SessionSummary[] };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
export const subagentRouteSchema = z.object({
|
|
5
|
+
provider: z.string().min(1),
|
|
6
|
+
model: z.string().min(1),
|
|
7
|
+
});
|
|
8
|
+
export type SubagentRoute = z.infer<typeof subagentRouteSchema>;
|
|
9
|
+
|
|
10
|
+
export const subagentSelectionSchema = z.object({
|
|
11
|
+
enabled: z.boolean(),
|
|
12
|
+
allowedModels: z.array(subagentRouteSchema),
|
|
13
|
+
}).superRefine((value, ctx) => {
|
|
14
|
+
if (value.enabled && value.allowedModels.length === 0) {
|
|
15
|
+
ctx.addIssue({ code: "custom", message: "Select at least one model before enabling model selection." });
|
|
16
|
+
}
|
|
17
|
+
const keys = value.allowedModels.map(subagentRouteKey);
|
|
18
|
+
if (new Set(keys).size !== keys.length) {
|
|
19
|
+
ctx.addIssue({ code: "custom", message: "A model may only be selected once." });
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
export type SubagentSelection = z.infer<typeof subagentSelectionSchema>;
|
|
23
|
+
|
|
24
|
+
export const subagentSettingsViewSchema = z.object({
|
|
25
|
+
supported: z.boolean(),
|
|
26
|
+
profile: z.string(),
|
|
27
|
+
reason: z.string().optional(),
|
|
28
|
+
revision: z.string(),
|
|
29
|
+
initialized: z.boolean(),
|
|
30
|
+
selection: subagentSelectionSchema,
|
|
31
|
+
models: z.array(subagentRouteSchema.extend({
|
|
32
|
+
name: z.string(),
|
|
33
|
+
providerName: z.string(),
|
|
34
|
+
})),
|
|
35
|
+
failedProviders: z.array(z.string()),
|
|
36
|
+
});
|
|
37
|
+
export type SubagentSettingsView = z.infer<typeof subagentSettingsViewSchema>;
|
|
38
|
+
|
|
39
|
+
export const subagentSettingsSaveSchema = z.object({
|
|
40
|
+
profile: z.string(),
|
|
41
|
+
revision: z.string().min(1),
|
|
42
|
+
selection: subagentSelectionSchema,
|
|
43
|
+
});
|
|
44
|
+
export type SubagentSettingsSave = z.infer<typeof subagentSettingsSaveSchema>;
|
|
45
|
+
|
|
46
|
+
export const readSubagentSettings = defineRpc({
|
|
47
|
+
name: "subagent-settings.read",
|
|
48
|
+
input: z.object({}),
|
|
49
|
+
output: subagentSettingsViewSchema,
|
|
50
|
+
});
|
|
51
|
+
export const saveSubagentSettings = defineRpc({
|
|
52
|
+
name: "subagent-settings.save",
|
|
53
|
+
input: subagentSettingsSaveSchema,
|
|
54
|
+
output: subagentSettingsViewSchema,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Preserve exact provider/model pairs, including model ids containing slashes. */
|
|
58
|
+
export function subagentRouteKey(route: SubagentRoute): string {
|
|
59
|
+
return JSON.stringify([route.provider, route.model]);
|
|
60
|
+
}
|