chatccc 0.2.192 → 0.2.194

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.
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
 
@@ -12,14 +12,27 @@ export interface BuiltinContextMessage {
12
12
 
13
13
  export interface BuiltinContextState {
14
14
  version: 1;
15
+ createdAt: number;
15
16
  updatedAt: number;
16
17
  sessionId: string;
18
+ cwd?: string;
17
19
  summary: string;
18
20
  messages: BuiltinContextMessage[];
19
21
  totalMessages: number;
20
22
  compactedMessages: number;
21
23
  }
22
24
 
25
+ export interface BuiltinContextSessionInfo {
26
+ sessionId: string;
27
+ createdAt: number;
28
+ updatedAt: number;
29
+ cwd?: string;
30
+ totalMessages: number;
31
+ compactedMessages: number;
32
+ hasSummary: boolean;
33
+ contextFilePath: string;
34
+ }
35
+
23
36
  export interface BuiltinCompactionPlan {
24
37
  previousSummary: string;
25
38
  oldMessages: BuiltinContextMessage[];
@@ -30,6 +43,7 @@ export interface BuiltinContextOptions {
30
43
  persist?: boolean;
31
44
  contextDir?: string;
32
45
  sessionId?: string;
46
+ cwd?: string;
33
47
  compactAtTokens?: number;
34
48
  keepRecentMessages?: number;
35
49
  }
@@ -38,7 +52,7 @@ export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".chatccc", "builtin"
38
52
  export const DEFAULT_COMPACT_AT_TOKENS = 48_000;
39
53
  export const DEFAULT_KEEP_RECENT_MESSAGES = 16;
40
54
 
41
- function sanitizeSessionId(value: string): string {
55
+ export function normalizeBuiltinSessionId(value: string): string {
42
56
  return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "default";
43
57
  }
44
58
 
@@ -47,6 +61,23 @@ export function defaultBuiltinSessionId(cwd: string = process.cwd()): string {
47
61
  return `cwd-${hash}`;
48
62
  }
49
63
 
64
+ function pad(value: number): string {
65
+ return String(value).padStart(2, "0");
66
+ }
67
+
68
+ export function newBuiltinSessionId(now: Date = new Date(), suffix: string = randomBytes(3).toString("hex")): string {
69
+ const timestamp = [
70
+ now.getFullYear(),
71
+ pad(now.getMonth() + 1),
72
+ pad(now.getDate()),
73
+ "-",
74
+ pad(now.getHours()),
75
+ pad(now.getMinutes()),
76
+ pad(now.getSeconds()),
77
+ ].join("");
78
+ return normalizeBuiltinSessionId(`session-${timestamp}-${suffix}`);
79
+ }
80
+
50
81
  function normalizeMessage(value: unknown): BuiltinContextMessage | null {
51
82
  if (!value || typeof value !== "object") return null;
52
83
  const raw = value as { role?: unknown; content?: unknown };
@@ -55,11 +86,14 @@ function normalizeMessage(value: unknown): BuiltinContextMessage | null {
55
86
  return { role: raw.role, content: raw.content };
56
87
  }
57
88
 
58
- function emptyState(sessionId: string): BuiltinContextState {
89
+ function emptyState(sessionId: string, cwd?: string): BuiltinContextState {
90
+ const now = Date.now();
59
91
  return {
60
92
  version: 1,
61
- updatedAt: Date.now(),
93
+ createdAt: now,
94
+ updatedAt: now,
62
95
  sessionId,
96
+ ...(cwd ? { cwd } : {}),
63
97
  summary: "",
64
98
  messages: [],
65
99
  totalMessages: 0,
@@ -67,17 +101,20 @@ function emptyState(sessionId: string): BuiltinContextState {
67
101
  };
68
102
  }
69
103
 
70
- function normalizeState(value: unknown, sessionId: string): BuiltinContextState {
71
- if (!value || typeof value !== "object") return emptyState(sessionId);
104
+ function normalizeState(value: unknown, sessionId: string, cwd?: string): BuiltinContextState {
105
+ if (!value || typeof value !== "object") return emptyState(sessionId, cwd);
72
106
  const raw = value as Partial<BuiltinContextState>;
73
107
  const messages = Array.isArray(raw.messages)
74
108
  ? raw.messages.map(normalizeMessage).filter((m): m is BuiltinContextMessage => !!m)
75
109
  : [];
110
+ const updatedAt = typeof raw.updatedAt === "number" ? raw.updatedAt : Date.now();
76
111
 
77
112
  return {
78
113
  version: 1,
79
- updatedAt: typeof raw.updatedAt === "number" ? raw.updatedAt : Date.now(),
114
+ createdAt: typeof raw.createdAt === "number" ? raw.createdAt : updatedAt,
115
+ updatedAt,
80
116
  sessionId,
117
+ ...(typeof raw.cwd === "string" ? { cwd: raw.cwd } : cwd ? { cwd } : {}),
81
118
  summary: typeof raw.summary === "string" ? raw.summary : "",
82
119
  messages,
83
120
  totalMessages: typeof raw.totalMessages === "number" ? raw.totalMessages : messages.length,
@@ -85,6 +122,65 @@ function normalizeState(value: unknown, sessionId: string): BuiltinContextState
85
122
  };
86
123
  }
87
124
 
125
+ function contextFilePath(contextDir: string, sessionId: string): string {
126
+ return join(contextDir, sessionId, "context.json");
127
+ }
128
+
129
+ function readSessionInfo(contextDir: string, sessionId: string): BuiltinContextSessionInfo | null {
130
+ const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
131
+ const filePath = contextFilePath(contextDir, normalizedSessionId);
132
+ if (!existsSync(filePath)) return null;
133
+ try {
134
+ const raw = readFileSync(filePath, "utf8");
135
+ const state = normalizeState(JSON.parse(raw), normalizedSessionId);
136
+ return {
137
+ sessionId: normalizedSessionId,
138
+ createdAt: state.createdAt,
139
+ updatedAt: state.updatedAt,
140
+ ...(state.cwd ? { cwd: state.cwd } : {}),
141
+ totalMessages: state.totalMessages,
142
+ compactedMessages: state.compactedMessages,
143
+ hasSummary: state.summary.trim().length > 0,
144
+ contextFilePath: filePath,
145
+ };
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ export function getBuiltinContextSession(
152
+ sessionId: string,
153
+ contextDir: string = DEFAULT_BUILTIN_CONTEXT_DIR,
154
+ ): BuiltinContextSessionInfo | null {
155
+ return readSessionInfo(contextDir, sessionId);
156
+ }
157
+
158
+ export function listBuiltinContextSessions(
159
+ contextDir: string = DEFAULT_BUILTIN_CONTEXT_DIR,
160
+ ): BuiltinContextSessionInfo[] {
161
+ if (!existsSync(contextDir)) return [];
162
+ const sessions: BuiltinContextSessionInfo[] = [];
163
+ for (const entry of readdirSync(contextDir, { withFileTypes: true })) {
164
+ if (!entry.isDirectory()) continue;
165
+ const info = readSessionInfo(contextDir, entry.name);
166
+ if (info) sessions.push(info);
167
+ }
168
+ return sessions.sort((a, b) => {
169
+ if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt;
170
+ return a.sessionId.localeCompare(b.sessionId);
171
+ });
172
+ }
173
+
174
+ export function latestBuiltinSessionForCwd(
175
+ cwd: string,
176
+ contextDir: string = DEFAULT_BUILTIN_CONTEXT_DIR,
177
+ ): BuiltinContextSessionInfo | null {
178
+ const legacySessionId = defaultBuiltinSessionId(cwd);
179
+ return listBuiltinContextSessions(contextDir).find((session) =>
180
+ session.cwd === cwd || session.sessionId === legacySessionId
181
+ ) ?? null;
182
+ }
183
+
88
184
  export function estimateBuiltinContextTokens(summary: string, messages: readonly BuiltinContextMessage[]): number {
89
185
  const chars = summary.length + messages.reduce((sum, m) => sum + m.role.length + m.content.length, 0);
90
186
  return Math.ceil(chars / 3);
@@ -123,12 +219,14 @@ export class BuiltinContextManager {
123
219
  readonly compactAtTokens: number;
124
220
  readonly keepRecentMessages: number;
125
221
 
222
+ private readonly cwd?: string;
126
223
  private state: BuiltinContextState;
127
224
 
128
225
  constructor(options: BuiltinContextOptions = {}) {
129
226
  this.persist = options.persist ?? false;
130
227
  this.contextDir = options.contextDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
131
- this.sessionId = sanitizeSessionId(options.sessionId ?? defaultBuiltinSessionId());
228
+ this.sessionId = normalizeBuiltinSessionId(options.sessionId ?? defaultBuiltinSessionId());
229
+ this.cwd = options.cwd;
132
230
  this.compactAtTokens = options.compactAtTokens ?? DEFAULT_COMPACT_AT_TOKENS;
133
231
  this.keepRecentMessages = Math.max(1, options.keepRecentMessages ?? DEFAULT_KEEP_RECENT_MESSAGES);
134
232
  this.state = this.load();
@@ -147,7 +245,7 @@ export class BuiltinContextManager {
147
245
  }
148
246
 
149
247
  get contextFilePath(): string {
150
- return join(this.contextDir, this.sessionId, "context.json");
248
+ return contextFilePath(this.contextDir, this.sessionId);
151
249
  }
152
250
 
153
251
  appendMessage(message: BuiltinContextMessage): void {
@@ -199,7 +297,7 @@ export class BuiltinContextManager {
199
297
  }
200
298
 
201
299
  reset(): void {
202
- this.state = emptyState(this.sessionId);
300
+ this.state = emptyState(this.sessionId, this.cwd);
203
301
  this.save();
204
302
  }
205
303
 
@@ -214,12 +312,12 @@ export class BuiltinContextManager {
214
312
  }
215
313
 
216
314
  private load(): BuiltinContextState {
217
- if (!this.persist || !existsSync(this.contextFilePath)) return emptyState(this.sessionId);
315
+ if (!this.persist || !existsSync(this.contextFilePath)) return emptyState(this.sessionId, this.cwd);
218
316
  try {
219
317
  const raw = readFileSync(this.contextFilePath, "utf8");
220
- return normalizeState(JSON.parse(raw), this.sessionId);
318
+ return normalizeState(JSON.parse(raw), this.sessionId, this.cwd);
221
319
  } catch {
222
- return emptyState(this.sessionId);
320
+ return emptyState(this.sessionId, this.cwd);
223
321
  }
224
322
  }
225
323
  }
@@ -126,6 +126,7 @@ export class ChatSession {
126
126
  persist: options.persist ?? false,
127
127
  contextDir: options.contextDir,
128
128
  sessionId: options.sessionId ?? defaultBuiltinSessionId(options.cwd ?? process.cwd()),
129
+ cwd: options.cwd ?? process.cwd(),
129
130
  compactAtTokens: options.compactAtTokens,
130
131
  keepRecentMessages: options.keepRecentMessages,
131
132
  });
@@ -0,0 +1,48 @@
1
+ import {
2
+ DEFAULT_BUILTIN_CONTEXT_DIR,
3
+ getBuiltinContextSession,
4
+ latestBuiltinSessionForCwd,
5
+ newBuiltinSessionId,
6
+ normalizeBuiltinSessionId,
7
+ } from "./context.js";
8
+
9
+ export type BuiltinResumeRequest = true | string | undefined;
10
+
11
+ export interface ResolveBuiltinSessionOptions {
12
+ cwd: string;
13
+ contextDir?: string;
14
+ resume?: BuiltinResumeRequest;
15
+ now?: Date;
16
+ randomSuffix?: string;
17
+ }
18
+
19
+ export interface ResolvedBuiltinSession {
20
+ mode: "new" | "resumed";
21
+ sessionId: string;
22
+ }
23
+
24
+ export function resolveBuiltinSession(options: ResolveBuiltinSessionOptions): ResolvedBuiltinSession {
25
+ const contextDir = options.contextDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
26
+
27
+ if (options.resume === true) {
28
+ const latest = latestBuiltinSessionForCwd(options.cwd, contextDir);
29
+ if (!latest) {
30
+ throw new Error(`未找到当前目录可恢复的 ccc 会话: ${options.cwd}`);
31
+ }
32
+ return { mode: "resumed", sessionId: latest.sessionId };
33
+ }
34
+
35
+ if (typeof options.resume === "string") {
36
+ const sessionId = normalizeBuiltinSessionId(options.resume);
37
+ const existing = getBuiltinContextSession(sessionId, contextDir);
38
+ if (!existing) {
39
+ throw new Error(`未找到 ccc 会话: ${sessionId}`);
40
+ }
41
+ return { mode: "resumed", sessionId };
42
+ }
43
+
44
+ return {
45
+ mode: "new",
46
+ sessionId: newBuiltinSessionId(options.now, options.randomSuffix),
47
+ };
48
+ }
package/src/cards.ts CHANGED
@@ -274,8 +274,48 @@ export function buildCdCard(
274
274
  });
275
275
  }
276
276
 
277
- // 所有会话列表卡片(Claude Code 优先,然后 Cursor)
278
- export function buildSessionsCard(sessions: Array<{
277
+ function sessionToolLabel(tool: string): string {
278
+ if (tool === "cursor") return "Cursor";
279
+ if (tool === "codex") return "Codex";
280
+ if (tool === "ccc") return "CCC Agent";
281
+ return "Claude Code";
282
+ }
283
+
284
+ function pushSessionGroup(
285
+ lines: string[],
286
+ title: string,
287
+ sessions: Array<{
288
+ sessionId: string;
289
+ chatName: string;
290
+ chatId: string;
291
+ active: boolean;
292
+ turnCount: number;
293
+ elapsedSeconds: number | null;
294
+ model: string;
295
+ tool: string;
296
+ }>,
297
+ formatSession: (session: {
298
+ sessionId: string;
299
+ chatName: string;
300
+ chatId: string;
301
+ active: boolean;
302
+ turnCount: number;
303
+ elapsedSeconds: number | null;
304
+ model: string;
305
+ tool: string;
306
+ }, index: number) => string,
307
+ index: { value: number },
308
+ ): void {
309
+ if (sessions.length === 0) return;
310
+ if (index.value > 0) lines.push("", `**${title}:**`, "");
311
+ else lines.push(`**${title}:**`, "");
312
+ for (const session of sessions) {
313
+ lines.push(formatSession(session, index.value++));
314
+ }
315
+ }
316
+
317
+ // 所有会话列表卡片(Claude Code 优先,然后 Cursor)
318
+ export function buildSessionsCard(sessions: Array<{
279
319
  sessionId: string;
280
320
  chatName: string;
281
321
  chatId: string;
@@ -285,14 +325,16 @@ export function buildSessionsCard(sessions: Array<{
285
325
  model: string;
286
326
  tool: string;
287
327
  }>, opts: { defaultToolLabel?: string } = {}): string {
288
- const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
289
- // 按 tool 分组排序:Claude Code 在前,Cursor 其次,Codex 最后
290
- const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor" && s.tool !== "codex");
291
- const cursorSessions = sessions.filter(s => s.tool === "cursor");
292
- const codexSessions = sessions.filter(s => s.tool === "codex");
293
- const hasClaudeCode = claudeCodeSessions.length > 0;
294
- const hasCursor = cursorSessions.length > 0;
295
- const hasCodex = codexSessions.length > 0;
328
+ const defaultToolLabel = opts.defaultToolLabel ?? "Claude Code";
329
+ // 按 tool 分组排序:Claude Code 在前,Cursor 其次,Codex 最后
330
+ const claudeCodeSessions = sessions.filter(s => s.tool !== "cursor" && s.tool !== "codex" && s.tool !== "ccc");
331
+ const cursorSessions = sessions.filter(s => s.tool === "cursor");
332
+ const codexSessions = sessions.filter(s => s.tool === "codex");
333
+ const cccSessions = sessions.filter(s => s.tool === "ccc");
334
+ const hasClaudeCode = claudeCodeSessions.length > 0;
335
+ const hasCursor = cursorSessions.length > 0;
336
+ const hasCodex = codexSessions.length > 0;
337
+ const hasCcc = cccSessions.length > 0;
296
338
 
297
339
  if (sessions.length === 0) {
298
340
  return JSON.stringify({
@@ -315,37 +357,18 @@ export function buildSessionsCard(sessions: Array<{
315
357
  const secs = s.elapsedSeconds % 60;
316
358
  extra = ` | 本轮: ${mins}分${secs}秒`;
317
359
  }
318
- const toolLabel = s.tool === "cursor" ? "Cursor" : s.tool === "codex" ? "Codex" : "Claude Code";
360
+ const toolLabel = sessionToolLabel(s.tool);
319
361
  const namePart = s.chatName ? `**${s.chatName}** ` : "";
320
362
  const chatTag = !s.chatId ? " (chat id缺失)" : s.chatId.startsWith("oc_") ? " (群聊)" : "";
321
363
  return `**${i + 1}.** ${namePart}${chatTag} \`${shortId}\` ${status} | 工具: ${toolLabel} | 轮数: ${s.turnCount} | ${s.model}${extra}`;
322
364
  };
323
365
 
324
366
  const lines: string[] = [`共 **${sessions.length}** 个会话:`, ""];
325
- let idx = 0;
326
-
327
- if (hasClaudeCode) {
328
- lines.push("**Claude Code 会话:**", "");
329
- for (const s of claudeCodeSessions) {
330
- lines.push(formatSession(s, idx++));
331
- }
332
- }
333
-
334
- if (hasCursor) {
335
- if (hasClaudeCode) lines.push("", "**Cursor 会话:**", "");
336
- else lines.push("**Cursor 会话:**", "");
337
- for (const s of cursorSessions) {
338
- lines.push(formatSession(s, idx++));
339
- }
340
- }
341
-
342
- if (hasCodex) {
343
- if (hasClaudeCode || hasCursor) lines.push("", "**Codex 会话:**", "");
344
- else lines.push("**Codex 会话:**", "");
345
- for (const s of codexSessions) {
346
- lines.push(formatSession(s, idx++));
347
- }
348
- }
367
+ const idx = { value: 0 };
368
+ if (hasClaudeCode) pushSessionGroup(lines, "Claude Code 会话", claudeCodeSessions, formatSession, idx);
369
+ if (hasCursor) pushSessionGroup(lines, "Cursor 会话", cursorSessions, formatSession, idx);
370
+ if (hasCodex) pushSessionGroup(lines, "Codex 会话", codexSessions, formatSession, idx);
371
+ if (hasCcc) pushSessionGroup(lines, "CCC Agent 会话", cccSessions, formatSession, idx);
349
372
 
350
373
  return JSON.stringify({
351
374
  config: { wide_screen_mode: true },
package/src/config.ts CHANGED
@@ -169,7 +169,7 @@ export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
169
169
  export type CursorAvatarBatteryMode = "apiPercent" | "onDemandUse";
170
170
 
171
171
  /** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
172
- export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
172
+ export function getAllModelsForTool(tool: string, cfg: AppConfig = config): string[] {
173
173
  const seen = new Set<string>();
174
174
  const collect = (v: unknown) => {
175
175
  if (typeof v === "string" && v.trim()) seen.add(v.trim());
@@ -181,10 +181,12 @@ export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): s
181
181
  } else if (tool === "cursor") {
182
182
  collect(cfg.cursor.model);
183
183
  collect(cfg.cursor.alternativeModel);
184
- } else if (tool === "codex") {
185
- collect(cfg.codex.model);
186
- collect(cfg.codex.alternativeModel);
187
- }
184
+ } else if (tool === "codex") {
185
+ collect(cfg.codex.model);
186
+ collect(cfg.codex.alternativeModel);
187
+ } else if (tool === "ccc") {
188
+ collect(cfg.ccc.model);
189
+ }
188
190
 
189
191
  return Array.from(seen).slice(0, 100);
190
192
  }
@@ -192,7 +194,7 @@ export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): s
192
194
  export const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
193
195
  export const CODEX_EFFORT_LEVELS = ["minimal", "low", "medium", "high", "xhigh"] as const;
194
196
 
195
- export function getAllEffortsForTool(tool: AgentTool): string[] {
197
+ export function getAllEffortsForTool(tool: string): string[] {
196
198
  if (tool === "claude") return [...CLAUDE_EFFORT_LEVELS];
197
199
  if (tool === "codex") return [...CODEX_EFFORT_LEVELS];
198
200
  return [];
@@ -962,22 +964,26 @@ export function explainMissingFeishuCredentialsAndExit(): never {
962
964
  export const CLAUDE_SESSION_PREFIX = "Claude Code Session:";
963
965
  /** 群描述中用于识别 Cursor 会话的前缀 */
964
966
  export const CURSOR_SESSION_PREFIX = "Cursor Session:";
965
- /** 群描述中用于识别 Codex 会话的前缀 */
966
- export const CODEX_SESSION_PREFIX = "Codex Session:";
967
+ /** 群描述中用于识别 Codex 会话的前缀 */
968
+ export const CODEX_SESSION_PREFIX = "Codex Session:";
969
+ /** 群描述中用于识别 hidden ccc agent 会话的前缀 */
970
+ export const CCC_SESSION_PREFIX = "CCC Session:";
967
971
 
968
972
  /** 根据 tool 名称返回对应的群描述前缀 */
969
- export function sessionPrefixForTool(tool: string): string {
970
- if (tool === "cursor") return CURSOR_SESSION_PREFIX;
971
- if (tool === "codex") return CODEX_SESSION_PREFIX;
972
- return CLAUDE_SESSION_PREFIX;
973
- }
973
+ export function sessionPrefixForTool(tool: string): string {
974
+ if (tool === "cursor") return CURSOR_SESSION_PREFIX;
975
+ if (tool === "codex") return CODEX_SESSION_PREFIX;
976
+ if (tool === "ccc") return CCC_SESSION_PREFIX;
977
+ return CLAUDE_SESSION_PREFIX;
978
+ }
974
979
 
975
980
  /** 根据 tool 名称返回用于状态展示的标签 */
976
- export function toolDisplayName(tool: string): string {
977
- if (tool === "cursor") return "Cursor";
978
- if (tool === "codex") return "Codex";
979
- return "Claude Code";
980
- }
981
+ export function toolDisplayName(tool: string): string {
982
+ if (tool === "cursor") return "Cursor";
983
+ if (tool === "codex") return "Codex";
984
+ if (tool === "ccc") return "CCC Agent";
985
+ return "Claude Code";
986
+ }
981
987
 
982
988
  /** 解析 /new 未指定工具时使用的默认 Agent。旧配置缺省 defaultAgent 时保持 Claude 优先。 */
983
989
  export function resolveDefaultAgentTool(cfg: AppConfig = config): AgentTool {
package/src/feishu-api.ts CHANGED
@@ -11,9 +11,10 @@ import {
11
11
  CHAT_LOGS_DIR,
12
12
  PROJECT_ROOT,
13
13
  USER_DATA_DIR,
14
- CLAUDE_SESSION_PREFIX,
15
- CURSOR_SESSION_PREFIX,
16
- CODEX_SESSION_PREFIX,
14
+ CLAUDE_SESSION_PREFIX,
15
+ CURSOR_SESSION_PREFIX,
16
+ CODEX_SESSION_PREFIX,
17
+ CCC_SESSION_PREFIX,
17
18
  ts,
18
19
  resolveDefaultAgentTool,
19
20
  toolDisplayName,
@@ -276,15 +277,16 @@ export async function disbandChat(
276
277
 
277
278
  export function extractSessionInfo(description: string): { sessionId: string; tool: string } | null {
278
279
  const PREFIXES: Array<{ prefix: string; tool: string }> = [
279
- { prefix: CLAUDE_SESSION_PREFIX, tool: "claude" },
280
- { prefix: CURSOR_SESSION_PREFIX, tool: "cursor" },
281
- { prefix: CODEX_SESSION_PREFIX, tool: "codex" },
282
- ];
283
- for (const { prefix, tool } of PREFIXES) {
284
- const idx = description.indexOf(prefix);
285
- if (idx === -1) continue;
286
- const after = description.slice(idx + prefix.length).trim();
287
- const match = after.match(/^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i);
280
+ { prefix: CLAUDE_SESSION_PREFIX, tool: "claude" },
281
+ { prefix: CURSOR_SESSION_PREFIX, tool: "cursor" },
282
+ { prefix: CODEX_SESSION_PREFIX, tool: "codex" },
283
+ { prefix: CCC_SESSION_PREFIX, tool: "ccc" },
284
+ ];
285
+ for (const { prefix, tool } of PREFIXES) {
286
+ const idx = description.indexOf(prefix);
287
+ if (idx === -1) continue;
288
+ const after = description.slice(idx + prefix.length).trim();
289
+ const match = after.match(/^([a-zA-Z0-9_.-]+)/);
288
290
  if (match) return { sessionId: match[1], tool };
289
291
  }
290
292
  return null;
@@ -317,11 +319,12 @@ const AVATAR_BADGES: Record<string, string> = {
317
319
  cursor: resolvePath(AVATAR_BADGE_DIR, "badge_cursor.png"),
318
320
  codex: resolvePath(AVATAR_BADGE_DIR, "badge_codex.png"),
319
321
  };
320
- const AVATAR_SIZE = 256;
321
- const AVATAR_BADGE_SIZE = 92;
322
- const AVATAR_BADGE_MARGIN = 10;
323
- const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v13";
324
- const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
322
+ const AVATAR_SIZE = 256;
323
+ const AVATAR_BADGE_SIZE = 92;
324
+ const AVATAR_BADGE_MARGIN = 10;
325
+ const PLAIN_AVATAR_TOOL = "plain";
326
+ const CODEX_AVATAR_USAGE_STYLE_VERSION = "usage-ring-gray-consumed-v13";
327
+ const CURSOR_AVATAR_USAGE_STYLE_VERSION = "usage-battery-v1";
325
328
 
326
329
  export interface CodexUsageBalance {
327
330
  usedPercent: number;
@@ -352,17 +355,17 @@ export interface CodexResetConsumeResult {
352
355
  const avatarKeyCache = new Map<string, string>();
353
356
  let avatarKeyCacheLoaded = false;
354
357
 
355
- function normalizeAvatarTool(tool: string): string {
356
- return AVATAR_BADGES[tool] ? tool : "claude";
357
- }
358
+ function normalizeAvatarTool(tool: string): string {
359
+ return AVATAR_BADGES[tool] ? tool : PLAIN_AVATAR_TOOL;
360
+ }
358
361
 
359
362
  function normalizeAvatarStatus(status: string): string {
360
363
  return AVATAR_SOURCES[status] ? status : "idle";
361
364
  }
362
365
 
363
- function avatarCombinationPath(tool: string, status: string): string {
364
- return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
365
- }
366
+ function avatarCombinationPath(tool: string, status: string): string {
367
+ return resolvePath(AVATAR_COMBINATIONS_DIR, `avatar_${normalizeAvatarTool(tool)}_${normalizeAvatarStatus(status)}.png`);
368
+ }
366
369
 
367
370
  function avatarCacheKey(
368
371
  tool: string,
@@ -755,16 +758,19 @@ async function renderAvatar(
755
758
  codexUsage: CodexUsageSummary | null = null,
756
759
  cursorBatteryPercent: number | null = null,
757
760
  ): Promise<{ buffer: Buffer; contentType: string; filename: string }> {
758
- const normalizedTool = normalizeAvatarTool(tool);
759
- const normalizedStatus = normalizeAvatarStatus(status);
760
- const composites: sharp.OverlayOptions[] = [];
761
-
762
- const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly : null;
763
- const useDynamicCodexAvatar = codexUsage && codexWeeklyUsage;
764
- const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
765
- const basePath = useDynamicCodexAvatar || useDynamicCursorAvatar
766
- ? AVATAR_SOURCES[normalizedStatus]
767
- : avatarCombinationPath(normalizedTool, normalizedStatus);
761
+ const normalizedTool = normalizeAvatarTool(tool);
762
+ const normalizedStatus = normalizeAvatarStatus(status);
763
+ const composites: sharp.OverlayOptions[] = [];
764
+ const hasAgentBadge = normalizedTool !== PLAIN_AVATAR_TOOL;
765
+
766
+ const codexWeeklyUsage = normalizedTool === "codex" ? codexUsage?.weekly : null;
767
+ const useDynamicCodexAvatar = codexUsage && codexWeeklyUsage;
768
+ const useDynamicCursorAvatar = normalizedTool === "cursor" && cursorBatteryPercent !== null;
769
+ const basePath = useDynamicCodexAvatar || useDynamicCursorAvatar
770
+ ? AVATAR_SOURCES[normalizedStatus]
771
+ : hasAgentBadge
772
+ ? avatarCombinationPath(normalizedTool, normalizedStatus)
773
+ : AVATAR_SOURCES[normalizedStatus];
768
774
 
769
775
  if (useDynamicCodexAvatar) {
770
776
  composites.push(
@@ -791,16 +797,16 @@ async function renderAvatar(
791
797
  .jpeg({ quality: 95, progressive: false })
792
798
  .toBuffer();
793
799
 
794
- return {
795
- buffer: jpeg,
796
- contentType: "image/jpeg",
797
- filename: codexUsage?.weekly
798
- ? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
799
- : cursorBatteryPercent !== null
800
- ? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
801
- : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
802
- };
803
- }
800
+ return {
801
+ buffer: jpeg,
802
+ contentType: "image/jpeg",
803
+ filename: normalizedTool === "codex" && codexUsage?.weekly
804
+ ? `avatar_${normalizedTool}_${normalizedStatus}_week_${codexUsage.weekly.remainingPercent}_5h_${codexUsage.fiveHour.remainingPercent}.jpg`
805
+ : normalizedTool === "cursor" && cursorBatteryPercent !== null
806
+ ? `avatar_${normalizedTool}_${normalizedStatus}_battery_${cursorBatteryPercent}.jpg`
807
+ : `avatar_${normalizedTool}_${normalizedStatus}.jpg`,
808
+ };
809
+ }
804
810
 
805
811
  async function uploadImage(
806
812
  token: string,
@@ -742,7 +742,7 @@ export async function handleCommand(
742
742
  const toolArg = text.slice(5).trim().toLowerCase();
743
743
  const tool = toolArg || resolveDefaultAgentTool();
744
744
  logTrace(tid, "BRANCH", { cmd: "/new", tool });
745
- const validTools = ["claude", "cursor", "codex"];
745
+ const validTools = ["claude", "cursor", "codex", "ccc"];
746
746
  if (!validTools.includes(tool)) {
747
747
  logTrace(tid, "DONE", { outcome: "new_invalid_tool", tool });
748
748
  await platform.sendCard(