super-dsh 0.1.10 → 0.1.11

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.
Files changed (50) hide show
  1. package/README.md +18 -2
  2. package/agent-agy/dist/agent.js +19 -1
  3. package/agent-claude/dist/adapter.js +106 -31
  4. package/agent-claude/dist/agent.js +177 -22
  5. package/agent-claude/dist/claude-client.js +84 -43
  6. package/agent-claude/dist/claude-launch.js +101 -0
  7. package/agent-claude/dist/claude-native-config.js +169 -0
  8. package/agent-claude/dist/claude-provider-gateway.js +340 -0
  9. package/agent-claude/dist/claude-providers.js +391 -0
  10. package/agent-claude/dist/claude-route.js +152 -0
  11. package/agent-claude/dist/index.js +214 -37
  12. package/agent-claude/dist/models.js +14 -0
  13. package/agent-claude/dist/sdk-runtime.js +320 -0
  14. package/agent-claude/package.json +6 -4
  15. package/agent-codex/README.md +18 -6
  16. package/agent-codex/dist/adapter.js +3 -2
  17. package/agent-codex/dist/agent.js +41 -7
  18. package/agent-codex/dist/app-server-client.js +7 -1
  19. package/agent-codex/dist/app-server-wire.js +10 -22
  20. package/agent-codex/dist/codex-launch.js +10 -32
  21. package/agent-codex/dist/codex-native-config.js +9 -3
  22. package/agent-codex/dist/codex-oauth.js +153 -0
  23. package/agent-codex/dist/codex-provider-gateway.js +36 -38
  24. package/agent-codex/dist/codex-providers.js +20 -7
  25. package/agent-codex/dist/codex-runtime.js +120 -0
  26. package/agent-codex/dist/index.js +18 -11
  27. package/agent-codex/package.json +1 -1
  28. package/agent-codex-sdk/dist/adapter.js +3 -2
  29. package/agent-codex-sdk/dist/agent.js +35 -1
  30. package/agent-codex-sdk/dist/codex-native-config.js +9 -3
  31. package/agent-codex-sdk/dist/codex-oauth.js +153 -0
  32. package/agent-codex-sdk/dist/codex-provider-gateway.js +36 -38
  33. package/agent-codex-sdk/dist/codex-providers.js +20 -7
  34. package/agent-codex-sdk/dist/codex-sdk-launch.js +5 -38
  35. package/agent-codex-sdk/dist/index.js +18 -11
  36. package/agent-codex-sdk/dist/sdk-runtime.js +2 -2
  37. package/agent-hermes/dist/agent.js +46 -2
  38. package/agent-hermes/dist/hermes-client.js +49 -1
  39. package/agent-omp/dist/agent.js +17 -4
  40. package/agent-omp-sdk/dist/agent.d.ts +11 -0
  41. package/agent-omp-sdk/dist/agent.js +17 -4
  42. package/agent-pi/dist/agent.js +33 -1
  43. package/client/index.js +150 -58
  44. package/package.json +2 -3
  45. package/superd-hub/dist/client/agent-settings.d.ts +66 -12
  46. package/superd-hub/dist/client/agent-settings.js +85 -14
  47. package/superd-hub/dist/client/runtime-settings-api.js +5 -5
  48. package/superd-hub/dist/world-mount.d.ts +6 -5
  49. package/superd-hub/dist/world-mount.js +6 -6
  50. package/superd-hub/lib/client/index.js +150 -58
