chatccc 0.2.279 → 0.2.281
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 +3 -1
- package/dist/src/codex-reset-credits-cache.js +105 -0
- package/dist/src/feishu-api.js +100 -26
- package/dist/src/index.js +26 -8
- package/dist/src/orchestrator.js +188 -81
- package/dist/src/shared.js +34 -17
- package/dist/src/sim-platform.js +4 -0
- package/dist/src/startup-lifecycle.js +43 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -430,7 +430,7 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
430
430
|
| `/cd` | 查看或设置后续新建会话的默认工作目录,不改变当前会话;飞书私聊自身始终使用系统用户目录 |
|
|
431
431
|
| `/sessions` | 查看所有会话状态 |
|
|
432
432
|
| `/session <数字>` | 将当前群聊切换到 `/sessions` 列表中的指定会话;飞书私聊不支持切换 |
|
|
433
|
-
| `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7
|
|
433
|
+
| `/usage` | 查看当前会话对应 Agent 的用量;Codex 显示 5h/7天窗口,主动重置次数查询失败时会明确展示上次成功快照及其查询时间(缓存结果不提供重置按钮);Cursor 显示当前周期用量,CCC Agent 和 DSH 仅在官方 DeepSeek 端点时显示账户余额(其他兼容端点自动跳过) |
|
|
434
434
|
| `/git <子命令>` | 在当前会话工作目录执行 `git ...` 并回传输出 |
|
|
435
435
|
| `/abd<内容>` | 去掉 `/abd` 前缀后把内容发给 Agent,并在消息末尾追加第一性原理需求澄清提示 |
|
|
436
436
|
| `/plan <内容>` | 只读计划模式:仅允许读文件和 stop-stuck-loop 请求,不执行任何写操作 |
|
|
@@ -446,6 +446,8 @@ Codex 的默认模型和推理强度可继续由 `~/.codex/config.toml` 管理
|
|
|
446
446
|
`/update`、`/update safe` 与短别名 `/updatesf` 会把飞书消息或按钮事件 ID 原子写入 `~/.chatccc/state/update-command-guard.json`。同一 ID 跨重启重投时会静默忽略;用户主动发送的新更新指令因事件 ID 不同,仍可执行。该保护不改变普通消息与重启指令的处理方式。
|
|
447
447
|
|
|
448
448
|
`/restart safe`(短别名 `/restartsf`)与 `/update safe`(短别名 `/updatesf`)会先建立全局准入门禁:指令到达前已经运行或进入单会话缓存队列的工作会继续完成,之后到达的新普通任务会被提示在维护完成后重发。维护任务持久化到 `~/.chatccc/state/safe-maintenance.json`,进程意外退出后可继续排空;依赖安装、会话收尾、自动恢复和 Agent Teams 执行也计入等待条件。内存缓存随重启自然重建,磁盘会话、看板、图片等持久数据不会被清理。
|
|
449
|
+
|
|
450
|
+
ChatCCC 的内部重启和更新使用跨平台父子进程握手:替代进程完成启动预检后通知父进程退出,再等待旧监听端口实际释放并接管 PID;替代进程未就绪或握手超时时,父进程会保留并继续服务。
|
|
449
451
|
|
|
450
452
|
> **模型切换**:`/model` 查看当前会话 Agent 的可选模型清单,`/model <名称>` 模糊匹配切换,`/model clear` 恢复默认。可选模型来自当前 Agent 的配置:Claude 使用 `claude.model` / `claude.subagentModel`;Cursor、Codex、CCC Agent 和 DSH 使用各自的 `model` / `alternativeModel`。
|
|
451
453
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { USER_DATA_DIR } from "./config.js";
|
|
5
|
+
const CACHE_FILE = join(USER_DATA_DIR, "state", "codex-reset-credits.json");
|
|
6
|
+
let mutationQueue = Promise.resolve();
|
|
7
|
+
export function codexResetCreditsAccountKey(accountId, accessToken) {
|
|
8
|
+
if (accountId?.trim())
|
|
9
|
+
return `account:${accountId.trim()}`;
|
|
10
|
+
const digest = createHash("sha256").update(accessToken).digest("hex").slice(0, 24);
|
|
11
|
+
return `token-sha256:${digest}`;
|
|
12
|
+
}
|
|
13
|
+
function emptyCache() {
|
|
14
|
+
return { version: 1, accounts: {} };
|
|
15
|
+
}
|
|
16
|
+
function normalizeSnapshot(raw) {
|
|
17
|
+
if (!raw || typeof raw !== "object")
|
|
18
|
+
return null;
|
|
19
|
+
const value = raw;
|
|
20
|
+
const availableCount = Number(value.availableCount);
|
|
21
|
+
if (!Number.isFinite(availableCount) || typeof value.queriedAt !== "string" || !value.queriedAt.trim())
|
|
22
|
+
return null;
|
|
23
|
+
const credits = Array.isArray(value.credits)
|
|
24
|
+
? value.credits.flatMap((credit) => {
|
|
25
|
+
if (!credit || typeof credit !== "object")
|
|
26
|
+
return [];
|
|
27
|
+
const item = credit;
|
|
28
|
+
if (typeof item.expiresAt !== "string" || !item.expiresAt.trim())
|
|
29
|
+
return [];
|
|
30
|
+
return [{
|
|
31
|
+
grantedAt: typeof item.grantedAt === "string" && item.grantedAt.trim() ? item.grantedAt : null,
|
|
32
|
+
expiresAt: item.expiresAt,
|
|
33
|
+
}];
|
|
34
|
+
})
|
|
35
|
+
: [];
|
|
36
|
+
return {
|
|
37
|
+
availableCount: Math.max(0, Math.trunc(availableCount)),
|
|
38
|
+
credits,
|
|
39
|
+
queriedAt: value.queriedAt,
|
|
40
|
+
locallyAdjusted: value.locallyAdjusted === true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function readCache() {
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(await readFile(CACHE_FILE, "utf-8"));
|
|
46
|
+
const accounts = {};
|
|
47
|
+
if (parsed.accounts && typeof parsed.accounts === "object") {
|
|
48
|
+
for (const [key, raw] of Object.entries(parsed.accounts)) {
|
|
49
|
+
const snapshot = normalizeSnapshot(raw);
|
|
50
|
+
if (snapshot)
|
|
51
|
+
accounts[key] = snapshot;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { version: 1, accounts };
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return emptyCache();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function writeCache(cache) {
|
|
61
|
+
await mkdir(dirname(CACHE_FILE), { recursive: true });
|
|
62
|
+
const tempFile = `${CACHE_FILE}.${process.pid}.${randomUUID()}.tmp`;
|
|
63
|
+
await writeFile(tempFile, JSON.stringify(cache, null, 2), "utf-8");
|
|
64
|
+
try {
|
|
65
|
+
await rename(tempFile, CACHE_FILE);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
await writeFile(CACHE_FILE, JSON.stringify(cache, null, 2), "utf-8");
|
|
69
|
+
await rm(tempFile, { force: true }).catch(() => { });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function mutateCache(mutate) {
|
|
73
|
+
const operation = mutationQueue.then(async () => {
|
|
74
|
+
const cache = await readCache();
|
|
75
|
+
mutate(cache);
|
|
76
|
+
await writeCache(cache);
|
|
77
|
+
});
|
|
78
|
+
mutationQueue = operation.catch(() => { });
|
|
79
|
+
return operation;
|
|
80
|
+
}
|
|
81
|
+
export async function readCodexResetCreditsSnapshot(accountKey) {
|
|
82
|
+
return (await readCache()).accounts[accountKey] ?? null;
|
|
83
|
+
}
|
|
84
|
+
export async function saveCodexResetCreditsSnapshot(accountKey, snapshot) {
|
|
85
|
+
await mutateCache((cache) => {
|
|
86
|
+
cache.accounts[accountKey] = {
|
|
87
|
+
...snapshot,
|
|
88
|
+
availableCount: Math.max(0, Math.trunc(snapshot.availableCount)),
|
|
89
|
+
locallyAdjusted: false,
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export async function decrementCachedCodexResetCredits(accountKey) {
|
|
94
|
+
await mutateCache((cache) => {
|
|
95
|
+
const current = cache.accounts[accountKey];
|
|
96
|
+
if (!current)
|
|
97
|
+
return;
|
|
98
|
+
cache.accounts[accountKey] = {
|
|
99
|
+
...current,
|
|
100
|
+
availableCount: Math.max(0, current.availableCount - 1),
|
|
101
|
+
credits: current.credits.slice(1),
|
|
102
|
+
locallyAdjusted: true,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
package/dist/src/feishu-api.js
CHANGED
|
@@ -6,6 +6,7 @@ import sharp from "sharp";
|
|
|
6
6
|
import { APP_ID, APP_SECRET, BASE_URL, CHAT_LOGS_DIR, PROJECT_ROOT, USER_DATA_DIR, CLAUDE_SESSION_PREFIX, CURSOR_SESSION_PREFIX, CODEX_SESSION_PREFIX, CCC_SESSION_PREFIX, DSH_SESSION_PREFIX, ts, resolveDefaultAgentTool, toolDisplayName, config, } from "./config.js";
|
|
7
7
|
import { getCursorUsageSummary } from "./cursor-usage.js";
|
|
8
8
|
import { applyPrivacy } from "./privacy.js";
|
|
9
|
+
import { codexResetCreditsAccountKey, decrementCachedCodexResetCredits, readCodexResetCreditsSnapshot, saveCodexResetCreditsSnapshot, } from "./codex-reset-credits-cache.js";
|
|
9
10
|
import { buildHelpCard } from "./cards.js";
|
|
10
11
|
// ---------------------------------------------------------------------------
|
|
11
12
|
// Auth
|
|
@@ -366,9 +367,6 @@ async function getCodexAuth() {
|
|
|
366
367
|
return null;
|
|
367
368
|
}
|
|
368
369
|
}
|
|
369
|
-
async function getCodexAccessToken() {
|
|
370
|
-
return (await getCodexAuth())?.accessToken ?? null;
|
|
371
|
-
}
|
|
372
370
|
function codexAuthHeaders(auth) {
|
|
373
371
|
const headers = {
|
|
374
372
|
Authorization: `Bearer ${auth.accessToken}`,
|
|
@@ -399,26 +397,42 @@ function parseCodexResetCreditDetails(data) {
|
|
|
399
397
|
}),
|
|
400
398
|
};
|
|
401
399
|
}
|
|
400
|
+
const codexResetCreditsInFlight = new Map();
|
|
402
401
|
async function fetchCodexRateLimitResetCredits(auth) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
402
|
+
const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
|
|
403
|
+
const existing = codexResetCreditsInFlight.get(accountKey);
|
|
404
|
+
if (existing)
|
|
405
|
+
return existing;
|
|
406
|
+
const lookup = (async () => {
|
|
407
|
+
try {
|
|
408
|
+
const resp = await fetch(CODEX_RESET_CREDITS_URL, {
|
|
409
|
+
headers: codexAuthHeaders(auth),
|
|
410
|
+
});
|
|
411
|
+
const text = await resp.text();
|
|
412
|
+
if (!resp.ok)
|
|
413
|
+
throw new Error(`HTTP ${resp.status}: ${text.slice(0, 160)}`);
|
|
414
|
+
return {
|
|
415
|
+
details: parseCodexResetCreditDetails(JSON.parse(text)),
|
|
416
|
+
error: null,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
catch (err) {
|
|
420
|
+
const error = err.message;
|
|
421
|
+
console.warn(`[Codex] reset credits lookup failed: ${error}`);
|
|
422
|
+
return { details: null, error };
|
|
423
|
+
}
|
|
424
|
+
})().finally(() => {
|
|
425
|
+
codexResetCreditsInFlight.delete(accountKey);
|
|
426
|
+
});
|
|
427
|
+
codexResetCreditsInFlight.set(accountKey, lookup);
|
|
428
|
+
return lookup;
|
|
416
429
|
}
|
|
417
|
-
export async function getCodexUsageSummary() {
|
|
430
|
+
export async function getCodexUsageSummary(options = {}) {
|
|
418
431
|
const auth = await getCodexAuth();
|
|
419
432
|
if (!auth)
|
|
420
433
|
throw new Error("missing ~/.codex/auth.json access token");
|
|
421
|
-
const
|
|
434
|
+
const includeResetCredits = options.includeResetCredits !== false;
|
|
435
|
+
const resetCreditsPromise = includeResetCredits ? fetchCodexRateLimitResetCredits(auth) : null;
|
|
422
436
|
const resp = await fetch(CODEX_USAGE_URL, {
|
|
423
437
|
headers: { Authorization: `Bearer ${auth.accessToken}` },
|
|
424
438
|
});
|
|
@@ -443,24 +457,77 @@ export async function getCodexUsageSummary() {
|
|
|
443
457
|
?? (primaryWindow?.limitWindowSeconds === undefined ? primaryWindow : null);
|
|
444
458
|
const weekly = windows.find((window) => isUsageWindowDuration(window, SEVEN_DAY_WINDOW_SECONDS))
|
|
445
459
|
?? (secondaryWindow?.limitWindowSeconds === undefined ? secondaryWindow : null);
|
|
446
|
-
|
|
460
|
+
if (!includeResetCredits) {
|
|
461
|
+
return {
|
|
462
|
+
fiveHour,
|
|
463
|
+
weekly,
|
|
464
|
+
rateLimitResetCreditsAvailable: null,
|
|
465
|
+
rateLimitResetCredits: null,
|
|
466
|
+
rateLimitResetCreditsSource: "not_requested",
|
|
467
|
+
rateLimitResetCreditsQueriedAt: null,
|
|
468
|
+
rateLimitResetCreditsLocallyAdjusted: false,
|
|
469
|
+
rateLimitResetCreditsError: null,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
|
|
473
|
+
const resetLookup = await resetCreditsPromise;
|
|
474
|
+
const embeddedAvailableCount = parseRateLimitResetCredits(data);
|
|
475
|
+
const liveAvailableCount = resetLookup.details?.availableCount ?? embeddedAvailableCount;
|
|
476
|
+
if (liveAvailableCount !== null) {
|
|
477
|
+
const queriedAt = new Date().toISOString();
|
|
478
|
+
const liveCredits = resetLookup.details?.availableCredits ?? [];
|
|
479
|
+
await saveCodexResetCreditsSnapshot(accountKey, {
|
|
480
|
+
availableCount: liveAvailableCount,
|
|
481
|
+
credits: liveCredits,
|
|
482
|
+
queriedAt,
|
|
483
|
+
}).catch((err) => {
|
|
484
|
+
console.warn(`[Codex] reset credits snapshot write failed: ${err.message}`);
|
|
485
|
+
});
|
|
486
|
+
return {
|
|
487
|
+
fiveHour,
|
|
488
|
+
weekly,
|
|
489
|
+
rateLimitResetCreditsAvailable: liveAvailableCount,
|
|
490
|
+
rateLimitResetCredits: liveCredits,
|
|
491
|
+
rateLimitResetCreditsSource: "live",
|
|
492
|
+
rateLimitResetCreditsQueriedAt: queriedAt,
|
|
493
|
+
rateLimitResetCreditsLocallyAdjusted: false,
|
|
494
|
+
rateLimitResetCreditsError: null,
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const cached = await readCodexResetCreditsSnapshot(accountKey);
|
|
498
|
+
if (cached) {
|
|
499
|
+
return {
|
|
500
|
+
fiveHour,
|
|
501
|
+
weekly,
|
|
502
|
+
rateLimitResetCreditsAvailable: cached.availableCount,
|
|
503
|
+
rateLimitResetCredits: cached.credits,
|
|
504
|
+
rateLimitResetCreditsSource: "cache",
|
|
505
|
+
rateLimitResetCreditsQueriedAt: cached.queriedAt,
|
|
506
|
+
rateLimitResetCreditsLocallyAdjusted: cached.locallyAdjusted,
|
|
507
|
+
rateLimitResetCreditsError: resetLookup.error ?? "OpenAI returned no reset-credit data",
|
|
508
|
+
};
|
|
509
|
+
}
|
|
447
510
|
return {
|
|
448
511
|
fiveHour,
|
|
449
512
|
weekly,
|
|
450
|
-
rateLimitResetCreditsAvailable:
|
|
451
|
-
rateLimitResetCredits:
|
|
513
|
+
rateLimitResetCreditsAvailable: null,
|
|
514
|
+
rateLimitResetCredits: null,
|
|
515
|
+
rateLimitResetCreditsSource: "unavailable",
|
|
516
|
+
rateLimitResetCreditsQueriedAt: null,
|
|
517
|
+
rateLimitResetCreditsLocallyAdjusted: false,
|
|
518
|
+
rateLimitResetCreditsError: resetLookup.error ?? "OpenAI returned no reset-credit data",
|
|
452
519
|
};
|
|
453
520
|
}
|
|
454
521
|
export async function consumeCodexRateLimitResetCredit(redeemRequestId) {
|
|
455
522
|
if (!redeemRequestId.trim())
|
|
456
523
|
throw new Error("missing redeem_request_id");
|
|
457
|
-
const
|
|
458
|
-
if (!
|
|
524
|
+
const auth = await getCodexAuth();
|
|
525
|
+
if (!auth)
|
|
459
526
|
throw new Error("missing ~/.codex/auth.json access token");
|
|
460
527
|
const resp = await fetch(CODEX_RESET_CONSUME_URL, {
|
|
461
528
|
method: "POST",
|
|
462
529
|
headers: {
|
|
463
|
-
Authorization: `Bearer ${
|
|
530
|
+
Authorization: `Bearer ${auth.accessToken}`,
|
|
464
531
|
"Content-Type": "application/json",
|
|
465
532
|
},
|
|
466
533
|
body: JSON.stringify({ redeem_request_id: redeemRequestId }),
|
|
@@ -477,10 +544,17 @@ export async function consumeCodexRateLimitResetCredit(redeemRequestId) {
|
|
|
477
544
|
throw new Error("missing or unknown reset result code");
|
|
478
545
|
}
|
|
479
546
|
const windowsReset = Number(data.windows_reset);
|
|
480
|
-
|
|
547
|
+
const result = {
|
|
481
548
|
code,
|
|
482
549
|
windowsReset: Number.isFinite(windowsReset) ? Math.max(0, Math.trunc(windowsReset)) : 0,
|
|
483
550
|
};
|
|
551
|
+
if (result.code === "reset") {
|
|
552
|
+
const accountKey = codexResetCreditsAccountKey(auth.accountId, auth.accessToken);
|
|
553
|
+
await decrementCachedCodexResetCredits(accountKey).catch((err) => {
|
|
554
|
+
console.warn(`[Codex] reset credits snapshot update failed: ${err.message}`);
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return result;
|
|
484
558
|
}
|
|
485
559
|
async function resolveCodexAvatarUsage(usageHint) {
|
|
486
560
|
if (usageHint !== undefined) {
|
|
@@ -489,7 +563,7 @@ async function resolveCodexAvatarUsage(usageHint) {
|
|
|
489
563
|
return usageHint;
|
|
490
564
|
}
|
|
491
565
|
try {
|
|
492
|
-
const summary = await getCodexUsageSummary();
|
|
566
|
+
const summary = await getCodexUsageSummary({ includeResetCredits: false });
|
|
493
567
|
if (!summary.weekly)
|
|
494
568
|
throw new Error("missing weekly usage window");
|
|
495
569
|
return summary;
|
package/dist/src/index.js
CHANGED
|
@@ -27,7 +27,7 @@ import WebSocket from "ws";
|
|
|
27
27
|
import { appendStartupTrace, attachRelayWebSocket, ensureSingleInstance, freeRelayListenPort, installCrashLogging, installEpipeGuard, waitForPortFree } from "./shared.js";
|
|
28
28
|
import { createUiRouter, setExtraApiHandler, setReloadConfigHook, startSetupMode } from "./web-ui.js";
|
|
29
29
|
import { configureAgentTeamMainAgent } from "./agent-team/main-agent-bootstrap.js";
|
|
30
|
-
import { buildWebUiUrl, createServiceLifecycleGuard, INTERNAL_RESTART_ENV_VAR, openWebUiInDefaultBrowser, shouldAutoOpenWebUi, } from "./startup-lifecycle.js";
|
|
30
|
+
import { buildWebUiUrl, createServiceLifecycleGuard, announceInternalRestartReady, INTERNAL_RESTART_ENV_VAR, openWebUiInDefaultBrowser, shouldAutoOpenWebUi, } from "./startup-lifecycle.js";
|
|
31
31
|
import { buildPlatformStartupPlan } from "./platform-startup.js";
|
|
32
32
|
import { makeTraceId, logTrace } from "./trace.js";
|
|
33
33
|
import { CHATCCC_PORT, config, APP_ID, APP_SECRET, FEISHU_ENABLED, FEISHU_PLATFORM_TYPE, ILINK_ENABLED, ILINK_REUSE_TOKEN_ON_START, BASE_URL, LOCAL_RELAY_URL, PID_FILE, PROJECT_ROOT, USE_LOCAL, USE_SIMULATE, appendChatLog, fileLog, reportEnvironmentVariableReadout, maskAppId, resolveDefaultAgentTool, toolDisplayName, ts, } from "./config.js";
|
|
@@ -609,6 +609,23 @@ async function main() {
|
|
|
609
609
|
printServiceDidNotStart("config.json 的 port 字段配置无效(须为 1–65535 的整数)");
|
|
610
610
|
process.exit(1);
|
|
611
611
|
}
|
|
612
|
+
const isInternalRestart = process.env[INTERNAL_RESTART_ENV_VAR] === "1";
|
|
613
|
+
if (isInternalRestart) {
|
|
614
|
+
const announced = await announceInternalRestartReady();
|
|
615
|
+
appendStartupTrace("restart-handoff: replacement preflight ready", {
|
|
616
|
+
announced,
|
|
617
|
+
port: CHATCCC_PORT,
|
|
618
|
+
});
|
|
619
|
+
const portReleased = await waitForPortFree(CHATCCC_PORT, 30_000);
|
|
620
|
+
appendStartupTrace("restart-handoff: parent port wait finished", {
|
|
621
|
+
port: CHATCCC_PORT,
|
|
622
|
+
portReleased,
|
|
623
|
+
});
|
|
624
|
+
if (!portReleased) {
|
|
625
|
+
printServiceDidNotStart(`内部重启交接超时:端口 ${CHATCCC_PORT} 在 30 秒内未释放`);
|
|
626
|
+
process.exit(1);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
612
629
|
console.log(`\n[启动 1/7] 单实例:按 PID 文件清理旧 ChatCCC 进程`);
|
|
613
630
|
console.log(` PID 文件: ${PID_FILE}`);
|
|
614
631
|
appendStartupTrace("main: before ensureSingleInstance", { PID_FILE, CHATCCC_PORT });
|
|
@@ -680,14 +697,15 @@ async function main() {
|
|
|
680
697
|
// 启动 HTTP server(同时挂 UI router,供 dashboard / setup / agent image/file 使用)
|
|
681
698
|
appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
|
|
682
699
|
const killed = freeRelayListenPort(CHATCCC_PORT);
|
|
683
|
-
const isInternalRestart = process.env[INTERNAL_RESTART_ENV_VAR] === "1";
|
|
684
700
|
appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT, killed, isInternalRestart });
|
|
685
|
-
//
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
701
|
+
// 普通启动清理过旧实例后,也必须确认端口真正释放再继续监听。
|
|
702
|
+
if (killed > 0) {
|
|
703
|
+
const portReleased = await waitForPortFree(CHATCCC_PORT, 5_000);
|
|
704
|
+
appendStartupTrace("main: post-kill port wait finished", { CHATCCC_PORT, portReleased });
|
|
705
|
+
if (!portReleased) {
|
|
706
|
+
printServiceDidNotStart(`旧进程退出后端口 ${CHATCCC_PORT} 在 5 秒内仍未释放`);
|
|
707
|
+
process.exit(1);
|
|
708
|
+
}
|
|
691
709
|
}
|
|
692
710
|
const httpServer = createServer(createUiRouter());
|
|
693
711
|
await listenWithRetry(httpServer, CHATCCC_PORT, "127.0.0.1").catch((err) => {
|
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 { appendFileSync, closeSync,
|
|
9
|
+
import { appendFileSync, 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";
|
|
@@ -25,7 +25,7 @@ import { applySharedPrefix } from "./shared-prefix.js";
|
|
|
25
25
|
import { normalizeSessionDisplayTitle, sessionChatName, sessionDisplayTitleFromPrompt, } from "./session-name.js";
|
|
26
26
|
import { reloadRuntimeConfig } from "./runtime-reload.js";
|
|
27
27
|
import { acquireUpdateCommandGuard } from "./update-command-guard.js";
|
|
28
|
-
import { createInternalRestartEnv, INTERNAL_RESTART_ENV_VAR } from "./startup-lifecycle.js";
|
|
28
|
+
import { createInternalRestartEnv, INTERNAL_RESTART_ENV_VAR, INTERNAL_RESTART_READY_MESSAGE, } from "./startup-lifecycle.js";
|
|
29
29
|
import { resolveChatCccRuntimeSpawnSpec } from "./runtime-entry.js";
|
|
30
30
|
import { engineManager } from "./engines/engine-specs.js";
|
|
31
31
|
import { beginSafeMaintenanceTrackedWork, isSafeMaintenanceAdmissionClosed, safeMaintenanceCoordinator, } from "./safe-maintenance.js";
|
|
@@ -100,33 +100,69 @@ function formatCodexUsageSummary(usage, chatGptSubscription = null) {
|
|
|
100
100
|
].join("\n");
|
|
101
101
|
};
|
|
102
102
|
const formatResetCredits = () => {
|
|
103
|
+
const formatDateTime = (value) => {
|
|
104
|
+
const date = new Date(value);
|
|
105
|
+
if (!Number.isFinite(date.getTime()))
|
|
106
|
+
return value;
|
|
107
|
+
const pad = (part) => String(part).padStart(2, "0");
|
|
108
|
+
return [
|
|
109
|
+
date.getFullYear(),
|
|
110
|
+
"-",
|
|
111
|
+
pad(date.getMonth() + 1),
|
|
112
|
+
"-",
|
|
113
|
+
pad(date.getDate()),
|
|
114
|
+
" ",
|
|
115
|
+
pad(date.getHours()),
|
|
116
|
+
":",
|
|
117
|
+
pad(date.getMinutes()),
|
|
118
|
+
":",
|
|
119
|
+
pad(date.getSeconds()),
|
|
120
|
+
].join("");
|
|
121
|
+
};
|
|
122
|
+
const failureReason = () => {
|
|
123
|
+
const raw = usage.rateLimitResetCreditsError?.replace(/\s+/g, " ").trim();
|
|
124
|
+
if (!raw)
|
|
125
|
+
return "OpenAI 未返回主动重置数据";
|
|
126
|
+
if (/HTTP 429/i.test(raw))
|
|
127
|
+
return "OpenAI 返回 HTTP 429(请求受限)";
|
|
128
|
+
if (/HTTP 404/i.test(raw))
|
|
129
|
+
return "OpenAI 返回 HTTP 404(接口暂不可用)";
|
|
130
|
+
return raw.length > 180 ? `${raw.slice(0, 180)}...` : raw;
|
|
131
|
+
};
|
|
132
|
+
const source = usage.rateLimitResetCreditsSource ?? "live";
|
|
133
|
+
if (source === "unavailable") {
|
|
134
|
+
return [
|
|
135
|
+
`**主动重置:** 本次查询失败(${failureReason()})`,
|
|
136
|
+
"- 没有可用的历史缓存结果",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
if (source === "cache") {
|
|
140
|
+
const lines = [
|
|
141
|
+
`**主动重置:** 本次查询失败(${failureReason()})`,
|
|
142
|
+
`**上次缓存结果:** 剩余 ${usage.rateLimitResetCreditsAvailable ?? 0} 次`,
|
|
143
|
+
];
|
|
144
|
+
if (usage.rateLimitResetCreditsQueriedAt) {
|
|
145
|
+
lines.push(`- 查询时间: ${formatDateTime(usage.rateLimitResetCreditsQueriedAt)}`);
|
|
146
|
+
}
|
|
147
|
+
if (usage.rateLimitResetCreditsLocallyAdjusted) {
|
|
148
|
+
lines.push("- 状态: 本地推算,待下次查询确认");
|
|
149
|
+
}
|
|
150
|
+
const credits = usage.rateLimitResetCredits ?? [];
|
|
151
|
+
if (credits.length > 0) {
|
|
152
|
+
lines.push("**缓存中的过期时间:**");
|
|
153
|
+
for (const credit of credits)
|
|
154
|
+
lines.push(`- ${formatDateTime(credit.expiresAt)}`);
|
|
155
|
+
}
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
103
158
|
if (usage.rateLimitResetCreditsAvailable === null)
|
|
104
159
|
return "**主动重置:** 暂无数据";
|
|
105
160
|
const lines = [`**主动重置:** 剩余 ${usage.rateLimitResetCreditsAvailable} 次`];
|
|
106
161
|
const credits = usage.rateLimitResetCredits ?? [];
|
|
107
162
|
if (credits.length > 0) {
|
|
108
|
-
const pad = (value) => String(value).padStart(2, "0");
|
|
109
|
-
const formatExpiresAt = (value) => {
|
|
110
|
-
const date = new Date(value);
|
|
111
|
-
if (!Number.isFinite(date.getTime()))
|
|
112
|
-
return value;
|
|
113
|
-
return [
|
|
114
|
-
date.getFullYear(),
|
|
115
|
-
"-",
|
|
116
|
-
pad(date.getMonth() + 1),
|
|
117
|
-
"-",
|
|
118
|
-
pad(date.getDate()),
|
|
119
|
-
" ",
|
|
120
|
-
pad(date.getHours()),
|
|
121
|
-
":",
|
|
122
|
-
pad(date.getMinutes()),
|
|
123
|
-
":",
|
|
124
|
-
pad(date.getSeconds()),
|
|
125
|
-
].join("");
|
|
126
|
-
};
|
|
127
163
|
lines.push("**过期时间:**");
|
|
128
164
|
for (const credit of credits) {
|
|
129
|
-
lines.push(`- ${
|
|
165
|
+
lines.push(`- ${formatDateTime(credit.expiresAt)}`);
|
|
130
166
|
}
|
|
131
167
|
}
|
|
132
168
|
return lines.join("\n");
|
|
@@ -397,7 +433,11 @@ async function sendUsageSummary(platform, chatId, tool, avatarStatus = "idle", s
|
|
|
397
433
|
await platform.sendText(chatId, content).catch(() => { });
|
|
398
434
|
}
|
|
399
435
|
else if (platform.kind === "feishu") {
|
|
400
|
-
|
|
436
|
+
const liveResetCredits = usage.rateLimitResetCreditsSource === undefined
|
|
437
|
+
|| usage.rateLimitResetCreditsSource === "live"
|
|
438
|
+
? usage.rateLimitResetCreditsAvailable
|
|
439
|
+
: null;
|
|
440
|
+
await platform.sendRawCard(chatId, buildCodexUsageCard(content, liveResetCredits));
|
|
401
441
|
}
|
|
402
442
|
else {
|
|
403
443
|
await platform.sendCard(chatId, "Codex Usage", content, "blue");
|
|
@@ -638,41 +678,18 @@ function syncUpdateAndRestart(options = {}) {
|
|
|
638
678
|
appendStartupTrace("update: safe update aborted before restart", {});
|
|
639
679
|
return undefined;
|
|
640
680
|
}
|
|
641
|
-
// 2.
|
|
642
|
-
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
appendStartupTrace("update: spawn begin", { npmPrefix: npmPrefix || "(empty)", binPath });
|
|
647
|
-
// 3. spawn new chatccc:优先 node + 全局包入口绝对路径(不依赖 PATH/shell),
|
|
648
|
-
// 避免继承环境 PATH 异常时秒退;失败时回退到 binPath(走 shell)。
|
|
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] });
|
|
649
686
|
try {
|
|
650
|
-
|
|
651
|
-
if (npmPrefix) {
|
|
652
|
-
const entry = join(npmPrefix, "node_modules", "chatccc", "bin", "chatccc.mjs");
|
|
653
|
-
if (existsSync(entry)) {
|
|
654
|
-
spawnSpec = { command: process.execPath, args: [entry] };
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
const child = spawnSpec
|
|
658
|
-
? spawn(spawnSpec.command, spawnSpec.args, {
|
|
659
|
-
detached: true,
|
|
660
|
-
stdio: "ignore",
|
|
661
|
-
shell: false,
|
|
662
|
-
env: createInternalRestartEnv(),
|
|
663
|
-
})
|
|
664
|
-
: spawn(binPath, [], {
|
|
665
|
-
detached: true,
|
|
666
|
-
stdio: "ignore",
|
|
667
|
-
shell: true,
|
|
668
|
-
env: createInternalRestartEnv(),
|
|
669
|
-
});
|
|
687
|
+
const child = spawnRestartChild({ projectRoot: PROJECT_ROOT });
|
|
670
688
|
child.unref();
|
|
671
|
-
|
|
672
|
-
updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${spawnedAs}`);
|
|
689
|
+
updLog(`spawn new chatccc OK, childPid=${child.pid}, bin=${spawnSpec.command} ${spawnSpec.args.join(" ")}`);
|
|
673
690
|
appendStartupTrace("update: spawn OK", {
|
|
674
691
|
childPid: child.pid,
|
|
675
|
-
binPath: spawnSpec
|
|
692
|
+
binPath: spawnSpec.args[0],
|
|
676
693
|
});
|
|
677
694
|
return child;
|
|
678
695
|
}
|
|
@@ -686,8 +703,8 @@ function syncUpdateAndRestart(options = {}) {
|
|
|
686
703
|
// ---------------------------------------------------------------------------
|
|
687
704
|
// /restart — 自重启子进程(不经过 npx/npm,避免 PATH 注入秒退;防空窗兜底)
|
|
688
705
|
// ---------------------------------------------------------------------------
|
|
689
|
-
/**
|
|
690
|
-
export const RESTART_CHILD_READY_MS =
|
|
706
|
+
/** 父进程等待替代进程通过 IPC 完成启动预检的最长时间(毫秒)。 */
|
|
707
|
+
export const RESTART_CHILD_READY_MS = 15_000;
|
|
691
708
|
/**
|
|
692
709
|
* 构建自重启的 spawn 参数:发布包直接运行编译后的 JavaScript;只有尚未
|
|
693
710
|
* build 的开发工作区才使用本地 tsx CLI。两种情况都不经过 npx/npm。
|
|
@@ -695,12 +712,64 @@ export const RESTART_CHILD_READY_MS = 3000;
|
|
|
695
712
|
export function buildRestartSpawnSpec(projectRoot = PROJECT_ROOT) {
|
|
696
713
|
return resolveChatCccRuntimeSpawnSpec(projectRoot);
|
|
697
714
|
}
|
|
715
|
+
const restartHandoffMonitors = new WeakMap();
|
|
716
|
+
function getOrCreateRestartHandoffMonitor(child) {
|
|
717
|
+
const existing = restartHandoffMonitors.get(child);
|
|
718
|
+
if (existing)
|
|
719
|
+
return existing;
|
|
720
|
+
if (typeof child.on !== "function") {
|
|
721
|
+
const unavailable = { promise: Promise.resolve({ kind: "no_ipc" }), cancel() { } };
|
|
722
|
+
restartHandoffMonitors.set(child, unavailable);
|
|
723
|
+
return unavailable;
|
|
724
|
+
}
|
|
725
|
+
const addListener = child.on.bind(child);
|
|
726
|
+
const removeListener = typeof child.off === "function"
|
|
727
|
+
? child.off.bind(child)
|
|
728
|
+
: undefined;
|
|
729
|
+
let resolveOutcome = () => { };
|
|
730
|
+
let settled = false;
|
|
731
|
+
const cleanup = () => {
|
|
732
|
+
removeListener?.("message", onMessage);
|
|
733
|
+
removeListener?.("exit", onExit);
|
|
734
|
+
removeListener?.("error", onError);
|
|
735
|
+
};
|
|
736
|
+
const settle = (outcome) => {
|
|
737
|
+
if (settled)
|
|
738
|
+
return;
|
|
739
|
+
settled = true;
|
|
740
|
+
cleanup();
|
|
741
|
+
resolveOutcome(outcome);
|
|
742
|
+
};
|
|
743
|
+
const onMessage = (message) => {
|
|
744
|
+
const ready = message;
|
|
745
|
+
if (!ready || ready.type !== INTERNAL_RESTART_READY_MESSAGE)
|
|
746
|
+
return;
|
|
747
|
+
if (typeof ready.pid === "number" && child.pid !== undefined && ready.pid !== child.pid)
|
|
748
|
+
return;
|
|
749
|
+
if (ready.parentPid !== process.pid)
|
|
750
|
+
return;
|
|
751
|
+
settle({ kind: "ready" });
|
|
752
|
+
};
|
|
753
|
+
const onExit = () => settle({ kind: "exit" });
|
|
754
|
+
const onError = (error) => settle({ kind: "error", error });
|
|
755
|
+
const promise = new Promise((resolve) => {
|
|
756
|
+
resolveOutcome = resolve;
|
|
757
|
+
});
|
|
758
|
+
const monitor = { promise, cancel: cleanup };
|
|
759
|
+
restartHandoffMonitors.set(child, monitor);
|
|
760
|
+
addListener("message", onMessage);
|
|
761
|
+
addListener("exit", onExit);
|
|
762
|
+
addListener("error", onError);
|
|
763
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
764
|
+
settle({ kind: "exit" });
|
|
765
|
+
return monitor;
|
|
766
|
+
}
|
|
698
767
|
/**
|
|
699
768
|
* spawn 自重启子进程。
|
|
700
769
|
*
|
|
701
770
|
* stdout/stderr 按启动方式分流:
|
|
702
771
|
* - **终端(TTY)场景**(用户从 cmd/PowerShell/node.exe 窗口启动):stdio 用
|
|
703
|
-
*
|
|
772
|
+
* 前三个 stdio 直接继承终端句柄,第四个通道保留给 IPC 握手;restart 后窗口日志不中断。
|
|
704
773
|
* 终端句柄的生命周期不随父进程退出而关闭,因此不存在 EPIPE 风险。
|
|
705
774
|
* - **非终端场景**(守护进程/黑匣子等管道或文件启动):stderr 重定向到磁盘日志
|
|
706
775
|
* 文件(restart-*.log),子进程继承文件句柄,父进程退出不影响写入。
|
|
@@ -716,7 +785,7 @@ export function spawnRestartChild(deps = {}) {
|
|
|
716
785
|
const isTty = deps.isTty ?? (() => process.stdout.isTTY === true || process.stderr.isTTY === true);
|
|
717
786
|
const { command, args } = buildRestartSpawnSpec(projectRoot);
|
|
718
787
|
let stderrFd;
|
|
719
|
-
const stdio = ["ignore", "ignore", "pipe"];
|
|
788
|
+
const stdio = ["ignore", "ignore", "pipe", "ipc"];
|
|
720
789
|
const tty = isTty();
|
|
721
790
|
if (tty) {
|
|
722
791
|
// 终端场景:全部 inherit(含 stdin)。注意 stdin 不能是 "ignore":
|
|
@@ -753,8 +822,11 @@ export function spawnRestartChild(deps = {}) {
|
|
|
753
822
|
detached: true,
|
|
754
823
|
stdio,
|
|
755
824
|
shell: false,
|
|
756
|
-
env: createInternalRestartEnv(),
|
|
825
|
+
env: createInternalRestartEnv(process.env, process.pid),
|
|
757
826
|
});
|
|
827
|
+
// Attach before returning so an extremely fast replacement cannot emit its
|
|
828
|
+
// IPC readiness message between spawnRestartChild() and the caller's wait.
|
|
829
|
+
getOrCreateRestartHandoffMonitor(child);
|
|
758
830
|
// 子进程已继承 stderr 文件句柄;父进程关闭自己的副本,避免"子进程早退、
|
|
759
831
|
// 父进程留下继续服务"时 fd 泄漏。
|
|
760
832
|
if (stderrFd !== undefined) {
|
|
@@ -777,24 +849,61 @@ export function spawnRestartChild(deps = {}) {
|
|
|
777
849
|
}
|
|
778
850
|
/**
|
|
779
851
|
* 决定父进程是否应退出(防空窗兜底):
|
|
780
|
-
* -
|
|
781
|
-
* -
|
|
852
|
+
* - 替代进程通过 IPC 明确完成预检 → 返回 true,父进程退出并交出端口;
|
|
853
|
+
* - 替代进程退出、报错或握手超时 → 返回 false,父进程继续服务。
|
|
782
854
|
*/
|
|
783
|
-
export async function decideRestartParentExit(child, timeoutMs,
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
855
|
+
export async function decideRestartParentExit(child, timeoutMs, _pollMs = 500, trace = appendStartupTrace) {
|
|
856
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
857
|
+
trace("restart: child died during window, keeping parent", {
|
|
858
|
+
childPid: child.pid,
|
|
859
|
+
exitCode: child.exitCode,
|
|
860
|
+
signalCode: child.signalCode,
|
|
861
|
+
});
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
const monitor = getOrCreateRestartHandoffMonitor(child);
|
|
865
|
+
let timeout;
|
|
866
|
+
const timeoutOutcome = new Promise((resolve) => {
|
|
867
|
+
timeout = setTimeout(() => resolve({ kind: "no_ipc" }), timeoutMs);
|
|
868
|
+
timeout.unref?.();
|
|
869
|
+
});
|
|
870
|
+
const outcome = await Promise.race([monitor.promise, timeoutOutcome]);
|
|
871
|
+
if (timeout)
|
|
872
|
+
clearTimeout(timeout);
|
|
873
|
+
monitor.cancel();
|
|
874
|
+
if (outcome.kind === "ready") {
|
|
875
|
+
if (child.connected && typeof child.disconnect === "function") {
|
|
876
|
+
try {
|
|
877
|
+
child.disconnect();
|
|
878
|
+
}
|
|
879
|
+
catch { /* child may disconnect first */ }
|
|
880
|
+
}
|
|
881
|
+
trace("restart: child handoff ready, parent exiting", { childPid: child.pid });
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
if (outcome.kind === "exit") {
|
|
885
|
+
trace("restart: child died during window, keeping parent", {
|
|
886
|
+
childPid: child.pid,
|
|
887
|
+
exitCode: child.exitCode,
|
|
888
|
+
signalCode: child.signalCode,
|
|
889
|
+
});
|
|
890
|
+
return false;
|
|
891
|
+
}
|
|
892
|
+
if (outcome.kind === "error") {
|
|
893
|
+
trace("restart: child handoff error, keeping parent", {
|
|
894
|
+
childPid: child.pid,
|
|
895
|
+
error: outcome.error.message,
|
|
896
|
+
});
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
if (typeof child.kill === "function") {
|
|
900
|
+
try {
|
|
901
|
+
child.kill();
|
|
793
902
|
}
|
|
794
|
-
|
|
903
|
+
catch { /* best effort */ }
|
|
795
904
|
}
|
|
796
|
-
trace("restart: child
|
|
797
|
-
return
|
|
905
|
+
trace("restart: child handoff timeout, keeping parent", { childPid: child.pid });
|
|
906
|
+
return false;
|
|
798
907
|
}
|
|
799
908
|
const safeMaintenancePlatforms = new Map();
|
|
800
909
|
export function configureSafeMaintenanceRuntime(platforms) {
|
|
@@ -1011,8 +1120,8 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
|
|
|
1011
1120
|
appendStartupTrace("restart: spawn begin", { fromPid: process.pid });
|
|
1012
1121
|
const child = spawnRestartChild();
|
|
1013
1122
|
child.unref();
|
|
1014
|
-
//
|
|
1015
|
-
//
|
|
1123
|
+
// 只有替代进程通过 IPC 明确完成预检才退出父进程;超时或早退时父进程
|
|
1124
|
+
// 继续服务,并保留替代进程 stderr 日志供排查。
|
|
1016
1125
|
void decideRestartParentExit(child, RESTART_CHILD_READY_MS).then((shouldExit) => {
|
|
1017
1126
|
if (!shouldExit)
|
|
1018
1127
|
return;
|
|
@@ -1060,10 +1169,9 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
|
|
|
1060
1169
|
await platform.sendText(chatId, "正在更新并重启,请稍候...").catch(() => { });
|
|
1061
1170
|
logTrace(tid, "DONE", { outcome: "update" });
|
|
1062
1171
|
appendStartupTrace("update: sync update begin", { fromPid: process.pid });
|
|
1063
|
-
const child = syncUpdateAndRestart();
|
|
1172
|
+
const child = syncUpdateAndRestart({ spawnOnUpdateFailure: false });
|
|
1064
1173
|
if (child) {
|
|
1065
|
-
//
|
|
1066
|
-
// 服务(防空窗)。
|
|
1174
|
+
// 只有替代进程通过 IPC 明确完成预检才退出父进程;否则父进程继续服务。
|
|
1067
1175
|
void decideRestartParentExit(child, RESTART_CHILD_READY_MS).then((shouldExit) => {
|
|
1068
1176
|
if (!shouldExit)
|
|
1069
1177
|
return;
|
|
@@ -1072,8 +1180,7 @@ async function handleCommandInternal(platform, text, chatId, openId, msgTimestam
|
|
|
1072
1180
|
});
|
|
1073
1181
|
}
|
|
1074
1182
|
else {
|
|
1075
|
-
|
|
1076
|
-
setTimeout(() => process.exit(0), 2000);
|
|
1183
|
+
appendStartupTrace("update: replacement unavailable, parent stays alive", {});
|
|
1077
1184
|
}
|
|
1078
1185
|
return;
|
|
1079
1186
|
}
|
package/dist/src/shared.js
CHANGED
|
@@ -164,25 +164,42 @@ export function freeRelayListenPort(port) {
|
|
|
164
164
|
return killed;
|
|
165
165
|
}
|
|
166
166
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
167
|
+
* 跨平台轮询等待端口释放。既用于 Windows taskkill 后的延迟释放,也用于
|
|
168
|
+
* Linux/macOS 内部重启时等待父进程交出监听端口。
|
|
169
169
|
*/
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
const pids = collectListeningPidsOnPortWindows(port, out);
|
|
178
|
-
if (pids.length === 0)
|
|
170
|
+
async function canBindLoopbackPort(port) {
|
|
171
|
+
const probe = createServer();
|
|
172
|
+
probe.unref();
|
|
173
|
+
return new Promise((resolve) => {
|
|
174
|
+
let settled = false;
|
|
175
|
+
const finish = (available) => {
|
|
176
|
+
if (settled)
|
|
179
177
|
return;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
178
|
+
settled = true;
|
|
179
|
+
probe.removeAllListeners();
|
|
180
|
+
if (probe.listening) {
|
|
181
|
+
probe.close(() => resolve(available));
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
resolve(available);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
probe.once("error", () => finish(false));
|
|
188
|
+
probe.once("listening", () => finish(true));
|
|
189
|
+
probe.listen(port, "127.0.0.1");
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
/** Wait until the loopback port can actually be bound on every supported OS. */
|
|
193
|
+
export async function waitForPortFree(port, timeoutMs = 3000, pollMs = 200) {
|
|
194
|
+
const deadline = Date.now() + timeoutMs;
|
|
195
|
+
do {
|
|
196
|
+
if (await canBindLoopbackPort(port))
|
|
197
|
+
return true;
|
|
198
|
+
if (Date.now() >= deadline)
|
|
199
|
+
break;
|
|
200
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
201
|
+
} while (Date.now() <= deadline);
|
|
202
|
+
return false;
|
|
186
203
|
}
|
|
187
204
|
// ---------------------------------------------------------------------------
|
|
188
205
|
// 单实例保证:PID 文件互斥(端口占用在 freeRelayListenPort + 监听前处理)
|
package/dist/src/sim-platform.js
CHANGED
|
@@ -105,6 +105,10 @@ export const SimulatedPlatform = {
|
|
|
105
105
|
weekly: { usedPercent: 0, remainingPercent: 100, resetAtEpochSeconds: null, resetAfterSeconds: null },
|
|
106
106
|
rateLimitResetCreditsAvailable: null,
|
|
107
107
|
rateLimitResetCredits: null,
|
|
108
|
+
rateLimitResetCreditsSource: "unavailable",
|
|
109
|
+
rateLimitResetCreditsQueriedAt: null,
|
|
110
|
+
rateLimitResetCreditsLocallyAdjusted: false,
|
|
111
|
+
rateLimitResetCreditsError: "Simulated platform has no reset-credit data",
|
|
108
112
|
};
|
|
109
113
|
},
|
|
110
114
|
async consumeCodexRateLimitResetCredit(_redeemRequestId) {
|
|
@@ -147,12 +147,54 @@ export function createServiceLifecycleGuard(options = {}) {
|
|
|
147
147
|
* 会自然穿过 cmd/bash/npx 这几层启动器,因此也适用于 Windows 与 Linux。
|
|
148
148
|
*/
|
|
149
149
|
export const INTERNAL_RESTART_ENV_VAR = "CHATCCC_INTERNAL_RESTART";
|
|
150
|
-
export
|
|
150
|
+
export const INTERNAL_RESTART_PARENT_PID_ENV_VAR = "CHATCCC_RESTART_PARENT_PID";
|
|
151
|
+
export const INTERNAL_RESTART_READY_MESSAGE = "chatccc:restart-handoff-ready";
|
|
152
|
+
export function createInternalRestartEnv(inherited = process.env, parentPid = process.pid) {
|
|
151
153
|
return {
|
|
152
154
|
...inherited,
|
|
153
155
|
[INTERNAL_RESTART_ENV_VAR]: "1",
|
|
156
|
+
[INTERNAL_RESTART_PARENT_PID_ENV_VAR]: String(parentPid),
|
|
154
157
|
};
|
|
155
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* Tell the current parent that the replacement runtime loaded successfully and
|
|
161
|
+
* is ready to wait for the listening port. Older parents do not provide IPC;
|
|
162
|
+
* returning false preserves the port-wait fallback used during an upgrade from
|
|
163
|
+
* a pre-handoff ChatCCC version.
|
|
164
|
+
*/
|
|
165
|
+
export async function announceInternalRestartReady(options = {}) {
|
|
166
|
+
const env = options.env ?? process.env;
|
|
167
|
+
if (env[INTERNAL_RESTART_ENV_VAR] !== "1")
|
|
168
|
+
return false;
|
|
169
|
+
const send = options.send ?? (typeof process.send === "function"
|
|
170
|
+
? process.send.bind(process)
|
|
171
|
+
: undefined);
|
|
172
|
+
if (!send)
|
|
173
|
+
return false;
|
|
174
|
+
const pid = options.pid ?? process.pid;
|
|
175
|
+
const parentPid = Number(env[INTERNAL_RESTART_PARENT_PID_ENV_VAR]);
|
|
176
|
+
if (!Number.isInteger(parentPid) || parentPid <= 0)
|
|
177
|
+
return false;
|
|
178
|
+
const timeoutMs = options.timeoutMs ?? 2_000;
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
let settled = false;
|
|
181
|
+
const finish = (ok) => {
|
|
182
|
+
if (settled)
|
|
183
|
+
return;
|
|
184
|
+
settled = true;
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
resolve(ok);
|
|
187
|
+
};
|
|
188
|
+
const timer = setTimeout(() => finish(false), timeoutMs);
|
|
189
|
+
timer.unref?.();
|
|
190
|
+
try {
|
|
191
|
+
send({ type: INTERNAL_RESTART_READY_MESSAGE, pid, parentPid }, (error) => finish(!error));
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
finish(false);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
156
198
|
export function shouldAutoOpenWebUi(options = {}) {
|
|
157
199
|
const env = options.env ?? process.env;
|
|
158
200
|
return options.openOnStart !== false && env[INTERNAL_RESTART_ENV_VAR] !== "1";
|