dsh-thesis 1.0.0 → 1.0.1

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/docs/host-api.md CHANGED
@@ -1,640 +1,692 @@
1
- # DSH 第三方插件宿主 API 参考(0.1.5-rc.1)
2
-
3
- 只记录契约:签名、类型、事件名、注册与加载规则。签名逐字摘自已安装产物的 `lib/types/**/*.d.ts` 与 `lib/*.js`,
4
- 冲突处已标注。安装根:`C:\Users\TTTT\AppData\Local\npm-cache\_npx\1e7f6d9597241db0\node_modules\@deepseek-ai\`
5
-
6
- ---
7
-
8
- ## 1. 插件模块契约(`@deepseek-ai/cordis`)
9
-
10
- ```ts
11
- namespace Plugin { // cordis/lib/types/registry.d.ts
12
- interface Base<T = any> {
13
- name?: string; Config?: StandardSchemaV1<any, T>; inject?: Inject
14
- provide?: string | string[]; intercept?: Dict<boolean>
15
- }
16
- interface Function<T = any> extends Base<T> { (ctx: Context, config: T): any }
17
- interface Constructor<T = any> extends Base<T> { new (ctx: Context, config: T): any }
18
- interface Object<T = any> extends Base<T> { apply(ctx: Context, config: T): any }
19
- }
20
- type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }
21
- // name=诊断名;Config=启动前校验 config 的 Standard Schema(schemastery 满足);inject=依赖服务名
22
-
23
- export const name = 'my-plugin'
24
- export const inject = ['fs'] // 服务齐备前保持 PENDING,不加载
25
- export const Config = z.object({ stateDir: z.string().default('.my-plugin') })
26
- export type MyConfig = z.infer<typeof Config>
27
- export function apply(ctx: Context, config: MyConfig = {}): void { /* ... */ }
28
- ```
29
-
30
- 函数插件用 `export const inject`,class 插件用 `static inject`,延迟到服务就绪再跑用 `ctx.inject(deps, cb)`。
31
- **函数插件不要 `export default`**(只有 class service 用 default export)。
32
-
33
- ### 1.1 `ctx` 核心方法
34
-
35
- ```ts
36
- interface Context { // cordis/lib/types/context.d.ts
37
- root: this; baseUrl?: string
38
- events: EventsService; logger: LoggerService; reflect: ReflectService
39
- registry: RegistryService; fiber: Fiber
40
- get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
41
- set<K extends string & keyof this>(name: K, value: undefined | this[K]): void
42
- provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void // 返回注销 disposer
43
- // 以上三者各另有 (name: string, ...) => any 的非类型化重载
44
- accessor(name: string, options: Omit<Property.Accessor, 'type'>): void
45
- mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void
46
- on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
47
- once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
48
- emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void
49
- parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>
50
- serial<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
51
- bail<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
52
- waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
53
- plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
54
- inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
55
- }
56
- interface EventOptions { prepend?: boolean; global?: boolean }
57
- interface Fiber {
58
- uid: number | null; readonly ctx: Context; config: any
59
- state: FiberState // PENDING=0 LOADING=1 ACTIVE=2 FAILED=3 DISPOSED=4 UNLOADING=5
60
- readonly dispose: () => Promise<void>; await(): Promise<this>; restart(): Promise<void>
61
- update(config: any, noSave?: boolean): void | Promise<void>
62
- effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
63
- effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
64
- getEffects(): EffectMeta[]
65
- }
66
- ```
67
-
68
- **disposal**:`provide` / `on` / `effect` 的 disposer 都归属当前 fiber,fiber 卸载时按注册**逆序**自动执行。
69
- `ctx.effect(fn, label)` 的 `fn` 可返回 disposer、disposer 数组、`Promise<disposer>` 或 async generator:
70
- `ctx.effect(() => { const t = setInterval(tick, 1000); return () => clearInterval(t) }, 'my-plugin.interval')`。
71
-
72
- ### 1.2 `Config`(schemastery `z`)
73
-
74
- ```ts
75
- z.string(): Schema<string>; z.number(): Schema<number>; z.natural(): Schema<number>; z.percent(): Schema<number>
76
- z.boolean(): Schema<boolean>; z.date(): Schema<string | Date, Date>; z.regExp(flag?): Schema<string | RegExp, RegExp>
77
- z.array<X>(inner: X): Schema<TypeS<X>[], TypeT<X>[]>
78
- z.dict<X, Y extends Schema<any, string> = Schema<string>>(inner: X, sKey?: Y): Schema<Dict<...>, Dict<...>>
79
- z.object<X extends Dict>(dict: X): Schema<ObjectS<X>, ObjectT<X>>
80
- z.union<const X>(list: readonly X[]): Schema<TypeS<X>, TypeT<X>>
81
- z.intersect<const X>(list: readonly X[]): Schema<IntersectS<X>, IntersectT<X>>
82
- z.transform<X, T>(inner: X, cb: (v: TypeS<X>, o: Options) => T, preserve?: boolean): Schema<TypeS<X>, T>
83
- // 链式:.default(v) .description(text) .required(v?) .hidden(v?) .loose(v?) .disabled(v?)
84
- // .collapse(v?) .role(text, extra?) .link(s) .comment(s) .min(n) .max(n) .step(n) .set(k, schema)
85
- ```
86
-
87
- Cordis 用 `resolveConfig(runtime, config)` 把 `Config` 当 Standard Schema 校验;失败 → fiber `FAILED`,`await fiber` 抛 `ValidationError`。
88
- 服务声明可 `declare module '@deepseek-ai/cordis' { interface Context { myService: MyService } }`,也可不声明类型而用
89
- `ctx.provide('myService', obj)` + `ctx.get('myService')`(本插件用后者规避对 `@deepseek-ai/dsh-*` 的类型依赖)。
90
-
91
- ---
92
-
93
- ## 2. 工具:`@deepseek-ai/dsh-tools`
94
-
95
- ### 2.1 `defineTool` 精确签名
96
-
97
- ```ts
98
- export declare function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
99
- options: DefineToolOptions<S, O>): ToolDefinition
100
-
101
- export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends ValueSchemaSpec> {
102
- readonly name: string
103
- readonly description: string
104
- readonly parameters: S // 隐式开放对象根 —— 是 DSL,不是 JSON Schema
105
- readonly output: {
106
- readonly schema: O
107
- render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
108
- presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
109
- }
110
- readonly timeoutMs?: number
111
- isConcurrencySafe?(args: InferArgs<S>): boolean
112
- execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
113
- finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
114
- presentCall?(args: InferArgs<S>): ToolCallView | undefined
115
- presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
116
- }
117
- ```
118
-
119
- ### 2.2 参数/输出 schema DSL(全部字段描述符)
120
-
121
- ```ts
122
- interface ValueSchemaAnnotations { description?: string; title?: string; default?: JsonValue; examples?: JsonValue }
123
- interface StringValueSchemaSpec extends ValueSchemaAnnotations { type: 'string'; enum?: readonly string[]; const?: string }
124
- interface NumberValueSchemaSpec extends ValueSchemaAnnotations { type: 'number'; enum?: readonly number[]; const?: number }
125
- interface IntegerValueSchemaSpec extends ValueSchemaAnnotations { type: 'integer'; enum?: readonly number[]; const?: number }
126
- interface BooleanValueSchemaSpec extends ValueSchemaAnnotations { type: 'boolean'; enum?: readonly boolean[]; const?: boolean }
127
- interface NullValueSchemaSpec extends ValueSchemaAnnotations { type: 'null'; enum?: readonly null[]; const?: null }
128
- interface ArrayValueSchemaSpec extends ValueSchemaAnnotations { type: 'array'; items?: ValueSchemaSpec }
129
- interface ObjectValueSchemaSpec extends ValueSchemaAnnotations { // additionalProperties 必填:显式声明开放性
130
- type: 'object'; properties?: ParameterSchemaSpec; additionalProperties: boolean }
131
- interface JsonValueSchemaSpec extends ValueSchemaAnnotations { type: 'json' } // 仅供作者,编译为注解式 schema
132
- interface OneOfValueSchemaSpec extends ValueSchemaAnnotations { // 至少两个分支
133
- oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]] }
134
- type ValueSchemaSpec = StringValueSchemaSpec | NumberValueSchemaSpec | IntegerValueSchemaSpec
135
- | BooleanValueSchemaSpec | NullValueSchemaSpec | ArrayValueSchemaSpec
136
- | ObjectValueSchemaSpec | JsonValueSchemaSpec | OneOfValueSchemaSpec
137
- type ParameterPropertySpec = ValueSchemaSpec & { required?: true } // required 是每属性注解
138
- type ParameterSchemaSpec = { [key: string]: ParameterPropertySpec; [key: symbol]: never }
139
- type InferValue<S> = /* 精确推导,最多 16 层容器,之后退化为 JsonValue */
140
- type InferArgs<S> = InferProperties<S, []>
141
- ```
142
-
143
- `defineTool` 用 `parameterSchemaSpecToJsonSchema` / `valueSchemaSpecToJsonSchema` 编译成受支持的 JSON Schema 子集;
144
- 每次 `execute` 前用 `validateArgs(spec, args)`(= `validateJsonSchemaValue`)校验,违规抛 `ToolArgsError`(`code: 'INVALID_ARGS'`)。
145
-
146
- ### 2.3 约定与 `exec` 上下文
147
-
148
- ```ts
149
- interface ToolExecutionInput {
150
- readonly callId: ToolCallId; readonly rootCallId?: ToolCallId; readonly name: string
151
- readonly arguments: unknown; readonly agent?: Agent // 无 agent 的组合为 undefined
152
- readonly parent?: ToolExecutionToken; readonly signal: AbortSignal
153
- }
154
- interface ToolExecution extends ToolExecutionInput { readonly rootCallId: ToolCallId; readonly token: ToolExecutionToken }
155
- interface ToolRunContext extends ToolExecution {
156
- deferContext(context: UserMessage): void // 结果之后注入的上下文
157
- concludeTurn(): void // 成功即结束当前轮
158
- }
159
- const cwd = exec.agent?.session.header.cwd // string | undefined ← 会话工作目录
160
- ```
161
-
162
- - `execute` 只返回**规范 JSON 值**;不符 `output.schema` → `ToolOutputError`(`violations: string[]`)。
163
- > **不一致**:`.d.ts` 写 `constructor(toolName: string, violations: string[])`,`lib/index.js` 实为 `constructor(violations)`;以运行时为准。
164
- - `render(args, value)` 的返回值就是模型看到的 `content`(`ContentBlock[]`,通常 `[{ type: 'text', text }]`)。
165
- - `execute` 抛出 → `Error: <message>` 的 `isError` 结果;结构化身份走 `ToolFailure.info = { name, code }`。失败不结束轮次。
166
- - 取消:调用前取消 → `ABORTED_BEFORE_DISPATCH`;调用后取消 → `ABORTED`。必须把 `exec.signal` 传给下游 I/O。
167
- - `timeoutMs` 只是**声明**,注册表不强制;需要 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。
168
- - `Agent` 另有 `agent.ctx`(agent 作用域 Context)、`agent.inbox`、`agent.status`、`agent.options`、`agent.session`、
169
- `cancel(cause, opts?)`、`whenIdle()`、`runMaintenance(task)`、`inject/steer/followup/send`。
170
-
171
- ### 2.4 注册
172
-
173
- ```ts
174
- ctx.tools.register(definition: ToolDefinition): () => void // 返回注销 disposer
175
- ctx.tools.guard(guard: (exec: Readonly<ToolExecution>) => string | undefined): () => void
176
- ctx.tools.restrict(filter: { allow?: readonly string[]; deny?: readonly string[] }): () => void
177
- ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined
178
- ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]
179
- ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
180
- ```
181
-
182
- 注册强制:同层重名抛错;`run_code` 为 PTC 保留名(注册/遮蔽均抛错);`output` 必须是带 `render` 函数的对象;
183
- `output.schema` 必须落在受支持子集内;`timeoutMs` 若是则必须为正有限数。
184
- 流程:`tools/pre-execute` → `ctx.tools.guard()` → `tools/execute` → `tools/post-execute` → `finalizeContent` → `tools/result`。
185
- `restrict()` 要求 agent 作用域 Context(`agent.ctx`);空过滤器与未知工具名(含保留名)抛错。
186
-
187
- ### 2.5 最小可运行工具
188
-
189
- ```ts
190
- import { defineTool } from '@deepseek-ai/dsh-tools'
191
- import type { Context } from '@deepseek-ai/cordis'
192
-
193
- export function registerTools(ctx: Context, readAll: (cwd: string, rel: string, signal: AbortSignal) => Promise<string>): void {
194
- ctx.tools.register(defineTool({
195
- name: 'my_read',
196
- description: 'Read one file inside the current session workspace.',
197
- parameters: { path: { type: 'string', required: true }, maxLines: { type: 'integer' } },
198
- output: {
199
- schema: { type: 'object', additionalProperties: false, properties: {
200
- path: { type: 'string', required: true }, text: { type: 'string', required: true } } },
201
- render: (_a, v) => [{ type: 'text', text: v.text }],
202
- },
203
- async execute(args, exec) {
204
- const cwd = exec.agent?.session.header.cwd ?? process.cwd()
205
- return { path: args.path, text: await readAll(cwd, args.path, exec.signal) }
206
- },
207
- }))
208
- }
209
- ```
210
-
211
- ---
212
-
213
- ## 3. 文件系统:`@deepseek-ai/dsh-fs`
214
-
215
- `ctx.fs` 是抽象 `FileSystem extends Service`;**全部**成员如下(不省略,无其他方法):
216
-
217
- ```ts
218
- abstract class FileSystem extends Service {
219
- get sandboxMode(): SandboxMode | undefined // 基类/裸本机后端为 undefined(不限制)
220
- abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
221
- abstract processPath(target: FsTarget): string
222
- processPathFromHostPath(hostPath: string): string | undefined
223
- abstract fileUrl(target: FsTarget): string
224
- abstract contains(parent: FsTarget, child: FsTarget): boolean
225
- abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
226
- abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
227
- abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
228
- abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
229
- abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
230
- abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array>
231
- abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
232
- abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent,
233
- signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsWriteOutcome>
234
- abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion },
235
- signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsEditOutcome>
236
- }
237
-
238
- interface FsTarget { targetKey: FsTargetKey; displayPath: string } // targetKey 不透明,禁止解析
239
- interface FsInfo { version: FsVersion; type: 'file' | 'directory' | 'other'; size?: number }
240
- interface FsPathInfo { version: FsVersion; type: 'file' | 'directory' | 'symlink' | 'other'; size?: number }
241
- interface FsDirEntry { name: string; type: 'file' | 'directory' | 'other'; target: FsTarget; version?: FsVersion; size?: number }
242
- type FsObservation = { kind: 'present'; version: FsVersion } | { kind: 'absent' }
243
- type FsWriteIntent = { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion }
244
- interface FsEditRequest { oldString: string; newString: string; replaceAll: boolean }
245
- interface FsWriteOutcome { operation: 'create' | 'update'; version: FsVersion; before: string | null; after: string }
246
- interface FsEditOutcome { version: FsVersion; before: string; after: string }
247
- type FsErrorCode = 'FS_NOT_FOUND' | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' | 'FS_TOO_LARGE'
248
- | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED'
249
- | 'FS_AMBIGUOUS_EDIT' | 'FS_EDIT_NOT_FOUND' | 'FS_ABORTED'
250
- class FsError extends HarnessError { readonly code: FsErrorCode; constructor(message: string, code: FsErrorCode, options?: ErrorOptions) }
251
- ```
252
-
253
- ### 3.1 关键契约(也是"缺失能力"的正式答案)
254
-
255
- - **没有 `exists()`**:存在性 = `await fs.stat(target) !== undefined`(或 `lstat(path)`)。
256
- - **没有递归列举**:`listDir` 只返回直接子项,按名字稳定排序、不含内容;递归/glob/分页/搜索要自己实现。
257
- - **没有 delete / rename / copy / watch**:整个 seam 就是上面 13 个原语(README 明写"只有十三个原语")。
258
- - **变更只支持文本**:`writeText`/`editText` 遇二进制或非 UTF-8 → `FS_NOT_TEXT`;`readBytes`/`readByteRange` 是原始字节**读**原语,
259
- **二进制安全的写入不存在**。
260
- - **路径解析**:相对路径以 `opts.cwd` 为基准(省略用后端默认基准)。`resolve` 跟随符号链接得到稳定身份 → 同一文件不同路径产生
261
- 同一 `targetKey`;"不跟随最后一跳"用 `lstat(path, { cwd })`。`displayPath` 才是给模型/UI 的路径。
262
- - **原子性**:写入与编辑都原子;编辑在可选 `expected.version` 下先校验新鲜度再字面匹配
263
- (`FS_STALE_VERSION` / `FS_AMBIGUOUS_EDIT` / `FS_EDIT_NOT_FOUND`)。
264
- - **上限**:`readBytes` 超 `maxBytes` → `FS_TOO_LARGE`(不截断);`readByteRange` 由调用方对 `length` 负责。
265
- - **无 I/O deadline**;取消只有每原语上的可选 `AbortSignal`。
266
-
267
- ### 3.2 `fs/*` 策略事件(全部三个)
268
-
269
- ```ts
270
- // 单槽决策 waterfall:第一个返回值即决策,绝不调用 next()。next() = 无条件写入。
271
- 'fs/write-intent'(target: FsTarget, actor: object | undefined,
272
- next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
273
- // 单槽决策 waterfall:next() = 无条件编辑。
274
- 'fs/edit-intent'(target: FsTarget, actor: object | undefined,
275
- next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
276
- // emit:同步记录;抛错会让工具调用失败,返回的 Promise 不会被 await。
277
- 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
278
- ```
279
-
280
- `actor` 是发起调用的执行对象(`dsh-tool-fs` 传 `exec`;策略按 `actor.name === 'edit' | 'write'` 判定)。
281
-
282
- ---
283
-
284
- ## 4. 人类斜杠命令:`@deepseek-ai/dsh-commands`
285
-
286
- ```ts
287
- ctx.commands.register(definition: CommandDefinition): () => void
288
-
289
- interface CommandDefinition {
290
- readonly name: string // 斜杠后的小写名,不含 '/'
291
- readonly description: string
292
- readonly input?: CommandInputDescriptor
293
- readonly recordInput?: boolean // 默认 true;设 false 让命令自己的领域事件持有输入载荷
294
- readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
295
- }
296
- interface CommandInputDescriptor { readonly hint: string; readonly attachments?: boolean }
297
- interface CommandInvocation {
298
- readonly commandId: CommandId; readonly agent: Agent // 精确的接收 agent
299
- readonly rawInput: string // 命令名之后的每个字节,含分隔空白
300
- readonly attachments: readonly (ImageBlock | FileBlock)[]; readonly signal: AbortSignal
301
- }
302
- type CommandResult =
303
- | { kind: 'success'; text?: string; sourceEventSeq?: SessionSeq }
304
- | { kind: 'error'; text: string }
305
- ```
306
-
307
- **名称规则(运行时强制)**:`const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u`;不匹配 →
308
- `TypeError: command name "X" must match /^[a-z][a-z0-9_-]*$/u`。同层重名注册抛错。
309
- **命令行语法**:第 0 字节必须是 `/`,随后小写名,名后为行尾或**空白**;名后一切(含分隔空白)是 `rawInput`。
310
- 语法非法或名字未知的行由适配器拒绝,不会变成模型提示词。`agent.ctx` 下注册的定义只对该 agent 遮蔽全局同名定义。
311
- **取当前会话 / cwd**:`CommandInvocation` 没有 session 字段 —— 经 agent 取:
312
- `const cwd: string | undefined = invocation.agent.session.header.cwd`
313
- **返回 UI 文本**:`{ kind: 'success', text }` 或 `{ kind: 'error', text }`,由分发 UI 直接渲染,**不进入模型历史**。
314
-
315
- ```ts
316
- const commands = ctx.get('commands') as { register(d: CommandDefinition): unknown } | undefined
317
- if (commands === undefined) return // headless/ACP 组合没有命令面,静默跳过
318
-
319
- commands.register({
320
- name: 'my-status',
321
- description: 'Show my plugin state for this session',
322
- input: { hint: '[verbose]' },
323
- handler: ({ agent, rawInput }) => {
324
- const cwd = agent.session.header.cwd
325
- if (cwd === undefined) return { kind: 'error', text: 'no session workspace' }
326
- return { kind: 'success', text: `cwd: ${cwd}\ninput: ${rawInput.trim()}` }
327
- },
328
- })
329
- ```
330
-
331
- 生命周期:`execute()` 先追加仅写日志的 `command/run`,结算时追加 `command/done`(抛错/取消以 `kind: 'error'` 结算);
332
- 准入未通过的输入不记录任何事件。注册/注销触发 `commands/change`(emit,无参)。
333
-
334
- ---
335
-
336
- ## 5. Skill:`@deepseek-ai/dsh-skill` + `dsh-skill-filesystem`
337
-
338
- ### 5.1 编程式注册(精确方法名与参数)
339
-
340
- ```ts
341
- ctx.skills.register(skill: SkillRegistration): () => void
342
-
343
- type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
344
- readonly invocation?: SkillInvocationPolicy // 省略 = 模型与用户都为 true
345
- readonly provider?: string // 省略 = 注册表自有的 'runtime'
346
- }
347
- interface SkillDefinition extends SkillSummary {
348
- readonly content: string // 必填:指令正文
349
- readonly path?: string; readonly metadata?: Readonly<Record<string, unknown>>
350
- }
351
- interface SkillSummary {
352
- readonly name: string // kebab-case
353
- readonly description: string; readonly whenToUse?: string
354
- readonly invocation: SkillInvocationPolicy; readonly source: SkillSource
355
- readonly provider: string; readonly resourceBase?: SkillResourceBase
356
- }
357
- interface SkillInvocationPolicy { readonly modelInvocable: boolean; readonly userInvocable: boolean }
358
- type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
359
-
360
- ctx.skills.registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void
361
- interface SkillProviderControl { readonly signal: AbortSignal; readonly invalidate: () => void }
362
- interface SkillProvider {
363
- readonly name: string
364
- readonly list: (o: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
365
- readonly get: (c: SkillCandidate, o: SkillLookupOptions) => Promise<SkillDefinition | undefined>
366
- }
367
- ```
368
-
369
- `registerProvider` 同步注册;同层重名或使用保留名 `runtime` 抛错;disposer 或 fiber 卸载即注销并失效缓存。
370
- 同层同名 runtime 注册先到先得(后者得 no-op disposer + 警告)。
371
- Skill 名文法:`const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/`(可用 `isSkillName(name)`)。
372
-
373
- ### 5.2 发现目录与优先级(rank 小者胜)
374
-
375
- | rank | `source` | 路径 |
376
- |---|---|---|
377
- | 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
378
- | 200 | `project-agents` | `<projectRoot>/.agents/skills` |
379
- | 300 | `custom` | `Config.customSkillDirs` 各项 |
380
- | 400 | `user-dsh` | `<dshHome>/skills`(跳过其 `.system` 子目录) |
381
- | 500 | `user-agents` | `<agentsHome>/skills` |
382
- | 600 | `bundled` | `Config.bundledSkillDir` / `$DSH_BUNDLED_SKILL_DIR`(`BUNDLED_SKILL_RANK`,`trustedHost`) |
383
-
384
- `projectRoot` = 含 `.git` 的最近祖先目录,无则用查询 cwd;`dshHome` 默认 `$DSH_HOME` 或 `~/.dsh`,
385
- `agentsHome` 默认 `$DSH_AGENTS_HOME` 或 `~/.agents`。发现深度固定为 1:`<root>/<name>/SKILL.md` 或 `<root>/<name>.md`,
386
- **不支持嵌套 `**/SKILL.md`**。层间同名由就近层覆盖;**rank 只决定同层内去重**。
387
- `includeDefaultRoots: false` 同时去掉项目根、用户根与 `$DSH_BUNDLED_SKILL_DIR` 默认值。
388
-
389
- ### 5.3 `SKILL.md` frontmatter 契约
390
-
391
- ```yaml
392
- ---
393
- name: my-skill # 必填,kebab-case
394
- description: One-line routing description. # 必填
395
- whenToUse: Optional extra routing guidance. # 可选
396
- metadata: { owner: team-x } # 可选,任意对象 → candidate.metadata
397
- disable-model-invocation: true # 可选:true 则从模型面目录与 loader 排除
398
- user-invocable: false # 可选:false 则从用户面命令排除
399
- ---
400
- 正文 Markdown。每次加载都重读文件,正文没有版本/缓存协议。
401
- ```
402
-
403
- 布尔键接受 YAML 布尔以及不分大小写的 `true/false`、`yes/no`、`on/off`、`1/0`;其他拼写或非布尔值 → 整个 skill 连警告一起丢弃。
404
- 旧键 `disableModelInvocation` / `modelInvocable` / `userInvocable` → 抛错 `frontmatter field "X" is unsupported; use "Y"`。
405
- 无 frontmatter、缺 `name`/`description`、名字非法、YAML 失败 → 记警告跳过。
406
- bundle 的相对资源基底是 `<name>/` 目录;渲染给模型用 `renderSkillContent(skill)` 得到规范 `<skill_content>` 块。
407
-
408
- ---
409
-
410
- ## 6. Agent 循环钩子
411
-
412
- `dsh-agent` 在 cordis 的 `Events` 上声明钩子。**没有** `agent/post-step`,也**没有** `agent/post-tool`:步骤结束是会话日志事件
413
- `step/end`;工具结果观测用 `tools/result`(管线)或会话事件 `tool/result`(日志)。
414
-
415
- ### 6.1 `agent/pre-step`(注入消息 / 拒绝步骤)—— 完整示例
416
-
417
- ```ts
418
- 'agent/pre-step'(
419
- this: Scoped<Agent>,
420
- payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal },
421
- next: () => Promise<PreStepDecision>,
422
- ): Promise<PreStepDecision>
423
-
424
- type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[]; startsRequestSeries?: true }
425
- ```
426
-
427
- `next()` 保持当前 messages;返回值替换进入该步骤的消息,或拒绝该步骤(mode `waterfall`,按 agent 作用域过滤派发)。
428
- 注入物必须是 `UserMessage`:`{ id, role: 'user', content: ContentBlock[], source }`,`source` 用
429
- `{ kind: 'plugin', plugin: '<name>' } & ContextFormed`(`form: 'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall'`)。
430
-
431
- ```ts
432
- import type { Context } from '@deepseek-ai/cordis'
433
- import { randomUUID } from 'node:crypto'
434
-
435
- export function registerPreStep(ctx: Context, buildCard: (cwd: string) => Promise<string | undefined>): void {
436
- ctx.on('agent/pre-step', async (payload, next) => {
437
- const decision = await next()
438
- if (decision.kind === 'reject' || payload.signal.aborted) return decision
439
- const cwd = payload.agent.session.header.cwd
440
- if (cwd === undefined) return decision
441
- const note = await buildCard(cwd) // 你自己决定"注什么、何时注"
442
- if (note === undefined) return decision
443
- return {
444
- kind: 'enter',
445
- messages: [...decision.messages, {
446
- id: randomUUID(), role: 'user', content: [{ type: 'text', text: note }],
447
- source: { kind: 'plugin', plugin: 'my-plugin', form: 'snapshot', sections: [{ name: 'my-plugin', text: note }] },
448
- }],
449
- }
450
- }, { prepend: true })
451
- }
452
- ```
453
-
454
- 若 bundle 不依赖 `@deepseek-ai/dsh-agent` 类型(本插件的处境),可把 `ctx.on` 断言成
455
- `(name: string, listener: (payload: unknown, next: () => Promise<PreStepDecision>) => Promise<PreStepDecision>) => unknown`,
456
- 再手动收窄 payload。
457
-
458
- ### 6.2 `tools/pre-execute`(否决 / 审批)—— 完整示例
459
-
460
- ```ts
461
- 'tools/pre-execute'(
462
- this: Scoped<ToolRuntime>,
463
- exec: ToolExecution,
464
- next: () => Promise<PreToolDecision>,
465
- ): Promise<PreToolDecision>
466
-
467
- type PreToolDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
468
- ```
469
-
470
- `next()` 委托为 allow;缺失审批服务时 `ask` 降级为拒绝;参数**不可改写**(`exec.arguments` 已入日志)。
471
- 异步门禁必须观测 `exec.signal`(注册表在门禁结算后重新检查取消)。
472
- 单调否决请用 `ctx.tools.guard(fn)`:同步、只能拒绝、不能被后续监听器翻回允许。
473
-
474
- ```ts
475
- import type { Context } from '@deepseek-ai/cordis'
476
-
477
- const FORBIDDEN = new Set(['my_dangerous_tool'])
478
-
479
- export function registerToolGate(ctx: Context): void {
480
- ctx.on('tools/pre-execute', async (exec, next) => {
481
- if (FORBIDDEN.has(exec.name)) return { kind: 'deny', reason: `tool "${exec.name}" is disabled by my-plugin` }
482
- if (exec.name === 'write') return { kind: 'ask', reason: 'my-plugin requires approval for writes' }
483
- return next()
484
- })
485
- ctx.tools.guard((exec) => (FORBIDDEN.has(exec.name) ? 'blocked by my-plugin policy' : undefined))
486
- }
487
- ```
488
-
489
- ### 6.3 同族事件
490
-
491
- ```ts
492
- 'tools/execute'(exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
493
- 'tools/post-execute'(exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
494
- 'tools/result'(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined // emit
495
- 'tools/change'(): void // emit
496
- 'tools/ptc-dispatch-log'(dispatch: PtcDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
497
- 'agent/session-start'({ agent: Agent; source: 'startup' | 'resume' | 'clear' | 'compact' }): void
498
- 'agent/turn-stopping'({ agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void // serial
499
- 'agent/created' | 'agent/disposed'({ agent: Agent }): void
500
- 'agent/status'({ agent: Agent; status: 'idle' | 'running' }): void
501
- 'agent/request'({ agent; turn; step; signal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
502
- 'agent/request-error'({ agent; turn; step; provider; failure; retryPolicy; signal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
503
- 'agent/error'({ agent: Agent; turn: number; step: number; error: unknown }): void
504
- 'commands/change'(): void; 'skills/change'(): void
505
-
506
- type PostToolDecision =
507
- | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
508
- | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
509
- | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
510
- ```
511
-
512
- 会话日志事件(`SessionEventMap`,经 `ctx.on('session/event', ...)` 观测):`turn/start`、`turn/end`、`step/start`、`step/end`、
513
- `user/message`、`system/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`。
514
- 所有带 `this: Scoped<Agent>` / `Scoped<ToolRuntime>` 的事件都按 agent 作用域过滤派发。
515
-
516
- ---
517
-
518
- ## 7. CLI:`@deepseek-ai/dsh`(profile 与插件安装)
519
-
520
- ### 7.1 本地安装的确切命令行
521
-
522
- `dsh plugin` 是 **pnpm 的薄转发器**(`lib/plugin-Ddi42qoW.js`):按需初始化 profile → 在 profile 目录执行
523
- `spawnSync('pnpm', args, { cwd: profileDir, stdio: 'inherit' })` → 按安装结果校正 `dsh.profile.bundles`。
524
-
525
- ```sh
526
- # 本地目录:相对路径会被锚定到"你执行 dsh 的目录",而不是 profile 目录
527
- dsh plugin --profile web add /abs/path/to/dsh-my-plugin
528
- dsh plugin --profile web add ../dsh-my-plugin # → <cwd>/../dsh-my-plugin
529
- dsh plugin --profile web add file:../dsh-my-plugin # file:/link: 前缀保留
530
- dsh plugin --profile web add link:../dsh-my-plugin
531
- dsh plugin --profile web remove dsh-my-plugin
532
- dsh plugin --profile web install
533
- dsh plugin --profile web why dsh-my-plugin
534
- ```
535
-
536
- 只有**相对路径规格**会被重写为绝对路径:`/^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/`;
537
- 绝对路径、registry 名、`git+`/`github:`、tarball 规格原样透传。pnpm 退出码非 0 → 只打印
538
- `pnpm failed in profile directory <dir>`;参数形如 `^git\+|^github:|\.git(?:#|$)` 时另提示需在
539
- `<profileDir>/pnpm-workspace.yaml` 的 `allowBuilds` 放行 prepare 脚本。`--profile` 是 `plugin` 的 `requiredOption`,
540
- 无参数报错,profile 名 `desktop` 被拒绝(Electron 独占)。**没有** `dsh plugin link` 子命令 —— 用 pnpm 的 `link:` 规格。
541
-
542
- ### 7.2 `dsh.bundle.patch` 如何被消费
543
-
544
- ```json
545
- { "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } }
546
- ```
547
-
548
- `dsh plugin` 跑完 pnpm 后调用 `reconcilePlugins(before, profileDir)`:遍历 `dependencies`,对每个包 `resolveBundleDir()`
549
- 并读 manifest,若 `dsh.bundle.patch !== undefined` 就把包名**追加**进 `dsh.profile.bundles`;不再声明或已从依赖移除的包移出列表。
550
- 未声明 `dsh.bundle` 的依赖只是普通依赖,并打印一条 `declares no dsh.bundle` 警告(不是错误)。校正基于**安装后的实际状态**
551
- 而非依赖 diff,因此 `update` 让某包获得 `dsh.bundle` 后会自动激活。profile 模板自带的内置 bundle 不是依赖,永不被改动。
552
- `DshBundleManifest.patch` 是相对包目录的路径,`loadProfile` 解析为 `patchPath` 与 `patches: PatchOptions[]`。
553
-
554
- ### 7.3 `cordis.patch.yml` schema
555
-
556
- 文件是**顶层 YAML 数组**,元素为 `PatchOptions`(`@deepseek-ai/cordis-plugin-include`):
557
-
558
- ```ts
559
- interface PatchOptions {
560
- id?: string; insert?: EntryOptions[]; name?: string; config?: any
561
- group?: boolean | null; disabled?: boolean | null
562
- inject?: any; intercept?: any; isolate?: any
563
- [key: string]: any
564
- }
565
- interface EntryOptions {
566
- id: string // 必填:包含树内稳定 id
567
- name: string // 必填:模块说明符(相对名按配置目录解析,裸包名按 baseUrl)
568
- config?: any; group?: boolean | null; disabled?: boolean | null; inject?: Inject | null
569
- }
570
- ```
571
-
572
- 语义(`applyEntryPatches`,逐字对齐 `dsh-app-boot/lib/index.js`):
573
-
574
- - 有 `insert`:带 `id` → 命中该 entry,要求它是 `group`,把 `insert` 追加进它的 `config` 数组;找不到 → 警告
575
- `patch insert: entry "X" not found`;非 group → 警告并跳过。无 `id` → 追加到**根数组**。
576
- - 无 `insert`:`id` 必填;命中目标后,若给了 `name` 且与目标 `name` 不符 → 警告并跳过;否则把其余键逐个覆盖到目标。
577
- - 输入永不被修改(`structuredClone`);同一列表内后面的 patch 可命中前面 patch 插入的行;未命中任何行的 patch 只警告。
578
-
579
- ```yaml
580
- # bundle 的最小 patch 层(dsh-thesis 的真实文件)
581
- - insert:
582
- - id: ai-learning
583
- name: dsh-thesis
584
- # config: { stateDir: .ai-learning }
585
- ---
586
- - id: tools # 按 id 覆盖已有条目
587
- config: { mode: both }
588
- - id: my-row
589
- disabled: true
590
- ```
591
-
592
- ### 7.4 profile 层叠顺序
593
-
594
- 以空 entry 列表为起点,依次叠加:① `dsh.profile.bundles` 中每个 bundle 的 `cordis.patch.yml`(按列表顺序);
595
- ② profile 目录自己的 `cordis.patch.yml`(`PROFILE_PATCH_FILENAME`);③ `$DSH_HOME/cordis.patch.yml`(home 级);
596
- ④ `--patch <path>` 覆盖层(可重复,按给出顺序)。`patchReload: 'live' | 'startup'`:前者监视 profile 与 home 级 patch 文件
597
- 并事务性重放,后者只在启动时应用一次。`--dump-config` / `--dump-default-config` 可在不启动的情况下查看合成树。
598
-
599
- ### 7.5 运行时依赖解析与 "install farm" 兜底
600
-
601
- - 共享兜底目录:`$DSH_HOME/profiles/node_modules`。`healProfilesModuleFallback()` 在每次 profile 启动时让它镜像
602
- **dsh 安装自身的依赖闭包**;普通 Node 写 symlink(junction),打包可执行文件在跨进程锁下写 ESM 代理。
603
- - 因此插件对 `@deepseek-ai/*` 的运行时 `import` 会沿 Node 父目录行走落到 `$DSH_HOME/profiles/node_modules`,
604
- **无需自己安装 peer**(`@deepseek-ai/cordis` 即典型)。
605
- - bundle 名解析**双锚**:先从 dsh 安装目录解析(保证 `@deepseek-ai/dsh-base` 永远来自同一安装),再从 profile 目录解析。
606
- pnpm 管理的 `profile/node_modules` 条目优先;profile 私有链接只补"仅被所选 bundle 携带"的包,且不影响别的 profile。
607
- - 建议:`@deepseek-ai/cordis` 放 `peerDependencies`;安装闭包之外的包放 `dependencies`。
608
- - **必须发布编译后的 JS**:dsh CLI 依赖中没有 TypeScript 运行时加载器(`tsx`/`jiti` 均不在 `@deepseek-ai/dsh` 与
609
- `dsh-app-boot` 的依赖中),`lib/bin.js` 是纯 ESM import。让 `main` 指向 `lib/index.js`,`exports` 同时给出 `types`
610
- 与 `default`。源码执行(vite/tsx/plain ESM)只适用于在 dsh 仓库内用 `pnpm dsh` 的开发路径。
611
-
612
- ### 7.6 名称与字符规则
613
-
614
- | 对象 | 规则 | 违反后果 |
615
- |---|---|---|
616
- | 命令名 | `/^[a-z][a-z0-9_-]*$/u` | 注册时 `TypeError` |
617
- | skill 名 | `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` | 注册时 `Error: invalid skill name "X"`;文件发现时警告跳过 |
618
- | profile 名 | 非空、不含 `/` 或 `\`、非 `.`/`..`/`node_modules`、非 `desktop` | `Error: dsh: invalid profile name "X"` / CLI 拒绝 desktop |
619
- | bundle 包名 | 必须是 Node 可解析的 npm 包名;作为 `dsh.profile.bundles` 项时必须声明 `dsh.bundle` | `profile bundle "X" declares no dsh.bundle in its package.json` |
620
- | 工具名 | `run_code` 被 PTC 传输保留 | `tool name "run_code" is reserved ...` |
621
- | Loader entry `id` | 包含树内稳定唯一、非空 | 由 Loader entry 树裁决 |
622
-
623
- ---
624
-
625
- ## 8. 无法确定的事项(含已检查的内容)
626
-
627
- 1. **工具名的字符文法**:`dsh-tools` 的 `register()` 只检查 `run_code` 保留名与同层重复,**没有**名字正则,`ToolSchema.name` 也无 brand;
628
- `dsh-tools/lib/index.js` 的 SDK 渲染注释甚至以 `zz-\u{1E4D0}x` 这类名字为合法输入举例。我在已安装产物中找不到工具名字符白名单。
629
- 检查过:`dsh-tools/lib/index.js`(`register`、`defineTool`、`ts-types` 渲染)、`dsh-llm/lib/types/*.d.ts` 的 `ToolSchema`。
630
- 实际约束来自模型 Function Calling 惯例与 `run_code` 保留名,无法从本产物给出权威正则。
631
- 2. **"插件名允许字符"没有独立校验器**:`dsh plugin` 把包名交给 pnpm,加载时靠 Node 模块解析;唯一相近的显式规则是 7.6 的
632
- profile 名与命令名。检查过:`dsh/lib/plugin-Ddi42qoW.js`、`dsh/lib/bin.js`、`dsh-app-boot/lib/index.js`、
633
- `dsh-host-plugin-inventory/lib/types/*.d.ts`、`dsh-client-ui-settings-plugins/lib/client.js`。
634
- 3. `ctx.fs` 的存在性检查与递归列举**确实不存在**(合约边界,非搜索遗漏):抽象类成员即第 3 节列出的 13 个原语,
635
- README.zh.md 亦明写"只有十三个原语""`listDir` 只列出一层"。
636
- 4. **`agent/post-step` 不存在**:`dsh-agent` 事件表止于 `agent/turn-stopping`;对应观测点是 `step/end`。
637
- 检查过 `dsh-agent/lib/types/runtime-types.d.ts` 全文 419 行。
638
- 5. `dsh plugin link` 子命令不存在(见 7.1)。
639
- 6. `ToolOutputError` 构造签名 `.d.ts` 与 `.js` 不一致(见 2.3)。
640
- 7. `defineTool` 的 `parameters` 在 README.zh.md 示例里长得像 JSON Schema,实际是 DSL 属性映射(见 2.1)。
1
+ # DSH 第三方插件宿主 API 参考(0.1.5-rc.1)
2
+
3
+ > **版本与兼容**:本文的签名摘自已安装产物,最初对着 **dsh 0.1.5-rc.1** 记录;
4
+ > 已用 **dsh 0.2.0-rc.2** 重新核对本插件实际用到的每一处(`defineTool` 的 DSL、
5
+ > `ctx.tools.register`、`agent/pre-step` 与 `tools/pre-execute` 的 waterfall、
6
+ > `ctx.commands.register`、`ctx.skills.registerProvider`、`ctx.shell` /
7
+ > `ctx.sandboxPolicy`),结论是契约未变。
8
+ > **唯一真正会挡住装载的不是 API,而是 manifest 的 peer range**——见 §7.7。
9
+
10
+ 只记录契约:签名、类型、事件名、注册与加载规则。签名逐字摘自已安装产物的 `lib/types/**/*.d.ts` 与 `lib/*.js`,
11
+ 冲突处已标注。安装根:`C:\Users\TTTT\AppData\Local\npm-cache\_npx\1e7f6d9597241db0\node_modules\@deepseek-ai\`
12
+
13
+ ---
14
+
15
+ ## 1. 插件模块契约(`@deepseek-ai/cordis`)
16
+
17
+ ```ts
18
+ namespace Plugin { // cordis/lib/types/registry.d.ts
19
+ interface Base<T = any> {
20
+ name?: string; Config?: StandardSchemaV1<any, T>; inject?: Inject
21
+ provide?: string | string[]; intercept?: Dict<boolean>
22
+ }
23
+ interface Function<T = any> extends Base<T> { (ctx: Context, config: T): any }
24
+ interface Constructor<T = any> extends Base<T> { new (ctx: Context, config: T): any }
25
+ interface Object<T = any> extends Base<T> { apply(ctx: Context, config: T): any }
26
+ }
27
+ type Inject<M = Dict> = (keyof M)[] | { [K in keyof M]?: M[K] }
28
+ // name=诊断名;Config=启动前校验 config 的 Standard Schema(schemastery 满足);inject=依赖服务名
29
+
30
+ export const name = 'my-plugin'
31
+ export const inject = ['fs'] // 服务齐备前保持 PENDING,不加载
32
+ export const Config = z.object({ stateDir: z.string().default('.my-plugin') })
33
+ export type MyConfig = z.infer<typeof Config>
34
+ export function apply(ctx: Context, config: MyConfig = {}): void { /* ... */ }
35
+ ```
36
+
37
+ 函数插件用 `export const inject`,class 插件用 `static inject`,延迟到服务就绪再跑用 `ctx.inject(deps, cb)`。
38
+ **函数插件不要 `export default`**(只有 class service 用 default export)。
39
+
40
+ ### 1.1 `ctx` 核心方法
41
+
42
+ ```ts
43
+ interface Context { // cordis/lib/types/context.d.ts
44
+ root: this; baseUrl?: string
45
+ events: EventsService; logger: LoggerService; reflect: ReflectService
46
+ registry: RegistryService; fiber: Fiber
47
+ get<K extends string & keyof this>(name: K, strict?: boolean): undefined | this[K]
48
+ set<K extends string & keyof this>(name: K, value: undefined | this[K]): void
49
+ provide<K extends string & keyof this>(name: K, value: undefined | this[K]): () => void // 返回注销 disposer
50
+ // 以上三者各另有 (name: string, ...) => any 的非类型化重载
51
+ accessor(name: string, options: Omit<Property.Accessor, 'type'>): void
52
+ mixin<K extends string & keyof this>(name: K, mixins: (keyof this & keyof this[K])[] | Dict<string>): void
53
+ on<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
54
+ once<K extends keyof Events>(name: K, listener: Events[K], options?: boolean | EventOptions): () => boolean
55
+ emit<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): void
56
+ parallel<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promise<void>
57
+ serial<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): Promisify<ReturnType<Events[K]>>
58
+ bail<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
59
+ waterfall<K extends keyof Events>(name: K, ...args: Parameters<Events[K]>): ReturnType<Events[K]>
60
+ plugin<P extends Plugin>(plugin: P, ...args: Spread<GetPluginConfig<P>>): Fiber & PromiseLike<Fiber>
61
+ inject(deps: Inject, callback: Plugin.Function<void>): Fiber & PromiseLike<Fiber>
62
+ }
63
+ interface EventOptions { prepend?: boolean; global?: boolean }
64
+ interface Fiber {
65
+ uid: number | null; readonly ctx: Context; config: any
66
+ state: FiberState // PENDING=0 LOADING=1 ACTIVE=2 FAILED=3 DISPOSED=4 UNLOADING=5
67
+ readonly dispose: () => Promise<void>; await(): Promise<this>; restart(): Promise<void>
68
+ update(config: any, noSave?: boolean): void | Promise<void>
69
+ effect(execute: () => SyncEffect, label?: string): Disposable<Promise<void>>
70
+ effect(execute: () => Effect, label?: string): AsyncDisposable<Promise<void>>
71
+ getEffects(): EffectMeta[]
72
+ }
73
+ ```
74
+
75
+ **disposal**:`provide` / `on` / `effect` 的 disposer 都归属当前 fiber,fiber 卸载时按注册**逆序**自动执行。
76
+ `ctx.effect(fn, label)` 的 `fn` 可返回 disposer、disposer 数组、`Promise<disposer>` 或 async generator:
77
+ `ctx.effect(() => { const t = setInterval(tick, 1000); return () => clearInterval(t) }, 'my-plugin.interval')`。
78
+
79
+ ### 1.2 `Config`(schemastery `z`)
80
+
81
+ ```ts
82
+ z.string(): Schema<string>; z.number(): Schema<number>; z.natural(): Schema<number>; z.percent(): Schema<number>
83
+ z.boolean(): Schema<boolean>; z.date(): Schema<string | Date, Date>; z.regExp(flag?): Schema<string | RegExp, RegExp>
84
+ z.array<X>(inner: X): Schema<TypeS<X>[], TypeT<X>[]>
85
+ z.dict<X, Y extends Schema<any, string> = Schema<string>>(inner: X, sKey?: Y): Schema<Dict<...>, Dict<...>>
86
+ z.object<X extends Dict>(dict: X): Schema<ObjectS<X>, ObjectT<X>>
87
+ z.union<const X>(list: readonly X[]): Schema<TypeS<X>, TypeT<X>>
88
+ z.intersect<const X>(list: readonly X[]): Schema<IntersectS<X>, IntersectT<X>>
89
+ z.transform<X, T>(inner: X, cb: (v: TypeS<X>, o: Options) => T, preserve?: boolean): Schema<TypeS<X>, T>
90
+ // 链式:.default(v) .description(text) .required(v?) .hidden(v?) .loose(v?) .disabled(v?)
91
+ // .collapse(v?) .role(text, extra?) .link(s) .comment(s) .min(n) .max(n) .step(n) .set(k, schema)
92
+ ```
93
+
94
+ Cordis 用 `resolveConfig(runtime, config)` 把 `Config` 当 Standard Schema 校验;失败 → fiber `FAILED`,`await fiber` 抛 `ValidationError`。
95
+ 服务声明可 `declare module '@deepseek-ai/cordis' { interface Context { myService: MyService } }`,也可不声明类型而用
96
+ `ctx.provide('myService', obj)` + `ctx.get('myService')`(本插件用后者规避对 `@deepseek-ai/dsh-*` 的类型依赖)。
97
+
98
+ ---
99
+
100
+ ## 2. 工具:`@deepseek-ai/dsh-tools`
101
+
102
+ ### 2.1 `defineTool` 精确签名
103
+
104
+ ```ts
105
+ export declare function defineTool<const S extends ParameterSchemaSpec, const O extends ValueSchemaSpec>(
106
+ options: DefineToolOptions<S, O>): ToolDefinition
107
+
108
+ export interface DefineToolOptions<S extends ParameterSchemaSpec, O extends ValueSchemaSpec> {
109
+ readonly name: string
110
+ readonly description: string
111
+ readonly parameters: S // 隐式开放对象根 —— 是 DSL,不是 JSON Schema
112
+ readonly output: {
113
+ readonly schema: O
114
+ render(args: InferArgs<S>, value: InferValue<NoInfer<O>>): ContentBlock[]
115
+ presentationMeta?(args: InferArgs<S>, value: InferValue<NoInfer<O>>): JsonValue
116
+ }
117
+ readonly timeoutMs?: number
118
+ isConcurrencySafe?(args: InferArgs<S>): boolean
119
+ execute(args: InferArgs<S>, exec: ToolRunContext): Promise<InferValue<NoInfer<O>>>
120
+ finalizeContent?(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): ContentBlock[] | undefined
121
+ presentCall?(args: InferArgs<S>): ToolCallView | undefined
122
+ presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
123
+ }
124
+ ```
125
+
126
+ ### 2.2 参数/输出 schema DSL(全部字段描述符)
127
+
128
+ ```ts
129
+ interface ValueSchemaAnnotations { description?: string; title?: string; default?: JsonValue; examples?: JsonValue }
130
+ interface StringValueSchemaSpec extends ValueSchemaAnnotations { type: 'string'; enum?: readonly string[]; const?: string }
131
+ interface NumberValueSchemaSpec extends ValueSchemaAnnotations { type: 'number'; enum?: readonly number[]; const?: number }
132
+ interface IntegerValueSchemaSpec extends ValueSchemaAnnotations { type: 'integer'; enum?: readonly number[]; const?: number }
133
+ interface BooleanValueSchemaSpec extends ValueSchemaAnnotations { type: 'boolean'; enum?: readonly boolean[]; const?: boolean }
134
+ interface NullValueSchemaSpec extends ValueSchemaAnnotations { type: 'null'; enum?: readonly null[]; const?: null }
135
+ interface ArrayValueSchemaSpec extends ValueSchemaAnnotations { type: 'array'; items?: ValueSchemaSpec }
136
+ interface ObjectValueSchemaSpec extends ValueSchemaAnnotations { // additionalProperties 必填:显式声明开放性
137
+ type: 'object'; properties?: ParameterSchemaSpec; additionalProperties: boolean }
138
+ interface JsonValueSchemaSpec extends ValueSchemaAnnotations { type: 'json' } // 仅供作者,编译为注解式 schema
139
+ interface OneOfValueSchemaSpec extends ValueSchemaAnnotations { // 至少两个分支
140
+ oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]] }
141
+ type ValueSchemaSpec = StringValueSchemaSpec | NumberValueSchemaSpec | IntegerValueSchemaSpec
142
+ | BooleanValueSchemaSpec | NullValueSchemaSpec | ArrayValueSchemaSpec
143
+ | ObjectValueSchemaSpec | JsonValueSchemaSpec | OneOfValueSchemaSpec
144
+ type ParameterPropertySpec = ValueSchemaSpec & { required?: true } // required 是每属性注解
145
+ type ParameterSchemaSpec = { [key: string]: ParameterPropertySpec; [key: symbol]: never }
146
+ type InferValue<S> = /* 精确推导,最多 16 层容器,之后退化为 JsonValue */
147
+ type InferArgs<S> = InferProperties<S, []>
148
+ ```
149
+
150
+ `defineTool` 用 `parameterSchemaSpecToJsonSchema` / `valueSchemaSpecToJsonSchema` 编译成受支持的 JSON Schema 子集;
151
+ 每次 `execute` 前用 `validateArgs(spec, args)`(= `validateJsonSchemaValue`)校验,违规抛 `ToolArgsError`(`code: 'INVALID_ARGS'`)。
152
+
153
+ ### 2.3 约定与 `exec` 上下文
154
+
155
+ ```ts
156
+ interface ToolExecutionInput {
157
+ readonly callId: ToolCallId; readonly rootCallId?: ToolCallId; readonly name: string
158
+ readonly arguments: unknown; readonly agent?: Agent // 无 agent 的组合为 undefined
159
+ readonly parent?: ToolExecutionToken; readonly signal: AbortSignal
160
+ }
161
+ interface ToolExecution extends ToolExecutionInput { readonly rootCallId: ToolCallId; readonly token: ToolExecutionToken }
162
+ interface ToolRunContext extends ToolExecution {
163
+ deferContext(context: UserMessage): void // 结果之后注入的上下文
164
+ concludeTurn(): void // 成功即结束当前轮
165
+ }
166
+ const cwd = exec.agent?.session.header.cwd // string | undefined ← 会话工作目录
167
+ ```
168
+
169
+ - `execute` 只返回**规范 JSON 值**;不符 `output.schema` → `ToolOutputError`(`violations: string[]`)。
170
+ > **不一致**:`.d.ts` 写 `constructor(toolName: string, violations: string[])`,`lib/index.js` 实为 `constructor(violations)`;以运行时为准。
171
+ - `render(args, value)` 的返回值就是模型看到的 `content`(`ContentBlock[]`,通常 `[{ type: 'text', text }]`)。
172
+ - `execute` 抛出 → `Error: <message>` 的 `isError` 结果;结构化身份走 `ToolFailure.info = { name, code }`。失败不结束轮次。
173
+ - 取消:调用前取消 → `ABORTED_BEFORE_DISPATCH`;调用后取消 → `ABORTED`。必须把 `exec.signal` 传给下游 I/O。
174
+ - `timeoutMs` 只是**声明**,注册表不强制;需要 `@deepseek-ai/dsh-tool-call-timeout-policy` 包装层。
175
+ - `Agent` 另有 `agent.ctx`(agent 作用域 Context)、`agent.inbox`、`agent.status`、`agent.options`、`agent.session`、
176
+ `cancel(cause, opts?)`、`whenIdle()`、`runMaintenance(task)`、`inject/steer/followup/send`。
177
+
178
+ ### 2.4 注册
179
+
180
+ ```ts
181
+ ctx.tools.register(definition: ToolDefinition): () => void // 返回注销 disposer
182
+ ctx.tools.guard(guard: (exec: Readonly<ToolExecution>) => string | undefined): () => void
183
+ ctx.tools.restrict(filter: { allow?: readonly string[]; deny?: readonly string[] }): () => void
184
+ ctx.tools.get(name: string, scope?: ScopeKey): ToolDefinition | undefined
185
+ ctx.tools.schemas(scope?: ScopeKey): ToolSchema[]
186
+ ctx.tools.execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
187
+ ```
188
+
189
+ 注册强制:同层重名抛错;`run_code` 为 PTC 保留名(注册/遮蔽均抛错);`output` 必须是带 `render` 函数的对象;
190
+ `output.schema` 必须落在受支持子集内;`timeoutMs` 若是则必须为正有限数。
191
+ 流程:`tools/pre-execute` → `ctx.tools.guard()` → `tools/execute` → `tools/post-execute` → `finalizeContent` → `tools/result`。
192
+ `restrict()` 要求 agent 作用域 Context(`agent.ctx`);空过滤器与未知工具名(含保留名)抛错。
193
+
194
+ ### 2.5 最小可运行工具
195
+
196
+ ```ts
197
+ import { defineTool } from '@deepseek-ai/dsh-tools'
198
+ import type { Context } from '@deepseek-ai/cordis'
199
+
200
+ export function registerTools(ctx: Context, readAll: (cwd: string, rel: string, signal: AbortSignal) => Promise<string>): void {
201
+ ctx.tools.register(defineTool({
202
+ name: 'my_read',
203
+ description: 'Read one file inside the current session workspace.',
204
+ parameters: { path: { type: 'string', required: true }, maxLines: { type: 'integer' } },
205
+ output: {
206
+ schema: { type: 'object', additionalProperties: false, properties: {
207
+ path: { type: 'string', required: true }, text: { type: 'string', required: true } } },
208
+ render: (_a, v) => [{ type: 'text', text: v.text }],
209
+ },
210
+ async execute(args, exec) {
211
+ const cwd = exec.agent?.session.header.cwd ?? process.cwd()
212
+ return { path: args.path, text: await readAll(cwd, args.path, exec.signal) }
213
+ },
214
+ }))
215
+ }
216
+ ```
217
+
218
+ ---
219
+
220
+ ## 3. 文件系统:`@deepseek-ai/dsh-fs`
221
+
222
+ `ctx.fs` 是抽象 `FileSystem extends Service`;**全部**成员如下(不省略,无其他方法):
223
+
224
+ ```ts
225
+ abstract class FileSystem extends Service {
226
+ get sandboxMode(): SandboxMode | undefined // 基类/裸本机后端为 undefined(不限制)
227
+ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget>
228
+ abstract processPath(target: FsTarget): string
229
+ processPathFromHostPath(hostPath: string): string | undefined
230
+ abstract fileUrl(target: FsTarget): string
231
+ abstract contains(parent: FsTarget, child: FsTarget): boolean
232
+ abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
233
+ abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined>
234
+ abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
235
+ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
236
+ abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array>
237
+ abstract readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array>
238
+ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
239
+ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent,
240
+ signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsWriteOutcome>
241
+ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion },
242
+ signal?: AbortSignal, sandboxPolicy?: SandboxExecutionPolicy): Promise<FsEditOutcome>
243
+ }
244
+
245
+ interface FsTarget { targetKey: FsTargetKey; displayPath: string } // targetKey 不透明,禁止解析
246
+ interface FsInfo { version: FsVersion; type: 'file' | 'directory' | 'other'; size?: number }
247
+ interface FsPathInfo { version: FsVersion; type: 'file' | 'directory' | 'symlink' | 'other'; size?: number }
248
+ interface FsDirEntry { name: string; type: 'file' | 'directory' | 'other'; target: FsTarget; version?: FsVersion; size?: number }
249
+ type FsObservation = { kind: 'present'; version: FsVersion } | { kind: 'absent' }
250
+ type FsWriteIntent = { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion }
251
+ interface FsEditRequest { oldString: string; newString: string; replaceAll: boolean }
252
+ interface FsWriteOutcome { operation: 'create' | 'update'; version: FsVersion; before: string | null; after: string }
253
+ interface FsEditOutcome { version: FsVersion; before: string; after: string }
254
+ type FsErrorCode = 'FS_NOT_FOUND' | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' | 'FS_TOO_LARGE'
255
+ | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' | 'FS_STALE_VERSION' | 'FS_NOT_OBSERVED'
256
+ | 'FS_AMBIGUOUS_EDIT' | 'FS_EDIT_NOT_FOUND' | 'FS_ABORTED'
257
+ class FsError extends HarnessError { readonly code: FsErrorCode; constructor(message: string, code: FsErrorCode, options?: ErrorOptions) }
258
+ ```
259
+
260
+ ### 3.1 关键契约(也是"缺失能力"的正式答案)
261
+
262
+ - **没有 `exists()`**:存在性 = `await fs.stat(target) !== undefined`(或 `lstat(path)`)。
263
+ - **没有递归列举**:`listDir` 只返回直接子项,按名字稳定排序、不含内容;递归/glob/分页/搜索要自己实现。
264
+ - **没有 delete / rename / copy / watch**:整个 seam 就是上面 13 个原语(README 明写"只有十三个原语")。
265
+ - **变更只支持文本**:`writeText`/`editText` 遇二进制或非 UTF-8 → `FS_NOT_TEXT`;`readBytes`/`readByteRange` 是原始字节**读**原语,
266
+ **二进制安全的写入不存在**。
267
+ - **路径解析**:相对路径以 `opts.cwd` 为基准(省略用后端默认基准)。`resolve` 跟随符号链接得到稳定身份 → 同一文件不同路径产生
268
+ 同一 `targetKey`;"不跟随最后一跳"用 `lstat(path, { cwd })`。`displayPath` 才是给模型/UI 的路径。
269
+ - **原子性**:写入与编辑都原子;编辑在可选 `expected.version` 下先校验新鲜度再字面匹配
270
+ (`FS_STALE_VERSION` / `FS_AMBIGUOUS_EDIT` / `FS_EDIT_NOT_FOUND`)。
271
+ - **上限**:`readBytes` 超 `maxBytes` → `FS_TOO_LARGE`(不截断);`readByteRange` 由调用方对 `length` 负责。
272
+ - **无 I/O deadline**;取消只有每原语上的可选 `AbortSignal`。
273
+
274
+ ### 3.2 `fs/*` 策略事件(全部三个)
275
+
276
+ ```ts
277
+ // 单槽决策 waterfall:第一个返回值即决策,绝不调用 next()。next() = 无条件写入。
278
+ 'fs/write-intent'(target: FsTarget, actor: object | undefined,
279
+ next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
280
+ // 单槽决策 waterfall:next() = 无条件编辑。
281
+ 'fs/edit-intent'(target: FsTarget, actor: object | undefined,
282
+ next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
283
+ // emit:同步记录;抛错会让工具调用失败,返回的 Promise 不会被 await。
284
+ 'fs/observed'(target: FsTarget, observation: FsObservation, actor: object | undefined): void
285
+ ```
286
+
287
+ `actor` 是发起调用的执行对象(`dsh-tool-fs` 传 `exec`;策略按 `actor.name === 'edit' | 'write'` 判定)。
288
+
289
+ ---
290
+
291
+ ## 4. 人类斜杠命令:`@deepseek-ai/dsh-commands`
292
+
293
+ ```ts
294
+ ctx.commands.register(definition: CommandDefinition): () => void
295
+
296
+ interface CommandDefinition {
297
+ readonly name: string // 斜杠后的小写名,不含 '/'
298
+ readonly description: string
299
+ readonly input?: CommandInputDescriptor
300
+ readonly recordInput?: boolean // 默认 true;设 false 让命令自己的领域事件持有输入载荷
301
+ readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
302
+ }
303
+ interface CommandInputDescriptor { readonly hint: string; readonly attachments?: boolean }
304
+ interface CommandInvocation {
305
+ readonly commandId: CommandId; readonly agent: Agent // 精确的接收 agent
306
+ readonly rawInput: string // 命令名之后的每个字节,含分隔空白
307
+ readonly attachments: readonly (ImageBlock | FileBlock)[]; readonly signal: AbortSignal
308
+ }
309
+ type CommandResult =
310
+ | { kind: 'success'; text?: string; sourceEventSeq?: SessionSeq }
311
+ | { kind: 'error'; text: string }
312
+ ```
313
+
314
+ **名称规则(运行时强制)**:`const COMMAND_NAME = /^[a-z][a-z0-9_-]*$/u`;不匹配 →
315
+ `TypeError: command name "X" must match /^[a-z][a-z0-9_-]*$/u`。同层重名注册抛错。
316
+ **命令行语法**:第 0 字节必须是 `/`,随后小写名,名后为行尾或**空白**;名后一切(含分隔空白)是 `rawInput`。
317
+ 语法非法或名字未知的行由适配器拒绝,不会变成模型提示词。`agent.ctx` 下注册的定义只对该 agent 遮蔽全局同名定义。
318
+ **取当前会话 / cwd**:`CommandInvocation` 没有 session 字段 —— 经 agent 取:
319
+ `const cwd: string | undefined = invocation.agent.session.header.cwd`
320
+ **返回 UI 文本**:`{ kind: 'success', text }` 或 `{ kind: 'error', text }`,由分发 UI 直接渲染,**不进入模型历史**。
321
+
322
+ ```ts
323
+ const commands = ctx.get('commands') as { register(d: CommandDefinition): unknown } | undefined
324
+ if (commands === undefined) return // headless/ACP 组合没有命令面,静默跳过
325
+
326
+ commands.register({
327
+ name: 'my-status',
328
+ description: 'Show my plugin state for this session',
329
+ input: { hint: '[verbose]' },
330
+ handler: ({ agent, rawInput }) => {
331
+ const cwd = agent.session.header.cwd
332
+ if (cwd === undefined) return { kind: 'error', text: 'no session workspace' }
333
+ return { kind: 'success', text: `cwd: ${cwd}\ninput: ${rawInput.trim()}` }
334
+ },
335
+ })
336
+ ```
337
+
338
+ 生命周期:`execute()` 先追加仅写日志的 `command/run`,结算时追加 `command/done`(抛错/取消以 `kind: 'error'` 结算);
339
+ 准入未通过的输入不记录任何事件。注册/注销触发 `commands/change`(emit,无参)。
340
+
341
+ ---
342
+
343
+ ## 5. Skill:`@deepseek-ai/dsh-skill` + `dsh-skill-filesystem`
344
+
345
+ ### 5.1 编程式注册(精确方法名与参数)
346
+
347
+ ```ts
348
+ ctx.skills.register(skill: SkillRegistration): () => void
349
+
350
+ type SkillRegistration = Omit<SkillDefinition, 'invocation' | 'provider'> & {
351
+ readonly invocation?: SkillInvocationPolicy // 省略 = 模型与用户都为 true
352
+ readonly provider?: string // 省略 = 注册表自有的 'runtime'
353
+ }
354
+ interface SkillDefinition extends SkillSummary {
355
+ readonly content: string // 必填:指令正文
356
+ readonly path?: string; readonly metadata?: Readonly<Record<string, unknown>>
357
+ }
358
+ interface SkillSummary {
359
+ readonly name: string // kebab-case
360
+ readonly description: string; readonly whenToUse?: string
361
+ readonly invocation: SkillInvocationPolicy; readonly source: SkillSource
362
+ readonly provider: string; readonly resourceBase?: SkillResourceBase
363
+ }
364
+ interface SkillInvocationPolicy { readonly modelInvocable: boolean; readonly userInvocable: boolean }
365
+ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
366
+
367
+ ctx.skills.registerProvider(create: (control: SkillProviderControl) => SkillProvider): () => void
368
+ interface SkillProviderControl { readonly signal: AbortSignal; readonly invalidate: () => void }
369
+ interface SkillProvider {
370
+ readonly name: string
371
+ readonly list: (o: SkillLookupOptions) => Promise<readonly SkillCandidate[] | SkillProviderObservation>
372
+ readonly get: (c: SkillCandidate, o: SkillLookupOptions) => Promise<SkillDefinition | undefined>
373
+ }
374
+ ```
375
+
376
+ `registerProvider` 同步注册;同层重名或使用保留名 `runtime` 抛错;disposer 或 fiber 卸载即注销并失效缓存。
377
+ 同层同名 runtime 注册先到先得(后者得 no-op disposer + 警告)。
378
+ Skill 名文法:`const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/`(可用 `isSkillName(name)`)。
379
+
380
+ ### 5.2 发现目录与优先级(rank 小者胜)
381
+
382
+ | rank | `source` | 路径 |
383
+ |---|---|---|
384
+ | 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
385
+ | 200 | `project-agents` | `<projectRoot>/.agents/skills` |
386
+ | 300 | `custom` | `Config.customSkillDirs` 各项 |
387
+ | 400 | `user-dsh` | `<dshHome>/skills`(跳过其 `.system` 子目录) |
388
+ | 500 | `user-agents` | `<agentsHome>/skills` |
389
+ | 600 | `bundled` | `Config.bundledSkillDir` / `$DSH_BUNDLED_SKILL_DIR`(`BUNDLED_SKILL_RANK`,`trustedHost`) |
390
+
391
+ `projectRoot` = 含 `.git` 的最近祖先目录,无则用查询 cwd;`dshHome` 默认 `$DSH_HOME` 或 `~/.dsh`,
392
+ `agentsHome` 默认 `$DSH_AGENTS_HOME` 或 `~/.agents`。发现深度固定为 1:`<root>/<name>/SKILL.md` 或 `<root>/<name>.md`,
393
+ **不支持嵌套 `**/SKILL.md`**。层间同名由就近层覆盖;**rank 只决定同层内去重**。
394
+ `includeDefaultRoots: false` 同时去掉项目根、用户根与 `$DSH_BUNDLED_SKILL_DIR` 默认值。
395
+
396
+ ### 5.3 `SKILL.md` frontmatter 契约
397
+
398
+ ```yaml
399
+ ---
400
+ name: my-skill # 必填,kebab-case
401
+ description: One-line routing description. # 必填
402
+ whenToUse: Optional extra routing guidance. # 可选
403
+ metadata: { owner: team-x } # 可选,任意对象 → candidate.metadata
404
+ disable-model-invocation: true # 可选:true 则从模型面目录与 loader 排除
405
+ user-invocable: false # 可选:false 则从用户面命令排除
406
+ ---
407
+ 正文 Markdown。每次加载都重读文件,正文没有版本/缓存协议。
408
+ ```
409
+
410
+ 布尔键接受 YAML 布尔以及不分大小写的 `true/false`、`yes/no`、`on/off`、`1/0`;其他拼写或非布尔值 → 整个 skill 连警告一起丢弃。
411
+ 旧键 `disableModelInvocation` / `modelInvocable` / `userInvocable` → 抛错 `frontmatter field "X" is unsupported; use "Y"`。
412
+ 无 frontmatter、缺 `name`/`description`、名字非法、YAML 失败 → 记警告跳过。
413
+ bundle 的相对资源基底是 `<name>/` 目录;渲染给模型用 `renderSkillContent(skill)` 得到规范 `<skill_content>` 块。
414
+
415
+ ---
416
+
417
+ ## 6. Agent 循环钩子
418
+
419
+ `dsh-agent` 在 cordis 的 `Events` 上声明钩子。**没有** `agent/post-step`,也**没有** `agent/post-tool`:步骤结束是会话日志事件
420
+ `step/end`;工具结果观测用 `tools/result`(管线)或会话事件 `tool/result`(日志)。
421
+
422
+ ### 6.1 `agent/pre-step`(注入消息 / 拒绝步骤)—— 完整示例
423
+
424
+ ```ts
425
+ 'agent/pre-step'(
426
+ this: Scoped<Agent>,
427
+ payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal },
428
+ next: () => Promise<PreStepDecision>,
429
+ ): Promise<PreStepDecision>
430
+
431
+ type PreStepDecision = { kind: 'reject' } | { kind: 'enter'; messages: UserMessage[]; startsRequestSeries?: true }
432
+ ```
433
+
434
+ `next()` 保持当前 messages;返回值替换进入该步骤的消息,或拒绝该步骤(mode `waterfall`,按 agent 作用域过滤派发)。
435
+ 注入物必须是 `UserMessage`:`{ id, role: 'user', content: ContentBlock[], source }`,`source` 用
436
+ `{ kind: 'plugin', plugin: '<name>' } & ContextFormed`(`form: 'instructions' | 'catalog' | 'snapshot' | 'notice' | 'relay' | 'recall'`)。
437
+ `form` 决定客户端怎么渲染,且**每个形态只带自己那几个字段**(`ContextFormed` 是判别联合):
438
+ `sections` 只属于 `snapshot`,`summary` 只属于 `notice`,`instructions` 的渲染依据是
439
+ `changes`(工作区指令文件的改动清单,见 `dsh-agent-instructions`)。
440
+ 形态带错字段不会报错,但客户端会判为不可读并退回**不透明**渲染(仍然可见、带生产者标签),
441
+ 所以「注入了但样式不对」通常是 form/字段配错了。
442
+
443
+ ```ts
444
+ import type { Context } from '@deepseek-ai/cordis'
445
+ import { randomUUID } from 'node:crypto'
446
+
447
+ export function registerPreStep(ctx: Context, buildCard: (cwd: string) => Promise<string | undefined>): void {
448
+ ctx.on('agent/pre-step', async (payload, next) => {
449
+ const decision = await next()
450
+ if (decision.kind === 'reject' || payload.signal.aborted) return decision
451
+ const cwd = payload.agent.session.header.cwd
452
+ if (cwd === undefined) return decision
453
+ const note = await buildCard(cwd) // 你自己决定"注什么、何时注"
454
+ if (note === undefined) return decision
455
+ return {
456
+ kind: 'enter',
457
+ messages: [...decision.messages, {
458
+ id: randomUUID(), role: 'user', content: [{ type: 'text', text: note }],
459
+ source: { kind: 'plugin', plugin: 'my-plugin', form: 'snapshot', sections: [{ name: 'my-plugin', text: note }] },
460
+ }],
461
+ }
462
+ }, { prepend: true })
463
+ }
464
+ ```
465
+
466
+ 若 bundle 不依赖 `@deepseek-ai/dsh-agent` 类型(本插件的处境),可把 `ctx.on` 断言成
467
+ `(name: string, listener: (payload: unknown, next: () => Promise<PreStepDecision>) => Promise<PreStepDecision>) => unknown`,
468
+ 再手动收窄 payload。
469
+
470
+ ### 6.2 `tools/pre-execute`(否决 / 审批)—— 完整示例
471
+
472
+ ```ts
473
+ 'tools/pre-execute'(
474
+ this: Scoped<ToolRuntime>,
475
+ exec: ToolExecution,
476
+ next: () => Promise<PreToolDecision>,
477
+ ): Promise<PreToolDecision>
478
+
479
+ type PreToolDecision = { kind: 'allow' } | { kind: 'deny'; reason: string } | { kind: 'ask'; reason?: string }
480
+ ```
481
+
482
+ `next()` 委托为 allow;缺失审批服务时 `ask` 降级为拒绝;参数**不可改写**(`exec.arguments` 已入日志)。
483
+ 异步门禁必须观测 `exec.signal`(注册表在门禁结算后重新检查取消)。
484
+ 单调否决请用 `ctx.tools.guard(fn)`:同步、只能拒绝、不能被后续监听器翻回允许。
485
+
486
+ ```ts
487
+ import type { Context } from '@deepseek-ai/cordis'
488
+
489
+ const FORBIDDEN = new Set(['my_dangerous_tool'])
490
+
491
+ export function registerToolGate(ctx: Context): void {
492
+ ctx.on('tools/pre-execute', async (exec, next) => {
493
+ if (FORBIDDEN.has(exec.name)) return { kind: 'deny', reason: `tool "${exec.name}" is disabled by my-plugin` }
494
+ if (exec.name === 'write') return { kind: 'ask', reason: 'my-plugin requires approval for writes' }
495
+ return next()
496
+ })
497
+ ctx.tools.guard((exec) => (FORBIDDEN.has(exec.name) ? 'blocked by my-plugin policy' : undefined))
498
+ }
499
+ ```
500
+
501
+ ### 6.3 同族事件
502
+
503
+ ```ts
504
+ 'tools/execute'(exec: ToolDispatchExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
505
+ 'tools/post-execute'(exec: ToolExecution, result: Readonly<ToolExecutionResult>, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
506
+ 'tools/result'(exec: Readonly<ToolExecution>, result: Readonly<ToolExecutionResult>): undefined // emit
507
+ 'tools/change'(): void // emit
508
+ 'tools/ptc-dispatch-log'(dispatch: PtcDispatchLog, next: () => Promise<ContentBlock[]>): Promise<ContentBlock[]>
509
+ 'agent/session-start'({ agent: Agent; source: 'startup' | 'resume' | 'clear' | 'compact' }): void
510
+ 'agent/turn-stopping'({ agent: Agent; turn: number; signal: AbortSignal }): Promise<void> | void // serial
511
+ 'agent/created' | 'agent/disposed'({ agent: Agent }): void
512
+ 'agent/status'({ agent: Agent; status: 'idle' | 'running' }): void
513
+ 'agent/request'({ agent; turn; step; signal }, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
514
+ 'agent/request-error'({ agent; turn; step; provider; failure; retryPolicy; signal }, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
515
+ 'agent/error'({ agent: Agent; turn: number; step: number; error: unknown }): void
516
+ 'commands/change'(): void; 'skills/change'(): void
517
+
518
+ type PostToolDecision =
519
+ | { kind: 'accept'; content?: ContentBlock[]; value?: never; additionalContexts?: UserMessage[] }
520
+ | { kind: 'accept'; value: JsonValue; content?: never; additionalContexts?: UserMessage[] }
521
+ | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: UserMessage[] }
522
+ ```
523
+
524
+ 会话日志事件(`SessionEventMap`,经 `ctx.on('session/event', ...)` 观测):`turn/start`、`turn/end`、`step/start`、`step/end`、
525
+ `user/message`、`system/message`、`assistant/message`、`assistant/attempt`、`tool/call`、`tool/result`。
526
+ 所有带 `this: Scoped<Agent>` / `Scoped<ToolRuntime>` 的事件都按 agent 作用域过滤派发。
527
+
528
+ ---
529
+
530
+ ## 7. CLI:`@deepseek-ai/dsh`(profile 与插件安装)
531
+
532
+ ### 7.1 本地安装的确切命令行
533
+
534
+ `dsh plugin` 是 **pnpm 的薄转发器**(`lib/plugin-Ddi42qoW.js`):按需初始化 profile → 在 profile 目录执行
535
+ `spawnSync('pnpm', args, { cwd: profileDir, stdio: 'inherit' })` → 按安装结果校正 `dsh.profile.bundles`。
536
+
537
+ ```sh
538
+ # 本地目录:相对路径会被锚定到"你执行 dsh 的目录",而不是 profile 目录
539
+ dsh plugin --profile web add /abs/path/to/dsh-my-plugin
540
+ dsh plugin --profile web add ../dsh-my-plugin # → <cwd>/../dsh-my-plugin
541
+ dsh plugin --profile web add file:../dsh-my-plugin # file:/link: 前缀保留
542
+ dsh plugin --profile web add link:../dsh-my-plugin
543
+ dsh plugin --profile web remove dsh-my-plugin
544
+ dsh plugin --profile web install
545
+ dsh plugin --profile web why dsh-my-plugin
546
+ ```
547
+
548
+ 只有**相对路径规格**会被重写为绝对路径:`/^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/`;
549
+ 绝对路径、registry 名、`git+`/`github:`、tarball 规格原样透传。pnpm 退出码非 0 → 只打印
550
+ `pnpm failed in profile directory <dir>`;参数形如 `^git\+|^github:|\.git(?:#|$)` 时另提示需在
551
+ `<profileDir>/pnpm-workspace.yaml` 的 `allowBuilds` 放行 prepare 脚本。`--profile` 是 `plugin` 的 `requiredOption`,
552
+ 无参数报错,profile 名 `desktop` 被拒绝(Electron 独占)。**没有** `dsh plugin link` 子命令 —— 用 pnpm 的 `link:` 规格。
553
+
554
+ ### 7.2 `dsh.bundle.patch` 如何被消费
555
+
556
+ ```json
557
+ { "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } }
558
+ ```
559
+
560
+ `dsh plugin` 跑完 pnpm 后调用 `reconcilePlugins(before, profileDir)`:遍历 `dependencies`,对每个包 `resolveBundleDir()`
561
+ 并读 manifest,若 `dsh.bundle.patch !== undefined` 就把包名**追加**进 `dsh.profile.bundles`;不再声明或已从依赖移除的包移出列表。
562
+ 未声明 `dsh.bundle` 的依赖只是普通依赖,并打印一条 `declares no dsh.bundle` 警告(不是错误)。校正基于**安装后的实际状态**
563
+ 而非依赖 diff,因此 `update` 让某包获得 `dsh.bundle` 后会自动激活。profile 模板自带的内置 bundle 不是依赖,永不被改动。
564
+ `DshBundleManifest.patch` 是相对包目录的路径,`loadProfile` 解析为 `patchPath` 与 `patches: PatchOptions[]`。
565
+
566
+ ### 7.3 `cordis.patch.yml` schema
567
+
568
+ 文件是**顶层 YAML 数组**,元素为 `PatchOptions`(`@deepseek-ai/cordis-plugin-include`):
569
+
570
+ ```ts
571
+ interface PatchOptions {
572
+ id?: string; insert?: EntryOptions[]; name?: string; config?: any
573
+ group?: boolean | null; disabled?: boolean | null
574
+ inject?: any; intercept?: any; isolate?: any
575
+ [key: string]: any
576
+ }
577
+ interface EntryOptions {
578
+ id: string // 必填:包含树内稳定 id
579
+ name: string // 必填:模块说明符(相对名按配置目录解析,裸包名按 baseUrl)
580
+ config?: any; group?: boolean | null; disabled?: boolean | null; inject?: Inject | null
581
+ }
582
+ ```
583
+
584
+ 语义(`applyEntryPatches`,逐字对齐 `dsh-app-boot/lib/index.js`):
585
+
586
+ - 有 `insert`:带 `id` → 命中该 entry,要求它是 `group`,把 `insert` 追加进它的 `config` 数组;找不到 → 警告
587
+ `patch insert: entry "X" not found`;非 group → 警告并跳过。无 `id` → 追加到**根数组**。
588
+ - 无 `insert`:`id` 必填;命中目标后,若给了 `name` 且与目标 `name` 不符 → 警告并跳过;否则把其余键逐个覆盖到目标。
589
+ - 输入永不被修改(`structuredClone`);同一列表内后面的 patch 可命中前面 patch 插入的行;未命中任何行的 patch 只警告。
590
+
591
+ ```yaml
592
+ # bundle 的最小 patch 层(dsh-thesis 的真实文件)
593
+ - insert:
594
+ - id: ai-learning
595
+ name: dsh-thesis
596
+ # config: { stateDir: .ai-learning }
597
+ ---
598
+ - id: tools # 按 id 覆盖已有条目
599
+ config: { mode: both }
600
+ - id: my-row
601
+ disabled: true
602
+ ```
603
+
604
+ ### 7.4 profile 层叠顺序
605
+
606
+ 以空 entry 列表为起点,依次叠加:① `dsh.profile.bundles` 中每个 bundle 的 `cordis.patch.yml`(按列表顺序);
607
+ ② profile 目录自己的 `cordis.patch.yml`(`PROFILE_PATCH_FILENAME`);③ `$DSH_HOME/cordis.patch.yml`(home 级);
608
+ ④ `--patch <path>` 覆盖层(可重复,按给出顺序)。`patchReload: 'live' | 'startup'`:前者监视 profile 与 home 级 patch 文件
609
+ 并事务性重放,后者只在启动时应用一次。`--dump-config` / `--dump-default-config` 可在不启动的情况下查看合成树。
610
+
611
+ ### 7.5 运行时依赖解析与 "install farm" 兜底
612
+
613
+ - 共享兜底目录:`$DSH_HOME/profiles/node_modules`。`healProfilesModuleFallback()` 在每次 profile 启动时让它镜像
614
+ **dsh 安装自身的依赖闭包**;普通 Node 写 symlink(junction),打包可执行文件在跨进程锁下写 ESM 代理。
615
+ - 因此插件对 `@deepseek-ai/*` 的运行时 `import` 会沿 Node 父目录行走落到 `$DSH_HOME/profiles/node_modules`,
616
+ **无需自己安装 peer**(`@deepseek-ai/cordis` 即典型)。
617
+ - bundle 名解析**双锚**:先从 dsh 安装目录解析(保证 `@deepseek-ai/dsh-base` 永远来自同一安装),再从 profile 目录解析。
618
+ pnpm 管理的 `profile/node_modules` 条目优先;profile 私有链接只补"仅被所选 bundle 携带"的包,且不影响别的 profile。
619
+ - 建议:`@deepseek-ai/cordis` 放 `peerDependencies`;安装闭包之外的包放 `dependencies`。
620
+ - **必须发布编译后的 JS**:dsh CLI 依赖中没有 TypeScript 运行时加载器(`tsx`/`jiti` 均不在 `@deepseek-ai/dsh` 与
621
+ `dsh-app-boot` 的依赖中),`lib/bin.js` 是纯 ESM import。让 `main` 指向 `lib/index.js`,`exports` 同时给出 `types`
622
+ 与 `default`。源码执行(vite/tsx/plain ESM)只适用于在 dsh 仓库内用 `pnpm dsh` 的开发路径。
623
+
624
+ ### 7.6 名称与字符规则
625
+
626
+ | 对象 | 规则 | 违反后果 |
627
+ |---|---|---|
628
+ | 命令名 | `/^[a-z][a-z0-9_-]*$/u` | 注册时 `TypeError` |
629
+ | skill 名 | `/^[a-z0-9]+(?:-[a-z0-9]+)*$/` | 注册时 `Error: invalid skill name "X"`;文件发现时警告跳过 |
630
+ | profile 名 | 非空、不含 `/` 或 `\`、非 `.`/`..`/`node_modules`、非 `desktop` | `Error: dsh: invalid profile name "X"` / CLI 拒绝 desktop |
631
+ | bundle 包名 | 必须是 Node 可解析的 npm 包名;作为 `dsh.profile.bundles` 项时必须声明 `dsh.bundle` | `profile bundle "X" declares no dsh.bundle in its package.json` |
632
+ | 工具名 | `run_code` 被 PTC 传输保留 | `tool name "run_code" is reserved ...` |
633
+ | Loader entry `id` | 包含树内稳定唯一、非空 | 由 Loader entry 树裁决 |
634
+
635
+ ### 7.7 插件兼容性预检(peer 版本门)—— 最容易被忽略的一条装载规则
636
+
637
+ `dsh-app-boot` 的 `preflight()`(由 `prepareProfileEntries()` 在装载前对**每一行**调用)
638
+ 只做一件事:把该行解析到某个 `package.json`(`manifestOf()`:裸包名沿
639
+ `createRequire(parentURL).resolve.paths()` 逐级找 `<dir>/<bare>/package.json`;
640
+ `file:` / 绝对路径则从解析出的真实文件向上找最近的 `package.json`),然后执行
641
+ `evaluatePluginCompatibility(manifest, exemptions)`:
642
+
643
+ ```ts
644
+ // 逐字对齐 dsh-app-boot/lib/index.js 的 evaluatePluginCompatibility()
645
+ for (const [name, range] of Object.entries(manifest.peerDependencies ?? {})) {
646
+ if (name !== '@deepseek-ai/dsh' && !name.startsWith('@deepseek-ai/dsh-')) continue // 只查 dsh 系
647
+ const requirement = ['workspace:^', 'workspace:~', 'workspace:*'].includes(range)
648
+ ? runtimeVersion : range
649
+ if (!semver.satisfies(runtimeVersion, requirement, { includePrerelease: true })) peers[name] = range
650
+ }
651
+ // peers 非空 → 返回 issue;exempted === false 时该行被 disabled
652
+ ```
653
+
654
+ 要点(都是踩过的坑):
655
+
656
+ - **运行期版本来自 `@deepseek-ai/dsh-app-boot` 自己的 `package.json`**
657
+ (`getDshRuntimeVersion()`),不是 `dsh` 的版本号,也不是插件的 `version`。
658
+ - **只有 `@deepseek-ai/dsh` / `@deepseek-ai/dsh-*` 被检查**:`@deepseek-ai/cordis`、
659
+ `@deepseek-ai/schemastery` 写什么都不影响这条门(第一方插件的惯例是 dsh 系**精确锁版**
660
+ `"0.2.0-rc.2"`、cordis 用 `~4.0.4`;第三方插件无法与 dsh 同步发版,因此本项目用
661
+ `>=0.2.0-rc.2 <0.3.0-0` 这种带下界的 range)。
662
+ - **`peerDependenciesMeta.optional` 不豁免**;`engines.dsh` 也不参与判定(只是声明)。
663
+ - **`includePrerelease: true` 让 `<0.3.0` 也接受 `0.3.0-rc.1`**(预发布版排序在正式版之前),
664
+ 要真正卡住小版本边界得写 `<0.3.0-0`。
665
+ - 后果不是异常,而是**该行 `disabled` + 一行 stderr**:
666
+ `dsh: disabling profile plugin row "paper": Plugin <name>@<version> is incompatible with dsh <runtime>: peerDependencies {...}`
667
+ —— 现场表现是「插件装了但完全没反应」。可用 `dsh plugin allow-version` 给
668
+ `包名@版本` × `精确 dsh 版本` 开豁免(写进 profile 的 `compatibility.json`),
669
+ 但正解是修 range。
670
+ - **这条门不执行插件代码,也不跑 `--dump-config`**:`--dump-config` 只组合配置树,
671
+ 旧 range 也能组合成功。要观察它必须真的启动一次,或者直接用上面的函数判定
672
+ (本仓库的 `scripts/lib/host-compat.mjs` 就是这么做的,`npm run test:host-load`
673
+ 会同时检查**本仓库 manifest** 与 **profile 里已安装的那一份**)。
674
+
675
+ ---
676
+
677
+ ## 8. 无法确定的事项(含已检查的内容)
678
+
679
+ 1. **工具名的字符文法**:`dsh-tools` 的 `register()` 只检查 `run_code` 保留名与同层重复,**没有**名字正则,`ToolSchema.name` 也无 brand;
680
+ `dsh-tools/lib/index.js` 的 SDK 渲染注释甚至以 `zz-\u{1E4D0}x` 这类名字为合法输入举例。我在已安装产物中找不到工具名字符白名单。
681
+ 检查过:`dsh-tools/lib/index.js`(`register`、`defineTool`、`ts-types` 渲染)、`dsh-llm/lib/types/*.d.ts` 的 `ToolSchema`。
682
+ 实际约束来自模型 Function Calling 惯例与 `run_code` 保留名,无法从本产物给出权威正则。
683
+ 2. **"插件名允许字符"没有独立校验器**:`dsh plugin` 把包名交给 pnpm,加载时靠 Node 模块解析;唯一相近的显式规则是 7.6 的
684
+ profile 名与命令名。检查过:`dsh/lib/plugin-Ddi42qoW.js`、`dsh/lib/bin.js`、`dsh-app-boot/lib/index.js`、
685
+ `dsh-host-plugin-inventory/lib/types/*.d.ts`、`dsh-client-ui-settings-plugins/lib/client.js`。
686
+ 3. `ctx.fs` 的存在性检查与递归列举**确实不存在**(合约边界,非搜索遗漏):抽象类成员即第 3 节列出的 13 个原语,
687
+ README.zh.md 亦明写"只有十三个原语""`listDir` 只列出一层"。
688
+ 4. **`agent/post-step` 不存在**:`dsh-agent` 事件表止于 `agent/turn-stopping`;对应观测点是 `step/end`。
689
+ 检查过 `dsh-agent/lib/types/runtime-types.d.ts` 全文 419 行。
690
+ 5. `dsh plugin link` 子命令不存在(见 7.1)。
691
+ 6. `ToolOutputError` 构造签名 `.d.ts` 与 `.js` 不一致(见 2.3)。
692
+ 7. `defineTool` 的 `parameters` 在 README.zh.md 示例里长得像 JSON Schema,实际是 DSL 属性映射(见 2.1)。