@dsh-plus/agent-preset-rea 0.1.0 → 0.1.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/src/exec.ts ADDED
@@ -0,0 +1,155 @@
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
+ /** 实际 runner:spawn 子进程、逐行回调、AbortSignal 触发即杀。 */
57
+ export const spawnRunner: CommandRunner = {
58
+ run: (command, args, options) =>
59
+ new Promise((resolve, reject) => {
60
+ // Windows 下 npm/tar 都是 .cmd/.exe 包装,shell:false 无法直接执行
61
+ // (与官方 MCP 客户端同一坑);参数全部由本模块构造,用户可配的版本号已按
62
+ // 形状收窄,无拼串注入面。
63
+ const child = spawn(command, args, {
64
+ env: options.env,
65
+ shell: process.platform === 'win32',
66
+ stdio: ['ignore', 'pipe', 'pipe'],
67
+ })
68
+ let pending = ''
69
+ const emit = (chunk: string): void => {
70
+ pending += chunk
71
+ const lines = pending.split('\n')
72
+ pending = lines.pop() ?? ''
73
+ for (const line of lines) options.onLine(line.trimEnd())
74
+ }
75
+ child.stdout.setEncoding('utf8')
76
+ child.stderr.setEncoding('utf8')
77
+ child.stdout.on('data', emit)
78
+ child.stderr.on('data', emit)
79
+ const abort = (): void => {
80
+ child.kill('SIGTERM')
81
+ }
82
+ options.signal.addEventListener('abort', abort, { once: true })
83
+ child.on('error', reject)
84
+ child.on('close', (code) => {
85
+ options.signal.removeEventListener('abort', abort)
86
+ if (pending !== '') options.onLine(pending.trimEnd())
87
+ if (options.signal.aborted) reject(new JobCancelled())
88
+ else resolve({ code: code ?? 1 })
89
+ })
90
+ }),
91
+ }
92
+
93
+ /**
94
+ * npm 子进程环境:继承宿主环境,附带代理端口(port > 0 时同时给 npm 与
95
+ * prebuild-install 注入代理——后者只认 `https_proxy` 系列环境变量,
96
+ * 这是原生依赖 prebuild 下载卡死的正解)。
97
+ */
98
+ export function npmEnv(config: AgentPresetReaConfig): Record<string, string> {
99
+ const env: Record<string, string> = {}
100
+ for (const [key, value] of Object.entries(process.env)) {
101
+ if (value !== undefined) env[key] = value
102
+ }
103
+ env['npm_config_audit'] = 'false'
104
+ env['npm_config_fund'] = 'false'
105
+ env['npm_config_update_notifier'] = 'false'
106
+ env['npm_config_loglevel'] = 'http'
107
+ if (config.proxyPort > 0) {
108
+ const proxy = `http://127.0.0.1:${config.proxyPort}`
109
+ env['HTTP_PROXY'] = proxy
110
+ env['HTTPS_PROXY'] = proxy
111
+ env['http_proxy'] = proxy
112
+ env['https_proxy'] = proxy
113
+ env['npm_config_proxy'] = proxy
114
+ env['npm_config_https_proxy'] = proxy
115
+ }
116
+ return env
117
+ }
118
+
119
+ /** 版本收窄:进命令前把配置里的钉版校验成纯版本号。 */
120
+ export function safeVersion(version: string): string {
121
+ const trimmed = version.trim()
122
+ if (!VERSION_PATTERN.test(trimmed)) {
123
+ throw new Error(`配置里的 reaVersion 不是合法版本号:${JSON.stringify(version)}`)
124
+ }
125
+ return trimmed
126
+ }
127
+
128
+ /** 逐行转发子进程输出到作业日志(截断超长行)。 */
129
+ export function lineSink(ctx: JobTaskContext): (line: string) => void {
130
+ return (line) => {
131
+ if (line !== '') ctx.log(line.slice(0, 500))
132
+ }
133
+ }
134
+
135
+ /** 递归列出目录下全部文件(相对 POSIX 路径,排序稳定)。 */
136
+ export function listFiles(dir: string, base = ''): string[] {
137
+ const out: string[] = []
138
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
139
+ a.name.localeCompare(b.name),
140
+ )) {
141
+ const rel = base === '' ? entry.name : `${base}/${entry.name}`
142
+ if (entry.isDirectory()) out.push(...listFiles(join(dir, entry.name), rel))
143
+ else out.push(rel)
144
+ }
145
+ return out
146
+ }
147
+
148
+ /** 目录下文件数(目录缺席或不可读按 0;状态展示用,不抛)。 */
149
+ export function countFiles(dir: string): number {
150
+ try {
151
+ return listFiles(dir).length
152
+ } catch {
153
+ return 0
154
+ }
155
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,11 @@
18
18
  * 本插件据此换代注册(注销旧声明 → 按新值重建声明),**新建会话**即用新提示词,
19
19
  * 已存在会话保持既有 composition(注册表语义:运行中会话的装配不随换代改变)。
20
20
  *
21
+ * REA 的安装与更新也在卡片上(受管安装 + 后台作业 + 实时进度 + 可选代理端口):
22
+ * 宿主半的 manager 把 `rea-agents` 装进插件数据目录、把 skill 拉成可替换副本,
23
+ * 作业完成后同样换代注册——因此预设声明里**不会**在 boot 期 spawn `npx`
24
+ * (那会跑 npm 安装脚本,原生依赖 prebuild 下载一卡就吊死预设激活与整个名册)。
25
+ *
21
26
  * 设计取舍:
22
27
  * - 独立插件而非 cordis.patch.yml 里的裸 `@deepseek-ai/dsh-agent-preset`
23
28
  * 行:便于纳入本仓的构建/测试/文档体系,并把显示面与 MCP/skill 参数开放为
@@ -36,7 +41,12 @@ import { unwrapVolatile } from '@dsh-plus/shared'
36
41
 
37
42
  import { type AgentPresetReaConfig, type AgentPresetReaConfigFields, Config } from './config.ts'
38
43
  import { reaPresetDefinition } from './definition.ts'
44
+ import { createJobBoard } from './jobs.ts'
45
+ import { createManager } from './manager.ts'
46
+ import { REA_PINNED_VERSION } from './mcp.ts'
39
47
  import { createPresetRegistrar } from './registration.ts'
48
+ import { registerReaRoutes } from './routes.ts'
49
+ import { BUNDLED_SKILL_ROOT } from './skill.ts'
40
50
 
41
51
  export const name = 'dsh-plus-agent-preset-rea'
42
52
 
@@ -44,17 +54,71 @@ export const name = 'dsh-plus-agent-preset-rea'
44
54
  export const inject = [] as const
45
55
 
46
56
  export { SETTINGS_NS } from './config.ts'
47
- export { ADDED_ROWS, CONFIG_DELTAS, REA_PRESET_ID, reaPresetDefinition } from './definition.ts'
57
+ export {
58
+ ADDED_ROWS,
59
+ CONFIG_DELTAS,
60
+ emptyRuntime,
61
+ REA_PRESET_ID,
62
+ reaPresetDefinition,
63
+ } from './definition.ts'
64
+ export type {
65
+ InstallDto,
66
+ JobActionDto,
67
+ JobDto,
68
+ JobKind,
69
+ JobProgressDto,
70
+ JobState,
71
+ McpDto,
72
+ SkillDto,
73
+ StatusDto,
74
+ } from './dto.ts'
75
+ export {
76
+ type CommandRunner,
77
+ JobCancelled,
78
+ type JobOperation,
79
+ type JobTaskContext,
80
+ NPM,
81
+ npmEnv,
82
+ safeVersion,
83
+ spawnRunner,
84
+ } from './exec.ts'
85
+ export { createJobBoard, type JobBoard, type JobSnapshot } from './jobs.ts'
86
+ export {
87
+ configWith,
88
+ createManager,
89
+ INSTALL_PHASES,
90
+ type JobDeps,
91
+ type JobOverrides,
92
+ type ManagerPaths,
93
+ managerPaths,
94
+ probeInstall,
95
+ probeRuntime,
96
+ probeSkill,
97
+ REA_INSTALL_DIR,
98
+ REA_PACKAGE,
99
+ REA_SKILL_DIR,
100
+ REA_SKILL_NAME,
101
+ type ReaInstall,
102
+ type ReaManager,
103
+ type RuntimeSnapshot,
104
+ SKILL_PHASES,
105
+ type SkillManifest,
106
+ type SkillState,
107
+ } from './manager.ts'
48
108
  export {
49
109
  DEFAULT_MCP_COMMAND,
50
110
  INPUT_SCHEMA_PROFILES,
111
+ type InstalledEntry,
112
+ type McpLaunch,
51
113
  OUTPUT_SCHEMA_PROFILES,
52
114
  REA_PINNED_VERSION,
53
115
  reaMcpArgs,
54
116
  reaMcpEnv,
117
+ reaMcpLaunch,
55
118
  } from './mcp.ts'
56
119
  export { PLAN_MODE_SECTION } from './prompt.ts'
57
120
  export { createPresetRegistrar, type PresetRegistrar, type Unregister } from './registration.ts'
121
+ export { ROUTE_BASE, registerReaRoutes, statusPayload } from './routes.ts'
58
122
  export { BUNDLED_SKILL_ROOT, reaSkillDirs } from './skill.ts'
59
123
  export type { AgentPresetReaConfig, AgentPresetReaConfigFields }
60
124
  export { Config }
@@ -76,20 +140,47 @@ export function apply(ctx: Context, config: AgentPresetReaConfigFields): void {
76
140
  const detail = error instanceof Error ? error.message : String(error)
77
141
  logger.warn(`register rea preset failed: ${detail}`)
78
142
  }
143
+ const manager = createManager({
144
+ config: current,
145
+ bundledRoot: BUNDLED_SKILL_ROOT,
146
+ bundledVersion: REA_PINNED_VERSION,
147
+ })
148
+ const board = createJobBoard()
149
+ // 换代入口:安装/更新作业完成、或活动配置提交后,都要按新事实重建声明。
150
+ let reregister: (() => void) | null = null
151
+ const refresh = (): void => {
152
+ manager.refresh()
153
+ reregister?.()
154
+ }
155
+ ctx.effect(
156
+ () =>
157
+ board.onSettled((snapshot) => {
158
+ refresh()
159
+ logger.info(`rea ${snapshot.kind} 作业结束:${snapshot.state}`)
160
+ }),
161
+ 'agent-preset-rea: refresh after job',
162
+ )
79
163
 
80
164
  ctx.inject(['agentPresets'], (scope) => {
81
165
  scope.effect(() => {
82
166
  const registrar = createPresetRegistrar({
83
167
  register: (definition) => scope.agentPresets.register(definition),
84
- build: () => reaPresetDefinition(current()),
168
+ build: () => reaPresetDefinition(current(), manager.snapshot()),
85
169
  onError,
86
170
  })
171
+ reregister = () => registrar.refresh()
87
172
  // 监听器挂在插件自身 fiber 上:loader 正是对该 fiber 广播 volatile 提交。
88
- const off = ctx.events.on('loader/volatile-update', () => registrar.refresh())
173
+ const off = ctx.events.on('loader/volatile-update', () => refresh())
89
174
  return () => {
175
+ reregister = null
90
176
  off()
91
177
  registrar.dispose()
92
178
  }
93
179
  }, 'agent-preset-rea: register rea preset')
94
180
  })
181
+
182
+ // 安装/更新端点:webServer 缺席(headless)即不注册,插件其余能力照常。
183
+ ctx.inject(['webServer'], (scope) => {
184
+ registerReaRoutes(scope as unknown as Context, { manager, board, config: current })
185
+ })
95
186
  }
