Bladeren bron

refactor(util): extract the shared no-shell native-command runner to dsh-native-command

master's toolcall-open extracted runNativeCommand inside apiproxy for the
openPath opener while the picker seam had moved the native chooser (its other
consumer) into directory-picker-native; after the merge the two packages each
carried a verbatim copy. The runner now lives in packages/util/native-command
(zero-dependency library, per the util-group contract) and both native
integrations depend on it.
creatixchu 2 maanden geleden
bovenliggende
commit
51402ac7af

+ 3 - 0
docs/module-graph.md

@@ -9,6 +9,7 @@ Inter-package dependencies among the `@deepseek-ai/dsh-*` harness packages, deri
 flowchart TD
   subgraph group_util["packages/util"]
     pkg_brand["brand"]
+    pkg_native_command["native-command"]
     pkg_paths["paths"]
     pkg_retention["retention"]
     pkg_timeout["timeout"]
@@ -244,6 +245,7 @@ flowchart TD
     pkg_workspace["workspace"]
   end
   pkg_brand --> pkg_invariants
+  pkg_native_command --> pkg_invariants
   pkg_paths --> pkg_invariants
   pkg_retention --> pkg_invariants
   pkg_timeout --> pkg_invariants
@@ -920,6 +922,7 @@ flowchart TD
 | --- | --- | --- |
 | [`invariants`](../packages/support/invariants) | `support` | — |
 | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/support/invariants) |
+| [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/support/invariants) |
 | [`paths`](../packages/util/paths) | `util` | [`invariants`](../packages/support/invariants) |
 | [`retention`](../packages/util/retention) | `util` | [`invariants`](../packages/support/invariants) |
 | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) |

+ 1 - 0
packages/host/apiproxy/package.json

@@ -45,6 +45,7 @@
     "@deepseek-ai/dsh-commands": "workspace:^",
     "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-native-command": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",

+ 1 - 1
packages/host/apiproxy/src/native-path-opener.ts

@@ -1,6 +1,6 @@
 /** Cross-platform open-with-default-application used by the local GUI carrier. */
 
-import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
+import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
 
 /** Testable command boundary; native implementations never invoke a shell. */
 export type PathOpenerRunner = NativeCommandRunner

+ 3 - 0
packages/host/apiproxy/tsconfig.json

@@ -55,6 +55,9 @@
     },
     {
       "path": "../../support/invariants"
+    },
+    {
+      "path": "../../util/native-command"
     }
   ]
 }

+ 2 - 1
packages/host/directory-picker-native/package.json

@@ -27,7 +27,8 @@
   ],
   "license": "BSD-3-Clause",
   "dependencies": {
-    "@deepseek-ai/dsh-host-directory-picker": "workspace:^"
+    "@deepseek-ai/dsh-host-directory-picker": "workspace:^",
+    "@deepseek-ai/dsh-native-command": "workspace:^"
   },
   "peerDependencies": {
     "@deepseek-ai/dsh-invariants": "^0.0.1",

+ 0 - 38
packages/host/directory-picker-native/src/native-command.ts

@@ -1,38 +0,0 @@
-/** Shared no-shell `execFile` runner for native host dialogs and openers. */
-
-import { execFile } from 'node:child_process'
-
-/** Testable command boundary; native implementations never invoke a shell. */
-export type NativeCommandRunner = (
-  command: string,
-  args: readonly string[],
-  signal: AbortSignal,
-) => Promise<{ stdout: string; stderr: string }>
-
-/**
- * Run a host command with utf8 stdio, abort propagation, and Windows hide.
- * @param command - executable path or PATH name.
- * @param args - argv (never a shell string).
- * @param signal - caller/connection lifetime; abort terminates the child.
- * @returns captured stdout/stderr on exit 0.
- */
-export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
-  new Promise((resolve, reject) => {
-    execFile(
-      command,
-      [...args],
-      { encoding: 'utf8', signal, windowsHide: true },
-      (error, stdout, stderr) => {
-        if (error !== null) {
-          const failure = Object.assign(new Error(error.message, { cause: error }), {
-            code: error.code,
-            stdout,
-            stderr,
-          })
-          reject(failure)
-          return
-        }
-        resolve({ stdout, stderr })
-      },
-    )
-  })

+ 1 - 1
packages/host/directory-picker-native/src/native-picker.ts

@@ -1,6 +1,6 @@
 /** Cross-platform native single-directory chooser behind the dialog backend's capability. */
 
-import { runNativeCommand, type NativeCommandRunner } from './native-command.ts'
+import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
 
 /** Testable command boundary; native implementations never invoke a shell. */
 export type DirectoryPickerRunner = NativeCommandRunner

+ 3 - 0
packages/host/directory-picker-native/tsconfig.json

@@ -19,6 +19,9 @@
     },
     {
       "path": "../../support/invariants"
+    },
+    {
+      "path": "../../util/native-command"
     }
   ]
 }

