hamster-wheel-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.github/ISSUE_TEMPLATE/bug_report.yml +107 -0
  2. package/.github/ISSUE_TEMPLATE/config.yml +15 -0
  3. package/.github/ISSUE_TEMPLATE/feature_request.yml +56 -0
  4. package/.github/workflows/ci-pr.yml +50 -0
  5. package/.github/workflows/publish.yml +121 -0
  6. package/.github/workflows/sync-master-to-dev.yml +100 -0
  7. package/AGENTS.md +20 -0
  8. package/CHANGELOG.md +12 -0
  9. package/LICENSE +21 -0
  10. package/README.md +90 -0
  11. package/dist/cli.d.ts +6 -0
  12. package/dist/cli.js +2678 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/index.d.ts +80 -0
  15. package/dist/index.js +2682 -0
  16. package/dist/index.js.map +1 -0
  17. package/docs/ai-workflow.md +58 -0
  18. package/package.json +44 -0
  19. package/src/ai.ts +173 -0
  20. package/src/cli.ts +189 -0
  21. package/src/config.ts +134 -0
  22. package/src/deps.ts +210 -0
  23. package/src/gh.ts +228 -0
  24. package/src/git.ts +285 -0
  25. package/src/global-config.ts +296 -0
  26. package/src/index.ts +3 -0
  27. package/src/logger.ts +122 -0
  28. package/src/logs-viewer.ts +420 -0
  29. package/src/logs.ts +132 -0
  30. package/src/loop.ts +422 -0
  31. package/src/monitor.ts +291 -0
  32. package/src/runtime-tracker.ts +65 -0
  33. package/src/summary.ts +255 -0
  34. package/src/types.ts +176 -0
  35. package/src/utils.ts +179 -0
  36. package/src/webhook.ts +107 -0
  37. package/tests/deps.test.ts +72 -0
  38. package/tests/e2e/cli.e2e.test.ts +77 -0
  39. package/tests/e2e/gh-pr-create.e2e.test.ts +55 -0
  40. package/tests/e2e/gh-run-list.e2e.test.ts +47 -0
  41. package/tests/gh-pr-create.test.ts +55 -0
  42. package/tests/gh-run-list.test.ts +35 -0
  43. package/tests/global-config.test.ts +52 -0
  44. package/tests/logger-file.test.ts +56 -0
  45. package/tests/logger.test.ts +72 -0
  46. package/tests/logs-viewer.test.ts +57 -0
  47. package/tests/logs.test.ts +33 -0
  48. package/tests/prompt.test.ts +20 -0
  49. package/tests/run-command-stream.test.ts +60 -0
  50. package/tests/summary.test.ts +58 -0
  51. package/tests/token-usage.test.ts +33 -0
  52. package/tests/utils.test.ts +8 -0
  53. package/tests/webhook.test.ts +89 -0
  54. package/tsconfig.json +18 -0
  55. package/tsup.config.ts +18 -0
