@dsh-plus/agent-preset-rea 0.1.0 → 0.1.2

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/src/definition.ts CHANGED
@@ -8,8 +8,13 @@
8
8
  *
9
9
  * 增量仅三处,测试按 {@link ADDED_ROWS} / {@link CONFIG_DELTAS} 白名单放行:
10
10
  * 1. `persona` 行 `prefix` 取配置值(默认空串 = 空 persona);
11
- * 2. `skill-filesystem` 行加 `customSkillDirs`(随包 REA skill + 配置追加目录);
12
- * 3. 新增 `mcp-rea` 行:官方 MCP 客户端连 REA stdio 服务,落在本预设作用域内。
11
+ * 2. `skill-filesystem` 行加 `customSkillDirs`(REA skill 源与 reverse-skill 技能组,
12
+ * 各受一个活动开关控制,另加部署方 `extraSkillDirs`);
13
+ * 3. 新增 `mcp-rea` 行:官方 MCP 客户端连 REA stdio 服务,落在本预设作用域内;
14
+ * **未安装 REA 时该行 `disabled`**(不 spawn npx,见 mcp.ts 的说明)。
15
+ *
16
+ * {@link RuntimeSnapshot} 是运行期事实(受管安装与 skill 来源),由 manager 现探后
17
+ * 传入;不传即「未安装 + 随包 skill」,测试据此断言两种形态。
13
18
  *
14
19
  * 组行必须带 `isolate` 归属 realm,否则注册表拒绝挂载(服务泄漏判定)。
15
20
  * @module @dsh-plus/agent-preset-rea/definition
