chatccc 0.2.94 → 0.2.96

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/src/session.ts CHANGED
@@ -25,6 +25,7 @@ import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
25
25
  import { logTrace } from "./trace.ts";
26
26
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
27
27
  import type { ToolAdapter } from "./adapters/adapter-interface.ts";
28
+ import type { ToolProcessInfo } from "./adapters/adapter-interface.ts";
28
29
  import { createClaudeAdapter } from "./adapters/claude-adapter.ts";
29
30
  import { createCursorAdapter } from "./adapters/cursor-adapter.ts";
30
31
  import { createCodexAdapter } from "./adapters/codex-adapter.ts";
@@ -114,6 +115,110 @@ function platformForChat(chatId: string): PlatformAdapter | null {
114
115
  return chatPlatformMap.get(chatId) ?? platformRef;
115
116
  }
116
117
 
118
+ const DEFAULT_PROCESS_MONITOR_INTERVAL_MS = 5000;
119
+ let processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
120
+ let isProcessAliveImpl = (pid: number): boolean => {
121
+ try {
122
+ process.kill(pid, 0);
123
+ return true;
124
+ } catch {
125
+ return false;
126
+ }
127
+ };
128
+
129
+ export function _setProcessAliveForTest(impl: (pid: number) => boolean): void {
130
+ isProcessAliveImpl = impl;
131
+ }
132
+
133
+ export function _resetProcessAliveForTest(): void {
134
+ isProcessAliveImpl = (pid: number): boolean => {
135
+ try {
136
+ process.kill(pid, 0);
137
+ return true;
138
+ } catch {
139
+ return false;
140
+ }
141
+ };
142
+ }
143
+
144
+ export function _setProcessMonitorIntervalForTest(ms: number): void {
145
+ processMonitorIntervalMs = ms;
146
+ }
147
+
148
+ export function _resetProcessMonitorIntervalForTest(): void {
149
+ processMonitorIntervalMs = DEFAULT_PROCESS_MONITOR_INTERVAL_MS;
150
+ }
151
+
152
+ function clearPromptProcessMonitor(sessionId: string): void {
153
+ const prompt = activePrompts.get(sessionId);
154
+ if (!prompt?.processMonitor) return;
155
+ clearInterval(prompt.processMonitor);
156
+ prompt.processMonitor = undefined;
157
+ }
158
+
159
+ function formatTerminalHeader(status: "running" | "done" | "stopped" | "error"): {
160
+ title: string;
161
+ template?: string;
162
+ } {
163
+ if (status === "stopped") return { title: "已停止", template: "red" };
164
+ if (status === "error") return { title: "异常结束", template: "red" };
165
+ return { title: "完成" };
166
+ }
167
+
168
+ function turnFinalStatus(status: "running" | "done" | "stopped" | "error"): "done" | "stopped" {
169
+ return status === "stopped" || status === "error" ? "stopped" : "done";
170
+ }
171
+
172
+ function startPromptProcessMonitor(sessionId: string, info: ToolProcessInfo): void {
173
+ const prompt = activePrompts.get(sessionId);
174
+ if (!prompt) return;
175
+ prompt.processPid = info.pid;
176
+ clearPromptProcessMonitor(sessionId);
177
+
178
+ const check = async () => {
179
+ const current = activePrompts.get(sessionId);
180
+ if (!current || current !== prompt) {
181
+ clearPromptProcessMonitor(sessionId);
182
+ return;
183
+ }
184
+ if (current.stopped || current.abnormalExit) return;
185
+ if (isProcessAliveImpl(info.pid)) return;
186
+
187
+ current.abnormalExit = true;
188
+ clearPromptProcessMonitor(sessionId);
189
+
190
+ const state = await readStreamState(sessionId);
191
+ if (state?.status === "running") {
192
+ await writeStreamState({
193
+ ...state,
194
+ status: "error",
195
+ updatedAt: Date.now(),
196
+ });
197
+ }
198
+
199
+ const chatId = pickDisplayChat(sessionId) ?? getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
200
+ const p = chatId ? platformForChat(chatId) : null;
201
+ if (chatId && p && !current.abnormalExitNotified) {
202
+ current.abnormalExitNotified = true;
203
+ await p.sendText(
204
+ chatId,
205
+ `⚠️ 进程异常结束:session ${sessionId} 对应的 CLI 进程 PID ${info.pid} 已不存在,已按完成处理。若回复不完整,请重新发送上一条指令。`,
206
+ ).catch(() => {});
207
+ }
208
+
209
+ // 主动关闭 readline,让 runAgentSession 的 finally 落盘 error 终态并清理 activePrompts。
210
+ current.controller.abort();
211
+ };
212
+
213
+ const handle = setInterval(() => {
214
+ void check().catch((err) => {
215
+ console.warn(`[${ts()}] [PROCESS-MONITOR] check failed for ${sessionId}: ${(err as Error).message}`);
216
+ });
217
+ }, processMonitorIntervalMs);
218
+ handle.unref?.();
219
+ prompt.processMonitor = handle;
220
+ }
221
+
117
222
  export function _getPlatformForChatForTest(chatId: string): PlatformAdapter | null {
118
223
  return platformForChat(chatId);
119
224
  }