package/src/loop.ts ADDED
@@ -0,0 +1,422 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { buildPrompt, formatIterationRecord, mergeTokenUsage, runAi } from './ai';
4
+ import { ensureDependencies } from './deps';
5
+ import { GhPrInfo, createPr, listFailedRuns, viewPr } from './gh';
6
+ import { Logger } from './logger';
7
+ import { commitAll, ensureWorktree, getCurrentBranch, getRepoRoot, isBranchPushed, isWorktreeClean, pushBranch, removeWorktree } from './git';
8
+ import { formatCommandLine } from './logs';
9
+ import { createRunTracker } from './runtime-tracker';
10
+ import { buildFallbackSummary, buildSummaryPrompt, ensurePrBodySections, parseDeliverySummary } from './summary';
11
+ import { CommitMessage, DeliverySummary, LoopConfig, TestRunResult, TokenUsage, WorkflowFiles, WorktreeResult } from './types';
12
+ import { appendSection, ensureFile, isoNow, readFileSafe, runCommand } from './utils';
13
+ import { buildWebhookPayload, sendWebhookNotifications } from './webhook';
14
+
15
+ async function ensureWorkflowFiles(workflowFiles: WorkflowFiles): Promise<void> {
16
+ await ensureFile(workflowFiles.workflowDoc, '# AI 工作流程基线\n');
17
+ await ensureFile(workflowFiles.planFile, '# 计划\n');
18
+ await ensureFile(workflowFiles.notesFile, '# 持久化记忆\n');
19
+ }
20
+
21
+ const MAX_TEST_LOG_LENGTH = 4000;
22
+
23
+ function trimOutput(output: string, limit = MAX_TEST_LOG_LENGTH): string {
24
+ if (!output) return '';
25
+ if (output.length <= limit) return output;
26
+ return `${output.slice(0, limit)}\n……(输出已截断,原始长度 ${output.length} 字符)`;
27
+ }
28
+
29
+ async function safeCommandOutput(command: string, args: string[], cwd: string, logger: Logger, label: string, verboseCommand: string): Promise<string> {
30
+ const result = await runCommand(command, args, {
31
+ cwd,
32
+ logger,
33
+ verboseLabel: label,
34
+ verboseCommand
35
+ });
36
+ if (result.exitCode !== 0) {
37
+ logger.warn(`${label} 命令失败: ${result.stderr || result.stdout}`);
38
+ return '';
39
+ }
40
+ return result.stdout.trim();
41
+ }
42
+
43
+ async function runSingleTest(kind: 'unit' | 'e2e', command: string, cwd: string, logger: Logger): Promise<TestRunResult> {
44
+ const label = kind === 'unit' ? '单元测试' : 'e2e 测试';
45
+ logger.info(`执行${label}: ${command}`);
46
+ const result = await runCommand('bash', ['-lc', command], {
47
+ cwd,
48
+ logger,
49
+ verboseLabel: 'shell',
50
+ verboseCommand: `bash -lc "${command}"`
51
+ });
52
+ const success = result.exitCode === 0;
53
+ if (success) {
54
+ logger.success(`${label}完成`);
55
+ } else {
56
+ logger.warn(`${label}失败(退出码 ${result.exitCode})`);
57
+ }
58
+
59
+ return {
60
+ kind,
61
+ command,
62
+ success,
63
+ exitCode: result.exitCode,
64
+ stdout: trimOutput(result.stdout.trim()),
65
+ stderr: trimOutput(result.stderr.trim())
66
+ };
67
+ }
68
+
69
+ async function runTests(config: LoopConfig, workDir: string, logger: Logger): Promise<TestRunResult[]> {
70
+ const results: TestRunResult[] = [];
71
+
72
+ if (config.runTests && config.tests.unitCommand) {
73
+ const unitResult = await runSingleTest('unit', config.tests.unitCommand, workDir, logger);
74
+ results.push(unitResult);
75
+ }
76
+
77
+ if (config.runE2e && config.tests.e2eCommand) {
78
+ const e2eResult = await runSingleTest('e2e', config.tests.e2eCommand, workDir, logger);
79
+ results.push(e2eResult);
80
+ }
81
+
82
+ return results;
83
+ }
84
+
85
+ function reRootPath(filePath: string, repoRoot: string, workDir: string): string {
86
+ const relative = path.relative(repoRoot, filePath);
87
+ if (relative.startsWith('..')) return filePath;
88
+ return path.join(workDir, relative);
89
+ }
90
+
91
+ function reRootWorkflowFiles(workflowFiles: WorkflowFiles, repoRoot: string, workDir: string): WorkflowFiles {
92
+ if (repoRoot === workDir) return workflowFiles;
93
+ return {
94
+ workflowDoc: reRootPath(workflowFiles.workflowDoc, repoRoot, workDir),
95
+ notesFile: reRootPath(workflowFiles.notesFile, repoRoot, workDir),
96
+ planFile: reRootPath(workflowFiles.planFile, repoRoot, workDir)
97
+ };
98
+ }
99
+
100
+ function buildBodyFile(workDir: string): string {
101
+ return path.join(workDir, 'memory', 'pr-body.md');
102
+ }
103
+
104
+ async function writePrBody(bodyPath: string, content: string, appendExisting: boolean): Promise<void> {
105
+ await fs.mkdirp(path.dirname(bodyPath));
106
+ let finalContent = content.trim();
107
+ if (appendExisting) {
108
+ const existing = await readFileSafe(bodyPath);
109
+ const trimmedExisting = existing.trim();
110
+ if (trimmedExisting.length > 0) {
111
+ finalContent = `${trimmedExisting}\n\n---\n\n${finalContent}`;
112
+ }
113
+ }
114
+ await fs.writeFile(bodyPath, `${finalContent}\n`, 'utf8');
115
+ }
116
+
117
+ interface WorktreeCleanupContext {
118
+ readonly repoRoot: string;
119
+ readonly workDir: string;
120
+ readonly branchName?: string;
121
+ readonly prInfo: GhPrInfo | null;
122
+ readonly worktreeCreated: boolean;
123
+ readonly logger: Logger;
124
+ }
125
+
126
+ async function cleanupWorktreeIfSafe(context: WorktreeCleanupContext): Promise<void> {
127
+ const { repoRoot, workDir, branchName, prInfo, worktreeCreated, logger } = context;
128
+ if (!worktreeCreated) {
129
+ logger.debug('worktree 并非本次创建,跳过自动清理');
130
+ return;
131
+ }
132
+ if (workDir === repoRoot) {
133
+ logger.debug('当前未使用独立 worktree,跳过自动清理');
134
+ return;
135
+ }
136
+ if (!branchName) {
137
+ logger.warn('未能确定 worktree 分支名,保留工作目录以免丢失进度');
138
+ return;
139
+ }
140
+
141
+ const clean = await isWorktreeClean(workDir, logger);
142
+ if (!clean) {
143
+ logger.warn('worktree 仍有未提交变更,已保留工作目录');
144
+ return;
145
+ }
146
+
147
+ const pushed = await isBranchPushed(branchName, workDir, logger);
148
+ if (!pushed) {
149
+ logger.warn(`分支 ${branchName} 尚未推送到远端,已保留 worktree`);
150
+ return;
151
+ }
152
+
153
+ if (!prInfo) {
154
+ logger.warn('未检测到关联 PR,已保留 worktree');
155
+ return;
156
+ }
157
+
158
+ await removeWorktree(workDir, repoRoot, logger);
159
+ }
160
+
161
+ /**
162
+ * 执行主迭代循环。
163
+ */
164
+ export async function runLoop(config: LoopConfig): Promise<void> {
165
+ const logger = new Logger({ verbose: config.verbose, logFile: config.logFile });
166
+ const repoRoot = await getRepoRoot(config.cwd, logger);
167
+ logger.debug(`仓库根目录: ${repoRoot}`);
168
+
169
+ const worktreeResult: WorktreeResult = config.git.useWorktree
170
+ ? await ensureWorktree(config.git, repoRoot, logger)
171
+ : { path: repoRoot, created: false };
172
+ const workDir = worktreeResult.path;
173
+ const worktreeCreated = worktreeResult.created;
174
+ logger.debug(`工作目录: ${workDir}`);
175
+
176
+ const commandLine = formatCommandLine(process.argv);
177
+ const runTracker = await createRunTracker({
178
+ logFile: config.logFile,
179
+ command: commandLine,
180
+ path: workDir,
181
+ logger
182
+ });
183
+
184
+ let branchName = config.git.branchName;
185
+ let lastRound = 0;
186
+ let runError: string | null = null;
187
+
188
+ const notifyWebhook = async (event: 'task_start' | 'iteration_start' | 'task_end', iteration: number, stage: string): Promise<void> => {
189
+ const payload = buildWebhookPayload({
190
+ event,
191
+ task: config.task,
192
+ branch: branchName,
193
+ iteration,
194
+ stage
195
+ });
196
+ await sendWebhookNotifications(config.webhooks, payload, logger);
197
+ };
198
+
199
+ try {
200
+ if (!branchName) {
201
+ try {
202
+ branchName = await getCurrentBranch(workDir, logger);
203
+ } catch (error) {
204
+ const message = error instanceof Error ? error.message : String(error);
205
+ logger.warn(`读取分支名失败,webhook 中将缺失分支信息:${message}`);
206
+ }
207
+ }
208
+
209
+ await notifyWebhook('task_start', 0, '任务开始');
210
+
211
+ if (config.skipInstall) {
212
+ logger.info('已跳过依赖检查');
213
+ } else {
214
+ await ensureDependencies(workDir, logger);
215
+ }
216
+
217
+ const workflowFiles = reRootWorkflowFiles(config.workflowFiles, repoRoot, workDir);
218
+ await ensureWorkflowFiles(workflowFiles);
219
+
220
+ const planContent = await readFileSafe(workflowFiles.planFile);
221
+ if (planContent.trim().length === 0) {
222
+ logger.warn('plan 文件为空,建议 AI 首轮生成计划');
223
+ }
224
+
225
+ const aiConfig = config.ai;
226
+
227
+ let accumulatedUsage: TokenUsage | null = null;
228
+ let lastTestResults: TestRunResult[] | null = null;
229
+ let lastAiOutput = '';
230
+ let prInfo: GhPrInfo | null = null;
231
+ let prFailed = false;
232
+
233
+ for (let i = 1; i <= config.iterations; i += 1) {
234
+ await notifyWebhook('iteration_start', i, `开始第 ${i} 轮迭代`);
235
+
236
+ const workflowGuide = await readFileSafe(workflowFiles.workflowDoc);
237
+ const plan = await readFileSafe(workflowFiles.planFile);
238
+ const notes = await readFileSafe(workflowFiles.notesFile);
239
+ logger.debug(`加载提示上下文,长度:workflow=${workflowGuide.length}, plan=${plan.length}, notes=${notes.length}`);
240
+
241
+ const prompt = buildPrompt({
242
+ task: config.task,
243
+ workflowGuide,
244
+ plan,
245
+ notes,
246
+ iteration: i
247
+ });
248
+ logger.debug(`第 ${i} 轮提示长度: ${prompt.length}`);
249
+
250
+ logger.info(`第 ${i} 轮提示构建完成,调用 AI CLI...`);
251
+ const aiResult = await runAi(prompt, aiConfig, logger, workDir);
252
+ accumulatedUsage = mergeTokenUsage(accumulatedUsage, aiResult.usage);
253
+ lastAiOutput = aiResult.output;
254
+
255
+ const hitStop = aiResult.output.includes(config.stopSignal);
256
+ let testResults: TestRunResult[] = [];
257
+ const shouldRunTests = config.runTests || config.runE2e;
258
+ if (shouldRunTests) {
259
+ try {
260
+ testResults = await runTests(config, workDir, logger);
261
+ } catch (error) {
262
+ const errorMessage = String(error);
263
+ logger.warn(`测试执行异常: ${errorMessage}`);
264
+ testResults = [{
265
+ kind: 'unit',
266
+ command: config.tests.unitCommand ?? '未知测试命令',
267
+ success: false,
268
+ exitCode: -1,
269
+ stdout: '',
270
+ stderr: trimOutput(errorMessage)
271
+ }];
272
+ }
273
+ }
274
+
275
+ const record = formatIterationRecord({
276
+ iteration: i,
277
+ prompt,
278
+ aiOutput: aiResult.output,
279
+ timestamp: isoNow(),
280
+ testResults
281
+ });
282
+
283
+ await appendSection(workflowFiles.notesFile, record);
284
+ logger.success(`已将第 ${i} 轮输出写入 ${workflowFiles.notesFile}`);
285
+
286
+ lastTestResults = testResults;
287
+ lastRound = i;
288
+ await runTracker?.update(i, accumulatedUsage?.totalTokens ?? 0);
289
+
290
+ const hasTestFailure = testResults.some(result => !result.success);
291
+
292
+ if (hitStop && !hasTestFailure) {
293
+ logger.info(`检测到停止标记 ${config.stopSignal},提前结束循环`);
294
+ break;
295
+ }
296
+ if (hitStop && hasTestFailure) {
297
+ logger.info(`检测到停止标记 ${config.stopSignal},但测试失败,继续进入下一轮修复`);
298
+ }
299
+ }
300
+
301
+ const lastTestFailed = lastTestResults?.some(result => !result.success) ?? false;
302
+
303
+ if (lastTestFailed) {
304
+ logger.warn('存在未通过的测试,已跳过自动提交/推送/PR');
305
+ }
306
+
307
+ let deliverySummary: DeliverySummary | null = null;
308
+ const shouldPrepareDelivery = !lastTestFailed && (config.autoCommit || config.pr.enable);
309
+ if (shouldPrepareDelivery) {
310
+ const [gitStatus, diffStat] = await Promise.all([
311
+ safeCommandOutput('git', ['status', '--short'], workDir, logger, 'git', 'git status --short'),
312
+ safeCommandOutput('git', ['diff', '--stat'], workDir, logger, 'git', 'git diff --stat')
313
+ ]);
314
+ const summaryPrompt = buildSummaryPrompt({
315
+ task: config.task,
316
+ plan: await readFileSafe(workflowFiles.planFile),
317
+ notes: await readFileSafe(workflowFiles.notesFile),
318
+ lastAiOutput,
319
+ testResults: lastTestResults,
320
+ gitStatus,
321
+ diffStat,
322
+ branchName
323
+ });
324
+ try {
325
+ const summaryResult = await runAi(summaryPrompt, aiConfig, logger, workDir);
326
+ accumulatedUsage = mergeTokenUsage(accumulatedUsage, summaryResult.usage);
327
+ deliverySummary = parseDeliverySummary(summaryResult.output);
328
+ if (!deliverySummary) {
329
+ logger.warn('AI 总结输出解析失败,使用兜底文案');
330
+ }
331
+ } catch (error) {
332
+ logger.warn(`AI 总结生成失败: ${String(error)}`);
333
+ }
334
+ if (!deliverySummary) {
335
+ deliverySummary = buildFallbackSummary({ task: config.task, testResults: lastTestResults });
336
+ }
337
+ }
338
+ await runTracker?.update(lastRound, accumulatedUsage?.totalTokens ?? 0);
339
+
340
+ if (config.autoCommit && !lastTestFailed) {
341
+ const summary = deliverySummary ?? buildFallbackSummary({ task: config.task, testResults: lastTestResults });
342
+ const commitMessage: CommitMessage = {
343
+ title: summary.commitTitle,
344
+ body: summary.commitBody
345
+ };
346
+ await commitAll(commitMessage, workDir, logger).catch(error => {
347
+ logger.warn(String(error));
348
+ });
349
+ }
350
+
351
+ if (config.autoPush && branchName && !lastTestFailed) {
352
+ await pushBranch(branchName, workDir, logger).catch(error => {
353
+ logger.warn(String(error));
354
+ });
355
+ }
356
+
357
+ if (config.pr.enable && branchName && !lastTestFailed) {
358
+ logger.info('开始创建 PR...');
359
+ const summary = deliverySummary ?? buildFallbackSummary({ task: config.task, testResults: lastTestResults });
360
+ const prTitleCandidate = config.pr.title?.trim() || summary.prTitle;
361
+ const prBodyContent = ensurePrBodySections(summary.prBody, {
362
+ commitTitle: summary.commitTitle,
363
+ commitBody: summary.commitBody,
364
+ testResults: lastTestResults
365
+ });
366
+ const bodyFile = config.pr.bodyPath ?? buildBodyFile(workDir);
367
+ await writePrBody(bodyFile, prBodyContent, Boolean(config.pr.bodyPath));
368
+
369
+ const createdPr = await createPr(branchName, { ...config.pr, title: prTitleCandidate, bodyPath: bodyFile }, workDir, logger);
370
+ prInfo = createdPr;
371
+ if (createdPr) {
372
+ logger.success(`PR 已创建: ${createdPr.url}`);
373
+ const failedRuns = await listFailedRuns(branchName, workDir, logger);
374
+ if (failedRuns.length > 0) {
375
+ failedRuns.forEach(run => {
376
+ logger.warn(`Actions 失败: ${run.name} (${run.status}/${run.conclusion ?? 'unknown'}) ${run.url}`);
377
+ });
378
+ }
379
+ } else {
380
+ prFailed = true;
381
+ logger.error('PR 创建失败,详见上方 gh 输出');
382
+ }
383
+ } else if (branchName && !config.pr.enable) {
384
+ logger.info('未开启 PR 创建(--pr 未传),尝试查看已有 PR');
385
+ const existingPr = await viewPr(branchName, workDir, logger);
386
+ prInfo = existingPr;
387
+ if (existingPr) logger.info(`已有 PR: ${existingPr.url}`);
388
+ }
389
+
390
+ if (accumulatedUsage) {
391
+ const input = accumulatedUsage.inputTokens ?? '-';
392
+ const output = accumulatedUsage.outputTokens ?? '-';
393
+ logger.info(`Token 消耗汇总:输入 ${input}|输出 ${output}|总计 ${accumulatedUsage.totalTokens}`);
394
+ } else {
395
+ logger.info('未解析到 Token 消耗信息,可检查 AI CLI 输出格式是否包含 token 提示');
396
+ }
397
+
398
+ if (lastTestFailed || prFailed) {
399
+ throw new Error('流程存在未解决的问题(测试未通过或 PR 创建失败)');
400
+ }
401
+
402
+ if (config.git.useWorktree && workDir !== repoRoot) {
403
+ await cleanupWorktreeIfSafe({
404
+ repoRoot,
405
+ workDir,
406
+ branchName,
407
+ prInfo,
408
+ worktreeCreated,
409
+ logger
410
+ });
411
+ }
412
+
413
+ logger.success(`wheel-ai 迭代流程结束|Token 总计 ${accumulatedUsage?.totalTokens ?? '未知'}`);
414
+ } catch (error) {
415
+ runError = error instanceof Error ? error.message : String(error);
416
+ throw error;
417
+ } finally {
418
+ const stage = runError ? '任务结束(失败)' : '任务结束';
419
+ await notifyWebhook('task_end', lastRound, stage);
420
+ await runTracker?.finalize();
421
+ }
422
+ }
package/src/monitor.ts ADDED
@@ -0,0 +1,291 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'node:path';
3
+ import { CurrentRegistryEntry, getLogsDir, readCurrentRegistry } from './logs';
4
+
5
+ interface TaskView {
6
+ readonly key: string;
7
+ readonly logFile: string;
8
+ readonly meta: CurrentRegistryEntry;
9
+ readonly lines: string[];
10
+ }
11
+
12
+ interface MonitorState {
13
+ tasks: TaskView[];
14
+ selectedIndex: number;
15
+ selectedKey?: string;
16
+ pageOffsets: Map<string, number>;
17
+ stickToBottom: Map<string, boolean>;
18
+ lastError?: string;
19
+ }
20
+
21
+ const REFRESH_INTERVAL = 1000;
22
+
23
+ function getTerminalSize(): { rows: number; columns: number } {
24
+ const rows = process.stdout.rows ?? 24;
25
+ const columns = process.stdout.columns ?? 80;
26
+ return { rows, columns };
27
+ }
28
+
29
+ function truncateLine(line: string, width: number): string {
30
+ if (width <= 0) return '';
31
+ if (line.length <= width) return line;
32
+ return line.slice(0, width);
33
+ }
34
+
35
+ async function readLogLines(logFile: string): Promise<string[]> {
36
+ try {
37
+ const content = await fs.readFile(logFile, 'utf8');
38
+ const normalized = content.replace(/\r\n?/g, '\n');
39
+ const lines = normalized.split('\n');
40
+ return lines.length > 0 ? lines : [''];
41
+ } catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ return [`(无法读取日志文件:${message})`];
44
+ }
45
+ }
46
+
47
+ async function loadTasks(logsDir: string): Promise<TaskView[]> {
48
+ const registry = await readCurrentRegistry();
49
+ const entries = Object.entries(registry).sort(([a], [b]) => a.localeCompare(b));
50
+ const tasks = await Promise.all(entries.map(async ([key, meta]) => {
51
+ const logFile = meta.logFile ?? path.join(logsDir, key);
52
+ const lines = await readLogLines(logFile);
53
+ return {
54
+ key,
55
+ logFile,
56
+ meta,
57
+ lines
58
+ } satisfies TaskView;
59
+ }));
60
+ return tasks;
61
+ }
62
+
63
+ function buildHeader(state: MonitorState, columns: number): string {
64
+ if (state.tasks.length === 0) {
65
+ return truncateLine('暂无运行中的任务,按 q 退出', columns);
66
+ }
67
+ const current = state.tasks[state.selectedIndex];
68
+ const total = state.tasks.length;
69
+ const index = state.selectedIndex + 1;
70
+ const title = `任务 ${index}/${total} | ${current.key} | ←/→ 切换任务 ↑/↓ 翻页 q 退出`;
71
+ return truncateLine(title, columns);
72
+ }
73
+
74
+ function buildStatus(
75
+ task: TaskView,
76
+ page: { current: number; total: number },
77
+ columns: number,
78
+ errorMessage?: string
79
+ ): string {
80
+ const meta = task.meta;
81
+ const status = `轮次 ${meta.round} | Token ${meta.tokenUsed} | 页 ${page.current}/${page.total} | 项目 ${meta.path}`;
82
+ const suffix = errorMessage ? ` | 刷新失败:${errorMessage}` : '';
83
+ return truncateLine(`${status}${suffix}`, columns);
84
+ }
85
+
86
+ function getPageSize(rows: number): number {
87
+ return Math.max(1, rows - 2);
88
+ }
89
+
90
+ function render(state: MonitorState): void {
91
+ const { rows, columns } = getTerminalSize();
92
+ const pageSize = getPageSize(rows);
93
+ const header = buildHeader(state, columns);
94
+
95
+ if (state.tasks.length === 0) {
96
+ const filler = Array.from({ length: pageSize }, () => '');
97
+ const statusText = state.lastError ? `刷新失败:${state.lastError}` : '等待后台任务启动…';
98
+ const status = truncateLine(statusText, columns);
99
+ const content = [header, ...filler, status].join('\n');
100
+ process.stdout.write(`\u001b[2J\u001b[H${content}`);
101
+ return;
102
+ }
103
+
104
+ const current = state.tasks[state.selectedIndex];
105
+ const lines = current.lines;
106
+ const maxOffset = Math.max(0, Math.ceil(lines.length / pageSize) - 1);
107
+ const offset = state.pageOffsets.get(current.key) ?? maxOffset;
108
+ const stick = state.stickToBottom.get(current.key) ?? true;
109
+ const nextOffset = Math.min(Math.max(stick ? maxOffset : offset, 0), maxOffset);
110
+ state.pageOffsets.set(current.key, nextOffset);
111
+ state.stickToBottom.set(current.key, nextOffset === maxOffset);
112
+
113
+ const start = nextOffset * pageSize;
114
+ const pageLines = lines.slice(start, start + pageSize).map(line => truncateLine(line, columns));
115
+ while (pageLines.length < pageSize) {
116
+ pageLines.push('');
117
+ }
118
+
119
+ const status = buildStatus(
120
+ current,
121
+ { current: nextOffset + 1, total: Math.max(1, maxOffset + 1) },
122
+ columns,
123
+ state.lastError
124
+ );
125
+ const content = [header, ...pageLines, status].join('\n');
126
+ process.stdout.write(`\u001b[2J\u001b[H${content}`);
127
+ }
128
+
129
+ function updateSelection(state: MonitorState, tasks: TaskView[]): void {
130
+ state.tasks = tasks;
131
+ if (tasks.length === 0) {
132
+ state.selectedIndex = 0;
133
+ state.selectedKey = undefined;
134
+ return;
135
+ }
136
+
137
+ if (state.selectedKey) {
138
+ const index = tasks.findIndex(task => task.key === state.selectedKey);
139
+ if (index >= 0) {
140
+ state.selectedIndex = index;
141
+ } else {
142
+ state.selectedIndex = 0;
143
+ }
144
+ } else {
145
+ state.selectedIndex = 0;
146
+ }
147
+
148
+ state.selectedKey = tasks[state.selectedIndex]?.key;
149
+
150
+ const existing = new Set(tasks.map(task => task.key));
151
+ for (const key of state.pageOffsets.keys()) {
152
+ if (!existing.has(key)) {
153
+ state.pageOffsets.delete(key);
154
+ }
155
+ }
156
+ for (const key of state.stickToBottom.keys()) {
157
+ if (!existing.has(key)) {
158
+ state.stickToBottom.delete(key);
159
+ }
160
+ }
161
+ }
162
+
163
+ function moveSelection(state: MonitorState, direction: -1 | 1): void {
164
+ if (state.tasks.length === 0) return;
165
+ const total = state.tasks.length;
166
+ state.selectedIndex = (state.selectedIndex + direction + total) % total;
167
+ state.selectedKey = state.tasks[state.selectedIndex]?.key;
168
+ }
169
+
170
+ function movePage(state: MonitorState, direction: -1 | 1): void {
171
+ if (state.tasks.length === 0) return;
172
+ const { rows } = getTerminalSize();
173
+ const pageSize = getPageSize(rows);
174
+ const current = state.tasks[state.selectedIndex];
175
+ const lines = current.lines;
176
+ const maxOffset = Math.max(0, Math.ceil(lines.length / pageSize) - 1);
177
+ const offset = state.pageOffsets.get(current.key) ?? maxOffset;
178
+ const nextOffset = Math.min(Math.max(offset + direction, 0), maxOffset);
179
+ state.pageOffsets.set(current.key, nextOffset);
180
+ state.stickToBottom.set(current.key, nextOffset === maxOffset);
181
+ }
182
+
183
+ function shouldExit(input: string): boolean {
184
+ if (input === '\u0003') return true;
185
+ if (input.toLowerCase() === 'q') return true;
186
+ return false;
187
+ }
188
+
189
+ function handleInput(state: MonitorState, input: string): void {
190
+ if (input.includes('\u001b[D')) {
191
+ moveSelection(state, -1);
192
+ return;
193
+ }
194
+ if (input.includes('\u001b[C')) {
195
+ moveSelection(state, 1);
196
+ return;
197
+ }
198
+ if (input.includes('\u001b[A')) {
199
+ movePage(state, -1);
200
+ return;
201
+ }
202
+ if (input.includes('\u001b[B')) {
203
+ movePage(state, 1);
204
+ return;
205
+ }
206
+ }
207
+
208
+ function setupCleanup(cleanup: () => void): void {
209
+ const exitHandler = (): void => {
210
+ cleanup();
211
+ };
212
+ const signalHandler = (): void => {
213
+ cleanup();
214
+ process.exit(0);
215
+ };
216
+ process.on('SIGINT', signalHandler);
217
+ process.on('SIGTERM', signalHandler);
218
+ process.on('exit', exitHandler);
219
+ }
220
+
221
+ /**
222
+ * 启动 monitor 终端界面。
223
+ */
224
+ export async function runMonitor(): Promise<void> {
225
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
226
+ console.log('当前终端不支持交互式 monitor。');
227
+ return;
228
+ }
229
+
230
+ const logsDir = getLogsDir();
231
+ const state: MonitorState = {
232
+ tasks: [],
233
+ selectedIndex: 0,
234
+ pageOffsets: new Map(),
235
+ stickToBottom: new Map()
236
+ };
237
+
238
+ let cleaned = false;
239
+ const cleanup = (): void => {
240
+ if (cleaned) return;
241
+ cleaned = true;
242
+ if (process.stdin.isTTY) {
243
+ process.stdin.setRawMode(false);
244
+ process.stdin.pause();
245
+ }
246
+ process.stdout.write('\u001b[?25h');
247
+ };
248
+
249
+ setupCleanup(cleanup);
250
+ process.stdout.write('\u001b[?25l');
251
+ process.stdin.setRawMode(true);
252
+ process.stdin.resume();
253
+
254
+ let refreshing = false;
255
+
256
+ const refresh = async (): Promise<void> => {
257
+ if (refreshing) return;
258
+ refreshing = true;
259
+ try {
260
+ const tasks = await loadTasks(logsDir);
261
+ state.lastError = undefined;
262
+ updateSelection(state, tasks);
263
+ render(state);
264
+ } catch (error) {
265
+ const message = error instanceof Error ? error.message : String(error);
266
+ state.lastError = message;
267
+ render(state);
268
+ } finally {
269
+ refreshing = false;
270
+ }
271
+ };
272
+
273
+ await refresh();
274
+
275
+ const timer = setInterval(refresh, REFRESH_INTERVAL);
276
+
277
+ process.stdin.on('data', (data: Buffer) => {
278
+ const input = data.toString('utf8');
279
+ if (shouldExit(input)) {
280
+ clearInterval(timer);
281
+ cleanup();
282
+ process.exit(0);
283
+ }
284
+ handleInput(state, input);
285
+ render(state);
286
+ });
287
+
288
+ process.stdout.on('resize', () => {
289
+ render(state);
290
+ });
291
+ }