chatccc 0.2.266 → 0.2.268

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/dist/src/index.js CHANGED
@@ -47,7 +47,7 @@ import { loadSessionRegistryForBinding, rebuildBindingsFromRegistry, resetState,
47
47
  import { startChromeDevtoolsGuard, stopChromeDevtoolsGuard } from "./chrome-devtools-guard.js";
48
48
  import { rebuildSessionChatsFromRegistry, setQueueConsumer, } from "./session-chat-binding.js";
49
49
  import { fixStaleStreamStates } from "./stream-state.js";
50
- import { handleCommand } from "./orchestrator.js";
50
+ import { configureSafeMaintenanceRuntime, handleCommand, recoverSafeMaintenanceAfterStartup, } from "./orchestrator.js";
51
51
  import { createWechatAdapter, startWechatPlatform } from "./wechat-platform.js";
52
52
  import { handleCodexResetCardAction } from "./codex-reset-actions.js";
53
53
  import { resolveFeishuCardActionChatType } from "./card-action-routing.js";
@@ -103,11 +103,12 @@ function createFeishuAdapter() {
103
103
  }
104
104
  const feishuPlatform = createFeishuAdapter();
105
105
  const wechatPlatform = createWechatAdapter();
106
+ configureSafeMaintenanceRuntime([feishuPlatform, wechatPlatform]);
106
107
  setSessionPlatform(feishuPlatform);
107
108
  configureAgentTeamMainAgent(feishuPlatform);
108
109
  // 注册队列消费回调:session 生成完成后自动处理缓存消息
109
110
  setQueueConsumer((platform, msg) => {
110
- handleCommand(platform, msg.text, msg.chatId, msg.openId, msg.msgTimestamp, msg.chatType, msg.traceId).catch(err => console.error(`[${ts()}] Queue consume failed: ${err.message}`));
111
+ handleCommand(platform, msg.text, msg.chatId, msg.openId, msg.msgTimestamp, msg.chatType, msg.traceId, undefined, true).catch(err => console.error(`[${ts()}] Queue consume failed: ${err.message}`));
111
112
  });
112
113
  function getInnerEvent(data) {
113
114
  return (data.event ?? data);
@@ -596,6 +597,7 @@ async function main() {
596
597
  appendStartupTrace(opened ? "web-ui: opening simulate browser" : "web-ui: simulate browser unavailable", { url });
597
598
  }
598
599
  installShutdownHandlers(simServer, serviceLifecycle);
600
+ await recoverSafeMaintenanceAfterStartup();
599
601
  return;
600
602
  }
601
603
  if (Number.isNaN(CHATCCC_PORT) || CHATCCC_PORT < 1 || CHATCCC_PORT > 65535) {
@@ -647,6 +649,7 @@ async function main() {
647
649
  });
648
650
  try {
649
651
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: true });
652
+ await recoverSafeMaintenanceAfterStartup();
650
653
  return { ok: true };
651
654
  }
652
655
  catch (err) {
@@ -714,6 +717,7 @@ async function main() {
714
717
  });
715
718
  }
716
719
  await startConfiguredPlatforms(httpServer, { failOnFeishuError: false });
720
+ await recoverSafeMaintenanceAfterStartup();
717
721
  }
