chatccc 0.2.211 → 0.2.213

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -332,9 +332,11 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
332
332
 
333
333
  **飞书:** 找到机器人后直接发送普通消息,即可在当前私聊中创建并持续使用专属 AI 会话;私聊工作目录固定为运行 ChatCCC 的系统账号用户目录。默认 Agent 发生变化后,下一条私聊普通消息会触发切换并创建新的空会话;若旧 Agent 正在生成,该消息会先排队,待当前回复完成后再切换。命令不会触发自动切换。需要独立任务时,发送 `/new`、`/new claude`、`/new cursor` 或 `/new codex`,机器人会另外创建会话群。
334
334
 
335
- **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
336
-
337
- ## 可用指令
335
+ **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
336
+
337
+ **会话停滞保护:** 当 Agent 连续 3 分钟停在“正在启动 Agent”且没有任何事件,或停在“正在生成回复”且回复字符数没有变化,同时尚未报告权威终态时,ChatCCC 会结束旧 CLI,并优先补发一次“完成了吗?如果没完成继续”;恢复轮再次发生相同停滞时不再递归续跑。`/new claude` 和 `/new cursor` 在等待底层 init 事件时也使用 3 分钟超时并主动清理 SDK/CLI。思考、搜索和工具调用阶段不按回复字符数误判,由进程资源监控负责识别真正僵死。Codex 只有 `turn.completed` 才算权威终态,阶段性的 `agent_message` 不算;任一 Agent 报告权威终态后若输出流仍超过 10 秒未关闭,ChatCCC 会强制清理该 CLI 并按正常完成收尾,不会重复询问 Agent。
338
+
339
+ ## 可用指令
338
340
 
339
341
  | 指令 | 作用 |
340
342
  | --- | --- |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.211",
3
+ "version": "0.2.213",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -52,6 +52,7 @@ describe("normalizeSdkMessage", () => {
52
52
  expect(result).not.toBeNull();
53
53
  expect(result!.type).toBe("assistant");
54
54
  expect(result!.blocks).toEqual([{ type: "text", text: "Hello world" }]);
55
+ expect(result!.isFinalResponse).toBeUndefined();
55
56
  });
56
57
 
