chatccc 0.2.195 → 0.2.196

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.195",
3
+ "version": "0.2.196",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,9 +1,16 @@
1
- import { describe, it, expect } from "vitest";
2
- import { readFileSync } from "node:fs";
3
- import { join, dirname } from "node:path";
4
- import { fileURLToPath } from "node:url";
5
- import { normalizeCursorMessage, createCursorAdapter } from "../adapters/cursor-adapter.ts";
6
- import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
1
+ import { describe, it, expect } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { readFileSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { PassThrough } from "node:stream";
6
+ import type { ChildProcess } from "node:child_process";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ normalizeCursorMessage,
10
+ createCursorAdapter,
11
+ formatCursorAgentEmptyOutputMessage,
12
+ } from "../adapters/cursor-adapter.ts";
13
+ import type { UnifiedStreamMessage } from "../adapters/adapter-interface.ts";
7
14
  import type {
8
15
  CursorSessionMeta,
9
16
  CursorSessionMetaStore,
@@ -37,12 +44,43 @@ function createInMemoryMetaStore(
37
44
  };
38
45
  }
39
46
 
40
- const __dirname = dirname(fileURLToPath(import.meta.url));
41
-
42
- function readFixture(name: string): unknown[] {
43
- const raw = readFileSync(join(__dirname, "fixtures", name), "utf-8");
44
- return raw.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
45
- }
47
+ const __dirname = dirname(fileURLToPath(import.meta.url));
48
+
49
+ type CursorSpawnForTest = NonNullable<
50
+ NonNullable<Parameters<typeof createCursorAdapter>[0]>["spawn"]
51
+ >;
52
+
53
+ function readFixture(name: string): unknown[] {
54
+ const raw = readFileSync(join(__dirname, "fixtures", name), "utf-8");
55
+ return raw.split(/\r?\n/).filter(Boolean).map((line) => JSON.parse(line));
56
+ }
57
+
58
+ function createMockCursorProcess(args: {
59
+ stdout?: string;
60
+ stderr?: string;
61
+ exitCode?: number;
62
+ }): ChildProcess {
63
+ const child = new EventEmitter() as ChildProcess;
64
+ const stdout = new PassThrough();
65
+ const stderr = new PassThrough();
66
+ const stdin = new PassThrough();
67
+ Object.assign(child, {
68
+ stdout,
69
+ stderr,
70
+ stdin,
71
+ pid: undefined,
72
+ });
73
+
74
+ setImmediate(() => {
75
+ if (args.stdout) stdout.write(args.stdout);
76
+ if (args.stderr) stderr.write(args.stderr);
77
+ stdout.end();
78
+ stderr.end();
79
+ child.emit("close", args.exitCode ?? 0, null);
80
+ });
81
+
82
+ return child;
83
+ }
46
84
 
47
85
  // ---------------------------------------------------------------------------
48
86
  // normalizeCursorMessage — 核心映射逻辑测试(纯函数)
@@ -642,7 +680,7 @@ describe("Cursor stream fixture - 端到端不重复", () => {
642
680
  });
643
681
  });
644
682
 
