@dsh-plus/agent-preset-rea 0.0.0-stage → 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/lib/client.js +1197 -0
- package/lib/index.d.ts +431 -0
- package/lib/index.js +1224 -0
- package/package.json +61 -3
- package/skills/reverse-engineer-anything/LICENSE +21 -0
- package/skills/reverse-engineer-anything/SKILL.md +342 -0
- package/skills/reverse-engineer-anything/references/android-applications.md +34 -0
- package/skills/reverse-engineer-anything/references/connection-and-recovery.md +127 -0
- package/skills/reverse-engineer-anything/references/evidence-workflows.md +71 -0
- package/skills/reverse-engineer-anything/references/javascript-applications.md +175 -0
- package/skills/reverse-engineer-anything/references/native-and-artifacts.md +277 -0
- package/skills/reverse-engineer-anything/references/runtime-observation.md +78 -0
- package/src/client/api.ts +76 -0
- package/src/client/card.tsx +145 -0
- package/src/client/client.ts +51 -0
- package/src/client/draft.ts +55 -0
- package/src/client/i18n.ts +134 -0
- package/src/client/install.tsx +274 -0
- package/src/client/styles.ts +57 -0
- package/src/config.ts +121 -0
- package/src/definition.ts +245 -0
- package/src/dto.ts +87 -0
- package/src/exec.ts +155 -0
- package/src/index.ts +186 -0
- package/src/jobs.ts +145 -0
- package/src/manager.ts +419 -0
- package/src/mcp.ts +83 -0
- package/src/ns.ts +9 -0
- package/src/prompt.ts +25 -0
- package/src/registration.ts +86 -0
- package/src/routes.ts +181 -0
- package/src/skill.ts +34 -0
- package/README.md +0 -3
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
|
+
}
|
package/src/manager.ts
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REA 的受管安装与状态探测:把「REA 包」与「REA skill」都变成插件自己管理的本机资源,
|
|
3
|
+
* 从而摆脱启动期 `npx -y` 拉取(那会在 boot 里跑 npm 安装脚本:原生依赖
|
|
4
|
+
* `@lydell/node-pty` 的 prebuild 下载一旦卡住,预设激活就不返回,名册与插件页
|
|
5
|
+
* 一起被吊住;事故经过见 docs/README.md「启动期拉取的教训」)。
|
|
6
|
+
*
|
|
7
|
+
* 落点(属插件数据,不进 settings,见 @dsh-plus/shared/plugin-data;插件数据目录名
|
|
8
|
+
* 取插件 id `dsh-plus-agent-preset-rea`):
|
|
9
|
+
* - REA 包:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/rea-agent/`(npm --prefix
|
|
10
|
+
* 安装树;入口 `node_modules/rea-agents/scripts/rea.mjs`,用 `process.execPath` 直接
|
|
11
|
+
* spawn,跨平台不经 shell shim);
|
|
12
|
+
* - 手动更新的 skill:`<DSH_HOME>/dsh-plus/dsh-plus-agent-preset-rea/skills/`
|
|
13
|
+
* (根下是 `<技能名>/` + `manifest.json`,与随包 `skills/` 同构;
|
|
14
|
+
* 清单完整时替代随包 skill 根)。
|
|
15
|
+
*
|
|
16
|
+
* 作业都是「先落 staging 再换目录」:中断只留下 staging,不会让半截安装树被当成可用
|
|
17
|
+
* 安装;换目录后清理旧树,运行中的旧进程按 POSIX 语义继续持有已打开文件。
|
|
18
|
+
*
|
|
19
|
+
* 外部进程经 {@link CommandRunner} 注入(见 exec.ts),单测用假 runner 跑完整状态机。
|
|
20
|
+
* @module @dsh-plus/agent-preset-rea/manager
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'node:crypto'
|
|
23
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
|
|
27
|
+
import { pluginDataPath } from '@dsh-plus/shared'
|
|
28
|
+
|
|
29
|
+
import type { AgentPresetReaConfig } from './config.ts'
|
|
30
|
+
import {
|
|
31
|
+
type CommandRunner,
|
|
32
|
+
countFiles,
|
|
33
|
+
type JobOperation,
|
|
34
|
+
type JobTaskContext,
|
|
35
|
+
lineSink,
|
|
36
|
+
listFiles,
|
|
37
|
+
NPM,
|
|
38
|
+
npmEnv,
|
|
39
|
+
safeVersion,
|
|
40
|
+
spawnRunner,
|
|
41
|
+
} from './exec.ts'
|
|
42
|
+
|
|
43
|
+
/** npm 包名。 */
|
|
44
|
+
export const REA_PACKAGE = 'rea-agents'
|
|
45
|
+
|
|
46
|
+
/** 受管安装树目录名。 */
|
|
47
|
+
export const REA_INSTALL_DIR = 'rea-agent'
|
|
48
|
+
|
|
49
|
+
/** 手动更新 skill 的目录名。 */
|
|
50
|
+
export const REA_SKILL_DIR = 'skills'
|
|
51
|
+
|
|
52
|
+
/** REA 随包 skill 名(tar 包内路径与目录名同此)。 */
|
|
53
|
+
export const REA_SKILL_NAME = 'reverse-engineer-anything'
|
|
54
|
+
|
|
55
|
+
/** 插件数据目录标识(`<DSH_HOME>/dsh-plus/<id>`)。 */
|
|
56
|
+
const PLUGIN_ID = 'dsh-plus-agent-preset-rea'
|
|
57
|
+
|
|
58
|
+
/** 已安装的 REA 事实。 */
|
|
59
|
+
export interface ReaInstall {
|
|
60
|
+
version: string
|
|
61
|
+
root: string
|
|
62
|
+
/** spawn 用的可执行文件(`process.execPath`,即当前 node)。 */
|
|
63
|
+
command: string
|
|
64
|
+
/** 入口脚本绝对路径(`scripts/rea.mjs`)。 */
|
|
65
|
+
entry: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** skill 来源事实。 */
|
|
69
|
+
export interface SkillState {
|
|
70
|
+
/** `bundled` = 随包副本;`updated` = 插件数据目录里的手动更新副本。 */
|
|
71
|
+
source: 'bundled' | 'updated'
|
|
72
|
+
/** 来源版本(从清单 source 解析;解析不出为 null)。 */
|
|
73
|
+
version: string | null
|
|
74
|
+
root: string
|
|
75
|
+
files: number
|
|
76
|
+
updatedAt: string | null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 运行期事实快照(现探,不进配置;声明组装按它决定 mcp 行与 skill 根)。 */
|
|
80
|
+
export interface RuntimeSnapshot {
|
|
81
|
+
install: ReaInstall | null
|
|
82
|
+
skill: SkillState
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** skill 清单(与随包夹具同形;更新副本多写入 updatedAt)。 */
|
|
86
|
+
export interface SkillManifest {
|
|
87
|
+
source: string
|
|
88
|
+
files: { path: string; sha256: string; bytes: number }[]
|
|
89
|
+
updatedAt?: string
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 受管路径。 */
|
|
93
|
+
export interface ManagerPaths {
|
|
94
|
+
installRoot: string
|
|
95
|
+
skillRoot: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 作业可一次性覆盖的配置项(卡片未保存的代理端口即时生效用)。 */
|
|
99
|
+
export interface JobOverrides {
|
|
100
|
+
proxyPort?: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** REA 管理面。 */
|
|
104
|
+
export interface ReaManager {
|
|
105
|
+
paths(): ManagerPaths
|
|
106
|
+
/** 现探运行期事实(同步;声明组装在 `apply` 期就要读到)。 */
|
|
107
|
+
snapshot(): RuntimeSnapshot
|
|
108
|
+
/** 重新现探并更新快照(作业完成、volatile 变更后调用)。 */
|
|
109
|
+
refresh(): RuntimeSnapshot
|
|
110
|
+
/** 受管作业(安装/更新 REA 包、更新 skill)。 */
|
|
111
|
+
operations(overrides?: JobOverrides): { install: JobOperation; skill: JobOperation }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 安装作业阶段名。 */
|
|
115
|
+
export const INSTALL_PHASES: string[] = [
|
|
116
|
+
'解析版本信息',
|
|
117
|
+
`下载并安装 ${REA_PACKAGE}`,
|
|
118
|
+
'替换安装目录',
|
|
119
|
+
'校验安装',
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
/** skill 作业阶段名。 */
|
|
123
|
+
export const SKILL_PHASES: string[] = [
|
|
124
|
+
'解析版本信息',
|
|
125
|
+
'下载 skill 包',
|
|
126
|
+
'解包 skills',
|
|
127
|
+
'校验并写入清单',
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 解析受管路径:`installRoot` 非空即运维指定位置(部署方平面),
|
|
132
|
+
* 否则落插件数据目录;skill 更新副本固定落插件数据目录。
|
|
133
|
+
*/
|
|
134
|
+
export function managerPaths(config: AgentPresetReaConfig): ManagerPaths {
|
|
135
|
+
const root = config.installRoot.trim()
|
|
136
|
+
return {
|
|
137
|
+
installRoot: root === '' ? pluginDataPath(PLUGIN_ID, REA_INSTALL_DIR) : root,
|
|
138
|
+
skillRoot: pluginDataPath(PLUGIN_ID, REA_SKILL_DIR),
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 读 JSON(不存在或坏内容返回 null)。 */
|
|
143
|
+
function readJson(file: string): unknown {
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(readFileSync(file, 'utf8')) as unknown
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 记录型守卫。 */
|
|
152
|
+
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
153
|
+
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 安装树内固定入口:`node_modules/rea-agents/scripts/rea.mjs`。 */
|
|
157
|
+
function reaEntryFile(root: string): string {
|
|
158
|
+
return join(root, 'node_modules', REA_PACKAGE, 'scripts', 'rea.mjs')
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 从清单 source(`rea-agents@1.2.3`)解析版本。 */
|
|
162
|
+
function versionOfSource(source: string): string | null {
|
|
163
|
+
const prefix = `${REA_PACKAGE}@`
|
|
164
|
+
return source.startsWith(prefix) ? source.slice(prefix.length) : null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 探测已安装 REA:包版本可读 **且** 入口脚本存在才算安装完整
|
|
169
|
+
* (只有一半的安装树、或换目录中断留下的 staging 都不算)。
|
|
170
|
+
*/
|
|
171
|
+
export function probeInstall(paths: ManagerPaths): ReaInstall | null {
|
|
172
|
+
const pkg = asRecord(
|
|
173
|
+
readJson(join(paths.installRoot, 'node_modules', REA_PACKAGE, 'package.json')),
|
|
174
|
+
)
|
|
175
|
+
const version = typeof pkg?.['version'] === 'string' ? pkg['version'] : null
|
|
176
|
+
const entry = reaEntryFile(paths.installRoot)
|
|
177
|
+
if (version === null || !existsSync(entry)) return null
|
|
178
|
+
return { version, root: paths.installRoot, command: process.execPath, entry }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 探测 skill 来源:数据目录里清单完整且技能文件在位才认更新副本。 */
|
|
182
|
+
export function probeSkill(
|
|
183
|
+
paths: ManagerPaths,
|
|
184
|
+
bundledRoot: string,
|
|
185
|
+
bundledVersion: string,
|
|
186
|
+
): SkillState {
|
|
187
|
+
const manifest = asRecord(readJson(join(paths.skillRoot, 'manifest.json')))
|
|
188
|
+
const source = typeof manifest?.['source'] === 'string' ? manifest['source'] : null
|
|
189
|
+
const files = Array.isArray(manifest?.['files']) ? manifest['files'].length : 0
|
|
190
|
+
if (
|
|
191
|
+
source !== null &&
|
|
192
|
+
files > 0 &&
|
|
193
|
+
existsSync(join(paths.skillRoot, REA_SKILL_NAME, 'SKILL.md'))
|
|
194
|
+
) {
|
|
195
|
+
return {
|
|
196
|
+
source: 'updated',
|
|
197
|
+
version: versionOfSource(source),
|
|
198
|
+
root: paths.skillRoot,
|
|
199
|
+
files,
|
|
200
|
+
updatedAt: typeof manifest?.['updatedAt'] === 'string' ? manifest['updatedAt'] : null,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
source: 'bundled',
|
|
205
|
+
version: bundledVersion,
|
|
206
|
+
root: bundledRoot,
|
|
207
|
+
files: countFiles(bundledRoot),
|
|
208
|
+
updatedAt: null,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** 现探运行期事实。 */
|
|
213
|
+
export function probeRuntime(
|
|
214
|
+
config: AgentPresetReaConfig,
|
|
215
|
+
bundledRoot: string,
|
|
216
|
+
bundledVersion: string,
|
|
217
|
+
): RuntimeSnapshot {
|
|
218
|
+
const paths = managerPaths(config)
|
|
219
|
+
return {
|
|
220
|
+
install: probeInstall(paths),
|
|
221
|
+
skill: probeSkill(paths, bundledRoot, bundledVersion),
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 作业依赖:生效配置、命令执行面、作业完成后的现探刷新。 */
|
|
226
|
+
export interface JobDeps {
|
|
227
|
+
config: AgentPresetReaConfig
|
|
228
|
+
runner: CommandRunner
|
|
229
|
+
refresh(): void
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 解析钉版在 registry 上的实际版本:顺带做存在性校验(失败即报错,
|
|
234
|
+
* 而不是装出一棵空树让 MCP 行指向不存在的入口)。
|
|
235
|
+
*/
|
|
236
|
+
async function resolveVersion(deps: JobDeps, ctx: JobTaskContext, wanted: string): Promise<void> {
|
|
237
|
+
ctx.phase(0)
|
|
238
|
+
const lines: string[] = []
|
|
239
|
+
const result = await deps.runner.run(
|
|
240
|
+
NPM,
|
|
241
|
+
['view', `${REA_PACKAGE}@${wanted}`, 'version', '--json'],
|
|
242
|
+
{
|
|
243
|
+
env: npmEnv(deps.config),
|
|
244
|
+
signal: ctx.signal,
|
|
245
|
+
onLine: (line) => {
|
|
246
|
+
lines.push(line)
|
|
247
|
+
lineSink(ctx)(line)
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
)
|
|
251
|
+
if (result.code !== 0) throw new Error(`registry 上找不到 ${REA_PACKAGE}@${wanted}`)
|
|
252
|
+
const reported = lines.join('').replace(/["\s]/g, '')
|
|
253
|
+
if (reported !== '' && reported !== wanted) {
|
|
254
|
+
ctx.log(`registry 返回 ${reported}(配置钉的是 ${wanted})`)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 装到 staging 并清掉失败现场的公共包壳。 */
|
|
259
|
+
async function npmInstall(deps: JobDeps, ctx: JobTaskContext, staging: string): Promise<void> {
|
|
260
|
+
const version = safeVersion(deps.config.reaVersion)
|
|
261
|
+
await rm(staging, { recursive: true, force: true })
|
|
262
|
+
const result = await deps.runner.run(
|
|
263
|
+
NPM,
|
|
264
|
+
[
|
|
265
|
+
'install',
|
|
266
|
+
'--prefix',
|
|
267
|
+
staging,
|
|
268
|
+
'--no-audit',
|
|
269
|
+
'--no-fund',
|
|
270
|
+
'--loglevel',
|
|
271
|
+
'http',
|
|
272
|
+
`${REA_PACKAGE}@${version}`,
|
|
273
|
+
],
|
|
274
|
+
{ env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
|
|
275
|
+
)
|
|
276
|
+
if (result.code !== 0) {
|
|
277
|
+
await rm(staging, { recursive: true, force: true })
|
|
278
|
+
throw new Error(`npm install 失败(退出码 ${result.code})`)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 安装/更新 REA 包:staging 安装 → 换目录 → 校验入口可读。 */
|
|
283
|
+
export async function runInstallJob(deps: JobDeps, ctx: JobTaskContext): Promise<string> {
|
|
284
|
+
const version = safeVersion(deps.config.reaVersion)
|
|
285
|
+
const root = managerPaths(deps.config).installRoot
|
|
286
|
+
const staging = `${root}.staging`
|
|
287
|
+
await resolveVersion(deps, ctx, version)
|
|
288
|
+
|
|
289
|
+
ctx.phase(1)
|
|
290
|
+
await npmInstall(deps, ctx, staging)
|
|
291
|
+
|
|
292
|
+
ctx.phase(2)
|
|
293
|
+
const backup = `${root}.old`
|
|
294
|
+
await rm(backup, { recursive: true, force: true })
|
|
295
|
+
if (existsSync(root)) await rename(root, backup)
|
|
296
|
+
await rename(staging, root)
|
|
297
|
+
await rm(backup, { recursive: true, force: true })
|
|
298
|
+
|
|
299
|
+
ctx.phase(3)
|
|
300
|
+
const probe = probeInstall(managerPaths(deps.config))
|
|
301
|
+
if (probe === null) throw new Error('安装校验失败:入口脚本不可用')
|
|
302
|
+
deps.refresh()
|
|
303
|
+
ctx.log(`已安装 ${REA_PACKAGE}@${probe.version} → ${probe.root}`)
|
|
304
|
+
return probe.version
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 逐文件 sha256(相对路径 + 字节数)。 */
|
|
308
|
+
function hashFiles(dir: string): SkillManifest['files'] {
|
|
309
|
+
return listFiles(dir).map((path) => {
|
|
310
|
+
const bytes = readFileSync(join(dir, path))
|
|
311
|
+
return { path, sha256: createHash('sha256').update(bytes).digest('hex'), bytes: bytes.length }
|
|
312
|
+
})
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 从 tar 包取钉版 skill 目录(副本根与随包 `skills/` 同构:只 strip 两层)。 */
|
|
316
|
+
async function unpackSkill(
|
|
317
|
+
deps: JobDeps,
|
|
318
|
+
ctx: JobTaskContext,
|
|
319
|
+
staging: string,
|
|
320
|
+
tree: string,
|
|
321
|
+
): Promise<void> {
|
|
322
|
+
const version = safeVersion(deps.config.reaVersion)
|
|
323
|
+
await rm(staging, { recursive: true, force: true })
|
|
324
|
+
await mkdir(staging, { recursive: true })
|
|
325
|
+
const packed = await deps.runner.run(
|
|
326
|
+
NPM,
|
|
327
|
+
['pack', `${REA_PACKAGE}@${version}`, '--pack-destination', staging, '--loglevel', 'http'],
|
|
328
|
+
{ env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
|
|
329
|
+
)
|
|
330
|
+
if (packed.code !== 0) {
|
|
331
|
+
await rm(staging, { recursive: true, force: true })
|
|
332
|
+
throw new Error(`npm pack 失败(退出码 ${packed.code})`)
|
|
333
|
+
}
|
|
334
|
+
const tarball = readdirSync(staging).find((name) => name.endsWith('.tgz'))
|
|
335
|
+
if (tarball === undefined) throw new Error('npm pack 未产出 tarball')
|
|
336
|
+
|
|
337
|
+
ctx.phase(2)
|
|
338
|
+
await mkdir(tree, { recursive: true })
|
|
339
|
+
const untar = await deps.runner.run(
|
|
340
|
+
'tar',
|
|
341
|
+
[
|
|
342
|
+
'-xzf',
|
|
343
|
+
join(staging, tarball),
|
|
344
|
+
'-C',
|
|
345
|
+
tree,
|
|
346
|
+
'--strip-components=2',
|
|
347
|
+
`package/skills/${REA_SKILL_NAME}`,
|
|
348
|
+
],
|
|
349
|
+
{ env: npmEnv(deps.config), signal: ctx.signal, onLine: lineSink(ctx) },
|
|
350
|
+
)
|
|
351
|
+
if (untar.code !== 0) {
|
|
352
|
+
await rm(staging, { recursive: true, force: true })
|
|
353
|
+
throw new Error(`tar 解包失败(退出码 ${untar.code})`)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 拉取钉版 tarball 并替换手动更新的 skill 副本(写 sha256 清单后换目录)。 */
|
|
358
|
+
export async function runSkillJob(deps: JobDeps, ctx: JobTaskContext): Promise<string> {
|
|
359
|
+
const version = safeVersion(deps.config.reaVersion)
|
|
360
|
+
const root = managerPaths(deps.config).skillRoot
|
|
361
|
+
const staging = `${root}.staging`
|
|
362
|
+
const tree = join(staging, 'tree')
|
|
363
|
+
await resolveVersion(deps, ctx, version)
|
|
364
|
+
ctx.phase(1)
|
|
365
|
+
await unpackSkill(deps, ctx, staging, tree)
|
|
366
|
+
|
|
367
|
+
ctx.phase(3)
|
|
368
|
+
const files = hashFiles(tree)
|
|
369
|
+
if (files.length === 0) throw new Error('解包结果为空:上游 skill 目录结构可能已变')
|
|
370
|
+
const manifest: SkillManifest = {
|
|
371
|
+
source: `${REA_PACKAGE}@${version}`,
|
|
372
|
+
files,
|
|
373
|
+
updatedAt: new Date().toISOString(),
|
|
374
|
+
}
|
|
375
|
+
await writeFile(join(tree, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
376
|
+
await rm(root, { recursive: true, force: true })
|
|
377
|
+
await rename(tree, root)
|
|
378
|
+
await rm(staging, { recursive: true, force: true })
|
|
379
|
+
deps.refresh()
|
|
380
|
+
ctx.log(`已更新 skill ${REA_SKILL_NAME}(${files.length} 文件)→ ${root}`)
|
|
381
|
+
return version
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 生效配置:作业可带一次性覆盖(卡片未保存的代理端口即时可用)。 */
|
|
385
|
+
export function configWith(
|
|
386
|
+
base: AgentPresetReaConfig,
|
|
387
|
+
overrides?: JobOverrides,
|
|
388
|
+
): AgentPresetReaConfig {
|
|
389
|
+
if (overrides?.proxyPort === undefined) return base
|
|
390
|
+
return { ...base, proxyPort: overrides.proxyPort }
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** 建一个 REA 管理器。 */
|
|
394
|
+
export function createManager(options: {
|
|
395
|
+
config: () => AgentPresetReaConfig
|
|
396
|
+
bundledRoot: string
|
|
397
|
+
bundledVersion: string
|
|
398
|
+
runner?: CommandRunner
|
|
399
|
+
}): ReaManager {
|
|
400
|
+
const runner = options.runner ?? spawnRunner
|
|
401
|
+
const paths = (): ManagerPaths => managerPaths(options.config())
|
|
402
|
+
let cache: RuntimeSnapshot | null = null
|
|
403
|
+
const refresh = (): RuntimeSnapshot => {
|
|
404
|
+
cache = probeRuntime(options.config(), options.bundledRoot, options.bundledVersion)
|
|
405
|
+
return cache
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
paths,
|
|
409
|
+
refresh,
|
|
410
|
+
snapshot: () => cache ?? refresh(),
|
|
411
|
+
operations: (overrides) => {
|
|
412
|
+
const deps: JobDeps = { config: configWith(options.config(), overrides), runner, refresh }
|
|
413
|
+
return {
|
|
414
|
+
install: { phases: INSTALL_PHASES, run: (ctx) => runInstallJob(deps, ctx) },
|
|
415
|
+
skill: { phases: SKILL_PHASES, run: (ctx) => runSkillJob(deps, ctx) },
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
}
|
package/src/mcp.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REA MCP 行的取值派生:启动方式(受管安装入口 or 显式命令)、参数、入参/出参 schema 档位。
|
|
3
|
+
*
|
|
4
|
+
* REA 的 stdio 入口是 `rea-agents mcp`(`server.json` 的 positional 参数),
|
|
5
|
+
* 包版本钉在 {@link REA_PINNED_VERSION}——升级只改这一个常量,漂移由测试拦住。
|
|
6
|
+
* REA 的 Node 引擎要求为 `^22.19 || ^24.11 || >=26`(见 REA `docs/installation.md`)。
|
|
7
|
+
*
|
|
8
|
+
* **默认不 spawn npx**:启动期 `npx -y` 会跑 npm 安装脚本(原生依赖 prebuild 下载),
|
|
9
|
+
* 一旦卡住就把预设激活与整棵名册一起吊住;因此默认只认受管安装入口(见 manager.ts),
|
|
10
|
+
* 未安装时该行 `disabled`,安装入口在插件页卡片上。`mcpCommand` 显式配置时按用户
|
|
11
|
+
* 命令启动(经典 npx 配方仍可用,风险自负)。
|
|
12
|
+
* @module @dsh-plus/agent-preset-rea/mcp
|
|
13
|
+
*/
|
|
14
|
+
import type { AgentPresetReaConfig } from './config.ts'
|
|
15
|
+
|
|
16
|
+
/** 钉版基线:`rea-agents@6.5.0`(升级时同步测试断言、tests 清单与包文档)。 */
|
|
17
|
+
export const REA_PINNED_VERSION = '6.5.0'
|
|
18
|
+
|
|
19
|
+
/** 显式命令配 npx 时的参数配方基准值(`mcpArgs` 留空即按它派生)。 */
|
|
20
|
+
export const DEFAULT_MCP_COMMAND = 'npx'
|
|
21
|
+
|
|
22
|
+
/** REA 工具入参 schema 广告档位:`compact` 压请求前缀体积,`full` 与上游默认一致。 */
|
|
23
|
+
export const INPUT_SCHEMA_PROFILES = ['compact', 'full'] as const
|
|
24
|
+
|
|
25
|
+
/** REA 工具出参 schema 广告档位:`off` 不下发(结果仍由 REA 侧校验),`full` 与上游默认一致。 */
|
|
26
|
+
export const OUTPUT_SCHEMA_PROFILES = ['off', 'full'] as const
|
|
27
|
+
|
|
28
|
+
/** 受管安装的入口事实(结构上由 manager 的 ReaInstall 满足)。 */
|
|
29
|
+
export interface InstalledEntry {
|
|
30
|
+
/** spawn 用的可执行文件。 */
|
|
31
|
+
command: string
|
|
32
|
+
/** 入口脚本绝对路径。 */
|
|
33
|
+
entry: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** MCP 行的启动方式。 */
|
|
37
|
+
export interface McpLaunch {
|
|
38
|
+
command: string
|
|
39
|
+
args: string[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 显式命令(`mcpCommand` 非空)时的参数:`mcpArgs` 优先;
|
|
44
|
+
* 留空时 npx 走 `-y rea-agents@<钉版> mcp`,其他可执行文件只补 `mcp`。
|
|
45
|
+
*/
|
|
46
|
+
export function reaMcpArgs(config: AgentPresetReaConfig): string[] {
|
|
47
|
+
if (config.mcpArgs.length > 0) return [...config.mcpArgs]
|
|
48
|
+
return config.mcpCommand === DEFAULT_MCP_COMMAND
|
|
49
|
+
? ['-y', `rea-agents@${config.reaVersion}`, 'mcp']
|
|
50
|
+
: ['mcp']
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 本预设 MCP 行的启动方式:
|
|
55
|
+
* 1. `mcpCommand` 显式配置 → 用用户命令(Windows 配方、全局安装场景);
|
|
56
|
+
* 2. 受管安装存在 → 用当前 node 直接跑安装树里的入口脚本(跨平台、不经 shell shim);
|
|
57
|
+
* 3. 都没有 → `null`,调用方把该行置 `disabled`(未安装,等卡片上的安装动作)。
|
|
58
|
+
*/
|
|
59
|
+
export function reaMcpLaunch(
|
|
60
|
+
config: AgentPresetReaConfig,
|
|
61
|
+
installed: InstalledEntry | null,
|
|
62
|
+
): McpLaunch | null {
|
|
63
|
+
if (config.mcpCommand.trim() !== '') {
|
|
64
|
+
return { command: config.mcpCommand, args: reaMcpArgs(config) }
|
|
65
|
+
}
|
|
66
|
+
if (installed === null) return null
|
|
67
|
+
const extra = config.mcpArgs.length > 0 ? [...config.mcpArgs] : ['mcp']
|
|
68
|
+
return { command: installed.command, args: [installed.entry, ...extra] }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 传给 REA 子进程的环境变量(并入被清洗过的宿主环境之上)。
|
|
73
|
+
*
|
|
74
|
+
* REA 有 207 个工具(`server.json` 同版的 skill 元数据 `tool_count`),工具定义
|
|
75
|
+
* 是每轮请求的固定开销,因此两个 schema 档位默认都取最省的一档;REA 自己仍按
|
|
76
|
+
* 规范契约校验结果,`full` 可逐项还原上游默认。
|
|
77
|
+
*/
|
|
78
|
+
export function reaMcpEnv(config: AgentPresetReaConfig): Record<string, string> {
|
|
79
|
+
return {
|
|
80
|
+
REA_MCP_INPUT_SCHEMA_PROFILE: config.inputSchemaProfile,
|
|
81
|
+
REA_MCP_OUTPUT_SCHEMA_PROFILE: config.outputSchemaProfile,
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/ns.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
3
|
+
*
|
|
4
|
+
* 服务端以字面量参与 `Config` 的活动字段(dsh-settings 对命名空间做语法校验),
|
|
5
|
+
* 浏览器半把它作为配置卡片的注册键(插件页配置卡片挂在 row/bundle config 槽位上,
|
|
6
|
+
* 与命名空间解耦,见 shared/client/config-slots);同一字面量两处共用,防止漂移。
|
|
7
|
+
* @module @dsh-plus/agent-preset-rea/ns
|
|
8
|
+
*/
|
|
9
|
+
export const SETTINGS_NS = 'dsh-plus-agent-preset-rea'
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本包随包携带的上游提示词文本:`standard` 预设 `plan-mode` 行的 section 原文。
|
|
3
|
+
*
|
|
4
|
+
* `dsh-plan-mode` 要求 `config.section` 非空(部署方所有权),因此该文本必须随包;
|
|
5
|
+
* 逐字相等由 tests/upstream-mirror.test.ts 对着上游夹具守卫,上游一改即失败。
|
|
6
|
+
*
|
|
7
|
+
* persona 不在本文件:本预设的 persona 前缀默认为**空串**(见 config.ts 的
|
|
8
|
+
* `personaPrefix`),不随包任何自有 persona 文案;官方工具指引、项目指令
|
|
9
|
+
* (AGENTS.md)与运行时上下文照常装配。
|
|
10
|
+
* @module @dsh-plus/agent-preset-rea/prompt
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 上游 `standard` 预设 `plan-mode` 行的 section 原文(逐字,勿手改)。 */
|
|
14
|
+
export const PLAN_MODE_SECTION = `You are in plan mode. Stay in plan mode until the user approves your plan through exit_plan_mode or switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode.
|
|
15
|
+
|
|
16
|
+
Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery.
|
|
17
|
+
|
|
18
|
+
The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed to keep the tool catalog unchanged. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode.
|
|
19
|
+
|
|
20
|
+
Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out.
|
|
21
|
+
|
|
22
|
+
Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions.
|
|
23
|
+
|
|
24
|
+
When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.
|
|
25
|
+
`
|