chatccc 0.2.212 → 0.2.214

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.
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: create-chatccc-feishu-app
3
+ description: Create, clone, or configure a Feishu enterprise self-built bot application for ChatCCC. Use when asked to create a ChatCCC Feishu app, imitate an existing lowercase `chatccc` app, prepare a bot for another computer, or configure its bot capability, `im:`/`cardkit:` permissions, events, credentials, version, and internal release from this repository's README.
4
+ ---
5
+
6
+ # Create a ChatCCC Feishu App
7
+
8
+ Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the minimum requirement and the existing app named exactly lowercase `chatccc` as the configuration template.
9
+
10
+ ## 1. Establish the target and authorization
11
+
12
+ - Read the `README.md` section `#### 飞书(推荐)` before changing the console.
13
+ - Determine the requested app name, target computer, description, icon, availability, and whether the user wants configuration only or a real release. Derive a concise description when one is not supplied; for example, `用飞书聊天控制<目标电脑>上的 Claude Code / Cursor / Codex。`.
14
+ - Use the browser surface the user requested. If the user specifies Chrome DevTools/CDP, connect directly to `chromeDevtools.port` or the README default `15166`; do not substitute extension-based Chrome control.
15
+ - Obtain action-time confirmation before creating the app, granting permissions, saving event/callback subscriptions, or publishing. One explicit instruction such as `完整配置并真正发布` may authorize the listed actions as a batch.
16
+
17
+ ## 2. Inventory the lowercase template
18
+
19
+ 1. Open `https://open.feishu.cn/app?from=devbotmenu` in the authenticated browser.
20
+ 2. Open the app named exactly lowercase `chatccc` in a separate tab.
21
+ 3. Record its non-secret configuration:
22
+ - app description and icon;
23
+ - application capabilities, especially `机器人`;
24
+ - every granted permission and its identity type;
25
+ - event and callback subscription modes;
26
+ - every event and callback identifier;
27
+ - version availability, external sharing choices, and release state.
28
+ 4. Never reveal, log, export to chat, or persist the template App Secret.
29
+
30
+ Prefer current console data over a stale hard-coded list. At the time this workflow was verified, lowercase `chatccc` had 62 application-identity permissions, one user-identity permission, 12 application events, and one callback. Use those counts as a sanity check, not as a replacement for inspecting the template.
31
+
32
+ ## 3. Create the target app
33
+
34
+ 1. Choose `创建企业自建应用` and fill the requested name and description.
35
+ 2. Reuse the lowercase `chatccc` icon only when imitation was requested, and verify its crop preview.
36
+ 3. Click the final `创建` action only after confirmation.
37
+ 4. Click once and wait for SPA navigation. Verify the exact app name, App ID, URL, and status before retrying; the console can temporarily show stale text after a successful mutation.
38
+
39
+ ## 4. Clone the complete ChatCCC configuration
40
+
41
+ Perform the configuration in this order:
42
+
43
+ 1. Add the `机器人` application capability and verify that the page offers `删除能力`, which means the capability is enabled.
44
+ 2. Clone permissions with `权限管理` > `批量处理` > `批量导入/导出权限`:
45
+ - export from lowercase `chatccc`;
46
+ - import into the target app;
47
+ - confirm all additions and verify both application-identity and user-identity totals;
48
+ - do not save the export JSON in the repository or chat.
49
+ 3. Under `事件与回调` > `事件配置`, set the subscription mode to `使用长连接接收事件`, then add every event present in the template. The verified baseline is:
50
+ - `im.chat.disbanded_v1`
51
+ - `im.chat.member.bot.added_v1`
52
+ - `im.chat.member.bot.deleted_v1`
53
+ - `im.chat.member.user.added_v1`
54
+ - `im.chat.member.user.deleted_v1`
55
+ - `im.chat.member.user.withdrawn_v1`
56
+ - `im.message.bot_muted_v1`
57
+ - `im.message.message_read_v1`
58
+ - `im.message.reaction.created_v1`
59
+ - `im.message.reaction.deleted_v1`
60
+ - `im.message.recalled_v1`
61
+ - `im.message.receive_v1`
62
+ 4. Under `回调配置`, independently set `使用长连接接收回调` and add `card.action.trigger`. Configuring the event mode does not configure the callback mode.
63
+ 5. Verify that every listed event shows a sufficient permission as `已开通`.
64
+
65
+ The README minimum is the bot capability, relevant `im:` and `cardkit:` permissions, `im.message.receive_v1`, and `card.action.trigger`; a clone request requires the complete current template configuration, not only that minimum.
66
+
67
+ ## 5. Create and publish a version
68
+
69
+ 1. Open `版本管理与发布` and create a version such as `1.0.0`.
70
+ 2. Keep mobile and desktop default abilities set to `机器人` and write a meaningful update note.
71
+ 3. Match the requested or template availability. For a personal computer, default to the owner-only internal range and leave external group/private-chat sharing disabled unless explicitly requested.
72
+ 4. Save the version, then complete the separate `确认发布` action. A successful version creation alone is not a release.
73
+ 5. Refresh the version list and require all of the following evidence before reporting success:
74
+ - application status `已启用`;
75
+ - banner `当前修改均已发布`;
76
+ - version status `已发布` with a publication time.
77
+ 6. If administrator review is required, submit everything authorized and report the exact remaining approval state.
78
+
79
+ ## 6. Verify and hand off safely
80
+
81
+ - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, permission identity counts match, both subscription modes are long connection, event/callback identifiers match, and the version is published.
82
+ - Treat network responses and a refreshed page as stronger evidence than an immediately stale SPA view. Never repeat a create, add, or publish action only because the first page read is stale.
83
+ - Report the app name, App ID, capability, permission counts, event/callback counts, version, release state, and any remaining action.
84
+ - Never print the App Secret. If the user explicitly asks to configure ChatCCC, transfer the App ID and App Secret directly into the local private configuration without echoing the secret in commands, logs, screenshots, files tracked by Git, or chat.
85
+ - Do not modify local ChatCCC configuration or transmit credentials elsewhere unless explicitly authorized.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Create ChatCCC Feishu App"
3
+ short_description: "Create and configure a Feishu bot app for ChatCCC"
4
+ default_prompt: "Use $create-chatccc-feishu-app to create and configure a Feishu enterprise bot app for this ChatCCC project."
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: create-chatccc-feishu-app
3
+ description: Create, clone, or configure a Feishu enterprise self-built bot application for ChatCCC. Use when asked to create a ChatCCC Feishu app, imitate an existing lowercase `chatccc` app, prepare a bot for another computer, or configure its bot capability, `im:`/`cardkit:` permissions, events, credentials, version, and internal release from this repository's README.
4
+ ---
5
+
6
+ # Create a ChatCCC Feishu App
7
+
8
+ Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the minimum requirement and the existing app named exactly lowercase `chatccc` as the configuration template.
9
+
10
+ ## 1. Establish the target and authorization
11
+
12
+ - Read the `README.md` section `#### 飞书(推荐)` before changing the console.
13
+ - Determine the requested app name, target computer, description, icon, availability, and whether the user wants configuration only or a real release. Derive a concise description when one is not supplied; for example, `用飞书聊天控制<目标电脑>上的 Claude Code / Cursor / Codex。`.
14
+ - Use the browser surface the user requested. If the user specifies Chrome DevTools/CDP, connect directly to `chromeDevtools.port` or the README default `15166`; do not substitute extension-based Chrome control.
15
+ - Obtain action-time confirmation before creating the app, granting permissions, saving event/callback subscriptions, or publishing. One explicit instruction such as `完整配置并真正发布` may authorize the listed actions as a batch.
16
+
17
+ ## 2. Inventory the lowercase template
18
+
19
+ 1. Open `https://open.feishu.cn/app?from=devbotmenu` in the authenticated browser.
20
+ 2. Open the app named exactly lowercase `chatccc` in a separate tab.
21
+ 3. Record its non-secret configuration:
22
+ - app description and icon;
23
+ - application capabilities, especially `机器人`;
24
+ - every granted permission and its identity type;
25
+ - event and callback subscription modes;
26
+ - every event and callback identifier;
27
+ - version availability, external sharing choices, and release state.
28
+ 4. Never reveal, log, export to chat, or persist the template App Secret.
29
+
30
+ Prefer current console data over a stale hard-coded list. At the time this workflow was verified, lowercase `chatccc` had 62 application-identity permissions, one user-identity permission, 12 application events, and one callback. Use those counts as a sanity check, not as a replacement for inspecting the template.
31
+
32
+ ## 3. Create the target app
33
+
34
+ 1. Choose `创建企业自建应用` and fill the requested name and description.
35
+ 2. Reuse the lowercase `chatccc` icon only when imitation was requested, and verify its crop preview.
36
+ 3. Click the final `创建` action only after confirmation.
37
+ 4. Click once and wait for SPA navigation. Verify the exact app name, App ID, URL, and status before retrying; the console can temporarily show stale text after a successful mutation.
38
+
39
+ ## 4. Clone the complete ChatCCC configuration
40
+
41
+ Perform the configuration in this order:
42
+
43
+ 1. Add the `机器人` application capability and verify that the page offers `删除能力`, which means the capability is enabled.
44
+ 2. Clone permissions with `权限管理` > `批量处理` > `批量导入/导出权限`:
45
+ - export from lowercase `chatccc`;
46
+ - import into the target app;
47
+ - confirm all additions and verify both application-identity and user-identity totals;
48
+ - do not save the export JSON in the repository or chat.
49
+ 3. Under `事件与回调` > `事件配置`, set the subscription mode to `使用长连接接收事件`, then add every event present in the template. The verified baseline is:
50
+ - `im.chat.disbanded_v1`
51
+ - `im.chat.member.bot.added_v1`
52
+ - `im.chat.member.bot.deleted_v1`
53
+ - `im.chat.member.user.added_v1`
54
+ - `im.chat.member.user.deleted_v1`
55
+ - `im.chat.member.user.withdrawn_v1`
56
+ - `im.message.bot_muted_v1`
57
+ - `im.message.message_read_v1`
58
+ - `im.message.reaction.created_v1`
59
+ - `im.message.reaction.deleted_v1`
60
+ - `im.message.recalled_v1`
61
+ - `im.message.receive_v1`
62
+ 4. Under `回调配置`, independently set `使用长连接接收回调` and add `card.action.trigger`. Configuring the event mode does not configure the callback mode.
63
+ 5. Verify that every listed event shows a sufficient permission as `已开通`.
64
+
65
+ The README minimum is the bot capability, relevant `im:` and `cardkit:` permissions, `im.message.receive_v1`, and `card.action.trigger`; a clone request requires the complete current template configuration, not only that minimum.
66
+
67
+ ## 5. Create and publish a version
68
+
69
+ 1. Open `版本管理与发布` and create a version such as `1.0.0`.
70
+ 2. Keep mobile and desktop default abilities set to `机器人` and write a meaningful update note.
71
+ 3. Match the requested or template availability. For a personal computer, default to the owner-only internal range and leave external group/private-chat sharing disabled unless explicitly requested.
72
+ 4. Save the version, then complete the separate `确认发布` action. A successful version creation alone is not a release.
73
+ 5. Refresh the version list and require all of the following evidence before reporting success:
74
+ - application status `已启用`;
75
+ - banner `当前修改均已发布`;
76
+ - version status `已发布` with a publication time.
77
+ 6. If administrator review is required, submit everything authorized and report the exact remaining approval state.
78
+
79
+ ## 6. Verify and hand off safely
80
+
81
+ - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, permission identity counts match, both subscription modes are long connection, event/callback identifiers match, and the version is published.
82
+ - Treat network responses and a refreshed page as stronger evidence than an immediately stale SPA view. Never repeat a create, add, or publish action only because the first page read is stale.
83
+ - Report the app name, App ID, capability, permission counts, event/callback counts, version, release state, and any remaining action.
84
+ - Never print the App Secret. If the user explicitly asks to configure ChatCCC, transfer the App ID and App Secret directly into the local private configuration without echoing the secret in commands, logs, screenshots, files tracked by Git, or chat.
85
+ - Do not modify local ChatCCC configuration or transmit credentials elsewhere unless explicitly authorized.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Create ChatCCC Feishu App"
3
+ short_description: "Create and configure a Feishu bot app for ChatCCC"
4
+ default_prompt: "Use $create-chatccc-feishu-app to create and configure a Feishu enterprise bot app for this ChatCCC project."
@@ -0,0 +1,85 @@
1
+ ---
2
+ name: create-chatccc-feishu-app
3
+ description: Create, clone, or configure a Feishu enterprise self-built bot application for ChatCCC. Use when asked to create a ChatCCC Feishu app, imitate an existing lowercase `chatccc` app, prepare a bot for another computer, or configure its bot capability, `im:`/`cardkit:` permissions, events, credentials, version, and internal release from this repository's README.
4
+ ---
5
+
6
+ # Create a ChatCCC Feishu App
7
+
8
+ Use the logged-in Feishu developer console to create and completely configure a ChatCCC bot app. Treat this repository's README as the minimum requirement and the existing app named exactly lowercase `chatccc` as the configuration template.
9
+
10
+ ## 1. Establish the target and authorization
11
+
12
+ - Read the `README.md` section `#### 飞书(推荐)` before changing the console.
13
+ - Determine the requested app name, target computer, description, icon, availability, and whether the user wants configuration only or a real release. Derive a concise description when one is not supplied; for example, `用飞书聊天控制<目标电脑>上的 Claude Code / Cursor / Codex。`.
14
+ - Use the browser surface the user requested. If the user specifies Chrome DevTools/CDP, connect directly to `chromeDevtools.port` or the README default `15166`; do not substitute extension-based Chrome control.
15
+ - Obtain action-time confirmation before creating the app, granting permissions, saving event/callback subscriptions, or publishing. One explicit instruction such as `完整配置并真正发布` may authorize the listed actions as a batch.
16
+
17
+ ## 2. Inventory the lowercase template
18
+
19
+ 1. Open `https://open.feishu.cn/app?from=devbotmenu` in the authenticated browser.
20
+ 2. Open the app named exactly lowercase `chatccc` in a separate tab.
21
+ 3. Record its non-secret configuration:
22
+ - app description and icon;
23
+ - application capabilities, especially `机器人`;
24
+ - every granted permission and its identity type;
25
+ - event and callback subscription modes;
26
+ - every event and callback identifier;
27
+ - version availability, external sharing choices, and release state.
28
+ 4. Never reveal, log, export to chat, or persist the template App Secret.
29
+
30
+ Prefer current console data over a stale hard-coded list. At the time this workflow was verified, lowercase `chatccc` had 62 application-identity permissions, one user-identity permission, 12 application events, and one callback. Use those counts as a sanity check, not as a replacement for inspecting the template.
31
+
32
+ ## 3. Create the target app
33
+
34
+ 1. Choose `创建企业自建应用` and fill the requested name and description.
35
+ 2. Reuse the lowercase `chatccc` icon only when imitation was requested, and verify its crop preview.
36
+ 3. Click the final `创建` action only after confirmation.
37
+ 4. Click once and wait for SPA navigation. Verify the exact app name, App ID, URL, and status before retrying; the console can temporarily show stale text after a successful mutation.
38
+
39
+ ## 4. Clone the complete ChatCCC configuration
40
+
41
+ Perform the configuration in this order:
42
+
43
+ 1. Add the `机器人` application capability and verify that the page offers `删除能力`, which means the capability is enabled.
44
+ 2. Clone permissions with `权限管理` > `批量处理` > `批量导入/导出权限`:
45
+ - export from lowercase `chatccc`;
46
+ - import into the target app;
47
+ - confirm all additions and verify both application-identity and user-identity totals;
48
+ - do not save the export JSON in the repository or chat.
49
+ 3. Under `事件与回调` > `事件配置`, set the subscription mode to `使用长连接接收事件`, then add every event present in the template. The verified baseline is:
50
+ - `im.chat.disbanded_v1`
51
+ - `im.chat.member.bot.added_v1`
52
+ - `im.chat.member.bot.deleted_v1`
53
+ - `im.chat.member.user.added_v1`
54
+ - `im.chat.member.user.deleted_v1`
55
+ - `im.chat.member.user.withdrawn_v1`
56
+ - `im.message.bot_muted_v1`
57
+ - `im.message.message_read_v1`
58
+ - `im.message.reaction.created_v1`
59
+ - `im.message.reaction.deleted_v1`
60
+ - `im.message.recalled_v1`
61
+ - `im.message.receive_v1`
62
+ 4. Under `回调配置`, independently set `使用长连接接收回调` and add `card.action.trigger`. Configuring the event mode does not configure the callback mode.
63
+ 5. Verify that every listed event shows a sufficient permission as `已开通`.
64
+
65
+ The README minimum is the bot capability, relevant `im:` and `cardkit:` permissions, `im.message.receive_v1`, and `card.action.trigger`; a clone request requires the complete current template configuration, not only that minimum.
66
+
67
+ ## 5. Create and publish a version
68
+
69
+ 1. Open `版本管理与发布` and create a version such as `1.0.0`.
70
+ 2. Keep mobile and desktop default abilities set to `机器人` and write a meaningful update note.
71
+ 3. Match the requested or template availability. For a personal computer, default to the owner-only internal range and leave external group/private-chat sharing disabled unless explicitly requested.
72
+ 4. Save the version, then complete the separate `确认发布` action. A successful version creation alone is not a release.
73
+ 5. Refresh the version list and require all of the following evidence before reporting success:
74
+ - application status `已启用`;
75
+ - banner `当前修改均已发布`;
76
+ - version status `已发布` with a publication time.
77
+ 6. If administrator review is required, submit everything authorized and report the exact remaining approval state.
78
+
79
+ ## 6. Verify and hand off safely
80
+
81
+ - Re-open the bot, permission, event/callback, and version pages after publication. Verify the bot is enabled, permission identity counts match, both subscription modes are long connection, event/callback identifiers match, and the version is published.
82
+ - Treat network responses and a refreshed page as stronger evidence than an immediately stale SPA view. Never repeat a create, add, or publish action only because the first page read is stale.
83
+ - Report the app name, App ID, capability, permission counts, event/callback counts, version, release state, and any remaining action.
84
+ - Never print the App Secret. If the user explicitly asks to configure ChatCCC, transfer the App ID and App Secret directly into the local private configuration without echoing the secret in commands, logs, screenshots, files tracked by Git, or chat.
85
+ - Do not modify local ChatCCC configuration or transmit credentials elsewhere unless explicitly authorized.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Create ChatCCC Feishu App"
3
+ short_description: "Create and configure a Feishu bot app for ChatCCC"
4
+ default_prompt: "Use $create-chatccc-feishu-app to create and configure a Feishu enterprise bot app for this ChatCCC project."
package/README.md CHANGED
@@ -334,7 +334,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
334
334
 