645
- it("mapToolCallKey: maps known keys to readable names", () => {
683
+ it("mapToolCallKey: maps known keys to readable names", () => {
646
684
  const cases: [string, string][] = [
647
685
  ["globToolCall", "Glob"],
648
686
  ["shellToolCall", "Bash"],
@@ -658,6 +696,65 @@ describe("Cursor stream fixture - 端到端不重复", () => {
658
696
  tool_call: { [raw]: { args: {} } },
659
697
  } as Parameters<typeof normalizeCursorMessage>[0]);
660
698
  expect(r!.blocks[0]).toMatchObject({ type: "tool_use", name: expected });
661
- }
662
- });
663
- });
699
+ }
700
+ });
701
+ });
702
+
703
+ describe("Cursor adapter process failures", () => {
704
+ it("formats empty stdout auth stderr as a cautious visible message", () => {
705
+ const message = formatCursorAgentEmptyOutputMessage({
706
+ exitCode: 1,
707
+ stdoutLength: 0,
708
+ stderr: "Error: Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.\n",
709
+ });
710
+
711
+ expect(message).toContain("检测到认证相关错误");
712
+ expect(message).toContain("可能需要重新登录 Cursor Agent");
713
+ expect(message).toContain("CURSOR_API_KEY");
714
+ expect(message).toContain("agent status");
715
+ expect(message).toContain("agent login");
716
+ expect(message).not.toContain("登录态已失效");
717
+ });
718
+
719
+ it("does not format a message when stdout is non-empty", () => {
720
+ expect(
721
+ formatCursorAgentEmptyOutputMessage({
722
+ exitCode: 1,
723
+ stdoutLength: 10,
724
+ stderr: "Error: Authentication required",
725
+ }),
726
+ ).toBeNull();
727
+ });
728
+
729
+ it("prompt surfaces auth-related empty output before failing the stream", async () => {
730
+ const store = createInMemoryMetaStore({ sid: { cwd: "F:/repo" } });
731
+ const spawnImpl = (() =>
732
+ createMockCursorProcess({
733
+ exitCode: 1,
734
+ stderr: "Error: Authentication required. Please run 'agent login' first, or set CURSOR_API_KEY environment variable.\n",
735
+ })) as CursorSpawnForTest;
736
+ const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
737
+ const iterator = adapter.prompt(
738
+ "sid",
739
+ "[User message]\nhello\n[/User message]",
740
+ "F:/repo",
741
+ )[Symbol.asyncIterator]();
742
+
743
+ const first = await iterator.next();
744
+ expect(first.done).toBe(false);
745
+ expect(first.value.blocks).toHaveLength(1);
746
+ expect(first.value.blocks[0]).toMatchObject({ type: "text_final" });
747
+ expect(first.value.blocks[0]).toHaveProperty(
748
+ "text",
749
+ expect.stringContaining("可能需要重新登录 Cursor Agent"),
750
+ );
751
+ expect(first.value.blocks[0]).toHaveProperty(
752
+ "text",
753
+ expect.not.stringContaining("登录态已失效"),
754
+ );
755
+
756
+ await expect(iterator.next()).rejects.toThrow(
757
+ /Cursor Agent exited without stream-json output/,
758
+ );
759
+ });
760
+ });
@@ -105,9 +105,9 @@ interface CursorMessageLine {
105
105
  tool_call?: Record<string, unknown>;
106
106
  }
107
107
 