718
722
  /**
719
723
  * 生命周期健康检查发现 HTTP Server 已停止监听时,优先原地恢复同一个 Server。
@@ -17,7 +17,7 @@ import { CLAUDE_MODEL, GIT_TIMEOUT_MS, PROJECT_ROOT, anthropicConfigDisplay, con
17
17
  import { buildHelpCard, buildEffortCard, buildFastModeCard, buildModelCard, buildStatusCard, buildCdContent, buildCdCard, buildSessionsCard, buildQueuedCard, buildQueueFullCard, buildCodexUsageCard, } from "./cards.js";
18
18
  import { formatGitResult, gitResultHeaderTemplate, runGitCommand, } from "./git-command.js";
19
19
  import { clearSessionModelOverride, clearSessionEffortOverride, getSessionStatus, getAllSessionsStatus, initClaudeSession, lastMsgTimestamps, resumeAndPrompt, sessionInfoMap, setSessionModelOverride, setSessionEffortOverride, switchChatBinding, recordSessionRegistry, getAdapterForTool, getEffectiveModelForTool, getEffectiveEffortForTool, getEffectiveFastModeForTool, setSessionFastModeOverride, stopSession, loadSessionRegistryForBinding, removeSessionRegistryRecord, saveSessionTool, recordChatPlatform, } from "./session.js";
20
- import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, } from "./session-chat-binding.js";
20
+ import { bindChatToSession, unbindChatFromSession, isSessionRunning, displayCards, enqueueMessage, cancelQueuedMessage, getSessionDrainSnapshot, } from "./session-chat-binding.js";
21
21
  import { getCodexUsageSummary, getTenantAccessToken, sendPostMessage } from "./feishu-platform.js";
22
22
  import { getCursorUsageSummary } from "./cursor-usage.js";
23
23
  import { getChatGptSubscriptionStatus } from "./chatgpt-subscription.js";
@@ -25,8 +25,10 @@ import { applySharedPrefix } from "./shared-prefix.js";
25
25
  import { sessionChatName } from "./session-name.js";
26
26
  import { reloadRuntimeConfig } from "./runtime-reload.js";
27
27
  import { acquireUpdateCommandGuard } from "./update-command-guard.js";
28
- import { createInternalRestartEnv } from "./startup-lifecycle.js";
28
+ import { createInternalRestartEnv, INTERNAL_RESTART_ENV_VAR } from "./startup-lifecycle.js";
29
29
  import { resolveChatCccRuntimeSpawnSpec } from "./runtime-entry.js";
30
+ import { engineManager } from "./engines/engine-specs.js";
31
+ import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, safeMaintenanceCoordinator, } from "./safe-maintenance.js";
30
32
  import { feishuP2pContactStore, isValidFeishuOpenId, } from "./agent-team/repositories/feishu-p2p-contact-store.js";
31
33
  // ---------------------------------------------------------------------------
32
34
  // 辅助函数
@@ -577,10 +579,11 @@ function updLog(msg) {
577
579
  catch { }
578
580
  }
579
581
  /** 同步更新 npm 全局包并 spawn 新进程重启。不依赖 systemd 或任何服务管理器。 */