@@ -0,0 +1,320 @@
1
+ /**
2
+ * sdk-runtime.ts — the lazy, durable Claude Agent SDK runtime (2026-10-04 spec §2.1).
3
+ *
4
+ * Zero declared dependency: the FIRST real prompt fetches
5
+ * `@anthropic-ai/claude-agent-sdk` into <worldHome>/sdk-runtime/ and it stays
6
+ * durable there. Two modes (user ruling 2026-10-04): host-binary (default,
7
+ * `--omit=optional` — the 206 MB platform payload is never downloaded; the CLI
8
+ * is the user's own binary) and bundled-payload (no local binary: the SDK's
9
+ * own platform payload IS the CLI). A mode flip re-installs with the other
10
+ * flag set (idempotent heal); a FAILED fetch writes sdk-runtime.error.log and
11
+ * throws — never a silent degradation.
12
+ *
13
+ * Layout (the marker/lock/error-log mechanism is, in shape, the one
14
+ * `agent-codex-sdk/src/sdk-runtime.ts` runs on):
15
+ *
16
+ * <worldHome>/sdk-runtime/ npm tree (`--ignore-scripts`)
17
+ * package.json the ANCHOR we write before install
18
+ * (private, never published): npm otherwise
19
+ * resolves the nearest ancestor project and
20
+ * may exit 0 having landed nothing here
21
+ * node_modules/@anthropic-ai/claude-agent-sdk/
22
+ * sdk.mjs the entry `loadClaudeQuery` imports
23
+ * <worldHome>/sdk-runtime.json marker — written LAST; a matching
24
+ * identity AND a live entry mean complete
25
+ * <worldHome>/sdk-runtime.lock atomic (mkdir) bootstrap lock
26
+ * <worldHome>/sdk-runtime.error.log written ONLY by a failed fetch
27
+ *
28
+ * Why the world home rather than the package root: the package root is the
29
+ * installed tarball — read-only in every real consumer install and shared by
30
+ * every world — while the world home is per-runtime, writable, and already the
31
+ * home of this adapter's own DSH state.
32
+ *
33
+ * Mode is re-derived on EVERY call: the host binary is resolved again (it may
34
+ * have appeared or vanished since the fetch), and a marker whose recorded mode
35
+ * no longer matches re-installs under the other flag set.
36
+ *
37
+ * Every path derives from the explicit `worldHome` argument — this module never
38
+ * reads the ambient `DSH_HOME` — and the SDK entry is imported BY RESOLVED PATH,
39
+ * never by a bare specifier: the runtime lives in the world home while this
40
+ * module lives in the plugin tarball, so a bare specifier could not resolve it
41
+ * and would quietly reintroduce the dependency this module exists to delete.
42
+ */
43
+ import { execFile } from "node:child_process";
44
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
45
+ import { homedir } from "node:os";
46
+ import { join } from "node:path";
47
+ import { pathToFileURL } from "node:url";
48
+ import { promisify } from "node:util";
49
+ /** The coding-agent SDK this line drives (a plain npm package, never declared). */
50
+ export const SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
51
+ /** The pin, written here rather than in `package.json` — the marker carries it. */
52
+ export const SDK_PACKAGE_VERSION = "0.3.263";
53
+ export function sdkRuntimeLayout(worldHome) {
54
+ return {
55
+ dir: join(worldHome, "sdk-runtime"),
56
+ marker: join(worldHome, "sdk-runtime.json"),
57
+ lock: join(worldHome, "sdk-runtime.lock"),
58
+ errorLog: join(worldHome, "sdk-runtime.error.log"),
59
+ };
60
+ }
61
+ /** The entry `loadClaudeQuery` imports — the SDK's `exports["."]`. */
62
+ export function sdkEntryPath(worldHome) {
63
+ return join(sdkRuntimeLayout(worldHome).dir, "node_modules", SDK_PACKAGE, "sdk.mjs");
64
+ }
65
+ /** Read the install marker; undefined when absent, unreadable or not our shape. */
66
+ export function readSdkRuntimeRecord(worldHome) {
67
+ const { marker } = sdkRuntimeLayout(worldHome);
68
+ if (!existsSync(marker))
69
+ return undefined;
70
+ try {
71
+ const parsed = JSON.parse(readFileSync(marker, "utf8"));
72
+ if (typeof parsed !== "object" || parsed === null)
73
+ return undefined;
74
+ const record = parsed;
75
+ if (record.sdkPackage !== SDK_PACKAGE)
76
+ return undefined;
77
+ if (typeof record.sdkVersion !== "string")
78
+ return undefined;
79
+ if (record.mode !== "host-binary" && record.mode !== "bundled-payload")
80
+ return undefined;
81
+ if (typeof record.createdAt !== "number")
82
+ return undefined;
83
+ return record;
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ /**
90
+ * The fast path: the marker is present, is ours, carries the current pin, was
91
+ * written under `wantedMode`, AND the SDK entry it promises still exists.
92
+ *
93
+ * The marker is written LAST, after a successful install, so its identity match
94
+ * normally implies the tree underneath; but an identity match alone is not
95
+ * enough. `fetchRuntime`'s failure path deletes the tree, a user may delete it
96
+ * by hand, and a half-wiped install can leave the (separate) marker behind. The
97
+ * entry `existsSync` is therefore the completeness contract: without it a stale
98
+ * marker would hand back a record whose `sdk.mjs` is gone and every prompt would
99
+ * fail `ERR_MODULE_NOT_FOUND` until someone removed the marker by hand. One stat
100
+ * costs nothing next to a 206 MB re-download.
101
+ */
102
+ function sdkRuntimeReady(worldHome, wantedMode) {
103
+ const record = readSdkRuntimeRecord(worldHome);
104
+ if (record === undefined)
105
+ return undefined;
106
+ if (record.sdkVersion !== SDK_PACKAGE_VERSION)
107
+ return undefined;
108
+ if (record.mode !== wantedMode)
109
+ return undefined;
110
+ if (!existsSync(sdkEntryPath(worldHome)))
111
+ return undefined;
112
+ return record;
113
+ }
114
+ /**
115
+ * Resolve the host Claude Code binary, most specific first:
116
+ * `CLAUDE_EXECUTABLE` (only when it exists) > `claude` on `PATH` >
117
+ * `~/.local/bin/claude` > `/usr/local/bin/claude`.
118
+ *
119
+ * `undefined` means "no local binary" and is the trigger for
120
+ * `bundled-payload` — never a bare `"claude"` fallback, which would pretend a
121
+ * host binary exists and make the SDK spawn a name that is not on `PATH`.
122
+ */
123
+ export function resolveClaudeBinary() {
124
+ const override = process.env.CLAUDE_EXECUTABLE;
125
+ if (override && existsSync(override))
126
+ return override;
127
+ for (const dir of (process.env.PATH ?? "").split(":").filter(Boolean)) {
128
+ const candidate = join(dir, "claude");
129
+ if (existsSync(candidate))
130
+ return candidate;
131
+ }
132
+ for (const candidate of [join(homedir(), ".local/bin/claude"), "/usr/local/bin/claude"]) {
133
+ if (existsSync(candidate))
134
+ return candidate;
135
+ }
136
+ return undefined;
137
+ }
138
+ /** The npm argv the runtime tree is installed with (kept pure for the test). */
139
+ export function sdkInstallArgv(version, mode) {
140
+ const argv = [
141
+ "install",
142
+ "--exact",
143
+ "--ignore-scripts",
144
+ "--omit=dev",
145
+ "--no-audit",
146
+ "--no-fund",
147
+ `${SDK_PACKAGE}@${version}`,
148
+ ];
149
+ if (mode === "host-binary")
150
+ argv.push("--omit=optional");
151
+ return argv;
152
+ }
153
+ /** A held lock older than this is treated as abandoned and reclaimed. */
154
+ const LOCK_STALE_MS = 10 * 60 * 1000;
155
+ const execFileAsync = promisify(execFile);
156
+ /**
157
+ * The production `install`: `SUPERD_SDK_NPM` (so the fetch can be pointed at
158
+ * pnpm/corepack) over an otherwise inherited environment, run in the runtime
159
+ * dir. `maxBuffer` is raised for npm's progress output.
160
+ */
161
+ async function defaultInstall(cmd, args, opts) {
162
+ await execFileAsync(cmd, args, {
163
+ cwd: opts.cwd,
164
+ env: opts.env ?? process.env,
165
+ maxBuffer: 64 * 1024 * 1024,
166
+ });
167
+ }
168
+ /**
169
+ * Return the record for `<worldHome>`'s durable SDK runtime, fetching it first
170
+ * when it is absent, stale or was installed under the other mode.
171
+ *
172
+ * Idempotent: a complete, matching runtime short-circuits without touching the
173
+ * network. Concurrent callers are separated by a `mkdir` lock — a second caller
174
+ * that finds a FRESH lock fails with a clear "fetch in progress" error (the SDK
175
+ * handle is a per-process singleton; the real race is another dsh process on
176
+ * the same world home), while an abandoned lock (older than
177
+ * {@link LOCK_STALE_MS}) is reclaimed so a crashed fetch cannot brick the line.
178
+ *
179
+ * A failure appends to `<worldHome>/sdk-runtime.error.log`, drops the partial
180
+ * tree and throws one actionable error — never a marker, so the next call heals.
181
+ */
182
+ export async function ensureSdkRuntime(worldHome, deps) {
183
+ const install = deps?.install ?? defaultInstall;
184
+ const now = deps?.now ?? Date.now;
185
+ const layout = sdkRuntimeLayout(worldHome);
186
+ const wantedMode = resolveClaudeBinary() !== undefined ? "host-binary" : "bundled-payload";
187
+ const ready = sdkRuntimeReady(worldHome, wantedMode);
188
+ if (ready !== undefined)
189
+ return ready;
190
+ mkdirSync(worldHome, { recursive: true });
191
+ acquireLock(layout);
192
+ try {
193
+ // Re-check inside the lock: a peer may have completed the fetch in the
194
+ // window between our check and the lock, in which case we pay nothing.
195
+ const done = sdkRuntimeReady(worldHome, wantedMode);
196
+ if (done !== undefined)
197
+ return done;
198
+ try {
199
+ return await fetchRuntime(worldHome, layout, wantedMode, install, now);
200
+ }
201
+ catch (error) {
202
+ const detail = error instanceof Error ? error.message : String(error);
203
+ try {
204
+ appendFileSync(layout.errorLog, `[${new Date().toISOString()}] agent-claude SDK runtime fetch failed: ${detail}\n`);
205
+ }
206
+ catch {
207
+ /* the trace is best-effort; the throw below is the contract */
208
+ }
209
+ rmSync(layout.dir, { recursive: true, force: true });
210
+ throw new Error(`agent-claude: could not fetch the SDK runtime ` +
211
+ `(${SDK_PACKAGE}@${SDK_PACKAGE_VERSION}, ${wantedMode} mode) at ${layout.dir}: ${detail}. ` +
212
+ `Trace: ${layout.errorLog}`);
213
+ }
214
+ }
215
+ finally {
216
+ rmSync(layout.lock, { recursive: true, force: true });
217
+ }
218
+ }
219
+ /** The private manifest that anchors npm to the runtime dir (never published). */
220
+ const RUNTIME_MANIFEST = '{"name":"superd-claude-sdk-runtime","private":true}\n';
221
+ /**
222
+ * The idempotent install step. npm is anchored to the runtime dir by a private
223
+ * manifest BEFORE install (without one, npm resolves the nearest ANCESTOR
224
+ * project and may exit 0 having landed nothing here), and the entry is
225
+ * verified on disk BEFORE the marker is written — exit 0 alone is not success.
226
+ */
227
+ async function fetchRuntime(worldHome, layout, mode, install, now) {
228
+ mkdirSync(layout.dir, { recursive: true });
229
+ // ANCHOR — npm resolves a tree from the nearest ancestor holding a
230
+ // package.json; with none in the runtime dir it walks up (on the real
231
+ // machine: to the repo root), reports "up to date" against THAT project,
232
+ // exits 0 and lands no tree here — yet the marker used to be written anyway,
233
+ // so every later loadClaudeQuery failed and each "heal" re-hit the same
234
+ // no-op install (reproduced twice, 2026-10-04). The private manifest pins
235
+ // the install to THIS directory; written only when missing or not
236
+ // byte-exact, so a re-run never rewrites a manifest that is already correct.
237
+ const manifest = join(layout.dir, "package.json");
238
+ let manifestExact = false;
239
+ if (existsSync(manifest)) {
240
+ try {
241
+ manifestExact = readFileSync(manifest, "utf8") === RUNTIME_MANIFEST;
242
+ }
243
+ catch {
244
+ manifestExact = false; // unreadable ⇒ treat as absent and re-anchor
245
+ }
246
+ }
247
+ if (!manifestExact)
248
+ writeFileSync(manifest, RUNTIME_MANIFEST);
249
+ const npm = process.env.SUPERD_SDK_NPM ?? "npm";
250
+ await install(npm, sdkInstallArgv(SDK_PACKAGE_VERSION, mode), { cwd: layout.dir });
251
+ // VERIFY — a zero exit is not success: an unanchored (or no-op) install
252
+ // resolves without producing the entry. Fail BEFORE the marker — it must
253
+ // only ever exist with the entry verifiably on disk, or the self-healing
254
+ // loop never converges. The catch in `ensureSdkRuntime` appends the trace
255
+ // (the note below names the cause) and wipes this partial tree.
256
+ const entry = sdkEntryPath(worldHome);
257
+ if (!existsSync(entry)) {
258
+ throw new Error(`npm install exited 0 but produced no entry at ${entry} — ` +
259
+ `the install was not anchored to the runtime dir ` +
260
+ `(npm likely resolved an ancestor project's tree instead of ${manifest})`);
261
+ }
262
+ // Marker LAST — a crash before this point leaves an incomplete runtime that
263
+ // the next call heals, never a false success.
264
+ const record = {
265
+ sdkPackage: SDK_PACKAGE,
266
+ sdkVersion: SDK_PACKAGE_VERSION,
267
+ mode,
268
+ createdAt: now(),
269
+ };
270
+ writeFileSync(layout.marker, JSON.stringify(record, null, 2) + "\n");
271
+ rmSync(layout.errorLog, { force: true });
272
+ return record;
273
+ }
274
+ /**
275
+ * mkdir-based mutex: returns owning the lock, throws when a FRESH lock is held
276
+ * by someone else, reclaims a lock older than {@link LOCK_STALE_MS}.
277
+ */
278
+ function acquireLock(layout) {
279
+ for (;;) {
280
+ try {
281
+ mkdirSync(layout.lock);
282
+ return;
283
+ }
284
+ catch (error) {
285
+ if (error.code !== "EEXIST")
286
+ throw error;
287
+ }
288
+ let ageMs;
289
+ try {
290
+ ageMs = Date.now() - statSync(layout.lock).mtimeMs;
291
+ }
292
+ catch {
293
+ continue; // released between the mkdir attempt and the stat — retry now
294
+ }
295
+ if (ageMs > LOCK_STALE_MS) {
296
+ rmSync(layout.lock, { recursive: true, force: true });
297
+ continue;
298
+ }
299
+ throw new Error(`agent-claude: another SDK runtime fetch is already in progress (lock ${layout.lock}). ` +
300
+ `Retry once it finishes; if no fetch is running, remove that lock directory.`);
301
+ }
302
+ }
303
+ /**
304
+ * Ensure the durable runtime and return the installed SDK entry's `query`
305
+ * function — the ONE function the client's default factory drives, and the only
306
+ * place a real download/install happens on the lazy path.
307
+ *
308
+ * The entry is imported by resolved path (see the header) and its shape is
309
+ * checked here: a tree that installs but exposes no `query` fails at fetch
310
+ * time, loud and near the cause, rather than inside the agent's first prompt.
311
+ */
312
+ export async function loadClaudeQuery(worldHome) {
313
+ await ensureSdkRuntime(worldHome);
314
+ const entry = sdkEntryPath(worldHome);
315
+ const module = (await import(pathToFileURL(entry).href));
316
+ if (typeof module.query !== "function") {
317
+ throw new Error(`agent-claude: ${entry} exposes no query function`);
318
+ }
319
+ return module.query;
320
+ }
@@ -18,10 +18,8 @@
18
18
  },
19
19
  "scripts": {
20
20
  "build": "tsc -p tsconfig.json",
21
- "test": "node --test test/*.test.mjs"
22
- },
23
- "dependencies": {
24
- "@anthropic-ai/claude-agent-sdk": "0.3.263"
21
+ "test": "node --test test/*.test.mjs",
22
+ "fetch-sdk-runtime": "node scripts/fetch-sdk-runtime.mjs"
25
23
  },
26
24
  "peerDependencies": {
27
25
  "@deepseek-ai/cordis": "^4.0.1",
@@ -30,6 +28,7 @@
30
28
  "@deepseek-ai/dsh-llm": ">=0.1.5-0 <0.3.0-0",
31
29
  "@deepseek-ai/dsh-session": ">=0.1.5-0 <0.3.0-0",
32
30
  "@deepseek-ai/dsh-session-persistence": ">=0.1.5-0 <0.3.0-0",
31
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.5-0 <0.3.0-0",
33
32
  "@deepseek-ai/dsh-user-approval": ">=0.1.5-0 <0.3.0-0",
34
33
  "@deepseek-ai/dsh-user-questions": ">=0.1.5-0 <0.3.0-0"
35
34
  },
@@ -52,6 +51,9 @@
52
51
  "@deepseek-ai/dsh-session-persistence": {
53
52
  "optional": true
54
53
  },
54
+ "@deepseek-ai/dsh-typert-protocol": {
55
+ "optional": true
56
+ },
55
57
  "@deepseek-ai/dsh-user-approval": {
56
58
  "optional": true
57
59
  },
@@ -19,10 +19,10 @@ superd 外壳下的 **Codex adapter**(super-dsh 线):把 Codex CLI 经由
19
19
 
20
20
  ## 线别(2026-10-03 迁移)
21
21
 
22
- **app-server 线**取代了原 `@openai/codex-sdk` 线(SDK 依赖已从本包与主 pack 移除)。
22
+ **app-server 线**是主路线(SDK 依赖已从本包与主 pack 移除)。SDK 另存为默认关闭的 `agent-codex-sdk`,拥有独立的按需 runtime 安装。
23
23
  SDK 线在 adapter 里登记的一整排降级被 app-server 协议面直接消掉:
24
24
 
25
- | 能力 | SDK 线(已退役) | app-server 线(现役) |
25
+ | 能力 | SDK 可选线 | app-server 主路线 |
26
26
  |---|---|---|
27
27
  | 打断 | 只有 AbortSignal(掐断本轮 stream) | **`turn/interrupt`** |
28
28
  | steer | 无 → 排队 followUp 近似 | **`turn/steer`**(注入进行中的 turn) |
@@ -82,19 +82,31 @@ collab/subagent 项。
82
82
  | 变量 | 作用 |
83
83
  |---|---|
84
84
  | `CODEX_TRACE=1` | provider/agent/app-server 追踪(stderr) |
85
- | `CODEX_BIN` | 覆盖 codex 入口脚本(测试);默认 = 本包 pin 的 `@openai/codex/bin/codex.js` |
85
+ | `CODEX_BIN` | 显式覆盖 Codex 核心或 JS 入口;默认依序发现 desktop 核心、用户 CLI、包内 exact-pin fallback |
86
86
  | `CODEX_HOME` | Codex 原生 home 重定向(**测试专用**;生产路径零重定向) |
87
87
  | `CODEX_APPROVAL_MODE` | 覆盖审批档(`never`/`on-request`/`on-failure`/`untrusted`;非法值忽略) |
88
88
  | `CODEX_APPROVAL_TIMEOUT_MS` | 审批应答超时;`0` 关闭;默认 300000 |
89
89
  | `CODEX_IDLE_EXIT_MS` | 会话空闲后拆客户端;`0` 关闭;默认 600000 |
90
90
 
91
+ ## Binary 与官方 OAuth
92
+
93
+ 核心发现顺序是显式 `CODEX_BIN` → 已安装 desktop 核心 → 用户 CLI → 包内 `@openai/codex@0.160.0`。
94
+ 发现和启动仅发生在真正 prompt 时。JS 入口经 Node 启动,原生核心直接执行;认证或生成失败不会静默换 provider。
95
+ desktop 优先选择核心版本,不代表复制 desktop 宿主:独立 app-server 使用 Super D 自有 clientInfo,
96
+ 不借用父任务的 desktop 工具 pipe。browser/computer use 仍需要对应宿主服务与工具接线。
97
+
98
+ 本地 `auth.json` 有 ChatGPT OAuth 时,模型选择器增加只读的 **OpenAI OAuth** provider。
99
+ adapter 正本仅存其 native 身份,token 留在 Codex home;选择该 route 时通过子进程配置覆盖 builtin `openai`,
100
+ 不会改写用户的 config/auth。官方模型读取原生 `models_cache.json`,并保留原生配置的官方默认模型;第三方自定义目录不会当作官方目录。
101
+ 没有原生目录冲突时不强制注入静态 catalog,让实际选中的 binary 按版本、身份与有效期维护 cache;有目录冲突时仅注入可消费的官方 cache,缺失时明确报错。原生默认模型的 ID 不代表当前 binary 的访问权限。OAuth logout 后显式选择必须失败,不能落到本地第三方默认配置。
102
+
91
103
  ## 测试
92
104
 
93
105
  ```bash
94
- npm run build # tsc strict
106
+ node super-dsh/scripts/pack.mjs # 仓根执行,唯一构建打包入口
95
107
  DSH_HOME=<repo>/.tests \
96
- SUPERD_DSH_ANCHOR=<repo>/upstream/deepseek-harness/package.json \
97
- node --test test/*.test.mjs
108
+ SUPERD_DSH_ANCHOR=<repo>/upstream/deepseek-harness/apps/cli/package.json \
109
+ node --test super-dsh/agent-codex/test/*.test.mjs
98
110
  ```
99
111
 
100
112
  单测全部是 fake:`test/fixtures/fake-app-server.mjs` 是一个**可编程的 app-server 替身**
@@ -1,3 +1,5 @@
1
+ import { readEffectiveProviderStore } from "./codex-oauth.js";
2
+ import { resolveCodexHome } from "./codex-store.js";
1
3
  /**
2
4
  * Codex-backed `LlmAdapter` registered on `ctx.llm` by the Codex provider.
3
5
  *
@@ -23,7 +25,6 @@
23
25
  * @module agent-codex/adapter
24
26
  */
25
27
  import { LlmAdapter, LlmError, } from "@deepseek-ai/dsh-llm";
26
- import { readProviderStore } from "./codex-providers.js";
27
28
  /**
28
29
  * The single route every Codex model was served under before the multi-provider
29
30
  * rework. No longer registered (a session pinned to it falls back to whatever
@@ -41,7 +42,7 @@ export function routeIds(store) {
41
42
  }
42
43
  /** The provider entry a route id names, or `undefined`. */
43
44
  export function providerFor(worldHome, providerId) {
44
- return readProviderStore(worldHome).providers.find((entry) => entry.id === providerId);
45
+ return readEffectiveProviderStore(worldHome, resolveCodexHome()).providers.find((entry) => entry.id === providerId);
45
46
  }
46
47
  export class CodexLlmAdapter extends LlmAdapter {
47
48
  worldHome;
@@ -289,6 +289,11 @@ export class CodexAgent {
289
289
  */
290
290
  #remoteQueue = [];
291
291
  #bridgedToolResults = new Set();
292
+ /** callIds already advertised by an appended assistant/message frame — the
293
+ * strict validator refuses any `tool/call` whose callId no assistant frame
294
+ * advertised, and requires the execution arguments to equal the advertised
295
+ * string (2026-10-04 dev3 omp/hermes repair: same write-side family). */
296
+ #advertisedToolCalls = new Set();
292
297
  /**
293
298
  * The open turn's FINAL assistant message, buffered from `message_end`
294
299
  * until `turn_end` so the SAME turn's usage can land on it (the DSH
@@ -711,9 +716,14 @@ export class CodexAgent {
711
716
  // An unknown provider id (a session pinned before the multi-provider rework)
712
717
  // maps to "no injection": the user's own config.toml is the honest answer.
713
718
  const launchSpec = launch ?? NATIVE_LAUNCH;
714
- const providerId = launch === undefined ? null : route?.provider ?? null;
719
+ const providerId = launch === undefined ? null : launch.runtimeProvider ?? route?.provider ?? null;
715
720
  const client = this.#client;
716
721
  const key = `${providerId ?? "native"}/${route?.model ?? ""}`;
722
+ // Pin before ensureStarted: the first thread and turn otherwise inherit
723
+ // the native model captured at construction, even with a new provider.
724
+ if (route !== undefined) {
725
+ await client.setModel(route.provider, route.model);
726
+ }
717
727
  if (!client.spawned && client.threadId === null) {
718
728
  client.applyLaunch(launchSpec, providerId);
719
729
  await client.ensureStarted();
@@ -726,11 +736,6 @@ export class CodexAgent {
726
736
  else if (client.appliedProvider !== providerId) {
727
737
  await client.reattach(launchSpec, providerId, route?.model);
728
738
  }
729
- else if (route !== undefined) {
730
- await client.setModel(route.provider, route.model).catch((error) => {
731
- trace(`set_model ${key} failed: ${String(error)}`);
732
- });
733
- }
734
739
  this.#routeKey = key;
735
740
  }
736
741
  /**
@@ -1043,7 +1048,32 @@ export class CodexAgent {
1043
1048
  case "tool_execution_start": {
1044
1049
  const callId = String(event.toolCallId ?? "");
1045
1050
  const name = String(event.toolName ?? "");
1051
+ const argumentsJson = String(event.argumentsJson ?? JSON.stringify(event.args ?? {}));
1046
1052
  this.#commitStepStart();
1053
+ // Advertisement-before-execution (validator invariant, 2026-10-04 dev3
1054
+ // omp/hermes repair family): unless a real assistant frame advertised
1055
+ // this callId, bridge a minimal assistant/message carrying the
1056
+ // tool-call block — arguments copied verbatim into both frames. The
1057
+ // synthesized source mirrors #appendAssistantMessage's open-turn
1058
+ // fallback chain.
1059
+ if (!this.#advertisedToolCalls.has(callId)) {
1060
+ const advertisement = createAssistantMessage({
1061
+ content: [{ type: "tool-call", id: ToolCallId(callId), name, arguments: argumentsJson }],
1062
+ source: {
1063
+ provider: String(this.#turnRoute?.provider ?? this.options.provider ?? ""),
1064
+ model: String(this.#turnRoute?.model ?? this.options.model ?? ""),
1065
+ },
1066
+ });
1067
+ this.session.append("assistant/message", {
1068
+ turn: this.#dashTurn,
1069
+ step: this.#step,
1070
+ message: advertisement,
1071
+ stream: [],
1072
+ }, {
1073
+ surfaceOp: "append",
1074
+ });
1075
+ this.#advertisedToolCalls.add(callId);
1076
+ }
1047
1077
  this.session.append("tool/call", {
1048
1078
  turn: this.#dashTurn,
1049
1079
  step: this.#step,
@@ -1052,7 +1082,7 @@ export class CodexAgent {
1052
1082
  // The DSH payload requires the RAW JSON string the model produced;
1053
1083
  // the wire carries it verbatim for MCP calls and renders it from the
1054
1084
  // discrete fields for the other item shapes.
1055
- arguments: String(event.argumentsJson ?? JSON.stringify(event.args ?? {})),
1085
+ arguments: argumentsJson,
1056
1086
  });
1057
1087
  break;
1058
1088
  }
@@ -1164,6 +1194,10 @@ export class CodexAgent {
1164
1194
  }
1165
1195
  #appendAssistantMessage(message, usage) {
1166
1196
  const content = convertContent(message.content);
1197
+ for (const block of content) {
1198
+ if (block.type === "tool-call")
1199
+ this.#advertisedToolCalls.add(block.id);
1200
+ }
1167
1201
  const assistant = createAssistantMessage({
1168
1202
  content,
1169
1203
  source: {
@@ -440,11 +440,17 @@ export class CodexAppServerClient {
440
440
  // crash still fails the client.
441
441
  const generation = ++this.#transportGeneration;
442
442
  const launch = this.#launch;
443
+ // This is an independent Super D consumer, including when its parent
444
+ // happens to be a desktop task. The desktop host's attribution and
445
+ // tools pipe belong to that host, not to this new app-server.
446
+ const childEnv = { ...process.env, ...launch.env };
447
+ delete childEnv.CODEX_INTERNAL_ORIGINATOR_OVERRIDE;
448
+ delete childEnv.CODEX_APP_TOOLS_PIPE_PATH;
443
449
  transport = transportFactory({
444
450
  ...(this.#options.cwd === undefined ? {} : { cwd: this.#options.cwd }),
445
451
  ...(this.#options.requestTimeoutMs === undefined ? {} : { requestTimeoutMs: this.#options.requestTimeoutMs }),
446
452
  args: ["app-server", ...launch.argv],
447
- env: { ...process.env, ...launch.env },
453
+ env: childEnv,
448
454
  onNotification: (notification) => this.#onNotification(notification),
449
455
  onServerRequest: (request) => this.#onServerRequest(request),
450
456
  onExit: (info) => {
@@ -7,7 +7,7 @@
7
7
  * `{"id":…,"result":…}` / `{"id":…,"error":{…}}`).
8
8
  *
9
9
  * This module owns exactly four things and nothing else:
10
- * 1. executable resolution (pin-faithful: the package's own `@openai/codex`),
10
+ * 1. runtime selection (override → installed desktop → CLI → pinned package),
11
11
  * 2. process spawn + line framing + per-id request/response correlation,
12
12
  * 3. dispatch of server-initiated notifications and server-requests,
13
13
  * 4. teardown (process GROUP kill — the app-server may spawn stdio MCP
@@ -22,7 +22,8 @@
22
22
  * process, and the client calls it on first real work.
23
23
  */
24
24
  import { spawn } from "node:child_process";
25
- import { createRequire } from "node:module";
25
+ import { resolveCodexRuntime } from "./codex-runtime.js";
26
+ export { resolveCodexCommand, resolveCodexRuntime } from "./codex-runtime.js";
26
27
  /** Per-request protocol error surfaced by the server (JSON-RPC `error` object). */
27
28
  export class CodexWireError extends Error {
28
29
  code;
@@ -37,23 +38,6 @@ export class CodexWireError extends Error {
37
38
  /** Server overload: the documented retryable backpressure code. */
38
39
  export const SERVER_OVERLOADED_CODE = -32001;
39
40
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
40
- /** The codex entry script this package pins (`@openai/codex/bin/codex.js`). */
41
- export function resolveCodexCommand() {
42
- const override = process.env.CODEX_BIN;
43
- if (override !== undefined && override.trim() !== "")
44
- return override;
45
- const require = createRequire(import.meta.url);
46
- try {
47
- // Pin-faithful: the SAME `@openai/codex` this package depends on, so the
48
- // wire protocol and the binary version can never drift apart. Set
49
- // CODEX_BIN to override (tests).
50
- return require.resolve("@openai/codex/bin/codex.js");
51
- }
52
- catch (error) {
53
- throw new Error("cannot locate the pinned codex CLI (@openai/codex/bin/codex.js): "
54
- + `${error instanceof Error ? error.message : String(error)}. Install the package or set CODEX_BIN.`);
55
- }
56
- }
57
41
  /** True for a plain JSON object (never arrays/null). */
58
42
  function asRecord(value) {
59
43
  if (value === null || typeof value !== "object" || Array.isArray(value))
@@ -90,17 +74,21 @@ export class CodexAppServerTransport {
90
74
  start() {
91
75
  if (this.#child !== undefined)
92
76
  return;
93
- const command = this.#options.command ?? resolveCodexCommand();
77
+ const env = this.#options.env ?? { ...process.env };
78
+ const runtime = resolveCodexRuntime({
79
+ env: this.#options.command === undefined ? env : { ...env, CODEX_BIN: this.#options.command },
80
+ });
94
81
  const args = [...(this.#options.args ?? ["app-server"])];
95
82
  // `detached` gives the child its own process group so teardown can kill
96
83
  // the whole tree (stdio MCP descendants included), not just the root.
97
- const child = spawn(process.execPath, [command, ...args], {
84
+ const child = spawn(runtime.nodeScript ? process.execPath : runtime.command, runtime.nodeScript ? [runtime.command, ...args] : args, {
98
85
  cwd: this.#options.cwd,
99
- env: this.#options.env ?? { ...process.env },
86
+ env,
100
87
  stdio: ["pipe", "pipe", "pipe"],
101
88
  detached: true,
102
89
  });
103
90
  this.#child = child;
91
+ this.#options.onStderr?.(`codex runtime source=${runtime.source} command=${runtime.command} mode=${runtime.nodeScript ? "node" : "native"}\n`);
104
92
  child.stdout?.setEncoding("utf8");
105
93
  child.stderr?.setEncoding("utf8");
106
94
  child.stdout?.on("data", (chunk) => this.#onStdout(chunk));
@@ -1,35 +1,4 @@
1
- /**
2
- * codex-launch.ts — how a provider reaches a `codex app-server` child
3
- * (2026-10-04 on-the-fly rework).
4
- *
5
- * The retired design put the provider in force by REWRITING the user's
6
- * `~/.codex/config.toml` before the child started. That made the user's native
7
- * home the switchboard: every switch was a disk write, only ever affected the
8
- * next child, and fought every other tool that owns the same file (cc-switch's
9
- * whole-config rewrites, the user's own hand edits).
10
- *
11
- * This module replaces it with process arguments. `codex app-server -c
12
- * <dotted.path>=<value>` overrides any `config.toml` value for THAT child
13
- * alone (the flag's own help text: "Override a configuration value that would
14
- * otherwise be loaded from `~/.codex/config.toml`. Use a dotted path."), and
15
- * `ThreadStartParams`/`ThreadResumeParams` accept the same object as `config`.
16
- * A provider therefore costs nothing on disk and is scoped to one process, which
17
- * is exactly what a per-session provider choice needs.
18
- *
19
- * Measured constraints this module encodes (see
20
- * `docs/test-reports/2026-10-03-codex-provider-model-switch-probe.md`):
21
- *
22
- * - a HYPHENATED id is fine in a dotted path (`model_providers.zhipu-cn.…`),
23
- * so native table names survive verbatim as provider ids;
24
- * - `model_catalog_json` is resolved against the process CWD when it arrives
25
- * as an override, so the path is always absolute;
26
- * - `env_key` is the one auth shape that never consults the auth manager, so a
27
- * stored API key rides the CHILD's environment under a synthetic variable
28
- * name. That is also what makes a keyed provider usable behind a ChatGPT
29
- * login, where `requires_openai_auth` is silently shadowed.
30
- *
31
- * @module agent-codex/codex-launch
32
- */
1
+ import { officialCatalogForLaunch } from "./codex-oauth.js";
33
2
  import { tomlString, writeProviderCatalog } from "./codex-native-config.js";
34
3
  /** The environment variable prefix for a key this adapter injects itself. */
35
4
  const SYNTHETIC_KEY_PREFIX = "SUPERD_CODEX_KEY_";
@@ -48,6 +17,15 @@ export function syntheticKeyName(providerId) {
48
17
  * @returns the argv/env additions and the provider id they pin.
49
18
  */
50
19
  export function providerLaunch(worldHome, codexHome, provider) {
20
+ if (provider.kind === "openai-oauth") {
21
+ const catalog = officialCatalogForLaunch(codexHome);
22
+ // Empty override is filtered to None by Codex, restoring its builtin
23
+ // endpoint without changing the user's native configuration.
24
+ const argv = ["-c", 'model_provider="openai"', "-c", 'openai_base_url=""'];
25
+ if (catalog !== undefined)
26
+ argv.push("-c", `model_catalog_json=${tomlString(catalog)}`);
27
+ return { argv, env: {}, runtimeProvider: "openai" };
28
+ }
51
29
  const table = `model_providers.${provider.id}`;
52
30
  const argv = [
53
31
  "-c", `${table}.name=${tomlString(provider.label)}`,