335
335
  **微信:** 扫码登录后,在机器人私聊里发送 `/new` 或指定 Agent 的 `/new ...` 命令即可开始。功能与飞书基本一致,但展示为纯文本。
336
336
 
337
- **会话停滞保护:** 当 Agent 连续 3 分钟处于“正在生成回复”且回复字符数没有变化、同时尚未报告权威终态时,ChatCCC 会结束旧 CLI,并优先补发一次“完成了吗?如果没完成继续”;恢复轮再次发生相同停滞时不再递归续跑。Codex 只有 `turn.completed` 才算权威终态,阶段性的 `agent_message` 不算;任一 Agent 报告权威终态后若输出流仍超过 10 秒未关闭,ChatCCC 会强制清理该 CLI 并按正常完成收尾,不会重复询问 Agent。
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
338
 
339
339
  ## 可用指令
340
340
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.212",
3
+ "version": "0.2.214",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -16,6 +16,9 @@
16
16
  "demo/ilink_echo_probe.ts",
17
17
  "agent-prompts/",
18
18
  "im-skills/",
19
+ ".agents/skills/create-chatccc-feishu-app/",
20
+ ".claude/skills/create-chatccc-feishu-app/",
21
+ ".cursor/skills/create-chatccc-feishu-app/",
19
22
  "images/img_readme_*.jpg",
