paseo-dsh-direct 0.2.7 → 0.2.9

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
@@ -18,8 +18,9 @@ Runs DeepSeek Harness (`dsh`) as a native Paseo agent provider. Paseo clients (d
18
18
  ## Processes and environment
19
19
 
20
20
  - **Child process.** For each session the plugin starts the `dsh` executable as a child process and loads a small bridge plugin into it. The two communicate over the child's stdio.
21
- - **Executable lookup.** In order: the `PASEO_DSH_EXECUTABLE` environment variable, then the Paseo setting `agents.providers.dsh-pi.command`, then `dsh` on `PATH`.
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` selects another existing profile instead; a missing one is reported as an error, not created.
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
+ - **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
25
  - **Environment passed to `dsh`.** The child inherits the Paseo daemon's environment.
25
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
@@ -33,6 +33,7 @@ Most dsh integrations go through ACP, a generic protocol that every agent can sp
33
33
  - Your existing dsh sessions can be imported and resumed in Paseo.
34
34
  - dsh's slash commands (`/compact`, `/plan`, ...) appear in Paseo's `/` menu.
35
35
  - Paseo's usage meter shows dsh's token counts and how full the context window is.
36
+ - Goal changes, plan mode switches and permission preset changes show up in the conversation as small timeline cards.
36
37
 
37
38
  ## Versioning
38
39
 
@@ -44,7 +45,7 @@ Create a conversation with the **DeepSeek Harness** provider. Models and presets
44
45
 
45
46
  The provider supports streamed assistant and reasoning output, tool execution and approval, user questions, model/preset configuration, interruption, and session recovery. It starts DSH with its own `paseo` profile and does not modify Paseo or DSH core.
46
47
 
47
- 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`. To use a different `dsh` executable, set `agents.providers.dsh-pi.command` in the Paseo configuration or `PASEO_DSH_EXECUTABLE` (which takes precedence); or `PASEO_DSH_PROFILE` to use another existing profile; a profile named there is not created automatically. If the Paseo Daemon has plugin loading disabled, enable `pluginsEnabled` in its configuration before installing the plugin.
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.
48
49
 
49
50
  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.
50
51
 
package/README.zh.md CHANGED
@@ -33,6 +33,7 @@ paseo plugin update paseo-dsh-direct --yes # 更新到最新
33
33
  - 已有的 dsh 会话可以导入 Paseo 接着用。
34
34
  - dsh 的斜杠命令(`/compact`、`/plan` 等)出现在 Paseo 的 `/` 菜单里。
35
35
  - Paseo 的用量表显示 dsh 的 token 用量和上下文占用。
36
+ - 目标变化、计划模式切换和权限预设变化会在对话里显示为小的时间线卡片。
36
37
 
37
38
  ## 版本号
38
39
 
@@ -44,7 +45,7 @@ paseo plugin update paseo-dsh-direct --yes # 更新到最新
44
45
 
45
46
  Provider 支持连续正文和思考输出、工具执行与审批、用户问答、模型和 preset 配置、中断及会话恢复。它用插件专用的 `paseo` profile 启动 DSH,不修改 Paseo 或 DSH 核心。
46
47
 
47
- 插件使用名为 `paseo` 的 DSH profile。首次启动时,插件用 DSH 官方的 `web` 模板自动创建它(位置是 `~/.dsh/profiles/paseo`;设置了 `$DSH_HOME` 时在其下)。模型路由、默认模型和权限预设写在 `~/.dsh/profiles/paseo/cordis.patch.yml`。可在 Paseo 配置的 `agents.providers.dsh-pi.command` 或 `PASEO_DSH_EXECUTABLE`(后者优先)指定其他 `dsh` 可执行文件,通过 `PASEO_DSH_PROFILE` 改用另一个已存在的 profile;这样指定的 profile 不会被自动创建。如果 Paseo Daemon 默认关闭插件加载,请先在配置中启用 `pluginsEnabled`。
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`。
48
49
 
49
50
  如果原生会话仍被另一个 DSH 客户端(如终端界面)占用,请先在那里释放,再从 Paseo 导入或恢复。
50
51
 
@@ -0,0 +1,101 @@
1
+ import { useSettings } from "@getpaseo/plugin/client";
2
+ import { ScrollView } from "@getpaseo/plugin/client/react-native";
3
+ import { SettingsAction, SettingsInput, SettingsRow, SettingsSection } from "@getpaseo/plugin/client/ui";
4
+ import { useRef } from "react";
5
+ import { DSH_SETTINGS } from "../shared/settings.js";
6
+
7
+ const EXECUTABLE_HINT =
8
+ "Path or command of dsh. PASEO_DSH_EXECUTABLE overrides this; leave it empty to use the Paseo provider command or dsh on PATH. Changes apply to new sessions.";
9
+ const PROFILE_HINT =
10
+ 'Name of the dsh profile to launch. PASEO_DSH_PROFILE overrides this; leave it empty to use the "paseo" profile. Changes apply to new sessions.';
11
+
12
+ /** Edits the dsh executable and profile that the server reads when it launches a bridge. */
13
+ export default function DshSettingsScreen() {
14
+ const settings = useSettings(DSH_SETTINGS);
15
+ const draftState = useRef<{ revision: string | undefined; edits: { executable?: string; profile?: string } }>({
16
+ revision: undefined,
17
+ edits: {},
18
+ });
19
+
20
+ if (settings.status === "loading") {
21
+ return (
22
+ <ScrollView>
23
+ <SettingsSection title="DeepSeek Harness">
24
+ <SettingsRow label="Loading settings" />
25
+ </SettingsSection>
26
+ </ScrollView>
27
+ );
28
+ }
29
+ if (settings.status === "error") {
30
+ return (
31
+ <ScrollView>
32
+ <SettingsSection title="DeepSeek Harness">
33
+ <SettingsRow label="Settings unavailable" error={settings.error} />
34
+ <SettingsAction label="Retry" actionLabel="Retry" onPress={() => void settings.reload()} />
35
+ </SettingsSection>
36
+ </ScrollView>
37
+ );
38
+ }
39
+ if (settings.status === "invalid") {
40
+ return (
41
+ <ScrollView>
42
+ <SettingsSection title="DeepSeek Harness">
43
+ <SettingsRow label="Stored settings are invalid" error={settings.error} hint="They are ignored until you reset them." />
44
+ <SettingsAction label="Reset" actionLabel="Reset" onPress={() => void settings.reset()} disabled={settings.saving} />
45
+ </SettingsSection>
46
+ </ScrollView>
47
+ );
48
+ }
49
+
50
+ const { revision, values } = settings;
51
+ // Edits belong to the revision they were typed against; a new revision remounts the inputs, so drop them.
52
+ if (draftState.current.revision !== revision) {
53
+ draftState.current = { revision, edits: {} };
54
+ }
55
+ const draft = { current: draftState.current.edits };
56
+ const save = () =>
57
+ void settings.save(
58
+ {
59
+ executable: draft.current.executable ?? values.executable ?? "",
60
+ profile: draft.current.profile ?? values.profile ?? "",
61
+ },
62
+ revision,
63
+ );
64
+
65
+ return (
66
+ <ScrollView>
67
+ <SettingsSection title="DeepSeek Harness">
68
+ <SettingsInput
69
+ key={`executable-${revision}`}
70
+ label="dsh executable"
71
+ hint={EXECUTABLE_HINT}
72
+ placeholder="dsh"
73
+ initialValue={values.executable ?? ""}
74
+ onChangeText={(text) => {
75
+ draft.current.executable = text;
76
+ }}
77
+ disabled={settings.saving}
78
+ />
79
+ <SettingsInput
80
+ key={`profile-${revision}`}
81
+ label="dsh profile"
82
+ hint={PROFILE_HINT}
83
+ placeholder="paseo"
84
+ initialValue={values.profile ?? ""}
85
+ onChangeText={(text) => {
86
+ draft.current.profile = text;
87
+ }}
88
+ disabled={settings.saving}
89
+ />
90
+ <SettingsAction
91
+ label="Save"
92
+ hint={settings.saveError ?? undefined}
93
+ error={settings.saveError}
94
+ actionLabel={settings.saving ? "Saving" : "Save"}
95
+ onPress={save}
96
+ disabled={settings.saving}
97
+ />
98
+ </SettingsSection>
99
+ </ScrollView>
100
+ );
101
+ }
@@ -0,0 +1,50 @@
1
+ import { SettingsRow } from "@getpaseo/plugin/client/ui";
2
+ import {
3
+ type GoalCard,
4
+ type PermissionPresetCard,
5
+ type PlanModeCard,
6
+ } from "../shared/timeline-cards.js";
7
+
8
+ interface CardProps<Data> {
9
+ item: { data: Data };
10
+ }
11
+
12
+ /** Text of a goal card. */
13
+ function goalCardText(data: GoalCard): string {
14
+ const objective = data.objective ? `: ${data.objective}` : "";
15
+ switch (data.operation) {
16
+ case "create":
17
+ return `Goal set${objective}`;
18
+ case "edit":
19
+ return `Goal edited${objective}`;
20
+ case "pause":
21
+ return "Goal paused";
22
+ case "resume":
23
+ return "Goal resumed";
24
+ case "complete":
25
+ return "Goal completed";
26
+ case "block":
27
+ return data.reason ? `Goal blocked: ${data.reason}` : "Goal blocked";
28
+ case "clear":
29
+ return "Goal cleared";
30
+ case "continue": {
31
+ const round = data.round === undefined ? "" : ` (round ${data.round}${data.maxRounds === undefined ? "" : `/${data.maxRounds}`})`;
32
+ return `Goal continued${round}`;
33
+ }
34
+ }
35
+ }
36
+
37
+ /** One-row card for a plan mode switch. */
38
+ export function PlanModeCardView({ item }: CardProps<PlanModeCard>) {
39
+ return <SettingsRow label={item.data.active ? "Plan mode on" : "Plan mode off"} />;
40
+ }
41
+
42
+ /** One-row card for a permission preset change. */
43
+ export function PermissionPresetCardView({ item }: CardProps<PermissionPresetCard>) {
44
+ return <SettingsRow label={`Permission preset: ${item.data.preset}`} />;
45
+ }
46
+
47
+ /** One-row card for a goal change or an automatic goal round. */
48
+ export function GoalCardView({ item }: CardProps<GoalCard>) {
49
+ return <SettingsRow label={goalCardText(item.data)} />;
50
+ }
package/index.client.tsx CHANGED
@@ -1,6 +1,48 @@
1
1
  import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { SETTINGS_SCREEN_ICON } from "./shared/settings.js";
3
+ import DshSettingsScreen from "./client/settings-screen.js";
4
+ import {
5
+ GoalCardView,
6
+ PermissionPresetCardView,
7
+ PlanModeCardView,
8
+ } from "./client/timeline-cards.js";
9
+ import {
10
+ CARD_KINDS,
11
+ CARD_VERSION,
12
+ goalCardSchema,
13
+ permissionPresetCardSchema,
14
+ planModeCardSchema,
15
+ } from "./shared/timeline-cards.js";
2
16
 
3
- /** Provider presentation is supplied by the server registration and its SVG asset. */
4
- export default function contribute(_client: PluginClientContext) {
5
- return () => {};
17
+ /** Provider presentation comes from the server registration; the client adds the dsh settings screen and timeline cards. */
18
+ export default function contribute(client: PluginClientContext) {
19
+ const cleanups = [
20
+ client.addSettingsScreen({
21
+ id: "dsh",
22
+ title: "DeepSeek Harness",
23
+ icon: SETTINGS_SCREEN_ICON,
24
+ Component: DshSettingsScreen,
25
+ }),
26
+ client.addTimelineRenderer({
27
+ kind: CARD_KINDS.planMode,
28
+ version: CARD_VERSION,
29
+ schema: planModeCardSchema,
30
+ Component: PlanModeCardView,
31
+ }),
32
+ client.addTimelineRenderer({
33
+ kind: CARD_KINDS.permissionPreset,
34
+ version: CARD_VERSION,
35
+ schema: permissionPresetCardSchema,
36
+ Component: PermissionPresetCardView,
37
+ }),
38
+ client.addTimelineRenderer({
39
+ kind: CARD_KINDS.goal,
40
+ version: CARD_VERSION,
41
+ schema: goalCardSchema,
42
+ Component: GoalCardView,
43
+ }),
44
+ ];
45
+ return async () => {
46
+ await Promise.all(cleanups.map((cleanup) => cleanup()));
47
+ };
6
48
  }
package/index.server.ts CHANGED
@@ -1,22 +1,23 @@
1
1
  import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { DSH_SETTINGS } from "./shared/settings.js";
2
3
  import { launchDshBridge } from "./server/bridge-client.js";
4
+ import { currentDshConfigured } from "./server/dsh-config.js";
3
5
  import { defaultDshCommand } from "./server/dsh-spawn.js";
4
6
  import { DSH_BRIDGE_SOURCE } from "./server/generated-bridge.js";
5
7
  import { createDshProvider } from "./server/provider.js";
6
8
 
7
9
  export default function contribute(server: PluginServerContext) {
8
- const executable = process.env.PASEO_DSH_EXECUTABLE;
10
+ const settings = server.registerSettings(DSH_SETTINGS);
11
+ // Read per call, not once: a changed setting applies to new sessions and new catalog discovery.
12
+ const configured = () => currentDshConfigured(process.env, settings);
9
13
  server.registerProvider(
10
14
  createDshProvider({
11
- executable,
15
+ configured,
12
16
  command: defaultDshCommand(process.env),
13
- createBridge: (launch) =>
14
- launchDshBridge({
15
- ...launch,
16
- bridgeSource: DSH_BRIDGE_SOURCE,
17
- executable,
18
- profile: process.env.PASEO_DSH_PROFILE,
19
- }),
17
+ createBridge: async (launch) => {
18
+ const { executable, profile } = await configured();
19
+ return launchDshBridge({ ...launch, bridgeSource: DSH_BRIDGE_SOURCE, executable, profile });
20
+ },
20
21
  }),
21
22
  );
22
23
  return () => {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paseo-dsh-direct",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "DeepSeek Harness Direct Provider Plugin for Paseo",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,6 +19,7 @@
19
19
  "index.server.ts",
20
20
  "index.client.tsx",
21
21
  "server",
22
+ "client",
22
23
  "shared",
23
24
  "scripts/build.mjs",
24
25
  "scripts/install-lefthook.mjs",
@@ -27,14 +28,16 @@
27
28
  "dsh.png",
28
29
  "OVERVIEW.md",
29
30
  "!server/**/*.test.ts",
31
+ "!client/**/*.test.ts",
30
32
  "!server/generated-bridge.ts",
31
33
  "!server/generated-version.ts"
32
34
  ],
33
35
  "peerDependencies": {
34
- "@getpaseo/client": "0.11.2",
35
- "@getpaseo/protocol": "0.11.2",
36
+ "@getpaseo/client": "0.12.0",
37
+ "@getpaseo/protocol": "0.12.0",
36
38
  "react": "~19.1.0",
37
- "react-native": ">=0.81.5"
39
+ "react-native": ">=0.81.5",
40
+ "zod": "^4.4.3"
38
41
  },
39
42
  "peerDependenciesMeta": {
40
43
  "@getpaseo/client": {
@@ -48,12 +51,16 @@
48
51
  },
49
52
  "react-native": {
50
53
  "optional": true
54
+ },
55
+ "zod": {
56
+ "optional": true
51
57
  }
52
58
  },
53
59
  "devDependencies": {
54
- "@getpaseo/plugin": "0.11.2",
60
+ "@getpaseo/plugin": "0.12.0",
55
61
  "@types/mdast": "^4.0.4",
56
62
  "@types/node": "^24.0.0",
63
+ "@types/react": "~19.2.0",
57
64
  "esbuild": "^0.25.0",
58
65
  "lefthook": "^2.1.9",
59
66
  "mdast-util-from-markdown": "^2.0.3",
@@ -62,7 +62,7 @@ export interface SessionLaunch {
62
62
  }
63
63
 
64
64
  export interface LaunchBridgeOptions extends SessionLaunch {
65
- /** The `PASEO_DSH_EXECUTABLE` value; it takes precedence over `providerLaunch.command`. */
65
+ /** The configured executable (`PASEO_DSH_EXECUTABLE`, else the plugin setting); it takes precedence over `providerLaunch.command`. */
66
66
  executable?: string;
67
67
  profile?: string;
68
68
  bridgeSource: string;
@@ -77,7 +77,7 @@ interface PendingRequest {
77
77
 
78
78
  const MAX_STDERR = 16_384;
79
79
 
80
- /** The profile the plugin launches unless `PASEO_DSH_PROFILE` names another one. */
80
+ /** The profile the plugin launches unless `PASEO_DSH_PROFILE` or the plugin setting names another one. */
81
81
  export const DEFAULT_PROFILE = "paseo";
82
82
  /** The official DSH profile template the default profile is created from. */
83
83
  export const DEFAULT_PROFILE_TEMPLATE = "web";
@@ -142,7 +142,7 @@ export async function ensureDshProfile(options: {
142
142
  throw new Error(
143
143
  `DSH profile "${options.profile}" does not exist at ${directory}. ` +
144
144
  `Create it with \`dsh --profile ${options.profile} --from-default-profile ${DEFAULT_PROFILE_TEMPLATE} --dump-config\`, ` +
145
- `or unset PASEO_DSH_PROFILE to use the "${DEFAULT_PROFILE}" profile.`,
145
+ `or clear the profile in the DeepSeek Harness plugin settings and unset PASEO_DSH_PROFILE to use the "${DEFAULT_PROFILE}" profile.`,
146
146
  );
