chatccc 0.2.186 → 0.2.188
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/config.sample.json +39 -32
- package/package.json +1 -1
- package/src/__tests__/builtin-config.test.ts +33 -0
- package/src/__tests__/chrome-devtools-guard.test.ts +40 -0
- package/src/__tests__/config-reload.test.ts +30 -16
- package/src/__tests__/config-sample.test.ts +41 -19
- package/src/__tests__/feishu-avatar.test.ts +180 -115
- package/src/__tests__/orchestrator.test.ts +165 -17
- package/src/__tests__/session.test.ts +17 -7
- package/src/__tests__/web-ui.test.ts +22 -0
- package/src/adapters/codex-adapter.ts +26 -21
- package/src/builtin/cli.ts +8 -16
- package/src/builtin/index.ts +12 -11
- package/src/cards.ts +48 -7
- package/src/chrome-devtools-guard.ts +78 -2
- package/src/config.ts +158 -108
- package/src/feishu-api.ts +219 -190
- package/src/index.ts +2 -2
- package/src/orchestrator.ts +211 -19
- package/src/platform-adapter.ts +9 -1
- package/src/session.ts +92 -64
- package/src/web-ui.ts +32 -7
|
@@ -184,12 +184,13 @@ export function normalizeCodexMessage(
|
|
|
184
184
|
// 子进程辅助函数
|
|
185
185
|
// ---------------------------------------------------------------------------
|
|
186
186
|
|
|
187
|
-
function spawnCodex(
|
|
188
|
-
args: string[],
|
|
189
|
-
cwd?: string,
|
|
190
|
-
stdinText?: string,
|
|
191
|
-
modelOverride?: string,
|
|
192
|
-
|
|
187
|
+
function spawnCodex(
|
|
188
|
+
args: string[],
|
|
189
|
+
cwd?: string,
|
|
190
|
+
stdinText?: string,
|
|
191
|
+
modelOverride?: string,
|
|
192
|
+
effortOverride?: string,
|
|
193
|
+
): ChildProcess {
|
|
193
194
|
const allArgs = [...args];
|
|
194
195
|
const model = modelOverride ?? resolveCodexModel();
|
|
195
196
|
if (model) {
|
|
@@ -197,7 +198,7 @@ function spawnCodex(
|
|
|
197
198
|
const execIdx = allArgs.indexOf("exec");
|
|
198
199
|
allArgs.splice(execIdx + 1, 0, "-m", model);
|
|
199
200
|
}
|
|
200
|
-
const effort = resolveCodexEffort();
|
|
201
|
+
const effort = effortOverride ?? resolveCodexEffort();
|
|
201
202
|
if (effort) {
|
|
202
203
|
allArgs.push("-c", `model_reasoning_effort="${effort}"`);
|
|
203
204
|
}
|
|
@@ -258,15 +259,17 @@ async function* readJsonLines(
|
|
|
258
259
|
// ---------------------------------------------------------------------------
|
|
259
260
|
|
|
260
261
|
class CodexAdapter implements ToolAdapter {
|
|
261
|
-
readonly displayName = "Codex";
|
|
262
|
-
readonly sessionDescPrefix = "Codex Session:";
|
|
263
|
-
private metaStore: CodexSessionMetaStore;
|
|
264
|
-
private modelOverride: string | undefined;
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
this.
|
|
269
|
-
|
|
262
|
+
readonly displayName = "Codex";
|
|
263
|
+
readonly sessionDescPrefix = "Codex Session:";
|
|
264
|
+
private metaStore: CodexSessionMetaStore;
|
|
265
|
+
private modelOverride: string | undefined;
|
|
266
|
+
private effortOverride: string | undefined;
|
|
267
|
+
|
|
268
|
+
constructor(metaStore: CodexSessionMetaStore, modelOverride?: string, effortOverride?: string) {
|
|
269
|
+
this.metaStore = metaStore;
|
|
270
|
+
this.modelOverride = modelOverride;
|
|
271
|
+
this.effortOverride = effortOverride;
|
|
272
|
+
}
|
|
270
273
|
|
|
271
274
|
// createSession: 生成 sessionId,记录 cwd,不创建 Codex 线程(延迟到首次 prompt)
|
|
272
275
|
async createSession(cwd: string): Promise<CreateSessionResult> {
|
|
@@ -296,7 +299,7 @@ class CodexAdapter implements ToolAdapter {
|
|
|
296
299
|
? [...baseArgs, "-C", cwd, "-"]
|
|
297
300
|
: [...baseArgs, "resume", threadId, "-"];
|
|
298
301
|
|
|
299
|
-
const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride);
|
|
302
|
+
const proc = spawnCodex(args, cwd, buildCodexPromptText(userText), this.modelOverride, this.effortOverride);
|
|
300
303
|
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
301
304
|
|
|
302
305
|
// 关键:spawn 用了 shell:true,proc.pid 指向的是壳进程(cmd.exe / sh)。
|
|
@@ -350,7 +353,8 @@ class CodexAdapter implements ToolAdapter {
|
|
|
350
353
|
export interface CreateCodexAdapterOptions {
|
|
351
354
|
metaStore?: CodexSessionMetaStore;
|
|
352
355
|
/** per-session 模型覆盖(/model 命令);传了就用,不传走全局 codex.model */
|
|
353
|
-
model?: string;
|
|
356
|
+
model?: string;
|
|
357
|
+
effort?: string;
|
|
354
358
|
}
|
|
355
359
|
|
|
356
360
|
export function createCodexAdapter(
|
|
@@ -358,6 +362,7 @@ export function createCodexAdapter(
|
|
|
358
362
|
): ToolAdapter {
|
|
359
363
|
return new CodexAdapter(
|
|
360
364
|
options.metaStore ?? defaultCodexSessionMetaStore,
|
|
361
|
-
options.model,
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
options.model,
|
|
366
|
+
options.effort,
|
|
367
|
+
);
|
|
368
|
+
}
|
package/src/builtin/cli.ts
CHANGED
|
@@ -5,14 +5,11 @@
|
|
|
5
5
|
* npx tsx src/builtin/cli.ts
|
|
6
6
|
* npx tsx src/builtin/cli.ts --model deepseek-chat
|
|
7
7
|
* npx tsx src/builtin/cli.ts --cwd /path/to/project
|
|
8
|
-
*
|
|
9
|
-
* 环境变量:
|
|
10
|
-
* DEEPSEEK_API_KEY — DeepSeek API Key(必需)
|
|
11
|
-
* DEEPSEEK_BASE_URL — API 地址(可选,默认 https://api.deepseek.com/v1)
|
|
12
8
|
*/
|
|
13
9
|
|
|
14
10
|
import * as readline from "node:readline";
|
|
15
11
|
import * as process from "node:process";
|
|
12
|
+
import { config as appConfig } from "../config.ts";
|
|
16
13
|
import { ChatSession, type ChatSessionConfig, type ChatSessionOptions } from "./index.js";
|
|
17
14
|
|
|
18
15
|
// ---------------------------------------------------------------------------
|
|
@@ -55,15 +52,14 @@ function printHelp(): void {
|
|
|
55
52
|
"用法: npx tsx src/builtin/cli.ts [选项]",
|
|
56
53
|
"",
|
|
57
54
|
"选项:",
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
" --api-key <key> API Key
|
|
55
|
+
` --model <name> 模型名称(覆盖 config.ccc.model,当前默认 ${appConfig.ccc.model})`,
|
|
56
|
+
` --base-url <url> API 地址(覆盖 config.ccc.DEEPSEEK_BASE_URL,当前默认 ${appConfig.ccc.DEEPSEEK_BASE_URL})`,
|
|
57
|
+
" --api-key <key> API Key(覆盖 config.ccc.DEEPSEEK_API_KEY)",
|
|
61
58
|
" --cwd <path> 工作目录",
|
|
62
59
|
" --help, -h 显示帮助",
|
|
63
60
|
"",
|
|
64
|
-
"
|
|
65
|
-
" DEEPSEEK_API_KEY
|
|
66
|
-
" DEEPSEEK_BASE_URL API 地址",
|
|
61
|
+
"默认配置来源:",
|
|
62
|
+
" ~/.chatccc/config.json 的 ccc.DEEPSEEK_API_KEY / ccc.DEEPSEEK_BASE_URL / ccc.model",
|
|
67
63
|
"",
|
|
68
64
|
].join("\n"));
|
|
69
65
|
}
|
|
@@ -87,12 +83,8 @@ const C = {
|
|
|
87
83
|
async function main(): Promise<void> {
|
|
88
84
|
const { config, options } = parseArgs();
|
|
89
85
|
|
|
90
|
-
// 环境变量回退
|
|
91
|
-
if (!config.baseURL) config.baseURL = process.env.DEEPSEEK_BASE_URL;
|
|
92
|
-
if (!config.apiKey) config.apiKey = process.env.DEEPSEEK_API_KEY;
|
|
93
|
-
|
|
94
86
|
console.log(`${C.dim}ChatCCC 内置 Agent 原型${C.reset}`);
|
|
95
|
-
console.log(`${C.dim}模型: ${config.model ??
|
|
87
|
+
console.log(`${C.dim}模型: ${config.model ?? appConfig.ccc.model}${C.reset}`);
|
|
96
88
|
if (options.cwd) {
|
|
97
89
|
console.log(`${C.dim}目录: ${options.cwd}${C.reset}`);
|
|
98
90
|
}
|
|
@@ -194,4 +186,4 @@ async function main(): Promise<void> {
|
|
|
194
186
|
main().catch((err) => {
|
|
195
187
|
console.error(`${C.yellow}启动失败: ${(err as Error).message}${C.reset}`);
|
|
196
188
|
process.exit(1);
|
|
197
|
-
});
|
|
189
|
+
});
|
package/src/builtin/index.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块(如 ToolAdapter)调用。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
|
7
8
|
import { streamText } from "ai";
|
|
8
9
|
|
|
10
|
+
import { config as appConfig } from "../config.ts";
|
|
11
|
+
|
|
9
12
|
// ---------------------------------------------------------------------------
|
|
10
13
|
// 系统提示词 — 编译期冻结常量
|
|
11
14
|
// ---------------------------------------------------------------------------
|
|
@@ -25,11 +28,11 @@ const SYSTEM_PROMPT = [
|
|
|
25
28
|
// ---------------------------------------------------------------------------
|
|
26
29
|
|
|
27
30
|
export interface ChatSessionConfig {
|
|
28
|
-
/** DeepSeek API
|
|
31
|
+
/** DeepSeek API 兼容的服务地址;传入时覆盖 config.ccc.DEEPSEEK_BASE_URL */
|
|
29
32
|
baseURL?: string;
|
|
30
|
-
/** API Key
|
|
33
|
+
/** API Key;传入时覆盖 config.ccc.DEEPSEEK_API_KEY */
|
|
31
34
|
apiKey?: string;
|
|
32
|
-
/**
|
|
35
|
+
/** 模型名称;传入时覆盖 config.ccc.model */
|
|
33
36
|
model?: string;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -66,21 +69,19 @@ export class ChatSession {
|
|
|
66
69
|
private messages: ChatMessage[];
|
|
67
70
|
|
|
68
71
|
constructor(
|
|
69
|
-
|
|
72
|
+
overrides: ChatSessionConfig = {},
|
|
70
73
|
options: ChatSessionOptions = {},
|
|
71
74
|
) {
|
|
72
|
-
const apiKey =
|
|
75
|
+
const apiKey = overrides.apiKey ?? appConfig.ccc.DEEPSEEK_API_KEY;
|
|
73
76
|
if (!apiKey) {
|
|
74
77
|
throw new Error(
|
|
75
|
-
"DEEPSEEK_API_KEY
|
|
78
|
+
"ccc.DEEPSEEK_API_KEY 未设置。请在 config.json 中配置,或通过 --api-key 临时传入",
|
|
76
79
|
);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
const baseURL =
|
|
80
|
-
const modelId =
|
|
82
|
+
const baseURL = overrides.baseURL ?? appConfig.ccc.DEEPSEEK_BASE_URL;
|
|
83
|
+
const modelId = overrides.model ?? appConfig.ccc.model;
|
|
81
84
|
|
|
82
|
-
// 动态 import 以支持 ESM 包在 CJS 环境下的加载(tsx 处理)
|
|
83
|
-
const { createOpenAICompatible } = require("@ai-sdk/openai-compatible");
|
|
84
85
|
const provider = createOpenAICompatible({
|
|
85
86
|
name: "deepseek",
|
|
86
87
|
baseURL,
|
|
@@ -164,4 +165,4 @@ export class ChatSession {
|
|
|
164
165
|
const system = this.messages[0];
|
|
165
166
|
this.messages = [system];
|
|
166
167
|
}
|
|
167
|
-
}
|
|
168
|
+
}
|
package/src/cards.ts
CHANGED
|
@@ -498,10 +498,10 @@ export function buildCodexResetConfirmCard(params: {
|
|
|
498
498
|
|
|
499
499
|
/** 模型切换卡片(/model 命令,飞书专用,含切换按钮) */
|
|
500
500
|
export function buildModelCard(
|
|
501
|
-
currentModel: string,
|
|
502
|
-
models: string[],
|
|
503
|
-
tool?: string,
|
|
504
|
-
): string {
|
|
501
|
+
currentModel: string,
|
|
502
|
+
models: string[],
|
|
503
|
+
tool?: string,
|
|
504
|
+
): string {
|
|
505
505
|
const toolLabel = tool ? ` (${tool})` : "";
|
|
506
506
|
const currentLine = currentModel
|
|
507
507
|
? `**当前模型:** \`${currentModel}\``
|
|
@@ -535,7 +535,48 @@ export function buildModelCard(
|
|
|
535
535
|
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
536
536
|
{ tag: "hr" },
|
|
537
537
|
buildButtons(buttons),
|
|
538
|
-
],
|
|
539
|
-
});
|
|
540
|
-
}
|
|
538
|
+
],
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function buildEffortCard(
|
|
543
|
+
currentEffort: string,
|
|
544
|
+
efforts: string[],
|
|
545
|
+
tool?: string,
|
|
546
|
+
): string {
|
|
547
|
+
const toolLabel = tool ? ` (${tool})` : "";
|
|
548
|
+
const currentLine = currentEffort
|
|
549
|
+
? `**当前 effort:** \`${currentEffort}\``
|
|
550
|
+
: "**当前 effort:** 未指定";
|
|
551
|
+
|
|
552
|
+
const lines: string[] = [currentLine];
|
|
553
|
+
if (efforts.length > 0) {
|
|
554
|
+
lines.push("", "**可切换 effort**");
|
|
555
|
+
for (const effort of efforts) {
|
|
556
|
+
lines.push(`- \`${effort}\``);
|
|
557
|
+
}
|
|
558
|
+
lines.push("", "点击按钮切换 effort,或输入 `/effort clear` 恢复默认");
|
|
559
|
+
} else {
|
|
560
|
+
lines.push("", "当前 agent 不支持 effort 切换。");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const buttons: ButtonDef[] = [];
|
|
564
|
+
for (const effort of efforts.slice(0, 20)) {
|
|
565
|
+
buttons.push({
|
|
566
|
+
text: `/effort ${effort}`,
|
|
567
|
+
value: JSON.stringify({ cmd: `/effort ${effort}` }),
|
|
568
|
+
type: "primary",
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
return JSON.stringify({
|
|
573
|
+
config: { wide_screen_mode: true },
|
|
574
|
+
header: { template: "blue", title: { content: `Effort 切换${toolLabel}`, tag: "plain_text" } },
|
|
575
|
+
elements: [
|
|
576
|
+
{ tag: "div", text: { tag: "lark_md", content: lines.join("\n") } },
|
|
577
|
+
{ tag: "hr" },
|
|
578
|
+
buildButtons(buttons),
|
|
579
|
+
],
|
|
580
|
+
});
|
|
581
|
+
}
|
|
541
582
|
|
|
@@ -33,6 +33,18 @@ export interface ChromeCdpEnsureResult {
|
|
|
33
33
|
|
|
34
34
|
export type ChromeCdpProbeStatus = "healthy" | "occupied" | "unreachable";
|
|
35
35
|
|
|
36
|
+
interface ChromeCdpPage {
|
|
37
|
+
id?: string;
|
|
38
|
+
type?: string;
|
|
39
|
+
url?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface EnsureChatcccPageResult {
|
|
43
|
+
ok: boolean;
|
|
44
|
+
opened: boolean;
|
|
45
|
+
error?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
36
48
|
let guardTimer: ReturnType<typeof setInterval> | null = null;
|
|
37
49
|
let ensureInFlight: Promise<ChromeCdpEnsureResult> | null = null;
|
|
38
50
|
|
|
@@ -81,11 +93,11 @@ export function resolveChromeUserDataDir(port: number, env: NodeJS.ProcessEnv =
|
|
|
81
93
|
return join(root, `chrome-cdp-${port}`);
|
|
82
94
|
}
|
|
83
95
|
|
|
84
|
-
async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number): Promise<Response> {
|
|
96
|
+
async function fetchWithTimeout(url: string, fetchImpl: FetchLike, timeoutMs: number, init: RequestInit = {}): Promise<Response> {
|
|
85
97
|
const controller = new AbortController();
|
|
86
98
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
87
99
|
try {
|
|
88
|
-
return await fetchImpl(url, { signal: controller.signal });
|
|
100
|
+
return await fetchImpl(url, { ...init, signal: controller.signal });
|
|
89
101
|
} finally {
|
|
90
102
|
clearTimeout(timer);
|
|
91
103
|
}
|
|
@@ -163,6 +175,61 @@ function sleep(ms: number): Promise<void> {
|
|
|
163
175
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
164
176
|
}
|
|
165
177
|
|
|
178
|
+
function cdpBaseUrl(port: number): string {
|
|
179
|
+
return `http://${CDP_HOST}:${port}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isChatcccPageUrl(value: string | undefined, chatcccPort: number): boolean {
|
|
183
|
+
if (!value) return false;
|
|
184
|
+
try {
|
|
185
|
+
const url = new URL(value);
|
|
186
|
+
return url.protocol === "http:" &&
|
|
187
|
+
(url.hostname === "localhost" || url.hostname === CDP_HOST) &&
|
|
188
|
+
url.port === String(chatcccPort);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function ensureChatcccPageOpen(
|
|
195
|
+
cdpPort: number,
|
|
196
|
+
chatcccPort: number,
|
|
197
|
+
deps: Pick<ChromeDevtoolsGuardDeps, "fetchImpl"> = {},
|
|
198
|
+
): Promise<EnsureChatcccPageResult> {
|
|
199
|
+
const normalizedCdpPort = normalizePort(cdpPort);
|
|
200
|
+
const normalizedChatcccPort = normalizePort(chatcccPort);
|
|
201
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const listResponse = await fetchWithTimeout(
|
|
205
|
+
`${cdpBaseUrl(normalizedCdpPort)}/json/list`,
|
|
206
|
+
fetchImpl,
|
|
207
|
+
HEALTH_TIMEOUT_MS,
|
|
208
|
+
);
|
|
209
|
+
if (!listResponse.ok) {
|
|
210
|
+
return { ok: false, opened: false, error: `Cannot list Chrome CDP pages: HTTP ${listResponse.status}` };
|
|
211
|
+
}
|
|
212
|
+
const pages = await listResponse.json() as unknown;
|
|
213
|
+
if (Array.isArray(pages) && pages.some((page: ChromeCdpPage) => isChatcccPageUrl(page?.url, normalizedChatcccPort))) {
|
|
214
|
+
return { ok: true, opened: false };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const targetUrl = `http://localhost:${normalizedChatcccPort}/`;
|
|
218
|
+
const openResponse = await fetchWithTimeout(
|
|
219
|
+
`${cdpBaseUrl(normalizedCdpPort)}/json/new?${encodeURIComponent(targetUrl)}`,
|
|
220
|
+
fetchImpl,
|
|
221
|
+
HEALTH_TIMEOUT_MS,
|
|
222
|
+
{ method: "PUT" },
|
|
223
|
+
);
|
|
224
|
+
if (!openResponse.ok) {
|
|
225
|
+
return { ok: false, opened: false, error: `Cannot open ${targetUrl}: HTTP ${openResponse.status}` };
|
|
226
|
+
}
|
|
227
|
+
return { ok: true, opened: true };
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return { ok: false, opened: false, error: (err as Error).message };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
166
233
|
export async function ensureChromeCdpRunning(
|
|
167
234
|
cfg: ChromeDevtoolsConfig = config.chromeDevtools,
|
|
168
235
|
deps: ChromeDevtoolsGuardDeps = {},
|
|
@@ -202,6 +269,9 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
|
|
|
202
269
|
ensureInFlight = ensureChromeCdpRunning(config.chromeDevtools, deps);
|
|
203
270
|
try {
|
|
204
271
|
const result = await ensureInFlight;
|
|
272
|
+
const chatcccPage = config.chromeDevtools.enabled && result.ok
|
|
273
|
+
? await ensureChatcccPageOpen(result.port, config.port, deps)
|
|
274
|
+
: null;
|
|
205
275
|
appendStartupTrace("chrome-devtools-guard: ensure result", {
|
|
206
276
|
reason,
|
|
207
277
|
enabled: config.chromeDevtools.enabled,
|
|
@@ -209,6 +279,7 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
|
|
|
209
279
|
ok: result.ok,
|
|
210
280
|
started: result.started,
|
|
211
281
|
error: result.error,
|
|
282
|
+
chatcccPage,
|
|
212
283
|
});
|
|
213
284
|
if (!config.chromeDevtools.enabled) return;
|
|
214
285
|
if (result.ok && result.started) {
|
|
@@ -216,6 +287,11 @@ async function runGuardOnce(reason: string, deps: ChromeDevtoolsGuardDeps = {}):
|
|
|
216
287
|
} else if (!result.ok) {
|
|
217
288
|
log(`[${ts()}] [Chrome CDP] Guard failed: ${result.error}`);
|
|
218
289
|
}
|
|
290
|
+
if (chatcccPage?.opened) {
|
|
291
|
+
log(`[${ts()}] [Chrome CDP] Opened ChatCCC page http://localhost:${config.port}/`);
|
|
292
|
+
} else if (chatcccPage && !chatcccPage.ok) {
|
|
293
|
+
log(`[${ts()}] [Chrome CDP] Failed to ensure ChatCCC page: ${chatcccPage.error}`);
|
|
294
|
+
}
|
|
219
295
|
} finally {
|
|
220
296
|
ensureInFlight = null;
|
|
221
297
|
}
|