+ 3 - 3
packages/util/README.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-README.md: 140df90571d84320fb4eb888508c67e60aa29a22
-README.zh.md: 4c16df2a56476c0a7c965a037389fa5ba231e273
+#   pnpm run verify-translation-pairing --write packages/util/README.md
+README.md: 605c3dd0beebc16109e8e6bc944ea722a60975c0
+README.zh.md: 5c66ded33a36079f80965cf466449843e07511f0

+ 1 - 0
packages/util/README.md

@@ -10,6 +10,7 @@ Zero-dependency primitives shared across the other groups. A package lands here
 | `paths/` | Canonical single-root `DSH_HOME` resolution plus shared filesystem path constants and helpers for harness user data (no harness deps) |
 | `timeout/` | The timing/classification half of a timeout — `clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason` (pure functions, no harness deps); termination stays in each capability |
 | `retention/` | Bounded model-facing output — `ItemRetainer`/`TextRetainer` + neutral notice helpers (pure, no harness deps); business semantics stay in each tool |
+| `native-command/` | No-shell `execFile` runner for host-native OS integrations — utf8 capture, abort propagation, Windows hide (no harness deps); command choice stays in each caller |
 
 `dsh-brand` is the canonical case: it owns ONLY the `Branded<B>` helper, so a capability package can brand the ids it owns (`dsh-tasks`'s `TaskId`, `dsh-session`'s `SessionId`, …) by depending on `dsh-brand` alone, without pulling in an unrelated package just to reach `Branded`.
 

+ 1 - 0
packages/util/README.zh.md

@@ -10,6 +10,7 @@
 | `paths/` | 规范的单根 `DSH_HOME` 解析,以及 harness 用户数据的共享文件系统路径常量和辅助工具(无 harness 依赖) |
 | `timeout/` | 超时的时序/分类部分:`clampTimeout`/`deadline`/`timeoutOf`/`TimeoutReason`(纯函数,无 harness 依赖);终止机制保留在各个功能中 |
 | `retention/` | 有界的面向模型输出:`ItemRetainer`/`TextRetainer` 加上中性通知辅助工具(纯工具,无 harness 依赖);业务语义保留在各个工具中 |
+| `native-command/` | 宿主原生 OS 集成的免 shell `execFile` 运行器——utf8 捕获、abort 传播、Windows 窗口隐藏(无 harness 依赖);命令选择保留在各调用方 |
 
 `dsh-brand` 是规范示例:它只负责 `Branded<B>` 辅助工具,因此功能包可以为自己拥有的 id 添加品牌(`dsh-tasks` 的 `TaskId`、`dsh-session` 的 `SessionId` 等),而只需依赖 `dsh-brand`,无需仅为使用 `Branded` 而引入不相关的包。
 

+ 6 - 0
packages/util/native-command/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/util/native-command/README.md
+README.md: 7fc8b1f4640ef87ada62b6656854feb37080e4e6
+README.zh.md: 7c1cacb06d1d58601cc9e63c2ebd9c5f0ccb8159

+ 27 - 0
packages/util/native-command/README.md

