chatccc 0.2.187 → 0.2.188

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.
@@ -77,10 +77,11 @@ import {
77
77
  _setAdapterForToolForTest,
78
78
  _setSessionRegistryFileForTest,
79
79
  _setSessionToolsFileForTest,
80
- loadSessionRegistryForBinding,
81
- recordSessionRegistry,
82
- resetState,
83
- } from "../session.ts";
80
+ loadSessionRegistryForBinding,
81
+ recordSessionRegistry,
82
+ resetState,
83
+ sessionInfoMap,
84
+ } from "../session.ts";
84
85
  import { activePrompts, resetBindingState } from "../session-chat-binding.ts";
85
86
  import { ABD_APPEND_PROMPT } from "../shared-prefix.ts";
86
87
 
@@ -362,11 +363,11 @@ describe("handleCommand WeChat processing ack", () => {
362
363
  expect(userText).not.toContain("/abd");
363
364
  });
364
365
 
365
- it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
366
- const platform = mockPlatform("feishu");
367
- await recordSessionRegistry({
368
- chatId: "feishu-p2p",
369
- sessionId: "stale-sid",
366
+ it("cleans stale Feishu p2p binding but keeps valid commands from auto-creating a group", async () => {
367
+ const platform = mockPlatform("feishu");
368
+ await recordSessionRegistry({
369
+ chatId: "feishu-p2p",
370
+ sessionId: "stale-sid",
370
371
  tool: "claude",
371
372
  chatName: "旧私聊绑定",
372
373
  running: false,
@@ -376,13 +377,109 @@ describe("handleCommand WeChat processing ack", () => {
376
377
 
377
378
  expect(platform.createGroup).not.toHaveBeenCalled();
378
379
  expect(platform.sendRawCard).toHaveBeenCalled();
379
- const registry = await loadSessionRegistryForBinding();
380
- expect(registry["feishu-p2p"]).toBeUndefined();
381
- });
382
-
383
- it("handles /usage without creating a new Feishu group", async () => {
384
- const platform = mockPlatform("feishu");
385
- mockGetCodexUsageSummary.mockResolvedValue({
380
+ const registry = await loadSessionRegistryForBinding();
381
+ expect(registry["feishu-p2p"]).toBeUndefined();
382
+ });
383
+
384
+ it("shows Claude effort switch card in an active Feishu session", async () => {
385
+ const platform = mockPlatform("feishu");
386
+ vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "claude-session", description: "Claude Session: sid-claude-effort" });
387
+ vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-claude-effort", tool: "claude" });
388
+ await recordSessionRegistry({
389
+ chatId: "feishu-chat",
390
+ sessionId: "sid-claude-effort",
391
+ tool: "claude",
392
+ chatName: "claude-session",
393
+ running: false,
394
+ });
395
+ sessionInfoMap.set("feishu-chat", {
396
+ sessionId: "sid-claude-effort",
397
+ tool: "claude",
398
+ turnCount: 0,
399
+ lastContextTokens: 0,
400
+ startTime: Date.now(),
401
+ });
402
+
403
+ await handleCommand(platform, "/effort", "feishu-chat", "ou-user", Date.now(), "group");
404
+
405
+ expect(platform.sendRawCard).toHaveBeenCalled();
406
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
407
+ const raw = JSON.stringify(card);
408
+ expect(raw).toContain("/effort low");
409
+ expect(raw).toContain("/effort xhigh");
410
+ expect(raw).toContain("/effort max");
411
+ });
412
+
413
+ it("switches Codex effort for the current session and reflects it in /state", async () => {
414
+ const platform = mockPlatform("feishu");
415
+ vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "codex-session", description: "Codex Session: sid-codex-effort" });
416
+ vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-codex-effort", tool: "codex" });
417
+ _setAdapterForToolForTest("codex", mockAdapter("sid-codex-effort"));
418
+ await recordSessionRegistry({
419
+ chatId: "codex-chat",
420
+ sessionId: "sid-codex-effort",
421
+ tool: "codex",
422
+ chatName: "codex-session",
423
+ running: false,
424
+ });
425
+ sessionInfoMap.set("codex-chat", {
426
+ sessionId: "sid-codex-effort",
427
+ tool: "codex",
428
+ turnCount: 0,
429
+ lastContextTokens: 0,
430
+ startTime: Date.now(),
431
+ });
432
+
433
+ await handleCommand(platform, "/effort xhigh", "codex-chat", "ou-user", Date.now(), "group");
434
+ expect(platform.sendCard).toHaveBeenCalledWith(
435
+ "codex-chat",
436
+ "Effort 切换",
437
+ expect.stringContaining("xhigh"),
438
+ "green",
439
+ );
440
+
441
+ vi.mocked(platform.sendCard).mockClear();
442
+ vi.mocked(platform.sendRawCard).mockClear();
443
+ await handleCommand(platform, "/state", "codex-chat", "ou-user", Date.now() + 1, "group");
444
+
445
+ expect(platform.sendRawCard).toHaveBeenCalled();
446
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
447
+ expect(JSON.stringify(card)).toContain("xhigh");
448
+ });
449
+
450
+ it("rejects /effort in Cursor sessions", async () => {
451
+ const platform = mockPlatform("feishu");
452
+ vi.mocked(platform.getChatInfo).mockResolvedValue({ name: "cursor-session", description: "Cursor Session: sid-cursor-effort" });
453
+ vi.mocked(platform.extractSessionInfo).mockReturnValue({ sessionId: "sid-cursor-effort", tool: "cursor" });
454
+ _setAdapterForToolForTest("cursor", mockAdapter("sid-cursor-effort"));
455
+ await recordSessionRegistry({
456
+ chatId: "cursor-chat",
457
+ sessionId: "sid-cursor-effort",
458
+ tool: "cursor",
459
+ chatName: "cursor-session",
460
+ running: false,
461
+ });
462
+ sessionInfoMap.set("cursor-chat", {
463
+ sessionId: "sid-cursor-effort",
464
+ tool: "cursor",
465
+ turnCount: 0,
466
+ lastContextTokens: 0,
467
+ startTime: Date.now(),
468
+ });
469
+
470
+ await handleCommand(platform, "/effort high", "cursor-chat", "ou-user", Date.now(), "group");
471
+
472
+ expect(platform.sendCard).toHaveBeenCalledWith(
473
+ "cursor-chat",
474
+ "Effort 切换",
475
+ expect.stringContaining("不支持 effort"),
476
+ "red",
477
+ );
478
+ });
479
+
480
+ it("handles /usage without creating a new Feishu group", async () => {
481
+ const platform = mockPlatform("feishu");
482
+ const usage = {
386
483
  fiveHour: { usedPercent: 37, remainingPercent: 63, resetAtEpochSeconds: 1781528212, resetAfterSeconds: 10349 },
387
484
  weekly: { usedPercent: 12, remainingPercent: 88, resetAtEpochSeconds: 1781842926, resetAfterSeconds: 325063 },
388
485
  rateLimitResetCreditsAvailable: 2,
@@ -390,7 +487,8 @@ describe("handleCommand WeChat processing ack", () => {
390
487
  { grantedAt: "2026-06-12T04:01:47.770016Z", expiresAt: "2026-07-12T04:01:47.770016Z" },
391
488
  { grantedAt: "2026-06-18T00:44:23.904386Z", expiresAt: "2026-07-18T00:44:23.904386Z" },
392
489
  ],
393
- });
490
+ };
491
+ mockGetCodexUsageSummary.mockResolvedValue(usage);
394
492
 
395
493
  await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
396
494
 
@@ -411,6 +509,7 @@ describe("handleCommand WeChat processing ack", () => {
411
509
  expect(card.elements[0].text.content).toContain("[██░░░░░░░░░░░░░░░░░░]");
412
510
  expect(card.elements[2].actions[0].text.content).toBe("发起重置");
413
511
  expect(card.elements[2].actions[0].value).toEqual({ action: "codex_reset_request", availableCount: 2 });
512
+ expect(platform.setChatAvatar).toHaveBeenCalledWith("feishu-p2p", "codex", "idle", { codexUsage: usage });
414
513
  });
415
514
 
416
515
  it("adds ChatGPT subscription expiry to Codex /usage when CDP lookup succeeds", async () => {
@@ -445,6 +544,24 @@ describe("handleCommand WeChat processing ack", () => {
445
544
  expect(card.elements[0].text.content).toContain("- 自动续费: 否");
446
545
  });
447
546
 
547
+ it("shows an actionable ChatGPT subscription failure reason when Chrome CDP is enabled", async () => {
548
+ const platform = mockPlatform("feishu");
549
+ mockGetChatGptSubscriptionStatus.mockResolvedValue({
550
+ ok: false,
551
+ code: "chatgpt_session_missing",
552
+ reason: "ChatGPT browser session has no access token.",
553
+ chromeCdp: { enabled: true, port: 15166, status: "healthy" },
554
+ chatgpt: { sessionOk: true },
555
+ });
556
+
557
+ await handleCommand(platform, "/usage", "feishu-p2p", "ou-user", Date.now(), "p2p");
558
+
559
+ const card = JSON.parse(vi.mocked(platform.sendRawCard).mock.calls[0][1]);
560
+ expect(card.elements[0].text.content).toContain("**ChatGPT 订阅查询失败:**");
561
+ expect(card.elements[0].text.content).toContain("请在 15166 端口对应的 Chrome 浏览器中登录 ChatGPT");
562
+ expect(card.elements[0].text.content).toContain("ChatGPT browser session has no access token.");
563
+ });
564
+
448
565
  it("handles /usage as Cursor usage in Cursor chats", async () => {
449
566
  const platform = mockPlatform("feishu");
450
567
  await recordSessionRegistry({
@@ -472,6 +589,37 @@ describe("handleCommand WeChat processing ack", () => {
472
589
  expect.stringContaining("Pool remaining: $171417.76"),
473
590
  "blue",
474
591
  );
592
+ expect(platform.setChatAvatar).toHaveBeenCalledWith(
593
+ "cursor-chat",
594
+ "cursor",
595
+ "idle",
596
+ { cursorUsage: expect.objectContaining({ displayMessage: "You've hit your usage limit" }) },
597
+ );
598
+ });
599
+
600
+ it("keeps the busy avatar status when /usage runs for an active Cursor session", async () => {
601
+ const platform = mockPlatform("feishu");
602
+ await recordSessionRegistry({
603
+ chatId: "cursor-chat",
604
+ sessionId: "sid-cursor",
605
+ tool: "cursor",
606
+ chatName: "cursor-session",
607
+ running: true,
608
+ });
609
+ activePrompts.set("sid-cursor", {
610
+ controller: new AbortController(),
611
+ stopped: false,
612
+ startTime: Date.now(),
613
+ });
614
+
615
+ await handleCommand(platform, "/usage", "cursor-chat", "ou-user", Date.now(), "group");
616
+
617
+ expect(platform.setChatAvatar).toHaveBeenCalledWith(
618
+ "cursor-chat",
619
+ "cursor",
620
+ "busy",
621
+ { cursorUsage: expect.objectContaining({ displayMessage: "You've hit your usage limit" }) },
622
+ );
475
623
  });
476
624
 
477
625
  it("advertises /usage in new Codex and Cursor session ready messages", async () => {
@@ -91,11 +91,12 @@ import {
91
91
  stopSession,
92
92
  startUnifiedDisplayLoop,
93
93
  stopUnifiedDisplayLoop,
94
- _setProcessAliveForTest,
95
- _resetProcessAliveForTest,
96
- _setProcessMonitorIntervalForTest,
97
- _resetProcessMonitorIntervalForTest,
98
- } from "../session.ts";
94
+ _setProcessAliveForTest,
95
+ _resetProcessAliveForTest,
96
+ _setProcessMonitorIntervalForTest,
97
+ _resetProcessMonitorIntervalForTest,
98
+ setSessionEffortOverride,
99
+ } from "../session.ts";
99
100
  import {
100
101
  activePrompts,
101
102
  bindChatToSession,
@@ -895,8 +896,17 @@ describe("getSessionStatus", () => {
895
896
  expect(status!.effort).not.toBeNull();
896
897
  // model 必为字符串(留空时显示 '(留空)',否则为环境变量值);不应是占位符
897
898
  expect(typeof status!.model).toBe("string");
898
- expect(status!.model.length).toBeGreaterThan(0);
899
- });
899
+ expect(status!.model.length).toBeGreaterThan(0);
900
+ });
901
+
902
+ it("Codex session status uses the per-session effort override", async () => {
903
+ mockSessionInfo("chat-codex", { sessionId: "sid-codex-effort", tool: "codex" });
904
+ setSessionEffortOverride("sid-codex-effort", "xhigh");
905
+
906
+ const status = await getSessionStatus("chat-codex");
907
+
908
+ expect(status!.effort).toBe("xhigh");
909
+ });
900
910
 
901
911
  it("Cursor 会话:effort 恒为 null(卡片渲染时隐藏该行,避免显示无意义的 effort)", async () => {
902
912
  mockSessionInfo("chat-cursor", { sessionId: "sid-cur", tool: "cursor" });
@@ -184,12 +184,13 @@ export function normalizeCodexMessage(
184
184
  // 子进程辅助函数
185
185
  // ---------------------------------------------------------------------------
186
186
 
187
- function spawnCodex(
188
- args: string[],
189
- cwd?: string,
190
- stdinText?: string,
191
- modelOverride?: string,
192
- ): ChildProcess {
187
+ function spawnCodex(
188
+ args: string[],
189
+ cwd?: string,
190
+ stdinText?: string,
191
+ modelOverride?: string,
192
+ effortOverride?: string,
193
+ ): ChildProcess {
193
194
  const allArgs = [...args];
194
195
  const model = modelOverride ?? resolveCodexModel();
195
196
  if (model) {
@@ -197,7 +198,7 @@ function spawnCodex(
197
198
  const execIdx = allArgs.indexOf("exec");
198
199
  allArgs.splice(execIdx + 1, 0, "-m", model);
199
200
  }
200
- const effort = resolveCodexEffort();
201
+ const effort = effortOverride ?? resolveCodexEffort();
201
202
  if (effort) {
202
203
  allArgs.push("-c", `model_reasoning_effort="${effort}"`);
203
204
  }
@@ -258,15 +259,17 @@ async function* readJsonLines(
258
259
  // ---------------------------------------------------------------------------
259
260
 
260
261
  class CodexAdapter implements ToolAdapter {
261
- readonly displayName = "Codex";
262
- readonly sessionDescPrefix = "Codex Session:";
263
- private metaStore: CodexSessionMetaStore;
264
- private modelOverride: string | undefined;
265
-
266
- constructor(metaStore: CodexSessionMetaStore, modelOverride?: string) {
267
- this.metaStore = metaStore;
268
- this.modelOverride = modelOverride;
269
- }
262
+ readonly displayName = "Codex";
263
+ readonly sessionDescPrefix = "Codex Session:";
264
+ private metaStore: CodexSessionMetaStore;
265
+ private modelOverride: string | undefined;
266
+ private effortOverride: string | undefined;
267
+
268
+ constructor(metaStore: CodexSessionMetaStore, modelOverride?: string, effortOverride?: string) {
269
+ this.metaStore = metaStore;
270
+ this.modelOverride = modelOverride;
271
+ this.effortOverride = effortOverride;
272
+ }
270
273
 
271
274
  // createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
272
275
  async createSession(cwd: string): Promise<CreateSessionResult> {
@@ -296,7 +299,7 @@ class CodexAdapter implements ToolAdapter {
296
299
  ? [...baseArgs, "-C", cwd, "-"]
297
300
  : [...baseArgs, "resume", threadId, "-"];
298
301
 
299
- const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride);
302
+ const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride);
300
303
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
301
304
 
302
305
  // 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
@@ -350,7 +353,8 @@ class CodexAdapter implements ToolAdapter {
350
353
  export interface CreateCodexAdapterOptions {
351
354
  metaStore?: CodexSessionMetaStore;
352
355
  /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
353
- model?: string;
356
+ model?: string;
357
+ effort?: string;
354
358
  }
355
359
 
356
360
  export function createCodexAdapter(
@@ -358,6 +362,7 @@ export function createCodexAdapter(
358
362
  ): ToolAdapter {
359
363
  return new CodexAdapter(
360
364
  options.metaStore ?? defaultCodexSessionMetaStore,
361
- options.model,
362
- );
363
- }
365
+ options.model,
366
+ options.effort,
367
+ );
368
+ }
@@ -5,14 +5,11 @@
5
5
  * npx tsx src/builtin/cli.ts
6
6
  * npx tsx src/builtin/cli.ts --model deepseek-chat
7
7
  * npx tsx src/builtin/cli.ts --cwd /path/to/project
8
- *
9
- * 环境变量:
10
- * DEEPSEEK_API_KEY — DeepSeek API Key(必需)
11
- * DEEPSEEK_BASE_URL — API 地址(可选,默认 https://api.deepseek.com/v1)
12
8
  */
13
9
 
14
10
  import * as readline from "node:readline";
15
11
  import * as process from "node:process";
12
+ import { config as appConfig } from "../config.ts";
16
13
  import { ChatSession, type ChatSessionConfig, type ChatSessionOptions } from "./index.js";
17
14
 
18
15
  // ---------------------------------------------------------------------------
@@ -55,15 +52,14 @@ function printHelp(): void {
55
52
  "用法: npx tsx src/builtin/cli.ts [选项]",
56
53
  "",
57
54
  "选项:",
58
- " --model <name> 模型名称(默认 deepseek-chat)",
59
- " --base-url <url> API 地址(默认 https://api.deepseek.com/v1)",
60
- " --api-key <key> API Key(默认读 DEEPSEEK_API_KEY 环境变量)",
55
+ ` --model <name> 模型名称(覆盖 config.ccc.model,当前默认 ${appConfig.ccc.model})`,
56
+ ` --base-url <url> API 地址(覆盖 config.ccc.DEEPSEEK_BASE_URL,当前默认 ${appConfig.ccc.DEEPSEEK_BASE_URL})`,
57
+ " --api-key <key> API Key(覆盖 config.ccc.DEEPSEEK_API_KEY",
61
58
  " --cwd <path> 工作目录",
62
59
  " --help, -h 显示帮助",
63
60
  "",
64
- "环境变量:",
65
- " DEEPSEEK_API_KEY DeepSeek API Key",
66
- " DEEPSEEK_BASE_URL API 地址",
61
+ "默认配置来源:",
62
+ " ~/.chatccc/config.json 的 ccc.DEEPSEEK_API_KEY / ccc.DEEPSEEK_BASE_URL / ccc.model",
67
63
  "",
68
64
  ].join("\n"));
69
65
  }
@@ -87,12 +83,8 @@ const C = {
87
83
  async function main(): Promise<void> {
88
84
  const { config, options } = parseArgs();
89
85
 
90
- // 环境变量回退
91
- if (!config.baseURL) config.baseURL = process.env.DEEPSEEK_BASE_URL;
92
- if (!config.apiKey) config.apiKey = process.env.DEEPSEEK_API_KEY;
93
-
94
86
  console.log(`${C.dim}ChatCCC 内置 Agent 原型${C.reset}`);
95
- console.log(`${C.dim}模型: ${config.model ?? "deepseek-chat"}${C.reset}`);
87
+ console.log(`${C.dim}模型: ${config.model ?? appConfig.ccc.model}${C.reset}`);
96
88
  if (options.cwd) {
97
89
  console.log(`${C.dim}目录: ${options.cwd}${C.reset}`);
98
90
  }
@@ -194,4 +186,4 @@ async function main(): Promise<void> {
194
186
  main().catch((err) => {
195
187
  console.error(`${C.yellow}启动失败: ${(err as Error).message}${C.reset}`);
196
188
  process.exit(1);
197
- });
189
+ });
@@ -4,8 +4,11 @@
4
4
  * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
5
5
  */
6
6
 
7
+ import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
7
8
  import { streamText } from "ai";
8
9
 
10
+ import { config as appConfig } from "../config.ts";
11
+
9
12
  // ---------------------------------------------------------------------------
10
13
  // 系统提示词 — 编译期冻结常量
11
14
  // ---------------------------------------------------------------------------
@@ -25,11 +28,11 @@ const SYSTEM_PROMPT = [
25
28
  // ---------------------------------------------------------------------------
26
29
 
27
30
  export interface ChatSessionConfig {
28
- /** DeepSeek API 兼容的服务地址,默认 https://api.deepseek.com/v1 */
31
+ /** DeepSeek API 兼容的服务地址;传入时覆盖 config.ccc.DEEPSEEK_BASE_URL */
29
32
  baseURL?: string;
30
- /** API Key(优先用 DEEPSEEK_API_KEY 环境变量) */
33
+ /** API Key;传入时覆盖 config.ccc.DEEPSEEK_API_KEY */
31
34
  apiKey?: string;
32
- /** 模型名称,默认 deepseek-chat */
35
+ /** 模型名称;传入时覆盖 config.ccc.model */
33
36
  model?: string;
34
37
  }
35
38
 
@@ -66,21 +69,19 @@ export class ChatSession {
66
69
  private messages: ChatMessage[];
67
70
 
68
71
  constructor(
69
- config: ChatSessionConfig = {},
72
+ overrides: ChatSessionConfig = {},
70
73
  options: ChatSessionOptions = {},
71
74
  ) {
72
- const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY;
75
+ const apiKey = overrides.apiKey ?? appConfig.ccc.DEEPSEEK_API_KEY;
73
76
  if (!apiKey) {
74
77
  throw new Error(
75
- "DEEPSEEK_API_KEY 未设置。请设置环境变量 DEEPSEEK_API_KEY 或传入 apiKey",
78
+ "ccc.DEEPSEEK_API_KEY 未设置。请在 config.json 中配置,或通过 --api-key 临时传入",
76
79
  );
77
80
  }
78
81
 
79
- const baseURL = config.baseURL ?? "https://api.deepseek.com/v1";
80
- const modelId = config.model ?? "deepseek-chat";
82
+ const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
83
+ const modelId = overrides.model ?? appConfig.ccc.model;
81
84
 
82
- // 动态 import 以支持 ESM 包在 CJS 环境下的加载(tsx 处理)
83
- const { createOpenAICompatible } = require("@ai-sdk/openai-compatible");
84
85
  const provider = createOpenAICompatible({
85
86
  name: "deepseek",
86
87
  baseURL,
@@ -164,4 +165,4 @@ export class ChatSession {
164
165
  const system = this.messages[0];
165
166
  this.messages = [system];
166
167
  }
167
- }
168
+ }
package/src/cards.ts CHANGED
@@ -498,10 +498,10 @@ export function buildCodexResetConfirmCard(params: {
498
498
 
499
499
  /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
500
500
  export function buildModelCard(
501
- currentModel: string,
502
- models: string[],
503
- tool?: string,
504
- ): string {
501
+ currentModel: string,
502
+ models: string[],
503
+ tool?: string,
504
+ ): string {
505
505
  const toolLabel = tool ? ` (${tool})` : "";
506
506
  const currentLine = currentModel
507
507
  ? `**当前模型:** \`${currentModel}\``
@@ -535,7 +535,48 @@ export function buildModelCard(
535
535
  { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
536
536
  { tag: "hr" },
537
537
  buildButtons(buttons),
538
- ],
539
- });
540
- }
538
+ ],
539
+ });
540
+ }
541
+
542
+ export function buildEffortCard(
543
+ currentEffort: string,
544
+ efforts: string[],
545
+ tool?: string,
546
+ ): string {
547
+ const toolLabel = tool ? ` (${tool})` : "";
548
+ const currentLine = currentEffort
549
+ ? `**当前 effort:** \`${currentEffort}\``
550
+ : "**当前 effort:** 未指定";
551
+
552
+ const lines: string[] = [currentLine];
553
+ if (efforts.length > 0) {
554
+ lines.push("", "**可切换 effort**");
555
+ for (const effort of efforts) {
556
+ lines.push(`- \`${effort}\``);
557
+ }
558
+ lines.push("", "点击按钮切换 effort,或输入 `/effort clear` 恢复默认");
559
+ } else {
560
+ lines.push("", "当前 agent 不支持 effort 切换。");
561
+ }
562
+
563
+ const buttons: ButtonDef[] = [];
564
+ for (const effort of efforts.slice(0, 20)) {
565
+ buttons.push({
566
+ text: `/effort ${effort}`,
567
+ value: JSON.stringify({ cmd: `/effort ${effort}` }),
568
+ type: "primary",
569
+ });
570
+ }
571
+
572
+ return JSON.stringify({
573
+ config: { wide_screen_mode: true },
574
+ header: { template: "blue", title: { content: `Effort 切换${toolLabel}`, tag: "plain_text" } },
575
+ elements: [
576
+ { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
577
+ { tag: "hr" },
578
+ buildButtons(buttons),
579
+ ],
580
+ });
581
+ }
541
582
 
@@ -33,6 +33,18 @@ export interface ChromeCdpEnsureResult {
33
33
 
34
34
  export type ChromeCdpProbeStatus = "healthy" | "occupied" | "unreachable";
35
35
 
36
+ interface ChromeCdpPage {
37
+ id?: string;
38
+ type?: string;
39
+ url?: string;
40
+ }
41
+
42
+ export interface EnsureChatcccPageResult {
43
+ ok: boolean;
44
+ opened: boolean;
45
+ error?: string;
46
+ }
47
+
36
48
  let guardTimer: ReturnType<typeof setInterval> | null = null;
37
49
  let ensureInFlight: Promise<ChromeCdpEnsureResult> | null = null;
38
50
 
@@ -81,11 +93,11 @@ export function resolveChromeUserDataDir(port: number, env: NodeJS.ProcessEnv =
81
93
  return join(root, `chrome-cdp-${port}`);
82
94
  }
83
95
 
84
- async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number): Promise<Response> {
96
+ async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number, init: RequestInit = {}): Promise<Response> {
85
97
  const controller = new AbortController();
86
98
  const timer = setTimeout(() => controller.abort(), timeoutMs);
87
99
  try {
88
- return await fetchImpl(url, { signal: controller.signal });
100
+ return await fetchImpl(url, { ...init, signal: controller.signal });
89
101
  } finally {
90
102
  clearTimeout(timer);
91
103
  }
@@ -163,6 +175,61 @@ function sleep(ms: number): Promise<void> {
163
175
  return new Promise((resolve) => setTimeout(resolve, ms));
164
176
  }
165
177
 
178
+ function cdpBaseUrl(port: number): string {
179
+ return `http://${CDP_HOST}:${port}`;
180
+ }
181
+
182
+ function isChatcccPageUrl(value: string | undefined, chatcccPort: number): boolean {
183
+ if (!value) return false;
184
+ try {
185
+ const url = new URL(value);
186
+ return url.protocol === "http:" &&
187
+ (url.hostname === "localhost" || url.hostname === CDP_HOST) &&
188
+ url.port === String(chatcccPort);
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ export async function ensureChatcccPageOpen(
195
+ cdpPort: number,
196
+ chatcccPort: number,
197
+ deps: Pick<ChromeDevtoolsGuardDeps, "fetchImpl"> = {},
198
+ ): Promise<EnsureChatcccPageResult> {
199
+ const normalizedCdpPort = normalizePort(cdpPort);
200
+ const normalizedChatcccPort = normalizePort(chatcccPort);
201
+ const fetchImpl = deps.fetchImpl ?? fetch;
202
+
203
+ try {
204
+ const listResponse = await fetchWithTimeout(
205
+ `${cdpBaseUrl(normalizedCdpPort)}/json/list`,
206
+ fetchImpl,
207
+ HEALTH_TIMEOUT_MS,
208
+ );
209
+ if (!listResponse.ok) {
210
+ return { ok: false, opened: false, error: `Cannot list Chrome CDP pages: HTTP ${listResponse.status}` };
211
+ }
212
+ const pages = await listResponse.json() as unknown;
213
+ if (Array.isArray(pages) && pages.some((page: ChromeCdpPage) => isChatcccPageUrl(page?.url, normalizedChatcccPort))) {
214
+ return { ok: true, opened: false };
215
+ }
216
+
217
+ const targetUrl = `http://localhost:${normalizedChatcccPort}/`;
218
+ const openResponse = await fetchWithTimeout(
219
+ `${cdpBaseUrl(normalizedCdpPort)}/json/new?${encodeURIComponent(targetUrl)}`,
220
+ fetchImpl,
221
+ HEALTH_TIMEOUT_MS,
222
+ { method: "PUT" },
223
+ );
224
+ if (!openResponse.ok) {
225
+ return { ok: false, opened: false, error: `Cannot open ${targetUrl}: HTTP ${openResponse.status}` };
226
+ }
227
+ return { ok: true, opened: true };
228
+ } catch (err) {
229
+ return { ok: false, opened: false, error: (err as Error).message };
230
+ }
231
+ }
232
+
166
233
  export async function ensureChromeCdpRunning(
167
234
  cfg: ChromeDevtoolsConfig = config.chromeDevtools,
168
235
  deps: ChromeDevtoolsGuardDeps = {},
@@ -202,6 +269,9 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
202
269
  ensureInFlight = ensureChromeCdpRunning(config.chromeDevtools, deps);
203
270
  try {
204
271
  const result = await ensureInFlight;
272
+ const chatcccPage = config.chromeDevtools.enabled && result.ok
273
+ ? await ensureChatcccPageOpen(result.port, config.port, deps)
274
+ : null;
205
275
  appendStartupTrace("chrome-devtools-guard: ensure result", {
206
276
  reason,
207
277
  enabled: config.chromeDevtools.enabled,
@@ -209,6 +279,7 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
209
279
  ok: result.ok,
210
280
  started: result.started,
211
281
  error: result.error,
282
+ chatcccPage,
212
283
  });
213
284
  if (!config.chromeDevtools.enabled) return;
214
285
  if (result.ok && result.started) {
@@ -216,6 +287,11 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
216
287
  } else if (!result.ok) {
217
288
  log(`[${ts()}] [Chrome CDP] Guard failed: ${result.error}`);
218
289
  }
290
+ if (chatcccPage?.opened) {
291
+ log(`[${ts()}] [Chrome CDP] Opened ChatCCC page http://localhost:${config.port}/`);
292
+ } else if (chatcccPage && !chatcccPage.ok) {
293
+ log(`[${ts()}] [Chrome CDP] Failed to ensure ChatCCC page: ${chatcccPage.error}`);
294
+ }
219
295
  } finally {
220
296
  ensureInFlight = null;
221
297
  }