chatccc 0.2.297 → 0.2.299

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/README.md CHANGED
@@ -229,7 +229,7 @@ Claude Code、Cursor 和 Codex 需要对应的本地工具;CCC Agent 内置于
229
229
 
230
230
  CCC Agent 是 ChatCCC 内置的编程 Agent,不需要额外安装 CLI,开箱即用。在首次配置向导或 Web 管理页中启用后,填写 API Key、Base URL 和模型即可使用;它可以设为 `/new` 的默认 Agent,也可以通过 `/new ccc` 显式创建会话。
231
231
 
232
- CCC Agent 支持**协作式让位**:任务运行期间你可以继续在群里发消息,ChatCCC 会在 Agent 的每个模型步骤边界把新消息注入当前任务,不打断正在执行的工具步骤、不新开一轮对话。已完成的中间工作会保留,Agent 收到插话后会调整后续方向。运行中的消息进入独立的注入队列(深度 50),与整轮排队(深度 1)分离。
232
+ CCC Agent 支持**协作式让位**:任务运行期间你可以继续在群里发消息,ChatCCC 会在 Agent 的每个模型步骤边界把新消息注入当前任务,不打断正在执行的工具步骤、不新开一轮对话。已完成的中间工作会保留,Agent 收到插话后会调整后续方向。运行中的消息进入独立的注入队列(深度 50),与整轮排队(深度 1)分离。飞书中每条消息实际注入后,机器人会发送一条最多 200 字的回执,冻结注入前的过程卡片,并用新的“生成中”卡片展示后续过程。
233
233
 
234
234
  DeepCCC 同时提供独立端口的本地 Web UI。ChatCCC 管理页顶部点击 **DeepCCC Web** 会复用或按需启动该服务;只全局安装 `deepccc` 的用户直接运行 `deepccc` 即可启动并打开网页版,终端模式改用 `deepccc-cli`。默认地址为 `http://127.0.0.1:28080/`,端口可通过 `~/.deepccc/config.json` 的 `web.port` 修改。网页版支持多会话并发、持久化历史、会话级 model/effort、API 设置和会话内高风险操作审批。
235
235
 
@@ -290,7 +290,7 @@ codex --version
290
290
 
291
291
  Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理,也可以在 `config.json` 中覆盖。
292
292
 
293
- ChatCCC 通过 Codex 的 **app-server 模式**(一个常驻进程 + WebSocket JSON-RPC)驱动 Codex,而不是一次性 `codex exec` 子进程。因此 Codex 与 CCC Agent 一样支持**协作式让位**:任务运行期间继续发消息,ChatCCC 会在 Codex 的工具步骤边界把消息注入当前 turn(`turn/steer`),不打断正在执行的命令、不丢已完成的工作;`/stop` 通过 `turn/interrupt` 只打断当前 turn,不影响同进程内的其他会话。权限策略与旧 exec 的 `--dangerously-bypass-approvals-and-sandbox` 等价(零审批、完整沙箱权限),`/plan`、`/ask` 退化为只读沙箱。要求 Codex CLI >= 0.60.0(建议保持最新版)。
293
+ ChatCCC 通过 Codex 的 **app-server 模式**(一个常驻进程 + WebSocket JSON-RPC)驱动 Codex,而不是一次性 `codex exec` 子进程。因此 Codex 与 CCC Agent 一样支持**协作式让位**:任务运行期间继续发消息,ChatCCC 会在 Codex 的工具步骤边界把消息注入当前 turn(`turn/steer`),不打断正在执行的命令、不丢已完成的工作;飞书会在 `turn/steer` 成功后发送注入回执,并从新的过程卡片继续展示。`/stop` 通过 `turn/interrupt` 只打断当前 turn,不影响同进程内的其他会话。权限策略与旧 exec 的 `--dangerously-bypass-approvals-and-sandbox` 等价(零审批、完整沙箱权限),`/plan`、`/ask` 退化为只读沙箱。要求 Codex CLI >= 0.60.0(建议保持最新版)。
294
294
 
295
295
  #### 可选:Chrome CDP
296
296
 
@@ -455,7 +455,7 @@ ChatCCC 通过 Codex 的 **app-server 模式**(一个常驻进程 + WebSocket
455
455
 
456
456
  `/restart safe`(短别名 `/restartsf`)与 `/update safe`(短别名 `/updatesf`)会先建立全局准入门禁:指令到达前已经运行或进入单会话缓存队列的工作会继续完成,之后到达的新普通任务会被提示在维护完成后重发。维护任务持久化到 `~/.chatccc/state/safe-maintenance.json`,进程意外退出后可继续排空;依赖安装、会话收尾、自动恢复和 Agent Teams 执行也计入等待条件。内存缓存随重启自然重建,磁盘会话、看板、图片等持久数据不会被清理。
457
457
 
458
- ChatCCC 的内部重启和更新使用跨平台父子进程握手:替代进程完成启动预检后通知父进程退出,再等待旧监听端口实际释放并接管 PID;替代进程未就绪或握手超时时,父进程会保留并继续服务。
458
+ ChatCCC 的普通内部重启使用跨平台父子进程握手:替代进程完成启动预检后通知父进程退出,再等待旧监听端口实际释放并接管 PID;替代进程未就绪或握手超时时,父进程会保留并继续服务。自更新则先把仅依赖 Node.js 的更新器复制到 `~/.chatccc/update/`,确认更新器就绪后退出旧进程,再由安装目录外的更新器执行 npm 安装并启动新版,从而避免 Windows 因运行中进程或原生模块占用全局包目录而报 `EBUSY`。更新失败时会尝试恢复并重启原版本,详细记录写入 `~/.chatccc/logs/update-watcher.log`。
459
459
 
460
460
  Codex 和 Cursor 在 Linux/macOS 下使用独立进程组。停止时先显示“正在停止”,待进程组内后代退出后再显示“已停止”;仅外层 shell 退出不算清理完成。重复停止请求合并处理,清理失败会保留会话进程占用保护并报告“Agent 停止未完成”,后续请求必须先完成清理才能启动新 Agent。Windows 继续使用 `taskkill /T /F`,命令失败或超时不会当作成功。
461
461
 
package/dist/src/cards.js CHANGED
@@ -399,7 +399,7 @@ export function buildQueueFullCard() {
399
399
  ],
400
400
  });
