@vidge/dsh-agent-hub 0.1.0-rc1 → 0.1.0-rc3

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/README.md CHANGED
@@ -64,19 +64,36 @@ runtime state — switching never requires a restart again.
64
64
  > can take the factory slot and re-mount that loop itself. Everything else in
65
65
  > that file is preserved byte for byte.
66
66
 
67
- ### Requirements
67
+ ### Engine dependencies
68
68
 
69
- Only for the engines you actually use:
69
+ Each engine's SDK is an **optional peer** — installing this plugin pulls in the
70
+ router only. Add the engines you actually want:
70
71
 
71
- - **Claude Code** — the Claude Code CLI installed on the host. Credentials are
72
- derived from dsh's own LLM provider configuration (see below); a CLI login is
73
- a fallback, not a requirement.
72
+ ```sh
73
+ # Claude Code
74
+ pnpm add @anthropic-ai/claude-agent-sdk
75
+ # Codex
76
+ pnpm add @openai/codex
77
+ # Pi
78
+ pnpm add @earendil-works/pi-coding-agent
79
+ ```
80
+
81
+ The `in-process` engine needs nothing beyond dsh itself, so a profile that
82
+ installs no SDK at all still works.
83
+
84
+ Selecting an engine whose SDK is missing fails that turn with a message naming
85
+ the package to install; it never affects other sessions or engines.
86
+
87
+ ### Authentication
88
+
89
+ Only for the engines you use:
90
+
91
+ - **Claude Code** — credentials are derived from dsh's own LLM provider
92
+ configuration (see below); a CLI login is a fallback, not a requirement.
74
93
  - **Codex** — authenticated via `codex login`, or a `CODEX_API_KEY` entry.
75
94
  - **Pi** — authenticated the way `pi` expects: its own `~/.pi/agent/auth.json`,
76
95
  or the provider's API-key environment variable.
77
96
 
78
- The in-process engine needs nothing beyond dsh itself.
79
-
80
97
  ## Usage
81
98
 
82
99
  Choose an engine in the composer when starting a session. To change engines,
package/README.zh.md CHANGED
@@ -55,18 +55,36 @@ dsh plugin --profile web add @vidge/dsh-agent-hub
55
55
  > `agent-loop` 行,以便路由器接管 factory 槽位并由它自己重新挂载该 loop。文件中
56
56
  > 其余内容逐字节保留。
57
57
 
58
- ### 依赖要求
58
+ ### 引擎依赖
59
+
60
+ 各引擎的 SDK 都是**可选 peer 依赖**——安装本插件只会装入路由器本身。按需添加你
61
+ 真正要用的引擎:
62
+
63
+ ```sh
64
+ # Claude Code
65
+ pnpm add @anthropic-ai/claude-agent-sdk
66
+ # Codex
67
+ pnpm add @openai/codex
68
+ # Pi
69
+ pnpm add @earendil-works/pi-coding-agent
70
+ ```
71
+
72
+ `in-process` 引擎除 dsh 本身外无任何额外要求,因此一个 SDK 都不装的 profile 仍可
73
+ 正常工作。
74
+
75
+ 选择了 SDK 未安装的引擎时,只有该次对话失败,并给出需要安装哪个包的提示;不会影响
76
+ 其他会话或其他引擎。
77
+
78
+ ### 认证
59
79
 
60
80
  仅针对你实际使用的引擎:
61
81
 
62
- - **Claude Code** —— 宿主机安装 Claude Code CLI。凭证由 dsh 自身的 LLM provider
63
- 配置派生(见下文);CLI 登录只是兜底,不是必需。
82
+ - **Claude Code** —— 凭证由 dsh 自身的 LLM provider 配置派生(见下文);CLI 登录
83
+ 只是兜底,不是必需。
64
84
  - **Codex** —— 通过 `codex login` 认证,或提供 `CODEX_API_KEY`。
65
85
  - **Pi** —— 按 pi 自己的方式认证:`~/.pi/agent/auth.json`,或对应 provider 的
66
86
  API-key 环境变量。
67
87
 
68
- in-process 引擎除 dsh 本身外无任何额外要求。
69
-
70
88
  ## 使用
71
89
 