57
58
  it("normalizes assistant message with thinking block", () => {
@@ -378,6 +379,20 @@ describe("createClaudeAdapter", () => {
378
379
  await expect(adapter.closeSession("any-sid")).resolves.toBeUndefined();
379
380
  });
380
381
 
382
+ it("does not start SDK session creation when the startup signal is already aborted", async () => {
383
+ const adapter = createClaudeAdapter({
384
+ model: "claude-sonnet-4-6",
385
+ effort: "high",
386
+ isEmpty: () => false,
387
+ });
388
+ const controller = new AbortController();
389
+ controller.abort();
390
+
391
+ await expect(
392
+ adapter.createSession("F:\\repo", controller.signal),
393
+ ).rejects.toThrow("Claude session creation aborted");
394
+ });
395
+
381
396
  // -------------------------------------------------------------------------
382
397
  // getSessionInfo 行为契约
383
398
  // - cwd 决定 /git 是否可用
@@ -56,15 +56,15 @@ function createInMemoryMetaStore(
56
56
  // ---------------------------------------------------------------------------
57
57
 
58
58
  describe("normalizeCodexMessage", () => {
59
- it("normalizes agent_message into assistant text block", () => {
60
- const result = normalizeCodexMessage({
61
- type: "item.completed",
62
- item: { id: "item_0", type: "agent_message", text: "hello" },
59
+ it("normalizes agent_message into assistant text block", () => {
60
+ const result = normalizeCodexMessage({
61
+ type: "item.completed",
62
+ item: { id: "item_0", type: "agent_message", text: "hello" },
63
63
  });
64
64
  expect(result).not.toBeNull();
65
65
  expect(result!.type).toBe("assistant");
66
66
  expect(result!.blocks).toEqual([{ type: "text", text: "hello" }]);
67
- expect(result!.isFinalResponse).toBe(true);
67
+ expect(result!.isFinalResponse).toBeUndefined();
68
68
  });
69
69
 
70
70
  it("normalizes command_execution start into tool_use block", () => {
@@ -143,13 +143,17 @@ describe("normalizeCodexMessage", () => {
143
143
  expect(normalizeCodexMessage({ type: "turn.started" })).toBeNull();
144
144
  });
145
145
 
146
- it("returns null for turn.completed events", () => {
147
- expect(
148
- normalizeCodexMessage({
149
- type: "turn.completed",
150
- usage: { input_tokens: 100, output_tokens: 50 },
151
- }),
152
- ).toBeNull();
146
+ it("marks turn.completed as the authoritative final response", () => {
147
+ expect(
148
+ normalizeCodexMessage({
149
+ type: "turn.completed",
150
+ usage: { input_tokens: 100, output_tokens: 50 },
151
+ }),
152
+ ).toEqual({
153
+ type: "assistant",
154
+ blocks: [],
155
+ isFinalResponse: true,
156
+ });
153
157
  });
154
158
 
155
159
  it("returns null for unknown event types", () => {
@@ -225,7 +229,7 @@ describe("Codex stream fixtures", () => {
225
229
  expect(state.accumulatedContent).toContain("tool_test");
226
230
  });
227
231
 
228
- it("with tool: 只映射 agent_message 和 command_execution,不映射元事件", () => {
232
+ it("with tool: 普通输出不标终态,只有 turn.completed 标记终态", () => {
229
233
  const lines = readFixture("codex_with_tool.jsonl");
230
234
  const messages: UnifiedStreamMessage[] = [];
231
235
  for (const raw of lines) {
@@ -235,12 +239,18 @@ describe("Codex stream fixtures", () => {
235
239
  if (normalized) messages.push(normalized);
236
240
  }
237
241
 
238
- // 应有: tool_use + tool_result + text = 3 条消息
239
- expect(messages.length).toBe(3);
240
- expect(messages[0].blocks[0].type).toBe("tool_use");
241
- expect(messages[1].blocks[0].type).toBe("tool_result");
242
- expect(messages[2].blocks[0].type).toBe("text");
243
- });
242
+ // 应有: tool_use + tool_result + text + turn.completed = 4 条消息
243
+ expect(messages.length).toBe(4);
244
+ expect(messages[0].blocks[0].type).toBe("tool_use");
245
+ expect(messages[1].blocks[0].type).toBe("tool_result");
246
+ expect(messages[2].blocks[0].type).toBe("text");
247
+ expect(messages[2].isFinalResponse).toBeUndefined();
248
+ expect(messages[3]).toEqual({
249
+ type: "assistant",
250
+ blocks: [],
251
+ isFinalResponse: true,
252
+ });
253
+ });
244
254
  });
245
255
 
246
256
  // ---------------------------------------------------------------------------
@@ -136,11 +136,17 @@ describe("Codex raw stream logs", () => {
136
136
  expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(2, lines[1]);
137
137
  expect(rawLogWriteLineMock).toHaveBeenNthCalledWith(3, lines[2]);
138
138
  expect(rawLogCloseMock).toHaveBeenCalledWith({ keep: false });
139
- expect(events).toContainEqual({
140
- type: "assistant",
141
- blocks: [{ type: "text", text: "hello" }],
142
- isFinalResponse: true,
143
- });
139
+ expect(events).toEqual([
140
+ {
141
+ type: "assistant",
142
+ blocks: [{ type: "text", text: "hello" }],
143
+ },
144
+ {
145
+ type: "assistant",
146
+ blocks: [],
147
+ isFinalResponse: true,
148
+ },
149
+ ]);
144
150
  });
145
151
 
146
152
  it("fails the turn and kills the process tree when bad JSON is followed by idle stdout", async () => {
@@ -347,19 +347,32 @@ describe("normalizeCursorMessage", () => {
347
347
  // createCursorAdapter — 工厂函数测试
348
348
  // ---------------------------------------------------------------------------
349
349
 
350
- describe("createCursorAdapter", () => {
350
+ describe("createCursorAdapter", () => {
351
351
  it("returns adapter with correct displayName and sessionDescPrefix", () => {
352
352
  const adapter = createCursorAdapter();
353
353
  expect(adapter.displayName).toBe("Cursor");
354
354
  expect(adapter.sessionDescPrefix).toBe("Cursor Session:");
355
355
  });
356
356
 
357
- it("closeSession does not throw", async () => {
358
- const adapter = createCursorAdapter();
359
- await expect(adapter.closeSession("any-id")).resolves.toBeUndefined();
360
- });
361
-
362
- // -------------------------------------------------------------------------
357
+ it("closeSession does not throw", async () => {
358
+ const adapter = createCursorAdapter();
359
+ await expect(adapter.closeSession("any-id")).resolves.toBeUndefined();
360
+ });
361
+
362
+ it("aborts a zero-output createSession stream instead of leaving Cursor Agent alive", async () => {
363
+ const store = createInMemoryMetaStore();
364
+ const spawnImpl = (() =>
365
+ createHangingMockCursorProcess({})) as CursorSpawnForTest;
366
+ const adapter = createCursorAdapter({ metaStore: store, spawn: spawnImpl });
367
+ const controller = new AbortController();
368
+
369
+ const pending = adapter.createSession("F:/repo", controller.signal);
370
+ controller.abort();
371
+
372
+ await expect(pending).rejects.toThrow("Cursor session creation aborted");
373
+ });
374
+
375
+ // -------------------------------------------------------------------------
363
376
  // getSessionInfo 行为契约
364
377
  // - cwd 决定 /git 是否可用
365
378
  // - model 决定 /state、/sessions 显示的是否是 Cursor 真实模型
@@ -445,9 +458,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
445
458
  type: "assistant",
446
459
  message: { role: "assistant", content: [{ type: "text", text: "你" }] },
447
460
  timestamp_ms: 1778411927583,
448
- });
449
- expect(result).not.toBeNull();
450
- expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
461
+ });
462
+ expect(result).not.toBeNull();
463
+ expect(result!.blocks).toEqual([{ type: "text", text: "你" }]);
464
+ expect(result!.isFinalResponse).toBeUndefined();
451
465
  });
452
466
 
453
467
  it("Buffered flush(has timestamp_ms, has model_call_id)→ text_final(覆盖,避免与 delta 重复累加)", () => {
@@ -461,9 +475,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
461
475
  model_call_id: "mc-1",
462
476
  } as Parameters<typeof normalizeCursorMessage>[0]);
463
477
  expect(result).not.toBeNull();
464
- expect(result!.blocks).toEqual([
465
- { type: "text_final", text: "完整快照(与 delta 累计相同)" },
466
- ]);
478
+ expect(result!.blocks).toEqual([
479
+ { type: "text_final", text: "完整快照(与 delta 累计相同)" },
480
+ ]);
481
+ expect(result!.isFinalResponse).toBeUndefined();
467
482
  });
468
483
 
469
484
  it("Final flush(no timestamp_ms)→ text_final(覆盖)", () => {
@@ -475,9 +490,10 @@ describe("normalizeCursorMessage - 三类 assistant 事件区分", () => {
475
490
  },
476
491
  });
477
492
  expect(result).not.toBeNull();
478
- expect(result!.blocks).toEqual([
479
- { type: "text_final", text: "你上一题问的是 1+2=?" },
480
- ]);
493
+ expect(result!.blocks).toEqual([
494
+ { type: "text_final", text: "你上一题问的是 1+2=?" },
495
+ ]);
496
+ expect(result!.isFinalResponse).toBeUndefined();
481
497
  });
482
498
  });
483
499
 
@@ -427,7 +427,7 @@ describe("handleCommand WeChat processing ack", () => {
427
427
 
428
428
  await handleCommand(platform, "使用新的默认 Agent", "feishu-p2p", "ou-user", Date.now(), "p2p");
429
429
 
430
- expect(createCursorSession).toHaveBeenCalledWith(homedir());
430
+ expect(createCursorSession).toHaveBeenCalledWith(homedir(), expect.any(AbortSignal));
431
431
  expect(oldPrompt).not.toHaveBeenCalled();
432
432
  expect(cursorPrompt).toHaveBeenCalledWith(
433
433
  "sid-new-cursor",
@@ -549,7 +549,7 @@ describe("handleCommand WeChat processing ack", () => {
549
549
 
550
550
  await handleCommand(platform, "帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
551
551
 
552
- expect(createSession).toHaveBeenCalledWith(homedir());
552
+ expect(createSession).toHaveBeenCalledWith(homedir(), expect.any(AbortSignal));
553
553
  expect(platform.createGroup).not.toHaveBeenCalled();
554
554
  expect(platform.updateChatInfo).not.toHaveBeenCalled();
555
555
  expect(prompt).toHaveBeenCalledWith(
@@ -596,7 +596,7 @@ describe("handleCommand WeChat processing ack", () => {
596
596
 
597
597
  await handleCommand(platform, "继续", "feishu-p2p", "ou-user", Date.now(), "p2p");
598
598
 
599
- expect(createSession).toHaveBeenCalledWith(homedir());
599
+ expect(createSession).toHaveBeenCalledWith(homedir(), expect.any(AbortSignal));
600
600
  expect(prompt).toHaveBeenCalledWith(
601
601
  "sid-feishu-migrated",
602
602
  expect.stringContaining("继续"),
@@ -633,7 +633,7 @@ describe("handleCommand WeChat processing ack", () => {
633
633
 
634
634
  await handleCommand(platform, "/abd帮我看一下日志", "feishu-p2p", "ou-user", Date.now(), "p2p");
635
635
 
636
- expect(createSession).toHaveBeenCalledWith(homedir());
636
+ expect(createSession).toHaveBeenCalledWith(homedir(), expect.any(AbortSignal));
637
637
  expect(platform.createGroup).not.toHaveBeenCalled();
638
638
  expect(platform.updateChatInfo).not.toHaveBeenCalled();
639
639
  const userText = prompt.mock.calls[0][1];
@@ -706,7 +706,7 @@ describe("handleCommand WeChat processing ack", () => {
706
706
 
707
707
  await handleCommand(platform, "/newh", "feishu-p2p", "ou-user", Date.now(), "p2p");
708
708
 
709
- expect(createSession).toHaveBeenCalledWith(homedir());
709
+ expect(createSession).toHaveBeenCalledWith(homedir(), expect.any(AbortSignal));
710
710
  expect(platform.createGroup).not.toHaveBeenCalled();
711
711
  expect(platform.updateChatInfo).not.toHaveBeenCalled();
712
712
  const registry = await loadSessionRegistryForBinding();
@@ -3,7 +3,7 @@ import { mkdtemp, rm, readFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { dirname, join } from "node:path";
5
5
 
6
- const killProcessTreeMock = vi.hoisted(() => vi.fn(async () => {}));
6
+ const killProcessTreeMock = vi.hoisted(() => vi.fn(async (_pid?: number) => {}));
7
7
  vi.mock("../adapters/proc-tree-kill.ts", () => ({
8
8
  killProcessTree: killProcessTreeMock,
9
9
  }));
@@ -109,8 +109,9 @@ import {
109
109
  _clearAdapterCacheForTest,
110
110
  setSessionPlatform,
111
111
  recordChatPlatform,
112
- _getPlatformForChatForTest,
113
- runAgentSession,
112
+ _getPlatformForChatForTest,
113
+ initClaudeSession,
114
+ runAgentSession,
114
115
  stopSession,
115
116
  startUnifiedDisplayLoop,
116
117
  stopUnifiedDisplayLoop,
@@ -122,6 +123,8 @@ import {
122
123
  _resetResponseStallTimeoutForTest,
123
124
  _setResponseStallCheckIntervalForTest,
124
125
  _resetResponseStallCheckIntervalForTest,
126
+ _setFinalResponseCloseTimeoutForTest,
127
+ _resetFinalResponseCloseTimeoutForTest,
125
128
  setSessionEffortOverride,
126
129
  RESPONSE_STALL_RECOVERY_PROMPT,
127
130
  RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE,
@@ -224,7 +227,7 @@ function mockPlatform(name: string): PlatformAdapter {
224
227
  };
225
228
  }
226
229
 
227
- describe("resetState", () => {
230
+ describe("resetState", () => {
228
231
  it("clears all maps and sets", () => {
229
232
  chatSessionMap.set("chat1", {
230
233
  gen: 1, close: () => {}, cardId: null, stopped: false,
@@ -243,9 +246,58 @@ describe("resetState", () => {
243
246
  expect(sessionInfoMap.size).toBe(0);
244
247
  expect(processedMessages.size).toBe(0);
245
248
  });
246
- });
247
-
248
- describe("chat platform routing", () => {
249
+ });
250
+
251
+ describe("initClaudeSession startup watchdog", () => {
252
+ afterEach(() => {
253
+ _clearAdapterCacheForTest();
254
+ _resetResponseStallTimeoutForTest();
255
+ vi.useRealTimers();
256
+ });
257
+
258
+ it("aborts session creation when an Agent never emits its init event", async () => {
259
+ vi.useFakeTimers();
260
+ _setResponseStallTimeoutForTest(100);
261
+
262
+ let aborted = false;
263
+ const adapter: ToolAdapter = {
264
+ displayName: "Silent Cursor",
265
+ sessionDescPrefix: "Cursor Session:",
266
+ createSession: async (_cwd: string, signal?: AbortSignal) => {
267
+ await new Promise<void>((resolve) => {
268
+ if (signal?.aborted) {
269
+ aborted = true;
270
+ resolve();
271
+ return;
272
+ }
273
+ signal?.addEventListener("abort", () => {
274
+ aborted = true;
275
+ resolve();
276
+ }, { once: true });
277
+ });
278
+ throw new Error("adapter aborted");
279
+ },
280
+ prompt: async function* () {},
281
+ getSessionInfo: async () => undefined,
282
+ closeSession: async () => {},
283
+ };
284
+ _setAdapterForToolForTest("cursor", adapter);
285
+
286
+ let outcome: unknown;
287
+ void initClaudeSession("cursor", "F:\\repo").then(
288
+ (value) => { outcome = value; },
289
+ (err) => { outcome = err; },
290
+ );
291
+
292
+ await vi.advanceTimersByTimeAsync(101);
293
+
294
+ expect(aborted).toBe(true);
295
+ expect(outcome).toBeInstanceOf(Error);
296
+ expect((outcome as Error).message).toContain("3 minutes");
297
+ });
298
+ });
299
+
300
+ describe("chat platform routing", () => {
249
301
  beforeEach(() => {
250
302
  resetState();
251
303
  });
@@ -371,6 +423,7 @@ describe("runAgentSession process monitor", () => {
371
423
  _resetProcessMonitorIntervalForTest();
372
424
  _resetResponseStallTimeoutForTest();
373
425
  _resetResponseStallCheckIntervalForTest();
426
+ _resetFinalResponseCloseTimeoutForTest();
374
427
  resetBindingState();
375
428
  vi.useRealTimers();
376
429
  });
@@ -692,6 +745,7 @@ describe("runAgentSession response stall watchdog", () => {
692
745
  _resetProcessAliveForTest();
693
746
  _resetResponseStallTimeoutForTest();
694
747
  _resetResponseStallCheckIntervalForTest();
748
+ _resetFinalResponseCloseTimeoutForTest();
695
749
  resetBindingState();
696
750
  vi.useRealTimers();
697
751
  if (tempDir) await rm(tempDir, { recursive: true, force: true });
@@ -770,6 +824,88 @@ describe("runAgentSession response stall watchdog", () => {
770
824
  });
771
825
  });
772
826
 
827
+ it("auto-ends an Agent that stays in starting state without emitting any event", async () => {
828
+ vi.setSystemTime(0);
829
+ _setResponseStallTimeoutForTest(180_000);
830
+ _setResponseStallCheckIntervalForTest(1_000);
831
+ _setProcessAliveForTest(() => true);
832
+
833
+ const platform = mockPlatform("feishu");
834
+ setSessionPlatform(platform);
835
+ bindChatToSession("sid-starting-stall", "chat-starting-stall");
836
+ recordLastActiveChat("sid-starting-stall", "chat-starting-stall");
837
+
838
+ const closeSession = vi.fn();
839
+ const adapter: ToolAdapter = {
840
+ displayName: "Silent Agent",
841
+ sessionDescPrefix: "Agent Session:",
842
+ createSession: async () => ({ sessionId: "sid-starting-stall" }),
843
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
844
+ closeSession: async () => {},
845
+ prompt: async function* (
846
+ _sid: string,
847
+ _text: string,
848
+ _cwd: string,
849
+ signal?: AbortSignal,
850
+ options?: ToolPromptOptions,
851
+ ) {
852
+ options?.onSessionCreated?.(closeSession);
853
+ options?.onProcessStart?.({ pid: 4343 });
854
+ await new Promise<void>((resolve) => {
855
+ if (signal?.aborted) {
856
+ resolve();
857
+ return;
858
+ }
859
+ signal?.addEventListener("abort", () => resolve(), { once: true });
860
+ });
861
+ if (false) yield { type: "assistant", blocks: [] };
862
+ },
863
+ };
864
+ _setAdapterForToolForTest("codex", adapter);
865
+
866
+ const runPromise = runAgentSession(
867
+ "sid-starting-stall",
868
+ "prompt",
869
+ platform,
870
+ "chat-starting-stall",
871
+ Date.now(),
872
+ "codex",
873
+ );
874
+
875
+ await vi.waitFor(() => {
876
+ expect(activePrompts.get("sid-starting-stall")).toMatchObject({
877
+ processPid: 4343,
878
+ responseProgress: {
879
+ totalChars: 0,
880
+ unchangedSince: expect.any(Number),
881
+ },
882
+ });
883
+ expect(closeSession).toHaveBeenCalledTimes(0);
884
+ });
885
+
886
+ await vi.advanceTimersByTimeAsync(181_001);
887
+ const stateAfterDeadline = mockStreamStates.get("sid-starting-stall");
888
+ const closeCountAfterDeadline = closeSession.mock.calls.length;
889
+ const treeKillCountAfterDeadline = killProcessTreeMock.mock.calls
890
+ .filter(([pid]) => pid === 4343)
891
+ .length;
892
+
893
+ // 旧实现不会结束真正的零事件启动;先清理测试会话,避免失败用例悬挂。
894
+ if (stateAfterDeadline?.status !== "auto_ended") {
895
+ stopSession("sid-starting-stall");
896
+ await vi.advanceTimersByTimeAsync(1_000);
897
+ }
898
+ await runPromise;
899
+
900
+ expect(stateAfterDeadline).toMatchObject({
901
+ status: "auto_ended",
902
+ finalReply: "",
903
+ autoEndedAt: expect.any(Number),
904
+ });
905
+ expect(closeCountAfterDeadline).toBe(1);
906
+ expect(treeKillCountAfterDeadline).toBeGreaterThanOrEqual(1);
907
+ });
908
+
773
909
  it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
774
910
  vi.setSystemTime(0);
775
911
  _setResponseStallTimeoutForTest(100);
@@ -919,6 +1055,141 @@ describe("runAgentSession response stall watchdog", () => {
919
1055
  );
920
1056
  });
921
1057
 
1058
+ it("force-closes a stream that stays open after an authoritative final event without auto-recovery", async () => {
1059
+ vi.setSystemTime(0);
1060
+ _setResponseStallTimeoutForTest(100);
1061
+ _setResponseStallCheckIntervalForTest(10);
1062
+ _setFinalResponseCloseTimeoutForTest(1_000);
1063
+ _setProcessAliveForTest(() => true);
1064
+
1065
+ const platform = mockPlatform("feishu");
1066
+ setSessionPlatform(platform);
1067
+ bindChatToSession("sid-final-close", "chat-final-close");
1068
+ recordLastActiveChat("sid-final-close", "chat-final-close");
1069
+
1070
+ const closeSession = vi.fn();
1071
+ const adapter: ToolAdapter = {
1072
+ displayName: "Any Agent",
1073
+ sessionDescPrefix: "Agent Session:",
1074
+ createSession: async () => ({ sessionId: "sid-final-close" }),
1075
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1076
+ closeSession: async () => {},
1077
+ prompt: async function* (
1078
+ _sid: string,
1079
+ _text: string,
1080
+ _cwd: string,
1081
+ signal?: AbortSignal,
1082
+ options?: ToolPromptOptions,
1083
+ ) {
1084
+ options?.onSessionCreated?.(closeSession);
1085
+ options?.onProcessStart?.({ pid: 7171 });
1086
+ yield {
1087
+ type: "assistant",
1088
+ blocks: [{ type: "text", text: "authoritative answer" }],
1089
+ };
1090
+ yield {
1091
+ type: "assistant",
1092
+ blocks: [],
1093
+ isFinalResponse: true,
1094
+ };
1095
+ await new Promise<void>((resolve) => {
1096
+ if (signal?.aborted) {
1097
+ resolve();
1098
+ return;
1099
+ }
1100
+ signal?.addEventListener("abort", () => resolve(), { once: true });
1101
+ });
1102
+ },
1103
+ };
1104
+ _setAdapterForToolForTest("claude", adapter);
1105
+
1106
+ const run = runAgentSession(
1107
+ "sid-final-close",
1108
+ "prompt",
1109
+ platform,
1110
+ "chat-final-close",
1111
+ 0,
1112
+ "claude",
1113
+ );
1114
+
1115
+ await vi.waitFor(() => {
1116
+ expect(activePrompts.get("sid-final-close")?.finalResponseObserved).toBe(true);
1117
+ });
1118
+ await vi.advanceTimersByTimeAsync(900);
1119
+ expect(activePrompts.has("sid-final-close")).toBe(true);
1120
+
1121
+ await vi.advanceTimersByTimeAsync(100);
1122
+ await run;
1123
+
1124
+ expect(closeSession).toHaveBeenCalledTimes(1);
1125
+ expect(killProcessTreeMock).toHaveBeenCalledWith(7171);
1126
+ expect(mockStreamStates.get("sid-final-close")).toMatchObject({
1127
+ status: "done",
1128
+ finalReply: "authoritative answer",
1129
+ });
1130
+ expect(platform.sendText).not.toHaveBeenCalledWith(
1131
+ "chat-final-close",
1132
+ expect.stringContaining(RESPONSE_STALL_RECOVERY_PROMPT),
1133
+ );
1134
+ });
1135
+
1136
+ it("cancels the final-response close guard when the stream exits normally", async () => {
1137
+ vi.setSystemTime(0);
1138
+ _setFinalResponseCloseTimeoutForTest(1_000);
1139
+ _setProcessAliveForTest(() => true);
1140
+
1141
+ const platform = mockPlatform("feishu");
1142
+ setSessionPlatform(platform);
1143
+ bindChatToSession("sid-final-normal", "chat-final-normal");
1144
+ recordLastActiveChat("sid-final-normal", "chat-final-normal");
1145
+
1146
+ const closeSession = vi.fn();
1147
+ const adapter: ToolAdapter = {
1148
+ displayName: "Any Agent",
1149
+ sessionDescPrefix: "Agent Session:",
1150
+ createSession: async () => ({ sessionId: "sid-final-normal" }),
1151
+ getSessionInfo: async (sid) => ({ sessionId: sid, cwd: "F:\\repo" }),
1152
+ closeSession: async () => {},
1153
+ prompt: async function* (
1154
+ _sid: string,
1155
+ _text: string,
1156
+ _cwd: string,
1157
+ _signal?: AbortSignal,
1158
+ options?: ToolPromptOptions,
1159
+ ) {
1160
+ options?.onSessionCreated?.(closeSession);
1161
+ options?.onProcessStart?.({ pid: 7272 });
1162
+ yield {
1163
+ type: "assistant",
1164
+ blocks: [{ type: "text", text: "normal answer" }],
1165
+ };
1166
+ yield {
1167
+ type: "assistant",
1168
+ blocks: [],
1169
+ isFinalResponse: true,
1170
+ };
1171
+ },
1172
+ };
1173
+ _setAdapterForToolForTest("claude", adapter);
1174
+
1175
+ await runAgentSession(
1176
+ "sid-final-normal",
1177
+ "prompt",
1178
+ platform,
1179
+ "chat-final-normal",
1180
+ 0,
1181
+ "claude",
1182
+ );
1183
+ await vi.advanceTimersByTimeAsync(2_000);
1184
+
1185
+ expect(closeSession).not.toHaveBeenCalled();
1186
+ expect(killProcessTreeMock).not.toHaveBeenCalled();
1187
+ expect(mockStreamStates.get("sid-final-normal")).toMatchObject({
1188
+ status: "done",
1189
+ finalReply: "normal answer",
1190
+ });
1191
+ });
1192
+
922
1193
  it("runs the reserved recovery prompt before an already queued user message", async () => {
923
1194
  vi.setSystemTime(0);
924
1195
  _setResponseStallTimeoutForTest(100);
@@ -1076,7 +1347,7 @@ describe("runAgentSession response stall watchdog", () => {
1076
1347
  expect(receivedPrompts[1]).toContain(recoveryPrompt);
1077
1348
  expect(platform.sendText).toHaveBeenCalledWith(
1078
1349
  "chat-recovery-limit",
1079
- "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。",
1350
+ "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
1080
1351
  );
1081
1352
  });
1082
1353
 
@@ -1335,7 +1606,7 @@ describe("unified display loop terminal card update", () => {
1335
1606
  expect(card.header.template).toBe("orange");
1336
1607
  expect(platform.sendText).toHaveBeenCalledWith(
1337
1608
  "chat-auto-ended",
1338
- "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。本轮没有可发送的回复内容。",
1609
+ "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
1339
1610
  );
1340
1611
  expect(displayCards.has("chat-auto-ended")).toBe(false);
1341
1612
  });
@@ -11,10 +11,15 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
11
11
  import type { StreamState } from "../stream-state.ts";
12
12
 
13
13
  // mock stream-state,使用模块内可观测的 Map 记录读写
14
- const stateStore = new Map<string, StreamState>();
15
- const writeCalls: StreamState[] = [];
16
-
17
- vi.mock("../stream-state.ts", () => ({
14
+ const stateStore = new Map<string, StreamState>();
15
+ const writeCalls: StreamState[] = [];
16
+ const killProcessTreeMock = vi.hoisted(() => vi.fn(async (_pid?: number) => {}));
17
+
18
+ vi.mock("../adapters/proc-tree-kill.ts", () => ({
19
+ killProcessTree: killProcessTreeMock,
20
+ }));
21
+
22
+ vi.mock("../stream-state.ts", () => ({
18
23
  readStreamState: async (sid: string): Promise<StreamState | null> => {
19
24
  return stateStore.get(sid) ?? null;
20
25
  },
@@ -72,9 +77,10 @@ async function flush(): Promise<void> {
72
77
  }
73
78
 
74
79
  beforeEach(() => {
75
- activePrompts.clear();
76
- stateStore.clear();
77
- writeCalls.length = 0;
80
+ activePrompts.clear();
81
+ stateStore.clear();
82
+ writeCalls.length = 0;
83
+ killProcessTreeMock.mockClear();
78
84
  });
79
85
 
80
86
  describe("stopSession 行为护栏", () => {
@@ -128,13 +134,29 @@ describe("stopSession 行为护栏", () => {
128
134
  expect(activePrompts.get("sid-C")?.stopped).toBe(true);
129
135
  });
130
136
 
131
- it("调用 adapter 提供的 closeSession 以主动关闭底层 SDK session", () => {
137
+ it("调用 adapter 提供的 closeSession 以主动关闭底层 SDK session", () => {
132
138
  const closeSession = vi.fn();
133
139
  seedRunningSession("sid-D", "partial output", closeSession);
134
140
 
135
141
  const ok = stopSession("sid-D");
136
142
 
137
- expect(ok).toBe(true);
138
- expect(closeSession).toHaveBeenCalledTimes(1);
139
- });
140
- });
143
+ expect(ok).toBe(true);
144
+ expect(closeSession).toHaveBeenCalledTimes(1);
145
+ });
146
+
147
+ it("按完整进程树停止 CLI,不先杀壳进程造成后代进程逃逸", async () => {
148
+ seedRunningSession("sid-tree");
149
+ activePrompts.get("sid-tree")!.processPid = 4242;
150
+ const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
151
+
152
+ try {
153
+ expect(stopSession("sid-tree")).toBe(true);
154
+ await flush();
155
+
156
+ expect(killProcessTreeMock).toHaveBeenCalledWith(4242);
157
+ expect(processKillSpy).not.toHaveBeenCalled();
158
+ } finally {
159
+ processKillSpy.mockRestore();
160
+ }
161
+ });
162
+ });
@@ -152,11 +152,12 @@ export interface ToolAdapter {
152
152
  /** 群描述中会话 ID 的前缀,如 "Claude Session:" */
153
153
  readonly sessionDescPrefix: string;
154
154
 
155
- /**
156
- * 创建新会话,返回会话 ID。
157
- * 适配器内部需处理后台流消费(静默消费 stream 中除 init 外的所有事件)。
158
- */
159
- createSession(cwd: string): Promise<CreateSessionResult>;
155
+ /**
156
+ * 创建新会话,返回会话 ID。
157
+ * 适配器内部需处理后台流消费(静默消费 stream 中除 init 外的所有事件)。
158
+ * signal 用于上层在长期收不到 init 事件时终止底层 SDK/CLI。
159
+ */
160
+ createSession(cwd: string, signal?: AbortSignal): Promise<CreateSessionResult>;
160
161
 
161
162
  /**
162
163
  * 向已有会话发送提示文本,返回归一化消息的异步迭代器。
@@ -452,9 +452,14 @@ class ClaudeAdapter implements ToolAdapter {
452
452
  this.maxTurn = options.maxTurn ?? 0;
453
453
  }
454
454
 
455
- async createSession(cwd: string): Promise<CreateSessionResult> {
455
+ async createSession(cwd: string, signal?: AbortSignal): Promise<CreateSessionResult> {
456
456
  logMcpConfig();
457
457
  const abortController = new AbortController();
458
+ const removeAbortListener = bridgeAbortSignal(signal, abortController);
459
+ if (abortController.signal.aborted) {
460
+ removeAbortListener?.();
461
+ throw new Error("Claude session creation aborted");
462
+ }
458
463
  let sessionId: string | undefined;
459
464
  const session = unstable_v2_createSession(
460
465
  toSdkSessionOptions(buildSdkOptions({
@@ -486,6 +491,7 @@ class ClaudeAdapter implements ToolAdapter {
486
491
  }
487
492
  }
488
493
  } finally {
494
+ removeAbortListener?.();
489
495
  closeSdkSession(session);
490
496
  }
491
497
 
@@ -130,7 +130,8 @@ interface CodexEvent {
130
130
  export function normalizeCodexMessage(
131
131
  msg: CodexEvent,
132
132
  ): UnifiedStreamMessage | null {
133
- // agent_message 文本回复
133
+ // agent_message 只是 Codex 的一条阶段性文本 item。即使内容看起来像完整答复,
134
+ // 后面仍可能继续发出工具调用,因此不能用它关闭 response-stall watchdog。
134
135
  if (
135
136
  msg.type === "item.completed" &&
136
137
  msg.item?.type === "agent_message" &&
@@ -139,6 +140,15 @@ export function normalizeCodexMessage(
139
140
  return {
140
141
  type: "assistant",
141
142
  blocks: [{ type: "text", text: msg.item.text }],
143
+ };
144
+ }
145
+
146
+ // turn.completed 是 Codex 对整轮完成的权威确认。正文已经由之前的
147
+ // agent_message 累计,这里只发送空终态信号,避免重复追加最终文本。
148
+ if (msg.type === "turn.completed") {
149
+ return {
150
+ type: "assistant",
151
+ blocks: [],
142
152
  isFinalResponse: true,
143
153
  };
144
154
  }
@@ -181,7 +191,7 @@ export function normalizeCodexMessage(
181
191
  };
182
192
  }
183
193
 
184
- // thread.started / turn.started / turn.completed → 不映射为用户可见消息
194
+ // thread.started / turn.started → 不映射为用户可见消息
185
195
  return null;
186
196
  }
187
197
 
@@ -511,34 +511,40 @@ class CursorAdapter implements ToolAdapter {
511
511
  this.badJsonIdleTimeoutMs = badJsonIdleTimeoutMs;
512
512
  }
513
513
 
514
- async createSession(cwd: string): Promise<CreateSessionResult> {
514
+ async createSession(cwd: string, signal?: AbortSignal): Promise<CreateSessionResult> {
515
+ if (signal?.aborted) throw new Error("Cursor session creation aborted");
515
516
  const handle = spawnAgent(["ok"], cwd, undefined, this.modelOverride, undefined, this.spawnImpl);
516
517
  const proc = handle.proc;
517
518
  const stats = createCursorStreamStats();
518
519
  this.activeProcs.add(proc);
520
+ const onAbort = () => { void killProcessTree(proc.pid); };
521
+ signal?.addEventListener("abort", onAbort, { once: true });
519
522
 
520
- for await (const msg of readJsonLines(proc, undefined, "createSession", null, stats, this.badJsonIdleTimeoutMs)) {
521
- if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
522
- const sessionId = msg.session_id;
523
- await this.metaStore
524
- .set(sessionId, { cwd: msg.cwd ?? cwd, model: msg.model })
525
- .catch(() => {});
526
- this.activeProcs.delete(proc);
527
- await killProcessTree(proc.pid);
528
- return { sessionId };
523
+ try {
524
+ for await (const msg of readJsonLines(proc, signal, "createSession", null, stats, this.badJsonIdleTimeoutMs)) {
525
+ if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
526
+ const sessionId = msg.session_id;
527
+ await this.metaStore
528
+ .set(sessionId, { cwd: msg.cwd ?? cwd, model: msg.model })
529
+ .catch(() => {});
530
+ return { sessionId };
531
+ }
529
532
  }
530
- }
531
533
 
532
- await killProcessTree(proc.pid);
533
- this.activeProcs.delete(proc);
534
- const closeInfo = await handle.waitForClose();
535
- const visibleMessage = formatCursorAgentEmptyOutputMessage({
536
- exitCode: closeInfo.code,
537
- stdoutLength: stats.stdoutLength,
538
- stderr: closeInfo.stderr,
539
- });
540
- if (visibleMessage) throw new Error(visibleMessage);
541
- throw new Error("No session ID in Cursor init event");
534
+ if (signal?.aborted) throw new Error("Cursor session creation aborted");
535
+ const closeInfo = await handle.waitForClose();
536
+ const visibleMessage = formatCursorAgentEmptyOutputMessage({
537
+ exitCode: closeInfo.code,
538
+ stdoutLength: stats.stdoutLength,
539
+ stderr: closeInfo.stderr,
540
+ });
541
+ if (visibleMessage) throw new Error(visibleMessage);
542
+ throw new Error("No session ID in Cursor init event");
543
+ } finally {
544
+ signal?.removeEventListener("abort", onAbort);
545
+ await killProcessTree(proc.pid);
546
+ this.activeProcs.delete(proc);
547
+ }
542
548
  }
543
549
 
544
550
  async *prompt(
@@ -1,20 +1,20 @@
1
- /** A snapshot of response output progress while the Agent is generating a reply. */
1
+ /** A snapshot of visible output progress while the Agent is starting or replying. */
2
2
  export interface ResponseProgressObservation {
3
3
  totalChars: number;
4
4
  unchangedSince: number;
5
5
  }
6
6
 
7
7
  /**
8
- * Tracks how long the displayed response character count has remained unchanged.
9
- * Leaving the responding phase clears the window; returning starts a fresh one.
8
+ * Tracks how long the displayed output character count has remained unchanged.
9
+ * Leaving a monitored phase clears the window; returning starts a fresh one.
10
10
  */
11
11
  export function observeResponseProgress(
12
12
  previous: ResponseProgressObservation | undefined,
13
- isResponding: boolean,
13
+ isMonitoredPhase: boolean,
14
14
  totalChars: number,
15
15
  now = Date.now(),
16
16
  ): ResponseProgressObservation | undefined {
17
- if (!isResponding) return undefined;
17
+ if (!isMonitoredPhase) return undefined;
18
18
  if (previous?.totalChars === totalChars) return previous;
19
19
  return { totalChars, unchangedSince: now };
20
20
  }
@@ -111,6 +111,8 @@ export interface ActivePrompt {
111
111
  processPid?: number;
112
112
  processMonitor?: ReturnType<typeof setInterval>;
113
113
  responseStallMonitor?: ReturnType<typeof setInterval>;
114
+ /** Grace timer that force-closes a stream which stays open after its authoritative final event. */
115
+ finalResponseCloseTimer?: ReturnType<typeof setTimeout>;
114
116
  /** Character-count progress observed only while the activity is "responding". */
115
117
  responseProgress?: ResponseProgressObservation;
116
118
  /** Set before a response-stall auto-end begins so competing monitors cannot win the race. */
@@ -275,6 +277,7 @@ export function resetBindingState(): void {
275
277
  for (const prompt of activePrompts.values()) {
276
278
  if (prompt.processMonitor) clearInterval(prompt.processMonitor);
277
279
  if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
280
+ if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
278
281
  }
279
282
  activePrompts.clear();
280
283
  finalizingSessions.clear();
package/src/session.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  formatAgentActivityTitle,
28
28
  updateAgentActivity,
29
29
  } from "./agent-activity.ts";
30
+ import type { AgentActivityKind } from "./agent-activity.ts";
30
31
  import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
31
32
  import { logTrace } from "./trace.ts";
32
33
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
@@ -165,15 +166,17 @@ function platformForChat(chatId: string): PlatformAdapter | null {
165
166
  const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
166
167
  const DEFAULT_RESPONSE_STALL_TIMEOUT_MS = 3 * 60 * 1000;
167
168
  const DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS = 5000;
169
+ const DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS = 10_000;
168
170
  export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继续";
169
171
  export const RESPONSE_STALL_RECOVERY_NOTICE =
170
172
  `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
171
173
  export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
172
- "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
174
+ "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。";
173
175
  const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
174
176
  let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
175
177
  let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
176
178
  let responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
179
+ let finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
177
180
  let isProcessAliveImpl = (pid: number): boolean => {
178
181
  try {
179
182
  process.kill(pid, 0);
@@ -221,7 +224,15 @@ export function _setResponseStallCheckIntervalForTest(ms: number): void {
221
224
  export function _resetResponseStallCheckIntervalForTest(): void {
222
225
  responseStallCheckIntervalMs = DEFAULT_RESPONSE_STALL_CHECK_INTERVAL_MS;
223
226
  }
224
-
227
+
228
+ export function _setFinalResponseCloseTimeoutForTest(ms: number): void {
229
+ finalResponseCloseTimeoutMs = ms;
230
+ }
231
+
232
+ export function _resetFinalResponseCloseTimeoutForTest(): void {
233
+ finalResponseCloseTimeoutMs = DEFAULT_FINAL_RESPONSE_CLOSE_TIMEOUT_MS;
234
+ }
235
+
225
236
  function clearPromptProcessMonitor(sessionId: string): void {
226
237
  const prompt = activePrompts.get(sessionId);
227
238
  if (!prompt?.processMonitor) return;
@@ -236,6 +247,59 @@ function clearPromptResponseStallMonitor(sessionId: string): void {
236
247
  prompt.responseStallMonitor = undefined;
237
248
  }
238
249
 
250
+ function clearPromptFinalResponseCloseTimer(sessionId: string): void {
251
+ const prompt = activePrompts.get(sessionId);
252
+ if (!prompt?.finalResponseCloseTimer) return;
253
+ clearTimeout(prompt.finalResponseCloseTimer);
254
+ prompt.finalResponseCloseTimer = undefined;
255
+ }
256
+
257
+ /**
258
+ * 权威终态只说明 Agent 已完成本轮,不保证 CLI/SDK 的输出流会及时关闭。
259
+ * 给正常清理保留 10 秒;若流仍悬挂,则关闭底层 session 并杀掉当前 CLI 树,
260
+ * 让 runAgentSession 以 done 收尾。这里绝不触发自动续跑,因为答案已完整到达。
261
+ */
262
+ function scheduleFinalResponseCloseGuard(
263
+ sessionId: string,
264
+ runningPrompt: NonNullable<ReturnType<typeof activePrompts.get>>,
265
+ ): void {
266
+ if (runningPrompt.finalResponseCloseTimer) return;
267
+
268
+ const timeoutMs = finalResponseCloseTimeoutMs;
269
+ const handle = setTimeout(() => {
270
+ const current = activePrompts.get(sessionId);
271
+ if (
272
+ !current
273
+ || current !== runningPrompt
274
+ || !current.finalResponseObserved
275
+ || current.stopped
276
+ || current.abnormalExit
277
+ || current.resourceStuck
278
+ || current.autoEnded
279
+ ) {
280
+ return;
281
+ }
282
+
283
+ current.finalResponseCloseTimer = undefined;
284
+ clearPromptProcessMonitor(sessionId);
285
+ clearPromptResponseStallMonitor(sessionId);
286
+ try {
287
+ current.closeSession?.();
288
+ } catch (err) {
289
+ console.warn(
290
+ `[${ts()}] [FINAL-RESPONSE] closeSession failed for ${sessionId}: ${(err as Error).message}`,
291
+ );
292
+ }
293
+ current.controller.abort();
294
+ void killProcessTree(current.processPid);
295
+ console.warn(
296
+ `[${ts()}] [FINAL-RESPONSE] Session ${sessionId} stream stayed open for ${timeoutMs}ms after its authoritative final event; forced clean shutdown`,
297
+ );
298
+ }, timeoutMs);
299
+ handle.unref?.();
300
+ runningPrompt.finalResponseCloseTimer = handle;
301
+ }
302
+
239
303
  function formatTerminalHeader(status: "running" | "done" | "stopped" | "error" | "auto_ended"): {
240
304
  title: string;
241
305
  template?: string;
@@ -251,12 +315,20 @@ function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "aut
251
315
  }
252
316
 
253
317
  function formatAutoEndedReply(finalReply: string): string {
254
- const reason = "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。";
318
+ const reason = "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。";
255
319
  return finalReply
256
320
  ? `${reason}以下回复可能不完整。\n\n${finalReply}`
257
321
  : `${reason}本轮没有可发送的回复内容。`;
258
322
  }
259
323
 
324
+ /**
325
+ * 只监控用户无法判断是否仍有进展的两个阶段。思考、工具调用和搜索可能合法地
326
+ * 长时间不产生回复字符,由资源监控负责识别真正僵死,不能在这里误杀。
327
+ */
328
+ function monitorsOutputProgress(kind: AgentActivityKind): boolean {
329
+ return kind === "starting" || kind === "responding";
330
+ }
331
+
260
332
  function formatTerminalReply(
261
333
  status: "running" | "done" | "stopped" | "error" | "auto_ended",
262
334
  finalReply: string,
@@ -397,6 +469,7 @@ export function resetState(): void {
397
469
  for (const prompt of activePrompts.values()) {
398
470
  if (prompt.processMonitor) clearInterval(prompt.processMonitor);
399
471
  if (prompt.responseStallMonitor) clearInterval(prompt.responseStallMonitor);
472
+ if (prompt.finalResponseCloseTimer) clearTimeout(prompt.finalResponseCloseTimer);
400
473
  }
401
474
  activePrompts.clear();
402
475
  displayCards.clear();
@@ -946,15 +1019,40 @@ function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?:
946
1019
  return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
947
1020
  }
948
1021
 
949
- export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
950
- const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
951
- const adapter = getAdapterForTool(tool);
1022
+ export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
1023
+ const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
1024
+ const adapter = getAdapterForTool(tool);
952
1025
  console.log(
953
- `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
954
- );
955
-
956
- const result = await adapter.createSession(cwd);
957
- const sessionId = result.sessionId;
1026
+ `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
1027
+ );
1028
+
1029
+ // Claude/Cursor 创建会话时需要先等待 SDK/CLI 的 init 事件。它们若在首个
1030
+ // 事件前卡死,正式 turn 尚未建立,runAgentSession 的看门狗无法介入。
1031
+ // 因此创建入口也使用相同的三分钟阈值,并通过 AbortSignal 释放底层资源。
1032
+ const createController = new AbortController();
1033
+ let createTimeout: ReturnType<typeof setTimeout> | undefined;
1034
+ const timeoutError = new Error(
1035
+ `${adapter.displayName} session creation timed out after 3 minutes without an init event`,
1036
+ );
1037
+ const timeoutPromise = new Promise<never>((_resolve, reject) => {
1038
+ createTimeout = setTimeout(() => {
1039
+ // 先固定对外错误,再 abort 适配器,避免适配器自己的 abort 错误赢得竞态。
1040
+ reject(timeoutError);
1041
+ createController.abort();
1042
+ }, responseStallTimeoutMs);
1043
+ createTimeout.unref?.();
1044
+ });
1045
+
1046
+ let result: Awaited<ReturnType<ToolAdapter["createSession"]>>;
1047
+ try {
1048
+ result = await Promise.race([
1049
+ adapter.createSession(cwd, createController.signal),
1050
+ timeoutPromise,
1051
+ ]);
1052
+ } finally {
1053
+ if (createTimeout) clearTimeout(createTimeout);
1054
+ }
1055
+ const sessionId = result.sessionId;
958
1056
  console.log(`[${ts()}] → sessionId: ${sessionId}`);
959
1057
 
960
1058
  await saveSessionTool(sessionId, tool);
@@ -1270,6 +1368,16 @@ export async function runAgentSession(
1270
1368
 
1271
1369
  const runningPrompt = activePrompts.get(sessionId);
1272
1370
  if (runningPrompt) {
1371
+ // 必须在消费第一个事件前建立零字符基线。部分 CLI 卡死时只启动了进程,
1372
+ // 甚至一个事件都不会 yield;若等循环体更新进度,这种会话会永久停在
1373
+ // “正在启动 Agent”,也永远触发不了三分钟保护。
1374
+ runningPrompt.responseProgress = observeResponseProgress(
1375
+ undefined,
1376
+ true,
1377
+ 0,
1378
+ activityTracker.activity.startedAt,
1379
+ );
1380
+
1273
1381
  const checkResponseStall = async () => {
1274
1382
  const current = activePrompts.get(sessionId);
1275
1383
  if (!current || current !== runningPrompt) {
@@ -1282,7 +1390,7 @@ export async function runAgentSession(
1282
1390
  || current.resourceStuck
1283
1391
  || current.autoEnded
1284
1392
  || current.finalResponseObserved
1285
- || activityTracker.activity.kind !== "responding"
1393
+ || !monitorsOutputProgress(activityTracker.activity.kind)
1286
1394
  || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1287
1395
  ) {
1288
1396
  return;
@@ -1338,7 +1446,7 @@ export async function runAgentSession(
1338
1446
  current.controller.abort();
1339
1447
  await killProcessTree(current.processPid);
1340
1448
  console.warn(
1341
- `[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without reply character changes`,
1449
+ `[${ts()}] [RESPONSE-STALL] Session ${sessionId} auto-ended after 3 minutes without startup or reply progress`,
1342
1450
  );
1343
1451
  };
1344
1452
 
@@ -1371,7 +1479,10 @@ export async function runAgentSession(
1371
1479
  if (prompt && prompt === runningPrompt) {
1372
1480
  // 同步标记必须发生在任何 await 之前,让 watchdog 无法在已收到完整
1373
1481
  // 最终事件后仍把本轮判为停滞。
1374
- prompt.finalResponseObserved = true;
1482
+ if (!prompt.finalResponseObserved) {
1483
+ prompt.finalResponseObserved = true;
1484
+ scheduleFinalResponseCloseGuard(sessionId, prompt);
1485
+ }
1375
1486
  }
1376
1487
  }
1377
1488
 
@@ -1399,8 +1510,10 @@ export async function runAgentSession(
1399
1510
  if (prompt && !prompt.autoEnded) {
1400
1511
  const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
1401
1512
  prompt.responseProgress = observeResponseProgress(
1402
- prompt.responseProgress,
1403
- activityTracker.activity.kind === "responding",
1513
+ // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1514
+ // 重新计时;其它活动阶段会清空观察窗口。
1515
+ activityChanged ? undefined : prompt.responseProgress,
1516
+ monitorsOutputProgress(activityTracker.activity.kind),
1404
1517
  totalChars,
1405
1518
  Date.now(),
1406
1519
  );
@@ -1443,6 +1556,7 @@ export async function runAgentSession(
1443
1556
  const autoEndedAt = wasAutoEnded ? prompt?.autoEndedAt : undefined;
1444
1557
  clearPromptResponseStallMonitor(sessionId);
1445
1558
  clearPromptProcessMonitor(sessionId);
1559
+ clearPromptFinalResponseCloseTimer(sessionId);
1446
1560
  markSessionFinalizing(sessionId);
1447
1561
  activePrompts.delete(sessionId);
1448
1562
 
@@ -1576,7 +1690,7 @@ export async function runAgentSession(
1576
1690
  autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1577
1691
  }
1578
1692
  }
1579
- console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled response output (content chunks: ${state.chunkCount})`);
1693
+ console.warn(`[${ts()}] Session ${sessionId} auto-ended after stalled startup or response output (content chunks: ${state.chunkCount})`);
1580
1694
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1581
1695
  } else if (wasAbnormalExit) {
1582
1696
  for (const cid of finalizationChatIds) {
@@ -2080,21 +2194,21 @@ export function stopSession(sessionId: string): boolean {
2080
2194
  prompt.stopped = true;
2081
2195
  clearPromptResponseStallMonitor(sessionId);
2082
2196
  clearPromptProcessMonitor(sessionId);
2083
- cancelQueuedMessage(sessionId);
2084
- try {
2085
- prompt.closeSession?.();
2086
- } catch (err) {
2197
+ clearPromptFinalResponseCloseTimer(sessionId);
2198
+ cancelQueuedMessage(sessionId);
2199
+
2200
+ // 先发起整棵进程树清理,再触发 close/abort。Windows 上 CLI 由
2201
+ // cmd.exe → node → 实际二进制组成;若先 process.kill(cmd.exe),taskkill
2202
+ // 随后便无法从已消失的根 PID 找到后代,正是幽灵 Codex/Cursor 的来源。
2203
+ // killProcessTree 在返回 Promise 前已启动 taskkill,因此这里无需阻塞。
2204
+ void killProcessTree(prompt.processPid);
2205
+ try {
2206
+ prompt.closeSession?.();
2207
+ } catch (err) {
2087
2208
  console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${(err as Error).message}`);
2088
- }
2089
- prompt.controller.abort();
2090
-
2091
- // 强制杀死 CLI 子进程。controller.abort() 只在 for-await 收到下一条
2092
- // stream 消息时才被检测到——如果 agent 陷入无输出的计算循环,abort 信号
2093
- // 永远不会生效。直接 process.kill 让 stdout/stderr 管道关闭,stream 立即结束。
2094
- if (prompt.processPid !== undefined) {
2095
- try { process.kill(prompt.processPid); } catch { /* 进程可能已退出 */ }
2096
- }
2097
- console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2209
+ }
2210
+ prompt.controller.abort();
2211
+ console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2098
2212
 
2099
2213
  // fire-and-forget:立刻把 stream-state.status 改成 stopped,
2100
2214
  // 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。