chatccc 0.2.95 → 0.2.97

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.95",
3
+ "version": "0.2.97",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from "vitest";
2
- import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard } from "../feishu-platform.ts";
2
+ import { getPlatform, setPlatform, getTenantAccessToken, getChatInfo, createGroupChat, updateChatInfo, sendTextReply, sendCardReply, sendRawCard, sendPostMessage, extractSessionInfo, formatDelayNotice, reportPermissionResults, verifyAllPermissions, addReaction, recallMessage, updateCardMessage, setChatAvatar, disbandChat, sendRestartCard } from "../feishu-platform.ts";
3
3
  import type { FeishuPlatform } from "../feishu-platform.ts";
4
4
 
5
5
  const realPlatform = getPlatform();
@@ -17,6 +17,7 @@ describe("feishu-platform", () => {
17
17
  sendTextReply: async () => true,
18
18
  sendCardReply: async () => true,
19
19
  sendRawCard: async () => true,
20
+ sendPostMessage: async () => true,
20
21
  sendImageReply: async () => true,
21
22
  sendFileReply: async () => true,
22
23
  addReaction: async () => {},
@@ -31,6 +31,11 @@ export interface UnifiedTextBlock {
31
31
  * 由 pickFinalReply 在两者之间挑选。
32
32
  *
33
33
  * 对没有 partial/final 双轨的适配器(如 Claude SDK),永远不会 emit 此类型。
34
+ *
35
+ * 命名注意:此处的 "final" 指"完整/最终版本"(与 partial delta 相对),
36
+ * 不等于 stream-state / AccumulatorState 中的 finalReply / finalText。
37
+ * 后者命名含 "final" 但实际语义是"本轮所有文本的累积",属历史遗留命名,
38
+ * 详见 session.ts 的 AccumulatorState 注释和 stream-state.ts 的 StreamState 注释。
34
39
  */
35
40
  export interface UnifiedTextFinalBlock {
36
41
  type: "text_final";
@@ -150,9 +150,10 @@ function spawnCodex(
150
150
  args: string[],
151
151
  cwd?: string,
152
152
  stdinText?: string,
153
+ modelOverride?: string,
153
154
  ): ChildProcess {
154
155
  const allArgs = [...args];
155
- const model = resolveCodexModel();
156
+ const model = modelOverride ?? resolveCodexModel();
156
157
  if (model) {
157
158
  // 把 -m 插在 exec 后面、其他参数前面
158
159
  const execIdx = allArgs.indexOf("exec");
@@ -222,9 +223,11 @@ class CodexAdapter implements ToolAdapter {
222
223
  readonly displayName = "Codex";
223
224
  readonly sessionDescPrefix = "Codex Session:";
224
225
  private metaStore: CodexSessionMetaStore;
226
+ private modelOverride: string | undefined;
225
227
 
226
- constructor(metaStore: CodexSessionMetaStore) {
228
+ constructor(metaStore: CodexSessionMetaStore, modelOverride?: string) {
227
229
  this.metaStore = metaStore;
230
+ this.modelOverride = modelOverride;
228
231
  }
229
232
 
230
233
  // createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
@@ -251,7 +254,7 @@ class CodexAdapter implements ToolAdapter {
251
254
  ? [...CODEX_BASE_ARGS, "-C", cwd, "-"]
252
255
  : [...CODEX_BASE_ARGS, "resume", threadId, "-"];
253
256
 
254
- const proc = spawnCodex(args, cwd, userText);
257
+ const proc = spawnCodex(args, cwd, userText, this.modelOverride);
255
258
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
256
259
 
257
260
  // 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
@@ -304,6 +307,8 @@ class CodexAdapter implements ToolAdapter {
304
307
 
305
308
  export interface CreateCodexAdapterOptions {
306
309
  metaStore?: CodexSessionMetaStore;
310
+ /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
311
+ model?: string;
307
312
  }
308
313
 
309
314
  export function createCodexAdapter(
@@ -311,5 +316,6 @@ export function createCodexAdapter(
311
316
  ): ToolAdapter {
312
317
  return new CodexAdapter(
313
318
  options.metaStore ?? defaultCodexSessionMetaStore,
319
+ options.model,
314
320
  );
315
321
  }
@@ -260,8 +260,18 @@ function spawnAgent(
260
260
  extraArgs: string[],
261
261
  cwd?: string,
262
262
  stdinText?: string,
263
+ modelOverride?: string,
263
264
  ): ChildProcess {
264
- const allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
265
+ let allArgs = [...CURSOR_AGENT_ARGS, ...extraArgs];
266
+ if (modelOverride) {
267
+ // 替换全局 --model 为 per-session override
268
+ const modelIdx = allArgs.findIndex((a, i) => a === "--model" && i + 1 < allArgs.length);
269
+ if (modelIdx >= 0) {
270
+ allArgs[modelIdx + 1] = modelOverride;
271
+ } else {
272
+ allArgs.push("--model", modelOverride);
273
+ }
274
+ }
265
275
  const proc = spawn(CURSOR_AGENT_COMMAND, allArgs, {
266
276
  cwd,
267
277
  stdio: [stdinText !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
@@ -324,13 +334,15 @@ class CursorAdapter implements ToolAdapter {
324
334
  readonly sessionDescPrefix = "Cursor Session:";
325
335
  private activeProcs = new Set<ChildProcess>();
326
336
  private metaStore: CursorSessionMetaStore;
337
+ private modelOverride: string | undefined;
327
338
 
328
- constructor(metaStore: CursorSessionMetaStore) {
339
+ constructor(metaStore: CursorSessionMetaStore, modelOverride?: string) {
329
340
  this.metaStore = metaStore;
341
+ this.modelOverride = modelOverride;
330
342
  }
331
343
 
332
344
  async createSession(cwd: string): Promise<CreateSessionResult> {
333
- const proc = spawnAgent(["ok"], cwd);
345
+ const proc = spawnAgent(["ok"], cwd, undefined, this.modelOverride);
334
346
  this.activeProcs.add(proc);
335
347
 
336
348
  for await (const msg of readJsonLines(proc, undefined, "createSession")) {
@@ -358,7 +370,7 @@ class CursorAdapter implements ToolAdapter {
358
370
  options?: ToolPromptOptions,
359
371
  ): AsyncIterable<UnifiedStreamMessage> {
360
372
  console.log(`[Cursor debug] prompt start: sessionId=${sessionId}, cwd=${cwd}, userTextLen=${userText.length}`);
361
- const proc = spawnAgent(["--resume", sessionId], cwd, userText);
373
+ const proc = spawnAgent(["--resume", sessionId], cwd, userText, this.modelOverride);
362
374
  this.activeProcs.add(proc);
363
375
  if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
364
376
 
@@ -414,10 +426,12 @@ class CursorAdapter implements ToolAdapter {
414
426
  export interface CreateCursorAdapterOptions {
415
427
  /** 注入自定义 meta 持久化 store(测试用);未提供时使用全局默认实例。 */
416
428
  metaStore?: CursorSessionMetaStore;
429
+ /** per-session 模型覆盖(/model 命令);传了就用,不传走全局 cursor.model */
430
+ model?: string;
417
431
  }
418
432
 
419
433
  export function createCursorAdapter(
420
434
  options: CreateCursorAdapterOptions = {},
421
435
  ): ToolAdapter {
422
- return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore);
436
+ return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore, options.model);
423
437
  }
package/src/cards.ts CHANGED
@@ -407,3 +407,46 @@ export function buildStatusCard(statusText: string, template = "blue"): string {
407
407
  ],
408
408
  });
409
409
  }
410
+
411
+ /** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
412
+ export function buildModelCard(
413
+ currentModel: string,
414
+ models: string[],
415
+ tool?: string,
416
+ ): string {
417
+ const toolLabel = tool ? ` (${tool})` : "";
418
+ const currentLine = currentModel
419
+ ? `**当前模型:** \`${currentModel}\``
420
+ : "**当前模型:** 未指定";
421
+
422
+ const lines: string[] = [currentLine];
423
+ if (models.length > 0) {
424
+ lines.push("", "**可切换模型:**");
425
+ for (const m of models) {
426
+ lines.push(`- \`${m}\``);
427
+ }
428
+ lines.push("", "点击按钮切换模型,或输入 `/model clear` 恢复默认");
429
+ } else {
430
+ lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
431
+ }
432
+
433
+ const buttons: ButtonDef[] = [];
434
+ for (const m of models.slice(0, 20)) {
435
+ const shortName = m.includes("/") ? m.slice(m.lastIndexOf("/") + 1) : m;
436
+ buttons.push({
437
+ text: `/model ${shortName}`,
438
+ value: JSON.stringify({ cmd: `/model ${m}` }),
439
+ type: "primary",
440
+ });
441
+ }
442
+
443
+ return JSON.stringify({
444
+ config: { wide_screen_mode: true },
445
+ header: { template: "blue", title: { content: `模型切换${toolLabel}`, tag: "plain_text" } },
446
+ elements: [
447
+ { tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
448
+ { tag: "hr" },
449
+ buildButtons(buttons),
450
+ ],
451
+ });
452
+ }
package/src/config.ts CHANGED
@@ -120,6 +120,25 @@ export interface AppConfig {
120
120
  export type AgentTool = "claude" | "cursor" | "codex";
121
121
  export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
122
122
 
123
+ /** 获取指定 agent 配置中所有模型相关的值(最多 100 个,去重) */
124
+ export function getAllModelsForTool(tool: AgentTool, cfg: AppConfig = config): string[] {
125
+ const seen = new Set<string>();
126
+ const collect = (v: unknown) => {
127
+ if (typeof v === "string" && v.trim()) seen.add(v.trim());
128
+ };
129
+
130
+ if (tool === "claude") {
131
+ collect(cfg.claude.model);
132
+ collect(cfg.claude.subagentModel);
133
+ } else if (tool === "cursor") {
134
+ collect(cfg.cursor.model);
135
+ } else if (tool === "codex") {
136
+ collect(cfg.codex.model);
137
+ }
138
+
139
+ return Array.from(seen).slice(0, 100);
140
+ }
141
+
123
142
  const CONFIG_FILE = join(USER_DATA_DIR, "config.json");
124
143
  const CONFIG_SAMPLE_FILE = join(PROJECT_ROOT, "config.sample.json");
125
144
 
@@ -511,6 +530,11 @@ export let CLAUDE_API_KEY = config.claude.apiKey;
511
530
  /** Anthropic 兼容网关的 base URL(仅经 SDK 子进程 env 传递,从不写入主进程 process.env) */
512
531
  export let CLAUDE_BASE_URL = config.claude.baseUrl;
513
532
 
533
+ /** 返回当前生效的 Claude 模型(per-session 覆盖由 session.ts 管理,此处仅返回全局配置) */
534
+ export function getEffectiveClaudeModel(): string {
535
+ return CLAUDE_MODEL;
536
+ }
537
+
514
538
  // ---------------------------------------------------------------------------
515
539
  // /git 超时配置(实际值来自 config.json,纯函数与常量见 ./config-utils.ts)
516
540
  // ---------------------------------------------------------------------------
package/src/feishu-api.ts CHANGED
@@ -532,15 +532,92 @@ export function formatDelayNotice(createTimeMs: number, messageText?: string, no
532
532
  return `> ⚠️ 延迟送达提醒:此消息于 ${sendTimeStr} 发送,因服务离线,延迟约 ${delayStr}后送达${contentLine}`;
533
533
  }
534
534
 
535
+ /**
536
+ * 检测文本中的 markdown 表格,用代码块包裹。
537
+ *
538
+ * 飞书 markdown 渲染对表格支持不稳定(列对齐易错乱),用代码块包裹可保证
539
+ * 等宽字体显示、列对齐正确。只处理不在已有代码块内的表格。
540
+ *
541
+ * 检测规则:至少两行连续的 "|col|col|" 模式,其中必须包含一行分隔行
542
+ * (如 |---|----|),避免将单行含 | 的普通文本误判为表格。
543
+ */
544
+ function wrapMarkdownTables(text: string): string {
545
+ const lines = text.split("\n");
546
+ const result: string[] = [];
547
+ let inCodeBlock = false;
548
+ let tableRows: string[] = [];
549
+ let foundSeparator = false;
550
+
551
+ const isTableRow = (line: string): boolean =>
552
+ /^\s*\|.+\|/.test(line);
553
+
554
+ const isSeparatorRow = (line: string): boolean =>
555
+ /^\s*\|[\s\-:]+\|/.test(line) && /-/.test(line);
556
+
557
+ const flushTable = (): void => {
558
+ if (foundSeparator && tableRows.length >= 2) {
559
+ result.push("```");
560
+ result.push(...tableRows);
561
+ result.push("```");
562
+ } else {
563
+ result.push(...tableRows);
564
+ }
565
+ tableRows = [];
566
+ foundSeparator = false;
567
+ };
568
+
569
+ for (const line of lines) {
570
+ const trimmed = line.trim();
571
+
572
+ if (trimmed.startsWith("```")) {
573
+ flushTable();
574
+ inCodeBlock = !inCodeBlock;
575
+ result.push(line);
576
+ continue;
577
+ }
578
+
579
+ if (inCodeBlock) {
580
+ result.push(line);
581
+ continue;
582
+ }
583
+
584
+ if (!foundSeparator) {
585
+ if (isTableRow(line)) {
586
+ tableRows.push(line);
587
+ } else if (tableRows.length > 0 && isSeparatorRow(line)) {
588
+ tableRows.push(line);
589
+ foundSeparator = true;
590
+ } else {
591
+ if (tableRows.length > 0) {
592
+ result.push(...tableRows);
593
+ tableRows = [];
594
+ }
595
+ result.push(line);
596
+ }
597
+ } else {
598
+ if (isTableRow(line)) {
599
+ tableRows.push(line);
600
+ } else {
601
+ flushTable();
602
+ result.push(line);
603
+ }
604
+ }
605
+ }
606
+
607
+ flushTable();
608
+ return result.join("\n");
609
+ }
610
+
535
611
  export async function sendTextReply(
536
612
  token: string,
537
613
  chatId: string,
538
614
  text: string
539
615
  ): Promise<boolean> {
540
616
  const safeText = applyPrivacy(text);
617
+ const wrappedText = wrapMarkdownTables(safeText);
541
618
  const card = JSON.stringify({
542
619
  config: { wide_screen_mode: true },
543
- elements: [{ tag: "markdown", content: safeText }],
620
+ elements: [{ tag: "markdown", content: wrappedText }],
544
621
  });
545
622
  try {
546
623
  const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
@@ -915,3 +992,51 @@ export async function updateCardMessage(token: string, messageId: string, conten
915
992
  }
916
993
  return true;
917
994
  }
995
+
996
+ /**
997
+ * 发送飞书 post 类型消息(非卡片富文本消息)。
998
+ *
999
+ * post 消息支持 table、text、a、at、img 等富文本元素,
1000
+ * 与 interactive 卡片消息不同,post 直接在消息流中渲染。
1001
+ *
1002
+ * @param postContent 飞书 post content 格式的二维数组,每个元素是一个 paragraph
1003
+ */
1004
+ export async function sendPostMessage(
1005
+ token: string,
1006
+ chatId: string,
1007
+ title: string,
1008
+ postContent: unknown[][],
1009
+ ): Promise<boolean> {
1010
+ const content = JSON.stringify({
1011
+ post: {
1012
+ zh_cn: {
1013
+ title,
1014
+ content: postContent,
1015
+ },
1016
+ },
1017
+ });
1018
+ try {
1019
+ const resp = await fetch(`${BASE_URL}/im/v1/messages?receive_id_type=chat_id`, {
1020
+ method: "POST",
1021
+ headers: {
1022
+ Authorization: `Bearer ${token}`,
1023
+ "Content-Type": "application/json",
1024
+ },
1025
+ body: JSON.stringify({
1026
+ receive_id: chatId,
1027
+ msg_type: "post",
1028
+ content,
1029
+ }),
1030
+ });
1031
+ const data = (await resp.json().catch(() => ({}))) as { code: number; msg?: string; data?: { message_id?: string } };
1032
+ if (data.code !== 0) {
1033
+ console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} code=${data.code} msg="${data.msg ?? ""}"`);
1034
+ return false;
1035
+ }
1036
+ console.log(`[${ts()}] [SEND] post OK: chatId=${chatId} msgId=${data.data?.message_id ?? "N/A"}`);
1037
+ return true;
1038
+ } catch (err) {
1039
+ console.error(`[${ts()}] [SEND] post FAIL: chatId=${chatId} ${(err as Error).message}`);
1040
+ return false;
1041
+ }
1042
+ }
@@ -19,6 +19,7 @@ export interface FeishuPlatform {
19
19
  sendTextReply: typeof realApi.sendTextReply;
20
20
  sendCardReply: typeof realApi.sendCardReply;
21
21
  sendRawCard: typeof realApi.sendRawCard;
22
+ sendPostMessage: typeof realApi.sendPostMessage;
22
23
  sendImageReply: typeof realApi.sendImageReply;
23
24
  sendFileReply: typeof realApi.sendFileReply;
24
25
  addReaction: typeof realApi.addReaction;
@@ -134,6 +135,10 @@ export function formatDelayNotice(...args: Parameters<typeof realApi.formatDelay
134
135
  return _impl.formatDelayNotice(...args);
135
136
  }
136
137
 
138
+ export function sendPostMessage(...args: Parameters<typeof realApi.sendPostMessage>): ReturnType<typeof realApi.sendPostMessage> {
139
+ return _impl.sendPostMessage(...args);
140
+ }
141
+
137
142
  export function sendRestartCard(...args: Parameters<typeof realApi.sendRestartCard>): ReturnType<typeof realApi.sendRestartCard> {
138
143
  return _impl.sendRestartCard(...args);
139
144
  }
package/src/index.ts CHANGED
@@ -291,6 +291,8 @@ function parseCardAction(data: unknown): CardActionResult | null {
291
291
  const path = (action.value as Record<string, string>).path;
292
292
  if (path) text = `/cd ${path}`;
293
293
  }
294
+ // cmd 本身就是以 / 开头的完整指令时,直接使用(如 /model <name> 动态按钮)
295
+ if (!text && cmd.startsWith("/")) text = cmd;
294
296
  if (!text) return null;
295
297
 
296
298
  const chatId =
@@ -13,10 +13,13 @@ import { makeTraceId, logTrace } from "./trace.ts";
13
13
  import {
14
14
  CLAUDE_EFFORT,
15
15
  CLAUDE_MODEL,
16
+ CLAUDE_SUBAGENT_MODEL,
16
17
  GIT_TIMEOUT_MS,
17
18
  PROJECT_ROOT,
18
19
  anthropicConfigDisplay,
20
+ config,
19
21
  fileLog,
22
+ getAllModelsForTool,
20
23
  getDefaultCwd,
21
24
  setDefaultCwd,
22
25
  getRecentDirs,
@@ -25,9 +28,11 @@ import {
25
28
  sessionPrefixForTool,
26
29
  toolDisplayName,
27
30
  ts,
31
+ type AgentTool,
28
32
  } from "./config.ts";
29
33
  import {
30
34
  buildHelpCard,
35
+ buildModelCard,
31
36
  buildStatusCard,
32
37
  buildCdContent,
33
38
  buildCdCard,
@@ -41,15 +46,18 @@ import {
41
46
  runGitCommand,
42
47
  } from "./git-command.ts";
43
48
  import {
49
+ clearSessionModelOverride,
44
50
  getSessionStatus,
45
51
  getAllSessionsStatus,
46
52
  initClaudeSession,
47
53
  lastMsgTimestamps,
48
54
  resumeAndPrompt,
49
55
  sessionInfoMap,
56
+ setSessionModelOverride,
50
57
  switchChatBinding,
51
58
  recordSessionRegistry,
52
59
  getAdapterForTool,
60
+ getEffectiveModelForTool,
53
61
  stopSession,
54
62
  loadSessionRegistryForBinding,
55
63
  removeSessionRegistryRecord,
@@ -65,6 +73,7 @@ import {
65
73
  enqueueMessage,
66
74
  cancelQueuedMessage,
67
75
  } from "./session-chat-binding.ts";
76
+ import { getTenantAccessToken, sendPostMessage } from "./feishu-platform.ts";
68
77
  export { type PlatformAdapter } from "./platform-adapter.ts";
69
78
  import type { PlatformAdapter } from "./platform-adapter.ts";
70
79
 
@@ -81,6 +90,21 @@ export function sessionChatName(left: string, cwd: string): string {
81
90
  return `${left}-${cwdDisplayName(cwd)}`;
82
91
  }
83
92
 
93
+ /** 模型模糊匹配:精确匹配优先,否则找子串匹配(模型名越短越优先) */
94
+ function findModelMatch(input: string, models: string[]): string | null {
95
+ if (models.length === 0) return null;
96
+ const inputLower = input.toLowerCase();
97
+ // 1) 精确匹配(忽略大小写)
98
+ for (const m of models) {
99
+ if (m.toLowerCase() === inputLower) return m;
100
+ }
101
+ // 2) 子串匹配:模型全名包含输入,按模型名长度升序(越短越优先)
102
+ const candidates = models
103
+ .filter(m => m.toLowerCase().includes(inputLower))
104
+ .sort((a, b) => a.length - b.length);
105
+ return candidates[0] ?? null;
106
+ }
107
+
84
108
  function isUntitledSessionChatName(name: string): boolean {
85
109
  return name === "新会话" || name.startsWith("新会话-");
86
110
  }
@@ -141,7 +165,7 @@ export async function handleCommand(
141
165
  chatInfo.description,
142
166
  );
143
167
  if (sessionInfoResult) {
144
- const adapter = getAdapterForTool(sessionInfoResult.tool);
168
+ const adapter = getAdapterForTool(sessionInfoResult.tool, sessionInfoResult.sessionId);
145
169
  const info = await adapter.getSessionInfo(sessionInfoResult.sessionId);
146
170
  sessionCwd = info?.cwd;
147
171
  }
@@ -243,6 +267,34 @@ export async function handleCommand(
243
267
  return;
244
268
  }
245
269
 
270
+ if (textLower === "/model") {
271
+ logTrace(tid, "BRANCH", { cmd: "/model" });
272
+
273
+ const defaultTool = resolveDefaultAgentTool();
274
+ const models = getAllModelsForTool(defaultTool);
275
+ let currentModel = "";
276
+ if (defaultTool === "cursor") currentModel = config.cursor.model;
277
+ else if (defaultTool === "codex") currentModel = config.codex.model;
278
+ else currentModel = CLAUDE_MODEL;
279
+
280
+ if (platform.kind === "wechat") {
281
+ const lines = [currentModel ? `当前模型 (${defaultTool}): ${currentModel}` : `当前模型 (${defaultTool}): 未指定`];
282
+ if (models.length > 0) {
283
+ lines.push("", "可切换模型:");
284
+ for (const m of models) lines.push(` ${m}`);
285
+ lines.push("", "在会话中输入 /model <模型名> 切换模型");
286
+ } else {
287
+ lines.push("", "没有可切换的模型。请在 config.json 中配置模型字段。");
288
+ }
289
+ await platform.sendText(chatId, lines.join("\n")).catch(() => {});
290
+ } else {
291
+ const card = buildModelCard(currentModel, models, defaultTool);
292
+ await platform.sendRawCard(chatId, card);
293
+ }
294
+ logTrace(tid, "DONE", { outcome: "model_query", defaultTool });
295
+ return;
296
+ }
297
+
246
298
  if (textLower === "/new" || textLower.startsWith("/new ")) {
247
299
  const toolArg = text.slice(5).trim().toLowerCase();
248
300
  const tool = toolArg || resolveDefaultAgentTool();
@@ -339,6 +391,7 @@ export async function handleCommand(
339
391
  `**工作目录:** \`${cwd}\`\n\n` +
340
392
  `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
341
393
  `发送 **/cd** 切换新建会话的默认目录。\n` +
394
+ `发送 **/model** 查看或切换当前会话的模型。\n` +
342
395
  `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
343
396
  `发送 **/sessions** 查看所有会话状态。\n` +
344
397
  `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
@@ -424,6 +477,7 @@ export async function handleCommand(
424
477
  `**工作目录:** \`${cwd}\`\n\n` +
425
478
  `直接在这里发消息即可与 ${toolLabel} 对话。\n\n` +
426
479
  `发送 **/cd** 切换新建会话的默认目录。\n` +
480
+ `发送 **/model** 查看或切换当前会话的模型。\n` +
427
481
  `发送 **/new** 创建新会话,**/newh** 重置当前会话(沿用工作目录)。\n` +
428
482
  `发送 **/sessions** 查看所有会话状态。\n` +
429
483
  `发送 \`/git <子命令>\` 在本会话工作目录执行 git,例如 \`/git status\`、\`/git log --oneline -n 5\`。`,
@@ -510,7 +564,7 @@ export async function handleCommand(
510
564
  ) {
511
565
  const MAX_PREFIX = 10;
512
566
  const prefix = text.slice(0, MAX_PREFIX);
513
- const adapter = getAdapterForTool(descriptionTool);
567
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
514
568
  const info = await adapter
515
569
  .getSessionInfo(sessionId)
516
570
  .catch(() => undefined);
@@ -553,7 +607,7 @@ export async function handleCommand(
553
607
  ) {
554
608
  const MAX_PREFIX = 10;
555
609
  const prefix = text.slice(0, MAX_PREFIX);
556
- const adapter = getAdapterForTool(descriptionTool!);
610
+ const adapter = getAdapterForTool(descriptionTool!, sessionId);
557
611
  const info = await adapter
558
612
  .getSessionInfo(sessionId)
559
613
  .catch(() => undefined);
@@ -608,6 +662,43 @@ export async function handleCommand(
608
662
  return;
609
663
  }
610
664
 
665
+ if (textLower === "/test") {
666
+ logTrace(tid, "BRANCH", { cmd: "/test" });
667
+ const tableHeaders = ["名称", "版本", "状态"];
668
+ const tableRows = [
669
+ ["ChatCCC", "0.2.96", "运行中"],
670
+ ["Claude SDK", "0.50.0", "已连接"],
671
+ ["Feishu API", "v1", "正常"],
672
+ ];
673
+ const mdTable = [
674
+ `| ${tableHeaders.join(" | ")} |`,
675
+ `| ${tableHeaders.map(() => "---").join(" | ")} |`,
676
+ ...tableRows.map((row) => `| ${row.join(" | ")} |`),
677
+ ].join("\n");
678
+
679
+ if (platform.kind === "feishu") {
680
+ try {
681
+ const token = await getTenantAccessToken();
682
+ const postContent: unknown[][] = [
683
+ // 先尝试富文本表格
684
+ [{ tag: "table", cells: [tableHeaders, ...tableRows] }],
685
+ // 再用代码块包起来
686
+ [{ tag: "text", text: `\n表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\`` }],
687
+ ];
688
+ await sendPostMessage(token, chatId, "测试表格", postContent);
689
+ } catch (err) {
690
+ console.error(`[${ts()}] [TEST] post message failed: ${(err as Error).message}`);
691
+ // Fallback to markdown card
692
+ await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => {});
693
+ }
694
+ } else {
695
+ // WeChat / other platforms: just send code block
696
+ await platform.sendText(chatId, `表格(代码块格式):\n\`\`\`\n${mdTable}\n\`\`\``).catch(() => {});
697
+ }
698
+ logTrace(tid, "DONE", { outcome: "test" });
699
+ return;
700
+ }
701
+
611
702
  if (textLower === "/status") {
612
703
  logTrace(tid, "BRANCH", { cmd: "/status" });
613
704
  const status = await getSessionStatus(chatId);
@@ -674,7 +765,7 @@ export async function handleCommand(
674
765
 
675
766
  if (textLower === "/newh") {
676
767
  logTrace(tid, "BRANCH", { cmd: "/newh" });
677
- const adapter = getAdapterForTool(descriptionTool);
768
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
678
769
  let cwd: string;
679
770
  try {
680
771
  const info = await adapter.getSessionInfo(sessionId);
@@ -747,7 +838,8 @@ export async function handleCommand(
747
838
  `**Session ID:** ${newSessionId}\n` +
748
839
  `**工作目录:** \`${cwd}\`(沿用当前会话目录)\n\n` +
749
840
  `直接在这里发消息即可继续对话。\n` +
750
- `发送 **/cd** 可切换新建会话的默认目录。`,
841
+ `发送 **/cd** 可切换新建会话的默认目录。\n` +
842
+ `发送 **/model** 查看或切换当前会话的模型。`,
751
843
  "green",
752
844
  );
753
845
 
@@ -849,7 +941,7 @@ export async function handleCommand(
849
941
  return;
850
942
  }
851
943
 
852
- const targetAdapter = getAdapterForTool(target.tool);
944
+ const targetAdapter = getAdapterForTool(target.tool, target.sessionId);
853
945
  let cwd2: string;
854
946
  try {
855
947
  const targetInfo = await targetAdapter.getSessionInfo(
@@ -907,7 +999,8 @@ export async function handleCommand(
907
999
  `**序号:** ${index + 1}\n` +
908
1000
  `**Session ID:** ${target.sessionId}\n` +
909
1001
  `**工作目录:** \`${cwd2}\`\n\n` +
910
- `直接在这里发消息即可继续对话。${busyNote}`,
1002
+ `直接在这里发消息即可继续对话。\n` +
1003
+ `发送 **/model** 查看或切换当前会话的模型。${busyNote}`,
911
1004
  "green",
912
1005
  );
913
1006
 
@@ -920,6 +1013,55 @@ export async function handleCommand(
920
1013
  return;
921
1014
  }
922
1015
 
1016
+ // /model clear — 清除当前 session 的模型覆盖
1017
+ if (textLower === "/model clear") {
1018
+ logTrace(tid, "BRANCH", { cmd: "/model clear", sessionId });
1019
+ clearSessionModelOverride(sessionId);
1020
+ const defaultModel = getEffectiveModelForTool(descriptionTool);
1021
+ const toolLabel = toolDisplayName(descriptionTool);
1022
+ const msg = `已清除当前 ${toolLabel} 会话的模型覆盖,恢复使用: \`${defaultModel || "(未指定)"}\``;
1023
+ await (platform.kind === "wechat"
1024
+ ? platform.sendText(chatId, msg)
1025
+ : platform.sendCard(chatId, "模型切换", msg, "green")
1026
+ ).catch(() => {});
1027
+ logTrace(tid, "DONE", { outcome: "model_cleared", sessionId, tool: descriptionTool });
1028
+ return;
1029
+ }
1030
+
1031
+ // /model <name> — 切换当前 session 的模型(支持所有 agent,模糊匹配)
1032
+ if (textLower.startsWith("/model ")) {
1033
+ const modelArg = text.slice(7).trim();
1034
+ if (!modelArg) return; // 纯 "/model " 不处理,交给上面的 /model 分支
1035
+ logTrace(tid, "BRANCH", { cmd: "/model", arg: modelArg, sessionId, tool: descriptionTool });
1036
+
1037
+ const models = getAllModelsForTool(descriptionTool as AgentTool);
1038
+ const toolLabel = toolDisplayName(descriptionTool);
1039
+
1040
+ // 查找目标模型:精确匹配优先,否则子串匹配(模型名越短越优先)
1041
+ const target = findModelMatch(modelArg, models);
1042
+
1043
+ if (!target) {
1044
+ const msg = models.length > 0
1045
+ ? `未找到匹配 "${modelArg}" 的模型。当前 ${toolLabel} 可选模型:\n${models.map(m => ` \`${m}\``).join("\n")}`
1046
+ : `当前 ${toolLabel} 没有可切换的模型。请在 config.json 中配置模型字段。`;
1047
+ await (platform.kind === "wechat"
1048
+ ? platform.sendText(chatId, msg)
1049
+ : platform.sendCard(chatId, "模型切换", msg, "red")
1050
+ ).catch(() => {});
1051
+ logTrace(tid, "DONE", { outcome: "model_not_found", arg: modelArg, tool: descriptionTool });
1052
+ return;
1053
+ }
1054
+
1055
+ setSessionModelOverride(sessionId, target);
1056
+ const msg = `已切换当前 ${toolLabel} 会话模型为: \`${target}\``;
1057
+ await (platform.kind === "wechat"
1058
+ ? platform.sendText(chatId, msg)
1059
+ : platform.sendCard(chatId, "模型切换", msg, "green")
1060
+ ).catch(() => {});
1061
+ logTrace(tid, "DONE", { outcome: "model_switched", arg: modelArg, target, sessionId, tool: descriptionTool });
1062
+ return;
1063
+ }
1064
+
923
1065
  // /git <args>:在「当前会话工作目录」执行 git 命令
924
1066
  if (textLower.startsWith("/git ") || textLower === "/git") {
925
1067
  const args = text === "/git" ? "" : text.slice(5).trim();
@@ -935,7 +1077,7 @@ export async function handleCommand(
935
1077
  return;
936
1078
  }
937
1079
 
938
- const adapter = getAdapterForTool(descriptionTool);
1080
+ const adapter = getAdapterForTool(descriptionTool, sessionId);
939
1081
  let cwd: string | undefined;
940
1082
  try {
941
1083
  const info = await adapter.getSessionInfo(sessionId);
package/src/session.ts CHANGED
@@ -307,23 +307,60 @@ export function resetState(): void {
307
307
  // 是 onReady/onReconnected 取代 resetState 的正确入口。
308
308
 
309
309
  // ---------------------------------------------------------------------------
310
- // Adapter: 按 tool 创建并缓存
310
+ // Adapter: 按 tool + effectiveModel 创建并缓存
311
311
  // ---------------------------------------------------------------------------
312
312
 
313
313
  const adapterCache = new Map<string, ToolAdapter>();
314
314
 
315
- export function getAdapterForTool(tool: string): ToolAdapter {
316
- const cached = adapterCache.get(tool);
315
+ // Per-session 模型覆盖(/model 命令设置,不持久化)
316
+ const sessionModelOverrides = new Map<string, string>();
317
+
318
+ /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置(Claude) */
319
+ function getModelForSession(sessionId?: string): string {
320
+ if (sessionId) {
321
+ const override = sessionModelOverrides.get(sessionId);
322
+ if (override) return override;
323
+ }
324
+ return CLAUDE_MODEL;
325
+ }
326
+
327
+ /** 返回指定 tool 的生效模型:优先 per-session 覆盖,其次 tool 默认配置 */
328
+ export function getEffectiveModelForTool(tool: string, sessionId?: string): string {
329
+ if (sessionId) {
330
+ const override = sessionModelOverrides.get(sessionId);
331
+ if (override) return override;
332
+ }
333
+ if (tool === "cursor") return config.cursor.model;
334
+ if (tool === "codex") return config.codex.model;
335
+ return CLAUDE_MODEL;
336
+ }
337
+
338
+ /** 为指定 session 设置模型覆盖(/model <name>) */
339
+ export function setSessionModelOverride(sessionId: string, model: string): void {
340
+ sessionModelOverrides.set(sessionId, model);
341
+ adapterCache.clear();
342
+ }
343
+
344
+ /** 清除指定 session 的模型覆盖(/model clear) */
345
+ export function clearSessionModelOverride(sessionId: string): void {
346
+ sessionModelOverrides.delete(sessionId);
347
+ adapterCache.clear();
348
+ }
349
+
350
+ export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
351
+ const effectiveModel = getEffectiveModelForTool(tool, sessionId);
352
+ const cacheKey = effectiveModel ? `${tool}:${effectiveModel}` : tool;
353
+ const cached = adapterCache.get(cacheKey);
317
354
  if (cached) return cached;
318
355
 
319
356
  let adapter: ToolAdapter;
320
357
  if (tool === "cursor") {
321
- adapter = createCursorAdapter();
358
+ adapter = createCursorAdapter({ model: effectiveModel || undefined });
322
359
  } else if (tool === "codex") {
323
- adapter = createCodexAdapter();
360
+ adapter = createCodexAdapter({ model: effectiveModel || undefined });
324
361
  } else {
325
362
  adapter = createClaudeAdapter({
326
- model: CLAUDE_MODEL,
363
+ model: effectiveModel,
327
364
  subagentModel: CLAUDE_SUBAGENT_MODEL,
328
365
  effort: CLAUDE_EFFORT,
329
366
  isEmpty: isAnthropicConfigEmpty,
@@ -331,7 +368,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
331
368
  baseUrl: CLAUDE_BASE_URL,
332
369
  });
333
370
  }
334
- adapterCache.set(tool, adapter);
371
+ adapterCache.set(cacheKey, adapter);
335
372
  return adapter;
336
373
  }
337
374
 
@@ -508,9 +545,13 @@ export function _resetSessionRegistryFileForTest(): void {
508
545
  // accumulateBlockContent — 将 UnifiedBlock 累积到渲染状态(纯函数,可测试)
509
546
  // ---------------------------------------------------------------------------
510
547
 
548
+ // 注意:以下字段命名含 "final",但实际语义是"本轮所有文本的累积",并非仅
549
+ // "最后一段回复"。历史原因得名——当时以为 finalReply 只包含最后一轮输出,实则
550
+ // 所有 text block(工具调用前、调用间、调用后)都累加在这里。
551
+ // accumulatedContent + finalReply 拼接后才是完整流式输出的全部内容。
511
552
  export interface AccumulatorState {
512
553
  accumulatedContent: string;
513
- /** partial text 块按追加语义累积;适用于 Cursor 的流式增量与 Claude SDK 的 delta */
554
+ /** 本轮所有 text block 的累加(流式文本 delta),包括工具调用前后的全部文本 */
514
555
  finalText: string;
515
556
  /**
516
557
  * 适配器明确给出的"完整最终文本"(覆盖语义)。
@@ -748,7 +789,7 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
748
789
  * Claude 显示 model/effort(来自环境变量);Cursor 显示 model(运行时由
749
790
  * cursor-agent 决定,初次创建时尚未学习到,故显示占位)。
750
791
  */
751
- function formatToolConfigForLog(tool: string, sessionModel?: string): string {
792
+ function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
752
793
  if (tool === "cursor") {
753
794
  return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
754
795
  }
@@ -761,7 +802,7 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
761
802
  : "effort=(由 codex config.toml 决定)";
762
803
  return `model=${modelStr}, ${effortStr}`;
763
804
  }
764
- return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
805
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
765
806
  }
766
807
 
767
808
  export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
@@ -841,12 +882,12 @@ export async function runAgentSession(
841
882
  let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
842
883
  let cwd: string;
843
884
  try {
844
- adapter = getAdapterForTool(tool);
885
+ adapter = getAdapterForTool(tool, sessionId);
845
886
  info = await adapter.getSessionInfo(sessionId);
846
887
  cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
847
888
  if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
848
889
  console.log(
849
- `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
890
+ `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model, sessionId)}, cwd=${cwd})`
850
891
  );
851
892
 
852
893
  // 构建 IM skills prompt(sessionId 方式,无 token)
@@ -1541,7 +1582,7 @@ async function resolveModelEffort(
1541
1582
  if (tool === "cursor") {
1542
1583
  let model = UNKNOWN_MODEL_PLACEHOLDER;
1543
1584
  try {
1544
- const adapter = getAdapterForTool(tool);
1585
+ const adapter = getAdapterForTool(tool, sessionId);
1545
1586
  const info = await adapter.getSessionInfo(sessionId);
1546
1587
  if (info?.model) model = info.model;
1547
1588
  } catch {
@@ -1558,7 +1599,7 @@ async function resolveModelEffort(
1558
1599
  };
1559
1600
  }
1560
1601
  return {
1561
- model: anthropicConfigDisplay(CLAUDE_MODEL),
1602
+ model: anthropicConfigDisplay(getModelForSession(sessionId)),
1562
1603
  effort: anthropicConfigDisplay(CLAUDE_EFFORT),
1563
1604
  };
1564
1605
  }
@@ -1665,6 +1706,9 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
1665
1706
 
1666
1707
  export function _setAdapterForToolForTest(tool: string, adapter: ToolAdapter): void {
1667
1708
  adapterCache.set(tool, adapter);
1709
+ // 同时设置当前配置模型对应的 key(getAdapterForTool 会优先 lookup 含 model 的 key)
1710
+ const effective = getEffectiveModelForTool(tool);
1711
+ if (effective) adapterCache.set(`${tool}:${effective}`, adapter);
1668
1712
  }
1669
1713
 
1670
1714
  export function clearAdapterCache(): void {
@@ -78,6 +78,12 @@ export const SimulatedPlatform: FeishuPlatform = {
78
78
  return true;
79
79
  },
80
80
 
81
+ async sendPostMessage(_token, chatId, title, postContent) {
82
+ console.log(`[${ts()}] [SIM:SEND] post → ${chatId}: title="${title}" paragraphs=${postContent.length}`);
83
+ simStore.sendReply(chatId, "post", `[${title}] ${postContent.length} paragraphs`);
84
+ return true;
85
+ },
86
+
81
87
  // ---- 消息管理 ----
82
88
  async addReaction(_token, _messageId, _emojiType) {
83
89
  // 模拟模式不需要表情回应
package/src/sim-store.ts CHANGED
@@ -30,7 +30,7 @@ export interface SimMessage {
30
30
  id: string; // UUID
31
31
  chatId: string;
32
32
  senderId: string; // SimAccount.id
33
- type: "text" | "card" | "image" | "file" | "raw_card" | "system";
33
+ type: "text" | "card" | "image" | "file" | "raw_card" | "post" | "system";
34
34
  content: string; // 文本内容或 JSON 字符串
35
35
  timestamp: number;
36
36
  }
@@ -9,17 +9,20 @@ import { USER_DATA_DIR, ts } from "./config.ts";
9
9
 
10
10
  export const STREAMS_DIR = join(USER_DATA_DIR, "state", "streams");
11
11
 
12
- export interface StreamState {
13
- sessionId: string;
14
- status: "running" | "done" | "stopped" | "error";
15
- accumulatedContent: string;
16
- finalReply: string;
17
- /** The turn whose terminal text reply has already been delivered to IM. */
18
- finalReplySentTurn?: number;
19
- finalReplySentAt?: number;
20
- chunkCount: number;
21
- turnCount: number;
22
- contextTokens: number;
12
+ export interface StreamState {
13
+ sessionId: string;
14
+ status: "running" | "done" | "stopped" | "error";
15
+ accumulatedContent: string;
16
+ /** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
17
+ * 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
18
+ * 参见 session.ts 的 AccumulatorState 注释。 */
19
+ finalReply: string;
20
+ /** The turn whose terminal text reply has already been delivered to IM. */
21
+ finalReplySentTurn?: number;
22
+ finalReplySentAt?: number;
23
+ chunkCount: number;
24
+ turnCount: number;
25
+ contextTokens: number;
23
26
  updatedAt: number;
24
27
  cwd: string;
25
28
  tool: string;
@@ -74,7 +77,7 @@ export function _resetRenameForTest(): void {
74
77
  * 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
75
78
  * 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
76
79
  */
77
- export async function writeStreamState(state: StreamState): Promise<void> {
80
+ export async function writeStreamState(state: StreamState): Promise<void> {
78
81
  const filePath = getStreamStatePath(state.sessionId);
79
82
  const payload = JSON.stringify(state, null, 2);
80
83
  try {
@@ -98,26 +101,26 @@ export async function writeStreamState(state: StreamState): Promise<void> {
98
101
  } catch (err) {
99
102
  console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
100
103
  }
101
- }
102
-
103
- export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
104
- return state.finalReplySentTurn === state.turnCount;
105
- }
106
-
107
- export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
108
- const state = await readStreamState(sessionId);
109
- if (!state) return;
110
- if (state.turnCount !== turnCount) return;
111
- if (state.status === "running") return;
112
-
113
- state.finalReplySentTurn = turnCount;
114
- state.finalReplySentAt = sentAt;
115
- state.updatedAt = Date.now();
116
- await writeStreamState(state);
117
- }
118
-
119
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
120
- return {
104
+ }
105
+
106
+ export function isFinalReplySentForTurn(state: Pick<StreamState, "turnCount" | "finalReplySentTurn">): boolean {
107
+ return state.finalReplySentTurn === state.turnCount;
108
+ }
109
+
110
+ export async function markFinalReplySent(sessionId: string, turnCount: number, sentAt = Date.now()): Promise<void> {
111
+ const state = await readStreamState(sessionId);
112
+ if (!state) return;
113
+ if (state.turnCount !== turnCount) return;
114
+ if (state.status === "running") return;
115
+
116
+ state.finalReplySentTurn = turnCount;
117
+ state.finalReplySentAt = sentAt;
118
+ state.updatedAt = Date.now();
119
+ await writeStreamState(state);
120
+ }
121
+
122
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
123
+ return {
121
124
  sessionId,
122
125
  status: "running",
123
126
  accumulatedContent: "",
@@ -157,4 +160,4 @@ export async function fixStaleStreamStates(): Promise<void> {
157
160
  } catch (err) {
158
161
  console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
159
162
  }
160
- }
163
+ }