72
90
  在 composer 中开始会话时选择引擎。要换引擎,开一个新会话——当前会话保持它的引擎,
package/lib/index.js CHANGED
@@ -61,11 +61,10 @@ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
61
61
  import { SessionPreparation } from "@deepseek-ai/dsh-session";
62
62
 
63
63
  // src/engine-claude/agent.ts
64
- import { Inbox, agentEvents } from "@deepseek-ai/dsh-agent";
64
+ import { Inbox, agentEvents, assembleContextFor } from "@deepseek-ai/dsh-agent";
65
65
  import { LlmError, createAssistantMessage, createUserMessage, errorChain } from "@deepseek-ai/dsh-llm";
66
66
  import { createScope } from "@deepseek-ai/dsh-scope";
67
- import { canonicalHeader } from "@deepseek-ai/dsh-session";
68
- import { query as officialQuery } from "@anthropic-ai/claude-agent-sdk";
67
+ import { canonicalHeader, headerEquals } from "@deepseek-ai/dsh-session";
69
68
 
70
69
  // src/engine-claude/mapping.ts
71
70
  import {
@@ -693,7 +692,21 @@ function invokedSkillNames(messages) {
693
692
 
694
693
  // src/engine-claude/agent.ts
695
694
  var PROVIDER = "claude-code";
695
+ var claudeQueryPromise;
696
+ async function loadClaudeQuery() {
697
+ claudeQueryPromise ??= import("@anthropic-ai/claude-agent-sdk").then(
698
+ (mod) => mod.query,
699
+ (error) => {
700
+ claudeQueryPromise = void 0;
701
+ throw new Error(
702
+ `the Claude Code engine requires "@anthropic-ai/claude-agent-sdk", which is not installed. Add it to this profile, or pick another engine for this session. (${String(error)})`
703
+ );
704
+ }
705
+ );
706
+ return claudeQueryPromise;
707
+ }
696
708
  var NATIVE_MODEL_LABEL = "claude-code-native";
709
+ var NATIVE_PROVIDER_LABEL = PROVIDER;
697
710
  function failureCode(subtype) {
698
711
  switch (subtype) {
699
712
  case "error_during_execution":
@@ -1024,19 +1037,35 @@ var ClaudeCodeAgent = class {
1024
1037
  return true;
1025
1038
  }
1026
1039
  /**
1027
- * Resolve the model one query runs on, session choice first.
1040
+ * Resolve the model one query runs on, per-session selection first.
1041
+ *
1042
+ * The layers, in precedence order:
1028
1043
  *
1029
- * The web surface sets `AgentOptions.model` when a session picks a model, and
1030
- * `agentDefaultModel` holds the global default; reading both is what makes
1031
- * the dsh model picker mean something for this engine. The service is
1032
- * optional a minimal profile may not mount it — so it is resolved through
1033
- * `ctx.get` rather than `inject`, and a faulting provider degrades to the
1034
- * next layer instead of failing the turn.
1044
+ * 1. **The `agent/request` waterfall.** This is the seam the dsh model
1045
+ * picker actually drives. api-proxy installs `installModelSelection` on
1046
+ * every agent's own context, which listens on `system-prompt/assemble`
1047
+ * to *snapshot* the session's selection and on `agent/request` to *apply*
1048
+ * the snapshot two stages, so a mid-turn switch lands on a later step
1049
+ * rather than splitting the prompt from the route. Both must be
1050
+ * dispatched, and in that order: the request listener reads
1051
+ * `selection.assembled`, which only the assemble listener writes, so
1052
+ * dispatching the request waterfall alone yields nothing.
1053
+ * 2. `AgentOptions.model` — the create-time seed.
1054
+ * 3. `agentDefaultModel` — the global default.
1055
+ * 4. The deployment's pinned `config.model`.
1056
+ * 5. Nothing, leaving the CLI on its own model.
1035
1057
  *
1058
+ * Every layer is optional and every failure degrades to the next one: a
1059
+ * minimal profile mounts neither service, and a listener that throws must
1060
+ * cost this session its turn no more than a missing service does.
1061
+ *
1062
+ * @param signal - the step's cancellation signal, forwarded to prompt assembly.
1036
1063
  * @returns the chosen id (undefined leaves the CLI on its own default),
1037
1064
  * its provider route, and the layer that chose it.
1038
1065
  */
1039
- resolveModel() {
1066
+ async resolveModel(signal) {
1067
+ const selected = await this.selectionFromWaterfall(signal);
1068
+ if (selected !== void 0) return selected;
1040
1069
  if (this.options.model !== void 0) {
1041
1070
  return { model: this.options.model, provider: this.options.provider, source: "session" };
1042
1071
  }
@@ -1056,22 +1085,88 @@ var ClaudeCodeAgent = class {
1056
1085
  }
1057
1086
  return { model: void 0, provider: void 0, source: "native" };
1058
1087
  }
1059
- /** Model label recorded in the request header for one lifecycle. */
1060
- modelLabel() {
1061
- return this.resolveModel().model ?? NATIVE_MODEL_LABEL;
1088
+ /**
1089
+ * Ask the host what this session is routed to, through the two waterfalls
1090
+ * that carry a per-session selection.
1091
+ *
1092
+ * The seed handed to `agent/request` is the same one the in-process loop
1093
+ * seeds with — the agent's own options — so a host that installs no listener
1094
+ * gets its own answer back and this returns undefined, leaving the layers
1095
+ * below untouched. A listener that replaces it wins.
1096
+ *
1097
+ * The assemble pass is dispatched for its *side effect* on the selection
1098
+ * state; its returned prompt is discarded, because Claude Code builds its own
1099
+ * prompt and dsh's assembly never reaches the child. That makes this a real
1100
+ * (if small) cost per step: the host's prompt providers run and their output
1101
+ * is dropped. It is the price of reaching a selection whose only publisher is
1102
+ * that listener pair.
1103
+ *
1104
+ * @param signal - the step's cancellation signal.
1105
+ * @returns the selection when a listener supplied one, else undefined.
1106
+ */
1107
+ async selectionFromWaterfall(signal) {
1108
+ const phase = this.phase;
1109
+ if (phase.kind !== "running") return void 0;
1110
+ const { turn, step } = phase;
1111
+ const seed = {
1112
+ provider: this.options.provider ?? "",
1113
+ model: this.options.model ?? ""
1114
+ };
1115
+ try {
1116
+ const systemPrompt = this.loopCtx.get("systemPrompt");
1117
+ if (systemPrompt !== void 0) {
1118
+ await systemPrompt.assemble(assembleContextFor(this, signal));
1119
+ }
1120
+ const proposed = await this.dispatch.waterfall(
1121
+ "agent/request",
1122
+ { turn, step, signal },
1123
+ () => Promise.resolve(seed)
1124
+ );
1125
+ if (proposed.model === "" || proposed.provider === "") return void 0;
1126
+ if (proposed.provider === seed.provider && proposed.model === seed.model) return void 0;
1127
+ return { model: proposed.model, provider: proposed.provider, source: "selection" };
1128
+ } catch (error) {
1129
+ this.ctx.logger.warn("claude-code: per-session model selection unavailable: %s", error);
1130
+ return void 0;
1131
+ }
1062
1132
  }
1063
- /** Append the request header snapshot once per loop instance. */
1064
- assertRequestHeader() {
1065
- if (this.requestHeaderLogged) return;
1133
+ /**
1134
+ * Append the request header, and re-append it whenever the route changes.
1135
+ *
1136
+ * The provider written here is the **real** dsh route (`copilot-proxy`,
1137
+ * `amazon-bedrock`, …), not this engine's name. That is not cosmetic:
1138
+ * api-proxy re-reads this field on every read as "the model this session is
1139
+ * on", resolves it against `ctx.llm.listProviders()`, and locks the composer
1140
+ * when the name is not a registered provider — so writing the engine name
1141
+ * here made every session demand a fresh model pick after each turn. The
1142
+ * engine that ran the turn is recorded in the `*.loop-engine.json` sidecar,
1143
+ * which is where per-session engine provenance already lives.
1144
+ *
1145
+ * Re-logging on change mirrors the in-process loop: the header is the log's
1146
+ * record of what each request ran under, so a mid-session model switch has to
1147
+ * produce a new snapshot or the log misattributes every later turn.
1148
+ *
1149
+ * @param selected - the model resolved for the step about to run.
1150
+ */
1151
+ noteRequestHeader(selected) {
1066
1152
  const header = canonicalHeader({
1067
- config: { provider: PROVIDER, model: this.modelLabel() }
1153
+ config: {
1154
+ provider: selected.provider ?? NATIVE_PROVIDER_LABEL,
1155
+ model: selected.model ?? NATIVE_MODEL_LABEL
1156
+ }
1068
1157
  });
1069
1158
  const baseline = this.session.requestHeader();
1070
- this.session.append("request/header", {
1071
- header,
1072
- reason: baseline === void 0 ? "initial" : "resume"
1073
- });
1074
- this.requestHeaderLogged = true;
1159
+ if (!this.requestHeaderLogged) {
1160
+ this.session.append("request/header", {
1161
+ header,
1162
+ reason: baseline === void 0 ? "initial" : "resume"
1163
+ });
1164
+ this.requestHeaderLogged = true;
1165
+ return;
1166
+ }
1167
+ if (baseline === void 0 || !headerEquals(baseline, header)) {
1168
+ this.session.append("request/header", { header, reason: "change" });
1169
+ }
1075
1170
  }
1076
1171
  /** Run one Claude Code query for the current step and map its transcript into the session log. */
1077
1172
  async step() {
@@ -1087,7 +1182,9 @@ var ClaudeCodeAgent = class {
1087
1182
  if (prompt.length === 0) {
1088
1183
  throw new Error(`agent "${this.id}": cannot derive a prompt from an empty session log`);
1089
1184
  }
1090
- this.assertRequestHeader();
1185
+ const selected = await this.resolveModel(signal);
1186
+ signal.throwIfAborted();
1187
+ this.noteRequestHeader(selected);
1091
1188
  signal.throwIfAborted();
1092
1189
  const controller = new AbortController();
1093
1190
  const cancel = () => {
@@ -1098,7 +1195,6 @@ var ClaudeCodeAgent = class {
1098
1195
  signal.addEventListener("abort", cancel, { once: true });
1099
1196
  const diagnostics = [];
1100
1197
  try {
1101
- const selected = this.resolveModel();
1102
1198
  const derived = await deriveProviderEnv(this.loopCtx, selected.provider);
1103
1199
  if (derived !== void 0) diagnostics.push(derived.diagnostic);
1104
1200
  const options = claudeQueryOptions({
@@ -1116,6 +1212,7 @@ var ClaudeCodeAgent = class {
1116
1212
  diagnostics.push(line);
1117
1213
  }
1118
1214
  }, controller);
1215
+ const officialQuery = await loadClaudeQuery();
1119
1216
  const query = officialQuery({ prompt, options });
1120
1217
  let finished = false;
1121
1218
  const chunkSeqs = [];
@@ -1606,7 +1703,13 @@ import { dirname, join } from "node:path";
1606
1703
  import { createInterface } from "node:readline";
1607
1704
  var require2 = createRequire(import.meta.url);
1608
1705
  function codexCliEntrypoint() {
1609
- return join(dirname(require2.resolve("@openai/codex/package.json")), "bin", "codex.js");
1706
+ try {
1707
+ return join(dirname(require2.resolve("@openai/codex/package.json")), "bin", "codex.js");
1708
+ } catch (error) {
1709
+ throw new Error(
1710
+ `the Codex engine requires "@openai/codex", which is not installed. Add it to this profile, or pick another engine for this session. (${String(error)})`
1711
+ );
1712
+ }
1610
1713
  }
1611
1714
  var AppServerClient = class _AppServerClient {
1612
1715
  process;
@@ -3609,7 +3712,14 @@ function resolveConfig3(config) {
3609
3712
  };
3610
3713
  }
3611
3714
  function piCliEntrypoint() {
3612
- const mainUrl = import.meta.resolve("@earendil-works/pi-coding-agent");
3715
+ let mainUrl;
3716
+ try {
3717
+ mainUrl = import.meta.resolve("@earendil-works/pi-coding-agent");
3718
+ } catch (error) {
3719
+ throw new Error(
3720
+ `the Pi engine requires "@earendil-works/pi-coding-agent", which is not installed. Add it to this profile, or pick another engine for this session. (${String(error)})`
3721
+ );
3722
+ }
3613
3723
  const root = dirname2(dirname2(fileURLToPath(mainUrl)));
3614
3724
  const pkg = JSON.parse(readFileSync(join2(root, "package.json"), "utf8"));
3615
3725
  const bin = pkg.bin;
@@ -93,23 +93,72 @@ export declare class ClaudeCodeAgent implements Agent {
93
93
  /** Open one turn before claiming its first proposed step. */
94
94
  private turn;
95
95
  /**
96
- * Resolve the model one query runs on, session choice first.
96
+ * Resolve the model one query runs on, per-session selection first.
97
97
  *
98
- * The web surface sets `AgentOptions.model` when a session picks a model, and
99
- * `agentDefaultModel` holds the global default; reading both is what makes
100
- * the dsh model picker mean something for this engine. The service is
101
- * optional — a minimal profile may not mount it — so it is resolved through
102
- * `ctx.get` rather than `inject`, and a faulting provider degrades to the
103
- * next layer instead of failing the turn.
98
+ * The layers, in precedence order:
104
99
  *
100
+ * 1. **The `agent/request` waterfall.** This is the seam the dsh model
101
+ * picker actually drives. api-proxy installs `installModelSelection` on
102
+ * every agent's own context, which listens on `system-prompt/assemble`
103
+ * to *snapshot* the session's selection and on `agent/request` to *apply*
104
+ * the snapshot — two stages, so a mid-turn switch lands on a later step
105
+ * rather than splitting the prompt from the route. Both must be
106
+ * dispatched, and in that order: the request listener reads
107
+ * `selection.assembled`, which only the assemble listener writes, so
108
+ * dispatching the request waterfall alone yields nothing.
109
+ * 2. `AgentOptions.model` — the create-time seed.
110
+ * 3. `agentDefaultModel` — the global default.
111
+ * 4. The deployment's pinned `config.model`.
112
+ * 5. Nothing, leaving the CLI on its own model.
113
+ *
114
+ * Every layer is optional and every failure degrades to the next one: a
115
+ * minimal profile mounts neither service, and a listener that throws must
116
+ * cost this session its turn no more than a missing service does.
117
+ *
118
+ * @param signal - the step's cancellation signal, forwarded to prompt assembly.
105
119
  * @returns the chosen id (undefined leaves the CLI on its own default),
106
120
  * its provider route, and the layer that chose it.
107
121
  */
108
122
  private resolveModel;
109
- /** Model label recorded in the request header for one lifecycle. */
110
- private modelLabel;
111
- /** Append the request header snapshot once per loop instance. */
112
- private assertRequestHeader;
123
+ /**
124
+ * Ask the host what this session is routed to, through the two waterfalls
125
+ * that carry a per-session selection.
126
+ *
127
+ * The seed handed to `agent/request` is the same one the in-process loop
128
+ * seeds with — the agent's own options — so a host that installs no listener
129
+ * gets its own answer back and this returns undefined, leaving the layers
130
+ * below untouched. A listener that replaces it wins.
131
+ *
132
+ * The assemble pass is dispatched for its *side effect* on the selection
133
+ * state; its returned prompt is discarded, because Claude Code builds its own
134
+ * prompt and dsh's assembly never reaches the child. That makes this a real
135
+ * (if small) cost per step: the host's prompt providers run and their output
136
+ * is dropped. It is the price of reaching a selection whose only publisher is
137
+ * that listener pair.
138
+ *
139
+ * @param signal - the step's cancellation signal.
140
+ * @returns the selection when a listener supplied one, else undefined.
141
+ */
142
+ private selectionFromWaterfall;
143
+ /**
144
+ * Append the request header, and re-append it whenever the route changes.
145
+ *
146
+ * The provider written here is the **real** dsh route (`copilot-proxy`,
147
+ * `amazon-bedrock`, …), not this engine's name. That is not cosmetic:
148
+ * api-proxy re-reads this field on every read as "the model this session is
149
+ * on", resolves it against `ctx.llm.listProviders()`, and locks the composer
150
+ * when the name is not a registered provider — so writing the engine name
151
+ * here made every session demand a fresh model pick after each turn. The
152
+ * engine that ran the turn is recorded in the `*.loop-engine.json` sidecar,
153
+ * which is where per-session engine provenance already lives.
154
+ *
155
+ * Re-logging on change mirrors the in-process loop: the header is the log's
156
+ * record of what each request ran under, so a mid-session model switch has to
157
+ * produce a new snapshot or the log misattributes every later turn.
158
+ *
159
+ * @param selected - the model resolved for the step about to run.
160
+ */
161
+ private noteRequestHeader;
113
162
  /** Run one Claude Code query for the current step and map its transcript into the session log. */
114
163
  private step;
115
164
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.0-rc1",
7
+ "version": "0.1.0-rc3",
8
8
  "repository": "github:vidgewong/dsh-agent-hub",
9
9
  "type": "module",
10
10
  "main": "lib/index.js",
@@ -61,12 +61,8 @@
61
61
  "@anthropic-ai/sdk": "0.93.0",
62
62
  "@deepseek-ai/schemastery": "3.18.1"
63
63
  },
64
- "optionalDependencies": {
65
- "@anthropic-ai/claude-agent-sdk": "0.3.220",
66
- "@earendil-works/pi-coding-agent": "0.84.3",
67
- "@openai/codex": "0.149.1"
68
- },
69
64
  "peerDependencies": {
65
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
70
66
  "@deepseek-ai/cordis": "^4.0.1",
71
67
  "@deepseek-ai/dsh-agent": "0.1.1-rc.2",
72
68
  "@deepseek-ai/dsh-agent-loop": "0.1.1-rc.2",
@@ -77,11 +73,14 @@
77
73
  "@deepseek-ai/dsh-session-persistence": "0.1.1-rc.2",
78
74
  "@deepseek-ai/dsh-settings": "0.1.1-rc.2",
79
75
  "@deepseek-ai/dsh-subprocess": "0.1.1-rc.2",
80
- "@deepseek-ai/dsh-timeout": "0.1.1-rc.2"
76
+ "@deepseek-ai/dsh-timeout": "0.1.1-rc.2",
77
+ "@earendil-works/pi-coding-agent": "0.84.3",
78
+ "@openai/codex": "0.149.1"
81
79
  },
82
80
  "devDependencies": {
83
- "@deepseek-ai/dsh-attachment": "0.1.1-rc.2",
81
+ "@anthropic-ai/claude-agent-sdk": "0.3.220",
84
82
  "@deepseek-ai/dsh-agent-loop": "0.1.1-rc.2",
83
+ "@deepseek-ai/dsh-attachment": "0.1.1-rc.2",
85
84
  "@deepseek-ai/dsh-client-locale": "0.1.1-rc.2",
86
85
  "@deepseek-ai/dsh-client-runtime": "0.1.1-rc.2",
87
86
  "@deepseek-ai/dsh-client-store": "0.1.2-alpha.2",
@@ -93,6 +92,8 @@
93
92
  "@deepseek-ai/dsh-session-persistence-jsonl": "0.1.1-rc.2",
94
93
  "@deepseek-ai/dsh-subprocess-local": "0.1.1-rc.2",
95
94
  "@deepseek-ai/dsh-system-prompt": "0.1.1-rc.2",
95
+ "@earendil-works/pi-coding-agent": "0.84.3",
96
+ "@openai/codex": "0.149.1",
96
97
  "@types/node": "^22.20.0",
97
98
  "@types/react": "^19.0.0",
98
99
  "@vitest/coverage-v8": "^4.1.8",
@@ -103,5 +104,16 @@
103
104
  "vite-tsconfig-paths": "^6.1.1",
104
105
  "vitest": "^4.1.8",
105
106
  "yaml": "^2.9.0"
107
+ },
108
+ "peerDependenciesMeta": {
109
+ "@anthropic-ai/claude-agent-sdk": {
110
+ "optional": true
111
+ },
112
+ "@earendil-works/pi-coding-agent": {
113
+ "optional": true
114
+ },
115
+ "@openai/codex": {
116
+ "optional": true
117
+ }
106
118
  }
107
119
  }