<!-- Source: https://deepseek-browser-use.io.nexus/29 -->

# DeepSeek Harness 插件开发入门：从入口到第一个工具

如果你会写 Java 或 JavaScript，但还不知道 Harness 插件从哪里开始，本系列按四个步骤带你理解并修改 `@litongjava/dsh-plugin-deepseek-browser-use`：建立插件入口、接入基础浏览器命令、处理会话和异步作业、安装到宿主。

本篇的目标是先看懂一个工具如何被 Harness 调用，并运行一个不需要浏览器的最小示例。下一篇再接入真实浏览器服务。示例以本仓库插件 **0.2.0** 和它固定的 Harness **0.2.0-rc.2** API 为基线，不代表其他版本接口完全相同。

## 1. 先分清三个程序

```text
Harness：与模型对话，管理 Agent，选择并执行工具
  └─ TypeScript 插件：注册 dsb_*，校验参数，管理任务，发送 HTTP
       └─ Java 浏览器服务：接收 /playwright/command，操作真实浏览器
```

插件运行在 Harness 后端的 Node.js 进程中，不运行在用户打开的 Web 页面里。Java 服务可以在同机，也可以在远程。插件通过 HTTP 使用已有浏览器能力，因此这里不需要重新用 TypeScript 实现 Playwright，也不需要调用 Python 或 `dsb` 子进程。

先认识后面会反复出现的几个词：

| 名称 | 在本项目中的含义 |
| --- | --- |
| Cordis | 宿主使用的插件和服务机制；入口通过 `apply(ctx)` 接收上下文 |
| Context / `ctx` | 获取宿主服务、注册资源及其清理回调的对象 |
| Agent | 当前正在执行工具的活跃智能体对象；本插件用它确定浏览器任务归属 |
| ToolDefinition | 工具定义：名称、说明、参数、执行函数、输出与展示方式 |
| ToolRuntime | 注册和派发工具的宿主运行时 |
| Schema | 描述数据结构；配置和工具调用分别使用不同的校验入口 |
| bundle / patch | 安装包声明及插件配置补丁；让宿主知道应该加载哪个插件 |

`dsb_navigate` 是模型看见的工具名，`go_to_url` 是 Java 服务的方法名，二者不必相同。插件承担的一个职责就是把适合模型使用的接口转换成服务协议。

## 2. 准备工程，而不是先安装一堆不确定的依赖

