paseo-dsh-direct 0.2.10 → 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.10",
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",
@@ -17,6 +17,15 @@ import type {
17
17
  type Method = keyof BridgeMethods;
18
18
  type Notification = keyof BridgeNotifications;
19
19
 
20
+ /**
21
+ * Default deadline for one bridge request. `session.list` title folds decode a
22
+ * whole session log per row, so callers list large histories with a wider
23
+ * deadline instead of raising this for every method.
24
+ */
25
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
26
+ /** Deadline for listing sessions: a cold title fold costs seconds per hundred sessions. */
27
+ export const LIST_SESSIONS_TIMEOUT_MS = 120_000;
28
+
20
29
  export interface DshBridge {
21
30
  readonly initialized: BridgeInitializeResult;
22
31
  /** The resolved `dsh` executable this bridge was launched with (see `resolveDshLaunch`). */
@@ -26,6 +35,7 @@ export interface DshBridge {
26
35
  request<Name extends Method>(
27
36
  method: Name,
28
37
  params: BridgeMethods[Name]["params"],
38
+ timeoutMs?: number,
29
39
  ): Promise<BridgeMethods[Name]["result"]>;
30
40
  on<Name extends Notification>(
31
41
  method: Name,
@@ -471,6 +481,7 @@ class JsonlBridgeClient implements DshBridge {
471
481
  request<Name extends Method>(
472
482
  method: Name,
473
483
  params: BridgeMethods[Name]["params"],
484
+ timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
474
485
  ): Promise<BridgeMethods[Name]["result"]> {
475
486
  if (this.terminalError) return Promise.reject(this.terminalError);
476
487
  if (this.closing && method !== "bridge.shutdown") {
@@ -481,7 +492,7 @@ class JsonlBridgeClient implements DshBridge {
481
492
  const timeout = setTimeout(() => {
482
493
  this.pending.delete(id);
483
494
  reject(new Error(`DSH bridge request timed out: ${method}`));
484
- }, 30_000);
495
+ }, timeoutMs);
485
496
  this.pending.set(id, { resolve, reject, timeout });
486
497
  const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
487
498
  this.child.stdin.write(payload, (error) => {
@@ -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,
@@ -140,8 +141,12 @@ export class BridgeService {
140
141
  private readonly subscriptions: Array<() => void> = [];
141
142
  /** Last usage payload sent per session, so an unchanged fold is not re-sent. */
142
143
  private readonly lastUsage = new Map<string, string>();
144
+ /** Folded titles per session id, so repeated import listings stay cheap. */
145
+ private readonly titles = new Map<string, string>();
143
146
  private stopping = false;
144
147
  private stopTask?: Promise<{}>;
148
+ private readonly subagentSettings: SubagentSettings;
149
+ private subagentSettingsInitializationError?: Error;
145
150
 
146
151
  constructor(
147
152
  private readonly ctx: HostContext,
@@ -149,6 +154,7 @@ export class BridgeService {
149
154
  private readonly profile = "paseo",
150
155
  private readonly helpers?: HostHelpers,
151
156
  ) {
157
+ this.subagentSettings = new SubagentSettings(ctx, profile);
152
158
  this.subscriptions.push(
153
159
  ctx.on("session/created", (session) => {
154
160
  if (session.header.parentSession)
@@ -262,7 +268,11 @@ export class BridgeService {
262
268
  }
263
269
  | undefined;
264
270
  const pressure = values.contextPressure as
265
- | { contextWindow?: number; pressureTokens?: number }
271
+ | {
272
+ contextWindow?: number;
273
+ pressureTokens?: number;
274
+ projectedTokens?: number;
275
+ }
266
276
  | undefined;
267
277
  const usage: BridgeUsage = {};
268
278
  if (typeof tokens?.uncachedInputTokens === "number")
@@ -271,8 +281,16 @@ export class BridgeService {
271
281
  usage.cachedInputTokens = tokens.cacheReadTokens;
272
282
  if (typeof tokens?.outputTokens === "number")
273
283
  usage.outputTokens = tokens.outputTokens;
274
- if (typeof pressure?.pressureTokens === "number")
275
- usage.contextWindowUsedTokens = pressure.pressureTokens;
284
+ // `projectedTokens` folds the shadow price of an adjacent compaction into
285
+ // the last request's prompt size, so the meter drops as soon as a
286
+ // compaction lands instead of waiting for the next request. dsh before
287
+ // 0.2.1 writes no shadow price and the field is absent: fall back to the
288
+ // raw prompt size.
289
+ const used =
290
+ typeof pressure?.projectedTokens === "number"
291
+ ? pressure.projectedTokens
292
+ : pressure?.pressureTokens;
293
+ if (typeof used === "number") usage.contextWindowUsedTokens = used;
276
294
  if (typeof pressure?.contextWindow === "number")
277
295
  usage.contextWindowMaxTokens = pressure.contextWindow;
278
296
  // The units materialize at zero before any provider usage lands, so a
@@ -439,6 +457,15 @@ export class BridgeService {
439
457
  }, catalog: { models: [], presets: [] }, missing } satisfies BridgeInitializeResult;
440
458
  break;
441
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
+ }
442
469
  result = {
443
470
  protocolVersion: 1,
444
471
  profile: this.profile,
@@ -453,29 +480,17 @@ export class BridgeService {
453
480
  } satisfies BridgeInitializeResult;
454
481
  break;
455
482
  }
456
- case "session.list": {
457
- const query = this.ctx.get("sessionQuery");
458
- const records = await query.listSessions();
459
- const sessions: SessionSummary[] = records.map((record) => ({
460
- ...record.header,
461
- }));
462
- // The official bulk title read owns live/cold source and validation.
463
- if (typeof query.readTitleSnapshots === "function") {
464
- const titles = await query.readTitleSnapshots(
465
- sessions.map((session) => session.id),
466
- );
467
- for (let i = 0; i < sessions.length; i++) {
468
- const result = titles[i];
469
- const title =
470
- result?.status === "fulfilled"
471
- ? result.value.title?.title
472
- : undefined;
473
- if (typeof title === "string") sessions[i]!.title = title;
474
- }
475
- }
476
- result = { sessions };
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;
491
+ case "session.list":
492
+ result = { sessions: await this.listSessions(listParams(params)) };
477
493
  break;
478
- }
479
494
  case "session.read":
480
495
  result = await this.ctx
481
496
  .get("sessionQuery")
@@ -622,6 +637,72 @@ export class BridgeService {
622
637
  );
623
638
  }
624
639
 
640
+ /**
641
+ * List native sessions for Paseo's import picker.
642
+ *
643
+ * `readTitleSnapshots` decodes each requested session's log to fold its
644
+ * title, which costs seconds per hundred sessions on a large history, so
645
+ * titles are folded only for the rows this call returns: cwd and query
646
+ * filter first, then newest first, then the limit. Folded titles are cached
647
+ * for this bridge's lifetime; a title renamed outside Paseo keeps its cached
648
+ * value until the bridge is replaced, while live sessions carry their header
649
+ * title regardless.
650
+ */
651
+ private async listSessions(
652
+ params: { cwd?: string; query?: string; limit?: number },
653
+ ): Promise<SessionSummary[]> {
654
+ const query = this.ctx.get("sessionQuery");
655
+ const records = await query.listSessions();
656
+ let sessions = records
657
+ .map((record) => ({ ...record.header }))
658
+ .filter((session) => !params.cwd || session.cwd === params.cwd);
659
+ if (params.query) {
660
+ // A title query needs every title, so this path folds the whole history.
661
+ await this.foldTitles(query, sessions);
662
+ const needle = params.query.toLowerCase();
663
+ sessions = sessions.filter(
664
+ (session) =>
665
+ session.title?.toLowerCase().includes(needle) ||
666
+ session.id.toLowerCase().includes(needle),
667
+ );
668
+ }
669
+ sessions.sort((left, right) => right.createdAt - left.createdAt);
670
+ if (params.limit !== undefined && sessions.length > params.limit)
671
+ sessions = sessions.slice(0, params.limit);
672
+ await this.foldTitles(query, sessions);
673
+ return sessions;
674
+ }
675
+
676
+ /** Fill in cached titles, reading only the sessions this bridge has not folded yet. */
677
+ private async foldTitles(
678
+ query: HostServices["sessionQuery"],
679
+ sessions: SessionSummary[],
680
+ ): Promise<void> {
681
+ if (typeof query.readTitleSnapshots !== "function") return;
682
+ // The official bulk title read owns live/cold source and validation. A
683
+ // header that already carries a title is live and needs no fold at all.
684
+ const missing = sessions.filter(
685
+ (session) => !session.title && !this.titles.has(session.id),
686
+ );
687
+ if (missing.length > 0) {
688
+ const titles = await query.readTitleSnapshots(
689
+ missing.map((session) => session.id),
690
+ );
691
+ for (let i = 0; i < missing.length; i++) {
692
+ const result = titles[i];
693
+ const title =
694
+ result?.status === "fulfilled"
695
+ ? result.value.title?.title
696
+ : undefined;
697
+ if (typeof title === "string") this.titles.set(missing[i]!.id, title);
698
+ }
699
+ }
700
+ for (const session of sessions) {
701
+ const title = this.titles.get(session.id);
702
+ if (title !== undefined) session.title = title;
703
+ }
704
+ }
705
+
625
706
  /** Registered commands first; a skill never shadows a command of the same name. */
626
707
  private async slashCommands(agent: HostAgent): Promise<SlashCommand[]> {
627
708
  const commands: SlashCommand[] = (this.ctx.get("commands")?.list(agent) ?? []).map(
@@ -971,6 +1052,31 @@ function validateContent(value: unknown): PromptPart[] {
971
1052
  return parts;
972
1053
  }
973
1054
 
1055
+ function listParams(params: Record<string, unknown>): {
1056
+ cwd?: string;
1057
+ query?: string;
1058
+ limit?: number;
1059
+ } {
1060
+ const result: { cwd?: string; query?: string; limit?: number } = {};
1061
+ if (params.cwd !== undefined) result.cwd = nonempty(params.cwd, "cwd");
1062
+ if (params.query !== undefined) {
1063
+ if (typeof params.query !== "string")
1064
+ throw new Error("query must be a string");
1065
+ const query = params.query.trim();
1066
+ if (query) result.query = query;
1067
+ }
1068
+ if (params.limit !== undefined) {
1069
+ if (
1070
+ typeof params.limit !== "number" ||
1071
+ !Number.isSafeInteger(params.limit) ||
1072
+ params.limit < 1
1073
+ )
1074
+ throw new Error("limit must be a positive integer");
1075
+ result.limit = params.limit;
1076
+ }
1077
+ return result;
1078
+ }
1079
+
974
1080
  function validateOpen(params: Record<string, unknown>): SessionOpenParams {
975
1081
  const result: SessionOpenParams = { cwd: nonempty(params.cwd, "cwd") };
976
1082
  if (params.sessionId !== undefined)
@@ -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
+ }
@@ -27,6 +27,7 @@ import { slashLine } from "../shared/bridge-protocol.js";
27
27
  import {
28
28
  DEFAULT_PROFILE,
29
29
  describeDshLaunch,
30
+ LIST_SESSIONS_TIMEOUT_MS,
30
31
  resolveDshLaunch,
31
32
  type BridgeMcpServer,
32
33
  type DshBridge,
@@ -64,6 +65,8 @@ type DetectVersion = (
64
65
 
65
66
  interface DshProviderOptions {
66
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;
67
70
  /**
68
71
  * The environment and settings-screen choices, read on every call so a changed
69
72
  * setting applies to new sessions. The executable outranks the `launch` Paseo
@@ -111,6 +114,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
111
114
  description: "Direct connection to the local DeepSeek Harness (dsh)",
112
115
  icon: "dsh.svg",
113
116
  getCatalogCacheKey: async (request) => {
117
+ options.observeLaunch?.(request.launch);
114
118
  const configured = (await options.configured?.()) ?? {};
115
119
  const launch = describeDshLaunch(resolveDshLaunch(configured.executable, request.launch));
116
120
  const scope = request.scope === "workspace" ? request.cwd : "global";
@@ -118,6 +122,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
118
122
  },
119
123
  command: [options.command ?? "dsh"],
120
124
  async status(request) {
125
+ options.observeLaunch?.(request?.launch);
121
126
  return dshStatus(
122
127
  resolveDshLaunch((await options.configured?.())?.executable, request?.launch),
123
128
  request?.launch?.env,
@@ -127,6 +132,7 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
127
132
  async connect(request) {
128
133
  if (!request.versions.includes(1)) throw new Error("Provider protocol version 1 is required");
129
134
  const providerLaunch = request.launch;
135
+ options.observeLaunch?.(providerLaunch);
130
136
  const createBridge = (launch?: SessionLaunch) =>
131
137
  options.createBridge(providerLaunch ? { ...launch, providerLaunch } : launch);
132
138
  const bridge = await createBridge();
@@ -404,8 +410,18 @@ async function listSessions(
404
410
  input: Extract<ProviderInput, { type: "sessions" }>,
405
411
  state: DispatchState,
406
412
  ): Promise<void> {
407
- const result = await state.discoveryBridge.request("session.list", {});
408
413
  const query = input.query?.trim().toLowerCase();
414
+ // Filtering and truncation run in the bridge so title folds only cover the
415
+ // rows Paseo will actually show; the local filters stay as a second net.
416
+ const result = await state.discoveryBridge.request(
417
+ "session.list",
418
+ {
419
+ ...(input.cwd ? { cwd: input.cwd } : {}),
420
+ ...(query ? { query } : {}),
421
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
422
+ },
423
+ LIST_SESSIONS_TIMEOUT_MS,
424
+ );
409
425
  const rows = result.sessions
410
426
  .filter((session): session is typeof session & { cwd: string } => Boolean(session.cwd))
411
427
  .filter((session) => !input.cwd || session.cwd === input.cwd)
@@ -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,7 +162,12 @@ export interface CommandResult {
160
162
  }
161
163
  export interface BridgeMethods {
162
164
  "bridge.initialize": { params: {}; result: BridgeInitializeResult };
163
- "session.list": { params: {}; result: { sessions: SessionSummary[] } };
165
+ "subagent.settings.read": { params: {}; result: SubagentSettingsView };
166
+ "subagent.settings.save": { params: SubagentSettingsSave; result: SubagentSettingsView };
167
+ "session.list": {
168
+ params: { cwd?: string; query?: string; limit?: number };
169
+ result: { sessions: SessionSummary[] };
170
+ };
164
171
  "session.read": { params: { sessionId: string }; result: SessionSnapshot };
165
172
  "session.open": { params: SessionOpenParams; result: SessionOpenResult };
166
173
  "session.prompt": {
@@ -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
+ }