@@ -0,0 +1,27 @@
+# dsh-native-command
+
+English | [中文](README.zh.md)
+
+A **zero-dependency no-shell `execFile` runner** shared by host-native OS integrations: one `runNativeCommand(command, args, signal)` call spawns the executable directly (never a shell string), captures utf8 stdout/stderr, propagates the caller's abort into child termination, and hides the transient console window on Windows. Failures reject with the exit `code` and both captured streams attached, so callers classify (missing tool, cancelled, real failure) without re-running anything.
+
+Its two consumers are the host-side native integrations: the [`directory-picker-native`](../../host/directory-picker-native/README.md) backend's OS chooser commands and the gateway's open-with-default-application hand-off ([`dsh-host-apiproxy`](../../host/apiproxy/README.md) `host.openPath`). The `NativeCommandRunner` type is the injectable command boundary those callers expose for deterministic tests.
+
+It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state, emits no events.
+
+## Surface
+
+```ts
+import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
+```
+
+## Model Experience
+
+None, as this is host-side subprocess plumbing; nothing here reaches a model request.
+
+#### KV Cache effect
+
+None; this package neither assembles nor sends a provider request.
+
+## Known Limitations and Deferred Work
+
+- **No output bounding** — both streams buffer unbounded in memory; every current caller invokes small native tools whose output is a path or an error line. Adopt `dsh-retention` bounding before pointing this at commands with meaningful output volume.

+ 27 - 0
packages/util/native-command/README.zh.md

@@ -0,0 +1,27 @@
+# dsh-native-command
+
+[English](README.md) | 中文
+
+宿主原生 OS 集成共享的**零依赖免 shell `execFile` 运行器**:一次 `runNativeCommand(command, args, signal)` 调用直接派生可执行文件(绝不拼 shell 字符串),以 utf8 捕获 stdout/stderr,把调用方的 abort 传播为子进程终止,并在 Windows 上隐藏瞬时控制台窗口。失败时以附带退出 `code` 与两路已捕获输出的错误拒绝,调用方无需重跑即可分类(工具缺失、已取消、真实失败)。
+
+它的两个消费者都是宿主侧原生集成:[`directory-picker-native`](../../host/directory-picker-native/README.zh.md) 后端的 OS 选择器命令,以及网关的按默认应用打开转交([`dsh-host-apiproxy`](../../host/apiproxy/README.zh.md) 的 `host.openPath`)。`NativeCommandRunner` 类型是这些调用方为确定性测试暴露的可注入命令边界。
+
+它是**库,不是服务或插件**:没有 `ctx`、不注册任何东西、不持有状态、不发事件。
+
+## Surface
+
+```ts
+import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
+```
+
+## Model Experience
+
+无;这是宿主侧子进程管道,这里没有任何东西进入模型请求。
+
+#### KV Cache effect
+
+无;该包既不组装也不发送 provider 请求。
+
+## Known Limitations and Deferred Work
+
+- **不做输出限量**——两路流在内存中无界缓冲;当前每个调用方只运行输出为一个路径或一行错误的小型原生工具。把它指向输出量可观的命令之前,先接入 `dsh-retention` 限量。

+ 37 - 0
packages/util/native-command/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "@deepseek-ai/dsh-native-command",
+  "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ],
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "cordis": "^4.0.0-rc.7"
+  }
+}

+ 7 - 1
packages/host/apiproxy/src/native-command.ts → packages/util/native-command/src/index.ts

@@ -1,4 +1,10 @@
-/** Shared no-shell `execFile` runner for native host dialogs and openers. */
+/**
+ * Shared no-shell `execFile` runner for host-native OS integrations (the
+ * native directory chooser, the open-with-default-application hand-off):
+ * utf8 stdio capture, abort propagation, Windows console hide. A library,
+ * not a plugin — no ctx, no state, no events.
+ * @module @deepseek-ai/dsh-native-command
+ */
 
 import { execFile } from 'node:child_process'
 

+ 31 - 0
packages/util/native-command/src/invariant.ts

