chatccc 0.2.121 → 0.2.122

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.
@@ -169,6 +169,48 @@ describe("handleCommand WeChat processing ack", () => {
169
169
  expect(platform.sendText).toHaveBeenCalledWith("wx-chat", "生成中...");
170
170
  });
171
171
 
172
+ it.each([
173
+ { tool: "claude" as const, sessionId: "sid-claude", text: "/ask 只回答不要改文件" },
174
+ { tool: "cursor" as const, sessionId: "sid-cursor", text: "/plan 先给我计划" },
175
+ ])("passes unsupported mode commands through as raw prompts for $tool", async ({ tool, sessionId, text }) => {
176
+ const platform = mockPlatform();
177
+ const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
178
+ yield {
179
+ type: "assistant" as const,
180
+ blocks: [{ type: "text" as const, text: `收到: ${userText}` }],
181
+ };
182
+ });
183
+ _setAdapterForToolForTest(tool, {
184
+ displayName: tool,
185
+ sessionDescPrefix: `${tool} Session:`,
186
+ createSession: vi.fn(async () => ({ sessionId })),
187
+ prompt,
188
+ getSessionInfo: async (sessionId: string): Promise<SessionInfo> => ({
189
+ sessionId,
190
+ cwd: "F:\\repo",
191
+ }),
192
+ closeSession: async () => {},
193
+ });
194
+ await recordSessionRegistry({
195
+ chatId: "wx-chat",
196
+ sessionId,
197
+ tool,
198
+ chatName: "ready-session",
199
+ running: false,
200
+ });
201
+
202
+ await handleCommand(platform, text, "wx-chat", "wx-user", Date.now(), "p2p");
203
+
204
+ expect(platform.sendText).not.toHaveBeenCalledWith("wx-chat", expect.stringContaining("仅在以下 Agent 会话中可用"));
205
+ expect(prompt).toHaveBeenCalledWith(
206
+ sessionId,
207
+ expect.stringContaining(text),
208
+ "F:\\repo",
209
+ expect.any(AbortSignal),
210
+ expect.objectContaining({ permissionMode: undefined }),
211
+ );
212
+ });
213
+
172
214
  it("cleans stale Feishu p2p binding, creates a group, and sends the private message as first prompt", async () => {
173
215
  const platform = mockPlatform("feishu");
174
216
  const prompt = vi.fn(async function* (_sessionId: string, userText: string) {
@@ -177,7 +219,7 @@ describe("handleCommand WeChat processing ack", () => {
177
219
  blocks: [{ type: "text" as const, text: `收到: ${userText}` }],
178
220
  };
179
221
  });
180
- _setAdapterForToolForTest("cursor", {
222
+ _setAdapterForToolForTest("claude", {
181
223
  displayName: "Claude",
182
224
  sessionDescPrefix: "Claude Session:",
183
225
  createSession: vi.fn(async () => ({ sessionId: "sid-feishu-new" })),
@@ -128,6 +128,13 @@ export interface ToolPromptOptions {
128
128
  onProcessStart?: (info: ToolProcessInfo) => void;
129
129
  /** Called when the adapter leaves the prompt process scope normally or by abort. */
130
130
  onProcessExit?: (info: ToolProcessInfo) => void;
131
+ /**
132
+ * 权限模式覆盖:
133
+ * - "plan": Claude Code --permission-mode plan(只读/规划)
134
+ * - "ask": Cursor Agent --mode ask(只读/问答)
135
+ * 不传或为 undefined 时使用适配器默认行为(正常编辑模式)。
136
+ */
137
+ permissionMode?: "plan" | "ask";
131
138
  }
132
139
 
133
140
  // ---------------------------------------------------------------------------
@@ -274,14 +274,17 @@ function buildCliArgs(
274
274
  isEmpty: (value: string) => boolean,
275
275
  mcpConfigJson: string | null,
276
276
  extraArgs: string[],
277
+ permissionMode?: "plan" | "ask",
277
278
  ): string[] {
279
+ const permMode = permissionMode === "plan" ? "plan" : permissionMode === "ask" ? "default" : "bypassPermissions";
280
+ const skipPermissions = permissionMode !== "plan" && permissionMode !== "ask";
278
281
  const args = [
279
282
  "-p",
280
283
  "--output-format", "stream-json",
281
284
  "--verbose",
282
285
  "--setting-sources", "user,project,local",
283
- "--permission-mode", "bypassPermissions",
284
- "--dangerously-skip-permissions",
286
+ "--permission-mode", permMode,
287
+ ...(skipPermissions ? ["--dangerously-skip-permissions"] : []),
285
288
  "--settings", "{\"maxTurns\":0}",
286
289
  ];
287
290
 
@@ -432,6 +435,7 @@ class ClaudeAdapter implements ToolAdapter {
432
435
  const args = buildCliArgs(
433
436
  this.model, this.effort, this.isEmpty, mcpConfigJson,
434
437
  ["--resume", sessionId, "--input-format", "stream-json", "--replay-user-messages"],
438
+ options?.permissionMode,
435
439
  );
436
440
 
437
441
  const proc = spawnCli(args, cwd, env, true);
@@ -261,8 +261,18 @@ function spawnAgent(
261
261
  cwd?: string,
262
262
  stdinText?: string,
263
263
  modelOverride?: string,
264
+ permissionMode?: "plan" | "ask",
264
265
  ): ChildProcess {
265
266
  let allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
267
+ if (permissionMode === "ask") {
268
+ // 替换已有的 --mode 或追加
269
+ const modeIdx = allArgs.findIndex((a, i) => a === "--mode" && i + 1 < allArgs.length);
270
+ if (modeIdx >= 0) {
271
+ allArgs[modeIdx + 1] = "ask";
272
+ } else {
273
+ allArgs.push("--mode", "ask");
274
+ }
275
+ }
266
276
  if (modelOverride) {
267
277
  // 替换全局 --model 为 per-session override
268
278
  const modelIdx = allArgs.findIndex((a, i) => a === "--model" && i + 1 < allArgs.length);
@@ -370,7 +380,7 @@ class CursorAdapter implements ToolAdapter {
370
380
  options?: ToolPromptOptions,
371
381
  ): AsyncIterable<UnifiedStreamMessage> {
372
382
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
373
- const proc = spawnAgent(["--resume", sessionId], cwd, userText, this.modelOverride);
383
+ const proc = spawnAgent(["--resume", sessionId], cwd, userText, this.modelOverride, options?.permissionMode);
374
384
  this.activeProcs.add(proc);
375
385
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
376
386
 
@@ -1,141 +1,141 @@
1
- // =============================================================================
2
- // resource-monitor.ts — CLI 进程资源监控(CPU + 内存)
3
- // =============================================================================
4
- // 对所有 chatccc 启动的 CLI 进程持续监控 CPU 和内存占用。
5
- // 若连续 3 分钟两项指标均无变化,判定为僵死,发出 "stuck" 事件。
6
- // =============================================================================
7
-
8
- import { exec } from "node:child_process";
9
- import { EventEmitter } from "node:events";
10
-
11
- const CHECK_INTERVAL_MS = 30_000; // 30 秒检查一次
12
- const STUCK_THRESHOLD = 6; // 连续 6 次无变化 = 3 分钟
13
-
14
- interface ProcessSnapshot {
15
- cpu: number;
16
- memory: number;
17
- unchangedCount: number;
18
- }
19
-
20
- interface TrackedProcess {
21
- pid: number;
22
- sessionId: string;
23
- snapshot: ProcessSnapshot;
24
- }
25
-
26
- export const resourceMonitor = new EventEmitter();
27
-
28
- const tracked = new Map<number, TrackedProcess>();
29
-
30
- let timer: ReturnType<typeof setInterval> | null = null;
31
-
32
- function startIfNeeded(): void {
33
- if (timer) return;
34
- timer = setInterval(checkAll, CHECK_INTERVAL_MS);
35
- timer.unref?.();
36
- }
37
-
38
- function stopIfIdle(): void {
39
- if (tracked.size > 0) return;
40
- if (timer) { clearInterval(timer); timer = null; }
41
- }
42
-
43
- export function registerProcess(pid: number, sessionId: string): void {
44
- tracked.set(pid, { pid, sessionId, snapshot: { cpu: -1, memory: -1, unchangedCount: 0 } });
45
- startIfNeeded();
46
- }
47
-
48
- export function unregisterProcess(pid: number): void {
49
- tracked.delete(pid);
50
- stopIfIdle();
51
- }
52
-
53
- // ---------------------------------------------------------------------------
54
- // 批量查询进程指标(Windows PowerShell)
55
- // ---------------------------------------------------------------------------
56
-
57
- function execPowerShell(script: string, timeoutMs: number): Promise<string> {
58
- return new Promise((resolve, reject) => {
59
- exec(
60
- `powershell -NoProfile -Command "${script}"`,
61
- { timeout: timeoutMs, windowsHide: true },
62
- (err, stdout) => {
63
- if (err) reject(err);
64
- else resolve(stdout);
65
- },
66
- );
67
- });
68
- }
69
-
70
- async function getProcessMetrics(pids: number[]): Promise<Map<number, { cpu: number; memory: number }>> {
71
- const result = new Map<number, { cpu: number; memory: number }>();
72
- if (pids.length === 0) return result;
73
-
74
- const psScript = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id)|$($_.CPU)|$($_.WorkingSet64)" }`;
75
-
76
- try {
77
- const stdout = await execPowerShell(psScript, 10_000);
78
- for (const line of stdout.trim().split(/\r?\n/)) {
79
- const trimmed = line.trim();
80
- if (!trimmed) continue;
81
- const [idStr, cpuStr, memStr] = trimmed.split("|");
82
- const id = parseInt(idStr, 10);
83
- const cpu = parseFloat(cpuStr);
84
- const memory = parseInt(memStr, 10);
85
- if (!isNaN(id) && !isNaN(cpu) && !isNaN(memory)) {
86
- result.set(id, { cpu, memory });
87
- }
88
- }
89
- } catch {
90
- // PowerShell 查询失败时跳过本轮,下次重试
91
- }
92
-
93
- return result;
94
- }
95
-
96
- // ---------------------------------------------------------------------------
97
- // 定时检查
98
- // ---------------------------------------------------------------------------
99
-
100
- async function checkAll(): Promise<void> {
101
- if (tracked.size === 0) return;
102
-
103
- const pids = [...tracked.keys()];
104
- const metrics = await getProcessMetrics(pids);
105
-
106
- for (const [pid, tp] of tracked) {
107
- const m = metrics.get(pid);
108
- if (!m) {
109
- // 进程已不存在,停止追踪
110
- tracked.delete(pid);
111
- continue;
112
- }
113
-
114
- const prev = tp.snapshot;
115
- const cpuChanged = m.cpu !== prev.cpu;
116
- // 内存允许 ±1% 波动,避免正常抖动触发误判
117
- const memTolerance = prev.memory > 0 ? Math.max(prev.memory * 0.01, 1024 * 1024) : 1024 * 1024;
118
- const memChanged = Math.abs(m.memory - prev.memory) > memTolerance;
119
-
120
- if (!cpuChanged && !memChanged) {
121
- tp.snapshot.unchangedCount++;
122
- if (tp.snapshot.unchangedCount >= STUCK_THRESHOLD) {
123
- const idleMinutes = Math.round(
124
- (tp.snapshot.unchangedCount * CHECK_INTERVAL_MS) / 60_000,
125
- );
126
- resourceMonitor.emit("stuck", {
127
- pid: tp.pid,
128
- sessionId: tp.sessionId,
129
- idleMinutes,
130
- });
131
- tracked.delete(pid);
132
- }
133
- } else {
134
- tp.snapshot.unchangedCount = 0;
135
- }
136
- tp.snapshot.cpu = m.cpu;
137
- tp.snapshot.memory = m.memory;
138
- }
139
-
140
- stopIfIdle();
1
+ // =============================================================================
2
+ // resource-monitor.ts — CLI 进程资源监控(CPU + 内存)
3
+ // =============================================================================
4
+ // 对所有 chatccc 启动的 CLI 进程持续监控 CPU 和内存占用。
5
+ // 若连续 3 分钟两项指标均无变化,判定为僵死,发出 "stuck" 事件。
6
+ // =============================================================================
7
+
8
+ import { exec } from "node:child_process";
9
+ import { EventEmitter } from "node:events";
10
+
11
+ const CHECK_INTERVAL_MS = 30_000; // 30 秒检查一次
12
+ const STUCK_THRESHOLD = 6; // 连续 6 次无变化 = 3 分钟
13
+
14
+ interface ProcessSnapshot {
15
+ cpu: number;
16
+ memory: number;
17
+ unchangedCount: number;
18
+ }
19
+
20
+ interface TrackedProcess {
21
+ pid: number;
22
+ sessionId: string;
23
+ snapshot: ProcessSnapshot;
24
+ }
25
+
26
+ export const resourceMonitor = new EventEmitter();
27
+
28
+ const tracked = new Map<number, TrackedProcess>();
29
+
30
+ let timer: ReturnType<typeof setInterval> | null = null;
31
+
32
+ function startIfNeeded(): void {
33
+ if (timer) return;
34
+ timer = setInterval(checkAll, CHECK_INTERVAL_MS);
35
+ timer.unref?.();
36
+ }
37
+
38
+ function stopIfIdle(): void {
39
+ if (tracked.size > 0) return;
40
+ if (timer) { clearInterval(timer); timer = null; }
41
+ }
42
+
43
+ export function registerProcess(pid: number, sessionId: string): void {
44
+ tracked.set(pid, { pid, sessionId, snapshot: { cpu: -1, memory: -1, unchangedCount: 0 } });
45
+ startIfNeeded();
46
+ }
47
+
48
+ export function unregisterProcess(pid: number): void {
49
+ tracked.delete(pid);
50
+ stopIfIdle();
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // 批量查询进程指标(Windows PowerShell)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ function execPowerShell(script: string, timeoutMs: number): Promise<string> {
58
+ return new Promise((resolve, reject) => {
59
+ exec(
60
+ `powershell -NoProfile -Command "${script}"`,
61
+ { timeout: timeoutMs, windowsHide: true },
62
+ (err, stdout) => {
63
+ if (err) reject(err);
64
+ else resolve(stdout);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ async function getProcessMetrics(pids: number[]): Promise<Map<number, { cpu: number; memory: number }>> {
71
+ const result = new Map<number, { cpu: number; memory: number }>();
72
+ if (pids.length === 0) return result;
73
+
74
+ const psScript = `Get-Process -Id ${pids.join(",")} -ErrorAction SilentlyContinue | ForEach-Object { "$($_.Id)|$($_.CPU)|$($_.WorkingSet64)" }`;
75
+
76
+ try {
77
+ const stdout = await execPowerShell(psScript, 10_000);
78
+ for (const line of stdout.trim().split(/\r?\n/)) {
79
+ const trimmed = line.trim();
80
+ if (!trimmed) continue;
81
+ const [idStr, cpuStr, memStr] = trimmed.split("|");
82
+ const id = parseInt(idStr, 10);
83
+ const cpu = parseFloat(cpuStr);
84
+ const memory = parseInt(memStr, 10);
85
+ if (!isNaN(id) && !isNaN(cpu) && !isNaN(memory)) {
86
+ result.set(id, { cpu, memory });
87
+ }
88
+ }
89
+ } catch {
90
+ // PowerShell 查询失败时跳过本轮,下次重试
91
+ }
92
+
93
+ return result;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // 定时检查
98
+ // ---------------------------------------------------------------------------
99
+
100
+ async function checkAll(): Promise<void> {
101
+ if (tracked.size === 0) return;
102
+
103
+ const pids = [...tracked.keys()];
104
+ const metrics = await getProcessMetrics(pids);
105
+
106
+ for (const [pid, tp] of tracked) {
107
+ const m = metrics.get(pid);
108
+ if (!m) {
109
+ // 进程已不存在,停止追踪
110
+ tracked.delete(pid);
111
+ continue;
112
+ }
113
+
114
+ const prev = tp.snapshot;
115
+ const cpuChanged = m.cpu !== prev.cpu;
116
+ // 内存允许 ±1% 波动,避免正常抖动触发误判
117
+ const memTolerance = prev.memory > 0 ? Math.max(prev.memory * 0.01, 1024 * 1024) : 1024 * 1024;
118
+ const memChanged = Math.abs(m.memory - prev.memory) > memTolerance;
119
+
120
+ if (!cpuChanged && !memChanged) {
121
+ tp.snapshot.unchangedCount++;
122
+ if (tp.snapshot.unchangedCount >= STUCK_THRESHOLD) {
123
+ const idleMinutes = Math.round(
124
+ (tp.snapshot.unchangedCount * CHECK_INTERVAL_MS) / 60_000,
125
+ );
126
+ resourceMonitor.emit("stuck", {
127
+ pid: tp.pid,
128
+ sessionId: tp.sessionId,
129
+ idleMinutes,
130
+ });
131
+ tracked.delete(pid);
132
+ }
133
+ } else {
134
+ tp.snapshot.unchangedCount = 0;
135
+ }
136
+ tp.snapshot.cpu = m.cpu;
137
+ tp.snapshot.memory = m.memory;
138
+ }
139
+
140
+ stopIfIdle();
141
141
  }
package/src/cards.ts CHANGED
@@ -131,6 +131,8 @@ export function buildHelpCard(
131
131
  "发送 **/new cursor** 创建新 Cursor 会话",
132
132
  "发送 **/new codex** 创建新 Codex 会话",
133
133
  "发送 **/newh** 重置当前会话(沿用当前工作目录,不切换)",
134
+ "发送 **/plan <消息>** 在 Claude 会话中以只读规划模式提问",
135
+ "发送 **/ask <消息>** 在 Cursor 会话中以只读问答模式提问",
134
136
  ].join("\n");
135
137
  return JSON.stringify({
136
138
  config: { wide_screen_mode: true },
package/src/config.ts CHANGED
@@ -122,6 +122,24 @@ export interface AppConfig {
122
122
  export type AgentTool = "claude" | "cursor" | "codex";
123
123
  export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
124
124
 
125
+ /**
126
+ * Agent 专属模式指令(内部配置,非用户可配)。
127
+ * 映射每个 Agent 支持的特殊指令及其对应的 CLI 权限模式。
128
+ *
129
+ * - claude: /plan → --permission-mode plan(只读/规划)
130
+ * - cursor: /ask → --mode ask(只读/问答)
131
+ * - codex: 暂无原生只读模式,不注册任何模式指令
132
+ */
133
+ export const AGENT_MODE_COMMANDS: Record<AgentTool, { command: string; mode: "plan" | "ask"; description: string }[]> = {
134
+ claude: [
135
+ { command: "/plan", mode: "plan", description: "以只读规划模式提问(Claude 专属)" },
136
+ ],
137
+ cursor: [
138
+ { command: "/ask", mode: "ask", description: "以只读问答模式提问(Cursor 专属)" },
139
+ ],
140
+ codex: [],
141
+ };
142
+
125
143
  /** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
126
144
  export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
127
145
  const seen = new Set<string>();