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.
This commit is contained in:
creatixchu
2026-07-28 21:25:19 +08:00
parent a94df18bcf
commit 51402ac7af
22 changed files with 229 additions and 45 deletions

View File

@@ -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) |

View File

@@ -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:^",

View File

@@ -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

View File

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

View File

@@ -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",

View File

@@ -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 })
},
)
})

View File

@@ -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

View File

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

View File

@@ -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

View File

@@ -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`.

View File

@@ -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` 而引入不相关的包。

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 限量。

View File

@@ -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"
}
}

View File

@@ -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'

View File

@@ -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 */

View File

@@ -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')
})
})

View File

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

15
pnpm-lock.yaml generated
View File

@@ -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':

View File

@@ -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.' },

View File

@@ -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" },