580
- function syncUpdateAndRestart() {
582
+ function syncUpdateAndRestart(options = {}) {
581
583
  updLog(`sync update start, pid=${process.pid}`);
582
584
  appendStartupTrace("update: sync update start", { pid: process.pid });
583
585
  const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
586
+ let updateSucceeded = false;
584
587
  // 1. npm update
585
588
  updLog(`running: ${npmCmd} update -g chatccc`);
586
589
  appendStartupTrace("update: npm update begin", { npmCmd });
@@ -590,6 +593,7 @@ function syncUpdateAndRestart() {
590
593
  const elapsed = Date.now() - t0;
591
594
  updLog(`npm update OK (${elapsed}ms): ${out.slice(0, 500)}`);
592
595
  appendStartupTrace("update: npm update OK", { elapsedMs: elapsed, outputLen: out.length });
596
+ updateSucceeded = true;
593
597
  }
594
598
  catch (e) {
595
599
  const elapsed = Date.now() - t0;
@@ -605,6 +609,7 @@ function syncUpdateAndRestart() {
605
609
  const elapsed2 = Date.now() - t1;
606
610
  updLog(`npm install fallback OK (${elapsed2}ms): ${out2.slice(0, 500)}`);
607
611
  appendStartupTrace("update: npm install fallback OK", { elapsedMs: elapsed2, outputLen: out2.length });
612
+ updateSucceeded = true;
608
613
  }
609
614
  catch (e2) {
610
615
  const elapsed2 = Date.now() - t1;
@@ -613,6 +618,11 @@ function syncUpdateAndRestart() {
613
618
  appendStartupTrace("update: npm install fallback failed", { elapsedMs: elapsed2, message: err2.message });
614
619
  }
615
620
  }
621
+ if (!updateSucceeded && options.spawnOnUpdateFailure === false) {
622
+ updLog("safe update aborted: both npm update and fallback install failed");
623
+ appendStartupTrace("update: safe update aborted before restart", {});
624
+ return undefined;
625
+ }
616
626
  // 2. resolve bin path
617
627
  const npmPrefix = process.env.NPM_PREFIX || "";
618
628
  const binName = process.platform === "win32" ? "chatccc.cmd" : "chatccc";
@@ -771,15 +781,106 @@ export async function decideRestartParentExit(child, timeoutMs, pollMs = 500, tr
771
781
  trace("restart: child alive after window, parent exiting", { childPid: child.pid });
772
782
  return true;
773
783
  }
784
+ const safeMaintenancePlatforms = new Map();
785
+ export function configureSafeMaintenanceRuntime(platforms) {
786
+ safeMaintenancePlatforms.clear();
787
+ for (const platform of platforms) {
788
+ if (platform.kind)
789
+ safeMaintenancePlatforms.set(platform.kind, platform);
790
+ }
791
+ safeMaintenanceCoordinator.configure({
792
+ getSnapshot() {
793
+ const sessions = getSessionDrainSnapshot();
794
+ return {
795
+ ...sessions,
796
+ activeEngineIds: engineManager.getActiveInstallIds(),
797
+ activeWorkLabels: [],
798
+ };
799
+ },
800
+ execute(kind) {
801
+ return kind === "update" ? executeSafeUpdate() : executeSafeRestart();
802
+ },
803
+ async notify(requester, message) {
804
+ const platform = safeMaintenancePlatforms.get(requester.platform);
805
+ if (!platform)
806
+ throw new Error(`Unavailable platform: ${requester.platform}`);
807
+ await platform.sendText(requester.chatId, message);
808
+ },
809
+ });
810
+ }
811
+ export function recoverSafeMaintenanceAfterStartup() {
812
+ return safeMaintenanceCoordinator.recoverAfterStartup(process.env[INTERNAL_RESTART_ENV_VAR] === "1");
813
+ }
814
+ async function executeSafeRestart() {
815
+ fileLog.flush();
816
+ appendStartupTrace("safe-maintenance: restart spawn begin", { fromPid: process.pid });
817
+ const child = spawnRestartChild();
818
+ child.unref();
819
+ const shouldExit = await decideRestartParentExit(child, RESTART_CHILD_READY_MS);
820
+ if (!shouldExit)
821
+ return false;
822
+ appendStartupTrace("safe-maintenance: restart parent exit", { childPid: child.pid });
823
+ process.exit(0);
824
+ }
825
+ async function executeSafeUpdate() {
826
+ fileLog.flush();
827
+ appendStartupTrace("safe-maintenance: update begin", { fromPid: process.pid });
828
+ const child = syncUpdateAndRestart({ spawnOnUpdateFailure: false });
829
+ if (!child)
830
+ return false;
831
+ child.unref();
832
+ const shouldExit = await decideRestartParentExit(child, RESTART_CHILD_READY_MS);
833
+ if (!shouldExit)
834
+ return false;
835
+ appendStartupTrace("safe-maintenance: update parent exit", { childPid: child.pid });
836
+ process.exit(0);
837
+ }
838
+ function safeMaintenanceRequester(platform, chatId, openId) {
839
+ return { platform: platform.kind ?? "feishu", chatId, openId };
840
+ }
841
+ function safeMaintenancePhaseLabel(phase) {
842
+ const labels = {
843
+ draining: "等待现有任务结束",
844
+ executing: "正在执行维护",
845
+ completed: "已完成",
846
+ failed: "执行失败",
847
+ };
848
+ return labels[phase] ?? phase;
849
+ }
850
+ async function safeMaintenanceStatusText() {
851
+ const status = await safeMaintenanceCoordinator.status();
852
+ if (!status.job)
853
+ return "当前没有安全维护预约。";
854
+ const snapshot = status.snapshot;
855
+ return [
856
+ `安全维护:${status.job.kind === "update" ? "更新并重启" : "重启"}`,
857
+ `状态:${safeMaintenancePhaseLabel(status.job.phase)}`,
858
+ `执行中/收尾会话:${snapshot.activeSessionIds.length}`,
859
+ `已接受的缓存消息:${snapshot.queuedSessionIds.length}`,
860
+ `依赖安装任务:${snapshot.activeEngineIds.length}`,
861
+ `其他处理中入口:${Math.max(0, snapshot.activeWorkLabels.length - 1)}`,
862
+ ...(status.job.lastError ? [`错误:${status.job.lastError}`] : []),
863
+ ].join("\n");
864
+ }
774
865
  // ---------------------------------------------------------------------------
775
866
  // handleCommand — 平台无关的命令分发
776
867
  // ---------------------------------------------------------------------------
777
- export async function handleCommand(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId) {
868
+ export async function handleCommand(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId, acceptedBeforeSafeMaintenance = false) {
869
+ const release = beginSafeMaintenanceTrackedWork(acceptedBeforeSafeMaintenance ? "accepted-queued-message" : "chat-command");
870
+ try {
871
+ await handleCommandInternal(platform, text, chatId, openId, msgTimestamp, chatType, traceId, commandId, acceptedBeforeSafeMaintenance);
872
+ }
873
+ finally {
874
+ release();
875
+ }
876
+ }
877
+ async function handleCommandInternal(platform, text, chatId, openId, msgTimestamp, chatType = "group", traceId, commandId, acceptedBeforeSafeMaintenance = false) {
778
878
  const tid = traceId ?? makeTraceId();
779
879
  const sharedPrefix = applySharedPrefix(text);
780
880
  const promptText = sharedPrefix.text;
781
881
  text = sharedPrefix.body;
782
882
  const textLower = text.toLowerCase();
883
+ const normalizedCommandText = textLower.trim().replace(/\s+/g, " ");
783
884
  const isCommandText = !sharedPrefix.matched && textLower.startsWith("/");
784
885
  recordChatPlatform(chatId, platform);
785
886
  if (platform.kind === "feishu" && chatType === "p2p" && isValidFeishuOpenId(openId)) {
@@ -788,6 +889,77 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
788
889
  console.error(`[${ts()}] [AGENT-TEAM] Failed to remember Feishu private contact: ${err.message}`);
789
890
  });
790
891
  }
892
+ if (isCommandText && normalizedCommandText === "/safestatus") {
893
+ logTrace(tid, "BRANCH", { cmd: "/safestatus" });
894
+ await platform.sendText(chatId, await safeMaintenanceStatusText()).catch(() => { });
895
+ logTrace(tid, "DONE", { outcome: "safe_maintenance_status" });
896
+ return;
897
+ }
898
+ if (isCommandText && normalizedCommandText === "/cancelsf") {
899
+ logTrace(tid, "BRANCH", { cmd: "/cancelsf" });
900
+ const canceled = await safeMaintenanceCoordinator.cancel();
901
+ if (!canceled) {
902
+ await platform.sendText(chatId, "当前没有可取消的安全维护预约;已经开始执行的维护也不能取消。").catch(() => { });
903
+ }
904
+ logTrace(tid, "DONE", { outcome: canceled ? "safe_maintenance_canceled" : "safe_maintenance_cancel_unavailable" });
905
+ return;
906
+ }
907
+ if (isCommandText && normalizedCommandText === "/restart safe") {
908
+ logTrace(tid, "BRANCH", { cmd: "/restart safe" });
909
+ try {
910
+ const job = await safeMaintenanceCoordinator.schedule("restart", safeMaintenanceRequester(platform, chatId, openId));
911
+ await platform.sendText(chatId, job.phase === "executing"
912
+ ? "安全维护已经开始执行,无法重复预约。"
913
+ : job.kind === "update"
914
+ ? "已经存在优先级更高的安全更新预约,将继续等待现有任务完成。发送 /safestatus 查看状态,/cancelsf 取消预约。"
915
+ : "已预约安全重启。现有会话、缓存消息和依赖安装会先自然完成;现在起不再接受新的普通任务。发送 /safestatus 查看状态,/cancelsf 取消预约。").catch(() => { });
916
+ logTrace(tid, "DONE", { outcome: "safe_restart_scheduled", jobId: job.jobId });
917
+ }
918
+ catch (error) {
919
+ await platform.sendText(chatId, `安全重启预约失败:${error.message}`).catch(() => { });
920
+ logTrace(tid, "DONE", { outcome: "safe_restart_schedule_failed", error: error.message });
921
+ }
922
+ return;
923
+ }
924
+ if (isCommandText && normalizedCommandText === "/update safe") {
925
+ logTrace(tid, "BRANCH", { cmd: "/update safe" });
926
+ const isGlobal = isRunningFromGlobalNpm();
927
+ if (!isGlobal) {
928
+ await platform.sendText(chatId, "当前进程非 npm 全局安装,无法使用 /update safe。请通过 npm install -g chatccc 安装后使用。").catch(() => { });
929
+ logTrace(tid, "DONE", { outcome: "safe_update_not_global" });
930
+ return;
931
+ }
932
+ const updateGuard = acquireUpdateCommandGuard({ commandId });
933
+ if (!updateGuard.allowed) {
934
+ if (updateGuard.reason !== "duplicate_id") {
935
+ await platform.sendText(chatId, "无法写入更新保护状态。为避免连续更新和重启,本次 /update safe 未预约。").catch(() => { });
936
+ }
937
+ logTrace(tid, "DONE", { outcome: updateGuard.reason === "duplicate_id" ? "safe_update_duplicate_id" : "safe_update_guard_failed" });
938
+ return;
939
+ }
940
+ try {
941
+ const job = await safeMaintenanceCoordinator.schedule("update", safeMaintenanceRequester(platform, chatId, openId));
942
+ await platform.sendText(chatId, job.phase === "executing"
943
+ ? "安全维护已经开始执行,无法重复预约。"
944
+ : "已预约安全更新。现有会话、缓存消息和依赖安装会先自然完成;现在起不再接受新的普通任务。发送 /safestatus 查看状态,/cancelsf 取消预约。").catch(() => { });
945
+ logTrace(tid, "DONE", { outcome: "safe_update_scheduled", jobId: job.jobId });
946
+ }
947
+ catch (error) {
948
+ await platform.sendText(chatId, `安全更新预约失败:${error.message}`).catch(() => { });
949
+ logTrace(tid, "DONE", { outcome: "safe_update_schedule_failed", error: error.message });
950
+ }
951
+ return;
952
+ }
953
+ const maintenanceAllowedCommands = new Set([
954
+ "/stop", "/cancel", "/state", "/sessions", "/usage", "/safestatus", "/cancelsf", "/restart", "/update", "/reload", "/help",
955
+ ]);
956
+ if (isSafeMaintenanceAdmissionClosed()
957
+ && !acceptedBeforeSafeMaintenance
958
+ && (!isCommandText || !maintenanceAllowedCommands.has(normalizedCommandText))) {
959
+ await platform.sendText(chatId, "ChatCCC 正在等待安全重启或更新,当前不接受新的任务。请在维护完成后重新发送;可用 /safestatus 查看状态。").catch(() => { });
960
+ logTrace(tid, "DONE", { outcome: "safe_maintenance_admission_closed" });
961
+ return;
962
+ }
791
963
  if (isCommandText && textLower === "/reload") {
792
964
  logTrace(tid, "BRANCH", { cmd: "/reload" });
793
965
  try {
@@ -808,6 +980,14 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
808
980
  }
809
981
  if (isCommandText && textLower === "/restart") {
810
982
  logTrace(tid, "BRANCH", { cmd: "/restart" });
983
+ const safeStatus = await safeMaintenanceCoordinator.status();
984
+ if (safeStatus.job?.phase === "executing") {
985
+ await platform.sendText(chatId, "安全维护已经开始执行,请勿重复重启。").catch(() => { });
986
+ logTrace(tid, "DONE", { outcome: "restart_blocked_by_executing_safe_maintenance" });
987
+ return;
988
+ }
989
+ if (safeStatus.job?.phase === "draining")
990
+ await safeMaintenanceCoordinator.cancel(false);
811
991
  await platform.sendText(chatId, "重启中...请几秒后发消息唤醒我").catch(() => { });
812
992
  logTrace(tid, "DONE", { outcome: "restart" });
813
993
  appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
@@ -825,6 +1005,12 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
825
1005
  }
826
1006
  if (isCommandText && textLower === "/update") {
827
1007
  logTrace(tid, "BRANCH", { cmd: "/update" });
1008
+ const safeStatus = await safeMaintenanceCoordinator.status();
1009
+ if (safeStatus.job?.phase === "executing") {
1010
+ await platform.sendText(chatId, "安全维护已经开始执行,请勿重复更新。").catch(() => { });
1011
+ logTrace(tid, "DONE", { outcome: "update_blocked_by_executing_safe_maintenance" });
1012
+ return;
1013
+ }
828
1014
  const isGlobal = isRunningFromGlobalNpm();
829
1015
  appendStartupTrace("update: command received", { isGlobal, chatId });
830
1016
  if (!isGlobal) {
@@ -851,6 +1037,8 @@ export async function handleCommand(platform, text, chatId, openId, msgTimestamp
851
1037
  logTrace(tid, "DONE", { outcome: "update_guard_write_failed" });
852
1038
  return;
853
1039
  }
1040
+ if (safeStatus.job?.phase === "draining")
1041
+ await safeMaintenanceCoordinator.cancel(false);
854
1042
  await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => { });
855
1043
  logTrace(tid, "DONE", { outcome: "update" });
856
1044
  appendStartupTrace("update: sync update begin", { fromPid: process.pid });
@@ -0,0 +1,271 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { USER_DATA_DIR } from "./config.js";
5
+ export const SAFE_MAINTENANCE_FILE = join(USER_DATA_DIR, "state", "safe-maintenance.json");
6
+ export const SAFE_MAINTENANCE_STABLE_IDLE_MS = 1_000;
7
+ const EMPTY_SNAPSHOT = {
8
+ activeSessionIds: [],
9
+ queuedSessionIds: [],
10
+ activeEngineIds: [],
11
+ activeWorkLabels: [],
12
+ };
13
+ export class SafeMaintenanceCoordinator {
14
+ filePath;
15
+ now;
16
+ idFactory;
17
+ stableIdleMs;
18
+ pollMs;
19
+ autoPoll;
20
+ trackedWork = new Map();
21
+ runtime = null;
22
+ job;
23
+ snapshot = EMPTY_SNAPSHOT;
24
+ stableSince = null;
25
+ timer = null;
26
+ tickRunning = false;
27
+ constructor(options = {}) {
28
+ this.filePath = options.filePath ?? SAFE_MAINTENANCE_FILE;
29
+ this.now = options.now ?? (() => new Date());
30
+ this.idFactory = options.idFactory ?? randomUUID;
31
+ this.stableIdleMs = options.stableIdleMs ?? SAFE_MAINTENANCE_STABLE_IDLE_MS;
32
+ this.pollMs = options.pollMs ?? 500;
33
+ this.autoPoll = options.autoPoll ?? true;
34
+ this.job = readJob(this.filePath);
35
+ }
36
+ configure(runtime) {
37
+ this.runtime = runtime;
38
+ }
39
+ isAdmissionClosed() {
40
+ return this.job?.phase === "draining" || this.job?.phase === "executing";
41
+ }
42
+ beginTrackedWork(label) {
43
+ const id = this.idFactory();
44
+ this.trackedWork.set(id, label);
45
+ let released = false;
46
+ return () => {
47
+ if (released)
48
+ return;
49
+ released = true;
50
+ this.trackedWork.delete(id);
51
+ };
52
+ }
53
+ async schedule(kind, requester) {
54
+ const now = this.now().toISOString();
55
+ if (this.job?.phase === "executing")
56
+ return structuredClone(this.job);
57
+ const previous = this.job;
58
+ if (this.job?.phase === "draining") {
59
+ const requesters = addRequester(this.job.requesters, requester);
60
+ this.job = {
61
+ ...this.job,
62
+ kind: this.job.kind === "update" || kind === "update" ? "update" : "restart",
63
+ requesters,
64
+ updatedAt: now,
65
+ };
66
+ }
67
+ else {
68
+ this.job = {
69
+ schemaVersion: 1,
70
+ jobId: this.idFactory(),
71
+ kind,
72
+ phase: "draining",
73
+ requestedAt: now,
74
+ updatedAt: now,
75
+ requesters: [requester],
76
+ };
77
+ }
78
+ this.stableSince = null;
79
+ try {
80
+ writeJob(this.filePath, this.job);
81
+ }
82
+ catch (error) {
83
+ this.job = previous;
84
+ throw error;
85
+ }
86
+ this.startPolling();
87
+ return structuredClone(this.job);
88
+ }
89
+ async cancel(notify = true) {
90
+ if (this.job?.phase !== "draining")
91
+ return false;
92
+ const requesters = this.job.requesters;
93
+ removeJob(this.filePath);
94
+ this.job = null;
95
+ this.snapshot = EMPTY_SNAPSHOT;
96
+ this.stableSince = null;
97
+ this.stopPolling();
98
+ if (notify)
99
+ await this.notifyAll(requesters, "已取消安全维护预约,ChatCCC 恢复接受新任务。");
100
+ return true;
101
+ }
102
+ async status() {
103
+ if (this.runtime && this.isAdmissionClosed())
104
+ this.snapshot = await this.collectSnapshot();
105
+ return {
106
+ job: this.job ? structuredClone(this.job) : null,
107
+ snapshot: structuredClone(this.snapshot),
108
+ waitingCount: snapshotCount(this.snapshot),
109
+ };
110
+ }
111
+ async tick() {
112
+ if (this.tickRunning || this.job?.phase !== "draining" || !this.runtime)
113
+ return;
114
+ this.tickRunning = true;
115
+ try {
116
+ this.snapshot = await this.collectSnapshot();
117
+ if (snapshotCount(this.snapshot) > 0) {
118
+ this.stableSince = null;
119
+ return;
120
+ }
121
+ const nowMs = this.now().getTime();
122
+ if (this.stableSince === null) {
123
+ this.stableSince = nowMs;
124
+ return;
125
+ }
126
+ if (nowMs - this.stableSince < this.stableIdleMs)
127
+ return;
128
+ await this.executeCurrentJob();
129
+ }
130
+ finally {
131
+ this.tickRunning = false;
132
+ }
133
+ }
134
+ async recoverAfterStartup(internalRestart) {
135
+ if (!this.job || !this.runtime)
136
+ return;
137
+ if (this.job.phase === "draining") {
138
+ this.startPolling();
139
+ await this.notifyAll(this.job.requesters, "ChatCCC 已恢复未完成的安全维护预约,继续等待现有任务结束。");
140
+ return;
141
+ }
142
+ if (this.job.phase !== "executing")
143
+ return;
144
+ if (internalRestart) {
145
+ const completed = { ...this.job, phase: "completed", updatedAt: this.now().toISOString() };
146
+ writeJob(this.filePath, completed);
147
+ this.job = completed;
148
+ await this.notifyAll(this.job.requesters, this.job.kind === "update" ? "ChatCCC 已安全更新并重新启动。" : "ChatCCC 已安全重新启动。");
149
+ return;
150
+ }
151
+ const message = "安全维护执行期间进程意外退出;为避免重启循环,未自动重试。";
152
+ const failed = {
153
+ ...this.job,
154
+ phase: "failed",
155
+ updatedAt: this.now().toISOString(),
156
+ lastError: message,
157
+ };
158
+ writeJob(this.filePath, failed);
159
+ this.job = failed;
160
+ await this.notifyAll(this.job.requesters, message);
161
+ }
162
+ async collectSnapshot() {
163
+ const external = await this.runtime.getSnapshot();
164
+ return {
165
+ activeSessionIds: [...external.activeSessionIds],
166
+ queuedSessionIds: [...external.queuedSessionIds],
167
+ activeEngineIds: [...external.activeEngineIds],
168
+ activeWorkLabels: [...external.activeWorkLabels, ...this.trackedWork.values()],
169
+ };
170
+ }
171
+ async executeCurrentJob() {
172
+ if (!this.job || !this.runtime)
173
+ return;
174
+ const executing = { ...this.job, phase: "executing", updatedAt: this.now().toISOString() };
175
+ writeJob(this.filePath, executing);
176
+ this.job = executing;
177
+ this.stopPolling();
178
+ const label = this.job.kind === "update" ? "更新并重启" : "重启";
179
+ await this.notifyAll(this.job.requesters, `现有任务已全部完成,开始安全${label}。`);
180
+ const started = await this.runtime.execute(this.job.kind).catch(async (error) => {
181
+ await this.markFailed(error instanceof Error ? error.message : String(error));
182
+ return false;
183
+ });
184
+ if (!started && this.job?.phase === "executing") {
185
+ await this.markFailed(`安全${label}未能启动,当前进程将继续提供服务。`);
186
+ }
187
+ }
188
+ async markFailed(message) {
189
+ if (!this.job)
190
+ return;
191
+ const failed = {
192
+ ...this.job,
193
+ phase: "failed",
194
+ updatedAt: this.now().toISOString(),
195
+ lastError: message,
196
+ };
197
+ writeJob(this.filePath, failed);
198
+ this.job = failed;
199
+ await this.notifyAll(this.job.requesters, message);
200
+ }
201
+ startPolling() {
202
+ if (!this.autoPoll || this.timer || this.job?.phase !== "draining")
203
+ return;
204
+ this.timer = setInterval(() => { void this.tick(); }, this.pollMs);
205
+ this.timer.unref?.();
206
+ }
207
+ stopPolling() {
208
+ if (!this.timer)
209
+ return;
210
+ clearInterval(this.timer);
211
+ this.timer = null;
212
+ }
213
+ async notifyAll(requesters, message) {
214
+ if (!this.runtime)
215
+ return;
216
+ await Promise.all(requesters.map((requester) => this.runtime.notify(requester, message).catch(() => { })));
217
+ }
218
+ }
219
+ function snapshotCount(snapshot) {
220
+ return snapshot.activeSessionIds.length
221
+ + snapshot.queuedSessionIds.length
222
+ + snapshot.activeEngineIds.length
223
+ + snapshot.activeWorkLabels.length;
224
+ }
225
+ function addRequester(requesters, requester) {
226
+ if (requesters.some((item) => item.platform === requester.platform && item.chatId === requester.chatId))
227
+ return requesters;
228
+ return [...requesters, requester];
229
+ }
230
+ function readJob(filePath) {
231
+ if (!existsSync(filePath))
232
+ return null;
233
+ try {
234
+ const value = JSON.parse(readFileSync(filePath, "utf8"));
235
+ if (value.schemaVersion !== 1 || !value.jobId || !Array.isArray(value.requesters))
236
+ return null;
237
+ if (!["restart", "update"].includes(value.kind) || !["draining", "executing", "completed", "failed"].includes(value.phase))
238
+ return null;
239
+ return value;
240
+ }
241
+ catch {
242
+ return null;
243
+ }
244
+ }
245
+ function writeJob(filePath, job) {
246
+ mkdirSync(dirname(filePath), { recursive: true });
247
+ const temporary = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
248
+ try {
249
+ writeFileSync(temporary, `${JSON.stringify(job, null, 2)}\n`, "utf8");
250
+ renameSync(temporary, filePath);
251
+ }
252
+ finally {
253
+ rmSync(temporary, { force: true });
254
+ }
255
+ }
256
+ function removeJob(filePath) {
257
+ rmSync(filePath, { force: true });
258
+ }
259
+ export let safeMaintenanceCoordinator = new SafeMaintenanceCoordinator();
260
+ export function _setSafeMaintenanceCoordinatorForTest(coordinator) {
261
+ safeMaintenanceCoordinator = coordinator;
262
+ }
263
+ export function _resetSafeMaintenanceCoordinatorForTest() {
264
+ safeMaintenanceCoordinator = new SafeMaintenanceCoordinator();
265
+ }
266
+ export function isSafeMaintenanceAdmissionClosed() {
267
+ return safeMaintenanceCoordinator.isAdmissionClosed();
268
+ }
269
+ export function beginSafeMaintenanceTrackedWork(label) {
270
+ return safeMaintenanceCoordinator.beginTrackedWork(label);
271
+ }
@@ -133,6 +133,18 @@ export function setUnifiedDisplayLoopHandle(h) {
133
133
  unifiedDisplayLoopHandle = h;
134
134
  }
135
135
  export const queuedMessages = new Map();
136
+ /** Includes prompt execution, finalization, auto-recovery reservations, and accepted queues. */
137
+ export function getSessionDrainSnapshot() {
138
+ const active = new Set([
139
+ ...activePrompts.keys(),
140
+ ...finalizingSessions,
141
+ ...autoRecoveryReservations,
142
+ ]);
143
+ return {
144
+ activeSessionIds: [...active].sort(),
145
+ queuedSessionIds: [...queuedMessages.keys()].sort(),
146
+ };
147
+ }
136
148
  export function enqueueMessage(sessionId, msg) {
137
149
  if (queuedMessages.has(sessionId))
138
150
  return false;
@@ -6,6 +6,7 @@ import { progressView } from "./progress/view.js";
6
6
  import { createAgentActivityTracker, formatAgentActivityTitle, updateAgentActivity, } from "./agent-activity.js";
7
7
  import { simplifyToolUse, simplifyToolResult } from "./simplify.js";
8
8
  import { logTrace } from "./trace.js";
9
+ import { appendExecutionTranscriptBlock, } from "./execution-transcript.js";
9
10
  import { createClaudeAdapter } from "./adapters/claude-adapter.js";
10
11
  import { createCursorAdapter } from "./adapters/cursor-adapter.js";
11
12
  import { createCodexAdapter } from "./adapters/codex-adapter.js";
@@ -698,6 +699,11 @@ export function pickFinalReply(state) {
698
699
  return state.finalCompleteText || state.finalText;
699
700
  }
700
701
  export function accumulateBlockContent(block, state, toolCallMap) {
702
+ if (state.transcript !== undefined || (block.type !== "agent_progress" && block.type !== "text_reset")) {
703
+ const transcriptState = { transcript: state.transcript ?? [] };
704
+ appendExecutionTranscriptBlock(block, transcriptState);
705
+ state.transcript = transcriptState.transcript;
706
+ }
701
707
  switch (block.type) {
702
708
  case "thinking":
703
709
  state.chunkCount++;
@@ -1145,6 +1151,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1145
1151
  refreshBusySessionAvatar(sessionId, tool, platform).catch(() => { });
1146
1152
  const state = {
1147
1153
  accumulatedContent: "",
1154
+ transcript: [],
1148
1155
  finalText: "",
1149
1156
  finalCompleteText: "",
1150
1157
  chunkCount: 0,
@@ -1196,6 +1203,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1196
1203
  status: "auto_ended",
1197
1204
  accumulatedContent: state.accumulatedContent,
1198
1205
  finalReply: pickFinalReply(state).trim(),
1206
+ transcript: state.transcript,
1199
1207
  activity: activityTracker.activity,
1200
1208
  chunkCount: state.chunkCount,
1201
1209
  turnCount: nextTurnCount,
@@ -1308,6 +1316,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1308
1316
  status: "running",
1309
1317
  accumulatedContent: state.accumulatedContent,
1310
1318
  finalReply: pickFinalReply(state),
1319
+ transcript: state.transcript,
1311
1320
  activity: activityTracker.activity,
1312
1321
  chunkCount: state.chunkCount,
1313
1322
  turnCount: nextTurnCount,
@@ -1406,6 +1415,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1406
1415
  status: finalStatus,
1407
1416
  accumulatedContent: state.accumulatedContent,
1408
1417
  finalReply: finalReplyToWrite,
1418
+ transcript: state.transcript,
1409
1419
  activity: activityTracker.activity,
1410
1420
  chunkCount: state.chunkCount,
1411
1421
  turnCount: nextTurnCount,