chatccc 0.2.6 → 0.2.7

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,154 @@
1
+ // =============================================================================
2
+ // cursor-session-meta-store.ts — Cursor 会话 sessionId → meta 持久化映射
3
+ // =============================================================================
4
+ // 背景:Claude Adapter 通过 SDK 的 getSessionInfo 能拿到会话的真实 cwd(SDK
5
+ // 内部已持久化)。Cursor CLI 没有等价机制,因此 ChatCCC 必须自己维护一份
6
+ // sessionId → { cwd, model } 映射,否则:
7
+ // 1. /git、/cd 等需要"会话真实工作目录"的命令将在 Cursor 会话上 100% 失败
8
+ // 2. /status、/sessions 显示的"模型"只能硬塞 ChatCCC 的 ANTHROPIC 环境变量,
9
+ // 与 Cursor 实际跑的 Composer 2 Fast 等真实模型无关
10
+ //
11
+ // 存储:
12
+ // 文件 .claude/cursor-session-meta.json,结构:
13
+ // { "<sessionId>": { "cwd": "...", "model": "..." } }
14
+ //
15
+ // API 设计:
16
+ // set(sid, partial) → 部分合并写入;只更新非空字段,不会清空其他字段
17
+ // 这样 createSession(拿到 cwd+model)与 prompt(resume 时再次学习)都用同一
18
+ // 接口,但若某次 init 事件少了某字段也不会破坏已记录值。
19
+ //
20
+ // 鲁棒性:文件不存在/损坏/IO 失败一律视为空映射,仅打日志,不阻断主流程。
21
+ // =============================================================================
22
+
23
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
24
+ import { dirname, join } from "node:path";
25
+
26
+ import { PROJECT_ROOT } from "../config.ts";
27
+
28
+ /** 持久化文件默认路径(生产)。测试可通过 createCursorSessionMetaStore(filePath) 注入。 */
29
+ export const CURSOR_SESSION_META_FILE = join(
30
+ PROJECT_ROOT,
31
+ ".claude",
32
+ "cursor-session-meta.json",
33
+ );
34
+
35
+ /**
36
+ * 单条会话元数据。cwd 为必填(无 cwd 的记录视为不完整,get 返回 undefined);
37
+ * model 可选(cursor agent init 事件理论上一定带,留可选是为防御性兜底)。
38
+ */
39
+ export interface CursorSessionMeta {
40
+ cwd: string;
41
+ model?: string;
42
+ }
43
+
44
+ export interface CursorSessionMetaStore {
45
+ /** 查询某 sessionId 对应的元数据;未记录或 cwd 缺失返回 undefined。 */
46
+ get(sessionId: string): Promise<CursorSessionMeta | undefined>;
47
+ /**
48
+ * 部分合并写入:仅写入非 undefined / 非空字段;其他字段保持原值。
49
+ * - 第一次写入若不含 cwd,记录视为不完整,get 仍返回 undefined
50
+ * - 同 sessionId 重复写入完全相同值时跳过 IO(性能优化)
51
+ */
52
+ set(sessionId: string, partial: Partial<CursorSessionMeta>): Promise<void>;
53
+ }
54
+
55
+ interface RawEntry {
56
+ cwd?: string;
57
+ model?: string;
58
+ }
59
+
60
+ function isNonEmptyString(v: unknown): v is string {
61
+ return typeof v === "string" && v.length > 0;
62
+ }
63
+
64
+ /**
65
+ * 解析持久化文件中的单条记录,兼容历史 schema:
66
+ * - 新版:{ cwd: string, model?: string }
67
+ * - 历史 v1:纯字符串(直接是 cwd 值)—— 升级前旧数据兼容
68
+ * 非法形态返回 null。
69
+ */
70
+ function parseEntry(raw: unknown): RawEntry | null {
71
+ if (typeof raw === "string" && raw.length > 0) {
72
+ return { cwd: raw };
73
+ }
74
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
75
+ const obj = raw as Record<string, unknown>;
76
+ const out: RawEntry = {};
77
+ if (isNonEmptyString(obj.cwd)) out.cwd = obj.cwd;
78
+ if (isNonEmptyString(obj.model)) out.model = obj.model;
79
+ return out;
80
+ }
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * 创建一个基于 JSON 文件的 store 实例。
86
+ *
87
+ * - 首次访问时懒加载文件到内存缓存;后续读全部走缓存
88
+ * - 写时先合并到缓存再落盘(写失败仅 console.error,不抛异常)
89
+ * - 同一 sessionId 重复 set 完全相同值时跳过 IO
90
+ */
91
+ export function createCursorSessionMetaStore(
92
+ filePath: string = CURSOR_SESSION_META_FILE,
93
+ ): CursorSessionMetaStore {
94
+ let cache: Record<string, RawEntry> | null = null;
95
+
96
+ async function load(): Promise<Record<string, RawEntry>> {
97
+ if (cache) return cache;
98
+ try {
99
+ const raw = await readFile(filePath, "utf-8");
100
+ const parsed = JSON.parse(raw);
101
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
102
+ const out: Record<string, RawEntry> = {};
103
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
104
+ const entry = parseEntry(v);
105
+ if (entry) out[k] = entry;
106
+ }
107
+ cache = out;
108
+ return out;
109
+ }
110
+ } catch {
111
+ // 文件不存在 / JSON 损坏 / 读权限失败 → 视为空映射,不阻断主流程
112
+ }
113
+ cache = {};
114
+ return cache;
115
+ }
116
+
117
+ return {
118
+ async get(sessionId: string): Promise<CursorSessionMeta | undefined> {
119
+ const map = await load();
120
+ const entry = map[sessionId];
121
+ if (!entry || !isNonEmptyString(entry.cwd)) return undefined;
122
+ return entry.model
123
+ ? { cwd: entry.cwd, model: entry.model }
124
+ : { cwd: entry.cwd };
125
+ },
126
+
127
+ async set(
128
+ sessionId: string,
129
+ partial: Partial<CursorSessionMeta>,
130
+ ): Promise<void> {
131
+ const map = await load();
132
+ const existing = map[sessionId] ?? {};
133
+ const merged: RawEntry = { ...existing };
134
+ if (isNonEmptyString(partial.cwd)) merged.cwd = partial.cwd;
135
+ if (isNonEmptyString(partial.model)) merged.model = partial.model;
136
+
137
+ // 与原值完全相同时跳过 IO
138
+ if (existing.cwd === merged.cwd && existing.model === merged.model) return;
139
+
140
+ map[sessionId] = merged;
141
+ try {
142
+ await mkdir(dirname(filePath), { recursive: true });
143
+ await writeFile(filePath, JSON.stringify(map, null, 2), "utf-8");
144
+ } catch (err) {
145
+ console.error(
146
+ `[cursor-session-meta] failed to persist ${filePath}: ${(err as Error).message}`,
147
+ );
148
+ }
149
+ },
150
+ };
151
+ }
152
+
153
+ /** 生产环境共享的全局默认实例(指向 .claude/cursor-session-meta.json)。 */
154
+ export const defaultCursorSessionMetaStore = createCursorSessionMetaStore();
package/src/cards.ts CHANGED
@@ -119,7 +119,7 @@ export function buildHelpCard(userText: string): string {
119
119
  { text: "新建 Claude Code 会话(/new claude)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
120
120
  { text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
121
121
  { text: "重启 ChatCCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
122
- { text: "查看/切换工作路径及最近使用(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
122
+ { text: "切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
123
123
  ]),
124
124
  ],
125
125
  });
@@ -162,6 +162,15 @@ export function buildCdContent(
162
162
  }
163
163
 
164
164
  // 工作路径卡片(/cd 无参数时使用,含最近使用路径按钮)
165
+ //
166
+ // 必须使用 v1 卡片格式(无 schema 字段、elements 放顶层)。原因:
167
+ // 此卡片通过 `/im/v1/messages?msg_type=interactive` 端点直接发出,content
168
+ // 字段就是卡片 JSON。该端点不接受 schema 2.0 的原始 JSON 作为 content
169
+ // (schema 2.0 卡片必须先通过 CardKit /cardkit/v1/cards 创建得到 card_id,
170
+ // 再以 `{type:"card", data:{card_id}}` 包装发出,参见 sendCardKitMessage)。
171
+ // 之前用过 schema 2.0 + body.elements 的结构,飞书会静默拒绝该消息,
172
+ // 导致 /cd 无任何回复——故此处与 buildSessionsCard/buildStatusCard 保持
173
+ // 同一 v1 结构。
165
174
  export function buildCdCard(
166
175
  dirPath: string,
167
176
  entries: { name: string; isDir: boolean }[],
@@ -173,24 +182,28 @@ export function buildCdCard(
173
182
  const overflow = entries.length > maxFiles ? `\n...(共 ${entries.length} 个条目,仅显示前 ${maxFiles} 个)` : "";
174
183
  const listing = display.map(e => e.isDir ? `📁 ${e.name}/` : `📄 ${e.name}`).join("\n");
175
184
 
176
- const currentLine = sessionCwd
177
- ? `**当前会话工作路径:** \`${sessionCwd}\``
178
- : "";
179
-
180
185
  const elements: object[] = [];
181
186
 
182
- if (currentLine) {
183
- elements.push({ tag: "markdown", content: currentLine });
187
+ if (sessionCwd) {
188
+ elements.push({
189
+ tag: "div",
190
+ text: { tag: "lark_md", content: `**当前会话工作路径:** \`${sessionCwd}\`` },
191
+ });
184
192
  }
185
- elements.push({ tag: "markdown", content: `**新会话默认工作路径:** \`${dirPath}\`` });
193
+ elements.push({
194
+ tag: "div",
195
+ text: { tag: "lark_md", content: `**新会话默认工作路径:** \`${dirPath}\`` },
196
+ });
186
197
 
187
198
  if (recentDirs.length > 0) {
188
199
  elements.push({ tag: "hr" });
189
- elements.push({ tag: "markdown", content: "**最近使用过的路径(点击切换):**" });
200
+ elements.push({
201
+ tag: "div",
202
+ text: { tag: "lark_md", content: "**最近使用过的路径(点击切换):**" },
203
+ });
190
204
  elements.push({
191
205
  tag: "action",
192
206
  actions: recentDirs.map(d => {
193
- const name = d.split(/[\\/]/).filter(Boolean).pop() ?? d;
194
207
  const label = d.length > 36 ? `...${d.slice(-33)}` : d;
195
208
  return {
196
209
  tag: "button",
@@ -204,25 +217,24 @@ export function buildCdCard(
204
217
 
205
218
  elements.push({ tag: "hr" });
206
219
  elements.push({
207
- tag: "markdown",
208
- content: [
209
- `**目录内容** (最多 ${maxFiles} 个):`,
210
- listing,
211
- overflow,
212
- ].join("\n"),
220
+ tag: "div",
221
+ text: {
222
+ tag: "lark_md",
223
+ content: [
224
+ `**目录内容** (最多 ${maxFiles} 个):`,
225
+ listing,
226
+ overflow,
227
+ ].join("\n"),
228
+ },
213
229
  });
214
230
 
215
231
  return JSON.stringify({
216
- schema: "2.0",
217
232
  config: { wide_screen_mode: true },
218
233
  header: {
219
234
  template: "blue",
220
235
  title: { tag: "plain_text", content: "工作路径" },
221
236
  },
222
- body: {
223
- direction: "vertical",
224
- elements,
225
- },
237
+ elements,
226
238
  });
227
239
  }
228
240
 
package/src/config.ts CHANGED
@@ -62,6 +62,59 @@ export const CLAUDE_MODEL = process.env.CHATCCC_ANTHROPIC_MODEL?.trim() || "defa
62
62
 
63
63
  export const CLAUDE_EFFORT = process.env.CHATCCC_ANTHROPIC_EFFORT?.trim() || "default";
64
64
 
65
+ // ---------------------------------------------------------------------------
66
+ // /git 超时配置(CHATCCC_GIT_TIMEOUT_SECONDS)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /** /git 命令默认超时秒数(用户未配置时使用) */
70
+ export const DEFAULT_GIT_TIMEOUT_SECONDS = 180;
71
+ /** /git 超时允许的下限/上限(防止 0、负数、过大值导致行为异常) */
72
+ export const MIN_GIT_TIMEOUT_SECONDS = 1;
73
+ export const MAX_GIT_TIMEOUT_SECONDS = 3600; // 1 小时
74
+
75
+ export interface ParsedGitTimeout {
76
+ /** 实际使用的超时秒数(无效时回退为 default) */
77
+ seconds: number;
78
+ /** 用户提供的原始字符串是否为合法整数秒(true 表示采纳了用户值或未提供) */
79
+ valid: boolean;
80
+ /** 用户原始输入;未设置时为 undefined */
81
+ raw?: string;
82
+ /** 是否使用了内置默认值(即用户未提供有效值) */
83
+ usingDefault: boolean;
84
+ }
85
+
86
+ /**
87
+ * 解析 CHATCCC_GIT_TIMEOUT_SECONDS 字符串为有效秒数。
88
+ * - 未设置 / 空 → 使用默认值,valid=true、usingDefault=true
89
+ * - 有效整数且在 [MIN, MAX] 区间 → 使用用户值,valid=true、usingDefault=false
90
+ * - 非整数 / 越界 / NaN → 使用默认值,valid=false、usingDefault=true(同时保留原始字符串供报错)
91
+ */
92
+ export function parseGitTimeoutSeconds(
93
+ raw: string | undefined,
94
+ defaultSeconds = DEFAULT_GIT_TIMEOUT_SECONDS,
95
+ ): ParsedGitTimeout {
96
+ const trimmed = raw?.trim();
97
+ if (!trimmed) {
98
+ return { seconds: defaultSeconds, valid: true, usingDefault: true };
99
+ }
100
+ const n = Number(trimmed);
101
+ if (
102
+ !Number.isFinite(n) ||
103
+ !Number.isInteger(n) ||
104
+ n < MIN_GIT_TIMEOUT_SECONDS ||
105
+ n > MAX_GIT_TIMEOUT_SECONDS
106
+ ) {
107
+ return { seconds: defaultSeconds, valid: false, raw: trimmed, usingDefault: true };
108
+ }
109
+ return { seconds: n, valid: true, raw: trimmed, usingDefault: false };
110
+ }
111
+
112
+ const gitTimeoutParsed = parseGitTimeoutSeconds(process.env.CHATCCC_GIT_TIMEOUT_SECONDS);
113
+ /** /git 命令实际使用的超时秒数 */
114
+ export const GIT_TIMEOUT_SECONDS = gitTimeoutParsed.seconds;
115
+ /** /git 命令实际使用的超时毫秒数(runGitCommand 直接使用) */
116
+ export const GIT_TIMEOUT_MS = GIT_TIMEOUT_SECONDS * 1000;
117
+
65
118
  /** 探测 cursor-agent 安装路径(优先环境变量,其次 LocalAppData,最后默认 agent) */
66
119
  function detectCursorAgent(): string {
67
120
  if (process.env.CHATCCC_CURSOR_COMMAND?.trim()) return process.env.CHATCCC_CURSOR_COMMAND.trim();
@@ -73,8 +126,21 @@ function detectCursorAgent(): string {
73
126
  return "agent";
74
127
  }
75
128
  export const CURSOR_AGENT_COMMAND = detectCursorAgent();
76
- /** Cursor agent 参数:-p 非交互模式,stream-json 流式 JSONL 输出 */
77
- export const CURSOR_AGENT_ARGS = (process.env.CHATCCC_CURSOR_ARGS?.trim() || "-p --output-format stream-json --stream-partial-output").split(/\s+/).filter(Boolean);
129
+
130
+ function resolveCursorAgentArgs(): string[] {
131
+ const custom = process.env.CHATCCC_CURSOR_ARGS?.trim();
132
+ if (custom) return custom.split(/\s+/).filter(Boolean);
133
+
134
+ let args = "-p --force --output-format stream-json --stream-partial-output";
135
+ const model = process.env.CHATCCC_CURSOR_MODEL?.trim();
136
+ if (model && model !== "default") {
137
+ args += ` --model ${model}`;
138
+ }
139
+ return args.split(/\s+/).filter(Boolean);
140
+ }
141
+
142
+ /** Cursor agent 参数:-p 非交互模式,--force 强制允许命令(yolo),stream-json 流式 JSONL 输出 */
143
+ export const CURSOR_AGENT_ARGS = resolveCursorAgentArgs();
78
144
 
79
145
  // 新建会话的默认工作路径(/cd 命令设置,持久化到本地文件)
80
146
  // 该路径仅影响通过 /new 新建的会话,不影响已有会话的 resume。
@@ -227,6 +293,30 @@ export function reportEnvironmentVariableReadout(): void {
227
293
  );
228
294
  }
229
295
 
296
+ const rawGitTimeout = process.env.CHATCCC_GIT_TIMEOUT_SECONDS?.trim();
297
+ if (!rawGitTimeout) {
298
+ console.log(` [默认] [可选] CHATCCC_GIT_TIMEOUT_SECONDS`);
299
+ console.log(
300
+ ` /git 命令超时: 未设置 → 使用内置默认 ${DEFAULT_GIT_TIMEOUT_SECONDS}s`
301
+ );
302
+ } else if (!gitTimeoutParsed.valid) {
303
+ row(
304
+ "/git 命令超时",
305
+ "CHATCCC_GIT_TIMEOUT_SECONDS",
306
+ "可选",
307
+ false,
308
+ `值无效 "${rawGitTimeout}",需为 ${MIN_GIT_TIMEOUT_SECONDS}–${MAX_GIT_TIMEOUT_SECONDS} 的整数秒;已回退为默认 ${DEFAULT_GIT_TIMEOUT_SECONDS}s`,
309
+ );
310
+ } else {
311
+ row(
312
+ "/git 命令超时",
313
+ "CHATCCC_GIT_TIMEOUT_SECONDS",
314
+ "可选",
315
+ true,
316
+ `已读入 → ${GIT_TIMEOUT_SECONDS}s`,
317
+ );
318
+ }
319
+
230
320
  console.log(" ------------------------------------------------------------------");
231
321
  }
232
322
 
package/src/feishu-api.ts CHANGED
@@ -343,7 +343,7 @@ export async function sendRestartCard(token: string): Promise<void> {
343
343
  { text: "新建 Claude Code 会话(/new claude)", value: JSON.stringify({ cmd: "new" }), type: "primary" },
344
344
  { text: "新建 Cursor 会话(/new cursor)", value: JSON.stringify({ cmd: "new cursor" }), type: "primary" },
345
345
  { text: "重启Chat CCC(/restart)", value: JSON.stringify({ cmd: "restart" }), type: "danger" },
346
- { text: "查看/切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
346
+ { text: "切换工作路径(/cd)", value: JSON.stringify({ cmd: "cd" }), type: "default" },
347
347
  ]),
348
348
  ],
349
349
  });
@@ -0,0 +1,202 @@
1
+ // =============================================================================
2
+ // git-command.ts — /git 命令的执行与结果格式化
3
+ // =============================================================================
4
+ // 解析自 /git 后的原始字符串作为 shell 参数,在指定 cwd 下通过 shell 调起
5
+ // `git ...`,收集 stdout/stderr 与退出码。带超时(kill)与逐路输出字节上限
6
+ // (超过即截断),避免长输出撑爆内存或刷屏。
7
+ //
8
+ // 抽成独立模块是为了便于在不依赖飞书 SDK / 网络的前提下跑单元测试。
9
+ // =============================================================================
10
+
11
+ import { spawn } from "node:child_process";
12
+
13
+ import { truncateContent } from "./cards.ts";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // 类型
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export interface GitCommandResult {
20
+ /** 进程退出码;spawn 失败或被 kill 时为 null */
21
+ exitCode: number | null;
22
+ stdout: string;
23
+ stderr: string;
24
+ durationMs: number;
25
+ /** 任意一路输出超过 maxBytes 上限,已被截断 */
26
+ truncated: boolean;
27
+ /** 命令超过 timeoutMs 被强制终止 */
28
+ timedOut: boolean;
29
+ /** spawn 自身错误(如 git 不在 PATH);正常退出(含非 0)时为 undefined */
30
+ spawnError?: string;
31
+ }
32
+
33
+ export interface RunGitOptions {
34
+ /** 命令最大允许执行毫秒数;超过则 SIGKILL,默认 60_000 */
35
+ timeoutMs?: number;
36
+ /** stdout 与 stderr 各自最多采集多少字节;默认 64 KiB */
37
+ maxBytes?: number;
38
+ /** 注入测试桩用:自定义 spawn 函数;默认使用 node:child_process 的 spawn */
39
+ spawnImpl?: typeof spawn;
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // runGitCommand —— 在 cwd 下执行 `git <args>`
44
+ // ---------------------------------------------------------------------------
45
+
46
+ /**
47
+ * 在 `cwd` 目录下执行 `git <args>`。
48
+ * - 通过 shell 执行(`shell: true`),允许用户使用引号、管道等 shell 语法
49
+ * - stdout/stderr 各自最多采集 `maxBytes` 字节,超过则置 truncated=true 并丢弃后续片段
50
+ * - 超时则 SIGKILL 并置 timedOut=true,仍返回已收集的部分输出
51
+ *
52
+ * 注意:本函数 **不会抛错**——任何失败都通过返回值传递(退出码、spawnError 等),
53
+ * 调用方需通过 exitCode/spawnError/timedOut/truncated 判断结果。
54
+ */
55
+ export function runGitCommand(
56
+ args: string,
57
+ cwd: string,
58
+ opts: RunGitOptions = {},
59
+ ): Promise<GitCommandResult> {
60
+ const timeoutMs = opts.timeoutMs ?? 60_000;
61
+ const maxBytes = opts.maxBytes ?? 64 * 1024;
62
+ const spawnImpl = opts.spawnImpl ?? spawn;
63
+ const startTime = Date.now();
64
+
65
+ return new Promise<GitCommandResult>((resolve) => {
66
+ let child: ReturnType<typeof spawn>;
67
+ try {
68
+ child = spawnImpl(`git ${args}`, {
69
+ cwd,
70
+ shell: true,
71
+ windowsHide: true,
72
+ });
73
+ } catch (err) {
74
+ resolve({
75
+ exitCode: null,
76
+ stdout: "",
77
+ stderr: "",
78
+ durationMs: Date.now() - startTime,
79
+ truncated: false,
80
+ timedOut: false,
81
+ spawnError: err instanceof Error ? err.message : String(err),
82
+ });
83
+ return;
84
+ }
85
+
86
+ let stdout = "";
87
+ let stderr = "";
88
+ let stdoutBytes = 0;
89
+ let stderrBytes = 0;
90
+ let truncated = false;
91
+ let timedOut = false;
92
+ let spawnError: string | undefined;
93
+
94
+ const collect = (chunk: Buffer, current: { bytes: number; text: string }): void => {
95
+ const room = maxBytes - current.bytes;
96
+ if (room <= 0) {
97
+ truncated = true;
98
+ return;
99
+ }
100
+ const slice = chunk.length <= room ? chunk : chunk.subarray(0, room);
101
+ current.text += slice.toString("utf-8");
102
+ current.bytes += slice.length;
103
+ if (chunk.length > room) truncated = true;
104
+ };
105
+
106
+ const stdoutState = { bytes: 0, text: "" };
107
+ const stderrState = { bytes: 0, text: "" };
108
+
109
+ child.stdout?.on("data", (chunk: Buffer) => {
110
+ collect(chunk, stdoutState);
111
+ stdout = stdoutState.text;
112
+ stdoutBytes = stdoutState.bytes;
113
+ });
114
+ child.stderr?.on("data", (chunk: Buffer) => {
115
+ collect(chunk, stderrState);
116
+ stderr = stderrState.text;
117
+ stderrBytes = stderrState.bytes;
118
+ });
119
+
120
+ const killTimer = setTimeout(() => {
121
+ timedOut = true;
122
+ try {
123
+ child.kill("SIGKILL");
124
+ } catch {
125
+ // ignore: 进程可能已退出
126
+ }
127
+ }, timeoutMs);
128
+
129
+ child.on("error", (err: Error) => {
130
+ spawnError = err.message;
131
+ });
132
+
133
+ child.on("close", (code: number | null) => {
134
+ clearTimeout(killTimer);
135
+ // 仅引用未直接使用的字节计数变量以避免编译告警
136
+ void stdoutBytes; void stderrBytes;
137
+ resolve({
138
+ exitCode: code,
139
+ stdout,
140
+ stderr,
141
+ durationMs: Date.now() - startTime,
142
+ truncated,
143
+ timedOut,
144
+ spawnError,
145
+ });
146
+ });
147
+ });
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+ // formatGitResult —— 把执行结果渲染为飞书卡片用的 markdown
152
+ // ---------------------------------------------------------------------------
153
+
154
+ /**
155
+ * 渲染 `/git` 执行结果为发送给用户的 markdown 字符串。
156
+ * stdout/stderr 单路各最多保留 `maxLines` 行 / `maxChars` 字符(沿用 truncateContent),
157
+ * 命令本身的截断状态(truncated/timedOut/spawnError)也会在头部说明。
158
+ */
159
+ export function formatGitResult(
160
+ args: string,
161
+ cwd: string,
162
+ result: GitCommandResult,
163
+ opts: { maxLines?: number; maxChars?: number } = {},
164
+ ): string {
165
+ const maxLines = opts.maxLines ?? 50;
166
+ const maxChars = opts.maxChars ?? 6000;
167
+ const sec = (result.durationMs / 1000).toFixed(2);
168
+ const lines: string[] = [];
169
+
170
+ lines.push(`**\$ git ${args}**`);
171
+ lines.push(`工作目录: \`${cwd}\``);
172
+ const exitDisplay = result.exitCode === null ? "(无)" : String(result.exitCode);
173
+ lines.push(`退出码: \`${exitDisplay}\` | 用时: \`${sec}s\``);
174
+ if (result.timedOut) lines.push(`⏱️ 命令超时被强制终止`);
175
+ if (result.truncated) lines.push(`⚠️ 输出超出采集上限,已截断`);
176
+ if (result.spawnError) lines.push(`❌ 启动失败: ${result.spawnError}`);
177
+
178
+ const stdoutTrim = result.stdout.trim();
179
+ const stderrTrim = result.stderr.trim();
180
+
181
+ if (stdoutTrim) {
182
+ lines.push("", "**stdout:**", "```", truncateContent(stdoutTrim, maxLines, maxChars), "```");
183
+ }
184
+ if (stderrTrim) {
185
+ lines.push("", "**stderr:**", "```", truncateContent(stderrTrim, maxLines, maxChars), "```");
186
+ }
187
+ if (!stdoutTrim && !stderrTrim && !result.spawnError) {
188
+ lines.push("", "_(命令无输出)_");
189
+ }
190
+
191
+ return lines.join("\n");
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 头部颜色:成功绿色,失败红色,超时黄色
196
+ // ---------------------------------------------------------------------------
197
+
198
+ export function gitResultHeaderTemplate(result: GitCommandResult): string {
199
+ if (result.timedOut || result.spawnError) return "yellow";
200
+ if (result.exitCode === 0) return "green";
201
+ return "red";
202
+ }