chatccc 0.2.187 → 0.2.189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/agent-prompts/claude_specific.md +45 -45
  2. package/agent-prompts/codex_specific.md +2 -2
  3. package/agent-prompts/cursor_specific.md +13 -13
  4. package/config.sample.json +5 -0
  5. package/im-skills/feishu-skill/receive-send-file.md +63 -63
  6. package/im-skills/feishu-skill/receive-send-image.md +24 -24
  7. package/im-skills/feishu-skill/skill.md +3 -3
  8. package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
  9. package/im-skills/wechat-file-skill/send-file.mjs +83 -83
  10. package/im-skills/wechat-file-skill/skill.md +10 -10
  11. package/im-skills/wechat-image-skill/skill.md +10 -10
  12. package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
  13. package/im-skills/wechat-video-skill/send-video.mjs +79 -79
  14. package/im-skills/wechat-video-skill/skill.md +10 -10
  15. package/package.json +1 -1
  16. package/scripts/postinstall-sharp-check.mjs +58 -58
  17. package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
  18. package/src/__tests__/builtin-config.test.ts +33 -0
  19. package/src/__tests__/card-plain-text.test.ts +5 -5
  20. package/src/__tests__/cardkit.test.ts +60 -60
  21. package/src/__tests__/cards.test.ts +77 -77
  22. package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
  23. package/src/__tests__/chatgpt-subscription.test.ts +135 -135
  24. package/src/__tests__/chrome-devtools-guard.test.ts +165 -125
  25. package/src/__tests__/claude-adapter.test.ts +592 -592
  26. package/src/__tests__/codex-reset-actions.test.ts +146 -146
  27. package/src/__tests__/config-reload.test.ts +1 -0
  28. package/src/__tests__/config-sample.test.ts +11 -0
  29. package/src/__tests__/feishu-api.test.ts +60 -60
  30. package/src/__tests__/feishu-avatar.test.ts +180 -115
  31. package/src/__tests__/feishu-platform.test.ts +22 -22
  32. package/src/__tests__/format-message.test.ts +47 -47
  33. package/src/__tests__/orchestrator.test.ts +150 -2
  34. package/src/__tests__/privacy.test.ts +198 -198
  35. package/src/__tests__/raw-stream-log.test.ts +106 -106
  36. package/src/__tests__/session.test.ts +10 -0
  37. package/src/__tests__/shared-prefix.test.ts +36 -36
  38. package/src/__tests__/stream-state.test.ts +42 -42
  39. package/src/__tests__/web-ui.test.ts +209 -130
  40. package/src/adapters/claude-adapter.ts +566 -566
  41. package/src/adapters/claude-session-meta-store.ts +120 -120
  42. package/src/adapters/codex-adapter.ts +10 -6
  43. package/src/adapters/cursor-adapter.ts +46 -46
  44. package/src/adapters/raw-stream-log.ts +124 -124
  45. package/src/adapters/resource-monitor.ts +140 -140
  46. package/src/agent-delegate-task-rpc.ts +153 -153
  47. package/src/agent-delegate-task.ts +81 -81
  48. package/src/agent-stop-stuck.ts +129 -129
  49. package/src/builtin/cli.ts +189 -197
  50. package/src/builtin/index.ts +168 -167
  51. package/src/cards.ts +130 -89
  52. package/src/chatgpt-subscription-rpc.ts +27 -27
  53. package/src/chatgpt-subscription.ts +299 -299
  54. package/src/chrome-devtools-guard.ts +318 -242
  55. package/src/codex-reset-actions.ts +184 -184
  56. package/src/config.ts +38 -0
  57. package/src/feishu-api.ts +219 -190
  58. package/src/feishu-platform.ts +20 -20
  59. package/src/format-message.ts +293 -293
  60. package/src/index.ts +2 -2
  61. package/src/litellm-proxy.ts +374 -374
  62. package/src/orchestrator.ts +201 -9
  63. package/src/platform-adapter.ts +9 -1
  64. package/src/privacy.ts +118 -118
  65. package/src/session-chat-binding.ts +6 -6
  66. package/src/session-name.ts +8 -8
  67. package/src/session.ts +44 -16
  68. package/src/shared-prefix.ts +29 -29
  69. package/src/sim-platform.ts +20 -20
  70. package/src/turn-cards.ts +117 -117
  71. package/src/web-ui.ts +142 -24
