@reconcrap/boss-recruit-mcp 1.0.19 → 1.0.21
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/README.md +24 -3
- package/package.json +3 -1
- package/skills/boss-recruit-pipeline/SKILL.md +11 -2
- package/src/adapters.js +204 -7
- package/src/index.js +608 -80
- package/src/pipeline.js +230 -11
- package/src/run-state.js +294 -0
- package/src/test-index-async.js +231 -0
- package/src/test-run-state.js +115 -0
package/README.md
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# @reconcrap/boss-recruit-mcp
|
|
2
2
|
|
|
3
|
-
统一招聘流水线 MCP(stdio)服务。将 `boss-search-cli` 与 `boss-screen-cli`
|
|
3
|
+
统一招聘流水线 MCP(stdio)服务。将 `boss-search-cli` 与 `boss-screen-cli` 串联为统一流程:
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
5
|
+
- `run_recruit_pipeline`(默认异步,但先执行与同步一致的前置门禁)
|
|
6
|
+
- `start_recruit_pipeline_run`(异步启动,返回 `run_id`)
|
|
7
|
+
- `get_recruit_pipeline_run`(查询运行状态快照)
|
|
8
|
+
- `cancel_recruit_pipeline_run`(取消运行中任务)
|
|
9
|
+
- 流程状态:`NEED_INPUT` / `NEED_CONFIRMATION` / `COMPLETED` / `FAILED`
|
|
10
|
+
- run 状态:`queued` / `running` / `completed` / `failed` / `canceled`
|
|
7
11
|
|
|
8
12
|
## 通过 npm / npx 安装
|
|
9
13
|
|
|
@@ -120,6 +124,21 @@ boss-recruit-mcp start
|
|
|
120
124
|
|
|
121
125
|
该服务通过 stdio 与 MCP client 通信。
|
|
122
126
|
|
|
127
|
+
## 长流程 Agent 兼容模式
|
|
128
|
+
|
|
129
|
+
当宿主 agent 对长时间阻塞调用敏感时,建议使用默认异步模式:
|
|
130
|
+
|
|
131
|
+
1. 直接调用 `run_recruit_pipeline`(默认 async)。
|
|
132
|
+
2. 工具会先执行与同步一致的前置门禁(参数确认、preflight、页面就绪)。
|
|
133
|
+
3. 门禁未通过时,直接返回 `NEED_INPUT/NEED_CONFIRMATION/FAILED`,不会启动后台 run。
|
|
134
|
+
4. 门禁通过后返回 `ACCEPTED + run_id`,随后轮询 `get_recruit_pipeline_run`。
|
|
135
|
+
5. 若需要阻塞式返回,显式传 `execution_mode=sync`。
|
|
136
|
+
|
|
137
|
+
说明:
|
|
138
|
+
|
|
139
|
+
- 运行态文件保存在 `~/.boss-recruit-mcp/runs/<run_id>.json`(可通过 `BOSS_RECRUIT_HOME` 覆盖)。
|
|
140
|
+
- 心跳默认 120 秒;阶段切换与进度更新会刷新 `updated_at`。
|
|
141
|
+
|
|
123
142
|
## CLI Fallback
|
|
124
143
|
|
|
125
144
|
如果当前 AI agent 无法添加新的 MCP、MCP 数量受限,或者只支持 shell/命令执行,也可以直接调用同一后端的 CLI fallback:
|
|
@@ -207,11 +226,13 @@ boss-recruit-mcp doctor --port <port>
|
|
|
207
226
|
|
|
208
227
|
```json
|
|
209
228
|
{
|
|
229
|
+
"execution_mode": "async",
|
|
210
230
|
"instruction": "自然语言招聘指令",
|
|
211
231
|
"confirmation": {
|
|
212
232
|
"keyword_confirmed": true,
|
|
213
233
|
"keyword_value": "ai infra",
|
|
214
234
|
"search_params_confirmed": true,
|
|
235
|
+
"criteria_confirmed": true,
|
|
215
236
|
"use_default_for_missing": false
|
|
216
237
|
},
|
|
217
238
|
"overrides": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reconcrap/boss-recruit-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "Unified MCP pipeline for boss-search-cli and boss-screen-cli",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"boss",
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
"postinstall": "node scripts/postinstall.cjs",
|
|
22
22
|
"test:parser": "node src/test-parser.js",
|
|
23
23
|
"test:pipeline": "node src/test-pipeline.js",
|
|
24
|
+
"test:run-state": "node src/test-run-state.js",
|
|
25
|
+
"test:async": "node src/test-index-async.js",
|
|
24
26
|
"test:protocol:win": "powershell -ExecutionPolicy Bypass -File scripts/verify-multi-round-protocol.ps1 -Mode unit",
|
|
25
27
|
"test:regression:win": "powershell -ExecutionPolicy Bypass -File scripts/regression.ps1 -Mode quick"
|
|
26
28
|
},
|
|
@@ -76,8 +76,9 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
|
|
|
76
76
|
|
|
77
77
|
## Required MCP Tool
|
|
78
78
|
|
|
79
|
-
- Tool name
|
|
79
|
+
- Tool name(默认异步): `run_recruit_pipeline`
|
|
80
80
|
- Input:
|
|
81
|
+
- `execution_mode` (optional: `async|sync`, default `async`)
|
|
81
82
|
- `instruction` (string, required)
|
|
82
83
|
- `confirmation` (object, optional)
|
|
83
84
|
- `keyword_confirmed` (boolean): 是否确认关键词
|
|
@@ -91,7 +92,7 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
|
|
|
91
92
|
- `schools` (string[] | comma-separated string)
|
|
92
93
|
- `keyword` (string)
|
|
93
94
|
- `target_count` (number)
|
|
94
|
-
- Tool response 重点字段:
|
|
95
|
+
- Tool response 重点字段:
|
|
95
96
|
- `status`
|
|
96
97
|
- `required_confirmations`
|
|
97
98
|
- `pending_questions`
|
|
@@ -102,9 +103,17 @@ description: "Use when users ask to recruit candidates on Boss Zhipin via the bo
|
|
|
102
103
|
- `review.default_preview`
|
|
103
104
|
- `review.applied_defaults`
|
|
104
105
|
|
|
106
|
+
异步工具(可选显式三步):
|
|
107
|
+
|
|
108
|
+
- `start_recruit_pipeline_run`
|
|
109
|
+
- `get_recruit_pipeline_run`
|
|
110
|
+
- `cancel_recruit_pipeline_run`
|
|
111
|
+
|
|
105
112
|
## Backend Selection
|
|
106
113
|
|
|
107
114
|
- 默认执行路径:优先使用 MCP 工具 `run_recruit_pipeline`。
|
|
115
|
+
- `run_recruit_pipeline` 默认 async,但会先执行与 sync 一致的前置门禁(参数确认、preflight、页面就绪);门禁不通过时不会返回 `run_id`。
|
|
116
|
+
- 只有门禁通过后才会返回 `ACCEPTED + run_id`,随后轮询 `get_recruit_pipeline_run`。
|
|
108
117
|
- 如果当前 AI agent 无法添加新的 MCP、MCP 数量受限、或当前会话拿不到该工具:
|
|
109
118
|
- 直接切换到 CLI fallback;
|
|
110
119
|
- 不要放弃流程,也不要要求用户手动把所有步骤重新翻译一遍。
|
package/src/adapters.js
CHANGED
|
@@ -89,20 +89,102 @@ function resolveScreenCliEntry(screenDir) {
|
|
|
89
89
|
return candidates.find((candidate) => pathExists(candidate)) || candidates[0];
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
function
|
|
92
|
+
function safeInvokeCallback(callback, payload) {
|
|
93
|
+
if (typeof callback !== "function") return;
|
|
94
|
+
try {
|
|
95
|
+
callback(payload);
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore callback errors to keep pipeline runtime stable.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function runProcess({
|
|
102
|
+
command,
|
|
103
|
+
args,
|
|
104
|
+
cwd,
|
|
105
|
+
timeoutMs,
|
|
106
|
+
onOutput,
|
|
107
|
+
onLine,
|
|
108
|
+
onHeartbeat,
|
|
109
|
+
heartbeatIntervalMs = 10_000,
|
|
110
|
+
signal
|
|
111
|
+
}) {
|
|
93
112
|
return new Promise((resolve) => {
|
|
94
113
|
let stdout = "";
|
|
95
114
|
let stderr = "";
|
|
115
|
+
let stdoutLineBuffer = "";
|
|
116
|
+
let stderrLineBuffer = "";
|
|
96
117
|
let settled = false;
|
|
97
118
|
let timer = null;
|
|
119
|
+
let heartbeatTimer = null;
|
|
120
|
+
let abortedBySignal = Boolean(signal?.aborted);
|
|
121
|
+
let abortListener = null;
|
|
122
|
+
|
|
123
|
+
function notifyHeartbeat(source) {
|
|
124
|
+
safeInvokeCallback(onHeartbeat, {
|
|
125
|
+
source,
|
|
126
|
+
command,
|
|
127
|
+
args,
|
|
128
|
+
cwd,
|
|
129
|
+
at: new Date().toISOString()
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function emitLine(stream, line) {
|
|
134
|
+
const normalized = String(line ?? "").replace(/\r$/, "");
|
|
135
|
+
if (!normalized) return;
|
|
136
|
+
safeInvokeCallback(onLine, {
|
|
137
|
+
stream,
|
|
138
|
+
line: normalized,
|
|
139
|
+
at: new Date().toISOString()
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function pushLineBuffer(stream, chunkText) {
|
|
144
|
+
if (stream === "stdout") {
|
|
145
|
+
stdoutLineBuffer += chunkText;
|
|
146
|
+
} else {
|
|
147
|
+
stderrLineBuffer += chunkText;
|
|
148
|
+
}
|
|
149
|
+
let buffer = stream === "stdout" ? stdoutLineBuffer : stderrLineBuffer;
|
|
150
|
+
let newlineIndex = buffer.indexOf("\n");
|
|
151
|
+
while (newlineIndex !== -1) {
|
|
152
|
+
emitLine(stream, buffer.slice(0, newlineIndex));
|
|
153
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
154
|
+
newlineIndex = buffer.indexOf("\n");
|
|
155
|
+
}
|
|
156
|
+
if (stream === "stdout") {
|
|
157
|
+
stdoutLineBuffer = buffer;
|
|
158
|
+
} else {
|
|
159
|
+
stderrLineBuffer = buffer;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
98
162
|
|
|
99
163
|
function finish(payload) {
|
|
100
164
|
if (settled) return;
|
|
101
165
|
settled = true;
|
|
102
166
|
if (timer) clearTimeout(timer);
|
|
167
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
168
|
+
if (signal && typeof signal.removeEventListener === "function" && abortListener) {
|
|
169
|
+
signal.removeEventListener("abort", abortListener);
|
|
170
|
+
}
|
|
171
|
+
emitLine("stdout", stdoutLineBuffer);
|
|
172
|
+
emitLine("stderr", stderrLineBuffer);
|
|
173
|
+
stdoutLineBuffer = "";
|
|
174
|
+
stderrLineBuffer = "";
|
|
103
175
|
resolve(payload);
|
|
104
176
|
}
|
|
105
177
|
|
|
178
|
+
if (abortedBySignal) {
|
|
179
|
+
finish({
|
|
180
|
+
code: -1,
|
|
181
|
+
stdout,
|
|
182
|
+
stderr: "Process aborted before spawn",
|
|
183
|
+
error_code: "ABORTED"
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
106
188
|
let child;
|
|
107
189
|
try {
|
|
108
190
|
child = spawn(command, args, {
|
|
@@ -121,6 +203,16 @@ function runProcess({ command, args, cwd, timeoutMs }) {
|
|
|
121
203
|
return;
|
|
122
204
|
}
|
|
123
205
|
|
|
206
|
+
if (signal && typeof signal.addEventListener === "function") {
|
|
207
|
+
abortListener = () => {
|
|
208
|
+
abortedBySignal = true;
|
|
209
|
+
try {
|
|
210
|
+
child.kill();
|
|
211
|
+
} catch {}
|
|
212
|
+
};
|
|
213
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
124
216
|
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
|
125
217
|
timer = setTimeout(() => {
|
|
126
218
|
try {
|
|
@@ -135,14 +227,45 @@ function runProcess({ command, args, cwd, timeoutMs }) {
|
|
|
135
227
|
}, timeoutMs);
|
|
136
228
|
}
|
|
137
229
|
|
|
230
|
+
if (Number.isFinite(heartbeatIntervalMs) && heartbeatIntervalMs > 0) {
|
|
231
|
+
heartbeatTimer = setInterval(() => {
|
|
232
|
+
notifyHeartbeat("timer");
|
|
233
|
+
}, heartbeatIntervalMs);
|
|
234
|
+
}
|
|
235
|
+
|
|
138
236
|
child.stdout.on("data", (chunk) => {
|
|
139
|
-
|
|
237
|
+
const text = chunk.toString();
|
|
238
|
+
stdout += text;
|
|
239
|
+
pushLineBuffer("stdout", text);
|
|
240
|
+
safeInvokeCallback(onOutput, {
|
|
241
|
+
stream: "stdout",
|
|
242
|
+
text,
|
|
243
|
+
at: new Date().toISOString()
|
|
244
|
+
});
|
|
245
|
+
notifyHeartbeat("stdout");
|
|
140
246
|
});
|
|
141
247
|
child.stderr.on("data", (chunk) => {
|
|
142
|
-
|
|
248
|
+
const text = chunk.toString();
|
|
249
|
+
stderr += text;
|
|
250
|
+
pushLineBuffer("stderr", text);
|
|
251
|
+
safeInvokeCallback(onOutput, {
|
|
252
|
+
stream: "stderr",
|
|
253
|
+
text,
|
|
254
|
+
at: new Date().toISOString()
|
|
255
|
+
});
|
|
256
|
+
notifyHeartbeat("stderr");
|
|
143
257
|
});
|
|
144
258
|
|
|
145
259
|
child.on("close", (code) => {
|
|
260
|
+
if (abortedBySignal) {
|
|
261
|
+
finish({
|
|
262
|
+
code: -1,
|
|
263
|
+
stdout,
|
|
264
|
+
stderr: `${stderr}\nProcess aborted by signal`.trim(),
|
|
265
|
+
error_code: "ABORTED"
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
146
269
|
finish({ code, stdout, stderr });
|
|
147
270
|
});
|
|
148
271
|
child.on("error", (error) => {
|
|
@@ -363,6 +486,40 @@ function parseScreenSummary(output) {
|
|
|
363
486
|
};
|
|
364
487
|
}
|
|
365
488
|
|
|
489
|
+
function parseScreenProgressLine(line, previous = {}) {
|
|
490
|
+
const text = String(line || "").trim();
|
|
491
|
+
if (!text) return null;
|
|
492
|
+
const next = {
|
|
493
|
+
processed: Number.isInteger(previous.processed) ? previous.processed : 0,
|
|
494
|
+
passed: Number.isInteger(previous.passed) ? previous.passed : 0,
|
|
495
|
+
skipped: Number.isInteger(previous.skipped) ? previous.skipped : 0,
|
|
496
|
+
greet_count: Number.isInteger(previous.greet_count) ? previous.greet_count : 0
|
|
497
|
+
};
|
|
498
|
+
let matched = false;
|
|
499
|
+
|
|
500
|
+
const processedMatch = text.match(/已处理[::]\s*(\d+)/);
|
|
501
|
+
if (processedMatch) {
|
|
502
|
+
next.processed = Number.parseInt(processedMatch[1], 10);
|
|
503
|
+
matched = true;
|
|
504
|
+
}
|
|
505
|
+
const passedMatch = text.match(/(?:通过筛选|通过|已通过)[::]\s*(\d+)/);
|
|
506
|
+
if (passedMatch) {
|
|
507
|
+
next.passed = Number.parseInt(passedMatch[1], 10);
|
|
508
|
+
matched = true;
|
|
509
|
+
}
|
|
510
|
+
const skippedMatch = text.match(/(?:已跳过|跳过)[::]\s*(\d+)/);
|
|
511
|
+
if (skippedMatch) {
|
|
512
|
+
next.skipped = Number.parseInt(skippedMatch[1], 10);
|
|
513
|
+
matched = true;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!matched) return null;
|
|
517
|
+
return {
|
|
518
|
+
line: text,
|
|
519
|
+
progress: next
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
|
|
366
523
|
function loadScreenConfig(configPath) {
|
|
367
524
|
if (!fs.existsSync(configPath)) {
|
|
368
525
|
return {
|
|
@@ -721,7 +878,7 @@ export async function ensureBossSearchPageReady(workspaceRoot, options = {}) {
|
|
|
721
878
|
};
|
|
722
879
|
}
|
|
723
880
|
|
|
724
|
-
export async function runSearchCli({ workspaceRoot, searchParams }) {
|
|
881
|
+
export async function runSearchCli({ workspaceRoot, searchParams, runtime = null }) {
|
|
725
882
|
const searchDir = resolveSearchCliDir(workspaceRoot);
|
|
726
883
|
const debugPort = resolveWorkspaceDebugPort(workspaceRoot);
|
|
727
884
|
if (!searchDir) {
|
|
@@ -757,7 +914,15 @@ export async function runSearchCli({ workspaceRoot, searchParams }) {
|
|
|
757
914
|
command: "node",
|
|
758
915
|
args,
|
|
759
916
|
cwd: searchDir,
|
|
760
|
-
timeoutMs: 180000
|
|
917
|
+
timeoutMs: 180000,
|
|
918
|
+
heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
|
|
919
|
+
signal: runtime?.signal,
|
|
920
|
+
onOutput: (event) => {
|
|
921
|
+
safeInvokeCallback(runtime?.onOutput, event);
|
|
922
|
+
},
|
|
923
|
+
onHeartbeat: (event) => {
|
|
924
|
+
safeInvokeCallback(runtime?.onHeartbeat, event);
|
|
925
|
+
}
|
|
761
926
|
});
|
|
762
927
|
|
|
763
928
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
@@ -780,7 +945,7 @@ export async function runSearchCli({ workspaceRoot, searchParams }) {
|
|
|
780
945
|
};
|
|
781
946
|
}
|
|
782
947
|
|
|
783
|
-
export async function runScreenCli({ workspaceRoot, screenParams }) {
|
|
948
|
+
export async function runScreenCli({ workspaceRoot, screenParams, runtime = null }) {
|
|
784
949
|
const screenDir = resolveScreenCliDir(workspaceRoot);
|
|
785
950
|
if (!screenDir) {
|
|
786
951
|
return {
|
|
@@ -845,14 +1010,46 @@ export async function runScreenCli({ workspaceRoot, screenParams }) {
|
|
|
845
1010
|
outputPath
|
|
846
1011
|
];
|
|
847
1012
|
|
|
1013
|
+
let inferredProgress = {
|
|
1014
|
+
processed: 0,
|
|
1015
|
+
passed: 0,
|
|
1016
|
+
skipped: 0,
|
|
1017
|
+
greet_count: 0
|
|
1018
|
+
};
|
|
1019
|
+
|
|
848
1020
|
const result = await runProcess({
|
|
849
1021
|
command: "node",
|
|
850
1022
|
args,
|
|
851
|
-
cwd: screenDir
|
|
1023
|
+
cwd: screenDir,
|
|
1024
|
+
heartbeatIntervalMs: runtime?.heartbeatIntervalMs,
|
|
1025
|
+
signal: runtime?.signal,
|
|
1026
|
+
onOutput: (event) => {
|
|
1027
|
+
safeInvokeCallback(runtime?.onOutput, event);
|
|
1028
|
+
},
|
|
1029
|
+
onLine: (event) => {
|
|
1030
|
+
const parsed = parseScreenProgressLine(event?.line, inferredProgress);
|
|
1031
|
+
if (!parsed) return;
|
|
1032
|
+
inferredProgress = parsed.progress;
|
|
1033
|
+
safeInvokeCallback(runtime?.onProgress, {
|
|
1034
|
+
...inferredProgress,
|
|
1035
|
+
line: parsed.line
|
|
1036
|
+
});
|
|
1037
|
+
},
|
|
1038
|
+
onHeartbeat: (event) => {
|
|
1039
|
+
safeInvokeCallback(runtime?.onHeartbeat, event);
|
|
1040
|
+
}
|
|
852
1041
|
});
|
|
853
1042
|
|
|
854
1043
|
const combined = `${result.stdout}\n${result.stderr}`;
|
|
855
1044
|
const summary = parseScreenSummary(combined);
|
|
1045
|
+
if (summary) {
|
|
1046
|
+
safeInvokeCallback(runtime?.onProgress, {
|
|
1047
|
+
processed: Number.isInteger(summary.processed_count) ? summary.processed_count : inferredProgress.processed,
|
|
1048
|
+
passed: Number.isInteger(summary.passed_count) ? summary.passed_count : inferredProgress.passed,
|
|
1049
|
+
skipped: inferredProgress.skipped,
|
|
1050
|
+
greet_count: inferredProgress.greet_count
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
856
1053
|
|
|
857
1054
|
return {
|
|
858
1055
|
ok: result.code === 0,
|