需要 Node.js >= 22.19、npm 和仓库源码。本篇的最小工具不需要 Java 或 Chrome；第二篇联调时才需要按 [02](<https://deepseek-browser-use.io.nexus/02>) 启动 Java 服务。已有仓库可以直接进入插件目录：

```powershell
cd deepseek-browser-use\plugins\deepseek-browser-use
node --version
npm --version
npm ci --ignore-scripts --legacy-peer-deps
npm run build
```

没有源码时，先克隆，再进入 `plugins/deepseek-browser-use`：

```powershell
git clone https://gitee.com/ppnt/deepseek-browser-use.git
cd deepseek-browser-use/plugins/deepseek-browser-use
```

上面两个路径入口二选一。`npm ci` 依据现有 `package-lock.json` 安装；新建空目录还没有锁文件时不能直接使用它。本系列以现有插件为可运行底座，逐步讲解和扩展，避免读者拼接片段后缺少依赖或辅助函数。

`--legacy-peer-deps` 用于独立插件开发目录的依赖安装，不表示任意 Harness 版本都兼容。部署到宿主时仍需匹配包中声明的 peerDependencies。

## 3. package.json 为什么这样写

下面是插件当前的完整 `package.json`：

```json
{
  "name": "@litongjava/dsh-plugin-deepseek-browser-use",
  "version": "0.2.0",
  "description": "Native DeepSeek Harness tools for the deepseek-browser-use HTTP service",
  "type": "module",
  "license": "MIT",
  "engines": {
    "node": ">=22.19.0"
  },
  "exports": {
    ".": "./dist/index.js",
    "./client": "./dist/client.js",
    "./package.json": "./package.json",
    "./locale/*.json": "./locale/*.json"
  },
  "types": "./dist/index.d.ts",
  "files": [
    "dist",
    "locale",
    "cordis.patch.yml",
    "scripts/backend.ps1",
    "README.md"
  ],
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  },
  "scripts": {
    "build": "tsc -p tsconfig.json",
    "test": "npm run build && node --test test/*.test.mjs",
    "prepack": "npm run build",
    "smoke": "npm run build && node scripts/smoke.mjs"
  },
  "dependencies": {
    "@deepseek-ai/schemastery": "3.18.4",
    "zod": "4.1.12"
  },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.4",
    "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
    "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
    "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
    "@deepseek-ai/dsh-tools": "0.2.0-rc.2"
  },
  "peerDependenciesMeta": {
    "@deepseek-ai/dsh-attachment": {
      "optional": true
    },
    "@deepseek-ai/dsh-llm": {
      "optional": true
    }
  },
  "devDependencies": {
    "@deepseek-ai/cordis": "4.0.4",
    "@deepseek-ai/dsh-agent": "0.2.0-rc.2",
    "@deepseek-ai/dsh-attachment": "0.2.0-rc.2",
    "@deepseek-ai/dsh-llm": "0.2.0-rc.2",
    "@deepseek-ai/dsh-sandbox": "^0.2.0-rc.2",
    "@deepseek-ai/dsh-scope": "^0.2.0-rc.2",
    "@deepseek-ai/dsh-tools": "0.2.0-rc.2",
    "@types/node": "22.19.0",
    "typescript": "5.9.3"
  }
}
```

不要只把它当成 npm 的名字和版本。对插件来说，几个字段直接决定能否加载：

- `type: "module"`：输出使用 ESM 的 `import/export`。
- `exports["."]`：宿主加载包时进入 `dist/index.js`，所以只写 `src/index.ts` 还不能安装运行。
- `types`：让 TypeScript 使用者拿到声明文件，不负责执行代码。
- `files`：决定打包进入 `.tgz` 的文件；`scripts/backend.ps1` 是后端自动管理实际要运行的脚本，不能漏掉。
- `dsh.bundle.patch`：指向安装配置；只有编译入口而没有激活配置，仍不等于插件已启用。
- `dependencies`：插件自己的运行依赖，如 Zod、Schemastery。
- `peerDependencies`：由宿主提供、需要版本兼容的服务包。开发目录也在 `devDependencies` 声明它们，便于编译与测试。
- `peerDependenciesMeta`：图片相关服务是可选依赖；没有这些服务时仍可使用 DOM 工具。
- `prepack`：打包前编译，减少把旧 `dist` 当作新代码发布的机会。

`tsconfig.json` 同样是实际文件：

```json
{
  "compilerOptions": {
    "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext",
    "strict": true, "skipLibCheck": true, "declaration": true,
    "outDir": "dist", "rootDir": "src", "types": ["node"]
  },
  "include": ["src/**/*.ts"]
}
```

`rootDir/outDir` 把源文件和输出分开；`declaration` 生成类型声明；`strict` 让类型问题尽早暴露。`NodeNext` 按 Node ESM 规则解析模块，所以源码里写 `import ... from './client.js'`，不是误把 `.ts` 写成了 `.js`：编译后的文件确实叫 `client.js`。

## 4. 写一个完整、可编译的最小工具

先不修改正式的 `src/index.ts`。在同一个插件目录新建 `src/tutorial-hello.ts`，写入以下完整文件：

```typescript
import type { Context } from '@deepseek-ai/cordis';
import Schema from '@deepseek-ai/schemastery';
import type {} from '@deepseek-ai/dsh-tools';

export const name = 'tutorial-hello';
export const inject = ['tools'];

export interface Config { greeting: string; }
export const Config = Schema.object({
  greeting: Schema.string().default('你好'),
});

export function apply(ctx: Context, input: Partial<Config> = {}): void {
  const config = Config(input) as Config;
  ctx.tools.register({
    name: 'tutorial_hello',
    description: 'Return a greeting to a named learner.',
    parameters: {
      type: 'object',
      properties: { learner: { type: 'string' } },
      required: ['learner'],
      additionalProperties: false,
    },
    output: {
      schema: {
        type: 'object',
        properties: { message: { type: 'string' } },
        required: ['message'],
        additionalProperties: false,
      },
      render: (_args, value) => [
        { type: 'text', text: JSON.stringify(value) },
      ],
    },
    async execute(raw, exec) {
      exec.signal.throwIfAborted();
      if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
        throw new Error('Arguments must be an object');
      }
      const args = raw as Record<string, unknown>;
      if (Object.keys(args).some(key => key !== 'learner') ||
          typeof args.learner !== 'string' || !args.learner.trim()) {
        throw new Error('Provide a non-empty learner string');
      }
      return { message: `${config.greeting}，${args.learner}！` };
    },
  });
}
```

逐步看这段代码的作用：

1. `inject = ['tools']` 声明需要宿主工具服务，`ctx.tools.register` 才有对应服务可用。`import type {}` 用于引入服务的 TypeScript 类型扩展，不是在创建服务实例。
2. `Config` 接口只做编译期检查；同名的 `Schema.object(...)` 是运行时的值，会校验配置并补齐默认值。TypeScript 允许类型和值同名。
3. `parameters` 告诉模型怎样调用工具；`execute` 才真正执行。不能因为模型看到了 schema 就省略运行时校验。正式插件用 `z.toJSONSchema` 再经一层投影生成 `parameters`（只保留 Harness 支持的 JSON Schema 子集），所以模型看到的 schema 会更简——`minLength`、`minimum`、`default` 这类约束不会出现在里面，执行时仍由 Zod 完整校验。
4. `exec.signal` 是本次调用的取消信号。工具应该检查它，后续 HTTP 也要继续传递这个信号。
5. `return` 是供程序使用的结果，`output.render` 是面向模型的内容块。把二者分开，后面才能只截短展示文本而保留完整结果。

这个问候工具没有浏览器资源，所以只依赖 `tools`。正式浏览器插件还需要 `agents` 验证调用者、管理任务归属。

## 5. 不启动完整 Harness，也能验证工具派发

新建 `scripts/tutorial-hello.mjs`，以下是完整文件。它使用真正的 ToolRuntime，但只提供本例需要的最少宿主服务：

```javascript
import assert from 'node:assert/strict';
import { Context } from '@deepseek-ai/cordis';
import { ToolRuntime } from '@deepseek-ai/dsh-tools';
import { apply } from '../dist/tutorial-hello.js';

const ctx = new Context();
ctx.provide('systemPrompt', { tools() {} });
const runtime = new ToolRuntime(ctx);
apply(ctx, { greeting: '欢迎' });

const result = await runtime.execute({
  name: 'tutorial_hello',
  arguments: { learner: '插件开发者' },
  callId: 'tutorial-1',
  signal: new AbortController().signal,
});
assert.equal(result.isError, false, JSON.stringify(result));
assert.equal(result.value.message, '欢迎，插件开发者！');
console.log(result.value);
await ctx.fiber.dispose();
```

在插件目录执行：

```powershell
npm run build
node scripts/tutorial-hello.mjs
```

预期输出包含 `欢迎，插件开发者！`。这验证了“注册 → 派发 → 执行 → 规范结果”链路，不代表已经安装到目标 Harness。正式包入口仍是 `dist/index.js`；练习文件不会自动成为 bundle 入口。

## 6. 对照真正的浏览器插件入口

正式 `src/index.ts` 中的以下片段，去掉了后端自动管理部分以突出装配关系；它依赖原文件的 import 和配置，**不能单独复制成完整文件**：

```typescript
export const name = 'litongjava-browser-use';
export const inject = ['tools', 'agents'];

// apply 内部，在 config、backend 和 client 已创建之后：
const sessions = new BrowserSessions(client, {
  browser: config.browser,
  headless: config.headless,
});
ctx.effect(() => () => sessions.dispose(), 'deepseek-browser-use.sessions');
for (const tool of createTools(ctx, client, sessions, config)) {
  ctx.tools.register(tool);
}
```

入口负责把对象连接起来，不应把 HTTP、会话、上传全部堆在 `apply` 中。`ctx.effect` 的外层回调注册资源，返回的内层函数在作用域释放时清理会话；注册时不会立即执行 `sessions.dispose()`。

正式包的分工如下：

| 文件 | 要解决的问题 | 接下来在哪篇展开 |
| --- | --- | --- |
| `src/index.ts` | 如何被宿主加载，怎样创建服务对象 | 本篇、安装篇 |
| `src/client.ts` | 怎样请求 Java 服务并可靠读取响应 | [基础功能](<https://deepseek-browser-use.io.nexus/30>) |
| `src/tools.ts` | 怎样把浏览器能力转换成模型工具 | [基础功能](<https://deepseek-browser-use.io.nexus/30>)、[核心功能](<https://deepseek-browser-use.io.nexus/31>) |
| `src/sessions.ts` | 谁拥有页面，如何排队和释放任务 | [核心功能](<https://deepseek-browser-use.io.nexus/31>) |
| `src/commands.ts` | 通用入口允许哪些准确命令名 | [核心功能](<https://deepseek-browser-use.io.nexus/31>) |
| `cordis.patch.yml` | 怎样安装和启用 | [安装篇](<https://deepseek-browser-use.io.nexus/32>) |
| `src/backend.ts`、`scripts/backend.ps1` | 怎样自动准备并启动 Java 服务 | [35](<https://deepseek-browser-use.io.nexus/35>) |

下一篇：[开发 dsh-plugin-deepseek-browser-use：基础功能](<https://deepseek-browser-use.io.nexus/30>)。
