chatccc 0.2.21 → 0.2.23

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.
package/README.md CHANGED
@@ -16,11 +16,13 @@ ChatCCC 把 Claude Code、Cursor Agent、Codex (OpenAI) 接入了飞书群聊:
16
16
 
17
17
  一句话:**在任何设备上打开飞书,就能让 Claude Code / Cursor / Codex 帮你写代码、排查问题、分析项目。**
18
18
 
19
- <p align="center">
20
- <img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="280" />
21
- &emsp;
22
- <img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="280" />
23
- </p>
19
+ <p align="center">
20
+ <img src="images/img_readme_messages.jpg" alt="飞书会话列表" width="220" align="top" />
21
+ &nbsp;
22
+ <img src="images/img_readme_0.jpg" alt="飞书群聊中使用 ChatCCC" width="220" align="top" />
23
+ &nbsp;
24
+ <img src="images/img_readme_1.jpg" alt="思考过程和工具调用" width="220" align="top" />
25
+ </p>
24
26
 
25
27
  ---
26
28
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,10 @@
11
11
  "files": [
12
12
  "src/",
13
13
  "bin/",
14
- "images/",
14
+ "images/img_readme_*.jpg",
15
+ "images/img_readme_*.png",
16
+ "images/avatars/status_*.png",
17
+ "images/avatars/badges/",
15
18
  "package.json",
16
19
  "README.md",
17
20
  "config.sample.json"
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ AGENT_SEND_IMAGE_PATH,
5
+ buildAgentImageCapabilityPrompt,
6
+ createAgentImageGrant,
7
+ getAgentImageGrantFromAuthorization,
8
+ revokeAgentImageGrant,
9
+ } from "../agent-image-rpc.ts";
10
+
11
+ describe("agent image RPC grants", () => {
12
+ it("creates a per-turn grant and validates bearer authorization", () => {
13
+ const grant = createAgentImageGrant({
14
+ chatId: "oc_chat",
15
+ sessionId: "sid-1",
16
+ cwd: "F:/repo",
17
+ port: 18080,
18
+ nowMs: 1000,
19
+ ttlMs: 60_000,
20
+ });
21
+
22
+ expect(grant.url).toBe(`http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`);
23
+ expect(grant.token.length).toBeGreaterThan(20);
24
+
25
+ const found = getAgentImageGrantFromAuthorization(
26
+ `Bearer ${grant.token}`,
27
+ 30_000,
28
+ );
29
+ expect(found).toMatchObject({
30
+ chatId: "oc_chat",
31
+ sessionId: "sid-1",
32
+ cwd: "F:/repo",
33
+ });
34
+
35
+ revokeAgentImageGrant(grant.token);
36
+ expect(getAgentImageGrantFromAuthorization(`Bearer ${grant.token}`, 30_000)).toBeNull();
37
+ });
38
+
39
+ it("rejects missing, malformed, revoked, and expired authorization", () => {
40
+ const grant = createAgentImageGrant({
41
+ chatId: "oc_chat",
42
+ sessionId: "sid-1",
43
+ cwd: "F:/repo",
44
+ port: 18080,
45
+ nowMs: 1000,
46
+ ttlMs: 10,
47
+ });
48
+
49
+ expect(getAgentImageGrantFromAuthorization("", 1000)).toBeNull();
50
+ expect(getAgentImageGrantFromAuthorization(grant.token, 1000)).toBeNull();
51
+ expect(getAgentImageGrantFromAuthorization(`Basic ${grant.token}`, 1000)).toBeNull();
52
+ expect(getAgentImageGrantFromAuthorization(`Bearer ${grant.token}`, 1011)).toBeNull();
53
+ });
54
+
55
+ it("builds prompt instructions without requiring environment variables", () => {
56
+ const prompt = buildAgentImageCapabilityPrompt({
57
+ url: `http://127.0.0.1:18080${AGENT_SEND_IMAGE_PATH}`,
58
+ token: "tok_test",
59
+ });
60
+
61
+ expect(prompt).toContain("POST http://127.0.0.1:18080/api/agent/send-image");
62
+ expect(prompt).toContain("Authorization: Bearer tok_test");
63
+ expect(prompt).toContain("\"path\"");
64
+ expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_URL");
65
+ expect(prompt).not.toContain("CHATCCC_SEND_IMAGE_TOKEN");
66
+ });
67
+ });
@@ -0,0 +1,204 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ import { extname, isAbsolute, resolve } from "node:path";
4
+ import { stat } from "node:fs/promises";
5
+
6
+ import { getTenantAccessToken, sendImageReply, sendTextReply } from "./feishu-api.ts";
7
+ import { ts } from "./config.ts";
8
+ import { logTrace } from "./trace.ts";
9
+
10
+ export const AGENT_SEND_IMAGE_PATH = "/api/agent/send-image";
11
+
12
+ const DEFAULT_GRANT_TTL_MS = 30 * 60 * 1000;
13
+ const MAX_REQUEST_BYTES = 64 * 1024;
14
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
15
+ const ALLOWED_IMAGE_EXTS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
16
+
17
+ export interface AgentImageGrant {
18
+ token: string;
19
+ url: string;
20
+ chatId: string;
21
+ sessionId: string;
22
+ cwd: string;
23
+ expiresAt: number;
24
+ traceId: string;
25
+ }
26
+
27
+ interface CreateAgentImageGrantInput {
28
+ chatId: string;
29
+ sessionId: string;
30
+ cwd: string;
31
+ port: number;
32
+ traceId?: string;
33
+ nowMs?: number;
34
+ ttlMs?: number;
35
+ }
36
+
37
+ const imageGrants = new Map<string, AgentImageGrant>();
38
+
39
+ function createToken(): string {
40
+ return randomBytes(24).toString("base64url");
41
+ }
42
+
43
+ export function createAgentImageGrant(input: CreateAgentImageGrantInput): AgentImageGrant {
44
+ const now = input.nowMs ?? Date.now();
45
+ const token = createToken();
46
+ const grant: AgentImageGrant = {
47
+ token,
48
+ url: `http://127.0.0.1:${input.port}${AGENT_SEND_IMAGE_PATH}`,
49
+ chatId: input.chatId,
50
+ sessionId: input.sessionId,
51
+ cwd: input.cwd,
52
+ expiresAt: now + (input.ttlMs ?? DEFAULT_GRANT_TTL_MS),
53
+ traceId: input.traceId ?? "",
54
+ };
55
+ imageGrants.set(token, grant);
56
+ if (grant.traceId) logTrace(grant.traceId, "IMAGE_GRANT_CREATED", { sessionId: grant.sessionId, ttlMs: input.ttlMs ?? DEFAULT_GRANT_TTL_MS });
57
+ return grant;
58
+ }
59
+
60
+ export function revokeAgentImageGrant(token: string): void {
61
+ const grant = imageGrants.get(token);
62
+ if (grant?.traceId) logTrace(grant.traceId, "IMAGE_GRANT_REVOKED", {});
63
+ imageGrants.delete(token);
64
+ }
65
+
66
+ export function getAgentImageGrantFromAuthorization(
67
+ authorization: string | undefined,
68
+ nowMs = Date.now(),
69
+ ): AgentImageGrant | null {
70
+ const match = (authorization ?? "").match(/^Bearer\s+(.+)$/i);
71
+ if (!match) return null;
72
+ const grant = imageGrants.get(match[1]);
73
+ if (!grant) return null;
74
+ if (grant.expiresAt <= nowMs) {
75
+ imageGrants.delete(match[1]);
76
+ return null;
77
+ }
78
+ return grant;
79
+ }
80
+
81
+ export function buildAgentImageCapabilityPrompt(input: {
82
+ url: string;
83
+ token: string;
84
+ cwd?: string;
85
+ }): string {
86
+ const lines = [
87
+ "[ChatCCC local capability: send image]",
88
+ "You can send an image to the current Feishu chat in real time by calling this local endpoint.",
89
+ "",
90
+ `POST ${input.url}`,
91
+ `Authorization: Bearer ${input.token}`,
92
+ "Content-Type: application/json",
93
+ "",
94
+ 'Body: {"path":"absolute image file path","caption":"optional caption"}',
95
+ "",
96
+ "Rules:",
97
+ "- Save or choose a local image file first, then call the endpoint.",
98
+ "- Use an absolute local file path. Do not call Feishu Open Platform directly.",
99
+ "- Only call this endpoint when the user asked for an image or when an image is useful to the answer.",
100
+ "[/ChatCCC local capability: send image]",
101
+ ];
102
+ if (input.cwd) {
103
+ lines.splice(2, 0, `Current working directory: ${input.cwd}`);
104
+ }
105
+ return lines.join("\n");
106
+ }
107
+
108
+ function jsonReply(res: ServerResponse, status: number, data: unknown): void {
109
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
110
+ res.end(JSON.stringify(data));
111
+ }
112
+
113
+ function readLimitedBody(req: IncomingMessage): Promise<string> {
114
+ return new Promise((resolve, reject) => {
115
+ let body = "";
116
+ let size = 0;
117
+ req.on("data", (chunk: Buffer) => {
118
+ size += chunk.length;
119
+ if (size > MAX_REQUEST_BYTES) {
120
+ reject(new Error("Request body too large"));
121
+ req.destroy();
122
+ return;
123
+ }
124
+ body += chunk.toString("utf8");
125
+ });
126
+ req.on("end", () => resolve(body));
127
+ req.on("error", reject);
128
+ });
129
+ }
130
+
131
+ async function resolveAndValidateImagePath(grant: AgentImageGrant, rawPath: unknown): Promise<string> {
132
+ if (typeof rawPath !== "string" || rawPath.trim() === "") {
133
+ throw new Error("path must be a non-empty string");
134
+ }
135
+
136
+ const sessionRoot = resolve(grant.cwd);
137
+ const imagePath = isAbsolute(rawPath)
138
+ ? resolve(rawPath)
139
+ : resolve(sessionRoot, rawPath);
140
+
141
+ const ext = extname(imagePath).toLowerCase();
142
+ if (!ALLOWED_IMAGE_EXTS.has(ext)) {
143
+ throw new Error(`unsupported image extension: ${ext || "(none)"}`);
144
+ }
145
+
146
+ const st = await stat(imagePath);
147
+ if (!st.isFile()) throw new Error("image path is not a file");
148
+ if (st.size <= 0) throw new Error("image file is empty");
149
+ if (st.size > MAX_IMAGE_BYTES) throw new Error("image file is larger than 10MB");
150
+ return imagePath;
151
+ }
152
+
153
+ export async function handleAgentImageRequest(
154
+ req: IncomingMessage,
155
+ res: ServerResponse,
156
+ ): Promise<boolean> {
157
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
158
+ if (url.pathname !== AGENT_SEND_IMAGE_PATH) return false;
159
+
160
+ if (req.method !== "POST") {
161
+ jsonReply(res, 405, { ok: false, error: "Method not allowed" });
162
+ return true;
163
+ }
164
+
165
+ const grant = getAgentImageGrantFromAuthorization(req.headers.authorization);
166
+ if (!grant) {
167
+ jsonReply(res, 401, { ok: false, error: "Invalid or expired image-send token" });
168
+ return true;
169
+ }
170
+ const tid = grant.traceId;
171
+
172
+ let payload: { path?: unknown; caption?: unknown };
173
+ try {
174
+ payload = JSON.parse(await readLimitedBody(req));
175
+ } catch (err) {
176
+ if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_json", error: (err as Error).message });
177
+ jsonReply(res, 400, { ok: false, error: (err as Error).message || "Invalid JSON" });
178
+ return true;
179
+ }
180
+
181
+ let imagePath: string;
182
+ try {
183
+ imagePath = await resolveAndValidateImagePath(grant, payload.path);
184
+ } catch (err) {
185
+ if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "invalid_path", path: String(payload.path), error: (err as Error).message });
186
+ jsonReply(res, 400, { ok: false, error: (err as Error).message });
187
+ return true;
188
+ }
189
+
190
+ try {
191
+ const token = await getTenantAccessToken();
192
+ const caption = typeof payload.caption === "string" ? payload.caption.trim() : "";
193
+ await sendImageReply(token, grant.chatId, imagePath);
194
+ if (caption) await sendTextReply(token, grant.chatId, caption);
195
+ if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "sent", path: imagePath, hasCaption: !!caption });
196
+ console.log(`[${ts()}] [AGENT-IMAGE] sent image chat=${grant.chatId} session=${grant.sessionId} path=${imagePath}`);
197
+ jsonReply(res, 200, { ok: true });
198
+ } catch (err) {
199
+ if (tid) logTrace(tid, "IMAGE_REQ", { outcome: "send_failed", path: imagePath!, error: (err as Error).message });
200
+ console.error(`[${ts()}] [AGENT-IMAGE] send failed: ${(err as Error).message}`);
201
+ jsonReply(res, 500, { ok: false, error: (err as Error).message });
202
+ }
203
+ return true;
204
+ }
package/src/feishu-api.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdir, stat, readFile, mkdir, writeFile } from "node:fs/promises";
2
- import { resolve as resolvePath } from "node:path";
2
+ import { extname, resolve as resolvePath } from "node:path";
3
3
  import { dirname, join } from "node:path";
