chatccc 0.2.298 → 0.2.300
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 +2 -2
- package/dist/src/feishu-api.js +3 -1
- package/dist/src/orchestrator.js +34 -94
- package/dist/src/safe-maintenance.js +21 -1
- package/dist/src/self-update.js +170 -0
- package/package.json +2 -1
- package/scripts/self-update-helper.mjs +301 -0
package/README.md
CHANGED
|
@@ -438,7 +438,7 @@ ChatCCC 通过 Codex 的 **app-server 模式**(一个常驻进程 + WebSocket
|
|
|
438
438
|
| `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
|
|
439
439
|
| `/sessions` | 查看所有会话状态 |
|
|
440
440
|
| `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
|
|
441
|
-
| `/usage` | 查看当前会话对应 Agent 的用量;Codex
|
|
441
|
+
| `/usage` | 查看当前会话对应 Agent 的用量;Codex 使用本机 Codex 登录凭据中的账户 ID 查询额度,只显示接口实际返回的 5h/7天窗口,主动重置次数查询失败时会明确展示上次成功快照及其查询时间(缓存结果不提供重置按钮);Cursor 显示当前周期用量,CCC Agent 和 DSH 仅在官方 DeepSeek 端点时显示账户余额(其他兼容端点自动跳过) |
|
|
442
442
|
| `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
|
|
443
443
|
| `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
|
|
444
444
|
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
@@ -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
|
|
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/feishu-api.js
CHANGED
|
@@ -434,7 +434,9 @@ export async function getCodexUsageSummary(options = {}) {
|
|
|
434
434
|
const includeResetCredits = options.includeResetCredits !== false;
|
|
435
435
|
const resetCreditsPromise = includeResetCredits ? fetchCodexRateLimitResetCredits(auth) : null;
|
|
436
436
|
const resp = await fetch(CODEX_USAGE_URL, {
|
|
437
|
-
|
|
437
|
+
// An unscoped request can return a different workspace's quota for the
|
|
438
|
+
// same login. Match the account headers used by Codex and reset-credit reads.
|
|
439
|
+
headers: codexAuthHeaders(auth),
|
|
438
440
|
});
|
|
439
441
|
const text = await resp.text();
|
|
440
442
|
if (!resp.ok)
|
package/dist/src/orchestrator.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1172
|
-
const
|
|
1173
|
-
if (
|
|
1174
|
-
|
|
1175
|
-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chatccc",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.300",
|
|
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
|
+
}
|