package/src/jobs.ts ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 单作业作业板:同一时刻只允许一个安装/更新作业,快照供 HTTP 轮询,
3
+ * 完成后回调订阅者(宿主据此换代注册预设声明)。
4
+ *
5
+ * 进度按「阶段」推进(`percent = step / steps`):安装类作业的外部命令不产出可靠
6
+ * 字节进度,硬造百分比是骗人;阶段 + 实时日志尾部 + 起止时间才是可核对的事实。
7
+ * 日志只保留尾部 {@link JOB_LOG_LIMIT} 行(回传浏览器,必须有界)。
8
+ *
9
+ * 状态放在 {@link BoardState} 里、函数都提到模块级:卡片/端点/测试三方共享同一份
10
+ * 语义,单个函数也保持在可读长度内(仓库门禁:函数 60/150 行)。
11
+ * @module @dsh-plus/agent-preset-rea/jobs
12
+ */
13
+ import type { JobDto, JobKind, JobProgressDto } from './dto.ts'
14
+ import { JobCancelled, type JobOperation, type JobTaskContext } from './exec.ts'
15
+
16
+ /** 作业进度(与 wire DTO 同形)。 */
17
+ export type JobProgress = JobProgressDto
18
+
19
+ /** 作业快照(与 wire DTO 同形,HTTP 直接回传)。 */
20
+ export type JobSnapshot = JobDto
21
+
22
+ /** 作业板。 */
23
+ export interface JobBoard {
24
+ /** 启动作业;已有作业在跑时返回 null(调用方回 409)。 */
25
+ start(kind: JobKind, operation: JobOperation): JobSnapshot | null
26
+ /** 取消当前作业(返回是否真的发出取消)。 */
27
+ cancel(): boolean
28
+ /** 当前/最近一次作业快照。 */
29
+ snapshot(): JobSnapshot | null
30
+ /** 订阅作业完成(返回退订函数)。 */
31
+ onSettled(listener: (snapshot: JobSnapshot) => void): () => void
32
+ }
33
+
34
+ /** 日志尾部保留行数。 */
35
+ export const JOB_LOG_LIMIT = 200
36
+
37
+ /** 作业板可变状态(函数按引用操作它,不靠闭包嵌套)。 */
38
+ interface BoardState {
39
+ seq: number
40
+ current: JobSnapshot | null
41
+ abort: AbortController | null
42
+ listeners: Set<(snapshot: JobSnapshot) => void>
43
+ }
44
+
45
+ /** 阶段序号 → 进度(进入该阶段即报该阶段起点百分比)。 */
46
+ function progressOf(operation: JobOperation, step: number): JobProgress {
47
+ const steps = operation.phases.length
48
+ return {
49
+ step: step + 1,
50
+ steps,
51
+ phase: operation.phases[step] ?? '',
52
+ percent: steps === 0 ? 0 : Math.round((step / steps) * 100),
53
+ }
54
+ }
55
+
56
+ /** 落定:写状态 + 通知订阅者(订阅者异常不得影响状态机)。 */
57
+ function settleJob(state: BoardState, patch: Partial<JobSnapshot>): void {
58
+ const base = state.current
59
+ if (base === null) return
60
+ state.current = { ...base, ...patch, finishedAt: new Date().toISOString() }
61
+ for (const listener of [...state.listeners]) {
62
+ try {
63
+ listener(state.current)
64
+ } catch {
65
+ // 订阅者异常不影响作业板状态
66
+ }
67
+ }
68
+ }
69
+
70
+ /** 兜底进度形状(正常路径下 `state.current` 必有值,这里只防类型上的缺席)。 */
71
+ const ZERO_PROGRESS: JobProgress = { step: 1, steps: 1, phase: '', percent: 0 }
72
+
73
+ /** 收尾:成功记 ok + 版本,失败区分「取消」与「真失败」。 */
74
+ function finishJob(state: BoardState, error: unknown, version: string | null): void {
75
+ if (version !== null) {
76
+ const progress = { ...(state.current?.progress ?? ZERO_PROGRESS), percent: 100 }
77
+ settleJob(state, { state: 'ok', version, progress })
78
+ return
79
+ }
80
+ const cancelled = error instanceof JobCancelled || state.abort?.signal.aborted === true
81
+ settleJob(state, {
82
+ state: cancelled ? 'cancelled' : 'error',
83
+ error: cancelled ? null : error instanceof Error ? error.message : String(error),
84
+ })
85
+ }
86
+
87
+ /** 起一个作业并挂上状态机(调用方已确认当前无运行中作业)。 */
88
+ function startJob(state: BoardState, kind: JobKind, operation: JobOperation): JobSnapshot {
89
+ const controller = new AbortController()
90
+ state.abort = controller
91
+ state.seq += 1
92
+ const snapshot: JobSnapshot = {
93
+ id: state.seq,
94
+ kind,
95
+ state: 'running',
96
+ progress: progressOf(operation, 0),
97
+ log: [],
98
+ startedAt: new Date().toISOString(),
99
+ finishedAt: null,
100
+ version: null,
101
+ error: null,
102
+ }
103
+ state.current = snapshot
104
+ const push = (patch: Partial<JobSnapshot>): void => {
105
+ if (state.current !== null) state.current = { ...state.current, ...patch }
106
+ }
107
+ const ctx: JobTaskContext = {
108
+ phase: (step) => push({ progress: progressOf(operation, step) }),
109
+ log: (line) => push({ log: [...(state.current?.log ?? []), line].slice(-JOB_LOG_LIMIT) }),
110
+ signal: controller.signal,
111
+ }
112
+ void operation
113
+ .run(ctx)
114
+ .then(
115
+ (version) => finishJob(state, null, version),
116
+ (error: unknown) => finishJob(state, error, null),
117
+ )
118
+ .finally(() => {
119
+ state.abort = null
120
+ })
121
+ return snapshot
122
+ }
123
+
124
+ /** 取消当前作业。 */
125
+ function cancelJob(state: BoardState): boolean {
126
+ // 只看状态:落定后 finally 还没跑(微任务次序),按 abort 句柄判断会误报「已取消」。
127
+ if (state.abort === null || state.current?.state !== 'running') return false
128
+ state.abort.abort()
129
+ return true
130
+ }
131
+
132
+ /** 建一个作业板。 */
133
+ export function createJobBoard(): JobBoard {
134
+ const state: BoardState = { seq: 0, current: null, abort: null, listeners: new Set() }
135
+ return {
136
+ start: (kind, operation) =>
137
+ state.current?.state === 'running' ? null : startJob(state, kind, operation),
138
+ cancel: () => cancelJob(state),
139
+ snapshot: () => state.current,
140
+ onSettled: (listener) => {
141
+ state.listeners.add(listener)
142
+ return () => state.listeners.delete(listener)
143
+ },
144
+ }
145
+ }