401
401
  }
402
- // 协作式让位卡片(仅 ccc):消息进入注入队列,将在 step 边界吸收进本轮。
402
+ // 协作式让位卡片(ccc/codex):消息进入注入队列,将在 step/item 边界吸收进本轮。
403
403
  // 与上面的整轮队列卡片区分,强调“无需等待整轮结束”。
404
404
  export function buildInjectionQueuedCard(text) {
405
405
  const preview = text.length > 100 ? text.slice(0, 100) + "…" : text;
@@ -419,7 +419,7 @@ export function buildInjectionQueuedCard(text) {
419
419
  ],
420
420
  });
421
421
  }
422
- // 注入队列满卡片(仅 ccc)
422
+ // 注入队列满卡片(ccc/codex)
423
423
  export function buildInjectionQueueFullCard() {
424
424
  return JSON.stringify({
425
425
  config: { wide_screen_mode: true },
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import { execSync, spawn } from "node:child_process";
8
8
  import { readdir, stat } from "node:fs/promises";
9
- import { appendFileSync, closeSync, mkdirSync, openSync } from "node:fs";
9
+ import { closeSync, mkdirSync, openSync } from "node:fs";
10
10
  import { join, resolve, dirname } from "node:path";
11
11
  import { homedir } from "node:os";
12
12
  import { config as deepCccConfig } from "../deepccc-agent/src/config.js";
@@ -27,6 +27,7 @@ import { reloadRuntimeConfig } from "./runtime-reload.js";
27
27
  import { acquireUpdateCommandGuard } from "./update-command-guard.js";
28
28
  import { createInternalRestartEnv, INTERNAL_RESTART_ENV_VAR, INTERNAL_RESTART_READY_MESSAGE, } from "./startup-lifecycle.js";
29
29
  import { resolveChatCccRuntimeSpawnSpec } from "./runtime-entry.js";
30
+ import { SELF_UPDATE_ERROR_ENV_VAR, SELF_UPDATE_RESULT_ENV_VAR, spawnSelfUpdateHelper, waitForSelfUpdateHelperReady, } from "./self-update.js";
30
31
  import { engineManager } from "./engines/engine-specs.js";
31
32
  import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, safeMaintenanceCoordinator, } from "./safe-maintenance.js";
32
33
  import { feishuP2pContactStore, isValidFeishuOpenId, } from "./agent-team/repositories/feishu-p2p-contact-store.js";
@@ -625,80 +626,28 @@ function isRunningFromGlobalNpm() {
625
626
  return false;
626
627
  }
627
628
  }
628
- const UPDATE_LOG = join(homedir(), ".chatccc", "logs", "update-watcher.log");
629
- function updLog(msg) {
630
- const ts = new Date().toISOString();
631
- try {
632
- appendFileSync(UPDATE_LOG, `${ts} [UPDATE-SYNC] ${msg}\n`, "utf-8");
633
- }
634
- catch { }
635
- }
636
- /** 同步更新 npm 全局包并 spawn 新进程重启。不依赖 systemd 或任何服务管理器。 */
637
- function syncUpdateAndRestart(options = {}) {
638
- updLog(`sync update start, pid=${process.pid}`);
639
- appendStartupTrace("update: sync update start", { pid: process.pid });
640
- const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
641
- let updateSucceeded = false;
642
- // 1. npm update
643
- updLog(`running: ${npmCmd} update -g chatccc`);
644
- appendStartupTrace("update: npm update begin", { npmCmd });
645
- const t0 = Date.now();
629
+ /**
630
+ * 将真正的 npm 更新交给安装目录外的独立进程。父进程只在更新器确认读入任务后
631
+ * 退出;更新器随后等待本进程和 npm bin 包装器释放目录,再安装并启动新版。
632
+ */
633
+ async function exitForExternalSelfUpdate() {
634
+ let child;
646
635
  try {
647
- const out = execSync(`${npmCmd} update -g chatccc 2>&1`, { encoding: "utf8", timeout: 120000, windowsHide: true });
648
- const elapsed = Date.now() - t0;
649
- updLog(`npm update OK (${elapsed}ms): ${out.slice(0, 500)}`);
650
- appendStartupTrace("update: npm update OK", { elapsedMs: elapsed, outputLen: out.length });
651
- updateSucceeded = true;
652
- }
653
- catch (e) {
654
- const elapsed = Date.now() - t0;
655
- const err = e;
656
- updLog(`npm update failed (${elapsed}ms): message=${err.message}, stderr=${(err.stderr || "").slice(0, 500)}, stdout=${(err.stdout || "").slice(0, 200)}`);
657
- appendStartupTrace("update: npm update failed", { elapsedMs: elapsed, message: err.message, stderrLen: (err.stderr || "").length });
658
- // fallback
659
- updLog(`fallback: ${npmCmd} install -g chatccc@latest`);
660
- appendStartupTrace("update: npm install fallback begin", { npmCmd });
661
- const t1 = Date.now();
662
- try {
663
- const out2 = execSync(`${npmCmd} install -g chatccc@latest 2>&1`, { encoding: "utf8", timeout: 120000, windowsHide: true });
664
- const elapsed2 = Date.now() - t1;
665
- updLog(`npm install fallback OK (${elapsed2}ms): ${out2.slice(0, 500)}`);
666
- appendStartupTrace("update: npm install fallback OK", { elapsedMs: elapsed2, outputLen: out2.length });
667
- updateSucceeded = true;
668
- }
669
- catch (e2) {
670
- const elapsed2 = Date.now() - t1;
671
- const err2 = e2;
672
- updLog(`npm install fallback also failed (${elapsed2}ms): message=${err2.message}, stderr=${(err2.stderr || "").slice(0, 500)}`);
673
- appendStartupTrace("update: npm install fallback failed", { elapsedMs: elapsed2, message: err2.message });
674
- }
675
- }
676
- if (!updateSucceeded && options.spawnOnUpdateFailure === false) {
677
- updLog("safe update aborted: both npm update and fallback install failed");
678
- appendStartupTrace("update: safe update aborted before restart", {});
679
- return undefined;
636
+ child = spawnSelfUpdateHelper();
680
637
  }
681
- // 2. Spawn the updated runtime directly through Node. Reusing the restart
682
- // launcher guarantees an IPC handoff channel and avoids shell/PATH variance.
683
- const spawnSpec = buildRestartSpawnSpec(PROJECT_ROOT);
684
- updLog(`runtime path: ${spawnSpec.command} ${spawnSpec.args.join(" ")}`);
685
- appendStartupTrace("update: spawn begin", { runtimeEntry: spawnSpec.args[0] });
686
- try {
687
- const child = spawnRestartChild({ projectRoot: PROJECT_ROOT });
688
- child.unref();
689
- updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${spawnSpec.command} ${spawnSpec.args.join(" ")}`);
690
- appendStartupTrace("update: spawn OK", {
691
- childPid: child.pid,
692
- binPath: spawnSpec.args[0],
638
+ catch (error) {
639
+ appendStartupTrace("update: external helper spawn failed", {
640
+ error: error.message,
693
641
  });
694
- return child;
695
- }
696
- catch (e) {
697
- const errMsg = e.message;
698
- updLog(`spawn new chatccc failed: ${errMsg}`);
699
- appendStartupTrace("update: spawn failed", { error: errMsg });
700
- return undefined;
642
+ return false;
701
643
  }
644
+ const ready = await waitForSelfUpdateHelperReady(child);
645
+ if (!ready)
646
+ return false;
647
+ child.unref();
648
+ fileLog.flush();
649
+ appendStartupTrace("update: parent exit for external helper", { childPid: child.pid });
650
+ process.exit(0);
702
651
  }
703
652
  // ---------------------------------------------------------------------------
704
653
  // /restart — 自重启子进程(不经过 npx/npm,避免 PATH 注入秒退;防空窗兜底)
@@ -933,7 +882,14 @@ export function configureSafeMaintenanceRuntime(platforms) {
933
882
  });
934
883
  }
935
884
  export function recoverSafeMaintenanceAfterStartup() {
936
- return safeMaintenanceCoordinator.recoverAfterStartup(process.env[INTERNAL_RESTART_ENV_VAR] === "1");
885
+ const updateResult = process.env[SELF_UPDATE_RESULT_ENV_VAR];
886
+ return safeMaintenanceCoordinator.recoverAfterStartup(process.env[INTERNAL_RESTART_ENV_VAR] === "1", updateResult === "failed"
887
+ ? {
888
+ succeeded: false,
889
+ error: process.env[SELF_UPDATE_ERROR_ENV_VAR]
890
+ || "ChatCCC 更新失败,已恢复更新前版本。",
891
+ }
892
+ : undefined);
937
893
  }
938
894
  async function executeSafeRestart() {
939
895
  fileLog.flush();
@@ -949,15 +905,7 @@ async function executeSafeRestart() {
949
905
  async function executeSafeUpdate() {
950
906
  fileLog.flush();
951
907
  appendStartupTrace("safe-maintenance: update begin", { fromPid: process.pid });
952
- const child = syncUpdateAndRestart({ spawnOnUpdateFailure: false });
953
- if (!child)
954
- return false;
955
- child.unref();
956
- const shouldExit = await decideRestartParentExit(child, RESTART_CHILD_READY_MS);
957
- if (!shouldExit)
958
- return false;
959
- appendStartupTrace("safe-maintenance: update parent exit", { childPid: child.pid });
960
- process.exit(0);
908
+ return exitForExternalSelfUpdate();
961
909
  }
962
910
  function safeMaintenanceRequester(platform, chatId, openId) {
963
911
  return { platform: platform.kind ?? "feishu", chatId, openId };
@@ -1168,19 +1116,11 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
1168
1116
  await safeMaintenanceCoordinator.cancel(false);
1169
1117
  await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => { });
1170
1118
  logTrace(tid, "DONE", { outcome: "update" });
1171
- appendStartupTrace("update: sync update begin", { fromPid: process.pid });
1172
- const child = syncUpdateAndRestart({ spawnOnUpdateFailure: false });
1173
- if (child) {
1174
- // 只有替代进程通过 IPC 明确完成预检才退出父进程;否则父进程继续服务。
1175
- void decideRestartParentExit(child, RESTART_CHILD_READY_MS).then((shouldExit) => {
1176
- if (!shouldExit)
1177
- return;
1178
- appendStartupTrace("update: parent exit", { childPid: child.pid });
1179
- process.exit(0);
1180
- });
1181
- }
1182
- else {
1183
- appendStartupTrace("update: replacement unavailable, parent stays alive", {});
1119
+ appendStartupTrace("update: external helper begin", { fromPid: process.pid });
1120
+ const started = await exitForExternalSelfUpdate();
1121
+ if (!started) {
1122
+ appendStartupTrace("update: external helper unavailable, parent stays alive", {});
1123
+ await platform.sendText(chatId, "更新器启动失败,ChatCCC 将继续运行。请查看 ~/.chatccc/logs/update-watcher.log。").catch(() => { });
1184
1124
  }
1185
1125
  return;
1186
1126
  }
@@ -134,7 +134,7 @@ export class SafeMaintenanceCoordinator {
134
134
  this.tickRunning = false;
135
135
  }
136
136
  }
137
- async recoverAfterStartup(internalRestart) {
137
+ async recoverAfterStartup(internalRestart, maintenanceResult) {
138
138
  if (!this.job || !this.runtime)
139
139
  return;
140
140
  if (this.job.phase === "draining") {
@@ -142,9 +142,29 @@ export class SafeMaintenanceCoordinator {
142
142
  await this.notifyAll(this.job.requesters, "ChatCCC 已恢复未完成的安全维护预约,继续等待现有任务结束。");
143
143
  return;
144
144
  }
145
+ if (this.job.phase === "failed"
146
+ && this.job.kind === "update"
147
+ && internalRestart
148
+ && maintenanceResult?.succeeded === false) {
149
+ await this.notifyAll(this.job.requesters, this.job.lastError || maintenanceResult.error || "ChatCCC 更新失败,已恢复更新前版本。");
150
+ return;
151
+ }
145
152
  if (this.job.phase !== "executing")
146
153
  return;
147
154
  if (internalRestart) {
155
+ if (this.job.kind === "update" && maintenanceResult?.succeeded === false) {
156
+ const message = maintenanceResult.error || "ChatCCC 更新失败,已恢复更新前版本。";
157
+ const failed = {
158
+ ...this.job,
159
+ phase: "failed",
160
+ updatedAt: this.now().toISOString(),
161
+ lastError: message,
162
+ };
163
+ writeJob(this.filePath, failed);
164
+ this.job = failed;
165
+ await this.notifyAll(this.job.requesters, message);
166
+ return;
167
+ }
148
168
  const completed = { ...this.job, phase: "completed", updatedAt: this.now().toISOString() };
149
169
  writeJob(this.filePath, completed);
150
170
  this.job = completed;
@@ -0,0 +1,170 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, copyFileSync, mkdirSync, openSync, readFileSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { PROJECT_ROOT, USER_DATA_DIR } from "./config.js";
7
+ import { resolveNpmInvocation } from "./engines/engine-manager.js";
8
+ import { appendStartupTrace } from "./shared.js";
9
+ export const SELF_UPDATE_HELPER_READY_MESSAGE = "chatccc:self-update-helper-ready";
10
+ export const SELF_UPDATE_RESULT_ENV_VAR = "CHATCCC_SELF_UPDATE_RESULT";
11
+ export const SELF_UPDATE_ERROR_ENV_VAR = "CHATCCC_SELF_UPDATE_ERROR";
12
+ const helperMonitors = new WeakMap();
13
+ function monitorSelfUpdateHelper(child, expectedParentPid) {
14
+ const existing = helperMonitors.get(child);
15
+ if (existing)
16
+ return existing;
17
+ if (typeof child.on !== "function") {
18
+ const unavailable = {
19
+ promise: Promise.resolve({ kind: "exit" }),
20
+ cancel() { },
21
+ };
22
+ helperMonitors.set(child, unavailable);
23
+ return unavailable;
24
+ }
25
+ const addListener = child.on.bind(child);
26
+ const removeListener = typeof child.off === "function"
27
+ ? child.off.bind(child)
28
+ : undefined;
29
+ let settlePromise = () => { };
30
+ let settled = false;
31
+ const cleanup = () => {
32
+ removeListener?.("message", onMessage);
33
+ removeListener?.("exit", onExit);
34
+ removeListener?.("error", onError);
35
+ };
36
+ const settle = (outcome) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ cleanup();
41
+ settlePromise(outcome);
42
+ };
43
+ const onMessage = (message) => {
44
+ const ready = message;
45
+ if (!ready || ready.type !== SELF_UPDATE_HELPER_READY_MESSAGE)
46
+ return;
47
+ if (ready.parentPid !== expectedParentPid)
48
+ return;
49
+ if (typeof ready.pid === "number" && child.pid !== undefined && ready.pid !== child.pid)
50
+ return;
51
+ settle({ kind: "ready" });
52
+ };
53
+ const onExit = () => settle({ kind: "exit" });
54
+ const onError = (error) => settle({ kind: "error", error });
55
+ const promise = new Promise((resolvePromise) => {
56
+ settlePromise = resolvePromise;
57
+ });
58
+ const monitor = { promise, cancel: cleanup };
59
+ helperMonitors.set(child, monitor);
60
+ addListener("message", onMessage);
61
+ addListener("exit", onExit);
62
+ addListener("error", onError);
63
+ if (child.exitCode !== null || child.signalCode !== null)
64
+ settle({ kind: "exit" });
65
+ return monitor;
66
+ }
67
+ /**
68
+ * 将更新器复制到 ~/.chatccc 后再启动。更新器本身和 cwd 都不位于 npm 包目录,
69
+ * 因而主进程退出后 npm 可以在 Windows 上重命名或替换全局安装目录。
70
+ */
71
+ export function spawnSelfUpdateHelper(options = {}) {
72
+ const projectRoot = options.projectRoot ?? PROJECT_ROOT;
73
+ const userDataDir = options.userDataDir ?? USER_DATA_DIR;
74
+ const helperSourcePath = options.helperSourcePath
75
+ ?? join(projectRoot, "scripts", "self-update-helper.mjs");
76
+ const parentPid = options.parentPid ?? process.pid;
77
+ const npmInvocation = options.npmInvocation ?? resolveNpmInvocation();
78
+ const spawnImpl = options.spawnImpl ?? spawn;
79
+ const trace = options.trace ?? appendStartupTrace;
80
+ const packageJson = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
81
+ if (packageJson.name !== "chatccc" || typeof packageJson.version !== "string") {
82
+ throw new Error("当前安装目录缺少有效的 chatccc package.json。");
83
+ }
84
+ const updateDir = join(userDataDir, "update");
85
+ const logDir = join(userDataDir, "logs");
86
+ mkdirSync(updateDir, { recursive: true });
87
+ mkdirSync(logDir, { recursive: true });
88
+ const id = `${Date.now()}-${randomUUID()}`;
89
+ const helperPath = join(updateDir, `self-update-helper-${id}.mjs`);
90
+ const jobPath = join(updateDir, `self-update-job-${id}.json`);
91
+ const resultFile = join(updateDir, `self-update-result-${id}.json`);
92
+ copyFileSync(helperSourcePath, helperPath);
93
+ const job = {
94
+ schemaVersion: 1,
95
+ parentPid,
96
+ packageRoot: projectRoot,
97
+ previousVersion: packageJson.version,
98
+ restartCwd: userDataDir || homedir(),
99
+ npmCommand: npmInvocation.command,
100
+ npmArgsPrefix: [...npmInvocation.argsPrefix],
101
+ runtimeCommand: process.execPath,
102
+ runtimeEntry: join(projectRoot, "dist", "src", "index.js"),
103
+ resultFile,
104
+ safeMaintenanceFile: join(userDataDir, "state", "safe-maintenance.json"),
105
+ cleanupPaths: [jobPath, helperPath],
106
+ };
107
+ writeFileSync(jobPath, JSON.stringify(job, null, 2), "utf8");
108
+ const logPath = join(logDir, "update-watcher.log");
109
+ const logFd = openSync(logPath, "a");
110
+ const stdio = ["ignore", logFd, logFd, "ipc"];
111
+ trace("update: external helper spawn", {
112
+ helperPath,
113
+ jobPath,
114
+ packageRoot: projectRoot,
115
+ cwd: userDataDir,
116
+ });
117
+ try {
118
+ const child = spawnImpl(process.execPath, [helperPath, jobPath], {
119
+ cwd: userDataDir,
120
+ detached: true,
121
+ stdio,
122
+ shell: false,
123
+ windowsHide: true,
124
+ env: process.env,
125
+ });
126
+ monitorSelfUpdateHelper(child, parentPid);
127
+ return child;
128
+ }
129
+ finally {
130
+ closeSync(logFd);
131
+ }
132
+ }
133
+ /**
134
+ * 只有外置更新器确认已读入任务后,主进程才可退出。若更新器早退或超时,
135
+ * 保留当前服务并终止更新器,避免服务空窗。
136
+ */
137
+ export async function waitForSelfUpdateHelperReady(child, timeoutMs = 5_000, trace = appendStartupTrace) {
138
+ const monitor = helperMonitors.get(child) ?? monitorSelfUpdateHelper(child, process.pid);
139
+ let timer;
140
+ const timeout = new Promise((resolvePromise) => {
141
+ timer = setTimeout(() => resolvePromise({ kind: "exit" }), timeoutMs);
142
+ timer.unref?.();
143
+ });
144
+ const outcome = await Promise.race([monitor.promise, timeout]);
145
+ if (timer)
146
+ clearTimeout(timer);
147
+ monitor.cancel();
148
+ if (outcome.kind === "ready") {
149
+ if (child.connected && typeof child.disconnect === "function") {
150
+ try {
151
+ child.disconnect();
152
+ }
153
+ catch { /* helper may disconnect first */ }
154
+ }
155
+ trace("update: external helper ready", { childPid: child.pid });
156
+ return true;
157
+ }
158
+ if (typeof child.kill === "function") {
159
+ try {
160
+ child.kill();
161
+ }
162
+ catch { /* best effort */ }
163
+ }
164
+ trace("update: external helper unavailable", {
165
+ childPid: child.pid,
166
+ outcome: outcome.kind,
167
+ error: outcome.kind === "error" ? outcome.error.message : undefined,
168
+ });
169
+ return false;
170
+ }
@@ -267,6 +267,28 @@ function formatTerminalCardContent(state) {
267
267
  ? `${content}\n\n${reason}\n以上内容可能不完整。`
268
268
  : reason;
269
269
  }
270
+ function formatTerminalCardContentForDisplay(state, display) {
271
+ if (display.rotationAccLen === undefined)
272
+ return formatTerminalCardContent(state);
273
+ const accumulatedDelta = state.accumulatedContent.slice(display.rotationAccLen);
274
+ const previousFinalReply = display.rotationFinalReply ?? "";
275
+ const finalReplyDelta = previousFinalReply && state.finalReply.startsWith(previousFinalReply)
276
+ ? state.finalReply.slice(previousFinalReply.length)
277
+ : state.finalReply;
278
+ const content = accumulatedDelta + finalReplyDelta;
279
+ if (state.status !== "error")
280
+ return content;
281
+ const error = state.terminalError ?? {
282
+ kind: "unknown",
283
+ title: "原因未记录",
284
+ message: "当前状态中没有可用的错误详情,请查看运行日志。",
285
+ occurredAt: Date.now(),
286
+ };
287
+ const reason = formatTerminalErrorReason(error);
288
+ return content.trim()
289
+ ? `${content}\n\n${reason}\n以上内容可能不完整。`
290
+ : reason;
291
+ }
270
292
  function isCardKitSequenceConflict(err) {
271
293
  return err instanceof Error && err.message.includes("300317");
272
294
  }
@@ -729,6 +751,85 @@ export function _resetSessionRegistryFileForTest() {
729
751
  export function pickFinalReply(state) {
730
752
  return state.finalCompleteText || state.finalText;
731
753
  }
754
+ const APPLIED_INJECTION_PREVIEW_CHARS = 200;
755
+ export function unwrapInjectedUserMessage(text) {
756
+ const match = text.match(/^\[User message\]\r?\n([\s\S]*)\r?\n\[\/User message\]$/);
757
+ return match?.[1] ?? text;
758
+ }
759
+ export function formatAppliedInjectionReceipt(text) {
760
+ const normalized = text.trim() || "(空消息)";
761
+ const chars = Array.from(normalized);
762
+ const preview = chars.length > APPLIED_INJECTION_PREVIEW_CHARS
763
+ ? `${chars.slice(0, APPLIED_INJECTION_PREVIEW_CHARS).join("")}…`
764
+ : normalized;
765
+ return `已收到注入信息:${preview}`;
766
+ }
767
+ async function rotateFeishuCardForAppliedInjection(sessionId, turnCount, tool, injectedText, state, nextHeaderTitle) {
768
+ if (tool !== "codex" && tool !== "ccc")
769
+ return;
770
+ const chatId = pickDisplayChat(sessionId);
771
+ if (!chatId)
772
+ return;
773
+ const platform = platformForChat(chatId);
774
+ if (platform?.kind !== "feishu")
775
+ return;
776
+ let previousDisplay = displayCards.get(chatId);
777
+ if (previousDisplay
778
+ && (previousDisplay.sessionId !== sessionId || previousDisplay.turnCount !== turnCount)) {
779
+ previousDisplay = undefined;
780
+ }
781
+ if (previousDisplay) {
782
+ while (previousDisplay.cardBusy)
783
+ await new Promise((resolve) => setTimeout(resolve, 20));
784
+ if (displayCards.get(chatId) !== previousDisplay)
785
+ previousDisplay = undefined;
786
+ }
787
+ if (previousDisplay) {
788
+ previousDisplay.cardBusy = true;
789
+ const nextSequence = previousDisplay.sequence + 1;
790
+ const oldContent = state.accumulatedContent + pickFinalReply(state);
791
+ const oldCard = buildProgressCard(progressView({
792
+ text: truncateContent(oldContent) || "等待 Agent 输出...",
793
+ status: "done",
794
+ showStop: false,
795
+ headerTitle: "已收到注入,后续转入新卡片",
796
+ headerTemplate: "blue",
797
+ }));
798
+ await platform.cardUpdate(previousDisplay.cardId, oldCard, nextSequence).then(() => {
799
+ previousDisplay.sequence = nextSequence;
800
+ }).catch((err) => {
801
+ console.error(`[${ts()}] [INJECT] freeze old card failed: ${err.message}`);
802
+ if (isCardKitSequenceConflict(err))
803
+ previousDisplay.sequence = nextSequence;
804
+ });
805
+ }
806
+ await platform.sendText(chatId, formatAppliedInjectionReceipt(injectedText)).catch((err) => {
807
+ console.error(`[${ts()}] [INJECT] receipt send failed: ${err.message}`);
808
+ });
809
+ const newCardId = await createVisibleProgressCard(platform, chatId, sessionId, turnCount, "新生成卡片发送失败,结果将继续更新在上一张卡片中。", nextHeaderTitle);
810
+ if (!newCardId) {
811
+ if (previousDisplay)
812
+ previousDisplay.cardBusy = false;
813
+ return;
814
+ }
815
+ if (previousDisplay) {
816
+ await markCardDone(sessionId, turnCount, previousDisplay.cardId);
817
+ }
818
+ displayCards.set(chatId, {
819
+ cardId: newCardId,
820
+ sequence: 1,
821
+ cardBusy: false,
822
+ cardCreatedAt: Date.now(),
823
+ lastSentContent: "",
824
+ lastSentHeaderTitle: nextHeaderTitle,
825
+ streamErrorNotified: false,
826
+ sessionId,
827
+ turnCount,
828
+ rotationAccLen: state.accumulatedContent.length,
829
+ rotationFinalReply: pickFinalReply(state),
830
+ dotCount: 0,
831
+ });
832
+ }
732
833
  export function accumulateBlockContent(block, state, toolCallMap) {
733
834
  if (state.transcript !== undefined || (block.type !== "agent_progress" && block.type !== "text_reset")) {
734
835
  const transcriptState = { transcript: state.transcript ?? [] };
@@ -1123,7 +1224,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1123
1224
  else {
1124
1225
  const nextSeq = display.sequence + 1;
1125
1226
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(prevState.status, prevState.terminalError);
1126
- const cardContent = truncateContent(formatTerminalCardContent(prevState)) || " ";
1227
+ const cardContent = truncateContent(formatTerminalCardContentForDisplay(prevState, display)) || " ";
1127
1228
  const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1128
1229
  await pp.cardUpdate(display.cardId, doneCard, nextSeq).catch(err => {
1129
1230
  console.error(`[${ts()}] [DISPLAY] prevState final cardUpdate failed: ${err.message}`);
@@ -1289,7 +1390,7 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1289
1390
  }
1290
1391
  try {
1291
1392
  // 供 onInjectionRejected 退回的“最后一条已取出但未成功注入”的消息
1292
- let lastShiftedInjection = null;
1393
+ const injectionState = { lastShifted: null };
1293
1394
  for await (const unifiedMsg of adapter.prompt(sessionId, userTextWithCapabilities, cwd, controller.signal, {
1294
1395
  onProcessStart: (processInfo) => {
1295
1396
  startPromptProcessMonitor(sessionId, processInfo);
@@ -1312,16 +1413,16 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1312
1413
  const injected = shiftInjection(sessionId);
1313
1414
  if (!injected)
1314
1415
  return undefined;
1315
- lastShiftedInjection = injected;
1416
+ injectionState.lastShifted = injected;
1316
1417
  // 与首次消息保持一致的结构化包装;imSkillsPrompt 已在首次消息中,
1317
1418
  // 此处不重复注入,避免多轮注入导致上下文膨胀。
1318
1419
  return `[User message]\n${injected.text}\n[/User message]`;
1319
1420
  } : undefined,
1320
1421
  // 注入未被当前 turn 吸收(如 turn 恰好已结束)时,退回队列头部,避免丢消息。
1321
1422
  onInjectionRejected: tool === "ccc" || tool === "codex" ? () => {
1322
- if (lastShiftedInjection) {
1323
- unshiftInjection(sessionId, lastShiftedInjection);
1324
- lastShiftedInjection = null;
1423
+ if (injectionState.lastShifted) {
1424
+ unshiftInjection(sessionId, injectionState.lastShifted);
1425
+ injectionState.lastShifted = null;
1325
1426
  }
1326
1427
  } : undefined,
1327
1428
  })) {
@@ -1339,7 +1440,15 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1339
1440
  let activityChanged = false;
1340
1441
  let progressHeartbeat = false;
1341
1442
  let outputReset = false;
1342
- for (const block of unifiedMsg.blocks) {
1443
+ let injectionBoundaryObserved = false;
1444
+ for (const originalBlock of unifiedMsg.blocks) {
1445
+ let block = originalBlock;
1446
+ if (originalBlock.type === "input_injected") {
1447
+ const injectedText = injectionState.lastShifted?.text
1448
+ ?? unwrapInjectedUserMessage(originalBlock.text);
1449
+ injectionState.lastShifted = null;
1450
+ block = { ...originalBlock, text: injectedText };
1451
+ }
1343
1452
  if (block.type === "agent_progress")
1344
1453
  progressHeartbeat = true;
1345
1454
  if (block.type === "text_reset") {
@@ -1349,6 +1458,10 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1349
1458
  if (updateAgentActivity(activityTracker, block))
1350
1459
  activityChanged = true;
1351
1460
  accumulateBlockContent(block, state, toolCallMap);
1461
+ if (block.type === "input_injected") {
1462
+ injectionBoundaryObserved = true;
1463
+ await rotateFeishuCardForAppliedInjection(sessionId, nextTurnCount, tool, block.text, state, formatAgentActivityTitle(activityTracker.activity));
1464
+ }
1352
1465
  if (block.type === "compact_boundary" && block.post_tokens) {
1353
1466
  for (const cid of getChatsForSession(sessionId)) {
1354
1467
  const sinfo = sessionInfoMap.get(cid);
@@ -1374,7 +1487,10 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1374
1487
  }
1375
1488
  // 定时写入文件
1376
1489
  const now2 = Date.now();
1377
- if (activityChanged || outputReset || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1490
+ if (activityChanged
1491
+ || outputReset
1492
+ || injectionBoundaryObserved
1493
+ || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1378
1494
  lastFileWrite = now2;
1379
1495
  await writeStreamState({
1380
1496
  sessionId,
@@ -1814,7 +1930,7 @@ export function startUnifiedDisplayLoop() {
1814
1930
  if (!terminalCardAlreadyUpdated) {
1815
1931
  const nextSeq = display.sequence + 1;
1816
1932
  const { title: headerTitle, template: headerTemplate } = formatTerminalHeader(state.status, state.terminalError);
1817
- const cardContent = truncateContent(formatTerminalCardContent(state)) || " ";
1933
+ const cardContent = truncateContent(formatTerminalCardContentForDisplay(state, display)) || " ";
1818
1934
  const doneCard = buildProgressCard(progressView({ text: cardContent, status: "done", showStop: false, headerTitle, headerTemplate }));
1819
1935
  await p.cardUpdate(display.cardId, doneCard, nextSeq).then(() => {
1820
1936
  display.sequence = nextSeq;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.297",
3
+ "version": "0.2.299",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -14,6 +14,7 @@
14
14
  "deepccc-agent/os-prompts/",
15
15
  "bin/",
16
16
  "scripts/postinstall-sharp-check.mjs",
17
+ "scripts/self-update-helper.mjs",
17
18
  "demo/ilink_echo_probe.ts",
18
19
  "agent-prompts/",
19
20
  "im-skills/",
@@ -0,0 +1,301 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import {
3
+ existsSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { basename, resolve } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ const READY_MESSAGE = "chatccc:self-update-helper-ready";
13
+ const INTERNAL_RESTART_ENV = "CHATCCC_INTERNAL_RESTART";
14
+ const RESTART_PARENT_PID_ENV = "CHATCCC_RESTART_PARENT_PID";
15
+ const UPDATE_RESULT_ENV = "CHATCCC_SELF_UPDATE_RESULT";
16
+ const UPDATE_ERROR_ENV = "CHATCCC_SELF_UPDATE_ERROR";
17
+
18
+ function log(message, details) {
19
+ const suffix = details === undefined
20
+ ? ""
21
+ : ` ${typeof details === "string" ? details : JSON.stringify(details)}`;
22
+ console.log(`${new Date().toISOString()} [UPDATE-HELPER] ${message}${suffix}`);
23
+ }
24
+
25
+ function sleep(ms) {
26
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
27
+ }
28
+
29
+ function pidIsAlive(pid) {
30
+ try {
31
+ process.kill(pid, 0);
32
+ return true;
33
+ } catch (error) {
34
+ return error?.code === "EPERM";
35
+ }
36
+ }
37
+
38
+ async function waitForParentExit(parentPid, timeoutMs) {
39
+ const deadline = Date.now() + timeoutMs;
40
+ while (pidIsAlive(parentPid)) {
41
+ if (Date.now() >= deadline) return false;
42
+ await sleep(100);
43
+ }
44
+ // npm 的 bin 包装器会在主进程结束后才从 spawnSync 返回;留出短暂时间,
45
+ // 让包装器也退出并释放它继承的 Windows 当前目录句柄。
46
+ await sleep(300);
47
+ return true;
48
+ }
49
+
50
+ function readInstalledPackage(job) {
51
+ const packageJsonPath = resolve(job.packageRoot, "package.json");
52
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
53
+ if (parsed?.name !== "chatccc" || typeof parsed.version !== "string") {
54
+ throw new Error("安装后的 package.json 无效。");
55
+ }
56
+ if (!existsSync(job.runtimeEntry)) {
57
+ throw new Error(`安装后的运行入口不存在:${job.runtimeEntry}`);
58
+ }
59
+ return parsed;
60
+ }
61
+
62
+ function runNpmInstall(job, packageSpec) {
63
+ const args = [
64
+ ...job.npmArgsPrefix,
65
+ "install",
66
+ "-g",
67
+ packageSpec,
68
+ "--no-audit",
69
+ "--no-fund",
70
+ ];
71
+ log("npm install begin", {
72
+ command: job.npmCommand,
73
+ args,
74
+ cwd: job.restartCwd,
75
+ });
76
+ const result = spawnSync(job.npmCommand, args, {
77
+ cwd: job.restartCwd,
78
+ env: process.env,
79
+ encoding: "utf8",
80
+ maxBuffer: 8 * 1024 * 1024,
81
+ timeout: 180_000,
82
+ windowsHide: true,
83
+ shell: false,
84
+ });
85
+ const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
86
+ log("npm install end", {
87
+ packageSpec,
88
+ status: result.status,
89
+ signal: result.signal,
90
+ error: result.error?.message,
91
+ output: output.slice(-4_000),
92
+ });
93
+ if (result.error) throw result.error;
94
+ if (result.status !== 0) {
95
+ throw new Error(`npm install ${packageSpec} 失败(退出码 ${result.status ?? "unknown"}):${output.slice(-1_000)}`);
96
+ }
97
+ }
98
+
99
+ async function installWithRetry(job, packageSpec) {
100
+ const attempts = Number.isInteger(job.maxInstallAttempts) && job.maxInstallAttempts > 0
101
+ ? job.maxInstallAttempts
102
+ : 3;
103
+ let lastError;
104
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
105
+ try {
106
+ runNpmInstall(job, packageSpec);
107
+ return;
108
+ } catch (error) {
109
+ lastError = error;
110
+ log("npm install attempt failed", {
111
+ packageSpec,
112
+ attempt,
113
+ attempts,
114
+ error: error instanceof Error ? error.message : String(error),
115
+ });
116
+ if (attempt < attempts) await sleep(500 * attempt);
117
+ }
118
+ }
119
+ throw lastError ?? new Error(`npm install ${packageSpec} 失败。`);
120
+ }
121
+
122
+ function persistResult(job, result) {
123
+ const temporary = `${job.resultFile}.${process.pid}.tmp`;
124
+ writeFileSync(temporary, JSON.stringify({
125
+ schemaVersion: 1,
126
+ completedAt: new Date().toISOString(),
127
+ ...result,
128
+ }, null, 2), "utf8");
129
+ renameSync(temporary, job.resultFile);
130
+ }
131
+
132
+ function markSafeUpdateFailed(job, message) {
133
+ if (!job.safeMaintenanceFile || !existsSync(job.safeMaintenanceFile)) return;
134
+ try {
135
+ const current = JSON.parse(readFileSync(job.safeMaintenanceFile, "utf8"));
136
+ if (current?.kind !== "update" || current.phase !== "executing") return;
137
+ const failed = {
138
+ ...current,
139
+ phase: "failed",
140
+ updatedAt: new Date().toISOString(),
141
+ lastError: message,
142
+ };
143
+ const temporary = `${job.safeMaintenanceFile}.${process.pid}.tmp`;
144
+ writeFileSync(temporary, JSON.stringify(failed, null, 2), "utf8");
145
+ renameSync(temporary, job.safeMaintenanceFile);
146
+ log("safe update state marked failed");
147
+ } catch (error) {
148
+ log("failed to persist safe update failure", error instanceof Error ? error.message : String(error));
149
+ }
150
+ }
151
+
152
+ function launchRuntime(job, status, errorMessage) {
153
+ const env = {
154
+ ...process.env,
155
+ [INTERNAL_RESTART_ENV]: "1",
156
+ [RESTART_PARENT_PID_ENV]: String(process.pid),
157
+ [UPDATE_RESULT_ENV]: status,
158
+ };
159
+ if (errorMessage) env[UPDATE_ERROR_ENV] = errorMessage.slice(0, 2_000);
160
+ const child = spawn(job.runtimeCommand, [job.runtimeEntry], {
161
+ cwd: job.restartCwd,
162
+ detached: true,
163
+ stdio: ["ignore", "ignore", "inherit"],
164
+ shell: false,
165
+ windowsHide: true,
166
+ env,
167
+ });
168
+ child.unref();
169
+ log("runtime launched", {
170
+ pid: child.pid,
171
+ status,
172
+ command: job.runtimeCommand,
173
+ entry: job.runtimeEntry,
174
+ cwd: job.restartCwd,
175
+ });
176
+ }
177
+
178
+ function validateJob(job) {
179
+ if (
180
+ job?.schemaVersion !== 1
181
+ || !Number.isInteger(job.parentPid)
182
+ || job.parentPid <= 0
183
+ || typeof job.packageRoot !== "string"
184
+ || typeof job.previousVersion !== "string"
185
+ || typeof job.restartCwd !== "string"
186
+ || typeof job.npmCommand !== "string"
187
+ || !Array.isArray(job.npmArgsPrefix)
188
+ || !job.npmArgsPrefix.every((arg) => typeof arg === "string")
189
+ || typeof job.runtimeCommand !== "string"
190
+ || typeof job.runtimeEntry !== "string"
191
+ || typeof job.resultFile !== "string"
192
+ || (job.safeMaintenanceFile !== undefined && typeof job.safeMaintenanceFile !== "string")
193
+ || (job.cleanupPaths !== undefined && (
194
+ !Array.isArray(job.cleanupPaths)
195
+ || !job.cleanupPaths.every((path) => typeof path === "string")
196
+ ))
197
+ ) {
198
+ throw new Error("自更新任务文件无效。");
199
+ }
200
+ return job;
201
+ }
202
+
203
+ async function announceReady(job) {
204
+ if (typeof process.send !== "function") return;
205
+ await new Promise((resolvePromise) => {
206
+ try {
207
+ process.send({
208
+ type: READY_MESSAGE,
209
+ pid: process.pid,
210
+ parentPid: job.parentPid,
211
+ }, () => resolvePromise());
212
+ } catch {
213
+ resolvePromise();
214
+ }
215
+ });
216
+ try { process.disconnect?.(); } catch { /* parent may disconnect first */ }
217
+ }
218
+
219
+ export async function runSelfUpdateJob(job) {
220
+ const parentExited = await waitForParentExit(
221
+ job.parentPid,
222
+ job.parentExitTimeoutMs ?? 30_000,
223
+ );
224
+ if (!parentExited) {
225
+ throw new Error(`旧 ChatCCC 进程 ${job.parentPid} 未在限定时间内退出,已取消更新。`);
226
+ }
227
+
228
+ try {
229
+ await installWithRetry(job, "chatccc@latest");
230
+ const installed = readInstalledPackage(job);
231
+ persistResult(job, {
232
+ status: "success",
233
+ previousVersion: job.previousVersion,
234
+ installedVersion: installed.version,
235
+ rollbackSucceeded: false,
236
+ });
237
+ launchRuntime(job, "success");
238
+ } catch (updateError) {
239
+ const message = updateError instanceof Error ? updateError.message : String(updateError);
240
+ log("latest installation failed; rollback begin", {
241
+ previousVersion: job.previousVersion,
242
+ error: message,
243
+ });
244
+ let rollbackSucceeded = false;
245
+ try {
246
+ await installWithRetry(job, `chatccc@${job.previousVersion}`);
247
+ readInstalledPackage(job);
248
+ rollbackSucceeded = true;
249
+ } catch (rollbackError) {
250
+ log("rollback failed", rollbackError instanceof Error ? rollbackError.message : String(rollbackError));
251
+ // npm 可能只在 postinstall 等末尾步骤失败;若入口仍完整,则优先恢复服务。
252
+ try {
253
+ readInstalledPackage(job);
254
+ rollbackSucceeded = true;
255
+ log("existing runtime remains usable after rollback failure");
256
+ } catch {
257
+ // 包目录确实不可用,只能保留结果和日志供人工恢复。
258
+ }
259
+ }
260
+ persistResult(job, {
261
+ status: "failed",
262
+ previousVersion: job.previousVersion,
263
+ rollbackSucceeded,
264
+ error: message,
265
+ });
266
+ markSafeUpdateFailed(job, message);
267
+ if (!rollbackSucceeded) {
268
+ throw new Error(`更新失败且无法恢复旧版本:${message}`);
269
+ }
270
+ launchRuntime(job, "failed", message);
271
+ }
272
+ }
273
+
274
+ async function main() {
275
+ const jobPath = process.argv[2];
276
+ if (!jobPath) throw new Error("缺少自更新任务文件路径。");
277
+ const job = validateJob(JSON.parse(readFileSync(jobPath, "utf8")));
278
+ log("helper started", {
279
+ pid: process.pid,
280
+ parentPid: job.parentPid,
281
+ packageRoot: job.packageRoot,
282
+ helper: basename(process.argv[1]),
283
+ });
284
+ await announceReady(job);
285
+ try {
286
+ await runSelfUpdateJob(job);
287
+ } finally {
288
+ for (const path of job.cleanupPaths ?? []) {
289
+ try { rmSync(path, { force: true }); } catch { /* best effort */ }
290
+ }
291
+ }
292
+ }
293
+
294
+ const invokedDirectly = process.argv[1]
295
+ && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
296
+ if (invokedDirectly) {
297
+ main().catch((error) => {
298
+ log("fatal", error instanceof Error ? error.stack ?? error.message : String(error));
299
+ process.exitCode = 1;
300
+ });
301
+ }