chatccc 0.2.231 → 0.2.233
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/.agents/skills/create-chatccc-feishu-app/SKILL.md +85 -85
- package/.claude/skills/create-chatccc-feishu-app/SKILL.md +85 -85
- package/.cursor/skills/create-chatccc-feishu-app/SKILL.md +85 -85
- package/README.md +90 -90
- package/agent-prompts/claude_specific.md +45 -45
- package/agent-prompts/codex_specific.md +2 -2
- package/agent-prompts/cursor_specific.md +13 -13
- package/bin/cccagent.mjs +17 -17
- package/im-skills/feishu-skill/receive-send-file.md +63 -63
- package/im-skills/feishu-skill/receive-send-image.md +24 -24
- package/im-skills/feishu-skill/skill.md +3 -3
- package/im-skills/wechat-file-skill/receive-send-file.md +38 -38
- package/im-skills/wechat-file-skill/send-file.mjs +83 -83
- package/im-skills/wechat-file-skill/skill.md +10 -10
- package/im-skills/wechat-image-skill/skill.md +10 -10
- package/im-skills/wechat-video-skill/receive-send-video.md +38 -38
- package/im-skills/wechat-video-skill/send-video.mjs +79 -79
- package/im-skills/wechat-video-skill/skill.md +10 -10
- package/package.json +1 -1
- package/scripts/postinstall-sharp-check.mjs +58 -58
- package/src/__tests__/agent-activity.test.ts +86 -86
- package/src/__tests__/agent-delegate-task-rpc.test.ts +165 -165
- package/src/__tests__/agent-reload-config-rpc.test.ts +99 -99
- package/src/__tests__/builtin-chat-session.test.ts +449 -393
- package/src/__tests__/builtin-cli-json.test.ts +39 -39
- package/src/__tests__/builtin-config.test.ts +26 -26
- package/src/__tests__/builtin-context.test.ts +319 -202
- package/src/__tests__/builtin-file-tools.test.ts +240 -240
- package/src/__tests__/builtin-permissions.test.ts +211 -211
- package/src/__tests__/builtin-session-search.test.ts +228 -0
- package/src/__tests__/builtin-session-select.test.ts +116 -116
- package/src/__tests__/builtin-sigint.test.ts +56 -56
- package/src/__tests__/builtin-skills.test.ts +284 -284
- package/src/__tests__/builtin-web-tools.test.ts +220 -220
- package/src/__tests__/card-action-routing.test.ts +18 -18
- package/src/__tests__/cardkit.test.ts +60 -60
- package/src/__tests__/ccc-adapter.test.ts +170 -170
- package/src/__tests__/chatgpt-subscription-rpc.test.ts +89 -89
- package/src/__tests__/chatgpt-subscription.test.ts +135 -135
- package/src/__tests__/chrome-devtools-guard.test.ts +165 -165
- package/src/__tests__/claude-adapter.test.ts +614 -614
- package/src/__tests__/claude-raw-stream-log.test.ts +87 -87
- package/src/__tests__/codex-adapter.test.ts +58 -58
- package/src/__tests__/codex-raw-stream-log.test.ts +170 -170
- package/src/__tests__/codex-reset-actions.test.ts +146 -146
- package/src/__tests__/crash-logging.test.ts +95 -1
- package/src/__tests__/cursor-adapter.test.ts +268 -268
- package/src/__tests__/feishu-api.test.ts +60 -60
- package/src/__tests__/feishu-avatar.test.ts +164 -164
- package/src/__tests__/feishu-message-ingress.test.ts +138 -138
- package/src/__tests__/feishu-platform.test.ts +22 -22
- package/src/__tests__/format-message.test.ts +47 -47
- package/src/__tests__/jsonl-stream.test.ts +79 -79
- package/src/__tests__/package-files.test.ts +24 -24
- package/src/__tests__/privacy.test.ts +198 -198
- package/src/__tests__/progress-reducer.test.ts +121 -121
- package/src/__tests__/raw-stream-log.test.ts +106 -106
- package/src/__tests__/response-stall.test.ts +49 -49
- package/src/__tests__/restart.test.ts +55 -10
- package/src/__tests__/session.test.ts +199 -199
- package/src/__tests__/shared-prefix.test.ts +36 -36
- package/src/__tests__/sim-platform.test.ts +16 -16
- package/src/__tests__/startup-lifecycle.test.ts +231 -231
- package/src/__tests__/stop-session.test.ts +34 -34
- package/src/__tests__/stream-state.test.ts +42 -42
- package/src/__tests__/terminal-error.test.ts +54 -54
- package/src/__tests__/terminal-renderer.test.ts +247 -247
- package/src/__tests__/update-command-guard.test.ts +144 -144
- package/src/__tests__/web-ui.test.ts +326 -326
- package/src/adapters/adapter-interface.ts +33 -33
- package/src/adapters/ccc-adapter.ts +141 -141
- package/src/adapters/claude-adapter.ts +620 -620
- package/src/adapters/claude-session-meta-store.ts +120 -120
- package/src/adapters/codex-adapter.ts +426 -426
- package/src/adapters/cursor-adapter.ts +681 -681
- package/src/adapters/jsonl-stream.ts +157 -157
- package/src/adapters/raw-stream-log.ts +124 -124
- package/src/adapters/resource-monitor.ts +140 -140
- package/src/agent-activity.ts +175 -175
- package/src/agent-delegate-task-rpc.ts +153 -153
- package/src/agent-delegate-task.ts +91 -91
- package/src/agent-reload-config-rpc.ts +34 -34
- package/src/agent-stop-stuck.ts +129 -129
- package/src/builtin/cli.ts +6 -0
- package/src/builtin/config.ts +84 -84
- package/src/builtin/context.ts +465 -391
- package/src/builtin/file-log.ts +38 -38
- package/src/builtin/file-tools.ts +34 -0
- package/src/builtin/index.ts +24 -23
- package/src/builtin/proc-tree-kill.ts +61 -61
- package/src/builtin/progress/cards-helpers.ts +76 -76
- package/src/builtin/progress/reducer.ts +113 -113
- package/src/builtin/progress/terminal-renderer.ts +294 -294
- package/src/builtin/progress/view.ts +77 -77
- package/src/builtin/raw-stream-log.ts +124 -124
- package/src/builtin/session-search.ts +363 -0
- package/src/builtin/session-select.ts +48 -48
- package/src/builtin/sigint.ts +50 -50
- package/src/builtin/skills.ts +205 -205
- package/src/builtin/web-tools.ts +313 -313
- package/src/card-action-routing.ts +14 -14
- package/src/chatgpt-subscription-rpc.ts +27 -27
- package/src/chatgpt-subscription.ts +299 -299
- package/src/chrome-devtools-guard.ts +318 -318
- package/src/codex-reset-actions.ts +184 -184
- package/src/feishu-api.ts +193 -193
- package/src/feishu-message-ingress.ts +195 -195
- package/src/feishu-platform.ts +20 -20
- package/src/format-message.ts +293 -293
- package/src/index.ts +6 -1
- package/src/litellm-proxy.ts +374 -374
- package/src/orchestrator.ts +36 -13
- package/src/platform-adapter.ts +6 -6
- package/src/privacy.ts +118 -118
- package/src/progress/reducer.ts +113 -113
- package/src/progress/terminal-renderer.ts +294 -294
- package/src/progress/view.ts +77 -77
- package/src/response-stall.ts +28 -28
- package/src/runtime-reload.ts +34 -34
- package/src/session-chat-binding.ts +82 -82
- package/src/session-name.ts +8 -8
- package/src/session.ts +201 -201
- package/src/shared-prefix.ts +29 -29
- package/src/shared.ts +44 -0
- package/src/sim-platform.ts +20 -20
- package/src/startup-lifecycle.ts +250 -250
- package/src/stream-state.ts +21 -21
- package/src/terminal-error.ts +129 -129
- package/src/turn-cards.ts +117 -117
- package/src/update-command-guard.ts +165 -165
package/src/shared.ts
CHANGED
|
@@ -340,6 +340,39 @@ export interface InstallCrashLoggingResult {
|
|
|
340
340
|
cleanup: () => void;
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
/**
|
|
344
|
+
* 常驻服务不能因 stdout/stderr 管道读端消失而 EPIPE 崩溃。
|
|
345
|
+
*
|
|
346
|
+
* 背景:`/restart` 后旧进程退出会关闭 stderr pipe 读端,但新进程(尤其经过
|
|
347
|
+
* tsx 包装层)的 stderr 仍指向该管道写端;一旦第三方 SDK(如飞书)打
|
|
348
|
+
* console.warn 就会 EPIPE。Node 对 stdout/stderr 的 EPIPE 若无 error 监听会
|
|
349
|
+
* 抛成 uncaughtException → 默认 onFatal 直接 process.exit(1) 杀死整个服务。
|
|
350
|
+
*
|
|
351
|
+
* 这里挂上 error 监听把这类 IO 错误降级为一条同步 trace(写磁盘,不走
|
|
352
|
+
* stdout/stderr,不会递归崩溃),服务继续运行。返回 cleanup 用于移除监听。
|
|
353
|
+
*/
|
|
354
|
+
export interface EpipeGuardOptions {
|
|
355
|
+
/** 用于写入诊断的同步函数,默认 appendStartupTrace */
|
|
356
|
+
tracer?: (message: string, extra?: Record<string, unknown>) => void;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function installEpipeGuard(
|
|
360
|
+
streams: NodeJS.WriteStream[] = [process.stdout, process.stderr],
|
|
361
|
+
options: EpipeGuardOptions = {},
|
|
362
|
+
): () => void {
|
|
363
|
+
const tracer = options.tracer ?? appendStartupTrace;
|
|
364
|
+
const onError = (err: NodeJS.ErrnoException): void => {
|
|
365
|
+
safeCall(tracer, "stdio write error (non-fatal)", {
|
|
366
|
+
code: err.code ?? "",
|
|
367
|
+
message: (err.message ?? String(err)).slice(0, 200),
|
|
368
|
+
});
|
|
369
|
+
};
|
|
370
|
+
for (const stream of streams) stream.on("error", onError);
|
|
371
|
+
return () => {
|
|
372
|
+
for (const stream of streams) stream.off("error", onError);
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
343
376
|
/**
|
|
344
377
|
* 把崩溃黑匣子 handler 装到 process 上,返回 cleanup。
|
|
345
378
|
*
|
|
@@ -389,6 +422,7 @@ export function setupFileLogging(logDir: string, prefix: string): { logPath: str
|
|
|
389
422
|
writeFileSync(logPath, "", { flag: "a", encoding: "utf8" });
|
|
390
423
|
const origConsoleLog = console.log.bind(console);
|
|
391
424
|
const origConsoleError = console.error.bind(console);
|
|
425
|
+
const origConsoleWarn = console.warn.bind(console);
|
|
392
426
|
const formatArg = (arg: unknown): string => {
|
|
393
427
|
if (typeof arg === "string") return arg;
|
|
394
428
|
if (arg instanceof Error) return arg.stack ?? arg.message;
|
|
@@ -422,6 +456,16 @@ export function setupFileLogging(logDir: string, prefix: string): { logPath: str
|
|
|
422
456
|
// 控制台输出失败也不能拖垮服务
|
|
423
457
|
}
|
|
424
458
|
};
|
|
459
|
+
// warn 同样落盘并兜底:飞书 SDK 等第三方库内部用 console.warn 打日志,
|
|
460
|
+
// 若不走这里(直接写 stderr),restart 后管道断开时会 EPIPE 崩溃。
|
|
461
|
+
console.warn = (...args: unknown[]) => {
|
|
462
|
+
writeLine("WARN", args);
|
|
463
|
+
try {
|
|
464
|
+
origConsoleWarn(...args);
|
|
465
|
+
} catch {
|
|
466
|
+
// 控制台输出失败也不能拖垮服务
|
|
467
|
+
}
|
|
468
|
+
};
|
|
425
469
|
const flush = () => {
|
|
426
470
|
try {
|
|
427
471
|
appendFileSync(logPath, "", "utf8");
|
package/src/sim-platform.ts
CHANGED
|
@@ -123,25 +123,25 @@ export const SimulatedPlatform: FeishuPlatform = {
|
|
|
123
123
|
},
|
|
124
124
|
|
|
125
125
|
// ---- 头像 ----
|
|
126
|
-
async setChatAvatar(_token, _chatId, _tool, _status) {
|
|
127
|
-
// 模拟模式不需要头像
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
async getCodexUsageSummary() {
|
|
131
|
-
return {
|
|
132
|
-
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
133
|
-
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
134
|
-
rateLimitResetCreditsAvailable: null,
|
|
135
|
-
rateLimitResetCredits: null,
|
|
136
|
-
};
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
async consumeCodexRateLimitResetCredit(_redeemRequestId) {
|
|
140
|
-
return { code: "no_credit" as const, windowsReset: 0 };
|
|
141
|
-
},
|
|
142
|
-
|
|
143
|
-
// ---- 图片下载 ----
|
|
144
|
-
async getOrDownloadImage(_token, _messageId, fileKey) {
|
|
126
|
+
async setChatAvatar(_token, _chatId, _tool, _status) {
|
|
127
|
+
// 模拟模式不需要头像
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
async getCodexUsageSummary() {
|
|
131
|
+
return {
|
|
132
|
+
fiveHour: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
133
|
+
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
134
|
+
rateLimitResetCreditsAvailable: null,
|
|
135
|
+
rateLimitResetCredits: null,
|
|
136
|
+
};
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
async consumeCodexRateLimitResetCredit(_redeemRequestId) {
|
|
140
|
+
return { code: "no_credit" as const, windowsReset: 0 };
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// ---- 图片下载 ----
|
|
144
|
+
async getOrDownloadImage(_token, _messageId, fileKey) {
|
|
145
145
|
return join(SIM_DIR, "images", fileKey);
|
|
146
146
|
},
|
|
147
147
|
|
|
@@ -174,4 +174,4 @@ export const SimulatedPlatform: FeishuPlatform = {
|
|
|
174
174
|
};
|
|
175
175
|
|
|
176
176
|
/** 模拟模式下的默认 chat_id(重新导出以保持向后兼容) */
|
|
177
|
-
export { SIM_DEFAULT_CHAT_ID } from "./sim-store.ts";
|
|
177
|
+
export { SIM_DEFAULT_CHAT_ID } from "./sim-store.ts";
|
package/src/startup-lifecycle.ts
CHANGED
|
@@ -1,250 +1,250 @@
|
|
|
1
|
-
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
-
|
|
3
|
-
const DEFAULT_SERVICE_HEALTH_INTERVAL_MS = 10_000;
|
|
4
|
-
|
|
5
|
-
interface RefTimer {
|
|
6
|
-
ref?: () => unknown;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export interface ServiceLifecycleServer {
|
|
10
|
-
listening: boolean;
|
|
11
|
-
address: () => unknown;
|
|
12
|
-
ref: () => unknown;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
interface ServiceLifecycleGuardOptions {
|
|
16
|
-
intervalMs?: number;
|
|
17
|
-
setIntervalImpl?: (callback: () => void, delayMs: number) => RefTimer;
|
|
18
|
-
clearIntervalImpl?: (timer: RefTimer) => void;
|
|
19
|
-
tracer?: (message: string, extra?: Record<string, unknown>) => void;
|
|
20
|
-
getActiveResourcesInfo?: () => string[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface ServiceLifecycleGuard {
|
|
24
|
-
start: () => void;
|
|
25
|
-
attachServer: (
|
|
26
|
-
server: ServiceLifecycleServer,
|
|
27
|
-
recoverServer?: () => void | Promise<void>,
|
|
28
|
-
) => void;
|
|
29
|
-
checkNow: () => Promise<void>;
|
|
30
|
-
handleBeforeExit: (code: number) => void;
|
|
31
|
-
beginShutdown: (reason: string) => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* 为 ChatCCC 这种常驻服务建立一个明确的进程生命周期锚点。
|
|
36
|
-
*
|
|
37
|
-
* 正常情况下,正在 listen 的 HTTP Server 自己就足以维持事件循环;额外的
|
|
38
|
-
* referenced timer 是最后一道保险,避免某个依赖升级或异常 close/unref 让进程在
|
|
39
|
-
* 没有信号、异常或退出码的情况下静默消失。定时检查同时会重新 ref Server,并在
|
|
40
|
-
* Server 确实停止监听时串行触发恢复,避免只把一个失去服务能力的僵尸进程留下来。
|
|
41
|
-
*/
|
|
42
|
-
export function createServiceLifecycleGuard(
|
|
43
|
-
options: ServiceLifecycleGuardOptions = {},
|
|
44
|
-
): ServiceLifecycleGuard {
|
|
45
|
-
const intervalMs = options.intervalMs ?? DEFAULT_SERVICE_HEALTH_INTERVAL_MS;
|
|
46
|
-
const setIntervalImpl = options.setIntervalImpl
|
|
47
|
-
?? ((callback, delayMs) => setInterval(callback, delayMs));
|
|
48
|
-
const clearIntervalImpl = options.clearIntervalImpl
|
|
49
|
-
?? ((timer) => clearInterval(timer as NodeJS.Timeout));
|
|
50
|
-
const tracer = options.tracer ?? (() => {});
|
|
51
|
-
const getActiveResourcesInfo = options.getActiveResourcesInfo
|
|
52
|
-
?? (() => process.getActiveResourcesInfo());
|
|
53
|
-
|
|
54
|
-
let timer: RefTimer | null = null;
|
|
55
|
-
let server: ServiceLifecycleServer | null = null;
|
|
56
|
-
let recoverServer: (() => void | Promise<void>) | undefined;
|
|
57
|
-
let recoveryPromise: Promise<void> | null = null;
|
|
58
|
-
let shuttingDown = false;
|
|
59
|
-
|
|
60
|
-
const trace = (message: string, extra?: Record<string, unknown>): void => {
|
|
61
|
-
try { tracer(message, extra); } catch { /* 诊断路径不能反过来打断服务 */ }
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const serverAddress = (): unknown => {
|
|
65
|
-
try { return server?.address() ?? null; } catch { return null; }
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
const activeResources = (): string[] => {
|
|
69
|
-
try { return getActiveResourcesInfo(); } catch { return []; }
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
const diagnostics = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
73
|
-
...extra,
|
|
74
|
-
uptimeSeconds: Math.floor(process.uptime()),
|
|
75
|
-
activeResources: activeResources(),
|
|
76
|
-
serverAttached: server !== null,
|
|
77
|
-
serverListening: server?.listening ?? false,
|
|
78
|
-
serverAddress: serverAddress(),
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const start = (): void => {
|
|
82
|
-
if (shuttingDown || timer) return;
|
|
83
|
-
timer = setIntervalImpl(() => { void checkNow(); }, intervalMs);
|
|
84
|
-
// Node 的 Timeout 默认就是 ref 状态;显式 ref 让常驻服务契约不会依赖默认值。
|
|
85
|
-
try { timer.ref?.(); } catch { /* ignore */ }
|
|
86
|
-
trace("service-lifecycle: guard started", { intervalMs });
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const checkNow = async (): Promise<void> => {
|
|
90
|
-
if (shuttingDown || !server) return;
|
|
91
|
-
if (server.listening) {
|
|
92
|
-
try { server.ref(); } catch (err) {
|
|
93
|
-
trace("service-lifecycle: HTTP server ref failed", diagnostics({
|
|
94
|
-
error: (err as Error).message,
|
|
95
|
-
}));
|
|
96
|
-
}
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (recoveryPromise) return recoveryPromise;
|
|
101
|
-
trace("service-lifecycle: HTTP server inactive", diagnostics());
|
|
102
|
-
if (!recoverServer) return;
|
|
103
|
-
|
|
104
|
-
recoveryPromise = Promise.resolve()
|
|
105
|
-
.then(() => recoverServer?.())
|
|
106
|
-
.then(() => {
|
|
107
|
-
if (server?.listening) {
|
|
108
|
-
try { server.ref(); } catch { /* 下一轮健康检查会再次尝试 */ }
|
|
109
|
-
trace("service-lifecycle: HTTP server recovered", diagnostics());
|
|
110
|
-
} else {
|
|
111
|
-
trace("service-lifecycle: HTTP recovery completed without listening", diagnostics());
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
.catch((err: unknown) => {
|
|
115
|
-
trace("service-lifecycle: HTTP server recovery failed", diagnostics({
|
|
116
|
-
error: err instanceof Error ? err.message : String(err),
|
|
117
|
-
}));
|
|
118
|
-
})
|
|
119
|
-
.finally(() => {
|
|
120
|
-
recoveryPromise = null;
|
|
121
|
-
});
|
|
122
|
-
return recoveryPromise;
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const attachServer = (
|
|
126
|
-
nextServer: ServiceLifecycleServer,
|
|
127
|
-
nextRecoverServer?: () => void | Promise<void>,
|
|
128
|
-
): void => {
|
|
129
|
-
server = nextServer;
|
|
130
|
-
recoverServer = nextRecoverServer;
|
|
131
|
-
if (server.listening) {
|
|
132
|
-
try { server.ref(); } catch { /* 下一轮健康检查会记录 */ }
|
|
133
|
-
}
|
|
134
|
-
trace("service-lifecycle: HTTP server attached", diagnostics());
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const handleBeforeExit = (code: number): void => {
|
|
138
|
-
if (shuttingDown) return;
|
|
139
|
-
trace("service-lifecycle: unexpected beforeExit", diagnostics({ code }));
|
|
140
|
-
start();
|
|
141
|
-
void checkNow();
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const beginShutdown = (reason: string): void => {
|
|
145
|
-
if (shuttingDown) return;
|
|
146
|
-
shuttingDown = true;
|
|
147
|
-
if (timer) {
|
|
148
|
-
try { clearIntervalImpl(timer); } catch { /* process 即将退出 */ }
|
|
149
|
-
timer = null;
|
|
150
|
-
}
|
|
151
|
-
trace("service-lifecycle: shutdown requested", { reason });
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
return { start, attachServer, checkNow, handleBeforeExit, beginShutdown };
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* ChatCCC 自己拉起替代进程时使用的内部标记。
|
|
159
|
-
*
|
|
160
|
-
* 不能用“是否已有配置”判断是否打开控制台:首次配置和日常直接启动都应该
|
|
161
|
-
* 打开,而 `/restart`、`/update` 和 Web UI 重启都不应该打扰用户。环境变量
|
|
162
|
-
* 会自然穿过 cmd/bash/npx 这几层启动器,因此也适用于 Windows 与 Linux。
|
|
163
|
-
*/
|
|
164
|
-
export const INTERNAL_RESTART_ENV_VAR = "CHATCCC_INTERNAL_RESTART";
|
|
165
|
-
|
|
166
|
-
type Environment = Record<string, string | undefined>;
|
|
167
|
-
|
|
168
|
-
export function createInternalRestartEnv(
|
|
169
|
-
inherited: Environment = process.env,
|
|
170
|
-
): NodeJS.ProcessEnv {
|
|
171
|
-
return {
|
|
172
|
-
...inherited,
|
|
173
|
-
[INTERNAL_RESTART_ENV_VAR]: "1",
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/** 用户直接启动时打开;ChatCCC 内部重启产生的替代进程不打开。 */
|
|
178
|
-
interface AutoOpenWebUiOptions {
|
|
179
|
-
env?: Environment;
|
|
180
|
-
openOnStart?: boolean;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
export function shouldAutoOpenWebUi(options: AutoOpenWebUiOptions = {}): boolean {
|
|
184
|
-
const env = options.env ?? process.env;
|
|
185
|
-
return options.openOnStart !== false && env[INTERNAL_RESTART_ENV_VAR] !== "1";
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/** Web UI 始终使用 localhost,并跟随实际配置端口。 */
|
|
189
|
-
export function buildWebUiUrl(port: number): string {
|
|
190
|
-
return `http://localhost:${port}/`;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
interface OpenBrowserDeps {
|
|
194
|
-
platform?: NodeJS.Platform;
|
|
195
|
-
env?: Environment;
|
|
196
|
-
spawnImpl?: typeof spawn;
|
|
197
|
-
onError?: (message: string) => void;
|
|
198
|
-
onInfo?: (message: string) => void;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* 调用操作系统默认浏览器打开 Web UI,与 Chrome CDP 守护功能完全独立。
|
|
203
|
-
* 返回值仅表示打开请求是否成功发起;浏览器是否复用标签页由系统浏览器决定。
|
|
204
|
-
*/
|
|
205
|
-
export function openWebUiInDefaultBrowser(
|
|
206
|
-
port: number,
|
|
207
|
-
deps: OpenBrowserDeps = {},
|
|
208
|
-
): boolean {
|
|
209
|
-
const url = buildWebUiUrl(port);
|
|
210
|
-
const platform = deps.platform ?? process.platform;
|
|
211
|
-
const env = deps.env ?? process.env;
|
|
212
|
-
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
213
|
-
const onError = deps.onError ?? ((message: string) => console.error(message));
|
|
214
|
-
const onInfo = deps.onInfo ?? ((message: string) => console.log(message));
|
|
215
|
-
|
|
216
|
-
// Linux 服务器通常没有图形会话。此时调用 xdg-open 只会制造噪音;
|
|
217
|
-
// 直接给出可复制的 SSH 隧道命令,让用户从自己的电脑访问本地 Web UI。
|
|
218
|
-
if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
219
|
-
onInfo(
|
|
220
|
-
`[WEB-UI] 未检测到 Linux 图形桌面,跳过自动打开浏览器。` +
|
|
221
|
-
`可在本机执行 ssh -L ${port}:127.0.0.1:${port} <user>@<server>,` +
|
|
222
|
-
`然后访问 ${url}`,
|
|
223
|
-
);
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
try {
|
|
228
|
-
let child: ChildProcess;
|
|
229
|
-
if (platform === "win32") {
|
|
230
|
-
// `start` 会把第一个带引号的参数当窗口标题,空字符串是必要占位符。
|
|
231
|
-
child = spawnImpl("cmd.exe", ["/c", "start", "", url], {
|
|
232
|
-
detached: true,
|
|
233
|
-
stdio: "ignore",
|
|
234
|
-
windowsHide: true,
|
|
235
|
-
});
|
|
236
|
-
} else if (platform === "darwin") {
|
|
237
|
-
child = spawnImpl("open", [url], { detached: true, stdio: "ignore" });
|
|
238
|
-
} else {
|
|
239
|
-
child = spawnImpl("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
240
|
-
}
|
|
241
|
-
child.on("error", (err) => {
|
|
242
|
-
onError(`[WEB-UI] 自动打开浏览器失败: ${err.message}`);
|
|
243
|
-
});
|
|
244
|
-
child.unref();
|
|
245
|
-
return true;
|
|
246
|
-
} catch (err) {
|
|
247
|
-
onError(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
|
|
248
|
-
return false;
|
|
249
|
-
}
|
|
250
|
-
}
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SERVICE_HEALTH_INTERVAL_MS = 10_000;
|
|
4
|
+
|
|
5
|
+
interface RefTimer {
|
|
6
|
+
ref?: () => unknown;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ServiceLifecycleServer {
|
|
10
|
+
listening: boolean;
|
|
11
|
+
address: () => unknown;
|
|
12
|
+
ref: () => unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ServiceLifecycleGuardOptions {
|
|
16
|
+
intervalMs?: number;
|
|
17
|
+
setIntervalImpl?: (callback: () => void, delayMs: number) => RefTimer;
|
|
18
|
+
clearIntervalImpl?: (timer: RefTimer) => void;
|
|
19
|
+
tracer?: (message: string, extra?: Record<string, unknown>) => void;
|
|
20
|
+
getActiveResourcesInfo?: () => string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ServiceLifecycleGuard {
|
|
24
|
+
start: () => void;
|
|
25
|
+
attachServer: (
|
|
26
|
+
server: ServiceLifecycleServer,
|
|
27
|
+
recoverServer?: () => void | Promise<void>,
|
|
28
|
+
) => void;
|
|
29
|
+
checkNow: () => Promise<void>;
|
|
30
|
+
handleBeforeExit: (code: number) => void;
|
|
31
|
+
beginShutdown: (reason: string) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 为 ChatCCC 这种常驻服务建立一个明确的进程生命周期锚点。
|
|
36
|
+
*
|
|
37
|
+
* 正常情况下,正在 listen 的 HTTP Server 自己就足以维持事件循环;额外的
|
|
38
|
+
* referenced timer 是最后一道保险,避免某个依赖升级或异常 close/unref 让进程在
|
|
39
|
+
* 没有信号、异常或退出码的情况下静默消失。定时检查同时会重新 ref Server,并在
|
|
40
|
+
* Server 确实停止监听时串行触发恢复,避免只把一个失去服务能力的僵尸进程留下来。
|
|
41
|
+
*/
|
|
42
|
+
export function createServiceLifecycleGuard(
|
|
43
|
+
options: ServiceLifecycleGuardOptions = {},
|
|
44
|
+
): ServiceLifecycleGuard {
|
|
45
|
+
const intervalMs = options.intervalMs ?? DEFAULT_SERVICE_HEALTH_INTERVAL_MS;
|
|
46
|
+
const setIntervalImpl = options.setIntervalImpl
|
|
47
|
+
?? ((callback, delayMs) => setInterval(callback, delayMs));
|
|
48
|
+
const clearIntervalImpl = options.clearIntervalImpl
|
|
49
|
+
?? ((timer) => clearInterval(timer as NodeJS.Timeout));
|
|
50
|
+
const tracer = options.tracer ?? (() => {});
|
|
51
|
+
const getActiveResourcesInfo = options.getActiveResourcesInfo
|
|
52
|
+
?? (() => process.getActiveResourcesInfo());
|
|
53
|
+
|
|
54
|
+
let timer: RefTimer | null = null;
|
|
55
|
+
let server: ServiceLifecycleServer | null = null;
|
|
56
|
+
let recoverServer: (() => void | Promise<void>) | undefined;
|
|
57
|
+
let recoveryPromise: Promise<void> | null = null;
|
|
58
|
+
let shuttingDown = false;
|
|
59
|
+
|
|
60
|
+
const trace = (message: string, extra?: Record<string, unknown>): void => {
|
|
61
|
+
try { tracer(message, extra); } catch { /* 诊断路径不能反过来打断服务 */ }
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const serverAddress = (): unknown => {
|
|
65
|
+
try { return server?.address() ?? null; } catch { return null; }
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const activeResources = (): string[] => {
|
|
69
|
+
try { return getActiveResourcesInfo(); } catch { return []; }
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const diagnostics = (extra: Record<string, unknown> = {}): Record<string, unknown> => ({
|
|
73
|
+
...extra,
|
|
74
|
+
uptimeSeconds: Math.floor(process.uptime()),
|
|
75
|
+
activeResources: activeResources(),
|
|
76
|
+
serverAttached: server !== null,
|
|
77
|
+
serverListening: server?.listening ?? false,
|
|
78
|
+
serverAddress: serverAddress(),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const start = (): void => {
|
|
82
|
+
if (shuttingDown || timer) return;
|
|
83
|
+
timer = setIntervalImpl(() => { void checkNow(); }, intervalMs);
|
|
84
|
+
// Node 的 Timeout 默认就是 ref 状态;显式 ref 让常驻服务契约不会依赖默认值。
|
|
85
|
+
try { timer.ref?.(); } catch { /* ignore */ }
|
|
86
|
+
trace("service-lifecycle: guard started", { intervalMs });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const checkNow = async (): Promise<void> => {
|
|
90
|
+
if (shuttingDown || !server) return;
|
|
91
|
+
if (server.listening) {
|
|
92
|
+
try { server.ref(); } catch (err) {
|
|
93
|
+
trace("service-lifecycle: HTTP server ref failed", diagnostics({
|
|
94
|
+
error: (err as Error).message,
|
|
95
|
+
}));
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (recoveryPromise) return recoveryPromise;
|
|
101
|
+
trace("service-lifecycle: HTTP server inactive", diagnostics());
|
|
102
|
+
if (!recoverServer) return;
|
|
103
|
+
|
|
104
|
+
recoveryPromise = Promise.resolve()
|
|
105
|
+
.then(() => recoverServer?.())
|
|
106
|
+
.then(() => {
|
|
107
|
+
if (server?.listening) {
|
|
108
|
+
try { server.ref(); } catch { /* 下一轮健康检查会再次尝试 */ }
|
|
109
|
+
trace("service-lifecycle: HTTP server recovered", diagnostics());
|
|
110
|
+
} else {
|
|
111
|
+
trace("service-lifecycle: HTTP recovery completed without listening", diagnostics());
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.catch((err: unknown) => {
|
|
115
|
+
trace("service-lifecycle: HTTP server recovery failed", diagnostics({
|
|
116
|
+
error: err instanceof Error ? err.message : String(err),
|
|
117
|
+
}));
|
|
118
|
+
})
|
|
119
|
+
.finally(() => {
|
|
120
|
+
recoveryPromise = null;
|
|
121
|
+
});
|
|
122
|
+
return recoveryPromise;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const attachServer = (
|
|
126
|
+
nextServer: ServiceLifecycleServer,
|
|
127
|
+
nextRecoverServer?: () => void | Promise<void>,
|
|
128
|
+
): void => {
|
|
129
|
+
server = nextServer;
|
|
130
|
+
recoverServer = nextRecoverServer;
|
|
131
|
+
if (server.listening) {
|
|
132
|
+
try { server.ref(); } catch { /* 下一轮健康检查会记录 */ }
|
|
133
|
+
}
|
|
134
|
+
trace("service-lifecycle: HTTP server attached", diagnostics());
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const handleBeforeExit = (code: number): void => {
|
|
138
|
+
if (shuttingDown) return;
|
|
139
|
+
trace("service-lifecycle: unexpected beforeExit", diagnostics({ code }));
|
|
140
|
+
start();
|
|
141
|
+
void checkNow();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const beginShutdown = (reason: string): void => {
|
|
145
|
+
if (shuttingDown) return;
|
|
146
|
+
shuttingDown = true;
|
|
147
|
+
if (timer) {
|
|
148
|
+
try { clearIntervalImpl(timer); } catch { /* process 即将退出 */ }
|
|
149
|
+
timer = null;
|
|
150
|
+
}
|
|
151
|
+
trace("service-lifecycle: shutdown requested", { reason });
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return { start, attachServer, checkNow, handleBeforeExit, beginShutdown };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* ChatCCC 自己拉起替代进程时使用的内部标记。
|
|
159
|
+
*
|
|
160
|
+
* 不能用“是否已有配置”判断是否打开控制台:首次配置和日常直接启动都应该
|
|
161
|
+
* 打开,而 `/restart`、`/update` 和 Web UI 重启都不应该打扰用户。环境变量
|
|
162
|
+
* 会自然穿过 cmd/bash/npx 这几层启动器,因此也适用于 Windows 与 Linux。
|
|
163
|
+
*/
|
|
164
|
+
export const INTERNAL_RESTART_ENV_VAR = "CHATCCC_INTERNAL_RESTART";
|
|
165
|
+
|
|
166
|
+
type Environment = Record<string, string | undefined>;
|
|
167
|
+
|
|
168
|
+
export function createInternalRestartEnv(
|
|
169
|
+
inherited: Environment = process.env,
|
|
170
|
+
): NodeJS.ProcessEnv {
|
|
171
|
+
return {
|
|
172
|
+
...inherited,
|
|
173
|
+
[INTERNAL_RESTART_ENV_VAR]: "1",
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 用户直接启动时打开;ChatCCC 内部重启产生的替代进程不打开。 */
|
|
178
|
+
interface AutoOpenWebUiOptions {
|
|
179
|
+
env?: Environment;
|
|
180
|
+
openOnStart?: boolean;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function shouldAutoOpenWebUi(options: AutoOpenWebUiOptions = {}): boolean {
|
|
184
|
+
const env = options.env ?? process.env;
|
|
185
|
+
return options.openOnStart !== false && env[INTERNAL_RESTART_ENV_VAR] !== "1";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Web UI 始终使用 localhost,并跟随实际配置端口。 */
|
|
189
|
+
export function buildWebUiUrl(port: number): string {
|
|
190
|
+
return `http://localhost:${port}/`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
interface OpenBrowserDeps {
|
|
194
|
+
platform?: NodeJS.Platform;
|
|
195
|
+
env?: Environment;
|
|
196
|
+
spawnImpl?: typeof spawn;
|
|
197
|
+
onError?: (message: string) => void;
|
|
198
|
+
onInfo?: (message: string) => void;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* 调用操作系统默认浏览器打开 Web UI,与 Chrome CDP 守护功能完全独立。
|
|
203
|
+
* 返回值仅表示打开请求是否成功发起;浏览器是否复用标签页由系统浏览器决定。
|
|
204
|
+
*/
|
|
205
|
+
export function openWebUiInDefaultBrowser(
|
|
206
|
+
port: number,
|
|
207
|
+
deps: OpenBrowserDeps = {},
|
|
208
|
+
): boolean {
|
|
209
|
+
const url = buildWebUiUrl(port);
|
|
210
|
+
const platform = deps.platform ?? process.platform;
|
|
211
|
+
const env = deps.env ?? process.env;
|
|
212
|
+
const spawnImpl = deps.spawnImpl ?? spawn;
|
|
213
|
+
const onError = deps.onError ?? ((message: string) => console.error(message));
|
|
214
|
+
const onInfo = deps.onInfo ?? ((message: string) => console.log(message));
|
|
215
|
+
|
|
216
|
+
// Linux 服务器通常没有图形会话。此时调用 xdg-open 只会制造噪音;
|
|
217
|
+
// 直接给出可复制的 SSH 隧道命令,让用户从自己的电脑访问本地 Web UI。
|
|
218
|
+
if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) {
|
|
219
|
+
onInfo(
|
|
220
|
+
`[WEB-UI] 未检测到 Linux 图形桌面,跳过自动打开浏览器。` +
|
|
221
|
+
`可在本机执行 ssh -L ${port}:127.0.0.1:${port} <user>@<server>,` +
|
|
222
|
+
`然后访问 ${url}`,
|
|
223
|
+
);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
let child: ChildProcess;
|
|
229
|
+
if (platform === "win32") {
|
|
230
|
+
// `start` 会把第一个带引号的参数当窗口标题,空字符串是必要占位符。
|
|
231
|
+
child = spawnImpl("cmd.exe", ["/c", "start", "", url], {
|
|
232
|
+
detached: true,
|
|
233
|
+
stdio: "ignore",
|
|
234
|
+
windowsHide: true,
|
|
235
|
+
});
|
|
236
|
+
} else if (platform === "darwin") {
|
|
237
|
+
child = spawnImpl("open", [url], { detached: true, stdio: "ignore" });
|
|
238
|
+
} else {
|
|
239
|
+
child = spawnImpl("xdg-open", [url], { detached: true, stdio: "ignore" });
|
|
240
|
+
}
|
|
241
|
+
child.on("error", (err) => {
|
|
242
|
+
onError(`[WEB-UI] 自动打开浏览器失败: ${err.message}`);
|
|
243
|
+
});
|
|
244
|
+
child.unref();
|
|
245
|
+
return true;
|
|
246
|
+
} catch (err) {
|
|
247
|
+
onError(`[WEB-UI] 自动打开浏览器失败: ${(err as Error).message}`);
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|