@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 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
- - 工具名:`run_recruit_pipeline`
6
- - 状态:`NEED_INPUT` / `NEED_CONFIRMATION` / `COMPLETED` / `FAILED`
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.19",
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: `run_recruit_pipeline`
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 runProcess({ command, args, cwd, timeoutMs }) {
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
- stdout += chunk.toString();
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
- stderr += chunk.toString();
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,