agent-loft 0.0.0-stage → 0.2.7

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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -2
  3. package/bin/cli.js +52 -0
  4. package/package.json +64 -4
  5. package/src/agents/claude/account.ts +122 -0
  6. package/src/agents/claude/driver.ts +216 -0
  7. package/src/agents/claude/errors.ts +129 -0
  8. package/src/agents/claude/permissions.ts +260 -0
  9. package/src/agents/claude/probe.ts +54 -0
  10. package/src/agents/claude/session.ts +487 -0
  11. package/src/agents/codex/account.ts +122 -0
  12. package/src/agents/codex/app-server.ts +346 -0
  13. package/src/agents/codex/driver.ts +210 -0
  14. package/src/agents/codex/errors.ts +158 -0
  15. package/src/agents/codex/protocol.ts +284 -0
  16. package/src/agents/codex/refusals.ts +62 -0
  17. package/src/agents/codex/session.ts +773 -0
  18. package/src/channels/feishu/bot.ts +669 -0
  19. package/src/channels/feishu/cards.ts +360 -0
  20. package/src/channels/feishu/client.ts +185 -0
  21. package/src/channels/feishu/envelope.ts +117 -0
  22. package/src/channels/feishu/index.ts +62 -0
  23. package/src/channels/feishu/message.ts +115 -0
  24. package/src/channels/feishu/register.ts +171 -0
  25. package/src/channels/feishu/slash-sync.ts +192 -0
  26. package/src/channels/feishu/turn-stream.ts +98 -0
  27. package/src/channels/web/api.ts +645 -0
  28. package/src/channels/web/auth.ts +204 -0
  29. package/src/channels/web/http.ts +118 -0
  30. package/src/channels/web/push.ts +218 -0
  31. package/src/channels/web/server.ts +446 -0
  32. package/src/cli/doctor.ts +39 -0
  33. package/src/cli/import.ts +155 -0
  34. package/src/cli/pair.ts +35 -0
  35. package/src/cli/passwd.ts +15 -0
  36. package/src/cli/service.ts +137 -0
  37. package/src/core/agent-env.ts +11 -0
  38. package/src/core/command-catalog.ts +112 -0
  39. package/src/core/commands.ts +104 -0
  40. package/src/core/config.ts +285 -0
  41. package/src/core/dedup.ts +16 -0
  42. package/src/core/footers.ts +88 -0
  43. package/src/core/hub.ts +1152 -0
  44. package/src/core/interactions.ts +273 -0
  45. package/src/core/pairing.ts +156 -0
  46. package/src/core/store.ts +178 -0
  47. package/src/core/types.ts +213 -0
  48. package/src/core/uploads.ts +200 -0
  49. package/src/home.ts +43 -0
  50. package/src/main.ts +68 -0
  51. package/web/app.js +3731 -0
  52. package/web/apple-touch-icon.png +0 -0
  53. package/web/icon-maskable.svg +13 -0
  54. package/web/icon.svg +12 -0
  55. package/web/index.html +23 -0
  56. package/web/manifest.webmanifest +15 -0
  57. package/web/style.css +2604 -0
  58. package/web/sw.js +76 -0
