@reconcrap/boss-recommend-mcp 1.3.32 → 1.3.34

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.
@@ -1,1580 +1,1580 @@
1
- #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
3
- import { appendFile, mkdir } from 'node:fs/promises';
4
- import path from 'node:path';
5
- import process from 'node:process';
6
- import * as readlineCore from 'node:readline';
7
- import readline from 'node:readline/promises';
8
- import util from 'node:util';
9
- import { fileURLToPath } from 'node:url';
10
-
11
- import { BossChatApp } from './app.js';
12
- import { BossChatPage } from './browser/chat-page.js';
13
- import {
14
- appendRunEvent,
15
- createRunId,
16
- createRunStateSnapshot,
17
- getRunEventsPath,
18
- isTerminalRunState,
19
- readRunState,
20
- RUN_STATE_CANCELED,
21
- RUN_STATE_COMPLETED,
22
- RUN_STATE_FAILED,
23
- RUN_STATE_PAUSED,
24
- RUN_STATE_QUEUED,
25
- RUN_STATE_RUNNING,
26
- updateRunState,
27
- writeRunState,
28
- } from './runtime/async-run-state.js';
29
- import { InteractionController } from './runtime/interaction.js';
30
- import { RunControl } from './runtime/run-control.js';
31
- import { ChromeClient } from './services/chrome-client.js';
32
- import { LlmClient } from './services/llm.js';
33
- import {
34
- normalizeProfile,
35
- ProfileStore,
36
- toPersistentProfile,
37
- validateProfile,
38
- } from './services/profile-store.js';
39
- import { ReportStore } from './services/report-store.js';
40
- import { ResumeCaptureService } from './services/resume-capture.js';
41
- import { ResumeNetworkTracker } from './services/resume-network.js';
42
- import { NoopStateStore, StateStore } from './services/state-store.js';
43
-
44
- const CLI_FILE_PATH = fileURLToPath(import.meta.url);
45
- const MINIMAL_TERMINAL_PATTERNS = [/^进度: /, /^候选人结果: /];
46
- const CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index';
47
- const CHAT_START_REQUIRED_FIELDS = ['job', 'start_from', 'target_count', 'criteria'];
48
- const CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS = 3;
49
- const CHAT_PAGE_HYDRATION_MAX_ATTEMPTS = 12;
50
- const CHAT_PAGE_HYDRATION_DELAY_MS = 250;
51
- const CHAT_JOB_LIST_MAX_ATTEMPTS = 16;
52
- const CHAT_JOB_LIST_DELAY_MS = 250;
53
-
54
- function sanitizePathToken(value, fallback = 'run') {
55
- const token = String(value || '')
56
- .trim()
57
- .replace(/[^\w.-]+/g, '_')
58
- .slice(0, 80);
59
- return token || fallback;
60
- }
61
-
62
- function formatLogLineArgs(args) {
63
- return args
64
- .map((arg) => {
65
- if (typeof arg === 'string') return arg;
66
- return util.inspect(arg, {
67
- depth: 6,
68
- breakLength: 120,
69
- maxArrayLength: 100,
70
- });
71
- })
72
- .join(' ');
73
- }
74
-
75
- function shouldPrintToMinimalTerminal(message) {
76
- return MINIMAL_TERMINAL_PATTERNS.some((pattern) => pattern.test(message));
77
- }
78
-
79
- function sleep(ms) {
80
- return new Promise((resolve) => setTimeout(resolve, ms));
81
- }
82
-
83
- async function createRunLogger(dataDir, { runId = '', detachedWorker = false } = {}) {
84
- const logsDir = path.join(dataDir, 'logs');
85
- await mkdir(logsDir, { recursive: true });
86
- const stamp = nowIso().replace(/[:.]/g, '-');
87
- const suffix = sanitizePathToken(runId || process.pid, detachedWorker ? 'detached' : 'run');
88
- const logPath = path.join(logsDir, `run-${stamp}-${suffix}.log`);
89
-
90
- let writeQueue = Promise.resolve();
91
- const enqueueWrite = (line) => {
92
- writeQueue = writeQueue.then(() => appendFile(logPath, line, 'utf8')).catch(() => {});
93
- return writeQueue;
94
- };
95
-
96
- const write = (level, sink, args) => {
97
- const message = formatLogLineArgs(args);
98
- enqueueWrite(`[${nowIso()}] [${level}] ${message}\n`);
99
- if (sink === 'stdout' && shouldPrintToMinimalTerminal(message)) {
100
- process.stdout.write(`${message}\n`);
101
- return;
102
- }
103
- if (sink === 'stderr' && shouldPrintToMinimalTerminal(message)) {
104
- process.stderr.write(`${message}\n`);
105
- }
106
- };
107
-
108
- const logger = {
109
- log: (...args) => write('INFO', 'stdout', args),
110
- info: (...args) => write('INFO', 'stdout', args),
111
- warn: (...args) => write('WARN', 'stderr', args),
112
- error: (...args) => write('ERROR', 'stderr', args),
113
- };
114
-
115
- enqueueWrite(`[${nowIso()}] [INFO] run-log-created path=${logPath}\n`);
116
-
117
- return {
118
- logger,
119
- logPath,
120
- flush: () => writeQueue,
121
- };
122
- }
123
-
124
- function nowIso() {
125
- return new Date().toISOString();
126
- }
127
-
128
- function parseBooleanFlag(value, fallback = true) {
129
- if (value === undefined) return fallback;
130
- const normalized = String(value).trim().toLowerCase();
131
- if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
132
- if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
133
- return fallback;
134
- }
135
-
136
- function parseStartFrom(value, fallback = 'unread') {
137
- if (value === undefined) return fallback;
138
- const normalized = String(value).trim().toLowerCase();
139
- if (['all', '全部', '2'].includes(normalized)) return 'all';
140
- if (['unread', '未读', '1'].includes(normalized)) return 'unread';
141
- return fallback;
142
- }
143
-
144
- function isUnlimitedTargetCountToken(value) {
145
- const token = String(value || '').trim().toLowerCase();
146
- if (!token) return false;
147
- return [
148
- 'all',
149
- 'unlimited',
150
- 'infinity',
151
- 'inf',
152
- 'max',
153
- 'full',
154
- 'allcandidates',
155
- '全部',
156
- '全量',
157
- '不限',
158
- '扫到底',
159
- '全部候选人',
160
- '所有候选人',
161
- '全部人选',
162
- '所有人选',
163
- '直到完成所有人选',
164
- ].includes(token);
165
- }
166
-
167
- function parseTargetCount(value) {
168
- if (value === undefined || value === null || String(value).trim() === '') {
169
- return null;
170
- }
171
- if (isUnlimitedTargetCountToken(value)) {
172
- return -1;
173
- }
174
- const parsed = Number.parseInt(String(value), 10);
175
- if (Number.isFinite(parsed) && parsed === -1) {
176
- return -1;
177
- }
178
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
179
- }
180
-
181
- function parseArgs(argv) {
182
- const args = {
183
- command: 'run',
184
- profile: 'default',
185
- dryRun: false,
186
- noState: false,
187
- json: false,
188
- runId: '',
189
- detachedWorker: false,
190
- overrides: {
191
- startFrom: undefined,
192
- targetCount: undefined,
193
- screeningCriteria: undefined,
194
- jobSelection: undefined,
195
- llm: {},
196
- chrome: {},
197
- runtime: {},
198
- },
199
- };
200
-
201
- const positionals = [];
202
- for (let index = 0; index < argv.length; index += 1) {
203
- const token = argv[index];
204
- if (!token.startsWith('--')) {
205
- positionals.push(token);
206
- continue;
207
- }
208
-
209
- const name = token.slice(2);
210
- const next = argv[index + 1];
211
- const value = next && !next.startsWith('--') ? next : undefined;
212
- if (value !== undefined) {
213
- index += 1;
214
- }
215
-
216
- switch (name) {
217
- case 'profile':
218
- args.profile = value || args.profile;
219
- break;
220
- case 'dry-run':
221
- args.dryRun = true;
222
- break;
223
- case 'no-state':
224
- args.noState = true;
225
- break;
226
- case 'json':
227
- args.json = true;
228
- break;
229
- case 'run-id':
230
- case 'runId':
231
- args.runId = String(value || '').trim();
232
- break;
233
- case 'detached-worker':
234
- args.detachedWorker = true;
235
- break;
236
- case 'targetCount':
237
- args.overrides.targetCount = parseTargetCount(value);
238
- break;
239
- case 'start-from':
240
- case 'startFrom':
241
- args.overrides.startFrom = parseStartFrom(value, 'unread');
242
- break;
243
- case 'criteria':
244
- case 'screeningCriteria':
245
- args.overrides.screeningCriteria = String(value || '').trim();
246
- break;
247
- case 'job':
248
- case 'jobSelection':
249
- args.overrides.jobSelection = String(value || '').trim();
250
- break;
251
- case 'baseurl':
252
- case 'baseUrl':
253
- args.overrides.llm.baseUrl = value || '';
254
- break;
255
- case 'apikey':
256
- case 'apiKey':
257
- args.overrides.llm.apiKey = value || '';
258
- break;
259
- case 'model':
260
- args.overrides.llm.model = value || '';
261
- break;
262
- case 'thinking-level':
263
- case 'thinkingLevel':
264
- case 'llm-thinking-level':
265
- case 'reasoning-effort':
266
- args.overrides.llm.thinkingLevel = value || '';
267
- break;
268
- case 'llm-timeout-ms':
269
- case 'timeout-ms':
270
- args.overrides.llm.timeoutMs = Number.parseInt(value, 10);
271
- break;
272
- case 'llm-max-retries':
273
- case 'max-retries':
274
- args.overrides.llm.maxRetries = Number.parseInt(value, 10);
275
- break;
276
- case 'port':
277
- args.overrides.chrome.port = Number.parseInt(value, 10);
278
- break;
279
- case 'safe-pacing':
280
- args.overrides.runtime.safePacing = parseBooleanFlag(value, true);
281
- break;
282
- case 'batch-rest':
283
- args.overrides.runtime.batchRestEnabled = parseBooleanFlag(value, true);
284
- break;
285
- case 'help':
286
- args.command = 'help';
287
- break;
288
- default:
289
- throw new Error(`Unknown option: --${name}`);
290
- }
291
- }
292
-
293
- if (positionals.length > 0) {
294
- args.command = positionals[0];
295
- }
296
- return args;
297
- }
298
-
299
- function printUsage() {
300
- console.log('Usage: boss-chat <command> [options]');
301
- console.log('');
302
- console.log('Commands:');
303
- console.log(' run Interactive/manual run');
304
- console.log(' prepare-run Preflight chat page and list jobs for required input collection');
305
- console.log(' start-run Start async run and return run_id');
306
- console.log(' get-run Query async run status');
307
- console.log(' pause-run Request async run pause');
308
- console.log(' resume-run Resume paused async run');
309
- console.log(' cancel-run Cancel async run');
310
- console.log('');
311
- console.log('Common options:');
312
- console.log(' --profile <name> Profile name (default: default)');
313
- console.log(' --json JSON output for agent integration');
314
- console.log(' --run-id <id> Target async run_id (for get/pause/resume/cancel)');
315
- console.log('');
316
- console.log('Run options:');
317
- console.log(' --dry-run Evaluate and click, but do not request resume');
318
- console.log(' --no-state Disable in-run candidate deduplication');
319
- console.log(' --job <text|value|index> Select job by label/value/index');
320
- console.log(' --criteria <text> Screening criteria for resume evaluation');
321
- console.log(' --start-from <unread|all> Start from unread or all list');
322
- console.log(' --targetCount <n|all> Maximum candidates to process; all means unlimited');
323
- console.log(' --baseurl <url> Override LLM base URL');
324
- console.log(' --apikey <key> Override LLM API key');
325
- console.log(' --model <name> Override LLM model');
326
- console.log(' --thinking-level <level> LLM thinking level: off|low|medium|high|current');
327
- console.log(' --llm-timeout-ms <n> Override per-request LLM timeout (default: 60000)');
328
- console.log(' --llm-max-retries <n> Override per-request LLM retry count (default: 3)');
329
- console.log(' --port <n> Override Chrome remote debugging port');
330
- }
331
-
332
- function outputCommandResult(args, payload) {
333
- if (args.json) {
334
- console.log(JSON.stringify(payload));
335
- return;
336
- }
337
-
338
- if (payload?.status) {
339
- console.log(`status: ${payload.status}`);
340
- }
341
- if (payload?.run_id) {
342
- console.log(`run_id: ${payload.run_id}`);
343
- }
344
- if (payload?.message) {
345
- console.log(payload.message);
346
- }
347
- if (payload?.error?.message) {
348
- console.log(`error: ${payload.error.message}`);
349
- }
350
- if (!payload?.status && !payload?.message && !payload?.error) {
351
- console.log(JSON.stringify(payload, null, 2));
352
- }
353
- }
354
-
355
- function setupRuntimeControls(runControl) {
356
- if (!process.stdin.isTTY) {
357
- return () => {};
358
- }
359
-
360
- readlineCore.emitKeypressEvents(process.stdin);
361
- if (typeof process.stdin.setRawMode === 'function') {
362
- process.stdin.setRawMode(true);
363
- }
364
-
365
- const onKeypress = (_str, key) => {
366
- if (key?.ctrl && key.name === 'c') {
367
- runControl.requestStop('收到 Ctrl+C');
368
- return;
369
- }
370
-
371
- if (key?.name === 'p') {
372
- runControl.togglePause();
373
- return;
374
- }
375
-
376
- if (key?.name === 'r') {
377
- runControl.resume();
378
- return;
379
- }
380
-
381
- if (key?.name === 'q') {
382
- runControl.requestStop('用户请求停止');
383
- }
384
- };
385
-
386
- process.stdin.on('keypress', onKeypress);
387
-
388
- return () => {
389
- process.stdin.off('keypress', onKeypress);
390
- if (typeof process.stdin.setRawMode === 'function') {
391
- process.stdin.setRawMode(false);
392
- }
393
- };
394
- }
395
-
396
- function startDetachedControlSync({ dataDir, runId, runControl }) {
397
- let lastHeartbeatAt = 0;
398
- let inTick = false;
399
-
400
- const timer = setInterval(() => {
401
- if (inTick) return;
402
- inTick = true;
403
- try {
404
- const snapshot = readRunState(dataDir, runId);
405
- if (!snapshot) return;
406
-
407
- const control = snapshot.control || {};
408
- if (control.cancelRequested && !runControl.isStopping()) {
409
- runControl.requestStop('收到 cancel-run 请求');
410
- appendRunEvent(dataDir, runId, {
411
- type: 'control',
412
- action: 'cancel-request-observed',
413
- message: '检测到 cancel-run 请求,准备安全停止。',
414
- });
415
- }
416
-
417
- if (control.pauseRequested) {
418
- if (!runControl.isPaused() && !runControl.isStopping()) {
419
- runControl.pause();
420
- updateRunState(dataDir, runId, {
421
- state: RUN_STATE_PAUSED,
422
- stage: 'running',
423
- heartbeatAt: nowIso(),
424
- lastMessage: '运行已暂停(来自 pause-run 请求)。',
425
- });
426
- }
427
- } else if (runControl.isPaused() && !runControl.isStopping()) {
428
- runControl.resume();
429
- updateRunState(dataDir, runId, {
430
- state: RUN_STATE_RUNNING,
431
- stage: 'running',
432
- heartbeatAt: nowIso(),
433
- lastMessage: '运行已继续(来自 resume-run 请求)。',
434
- });
435
- }
436
-
437
- const now = Date.now();
438
- if (now - lastHeartbeatAt >= 5000) {
439
- updateRunState(dataDir, runId, {
440
- heartbeatAt: nowIso(),
441
- state: runControl.isPaused() ? RUN_STATE_PAUSED : RUN_STATE_RUNNING,
442
- });
443
- lastHeartbeatAt = now;
444
- }
445
- } catch {} finally {
446
- inTick = false;
447
- }
448
- }, 700);
449
-
450
- return () => clearInterval(timer);
451
- }
452
-
453
- async function promptPersistentLlmIfMissing(profile, profileName) {
454
- const missing = validateProfile(profile);
455
- if (missing.length === 0) {
456
- return normalizeProfile(profile);
457
- }
458
-
459
- if (!process.stdin.isTTY) {
460
- throw new Error(
461
- `Profile "${profileName}" 缺少必要配置:${missing.join(', ')}。当前为非交互模式,请先补齐 profile 或通过参数传入。`,
462
- );
463
- }
464
-
465
- const rl = readline.createInterface({
466
- input: process.stdin,
467
- output: process.stdout,
468
- });
469
-
470
- try {
471
- console.log(`Profile "${profileName}" 缺少 LLM/Chrome 必要配置,开始交互填写。`);
472
- if (!profile.llm.baseUrl) {
473
- profile.llm.baseUrl = await rl.question('LLM baseUrl: ');
474
- }
475
- if (!profile.llm.apiKey) {
476
- profile.llm.apiKey = await rl.question('LLM apiKey: ');
477
- }
478
- if (!profile.llm.model) {
479
- profile.llm.model = await rl.question('LLM model: ');
480
- }
481
- profile.chrome.port =
482
- (await rl.question(`Chrome 远程调试端口 [${profile.chrome.port || 9222}]: `)) ||
483
- profile.chrome.port ||
484
- 9222;
485
- } finally {
486
- rl.close();
487
- }
488
-
489
- return normalizeProfile(profile);
490
- }
491
-
492
- function resolveJobSelection(jobs, input) {
493
- const normalizedInput = String(input || '').trim();
494
- if (!normalizedInput) return null;
495
-
496
- const asIndex = Number.parseInt(normalizedInput, 10);
497
- if (Number.isFinite(asIndex) && asIndex >= 1 && asIndex <= jobs.length) {
498
- return jobs[asIndex - 1];
499
- }
500
-
501
- const byValue = jobs.find((job) => String(job.value || '').trim() === normalizedInput);
502
- if (byValue) return byValue;
503
-
504
- const byExactLabel = jobs.find((job) => String(job.label || '').trim() === normalizedInput);
505
- if (byExactLabel) return byExactLabel;
506
-
507
- const normalizedLower = normalizedInput.toLowerCase();
508
- const fuzzy = jobs.filter((job) =>
509
- String(job.label || '').toLowerCase().includes(normalizedLower),
510
- );
511
- if (fuzzy.length === 1) return fuzzy[0];
512
- if (fuzzy.length > 1) {
513
- throw new Error('岗位选择有歧义,请输入编号或完整岗位名。');
514
- }
515
-
516
- return null;
517
- }
518
-
519
- async function promptRunProfile({ page, persistentProfile, overrides }) {
520
- const jobs = await resolveJobsWithRetry({ page });
521
- if (!Array.isArray(jobs) || jobs.length === 0) {
522
- throw new Error('未解析到岗位列表,请确认岗位下拉可见。');
523
- }
524
-
525
- let selectedJob = null;
526
- if (overrides.jobSelection) {
527
- selectedJob = resolveJobSelection(jobs, overrides.jobSelection);
528
- if (!selectedJob) {
529
- throw new Error(`未找到岗位: ${overrides.jobSelection}`);
530
- }
531
- }
532
-
533
- let startFrom = overrides.startFrom;
534
- let screeningCriteria = overrides.screeningCriteria;
535
- let targetCount = overrides.targetCount;
536
-
537
- if (process.stdin.isTTY) {
538
- const rl = readline.createInterface({
539
- input: process.stdin,
540
- output: process.stdout,
541
- });
542
- try {
543
- if (!selectedJob) {
544
- console.log('可选岗位:');
545
- jobs.forEach((job, index) => {
546
- console.log(` ${index + 1}. ${job.label}${job.active ? ' (当前)' : ''}`);
547
- });
548
- const answer = await rl.question('请选择岗位编号: ');
549
- selectedJob = resolveJobSelection(jobs, answer);
550
- if (!selectedJob) {
551
- throw new Error('岗位选择无效。');
552
- }
553
- }
554
-
555
- if (!startFrom) {
556
- const answer = await rl.question('列表范围 [1=未读, 2=全部] (1): ');
557
- startFrom = parseStartFrom(answer, 'unread');
558
- }
559
-
560
- if (!screeningCriteria) {
561
- screeningCriteria = String(await rl.question('筛选标准: ')).trim();
562
- }
563
-
564
- if (targetCount === undefined) {
565
- const answer = await rl.question('本次处理人数上限(回车=扫到底): ');
566
- targetCount = parseTargetCount(answer);
567
- }
568
- } finally {
569
- rl.close();
570
- }
571
- }
572
-
573
- if (!selectedJob) {
574
- selectedJob = jobs[0];
575
- }
576
- if (!startFrom) {
577
- startFrom = 'unread';
578
- }
579
- if (!screeningCriteria) {
580
- throw new Error('筛选标准不能为空(可通过 --criteria 传入,或在交互中输入)。');
581
- }
582
-
583
- return normalizeProfile({
584
- ...persistentProfile,
585
- jobSelection: {
586
- value: selectedJob.value,
587
- label: selectedJob.label,
588
- },
589
- startFrom,
590
- screeningCriteria,
591
- targetCount: targetCount ?? null,
592
- });
593
- }
594
-
595
- function validateStartRunArgs(args) {
596
- const missing = [];
597
- if (!args?.overrides?.jobSelection) missing.push('--job');
598
- if (!args?.overrides?.startFrom) missing.push('--start-from');
599
- if (args?.overrides?.targetCount === undefined || args?.overrides?.targetCount === null) {
600
- missing.push('--targetCount');
601
- }
602
- if (!args?.overrides?.screeningCriteria) missing.push('--criteria');
603
-
604
- if (missing.length === 0) return null;
605
- return {
606
- status: 'FAILED',
607
- error: {
608
- code: 'MISSING_REQUIRED_ARGS',
609
- message: `start-run 缺少必要参数:${missing.join(', ')}`,
610
- retryable: false,
611
- },
612
- };
613
- }
614
-
615
- function buildPreparePendingQuestions(args, jobs = []) {
616
- const pendingQuestions = [];
617
- const startFromValue = String(args?.overrides?.startFrom || '').trim().toLowerCase();
618
- const targetCountValue = Number.parseInt(String(args?.overrides?.targetCount ?? ''), 10);
619
- const hasTargetCount =
620
- args?.overrides?.targetCount !== undefined &&
621
- args?.overrides?.targetCount !== null &&
622
- Number.isFinite(targetCountValue) &&
623
- (targetCountValue > 0 || targetCountValue === -1);
624
- const criteriaValue = String(args?.overrides?.screeningCriteria || '').trim();
625
- const jobValue = String(args?.overrides?.jobSelection || '').trim();
626
- const jobOptions = jobs.map((job, index) => ({
627
- label: `${index + 1}. ${job.label}${job.active ? '(当前)' : ''}`,
628
- value: String(job.value || job.label || ''),
629
- index: index + 1,
630
- active: Boolean(job.active),
631
- }));
632
-
633
- if (!jobValue) {
634
- pendingQuestions.push({
635
- field: 'job',
636
- question: '请选择岗位(必须从岗位列表中选择)',
637
- required: true,
638
- options: jobOptions,
639
- });
640
- }
641
- if (!['unread', 'all'].includes(startFromValue)) {
642
- pendingQuestions.push({
643
- field: 'start_from',
644
- question: '请选择起始范围',
645
- required: true,
646
- options: [
647
- { label: '未读', value: 'unread' },
648
- { label: '全部', value: 'all' },
649
- ],
650
- });
651
- }
652
- if (!hasTargetCount) {
653
- pendingQuestions.push({
654
- field: 'target_count',
655
- question: '请输入目标数量(正整数)或 all(扫到底)',
656
- required: true,
657
- });
658
- }
659
- if (!criteriaValue) {
660
- pendingQuestions.push({
661
- field: 'criteria',
662
- question: '请输入筛选标准(自然语言)',
663
- required: true,
664
- });
665
- }
666
- return pendingQuestions;
667
- }
668
-
669
- function hasHydratedChatShell(pageState, jobs = []) {
670
- const hasChatList =
671
- Boolean(pageState?.hasListContainer)
672
- || Number(pageState?.listItemCount || 0) > 0;
673
- return hasChatList || (Array.isArray(jobs) && jobs.length > 0);
674
- }
675
-
676
- async function waitForChatShellHydration({
677
- page,
678
- maxAttempts = CHAT_PAGE_HYDRATION_MAX_ATTEMPTS,
679
- delayMs = CHAT_PAGE_HYDRATION_DELAY_MS,
680
- } = {}) {
681
- let lastPageState = null;
682
- let lastJobs = [];
683
- let lastError = null;
684
-
685
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
686
- try {
687
- lastPageState = await page.ensureOnChatPage();
688
- } catch (error) {
689
- lastError = error;
690
- break;
691
- }
692
-
693
- try {
694
- lastJobs = await page.listJobs();
695
- } catch (error) {
696
- lastError = error;
697
- lastJobs = [];
698
- }
699
-
700
- if (hasHydratedChatShell(lastPageState, lastJobs)) {
701
- return {
702
- pageState: lastPageState,
703
- jobs: lastJobs,
704
- };
705
- }
706
-
707
- if (attempt < maxAttempts) {
708
- await sleep(delayMs);
709
- }
710
- }
711
-
712
- const lastErrorMessage = String(lastError?.message || lastError || '');
713
- if (/ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE/.test(lastErrorMessage)) {
714
- throw lastError;
715
- }
716
-
717
- return {
718
- pageState: lastPageState,
719
- jobs: lastJobs,
720
- };
721
- }
722
-
723
- async function resolveJobsWithRetry({
724
- page,
725
- maxAttempts = CHAT_JOB_LIST_MAX_ATTEMPTS,
726
- delayMs = CHAT_JOB_LIST_DELAY_MS,
727
- } = {}) {
728
- let lastError = null;
729
-
730
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
731
- try {
732
- const jobs = await page.listJobs();
733
- if (Array.isArray(jobs) && jobs.length > 0) {
734
- return jobs;
735
- }
736
- } catch (error) {
737
- lastError = error;
738
- const message = String(error?.message || error || '');
739
- if (/ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE/.test(message)) {
740
- throw error;
741
- }
742
- }
743
-
744
- const hydrated = await waitForChatShellHydration({
745
- page,
746
- maxAttempts: 1,
747
- delayMs,
748
- });
749
- if (Array.isArray(hydrated?.jobs) && hydrated.jobs.length > 0) {
750
- return hydrated.jobs;
751
- }
752
-
753
- if (attempt < maxAttempts) {
754
- await sleep(delayMs);
755
- }
756
- }
757
-
758
- if (lastError) {
759
- throw lastError;
760
- }
761
-
762
- return [];
763
- }
764
-
765
- async function connectBossChatPage(chromeClient) {
766
- const isBossDomainTarget = (target) =>
767
- target?.type === 'page' && /zhipin\.com/i.test(String(target?.url || ''));
768
- let target = null;
769
- let recoveredToChatIndex = false;
770
- let blankChatPage = false;
771
- let renavigateAttempts = 0;
772
-
773
- try {
774
- target = await chromeClient.connect(BossChatPage.targetMatcher);
775
- } catch {
776
- target = await chromeClient.connect(isBossDomainTarget);
777
- }
778
-
779
- const page = new BossChatPage(chromeClient);
780
- for (let attempt = 1; attempt <= CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS + 1; attempt += 1) {
781
- try {
782
- await page.ensureReady();
783
- return {
784
- target,
785
- page,
786
- recoveredToChatIndex,
787
- blankChatPage,
788
- renavigateAttempts,
789
- };
790
- } catch (error) {
791
- const message = String(error?.message || error || '');
792
- if (/CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)) {
793
- blankChatPage = true;
794
- const hydrated = await waitForChatShellHydration({ page });
795
- if (hasHydratedChatShell(hydrated?.pageState, hydrated?.jobs)) {
796
- return {
797
- target,
798
- page,
799
- recoveredToChatIndex,
800
- blankChatPage,
801
- renavigateAttempts,
802
- };
803
- }
804
- }
805
- const canRetry =
806
- /ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE|CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)
807
- && attempt <= CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS;
808
-
809
- if (!canRetry) {
810
- if (/CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)) {
811
- await page.ensureOnChatPage();
812
- break;
813
- }
814
- throw error;
815
- }
816
-
817
- await page.recoverToChatIndex({
818
- forceNavigate: true,
819
- waitForReadyState: 'complete',
820
- });
821
- recoveredToChatIndex = true;
822
- renavigateAttempts += 1;
823
- }
824
- }
825
-
826
- return { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts };
827
- }
828
-
829
- async function handlePrepareRunCommand(args, dataDir) {
830
- const profileStore = new ProfileStore(dataDir);
831
- const savedProfile = (await profileStore.load(args.profile)) || {};
832
- const mergedProfile = normalizeProfile({
833
- ...savedProfile,
834
- llm: {
835
- ...(savedProfile.llm || {}),
836
- ...(args.overrides.llm || {}),
837
- },
838
- chrome: {
839
- ...(savedProfile.chrome || {}),
840
- ...(args.overrides.chrome || {}),
841
- },
842
- runtime: {
843
- ...(savedProfile.runtime || {}),
844
- ...(args.overrides.runtime || {}),
845
- },
846
- });
847
-
848
- const missingProfileConfig = validateProfile(mergedProfile);
849
- if (missingProfileConfig.length > 0) {
850
- return {
851
- status: 'FAILED',
852
- error: {
853
- code: 'PROFILE_CONFIG_MISSING',
854
- message: `profile 配置缺失:${missingProfileConfig.join(', ')}`,
855
- retryable: false,
856
- },
857
- };
858
- }
859
-
860
- let chromeClient = null;
861
- try {
862
- chromeClient = new ChromeClient(mergedProfile.chrome.port);
863
- const { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts } = await connectBossChatPage(chromeClient);
864
- const jobs = await resolveJobsWithRetry({ page });
865
- if (!Array.isArray(jobs) || jobs.length === 0) {
866
- return {
867
- status: 'FAILED',
868
- error: {
869
- code: 'CHAT_JOB_LIST_EMPTY',
870
- message: '未解析到岗位列表,请先在聊天页确认岗位下拉可见后重试。',
871
- retryable: true,
872
- },
873
- };
874
- }
875
-
876
- return {
877
- status: 'NEED_INPUT',
878
- stage: 'chat_run_setup',
879
- page_url: CHAT_INDEX_URL,
880
- connected_target: target?.url || '',
881
- recovered_to_chat_index: recoveredToChatIndex,
882
- blank_chat_page: blankChatPage,
883
- renavigate_attempts: renavigateAttempts,
884
- required_fields: CHAT_START_REQUIRED_FIELDS.slice(),
885
- defaults: {
886
- profile: String(args.profile || 'default').trim() || 'default',
887
- start_from: 'unread',
888
- },
889
- job_options: jobs.map((job, index) => ({
890
- index: index + 1,
891
- label: String(job.label || ''),
892
- value: String(job.value || job.label || ''),
893
- active: Boolean(job.active),
894
- })),
895
- pending_questions: buildPreparePendingQuestions(args, jobs),
896
- message:
897
- '已导航至 Boss 聊天页并加载岗位列表。请补齐 job / start_from / target_count / criteria 后再次调用 start-run。',
898
- };
899
- } catch (error) {
900
- return {
901
- status: 'FAILED',
902
- error: {
903
- code: 'CHAT_PREPARE_FAILED',
904
- message: error?.message || 'prepare-run 执行失败。',
905
- retryable: true,
906
- },
907
- };
908
- } finally {
909
- if (chromeClient) {
910
- await chromeClient.close();
911
- }
912
- }
913
- }
914
-
915
- function buildDetachedRunArgs(args, runId) {
916
- const workerArgs = [CLI_FILE_PATH, 'run', '--detached-worker', '--run-id', runId];
917
- workerArgs.push('--profile', args.profile);
918
- workerArgs.push('--job', String(args.overrides.jobSelection));
919
- workerArgs.push('--start-from', String(args.overrides.startFrom));
920
- workerArgs.push('--criteria', String(args.overrides.screeningCriteria));
921
-
922
- if (args.dryRun) workerArgs.push('--dry-run');
923
- if (args.noState) workerArgs.push('--no-state');
924
- if (args.overrides.targetCount !== undefined && args.overrides.targetCount !== null) {
925
- workerArgs.push('--targetCount', String(args.overrides.targetCount));
926
- }
927
- if (args.overrides.llm.baseUrl) {
928
- workerArgs.push('--baseurl', String(args.overrides.llm.baseUrl));
929
- }
930
- if (args.overrides.llm.apiKey) {
931
- workerArgs.push('--apikey', String(args.overrides.llm.apiKey));
932
- }
933
- if (args.overrides.llm.model) {
934
- workerArgs.push('--model', String(args.overrides.llm.model));
935
- }
936
- if (args.overrides.llm.thinkingLevel) {
937
- workerArgs.push('--thinking-level', String(args.overrides.llm.thinkingLevel));
938
- }
939
- if (Number.isFinite(args.overrides.llm.timeoutMs) && args.overrides.llm.timeoutMs > 0) {
940
- workerArgs.push('--llm-timeout-ms', String(args.overrides.llm.timeoutMs));
941
- }
942
- if (Number.isFinite(args.overrides.llm.maxRetries) && args.overrides.llm.maxRetries > 0) {
943
- workerArgs.push('--llm-max-retries', String(args.overrides.llm.maxRetries));
944
- }
945
- if (Number.isFinite(args.overrides.chrome.port)) {
946
- workerArgs.push('--port', String(args.overrides.chrome.port));
947
- }
948
- if (Object.prototype.hasOwnProperty.call(args.overrides.runtime, 'safePacing')) {
949
- workerArgs.push('--safe-pacing', String(Boolean(args.overrides.runtime.safePacing)));
950
- }
951
- if (Object.prototype.hasOwnProperty.call(args.overrides.runtime, 'batchRestEnabled')) {
952
- workerArgs.push('--batch-rest', String(Boolean(args.overrides.runtime.batchRestEnabled)));
953
- }
954
-
955
- return workerArgs;
956
- }
957
-
958
- async function handleStartRunCommand(args, dataDir) {
959
- const validateError = validateStartRunArgs(args);
960
- if (validateError) return validateError;
961
-
962
- const profileStore = new ProfileStore(dataDir);
963
- const savedProfile = (await profileStore.load(args.profile)) || {};
964
- const mergedProfile = normalizeProfile({
965
- ...savedProfile,
966
- llm: {
967
- ...(savedProfile.llm || {}),
968
- ...(args.overrides.llm || {}),
969
- },
970
- chrome: {
971
- ...(savedProfile.chrome || {}),
972
- ...(args.overrides.chrome || {}),
973
- },
974
- runtime: {
975
- ...(savedProfile.runtime || {}),
976
- ...(args.overrides.runtime || {}),
977
- },
978
- });
979
- const missingProfileConfig = validateProfile(mergedProfile);
980
- if (missingProfileConfig.length > 0) {
981
- return {
982
- status: 'FAILED',
983
- error: {
984
- code: 'PROFILE_CONFIG_MISSING',
985
- message: `profile 配置缺失:${missingProfileConfig.join(', ')}`,
986
- retryable: false,
987
- },
988
- };
989
- }
990
-
991
- const runId = createRunId();
992
- const snapshot = createRunStateSnapshot({
993
- runId,
994
- state: RUN_STATE_QUEUED,
995
- stage: 'preflight',
996
- lastMessage: '异步任务已创建,等待 detached worker 启动。',
997
- request: {
998
- profile: args.profile,
999
- dryRun: Boolean(args.dryRun),
1000
- noState: Boolean(args.noState),
1001
- input: {
1002
- job: String(args.overrides.jobSelection || ''),
1003
- startFrom: String(args.overrides.startFrom || ''),
1004
- criteria: String(args.overrides.screeningCriteria || ''),
1005
- targetCount: args.overrides.targetCount ?? null,
1006
- },
1007
- },
1008
- });
1009
- writeRunState(dataDir, snapshot);
1010
- appendRunEvent(dataDir, runId, {
1011
- type: 'lifecycle',
1012
- action: 'accepted',
1013
- state: RUN_STATE_QUEUED,
1014
- message: '异步任务已接受。',
1015
- });
1016
-
1017
- let worker = null;
1018
- try {
1019
- worker = spawn(process.execPath, buildDetachedRunArgs(args, runId), {
1020
- cwd: process.cwd(),
1021
- detached: true,
1022
- stdio: 'ignore',
1023
- windowsHide: true,
1024
- });
1025
- worker.unref();
1026
- } catch (error) {
1027
- const message = `无法启动 detached worker:${error?.message || 'unknown error'}`;
1028
- updateRunState(dataDir, runId, {
1029
- state: RUN_STATE_FAILED,
1030
- stage: 'preflight',
1031
- heartbeatAt: nowIso(),
1032
- lastMessage: message,
1033
- error: {
1034
- code: 'RUN_WORKER_LAUNCH_FAILED',
1035
- message,
1036
- retryable: true,
1037
- },
1038
- });
1039
- return {
1040
- status: 'FAILED',
1041
- run_id: runId,
1042
- error: {
1043
- code: 'RUN_WORKER_LAUNCH_FAILED',
1044
- message,
1045
- retryable: true,
1046
- },
1047
- };
1048
- }
1049
-
1050
- updateRunState(dataDir, runId, {
1051
- pid: worker?.pid,
1052
- state: RUN_STATE_QUEUED,
1053
- stage: 'preflight',
1054
- heartbeatAt: nowIso(),
1055
- lastMessage: '异步任务已启动(detached)。',
1056
- });
1057
- appendRunEvent(dataDir, runId, {
1058
- type: 'lifecycle',
1059
- action: 'detached-started',
1060
- state: RUN_STATE_QUEUED,
1061
- pid: worker?.pid || null,
1062
- message: 'detached worker 已启动。',
1063
- });
1064
-
1065
- return {
1066
- status: 'ACCEPTED',
1067
- run_id: runId,
1068
- state: RUN_STATE_QUEUED,
1069
- message: '异步任务已启动。默认不自动查询进度;如需进度请调用 get-run。',
1070
- };
1071
- }
1072
-
1073
- function buildRunNotFound(runId) {
1074
- return {
1075
- status: 'FAILED',
1076
- error: {
1077
- code: 'RUN_NOT_FOUND',
1078
- message: `未找到 run_id=${runId} 的运行记录。`,
1079
- retryable: false,
1080
- },
1081
- };
1082
- }
1083
-
1084
- function readRunOrError(args, dataDir) {
1085
- const runId = String(args.runId || '').trim();
1086
- if (!runId) {
1087
- return {
1088
- error: {
1089
- status: 'FAILED',
1090
- error: {
1091
- code: 'INVALID_RUN_ID',
1092
- message: 'run_id is required',
1093
- retryable: false,
1094
- },
1095
- },
1096
- runId: '',
1097
- snapshot: null,
1098
- };
1099
- }
1100
-
1101
- const snapshot = readRunState(dataDir, runId);
1102
- if (!snapshot) {
1103
- return {
1104
- error: buildRunNotFound(runId),
1105
- runId,
1106
- snapshot: null,
1107
- };
1108
- }
1109
- return { error: null, runId, snapshot };
1110
- }
1111
-
1112
- function handleGetRunCommand(args, dataDir) {
1113
- const resolved = readRunOrError(args, dataDir);
1114
- if (resolved.error) return resolved.error;
1115
-
1116
- return {
1117
- status: 'RUN_STATUS',
1118
- run: resolved.snapshot,
1119
- events_path: getRunEventsPath(dataDir, resolved.runId),
1120
- message: '按需查询成功。默认不自动轮询。',
1121
- };
1122
- }
1123
-
1124
- function handlePauseRunCommand(args, dataDir) {
1125
- const resolved = readRunOrError(args, dataDir);
1126
- if (resolved.error) return resolved.error;
1127
-
1128
- if (isTerminalRunState(resolved.snapshot.state)) {
1129
- return {
1130
- status: 'PAUSE_IGNORED',
1131
- run: resolved.snapshot,
1132
- message: '目标任务已结束,无需暂停。',
1133
- };
1134
- }
1135
- if (resolved.snapshot.control?.pauseRequested || resolved.snapshot.state === RUN_STATE_PAUSED) {
1136
- return {
1137
- status: 'PAUSE_IGNORED',
1138
- run: resolved.snapshot,
1139
- message: '目标任务已处于暂停请求中或已暂停。',
1140
- };
1141
- }
1142
-
1143
- const updated = updateRunState(dataDir, resolved.runId, {
1144
- control: {
1145
- pauseRequested: true,
1146
- cancelRequested: Boolean(resolved.snapshot.control?.cancelRequested),
1147
- },
1148
- lastMessage: '已收到暂停请求,将在安全检查点暂停。',
1149
- heartbeatAt: nowIso(),
1150
- });
1151
- appendRunEvent(dataDir, resolved.runId, {
1152
- type: 'control',
1153
- action: 'pause-requested',
1154
- message: '已写入 pauseRequested=true。',
1155
- });
1156
-
1157
- return {
1158
- status: 'PAUSE_REQUESTED',
1159
- run: updated || resolved.snapshot,
1160
- message: '暂停请求已接收。',
1161
- };
1162
- }
1163
-
1164
- function handleResumeRunCommand(args, dataDir) {
1165
- const resolved = readRunOrError(args, dataDir);
1166
- if (resolved.error) return resolved.error;
1167
-
1168
- if (isTerminalRunState(resolved.snapshot.state)) {
1169
- return {
1170
- status: 'FAILED',
1171
- error: {
1172
- code: 'RUN_ALREADY_TERMINATED',
1173
- message: '目标任务已结束,无法继续。',
1174
- retryable: false,
1175
- },
1176
- run: resolved.snapshot,
1177
- };
1178
- }
1179
-
1180
- if (!resolved.snapshot.control?.pauseRequested && resolved.snapshot.state !== RUN_STATE_PAUSED) {
1181
- return {
1182
- status: 'RESUME_IGNORED',
1183
- run: resolved.snapshot,
1184
- message: '目标任务未处于暂停状态。',
1185
- };
1186
- }
1187
-
1188
- const updated = updateRunState(dataDir, resolved.runId, {
1189
- state:
1190
- resolved.snapshot.state === RUN_STATE_PAUSED ? RUN_STATE_RUNNING : resolved.snapshot.state,
1191
- control: {
1192
- pauseRequested: false,
1193
- cancelRequested: false,
1194
- },
1195
- lastMessage: '已收到继续请求,将恢复执行。',
1196
- heartbeatAt: nowIso(),
1197
- });
1198
- appendRunEvent(dataDir, resolved.runId, {
1199
- type: 'control',
1200
- action: 'resume-requested',
1201
- message: '已写入 pauseRequested=false。',
1202
- });
1203
-
1204
- return {
1205
- status: 'RESUME_REQUESTED',
1206
- run: updated || resolved.snapshot,
1207
- message: '继续请求已接收。',
1208
- };
1209
- }
1210
-
1211
- function handleCancelRunCommand(args, dataDir) {
1212
- const resolved = readRunOrError(args, dataDir);
1213
- if (resolved.error) return resolved.error;
1214
-
1215
- if (isTerminalRunState(resolved.snapshot.state)) {
1216
- return {
1217
- status: 'CANCEL_IGNORED',
1218
- run: resolved.snapshot,
1219
- message: '目标任务已结束,无需取消。',
1220
- };
1221
- }
1222
-
1223
- const updated = updateRunState(dataDir, resolved.runId, {
1224
- control: {
1225
- pauseRequested: true,
1226
- cancelRequested: true,
1227
- },
1228
- lastMessage: '已收到取消请求,将在安全检查点停止。',
1229
- heartbeatAt: nowIso(),
1230
- });
1231
- appendRunEvent(dataDir, resolved.runId, {
1232
- type: 'control',
1233
- action: 'cancel-requested',
1234
- message: '已写入 cancelRequested=true。',
1235
- });
1236
-
1237
- return {
1238
- status: 'CANCEL_REQUESTED',
1239
- run: updated || resolved.snapshot,
1240
- message: '取消请求已接收。',
1241
- };
1242
- }
1243
-
1244
- async function executeRunCommand(args, dataDir) {
1245
- const asyncMode = Boolean(args.detachedWorker && args.runId);
1246
- const runId = asyncMode ? String(args.runId || '').trim() : '';
1247
-
1248
- if (asyncMode && !runId) {
1249
- throw new Error('detached worker mode requires --run-id');
1250
- }
1251
-
1252
- const runLogger = await createRunLogger(dataDir, {
1253
- runId,
1254
- detachedWorker: asyncMode,
1255
- });
1256
- const logger = runLogger.logger;
1257
- logger.log(
1258
- `运行日志已创建: ${runLogger.logPath} | mode=${asyncMode ? 'detached-worker' : 'interactive'}`,
1259
- );
1260
-
1261
- const runControl = new RunControl({ logger });
1262
- let cleanupRuntimeControls = () => {};
1263
- let cleanupControlSync = () => {};
1264
- let chromeClient = null;
1265
-
1266
- try {
1267
- const profileStore = new ProfileStore(dataDir);
1268
- const savedProfile = (await profileStore.load(args.profile)) || {};
1269
- const persistentMerged = normalizeProfile({
1270
- ...savedProfile,
1271
- llm: {
1272
- ...(savedProfile.llm || {}),
1273
- ...(args.overrides.llm || {}),
1274
- },
1275
- chrome: {
1276
- ...(savedProfile.chrome || {}),
1277
- ...(args.overrides.chrome || {}),
1278
- },
1279
- runtime: {
1280
- ...(savedProfile.runtime || {}),
1281
- ...(args.overrides.runtime || {}),
1282
- },
1283
- });
1284
- const persistentProfile = await promptPersistentLlmIfMissing(persistentMerged, args.profile);
1285
- await profileStore.save(args.profile, toPersistentProfile(persistentProfile));
1286
-
1287
- if (asyncMode) {
1288
- const existing = readRunState(dataDir, runId);
1289
- if (!existing) {
1290
- writeRunState(
1291
- dataDir,
1292
- createRunStateSnapshot({
1293
- runId,
1294
- state: RUN_STATE_QUEUED,
1295
- stage: 'preflight',
1296
- request: {
1297
- profile: args.profile,
1298
- dryRun: Boolean(args.dryRun),
1299
- noState: Boolean(args.noState),
1300
- },
1301
- lastMessage: 'detached worker 直接启动。',
1302
- }),
1303
- );
1304
- }
1305
-
1306
- updateRunState(dataDir, runId, {
1307
- pid: process.pid,
1308
- state: RUN_STATE_RUNNING,
1309
- stage: 'preflight',
1310
- heartbeatAt: nowIso(),
1311
- logPath: runLogger.logPath,
1312
- lastMessage: 'detached worker 已启动,准备执行。',
1313
- });
1314
- appendRunEvent(dataDir, runId, {
1315
- type: 'lifecycle',
1316
- action: 'worker-boot',
1317
- state: RUN_STATE_RUNNING,
1318
- pid: process.pid,
1319
- message: 'detached worker 已接管任务。',
1320
- });
1321
- cleanupControlSync = startDetachedControlSync({ dataDir, runId, runControl });
1322
- }
1323
-
1324
- chromeClient = new ChromeClient(persistentProfile.chrome.port);
1325
-
1326
- const { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts } = await connectBossChatPage(chromeClient);
1327
- logger.log(`已连接 Chrome tab: ${target.title || target.url}`);
1328
- if (recoveredToChatIndex) {
1329
- logger.log(`检测到页面不符合预期,已重新跳转到 ${CHAT_INDEX_URL} 并等待加载完成。attempts=${renavigateAttempts}`);
1330
- }
1331
- if (blankChatPage) {
1332
- logger.log('检测到聊天页处于空白未初始化状态,将继续通过岗位选择和首位候选人预热来恢复列表。');
1333
- }
1334
-
1335
- const runProfile = await promptRunProfile({
1336
- page,
1337
- persistentProfile,
1338
- overrides: args.overrides,
1339
- });
1340
- const appliedJob = await page.selectJob(runProfile.jobSelection);
1341
- runProfile.jobSelection = {
1342
- value: appliedJob.value || runProfile.jobSelection.value,
1343
- label: appliedJob.label || runProfile.jobSelection.label,
1344
- };
1345
-
1346
- if (asyncMode) {
1347
- updateRunState(dataDir, runId, {
1348
- state: RUN_STATE_RUNNING,
1349
- stage: 'preflight',
1350
- heartbeatAt: nowIso(),
1351
- lastMessage: '页面与岗位已就绪,开始执行候选人流程。',
1352
- });
1353
- }
1354
-
1355
- const interaction = new InteractionController(chromeClient, {
1356
- ...persistentProfile.runtime,
1357
- runControl,
1358
- });
1359
- const llmClient = new LlmClient(runProfile.llm);
1360
- const resumeCaptureService = new ResumeCaptureService({ chromeClient, logger });
1361
- const resumeNetworkTracker = new ResumeNetworkTracker({ chromeClient, logger });
1362
- const stateStore = args.noState ? new NoopStateStore() : new StateStore(dataDir, args.profile);
1363
- const reportStore = new ReportStore(dataDir);
1364
- const app = new BossChatApp({
1365
- page,
1366
- llmClient,
1367
- interaction,
1368
- resumeCaptureService,
1369
- stateStore,
1370
- reportStore,
1371
- resumeNetworkTracker,
1372
- runControl,
1373
- logger,
1374
- dryRun: args.dryRun,
1375
- artifactRootDir: path.join(dataDir, 'artifacts'),
1376
- onProgress: (progress, meta = {}) => {
1377
- if (!asyncMode) return;
1378
- const nextState = runControl.isPaused() ? RUN_STATE_PAUSED : RUN_STATE_RUNNING;
1379
- const stage = String(meta?.stage || 'running');
1380
- const message = String(
1381
- meta?.message ||
1382
- `进度更新 inspected=${progress.inspected},passed=${progress.passed},requested=${progress.requested}`,
1383
- );
1384
- updateRunState(dataDir, runId, {
1385
- state: nextState,
1386
- stage,
1387
- heartbeatAt: nowIso(),
1388
- progress: {
1389
- inspected: Number(progress.inspected || 0),
1390
- passed: Number(progress.passed || 0),
1391
- requested: Number(progress.requested || 0),
1392
- skipped: Number(progress.skipped || 0),
1393
- errors: Number(progress.errors || 0),
1394
- },
1395
- lastMessage: message,
1396
- });
1397
- appendRunEvent(dataDir, runId, {
1398
- type: 'progress',
1399
- state: nextState,
1400
- stage,
1401
- message,
1402
- progress: {
1403
- inspected: Number(progress.inspected || 0),
1404
- passed: Number(progress.passed || 0),
1405
- requested: Number(progress.requested || 0),
1406
- skipped: Number(progress.skipped || 0),
1407
- errors: Number(progress.errors || 0),
1408
- },
1409
- });
1410
- },
1411
- });
1412
-
1413
- cleanupRuntimeControls = setupRuntimeControls(runControl);
1414
-
1415
- logger.log('开始处理 Boss 聊天候选人列表...');
1416
- const targetCountLabel =
1417
- Number.isFinite(Number(runProfile.targetCount)) && Number(runProfile.targetCount) > 0
1418
- ? String(runProfile.targetCount)
1419
- : '扫到底';
1420
- logger.log(
1421
- `本次设置: 岗位=${runProfile.jobSelection.label}, 范围=${runProfile.startFrom === 'all' ? '全部' : '未读'}, 上限=${targetCountLabel}`,
1422
- );
1423
- logger.log('运行中快捷键: p=暂停/继续, r=继续, q=停止, Ctrl+C=停止');
1424
-
1425
- const summary = await app.run(runProfile);
1426
- logger.log(`已检查: ${summary.inspected}`);
1427
- logger.log(`通过: ${summary.passed}`);
1428
- logger.log(`已求简历: ${summary.requested}`);
1429
- logger.log(`跳过: ${summary.skipped}`);
1430
- logger.log(`错误: ${summary.errors}`);
1431
- if (summary.exhausted) {
1432
- logger.log('候选人列表已没有更多可处理项,提前结束。');
1433
- }
1434
- if (summary.stopped) {
1435
- logger.log(`运行已停止: ${summary.stopReason}`);
1436
- }
1437
- logger.log(`运行报告: ${summary.reportPath}`);
1438
-
1439
- if (asyncMode) {
1440
- const latest = readRunState(dataDir, runId);
1441
- const canceledRequested = Boolean(latest?.control?.cancelRequested);
1442
- const terminalState =
1443
- summary.stopped || canceledRequested ? RUN_STATE_CANCELED : RUN_STATE_COMPLETED;
1444
- const terminalMessage =
1445
- terminalState === RUN_STATE_CANCELED
1446
- ? `任务已停止:${summary.stopReason || '收到取消请求'}`
1447
- : '任务执行完成。';
1448
-
1449
- updateRunState(dataDir, runId, {
1450
- state: terminalState,
1451
- stage: 'finalize',
1452
- heartbeatAt: nowIso(),
1453
- progress: {
1454
- inspected: Number(summary.inspected || 0),
1455
- passed: Number(summary.passed || 0),
1456
- requested: Number(summary.requested || 0),
1457
- skipped: Number(summary.skipped || 0),
1458
- errors: Number(summary.errors || 0),
1459
- },
1460
- control: {
1461
- pauseRequested: false,
1462
- cancelRequested: false,
1463
- },
1464
- lastMessage: terminalMessage,
1465
- error: null,
1466
- result: {
1467
- finishedAt: nowIso(),
1468
- reportPath: String(summary.reportPath || ''),
1469
- summary,
1470
- },
1471
- });
1472
- appendRunEvent(dataDir, runId, {
1473
- type: 'lifecycle',
1474
- action: 'terminal',
1475
- state: terminalState,
1476
- message: terminalMessage,
1477
- });
1478
- }
1479
- } catch (error) {
1480
- logger.error(error?.stack || error?.message || String(error));
1481
- error.runLogPath = runLogger.logPath;
1482
- if (asyncMode) {
1483
- const message = error?.message || String(error);
1484
- updateRunState(dataDir, runId, {
1485
- state: RUN_STATE_FAILED,
1486
- stage: 'finalize',
1487
- heartbeatAt: nowIso(),
1488
- logPath: runLogger.logPath,
1489
- lastMessage: message,
1490
- error: {
1491
- code: 'RUN_EXECUTION_FAILED',
1492
- message,
1493
- retryable: true,
1494
- },
1495
- });
1496
- appendRunEvent(dataDir, runId, {
1497
- type: 'lifecycle',
1498
- action: 'failed',
1499
- state: RUN_STATE_FAILED,
1500
- message,
1501
- });
1502
- }
1503
- throw error;
1504
- } finally {
1505
- cleanupControlSync();
1506
- cleanupRuntimeControls();
1507
- if (chromeClient) {
1508
- await chromeClient.close();
1509
- }
1510
- await runLogger.flush();
1511
- }
1512
- }
1513
-
1514
- async function main() {
1515
- const args = parseArgs(process.argv.slice(2));
1516
- const dataDir = path.join(process.cwd(), '.boss-chat');
1517
- await mkdir(dataDir, { recursive: true });
1518
-
1519
- if (args.command === 'help') {
1520
- printUsage();
1521
- return;
1522
- }
1523
-
1524
- if (args.command === 'run') {
1525
- await executeRunCommand(args, dataDir);
1526
- return;
1527
- }
1528
-
1529
- let payload = null;
1530
- switch (args.command) {
1531
- case 'prepare-run':
1532
- payload = await handlePrepareRunCommand(args, dataDir);
1533
- break;
1534
- case 'start-run':
1535
- payload = await handleStartRunCommand(args, dataDir);
1536
- break;
1537
- case 'get-run':
1538
- payload = handleGetRunCommand(args, dataDir);
1539
- break;
1540
- case 'pause-run':
1541
- payload = handlePauseRunCommand(args, dataDir);
1542
- break;
1543
- case 'resume-run':
1544
- payload = handleResumeRunCommand(args, dataDir);
1545
- break;
1546
- case 'cancel-run':
1547
- payload = handleCancelRunCommand(args, dataDir);
1548
- break;
1549
- default:
1550
- printUsage();
1551
- process.exitCode = 1;
1552
- return;
1553
- }
1554
-
1555
- outputCommandResult(args, payload);
1556
- if (payload?.status === 'FAILED') {
1557
- process.exitCode = 1;
1558
- }
1559
- }
1560
-
1561
- export const __testables = {
1562
- parseArgs,
1563
- connectBossChatPage,
1564
- hasHydratedChatShell,
1565
- promptRunProfile,
1566
- resolveJobsWithRetry,
1567
- waitForChatShellHydration,
1568
- };
1569
-
1570
- if (process.argv[1] && path.resolve(process.argv[1]) === CLI_FILE_PATH) {
1571
- main().catch((error) => {
1572
- const runLogPath = String(error?.runLogPath || '').trim();
1573
- if (runLogPath) {
1574
- console.error(`执行失败,详细日志见: ${runLogPath}`);
1575
- } else {
1576
- console.error(`执行失败: ${error.message}`);
1577
- }
1578
- process.exitCode = 1;
1579
- });
1580
- }
1
+ #!/usr/bin/env node
2
+ import { spawn } from 'node:child_process';
3
+ import { appendFile, mkdir } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import process from 'node:process';
6
+ import * as readlineCore from 'node:readline';
7
+ import readline from 'node:readline/promises';
8
+ import util from 'node:util';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ import { BossChatApp } from './app.js';
12
+ import { BossChatPage } from './browser/chat-page.js';
13
+ import {
14
+ appendRunEvent,
15
+ createRunId,
16
+ createRunStateSnapshot,
17
+ getRunEventsPath,
18
+ isTerminalRunState,
19
+ readRunState,
20
+ RUN_STATE_CANCELED,
21
+ RUN_STATE_COMPLETED,
22
+ RUN_STATE_FAILED,
23
+ RUN_STATE_PAUSED,
24
+ RUN_STATE_QUEUED,
25
+ RUN_STATE_RUNNING,
26
+ updateRunState,
27
+ writeRunState,
28
+ } from './runtime/async-run-state.js';
29
+ import { InteractionController } from './runtime/interaction.js';
30
+ import { RunControl } from './runtime/run-control.js';
31
+ import { ChromeClient } from './services/chrome-client.js';
32
+ import { LlmClient } from './services/llm.js';
33
+ import {
34
+ normalizeProfile,
35
+ ProfileStore,
36
+ toPersistentProfile,
37
+ validateProfile,
38
+ } from './services/profile-store.js';
39
+ import { ReportStore } from './services/report-store.js';
40
+ import { ResumeCaptureService } from './services/resume-capture.js';
41
+ import { ResumeNetworkTracker } from './services/resume-network.js';
42
+ import { NoopStateStore, StateStore } from './services/state-store.js';
43
+
44
+ const CLI_FILE_PATH = fileURLToPath(import.meta.url);
45
+ const MINIMAL_TERMINAL_PATTERNS = [/^进度: /, /^候选人结果: /];
46
+ const CHAT_INDEX_URL = 'https://www.zhipin.com/web/chat/index';
47
+ const CHAT_START_REQUIRED_FIELDS = ['job', 'start_from', 'target_count', 'criteria'];
48
+ const CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS = 3;
49
+ const CHAT_PAGE_HYDRATION_MAX_ATTEMPTS = 12;
50
+ const CHAT_PAGE_HYDRATION_DELAY_MS = 250;
51
+ const CHAT_JOB_LIST_MAX_ATTEMPTS = 16;
52
+ const CHAT_JOB_LIST_DELAY_MS = 250;
53
+
54
+ function sanitizePathToken(value, fallback = 'run') {
55
+ const token = String(value || '')
56
+ .trim()
57
+ .replace(/[^\w.-]+/g, '_')
58
+ .slice(0, 80);
59
+ return token || fallback;
60
+ }
61
+
62
+ function formatLogLineArgs(args) {
63
+ return args
64
+ .map((arg) => {
65
+ if (typeof arg === 'string') return arg;
66
+ return util.inspect(arg, {
67
+ depth: 6,
68
+ breakLength: 120,
69
+ maxArrayLength: 100,
70
+ });
71
+ })
72
+ .join(' ');
73
+ }
74
+
75
+ function shouldPrintToMinimalTerminal(message) {
76
+ return MINIMAL_TERMINAL_PATTERNS.some((pattern) => pattern.test(message));
77
+ }
78
+
79
+ function sleep(ms) {
80
+ return new Promise((resolve) => setTimeout(resolve, ms));
81
+ }
82
+
83
+ async function createRunLogger(dataDir, { runId = '', detachedWorker = false } = {}) {
84
+ const logsDir = path.join(dataDir, 'logs');
85
+ await mkdir(logsDir, { recursive: true });
86
+ const stamp = nowIso().replace(/[:.]/g, '-');
87
+ const suffix = sanitizePathToken(runId || process.pid, detachedWorker ? 'detached' : 'run');
88
+ const logPath = path.join(logsDir, `run-${stamp}-${suffix}.log`);
89
+
90
+ let writeQueue = Promise.resolve();
91
+ const enqueueWrite = (line) => {
92
+ writeQueue = writeQueue.then(() => appendFile(logPath, line, 'utf8')).catch(() => {});
93
+ return writeQueue;
94
+ };
95
+
96
+ const write = (level, sink, args) => {
97
+ const message = formatLogLineArgs(args);
98
+ enqueueWrite(`[${nowIso()}] [${level}] ${message}\n`);
99
+ if (sink === 'stdout' && shouldPrintToMinimalTerminal(message)) {
100
+ process.stdout.write(`${message}\n`);
101
+ return;
102
+ }
103
+ if (sink === 'stderr' && shouldPrintToMinimalTerminal(message)) {
104
+ process.stderr.write(`${message}\n`);
105
+ }
106
+ };
107
+
108
+ const logger = {
109
+ log: (...args) => write('INFO', 'stdout', args),
110
+ info: (...args) => write('INFO', 'stdout', args),
111
+ warn: (...args) => write('WARN', 'stderr', args),
112
+ error: (...args) => write('ERROR', 'stderr', args),
113
+ };
114
+
115
+ enqueueWrite(`[${nowIso()}] [INFO] run-log-created path=${logPath}\n`);
116
+
117
+ return {
118
+ logger,
119
+ logPath,
120
+ flush: () => writeQueue,
121
+ };
122
+ }
123
+
124
+ function nowIso() {
125
+ return new Date().toISOString();
126
+ }
127
+
128
+ function parseBooleanFlag(value, fallback = true) {
129
+ if (value === undefined) return fallback;
130
+ const normalized = String(value).trim().toLowerCase();
131
+ if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) return true;
132
+ if (['false', '0', 'no', 'n', 'off'].includes(normalized)) return false;
133
+ return fallback;
134
+ }
135
+
136
+ function parseStartFrom(value, fallback = 'unread') {
137
+ if (value === undefined) return fallback;
138
+ const normalized = String(value).trim().toLowerCase();
139
+ if (['all', '全部', '2'].includes(normalized)) return 'all';
140
+ if (['unread', '未读', '1'].includes(normalized)) return 'unread';
141
+ return fallback;
142
+ }
143
+
144
+ function isUnlimitedTargetCountToken(value) {
145
+ const token = String(value || '').trim().toLowerCase();
146
+ if (!token) return false;
147
+ return [
148
+ 'all',
149
+ 'unlimited',
150
+ 'infinity',
151
+ 'inf',
152
+ 'max',
153
+ 'full',
154
+ 'allcandidates',
155
+ '全部',
156
+ '全量',
157
+ '不限',
158
+ '扫到底',
159
+ '全部候选人',
160
+ '所有候选人',
161
+ '全部人选',
162
+ '所有人选',
163
+ '直到完成所有人选',
164
+ ].includes(token);
165
+ }
166
+
167
+ function parseTargetCount(value) {
168
+ if (value === undefined || value === null || String(value).trim() === '') {
169
+ return null;
170
+ }
171
+ if (isUnlimitedTargetCountToken(value)) {
172
+ return -1;
173
+ }
174
+ const parsed = Number.parseInt(String(value), 10);
175
+ if (Number.isFinite(parsed) && parsed === -1) {
176
+ return -1;
177
+ }
178
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
179
+ }
180
+
181
+ function parseArgs(argv) {
182
+ const args = {
183
+ command: 'run',
184
+ profile: 'default',
185
+ dryRun: false,
186
+ noState: false,
187
+ json: false,
188
+ runId: '',
189
+ detachedWorker: false,
190
+ overrides: {
191
+ startFrom: undefined,
192
+ targetCount: undefined,
193
+ screeningCriteria: undefined,
194
+ jobSelection: undefined,
195
+ llm: {},
196
+ chrome: {},
197
+ runtime: {},
198
+ },
199
+ };
200
+
201
+ const positionals = [];
202
+ for (let index = 0; index < argv.length; index += 1) {
203
+ const token = argv[index];
204
+ if (!token.startsWith('--')) {
205
+ positionals.push(token);
206
+ continue;
207
+ }
208
+
209
+ const name = token.slice(2);
210
+ const next = argv[index + 1];
211
+ const value = next && !next.startsWith('--') ? next : undefined;
212
+ if (value !== undefined) {
213
+ index += 1;
214
+ }
215
+
216
+ switch (name) {
217
+ case 'profile':
218
+ args.profile = value || args.profile;
219
+ break;
220
+ case 'dry-run':
221
+ args.dryRun = true;
222
+ break;
223
+ case 'no-state':
224
+ args.noState = true;
225
+ break;
226
+ case 'json':
227
+ args.json = true;
228
+ break;
229
+ case 'run-id':
230
+ case 'runId':
231
+ args.runId = String(value || '').trim();
232
+ break;
233
+ case 'detached-worker':
234
+ args.detachedWorker = true;
235
+ break;
236
+ case 'targetCount':
237
+ args.overrides.targetCount = parseTargetCount(value);
238
+ break;
239
+ case 'start-from':
240
+ case 'startFrom':
241
+ args.overrides.startFrom = parseStartFrom(value, 'unread');
242
+ break;
243
+ case 'criteria':
244
+ case 'screeningCriteria':
245
+ args.overrides.screeningCriteria = String(value || '').trim();
246
+ break;
247
+ case 'job':
248
+ case 'jobSelection':
249
+ args.overrides.jobSelection = String(value || '').trim();
250
+ break;
251
+ case 'baseurl':
252
+ case 'baseUrl':
253
+ args.overrides.llm.baseUrl = value || '';
254
+ break;
255
+ case 'apikey':
256
+ case 'apiKey':
257
+ args.overrides.llm.apiKey = value || '';
258
+ break;
259
+ case 'model':
260
+ args.overrides.llm.model = value || '';
261
+ break;
262
+ case 'thinking-level':
263
+ case 'thinkingLevel':
264
+ case 'llm-thinking-level':
265
+ case 'reasoning-effort':
266
+ args.overrides.llm.thinkingLevel = value || '';
267
+ break;
268
+ case 'llm-timeout-ms':
269
+ case 'timeout-ms':
270
+ args.overrides.llm.timeoutMs = Number.parseInt(value, 10);
271
+ break;
272
+ case 'llm-max-retries':
273
+ case 'max-retries':
274
+ args.overrides.llm.maxRetries = Number.parseInt(value, 10);
275
+ break;
276
+ case 'port':
277
+ args.overrides.chrome.port = Number.parseInt(value, 10);
278
+ break;
279
+ case 'safe-pacing':
280
+ args.overrides.runtime.safePacing = parseBooleanFlag(value, true);
281
+ break;
282
+ case 'batch-rest':
283
+ args.overrides.runtime.batchRestEnabled = parseBooleanFlag(value, true);
284
+ break;
285
+ case 'help':
286
+ args.command = 'help';
287
+ break;
288
+ default:
289
+ throw new Error(`Unknown option: --${name}`);
290
+ }
291
+ }
292
+
293
+ if (positionals.length > 0) {
294
+ args.command = positionals[0];
295
+ }
296
+ return args;
297
+ }
298
+
299
+ function printUsage() {
300
+ console.log('Usage: boss-chat <command> [options]');
301
+ console.log('');
302
+ console.log('Commands:');
303
+ console.log(' run Interactive/manual run');
304
+ console.log(' prepare-run Preflight chat page and list jobs for required input collection');
305
+ console.log(' start-run Start async run and return run_id');
306
+ console.log(' get-run Query async run status');
307
+ console.log(' pause-run Request async run pause');
308
+ console.log(' resume-run Resume paused async run');
309
+ console.log(' cancel-run Cancel async run');
310
+ console.log('');
311
+ console.log('Common options:');
312
+ console.log(' --profile <name> Profile name (default: default)');
313
+ console.log(' --json JSON output for agent integration');
314
+ console.log(' --run-id <id> Target async run_id (for get/pause/resume/cancel)');
315
+ console.log('');
316
+ console.log('Run options:');
317
+ console.log(' --dry-run Evaluate and click, but do not request resume');
318
+ console.log(' --no-state Disable in-run candidate deduplication');
319
+ console.log(' --job <text|value|index> Select job by label/value/index');
320
+ console.log(' --criteria <text> Screening criteria for resume evaluation');
321
+ console.log(' --start-from <unread|all> Start from unread or all list');
322
+ console.log(' --targetCount <n|all> Maximum candidates to process; all means unlimited');
323
+ console.log(' --baseurl <url> Override LLM base URL');
324
+ console.log(' --apikey <key> Override LLM API key');
325
+ console.log(' --model <name> Override LLM model');
326
+ console.log(' --thinking-level <level> LLM thinking level: off|low|medium|high|current');
327
+ console.log(' --llm-timeout-ms <n> Override per-request LLM timeout (default: 60000)');
328
+ console.log(' --llm-max-retries <n> Override per-request LLM retry count (default: 3)');
329
+ console.log(' --port <n> Override Chrome remote debugging port');
330
+ }
331
+
332
+ function outputCommandResult(args, payload) {
333
+ if (args.json) {
334
+ console.log(JSON.stringify(payload));
335
+ return;
336
+ }
337
+
338
+ if (payload?.status) {
339
+ console.log(`status: ${payload.status}`);
340
+ }
341
+ if (payload?.run_id) {
342
+ console.log(`run_id: ${payload.run_id}`);
343
+ }
344
+ if (payload?.message) {
345
+ console.log(payload.message);
346
+ }
347
+ if (payload?.error?.message) {
348
+ console.log(`error: ${payload.error.message}`);
349
+ }
350
+ if (!payload?.status && !payload?.message && !payload?.error) {
351
+ console.log(JSON.stringify(payload, null, 2));
352
+ }
353
+ }
354
+
355
+ function setupRuntimeControls(runControl) {
356
+ if (!process.stdin.isTTY) {
357
+ return () => {};
358
+ }
359
+
360
+ readlineCore.emitKeypressEvents(process.stdin);
361
+ if (typeof process.stdin.setRawMode === 'function') {
362
+ process.stdin.setRawMode(true);
363
+ }
364
+
365
+ const onKeypress = (_str, key) => {
366
+ if (key?.ctrl && key.name === 'c') {
367
+ runControl.requestStop('收到 Ctrl+C');
368
+ return;
369
+ }
370
+
371
+ if (key?.name === 'p') {
372
+ runControl.togglePause();
373
+ return;
374
+ }
375
+
376
+ if (key?.name === 'r') {
377
+ runControl.resume();
378
+ return;
379
+ }
380
+
381
+ if (key?.name === 'q') {
382
+ runControl.requestStop('用户请求停止');
383
+ }
384
+ };
385
+
386
+ process.stdin.on('keypress', onKeypress);
387
+
388
+ return () => {
389
+ process.stdin.off('keypress', onKeypress);
390
+ if (typeof process.stdin.setRawMode === 'function') {
391
+ process.stdin.setRawMode(false);
392
+ }
393
+ };
394
+ }
395
+
396
+ function startDetachedControlSync({ dataDir, runId, runControl }) {
397
+ let lastHeartbeatAt = 0;
398
+ let inTick = false;
399
+
400
+ const timer = setInterval(() => {
401
+ if (inTick) return;
402
+ inTick = true;
403
+ try {
404
+ const snapshot = readRunState(dataDir, runId);
405
+ if (!snapshot) return;
406
+
407
+ const control = snapshot.control || {};
408
+ if (control.cancelRequested && !runControl.isStopping()) {
409
+ runControl.requestStop('收到 cancel-run 请求');
410
+ appendRunEvent(dataDir, runId, {
411
+ type: 'control',
412
+ action: 'cancel-request-observed',
413
+ message: '检测到 cancel-run 请求,准备安全停止。',
414
+ });
415
+ }
416
+
417
+ if (control.pauseRequested) {
418
+ if (!runControl.isPaused() && !runControl.isStopping()) {
419
+ runControl.pause();
420
+ updateRunState(dataDir, runId, {
421
+ state: RUN_STATE_PAUSED,
422
+ stage: 'running',
423
+ heartbeatAt: nowIso(),
424
+ lastMessage: '运行已暂停(来自 pause-run 请求)。',
425
+ });
426
+ }
427
+ } else if (runControl.isPaused() && !runControl.isStopping()) {
428
+ runControl.resume();
429
+ updateRunState(dataDir, runId, {
430
+ state: RUN_STATE_RUNNING,
431
+ stage: 'running',
432
+ heartbeatAt: nowIso(),
433
+ lastMessage: '运行已继续(来自 resume-run 请求)。',
434
+ });
435
+ }
436
+
437
+ const now = Date.now();
438
+ if (now - lastHeartbeatAt >= 5000) {
439
+ updateRunState(dataDir, runId, {
440
+ heartbeatAt: nowIso(),
441
+ state: runControl.isPaused() ? RUN_STATE_PAUSED : RUN_STATE_RUNNING,
442
+ });
443
+ lastHeartbeatAt = now;
444
+ }
445
+ } catch {} finally {
446
+ inTick = false;
447
+ }
448
+ }, 700);
449
+
450
+ return () => clearInterval(timer);
451
+ }
452
+
453
+ async function promptPersistentLlmIfMissing(profile, profileName) {
454
+ const missing = validateProfile(profile);
455
+ if (missing.length === 0) {
456
+ return normalizeProfile(profile);
457
+ }
458
+
459
+ if (!process.stdin.isTTY) {
460
+ throw new Error(
461
+ `Profile "${profileName}" 缺少必要配置:${missing.join(', ')}。当前为非交互模式,请先补齐 profile 或通过参数传入。`,
462
+ );
463
+ }
464
+
465
+ const rl = readline.createInterface({
466
+ input: process.stdin,
467
+ output: process.stdout,
468
+ });
469
+
470
+ try {
471
+ console.log(`Profile "${profileName}" 缺少 LLM/Chrome 必要配置,开始交互填写。`);
472
+ if (!profile.llm.baseUrl) {
473
+ profile.llm.baseUrl = await rl.question('LLM baseUrl: ');
474
+ }
475
+ if (!profile.llm.apiKey) {
476
+ profile.llm.apiKey = await rl.question('LLM apiKey: ');
477
+ }
478
+ if (!profile.llm.model) {
479
+ profile.llm.model = await rl.question('LLM model: ');
480
+ }
481
+ profile.chrome.port =
482
+ (await rl.question(`Chrome 远程调试端口 [${profile.chrome.port || 9222}]: `)) ||
483
+ profile.chrome.port ||
484
+ 9222;
485
+ } finally {
486
+ rl.close();
487
+ }
488
+
489
+ return normalizeProfile(profile);
490
+ }
491
+
492
+ function resolveJobSelection(jobs, input) {
493
+ const normalizedInput = String(input || '').trim();
494
+ if (!normalizedInput) return null;
495
+
496
+ const asIndex = Number.parseInt(normalizedInput, 10);
497
+ if (Number.isFinite(asIndex) && asIndex >= 1 && asIndex <= jobs.length) {
498
+ return jobs[asIndex - 1];
499
+ }
500
+
501
+ const byValue = jobs.find((job) => String(job.value || '').trim() === normalizedInput);
502
+ if (byValue) return byValue;
503
+
504
+ const byExactLabel = jobs.find((job) => String(job.label || '').trim() === normalizedInput);
505
+ if (byExactLabel) return byExactLabel;
506
+
507
+ const normalizedLower = normalizedInput.toLowerCase();
508
+ const fuzzy = jobs.filter((job) =>
509
+ String(job.label || '').toLowerCase().includes(normalizedLower),
510
+ );
511
+ if (fuzzy.length === 1) return fuzzy[0];
512
+ if (fuzzy.length > 1) {
513
+ throw new Error('岗位选择有歧义,请输入编号或完整岗位名。');
514
+ }
515
+
516
+ return null;
517
+ }
518
+
519
+ async function promptRunProfile({ page, persistentProfile, overrides }) {
520
+ const jobs = await resolveJobsWithRetry({ page });
521
+ if (!Array.isArray(jobs) || jobs.length === 0) {
522
+ throw new Error('未解析到岗位列表,请确认岗位下拉可见。');
523
+ }
524
+
525
+ let selectedJob = null;
526
+ if (overrides.jobSelection) {
527
+ selectedJob = resolveJobSelection(jobs, overrides.jobSelection);
528
+ if (!selectedJob) {
529
+ throw new Error(`未找到岗位: ${overrides.jobSelection}`);
530
+ }
531
+ }
532
+
533
+ let startFrom = overrides.startFrom;
534
+ let screeningCriteria = overrides.screeningCriteria;
535
+ let targetCount = overrides.targetCount;
536
+
537
+ if (process.stdin.isTTY) {
538
+ const rl = readline.createInterface({
539
+ input: process.stdin,
540
+ output: process.stdout,
541
+ });
542
+ try {
543
+ if (!selectedJob) {
544
+ console.log('可选岗位:');
545
+ jobs.forEach((job, index) => {
546
+ console.log(` ${index + 1}. ${job.label}${job.active ? ' (当前)' : ''}`);
547
+ });
548
+ const answer = await rl.question('请选择岗位编号: ');
549
+ selectedJob = resolveJobSelection(jobs, answer);
550
+ if (!selectedJob) {
551
+ throw new Error('岗位选择无效。');
552
+ }
553
+ }
554
+
555
+ if (!startFrom) {
556
+ const answer = await rl.question('列表范围 [1=未读, 2=全部] (1): ');
557
+ startFrom = parseStartFrom(answer, 'unread');
558
+ }
559
+
560
+ if (!screeningCriteria) {
561
+ screeningCriteria = String(await rl.question('筛选标准: ')).trim();
562
+ }
563
+
564
+ if (targetCount === undefined) {
565
+ const answer = await rl.question('本次处理人数上限(回车=扫到底): ');
566
+ targetCount = parseTargetCount(answer);
567
+ }
568
+ } finally {
569
+ rl.close();
570
+ }
571
+ }
572
+
573
+ if (!selectedJob) {
574
+ selectedJob = jobs[0];
575
+ }
576
+ if (!startFrom) {
577
+ startFrom = 'unread';
578
+ }
579
+ if (!screeningCriteria) {
580
+ throw new Error('筛选标准不能为空(可通过 --criteria 传入,或在交互中输入)。');
581
+ }
582
+
583
+ return normalizeProfile({
584
+ ...persistentProfile,
585
+ jobSelection: {
586
+ value: selectedJob.value,
587
+ label: selectedJob.label,
588
+ },
589
+ startFrom,
590
+ screeningCriteria,
591
+ targetCount: targetCount ?? null,
592
+ });
593
+ }
594
+
595
+ function validateStartRunArgs(args) {
596
+ const missing = [];
597
+ if (!args?.overrides?.jobSelection) missing.push('--job');
598
+ if (!args?.overrides?.startFrom) missing.push('--start-from');
599
+ if (args?.overrides?.targetCount === undefined || args?.overrides?.targetCount === null) {
600
+ missing.push('--targetCount');
601
+ }
602
+ if (!args?.overrides?.screeningCriteria) missing.push('--criteria');
603
+
604
+ if (missing.length === 0) return null;
605
+ return {
606
+ status: 'FAILED',
607
+ error: {
608
+ code: 'MISSING_REQUIRED_ARGS',
609
+ message: `start-run 缺少必要参数:${missing.join(', ')}`,
610
+ retryable: false,
611
+ },
612
+ };
613
+ }
614
+
615
+ function buildPreparePendingQuestions(args, jobs = []) {
616
+ const pendingQuestions = [];
617
+ const startFromValue = String(args?.overrides?.startFrom || '').trim().toLowerCase();
618
+ const targetCountValue = Number.parseInt(String(args?.overrides?.targetCount ?? ''), 10);
619
+ const hasTargetCount =
620
+ args?.overrides?.targetCount !== undefined &&
621
+ args?.overrides?.targetCount !== null &&
622
+ Number.isFinite(targetCountValue) &&
623
+ (targetCountValue > 0 || targetCountValue === -1);
624
+ const criteriaValue = String(args?.overrides?.screeningCriteria || '').trim();
625
+ const jobValue = String(args?.overrides?.jobSelection || '').trim();
626
+ const jobOptions = jobs.map((job, index) => ({
627
+ label: `${index + 1}. ${job.label}${job.active ? '(当前)' : ''}`,
628
+ value: String(job.value || job.label || ''),
629
+ index: index + 1,
630
+ active: Boolean(job.active),
631
+ }));
632
+
633
+ if (!jobValue) {
634
+ pendingQuestions.push({
635
+ field: 'job',
636
+ question: '请选择岗位(必须从岗位列表中选择)',
637
+ required: true,
638
+ options: jobOptions,
639
+ });
640
+ }
641
+ if (!['unread', 'all'].includes(startFromValue)) {
642
+ pendingQuestions.push({
643
+ field: 'start_from',
644
+ question: '请选择起始范围',
645
+ required: true,
646
+ options: [
647
+ { label: '未读', value: 'unread' },
648
+ { label: '全部', value: 'all' },
649
+ ],
650
+ });
651
+ }
652
+ if (!hasTargetCount) {
653
+ pendingQuestions.push({
654
+ field: 'target_count',
655
+ question: '请输入目标数量(正整数)或 all(扫到底)',
656
+ required: true,
657
+ });
658
+ }
659
+ if (!criteriaValue) {
660
+ pendingQuestions.push({
661
+ field: 'criteria',
662
+ question: '请输入筛选标准(自然语言)',
663
+ required: true,
664
+ });
665
+ }
666
+ return pendingQuestions;
667
+ }
668
+
669
+ function hasHydratedChatShell(pageState, jobs = []) {
670
+ const hasChatList =
671
+ Boolean(pageState?.hasListContainer)
672
+ || Number(pageState?.listItemCount || 0) > 0;
673
+ return hasChatList || (Array.isArray(jobs) && jobs.length > 0);
674
+ }
675
+
676
+ async function waitForChatShellHydration({
677
+ page,
678
+ maxAttempts = CHAT_PAGE_HYDRATION_MAX_ATTEMPTS,
679
+ delayMs = CHAT_PAGE_HYDRATION_DELAY_MS,
680
+ } = {}) {
681
+ let lastPageState = null;
682
+ let lastJobs = [];
683
+ let lastError = null;
684
+
685
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
686
+ try {
687
+ lastPageState = await page.ensureOnChatPage();
688
+ } catch (error) {
689
+ lastError = error;
690
+ break;
691
+ }
692
+
693
+ try {
694
+ lastJobs = await page.listJobs();
695
+ } catch (error) {
696
+ lastError = error;
697
+ lastJobs = [];
698
+ }
699
+
700
+ if (hasHydratedChatShell(lastPageState, lastJobs)) {
701
+ return {
702
+ pageState: lastPageState,
703
+ jobs: lastJobs,
704
+ };
705
+ }
706
+
707
+ if (attempt < maxAttempts) {
708
+ await sleep(delayMs);
709
+ }
710
+ }
711
+
712
+ const lastErrorMessage = String(lastError?.message || lastError || '');
713
+ if (/ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE/.test(lastErrorMessage)) {
714
+ throw lastError;
715
+ }
716
+
717
+ return {
718
+ pageState: lastPageState,
719
+ jobs: lastJobs,
720
+ };
721
+ }
722
+
723
+ async function resolveJobsWithRetry({
724
+ page,
725
+ maxAttempts = CHAT_JOB_LIST_MAX_ATTEMPTS,
726
+ delayMs = CHAT_JOB_LIST_DELAY_MS,
727
+ } = {}) {
728
+ let lastError = null;
729
+
730
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
731
+ try {
732
+ const jobs = await page.listJobs();
733
+ if (Array.isArray(jobs) && jobs.length > 0) {
734
+ return jobs;
735
+ }
736
+ } catch (error) {
737
+ lastError = error;
738
+ const message = String(error?.message || error || '');
739
+ if (/ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE/.test(message)) {
740
+ throw error;
741
+ }
742
+ }
743
+
744
+ const hydrated = await waitForChatShellHydration({
745
+ page,
746
+ maxAttempts: 1,
747
+ delayMs,
748
+ });
749
+ if (Array.isArray(hydrated?.jobs) && hydrated.jobs.length > 0) {
750
+ return hydrated.jobs;
751
+ }
752
+
753
+ if (attempt < maxAttempts) {
754
+ await sleep(delayMs);
755
+ }
756
+ }
757
+
758
+ if (lastError) {
759
+ throw lastError;
760
+ }
761
+
762
+ return [];
763
+ }
764
+
765
+ async function connectBossChatPage(chromeClient) {
766
+ const isBossDomainTarget = (target) =>
767
+ target?.type === 'page' && /zhipin\.com/i.test(String(target?.url || ''));
768
+ let target = null;
769
+ let recoveredToChatIndex = false;
770
+ let blankChatPage = false;
771
+ let renavigateAttempts = 0;
772
+
773
+ try {
774
+ target = await chromeClient.connect(BossChatPage.targetMatcher);
775
+ } catch {
776
+ target = await chromeClient.connect(isBossDomainTarget);
777
+ }
778
+
779
+ const page = new BossChatPage(chromeClient);
780
+ for (let attempt = 1; attempt <= CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS + 1; attempt += 1) {
781
+ try {
782
+ await page.ensureReady();
783
+ return {
784
+ target,
785
+ page,
786
+ recoveredToChatIndex,
787
+ blankChatPage,
788
+ renavigateAttempts,
789
+ };
790
+ } catch (error) {
791
+ const message = String(error?.message || error || '');
792
+ if (/CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)) {
793
+ blankChatPage = true;
794
+ const hydrated = await waitForChatShellHydration({ page });
795
+ if (hasHydratedChatShell(hydrated?.pageState, hydrated?.jobs)) {
796
+ return {
797
+ target,
798
+ page,
799
+ recoveredToChatIndex,
800
+ blankChatPage,
801
+ renavigateAttempts,
802
+ };
803
+ }
804
+ }
805
+ const canRetry =
806
+ /ACTIVE_TAB_IS_NOT_BOSS_CHAT_PAGE|CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)
807
+ && attempt <= CHAT_PAGE_RENAVIGATE_MAX_ATTEMPTS;
808
+
809
+ if (!canRetry) {
810
+ if (/CHAT_LIST_CONTAINER_NOT_FOUND/.test(message)) {
811
+ await page.ensureOnChatPage();
812
+ break;
813
+ }
814
+ throw error;
815
+ }
816
+
817
+ await page.recoverToChatIndex({
818
+ forceNavigate: true,
819
+ waitForReadyState: 'complete',
820
+ });
821
+ recoveredToChatIndex = true;
822
+ renavigateAttempts += 1;
823
+ }
824
+ }
825
+
826
+ return { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts };
827
+ }
828
+
829
+ async function handlePrepareRunCommand(args, dataDir) {
830
+ const profileStore = new ProfileStore(dataDir);
831
+ const savedProfile = (await profileStore.load(args.profile)) || {};
832
+ const mergedProfile = normalizeProfile({
833
+ ...savedProfile,
834
+ llm: {
835
+ ...(savedProfile.llm || {}),
836
+ ...(args.overrides.llm || {}),
837
+ },
838
+ chrome: {
839
+ ...(savedProfile.chrome || {}),
840
+ ...(args.overrides.chrome || {}),
841
+ },
842
+ runtime: {
843
+ ...(savedProfile.runtime || {}),
844
+ ...(args.overrides.runtime || {}),
845
+ },
846
+ });
847
+
848
+ const missingProfileConfig = validateProfile(mergedProfile);
849
+ if (missingProfileConfig.length > 0) {
850
+ return {
851
+ status: 'FAILED',
852
+ error: {
853
+ code: 'PROFILE_CONFIG_MISSING',
854
+ message: `profile 配置缺失:${missingProfileConfig.join(', ')}`,
855
+ retryable: false,
856
+ },
857
+ };
858
+ }
859
+
860
+ let chromeClient = null;
861
+ try {
862
+ chromeClient = new ChromeClient(mergedProfile.chrome.port);
863
+ const { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts } = await connectBossChatPage(chromeClient);
864
+ const jobs = await resolveJobsWithRetry({ page });
865
+ if (!Array.isArray(jobs) || jobs.length === 0) {
866
+ return {
867
+ status: 'FAILED',
868
+ error: {
869
+ code: 'CHAT_JOB_LIST_EMPTY',
870
+ message: '未解析到岗位列表,请先在聊天页确认岗位下拉可见后重试。',
871
+ retryable: true,
872
+ },
873
+ };
874
+ }
875
+
876
+ return {
877
+ status: 'NEED_INPUT',
878
+ stage: 'chat_run_setup',
879
+ page_url: CHAT_INDEX_URL,
880
+ connected_target: target?.url || '',
881
+ recovered_to_chat_index: recoveredToChatIndex,
882
+ blank_chat_page: blankChatPage,
883
+ renavigate_attempts: renavigateAttempts,
884
+ required_fields: CHAT_START_REQUIRED_FIELDS.slice(),
885
+ defaults: {
886
+ profile: String(args.profile || 'default').trim() || 'default',
887
+ start_from: 'unread',
888
+ },
889
+ job_options: jobs.map((job, index) => ({
890
+ index: index + 1,
891
+ label: String(job.label || ''),
892
+ value: String(job.value || job.label || ''),
893
+ active: Boolean(job.active),
894
+ })),
895
+ pending_questions: buildPreparePendingQuestions(args, jobs),
896
+ message:
897
+ '已导航至 Boss 聊天页并加载岗位列表。请补齐 job / start_from / target_count / criteria 后再次调用 start-run。',
898
+ };
899
+ } catch (error) {
900
+ return {
901
+ status: 'FAILED',
902
+ error: {
903
+ code: 'CHAT_PREPARE_FAILED',
904
+ message: error?.message || 'prepare-run 执行失败。',
905
+ retryable: true,
906
+ },
907
+ };
908
+ } finally {
909
+ if (chromeClient) {
910
+ await chromeClient.close();
911
+ }
912
+ }
913
+ }
914
+
915
+ function buildDetachedRunArgs(args, runId) {
916
+ const workerArgs = [CLI_FILE_PATH, 'run', '--detached-worker', '--run-id', runId];
917
+ workerArgs.push('--profile', args.profile);
918
+ workerArgs.push('--job', String(args.overrides.jobSelection));
919
+ workerArgs.push('--start-from', String(args.overrides.startFrom));
920
+ workerArgs.push('--criteria', String(args.overrides.screeningCriteria));
921
+
922
+ if (args.dryRun) workerArgs.push('--dry-run');
923
+ if (args.noState) workerArgs.push('--no-state');
924
+ if (args.overrides.targetCount !== undefined && args.overrides.targetCount !== null) {
925
+ workerArgs.push('--targetCount', String(args.overrides.targetCount));
926
+ }
927
+ if (args.overrides.llm.baseUrl) {
928
+ workerArgs.push('--baseurl', String(args.overrides.llm.baseUrl));
929
+ }
930
+ if (args.overrides.llm.apiKey) {
931
+ workerArgs.push('--apikey', String(args.overrides.llm.apiKey));
932
+ }
933
+ if (args.overrides.llm.model) {
934
+ workerArgs.push('--model', String(args.overrides.llm.model));
935
+ }
936
+ if (args.overrides.llm.thinkingLevel) {
937
+ workerArgs.push('--thinking-level', String(args.overrides.llm.thinkingLevel));
938
+ }
939
+ if (Number.isFinite(args.overrides.llm.timeoutMs) && args.overrides.llm.timeoutMs > 0) {
940
+ workerArgs.push('--llm-timeout-ms', String(args.overrides.llm.timeoutMs));
941
+ }
942
+ if (Number.isFinite(args.overrides.llm.maxRetries) && args.overrides.llm.maxRetries > 0) {
943
+ workerArgs.push('--llm-max-retries', String(args.overrides.llm.maxRetries));
944
+ }
945
+ if (Number.isFinite(args.overrides.chrome.port)) {
946
+ workerArgs.push('--port', String(args.overrides.chrome.port));
947
+ }
948
+ if (Object.prototype.hasOwnProperty.call(args.overrides.runtime, 'safePacing')) {
949
+ workerArgs.push('--safe-pacing', String(Boolean(args.overrides.runtime.safePacing)));
950
+ }
951
+ if (Object.prototype.hasOwnProperty.call(args.overrides.runtime, 'batchRestEnabled')) {
952
+ workerArgs.push('--batch-rest', String(Boolean(args.overrides.runtime.batchRestEnabled)));
953
+ }
954
+
955
+ return workerArgs;
956
+ }
957
+
958
+ async function handleStartRunCommand(args, dataDir) {
959
+ const validateError = validateStartRunArgs(args);
960
+ if (validateError) return validateError;
961
+
962
+ const profileStore = new ProfileStore(dataDir);
963
+ const savedProfile = (await profileStore.load(args.profile)) || {};
964
+ const mergedProfile = normalizeProfile({
965
+ ...savedProfile,
966
+ llm: {
967
+ ...(savedProfile.llm || {}),
968
+ ...(args.overrides.llm || {}),
969
+ },
970
+ chrome: {
971
+ ...(savedProfile.chrome || {}),
972
+ ...(args.overrides.chrome || {}),
973
+ },
974
+ runtime: {
975
+ ...(savedProfile.runtime || {}),
976
+ ...(args.overrides.runtime || {}),
977
+ },
978
+ });
979
+ const missingProfileConfig = validateProfile(mergedProfile);
980
+ if (missingProfileConfig.length > 0) {
981
+ return {
982
+ status: 'FAILED',
983
+ error: {
984
+ code: 'PROFILE_CONFIG_MISSING',
985
+ message: `profile 配置缺失:${missingProfileConfig.join(', ')}`,
986
+ retryable: false,
987
+ },
988
+ };
989
+ }
990
+
991
+ const runId = createRunId();
992
+ const snapshot = createRunStateSnapshot({
993
+ runId,
994
+ state: RUN_STATE_QUEUED,
995
+ stage: 'preflight',
996
+ lastMessage: '异步任务已创建,等待 detached worker 启动。',
997
+ request: {
998
+ profile: args.profile,
999
+ dryRun: Boolean(args.dryRun),
1000
+ noState: Boolean(args.noState),
1001
+ input: {
1002
+ job: String(args.overrides.jobSelection || ''),
1003
+ startFrom: String(args.overrides.startFrom || ''),
1004
+ criteria: String(args.overrides.screeningCriteria || ''),
1005
+ targetCount: args.overrides.targetCount ?? null,
1006
+ },
1007
+ },
1008
+ });
1009
+ writeRunState(dataDir, snapshot);
1010
+ appendRunEvent(dataDir, runId, {
1011
+ type: 'lifecycle',
1012
+ action: 'accepted',
1013
+ state: RUN_STATE_QUEUED,
1014
+ message: '异步任务已接受。',
1015
+ });
1016
+
1017
+ let worker = null;
1018
+ try {
1019
+ worker = spawn(process.execPath, buildDetachedRunArgs(args, runId), {
1020
+ cwd: process.cwd(),
1021
+ detached: true,
1022
+ stdio: 'ignore',
1023
+ windowsHide: true,
1024
+ });
1025
+ worker.unref();
1026
+ } catch (error) {
1027
+ const message = `无法启动 detached worker:${error?.message || 'unknown error'}`;
1028
+ updateRunState(dataDir, runId, {
1029
+ state: RUN_STATE_FAILED,
1030
+ stage: 'preflight',
1031
+ heartbeatAt: nowIso(),
1032
+ lastMessage: message,
1033
+ error: {
1034
+ code: 'RUN_WORKER_LAUNCH_FAILED',
1035
+ message,
1036
+ retryable: true,
1037
+ },
1038
+ });
1039
+ return {
1040
+ status: 'FAILED',
1041
+ run_id: runId,
1042
+ error: {
1043
+ code: 'RUN_WORKER_LAUNCH_FAILED',
1044
+ message,
1045
+ retryable: true,
1046
+ },
1047
+ };
1048
+ }
1049
+
1050
+ updateRunState(dataDir, runId, {
1051
+ pid: worker?.pid,
1052
+ state: RUN_STATE_QUEUED,
1053
+ stage: 'preflight',
1054
+ heartbeatAt: nowIso(),
1055
+ lastMessage: '异步任务已启动(detached)。',
1056
+ });
1057
+ appendRunEvent(dataDir, runId, {
1058
+ type: 'lifecycle',
1059
+ action: 'detached-started',
1060
+ state: RUN_STATE_QUEUED,
1061
+ pid: worker?.pid || null,
1062
+ message: 'detached worker 已启动。',
1063
+ });
1064
+
1065
+ return {
1066
+ status: 'ACCEPTED',
1067
+ run_id: runId,
1068
+ state: RUN_STATE_QUEUED,
1069
+ message: '异步任务已启动。默认不自动查询进度;如需进度请调用 get-run。',
1070
+ };
1071
+ }
1072
+
1073
+ function buildRunNotFound(runId) {
1074
+ return {
1075
+ status: 'FAILED',
1076
+ error: {
1077
+ code: 'RUN_NOT_FOUND',
1078
+ message: `未找到 run_id=${runId} 的运行记录。`,
1079
+ retryable: false,
1080
+ },
1081
+ };
1082
+ }
1083
+
1084
+ function readRunOrError(args, dataDir) {
1085
+ const runId = String(args.runId || '').trim();
1086
+ if (!runId) {
1087
+ return {
1088
+ error: {
1089
+ status: 'FAILED',
1090
+ error: {
1091
+ code: 'INVALID_RUN_ID',
1092
+ message: 'run_id is required',
1093
+ retryable: false,
1094
+ },
1095
+ },
1096
+ runId: '',
1097
+ snapshot: null,
1098
+ };
1099
+ }
1100
+
1101
+ const snapshot = readRunState(dataDir, runId);
1102
+ if (!snapshot) {
1103
+ return {
1104
+ error: buildRunNotFound(runId),
1105
+ runId,
1106
+ snapshot: null,
1107
+ };
1108
+ }
1109
+ return { error: null, runId, snapshot };
1110
+ }
1111
+
1112
+ function handleGetRunCommand(args, dataDir) {
1113
+ const resolved = readRunOrError(args, dataDir);
1114
+ if (resolved.error) return resolved.error;
1115
+
1116
+ return {
1117
+ status: 'RUN_STATUS',
1118
+ run: resolved.snapshot,
1119
+ events_path: getRunEventsPath(dataDir, resolved.runId),
1120
+ message: '按需查询成功。默认不自动轮询。',
1121
+ };
1122
+ }
1123
+
1124
+ function handlePauseRunCommand(args, dataDir) {
1125
+ const resolved = readRunOrError(args, dataDir);
1126
+ if (resolved.error) return resolved.error;
1127
+
1128
+ if (isTerminalRunState(resolved.snapshot.state)) {
1129
+ return {
1130
+ status: 'PAUSE_IGNORED',
1131
+ run: resolved.snapshot,
1132
+ message: '目标任务已结束,无需暂停。',
1133
+ };
1134
+ }
1135
+ if (resolved.snapshot.control?.pauseRequested || resolved.snapshot.state === RUN_STATE_PAUSED) {
1136
+ return {
1137
+ status: 'PAUSE_IGNORED',
1138
+ run: resolved.snapshot,
1139
+ message: '目标任务已处于暂停请求中或已暂停。',
1140
+ };
1141
+ }
1142
+
1143
+ const updated = updateRunState(dataDir, resolved.runId, {
1144
+ control: {
1145
+ pauseRequested: true,
1146
+ cancelRequested: Boolean(resolved.snapshot.control?.cancelRequested),
1147
+ },
1148
+ lastMessage: '已收到暂停请求,将在安全检查点暂停。',
1149
+ heartbeatAt: nowIso(),
1150
+ });
1151
+ appendRunEvent(dataDir, resolved.runId, {
1152
+ type: 'control',
1153
+ action: 'pause-requested',
1154
+ message: '已写入 pauseRequested=true。',
1155
+ });
1156
+
1157
+ return {
1158
+ status: 'PAUSE_REQUESTED',
1159
+ run: updated || resolved.snapshot,
1160
+ message: '暂停请求已接收。',
1161
+ };
1162
+ }
1163
+
1164
+ function handleResumeRunCommand(args, dataDir) {
1165
+ const resolved = readRunOrError(args, dataDir);
1166
+ if (resolved.error) return resolved.error;
1167
+
1168
+ if (isTerminalRunState(resolved.snapshot.state)) {
1169
+ return {
1170
+ status: 'FAILED',
1171
+ error: {
1172
+ code: 'RUN_ALREADY_TERMINATED',
1173
+ message: '目标任务已结束,无法继续。',
1174
+ retryable: false,
1175
+ },
1176
+ run: resolved.snapshot,
1177
+ };
1178
+ }
1179
+
1180
+ if (!resolved.snapshot.control?.pauseRequested && resolved.snapshot.state !== RUN_STATE_PAUSED) {
1181
+ return {
1182
+ status: 'RESUME_IGNORED',
1183
+ run: resolved.snapshot,
1184
+ message: '目标任务未处于暂停状态。',
1185
+ };
1186
+ }
1187
+
1188
+ const updated = updateRunState(dataDir, resolved.runId, {
1189
+ state:
1190
+ resolved.snapshot.state === RUN_STATE_PAUSED ? RUN_STATE_RUNNING : resolved.snapshot.state,
1191
+ control: {
1192
+ pauseRequested: false,
1193
+ cancelRequested: false,
1194
+ },
1195
+ lastMessage: '已收到继续请求,将恢复执行。',
1196
+ heartbeatAt: nowIso(),
1197
+ });
1198
+ appendRunEvent(dataDir, resolved.runId, {
1199
+ type: 'control',
1200
+ action: 'resume-requested',
1201
+ message: '已写入 pauseRequested=false。',
1202
+ });
1203
+
1204
+ return {
1205
+ status: 'RESUME_REQUESTED',
1206
+ run: updated || resolved.snapshot,
1207
+ message: '继续请求已接收。',
1208
+ };
1209
+ }
1210
+
1211
+ function handleCancelRunCommand(args, dataDir) {
1212
+ const resolved = readRunOrError(args, dataDir);
1213
+ if (resolved.error) return resolved.error;
1214
+
1215
+ if (isTerminalRunState(resolved.snapshot.state)) {
1216
+ return {
1217
+ status: 'CANCEL_IGNORED',
1218
+ run: resolved.snapshot,
1219
+ message: '目标任务已结束,无需取消。',
1220
+ };
1221
+ }
1222
+
1223
+ const updated = updateRunState(dataDir, resolved.runId, {
1224
+ control: {
1225
+ pauseRequested: true,
1226
+ cancelRequested: true,
1227
+ },
1228
+ lastMessage: '已收到取消请求,将在安全检查点停止。',
1229
+ heartbeatAt: nowIso(),
1230
+ });
1231
+ appendRunEvent(dataDir, resolved.runId, {
1232
+ type: 'control',
1233
+ action: 'cancel-requested',
1234
+ message: '已写入 cancelRequested=true。',
1235
+ });
1236
+
1237
+ return {
1238
+ status: 'CANCEL_REQUESTED',
1239
+ run: updated || resolved.snapshot,
1240
+ message: '取消请求已接收。',
1241
+ };
1242
+ }
1243
+
1244
+ async function executeRunCommand(args, dataDir) {
1245
+ const asyncMode = Boolean(args.detachedWorker && args.runId);
1246
+ const runId = asyncMode ? String(args.runId || '').trim() : '';
1247
+
1248
+ if (asyncMode && !runId) {
1249
+ throw new Error('detached worker mode requires --run-id');
1250
+ }
1251
+
1252
+ const runLogger = await createRunLogger(dataDir, {
1253
+ runId,
1254
+ detachedWorker: asyncMode,
1255
+ });
1256
+ const logger = runLogger.logger;
1257
+ logger.log(
1258
+ `运行日志已创建: ${runLogger.logPath} | mode=${asyncMode ? 'detached-worker' : 'interactive'}`,
1259
+ );
1260
+
1261
+ const runControl = new RunControl({ logger });
1262
+ let cleanupRuntimeControls = () => {};
1263
+ let cleanupControlSync = () => {};
1264
+ let chromeClient = null;
1265
+
1266
+ try {
1267
+ const profileStore = new ProfileStore(dataDir);
1268
+ const savedProfile = (await profileStore.load(args.profile)) || {};
1269
+ const persistentMerged = normalizeProfile({
1270
+ ...savedProfile,
1271
+ llm: {
1272
+ ...(savedProfile.llm || {}),
1273
+ ...(args.overrides.llm || {}),
1274
+ },
1275
+ chrome: {
1276
+ ...(savedProfile.chrome || {}),
1277
+ ...(args.overrides.chrome || {}),
1278
+ },
1279
+ runtime: {
1280
+ ...(savedProfile.runtime || {}),
1281
+ ...(args.overrides.runtime || {}),
1282
+ },
1283
+ });
1284
+ const persistentProfile = await promptPersistentLlmIfMissing(persistentMerged, args.profile);
1285
+ await profileStore.save(args.profile, toPersistentProfile(persistentProfile));
1286
+
1287
+ if (asyncMode) {
1288
+ const existing = readRunState(dataDir, runId);
1289
+ if (!existing) {
1290
+ writeRunState(
1291
+ dataDir,
1292
+ createRunStateSnapshot({
1293
+ runId,
1294
+ state: RUN_STATE_QUEUED,
1295
+ stage: 'preflight',
1296
+ request: {
1297
+ profile: args.profile,
1298
+ dryRun: Boolean(args.dryRun),
1299
+ noState: Boolean(args.noState),
1300
+ },
1301
+ lastMessage: 'detached worker 直接启动。',
1302
+ }),
1303
+ );
1304
+ }
1305
+
1306
+ updateRunState(dataDir, runId, {
1307
+ pid: process.pid,
1308
+ state: RUN_STATE_RUNNING,
1309
+ stage: 'preflight',
1310
+ heartbeatAt: nowIso(),
1311
+ logPath: runLogger.logPath,
1312
+ lastMessage: 'detached worker 已启动,准备执行。',
1313
+ });
1314
+ appendRunEvent(dataDir, runId, {
1315
+ type: 'lifecycle',
1316
+ action: 'worker-boot',
1317
+ state: RUN_STATE_RUNNING,
1318
+ pid: process.pid,
1319
+ message: 'detached worker 已接管任务。',
1320
+ });
1321
+ cleanupControlSync = startDetachedControlSync({ dataDir, runId, runControl });
1322
+ }
1323
+
1324
+ chromeClient = new ChromeClient(persistentProfile.chrome.port);
1325
+
1326
+ const { target, page, recoveredToChatIndex, blankChatPage, renavigateAttempts } = await connectBossChatPage(chromeClient);
1327
+ logger.log(`已连接 Chrome tab: ${target.title || target.url}`);
1328
+ if (recoveredToChatIndex) {
1329
+ logger.log(`检测到页面不符合预期,已重新跳转到 ${CHAT_INDEX_URL} 并等待加载完成。attempts=${renavigateAttempts}`);
1330
+ }
1331
+ if (blankChatPage) {
1332
+ logger.log('检测到聊天页处于空白未初始化状态,将继续通过岗位选择和首位候选人预热来恢复列表。');
1333
+ }
1334
+
1335
+ const runProfile = await promptRunProfile({
1336
+ page,
1337
+ persistentProfile,
1338
+ overrides: args.overrides,
1339
+ });
1340
+ const appliedJob = await page.selectJob(runProfile.jobSelection);
1341
+ runProfile.jobSelection = {
1342
+ value: appliedJob.value || runProfile.jobSelection.value,
1343
+ label: appliedJob.label || runProfile.jobSelection.label,
1344
+ };
1345
+
1346
+ if (asyncMode) {
1347
+ updateRunState(dataDir, runId, {
1348
+ state: RUN_STATE_RUNNING,
1349
+ stage: 'preflight',
1350
+ heartbeatAt: nowIso(),
1351
+ lastMessage: '页面与岗位已就绪,开始执行候选人流程。',
1352
+ });
1353
+ }
1354
+
1355
+ const interaction = new InteractionController(chromeClient, {
1356
+ ...persistentProfile.runtime,
1357
+ runControl,
1358
+ });
1359
+ const llmClient = new LlmClient(runProfile.llm);
1360
+ const resumeCaptureService = new ResumeCaptureService({ chromeClient, logger });
1361
+ const resumeNetworkTracker = new ResumeNetworkTracker({ chromeClient, logger });
1362
+ const stateStore = args.noState ? new NoopStateStore() : new StateStore(dataDir, args.profile);
1363
+ const reportStore = new ReportStore(dataDir);
1364
+ const app = new BossChatApp({
1365
+ page,
1366
+ llmClient,
1367
+ interaction,
1368
+ resumeCaptureService,
1369
+ stateStore,
1370
+ reportStore,
1371
+ resumeNetworkTracker,
1372
+ runControl,
1373
+ logger,
1374
+ dryRun: args.dryRun,
1375
+ artifactRootDir: path.join(dataDir, 'artifacts'),
1376
+ onProgress: (progress, meta = {}) => {
1377
+ if (!asyncMode) return;
1378
+ const nextState = runControl.isPaused() ? RUN_STATE_PAUSED : RUN_STATE_RUNNING;
1379
+ const stage = String(meta?.stage || 'running');
1380
+ const message = String(
1381
+ meta?.message ||
1382
+ `进度更新 inspected=${progress.inspected},passed=${progress.passed},requested=${progress.requested}`,
1383
+ );
1384
+ updateRunState(dataDir, runId, {
1385
+ state: nextState,
1386
+ stage,
1387
+ heartbeatAt: nowIso(),
1388
+ progress: {
1389
+ inspected: Number(progress.inspected || 0),
1390
+ passed: Number(progress.passed || 0),
1391
+ requested: Number(progress.requested || 0),
1392
+ skipped: Number(progress.skipped || 0),
1393
+ errors: Number(progress.errors || 0),
1394
+ },
1395
+ lastMessage: message,
1396
+ });
1397
+ appendRunEvent(dataDir, runId, {
1398
+ type: 'progress',
1399
+ state: nextState,
1400
+ stage,
1401
+ message,
1402
+ progress: {
1403
+ inspected: Number(progress.inspected || 0),
1404
+ passed: Number(progress.passed || 0),
1405
+ requested: Number(progress.requested || 0),
1406
+ skipped: Number(progress.skipped || 0),
1407
+ errors: Number(progress.errors || 0),
1408
+ },
1409
+ });
1410
+ },
1411
+ });
1412
+
1413
+ cleanupRuntimeControls = setupRuntimeControls(runControl);
1414
+
1415
+ logger.log('开始处理 Boss 聊天候选人列表...');
1416
+ const targetCountLabel =
1417
+ Number.isFinite(Number(runProfile.targetCount)) && Number(runProfile.targetCount) > 0
1418
+ ? String(runProfile.targetCount)
1419
+ : '扫到底';
1420
+ logger.log(
1421
+ `本次设置: 岗位=${runProfile.jobSelection.label}, 范围=${runProfile.startFrom === 'all' ? '全部' : '未读'}, 上限=${targetCountLabel}`,
1422
+ );
1423
+ logger.log('运行中快捷键: p=暂停/继续, r=继续, q=停止, Ctrl+C=停止');
1424
+
1425
+ const summary = await app.run(runProfile);
1426
+ logger.log(`已检查: ${summary.inspected}`);
1427
+ logger.log(`通过: ${summary.passed}`);
1428
+ logger.log(`已求简历: ${summary.requested}`);
1429
+ logger.log(`跳过: ${summary.skipped}`);
1430
+ logger.log(`错误: ${summary.errors}`);
1431
+ if (summary.exhausted) {
1432
+ logger.log('候选人列表已没有更多可处理项,提前结束。');
1433
+ }
1434
+ if (summary.stopped) {
1435
+ logger.log(`运行已停止: ${summary.stopReason}`);
1436
+ }
1437
+ logger.log(`运行报告: ${summary.reportPath}`);
1438
+
1439
+ if (asyncMode) {
1440
+ const latest = readRunState(dataDir, runId);
1441
+ const canceledRequested = Boolean(latest?.control?.cancelRequested);
1442
+ const terminalState =
1443
+ summary.stopped || canceledRequested ? RUN_STATE_CANCELED : RUN_STATE_COMPLETED;
1444
+ const terminalMessage =
1445
+ terminalState === RUN_STATE_CANCELED
1446
+ ? `任务已停止:${summary.stopReason || '收到取消请求'}`
1447
+ : '任务执行完成。';
1448
+
1449
+ updateRunState(dataDir, runId, {
1450
+ state: terminalState,
1451
+ stage: 'finalize',
1452
+ heartbeatAt: nowIso(),
1453
+ progress: {
1454
+ inspected: Number(summary.inspected || 0),
1455
+ passed: Number(summary.passed || 0),
1456
+ requested: Number(summary.requested || 0),
1457
+ skipped: Number(summary.skipped || 0),
1458
+ errors: Number(summary.errors || 0),
1459
+ },
1460
+ control: {
1461
+ pauseRequested: false,
1462
+ cancelRequested: false,
1463
+ },
1464
+ lastMessage: terminalMessage,
1465
+ error: null,
1466
+ result: {
1467
+ finishedAt: nowIso(),
1468
+ reportPath: String(summary.reportPath || ''),
1469
+ summary,
1470
+ },
1471
+ });
1472
+ appendRunEvent(dataDir, runId, {
1473
+ type: 'lifecycle',
1474
+ action: 'terminal',
1475
+ state: terminalState,
1476
+ message: terminalMessage,
1477
+ });
1478
+ }
1479
+ } catch (error) {
1480
+ logger.error(error?.stack || error?.message || String(error));
1481
+ error.runLogPath = runLogger.logPath;
1482
+ if (asyncMode) {
1483
+ const message = error?.message || String(error);
1484
+ updateRunState(dataDir, runId, {
1485
+ state: RUN_STATE_FAILED,
1486
+ stage: 'finalize',
1487
+ heartbeatAt: nowIso(),
1488
+ logPath: runLogger.logPath,
1489
+ lastMessage: message,
1490
+ error: {
1491
+ code: 'RUN_EXECUTION_FAILED',
1492
+ message,
1493
+ retryable: true,
1494
+ },
1495
+ });
1496
+ appendRunEvent(dataDir, runId, {
1497
+ type: 'lifecycle',
1498
+ action: 'failed',
1499
+ state: RUN_STATE_FAILED,
1500
+ message,
1501
+ });
1502
+ }
1503
+ throw error;
1504
+ } finally {
1505
+ cleanupControlSync();
1506
+ cleanupRuntimeControls();
1507
+ if (chromeClient) {
1508
+ await chromeClient.close();
1509
+ }
1510
+ await runLogger.flush();
1511
+ }
1512
+ }
1513
+
1514
+ async function main() {
1515
+ const args = parseArgs(process.argv.slice(2));
1516
+ const dataDir = path.join(process.cwd(), '.boss-chat');
1517
+ await mkdir(dataDir, { recursive: true });
1518
+
1519
+ if (args.command === 'help') {
1520
+ printUsage();
1521
+ return;
1522
+ }
1523
+
1524
+ if (args.command === 'run') {
1525
+ await executeRunCommand(args, dataDir);
1526
+ return;
1527
+ }
1528
+
1529
+ let payload = null;
1530
+ switch (args.command) {
1531
+ case 'prepare-run':
1532
+ payload = await handlePrepareRunCommand(args, dataDir);
1533
+ break;
1534
+ case 'start-run':
1535
+ payload = await handleStartRunCommand(args, dataDir);
1536
+ break;
1537
+ case 'get-run':
1538
+ payload = handleGetRunCommand(args, dataDir);
1539
+ break;
1540
+ case 'pause-run':
1541
+ payload = handlePauseRunCommand(args, dataDir);
1542
+ break;
1543
+ case 'resume-run':
1544
+ payload = handleResumeRunCommand(args, dataDir);
1545
+ break;
1546
+ case 'cancel-run':
1547
+ payload = handleCancelRunCommand(args, dataDir);
1548
+ break;
1549
+ default:
1550
+ printUsage();
1551
+ process.exitCode = 1;
1552
+ return;
1553
+ }
1554
+
1555
+ outputCommandResult(args, payload);
1556
+ if (payload?.status === 'FAILED') {
1557
+ process.exitCode = 1;
1558
+ }
1559
+ }
1560
+
1561
+ export const __testables = {
1562
+ parseArgs,
1563
+ connectBossChatPage,
1564
+ hasHydratedChatShell,
1565
+ promptRunProfile,
1566
+ resolveJobsWithRetry,
1567
+ waitForChatShellHydration,
1568
+ };
1569
+
1570
+ if (process.argv[1] && path.resolve(process.argv[1]) === CLI_FILE_PATH) {
1571
+ main().catch((error) => {
1572
+ const runLogPath = String(error?.runLogPath || '').trim();
1573
+ if (runLogPath) {
1574
+ console.error(`执行失败,详细日志见: ${runLogPath}`);
1575
+ } else {
1576
+ console.error(`执行失败: ${error.message}`);
1577
+ }
1578
+ process.exitCode = 1;
1579
+ });
1580
+ }