20
23
  "images/img_readme_*.png",
21
24
  "images/avatars/status_*.png",
@@ -379,6 +379,20 @@ describe("createClaudeAdapter", () => {
379
379
  await expect(adapter.closeSession("any-sid")).resolves.toBeUndefined();
380
380
  });
381
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
+
382
396
  // -------------------------------------------------------------------------
383
397
  // getSessionInfo 行为契约
384
398
  // - cwd 决定 /git 是否可用
@@ -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 真实模型
@@ -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();
@@ -0,0 +1,24 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const CREATE_APP_SKILL_DIRS = [
7
+ ".agents/skills/create-chatccc-feishu-app/",
8
+ ".claude/skills/create-chatccc-feishu-app/",
9
+ ".cursor/skills/create-chatccc-feishu-app/",
10
+ ];
11
+
12
+ describe("npm package files", () => {
13
+ it("ships the Feishu app creation skill for every supported agent", () => {
14
+ const root = process.cwd();
15
+ const packageJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
16
+ files?: string[];
17
+ };
18
+
19
+ for (const skillDir of CREATE_APP_SKILL_DIRS) {
20
+ expect(packageJson.files).toContain(skillDir);
21
+ expect(existsSync(join(root, skillDir, "SKILL.md"))).toBe(true);
22
+ }
23
+ });
24
+ });
@@ -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,
@@ -226,7 +227,7 @@ function mockPlatform(name: string): PlatformAdapter {
226
227
  };