@@ -0,0 +1,129 @@
1
+ // 把 SDK / CLI 抛出的原始错误翻译成人能看懂、且知道下一步做什么的提示。
2
+ //
3
+ // 原则:聊天里给一句人话 + 一条明确动作;终端给完整修复步骤。
4
+ // 不要把 SDK 堆栈直接甩给用户 —— 那既吓人又没有信息量。
5
+
6
+ export type DiagnosisKind =
7
+ | "auth_expired"
8
+ | "auth_missing"
9
+ | "cli_missing"
10
+ | "rate_limit"
11
+ | "billing"
12
+ | "turn_aborted"
13
+ | "unknown";
14
+
15
+ export type Diagnosis = {
16
+ kind: DiagnosisKind;
17
+ /** 发到聊天里的简短说明 */
18
+ chatMessage: string;
19
+ /** 打在终端的修复步骤 */
20
+ consoleHint: string;
21
+ /** 是否属于「配置好之前重试也没用」的问题 */
22
+ fatal: boolean;
23
+ };
24
+
25
+ const LOGIN_STEPS = [
26
+ " 1) npm i -g @anthropic-ai/claude-code",
27
+ " 2) claude # 然后用 /login 登录",
28
+ ' 3) claude -p "回复 OK" # 验证认证是否恢复',
29
+ " 4) 重启 agent-loft:agent-loft service restart",
30
+ ].join("\n");
31
+
32
+ export function diagnose(err: unknown): Diagnosis {
33
+ const raw = err instanceof Error ? err.message : String(err);
34
+ const text = raw.toLowerCase();
35
+
36
+ if (
37
+ text.includes("oauth session expired") ||
38
+ text.includes("could not be refreshed") ||
39
+ text.includes("failed to authenticate")
40
+ ) {
41
+ return {
42
+ kind: "auth_expired",
43
+ chatMessage:
44
+ "Claude 的登录状态已过期,我暂时无法工作。\n请机器主人在终端重新登录(`claude` → `/login`),完成后我就能继续。",
45
+ consoleHint: `Claude Code 登录已过期,需要重新登录:\n${LOGIN_STEPS}`,
46
+ fatal: true,
47
+ };
48
+ }
49
+
50
+ if (
51
+ text.includes("invalid api key") ||
52
+ text.includes("authentication_error") ||
53
+ text.includes("unauthorized")
54
+ ) {
55
+ return {
56
+ kind: "auth_missing",
57
+ chatMessage:
58
+ "Claude 的凭证无效,我暂时无法工作。\n请机器主人检查登录状态或 ANTHROPIC_API_KEY。",
59
+ consoleHint: `Claude 凭证无效。若用订阅账号,重新登录:\n${LOGIN_STEPS}\n若用 API key,检查 ANTHROPIC_API_KEY 是否正确。`,
60
+ fatal: true,
61
+ };
62
+ }
63
+
64
+ if (
65
+ text.includes("enoent") ||
66
+ text.includes("command not found") ||
67
+ text.includes("spawn") ||
68
+ text.includes("could not find claude")
69
+ ) {
70
+ return {
71
+ kind: "cli_missing",
72
+ chatMessage:
73
+ "本机还没装好 Claude Code,我暂时无法工作。\n请机器主人先完成安装。",
74
+ consoleHint: `找不到可用的 Claude Code。安装并登录:\n${LOGIN_STEPS}`,
75
+ fatal: true,
76
+ };
77
+ }
78
+
79
+ if (text.includes("rate limit") || text.includes("usage limit")) {
80
+ return {
81
+ kind: "rate_limit",
82
+ chatMessage: "Claude 的用量已达上限,请稍后再试。",
83
+ consoleHint: "已达用量上限。等配额恢复,或换用额度更充足的账号 / API key。",
84
+ fatal: false,
85
+ };
86
+ }
87
+
88
+ // CLI 在一轮结束时发现 transcript 收尾不自洽(典型形态:最后一条是用户侧
89
+ // 消息,而助手那边还停在 tool_use),就判为 error_during_execution。
90
+ // 也就是工具调用循环跑到一半被打断了 —— /stop、审批超时、CLI 进程挂掉都算。
91
+ // SDK 会把它包成 Error("Claude Code returned an error result: …") 抛出来。
92
+ if (
93
+ text.includes("error_during_execution") ||
94
+ text.includes("returned an error result")
95
+ ) {
96
+ return {
97
+ kind: "turn_aborted",
98
+ chatMessage:
99
+ "这一轮没能正常收尾,多半是工具跑到一半被打断了(/stop、审批超时,或 Claude Code 自己退出了)。\n上下文没丢,把刚才的需求再说一遍就行。",
100
+ consoleHint:
101
+ "一轮执行被中途打断(error_during_execution)。\n" +
102
+ "常见原因:用户发了 /stop、审批超时按拒绝处理、或 Claude Code 进程退出。\n" +
103
+ "偶发不用管,下一条消息会带着原 sessionId 重新拉起。\n" +
104
+ "如果反复出现,去 ~/.claude/projects 下看对应会话的 jsonl 尾部,确认是不是每次都断在同一个工具上。",
105
+ fatal: false,
106
+ };
107
+ }
108
+
109
+ if (text.includes("credit balance") || text.includes("billing")) {
110
+ return {
111
+ kind: "billing",
112
+ chatMessage: "Claude 账户余额不足,请机器主人处理后再试。",
113
+ consoleHint: "账户余额不足,请到 Claude 控制台充值或检查订阅状态。",
114
+ fatal: false,
115
+ };
116
+ }
117
+
118
+ return {
119
+ kind: "unknown",
120
+ // 未知错误才把原文带给用户 —— 有原文总比一句「出错了」强
121
+ chatMessage: `出错了:${raw}`,
122
+ consoleHint: raw,
123
+ fatal: false,
124
+ };
125
+ }
126
+
127
+ /** 同一类问题只在终端刷一次完整指引,避免每条消息都刷屏 */
128
+ const announced = new Set<DiagnosisKind>();
129
+
@@ -0,0 +1,260 @@
1
+ // Claude 的授权细节:「总是允许」名单、工具调用的人话描述、AskUserQuestion 的解析。
2
+ //
3
+ // Codex 把「总是允许」做进了协议(acceptForSession / execpolicy 补丁),Claude 没有,
4
+ // 只能自己记一份名单。特征刻意保守:Bash 只认命令的第一个词,文件类只认所在目录。
5
+ // 宁可多问几次,也不要因为特征太宽而放行了本不该放行的东西。
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { dataDir, readJson, writeJsonAtomic } from "../../home.js";
9
+ import type { QuestionSpec } from "../../core/types.js";
10
+
11
+ const allowlistPath = path.join(dataDir, "claude-allowlist.json");
12
+ let allowlist: string[] | null = null;
13
+
14
+ /** 「总是允许一次提问」没有意义:只会让提问卡再也不出现。 */
15
+ const NEVER_REMEMBER = new Set(["AskUserQuestion", "ExitPlanMode"]);
16
+
17
+ export function isRememberable(toolName: string): boolean {
18
+ return !NEVER_REMEMBER.has(toolName);
19
+ }
20
+
21
+ /** 按目录记的签名,目录必须是绝对路径;老版本因读错参数名记下的「NotebookEdit:.」之类一律作废 */
22
+ function validSignature(sig: string): boolean {
23
+ const m = /^(Edit|Write|NotebookEdit):(.*)$/.exec(sig);
24
+ return !m || path.isAbsolute(m[2]!);
25
+ }
26
+
27
+ export function isRememberableSignature(toolName: string, signature: string): boolean {
28
+ return isRememberable(toolName) && validSignature(signature);
29
+ }
30
+
31
+ function load(): string[] {
32
+ allowlist ??= readJson<string[]>(allowlistPath, []).filter(validSignature);
33
+ return allowlist;
34
+ }
35
+
36
+ /**
37
+ * 「总是允许」按多宽记。
38
+ *
39
+ * 默认只记**完整命令**。只有白名单里的命令才按「首词」记:它们不论带什么参数都
40
+ * 只是读读看看,放宽了也出不了大事。黑名单的思路试过,不可靠 —— 包装程序数不完
41
+ * (npm exec、python3.12 -c、各种解释器……),漏一个就是任意命令执行。
42
+ *
43
+ * git 单独处理:按「git + 子命令」记(批准过 git status 不等于批准 git push),
44
+ * 并且带 -c / --config / --exec-path 之类能改行为的全局参数时只记完整命令。
45
+ *
46
+ * 无论哪种,命令里只要有 shell 组合语法(; && | $() 重定向……)就只记完整命令:
47
+ * 否则 `ls; rm -rf ~` 的首词也是 ls。
48
+ */
49
+ /**
50
+ * 出现这些字符就不按首词记:shell 组合语法、变量 / 命令替换,以及引号和通配符 ——
51
+ * 引号能把危险参数藏起来('--output=/x' 在 shell 去掉引号后才生效),
52
+ * 通配符和 ~ 会在执行时展开成我们看不到的东西。
53
+ */
54
+ const SHELL_SYNTAX = /[;&|`<>\n\r\\'"*?[\]{}~#!]|\$/;
55
+
56
+ /**
57
+ * 可按首词放行的命令,以及它们「哪些参数不行」。
58
+ *
59
+ * 入选标准:无论带什么参数,都既不能执行别的程序,也不能写文件。为此去掉了
60
+ * 看似只读、其实带「后门参数」的:rg(--pre 能把文件当脚本跑)、sort / uniq / tree
61
+ * (-o 或位置参数能写文件)。剩下的少数命令里,个别参数仍有副作用(date -s 改系统时间),
62
+ * 单独拦掉。
63
+ */
64
+ const PREFIX_SAFE: Record<string, (args: string[]) => boolean> = {
65
+ ls: () => true,
66
+ cat: () => true,
67
+ head: () => true,
68
+ tail: () => true,
69
+ wc: () => true,
70
+ grep: () => true,
71
+ pwd: (a) => a.length === 0,
72
+ echo: () => true,
73
+ stat: () => true,
74
+ du: () => true,
75
+ df: () => true,
76
+ which: () => true,
77
+ // GNU 长选项可以缩写(--se 就是 --set),所以只认明确列出的参数
78
+ date: (a) => a.every((x) => /^\+/.test(x) || ["-u", "--utc", "-R", "-I", "--iso-8601"].includes(x)),
79
+ basename: () => true,
80
+ dirname: () => true,
81
+ realpath: () => true,
82
+ readlink: () => true,
83
+ uname: () => true,
84
+ whoami: (a) => a.length === 0,
85
+ id: () => true,
86
+ hostname: (a) => a.length === 0, // 带参数就是改主机名
87
+ uptime: () => true,
88
+ ps: () => true,
89
+ mkdir: () => true,
90
+ touch: () => true,
91
+ };
92
+
93
+ /**
94
+ * git 只放行纯查看的子命令,参数也只认白名单 —— git 的长选项同样能缩写
95
+ * (--outp 就是 --output),用黑名单拦不住。不是选项的参数(路径、提交号)照常放行。
96
+ */
97
+ const GIT_READONLY = new Set(["status", "log", "show", "diff"]);
98
+ const GIT_SAFE_FLAGS = new Set([
99
+ "-s", "--short", "-b", "--branch", "--porcelain", "-v", "--verbose", "-u", "--untracked-files",
100
+ "--oneline", "--graph", "--decorate", "--all", "--stat", "--shortstat", "--name-only", "--name-status",
101
+ "-p", "--patch", "--cached", "--staged", "--no-color", "--color", "-w", "--ignore-all-space",
102
+ "--reverse", "--first-parent", "--no-merges", "--merges", "--", "-q", "--quiet",
103
+ ]);
104
+
105
+ function gitArgOk(arg: string): boolean {
106
+ if (!arg.startsWith("-")) return true;
107
+ if (/^-\d+$/.test(arg)) return true; // -5:最近 5 条
108
+ if (/^(-n|--max-count)=?\d*$/.test(arg)) return true;
109
+ if (/^--(since|until|author|grep)=[^=]+$/.test(arg)) return true;
110
+ return GIT_SAFE_FLAGS.has(arg);
111
+ }
112
+
113
+ function prefixSignature(command: string): string | null {
114
+ const trimmed = command.trim();
115
+ if (!trimmed || SHELL_SYNTAX.test(trimmed)) return null;
116
+ const words = trimmed.split(/\s+/);
117
+ const head = words[0] ?? "";
118
+ const args = words.slice(1);
119
+ const check = Object.hasOwn(PREFIX_SAFE, head) ? PREFIX_SAFE[head] : undefined;
120
+ if (check) return check(args) ? `Bash:${head}` : null;
121
+ if (head === "git") {
122
+ const sub = args[0] ?? "";
123
+ if (!GIT_READONLY.has(sub) || !args.slice(1).every(gitArgOk)) return null;
124
+ return `Bash:git ${sub}`;
125
+ }
126
+ return null;
127
+ }
128
+
129
+ export function signatureOf(toolName: string, input: Record<string, unknown>): string {
130
+ if (toolName === "Bash") {
131
+ const command = typeof input.command === "string" ? input.command : "";
132
+ return prefixSignature(command) ?? `BashExact:${command.trim()}`;
133
+ }
134
+ if (toolName === "Edit" || toolName === "Write" || toolName === "NotebookEdit") {
135
+ // NotebookEdit 的路径参数叫 notebook_path,不是 file_path
136
+ const raw = toolName === "NotebookEdit" ? input.notebook_path : input.file_path;
137
+ const file = typeof raw === "string" ? raw : "";
138
+ // 拿不到绝对路径就没法按目录记:给一个不可记忆的签名(见 isRememberableSignature)
139
+ if (!file || !path.isAbsolute(file)) return `${toolName}:`;
140
+ return `${toolName}:${path.dirname(file)}`;
141
+ }
142
+ return toolName;
143
+ }
144
+
145
+ export function isPreApproved(toolName: string, signature: string): boolean {
146
+ // 防绕过靠的是 signatureOf:复合命令拿到的是 BashExact 签名,命中不了 Bash:<首词>
147
+ if (!isRememberableSignature(toolName, signature)) return false;
148
+ const list = load();
149
+ if (list.includes(signature)) return true;
150
+ return coveredBySubdirectoryRule(toolName, signature, list);
151
+ }
152
+
153
+ const DIR_TOOLS = new Set(["Edit", "Write", "NotebookEdit"]);
154
+
155
+ /**
156
+ * 按目录记的批准连带子目录:批准过 /home/me/project,改 project/src/core 下的文件不用再问。
157
+ * 以前只认完全相同的目录,项目里每进一个子目录都要再点一次,点到后来就是闭眼点 —— 那才不安全。
158
+ *
159
+ * 两道收口:
160
+ * - 太宽的批准不往下延伸:/、家目录、家目录的上级。批准过「在家目录下新建文件」
161
+ * 不等于能改 ~/.ssh、~/.bashrc;
162
+ * - 不延伸进隐藏目录(.git/hooks、.github/workflows、.husky…):往那里写东西等于
163
+ * 让它下次自动执行。真要改,单独批一次那个目录。
164
+ */
165
+ function coveredBySubdirectoryRule(toolName: string, signature: string, list: string[]): boolean {
166
+ if (!DIR_TOOLS.has(toolName)) return false;
167
+ const target = signature.slice(toolName.length + 1);
168
+ const home = os.homedir();
169
+ for (const entry of list) {
170
+ if (!entry.startsWith(`${toolName}:`)) continue;
171
+ const approved = entry.slice(toolName.length + 1);
172
+ if (!path.isAbsolute(approved)) continue;
173
+ if (approved === path.parse(approved).root || approved === home || home.startsWith(`${approved}${path.sep}`)) continue;
174
+ const rel = path.relative(approved, target);
175
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) continue;
176
+ if (rel.split(path.sep).some((part) => part.startsWith("."))) continue;
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ export function rememberAlways(signature: string): void {
183
+ if (!validSignature(signature)) return;
184
+ const list = load();
185
+ if (list.includes(signature)) return;
186
+ list.push(signature);
187
+ writeJsonAtomic(allowlistPath, list);
188
+ }
189
+
190
+ export function listAllowlist(): string[] {
191
+ return [...load()];
192
+ }
193
+
194
+ export function setAllowlist(next: string[]): void {
195
+ allowlist = [...new Set(next.map((s) => s.trim()).filter(Boolean))];
196
+ writeJsonAtomic(allowlistPath, allowlist);
197
+ }
198
+
199
+ /** 「总是允许」按钮上写清楚到底放行多宽。 */
200
+ export function alwaysLabelOf(toolName: string, signature: string): string {
201
+ if (toolName === "Bash") {
202
+ return signature.startsWith("BashExact:") ? "总是允许这条完全相同的命令" : `总是允许 ${signature.slice(5)} 开头的单条命令`;
203
+ }
204
+ if (signature.includes(":")) return `总是允许在该目录及子目录下${toolName === "Write" ? "新建" : "修改"}`;
205
+ return `总是允许 ${toolName}`;
206
+ }
207
+
208
+ export function describeToolCall(toolName: string, input: Record<string, unknown>): string {
209
+ if (toolName === "Bash") {
210
+ const desc = typeof input.description === "string" ? `${input.description}\n\n` : "";
211
+ return `${desc}\`\`\`bash\n${String(input.command ?? "")}\n\`\`\``;
212
+ }
213
+ if (toolName === "Edit" || toolName === "Write") {
214
+ return `**文件**:\`${String(input.file_path ?? "")}\``;
215
+ }
216
+ // 计划本身就是写给人看的 markdown,套一层 JSON 只会让它没法读
217
+ if (toolName === "ExitPlanMode" && typeof input.plan === "string") return input.plan;
218
+ return `\`\`\`json\n${JSON.stringify(input, null, 2)}\n\`\`\``;
219
+ }
220
+
221
+ /**
222
+ * 从 AskUserQuestion 的入参里解出问题清单。解不出来返回 null,调用方退回普通授权卡
223
+ * —— 宁可显示得笨一点,也不要因为 SDK 改了 schema 就把一整轮卡死。
224
+ *
225
+ * 问题的 id 用问题原文:SDK 约定答案按「问题原文 → 答案」回填。
226
+ */
227
+ export function parseQuestions(input: Record<string, unknown>): QuestionSpec[] | null {
228
+ const raw = input.questions;
229
+ if (!Array.isArray(raw) || raw.length === 0) return null;
230
+ const specs: QuestionSpec[] = [];
231
+ for (const item of raw) {
232
+ if (typeof item !== "object" || item === null) return null;
233
+ const rec = item as Record<string, unknown>;
234
+ const question = typeof rec.question === "string" ? rec.question.trim() : "";
235
+ const rawOptions = Array.isArray(rec.options) ? rec.options : [];
236
+ if (!question || rawOptions.length === 0) return null;
237
+ const options: QuestionSpec["options"] = [];
238
+ for (const opt of rawOptions) {
239
+ if (typeof opt !== "object" || opt === null) return null;
240
+ const o = opt as Record<string, unknown>;
241
+ const label = typeof o.label === "string" ? o.label.trim() : "";
242
+ if (!label) return null;
243
+ options.push({
244
+ label,
245
+ ...(typeof o.description === "string" && o.description.trim()
246
+ ? { description: o.description.trim() }
247
+ : {}),
248
+ });
249
+ }
250
+ specs.push({
251
+ id: typeof rec.question === "string" ? rec.question : question,
252
+ header: typeof rec.header === "string" && rec.header.trim() ? rec.header.trim() : "选择",
253
+ question,
254
+ options,
255
+ multiSelect: rec.multiSelect === true,
256
+ allowOther: true,
257
+ });
258
+ }
259
+ return specs;
260
+ }
@@ -0,0 +1,54 @@
1
+ // 「探针」query:拉一个从不发消息的 CLI 进程,只用来问控制请求(额度、模型列表),问完就关。
2
+ //
3
+ // 这些控制方法挂在 Query 上,正常要借活会话才能问;但 /status、设置页不该依赖
4
+ // 「当前恰好有活会话」。不发消息就不耗 token,代价只是起一次进程(两三秒)。
5
+ import os from "node:os";
6
+ import { query, type Query, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
7
+ import { agentEnvironment } from "../../core/agent-env.js";
8
+
9
+ /** 永远不产出消息的输入流:query 必须是流式输入模式才能发控制请求 */
10
+ async function* silence(): AsyncGenerator<SDKUserMessage> {
11
+ await new Promise<never>(() => {});
12
+ }
13
+
14
+ /** cwd 会影响项目级的东西(.claude/commands、项目 skills),不关心就用家目录 */
15
+ export async function withProbe<T>(fn: (q: Query) => Promise<T>, cwd = os.homedir()): Promise<T> {
16
+ const q = query({ prompt: silence(), options: { cwd, env: agentEnvironment() } });
17
+ try {
18
+ return await fn(q);
19
+ } finally {
20
+ q.close();
21
+ }
22
+ }
23
+
24
+ /**
25
+ * 带缓存、并发合并的读取:同一时刻多个调用方共用一次探针,结果缓存 ttl 毫秒。
26
+ * 给了 fallback 就是「读不到也无所谓」:失败记日志、返回并缓存 fallback,免得每次都再等几秒;
27
+ * 没给就把错误抛给调用方,也不缓存,下次重试。
28
+ */
29
+ export function cachedProbe<T>(
30
+ ttl: number,
31
+ what: string,
32
+ fetch: () => Promise<T>,
33
+ ...fallback: [] | [T]
34
+ ): () => Promise<T> {
35
+ let cache: { at: number; value: T } | null = null;
36
+ let inflight: Promise<T> | null = null;
37
+ return () => {
38
+ if (cache && Date.now() - cache.at < ttl) return Promise.resolve(cache.value);
39
+ inflight ??= fetch()
40
+ .catch((err) => {
41
+ console.error(`[${what}] 读取失败:`, err instanceof Error ? err.message : err);
42
+ if (fallback.length === 0) throw err;
43
+ return fallback[0] as T;
44
+ })
45
+ .then((value) => {
46
+ cache = { at: Date.now(), value };
47
+ return value;
48
+ })
49
+ .finally(() => {
50
+ inflight = null;
51
+ });
52
+ return inflight;
53
+ };
54
+ }