paseo-dsh-direct 0.2.11 → 0.2.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/OVERVIEW.md CHANGED
@@ -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.** Executable and profile can also be set under Settings → DeepSeek Harness; changes apply to new sessions.
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 release/0.1` |
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 release/0.1` |
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.11",
3
+ "version": "0.2.12",
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",
@@ -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",
@@ -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>;
@@ -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
+ }
@@ -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();
@@ -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
+ }
@@ -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
+ }