4
4
  import sharp from "sharp";
5
5
 
@@ -424,7 +424,7 @@ async function loadImageCache(): Promise<void> {
424
424
  for (const [key, value] of Object.entries(parsed)) {
425
425
  if (typeof value === "string" && value.trim()) imageCache.set(key, value);
426
426
  }
427
- } catch { /* missing or malformed cache is not fatal */ }
427
+ } catch { /* missing or malformed cache is not fatal */ }
428
428
  }
429
429
 
430
430
  async function persistImageCache(): Promise<void> {
@@ -475,7 +475,7 @@ export async function getOrDownloadImage(token: string, messageId: string, fileK
475
475
  await persistImageCache().catch((err) => {
476
476
  console.error(`[${ts()}] [IMAGE] persist cache FAIL: ${(err as Error).message}`);
477
477
  });
478
- console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
478
+ console.log(`[${ts()}] [IMAGE] Downloaded ${fileKey} -> ${localPath}`);
479
479
  return localPath;
480
480
  }
481
481
 
@@ -521,23 +521,103 @@ export async function sendTextReply(
521
521
  token: string,
522
522
  chatId: string,
523
523
  text: string
524
- ): Promise<void> {
524
+ ): Promise<boolean> {
525
525
  const card = JSON.stringify({
526
526
  config: { wide_screen_mode: true },
527
527
  elements: [{ tag: "markdown", content: text }],
528
528
  });
529
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
529
+ try {
530
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
531
+ method: "POST",
532
+ headers: {
533
+ Authorization: `Bearer ${token}`,
534
+ "Content-Type": "application/json",
535
+ },
536
+ body: JSON.stringify({
537
+ receive_id: chatId,
538
+ msg_type: "interactive",
539
+ content: card,
540
+ }),
541
+ });
542
+ const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
543
+ if (data.code !== 0) {
544
+ console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
545
+ return false;
546
+ }
547
+ console.log(`[${ts()}] [SEND] text OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
548
+ return true;
549
+ } catch (err) {
550
+ console.error(`[${ts()}] [SEND] text FAIL: chatId=${chatId} ${(err as Error).message}`);
551
+ return false;
552
+ }
553
+ }
554
+
555
+ function messageImageContentType(imagePath: string): string {
556
+ const ext = extname(imagePath).toLowerCase();
557
+ if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
558
+ if (ext === ".webp") return "image/webp";
559
+ if (ext === ".gif") return "image/gif";
560
+ if (ext === ".bmp") return "image/bmp";
561
+ return "image/png";
562
+ }
563
+
564
+ async function uploadMessageImage(token: string, imagePath: string): Promise<string> {
565
+ const buffer = await readFile(imagePath);
566
+ const blob = new Blob([new Uint8Array(buffer)], {
567
+ type: messageImageContentType(imagePath),
568
+ });
569
+ const form = new FormData();
570
+ form.append("image_type", "message");
571
+ form.append("image", blob, imagePath.split(/[\\/]/).pop() || "image.png");
572
+
573
+ const resp = await fetch(`${BASE_URL}/im/v1/images`, {
530
574
  method: "POST",
531
- headers: {
532
- Authorization: `Bearer ${token}`,
533
- "Content-Type": "application/json",
534
- },
535
- body: JSON.stringify({
536
- receive_id: chatId,
537
- msg_type: "interactive",
538
- content: card,
539
- }),
575
+ headers: { Authorization: `Bearer ${token}` },
576
+ body: form,
540
577
  });
578
+ const text = await resp.text();
579
+ let data: { code: number; msg?: string; data?: { image_key?: string } };
580
+ try { data = JSON.parse(text); } catch {
581
+ throw new Error(`uploadMessageImage non-JSON response: ${text.slice(0, 200)}`);
582
+ }
583
+ if (data.code !== 0) throw new Error(`[${data.code}] ${data.msg}`);
584
+ const imageKey = data.data?.image_key;
585
+ if (!imageKey) throw new Error("uploadMessageImage response missing image_key");
586
+ return imageKey;
587
+ }
588
+
589
+ export async function sendImageReply(
590
+ token: string,
591
+ chatId: string,
592
+ imagePath: string,
593
+ ): Promise<boolean> {
594
+ try {
595
+ const imageKey = await uploadMessageImage(token, imagePath);
596
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
597
+ method: "POST",
598
+ headers: {
599
+ Authorization: `Bearer ${token}`,
600
+ "Content-Type": "application/json",
601
+ },
602
+ body: JSON.stringify({
603
+ receive_id: chatId,
604
+ msg_type: "image",
605
+ content: JSON.stringify({ image_key: imageKey }),
606
+ }),
607
+ });
608
+ const data = (await resp.json().catch(() => ({}))) as { code?: number; msg?: string; data?: { message_id?: string } };
609
+ if (data.code !== 0) {
610
+ console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} code=${data.code} msg="${data.msg ?? ""}"`);
611
+ throw new Error(`[${data.code}] ${data.msg ?? "send image failed"}`);
612
+ }
613
+ console.log(`[${ts()}] [SEND] image OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
614
+ return true;
615
+ } catch (err) {
616
+ if (!(err instanceof Error && err.message.startsWith("["))) {
617
+ console.error(`[${ts()}] [SEND] image FAIL: chatId=${chatId} path=${imagePath} ${(err as Error).message}`);
618
+ }
619
+ throw err;
620
+ }
541
621
  }
542
622
 
543
623
  export async function addReaction(
@@ -561,21 +641,33 @@ export async function sendCardReply(
561
641
  title: string,
562
642
  content: string,
563
643
  template = "green"
564
- ): Promise<void> {
644
+ ): Promise<boolean> {
565
645
  const card = JSON.stringify({
566
646
  config: { wide_screen_mode: true },
567
647
  header: { template, title: { content: title, tag: "plain_text" } },
568
648
  elements: [{ tag: "div", text: { tag: "lark_md", content } }],
569
649
  });
570
650
 
571
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
572
- method: "POST",
573
- headers: {
574
- Authorization: `Bearer ${token}`,
575
- "Content-Type": "application/json",
576
- },
577
- body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
578
- });
651
+ try {
652
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
653
+ method: "POST",
654
+ headers: {
655
+ Authorization: `Bearer ${token}`,
656
+ "Content-Type": "application/json",
657
+ },
658
+ body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
659
+ });
660
+ const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
661
+ if (data.code !== 0) {
662
+ console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" code=${data.code} msg="${data.msg ?? ""}"`);
663
+ return false;
664
+ }
665
+ console.log(`[${ts()}] [SEND] card OK: chatId=${chatId} title="${title}" msgId=${data.data?.message_id ?? "N/A"}`);
666
+ return true;
667
+ } catch (err) {
668
+ console.error(`[${ts()}] [SEND] card FAIL: chatId=${chatId} title="${title}" ${(err as Error).message}`);
669
+ return false;
670
+ }
579
671
  }
