chatccc 0.2.203 → 0.2.205

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
@@ -21,7 +21,12 @@ import {
21
21
  toolDisplayName,
22
22
  ts,
23
23
  } from "./config.ts";
24
- import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
24
+ import { buildProgressCard, getToolEmoji, isCodeBlockOpen, truncateContent } from "./cards.ts";
25
+ import {
26
+ createAgentActivityTracker,
27
+ formatAgentActivityTitle,
28
+ updateAgentActivity,
29
+ } from "./agent-activity.ts";
25
30
  import { simplifyToolUse, simplifyToolResult } from "./simplify.ts";
26
31
  import { logTrace } from "./trace.ts";
27
32
  import type { UnifiedBlock } from "./adapters/adapter-interface.ts";
@@ -82,18 +87,19 @@ async function sendFinalReplyTextOnce(
82
87
  return sent;
83
88
  }
84
89
 
85
- async function createVisibleProgressCard(
90
+ async function createVisibleProgressCard(
86
91
  platform: PlatformAdapter,
87
92
  chatId: string,
88
93
  sessionId: string,
89
- turnCount: number,
90
- notifyFailureText?: string,
91
- ): Promise<string | null> {
94
+ turnCount: number,
95
+ notifyFailureText?: string,
96
+ headerTitle = "正在启动 Agent · 0秒",
97
+ ): Promise<string | null> {
92
98
  for (let attempt = 1; attempt <= 2; attempt++) {
93
99
  let cardId: string | null = null;
94
100
  try {
95
- cardId = await platform.cardCreate(
96
- buildProgressCard("", { showStop: true, headerTitle: "生成中..." }),
101
+ cardId = await platform.cardCreate(
102
+ buildProgressCard("等待 Agent 输出...", { showStop: true, headerTitle }),
97
103
  );
98
104
  if (!cardId) throw new Error("empty card id");
99
105
  await platform.cardSend(chatId, cardId);
@@ -1115,29 +1121,33 @@ export async function runAgentSession(
1115
1121
  }
1116
1122
 
1117
1123
  // 初始化 stream-state.json
1118
- const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1119
- await writeStreamState(initialState);
1124
+ const initialState = createEmptyStreamState(sessionId, cwd, tool, nextTurnCount);
1125
+ const activityTracker = createAgentActivityTracker(initialState.activity?.startedAt ?? Date.now());
1126
+ await writeStreamState(initialState);
1120
1127
 
1121
1128
  // 为新 turn 创建第一张展示卡片,同时注册到 turn-cards 持久化。
1122
1129
  // 统一 display loop 始终运行,卡片创建后下一个 tick 即自动开始更新。
1123
1130
  const displayChatIdForNew = pickDisplayChat(sessionId);
1124
- if (displayChatIdForNew) {
1125
- const ppNew = platformForChat(displayChatIdForNew);
1126
- if (ppNew && ppNew.kind !== "wechat") {
1127
- const cardId = await createVisibleProgressCard(
1131
+ if (displayChatIdForNew) {
1132
+ const ppNew = platformForChat(displayChatIdForNew);
1133
+ if (ppNew && ppNew.kind !== "wechat") {
1134
+ const initialHeaderTitle = formatAgentActivityTitle(activityTracker.activity);
1135
+ const cardId = await createVisibleProgressCard(
1128
1136
  ppNew,
1129
1137
  displayChatIdForNew,
1130
1138
  sessionId,
1131
- nextTurnCount,
1132
- "生成中卡片发送失败,结果将以文本形式发送。",
1139
+ nextTurnCount,
1140
+ "生成中卡片发送失败,结果将以文本形式发送。",
1141
+ initialHeaderTitle,
1133
1142
  );
1134
1143
  if (cardId) {
1135
1144
  displayCards.set(displayChatIdForNew, {
1136
1145
  cardId,
1137
1146
  sequence: 1,
1138
1147
  cardBusy: false,
1139
- cardCreatedAt: Date.now(),
1140
- lastSentContent: "",
1148
+ cardCreatedAt: Date.now(),
1149
+ lastSentContent: "",
1150
+ lastSentHeaderTitle: initialHeaderTitle,
1141
1151
  streamErrorNotified: false,
1142
1152
  sessionId,
1143
1153
  turnCount: nextTurnCount,
@@ -1179,7 +1189,7 @@ export async function runAgentSession(
1179
1189
  let streamErrored = false;
1180
1190
 
1181
1191
  try {
1182
- for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1192
+ for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1183
1193
  onProcessStart: (processInfo) => {
1184
1194
  startPromptProcessMonitor(sessionId, processInfo);
1185
1195
  if (processInfo.pid !== undefined) registerProcess(processInfo.pid, sessionId);
@@ -1193,8 +1203,10 @@ export async function runAgentSession(
1193
1203
  if (prompt) prompt.closeSession = closeSession;
1194
1204
  },
1195
1205
  })) {
1196
- for (const block of unifiedMsg.blocks) {
1197
- accumulateBlockContent(block, state, toolCallMap);
1206
+ let activityChanged = false;
1207
+ for (const block of unifiedMsg.blocks) {
1208
+ if (updateAgentActivity(activityTracker, block)) activityChanged = true;
1209
+ accumulateBlockContent(block, state, toolCallMap);
1198
1210
 
1199
1211
  if (block.type === "compact_boundary" && block.post_tokens) {
1200
1212
  for (const cid of getChatsForSession(sessionId)) {
@@ -1213,13 +1225,14 @@ export async function runAgentSession(
1213
1225
 
1214
1226
  // 定时写入文件
1215
1227
  const now2 = Date.now();
1216
- if (now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1217
- lastFileWrite = now2;
1218
- await writeStreamState({
1228
+ if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1229
+ lastFileWrite = now2;
1230
+ await writeStreamState({
1219
1231
  sessionId,
1220
1232
  status: "running",
1221
- accumulatedContent: state.accumulatedContent,
1222
- finalReply: pickFinalReply(state),
1233
+ accumulatedContent: state.accumulatedContent,
1234
+ finalReply: pickFinalReply(state),
1235
+ activity: activityTracker.activity,
1223
1236
  chunkCount: state.chunkCount,
1224
1237
  turnCount: nextTurnCount,
1225
1238
  contextTokens: existingInfo?.lastContextTokens ?? 0,
@@ -1267,8 +1280,9 @@ export async function runAgentSession(
1267
1280
  await writeStreamState({
1268
1281
  sessionId,
1269
1282
  status: finalStatus,
1270
- accumulatedContent: state.accumulatedContent,
1271
- finalReply: finalReplyToWrite,
1283
+ accumulatedContent: state.accumulatedContent,
1284
+ finalReply: finalReplyToWrite,
1285
+ activity: activityTracker.activity,
1272
1286
  chunkCount: state.chunkCount,
1273
1287
  turnCount: nextTurnCount,
1274
1288
  contextTokens: existingInfo?.lastContextTokens ?? 0,
@@ -1553,14 +1567,16 @@ export function startUnifiedDisplayLoop(): void {
1553
1567
  }
1554
1568
  } else {
1555
1569
  // 非 WeChat: 卡片流程
1556
- if (display.turnCount !== state.turnCount) {
1570
+ if (display.turnCount !== state.turnCount) {
1557
1571
  console.log(`[${ts()}] [DISPLAY] turn mismatch for ${chatId}: display.turnCount=${display.turnCount} state.turnCount=${state.turnCount}, resetting`);
1558
1572
  finalizeTurnCards(sessionId, display.turnCount, "done").catch(() => {});
1559
1573
  displayCards.delete(chatId);
1560
- continue;
1561
- }
1562
-
1563
- // 卡片轮转
1574
+ continue;
1575
+ }
1576
+
1577
+ const activityHeaderTitle = formatAgentActivityTitle(state.activity, Date.now());
1578
+
1579
+ // 卡片轮转
1564
1580
  if (Date.now() - display.cardCreatedAt > CARD_ROTATE_MS) {
1565
1581
  display.cardBusy = true;
1566
1582
  try {
@@ -1568,8 +1584,9 @@ export function startUnifiedDisplayLoop(): void {
1568
1584
  p,
1569
1585
  chatId,
1570
1586
  sessionId,
1571
- display.turnCount,
1572
- display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
1587
+ display.turnCount,
1588
+ display.streamErrorNotified ? undefined : "生成中卡片发送失败,结果将继续更新在上一张卡片中。",
1589
+ activityHeaderTitle,
1573
1590
  );
1574
1591
  if (!newCardId) {
1575
1592
  display.streamErrorNotified = true;
@@ -1577,7 +1594,7 @@ export function startUnifiedDisplayLoop(): void {
1577
1594
  }
1578
1595
  const oldSeqBase = display.sequence;
1579
1596
  const oldContent = state.accumulatedContent + state.finalReply;
1580
- const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "生成中(上轮)" });
1597
+ const oldCard = buildProgressCard(truncateContent(oldContent) || " ", { showStop: false, headerTitle: "上一阶段记录" });
1581
1598
  await p.cardUpdate(display.cardId, oldCard, oldSeqBase + 1).then(() => {
1582
1599
  display.sequence = oldSeqBase + 1;
1583
1600
  }).catch(err => {
@@ -1588,9 +1605,10 @@ export function startUnifiedDisplayLoop(): void {
1588
1605
  display.sequence = 1;
1589
1606
  display.cardCreatedAt = Date.now();
1590
1607
  display.rotationAccLen = state.accumulatedContent.length;
1591
- display.rotationFinalReply = state.finalReply;
1592
- display.lastSentContent = "";
1593
- display.streamErrorNotified = false;
1608
+ display.rotationFinalReply = state.finalReply;
1609
+ display.lastSentContent = "";
1610
+ display.lastSentHeaderTitle = activityHeaderTitle;
1611
+ display.streamErrorNotified = false;
1594
1612
  } catch (err) {
1595
1613
  console.error(`[${ts()}] [CARDIKT] rotation FAIL for ${chatId}: ${(err as Error).message}`);
1596
1614
  } finally {
@@ -1608,17 +1626,24 @@ export function startUnifiedDisplayLoop(): void {
1608
1626
  replyDelta = state.finalReply.slice(rotReply.length);
1609
1627
  } else {
1610
1628
  replyDelta = state.finalReply;
1611
- }
1612
- const delta = (accDelta + replyDelta).trim();
1613
-
1614
- display.dotCount = (display.dotCount % 9) + 1;
1615
- let deltaBase = (delta || "");
1616
- if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
1617
- const displayContent = deltaBase + "\n" + "。" .repeat(display.dotCount);
1618
- if (displayContent === display.lastSentContent) continue;
1619
-
1620
- display.lastSentContent = displayContent;
1621
- const deltaCard = buildProgressCard(truncateContent(displayContent) || "处理中...", { showStop: true, headerTitle: "生成中..." });
1629
+ }
1630
+ const delta = (accDelta + replyDelta).trim();
1631
+
1632
+ display.dotCount = (display.dotCount % 9) + 1;
1633
+ let deltaBase = delta;
1634
+ if (isCodeBlockOpen(deltaBase)) deltaBase += "\n```";
1635
+ const displayContent = deltaBase + "\n" + "。".repeat(display.dotCount);
1636
+ if (
1637
+ displayContent === display.lastSentContent
1638
+ && activityHeaderTitle === display.lastSentHeaderTitle
1639
+ ) continue;
1640
+
1641
+ display.lastSentContent = displayContent;
1642
+ display.lastSentHeaderTitle = activityHeaderTitle;
1643
+ const deltaCard = buildProgressCard(truncateContent(displayContent) || "等待 Agent 输出...", {
1644
+ showStop: true,
1645
+ headerTitle: activityHeaderTitle,
1646
+ });
1622
1647
  display.cardBusy = true;
1623
1648
  const mySeq = display.sequence + 1;
1624
1649
  try {
@@ -1637,20 +1662,24 @@ export function startUnifiedDisplayLoop(): void {
1637
1662
  display.cardBusy = false;
1638
1663
  }
1639
1664
  continue;
1640
- }
1641
-
1642
- display.dotCount = (display.dotCount % 9) + 1;
1643
- let contentBase = state.accumulatedContent + state.finalReply;
1644
- if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
1645
- const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
1646
- if (fullContent === display.lastSentContent) continue;
1647
-
1648
- display.lastSentContent = fullContent;
1649
- const cardContent = truncateContent(fullContent);
1665
+ }
1666
+
1667
+ display.dotCount = (display.dotCount % 9) + 1;
1668
+ let contentBase = state.accumulatedContent + state.finalReply;
1669
+ if (isCodeBlockOpen(contentBase)) contentBase += "\n```";
1670
+ const fullContent = contentBase + "\n" + "。".repeat(display.dotCount);
1671
+ if (
1672
+ fullContent === display.lastSentContent
1673
+ && activityHeaderTitle === display.lastSentHeaderTitle
1674
+ ) continue;
1675
+
1676
+ display.lastSentContent = fullContent;
1677
+ display.lastSentHeaderTitle = activityHeaderTitle;
1678
+ const cardContent = truncateContent(fullContent) || "等待 Agent 输出...";
1650
1679
  display.cardBusy = true;
1651
1680
  const mySeq = display.sequence + 1;
1652
1681
  try {
1653
- const card = buildProgressCard(cardContent, { showStop: true, headerTitle: "生成中..." });
1682
+ const card = buildProgressCard(cardContent, { showStop: true, headerTitle: activityHeaderTitle });
1654
1683
  await p.cardUpdate(display.cardId, card, mySeq);
1655
1684
  display.sequence = mySeq;
1656
1685
  } catch (err) {
@@ -0,0 +1,96 @@
1
+ import { spawn, type ChildProcess } from "node:child_process";
2
+
3
+ /**
4
+ * ChatCCC 自己拉起替代进程时使用的内部标记。
5
+ *
6
+ * 不能用“是否已有配置”判断是否打开控制台:首次配置和日常直接启动都应该
7
+ * 打开,而 `/restart`、`/update` 和 Web UI 重启都不应该打扰用户。环境变量
8
+ * 会自然穿过 cmd/bash/npx 这几层启动器,因此也适用于 Windows 与 Linux。
9
+ */
10
+ export const INTERNAL_RESTART_ENV_VAR = "CHATCCC_INTERNAL_RESTART";
11
+
12
+ type Environment = Record<string, string | undefined>;
13
+
14
+ export function createInternalRestartEnv(
15
+ inherited: Environment = process.env,
16
+ ): NodeJS.ProcessEnv {
17
+ return {
18
+ ...inherited,
19
+ [INTERNAL_RESTART_ENV_VAR]: "1",
20
+ };
21
+ }
22
+
23
+ /** 用户直接启动时打开;ChatCCC 内部重启产生的替代进程不打开。 */
24
+ interface AutoOpenWebUiOptions {
25
+ env?: Environment;
26
+ openOnStart?: boolean;
27
+ }
28
+
29
+ export function shouldAutoOpenWebUi(options: AutoOpenWebUiOptions = {}): boolean {
30
+ const env = options.env ?? process.env;
31
+ return options.openOnStart !== false && env[INTERNAL_RESTART_ENV_VAR] !== "1";
32
+ }
33
+
34
+ /** Web UI 始终使用 localhost,并跟随实际配置端口。 */
35
+ export function buildWebUiUrl(port: number): string {
36
+ return `http://localhost:${port}/`;
37
+ }
38
+
39
+ interface OpenBrowserDeps {
40
+ platform?: NodeJS.Platform;
41
+ env?: Environment;
42
+ spawnImpl?: typeof spawn;
43
+ onError?: (message: string) => void;
44
+ onInfo?: (message: string) => void;
45
+ }
46
+
47
+ /**
48
+ * 调用操作系统默认浏览器打开 Web UI,与 Chrome CDP 守护功能完全独立。
49
+ * 返回值仅表示打开请求是否成功发起;浏览器是否复用标签页由系统浏览器决定。
50
+ */
51
+ export function openWebUiInDefaultBrowser(
52
+ port: number,
53
+ deps: OpenBrowserDeps = {},
54
+ ): boolean {
55
+ const url = buildWebUiUrl(port);
56
+ const platform = deps.platform ?? process.platform;
57
+ const env = deps.env ?? process.env;
58
+ const spawnImpl = deps.spawnImpl ?? spawn;
59
+ const onError = deps.onError ?? ((message: string) => console.error(message));
60
+ const onInfo = deps.onInfo ?? ((message: string) => console.log(message));
61
+
62
+ // Linux 服务器通常没有图形会话。此时调用 xdg-open 只会制造噪音;
63
+ // 直接给出可复制的 SSH 隧道命令,让用户从自己的电脑访问本地 Web UI。
64
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
65
+ onInfo(
66
+ `[WEB-UI] 未检测到 Linux 图形桌面,跳过自动打开浏览器。` +
67
+ `可在本机执行 ssh -L ${port}:127.0.0.1:${port} <user>@<server>,` +
68
+ `然后访问 ${url}`,
69
+ );
70
+ return false;
71
+ }
72
+
73
+ try {
74
+ let child: ChildProcess;
75
+ if (platform === "win32") {
76
+ // `start` 会把第一个带引号的参数当窗口标题,空字符串是必要占位符。
77
+ child = spawnImpl("cmd.exe", ["/c", "start", "", url], {
78
+ detached: true,
79
+ stdio: "ignore",
80
+ windowsHide: true,
81
+ });
82
+ } else if (platform === "darwin") {
83
+ child = spawnImpl("open", [url], { detached: true, stdio: "ignore" });
84
+ } else {
85
+ child = spawnImpl("xdg-open", [url], { detached: true, stdio: "ignore" });
86
+ }
87
+ child.on("error", (err) => {
88
+ onError(`[WEB-UI] 自动打开浏览器失败: ${err.message}`);
89
+ });
90
+ child.unref();
91
+ return true;
92
+ } catch (err) {
93
+ onError(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
94
+ return false;
95
+ }
96
+ }
@@ -1,7 +1,9 @@
1
1
  import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
 
4
- import { USER_DATA_DIR, ts } from "./config.ts";
4
+ import { USER_DATA_DIR, ts } from "./config.ts";
5
+ import { createAgentActivityTracker } from "./agent-activity.ts";
6
+ import type { AgentActivity } from "./agent-activity.ts";
5
7
 
6
8
  // ---------------------------------------------------------------------------
7
9
  // stream-state.json — 每个 session 的流式输出持久化文件
@@ -16,7 +18,9 @@ export interface StreamState {
16
18
  /** 本轮会话中 LLM 输出的全部文本内容(所有 text block 的累加)。
17
19
  * 命名含 "final" 但实为"全部累积文本",并非仅"最终一段回复"。
18
20
  * 参见 session.ts 的 AccumulatorState 注释。 */
19
- finalReply: string;
21
+ finalReply: string;
22
+ /** Current user-visible work phase for running progress cards. */
23
+ activity?: AgentActivity;
20
24
  /** The turn whose terminal text reply has already been delivered to IM. */
21
25
  finalReplySentTurn?: number;
22
26
  finalReplySentAt?: number;
@@ -122,16 +126,18 @@ export async function markFinalReplySent(sessionId: string, turnCount: number, s
122
126
  await writeStreamState(state);
123
127
  }
124
128
 
125
- export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
126
- return {
129
+ export function createEmptyStreamState(sessionId: string, cwd: string, tool: string, turnCount: number): StreamState {
130
+ const now = Date.now();
131
+ return {
127
132
  sessionId,
128
133
  status: "running",
129
- accumulatedContent: "",
130
- finalReply: "",
134
+ accumulatedContent: "",
135
+ finalReply: "",
136
+ activity: createAgentActivityTracker(now).activity,
131
137
  chunkCount: 0,
132
138
  turnCount,
133
139
  contextTokens: 0,
134
- updatedAt: Date.now(),
140
+ updatedAt: now,
135
141
  cwd,
136
142
  tool,
137
143
  };