@reconcrap/boss-recommend-mcp 2.0.47 → 2.0.49

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.
Files changed (55) hide show
  1. package/bin/boss-recommend-mcp.js +4 -4
  2. package/config/screening-config.example.json +27 -27
  3. package/package.json +1 -1
  4. package/scripts/postinstall.cjs +44 -44
  5. package/skills/boss-chat/README.md +39 -39
  6. package/skills/boss-chat/SKILL.md +93 -93
  7. package/skills/boss-recommend-pipeline/README.md +12 -12
  8. package/skills/boss-recommend-pipeline/SKILL.md +180 -180
  9. package/skills/boss-recruit-pipeline/README.md +17 -17
  10. package/skills/boss-recruit-pipeline/SKILL.md +58 -58
  11. package/src/chat-mcp.js +1780 -1780
  12. package/src/chat-runtime-config.js +749 -749
  13. package/src/cli.js +3054 -3054
  14. package/src/core/boss-cards/index.js +199 -199
  15. package/src/core/browser/index.js +1586 -1453
  16. package/src/core/capture/index.js +1201 -1201
  17. package/src/core/cv-acquisition/index.js +238 -238
  18. package/src/core/cv-capture-target/index.js +299 -299
  19. package/src/core/greet-quota/index.js +54 -54
  20. package/src/core/infinite-list/index.js +1326 -1326
  21. package/src/core/reporting/legacy-csv.js +341 -341
  22. package/src/core/run/timing.js +33 -33
  23. package/src/core/self-heal/index.js +973 -973
  24. package/src/core/self-heal/viewport.js +564 -564
  25. package/src/domains/chat/cards.js +137 -137
  26. package/src/domains/chat/constants.js +221 -221
  27. package/src/domains/chat/detail.js +1668 -1668
  28. package/src/domains/chat/index.js +7 -7
  29. package/src/domains/chat/jobs.js +592 -592
  30. package/src/domains/chat/page-guard.js +98 -98
  31. package/src/domains/chat/roots.js +56 -56
  32. package/src/domains/chat/run-service.js +1977 -1977
  33. package/src/domains/recommend/actions.js +457 -457
  34. package/src/domains/recommend/cards.js +243 -243
  35. package/src/domains/recommend/constants.js +165 -165
  36. package/src/domains/recommend/detail.js +1 -1
  37. package/src/domains/recommend/filters.js +610 -610
  38. package/src/domains/recommend/index.js +10 -10
  39. package/src/domains/recommend/jobs.js +378 -316
  40. package/src/domains/recommend/refresh.js +491 -472
  41. package/src/domains/recommend/roots.js +80 -80
  42. package/src/domains/recommend/run-service.js +50 -29
  43. package/src/domains/recommend/scopes.js +246 -246
  44. package/src/domains/recruit/actions.js +277 -277
  45. package/src/domains/recruit/cards.js +74 -74
  46. package/src/domains/recruit/constants.js +167 -167
  47. package/src/domains/recruit/detail.js +461 -461
  48. package/src/domains/recruit/index.js +9 -9
  49. package/src/domains/recruit/instruction-parser.js +451 -451
  50. package/src/domains/recruit/refresh.js +44 -44
  51. package/src/domains/recruit/roots.js +68 -68
  52. package/src/domains/recruit/run-service.js +1207 -1207
  53. package/src/domains/recruit/search.js +1202 -1202
  54. package/src/recommend-mcp.js +22 -22
  55. package/src/recruit-mcp.js +1338 -1338