147
147
  }
148
148
  // Concurrent first launches share one creation run.
@@ -222,8 +222,8 @@ export interface DshLaunch {
222
222
  }
223
223
 
224
224
  /**
225
- * The dsh launch, by precedence: a non-blank `PASEO_DSH_EXECUTABLE` (the most
226
- * specific signal, kept for existing installs), then the `launch` Paseo
225
+ * The dsh launch, by precedence: a non-blank configured executable (`PASEO_DSH_EXECUTABLE`,
226
+ * else the plugin setting; see `mergeDshConfigured`), then the `launch` Paseo
227
227
  * resolved from `command` / `agents.providers.dsh-pi.command`, then `dsh` from
228
228
  * `PATH`. `launch.args` belong to `launch.command`, so they are dropped when
229
229
  * the environment variable wins.
@@ -0,0 +1,65 @@
1
+ /** The dsh launch choices that outrank Paseo's own `launch` and the built-in defaults. */
2
+ export interface DshConfigured {
3
+ /** A non-blank dsh executable, or undefined when neither the environment nor the settings name one. */
4
+ executable?: string;
5
+ /** A non-blank profile name, or undefined to use the default profile. */
6
+ profile?: string;
7
+ }
8
+
9
+ /** The two values of the plugin settings screen; blank means unset. */
10
+ export interface DshSettingsValues {
11
+ executable?: string;
12
+ profile?: string;
13
+ }
14
+
15
+ const nonBlank = (value: string | undefined): string | undefined => value?.trim() || undefined;
16
+
17
+ /**
18
+ * Merges the environment and the settings screen: a non-blank
19
+ * `PASEO_DSH_EXECUTABLE` / `PASEO_DSH_PROFILE` wins over the setting of the
20
+ * same name. The result is then resolved against Paseo's `launch` and the
21
+ * default profile by `resolveDshLaunch` and `launchDshBridge`.
22
+ */
23
+ export function mergeDshConfigured(
24
+ env: Readonly<Record<string, string | undefined>>,
25
+ settings: DshSettingsValues | undefined,
26
+ ): DshConfigured {
27
+ const executable = nonBlank(env.PASEO_DSH_EXECUTABLE) ?? nonBlank(settings?.executable);
28
+ const profile = nonBlank(env.PASEO_DSH_PROFILE) ?? nonBlank(settings?.profile);
29
+ return { ...(executable ? { executable } : {}), ...(profile ? { profile } : {}) };
30
+ }
31
+
32
+ /** The slice of `PluginSettings.read()` this module needs. */
33
+ export interface DshSettingsSource {
34
+ read(): Promise<
35
+ | { status: "ready"; values: DshSettingsValues }
36
+ | { status: "invalid"; error: string }
37
+ | { status: "error"; error: string }
38
+ >;
39
+ }
40
+
41
+ /**
42
+ * Reads the current settings. Invalid or unreadable settings count as unset,
43
+ * with one line on stderr, so a bad setting never blocks launching dsh.
44
+ */
45
+ export async function readDshSettings(
46
+ source: DshSettingsSource,
47
+ warn: (line: string) => void = (line) => process.stderr.write(`${line}\n`),
48
+ ): Promise<DshSettingsValues | undefined> {
49
+ const state = await source.read().catch((error: unknown) => ({
50
+ status: "error" as const,
51
+ error: error instanceof Error ? error.message : String(error),
52
+ }));
53
+ if (state.status === "ready") return state.values;
54
+ warn(`[paseo-dsh-direct] ignoring the dsh plugin settings (${state.status}): ${state.error}`);
55
+ return undefined;
56
+ }
57
+
58
+ /** The configuration the provider uses right now: environment first, then the current settings. */
59
+ export async function currentDshConfigured(
60
+ env: Readonly<Record<string, string | undefined>>,
61
+ source: DshSettingsSource,
62
+ warn?: (line: string) => void,
63
+ ): Promise<DshConfigured> {
64
+ return mergeDshConfigured(env, await readDshSettings(source, warn));
65
+ }
@@ -25,6 +25,7 @@ import type {
25
25
  } from "../shared/bridge-protocol.js";