@@ -17,9 +22,11 @@
17
22
  import type { PresetDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
18
23
 
19
24
  import type { AgentPresetReaConfig } from './config.ts'
20
- import { reaMcpArgs, reaMcpEnv } from './mcp.ts'
25
+ import type { RuntimeSnapshot } from './manager.ts'
26
+ import { DEFAULT_MCP_COMMAND, reaMcpArgs, reaMcpEnv, reaMcpLaunch } from './mcp.ts'
21
27
  import { PLAN_MODE_SECTION } from './prompt.ts'
22
- import { reaSkillDirs } from './skill.ts'
28
+ import { emptyReverseState } from './reverse.ts'
29
+ import { BUNDLED_SKILL_ROOT, presetSkillDirs } from './skill.ts'
23
30
 
24
31
  /** 预设注册表身份(改 id 等于换预设,default 等引用都会落空)。 */
25
32
  export const REA_PRESET_ID = 'rea'
@@ -42,147 +49,197 @@ const POSIX_ONLY = { __jsExpr: "process.platform !== 'win32'" } as const
42
49
 
43
50
  /** 官方 loader 的嵌套组行名。 */
44
51
  const GROUP = 'cordis:group'
52
+ /** 未安装、随包 skill 的运行期事实(默认值:测试与无 manager 场景)。 */
53
+ export function emptyRuntime(): RuntimeSnapshot {
54
+ return {
55
+ install: null,
56
+ skill: {
57
+ source: 'bundled',
58
+ version: null,
59
+ root: BUNDLED_SKILL_ROOT,
60
+ files: 0,
61
+ updatedAt: null,
62
+ },
63
+ reverse: emptyReverseState(),
64
+ }
65
+ }
66
+
67
+ /** 声明里的行类型(官方 loader entry 结构)。 */
68
+ type Row = PresetDefinition['plugins'][number]
45
69
 
46
70
  /**
47
- * 由配置拼出注册表声明。
71
+ * 基础与工具面行:身份(persona)/ 项目指令与时钟 / shell 与文件工具 / 后台任务与
72
+ * 定时提醒 / skill 文件系统(本预设加 `customSkillDirs`,可指向手动更新副本)。
73
+ */
74
+ function baseRows(config: AgentPresetReaConfig, runtime: RuntimeSnapshot): Row[] {
75
+ return [
76
+ // 身份:沿用官方装配(非 complete、保留运行时上下文),前缀取配置(默认空串:
77
+ // 空模板照样遮蔽部署级 persona 段,渲染为空后消失,不追加任何自有文案)。
78
+ {
79
+ id: 'persona',
80
+ name: '@deepseek-ai/dsh-persona',
81
+ config: { prefix: config.personaPrefix },
82
+ },
83
+ // 项目指令(AGENTS.md)与逐步时钟上下文(默认 10 分钟一条)。
84
+ {
85
+ id: 'agent-instructions',
86
+ name: '@deepseek-ai/dsh-agent-instructions',
87
+ config: { maxBytes: 65536 },
88
+ },
89
+ { id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
90
+ // shell:一次性执行,执行器在宿主平面;平台门控与上游一致。
91
+ { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash', disabled: WINDOWS_ONLY },
92
+ { id: 'tool-pwsh', name: '@deepseek-ai/dsh-tool-pwsh', disabled: POSIX_ONLY },
93
+ // 文件读写、检索与后台任务控制。
94
+ { id: 'tool-fs', name: '@deepseek-ai/dsh-tool-fs' },
95
+ {
96
+ id: 'tool-fs-search',
97
+ name: '@deepseek-ai/dsh-tool-fs-search',
98
+ config: { sampleOverCapGlobResults: false },
99
+ },
100
+ { id: 'tool-jobs', name: '@deepseek-ai/dsh-tool-jobs' },
101
+ { id: 'tool-schedule', name: '@deepseek-ai/dsh-tool-schedule' },
102
+ // 技能:本地目录发现 + 模型面加载器;本预设额外挂上随包 REA 官方 skill 根
103
+ // (customSkillDirs 是 rank 300 的追加根,不动项目/用户/bundled 根)。
104
+ {
105
+ id: 'skill-filesystem',
106
+ name: '@deepseek-ai/dsh-skill-filesystem',
107
+ config: {
108
+ customSkillDirs: presetSkillDirs(config, runtime),
109
+ },
110
+ },
111
+ { id: 'tool-skill', name: '@deepseek-ai/dsh-tool-skill' },
112
+ // 同会话目标:/goal 命令与模型面目标工具。
113
+ { id: 'command-goal', name: '@deepseek-ai/dsh-command-goal' },
114
+ { id: 'tool-goal', name: '@deepseek-ai/dsh-tool-goal' },
115
+ ]
116
+ }
117
+
118
+ /**
119
+ * 编排与交付面行:计划模式 / 上下文压缩 / 委派与 workflow / 交互与待办 / 网络工具 /
120
+ * **REA MCP 客户端** / 展示与插件管理。
121
+ */
122
+ function orchestrationRows(config: AgentPresetReaConfig, runtime: RuntimeSnapshot): Row[] {
123
+ // 启动方式:显式 mcpCommand > 受管安装入口 > null(行禁用)。
124
+ const launch = reaMcpLaunch(config, runtime.install)
125
+ // 无启动方式时行被禁用,但 config 仍填经典 npx 配方:配置转储里能看到原始配方,
126
+ // 手工把行 enable 回来即回退到「启动期拉取」的老行为——默认路径绝不 spawn 它。
127
+ const mcp = launch ?? {
128
+ command: DEFAULT_MCP_COMMAND,
129
+ args: reaMcpArgs({ ...config, mcpCommand: DEFAULT_MCP_COMMAND }),
130
+ }
131
+ return [
132
+ // 计划模式:状态天然属于单个 agent,条目级 realm 即正确生命周期。
133
+ {
134
+ id: 'planning',
135
+ name: GROUP,
136
+ group: true,
137
+ isolate: { planMode: true },
138
+ config: [
139
+ {
140
+ id: 'plan-mode',
141
+ name: '@deepseek-ai/dsh-plan-mode',
142
+ config: { section: PLAN_MODE_SECTION },
143
+ },
144
+ ],
145
+ },
146
+ // 压缩:token 计量器留在宿主平面,本组只挂读取它的后端与结果裁剪器。
147
+ {
148
+ id: 'compaction',
149
+ name: GROUP,
150
+ group: true,
151
+ isolate: { compaction: true, toolResultPruner: true },
152
+ config: [
153
+ { id: 'compaction-basic', name: '@deepseek-ai/dsh-compaction-basic' },
154
+ { id: 'command-compact', name: '@deepseek-ai/dsh-command-compact' },
155
+ {
156
+ id: 'tool-result-pruner',
157
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner',
158
+ config: { thresholdChars: 8192, headChars: 4096, tailChars: 1024 },
159
+ },
160
+ ],
161
+ },
162
+ // 委派与编排:子代理、fork、workflow 整组(编排引擎只在本 realm 内共享)。
163
+ {
164
+ id: 'delegation',
165
+ name: GROUP,
166
+ group: true,
167
+ isolate: { workflowEngine: true },
168
+ config: [
169
+ { id: 'tool-subagent-control', name: '@deepseek-ai/dsh-tool-subagent-control' },
170
+ {
171
+ id: 'tool-subagent-list-agents',
172
+ name: '@deepseek-ai/dsh-tool-subagent-control/list-agents',
173
+ },
174
+ {
175
+ id: 'tool-subagent',
176
+ name: '@deepseek-ai/dsh-tool-subagent',
177
+ config: { provider: 'spawn', toolName: 'subagent', modelSelectionSettings: true },
178
+ },
179
+ {
180
+ id: 'tool-subagent-fork',
181
+ name: '@deepseek-ai/dsh-tool-subagent',
182
+ config: { provider: 'fork', toolName: 'subagent_fork' },
183
+ },
184
+ {
185
+ id: 'workflow-ptc',
186
+ name: '@deepseek-ai/dsh-workflow-ptc',
187
+ config: { provider: 'spawn' },
188
+ },
189
+ { id: 'tool-workflow', name: '@deepseek-ai/dsh-tool-workflow' },
190
+ ],
191
+ },
192
+ // 其余模型面行。
193
+ { id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' },
194
+ {
195
+ id: 'tool-todo',
196
+ name: '@deepseek-ai/dsh-tool-todo',
197
+ config: { allowParallelInProgress: true },
198
+ },
199
+ {
200
+ id: 'tool-web',
201
+ name: '@deepseek-ai/dsh-tool-web',
202
+ config: { fetch: true, searchTimeoutMs: 60000 },
203
+ },
204
+ // 本预设新增:REA MCP 客户端(stdio),工具以 mcp__<serverName>__<tool> 注册在
205
+ // 本预设作用域;服务名预留按作用域隔离,其他预设不受影响。
206
+ // 未安装 REA 且未显式配 mcpCommand 时整行禁用:绝不在 boot 期 spawn npx
207
+ // (那会跑 npm 安装脚本,卡住即吊死预设激活与名册——见 docs/README.md)。
208
+ {
209
+ id: 'mcp-rea',
210
+ name: '@deepseek-ai/dsh-mcp-client',
211
+ disabled: !config.mcpEnabled || launch === null,
212
+ config: {
213
+ transport: 'stdio',
214
+ serverName: config.mcpServerName,
215
+ command: mcp.command,
216
+ args: mcp.args,
217
+ env: reaMcpEnv(config),
218
+ toolCallTimeoutMs: config.toolCallTimeoutMs,
219
+ failOnStartupError: config.failOnStartupError,
220
+ },
221
+ },
222
+ { id: 'present', name: '@deepseek-ai/dsh-tool-present' },
223
+ // 宿主插件管理:上游 standard 里同样是 disabled 行。
224
+ { id: 'tool-plugin-manager', name: '@deepseek-ai/dsh-plugin-manager/tools', disabled: true },
225
+ ]
226
+ }
227
+
228
+ /**
229
+ * 由配置 + 运行期事实拼出注册表声明。
48
230
  * @param config - 已验证的插件配置。
231
+ * @param runtime - 受管安装与 skill 来源事实(默认「未安装 + 随包 skill」)。
49
232
  * @returns id 固定为 `rea` 的预设声明。
50
233
  */
51
- export function reaPresetDefinition(config: AgentPresetReaConfig): PresetDefinition {
234
+ export function reaPresetDefinition(
235
+ config: AgentPresetReaConfig,
236
+ runtime: RuntimeSnapshot = emptyRuntime(),
237
+ ): PresetDefinition {
52
238
  return {
53
239
  id: REA_PRESET_ID,
54
240
  name: config.name,
55
241
  description: config.description,
56
242
  order: config.order,
57
- plugins: [
58
- // 身份:沿用官方装配(非 complete、保留运行时上下文),前缀取配置(默认空串:
59
- // 空模板照样遮蔽部署级 persona 段,渲染为空后消失,不追加任何自有文案)。
60
- {
61
- id: 'persona',
62
- name: '@deepseek-ai/dsh-persona',
63
- config: { prefix: config.personaPrefix },
64
- },
65
- // 项目指令(AGENTS.md)与逐步时钟上下文(默认 10 分钟一条)。
66
- {
67
- id: 'agent-instructions',
68
- name: '@deepseek-ai/dsh-agent-instructions',
69
- config: { maxBytes: 65536 },
70
- },
71
- { id: 'time-context', name: '@deepseek-ai/dsh-time-context' },
72
- // shell:一次性执行,执行器在宿主平面;平台门控与上游一致。
73
- { id: 'tool-bash', name: '@deepseek-ai/dsh-tool-bash', disabled: WINDOWS_ONLY },
74
- { id: 'tool-pwsh', name: '@deepseek-ai/dsh-tool-pwsh', disabled: POSIX_ONLY },
75
- // 文件读写、检索与后台任务控制。
76
- { id: 'tool-fs', name: '@deepseek-ai/dsh-tool-fs' },
77
- {
78
- id: 'tool-fs-search',
79
- name: '@deepseek-ai/dsh-tool-fs-search',
80
- config: { sampleOverCapGlobResults: false },
81
- },
82
- { id: 'tool-jobs', name: '@deepseek-ai/dsh-tool-jobs' },
83
- { id: 'tool-schedule', name: '@deepseek-ai/dsh-tool-schedule' },
84
- // 技能:本地目录发现 + 模型面加载器;本预设额外挂上随包 REA 官方 skill 根
85
- // (customSkillDirs 是 rank 300 的追加根,不动项目/用户/bundled 根)。
86
- {
87
- id: 'skill-filesystem',
88
- name: '@deepseek-ai/dsh-skill-filesystem',
89
- config: { customSkillDirs: reaSkillDirs(config) },
90
- },
91
- { id: 'tool-skill', name: '@deepseek-ai/dsh-tool-skill' },
92
- // 同会话目标:/goal 命令与模型面目标工具。
93
- { id: 'command-goal', name: '@deepseek-ai/dsh-command-goal' },
94
- { id: 'tool-goal', name: '@deepseek-ai/dsh-tool-goal' },
95
- // 计划模式:状态天然属于单个 agent,条目级 realm 即正确生命周期。
96
- {
97
- id: 'planning',
98
- name: GROUP,
99
- group: true,
100
- isolate: { planMode: true },
101
- config: [
102
- {
103
- id: 'plan-mode',
104
- name: '@deepseek-ai/dsh-plan-mode',
105
- config: { section: PLAN_MODE_SECTION },
106
- },
107
- ],
108
- },
109
- // 压缩:token 计量器留在宿主平面,本组只挂读取它的后端与结果裁剪器。
110
- {
111
- id: 'compaction',
112
- name: GROUP,
113
- group: true,
114
- isolate: { compaction: true, toolResultPruner: true },
115
- config: [
116
- { id: 'compaction-basic', name: '@deepseek-ai/dsh-compaction-basic' },
117
- { id: 'command-compact', name: '@deepseek-ai/dsh-command-compact' },
118
- {
119
- id: 'tool-result-pruner',
120
- name: '@deepseek-ai/dsh-compaction-tool-result-pruner',
121
- config: { thresholdChars: 8192, headChars: 4096, tailChars: 1024 },
122
- },
123
- ],
124
- },
125
- // 委派与编排:子代理、fork、workflow 整组(编排引擎只在本 realm 内共享)。
126
- {
127
- id: 'delegation',
128
- name: GROUP,
129
- group: true,
130
- isolate: { workflowEngine: true },
131
- config: [
132
- { id: 'tool-subagent-control', name: '@deepseek-ai/dsh-tool-subagent-control' },
133
- {
134
- id: 'tool-subagent-list-agents',
135
- name: '@deepseek-ai/dsh-tool-subagent-control/list-agents',
136
- },
137
- {
138
- id: 'tool-subagent',
139
- name: '@deepseek-ai/dsh-tool-subagent',
140
- config: { provider: 'spawn', toolName: 'subagent', modelSelectionSettings: true },
141
- },
142
- {
143
- id: 'tool-subagent-fork',
144
- name: '@deepseek-ai/dsh-tool-subagent',
145
- config: { provider: 'fork', toolName: 'subagent_fork' },
146
- },
147
- {
148
- id: 'workflow-ptc',
149
- name: '@deepseek-ai/dsh-workflow-ptc',
150
- config: { provider: 'spawn' },
151
- },
152
- { id: 'tool-workflow', name: '@deepseek-ai/dsh-tool-workflow' },
153
- ],
154
- },
155
- // 其余模型面行。
156
- { id: 'tool-ask-user', name: '@deepseek-ai/dsh-tool-ask-user' },
157
- {
158
- id: 'tool-todo',
159
- name: '@deepseek-ai/dsh-tool-todo',
160
- config: { allowParallelInProgress: true },
161
- },
162
- {
163
- id: 'tool-web',
164
- name: '@deepseek-ai/dsh-tool-web',
165
- config: { fetch: true, searchTimeoutMs: 60000 },
166
- },
167
- // 本预设新增:REA MCP 客户端(stdio),工具以 mcp__<serverName>__<tool> 注册在
168
- // 本预设作用域;服务名预留按作用域隔离,其他预设不受影响。
169
- {
170
- id: 'mcp-rea',
171
- name: '@deepseek-ai/dsh-mcp-client',
172
- disabled: !config.mcpEnabled,
173
- config: {
174
- transport: 'stdio',
175
- serverName: config.mcpServerName,
176
- command: config.mcpCommand,
177
- args: reaMcpArgs(config),
178
- env: reaMcpEnv(config),
179
- toolCallTimeoutMs: config.toolCallTimeoutMs,
180
- failOnStartupError: config.failOnStartupError,
181
- },
182
- },
183
- { id: 'present', name: '@deepseek-ai/dsh-tool-present' },
184
- // 宿主插件管理:上游 standard 里同样是 disabled 行。
185
- { id: 'tool-plugin-manager', name: '@deepseek-ai/dsh-plugin-manager/tools', disabled: true },
186
- ],
243
+ plugins: [...baseRows(config, runtime), ...orchestrationRows(config, runtime)],
187
244
  }
188
245
  }
package/src/dto.ts ADDED
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 宿主半与浏览器半之间的 wire 数据门面(纯类型 + 纯函数,零 node 依赖)。
3
+ *
4
+ * 浏览器半只 `import type` 消费本文件(编译期擦除,不进 client bundle),
5
+ * 两端共用同一份字段定义,避免「宿主改了字段、卡片还在读老名字」的静默漂移。
6
+ * @module @dsh-plus/agent-preset-rea/dto
7
+ */
8
+
9
+ /** 作业种类:REA 包安装/更新、REA skill 更新、reverse-skill 更新。 */
10
+ export type JobKind = 'install' | 'skill' | 'reverse'
11
+
12
+ /** 作业状态。 */
13
+ export type JobState = 'running' | 'ok' | 'error' | 'cancelled'
14
+
15
+ /** 作业进度(按阶段推进;percent 为进入该阶段时的完成度)。 */
16
+ export interface JobProgressDto {
17
+ step: number
18
+ steps: number
19
+ phase: string
20
+ percent: number
21
+ }
22
+
23
+ /** 作业快照。 */
24
+ export interface JobDto {
25
+ id: number
26
+ kind: JobKind
27
+ state: JobState
28
+ progress: JobProgressDto
29
+ /** 日志尾部(宿主已有界)。 */
30
+ log: string[]
31
+ startedAt: string
32
+ finishedAt: string | null
33
+ version: string | null
34
+ error: string | null
35
+ }
36
+
37
+ /** 受管安装事实。 */
38
+ export interface InstallDto {
39
+ installed: boolean
40
+ /** 已安装版本(未安装为 null)。 */
41
+ version: string | null
42
+ /** 受管安装树位置(无论是否已安装都有值,便于用户核对)。 */
43
+ root: string
44
+ /** 入口脚本(未安装为 null)。 */
45
+ entry: string | null
46
+ }
47
+
48
+ /** skill 来源事实。 */
49
+ export interface SkillDto {
50
+ source: 'bundled' | 'updated'
51
+ version: string | null
52
+ root: string
53
+ files: number
54
+ updatedAt: string | null
55
+ }
56
+
57
+ /** MCP 行形态。 */
58
+ export interface McpDto {
59
+ /** `installed` = 用受管安装入口;`custom` = 用显式 mcpCommand;`missing` = 未安装且无显式命令(行禁用)。 */
60
+ mode: 'installed' | 'custom' | 'missing'
61
+ /** 行是否启用(mcpEnabled && 有启动方式)。 */
62
+ enabled: boolean
63
+ command: string | null
64
+ args: string[]
65
+ }
66
+
67
+ /** GitHub 技能源(zhaoxuya520/reverse-skill)事实。 */
68
+ export interface ReverseSkillDto {
69
+ installed: boolean
70
+ repo: string
71
+ /** 清单记录的 ref(空串 = 安装时取的默认分支)。 */
72
+ ref: string | null
73
+ /** 提交 sha(内容级溯源)。 */
74
+ sha: string | null
75
+ /** 仓库 VERSION 文件内容。 */
76
+ version: string | null
77
+ root: string
78
+ tree: string
79
+ files: number
80
+ /** 树内发现的全部技能组(相对安装树根)。 */
81
+ groups: string[]
82
+ /** 配置选中且实际挂进预设的组。 */
83
+ activeGroups: string[]
84
+ updatedAt: string | null
85
+ }
86
+
87
+ /** `GET /status` 响应。 */
88
+ export interface StatusDto {
89
+ config: {
90
+ reaVersion: string
91
+ proxyPort: number
92
+ mcpEnabled: boolean
93
+ skillEnabled: boolean
94
+ reverseSkillEnabled: boolean
95
+ reverseSkillRepo: string
96
+ reverseSkillRef: string
97
+ reverseSkillGroups: string[]
98
+ installRoot: string
99
+ }
100
+ install: InstallDto
101
+ skill: SkillDto
102
+ reverse: ReverseSkillDto
103
+ mcp: McpDto
104
+ paths: { installRoot: string; skillRoot: string; reverseRoot: string }
105
+ job: JobDto | null
106
+ }
107
+
108
+ /** 作业类响应(启动/取消)。 */
109
+ export interface JobActionDto {
110
+ job: JobDto | null
111
+ /** 拒绝原因(409/400 时给出人话)。 */
112
+ error?: string
113
+ }
package/src/exec.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * 外部命令执行面与作业原语:受管安装/更新作业要跑 `npm` / `tar`,这些调用全部经
3
+ * {@link CommandRunner} 注入,单测用假 runner 跑完整状态机(零网络、零 npm)。
4
+ *
5
+ * 作业阶段由 {@link JobTaskContext} 上报,日志行由 {@link JobOperation} 的调用方
6
+ * 收集;本模块只负责「怎么跑命令」与「怎么把输出变成日志行」。
7
+ * @module @dsh-plus/agent-preset-rea/exec
8
+ */
9
+ import { spawn } from 'node:child_process'
10
+ import { readdirSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ import type { AgentPresetReaConfig } from './config.ts'
14
+
15
+ /** 版本字符串形状:进 shell 前把用户可配项收窄成纯版本号。 */
16
+ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
17
+
18
+ /** npm 命令名(Windows 经 shell 执行,见 {@link spawnRunner} 注释)。 */
19
+ export const NPM = 'npm'
20
+
21
+ /** 作业取消(AbortSignal 触发后由 runner 抛出)。 */
22
+ export class JobCancelled extends Error {
23
+ constructor() {
24
+ super('作业已取消')
25
+ this.name = 'JobCancelled'
26
+ }
27
+ }
28
+
29
+ /** 外部命令执行面。 */
30
+ export interface CommandRunner {
31
+ run(
32
+ command: string,
33
+ args: string[],
34
+ options: {
35
+ env: Record<string, string>
36
+ signal: AbortSignal
37
+ onLine(line: string): void
38
+ },
39
+ ): Promise<{ code: number }>
40
+ }
41
+
42
+ /** 作业执行上下文(阶段推进 + 日志 + 取消信号)。 */
43
+ export interface JobTaskContext {
44
+ /** 进入第 step 个阶段(从 0 起,与 {@link JobOperation.phases} 对应)。 */
45
+ phase(step: number): void
46
+ log(line: string): void
47
+ signal: AbortSignal
48
+ }
49
+
50
+ /** 一类作业:阶段名 + 实现。 */
51
+ export interface JobOperation {
52
+ phases: string[]
53
+ run(ctx: JobTaskContext): Promise<string>
54
+ }
55
+
56
+ /** 作业依赖:生效配置、命令执行面、作业完成后的现探刷新。 */
57
+ export interface JobDeps {
58
+ config: AgentPresetReaConfig
59
+ runner: CommandRunner
60
+ refresh(): void
61
+ }
62
+
63
+ /** 实际 runner:spawn 子进程、逐行回调、AbortSignal 触发即杀。 */
64
+ export const spawnRunner: CommandRunner = {
65
+ run: (command, args, options) =>
66
+ new Promise((resolve, reject) => {
67
+ // Windows 下 npm/tar 都是 .cmd/.exe 包装,shell:false 无法直接执行
68
+ // (与官方 MCP 客户端同一坑);参数全部由本模块构造,用户可配的版本号已按
69
+ // 形状收窄,无拼串注入面。
70
+ const child = spawn(command, args, {
71
+ env: options.env,
72
+ shell: process.platform === 'win32',
73
+ stdio: ['ignore', 'pipe', 'pipe'],
74
+ })
75
+ let pending = ''
76
+ const emit = (chunk: string): void => {
77
+ pending += chunk
78
+ const lines = pending.split('\n')
79
+ pending = lines.pop() ?? ''
80
+ for (const line of lines) options.onLine(line.trimEnd())
81
+ }
82
+ child.stdout.setEncoding('utf8')
83
+ child.stderr.setEncoding('utf8')
84
+ child.stdout.on('data', emit)
85
+ child.stderr.on('data', emit)
86
+ const abort = (): void => {
87
+ child.kill('SIGTERM')
88
+ }
89
+ options.signal.addEventListener('abort', abort, { once: true })
90
+ child.on('error', reject)
91
+ child.on('close', (code) => {
92
+ options.signal.removeEventListener('abort', abort)
93
+ if (pending !== '') options.onLine(pending.trimEnd())
94
+ if (options.signal.aborted) reject(new JobCancelled())
95
+ else resolve({ code: code ?? 1 })
96
+ })
97
+ }),
98
+ }
99
+
100
+ /** 本机代理 URL(未配置代理时为 null)。 */
101
+ export function proxyUrl(config: AgentPresetReaConfig): string | null {
102
+ return config.proxyPort > 0 ? `http://127.0.0.1:${config.proxyPort}` : null
103
+ }
104
+
105
+ /**
106
+ * 子进程环境:继承宿主环境,附带代理端口(port > 0 时给 `https_proxy` 系列,
107
+ * prebuild-install 与 curl 都认这些变量——原生依赖 prebuild 下载卡死的正解)。
108
+ * @param config - 插件配置。
109
+ * @param npm - 是否附带 npm 专属开关(npm 子进程用;curl 等非 npm 命令不需要)。
110
+ */
111
+ export function commandEnv(config: AgentPresetReaConfig, npm = false): Record<string, string> {
112
+ const env: Record<string, string> = {}
113
+ for (const [key, value] of Object.entries(process.env)) {
114
+ if (value !== undefined) env[key] = value
115
+ }
116
+ if (npm) {
117
+ env['npm_config_audit'] = 'false'
118
+ env['npm_config_fund'] = 'false'
119
+ env['npm_config_update_notifier'] = 'false'
120
+ env['npm_config_loglevel'] = 'http'
121
+ }
122
+ const proxy = proxyUrl(config)
123
+ if (proxy !== null) {
124
+ env['HTTP_PROXY'] = proxy
125
+ env['HTTPS_PROXY'] = proxy
126
+ env['http_proxy'] = proxy
127
+ env['https_proxy'] = proxy
128
+ if (npm) {
129
+ env['npm_config_proxy'] = proxy
130
+ env['npm_config_https_proxy'] = proxy
131
+ }
132
+ }
133
+ return env
134
+ }
135
+
136
+ /** npm 子进程环境({@link commandEnv} 的 npm 形态)。 */
137
+ export function npmEnv(config: AgentPresetReaConfig): Record<string, string> {
138
+ return commandEnv(config, true)
139
+ }
140
+
141
+ /** 版本收窄:进命令前把配置里的钉版校验成纯版本号。 */
142
+ export function safeVersion(version: string): string {
143
+ const trimmed = version.trim()
144
+ if (!VERSION_PATTERN.test(trimmed)) {
145
+ throw new Error(`配置里的 reaVersion 不是合法版本号:${JSON.stringify(version)}`)
146
+ }
147
+ return trimmed
148
+ }
149
+
150
+ /** 逐行转发子进程输出到作业日志(截断超长行)。 */
151
+ export function lineSink(ctx: JobTaskContext): (line: string) => void {
152
+ return (line) => {
153
+ if (line !== '') ctx.log(line.slice(0, 500))
154
+ }
155
+ }
156
+
157
+ /** 递归列出目录下全部文件(相对 POSIX 路径,排序稳定)。 */
158
+ export function listFiles(dir: string, base = ''): string[] {
159
+ const out: string[] = []
160
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
161
+ a.name.localeCompare(b.name),
162
+ )) {
163
+ const rel = base === '' ? entry.name : `${base}/${entry.name}`
164
+ if (entry.isDirectory()) out.push(...listFiles(join(dir, entry.name), rel))
165
+ else out.push(rel)
166
+ }
167
+ return out
168
+ }
169
+
170
+ /** 目录下文件数(目录缺席或不可读按 0;状态展示用,不抛)。 */
171
+ export function countFiles(dir: string): number {
172
+ try {
173
+ return listFiles(dir).length
174
+ } catch {
175
+ return 0
176
+ }
177
+ }