@@ -0,0 +1,31 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-native-command`.
+ * @module @deepseek-ai/dsh-native-command/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-native-command'
+
+/** Cordis companion plugin name. */
+export const name = 'native-command-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: each run is one stateless child-process round trip
+ * with no owned event stream or mutable runtime data; behavior is enforced by
+ * unit tests.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 43 - 0
packages/util/native-command/tests/native-command.spec.ts

@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest'
+import { runNativeCommand } from '@deepseek-ai/dsh-native-command'
+
+const node = process.execPath
+
+describe('runNativeCommand', () => {
+  it('captures utf8 stdout and stderr on exit 0', async () => {
+    const result = await runNativeCommand(
+      node,
+      ['-e', 'process.stdout.write("out✓"); process.stderr.write("err")'],
+      new AbortController().signal,
+    )
+    expect(result).toEqual({ stdout: 'out✓', stderr: 'err' })
+  })
+
+  it('rejects a non-zero exit with code, stdout, and stderr attached', async () => {
+    const failure = await runNativeCommand(
+      node,
+      ['-e', 'process.stdout.write("partial"); process.stderr.write("boom"); process.exit(3)'],
+      new AbortController().signal,
+    ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
+    expect(failure).toMatchObject({ code: 3, stdout: 'partial', stderr: 'boom' })
+    expect((failure as Error).cause).toBeInstanceOf(Error)
+  })
+
+  it('rejects a missing executable with the spawn ENOENT code', async () => {
+    const failure = await runNativeCommand(
+      'dsh-definitely-missing-command',
+      [],
+      new AbortController().signal,
+    ).then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
+    expect(failure).toMatchObject({ code: 'ENOENT' })
+  })
+
+  it('terminates the child when the signal aborts', async () => {
+    const abort = new AbortController()
+    const pending = runNativeCommand(node, ['-e', 'setTimeout(() => {}, 60_000)'], abort.signal)
+    abort.abort()
+    const failure = await pending.then(() => { throw new Error('unexpected resolve') }, (error: unknown) => error)
+    expect(failure).toBeInstanceOf(Error)
+    expect((failure as { code?: unknown }).code).toBe('ABORT_ERR')
+  })
+})

+ 15 - 0
packages/util/native-command/tsconfig.json

@@ -0,0 +1,15 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 15 - 0
pnpm-lock.yaml

@@ -2682,6 +2682,9 @@ importers:
       '@deepseek-ai/dsh-llm':
         specifier: workspace:^
         version: link:../../llm/llm
+      '@deepseek-ai/dsh-native-command':
+        specifier: workspace:^
+        version: link:../../util/native-command
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
@@ -2753,6 +2756,9 @@ importers:
       '@deepseek-ai/dsh-host-directory-picker':
         specifier: workspace:^
         version: link:../directory-picker
+      '@deepseek-ai/dsh-native-command':
+        specifier: workspace:^
+        version: link:../../util/native-command
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
@@ -4797,6 +4803,15 @@ importers:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
+  packages/util/native-command:
+    devDependencies:
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/util/paths:
     devDependencies:
       '@deepseek-ai/dsh-invariants':

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -116,6 +116,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/ui/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
   'packages/util/timeout': { kind: 'indirect', reason: 'Only timeout consumers render timeout outcomes.' },
   'packages/util/retention': { kind: 'indirect', reason: 'Only retention consumers render retained content and omission metadata.' },
+  'packages/util/native-command': { kind: 'none', reason: 'The host-side subprocess runner registers no model surface.' },
   'packages/web/web': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-web.' },
   'packages/web/web-fetch-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },
   'packages/web/web-search-exa': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-web.' },

+ 1 - 0
tsconfig.host.json

@@ -46,6 +46,7 @@
     { "path": "./vendor/hmr" },
     { "path": "./vendor/logger-console" },
     { "path": "./packages/util/brand" },
+    { "path": "./packages/util/native-command" },
     { "path": "./packages/util/paths" },
     { "path": "./packages/util/timeout" },
     { "path": "./packages/util/retention" },