@@ -23,7 +23,9 @@ import {
23
23
  anthropicConfigDisplay,
24
24
  config,
25
25
  fileLog,
26
+ getAllEffortsForTool,
26
27
  getAllModelsForTool,
28
+ getDefaultEffortForTool,
27
29
  getDefaultCwd,
28
30
  setDefaultCwd,
29
31
  getRecentDirs,
@@ -37,6 +39,7 @@ import {
37
39
  } from "./config.ts";
38
40
  import {
39
41
  buildHelpCard,
42
+ buildEffortCard,
40
43
  buildModelCard,
41
44
  buildStatusCard,
42
45
  buildCdContent,
@@ -53,6 +56,7 @@ import {
53
56
  } from "./git-command.ts";
54
57
  import {
55
58
  clearSessionModelOverride,
59
+ clearSessionEffortOverride,
56
60
  getSessionStatus,
57
61
  getAllSessionsStatus,
58
62
  initClaudeSession,
@@ -60,10 +64,12 @@ import {
60
64
  resumeAndPrompt,
61
65
  sessionInfoMap,
62
66
  setSessionModelOverride,
67
+ setSessionEffortOverride,
63
68
  switchChatBinding,
64
69
  recordSessionRegistry,
65
70
  getAdapterForTool,
66
71
  getEffectiveModelForTool,
72
+ getEffectiveEffortForTool,
67
73
  stopSession,
68
74
  loadSessionRegistryForBinding,
69
75
  removeSessionRegistryRecord,
@@ -86,7 +92,7 @@ import { delegateAgentTask } from "./agent-delegate-task.ts";
86
92
  import { applySharedPrefix } from "./shared-prefix.ts";
87
93
  import { cwdDisplayName, sessionChatName } from "./session-name.ts";
88
94
  export { type PlatformAdapter } from "./platform-adapter.ts";
89
- import type { PlatformAdapter } from "./platform-adapter.ts";
95
+ import type { ChatAvatarUsageHints, PlatformAdapter } from "./platform-adapter.ts";
90
96
  import type { CodexUsageSummary } from "./feishu-api.ts";
91
97
 
92
98
  // ---------------------------------------------------------------------------
@@ -186,6 +192,39 @@ function formatCodexUsageSummary(usage: CodexUsageSummary, chatGptSubscription:
186
192
  return lines.join("\n");
187
193
  };
188
194
 
195
+ const formatSubscriptionFailureReason = (result: ChatGptSubscriptionResult) => {
196
+ const port = result.chromeCdp.port;
197
+ switch (result.code) {
198
+ case "chrome_cdp_unreachable":
199
+ return `Chrome CDP 端口 ${port} 不可访问。请确认常驻 Chrome 已启动,或重启 ChatCCC。`;
200
+ case "chrome_cdp_occupied":
201
+ return `${port} 端口可访问,但不是健康的 Chrome CDP。请释放该端口或修改 chromeDevtools.port。`;
202
+ case "chatgpt_page_missing":
203
+ return `没有可用的 ChatGPT 页面。请在 ${port} 端口对应的 Chrome 浏览器中打开 https://chatgpt.com/ 并登录。`;
204
+ case "chatgpt_session_missing":
205
+ return `请在 ${port} 端口对应的 Chrome 浏览器中登录 ChatGPT。`;
206
+ case "chatgpt_subscription_failed":
207
+ return "ChatGPT 订阅接口探测失败。可能是页面未加载完成、ChatGPT 接口变更或网络异常。";
208
+ case "chrome_cdp_disabled":
209
+ return "";
210
+ case "ok":
211
+ return "";
212
+ }
213
+ };
214
+
215
+ const formatChatGptSubscriptionFailure = () => {
216
+ if (!chatGptSubscription || chatGptSubscription.ok || !chatGptSubscription.chromeCdp.enabled) return "";
217
+ const lines = [
218
+ "**ChatGPT 订阅查询失败:**",
219
+ `- 原因: ${formatSubscriptionFailureReason(chatGptSubscription) || "暂无数据"}`,
220
+ ];
221
+ const detail = chatGptSubscription.reason?.replace(/\s+/g, " ").trim();
222
+ if (detail) {
223
+ lines.push(`- 详情: ${detail.length > 240 ? `${detail.slice(0, 240)}...` : detail}`);
224
+ }
225
+ return lines.join("\n");
226
+ };
227
+
189
228
  const formatChatGptSubscription = () => {
190
229
  if (!chatGptSubscription?.ok || !chatGptSubscription.subscription) return "";
191
230
  const subscription = chatGptSubscription.subscription;
@@ -221,7 +260,7 @@ function formatCodexUsageSummary(usage: CodexUsageSummary, chatGptSubscription:
221
260
  "Codex 用量:",
222
261
  "",
223
262
  formatChatGptSubscription(),
224
- chatGptSubscription?.ok ? "" : "",
263
+ formatChatGptSubscriptionFailure(),
225
264
  formatResetCredits(),
226
265
  "",
227
266
  formatWindow("5h", usage.fiveHour),
@@ -291,23 +330,46 @@ function usageHelpLine(tool: string): string {
291
330
  return "";
292
331
  }
293
332
 
294
- async function resolveUsageTool(chatId: string): Promise<"codex" | "cursor"> {
333
+ async function resolveUsageTarget(chatId: string): Promise<{ tool: "codex" | "cursor"; sessionId?: string }> {
295
334
  try {
296
335
  const registry = await loadSessionRegistryForBinding();
297
- return registry[chatId]?.tool === "cursor" ? "cursor" : "codex";
336
+ const record = registry[chatId];
337
+ return {
338
+ tool: record?.tool === "cursor" ? "cursor" : "codex",
339
+ sessionId: record?.sessionId,
340
+ };
298
341
  } catch {
299
- return "codex";
342
+ return { tool: "codex" };
300
343
  }
301
344
  }
302
345
 
303
- async function sendUsageSummary(platform: PlatformAdapter, chatId: string, tool: "codex" | "cursor"): Promise<void> {
346
+ function refreshUsageAvatar(
347
+ platform: PlatformAdapter,
348
+ chatId: string,
349
+ tool: "codex" | "cursor",
350
+ status: "busy" | "idle",
351
+ usageHints: ChatAvatarUsageHints,
352
+ ): void {
353
+ platform.setChatAvatar(chatId, tool, status, usageHints).catch((err) => {
354
+ console.warn(`[${ts()}] [AVATAR] usage refresh failed: chatId=${chatId} tool=${tool} ${(err as Error).message}`);
355
+ });
356
+ }
357
+
358
+ async function sendUsageSummary(
359
+ platform: PlatformAdapter,
360
+ chatId: string,
361
+ tool: "codex" | "cursor",
362
+ avatarStatus: "busy" | "idle" = "idle",
363
+ ): Promise<void> {
304
364
  if (tool === "cursor") {
305
- const content = formatCursorUsageSummary(await getCursorUsageSummary());
365
+ const usage = await getCursorUsageSummary();
366
+ const content = formatCursorUsageSummary(usage);
306
367
  if (platform.kind === "wechat") {
307
368
  await platform.sendText(chatId, content).catch(() => {});
308
369
  } else {
309
370
  await platform.sendCard(chatId, "Cursor Usage", content, "blue");
310
371
  }
372
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { cursorUsage: usage });
311
373
  return;
312
374
  }
313
375
 
@@ -323,6 +385,7 @@ async function sendUsageSummary(platform: PlatformAdapter, chatId: string, tool:
323
385
  } else {
324
386
  await platform.sendCard(chatId, "Codex Usage", content, "blue");
325
387
  }
388
+ refreshUsageAvatar(platform, chatId, tool, avatarStatus, { codexUsage: usage });
326
389
  }
327
390
 
328
391
  async function sendUsageError(platform: PlatformAdapter, chatId: string, tool: "codex" | "cursor", err: unknown): Promise<void> {
@@ -538,10 +601,12 @@ export async function handleCommand(
538
601
  }
539
602
 
540
603
  if (isCommandText && textLower === "/usage") {
541
- const usageTool = await resolveUsageTool(chatId);
604
+ const usageTarget = await resolveUsageTarget(chatId);
605
+ const usageTool = usageTarget.tool;
606
+ const avatarStatus = usageTarget.sessionId && isSessionRunning(usageTarget.sessionId) ? "busy" : "idle";
542
607
  logTrace(tid, "BRANCH", { cmd: "/usage", tool: usageTool });
543
608
  try {
544
- await sendUsageSummary(platform, chatId, usageTool);
609
+ await sendUsageSummary(platform, chatId, usageTool, avatarStatus);
545
610
  logTrace(tid, "DONE", { outcome: "usage", tool: usageTool });
546
611
  } catch (err) {
547
612
  await sendUsageError(platform, chatId, usageTool, err);
@@ -1469,6 +1534,95 @@ export async function handleCommand(
1469
1534
  }
1470
1535
 
1471
1536
  // /git <args>:在「当前会话工作目录」执行 git 命令
1537
+ if (isCommandText && textLower === "/effort clear") {
1538
+ logTrace(tid, "BRANCH", { cmd: "/effort clear", sessionId, tool: descriptionTool });
1539
+ const efforts = getAllEffortsForTool(descriptionTool as AgentTool);
1540
+ const toolLabel = toolDisplayName(descriptionTool);
1541
+ if (efforts.length === 0) {
1542
+ const msg = `当前 ${toolLabel} 不支持 effort 切换。`;
1543
+ await (platform.kind === "wechat"
1544
+ ? platform.sendText(chatId, msg)
1545
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")
1546
+ ).catch(() => {});
1547
+ logTrace(tid, "DONE", { outcome: "effort_unsupported", tool: descriptionTool });
1548
+ return;
1549
+ }
1550
+
1551
+ clearSessionEffortOverride(sessionId);
1552
+ const defaultEffort = getDefaultEffortForTool(descriptionTool as AgentTool);
1553
+ const msg = `已清除当前 ${toolLabel} 会话的 effort 覆盖,恢复使用: \`${defaultEffort || "(未指定)"}\``;
1554
+ await (platform.kind === "wechat"
1555
+ ? platform.sendText(chatId, msg)
1556
+ : platform.sendCard(chatId, "Effort 切换", msg, "green")
1557
+ ).catch(() => {});
1558
+ logTrace(tid, "DONE", { outcome: "effort_cleared", sessionId, tool: descriptionTool });
1559
+ return;
1560
+ }
1561
+
1562
+ if (isCommandText && textLower.startsWith("/effort ")) {
1563
+ const effortArg = text.slice(8).trim();
1564
+ if (!effortArg) return;
1565
+ logTrace(tid, "BRANCH", { cmd: "/effort", arg: effortArg, sessionId, tool: descriptionTool });
1566
+
1567
+ const efforts = getAllEffortsForTool(descriptionTool as AgentTool);
1568
+ const toolLabel = toolDisplayName(descriptionTool);
1569
+ if (efforts.length === 0) {
1570
+ const msg = `当前 ${toolLabel} 不支持 effort 切换。`;
1571
+ await (platform.kind === "wechat"
1572
+ ? platform.sendText(chatId, msg)
1573
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")
1574
+ ).catch(() => {});
1575
+ logTrace(tid, "DONE", { outcome: "effort_unsupported", tool: descriptionTool });
1576
+ return;
1577
+ }
1578
+
1579
+ const target = findModelMatch(effortArg, efforts);
1580
+ if (!target) {
1581
+ const msg = `未找到匹配 "${effortArg}" 的 effort。当前 ${toolLabel} 可选 effort:\n${efforts.map(e => ` \`${e}\``).join("\n")}`;
1582
+ await (platform.kind === "wechat"
1583
+ ? platform.sendText(chatId, msg)
1584
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")
1585
+ ).catch(() => {});
1586
+ logTrace(tid, "DONE", { outcome: "effort_not_found", arg: effortArg, tool: descriptionTool });
1587
+ return;
1588
+ }
1589
+
1590
+ setSessionEffortOverride(sessionId, target);
1591
+ const msg = `已切换当前 ${toolLabel} 会话 effort 为: \`${target}\``;
1592
+ await (platform.kind === "wechat"
1593
+ ? platform.sendText(chatId, msg)
1594
+ : platform.sendCard(chatId, "Effort 切换", msg, "green")
1595
+ ).catch(() => {});
1596
+ logTrace(tid, "DONE", { outcome: "effort_switched", arg: effortArg, target, sessionId, tool: descriptionTool });
1597
+ return;
1598
+ }
1599
+
1600
+ if (isCommandText && textLower === "/effort") {
1601
+ logTrace(tid, "BRANCH", { cmd: "/effort", sessionId, tool: descriptionTool });
1602
+ const efforts = getAllEffortsForTool(descriptionTool as AgentTool);
1603
+ const currentEffort = getEffectiveEffortForTool(descriptionTool, sessionId);
1604
+ const toolLabel = toolDisplayName(descriptionTool);
1605
+
1606
+ if (efforts.length === 0) {
1607
+ const msg = `当前 ${toolLabel} 不支持 effort 切换。`;
1608
+ await (platform.kind === "wechat"
1609
+ ? platform.sendText(chatId, msg)
1610
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")
1611
+ ).catch(() => {});
1612
+ } else if (platform.kind === "wechat") {
1613
+ const lines = [currentEffort ? `当前 effort (${toolLabel}): ${currentEffort}` : `当前 effort (${toolLabel}): 未指定`];
1614
+ lines.push("", "可切换 effort:");
1615
+ for (const e of efforts) lines.push(` ${e}`);
1616
+ lines.push("", "输入 /effort <effort> 切换 effort");
1617
+ await platform.sendText(chatId, lines.join("\n")).catch(() => {});
1618
+ } else {
1619
+ const card = buildEffortCard(currentEffort, efforts, descriptionTool);
1620
+ await platform.sendRawCard(chatId, card);
1621
+ }
1622
+ logTrace(tid, "DONE", { outcome: "effort_query", tool: descriptionTool });
1623
+ return;
1624
+ }
1625
+
1472
1626
  if (isCommandText && (textLower.startsWith("/git ") || textLower === "/git")) {
1473
1627
  const args = text === "/git" ? "" : text.slice(5).trim();
1474
1628
  logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
@@ -1627,6 +1781,44 @@ export async function handleCommand(
1627
1781
  }
1628
1782
 
1629
1783
  // 无会话上下文 → /sessions 仍是有效指令,不触发飞书私聊自动建群。
1784
+ if (isCommandText && textLower === "/effort") {
1785
+ const defaultTool = resolveDefaultAgentTool();
1786
+ const efforts = getAllEffortsForTool(defaultTool);
1787
+ const currentEffort = getDefaultEffortForTool(defaultTool);
1788
+ const toolLabel = toolDisplayName(defaultTool);
1789
+
1790
+ if (efforts.length === 0) {
1791
+ const msg = `当前默认 agent (${toolLabel}) 不支持 effort 切换。`;
1792
+ await (platform.kind === "wechat"
1793
+ ? platform.sendText(chatId, msg)
1794
+ : platform.sendCard(chatId, "Effort 切换", msg, "red")
1795
+ ).catch(() => {});
1796
+ } else if (platform.kind === "wechat") {
1797
+ const lines = [currentEffort ? `当前默认 effort (${toolLabel}): ${currentEffort}` : `当前默认 effort (${toolLabel}): 未指定`];
1798
+ lines.push("", "可切换 effort:");
1799
+ for (const e of efforts) lines.push(` ${e}`);
1800
+ lines.push("", "在会话中输入 /effort <effort> 切换 effort");
1801
+ await platform.sendText(chatId, lines.join("\n")).catch(() => {});
1802
+ } else {
1803
+ const card = buildEffortCard(currentEffort, efforts, defaultTool);
1804
+ await platform.sendRawCard(chatId, card);
1805
+ }
1806
+ logTrace(tid, "DONE", { outcome: "effort_query", defaultTool });
1807
+ return;
1808
+ }
1809
+
1810
+ if (isCommandText && textLower.startsWith("/effort ")) {
1811
+ const defaultTool = resolveDefaultAgentTool();
1812
+ const toolLabel = toolDisplayName(defaultTool);
1813
+ const msg = `当前没有绑定会话。请先进入 Claude/Codex 会话,再输入 /effort <effort> 切换当前会话的 effort。当前默认 agent: ${toolLabel}`;
1814
+ await (platform.kind === "wechat"
1815
+ ? platform.sendText(chatId, msg)
1816
+ : platform.sendCard(chatId, "Effort 切换", msg, "yellow")
1817
+ ).catch(() => {});
1818
+ logTrace(tid, "DONE", { outcome: "effort_no_session", defaultTool });
1819
+ return;
1820
+ }
1821
+
1630
1822
  if (isCommandText && textLower === "/sessions") {
1631
1823
  logTrace(tid, "BRANCH", { cmd: "/sessions", scope: "global" });
1632
1824
  const allSessions = await getAllSessionsStatus();
@@ -5,6 +5,14 @@
5
5
  * 每个方法内部自行管理认证,消费者不感知 token 等认证细节。
6
6
  */
7
7
 
8
+ import type { CursorUsageSummary } from "./cursor-usage.ts";
9
+ import type { CodexUsageSummary } from "./feishu-api.ts";
10
+
11
+ export interface ChatAvatarUsageHints {
12
+ codexUsage?: CodexUsageSummary | null;
13
+ cursorUsage?: CursorUsageSummary | null;
14
+ }
15
+
8
16
  export interface PlatformAdapter {
9
17
  /** 平台标识,用于区分不同平台的行为(如 wechat、feishu 等) */
10
18
  kind?: string;
@@ -40,7 +48,7 @@ export interface PlatformAdapter {
40
48
  disbandChat(chatId: string): Promise<void>;
41
49
 
42
50
  /** 设置群头像 */
43
- setChatAvatar(chatId: string, tool: string, status: string): Promise<void>;
51
+ setChatAvatar(chatId: string, tool: string, status: string, usageHints?: ChatAvatarUsageHints): Promise<void>;
44
52
 
45
53
  /** 从群描述中提取 session 信息 */
46
54
  extractSessionInfo(
package/src/privacy.ts CHANGED
@@ -1,118 +1,118 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { USER_DATA_DIR } from "./config.ts";
4
-
5
- interface PrivacyRules {
6
- [key: string]: string;
7
- }
8
-
9
- interface PrivacyConfig {
10
- enabled: boolean;
11
- rules: PrivacyRules;
12
- }
13
-
14
- let config: PrivacyConfig | null = null;
15
- let loadedStamp: string | null | undefined;
16
-
17
- function privacyFilePath(): string {
18
- return join(USER_DATA_DIR, "privacy.json");
19
- }
20
-
21
- function privacyFileStamp(): string | null {
22
- const filePath = privacyFilePath();
23
- if (!existsSync(filePath)) return null;
24
- try {
25
- const s = statSync(filePath);
26
- return `${s.mtimeMs}:${s.size}`;
27
- } catch {
28
- return null;
29
- }
30
- }
31
-
32
- function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
33
- const result: PrivacyRules = {};
34
- for (const [from, to] of Object.entries(raw)) {
35
- if (!from) {
36
- console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
37
- continue;
38
- }
39
- if (typeof to !== "string") {
40
- console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
41
- continue;
42
- }
43
- result[from] = to;
44
- }
45
- return result;
46
- }
47
-
48
- function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
49
- const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
50
- Object.prototype.hasOwnProperty.call(parsed, "rules");
51
-
52
- if (!hasNewSchema) {
53
- return { enabled: true, rules: sanitizeRules(parsed) };
54
- }
55
-
56
- const enabledRaw = parsed.enabled;
57
- const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
58
- if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
59
- console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
60
- }
61
-
62
- const rulesRaw = parsed.rules;
63
- if (rulesRaw === undefined) return { enabled, rules: {} };
64
- if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
65
- console.error("[PRIVACY] privacy.json rules must be an object");
66
- return { enabled, rules: {} };
67
- }
68
- return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
69
- }
70
-
71
- function loadConfig(): PrivacyConfig {
72
- const filePath = privacyFilePath();
73
- if (!existsSync(filePath)) {
74
- return { enabled: true, rules: {} };
75
- }
76
- try {
77
- const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
78
- const parsed = JSON.parse(raw);
79
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
- console.error("[PRIVACY] privacy.json must be an object");
81
- return { enabled: true, rules: {} };
82
- }
83
- return normalizeConfig(parsed as Record<string, unknown>);
84
- } catch (err) {
85
- console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
86
- return { enabled: true, rules: {} };
87
- }
88
- }
89
-
90
- export function getPrivacyConfig(): PrivacyConfig {
91
- const stamp = privacyFileStamp();
92
- if (!config || stamp !== loadedStamp) {
93
- config = loadConfig();
94
- loadedStamp = stamp;
95
- }
96
- return config;
97
- }
98
-
99
- export function getPrivacyRules(): PrivacyRules {
100
- return getPrivacyConfig().rules;
101
- }
102
-
103
- export function reloadPrivacyRules(): void {
104
- config = null;
105
- loadedStamp = undefined;
106
- }
107
-
108
- export function applyPrivacy(text: string): string {
109
- const { enabled, rules } = getPrivacyConfig();
110
- if (!enabled || Object.keys(rules).length === 0 || !text) return text;
111
-
112
- let result = text;
113
- for (const [from, to] of Object.entries(rules)) {
114
- // Use split+join instead of regex replacement so rule keys stay literal.
115
- result = result.split(from).join(to);
116
- }
117
- return result;
118
- }
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { USER_DATA_DIR } from "./config.ts";
4
+
5
+ interface PrivacyRules {
6
+ [key: string]: string;
7
+ }
8
+
9
+ interface PrivacyConfig {
10
+ enabled: boolean;
11
+ rules: PrivacyRules;
12
+ }
13
+
14
+ let config: PrivacyConfig | null = null;
15
+ let loadedStamp: string | null | undefined;
16
+
17
+ function privacyFilePath(): string {
18
+ return join(USER_DATA_DIR, "privacy.json");
19
+ }
20
+
21
+ function privacyFileStamp(): string | null {
22
+ const filePath = privacyFilePath();
23
+ if (!existsSync(filePath)) return null;
24
+ try {
25
+ const s = statSync(filePath);
26
+ return `${s.mtimeMs}:${s.size}`;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function sanitizeRules(raw: Record<string, unknown>): PrivacyRules {
33
+ const result: PrivacyRules = {};
34
+ for (const [from, to] of Object.entries(raw)) {
35
+ if (!from) {
36
+ console.error("[PRIVACY] privacy.json rule key cannot be empty, skipped");
37
+ continue;
38
+ }
39
+ if (typeof to !== "string") {
40
+ console.error(`[PRIVACY] privacy.json value must be a string, skipped "${from}"`);
41
+ continue;
42
+ }
43
+ result[from] = to;
44
+ }
45
+ return result;
46
+ }
47
+
48
+ function normalizeConfig(parsed: Record<string, unknown>): PrivacyConfig {
49
+ const hasNewSchema = Object.prototype.hasOwnProperty.call(parsed, "enabled") ||
50
+ Object.prototype.hasOwnProperty.call(parsed, "rules");
51
+
52
+ if (!hasNewSchema) {
53
+ return { enabled: true, rules: sanitizeRules(parsed) };
54
+ }
55
+
56
+ const enabledRaw = parsed.enabled;
57
+ const enabled = enabledRaw === undefined ? true : enabledRaw !== false;
58
+ if (enabledRaw !== undefined && typeof enabledRaw !== "boolean") {
59
+ console.error("[PRIVACY] privacy.json enabled must be a boolean, using true");
60
+ }
61
+
62
+ const rulesRaw = parsed.rules;
63
+ if (rulesRaw === undefined) return { enabled, rules: {} };
64
+ if (typeof rulesRaw !== "object" || rulesRaw === null || Array.isArray(rulesRaw)) {
65
+ console.error("[PRIVACY] privacy.json rules must be an object");
66
+ return { enabled, rules: {} };
67
+ }
68
+ return { enabled, rules: sanitizeRules(rulesRaw as Record<string, unknown>) };
69
+ }
70
+
71
+ function loadConfig(): PrivacyConfig {
72
+ const filePath = privacyFilePath();
73
+ if (!existsSync(filePath)) {
74
+ return { enabled: true, rules: {} };
75
+ }
76
+ try {
77
+ const raw = readFileSync(filePath, "utf-8").replace(/^\uFEFF/, "");
78
+ const parsed = JSON.parse(raw);
79
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
80
+ console.error("[PRIVACY] privacy.json must be an object");
81
+ return { enabled: true, rules: {} };
82
+ }
83
+ return normalizeConfig(parsed as Record<string, unknown>);
84
+ } catch (err) {
85
+ console.error(`[PRIVACY] failed to read privacy.json: ${(err as Error).message}`);
86
+ return { enabled: true, rules: {} };
87
+ }
88
+ }
89
+
90
+ export function getPrivacyConfig(): PrivacyConfig {
91
+ const stamp = privacyFileStamp();
92
+ if (!config || stamp !== loadedStamp) {
93
+ config = loadConfig();
94
+ loadedStamp = stamp;
95
+ }
96
+ return config;
97
+ }
98
+
99
+ export function getPrivacyRules(): PrivacyRules {
100
+ return getPrivacyConfig().rules;
101
+ }
102
+
103
+ export function reloadPrivacyRules(): void {
104
+ config = null;
105
+ loadedStamp = undefined;
106
+ }
107
+
108
+ export function applyPrivacy(text: string): string {
109
+ const { enabled, rules } = getPrivacyConfig();
110
+ if (!enabled || Object.keys(rules).length === 0 || !text) return text;
111
+
112
+ let result = text;
113
+ for (const [from, to] of Object.entries(rules)) {
114
+ // Use split+join instead of regex replacement so rule keys stay literal.
115
+ result = result.split(from).join(to);
116
+ }
117
+ return result;
118
+ }
@@ -71,11 +71,11 @@ export interface ActivePrompt {
71
71
  /** Set when the watchdog detects that the CLI process disappeared before stream finalization. */
72
72
  abnormalExit?: boolean;
73
73
  abnormalExitNotified?: boolean;
74
- /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
- resourceStuck?: boolean;
76
- /** Adapter-provided callback to close the underlying SDK session / subprocess.
77
- * Called by stop-stuck-loop before controller.abort() to terminate the CLI
78
- * process immediately, rather than waiting for the async generator to unblock. */
74
+ /** Set when the resource monitor detects CPU + memory unchanged for 3 minutes. */
75
+ resourceStuck?: boolean;
76
+ /** Adapter-provided callback to close the underlying SDK session / subprocess.
77
+ * Called by stop-stuck-loop before controller.abort() to terminate the CLI
78
+ * process immediately, rather than waiting for the async generator to unblock. */
79
79
  closeSession?: () => void;
80
80
  }
81
81
 
@@ -228,4 +228,4 @@ export function resetBindingState(): void {
228
228
  clearInterval(unifiedDisplayLoopHandle);
229
229
  unifiedDisplayLoopHandle = null;
230
230
  }
231
- }
231
+ }
@@ -1,8 +1,8 @@
1
- export function cwdDisplayName(cwd: string): string {
2
- const trimmed = cwd.trim().replace(/[\\/]+$/, "");
3
- return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
4
- }
5
-
6
- export function sessionChatName(left: string, cwd: string): string {
7
- return `${left}-${cwdDisplayName(cwd)}`;
8
- }
1
+ export function cwdDisplayName(cwd: string): string {
2
+ const trimmed = cwd.trim().replace(/[\\/]+$/, "");
3
+ return trimmed.split(/[\\/]/).filter(Boolean).pop() || trimmed || "cwd";
4
+ }
5
+
6
+ export function sessionChatName(left: string, cwd: string): string {
7
+ return `${left}-${cwdDisplayName(cwd)}`;
8
+ }