580
672
 
581
673
  // 重启后,向最后有发言的会话发送 "已重启" 卡片(基于 chat_logs 的文件修改时间)
package/src/index.ts CHANGED
@@ -31,7 +31,8 @@ import { WSClient, EventDispatcher } from "@larksuiteoapi/node-sdk";
31
31
  import WebSocket from "ws";
32
32
 
33
33
  import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging } from "./shared.ts";
34
- import { createUiRouter, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
34
+ import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.ts";
35
+ import { makeTraceId, logTrace } from "./trace.ts";
35
36
  import {
36
37
  CHATCCC_PORT,
37
38
  APP_ID,
@@ -81,6 +82,7 @@ import {
81
82
  } from "./feishu-api.ts";
82
83
  import { buildHelpCard, buildStatusCard, buildProgressCard, buildCdContent, buildCdCard, buildSessionsCard } from "./cards.ts";
83
84
  import { updateCardKitCard } from "./cardkit.ts";
85
+ import { handleAgentImageRequest } from "./agent-image-rpc.ts";
84
86
  import { formatGitResult, gitResultHeaderTemplate, runGitCommand } from "./git-command.ts";
85
87
  import {
86
88
  MAX_PROCESSED,
@@ -113,7 +115,7 @@ function isUntitledSessionChatName(name: string): boolean {
113
115
  // ---------------------------------------------------------------------------
114
116
 
115
117
  interface InnerEvent {
116
- message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; create_time?: string };
118
+ message?: { message_id?: string; message_type?: string; content?: string; chat_id?: string; chat_type?: string; create_time?: string };
117
119
  sender?: { sender_id?: { open_id?: string; union_id?: string } };
118
120
  }
119
121
 
@@ -242,10 +244,13 @@ let broadcastToRelay: (data: unknown) => void = () => {};
242
244
  // Command handler
243
245
  // ---------------------------------------------------------------------------
244
246
 
245
- async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number): Promise<void> {
247
+ async function handleCommand(text: string, chatId: string, openId: string, msgTimestamp: number, chatType = "group", traceId?: string): Promise<void> {
248
+ const tid = traceId ?? makeTraceId();
246
249
  if (text === "/restart") {
250
+ logTrace(tid, "BRANCH", { cmd: "/restart" });
247
251
  const restartToken = await getTenantAccessToken();
248
252
  await sendTextReply(restartToken, chatId, "正在重启...").catch(() => {});
253
+ logTrace(tid, "DONE", { outcome: "restart" });
249
254
  console.log(`[${ts()}] [RESTART] Spawning new process...`);
250
255
  const child = spawn("npx", ["tsx", "src/index.ts"], {
251
256
  cwd: PROJECT_ROOT,
@@ -259,6 +264,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
259
264
  }
260
265
 
261
266
  if (text === "/cd" || text.startsWith("/cd ")) {
267
+ logTrace(tid, "BRANCH", { cmd: "/cd", arg: text.slice(3).trim() || "(none)" });
262
268
  const cdToken = await getTenantAccessToken();
263
269
  const currentDir = await getDefaultCwd();
264
270
 
@@ -290,10 +296,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
290
296
  try {
291
297
  const s = await stat(targetDir);
292
298
  if (!s.isDirectory()) {
299
+ logTrace(tid, "DONE", { outcome: "cd_not_dir", targetDir });
293
300
  await sendCardReply(cdToken, chatId, "新会话工作路径", `路径存在但不是目录:\n\`${targetDir}\``, "red");
294
301
  return;
295
302
  }
296
303
  } catch {
304
+ logTrace(tid, "DONE", { outcome: "cd_not_found", targetDir });
297
305
  await sendCardReply(cdToken, chatId, "新会话工作路径", `路径不存在:\n\`${targetDir}\``, "red");
298
306
  return;
299
307
  }
@@ -310,6 +318,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
310
318
  try {
311
319
  entries = await readdir(targetDir);
312
320
  } catch (err) {
321
+ logTrace(tid, "DONE", { outcome: "cd_readdir_fail", error: (err as Error).message });
313
322
  await sendCardReply(cdToken, chatId, "新会话工作路径", `无法读取目录:\n\`${targetDir}\`\n\n${(err as Error).message}`, "red");
314
323
  return;
315
324
  }
@@ -341,10 +350,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
341
350
  });
342
351
  const respData: Record<string, any> = await resp.json().catch(() => ({}));
343
352
  console.log(`[${ts()}] [CD] card sent, code=${respData.code}, msgId=${respData.data?.message_id ?? "N/A"}, recentDirs=${recentDirs.length}`);
353
+ logTrace(tid, "DONE", { outcome: "cd_card", code: respData.code, msgId: respData.data?.message_id });
344
354
  } else {
345
355
  // /cd <path>:切换目录,发送文本卡片
346
356
  const content = buildCdContent(targetDir, withStats, isUpdate, sessionCwd);
347
357
  await sendCardReply(cdToken, chatId, "新会话工作路径", content, "blue");
358
+ logTrace(tid, "DONE", { outcome: "cd_path", targetDir, isUpdate });
348
359
  }
349
360
  return;
350
361
  }
@@ -352,8 +363,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
352
363
  if (text === "/new" || text.startsWith("/new ")) {
353
364
  const toolArg = text.slice(5).trim();
354
365
  const tool = toolArg || "claude";
366
+ logTrace(tid, "BRANCH", { cmd: "/new", tool });
355
367
  const validTools = ["claude", "cursor", "codex"];
356
368
  if (!validTools.includes(tool)) {
369
+ logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
357
370
  const warnToken = await getTenantAccessToken();
358
371
  await sendCardReply(warnToken, chatId, "Error", `未知的工具类型: "${toolArg}"。支持: claude (Claude Code), cursor (Cursor), codex (Codex)。`, "red");
359
372
  return;
@@ -361,6 +374,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
361
374
  const toolLabel = toolDisplayName(tool);
362
375
 
363
376
  if (!openId) {
377
+ logTrace(tid, "DONE", { outcome: "new_no_openid" });
364
378
  console.log(`[${ts()}] [WARN] Cannot get sender open_id`);
365
379
  const warnToken = await getTenantAccessToken();
366
380
  await sendCardReply(warnToken, chatId, "Error", "Cannot identify sender.", "red");
@@ -378,6 +392,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
378
392
  console.log(`[${ts()}] [STEP 1/4] ${toolLabel} session created: ${sessionId} → OK`);
379
393
  } catch (err) {
380
394
  console.error(`[${ts()}] [STEP 1/4] FAIL: ${(err as Error).message}`);
395
+ logTrace(tid, "DONE", { outcome: "new_session_fail", error: (err as Error).message });
381
396
  await sendCardReply(
382
397
  freshToken, chatId, "Error",
383
398
  `Failed to initialize ${toolLabel} session:\n${(err as Error).message}`,
@@ -395,6 +410,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
395
410
  console.log(`[${ts()}] [STEP 2/4] Created Feishu group: ${newChatId} → OK`);
396
411
  } catch (err) {
397
412
  console.error(`[${ts()}] [STEP 2/4] FAIL: ${(err as Error).message}`);
413
+ logTrace(tid, "DONE", { outcome: "new_group_fail", error: (err as Error).message });
398
414
  await sendCardReply(freshToken, chatId, "Error", `Failed to create group:\n${(err as Error).message}`, "red");
399
415
  return;
400
416
  }
@@ -405,6 +421,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
405
421
  console.log(`[${ts()}] [STEP 3/4] Renamed group → name="${initialName}" (${toolLabel}) → OK`);
406
422
  } catch (err) {
407
423
  console.error(`[${ts()}] [STEP 3/4] FAIL: ${(err as Error).message}`);
424
+ logTrace(tid, "DONE", { outcome: "new_rename_fail", error: (err as Error).message });
408
425
  await sendCardReply(freshToken, chatId, "Error", `Group created but rename failed:\n${(err as Error).message}`, "yellow");
409
426
  return;
410
427
  }
@@ -422,11 +439,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
422
439
  );
423
440
 
424
441
  console.log(`[${ts()}] [STEP 4/4] Replied to new group → OK`);
442
+ logTrace(tid, "DONE", { outcome: "session_ready", newChatId, sessionId, tool });
425
443
  setChatAvatar(freshToken, newChatId, tool, "new").catch(() => {});
426
444
  console.log(`${"=".repeat(60)}`);
427
445
  return;
428
446
  }
429
447
 
448
+ // 私聊没有群 description,无法存储/获取 session ID,跳过群聊检测直接发 help card
449
+ if (chatType !== "p2p") {
430
450
  try {
431
451
  const token = await getTenantAccessToken();
432
452
  const chatInfo = await getChatInfo(token, chatId);
@@ -437,6 +457,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
437
457
  const sessionId = sessionInfo.sessionId;
438
458
  const descriptionTool = sessionInfo.tool;
439
459
  const toolLabel = toolDisplayName(descriptionTool);
460
+ logTrace(tid, "BRANCH", { sessionId, tool: descriptionTool });
440
461
  console.log(`[${ts()}] [RESUME] ${toolLabel} session group detected, session=${sessionId} tool=${descriptionTool}`);
441
462
 
442
463
  const freshToken = await getTenantAccessToken();
@@ -457,6 +478,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
457
478
  }
458
479
 
459
480
  if (text === "/stop") {
481
+ logTrace(tid, "BRANCH", { cmd: "/stop" });
460
482
  const cEntry = chatSessionMap.get(chatId);
461
483
  if (cEntry) {
462
484
  cEntry.stopped = true;
@@ -464,13 +486,16 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
464
486
  cEntry.close();
465
487
  console.log(`[${ts()}] [STOP] User sent /stop, session=${sessionId}`);
466
488
  await sendTextReply(freshToken, chatId, "会话已停止。").catch(() => {});
489
+ logTrace(tid, "DONE", { outcome: "stopped" });
467
490
  } else {
468
491
  await sendTextReply(freshToken, chatId, "当前没有正在进行的会话。").catch(() => {});
492
+ logTrace(tid, "DONE", { outcome: "stop_no_session" });
469
493
  }
470
494
  return;
471
495
  }
472
496
 
473
497
  if (text === "/status") {
498
+ logTrace(tid, "BRANCH", { cmd: "/status" });
474
499
  const status = await getSessionStatus(chatId);
475
500
  const running = chatSessionMap.get(chatId);
476
501
  const isActive = running && !running.stopped;
@@ -507,10 +532,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
507
532
  });
508
533
  const statusRespData: Record<string, any> = await statusResp.json().catch(() => ({}));
509
534
  console.log(`[${ts()}] [STATUS] card sent, code=${statusRespData.code}, msgId=${statusRespData.data?.message_id ?? "N/A"}`);
535
+ logTrace(tid, "DONE", { outcome: "status", code: statusRespData.code });
510
536
  return;
511
537
  }
512
538
 
513
539
  if (text === "/sessions") {
540
+ logTrace(tid, "BRANCH", { cmd: "/sessions" });
514
541
  const allSessions = await getAllSessionsStatus();
515
542
  const now = Date.now();
516
543
  const cardData = allSessions.map(s => ({
@@ -532,10 +559,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
532
559
  });
533
560
  const sessionsRespData: Record<string, any> = await sessionsResp.json().catch(() => ({}));
534
561
  console.log(`[${ts()}] [SESSIONS] card sent, code=${sessionsRespData.code}, count=${allSessions.length}`);
562
+ logTrace(tid, "DONE", { outcome: "sessions", code: sessionsRespData.code, count: allSessions.length });
535
563
  return;
536
564
  }
537
565
 
538
566
  if (text === "/forget") {
567
+ logTrace(tid, "BRANCH", { cmd: "/forget" });
539
568
  const adapter = getAdapterForTool(descriptionTool);
540
569
  let cwd: string;
541
570
  try {
@@ -558,6 +587,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
558
587
  const init = await initClaudeSession(descriptionTool, cwd);
559
588
  newSessionId = init.sessionId;
560
589
  } catch (err) {
590
+ logTrace(tid, "DONE", { outcome: "forget_session_fail", error: (err as Error).message });
561
591
  await sendCardReply(freshToken, chatId, "Error", `Failed to create new session:\n${(err as Error).message}`, "red");
562
592
  return;
563
593
  }
@@ -587,6 +617,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
587
617
  );
588
618
 
589
619
  console.log(`[${ts()}] [FORGET] Session ${sessionId} → ${newSessionId} (same cwd=${cwd})`);
620
+ logTrace(tid, "DONE", { outcome: "forget", newSessionId, cwd });
590
621
  return;
591
622
  }
592
623
 
@@ -595,7 +626,9 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
595
626
  // getDefaultCwd(下一次 /new 才会使用的默认路径)。
596
627
  if (text.startsWith("/git ") || text === "/git") {
597
628
  const args = text === "/git" ? "" : text.slice(5).trim();
629
+ logTrace(tid, "BRANCH", { cmd: "/git", args: args || "(none)" });
598
630
  if (!args) {
631
+ logTrace(tid, "DONE", { outcome: "git_no_args" });
599
632
  await sendCardReply(
600
633
  freshToken, chatId, "/git",
601
634
  "用法:`/git <子命令> [参数]`,例如 `/git status`、`/git log --oneline -n 5`。",
@@ -613,6 +646,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
613
646
  console.error(`[${ts()}] [GIT] getSessionInfo FAIL: ${(err as Error).message}`);
614
647
  }
615
648
  if (!cwd) {
649
+ logTrace(tid, "DONE", { outcome: "git_no_cwd", tool: descriptionTool });
616
650
  // Cursor 会话的 cwd 依赖 state/cursor-session-meta.json 持久化映射;
617
651
  // 升级前创建的旧会话或映射文件丢失时,向会话发送一次普通消息即可触发
618
652
  // adapter 自动学习并补全(resume 流首条 init 事件携带 cwd)。
@@ -630,12 +664,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
630
664
  const content = formatGitResult(args, cwd, result);
631
665
  const template = gitResultHeaderTemplate(result);
632
666
  await sendCardReply(freshToken, chatId, "/git 输出", content, template);
667
+ logTrace(tid, "DONE", { outcome: "git_result", exitCode: result.exitCode, durationMs: result.durationMs });
633
668
  return;
634
669
  }
635
670
 
636
671
  const existing = chatSessionMap.get(chatId);
637
672
  if (existing && !existing.stopped) {
638
673
  if (msgTimestamp <= existing.msgTimestamp) {
674
+ logTrace(tid, "DONE", { outcome: "skip_old_message", msgTimestamp, existingTimestamp: existing.msgTimestamp });
639
675
  console.log(`[${ts()}] [SKIP] Older message (${msgTimestamp} <= ${existing.msgTimestamp}), ignoring`);
640
676
  return;
641
677
  }
@@ -644,6 +680,7 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
644
680
  existing.close();
645
681
  chatSessionMap.delete(chatId);
646
682
  console.log(`[${ts()}] [INTERRUPT] New message arrived, cancelled previous session ${sessionId}`);
683
+ logTrace(tid, "INTERRUPT", { oldSessionId: sessionId });
647
684
  if (existing.cardId) {
648
685
  while (existing.cardBusy) {
649
686
  await new Promise(r => setTimeout(r, 20));
@@ -662,9 +699,12 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
662
699
  }
663
700
 
664
701
  try {
665
- await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool);
702
+ logTrace(tid, "RESUME", { sessionId, tool: descriptionTool });
703
+ await resumeAndPrompt(sessionId, text, freshToken, chatId, msgTimestamp, descriptionTool, tid);
704
+ logTrace(tid, "DONE", { outcome: "resume_done", sessionId });
666
705
  console.log(`[${ts()}] [RESUME] Session ${sessionId} done`);
667
706
  } catch (err) {
707
+ logTrace(tid, "DONE", { outcome: "resume_fail", error: (err as Error).message });
668
708
  console.error(`[${ts()}] [RESUME] FAIL: ${(err as Error).message}`);
669
709
  fileLog.flush();
670
710
  await sendCardReply(
@@ -675,13 +715,19 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
675
715
  }
676
716
  return;
677
717
  }
718
+ // 群聊但 description 中没有 session info → 也走 help card
719
+ logTrace(tid, "BRANCH", { reason: "no_session_info_in_group" });
678
720
  } catch (err) {
721
+ logTrace(tid, "BRANCH", { reason: "get_chat_info_failed", error: (err as Error).message });
679
722
  console.log(`[${ts()}] [INFO] Cannot get chat info for ${chatId}: ${(err as Error).message}`);
680
723
  }
724
+ } // end if (chatType !== "p2p")
681
725
 
726
+ // 私聊或群聊无 session info → 发送 help card
727
+ logTrace(tid, "SEND", { method: "help_card", chatId });
682
728
  const replyToken = await getTenantAccessToken();
683
729
  const card = buildHelpCard(text);
684
- await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
730
+ const helpResp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
685
731
  method: "POST",
686
732
  headers: {
687
733
  Authorization: `Bearer ${replyToken}`,
@@ -689,6 +735,14 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
689
735
  },
690
736
  body: JSON.stringify({ receive_id: chatId, msg_type: "interactive", content: card }),
691
737
  });
738
+ const helpData = (await helpResp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
739
+ if (helpData.code !== 0) {
740
+ console.error(`[${ts()}] [SEND] help_card FAIL: chatId=${chatId} code=${helpData.code} msg="${helpData.msg ?? ""}"`);
741
+ logTrace(tid, "DONE", { outcome: "help_card_fail", code: helpData.code, msg: helpData.msg });
742
+ } else {
743
+ console.log(`[${ts()}] [SEND] help_card OK: chatId=${chatId} msgId=${helpData.data?.message_id ?? "N/A"}`);
744
+ logTrace(tid, "DONE", { outcome: "help_card_sent", msgId: helpData.data?.message_id });
745
+ }
692
746
  }
693
747
 
694
748
  // ---------------------------------------------------------------------------
@@ -779,6 +833,7 @@ async function startBotServiceCore(): Promise<void> {
779
833
  const eventDispatcher = new EventDispatcher({});
780
834
  eventDispatcher.register({
781
835
  "im.message.receive_v1": async (data: Evt) => {
836
+ const traceId = makeTraceId();
782
837
  try {
783
838
  broadcastToRelay(data);
784
839
 
@@ -803,8 +858,9 @@ async function startBotServiceCore(): Promise<void> {
803
858
  const sender = event.sender;
804
859
  const openId = sender?.sender_id?.open_id ?? "";
805
860
  const chatId = message.chat_id ?? "";
861
+ const chatType = message.chat_type ?? "group";
806
862
 
807
- console.log(`[MSG] sender=${openId} chat=${chatId} text="${text}"`);
863
+ console.log(`[MSG] sender=${openId} chat=${chatId} type=${chatType} text="${text}"`);
808
864
  appendChatLog(chatId, openId, text);
809
865
 
810
866
  if (messageId) {
@@ -819,13 +875,15 @@ async function startBotServiceCore(): Promise<void> {
819
875
 
820
876
  if (!text) return;
821
877
  const msgTimestamp = parseInt(message.create_time ?? "0", 10) || Date.now();
878
+ logTrace(traceId, "RECV", { chatId, chatType, text: text.slice(0, 100) });
822
879
  const delayNotice = formatDelayNotice(msgTimestamp);
823
880
  if (delayNotice) {
824
881
  const delayToken = await getTenantAccessToken();
825
882
  await sendCardReply(delayToken, chatId, "延迟送达", delayNotice, "yellow").catch(() => {});
826
883
  }
827
- await handleCommand(text, chatId, openId, msgTimestamp);
884
+ await handleCommand(text, chatId, openId, msgTimestamp, chatType, traceId);
828
885
  } catch (err) {
886
+ logTrace(traceId, "ERROR", { message: (err as Error).message });
829
887
  console.error(`[${ts()}] [FATAL] im.message.receive_v1 handler crashed: ${(err as Error).message}`);
830
888
  }
831
889
  },
@@ -990,6 +1048,7 @@ async function main(): Promise<void> {
990
1048
  appIdMask: maskAppId(APP_ID),
991
1049
  });
992
1050
  });
1051
+ setExtraApiHandler(handleAgentImageRequest);
993
1052
 
994
1053
  console.log(`[启动 2/7] 环境与凭证检查`);
995
1054
  reportEnvironmentVariableReadout();
package/src/session.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  CLAUDE_BASE_URL,
7
7
  CLAUDE_EFFORT,
8
8
  CLAUDE_MODEL,
9
+ CHATCCC_PORT,
9
10
  SESSIONS_FILE,
10
11
  addRecentDir,
11
12
  anthropicConfigDisplay,
@@ -23,11 +24,17 @@ import {
23
24
  updateCardKitCard,
24
25
  } from "./cardkit.ts";
25
26
  import { sendTextReply, setChatAvatar } from "./feishu-api.ts";
27
+ import { logTrace } from "./trace.ts";
26
28
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
27
29
  import type { ToolAdapter } from "./adapters/adapter-interface.ts";
28
30
  import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
29
31
  import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
30
32
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
33
+ import {
34
+ buildAgentImageCapabilityPrompt,
35
+ createAgentImageGrant,
36
+ revokeAgentImageGrant,
37
+ } from "./agent-image-rpc.ts";
31
38
 
32
39
  // ---------------------------------------------------------------------------
33
40
  // Shared state (imported by index.ts)
@@ -251,7 +258,7 @@ export function accumulateBlockContent(
251
258
  }
252
259
 
253
260
  // ---------------------------------------------------------------------------
254
- // AI tool session management
261
+ // AI tool session management
255
262
  // ---------------------------------------------------------------------------
256
263
 
257
264
  /**
@@ -300,13 +307,34 @@ export async function resumeAndPrompt(
300
307
  chatId: string,
301
308
  msgTimestamp: number,
302
309
  tool: string,
310
+ traceId?: string,
303
311
  ): Promise<void> {
312
+ const tid = traceId ?? "";
304
313
  const adapter = getAdapterForTool(tool);
305
314
  const info = await adapter.getSessionInfo(sessionId);
306
315
  const cwd = info?.cwd ?? (await getDefaultCwd());
316
+ if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(chatId)?.turnCount ?? 0) + 1 });
307
317
  console.log(
308
318
  `[${ts()}] Resuming ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
309
319
  );
320
+ const imageGrant = createAgentImageGrant({
321
+ chatId,
322
+ sessionId,
323
+ cwd,
324
+ port: CHATCCC_PORT,
325
+ traceId: tid || undefined,
326
+ });
327
+ const userTextWithCapabilities = [
328
+ buildAgentImageCapabilityPrompt({
329
+ url: imageGrant.url,
330
+ token: imageGrant.token,
331
+ cwd,
332
+ }),
333
+ "",
334
+ "[User message]",
335
+ userText,
336
+ "[/User message]",
337
+ ].join("\n");
310
338
 
311
339
  const controller = new AbortController();
312
340
 
@@ -338,6 +366,7 @@ export async function resumeAndPrompt(
338
366
 
339
367
  let cardId: string | null = null;
340
368
  cardId = await createCardKitCard(token, buildProgressCard("", { showStop: true, headerTitle: "生成中..." })).catch((err) => {
369
+ if (tid) logTrace(tid, "CARD_CREATE_FAIL", { error: (err as Error).message });
341
370
  console.error(`[${ts()}] [CARDIKT] createCard FAIL: chatId=${chatId} ${(err as Error).message}`);
342
371
  fileLog.flush();
343
372
  sendTextReply(token, chatId, "⚠️ 流式卡片创建失败(可能因限流),将使用文本回复。").catch(() => {});
@@ -347,6 +376,7 @@ export async function resumeAndPrompt(
347
376
  const cEntry = chatSessionMap.get(chatId);
348
377
  if (cEntry) { cEntry.cardId = cardId; cEntry.sequence = 1; }
349
378
  const sendOk = await sendCardKitMessage(token, chatId, cardId).catch((err) => {
379
+ if (tid) logTrace(tid, "CARD_SEND_FAIL", { cardId, error: (err as Error).message });
350
380
  console.error(`[${ts()}] [CARDIKT] sendMessage FAIL: chatId=${chatId} cardId=${cardId} ${(err as Error).message}`);
351
381
  fileLog.flush();
352
382
  return false;
@@ -445,7 +475,7 @@ export async function resumeAndPrompt(
445
475
  }
446
476
 
447
477
  try {
448
- for await (const unifiedMsg of adapter.prompt(sessionId, userText, cwd, controller.signal)) {
478
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
449
479
  for (const block of unifiedMsg.blocks) {
450
480
  accumulateBlockContent(block, state);
451
481
 
@@ -460,6 +490,7 @@ export async function resumeAndPrompt(
460
490
  console.error(`[${ts()}] [STREAM] Error in stream loop: ${(streamErr as Error).message}`);
461
491
  } finally {
462
492
  if (sendInterval) clearInterval(sendInterval);
493
+ revokeAgentImageGrant(imageGrant.token);
463
494
  }
464
495
 
465
496
  const cEntry = chatSessionMap.get(chatId);
@@ -501,6 +532,7 @@ export async function resumeAndPrompt(
501
532
  );
502
533
  }
503
534
  console.log(`[${ts()}] Session ${sessionId} stopped by user (content chunks: ${state.chunkCount})`);
535
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
504
536
  return;
505
537
  }
506
538
 
@@ -530,6 +562,7 @@ export async function resumeAndPrompt(
530
562
  }
531
563
 
532
564
  console.log(`[${ts()}] Session ${sessionId} stream complete (content chunks: ${state.chunkCount})`);
565
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, chunks: state.chunkCount, finalTextLen: finalReply.length });
533
566
  }
534
567
 
535
568
  // ---------------------------------------------------------------------------
package/src/trace.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * 轻量级消息全链路 trace 工具。
3
+ *
4
+ * 每条消息生成唯一 traceId,在关键决策点、API 调用、消息发送时输出结构化日志。
5
+ * grep traceId 即可看到该消息的完整 RECV → BRANCH → SEND → DONE 链路。
6
+ *
7
+ * 设计原则:trace 自身绝不能抛异常导致主流程中断——所有函数内都有 try/catch 兜底。
8
+ */
9
+
10
+ let _traceSeq = 0;
11
+
12
+ /** 基于时间戳 + 递增序号生成短 trace ID(约 10 字符) */
13
+ export function makeTraceId(): string {
14
+ try {
15
+ _traceSeq++;
16
+ const ts = Date.now().toString(36).slice(-6);
17
+ const seq = _traceSeq.toString(36).padStart(3, "0");
18
+ return `${ts}${seq}`;
19
+ } catch {
20
+ // 极端情况(计数器溢出等)降级为纯时间戳
21
+ return Date.now().toString(36);
22
+ }
23
+ }
24
+
25
+ /**
26
+ * 输出一条 trace 日志。
27
+ * detail 中的值做浅层截断(字符串 > 200 字符会截断),避免日志膨胀。
28
+ */
29
+ export function logTrace(traceId: string, step: string, detail?: Record<string, unknown>): void {
30
+ try {
31
+ const safe: Record<string, unknown> = {};
32
+ if (detail) {
33
+ for (const [k, v] of Object.entries(detail)) {
34
+ if (typeof v === "string" && v.length > 200) {
35
+ safe[k] = v.slice(0, 200) + "...(truncated)";
36
+ } else {
37
+ safe[k] = v;
38
+ }
39
+ }
40
+ }
41
+ const extra = Object.keys(safe).length > 0 ? " " + JSON.stringify(safe) : "";
42
+ console.log(`[TRACE ${traceId}] ${step}${extra}`);
43
+ } catch {
44
+ // trace 自身绝不能抛异常
45
+ }
46
+ }
47
+
48
+ /** 重置序号(仅单测使用,确保 trace ID 可预测) */
49
+ export function _resetTraceSeqForTest(): void {
50
+ _traceSeq = 0;
51
+ }
package/src/web-ui.ts CHANGED
@@ -1570,6 +1570,8 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
1570
1570
  const url = req.url ?? "/";
1571
1571
  const method = req.method ?? "GET";
1572
1572
 
1573
+ if (extraApiHandler && await extraApiHandler(req, res)) return;
1574
+
1573
1575
  // API routes
1574
1576
  if (url === "/api/check" && method === "GET") return handleApiCheck(req, res);
1575
1577
  if (url === "/api/config" && method === "GET") return handleGetConfig(req, res);
@@ -1662,6 +1664,8 @@ let setupActivateHook: SetupActivateHook | null = null;
1662
1664
  // 由 index.ts 注入,因为 web-ui.ts 自身**不应**直接 import config.ts——后者顶层
1663
1665
  // 有 loadConfig 副作用,被 web-ui.ts 间接 import 会污染所有依赖 web-ui.ts 的单测。
1664
1666
  let reloadConfigHook: (() => void) | null = null;
1667
+ type ExtraApiHandler = (req: IncomingMessage, res: ServerResponse) => Promise<boolean> | boolean;
1668
+ let extraApiHandler: ExtraApiHandler | null = null;
1665
1669
 
1666
1670
  /**
1667
1671
  * 注册"reload config"回调。约定:
@@ -1673,6 +1677,10 @@ export function setReloadConfigHook(hook: () => void | Promise<void>): void {
1673
1677
  reloadConfigHook = hook;
1674
1678
  }
1675
1679
 
1680
+ export function setExtraApiHandler(handler: ExtraApiHandler): void {
1681
+ extraApiHandler = handler;
1682
+ }
1683
+
1676
1684
  export function startSetupMode(port: number, options: StartSetupModeOptions = {}): void {
1677
1685
  const router = createUiRouter();
1678
1686
  const server = createServer(router);