108
- interface CursorContentBlock {
109
- type?: string;
110
- text?: string;
108
+ interface CursorContentBlock {
109
+ type?: string;
110
+ text?: string;
111
111
  thinking?: string;
112
112
  name?: string;
113
113
  input?: unknown;
@@ -115,12 +115,73 @@ interface CursorContentBlock {
115
115
  content?: unknown;
116
116
  is_error?: boolean;
117
117
  query?: string;
118
- [key: string]: unknown;
119
- }
120
-
121
- // ---------------------------------------------------------------------------
122
- // normalizeCursorMessage — Cursor 消息 → UnifiedStreamMessage | null
123
- // ---------------------------------------------------------------------------
118
+ [key: string]: unknown;
119
+ }
120
+
121
+ interface CursorProcessCloseInfo {
122
+ code: number | null;
123
+ signal: NodeJS.Signals | null;
124
+ stderr: string;
125
+ }
126
+
127
+ interface CursorProcessHandle {
128
+ proc: ChildProcess;
129
+ getStderr(): string;
130
+ waitForClose(): Promise<CursorProcessCloseInfo>;
131
+ }
132
+
133
+ interface CursorStreamStats {
134
+ stdoutLength: number;
135
+ rawLineCount: number;
136
+ parsedLineCount: number;
137
+ }
138
+
139
+ type CursorSpawn = typeof spawn;
140
+
141
+ function createCursorStreamStats(): CursorStreamStats {
142
+ return { stdoutLength: 0, rawLineCount: 0, parsedLineCount: 0 };
143
+ }
144
+
145
+ function isCursorAuthRelatedError(stderr: string): boolean {
146
+ const text = stderr.toLowerCase();
147
+ return (
148
+ text.includes("authentication required") ||
149
+ text.includes("not logged in") ||
150
+ text.includes("login") ||
151
+ text.includes("sign in") ||
152
+ text.includes("unauthorized") ||
153
+ text.includes("401") ||
154
+ text.includes("cursor_api_key") ||
155
+ text.includes("api key")
156
+ );
157
+ }
158
+
159
+ export function formatCursorAgentEmptyOutputMessage(args: {
160
+ exitCode: number | null;
161
+ stdoutLength: number;
162
+ stderr: string;
163
+ }): string | null {
164
+ if (args.exitCode === 0) return null;
165
+ if (args.stdoutLength !== 0) return null;
166
+ if (args.stderr.trim().length === 0) return null;
167
+
168
+ if (isCursorAuthRelatedError(args.stderr)) {
169
+ return "Cursor Agent 没有返回内容。检测到认证相关错误,可能需要重新登录 Cursor Agent,或配置 CURSOR_API_KEY。请在本机运行 agent status 检查状态;如未登录,请运行 agent login 后重试。";
170
+ }
171
+
172
+ return "Cursor Agent 没有返回内容。底层命令异常退出,请检查本机 Cursor Agent 状态后重试。";
173
+ }
174
+
175
+ function createCursorAgentFailureError(info: CursorProcessCloseInfo): Error {
176
+ const stderr = info.stderr.trim().slice(0, 500);
177
+ return new Error(
178
+ `Cursor Agent exited without stream-json output (exit=${info.code ?? "unknown"}, stderr=${stderr})`,
179
+ );
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // normalizeCursorMessage — Cursor 消息 → UnifiedStreamMessage | null
184
+ // ---------------------------------------------------------------------------
124
185
 
125
186
  /** Cursor tool_call 内部 key → 统一工具名 */
126
187
  function mapToolCallKey(key: string): string {
@@ -298,13 +359,14 @@ export function normalizeCursorMessage(
298
359
  // 子进程辅助函数
299
360
  // ---------------------------------------------------------------------------
300
361
 
301
- function spawnAgent(
302
- extraArgs: string[],
303
- cwd?: string,
304
- stdinText?: string,
305
- modelOverride?: string,
306
- mode?: "plan" | "ask",
307
- ): ChildProcess {
362
+ function spawnAgent(
363
+ extraArgs: string[],
364
+ cwd?: string,
365
+ stdinText?: string,
366
+ modelOverride?: string,
367
+ mode?: "plan" | "ask",
368
+ spawnImpl: CursorSpawn = spawn,
369
+ ): CursorProcessHandle {
308
370
  let allArgs: string[];
309
371
  if (mode) {
310
372
  // plan/ask 模式:移除 --force/--yolo,添加 --mode plan/ask
@@ -323,36 +385,58 @@ function spawnAgent(
323
385
  allArgs.push("--model", modelOverride);
324
386
  }
325
387
  }
326
- const proc = spawn(CURSOR_AGENT_COMMAND, allArgs, {
327
- cwd,
328
- stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
329
- windowsHide: true,
330
- shell: true,
331
- });
388
+ const proc = spawnImpl(CURSOR_AGENT_COMMAND, allArgs, {
389
+ cwd,
390
+ stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
391
+ windowsHide: true,
392
+ shell: true,
393
+ });
332
394
 
333
395
  console.log(`[Cursor debug] spawn: cmd=${CURSOR_AGENT_COMMAND}, args=[${allArgs.join(", ")}], cwd=${cwd ?? "(none)"}, stdinLen=${stdinText?.length ?? 0}, pid=${proc.pid}`);
334
396
 
335
- // 收集 stderr,子进程异常退出时输出到日志,方便排查静默失败
336
- let stderr = "";
337
- proc.stderr!.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
338
- proc.on("close", (code) => {
339
- if (stderr.trim()) {
340
- console.error(`[Cursor stderr] exit=${code}: ${stderr.trim().slice(0, 2000)}`);
341
- }
342
- });
343
-
344
- if (stdinText !== undefined) {
345
- proc.stdin!.write(stdinText);
346
- proc.stdin!.end();
347
- }
348
- return proc;
349
- }
350
-
397
+ // 收集 stderr,子进程异常退出时输出到日志,方便排查静默失败
398
+ let stderr = "";
399
+ let closeInfo: CursorProcessCloseInfo | null = null;
400
+ let resolveClose: (info: CursorProcessCloseInfo) => void = () => {};
401
+ const closePromise = new Promise<CursorProcessCloseInfo>((resolve) => {
402
+ resolveClose = resolve;
403
+ });
404
+ const settleClose = (code: number | null, signal: NodeJS.Signals | null) => {
405
+ if (closeInfo) return;
406
+ closeInfo = { code, signal, stderr };
407
+ if (stderr.trim()) {
408
+ console.error(`[Cursor stderr] exit=${code}: ${stderr.trim().slice(0, 2000)}`);
409
+ }
410
+ resolveClose(closeInfo);
411
+ };
412
+ proc.stderr!.on("data", (chunk: Buffer) => { stderr += chunk.toString(); });
413
+ proc.once("error", (err) => {
414
+ stderr += `${stderr ? "\n" : ""}${(err as Error).message}`;
415
+ settleClose(null, null);
416
+ });
417
+ proc.once("close", (code, signal) => { settleClose(code, signal); });
418
+
419
+ if (stdinText !== undefined) {
420
+ proc.stdin!.write(stdinText);
421
+ proc.stdin!.end();
422
+ }
423
+ return {
424
+ proc,
425
+ getStderr: () => stderr,
426
+ waitForClose: async () => {
427
+ if (closeInfo) return { ...closeInfo, stderr };
428
+ const info = await closePromise;
429
+ return { ...info, stderr };
430
+ },
431
+ };
432
+ }
433
+
351
434
  async function* readJsonLines(
352
435
  proc: ChildProcess,
353
436
  signal?: AbortSignal,
354
437
  debugTag?: string,
355
438
  rawLog?: RawStreamLogHandle | null,
439
+ stats?: CursorStreamStats,
356
440
  ): AsyncGenerator<CursorMessageLine> {
357
441
  const tag = debugTag ?? "cursor";
358
442
  const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
@@ -361,15 +445,21 @@ async function* readJsonLines(
361
445
  signal?.addEventListener("abort", onAbort, { once: true });
362
446
  let lineCount = 0;
363
447
  try {
364
- for await (const line of rl) {
365
- if (signal?.aborted) break;
366
- lineCount++;
448
+ for await (const line of rl) {
449
+ if (signal?.aborted) break;
450
+ lineCount++;
451
+ if (stats) {
452
+ stats.rawLineCount++;
453
+ stats.stdoutLength += Buffer.byteLength(line, "utf-8") + 1;
454
+ }
367
455
  const trimmed = line.trim();
368
456
  if (!trimmed) continue;
369
457
  rawLog?.writeLine(trimmed);
370
458
  try {
371
- yield JSON.parse(trimmed) as CursorMessageLine;
372
- } catch { /* 非 JSON 行静默跳过 */ }
459
+ const parsed = JSON.parse(trimmed) as CursorMessageLine;
460
+ if (stats) stats.parsedLineCount++;
461
+ yield parsed;
462
+ } catch { /* 非 JSON 行静默跳过 */ }
373
463
  }
374
464
  } finally {
375
465
  signal?.removeEventListener("abort", onAbort);
@@ -384,24 +474,28 @@ async function* readJsonLines(
384
474
 
385
475
  class CursorAdapter implements ToolAdapter {
386
476
  readonly displayName = "Cursor";
387
- readonly sessionDescPrefix = "Cursor Session:";
388
- private activeProcs = new Set<ChildProcess>();
389
- private metaStore: CursorSessionMetaStore;
390
- private modelOverride: string | undefined;
391
-
392
- constructor(metaStore: CursorSessionMetaStore, modelOverride?: string) {
393
- this.metaStore = metaStore;
394
- this.modelOverride = modelOverride;
395
- }
396
-
397
- async createSession(cwd: string): Promise<CreateSessionResult> {
398
- const proc = spawnAgent(["ok"], cwd, undefined, this.modelOverride);
399
- this.activeProcs.add(proc);
400
-
401
- for await (const msg of readJsonLines(proc, undefined, "createSession")) {
402
- if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
403
- const sessionId = msg.session_id;
404
- await this.metaStore
477
+ readonly sessionDescPrefix = "Cursor Session:";
478
+ private activeProcs = new Set<ChildProcess>();
479
+ private metaStore: CursorSessionMetaStore;
480
+ private modelOverride: string | undefined;
481
+ private spawnImpl: CursorSpawn;
482
+
483
+ constructor(metaStore: CursorSessionMetaStore, modelOverride?: string, spawnImpl: CursorSpawn = spawn) {
484
+ this.metaStore = metaStore;
485
+ this.modelOverride = modelOverride;
486
+ this.spawnImpl = spawnImpl;
487
+ }
488
+
489
+ async createSession(cwd: string): Promise<CreateSessionResult> {
490
+ const handle = spawnAgent(["ok"], cwd, undefined, this.modelOverride, undefined, this.spawnImpl);
491
+ const proc = handle.proc;
492
+ const stats = createCursorStreamStats();
493
+ this.activeProcs.add(proc);
494
+
495
+ for await (const msg of readJsonLines(proc, undefined, "createSession", null, stats)) {
496
+ if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
497
+ const sessionId = msg.session_id;
498
+ await this.metaStore
405
499
  .set(sessionId, { cwd: msg.cwd ?? cwd, model: msg.model })
406
500
  .catch(() => {});
407
501
  this.activeProcs.delete(proc);
@@ -409,11 +503,18 @@ class CursorAdapter implements ToolAdapter {
409
503
  return { sessionId };
410
504
  }
411
505
  }
412
-
413
- await killProcessTree(proc.pid);
414
- this.activeProcs.delete(proc);
415
- throw new Error("No session ID in Cursor init event");
416
- }
506
+
507
+ await killProcessTree(proc.pid);
508
+ this.activeProcs.delete(proc);
509
+ const closeInfo = await handle.waitForClose();
510
+ const visibleMessage = formatCursorAgentEmptyOutputMessage({
511
+ exitCode: closeInfo.code,
512
+ stdoutLength: stats.stdoutLength,
513
+ stderr: closeInfo.stderr,
514
+ });
515
+ if (visibleMessage) throw new Error(visibleMessage);
516
+ throw new Error("No session ID in Cursor init event");
517
+ }
417
518
 
418
519
  async *prompt(
419
520
  sessionId: string,
@@ -424,14 +525,16 @@ class CursorAdapter implements ToolAdapter {
424
525
  ): AsyncIterable<UnifiedStreamMessage> {
425
526
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
426
527
  const cmd = parseUserCommand(userText);
427
- const proc = spawnAgent(
428
- ["--resume", sessionId],
429
- cwd,
430
- buildCursorPromptText(userText),
431
- this.modelOverride,
432
- cmd.mode ?? undefined,
433
- );
434
- this.activeProcs.add(proc);
528
+ const handle = spawnAgent(
529
+ ["--resume", sessionId],
530
+ cwd,
531
+ buildCursorPromptText(userText),
532
+ this.modelOverride,
533
+ cmd.mode ?? undefined,
534
+ this.spawnImpl,
535
+ );
536
+ const proc = handle.proc;
537
+ this.activeProcs.add(proc);
435
538
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
436
539
 
437
540
  const rawLogConfig = config.rawStreamLogs.cursor;
@@ -455,12 +558,13 @@ class CursorAdapter implements ToolAdapter {
455
558
  const onAbort = () => { void killProcessTree(proc.pid); };
456
559
  signal?.addEventListener("abort", onAbort, { once: true });
457
560
  let sawResult = false;
561
+ const stats = createCursorStreamStats();
458
562
 
459
563
  try {
460
- for await (const raw of readJsonLines(proc, signal, sessionId, rawLog)) {
461
- if (signal?.aborted) break;
462
- if (
463
- raw.type === "system" &&
564
+ for await (const raw of readJsonLines(proc, signal, sessionId, rawLog, stats)) {
565
+ if (signal?.aborted) break;
566
+ if (
567
+ raw.type === "system" &&
464
568
  raw.subtype === "init" &&
465
569
  raw.session_id &&
466
570
  (raw.cwd || raw.model)
@@ -476,11 +580,26 @@ class CursorAdapter implements ToolAdapter {
476
580
  if (raw.type === "result") {
477
581
  sawResult = true;
478
582
  void killProcessTree(proc.pid);
479
- break;
480
- }
481
- }
482
- } finally {
483
- signal?.removeEventListener("abort", onAbort);
583
+ break;
584
+ }
585
+ }
586
+ if (!signal?.aborted && !sawResult) {
587
+ const closeInfo = await handle.waitForClose();
588
+ const visibleMessage = formatCursorAgentEmptyOutputMessage({
589
+ exitCode: closeInfo.code,
590
+ stdoutLength: stats.stdoutLength,
591
+ stderr: closeInfo.stderr,
592
+ });
593
+ if (visibleMessage) {
594
+ yield {
595
+ type: "assistant",
596
+ blocks: [{ type: "text_final", text: visibleMessage }],
597
+ };
598
+ throw createCursorAgentFailureError(closeInfo);
599
+ }
600
+ }
601
+ } finally {
602
+ signal?.removeEventListener("abort", onAbort);
484
603
  await killProcessTree(proc.pid);
485
604
  await rawLog?.close({ keep: rawLogConfig.keepCompleted || signal?.aborted === true || !sawResult });
486
605
  this.activeProcs.delete(proc);
@@ -508,15 +627,21 @@ class CursorAdapter implements ToolAdapter {
508
627
  // 工厂函数
509
628
  // ---------------------------------------------------------------------------
510
629
 
511
- export interface CreateCursorAdapterOptions {
512
- /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
513
- metaStore?: CursorSessionMetaStore;
514
- /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 cursor.model */
515
- model?: string;
516
- }
517
-
518
- export function createCursorAdapter(
519
- options: CreateCursorAdapterOptions = {},
520
- ): ToolAdapter {
521
- return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore, options.model);
630
+ export interface CreateCursorAdapterOptions {
631
+ /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
632
+ metaStore?: CursorSessionMetaStore;
633
+ /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 cursor.model */
634
+ model?: string;
635
+ /** 注入自定义 spawn 实现(测试用)。 */
636
+ spawn?: CursorSpawn;
637
+ }
638
+
639
+ export function createCursorAdapter(
640
+ options: CreateCursorAdapterOptions = {},
641
+ ): ToolAdapter {
642
+ return new CursorAdapter(
643
+ options.metaStore ?? defaultCursorSessionMetaStore,
644
+ options.model,
645
+ options.spawn,
646
+ );
522
647
  }
package/src/session.ts CHANGED
@@ -1168,11 +1168,12 @@ export async function runAgentSession(
1168
1168
  chunkCount: 0,
1169
1169
  };
1170
1170
 
1171
- let lastFileWrite = Date.now();
1172
- const FILE_WRITE_INTERVAL_MS = 2000;
1173
- const toolCallMap = new Map<string, { name: string; input: unknown }>();
1174
-
1175
- try {
1171
+ let lastFileWrite = Date.now();
1172
+ const FILE_WRITE_INTERVAL_MS = 2000;
1173
+ const toolCallMap = new Map<string, { name: string; input: unknown }>();
1174
+ let streamErrored = false;
1175
+
1176
+ try {
1176
1177
  for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1177
1178
  onProcessStart: (processInfo) => {
1178
1179
  startPromptProcessMonitor(sessionId, processInfo);
@@ -1222,10 +1223,11 @@ export async function runAgentSession(
1222
1223
  tool,
1223
1224
  });
1224
1225
  }
1225
- }
1226
- } catch (streamErr) {
1227
- console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1228
- } finally {
1226
+ }
1227
+ } catch (streamErr) {
1228
+ streamErrored = true;
1229
+ console.error(`[${ts()}] [STREAM] Error in stream loop for ${sessionId}: ${(streamErr as Error).message}`);
1230
+ } finally {
1229
1231
  // 标记 prompt 结束
1230
1232
  resourceMonitor.off("stuck", onResourceStuck);
1231
1233
  const prompt = activePrompts.get(sessionId);
@@ -1239,7 +1241,7 @@ export async function runAgentSession(
1239
1241
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
1240
1242
  // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
1241
1243
  // 运行中并更新旧卡片,而不是新建卡片。
1242
- const finalStatus = (wasAbnormalExit || wasResourceStuck) ? "error" : wasStopped ? "stopped" : "done";
1244
+ const finalStatus = (streamErrored || wasAbnormalExit || wasResourceStuck) ? "error" : wasStopped ? "stopped" : "done";
1243
1245
  const finalReply = pickFinalReply(state).trim();
1244
1246
 
1245
1247
  // stop-stuck-loop 接口可能在 fire-and-forget 中已写入带 final_reply 的