@@ -189,6 +294,9 @@ export function resetState(): void {
189
294
  processedMessages.clear();
190
295
  lastMsgTimestamps.clear();
191
296
  chatPlatformMap.clear();
297
+ for (const prompt of activePrompts.values()) {
298
+ if (prompt.processMonitor) clearInterval(prompt.processMonitor);
299
+ }
192
300
  activePrompts.clear();
193
301
  displayCards.clear();
194
302
  stopUnifiedDisplayLoop();
@@ -199,13 +307,39 @@ export function resetState(): void {
199
307
  // 是 onReady/onReconnected 取代 resetState 的正确入口。
200
308
 
201
309
  // ---------------------------------------------------------------------------
202
- // Adapter: 按 tool 创建并缓存
310
+ // Adapter: 按 tool + effectiveModel 创建并缓存
203
311
  // ---------------------------------------------------------------------------
204
312
 
205
313
  const adapterCache = new Map<string, ToolAdapter>();
206
314
 
207
- export function getAdapterForTool(tool: string): ToolAdapter {
208
- const cached = adapterCache.get(tool);
315
+ // Per-session 模型覆盖(/model 命令设置,不持久化)
316
+ const sessionModelOverrides = new Map<string, string>();
317
+
318
+ /** 返回 session 的生效模型:优先 per-session 覆盖,其次全局配置 */
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
+ /** 为指定 session 设置模型覆盖(/model pro、/model flash) */
328
+ export function setSessionModelOverride(sessionId: string, model: string): void {
329
+ sessionModelOverrides.set(sessionId, model);
330
+ adapterCache.clear();
331
+ }
332
+
333
+ /** 清除指定 session 的模型覆盖(/model clear) */
334
+ export function clearSessionModelOverride(sessionId: string): void {
335
+ sessionModelOverrides.delete(sessionId);
336
+ adapterCache.clear();
337
+ }
338
+
339
+ export function getAdapterForTool(tool: string, sessionId?: string): ToolAdapter {
340
+ const effectiveModel = tool === "claude" ? getModelForSession(sessionId) : "";
341
+ const cacheKey = tool === "claude" ? `${tool}:${effectiveModel}` : tool;
342
+ const cached = adapterCache.get(cacheKey);
209
343
  if (cached) return cached;
210
344
 
211
345
  let adapter: ToolAdapter;
@@ -215,7 +349,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
215
349
  adapter = createCodexAdapter();
216
350
  } else {
217
351
  adapter = createClaudeAdapter({
218
- model: CLAUDE_MODEL,
352
+ model: effectiveModel,
219
353
  subagentModel: CLAUDE_SUBAGENT_MODEL,
220
354
  effort: CLAUDE_EFFORT,
221
355
  isEmpty: isAnthropicConfigEmpty,
@@ -223,7 +357,7 @@ export function getAdapterForTool(tool: string): ToolAdapter {
223
357
  baseUrl: CLAUDE_BASE_URL,
224
358
  });
225
359
  }
226
- adapterCache.set(tool, adapter);
360
+ adapterCache.set(cacheKey, adapter);
227
361
  return adapter;
228
362
  }
229
363
 
@@ -640,7 +774,7 @@ export async function switchChatBinding(args: SwitchChatBindingArgs): Promise<Sw
640
774
  * Claude 显示 model/effort(来自环境变量);Cursor 显示 model(运行时由
641
775
  * cursor-agent 决定,初次创建时尚未学习到,故显示占位)。
642
776
  */
643
- function formatToolConfigForLog(tool: string, sessionModel?: string): string {
777
+ function formatToolConfigForLog(tool: string, sessionModel?: string, sessionId?: string): string {
644
778
  if (tool === "cursor") {
645
779
  return `model=${sessionModel ?? "(由 cursor-agent 决定,init 事件后学习)"}`;
646
780
  }
@@ -653,7 +787,7 @@ function formatToolConfigForLog(tool: string, sessionModel?: string): string {
653
787
  : "effort=(由 codex config.toml 决定)";
654
788
  return `model=${modelStr}, ${effortStr}`;
655
789
  }
656
- return `model=${anthropicConfigDisplay(CLAUDE_MODEL)}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
790
+ return `model=${anthropicConfigDisplay(getModelForSession(sessionId))}, subagentModel=${anthropicConfigDisplay(CLAUDE_SUBAGENT_MODEL)}, effort=${anthropicConfigDisplay(CLAUDE_EFFORT)}`;
657
791
  }
658
792
 
659
793
  export async function initClaudeSession(tool: string, overrideCwd?: string, chatId?: string): Promise<{ sessionId: string; cwd: string }> {
@@ -733,12 +867,12 @@ export async function runAgentSession(
733
867
  let info: Awaited<ReturnType<ToolAdapter["getSessionInfo"]>>;
734
868
  let cwd: string;
735
869
  try {
736
- adapter = getAdapterForTool(tool);
870
+ adapter = getAdapterForTool(tool, sessionId);
737
871
  info = await adapter.getSessionInfo(sessionId);
738
872
  cwd = info?.cwd ?? (await getDefaultCwd(_chatId));
739
873
  if (tid) logTrace(tid, "SESSION_START", { sessionId, tool, cwd, turn: (sessionInfoMap.get(_chatId)?.turnCount ?? 0) + 1 });
740
874
  console.log(
741
- `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model)}, cwd=${cwd})`
875
+ `[${ts()}] Running ${adapter.displayName} session: ${sessionId} (${formatToolConfigForLog(tool, info?.model, sessionId)}, cwd=${cwd})`
742
876
  );
743
877
 
744
878
  // 构建 IM skills prompt(sessionId 方式,无 token)
@@ -833,13 +967,12 @@ export async function runAgentSession(
833
967
  // terminal state,发送了 finalReply 并删除/替换了 displayCards 条目。
834
968
  // 通过引用比较检测——若统一 loop 已处理则只补持久化和头像,不重复发。
835
969
  if (displayCards.get(displayChatId) !== display) {
836
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
970
+ const finalStatus = turnFinalStatus(prevState.status);
837
971
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
838
972
  pp.setChatAvatar(displayChatId, prevState.tool, "idle").catch(() => {});
839
973
  } else {
840
974
  const nextSeq = display.sequence + 1;
841
- const headerTitle = prevState.status === "stopped" ? "已停止" : "完成";
842
- const headerTemplate = prevState.status === "stopped" ? "red" : undefined;
975
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status);
843
976
  const cardContent = truncateContent(prevState.accumulatedContent + prevState.finalReply) || " ";
844
977
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
845
978
  await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
@@ -848,7 +981,7 @@ export async function runAgentSession(
848
981
  displayCards.delete(displayChatId);
849
982
 
850
983
  // 持久化:标记上一轮所有卡片为终态
851
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
984
+ const finalStatus = turnFinalStatus(prevState.status);
852
985
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
853
986
 
854
987
  if (prevState.finalReply && stillOursAfterUpdate && !isFinalReplySentForTurn(prevState)) {
@@ -858,7 +991,7 @@ export async function runAgentSession(
858
991
  }
859
992
  } else if (pp && prevState.finalReply && !isFinalReplySentForTurn(prevState)) {
860
993
  // 无 display 记录但上一轮有 finalReply(极快轮次),至少发送
861
- const finalStatus = prevState.status === "stopped" ? "stopped" : "done";
994
+ const finalStatus = turnFinalStatus(prevState.status);
862
995
  finalizeTurnCards(sessionId, prevState.turnCount, finalStatus).catch(() => {});
863
996
  await sendFinalReplyTextOnce(pp, displayChatId, sessionId, prevState.turnCount, prevState.finalReply);
864
997
  }
@@ -928,7 +1061,14 @@ export async function runAgentSession(
928
1061
  const toolCallMap = new Map<string, { name: string; input: unknown }>();
929
1062
 
930
1063
  try {
931
- for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal)) {
1064
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1065
+ onProcessStart: (processInfo) => {
1066
+ startPromptProcessMonitor(sessionId, processInfo);
1067
+ },
1068
+ onProcessExit: () => {
1069
+ clearPromptProcessMonitor(sessionId);
1070
+ },
1071
+ })) {
932
1072
  for (const block of unifiedMsg.blocks) {
933
1073
  accumulateBlockContent(block, state, toolCallMap);
934
1074
 
@@ -971,13 +1111,15 @@ export async function runAgentSession(
971
1111
  // 标记 prompt 结束
972
1112
  const prompt = activePrompts.get(sessionId);
973
1113
  const wasStopped = prompt?.stopped ?? false;
1114
+ const wasAbnormalExit = prompt?.abnormalExit ?? false;
1115
+ clearPromptProcessMonitor(sessionId);
974
1116
  activePrompts.delete(sessionId);
975
1117
 
976
1118
  // 先写最终状态(done/stopped),确保 display loop 在下一轮消费前
977
1119
  // 读到新状态并终结旧卡片。否则 setImmediate 在 CHECK 阶段先于
978
1120
  // writeFile I/O(POLL 阶段)执行,display loop 会误以为旧轮仍在
979
1121
  // 运行中并更新旧卡片,而不是新建卡片。
980
- const finalStatus = wasStopped ? "stopped" : "done";
1122
+ const finalStatus = wasAbnormalExit ? "error" : wasStopped ? "stopped" : "done";
981
1123
  const finalReply = pickFinalReply(state).trim();
982
1124
  await writeStreamState({
983
1125
  sessionId,
@@ -1039,6 +1181,23 @@ export async function runAgentSession(
1039
1181
  if (active1) platform.setChatAvatar(active1, tool, "idle").catch(() => {});
1040
1182
  console.log(`[${ts()}] Session ${sessionId} stopped (content chunks: ${state.chunkCount})`);
1041
1183
  if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "stopped", chunks: state.chunkCount });
1184
+ } else if (wasAbnormalExit) {
1185
+ for (const cid of getChatsForSession(sessionId)) {
1186
+ const finfo = sessionInfoMap.get(cid);
1187
+ await recordSessionRegistry({
1188
+ chatId: cid,
1189
+ sessionId,
1190
+ tool,
1191
+ turnCount: finfo?.turnCount ?? nextTurnCount,
1192
+ lastContextTokens: finfo?.lastContextTokens ?? nextContextTokens,
1193
+ startTime: finfo?.startTime ?? now,
1194
+ running: false,
1195
+ });
1196
+ }
1197
+ const activeErr = getLastActiveChat(sessionId) ?? getChatsForSession(sessionId)[0];
1198
+ if (activeErr) platform.setChatAvatar(activeErr, tool, "idle").catch(() => {});
1199
+ console.log(`[${ts()}] Session ${sessionId} process exited unexpectedly (content chunks: ${state.chunkCount})`);
1200
+ if (tid) logTrace(tid, "SESSION_END", { sessionId, outcome: "process_missing", chunks: state.chunkCount });
1042
1201
  } else {
1043
1202
  for (const cid of getChatsForSession(sessionId)) {
1044
1203
  const finfo = sessionInfoMap.get(cid);
@@ -1140,8 +1299,7 @@ export function startUnifiedDisplayLoop(): void {
1140
1299
  // 发送最终结果(卡片平台)
1141
1300
  while (display.cardBusy) await new Promise(r => setTimeout(r, 20));
1142
1301
  const nextSeq = display.sequence + 1;
1143
- const headerTitle = state.status === "stopped" ? "已停止" : "完成";
1144
- const headerTemplate = state.status === "stopped" ? "red" : undefined;
1302
+ const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status);
1145
1303
  const cardContent = truncateContent(state.accumulatedContent + state.finalReply) || " ";
1146
1304
  const doneCard = buildProgressCard(cardContent, { showStop: false, headerTitle, headerTemplate });
1147
1305
  await p.cardUpdate(display.cardId, doneCard, nextSeq).catch(() => {});
@@ -1156,7 +1314,7 @@ export function startUnifiedDisplayLoop(): void {
1156
1314
  continue;
1157
1315
  }
1158
1316
 
1159
- const finalSt = state.status === "stopped" ? "stopped" : "done";
1317
+ const finalSt = turnFinalStatus(state.status);
1160
1318
  finalizeTurnCards(sessionId, state.turnCount, finalSt).catch(() => {});
1161
1319
  displayCards.delete(chatId);
1162
1320
  if (state.finalReply) {
@@ -1343,6 +1501,7 @@ export function stopSession(sessionId: string): boolean {
1343
1501
  const prompt = activePrompts.get(sessionId);
1344
1502
  if (!prompt) return false;
1345
1503
  prompt.stopped = true;
1504
+ clearPromptProcessMonitor(sessionId);
1346
1505
  cancelQueuedMessage(sessionId);
1347
1506
  prompt.controller.abort();
1348
1507
  console.log(`[${ts()}] [STOP] Session ${sessionId} aborted`);
@@ -1408,7 +1567,7 @@ async function resolveModelEffort(
1408
1567
  if (tool === "cursor") {
1409
1568
  let model = UNKNOWN_MODEL_PLACEHOLDER;
1410
1569
  try {
1411
- const adapter = getAdapterForTool(tool);
1570
+ const adapter = getAdapterForTool(tool, sessionId);
1412
1571
  const info = await adapter.getSessionInfo(sessionId);
1413
1572
  if (info?.model) model = info.model;
1414
1573
  } catch {
@@ -1425,7 +1584,7 @@ async function resolveModelEffort(
1425
1584
  };
1426
1585
  }
1427
1586
  return {
1428
- model: anthropicConfigDisplay(CLAUDE_MODEL),
1587
+ model: anthropicConfigDisplay(getModelForSession(sessionId)),
1429
1588
  effort: anthropicConfigDisplay(CLAUDE_EFFORT),
1430
1589
  };
1431
1590
  }
@@ -1434,7 +1593,8 @@ export async function getSessionStatus(chatId: string): Promise<SessionStatus |
1434
1593
  const info = sessionInfoMap.get(chatId);
1435
1594
  if (!info) return null;
1436
1595
 
1437
- const isActive = activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped);
1596
+ const activePrompt = activePrompts.get(info.sessionId);
1597
+ const isActive = !!activePrompt && !activePrompt.stopped && !activePrompt.abnormalExit;
1438
1598
  const { model, effort } = await resolveModelEffort(info.tool, info.sessionId);
1439
1599
 
1440
1600
  const registry = await loadSessionRegistry();
@@ -1509,7 +1669,9 @@ export async function getAllSessionsStatus(): Promise<SessionsListEntry[]> {
1509
1669
  chatId: info.chatId,
1510
1670
  sessionId: info.sessionId,
1511
1671
  chatName: info.chatName || "",
1512
- active: activePrompts.has(info.sessionId) && !(activePrompts.get(info.sessionId)?.stopped),
1672
+ active: !!activePrompts.get(info.sessionId) &&
1673
+ !activePrompts.get(info.sessionId)?.stopped &&
1674
+ !activePrompts.get(info.sessionId)?.abnormalExit,
1513
1675
  turnCount: info.turnCount,
1514
1676
  startTime: info.startTime,
1515
1677
  model,
@@ -9,17 +9,17 @@ 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
+ 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;
23
23
  updatedAt: number;
24
24
  cwd: string;
25
25
  tool: string;
@@ -74,7 +74,7 @@ export function _resetRenameForTest(): void {
74
74
  * 真文件,这一帧可能被 reader 读到半截,但 readStreamState 的 try/catch 兜底
75
75
  * 会让 loop 跳过这一帧,2 秒后下次 write 大概率成功——比写盘失败丢数据好。
76
76
  */
77
- export async function writeStreamState(state: StreamState): Promise<void> {
77
+ export async function writeStreamState(state: StreamState): Promise<void> {
78
78
  const filePath = getStreamStatePath(state.sessionId);
79
79
  const payload = JSON.stringify(state, null, 2);
80
80
  try {
@@ -98,26 +98,26 @@ export async function writeStreamState(state: StreamState): Promise<void> {
98
98
  } catch (err) {
99
99
  console.error(`[${ts()}] Failed to write stream-state for ${state.sessionId}: ${(err as Error).message}`);
100
100
  }
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 {
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 {
121
121
  sessionId,
122
122
  status: "running",
123
123
  accumulatedContent: "",
@@ -157,4 +157,4 @@ export async function fixStaleStreamStates(): Promise<void> {
157
157
  } catch (err) {
158
158
  console.error(`[${ts()}] [STREAM-STATE] fixStaleStreamStates failed: ${(err as Error).message}`);
159
159
  }
160
- }
160
+ }