@@ -1,1338 +1,1338 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import {
5
- assertNoForbiddenCdpCalls,
6
- bringPageToFront,
7
- connectToChromeTargetOrOpen,
8
- createBossLoginRequiredError,
9
- detectBossLoginState,
10
- enableDomains,
11
- getMainFrameUrl,
12
- isBossLoginUrl,
13
- waitForMainFrameUrl,
14
- sleep
15
- } from "./core/browser/index.js";
16
- import {
17
- RUN_STATUS_CANCELING,
18
- RUN_STATUS_CANCELED,
19
- RUN_STATUS_COMPLETED,
20
- RUN_STATUS_FAILED,
21
- RUN_STATUS_PAUSED
22
- } from "./core/run/index.js";
23
- import {
24
- buildLegacyScreenInputRows,
25
- cloneReportInput,
26
- writeLegacyScreenCsv
27
- } from "./core/reporting/legacy-csv.js";
28
- import {
29
- createRecruitRunService,
30
- parseRecruitInstruction,
31
- RECRUIT_TARGET_URL,
32
- runRecruitWorkflow,
33
- waitForRecruitSearchControls
34
- } from "./domains/recruit/index.js";
35
- import {
36
- resolveBossConfiguredOutputDir,
37
- resolveHumanBehaviorForRun,
38
- resolveBossScreeningConfig
39
- } from "./chat-runtime-config.js";
40
- import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
41
-
42
- const RUN_MODE_ASYNC = "async";
43
- const RUN_MODE_SYNC = "sync";
44
- const DEFAULT_RECRUIT_POLL_AFTER_SEC = 10;
45
- const DEFAULT_RECRUIT_HOST = "127.0.0.1";
46
- const DEFAULT_RECRUIT_PORT = 9222;
47
- const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
48
- const DEFAULT_RECRUIT_HOME_DIR = ".boss-recruit-mcp";
49
-
50
- const TERMINAL_STATUSES = new Set([
51
- RUN_STATUS_COMPLETED,
52
- RUN_STATUS_FAILED,
53
- RUN_STATUS_CANCELED
54
- ]);
55
-
56
- let recruitWorkflowImpl = runRecruitWorkflow;
57
- let recruitConnectorImpl = connectRecruitChromeSession;
58
- let recruitRunService = createRecruitRunService({
59
- idPrefix: "mcp_recruit",
60
- workflow: (...args) => recruitWorkflowImpl(...args),
61
- onSnapshot: persistRecruitLifecycleSnapshot
62
- });
63
- const recruitRunMeta = new Map();
64
-
65
- function normalizeText(value) {
66
- return String(value || "").replace(/\s+/g, " ").trim();
67
- }
68
-
69
- function parsePositiveInteger(raw, fallback) {
70
- const parsed = Number.parseInt(String(raw || ""), 10);
71
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
72
- }
73
-
74
- function parseNonNegativeInteger(raw, fallback) {
75
- const parsed = Number.parseInt(String(raw ?? ""), 10);
76
- return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
77
- }
78
-
79
- function isDebugTestMode(args = {}) {
80
- return args.debug_test_mode === true || args.allow_debug_test_mode === true;
81
- }
82
-
83
- function normalizeScreeningModeArg(args = {}) {
84
- const raw = normalizeText(args.screening_mode || args.screeningMode || "");
85
- if (args.use_llm === false) return "deterministic";
86
- return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
87
- ? "deterministic"
88
- : "llm";
89
- }
90
-
91
- function collectRecruitDebugTestOptions(args = {}) {
92
- const reasons = [];
93
- if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
94
- if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
95
- if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
96
- if (args.execute_post_action === false) reasons.push("execute_post_action=false");
97
- return reasons;
98
- }
99
-
100
- function methodSummary(methodLog = []) {
101
- const summary = {};
102
- for (const entry of methodLog || []) {
103
- summary[entry.method] = (summary[entry.method] || 0) + 1;
104
- }
105
- return summary;
106
- }
107
-
108
- function normalizeExecutionMode(value) {
109
- return normalizeText(value).toLowerCase() === RUN_MODE_SYNC ? RUN_MODE_SYNC : RUN_MODE_ASYNC;
110
- }
111
-
112
- function clonePlain(value, fallback = null) {
113
- try {
114
- return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
115
- } catch {
116
- return fallback;
117
- }
118
- }
119
-
120
- function normalizeRunId(runId) {
121
- const normalized = normalizeText(runId);
122
- if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
123
- return normalized;
124
- }
125
-
126
- function getRecruitStateHome() {
127
- const fromEnv = normalizeText(globalThis.process?.env?.BOSS_RECRUIT_HOME || "");
128
- return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), DEFAULT_RECRUIT_HOME_DIR);
129
- }
130
-
131
- function getRecruitRunsDir() {
132
- return path.join(getRecruitStateHome(), "runs");
133
- }
134
-
135
- function getRecruitRunArtifacts(runId) {
136
- const normalized = normalizeRunId(runId);
137
- if (!normalized) return null;
138
- const runsDir = getRecruitRunsDir();
139
- const outputDir = resolveBossConfiguredOutputDir("", runsDir);
140
- return {
141
- runs_dir: runsDir,
142
- output_dir: outputDir,
143
- run_state_path: path.join(runsDir, `${normalized}.json`),
144
- checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
145
- output_csv: path.join(outputDir, `${normalized}.results.csv`),
146
- report_json: path.join(outputDir, `${normalized}.report.json`)
147
- };
148
- }
149
-
150
- function ensureDirectory(dirPath) {
151
- fs.mkdirSync(dirPath, { recursive: true });
152
- }
153
-
154
- function writeJsonAtomic(filePath, payload) {
155
- ensureDirectory(path.dirname(filePath));
156
- const tempPath = `${filePath}.tmp`;
157
- fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
158
- fs.renameSync(tempPath, filePath);
159
- }
160
-
161
- function readJsonFile(filePath) {
162
- try {
163
- if (!fs.existsSync(filePath)) return null;
164
- const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
165
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
166
- } catch {
167
- return null;
168
- }
169
- }
170
-
171
- function selectedRecruitJobForCsv(meta = {}) {
172
- const keyword = normalizeText(
173
- meta.parsed?.proposed_keyword
174
- || meta.parsed?.searchParams?.keyword
175
- || meta.args?.confirmation?.keyword_value
176
- || meta.args?.overrides?.keyword
177
- || ""
178
- );
179
- return {
180
- value: keyword,
181
- title: keyword,
182
- label: keyword
183
- };
184
- }
185
-
186
- function buildRecruitCsvInputRows(snapshot = {}, meta = {}) {
187
- const searchParams = meta.parsed?.searchParams || snapshot.summary?.search_params || {};
188
- const screenParams = meta.parsed?.screenParams || {};
189
- return buildLegacyScreenInputRows({
190
- instruction: meta.args?.instruction || "",
191
- selectedPage: "search",
192
- selectedJob: selectedRecruitJobForCsv(meta),
193
- userSearchParams: cloneReportInput(searchParams, {}),
194
- effectiveSearchParams: cloneReportInput(searchParams, {}),
195
- screenParams: {
196
- criteria: screenParams.criteria || "",
197
- target_count: screenParams.target_count || snapshot.progress?.target_count || snapshot.context?.max_candidates || "",
198
- post_action: screenParams.post_action || "none",
199
- max_greet_count: screenParams.max_greet_count ?? ""
200
- },
201
- followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || null
202
- });
203
- }
204
-
205
- function writeRecruitLegacyCsvAtomic(filePath, rows = [], snapshot = {}, meta = {}) {
206
- writeLegacyScreenCsv(filePath, {
207
- inputRows: buildRecruitCsvInputRows(snapshot, meta),
208
- results: rows
209
- });
210
- }
211
-
212
- function readRecruitRunState(runId) {
213
- const artifacts = getRecruitRunArtifacts(runId);
214
- if (!artifacts) return null;
215
- return readJsonFile(artifacts.run_state_path);
216
- }
217
-
218
- function ensureRecruitRunArtifacts(snapshot) {
219
- const artifacts = getRecruitRunArtifacts(snapshot?.runId || snapshot?.run_id);
220
- if (!artifacts) return null;
221
-
222
- const meta = getRecruitRunMeta(snapshot?.runId || snapshot?.run_id);
223
- const checkpoint = snapshot?.checkpoint && typeof snapshot.checkpoint === "object"
224
- ? snapshot.checkpoint
225
- : {};
226
- writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
227
- if (meta) meta.checkpointPath = artifacts.checkpoint_path;
228
-
229
- const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
230
- const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
231
- const artifactSummary = summary || (checkpointResults.length ? {
232
- domain: "recruit",
233
- partial: true,
234
- partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
235
- results: checkpointResults
236
- } : null);
237
- if (artifactSummary) {
238
- const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
239
- writeRecruitLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
240
- writeJsonAtomic(artifacts.report_json, {
241
- run_id: snapshot.runId || snapshot.run_id,
242
- status: snapshot.status || snapshot.state,
243
- phase: snapshot.phase || snapshot.stage,
244
- progress: snapshot.progress || {},
245
- context: snapshot.context || {},
246
- checkpoint,
247
- summary: artifactSummary,
248
- generated_at: new Date().toISOString()
249
- });
250
- if (meta) {
251
- meta.outputCsvPath = artifacts.output_csv;
252
- meta.reportJsonPath = artifacts.report_json;
253
- }
254
- }
255
-
256
- return artifacts;
257
- }
258
-
259
- function persistRecruitCheckpointSnapshot(normalized) {
260
- const artifacts = getRecruitRunArtifacts(normalized?.run_id || normalized?.runId);
261
- if (!artifacts) return;
262
- const checkpoint = normalized?.checkpoint && typeof normalized.checkpoint === "object"
263
- ? normalized.checkpoint
264
- : {};
265
- writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
266
- const meta = getRecruitRunMeta(normalized?.run_id || normalized?.runId);
267
- if (meta) meta.checkpointPath = artifacts.checkpoint_path;
268
- }
269
-
270
- function toIsoOrNull(value) {
271
- const normalized = normalizeText(value);
272
- return normalized || null;
273
- }
274
-
275
- function secondsBetween(startedAt, endedAt) {
276
- const startMs = Date.parse(startedAt || "");
277
- const endMs = Date.parse(endedAt || "") || Date.now();
278
- if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
279
- return Math.max(1, Math.round((endMs - startMs) / 1000));
280
- }
281
-
282
- function normalizeLegacyProgress(progress = {}, summary = null) {
283
- const processed = Number.isInteger(progress.processed)
284
- ? progress.processed
285
- : Number.isInteger(summary?.processed)
286
- ? summary.processed
287
- : 0;
288
- const passed = Number.isInteger(progress.passed)
289
- ? progress.passed
290
- : Number.isInteger(summary?.passed)
291
- ? summary.passed
292
- : 0;
293
- return {
294
- ...progress,
295
- processed,
296
- passed,
297
- skipped: Number.isInteger(progress.skipped) ? progress.skipped : Math.max(processed - passed, 0),
298
- greet_count: Number.isInteger(progress.greet_count) ? progress.greet_count : 0
299
- };
300
- }
301
-
302
- function completionReason(status) {
303
- if (status === RUN_STATUS_COMPLETED) return "completed";
304
- if (status === RUN_STATUS_CANCELED) return "canceled_by_user";
305
- if (status === RUN_STATUS_FAILED) return "failed";
306
- if (status === RUN_STATUS_PAUSED) return "paused";
307
- return null;
308
- }
309
-
310
- function buildLegacyRunResult(snapshot) {
311
- if (!snapshot) return null;
312
- const artifacts = ensureRecruitRunArtifacts(snapshot);
313
- const meta = getRecruitRunMeta(snapshot.runId);
314
- const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
315
- const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
316
- const resultRows = Array.isArray(summary?.results)
317
- ? summary.results
318
- : Array.isArray(checkpoint.results)
319
- ? checkpoint.results
320
- : [];
321
- const progress = normalizeLegacyProgress(snapshot.progress, summary);
322
- const targetCount = Number.isInteger(progress.target_count)
323
- ? progress.target_count
324
- : Number.isInteger(snapshot.context?.max_candidates)
325
- ? snapshot.context.max_candidates
326
- : null;
327
- return {
328
- target_count: targetCount,
329
- processed_count: progress.processed,
330
- passed_count: progress.passed,
331
- screened_count: Number.isInteger(progress.screened)
332
- ? progress.screened
333
- : Number.isInteger(summary?.screened)
334
- ? summary.screened
335
- : progress.processed,
336
- detail_opened: Number.isInteger(progress.detail_opened)
337
- ? progress.detail_opened
338
- : Number.isInteger(summary?.detail_opened)
339
- ? summary.detail_opened
340
- : 0,
341
- duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt || snapshot.updatedAt),
342
- output_csv: summary?.output_csv || meta.outputCsvPath || artifacts?.output_csv || null,
343
- report_json: summary?.report_json || meta.reportJsonPath || artifacts?.report_json || null,
344
- round_count: 1,
345
- current_round_index: 1,
346
- checkpoint_path: snapshot.checkpoint?.checkpoint_path
347
- || snapshot.checkpoint?.path
348
- || meta.checkpointPath
349
- || artifacts?.checkpoint_path
350
- || null,
351
- completion_reason: completionReason(snapshot.status),
352
- target_count_semantics: TARGET_COUNT_SEMANTICS,
353
- run_id: snapshot.runId,
354
- results: resultRows
355
- };
356
- }
357
-
358
- function createTargetCountSchema(description) {
359
- return {
360
- oneOf: [
361
- { type: "integer", minimum: 1 },
362
- { type: "string", pattern: "^[1-9][0-9]*$" }
363
- ],
364
- description
365
- };
366
- }
367
-
368
- function createHumanBehaviorInputSchema(description = "可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on") {
369
- return {
370
- type: "object",
371
- properties: {
372
- enabled: { type: "boolean" },
373
- profile: {
374
- type: "string",
375
- enum: ["baseline", "paced", "paced_with_rests"]
376
- },
377
- clickMovement: { type: "boolean" },
378
- textEntry: { type: "boolean" },
379
- listScrollJitter: { type: "boolean" },
380
- shortRest: { type: "boolean" },
381
- batchRest: { type: "boolean" },
382
- actionCooldown: { type: "boolean" }
383
- },
384
- additionalProperties: false,
385
- description
386
- };
387
- }
388
-
389
- export function createRecruitPipelineInputSchema() {
390
- return {
391
- type: "object",
392
- properties: {
393
- instruction: {
394
- type: "string",
395
- description: "用户自然语言招聘指令"
396
- },
397
- execution_mode: {
398
- type: "string",
399
- enum: [RUN_MODE_ASYNC, RUN_MODE_SYNC],
400
- description: "执行模式;默认 async。"
401
- },
402
- confirmation: {
403
- type: "object",
404
- properties: {
405
- keyword_confirmed: { type: "boolean" },
406
- keyword_value: { type: "string" },
407
- search_params_confirmed: { type: "boolean" },
408
- criteria_confirmed: { type: "boolean" },
409
- use_default_for_missing: { type: "boolean" }
410
- },
411
- additionalProperties: false
412
- },
413
- overrides: {
414
- type: "object",
415
- properties: {
416
- city: { type: "string" },
417
- degree: { type: "string" },
418
- filter_recent_viewed: { type: "boolean" },
419
- schools: {
420
- anyOf: [
421
- { type: "array", items: { type: "string" } },
422
- { type: "string" }
423
- ]
424
- },
425
- keyword: { type: "string" },
426
- target_count: { type: "integer", minimum: 1 },
427
- criteria: { type: "string" }
428
- },
429
- additionalProperties: false
430
- },
431
- host: {
432
- type: "string",
433
- description: "可选,Chrome 调试 host;默认 127.0.0.1"
434
- },
435
- port: {
436
- type: "integer",
437
- minimum: 1,
438
- description: "可选,Chrome 调试端口;默认 9222"
439
- },
440
- target_url_includes: {
441
- type: "string",
442
- description: "可选,Chrome target URL 匹配片段;默认 Boss search 页"
443
- },
444
- allow_navigate: {
445
- type: "boolean",
446
- description: "找不到 search target 时,是否允许复用 Boss chat target 并导航到 search;默认 true"
447
- },
448
- reset_search: {
449
- type: "boolean",
450
- description: "执行前是否重置 Boss search frame;默认 true"
451
- },
452
- slow_live: {
453
- type: "boolean",
454
- description: "VPN/慢页面模式:放宽 live DOM 等待时间"
455
- },
456
- human_behavior: createHumanBehaviorInputSchema("可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on"),
457
- humanBehavior: createHumanBehaviorInputSchema("兼容字段;优先使用 human_behavior"),
458
- human_behavior_enabled: {
459
- type: "boolean",
460
- description: "兼容字段;true 等同启用 paced 默认配置,false 等同 baseline"
461
- },
462
- human_behavior_profile: {
463
- type: "string",
464
- enum: ["baseline", "paced", "paced_with_rests"],
465
- description: "可选实验 profile:baseline/paced/paced_with_rests"
466
- },
467
- safe_pacing: {
468
- type: "boolean",
469
- description: "兼容字段;true 启用 paced,false 关闭"
470
- },
471
- batch_rest_enabled: {
472
- type: "boolean",
473
- description: "兼容字段;true 启用 paced_with_rests 的候选人短休/批次休息"
474
- },
475
- max_candidates: createTargetCountSchema("本次最多处理候选人数;默认使用解析出的 target_count"),
476
- detail_limit: {
477
- type: "integer",
478
- minimum: 0,
479
- description: "打开详情/CV 的人数上限;默认跟随 max_candidates。detail_limit=0 属于调试路径,需要 debug_test_mode=true"
480
- },
481
- debug_test_mode: {
482
- type: "boolean",
483
- description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、detail_limit=0 等调试路径"
484
- },
485
- screening_mode: {
486
- type: "string",
487
- enum: ["llm", "deterministic"],
488
- description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true"
489
- },
490
- use_llm: {
491
- type: "boolean",
492
- description: "兼容字段;默认 true。use_llm=false 等同 deterministic,仅限 debug_test_mode=true"
493
- },
494
- llm_timeout_ms: {
495
- type: "integer",
496
- minimum: 1000,
497
- description: "可选,单个候选人的 LLM 调用超时"
498
- },
499
- llm_image_limit: {
500
- type: "integer",
501
- minimum: 1,
502
- description: "可选,传给 LLM 的图片简历截图页数上限"
503
- },
504
- llm_image_detail: {
505
- type: "string",
506
- description: "可选,图片输入 detail,默认 low"
507
- },
508
- delay_ms: {
509
- type: "integer",
510
- minimum: 0,
511
- description: "候选人之间的延迟;live pause/resume 测试可增大它"
512
- }
513
- },
514
- required: ["instruction"],
515
- additionalProperties: false
516
- };
517
- }
518
-
519
- export function createRecruitRunIdInputSchema() {
520
- return {
521
- type: "object",
522
- properties: {
523
- run_id: { type: "string" }
524
- },
525
- required: ["run_id"],
526
- additionalProperties: false
527
- };
528
- }
529
-
530
- export function validateRecruitPipelineArgs(args) {
531
- if (!args || typeof args !== "object") return "arguments must be an object";
532
- if (!args.instruction || typeof args.instruction !== "string") {
533
- return "instruction is required and must be a string";
534
- }
535
- return null;
536
- }
537
-
538
- function buildRequiredConfirmations(parsedResult) {
539
- const confirmations = [];
540
- if (parsedResult.needs_search_params_confirmation) confirmations.push("search_params");
541
- if (parsedResult.needs_keyword_confirmation) confirmations.push("keyword");
542
- if (parsedResult.needs_recent_viewed_filter_confirmation) confirmations.push("filter_recent_viewed");
543
- if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
544
- if (parsedResult.has_unresolved_missing_fields) confirmations.push("missing_fields_or_defaults");
545
- if ((parsedResult.suspicious_fields || []).length) confirmations.push("suspicious_fields");
546
- return confirmations;
547
- }
548
-
549
- function buildNeedInputResponse(parsedResult) {
550
- return {
551
- status: "NEED_INPUT",
552
- missing_fields: parsedResult.missing_fields,
553
- proposed_keyword: parsedResult.proposed_keyword,
554
- required_confirmations: buildRequiredConfirmations(parsedResult),
555
- search_params: parsedResult.searchParams,
556
- screen_params: parsedResult.screenParams,
557
- pending_questions: parsedResult.pending_questions,
558
- review: parsedResult.review,
559
- error: {
560
- code: "MISSING_REQUIRED_FIELDS",
561
- message: "缺少必要字段。请先补齐缺失项;若要按默认值继续,必须先明确确认默认值及其风险。",
562
- retryable: true
563
- }
564
- };
565
- }
566
-
567
- function buildNeedConfirmationResponse(parsedResult) {
568
- return {
569
- status: "NEED_CONFIRMATION",
570
- proposed_keyword: parsedResult.proposed_keyword,
571
- required_confirmations: buildRequiredConfirmations(parsedResult),
572
- search_params: {
573
- ...parsedResult.searchParams,
574
- keyword: parsedResult.proposed_keyword || parsedResult.searchParams.keyword
575
- },
576
- screen_params: parsedResult.screenParams,
577
- pending_questions: parsedResult.pending_questions,
578
- review: parsedResult.review
579
- };
580
- }
581
-
582
- function parseRecruitPipelineRequest(args = {}) {
583
- const parsed = parseRecruitInstruction({
584
- instruction: args.instruction,
585
- confirmation: args.confirmation,
586
- overrides: args.overrides
587
- });
588
- const criteriaOverride = normalizeText(args.overrides?.criteria || "");
589
- if (criteriaOverride) {
590
- parsed.screenParams = {
591
- ...parsed.screenParams,
592
- criteria: criteriaOverride
593
- };
594
- parsed.review = {
595
- ...parsed.review,
596
- current_screen_params: {
597
- ...(parsed.review?.current_screen_params || {}),
598
- criteria: criteriaOverride
599
- }
600
- };
601
- }
602
- return parsed;
603
- }
604
-
605
- function evaluateRecruitPipelineGate(parsed) {
606
- if (parsed.has_unresolved_missing_fields) return buildNeedInputResponse(parsed);
607
- if (
608
- parsed.needs_keyword_confirmation
609
- || parsed.needs_recent_viewed_filter_confirmation
610
- || parsed.needs_criteria_confirmation
611
- || parsed.needs_search_params_confirmation
612
- || (parsed.suspicious_fields || []).length > 0
613
- ) {
614
- return buildNeedConfirmationResponse(parsed);
615
- }
616
- return null;
617
- }
618
-
619
- function normalizeRunSnapshot(snapshot) {
620
- if (!snapshot) return null;
621
- const meta = getRecruitRunMeta(snapshot.runId);
622
- const artifacts = getRecruitRunArtifacts(snapshot.runId);
623
- const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
624
- const progress = normalizeLegacyProgress(snapshot.progress, summary);
625
- const legacyResult = (
626
- TERMINAL_STATUSES.has(snapshot.status)
627
- || snapshot.status === RUN_STATUS_PAUSED
628
- ) ? buildLegacyRunResult({ ...snapshot, progress }) : null;
629
- const oldContext = {
630
- workspace_root: meta.workspaceRoot || null,
631
- instruction: meta.args?.instruction || "",
632
- confirmation: clonePlain(meta.args?.confirmation || {}, {}),
633
- overrides: clonePlain(meta.args?.overrides || {}, {}),
634
- rounds: []
635
- };
636
- return {
637
- ...snapshot,
638
- progress,
639
- run_id: snapshot.runId,
640
- mode: meta.mode || RUN_MODE_ASYNC,
641
- state: snapshot.status,
642
- stage: snapshot.phase,
643
- started_at: snapshot.startedAt,
644
- updated_at: snapshot.updatedAt,
645
- completed_at: toIsoOrNull(snapshot.completedAt),
646
- heartbeat_at: snapshot.updatedAt,
647
- pid: globalThis.process?.pid || null,
648
- last_message: snapshot.error?.message || snapshot.phase || null,
649
- context: {
650
- ...(snapshot.context || {}),
651
- ...oldContext,
652
- shared_run_context: snapshot.context || {}
653
- },
654
- control: {
655
- pause_requested: snapshot.status === RUN_STATUS_PAUSED,
656
- pause_requested_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null,
657
- pause_requested_by: snapshot.status === RUN_STATUS_PAUSED ? "pause_recruit_pipeline_run" : null,
658
- cancel_requested: snapshot.status === RUN_STATUS_CANCELING
659
- },
660
- resume: {
661
- checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
662
- pause_control_path: artifacts?.run_state_path || null,
663
- output_csv: legacyResult?.output_csv || null,
664
- resume_count: meta.resumeCount || 0,
665
- last_resumed_at: meta.lastResumedAt || null,
666
- last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
667
- },
668
- result: legacyResult,
669
- artifacts
670
- };
671
- }
672
-
673
- function persistRecruitRunSnapshot(snapshot, {
674
- persistActiveCheckpoint = false
675
- } = {}) {
676
- const normalized = normalizeRunSnapshot(snapshot);
677
- if (!normalized?.run_id) return normalized;
678
- const artifacts = getRecruitRunArtifacts(normalized.run_id);
679
- if (!artifacts) return normalized;
680
- if (persistActiveCheckpoint) {
681
- persistRecruitCheckpointSnapshot(normalized);
682
- }
683
- const payload = {
684
- run_id: normalized.run_id,
685
- mode: normalized.mode,
686
- state: normalized.state,
687
- status: normalized.status,
688
- stage: normalized.stage,
689
- started_at: normalized.started_at,
690
- updated_at: normalized.updated_at,
691
- heartbeat_at: normalized.heartbeat_at,
692
- completed_at: normalized.completed_at,
693
- pid: normalized.pid,
694
- progress: normalized.progress,
695
- last_message: normalized.last_message,
696
- context: normalized.context,
697
- control: normalized.control,
698
- resume: normalized.resume,
699
- error: normalized.error,
700
- result: normalized.result,
701
- summary: normalized.summary,
702
- artifacts: normalized.artifacts
703
- };
704
- writeJsonAtomic(artifacts.run_state_path, payload);
705
- return normalized;
706
- }
707
-
708
- function persistRecruitLifecycleSnapshot(snapshot, event = {}) {
709
- return persistRecruitRunSnapshot(snapshot, {
710
- persistActiveCheckpoint: event?.type === "checkpoint"
711
- });
712
- }
713
-
714
- function getRecruitRunMeta(runId) {
715
- return recruitRunMeta.get(runId) || {};
716
- }
717
-
718
- function attachMethodEvidence(payload, runId) {
719
- const meta = getRecruitRunMeta(runId);
720
- return {
721
- ...payload,
722
- runtime_evaluate_used: false,
723
- method_summary: methodSummary(meta.methodLog || []),
724
- method_log: meta.methodLog || [],
725
- chrome: meta.chrome || null
726
- };
727
- }
728
-
729
- async function waitForRecruitSearchControlsOrLogin(client, {
730
- timeoutMs = 90000,
731
- intervalMs = 300
732
- } = {}) {
733
- const started = Date.now();
734
- let lastControls = null;
735
- while (Date.now() - started <= timeoutMs) {
736
- const loginDetection = await detectBossLoginState(client).catch(() => null);
737
- if (loginDetection?.requires_login) {
738
- return {
739
- ok: false,
740
- reason: "login_required",
741
- loginDetection
742
- };
743
- }
744
- const remainingMs = Math.max(1, timeoutMs - (Date.now() - started));
745
- lastControls = await waitForRecruitSearchControls(client, {
746
- timeoutMs: Math.min(remainingMs, 1500),
747
- intervalMs
748
- });
749
- if (lastControls.ok) return lastControls;
750
- await sleep(intervalMs);
751
- }
752
- return lastControls || { ok: false, reason: "timeout" };
753
- }
754
-
755
- async function connectRecruitChromeSession({
756
- host = DEFAULT_RECRUIT_HOST,
757
- port = DEFAULT_RECRUIT_PORT,
758
- targetUrlIncludes = RECRUIT_TARGET_URL,
759
- allowNavigate = true,
760
- slowLive = false
761
- } = {}) {
762
- const session = await connectToChromeTargetOrOpen({
763
- host,
764
- port,
765
- targetUrlIncludes,
766
- targetUrl: RECRUIT_TARGET_URL,
767
- allowNavigate,
768
- slowLive,
769
- fallbackTargetPredicate: (target) => (
770
- target?.type === "page"
771
- && String(target?.url || "").includes("zhipin.com")
772
- )
773
- });
774
-
775
- const { client, target } = session;
776
- await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
777
- if (typeof client?.Network?.setCacheDisabled === "function") {
778
- await client.Network.setCacheDisabled({ cacheDisabled: true });
779
- }
780
- await bringPageToFront(client);
781
-
782
- const targetUrl = String(target?.url || "");
783
- let navigation = {
784
- navigated: false,
785
- url: targetUrl
786
- };
787
- if (allowNavigate && !targetUrl.includes(targetUrlIncludes)) {
788
- await client.Page.navigate({ url: RECRUIT_TARGET_URL });
789
- const settleMs = slowLive ? 8000 : 3000;
790
- const waited = await waitForMainFrameUrl(
791
- client,
792
- (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
793
- { timeoutMs: settleMs, intervalMs: 500 }
794
- );
795
- navigation = {
796
- navigated: true,
797
- url: RECRUIT_TARGET_URL,
798
- settle_ms: settleMs,
799
- observed_url: waited.url || null,
800
- observed_url_ok: waited.ok
801
- };
802
- }
803
- let currentUrl = await getMainFrameUrl(client).catch(() => targetUrl);
804
- if (allowNavigate && !String(currentUrl || "").includes(RECRUIT_TARGET_URL) && !isBossLoginUrl(currentUrl)) {
805
- await client.Page.navigate({ url: RECRUIT_TARGET_URL });
806
- const settleMs = slowLive ? 8000 : 3000;
807
- const waited = await waitForMainFrameUrl(
808
- client,
809
- (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
810
- { timeoutMs: settleMs, intervalMs: 500 }
811
- );
812
- navigation = {
813
- navigated: true,
814
- url: RECRUIT_TARGET_URL,
815
- settle_ms: settleMs,
816
- observed_url: waited.url || null,
817
- observed_url_ok: waited.ok,
818
- reason: "observed_url_mismatch"
819
- };
820
- currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
821
- }
822
- const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
823
- requires_login: isBossLoginUrl(currentUrl),
824
- reason: "login_detection_failed",
825
- current_url: currentUrl
826
- }));
827
- if (loginDetection.requires_login) {
828
- await session.close?.();
829
- throw createBossLoginRequiredError({
830
- domain: "search",
831
- currentUrl: loginDetection.current_url || currentUrl,
832
- targetUrl: RECRUIT_TARGET_URL,
833
- loginDetection,
834
- chrome: session.chrome || null
835
- });
836
- }
837
- if (!String(currentUrl || "").includes(RECRUIT_TARGET_URL)) {
838
- await session.close?.();
839
- throw new Error(`Boss search page did not navigate to ${RECRUIT_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
840
- }
841
-
842
- const controls = await waitForRecruitSearchControlsOrLogin(client, {
843
- timeoutMs: slowLive ? 180000 : 90000,
844
- intervalMs: 300
845
- });
846
- if (controls.loginDetection?.requires_login) {
847
- await session.close?.();
848
- throw createBossLoginRequiredError({
849
- domain: "search",
850
- currentUrl: controls.loginDetection.current_url || currentUrl,
851
- targetUrl: RECRUIT_TARGET_URL,
852
- loginDetection: controls.loginDetection,
853
- chrome: session.chrome || null
854
- });
855
- }
856
- if (!controls.ok) {
857
- const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
858
- const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
859
- requires_login: isBossLoginUrl(latestUrl),
860
- reason: "login_detection_failed",
861
- current_url: latestUrl
862
- }));
863
- if (latestLoginDetection.requires_login) {
864
- await session.close?.();
865
- throw createBossLoginRequiredError({
866
- domain: "search",
867
- currentUrl: latestLoginDetection.current_url || latestUrl,
868
- targetUrl: RECRUIT_TARGET_URL,
869
- loginDetection: latestLoginDetection,
870
- chrome: session.chrome || null
871
- });
872
- }
873
- throw new Error("Boss recruit search page did not expose ready search controls");
874
- }
875
-
876
- return {
877
- ...session,
878
- navigation,
879
- controls
880
- };
881
- }
882
-
883
- function getRunOptions(args, parsed, session, configResolution = null) {
884
- const slowLive = args.slow_live === true;
885
- const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams.target_count || 10);
886
- const screeningMode = normalizeScreeningModeArg(args);
887
- const humanBehavior = resolveHumanBehaviorForRun(args, configResolution?.config || {});
888
- return {
889
- client: session.client,
890
- targetUrl: RECRUIT_TARGET_URL,
891
- criteria: parsed.screenParams.criteria,
892
- searchParams: parsed.searchParams,
893
- maxCandidates: targetCount,
894
- detailLimit: parseNonNegativeInteger(args.detail_limit, targetCount),
895
- closeDetail: true,
896
- delayMs: Math.max(0, parsePositiveInteger(args.delay_ms, 0)),
897
- cardTimeoutMs: slowLive ? 180000 : 90000,
898
- resetBeforeSearch: args.reset_search !== false,
899
- resetTimeoutMs: slowLive ? 300000 : 180000,
900
- cityOptionTimeoutMs: slowLive ? 60000 : 30000,
901
- maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
902
- screeningMode,
903
- llmConfig: screeningMode === "llm" && configResolution?.ok ? {
904
- ...configResolution.config
905
- } : null,
906
- llmTimeoutMs: parsePositiveInteger(
907
- args.llm_timeout_ms,
908
- parsePositiveInteger(configResolution?.config?.llmTimeoutMs || configResolution?.config?.timeoutMs, slowLive ? 180000 : 120000)
909
- ),
910
- llmImageLimit: parsePositiveInteger(
911
- args.llm_image_limit,
912
- parsePositiveInteger(configResolution?.config?.llmImageLimit || configResolution?.config?.imageLimit, 8)
913
- ),
914
- llmImageDetail: normalizeText(
915
- args.llm_image_detail || configResolution?.config?.llmImageDetail || configResolution?.config?.imageDetail
916
- ) || "low",
917
- imageOutputDir: resolveBossConfiguredOutputDir("", getRecruitRunsDir()),
918
- humanRestEnabled: humanBehavior.restEnabled,
919
- humanBehavior,
920
- name: "mcp-recruit-pipeline-run"
921
- };
922
- }
923
-
924
- async function closeRecruitRunSession(runId) {
925
- const meta = recruitRunMeta.get(runId);
926
- if (!meta || meta.closed) return;
927
- try {
928
- assertNoForbiddenCdpCalls(meta.methodLog || []);
929
- } finally {
930
- meta.closed = true;
931
- try {
932
- await meta.session?.close?.();
933
- } catch {
934
- // Nothing actionable for the caller once the run has settled.
935
- }
936
- }
937
- }
938
-
939
- async function waitForRecruitRunTerminal(runId) {
940
- while (true) {
941
- try {
942
- const snapshot = recruitRunService.getRecruitRun(runId);
943
- if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot;
944
- } catch {
945
- return null;
946
- }
947
- await sleep(1000);
948
- }
949
- }
950
-
951
- function trackRecruitRun(runId) {
952
- waitForRecruitRunTerminal(runId)
953
- .then((terminal) => {
954
- if (terminal) persistRecruitRunSnapshot(terminal);
955
- })
956
- .catch(() => null)
957
- .finally(() => {
958
- closeRecruitRunSession(runId).catch(() => {});
959
- });
960
- }
961
-
962
- async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" } = {}) {
963
- const parsed = parseRecruitPipelineRequest(args);
964
- const gate = evaluateRecruitPipelineGate(parsed);
965
- if (gate) return gate;
966
- const configResolution = resolveBossScreeningConfig(workspaceRoot);
967
- const screeningMode = normalizeScreeningModeArg(args);
968
- const debugTestOptions = collectRecruitDebugTestOptions(args);
969
- if (debugTestOptions.length && !isDebugTestMode(args)) {
970
- return {
971
- status: "FAILED",
972
- error: {
973
- code: "DEBUG_TEST_MODE_REQUIRED",
974
- message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
975
- retryable: false
976
- },
977
- debug_test_options: debugTestOptions
978
- };
979
- }
980
- if (screeningMode === "llm" && !configResolution.ok) {
981
- return {
982
- status: "FAILED",
983
- error: {
984
- code: "SCREEN_CONFIG_ERROR",
985
- message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
986
- retryable: true
987
- },
988
- config_path: configResolution.config_path || null,
989
- candidate_paths: configResolution.candidate_paths || []
990
- };
991
- }
992
- const host = normalizeText(args.host) || DEFAULT_RECRUIT_HOST;
993
- const port = parsePositiveInteger(
994
- args.port,
995
- configResolution.ok ? configResolution.config.debugPort : DEFAULT_RECRUIT_PORT
996
- );
997
-
998
- let session;
999
- try {
1000
- session = await recruitConnectorImpl({
1001
- host,
1002
- port,
1003
- targetUrlIncludes: normalizeText(args.target_url_includes) || RECRUIT_TARGET_URL,
1004
- allowNavigate: args.allow_navigate !== false,
1005
- slowLive: args.slow_live === true
1006
- });
1007
- } catch (error) {
1008
- const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1009
- return {
1010
- status: "FAILED",
1011
- error: {
1012
- code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_SEARCH_PAGE_NOT_READY",
1013
- message: error?.message || "Boss recruit search page is not ready",
1014
- requires_login: Boolean(error?.requires_login),
1015
- login_url: error?.login_url || null,
1016
- login_detection: error?.login_detection || null,
1017
- chrome: error?.chrome || null,
1018
- current_url: error?.current_url || null,
1019
- target_url: error?.target_url || RECRUIT_TARGET_URL,
1020
- retryable: true
1021
- },
1022
- chrome: error?.chrome || null
1023
- };
1024
- }
1025
-
1026
- let started;
1027
- try {
1028
- started = recruitRunService.startRecruitRun(getRunOptions(args, parsed, session, configResolution));
1029
- } catch (error) {
1030
- await session.close?.();
1031
- return {
1032
- status: "FAILED",
1033
- error: {
1034
- code: "RECRUIT_RUN_START_FAILED",
1035
- message: error?.message || "Failed to start recruit run",
1036
- retryable: true
1037
- }
1038
- };
1039
- }
1040
-
1041
- recruitRunMeta.set(started.runId, {
1042
- session,
1043
- methodLog: session.methodLog || [],
1044
- mode: normalizeExecutionMode(args.execution_mode),
1045
- workspaceRoot: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1046
- args: clonePlain(args, {}),
1047
- chrome: {
1048
- host,
1049
- port,
1050
- target_url: session.target?.url || RECRUIT_TARGET_URL,
1051
- target_id: session.target?.id || null,
1052
- auto_launch: session.chrome || null
1053
- },
1054
- parsed
1055
- });
1056
- trackRecruitRun(started.runId);
1057
- const persistedStarted = persistRecruitRunSnapshot(started);
1058
-
1059
- return {
1060
- status: "ACCEPTED",
1061
- run_id: persistedStarted.run_id,
1062
- state: persistedStarted.state,
1063
- run: persistedStarted,
1064
- poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1065
- review: parsed.review,
1066
- message: "Recruit pipeline run started through shared CDP-only recruit service."
1067
- };
1068
- }
1069
-
1070
- export async function runRecruitPipelineTool({ workspaceRoot = "", args = {} } = {}) {
1071
- const mode = normalizeExecutionMode(args.execution_mode);
1072
- const started = await startRecruitPipelineRunInternal({
1073
- ...args,
1074
- execution_mode: mode
1075
- }, { workspaceRoot });
1076
- if (started.status !== "ACCEPTED") return started;
1077
- if (mode !== RUN_MODE_SYNC) return attachMethodEvidence(started, started.run_id);
1078
-
1079
- const final = await waitForRecruitRunTerminal(started.run_id);
1080
- await closeRecruitRunSession(started.run_id);
1081
- const normalizedFinal = persistRecruitRunSnapshot(final);
1082
- const legacyResult = normalizedFinal?.result || buildLegacyRunResult(final);
1083
- const finalStatus = final?.status === RUN_STATUS_COMPLETED
1084
- ? "COMPLETED"
1085
- : final?.status === RUN_STATUS_CANCELED
1086
- ? "CANCELED"
1087
- : "FAILED";
1088
- return attachMethodEvidence({
1089
- status: finalStatus,
1090
- run_id: started.run_id,
1091
- run: normalizedFinal,
1092
- result: legacyResult,
1093
- partial_result: finalStatus === "CANCELED" ? legacyResult : undefined,
1094
- diagnostics: finalStatus === "FAILED"
1095
- ? {
1096
- run_id: started.run_id,
1097
- last_stage: normalizedFinal?.stage || "recruit:unknown"
1098
- }
1099
- : undefined,
1100
- summary: final?.summary || null,
1101
- error: finalStatus === "CANCELED"
1102
- ? {
1103
- code: "PIPELINE_CANCELED",
1104
- message: "流水线已取消。",
1105
- retryable: true
1106
- }
1107
- : final?.error || null
1108
- }, started.run_id);
1109
- }
1110
-
1111
- export async function startRecruitPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1112
- const started = await startRecruitPipelineRunInternal({
1113
- ...args,
1114
- execution_mode: RUN_MODE_ASYNC
1115
- }, { workspaceRoot });
1116
- if (started.status !== "ACCEPTED") return started;
1117
- return attachMethodEvidence(started, started.run_id);
1118
- }
1119
-
1120
- export function getRecruitPipelineRunTool({ args = {} } = {}) {
1121
- const runId = normalizeText(args.run_id);
1122
- if (!runId) {
1123
- return {
1124
- status: "FAILED",
1125
- error: {
1126
- code: "INVALID_RUN_ID",
1127
- message: "run_id is required",
1128
- retryable: false
1129
- }
1130
- };
1131
- }
1132
- try {
1133
- const run = recruitRunService.getRecruitRun(runId);
1134
- const normalizedRun = persistRecruitRunSnapshot(run);
1135
- return attachMethodEvidence({
1136
- status: "RUN_STATUS",
1137
- run: normalizedRun
1138
- }, runId);
1139
- } catch {
1140
- const persisted = readRecruitRunState(runId);
1141
- if (persisted) {
1142
- return {
1143
- status: "RUN_STATUS",
1144
- run: persisted,
1145
- persistence: {
1146
- source: "disk",
1147
- active_control_available: false
1148
- },
1149
- runtime_evaluate_used: false,
1150
- method_summary: {},
1151
- method_log: [],
1152
- chrome: null
1153
- };
1154
- }
1155
- return {
1156
- status: "FAILED",
1157
- error: {
1158
- code: "RUN_NOT_FOUND",
1159
- message: `No recruit run found for run_id=${runId}`,
1160
- retryable: false
1161
- }
1162
- };
1163
- }
1164
- }
1165
-
1166
- export function pauseRecruitPipelineRunTool({ args = {} } = {}) {
1167
- const runId = normalizeText(args.run_id);
1168
- try {
1169
- const before = recruitRunService.getRecruitRun(runId);
1170
- if (TERMINAL_STATUSES.has(before.status)) {
1171
- const normalizedBefore = persistRecruitRunSnapshot(before);
1172
- return attachMethodEvidence({
1173
- status: "PAUSE_IGNORED",
1174
- run: normalizedBefore,
1175
- message: "目标任务已结束,无需暂停。"
1176
- }, runId);
1177
- }
1178
- if (before.status === RUN_STATUS_PAUSED) {
1179
- const normalizedBefore = persistRecruitRunSnapshot(before);
1180
- return attachMethodEvidence({
1181
- status: "PAUSE_IGNORED",
1182
- run: normalizedBefore,
1183
- message: "目标任务已经处于 paused 状态。"
1184
- }, runId);
1185
- }
1186
- const run = recruitRunService.pauseRecruitRun(runId);
1187
- const normalizedRun = persistRecruitRunSnapshot(run);
1188
- return attachMethodEvidence({
1189
- status: "PAUSE_REQUESTED",
1190
- run: normalizedRun,
1191
- message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1192
- }, runId);
1193
- } catch {
1194
- const persisted = readRecruitRunState(runId);
1195
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1196
- return {
1197
- status: "PAUSE_IGNORED",
1198
- run: persisted,
1199
- message: "目标任务已结束,无需暂停。",
1200
- runtime_evaluate_used: false,
1201
- method_summary: {},
1202
- method_log: [],
1203
- chrome: null
1204
- };
1205
- }
1206
- return getRecruitPipelineRunTool({ args });
1207
- }
1208
- }
1209
-
1210
- export function resumeRecruitPipelineRunTool({ args = {} } = {}) {
1211
- const runId = normalizeText(args.run_id);
1212
- try {
1213
- const before = recruitRunService.getRecruitRun(runId);
1214
- if (TERMINAL_STATUSES.has(before.status)) {
1215
- const normalizedBefore = persistRecruitRunSnapshot(before);
1216
- return attachMethodEvidence({
1217
- status: "FAILED",
1218
- error: {
1219
- code: "RUN_ALREADY_TERMINATED",
1220
- message: "目标任务已结束,无法继续。",
1221
- retryable: false
1222
- },
1223
- run: normalizedBefore
1224
- }, runId);
1225
- }
1226
- if (before.status !== RUN_STATUS_PAUSED) {
1227
- const normalizedBefore = persistRecruitRunSnapshot(before);
1228
- return attachMethodEvidence({
1229
- status: "FAILED",
1230
- error: {
1231
- code: "RUN_NOT_PAUSED",
1232
- message: "仅 paused 状态的 run 才能继续。",
1233
- retryable: true
1234
- },
1235
- run: normalizedBefore
1236
- }, runId);
1237
- }
1238
- const run = recruitRunService.resumeRecruitRun(runId);
1239
- const meta = getRecruitRunMeta(runId);
1240
- if (meta) {
1241
- meta.resumeCount = (meta.resumeCount || 0) + 1;
1242
- meta.lastResumedAt = new Date().toISOString();
1243
- }
1244
- const normalizedRun = persistRecruitRunSnapshot(run);
1245
- return attachMethodEvidence({
1246
- status: "RESUME_REQUESTED",
1247
- run: normalizedRun,
1248
- poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1249
- message: "已恢复 Boss 招聘流水线,请使用 get_recruit_pipeline_run 按需轮询。"
1250
- }, runId);
1251
- } catch {
1252
- const persisted = readRecruitRunState(runId);
1253
- if (persisted) {
1254
- return {
1255
- status: TERMINAL_STATUSES.has(persisted.state) ? "FAILED" : "FAILED",
1256
- error: {
1257
- code: TERMINAL_STATUSES.has(persisted.state) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1258
- message: TERMINAL_STATUSES.has(persisted.state)
1259
- ? "目标任务已结束,无法继续。"
1260
- : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
1261
- retryable: !TERMINAL_STATUSES.has(persisted.state)
1262
- },
1263
- run: persisted,
1264
- persistence: {
1265
- source: "disk",
1266
- active_control_available: false
1267
- },
1268
- runtime_evaluate_used: false,
1269
- method_summary: {},
1270
- method_log: [],
1271
- chrome: null
1272
- };
1273
- }
1274
- return getRecruitPipelineRunTool({ args });
1275
- }
1276
- }
1277
-
1278
- export function cancelRecruitPipelineRunTool({ args = {} } = {}) {
1279
- const runId = normalizeText(args.run_id);
1280
- try {
1281
- const before = recruitRunService.getRecruitRun(runId);
1282
- if (TERMINAL_STATUSES.has(before.status)) {
1283
- const normalizedBefore = persistRecruitRunSnapshot(before);
1284
- return attachMethodEvidence({
1285
- status: "CANCEL_IGNORED",
1286
- run: normalizedBefore,
1287
- message: "目标任务已结束,无需取消。"
1288
- }, runId);
1289
- }
1290
- const run = recruitRunService.cancelRecruitRun(runId);
1291
- const normalizedRun = persistRecruitRunSnapshot(run);
1292
- return attachMethodEvidence({
1293
- status: "CANCEL_REQUESTED",
1294
- run: normalizedRun,
1295
- message: "已收到取消请求,将在当前候选人处理完成后安全停止。"
1296
- }, runId);
1297
- } catch {
1298
- const persisted = readRecruitRunState(runId);
1299
- if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1300
- return {
1301
- status: "CANCEL_IGNORED",
1302
- run: persisted,
1303
- message: "目标任务已结束,无需取消。",
1304
- runtime_evaluate_used: false,
1305
- method_summary: {},
1306
- method_log: [],
1307
- chrome: null
1308
- };
1309
- }
1310
- return getRecruitPipelineRunTool({ args });
1311
- }
1312
- }
1313
-
1314
- export function __setRecruitMcpConnectorForTests(nextConnector) {
1315
- recruitConnectorImpl = typeof nextConnector === "function" ? nextConnector : connectRecruitChromeSession;
1316
- }
1317
-
1318
- export function __setRecruitMcpWorkflowForTests(nextWorkflow) {
1319
- recruitWorkflowImpl = typeof nextWorkflow === "function" ? nextWorkflow : runRecruitWorkflow;
1320
- recruitRunService = createRecruitRunService({
1321
- idPrefix: "mcp_recruit",
1322
- workflow: (...args) => recruitWorkflowImpl(...args),
1323
- onSnapshot: persistRecruitLifecycleSnapshot
1324
- });
1325
- }
1326
-
1327
- export function __resetRecruitMcpStateForTests() {
1328
- for (const meta of recruitRunMeta.values()) {
1329
- try {
1330
- meta.session?.close?.();
1331
- } catch {
1332
- // Best-effort test cleanup.
1333
- }
1334
- }
1335
- recruitRunMeta.clear();
1336
- __setRecruitMcpConnectorForTests(null);
1337
- __setRecruitMcpWorkflowForTests(null);
1338
- }
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import {
5
+ assertNoForbiddenCdpCalls,
6
+ bringPageToFront,
7
+ connectToChromeTargetOrOpen,
8
+ createBossLoginRequiredError,
9
+ detectBossLoginState,
10
+ enableDomains,
11
+ getMainFrameUrl,
12
+ isBossLoginUrl,
13
+ waitForMainFrameUrl,
14
+ sleep
15
+ } from "./core/browser/index.js";
16
+ import {
17
+ RUN_STATUS_CANCELING,
18
+ RUN_STATUS_CANCELED,
19
+ RUN_STATUS_COMPLETED,
20
+ RUN_STATUS_FAILED,
21
+ RUN_STATUS_PAUSED
22
+ } from "./core/run/index.js";
23
+ import {
24
+ buildLegacyScreenInputRows,
25
+ cloneReportInput,
26
+ writeLegacyScreenCsv
27
+ } from "./core/reporting/legacy-csv.js";
28
+ import {
29
+ createRecruitRunService,
30
+ parseRecruitInstruction,
31
+ RECRUIT_TARGET_URL,
32
+ runRecruitWorkflow,
33
+ waitForRecruitSearchControls
34
+ } from "./domains/recruit/index.js";
35
+ import {
36
+ resolveBossConfiguredOutputDir,
37
+ resolveHumanBehaviorForRun,
38
+ resolveBossScreeningConfig
39
+ } from "./chat-runtime-config.js";
40
+ import { DEFAULT_MAX_IMAGE_PAGES } from "./core/cv-acquisition/index.js";
41
+
42
+ const RUN_MODE_ASYNC = "async";
43
+ const RUN_MODE_SYNC = "sync";
44
+ const DEFAULT_RECRUIT_POLL_AFTER_SEC = 10;
45
+ const DEFAULT_RECRUIT_HOST = "127.0.0.1";
46
+ const DEFAULT_RECRUIT_PORT = 9222;
47
+ const TARGET_COUNT_SEMANTICS = "target_count means candidates that pass screening; scan continues until that many candidates pass or the list ends";
48
+ const DEFAULT_RECRUIT_HOME_DIR = ".boss-recruit-mcp";
49
+
50
+ const TERMINAL_STATUSES = new Set([
51
+ RUN_STATUS_COMPLETED,
52
+ RUN_STATUS_FAILED,
53
+ RUN_STATUS_CANCELED
54
+ ]);
55
+
56
+ let recruitWorkflowImpl = runRecruitWorkflow;
57
+ let recruitConnectorImpl = connectRecruitChromeSession;
58
+ let recruitRunService = createRecruitRunService({
59
+ idPrefix: "mcp_recruit",
60
+ workflow: (...args) => recruitWorkflowImpl(...args),
61
+ onSnapshot: persistRecruitLifecycleSnapshot
62
+ });
63
+ const recruitRunMeta = new Map();
64
+
65
+ function normalizeText(value) {
66
+ return String(value || "").replace(/\s+/g, " ").trim();
67
+ }
68
+
69
+ function parsePositiveInteger(raw, fallback) {
70
+ const parsed = Number.parseInt(String(raw || ""), 10);
71
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
72
+ }
73
+
74
+ function parseNonNegativeInteger(raw, fallback) {
75
+ const parsed = Number.parseInt(String(raw ?? ""), 10);
76
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
77
+ }
78
+
79
+ function isDebugTestMode(args = {}) {
80
+ return args.debug_test_mode === true || args.allow_debug_test_mode === true;
81
+ }
82
+
83
+ function normalizeScreeningModeArg(args = {}) {
84
+ const raw = normalizeText(args.screening_mode || args.screeningMode || "");
85
+ if (args.use_llm === false) return "deterministic";
86
+ return ["deterministic", "local", "local_scorer"].includes(raw.toLowerCase())
87
+ ? "deterministic"
88
+ : "llm";
89
+ }
90
+
91
+ function collectRecruitDebugTestOptions(args = {}) {
92
+ const reasons = [];
93
+ if (normalizeScreeningModeArg(args) === "deterministic") reasons.push("deterministic_screening");
94
+ if (parseNonNegativeInteger(args.detail_limit, null) === 0) reasons.push("detail_limit=0");
95
+ if (args.dry_run_post_action === true) reasons.push("dry_run_post_action");
96
+ if (args.execute_post_action === false) reasons.push("execute_post_action=false");
97
+ return reasons;
98
+ }
99
+
100
+ function methodSummary(methodLog = []) {
101
+ const summary = {};
102
+ for (const entry of methodLog || []) {
103
+ summary[entry.method] = (summary[entry.method] || 0) + 1;
104
+ }
105
+ return summary;
106
+ }
107
+
108
+ function normalizeExecutionMode(value) {
109
+ return normalizeText(value).toLowerCase() === RUN_MODE_SYNC ? RUN_MODE_SYNC : RUN_MODE_ASYNC;
110
+ }
111
+
112
+ function clonePlain(value, fallback = null) {
113
+ try {
114
+ return value === undefined ? fallback : JSON.parse(JSON.stringify(value));
115
+ } catch {
116
+ return fallback;
117
+ }
118
+ }
119
+
120
+ function normalizeRunId(runId) {
121
+ const normalized = normalizeText(runId);
122
+ if (!normalized || normalized.includes("/") || normalized.includes("\\")) return "";
123
+ return normalized;
124
+ }
125
+
126
+ function getRecruitStateHome() {
127
+ const fromEnv = normalizeText(globalThis.process?.env?.BOSS_RECRUIT_HOME || "");
128
+ return fromEnv ? path.resolve(fromEnv) : path.join(os.homedir(), DEFAULT_RECRUIT_HOME_DIR);
129
+ }
130
+
131
+ function getRecruitRunsDir() {
132
+ return path.join(getRecruitStateHome(), "runs");
133
+ }
134
+
135
+ function getRecruitRunArtifacts(runId) {
136
+ const normalized = normalizeRunId(runId);
137
+ if (!normalized) return null;
138
+ const runsDir = getRecruitRunsDir();
139
+ const outputDir = resolveBossConfiguredOutputDir("", runsDir);
140
+ return {
141
+ runs_dir: runsDir,
142
+ output_dir: outputDir,
143
+ run_state_path: path.join(runsDir, `${normalized}.json`),
144
+ checkpoint_path: path.join(runsDir, `${normalized}.checkpoint.json`),
145
+ output_csv: path.join(outputDir, `${normalized}.results.csv`),
146
+ report_json: path.join(outputDir, `${normalized}.report.json`)
147
+ };
148
+ }
149
+
150
+ function ensureDirectory(dirPath) {
151
+ fs.mkdirSync(dirPath, { recursive: true });
152
+ }
153
+
154
+ function writeJsonAtomic(filePath, payload) {
155
+ ensureDirectory(path.dirname(filePath));
156
+ const tempPath = `${filePath}.tmp`;
157
+ fs.writeFileSync(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
158
+ fs.renameSync(tempPath, filePath);
159
+ }
160
+
161
+ function readJsonFile(filePath) {
162
+ try {
163
+ if (!fs.existsSync(filePath)) return null;
164
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf8"));
165
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ function selectedRecruitJobForCsv(meta = {}) {
172
+ const keyword = normalizeText(
173
+ meta.parsed?.proposed_keyword
174
+ || meta.parsed?.searchParams?.keyword
175
+ || meta.args?.confirmation?.keyword_value
176
+ || meta.args?.overrides?.keyword
177
+ || ""
178
+ );
179
+ return {
180
+ value: keyword,
181
+ title: keyword,
182
+ label: keyword
183
+ };
184
+ }
185
+
186
+ function buildRecruitCsvInputRows(snapshot = {}, meta = {}) {
187
+ const searchParams = meta.parsed?.searchParams || snapshot.summary?.search_params || {};
188
+ const screenParams = meta.parsed?.screenParams || {};
189
+ return buildLegacyScreenInputRows({
190
+ instruction: meta.args?.instruction || "",
191
+ selectedPage: "search",
192
+ selectedJob: selectedRecruitJobForCsv(meta),
193
+ userSearchParams: cloneReportInput(searchParams, {}),
194
+ effectiveSearchParams: cloneReportInput(searchParams, {}),
195
+ screenParams: {
196
+ criteria: screenParams.criteria || "",
197
+ target_count: screenParams.target_count || snapshot.progress?.target_count || snapshot.context?.max_candidates || "",
198
+ post_action: screenParams.post_action || "none",
199
+ max_greet_count: screenParams.max_greet_count ?? ""
200
+ },
201
+ followUp: meta.args?.follow_up || meta.args?.overrides?.follow_up || null
202
+ });
203
+ }
204
+
205
+ function writeRecruitLegacyCsvAtomic(filePath, rows = [], snapshot = {}, meta = {}) {
206
+ writeLegacyScreenCsv(filePath, {
207
+ inputRows: buildRecruitCsvInputRows(snapshot, meta),
208
+ results: rows
209
+ });
210
+ }
211
+
212
+ function readRecruitRunState(runId) {
213
+ const artifacts = getRecruitRunArtifacts(runId);
214
+ if (!artifacts) return null;
215
+ return readJsonFile(artifacts.run_state_path);
216
+ }
217
+
218
+ function ensureRecruitRunArtifacts(snapshot) {
219
+ const artifacts = getRecruitRunArtifacts(snapshot?.runId || snapshot?.run_id);
220
+ if (!artifacts) return null;
221
+
222
+ const meta = getRecruitRunMeta(snapshot?.runId || snapshot?.run_id);
223
+ const checkpoint = snapshot?.checkpoint && typeof snapshot.checkpoint === "object"
224
+ ? snapshot.checkpoint
225
+ : {};
226
+ writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
227
+ if (meta) meta.checkpointPath = artifacts.checkpoint_path;
228
+
229
+ const summary = snapshot?.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
230
+ const checkpointResults = Array.isArray(checkpoint.results) ? checkpoint.results : [];
231
+ const artifactSummary = summary || (checkpointResults.length ? {
232
+ domain: "recruit",
233
+ partial: true,
234
+ partial_reason: snapshot?.status || snapshot?.state || "non_terminal",
235
+ results: checkpointResults
236
+ } : null);
237
+ if (artifactSummary) {
238
+ const rows = Array.isArray(artifactSummary.results) ? artifactSummary.results : [];
239
+ writeRecruitLegacyCsvAtomic(artifacts.output_csv, rows, snapshot, meta);
240
+ writeJsonAtomic(artifacts.report_json, {
241
+ run_id: snapshot.runId || snapshot.run_id,
242
+ status: snapshot.status || snapshot.state,
243
+ phase: snapshot.phase || snapshot.stage,
244
+ progress: snapshot.progress || {},
245
+ context: snapshot.context || {},
246
+ checkpoint,
247
+ summary: artifactSummary,
248
+ generated_at: new Date().toISOString()
249
+ });
250
+ if (meta) {
251
+ meta.outputCsvPath = artifacts.output_csv;
252
+ meta.reportJsonPath = artifacts.report_json;
253
+ }
254
+ }
255
+
256
+ return artifacts;
257
+ }
258
+
259
+ function persistRecruitCheckpointSnapshot(normalized) {
260
+ const artifacts = getRecruitRunArtifacts(normalized?.run_id || normalized?.runId);
261
+ if (!artifacts) return;
262
+ const checkpoint = normalized?.checkpoint && typeof normalized.checkpoint === "object"
263
+ ? normalized.checkpoint
264
+ : {};
265
+ writeJsonAtomic(artifacts.checkpoint_path, checkpoint);
266
+ const meta = getRecruitRunMeta(normalized?.run_id || normalized?.runId);
267
+ if (meta) meta.checkpointPath = artifacts.checkpoint_path;
268
+ }
269
+
270
+ function toIsoOrNull(value) {
271
+ const normalized = normalizeText(value);
272
+ return normalized || null;
273
+ }
274
+
275
+ function secondsBetween(startedAt, endedAt) {
276
+ const startMs = Date.parse(startedAt || "");
277
+ const endMs = Date.parse(endedAt || "") || Date.now();
278
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs < startMs) return null;
279
+ return Math.max(1, Math.round((endMs - startMs) / 1000));
280
+ }
281
+
282
+ function normalizeLegacyProgress(progress = {}, summary = null) {
283
+ const processed = Number.isInteger(progress.processed)
284
+ ? progress.processed
285
+ : Number.isInteger(summary?.processed)
286
+ ? summary.processed
287
+ : 0;
288
+ const passed = Number.isInteger(progress.passed)
289
+ ? progress.passed
290
+ : Number.isInteger(summary?.passed)
291
+ ? summary.passed
292
+ : 0;
293
+ return {
294
+ ...progress,
295
+ processed,
296
+ passed,
297
+ skipped: Number.isInteger(progress.skipped) ? progress.skipped : Math.max(processed - passed, 0),
298
+ greet_count: Number.isInteger(progress.greet_count) ? progress.greet_count : 0
299
+ };
300
+ }
301
+
302
+ function completionReason(status) {
303
+ if (status === RUN_STATUS_COMPLETED) return "completed";
304
+ if (status === RUN_STATUS_CANCELED) return "canceled_by_user";
305
+ if (status === RUN_STATUS_FAILED) return "failed";
306
+ if (status === RUN_STATUS_PAUSED) return "paused";
307
+ return null;
308
+ }
309
+
310
+ function buildLegacyRunResult(snapshot) {
311
+ if (!snapshot) return null;
312
+ const artifacts = ensureRecruitRunArtifacts(snapshot);
313
+ const meta = getRecruitRunMeta(snapshot.runId);
314
+ const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
315
+ const checkpoint = snapshot.checkpoint && typeof snapshot.checkpoint === "object" ? snapshot.checkpoint : {};
316
+ const resultRows = Array.isArray(summary?.results)
317
+ ? summary.results
318
+ : Array.isArray(checkpoint.results)
319
+ ? checkpoint.results
320
+ : [];
321
+ const progress = normalizeLegacyProgress(snapshot.progress, summary);
322
+ const targetCount = Number.isInteger(progress.target_count)
323
+ ? progress.target_count
324
+ : Number.isInteger(snapshot.context?.max_candidates)
325
+ ? snapshot.context.max_candidates
326
+ : null;
327
+ return {
328
+ target_count: targetCount,
329
+ processed_count: progress.processed,
330
+ passed_count: progress.passed,
331
+ screened_count: Number.isInteger(progress.screened)
332
+ ? progress.screened
333
+ : Number.isInteger(summary?.screened)
334
+ ? summary.screened
335
+ : progress.processed,
336
+ detail_opened: Number.isInteger(progress.detail_opened)
337
+ ? progress.detail_opened
338
+ : Number.isInteger(summary?.detail_opened)
339
+ ? summary.detail_opened
340
+ : 0,
341
+ duration_sec: secondsBetween(snapshot.startedAt, snapshot.completedAt || snapshot.updatedAt),
342
+ output_csv: summary?.output_csv || meta.outputCsvPath || artifacts?.output_csv || null,
343
+ report_json: summary?.report_json || meta.reportJsonPath || artifacts?.report_json || null,
344
+ round_count: 1,
345
+ current_round_index: 1,
346
+ checkpoint_path: snapshot.checkpoint?.checkpoint_path
347
+ || snapshot.checkpoint?.path
348
+ || meta.checkpointPath
349
+ || artifacts?.checkpoint_path
350
+ || null,
351
+ completion_reason: completionReason(snapshot.status),
352
+ target_count_semantics: TARGET_COUNT_SEMANTICS,
353
+ run_id: snapshot.runId,
354
+ results: resultRows
355
+ };
356
+ }
357
+
358
+ function createTargetCountSchema(description) {
359
+ return {
360
+ oneOf: [
361
+ { type: "integer", minimum: 1 },
362
+ { type: "string", pattern: "^[1-9][0-9]*$" }
363
+ ],
364
+ description
365
+ };
366
+ }
367
+
368
+ function createHumanBehaviorInputSchema(description = "可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on") {
369
+ return {
370
+ type: "object",
371
+ properties: {
372
+ enabled: { type: "boolean" },
373
+ profile: {
374
+ type: "string",
375
+ enum: ["baseline", "paced", "paced_with_rests"]
376
+ },
377
+ clickMovement: { type: "boolean" },
378
+ textEntry: { type: "boolean" },
379
+ listScrollJitter: { type: "boolean" },
380
+ shortRest: { type: "boolean" },
381
+ batchRest: { type: "boolean" },
382
+ actionCooldown: { type: "boolean" }
383
+ },
384
+ additionalProperties: false,
385
+ description
386
+ };
387
+ }
388
+
389
+ export function createRecruitPipelineInputSchema() {
390
+ return {
391
+ type: "object",
392
+ properties: {
393
+ instruction: {
394
+ type: "string",
395
+ description: "用户自然语言招聘指令"
396
+ },
397
+ execution_mode: {
398
+ type: "string",
399
+ enum: [RUN_MODE_ASYNC, RUN_MODE_SYNC],
400
+ description: "执行模式;默认 async。"
401
+ },
402
+ confirmation: {
403
+ type: "object",
404
+ properties: {
405
+ keyword_confirmed: { type: "boolean" },
406
+ keyword_value: { type: "string" },
407
+ search_params_confirmed: { type: "boolean" },
408
+ criteria_confirmed: { type: "boolean" },
409
+ use_default_for_missing: { type: "boolean" }
410
+ },
411
+ additionalProperties: false
412
+ },
413
+ overrides: {
414
+ type: "object",
415
+ properties: {
416
+ city: { type: "string" },
417
+ degree: { type: "string" },
418
+ filter_recent_viewed: { type: "boolean" },
419
+ schools: {
420
+ anyOf: [
421
+ { type: "array", items: { type: "string" } },
422
+ { type: "string" }
423
+ ]
424
+ },
425
+ keyword: { type: "string" },
426
+ target_count: { type: "integer", minimum: 1 },
427
+ criteria: { type: "string" }
428
+ },
429
+ additionalProperties: false
430
+ },
431
+ host: {
432
+ type: "string",
433
+ description: "可选,Chrome 调试 host;默认 127.0.0.1"
434
+ },
435
+ port: {
436
+ type: "integer",
437
+ minimum: 1,
438
+ description: "可选,Chrome 调试端口;默认 9222"
439
+ },
440
+ target_url_includes: {
441
+ type: "string",
442
+ description: "可选,Chrome target URL 匹配片段;默认 Boss search 页"
443
+ },
444
+ allow_navigate: {
445
+ type: "boolean",
446
+ description: "找不到 search target 时,是否允许复用 Boss chat target 并导航到 search;默认 true"
447
+ },
448
+ reset_search: {
449
+ type: "boolean",
450
+ description: "执行前是否重置 Boss search frame;默认 true"
451
+ },
452
+ slow_live: {
453
+ type: "boolean",
454
+ description: "VPN/慢页面模式:放宽 live DOM 等待时间"
455
+ },
456
+ human_behavior: createHumanBehaviorInputSchema("可选,search/recruit 可靠性实验用节奏配置;默认 paced_with_rests/on"),
457
+ humanBehavior: createHumanBehaviorInputSchema("兼容字段;优先使用 human_behavior"),
458
+ human_behavior_enabled: {
459
+ type: "boolean",
460
+ description: "兼容字段;true 等同启用 paced 默认配置,false 等同 baseline"
461
+ },
462
+ human_behavior_profile: {
463
+ type: "string",
464
+ enum: ["baseline", "paced", "paced_with_rests"],
465
+ description: "可选实验 profile:baseline/paced/paced_with_rests"
466
+ },
467
+ safe_pacing: {
468
+ type: "boolean",
469
+ description: "兼容字段;true 启用 paced,false 关闭"
470
+ },
471
+ batch_rest_enabled: {
472
+ type: "boolean",
473
+ description: "兼容字段;true 启用 paced_with_rests 的候选人短休/批次休息"
474
+ },
475
+ max_candidates: createTargetCountSchema("本次最多处理候选人数;默认使用解析出的 target_count"),
476
+ detail_limit: {
477
+ type: "integer",
478
+ minimum: 0,
479
+ description: "打开详情/CV 的人数上限;默认跟随 max_candidates。detail_limit=0 属于调试路径,需要 debug_test_mode=true"
480
+ },
481
+ debug_test_mode: {
482
+ type: "boolean",
483
+ description: "高级测试开关;默认 false。只有显式为 true 时才允许 deterministic/local scorer、detail_limit=0 等调试路径"
484
+ },
485
+ screening_mode: {
486
+ type: "string",
487
+ enum: ["llm", "deterministic"],
488
+ description: "筛选引擎;默认 llm。deterministic 仅限 debug_test_mode=true"
489
+ },
490
+ use_llm: {
491
+ type: "boolean",
492
+ description: "兼容字段;默认 true。use_llm=false 等同 deterministic,仅限 debug_test_mode=true"
493
+ },
494
+ llm_timeout_ms: {
495
+ type: "integer",
496
+ minimum: 1000,
497
+ description: "可选,单个候选人的 LLM 调用超时"
498
+ },
499
+ llm_image_limit: {
500
+ type: "integer",
501
+ minimum: 1,
502
+ description: "可选,传给 LLM 的图片简历截图页数上限"
503
+ },
504
+ llm_image_detail: {
505
+ type: "string",
506
+ description: "可选,图片输入 detail,默认 low"
507
+ },
508
+ delay_ms: {
509
+ type: "integer",
510
+ minimum: 0,
511
+ description: "候选人之间的延迟;live pause/resume 测试可增大它"
512
+ }
513
+ },
514
+ required: ["instruction"],
515
+ additionalProperties: false
516
+ };
517
+ }
518
+
519
+ export function createRecruitRunIdInputSchema() {
520
+ return {
521
+ type: "object",
522
+ properties: {
523
+ run_id: { type: "string" }
524
+ },
525
+ required: ["run_id"],
526
+ additionalProperties: false
527
+ };
528
+ }
529
+
530
+ export function validateRecruitPipelineArgs(args) {
531
+ if (!args || typeof args !== "object") return "arguments must be an object";
532
+ if (!args.instruction || typeof args.instruction !== "string") {
533
+ return "instruction is required and must be a string";
534
+ }
535
+ return null;
536
+ }
537
+
538
+ function buildRequiredConfirmations(parsedResult) {
539
+ const confirmations = [];
540
+ if (parsedResult.needs_search_params_confirmation) confirmations.push("search_params");
541
+ if (parsedResult.needs_keyword_confirmation) confirmations.push("keyword");
542
+ if (parsedResult.needs_recent_viewed_filter_confirmation) confirmations.push("filter_recent_viewed");
543
+ if (parsedResult.needs_criteria_confirmation) confirmations.push("criteria");
544
+ if (parsedResult.has_unresolved_missing_fields) confirmations.push("missing_fields_or_defaults");
545
+ if ((parsedResult.suspicious_fields || []).length) confirmations.push("suspicious_fields");
546
+ return confirmations;
547
+ }
548
+
549
+ function buildNeedInputResponse(parsedResult) {
550
+ return {
551
+ status: "NEED_INPUT",
552
+ missing_fields: parsedResult.missing_fields,
553
+ proposed_keyword: parsedResult.proposed_keyword,
554
+ required_confirmations: buildRequiredConfirmations(parsedResult),
555
+ search_params: parsedResult.searchParams,
556
+ screen_params: parsedResult.screenParams,
557
+ pending_questions: parsedResult.pending_questions,
558
+ review: parsedResult.review,
559
+ error: {
560
+ code: "MISSING_REQUIRED_FIELDS",
561
+ message: "缺少必要字段。请先补齐缺失项;若要按默认值继续,必须先明确确认默认值及其风险。",
562
+ retryable: true
563
+ }
564
+ };
565
+ }
566
+
567
+ function buildNeedConfirmationResponse(parsedResult) {
568
+ return {
569
+ status: "NEED_CONFIRMATION",
570
+ proposed_keyword: parsedResult.proposed_keyword,
571
+ required_confirmations: buildRequiredConfirmations(parsedResult),
572
+ search_params: {
573
+ ...parsedResult.searchParams,
574
+ keyword: parsedResult.proposed_keyword || parsedResult.searchParams.keyword
575
+ },
576
+ screen_params: parsedResult.screenParams,
577
+ pending_questions: parsedResult.pending_questions,
578
+ review: parsedResult.review
579
+ };
580
+ }
581
+
582
+ function parseRecruitPipelineRequest(args = {}) {
583
+ const parsed = parseRecruitInstruction({
584
+ instruction: args.instruction,
585
+ confirmation: args.confirmation,
586
+ overrides: args.overrides
587
+ });
588
+ const criteriaOverride = normalizeText(args.overrides?.criteria || "");
589
+ if (criteriaOverride) {
590
+ parsed.screenParams = {
591
+ ...parsed.screenParams,
592
+ criteria: criteriaOverride
593
+ };
594
+ parsed.review = {
595
+ ...parsed.review,
596
+ current_screen_params: {
597
+ ...(parsed.review?.current_screen_params || {}),
598
+ criteria: criteriaOverride
599
+ }
600
+ };
601
+ }
602
+ return parsed;
603
+ }
604
+
605
+ function evaluateRecruitPipelineGate(parsed) {
606
+ if (parsed.has_unresolved_missing_fields) return buildNeedInputResponse(parsed);
607
+ if (
608
+ parsed.needs_keyword_confirmation
609
+ || parsed.needs_recent_viewed_filter_confirmation
610
+ || parsed.needs_criteria_confirmation
611
+ || parsed.needs_search_params_confirmation
612
+ || (parsed.suspicious_fields || []).length > 0
613
+ ) {
614
+ return buildNeedConfirmationResponse(parsed);
615
+ }
616
+ return null;
617
+ }
618
+
619
+ function normalizeRunSnapshot(snapshot) {
620
+ if (!snapshot) return null;
621
+ const meta = getRecruitRunMeta(snapshot.runId);
622
+ const artifacts = getRecruitRunArtifacts(snapshot.runId);
623
+ const summary = snapshot.summary && typeof snapshot.summary === "object" ? snapshot.summary : null;
624
+ const progress = normalizeLegacyProgress(snapshot.progress, summary);
625
+ const legacyResult = (
626
+ TERMINAL_STATUSES.has(snapshot.status)
627
+ || snapshot.status === RUN_STATUS_PAUSED
628
+ ) ? buildLegacyRunResult({ ...snapshot, progress }) : null;
629
+ const oldContext = {
630
+ workspace_root: meta.workspaceRoot || null,
631
+ instruction: meta.args?.instruction || "",
632
+ confirmation: clonePlain(meta.args?.confirmation || {}, {}),
633
+ overrides: clonePlain(meta.args?.overrides || {}, {}),
634
+ rounds: []
635
+ };
636
+ return {
637
+ ...snapshot,
638
+ progress,
639
+ run_id: snapshot.runId,
640
+ mode: meta.mode || RUN_MODE_ASYNC,
641
+ state: snapshot.status,
642
+ stage: snapshot.phase,
643
+ started_at: snapshot.startedAt,
644
+ updated_at: snapshot.updatedAt,
645
+ completed_at: toIsoOrNull(snapshot.completedAt),
646
+ heartbeat_at: snapshot.updatedAt,
647
+ pid: globalThis.process?.pid || null,
648
+ last_message: snapshot.error?.message || snapshot.phase || null,
649
+ context: {
650
+ ...(snapshot.context || {}),
651
+ ...oldContext,
652
+ shared_run_context: snapshot.context || {}
653
+ },
654
+ control: {
655
+ pause_requested: snapshot.status === RUN_STATUS_PAUSED,
656
+ pause_requested_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null,
657
+ pause_requested_by: snapshot.status === RUN_STATUS_PAUSED ? "pause_recruit_pipeline_run" : null,
658
+ cancel_requested: snapshot.status === RUN_STATUS_CANCELING
659
+ },
660
+ resume: {
661
+ checkpoint_path: legacyResult?.checkpoint_path || meta.checkpointPath || artifacts?.checkpoint_path || null,
662
+ pause_control_path: artifacts?.run_state_path || null,
663
+ output_csv: legacyResult?.output_csv || null,
664
+ resume_count: meta.resumeCount || 0,
665
+ last_resumed_at: meta.lastResumedAt || null,
666
+ last_paused_at: snapshot.status === RUN_STATUS_PAUSED ? snapshot.updatedAt : null
667
+ },
668
+ result: legacyResult,
669
+ artifacts
670
+ };
671
+ }
672
+
673
+ function persistRecruitRunSnapshot(snapshot, {
674
+ persistActiveCheckpoint = false
675
+ } = {}) {
676
+ const normalized = normalizeRunSnapshot(snapshot);
677
+ if (!normalized?.run_id) return normalized;
678
+ const artifacts = getRecruitRunArtifacts(normalized.run_id);
679
+ if (!artifacts) return normalized;
680
+ if (persistActiveCheckpoint) {
681
+ persistRecruitCheckpointSnapshot(normalized);
682
+ }
683
+ const payload = {
684
+ run_id: normalized.run_id,
685
+ mode: normalized.mode,
686
+ state: normalized.state,
687
+ status: normalized.status,
688
+ stage: normalized.stage,
689
+ started_at: normalized.started_at,
690
+ updated_at: normalized.updated_at,
691
+ heartbeat_at: normalized.heartbeat_at,
692
+ completed_at: normalized.completed_at,
693
+ pid: normalized.pid,
694
+ progress: normalized.progress,
695
+ last_message: normalized.last_message,
696
+ context: normalized.context,
697
+ control: normalized.control,
698
+ resume: normalized.resume,
699
+ error: normalized.error,
700
+ result: normalized.result,
701
+ summary: normalized.summary,
702
+ artifacts: normalized.artifacts
703
+ };
704
+ writeJsonAtomic(artifacts.run_state_path, payload);
705
+ return normalized;
706
+ }
707
+
708
+ function persistRecruitLifecycleSnapshot(snapshot, event = {}) {
709
+ return persistRecruitRunSnapshot(snapshot, {
710
+ persistActiveCheckpoint: event?.type === "checkpoint"
711
+ });
712
+ }
713
+
714
+ function getRecruitRunMeta(runId) {
715
+ return recruitRunMeta.get(runId) || {};
716
+ }
717
+
718
+ function attachMethodEvidence(payload, runId) {
719
+ const meta = getRecruitRunMeta(runId);
720
+ return {
721
+ ...payload,
722
+ runtime_evaluate_used: false,
723
+ method_summary: methodSummary(meta.methodLog || []),
724
+ method_log: meta.methodLog || [],
725
+ chrome: meta.chrome || null
726
+ };
727
+ }
728
+
729
+ async function waitForRecruitSearchControlsOrLogin(client, {
730
+ timeoutMs = 90000,
731
+ intervalMs = 300
732
+ } = {}) {
733
+ const started = Date.now();
734
+ let lastControls = null;
735
+ while (Date.now() - started <= timeoutMs) {
736
+ const loginDetection = await detectBossLoginState(client).catch(() => null);
737
+ if (loginDetection?.requires_login) {
738
+ return {
739
+ ok: false,
740
+ reason: "login_required",
741
+ loginDetection
742
+ };
743
+ }
744
+ const remainingMs = Math.max(1, timeoutMs - (Date.now() - started));
745
+ lastControls = await waitForRecruitSearchControls(client, {
746
+ timeoutMs: Math.min(remainingMs, 1500),
747
+ intervalMs
748
+ });
749
+ if (lastControls.ok) return lastControls;
750
+ await sleep(intervalMs);
751
+ }
752
+ return lastControls || { ok: false, reason: "timeout" };
753
+ }
754
+
755
+ async function connectRecruitChromeSession({
756
+ host = DEFAULT_RECRUIT_HOST,
757
+ port = DEFAULT_RECRUIT_PORT,
758
+ targetUrlIncludes = RECRUIT_TARGET_URL,
759
+ allowNavigate = true,
760
+ slowLive = false
761
+ } = {}) {
762
+ const session = await connectToChromeTargetOrOpen({
763
+ host,
764
+ port,
765
+ targetUrlIncludes,
766
+ targetUrl: RECRUIT_TARGET_URL,
767
+ allowNavigate,
768
+ slowLive,
769
+ fallbackTargetPredicate: (target) => (
770
+ target?.type === "page"
771
+ && String(target?.url || "").includes("zhipin.com")
772
+ )
773
+ });
774
+
775
+ const { client, target } = session;
776
+ await enableDomains(client, ["Page", "DOM", "Input", "Network", "Accessibility"]);
777
+ if (typeof client?.Network?.setCacheDisabled === "function") {
778
+ await client.Network.setCacheDisabled({ cacheDisabled: true });
779
+ }
780
+ await bringPageToFront(client);
781
+
782
+ const targetUrl = String(target?.url || "");
783
+ let navigation = {
784
+ navigated: false,
785
+ url: targetUrl
786
+ };
787
+ if (allowNavigate && !targetUrl.includes(targetUrlIncludes)) {
788
+ await client.Page.navigate({ url: RECRUIT_TARGET_URL });
789
+ const settleMs = slowLive ? 8000 : 3000;
790
+ const waited = await waitForMainFrameUrl(
791
+ client,
792
+ (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
793
+ { timeoutMs: settleMs, intervalMs: 500 }
794
+ );
795
+ navigation = {
796
+ navigated: true,
797
+ url: RECRUIT_TARGET_URL,
798
+ settle_ms: settleMs,
799
+ observed_url: waited.url || null,
800
+ observed_url_ok: waited.ok
801
+ };
802
+ }
803
+ let currentUrl = await getMainFrameUrl(client).catch(() => targetUrl);
804
+ if (allowNavigate && !String(currentUrl || "").includes(RECRUIT_TARGET_URL) && !isBossLoginUrl(currentUrl)) {
805
+ await client.Page.navigate({ url: RECRUIT_TARGET_URL });
806
+ const settleMs = slowLive ? 8000 : 3000;
807
+ const waited = await waitForMainFrameUrl(
808
+ client,
809
+ (url) => isBossLoginUrl(url) || String(url || "").includes(RECRUIT_TARGET_URL),
810
+ { timeoutMs: settleMs, intervalMs: 500 }
811
+ );
812
+ navigation = {
813
+ navigated: true,
814
+ url: RECRUIT_TARGET_URL,
815
+ settle_ms: settleMs,
816
+ observed_url: waited.url || null,
817
+ observed_url_ok: waited.ok,
818
+ reason: "observed_url_mismatch"
819
+ };
820
+ currentUrl = await getMainFrameUrl(client).catch(() => waited.url || currentUrl);
821
+ }
822
+ const loginDetection = await detectBossLoginState(client, { currentUrl }).catch(() => ({
823
+ requires_login: isBossLoginUrl(currentUrl),
824
+ reason: "login_detection_failed",
825
+ current_url: currentUrl
826
+ }));
827
+ if (loginDetection.requires_login) {
828
+ await session.close?.();
829
+ throw createBossLoginRequiredError({
830
+ domain: "search",
831
+ currentUrl: loginDetection.current_url || currentUrl,
832
+ targetUrl: RECRUIT_TARGET_URL,
833
+ loginDetection,
834
+ chrome: session.chrome || null
835
+ });
836
+ }
837
+ if (!String(currentUrl || "").includes(RECRUIT_TARGET_URL)) {
838
+ await session.close?.();
839
+ throw new Error(`Boss search page did not navigate to ${RECRUIT_TARGET_URL}; current URL: ${currentUrl || "unknown"}`);
840
+ }
841
+
842
+ const controls = await waitForRecruitSearchControlsOrLogin(client, {
843
+ timeoutMs: slowLive ? 180000 : 90000,
844
+ intervalMs: 300
845
+ });
846
+ if (controls.loginDetection?.requires_login) {
847
+ await session.close?.();
848
+ throw createBossLoginRequiredError({
849
+ domain: "search",
850
+ currentUrl: controls.loginDetection.current_url || currentUrl,
851
+ targetUrl: RECRUIT_TARGET_URL,
852
+ loginDetection: controls.loginDetection,
853
+ chrome: session.chrome || null
854
+ });
855
+ }
856
+ if (!controls.ok) {
857
+ const latestUrl = await getMainFrameUrl(client).catch(() => currentUrl);
858
+ const latestLoginDetection = await detectBossLoginState(client, { currentUrl: latestUrl }).catch(() => ({
859
+ requires_login: isBossLoginUrl(latestUrl),
860
+ reason: "login_detection_failed",
861
+ current_url: latestUrl
862
+ }));
863
+ if (latestLoginDetection.requires_login) {
864
+ await session.close?.();
865
+ throw createBossLoginRequiredError({
866
+ domain: "search",
867
+ currentUrl: latestLoginDetection.current_url || latestUrl,
868
+ targetUrl: RECRUIT_TARGET_URL,
869
+ loginDetection: latestLoginDetection,
870
+ chrome: session.chrome || null
871
+ });
872
+ }
873
+ throw new Error("Boss recruit search page did not expose ready search controls");
874
+ }
875
+
876
+ return {
877
+ ...session,
878
+ navigation,
879
+ controls
880
+ };
881
+ }
882
+
883
+ function getRunOptions(args, parsed, session, configResolution = null) {
884
+ const slowLive = args.slow_live === true;
885
+ const targetCount = parsePositiveInteger(args.max_candidates, parsed.screenParams.target_count || 10);
886
+ const screeningMode = normalizeScreeningModeArg(args);
887
+ const humanBehavior = resolveHumanBehaviorForRun(args, configResolution?.config || {});
888
+ return {
889
+ client: session.client,
890
+ targetUrl: RECRUIT_TARGET_URL,
891
+ criteria: parsed.screenParams.criteria,
892
+ searchParams: parsed.searchParams,
893
+ maxCandidates: targetCount,
894
+ detailLimit: parseNonNegativeInteger(args.detail_limit, targetCount),
895
+ closeDetail: true,
896
+ delayMs: Math.max(0, parsePositiveInteger(args.delay_ms, 0)),
897
+ cardTimeoutMs: slowLive ? 180000 : 90000,
898
+ resetBeforeSearch: args.reset_search !== false,
899
+ resetTimeoutMs: slowLive ? 300000 : 180000,
900
+ cityOptionTimeoutMs: slowLive ? 60000 : 30000,
901
+ maxImagePages: parsePositiveInteger(args.max_image_pages, DEFAULT_MAX_IMAGE_PAGES),
902
+ screeningMode,
903
+ llmConfig: screeningMode === "llm" && configResolution?.ok ? {
904
+ ...configResolution.config
905
+ } : null,
906
+ llmTimeoutMs: parsePositiveInteger(
907
+ args.llm_timeout_ms,
908
+ parsePositiveInteger(configResolution?.config?.llmTimeoutMs || configResolution?.config?.timeoutMs, slowLive ? 180000 : 120000)
909
+ ),
910
+ llmImageLimit: parsePositiveInteger(
911
+ args.llm_image_limit,
912
+ parsePositiveInteger(configResolution?.config?.llmImageLimit || configResolution?.config?.imageLimit, 8)
913
+ ),
914
+ llmImageDetail: normalizeText(
915
+ args.llm_image_detail || configResolution?.config?.llmImageDetail || configResolution?.config?.imageDetail
916
+ ) || "low",
917
+ imageOutputDir: resolveBossConfiguredOutputDir("", getRecruitRunsDir()),
918
+ humanRestEnabled: humanBehavior.restEnabled,
919
+ humanBehavior,
920
+ name: "mcp-recruit-pipeline-run"
921
+ };
922
+ }
923
+
924
+ async function closeRecruitRunSession(runId) {
925
+ const meta = recruitRunMeta.get(runId);
926
+ if (!meta || meta.closed) return;
927
+ try {
928
+ assertNoForbiddenCdpCalls(meta.methodLog || []);
929
+ } finally {
930
+ meta.closed = true;
931
+ try {
932
+ await meta.session?.close?.();
933
+ } catch {
934
+ // Nothing actionable for the caller once the run has settled.
935
+ }
936
+ }
937
+ }
938
+
939
+ async function waitForRecruitRunTerminal(runId) {
940
+ while (true) {
941
+ try {
942
+ const snapshot = recruitRunService.getRecruitRun(runId);
943
+ if (TERMINAL_STATUSES.has(snapshot.status)) return snapshot;
944
+ } catch {
945
+ return null;
946
+ }
947
+ await sleep(1000);
948
+ }
949
+ }
950
+
951
+ function trackRecruitRun(runId) {
952
+ waitForRecruitRunTerminal(runId)
953
+ .then((terminal) => {
954
+ if (terminal) persistRecruitRunSnapshot(terminal);
955
+ })
956
+ .catch(() => null)
957
+ .finally(() => {
958
+ closeRecruitRunSession(runId).catch(() => {});
959
+ });
960
+ }
961
+
962
+ async function startRecruitPipelineRunInternal(args = {}, { workspaceRoot = "" } = {}) {
963
+ const parsed = parseRecruitPipelineRequest(args);
964
+ const gate = evaluateRecruitPipelineGate(parsed);
965
+ if (gate) return gate;
966
+ const configResolution = resolveBossScreeningConfig(workspaceRoot);
967
+ const screeningMode = normalizeScreeningModeArg(args);
968
+ const debugTestOptions = collectRecruitDebugTestOptions(args);
969
+ if (debugTestOptions.length && !isDebugTestMode(args)) {
970
+ return {
971
+ status: "FAILED",
972
+ error: {
973
+ code: "DEBUG_TEST_MODE_REQUIRED",
974
+ message: `这些参数属于调试/测试路径,正式 live run 不会默认启用:${debugTestOptions.join(", ")}。如确需测试,请显式传 debug_test_mode=true。`,
975
+ retryable: false
976
+ },
977
+ debug_test_options: debugTestOptions
978
+ };
979
+ }
980
+ if (screeningMode === "llm" && !configResolution.ok) {
981
+ return {
982
+ status: "FAILED",
983
+ error: {
984
+ code: "SCREEN_CONFIG_ERROR",
985
+ message: configResolution.error?.message || "screening-config.json is required for LLM screening.",
986
+ retryable: true
987
+ },
988
+ config_path: configResolution.config_path || null,
989
+ candidate_paths: configResolution.candidate_paths || []
990
+ };
991
+ }
992
+ const host = normalizeText(args.host) || DEFAULT_RECRUIT_HOST;
993
+ const port = parsePositiveInteger(
994
+ args.port,
995
+ configResolution.ok ? configResolution.config.debugPort : DEFAULT_RECRUIT_PORT
996
+ );
997
+
998
+ let session;
999
+ try {
1000
+ session = await recruitConnectorImpl({
1001
+ host,
1002
+ port,
1003
+ targetUrlIncludes: normalizeText(args.target_url_includes) || RECRUIT_TARGET_URL,
1004
+ allowNavigate: args.allow_navigate !== false,
1005
+ slowLive: args.slow_live === true
1006
+ });
1007
+ } catch (error) {
1008
+ const loginRequired = error?.code === "BOSS_LOGIN_REQUIRED";
1009
+ return {
1010
+ status: "FAILED",
1011
+ error: {
1012
+ code: loginRequired ? "BOSS_LOGIN_REQUIRED" : "BOSS_SEARCH_PAGE_NOT_READY",
1013
+ message: error?.message || "Boss recruit search page is not ready",
1014
+ requires_login: Boolean(error?.requires_login),
1015
+ login_url: error?.login_url || null,
1016
+ login_detection: error?.login_detection || null,
1017
+ chrome: error?.chrome || null,
1018
+ current_url: error?.current_url || null,
1019
+ target_url: error?.target_url || RECRUIT_TARGET_URL,
1020
+ retryable: true
1021
+ },
1022
+ chrome: error?.chrome || null
1023
+ };
1024
+ }
1025
+
1026
+ let started;
1027
+ try {
1028
+ started = recruitRunService.startRecruitRun(getRunOptions(args, parsed, session, configResolution));
1029
+ } catch (error) {
1030
+ await session.close?.();
1031
+ return {
1032
+ status: "FAILED",
1033
+ error: {
1034
+ code: "RECRUIT_RUN_START_FAILED",
1035
+ message: error?.message || "Failed to start recruit run",
1036
+ retryable: true
1037
+ }
1038
+ };
1039
+ }
1040
+
1041
+ recruitRunMeta.set(started.runId, {
1042
+ session,
1043
+ methodLog: session.methodLog || [],
1044
+ mode: normalizeExecutionMode(args.execution_mode),
1045
+ workspaceRoot: normalizeText(workspaceRoot) || globalThis.process?.cwd?.() || "",
1046
+ args: clonePlain(args, {}),
1047
+ chrome: {
1048
+ host,
1049
+ port,
1050
+ target_url: session.target?.url || RECRUIT_TARGET_URL,
1051
+ target_id: session.target?.id || null,
1052
+ auto_launch: session.chrome || null
1053
+ },
1054
+ parsed
1055
+ });
1056
+ trackRecruitRun(started.runId);
1057
+ const persistedStarted = persistRecruitRunSnapshot(started);
1058
+
1059
+ return {
1060
+ status: "ACCEPTED",
1061
+ run_id: persistedStarted.run_id,
1062
+ state: persistedStarted.state,
1063
+ run: persistedStarted,
1064
+ poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1065
+ review: parsed.review,
1066
+ message: "Recruit pipeline run started through shared CDP-only recruit service."
1067
+ };
1068
+ }
1069
+
1070
+ export async function runRecruitPipelineTool({ workspaceRoot = "", args = {} } = {}) {
1071
+ const mode = normalizeExecutionMode(args.execution_mode);
1072
+ const started = await startRecruitPipelineRunInternal({
1073
+ ...args,
1074
+ execution_mode: mode
1075
+ }, { workspaceRoot });
1076
+ if (started.status !== "ACCEPTED") return started;
1077
+ if (mode !== RUN_MODE_SYNC) return attachMethodEvidence(started, started.run_id);
1078
+
1079
+ const final = await waitForRecruitRunTerminal(started.run_id);
1080
+ await closeRecruitRunSession(started.run_id);
1081
+ const normalizedFinal = persistRecruitRunSnapshot(final);
1082
+ const legacyResult = normalizedFinal?.result || buildLegacyRunResult(final);
1083
+ const finalStatus = final?.status === RUN_STATUS_COMPLETED
1084
+ ? "COMPLETED"
1085
+ : final?.status === RUN_STATUS_CANCELED
1086
+ ? "CANCELED"
1087
+ : "FAILED";
1088
+ return attachMethodEvidence({
1089
+ status: finalStatus,
1090
+ run_id: started.run_id,
1091
+ run: normalizedFinal,
1092
+ result: legacyResult,
1093
+ partial_result: finalStatus === "CANCELED" ? legacyResult : undefined,
1094
+ diagnostics: finalStatus === "FAILED"
1095
+ ? {
1096
+ run_id: started.run_id,
1097
+ last_stage: normalizedFinal?.stage || "recruit:unknown"
1098
+ }
1099
+ : undefined,
1100
+ summary: final?.summary || null,
1101
+ error: finalStatus === "CANCELED"
1102
+ ? {
1103
+ code: "PIPELINE_CANCELED",
1104
+ message: "流水线已取消。",
1105
+ retryable: true
1106
+ }
1107
+ : final?.error || null
1108
+ }, started.run_id);
1109
+ }
1110
+
1111
+ export async function startRecruitPipelineRunTool({ workspaceRoot = "", args = {} } = {}) {
1112
+ const started = await startRecruitPipelineRunInternal({
1113
+ ...args,
1114
+ execution_mode: RUN_MODE_ASYNC
1115
+ }, { workspaceRoot });
1116
+ if (started.status !== "ACCEPTED") return started;
1117
+ return attachMethodEvidence(started, started.run_id);
1118
+ }
1119
+
1120
+ export function getRecruitPipelineRunTool({ args = {} } = {}) {
1121
+ const runId = normalizeText(args.run_id);
1122
+ if (!runId) {
1123
+ return {
1124
+ status: "FAILED",
1125
+ error: {
1126
+ code: "INVALID_RUN_ID",
1127
+ message: "run_id is required",
1128
+ retryable: false
1129
+ }
1130
+ };
1131
+ }
1132
+ try {
1133
+ const run = recruitRunService.getRecruitRun(runId);
1134
+ const normalizedRun = persistRecruitRunSnapshot(run);
1135
+ return attachMethodEvidence({
1136
+ status: "RUN_STATUS",
1137
+ run: normalizedRun
1138
+ }, runId);
1139
+ } catch {
1140
+ const persisted = readRecruitRunState(runId);
1141
+ if (persisted) {
1142
+ return {
1143
+ status: "RUN_STATUS",
1144
+ run: persisted,
1145
+ persistence: {
1146
+ source: "disk",
1147
+ active_control_available: false
1148
+ },
1149
+ runtime_evaluate_used: false,
1150
+ method_summary: {},
1151
+ method_log: [],
1152
+ chrome: null
1153
+ };
1154
+ }
1155
+ return {
1156
+ status: "FAILED",
1157
+ error: {
1158
+ code: "RUN_NOT_FOUND",
1159
+ message: `No recruit run found for run_id=${runId}`,
1160
+ retryable: false
1161
+ }
1162
+ };
1163
+ }
1164
+ }
1165
+
1166
+ export function pauseRecruitPipelineRunTool({ args = {} } = {}) {
1167
+ const runId = normalizeText(args.run_id);
1168
+ try {
1169
+ const before = recruitRunService.getRecruitRun(runId);
1170
+ if (TERMINAL_STATUSES.has(before.status)) {
1171
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1172
+ return attachMethodEvidence({
1173
+ status: "PAUSE_IGNORED",
1174
+ run: normalizedBefore,
1175
+ message: "目标任务已结束,无需暂停。"
1176
+ }, runId);
1177
+ }
1178
+ if (before.status === RUN_STATUS_PAUSED) {
1179
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1180
+ return attachMethodEvidence({
1181
+ status: "PAUSE_IGNORED",
1182
+ run: normalizedBefore,
1183
+ message: "目标任务已经处于 paused 状态。"
1184
+ }, runId);
1185
+ }
1186
+ const run = recruitRunService.pauseRecruitRun(runId);
1187
+ const normalizedRun = persistRecruitRunSnapshot(run);
1188
+ return attachMethodEvidence({
1189
+ status: "PAUSE_REQUESTED",
1190
+ run: normalizedRun,
1191
+ message: "暂停请求已接收,将在当前候选人处理完成后进入 paused。"
1192
+ }, runId);
1193
+ } catch {
1194
+ const persisted = readRecruitRunState(runId);
1195
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1196
+ return {
1197
+ status: "PAUSE_IGNORED",
1198
+ run: persisted,
1199
+ message: "目标任务已结束,无需暂停。",
1200
+ runtime_evaluate_used: false,
1201
+ method_summary: {},
1202
+ method_log: [],
1203
+ chrome: null
1204
+ };
1205
+ }
1206
+ return getRecruitPipelineRunTool({ args });
1207
+ }
1208
+ }
1209
+
1210
+ export function resumeRecruitPipelineRunTool({ args = {} } = {}) {
1211
+ const runId = normalizeText(args.run_id);
1212
+ try {
1213
+ const before = recruitRunService.getRecruitRun(runId);
1214
+ if (TERMINAL_STATUSES.has(before.status)) {
1215
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1216
+ return attachMethodEvidence({
1217
+ status: "FAILED",
1218
+ error: {
1219
+ code: "RUN_ALREADY_TERMINATED",
1220
+ message: "目标任务已结束,无法继续。",
1221
+ retryable: false
1222
+ },
1223
+ run: normalizedBefore
1224
+ }, runId);
1225
+ }
1226
+ if (before.status !== RUN_STATUS_PAUSED) {
1227
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1228
+ return attachMethodEvidence({
1229
+ status: "FAILED",
1230
+ error: {
1231
+ code: "RUN_NOT_PAUSED",
1232
+ message: "仅 paused 状态的 run 才能继续。",
1233
+ retryable: true
1234
+ },
1235
+ run: normalizedBefore
1236
+ }, runId);
1237
+ }
1238
+ const run = recruitRunService.resumeRecruitRun(runId);
1239
+ const meta = getRecruitRunMeta(runId);
1240
+ if (meta) {
1241
+ meta.resumeCount = (meta.resumeCount || 0) + 1;
1242
+ meta.lastResumedAt = new Date().toISOString();
1243
+ }
1244
+ const normalizedRun = persistRecruitRunSnapshot(run);
1245
+ return attachMethodEvidence({
1246
+ status: "RESUME_REQUESTED",
1247
+ run: normalizedRun,
1248
+ poll_after_sec: DEFAULT_RECRUIT_POLL_AFTER_SEC,
1249
+ message: "已恢复 Boss 招聘流水线,请使用 get_recruit_pipeline_run 按需轮询。"
1250
+ }, runId);
1251
+ } catch {
1252
+ const persisted = readRecruitRunState(runId);
1253
+ if (persisted) {
1254
+ return {
1255
+ status: TERMINAL_STATUSES.has(persisted.state) ? "FAILED" : "FAILED",
1256
+ error: {
1257
+ code: TERMINAL_STATUSES.has(persisted.state) ? "RUN_ALREADY_TERMINATED" : "RUN_NOT_ACTIVE",
1258
+ message: TERMINAL_STATUSES.has(persisted.state)
1259
+ ? "目标任务已结束,无法继续。"
1260
+ : "该 run 只有磁盘快照,没有当前进程内的活动 CDP 会话,无法安全继续。",
1261
+ retryable: !TERMINAL_STATUSES.has(persisted.state)
1262
+ },
1263
+ run: persisted,
1264
+ persistence: {
1265
+ source: "disk",
1266
+ active_control_available: false
1267
+ },
1268
+ runtime_evaluate_used: false,
1269
+ method_summary: {},
1270
+ method_log: [],
1271
+ chrome: null
1272
+ };
1273
+ }
1274
+ return getRecruitPipelineRunTool({ args });
1275
+ }
1276
+ }
1277
+
1278
+ export function cancelRecruitPipelineRunTool({ args = {} } = {}) {
1279
+ const runId = normalizeText(args.run_id);
1280
+ try {
1281
+ const before = recruitRunService.getRecruitRun(runId);
1282
+ if (TERMINAL_STATUSES.has(before.status)) {
1283
+ const normalizedBefore = persistRecruitRunSnapshot(before);
1284
+ return attachMethodEvidence({
1285
+ status: "CANCEL_IGNORED",
1286
+ run: normalizedBefore,
1287
+ message: "目标任务已结束,无需取消。"
1288
+ }, runId);
1289
+ }
1290
+ const run = recruitRunService.cancelRecruitRun(runId);
1291
+ const normalizedRun = persistRecruitRunSnapshot(run);
1292
+ return attachMethodEvidence({
1293
+ status: "CANCEL_REQUESTED",
1294
+ run: normalizedRun,
1295
+ message: "已收到取消请求,将在当前候选人处理完成后安全停止。"
1296
+ }, runId);
1297
+ } catch {
1298
+ const persisted = readRecruitRunState(runId);
1299
+ if (persisted && TERMINAL_STATUSES.has(persisted.state)) {
1300
+ return {
1301
+ status: "CANCEL_IGNORED",
1302
+ run: persisted,
1303
+ message: "目标任务已结束,无需取消。",
1304
+ runtime_evaluate_used: false,
1305
+ method_summary: {},
1306
+ method_log: [],
1307
+ chrome: null
1308
+ };
1309
+ }
1310
+ return getRecruitPipelineRunTool({ args });
1311
+ }
1312
+ }
1313
+
1314
+ export function __setRecruitMcpConnectorForTests(nextConnector) {
1315
+ recruitConnectorImpl = typeof nextConnector === "function" ? nextConnector : connectRecruitChromeSession;
1316
+ }
1317
+
1318
+ export function __setRecruitMcpWorkflowForTests(nextWorkflow) {
1319
+ recruitWorkflowImpl = typeof nextWorkflow === "function" ? nextWorkflow : runRecruitWorkflow;
1320
+ recruitRunService = createRecruitRunService({
1321
+ idPrefix: "mcp_recruit",
1322
+ workflow: (...args) => recruitWorkflowImpl(...args),
1323
+ onSnapshot: persistRecruitLifecycleSnapshot
1324
+ });
1325
+ }
1326
+
1327
+ export function __resetRecruitMcpStateForTests() {
1328
+ for (const meta of recruitRunMeta.values()) {
1329
+ try {
1330
+ meta.session?.close?.();
1331
+ } catch {
1332
+ // Best-effort test cleanup.
1333
+ }
1334
+ }
1335
+ recruitRunMeta.clear();
1336
+ __setRecruitMcpConnectorForTests(null);
1337
+ __setRecruitMcpWorkflowForTests(null);
1338
+ }