227
228
  }
228
229
 
229
- describe("resetState", () => {
230
+ describe("resetState", () => {
230
231
  it("clears all maps and sets", () => {
231
232
  chatSessionMap.set("chat1", {
232
233
  gen: 1, close: () => {}, cardId: null, stopped: false,
@@ -245,9 +246,58 @@ describe("resetState", () => {
245
246
  expect(sessionInfoMap.size).toBe(0);
246
247
  expect(processedMessages.size).toBe(0);
247
248
  });
248
- });
249
-
250
- 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", () => {
251
301
  beforeEach(() => {
252
302
  resetState();
253
303
  });
@@ -774,6 +824,88 @@ describe("runAgentSession response stall watchdog", () => {
774
824
  });
775
825
  });
776
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
+
777
909
  it("self-heals a missing trigger-chat binding and gives automatic recovery the normal card lifecycle", async () => {
778
910
  vi.setSystemTime(0);
779
911
  _setResponseStallTimeoutForTest(100);
@@ -1142,7 +1274,7 @@ describe("runAgentSession response stall watchdog", () => {
1142
1274
  expect(consumeQueued).not.toHaveBeenCalled();
1143
1275
 
1144
1276
  await vi.advanceTimersByTimeAsync(200);
1145
- expect(consumeQueued).toHaveBeenCalledTimes(1);
1277
+ await vi.waitFor(() => expect(consumeQueued).toHaveBeenCalledTimes(1));
1146
1278
  expect(consumeQueued).toHaveBeenCalledWith(
1147
1279
  platform,
1148
1280
  expect.objectContaining({ text: "queued user prompt" }),
@@ -1215,7 +1347,7 @@ describe("runAgentSession response stall watchdog", () => {
1215
1347
  expect(receivedPrompts[1]).toContain(recoveryPrompt);
1216
1348
  expect(platform.sendText).toHaveBeenCalledWith(
1217
1349
  "chat-recovery-limit",
1218
- "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。",
1350
+ "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。",
1219
1351
  );
1220
1352
  });
1221
1353
 
@@ -1474,7 +1606,7 @@ describe("unified display loop terminal card update", () => {
1474
1606
  expect(card.header.template).toBe("orange");
1475
1607
  expect(platform.sendText).toHaveBeenCalledWith(
1476
1608
  "chat-auto-ended",
1477
- "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。本轮没有可发送的回复内容。",
1609
+ "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。本轮没有可发送的回复内容。",
1478
1610
  );
1479
1611
  expect(displayCards.has("chat-auto-ended")).toBe(false);
1480
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
 
@@ -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
  }
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";
@@ -170,7 +171,7 @@ export const RESPONSE_STALL_RECOVERY_PROMPT = "完成了吗?如果没完成继
170
171
  export const RESPONSE_STALL_RECOVERY_NOTICE =
171
172
  `检测到会话停滞,正在自动确认并继续。\n\n${RESPONSE_STALL_RECOVERY_PROMPT}`;
172
173
  export const RESPONSE_STALL_RECOVERY_EXHAUSTED_NOTICE =
173
- "⚠️ 自动续跑仍连续 3 分钟没有新回复,本次不再自动继续。";
174
+ "⚠️ 自动续跑仍连续 3 分钟没有启动进展或新回复,本次不再自动继续。";
174
175
  const RESPONSE_STALL_RECOVERY_DELAY_MS = 200;
175
176
  let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
176
177
  let responseStallTimeoutMs = DEFAULT_RESPONSE_STALL_TIMEOUT_MS;
@@ -314,12 +315,20 @@ function turnFinalStatus(status: "running" | "done" | "stopped" | "error" | "aut
314
315
  }
315
316
 
316
317
  function formatAutoEndedReply(finalReply: string): string {
317
- const reason = "⚠️ 已自动结束:连续 3 分钟处于“正在生成回复”且回复字符总数没有变化。";
318
+ const reason = "⚠️ 已自动结束:连续 3 分钟没有启动进展或回复字符变化。";
318
319
  return finalReply
319
320
  ? `${reason}以下回复可能不完整。\n\n${finalReply}`
320
321
  : `${reason}本轮没有可发送的回复内容。`;
321
322
  }
322
323
 
324
+ /**
325
+ * 只监控用户无法判断是否仍有进展的两个阶段。思考、工具调用和搜索可能合法地
326
+ * 长时间不产生回复字符,由资源监控负责识别真正僵死,不能在这里误杀。
327
+ */
328
+ function monitorsOutputProgress(kind: AgentActivityKind): boolean {
329
+ return kind === "starting" || kind === "responding";
330
+ }
331
+
323
332
  function formatTerminalReply(
324
333
  status: "running" | "done" | "stopped" | "error" | "auto_ended",
325
334
  finalReply: string,
@@ -1010,15 +1019,40 @@ function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?:
1010
1019
  return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(getEffectiveEffortForTool(tool, sessionId))}`;
1011
1020
  }
1012
1021
 
1013
- export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
1014
- const cwd = overrideCwd ?? (await getDefaultCwd(chatId));
1015
- 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);
1016
1025
  console.log(
1017
- `[${ts()}] [STEP 1/5] Creating ${adapter.displayName} session (${formatToolConfigForLog(tool)}, cwd=${cwd})`
1018
- );
1019
-
1020
- const result = await adapter.createSession(cwd);
1021
- 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;
1022
1056
  console.log(`[${ts()}] → sessionId: ${sessionId}`);
1023
1057
 
1024
1058
  await saveSessionTool(sessionId, tool);
@@ -1334,6 +1368,16 @@ export async function runAgentSession(
1334
1368
 
1335
1369
  const runningPrompt = activePrompts.get(sessionId);
1336
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
+
1337
1381
  const checkResponseStall = async () => {
1338
1382
  const current = activePrompts.get(sessionId);
1339
1383
  if (!current || current !== runningPrompt) {
@@ -1346,7 +1390,7 @@ export async function runAgentSession(
1346
1390
  || current.resourceStuck
1347
1391
  || current.autoEnded
1348
1392
  || current.finalResponseObserved
1349
- || activityTracker.activity.kind !== "responding"
1393
+ || !monitorsOutputProgress(activityTracker.activity.kind)
1350
1394
  || !hasResponseStalled(current.responseProgress, Date.now(), responseStallTimeoutMs)
1351
1395
  ) {
1352
1396
  return;
@@ -1402,7 +1446,7 @@ export async function runAgentSession(
1402
1446
  current.controller.abort();
1403
1447
  await killProcessTree(current.processPid);
1404
1448
  console.warn(
1405
- `[${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`,
1406
1450
  );
1407
1451
  };
1408
1452
 
@@ -1466,8 +1510,10 @@ export async function runAgentSession(
1466
1510
  if (prompt && !prompt.autoEnded) {
1467
1511
  const totalChars = state.accumulatedContent.length + pickFinalReply(state).length;
1468
1512
  prompt.responseProgress = observeResponseProgress(
1469
- prompt.responseProgress,
1470
- activityTracker.activity.kind === "responding",
1513
+ // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1514
+ // 重新计时;其它活动阶段会清空观察窗口。
1515
+ activityChanged ? undefined : prompt.responseProgress,
1516
+ monitorsOutputProgress(activityTracker.activity.kind),
1471
1517
  totalChars,
1472
1518
  Date.now(),
1473
1519
  );
@@ -1644,7 +1690,7 @@ export async function runAgentSession(
1644
1690
  autoRecoveryTarget = { chatId: activeAutoEnded, platform: pp };
1645
1691
  }
1646
1692
  }
1647
- 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})`);
1648
1694
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "response_stall", chunks: state.chunkCount });
1649
1695
  } else if (wasAbnormalExit) {
1650
1696
  for (const cid of finalizationChatIds) {
@@ -2149,21 +2195,20 @@ export function stopSession(sessionId: string): boolean {
2149
2195
  clearPromptResponseStallMonitor(sessionId);
2150
2196
  clearPromptProcessMonitor(sessionId);
2151
2197
  clearPromptFinalResponseCloseTimer(sessionId);
2152
- cancelQueuedMessage(sessionId);
2153
- try {
2154
- prompt.closeSession?.();
2155
- } catch (err) {
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) {
2156
2208
  console.warn(`[${ts()}] [STOP] closeSession failed for ${sessionId}: ${(err as Error).message}`);
2157
- }
2158
- prompt.controller.abort();
2159
-
2160
- // 强制杀死 CLI 子进程。controller.abort() 只在 for-await 收到下一条
2161
- // stream 消息时才被检测到——如果 agent 陷入无输出的计算循环,abort 信号
2162
- // 永远不会生效。直接 process.kill 让 stdout/stderr 管道关闭,stream 立即结束。
2163
- if (prompt.processPid !== undefined) {
2164
- try { process.kill(prompt.processPid); } catch { /* 进程可能已退出 */ }
2165
- }
2166
- console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2209
+ }
2210
+ prompt.controller.abort();
2211
+ console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
2167
2212
 
2168
2213
  // fire-and-forget:立刻把 stream-state.status 改成 stopped,
2169
2214
  // 让 display loop 下一次扫到立刻渲染"已停止"卡片,不必再等几秒。