26
26
  import { slashLine } from "../shared/bridge-protocol.js";
27
27
  import {
28
+ DEFAULT_PROFILE,
28
29
  describeDshLaunch,
29
30
  resolveDshLaunch,
30
31
  type BridgeMcpServer,
@@ -32,6 +33,7 @@ import {
32
33
  type DshLaunch,
33
34
  type SessionLaunch,
34
35
  } from "./bridge-client.js";
36
+ import type { DshConfigured } from "./dsh-config.js";
35
37
  import { detectDshVersion } from "./dsh-version.js";
36
38
  import { PLUGIN_VERSION } from "./plugin-version.js";
37
39
  import { displayText, toPromptParts } from "./prompt-content.js";
@@ -50,6 +52,7 @@ const CAPABILITIES = [
50
52
  "session.persistence",
51
53
  "permission",
52
54
  "permission.tool_policy",
55
+ "timeline.plugin",
53
56
  ] as const;
54
57
  const PERMISSION_PRESET_SETTING = "permissionPreset";
55
58
  const PLAN_MODE_SETTING = "planMode";
@@ -61,8 +64,12 @@ type DetectVersion = (
61
64
 
62
65
  interface DshProviderOptions {
63
66
  createBridge(launch?: SessionLaunch): Promise<DshBridge>;
64
- /** The `PASEO_DSH_EXECUTABLE` value; it outranks the `launch` Paseo resolves. `status()` and `createBridge` use the same precedence. */
65
- executable?: string;
67
+ /**
68
+ * The environment and settings-screen choices, read on every call so a changed
69
+ * setting applies to new sessions. The executable outranks the `launch` Paseo
70
+ * resolves; `status()`, the catalog cache key and `createBridge` use the same values.
71
+ */
72
+ configured?: () => Promise<DshConfigured> | DshConfigured;
66
73
  /** The command registered with Paseo; defaults to `dsh` (see `defaultDshCommand`). */
67
74
  command?: string;
68
75
  /** Detects the local dsh version for the startup compatibility check; defaults to spawning `<exe> --version`. */
@@ -103,12 +110,16 @@ export function createDshProvider(options: DshProviderOptions): ProviderRegistra
103
110
  label: "DeepSeek Harness",
104
111
  description: "Direct connection to the local DeepSeek Harness (dsh)",
105
112
  icon: "dsh.svg",
106
- getCatalogCacheKey: async (options) =>
107
- options.scope === "workspace" ? `dsh-pi:${options.cwd}` : "dsh-pi:global",
113
+ getCatalogCacheKey: async (request) => {
114
+ const configured = (await options.configured?.()) ?? {};
115
+ const launch = describeDshLaunch(resolveDshLaunch(configured.executable, request.launch));
116
+ const scope = request.scope === "workspace" ? request.cwd : "global";
117
+ return JSON.stringify(["dsh-pi", scope, launch, configured.profile ?? DEFAULT_PROFILE]);
118
+ },
108
119
  command: [options.command ?? "dsh"],
109
120
  async status(request) {
110
121
  return dshStatus(
111
- resolveDshLaunch(options.executable, request?.launch),
122
+ resolveDshLaunch((await options.configured?.())?.executable, request?.launch),
112
123
  request?.launch?.env,
113
124
  options.detectDshVersion ?? detectDshVersion,
114
125
  );
@@ -153,7 +164,7 @@ async function dshStatus(
153
164
  available: false,
154
165
  diagnostic:
155
166
  `Could not run "${describeDshLaunch(launch)} --version". Install dsh, ` +
156
- `set agents.providers.dsh-pi.command in Paseo config, or set PASEO_DSH_EXECUTABLE, ` +
167
+ `set agents.providers.dsh-pi.command in Paseo config, set the executable in the DeepSeek Harness plugin settings, or set PASEO_DSH_EXECUTABLE, ` +
157
168
  `then restart the Paseo daemon.`,
158
169
  };
159
170
  }
@@ -458,7 +469,7 @@ async function openSession(
458
469
  open: opened,
459
470
  bridge,
460
471
  releaseBridgeListeners: [],
461
- projector: new TimelineProjector(input.sessionId),
472
+ projector: new TimelineProjector(input.sessionId, state.capabilities.includes("timeline.plugin")),
462
473
  activeTurnId: null,
463
474
  cancelRequested: false,
464
475
  dispatch: null,
@@ -494,6 +505,8 @@ async function openSession(
494
505
  for (const event of session.open.events) {
495
506
  for (const projected of session.projector.projectEvent(event)) state.emit(projected);
496
507
  }
508
+ } else {
509
+ session.projector.seed(session.open.events);
497
510
  }
498
511
  await warnOnDshVersionMismatch(session, state);
499
512
  state.emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -4,6 +4,12 @@ import type {
4
4
  ProviderToolCallDetail,
5
5
  } from "@getpaseo/plugin/server/provider";
6
6
  import type { DshSessionEvent, DshStreamFrame, JsonObject } from "../shared/bridge-protocol.js";
7
+ import {
8
+ CARD_KINDS,
9
+ CARD_VERSION,
10
+ PLUGIN_ID,
11
+ type GoalCard,
12
+ } from "../shared/timeline-cards.js";
7
13
  import { attachmentLabel } from "./prompt-content.js";
8
14
  import { classifyTurnEnd } from "./turn-end.js";
9
15
  import { arrayValue, numberValue, objectValue, stringValue } from "./values.js";
@@ -26,7 +32,27 @@ export class TimelineProjector {
26
32
  { attemptId: string; ordinal: number }
27
33
  >();
28
34
 
29
- constructor(private readonly paseoSessionId: string) {}
35
+ private planActive = false;
36
+ private lastPreset: string | undefined;
37
+ private readonly goals = new Map<string, { objective?: string; maxRounds?: number }>();
38
+
39
+ /**
40
+ * @param paseoSessionId Paseo session id stamped on every projected event.
41
+ * @param pluginCards Whether the host negotiated `timeline.plugin`; when false no cards are emitted.
42
+ */
43
+ constructor(
44
+ private readonly paseoSessionId: string,
45
+ private readonly pluginCards = false,
46
+ ) {}
47
+
48
+ /**
49
+ * Records plan mode, permission preset and goal objectives from history that is
50
+ * not replayed, so the first live change is compared against the right value.
51
+ * @param events DSH durable events of the resumed session, oldest first.
52
+ */
53
+ seed(events: readonly DshSessionEvent[]): void {
54
+ for (const event of events) this.projectCards(event);
55
+ }
30
56
 
31
57
  projectEvent(
32
58
  event: DshSessionEvent,
@@ -35,7 +61,14 @@ export class TimelineProjector {
35
61
  const timestamp = toTimestamp(event.time);
36
62
  switch (event.type) {
37
63
  case "user/message":
38
- return this.projectUserMessage(event, timestamp);
64
+ return [
65
+ ...this.projectCards(event, timestamp),
66
+ ...this.projectUserMessage(event, timestamp),
67
+ ];
68
+ case "plan/mode":
69
+ case "permission/preset":
70
+ case "goal/change":
71
+ return this.projectCards(event, timestamp);
39
72
  case "command/run":
40
73
  return this.projectCommandRun(event, options.clientMessageId, timestamp);
41
74
  case "command/done":
@@ -208,6 +241,75 @@ export class TimelineProjector {
208
241
  return projected;
209
242
  }
210
243
 
244
+ /** Updates card state for `event` and returns the plugin cards it produces (none when cards are off). */
245
+ private projectCards(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
246
+ const cards: Array<{ id: string; kind: string; data: Record<string, string | number | boolean> }> = [];
247
+ const data = event.data;
248
+ if (event.type === "plan/mode" && typeof data.active === "boolean") {
249
+ if (data.active !== this.planActive) {
250
+ this.planActive = data.active;
251
+ cards.push({ id: `plan-mode:${event.seq}`, kind: CARD_KINDS.planMode, data: { active: data.active } });
252
+ }
253
+ } else if (event.type === "permission/preset" && typeof data.preset === "string") {
254
+ const previous = this.lastPreset;
255
+ this.lastPreset = data.preset;
256
+ if (previous !== undefined && previous !== data.preset) {
257
+ cards.push({ id: `permission-preset:${event.seq}`, kind: CARD_KINDS.permissionPreset, data: { preset: data.preset } });
258
+ }
259
+ } else if (event.type === "goal/change") {
260
+ const card = this.goalChangeCard(data);
261
+ if (card) cards.push({ id: `goal:${event.seq}`, kind: CARD_KINDS.goal, data: { ...card } });
262
+ } else if (event.type === "user/message") {
263
+ const source = objectValue(data.source);
264
+ const round = numberValue(source?.round);
265
+ const goalId = stringValue(source?.goalId);
266
+ if (source?.kind === "goal" && round !== undefined && round >= 1) {
267
+ const known = goalId ? this.goals.get(goalId) : undefined;
268
+ const card: GoalCard = {
269
+ operation: "continue",
270
+ round,
271
+ ...(known?.maxRounds !== undefined ? { maxRounds: known.maxRounds } : {}),
272
+ ...(known?.objective !== undefined ? { objective: known.objective } : {}),
273
+ };
274
+ cards.push({ id: `goal-continue:${event.seq}`, kind: CARD_KINDS.goal, data: { ...card } });
275
+ }
276
+ }
277
+ if (!this.pluginCards) return [];
278
+ return cards.map((card) =>
279
+ timeline(
280
+ this.paseoSessionId,
281
+ { type: "plugin", id: card.id, pluginId: PLUGIN_ID, kind: card.kind, version: CARD_VERSION, data: card.data },
282
+ timestamp,
283
+ ),
284
+ );
285
+ }
286
+
287
+ private goalChangeCard(data: JsonObject): GoalCard | undefined {
288
+ const operation = stringValue(data.operation);
289
+ if (
290
+ operation !== "create" && operation !== "edit" && operation !== "pause" &&
291
+ operation !== "resume" && operation !== "complete" && operation !== "block" &&
292
+ operation !== "clear"
293
+ ) {
294
+ return undefined;
295
+ }
296
+ const goal = objectValue(operation === "clear" ? data.cleared : data.goal);
297
+ const goalId = stringValue(goal?.id);
298
+ if (operation !== "clear" && goalId) {
299
+ this.goals.set(goalId, {
300
+ objective: stringValue(goal?.objective),
301
+ maxRounds: numberValue(goal?.maxGoalRounds),
302
+ });
303
+ }
304
+ const objective = stringValue(goal?.objective) ?? (goalId ? this.goals.get(goalId)?.objective : undefined);
305
+ const reason = operation === "block" ? stringValue(objectValue(goal?.blockedReason)?.message) : undefined;
306
+ return {
307
+ operation,
308
+ ...(objective !== undefined ? { objective } : {}),
309
+ ...(reason !== undefined ? { reason } : {}),
310
+ };
311
+ }
312
+
211
313
  private projectUserMessage(event: DshSessionEvent, timestamp?: string): SessionEvent[] {
212
314
  const data = event.data;
213
315
  const source = objectValue(data.source);
@@ -0,0 +1,25 @@
1
+ import { defineSettings } from "@getpaseo/plugin";
2
+ import { z } from "zod";
3
+
4
+ /**
5
+ * Host-scoped plugin settings for the dsh launch. Both fields are optional; an
6
+ * empty or whitespace-only value means unset. The server reads them when a
7
+ * bridge is launched, and the client settings screen edits them.
8
+ */
9
+ export const DSH_SETTINGS = defineSettings({
10
+ id: "dsh",
11
+ scope: "host",
12
+ version: 1,
13
+ schema: z.object({
14
+ /** Path or command of the dsh executable. */
15
+ executable: z.string().optional(),
16
+ /** Name of the dsh profile the plugin launches. */
17
+ profile: z.string().optional(),
18
+ }),
19
+ });
20
+
21
+ /**
22
+ * Icon of the dsh settings screen. Paseo resolves plugin icons by the exact export name of
23
+ * `lucide-react-native` (PascalCase) and rejects the whole client contribution otherwise.
24
+ */
25
+ export const SETTINGS_SCREEN_ICON = "Settings";
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+
3
+ /** Plugin id from paseo-plugin.json; Paseo matches timeline plugin items by it, not by the provider id. */
4
+ export const PLUGIN_ID = "paseo-dsh-direct";
5
+
6
+ /** Schema version shared by every card kind below. */
7
+ export const CARD_VERSION = 1;
8
+
9
+ /** Plan mode switched on or off. */
10
+ export const planModeCardSchema = z.object({ active: z.boolean() });
11
+
12
+ /** Permission preset changed to `preset`. */
13
+ export const permissionPresetCardSchema = z.object({ preset: z.string() });
14
+
15
+ /** Goal operations from `goal/change`, plus `continue` for an automatic goal round. */
16
+ export const goalCardSchema = z.object({
17
+ operation: z.enum([
18
+ "create",
19
+ "edit",
20
+ "pause",
21
+ "resume",
22
+ "complete",
23
+ "block",
24
+ "clear",
25
+ "continue",
26
+ ]),
27
+ objective: z.string().optional(),
28
+ round: z.number().optional(),
29
+ maxRounds: z.number().optional(),
30
+ reason: z.string().optional(),
31
+ });
32
+
33
+ export type PlanModeCard = z.infer<typeof planModeCardSchema>;
34
+ export type PermissionPresetCard = z.infer<typeof permissionPresetCardSchema>;
35
+ export type GoalCard = z.infer<typeof goalCardSchema>;
36
+
37
+ /** Card kinds keyed by the `kind` field of the Paseo plugin timeline item. */
38
+ export const CARD_KINDS = {
39
+ planMode: "plan-mode",
40
+ permissionPreset: "permission-preset",
41
+ goal: "goal",
42
+ } as const;