pi-firecode 0.8.0 → 1.0.0

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 (79) hide show
  1. package/README.md +35 -15
  2. package/activity.ts +92 -0
  3. package/busy.ts +188 -0
  4. package/config.example.jsonc +38 -47
  5. package/config.ts +198 -172
  6. package/deliver.ts +100 -13
  7. package/flame.ts +149 -0
  8. package/format.ts +84 -31
  9. package/header.ts +208 -82
  10. package/index.ts +17 -11
  11. package/master/actions.ts +293 -0
  12. package/master/activity-list.ts +281 -0
  13. package/master/event-card.ts +18 -37
  14. package/master/event-format.ts +71 -40
  15. package/master/guard.ts +46 -0
  16. package/master/index.ts +88 -1036
  17. package/master/list-view.ts +129 -0
  18. package/master/outbox.ts +188 -0
  19. package/master/prompts/master.zh.md +18 -13
  20. package/master/prompts/worker.zh.md +1 -1
  21. package/master/role.ts +6 -1
  22. package/master/run.ts +315 -0
  23. package/master/runtime.ts +259 -0
  24. package/master/spawn.ts +79 -38
  25. package/master/state.ts +30 -6
  26. package/master/worker-view.ts +517 -0
  27. package/package.json +1 -1
  28. package/provider/claude-sub.ts +35 -13
  29. package/provider/openai-native/src/compact-client.ts +2 -14
  30. package/provider/openai-native/src/options.ts +4 -2
  31. package/review/advisor.ts +0 -2
  32. package/review/card.ts +36 -23
  33. package/review/checkpoint.ts +2 -8
  34. package/review/evidence.ts +42 -44
  35. package/review/index.ts +271 -525
  36. package/review/occupancy.ts +25 -0
  37. package/review/outcome.ts +31 -7
  38. package/review/prompt.ts +7 -8
  39. package/review/prompts/review.en.md +7 -6
  40. package/review/prompts/review.zh.md +7 -6
  41. package/review/reviewer.ts +2 -5
  42. package/review/session.ts +11 -30
  43. package/review/state.ts +32 -60
  44. package/review/ui.ts +30 -469
  45. package/round-recorder.ts +18 -0
  46. package/session/herdr-display.ts +1 -1
  47. package/session/presets.ts +130 -87
  48. package/session/quota.ts +123 -0
  49. package/session/stats.ts +4 -1
  50. package/statusbar/index.ts +242 -104
  51. package/statusbar/render.ts +95 -163
  52. package/theme.ts +2 -41
  53. package/tools/actions.ts +53 -0
  54. package/tools/assistant-view.ts +64 -0
  55. package/tools/click-anchor.ts +56 -0
  56. package/tools/group-view.ts +420 -0
  57. package/tools/grouping.ts +146 -221
  58. package/tools/host.ts +369 -0
  59. package/tools/index.ts +108 -105
  60. package/tools/line.ts +95 -58
  61. package/tools/machine.ts +78 -0
  62. package/tools/parts.ts +2 -2
  63. package/tools/round.ts +95 -0
  64. package/tools/turn-clock.ts +41 -0
  65. package/tools/turn-summary.ts +109 -0
  66. package/truncated-write.ts +18 -0
  67. package/watcher/card.ts +28 -22
  68. package/watcher/index.ts +16 -44
  69. package/watcher/observer.ts +3 -3
  70. package/watcher/prompts/watch.zh.md +1 -1
  71. package/watcher/transcript.ts +4 -12
  72. package/flame-frames.ts +0 -460
  73. package/review/progress.ts +0 -324
  74. package/session/bark.ts +0 -157
  75. package/session/working-flame.ts +0 -116
  76. package/statusbar/quota-cache.ts +0 -60
  77. package/statusbar/quota-parse.ts +0 -85
  78. package/statusbar/quota.ts +0 -203
  79. package/statusbar/tps.ts +0 -87
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * 预设:一键切换模型、思考等级、工具集与附加指令。
3
- * 入口有 `--preset`、`/preset [名字]`、Option+1-9、Ctrl+Shift+U 循环。
3
+ * 入口有 `--preset`、`/preset [名字]` 与各预设的 key 快捷键。
4
4
  * 预设定义见 firecode/config.jsonc 的 presets 节。
5
+ *
6
+ * 模型、思考档与工具集的事实源是宿主:宿主把它们记在会话里,并在 session_start 之前(切分支时同样)恢复。
7
+ * 这里只持有宿主不知道的两样——预设名与附加指令,且只在“当前模型仍是预设的模型”时成立:手动切走或重开会话时
8
+ * 宿主恢复的不是它,预设即失效(名字与指令一并清掉并记入会话),不另存一份模型去覆盖宿主,也不动宿主的工具集。
5
9
  */
6
10
  import type { Api, Model } from "@earendil-works/pi-ai";
7
11
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -10,10 +14,11 @@ import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi
10
14
  import { type Preset, loadConfig } from "../config.js";
11
15
 
12
16
  const CLEAR_ITEM = "(无)";
17
+ const STATE_ENTRY = "preset-state";
13
18
  const INSTRUCTIONS_PREVIEW_CHARS = 30;
14
19
  const SELECTOR_MAX_ROWS = 10;
15
20
 
16
- /** 会话可用的等级比预设可配置的多(含 max),快照要按前者存。 */
21
+ /** 会话可用的等级比预设可配置的多(含 max),快照要按前者存。重开会话后预设之前的模型已不可知,model 为空表示不动模型。 */
17
22
  type OriginalState = {
18
23
  model: Model<Api> | undefined;
19
24
  thinkingLevel: ReturnType<ExtensionAPI["getThinkingLevel"]>;
@@ -23,10 +28,20 @@ type OriginalState = {
23
28
  const title = (text: string): string =>
24
29
  text ? `${text[0].toUpperCase()}${text.slice(1)}` : text;
25
30
 
31
+ /** 模型原子在配置层已校验为 provider/model;调 Pi 接口时才拆成两段。 */
32
+ function splitModel(id: string): [provider: string, model: string] {
33
+ const slash = id.indexOf("/");
34
+ return [id.slice(0, slash), id.slice(slash + 1)];
35
+ }
36
+
37
+ /** 预设对模型的要求:当前模型就是预设的模型,或预设根本不管模型。 */
38
+ function holds(preset: Preset, model: Model<Api> | undefined): boolean {
39
+ return !preset.model || (!!model && `${model.provider}/${model.id}` === preset.model.model);
40
+ }
41
+
26
42
  function describe(preset: Preset): string {
27
43
  const parts: string[] = [];
28
- if (preset.provider && preset.model) parts.push(`${preset.provider}/${preset.model}`);
29
- if (preset.thinkingLevel) parts.push(`thinking:${preset.thinkingLevel}`);
44
+ if (preset.model) parts.push(preset.model.model, `thinking:${preset.model.thinking}`);
30
45
  if (preset.tools) parts.push(`tools:${preset.tools.join(",")}`);
31
46
  if (preset.instructions) {
32
47
  const preview =
@@ -43,79 +58,100 @@ export function registerPresets(pi: ExtensionAPI): void {
43
58
  let activeName: string | undefined;
44
59
  let activePreset: Preset | undefined;
45
60
  let originalState: OriginalState | undefined;
61
+ /** 本模块自己切模型期间的 model_select 不算用户切走。 */
62
+ let applying = false;
46
63
 
47
64
  pi.registerFlag("preset", {
48
65
  description: "要启用的预设名",
49
66
  type: "string",
50
67
  });
51
68
 
69
+ /**
70
+ * 预设名只在它带来边框看不见的改动(工具集、附加指令)时发布:模型与思考档已显示在输入框下边框,
71
+ * 只改这两样的预设再报名字就是同一信息说两遍。
72
+ */
52
73
  const updateStatus = (ctx: ExtensionContext) =>
53
74
  ctx.ui.setStatus(
54
75
  "preset",
55
- activeName ? ctx.ui.theme.fg("accent", `🧩 ${title(activeName)}`) : undefined,
76
+ activeName && (activePreset?.tools?.length || activePreset?.instructions)
77
+ ? ctx.ui.theme.fg("accent", title(activeName))
78
+ : undefined,
56
79
  );
57
80
 
58
81
  const noPresetsHint = (ctx: ExtensionContext) =>
59
82
  ctx.ui.notify("未定义任何预设。在 firecode/config.jsonc 的 presets 里添加。", "warning");
60
83
 
84
+ /** 预设状态写进当前分支(与宿主的模型记录同一棵树),null 表示没有预设;重开会话按它恢复。 */
85
+ function setActive(name: string | undefined, ctx: ExtensionContext): void {
86
+ activeName = name;
87
+ activePreset = name === undefined ? undefined : presets[name];
88
+ if (name === undefined) originalState = undefined;
89
+ pi.appendEntry(STATE_ENTRY, { name: name ?? null });
90
+ updateStatus(ctx);
91
+ }
92
+
93
+ function applyTools(name: string, preset: Preset, ctx: ExtensionContext): void {
94
+ if (!preset.tools?.length) return;
95
+ const known = new Set(pi.getAllTools().map((tool) => tool.name));
96
+ const valid = preset.tools.filter((tool) => known.has(tool));
97
+ const unknown = preset.tools.filter((tool) => !known.has(tool));
98
+ if (unknown.length)
99
+ ctx.ui.notify(`预设「${name}」含未知工具:${unknown.join("、")}`, "warning");
100
+ if (valid.length) pi.setActiveTools(valid);
101
+ }
102
+
103
+ /** 先套模型;模型套用失败就整套不套(不动工具、不设预设名),只提示原因。 */
61
104
  async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<void> {
62
105
  // 首次应用前留一份快照,用于恢复默认。
63
- if (activeName === undefined) {
64
- originalState = {
65
- model: ctx.model,
66
- thinkingLevel: pi.getThinkingLevel(),
67
- tools: pi.getActiveTools(),
68
- };
69
- }
70
-
71
- if (preset.provider && preset.model) {
72
- const model = ctx.modelRegistry.find(preset.provider, preset.model);
73
- if (!model) {
74
- ctx.ui.notify(
75
- `Preset "${name}": Model ${preset.provider}/${preset.model} not found`,
76
- "warning",
77
- );
78
- } else if (!(await pi.setModel(model))) {
79
- ctx.ui.notify(
80
- `Preset "${name}": No API key for ${preset.provider}/${preset.model}`,
81
- "warning",
82
- );
83
- }
106
+ const snapshot = activeName === undefined
107
+ ? { model: ctx.model, thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools() }
108
+ : originalState;
109
+ applying = true;
110
+ const failure = await applyModel(preset, ctx).finally(() => { applying = false; });
111
+ if (failure) {
112
+ ctx.ui.notify(`预设「${name}」未切换:${failure}`, "warning");
113
+ return;
84
114
  }
115
+ originalState = snapshot;
116
+ applyTools(name, preset, ctx);
117
+ setActive(name, ctx);
118
+ ctx.ui.notify(`已切换预设「${name}」`, "info");
119
+ }
85
120
 
86
- if (preset.thinkingLevel) pi.setThinkingLevel(preset.thinkingLevel);
87
-
88
- if (preset.tools?.length) {
89
- const known = new Set(pi.getAllTools().map((tool) => tool.name));
90
- const valid = preset.tools.filter((tool) => known.has(tool));
91
- const unknown = preset.tools.filter((tool) => !known.has(tool));
92
- if (unknown.length)
93
- ctx.ui.notify(`预设「${name}」含未知工具:${unknown.join("、")}`, "warning");
94
- if (valid.length) pi.setActiveTools(valid);
95
- }
121
+ /** 返回失败原因;成功或预设不管模型时为 undefined。 */
122
+ async function applyModel(preset: Preset, ctx: ExtensionContext): Promise<string | undefined> {
123
+ if (!preset.model) return undefined;
124
+ const [provider, id] = splitModel(preset.model.model);
125
+ const model = ctx.modelRegistry.find(provider, id);
126
+ if (!model) return `找不到模型 ${preset.model.model}`;
127
+ if (!(await pi.setModel(model))) return `模型 ${preset.model.model} 没有可用凭据`;
128
+ pi.setThinkingLevel(preset.model.thinking);
129
+ return undefined;
130
+ }
96
131
 
97
- activeName = name;
98
- activePreset = preset;
132
+ /** 当前模型已不是预设的:预设失效。 */
133
+ function dropIfDiverged(ctx: ExtensionContext): void {
134
+ if (!activeName || !activePreset || holds(activePreset, ctx.model)) return;
135
+ const name = activeName;
136
+ setActive(undefined, ctx);
137
+ ctx.ui.notify(`模型已不是预设「${name}」的,预设已失效`, "info");
99
138
  }
100
139
 
101
140
  async function activate(name: string, ctx: ExtensionContext): Promise<void> {
102
141
  const preset = presets[name];
103
142
  if (!preset) return;
104
143
  await applyPreset(name, preset, ctx);
105
- ctx.ui.notify(`已切换预设「${name}」`, "info");
106
- updateStatus(ctx);
107
144
  }
108
145
 
109
146
  async function clearPreset(ctx: ExtensionContext): Promise<void> {
110
- activeName = undefined;
111
- activePreset = undefined;
112
- if (originalState) {
113
- if (originalState.model) await pi.setModel(originalState.model);
114
- pi.setThinkingLevel(originalState.thinkingLevel);
115
- pi.setActiveTools(originalState.tools);
147
+ const original = originalState;
148
+ setActive(undefined, ctx);
149
+ if (original) {
150
+ if (original.model) await pi.setModel(original.model);
151
+ pi.setThinkingLevel(original.thinkingLevel);
152
+ pi.setActiveTools(original.tools);
116
153
  }
117
154
  ctx.ui.notify("预设已清除,恢复默认", "info");
118
- updateStatus(ctx);
119
155
  }
120
156
 
121
157
  async function showSelector(ctx: ExtensionContext): Promise<void> {
@@ -169,25 +205,7 @@ export function registerPresets(pi: ExtensionAPI): void {
169
205
  else await activate(choice, ctx);
170
206
  }
171
207
 
172
- async function cyclePreset(ctx: ExtensionContext): Promise<void> {
173
- const names = Object.keys(presets);
174
- if (names.length === 0) {
175
- noPresetsHint(ctx);
176
- return;
177
- }
178
- const cycle = [CLEAR_ITEM, ...names];
179
- const current = cycle.indexOf(activeName ?? CLEAR_ITEM);
180
- const next = cycle[current === -1 ? 0 : (current + 1) % cycle.length];
181
- if (next === CLEAR_ITEM) await clearPreset(ctx);
182
- else await activate(next, ctx);
183
- }
184
-
185
- const { keys, presets: configured } = loadConfig().config;
186
-
187
- pi.registerShortcut(keys.cyclePreset as never, {
188
- description: "轮切预设",
189
- handler: (ctx) => cyclePreset(ctx),
190
- });
208
+ const { presets: configured } = loadConfig().config;
191
209
 
192
210
  for (const [name, preset] of Object.entries(configured)) {
193
211
  if (!preset.key) continue;
@@ -205,6 +223,11 @@ export function registerPresets(pi: ExtensionAPI): void {
205
223
  await showSelector(ctx);
206
224
  return;
207
225
  }
226
+ // 与选择器同一个“无”项:清除当前预设。
227
+ if (name === CLEAR_ITEM) {
228
+ await clearPreset(ctx);
229
+ return;
230
+ }
208
231
  if (!presets[name]) {
209
232
  const available = Object.keys(presets).join(", ") || "(none defined)";
210
233
  ctx.ui.notify(`未知预设「${name}」,可用:${available}`, "error");
@@ -214,12 +237,32 @@ export function registerPresets(pi: ExtensionAPI): void {
214
237
  },
215
238
  });
216
239
 
217
- pi.on("before_agent_start", async (event) => {
240
+ pi.on("model_select", (_event, ctx) => {
241
+ if (!applying) dropIfDiverged(ctx);
242
+ });
243
+
244
+ pi.on("before_agent_start", async (event, ctx) => {
245
+ dropIfDiverged(ctx);
218
246
  if (!activePreset?.instructions) return;
219
247
  return { systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}` };
220
248
  });
221
249
 
250
+ /** 同进程内新开/切换会话或切分支复用本模块实例:上一处的预设不带进来。 */
251
+ const reset = () => {
252
+ activeName = undefined;
253
+ activePreset = undefined;
254
+ originalState = undefined;
255
+ };
256
+
257
+ // 切到另一分支:宿主按该分支恢复模型,预设也按该分支的记录重新判定。
258
+ pi.on("session_tree", (_event, ctx) => {
259
+ reset();
260
+ restore(ctx);
261
+ updateStatus(ctx);
262
+ });
263
+
222
264
  pi.on("session_start", async (_event, ctx) => {
265
+ reset();
223
266
  // 配置问题由 index.ts 统一提示,这里只取预设。
224
267
  presets = loadConfig().config.presets;
225
268
 
@@ -227,31 +270,31 @@ export function registerPresets(pi: ExtensionAPI): void {
227
270
  if (typeof flag === "string" && flag) {
228
271
  if (presets[flag]) {
229
272
  await applyPreset(flag, presets[flag], ctx);
230
- ctx.ui.notify(`已切换预设「${flag}」`, "info");
231
273
  } else {
232
274
  const available = Object.keys(presets).join(", ") || "(none defined)";
233
275
  ctx.ui.notify(`未知预设「${flag}」,可用:${available}`, "warning");
234
276
  }
235
- } else {
236
- // 恢复上次会话的预设:只认名字,不重放模型与工具切换。
237
- const restored = ctx.sessionManager
238
- .getEntries()
239
- .filter(
240
- (entry: { type: string; customType?: string }) =>
241
- entry.type === "custom" && entry.customType === "preset-state",
242
- )
243
- .pop() as { data?: { name: string } } | undefined;
244
- const name = restored?.data?.name;
245
- if (name && presets[name]) {
246
- activeName = name;
247
- activePreset = presets[name];
248
- }
249
- }
250
-
277
+ } else restore(ctx);
251
278
  updateStatus(ctx);
252
279
  });
253
280
 
254
- pi.on("turn_start", async () => {
255
- if (activeName) pi.appendEntry("preset-state", { name: activeName });
256
- });
281
+ /** 按当前分支最后一条预设记录恢复:宿主已恢复模型与工具集,模型仍是预设的才算生效。 */
282
+ function restore(ctx: ExtensionContext): void {
283
+ const record = ctx.sessionManager
284
+ .getBranch()
285
+ .filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === STATE_ENTRY)
286
+ .pop() as { data?: { name: string | null } } | undefined;
287
+ const name = record?.data?.name;
288
+ if (!name) return;
289
+ const preset = presets[name];
290
+ if (!preset || !holds(preset, ctx.model)) {
291
+ setActive(undefined, ctx);
292
+ ctx.ui.notify(preset ? `模型已不是预设「${name}」的,预设已失效` : `预设「${name}」已不在配置里,已清除`, "info");
293
+ return;
294
+ }
295
+ activeName = name;
296
+ activePreset = preset;
297
+ // 应用预设之前的状态不在会话记录里:清除时不动模型,思考档与工具集维持宿主恢复的。
298
+ originalState = { model: undefined, thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools() };
299
+ }
257
300
  }
@@ -0,0 +1,123 @@
1
+ /** /quota:按需查询 Pi OAuth 订阅额度,只投界面通知,不参与会话或模型调用。 */
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ const REQUEST_TIMEOUT_MS = 3_000;
5
+ const FABLE_WINDOW = "Fable 本周";
6
+ const HOUR_SECONDS = 3_600;
7
+ const DAY_SECONDS = 86_400;
8
+ const WEEK_SECONDS = 7 * DAY_SECONDS;
9
+ const PROVIDERS = [
10
+ { id: "openai-codex", name: "Codex", url: "https://chatgpt.com/backend-api/wham/usage", parse: codexWindows },
11
+ { id: "anthropic", name: "Claude", url: "https://api.anthropic.com/api/oauth/usage", parse: claudeWindows },
12
+ ] as const;
13
+ type QuotaProvider = (typeof PROVIDERS)[number];
14
+ type QuotaWindow = { label: string; remaining: number };
15
+ const record = (value: unknown): Record<string, unknown> =>
16
+ value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
17
+
18
+ function changedFormat(): never {
19
+ throw new Error("额度响应格式已变化");
20
+ }
21
+
22
+ function quotaWindow(label: string, used: unknown): QuotaWindow {
23
+ if (typeof used !== "number" || !Number.isFinite(used) || used < 0) return changedFormat();
24
+ return { label, remaining: Math.max(0, Math.round(100 - used)) };
25
+ }
26
+
27
+ function codexWindows(value: unknown): QuotaWindow[] {
28
+ const rate = record(record(value).rate_limit);
29
+ const windows: QuotaWindow[] = [];
30
+ for (const value of [rate.primary_window, rate.secondary_window]) {
31
+ if (value == null) continue;
32
+ const entry = record(value);
33
+ const seconds = entry.limit_window_seconds;
34
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return changedFormat();
35
+ const label = seconds === WEEK_SECONDS ? "本周"
36
+ : seconds % DAY_SECONDS === 0 ? `${seconds / DAY_SECONDS}天` : `${seconds / HOUR_SECONDS}小时`;
37
+ windows.push(quotaWindow(label, entry.used_percent));
38
+ }
39
+ return windows;
40
+ }
41
+
42
+ function claudeWindows(value: unknown): QuotaWindow[] {
43
+ const limits = record(value).limits;
44
+ if (!Array.isArray(limits)) return changedFormat();
45
+ return limits.flatMap((value): QuotaWindow[] => {
46
+ const entry = record(value);
47
+ if (entry.kind === "session") return [quotaWindow("5小时", entry.percent)];
48
+ if (entry.kind === "weekly_all") return [quotaWindow("本周", entry.percent)];
49
+ const model = record(record(entry.scope).model).display_name;
50
+ if (entry.kind === "weekly_scoped" && model === "Fable") return [quotaWindow(FABLE_WINDOW, entry.percent)];
51
+ return [];
52
+ });
53
+ }
54
+
55
+ function codexAccountId(token: string): string {
56
+ try {
57
+ const claims = record(JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")));
58
+ const id = record(claims["https://api.openai.com/auth"]).chatgpt_account_id;
59
+ if (typeof id === "string" && id) return id;
60
+ } catch {
61
+ throw new Error("Codex 登录凭据无效");
62
+ }
63
+ throw new Error("Codex 登录凭据缺少账户信息");
64
+ }
65
+
66
+ async function query(
67
+ provider: QuotaProvider,
68
+ registry: ExtensionContext["modelRegistry"],
69
+ signal: AbortSignal,
70
+ fetcher: typeof fetch,
71
+ ): Promise<string> {
72
+ try {
73
+ const model = registry.getAll().find((model) => model.provider === provider.id);
74
+ if (!model || !registry.isUsingOAuth(model)) return `${provider.name}:未通过 Pi OAuth 登录`;
75
+ const resolution = await registry.getProviderAuth(provider.id);
76
+ const token = resolution?.auth.apiKey;
77
+ if (!token) throw new Error("未取得 OAuth 登录凭据");
78
+ const headers = new Headers({ Authorization: `Bearer ${token}`, Accept: "application/json" });
79
+ if (provider.id === "anthropic") headers.set("anthropic-beta", "oauth-2025-04-20");
80
+ else headers.set("ChatGPT-Account-Id", codexAccountId(token));
81
+ const response = await fetcher(provider.url, {
82
+ headers, signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
83
+ });
84
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
85
+ const windows = provider.parse(await response.json());
86
+ if (!windows.length) return changedFormat();
87
+ const parts = windows.map(({ label, remaining }) => `${label}剩余 ${remaining}%`);
88
+ if (provider.id === "anthropic" && !windows.some(({ label }) => label === FABLE_WINDOW))
89
+ parts.push(`${FABLE_WINDOW}:未提供独立额度`);
90
+ return `${provider.name}:${parts.join(" | ")}`;
91
+ } catch (error) {
92
+ const reason = error instanceof Error ? error.message : String(error);
93
+ return `${provider.name}:查询失败(${reason})`;
94
+ }
95
+ }
96
+
97
+ export function registerQuota(pi: ExtensionAPI, fetcher: typeof fetch = fetch): void {
98
+ let inFlight: AbortController | undefined;
99
+ pi.registerCommand("quota", {
100
+ description: "查询 Codex、Claude 和 Fable 的订阅剩余额度,不打断当前任务",
101
+ handler: async (args, ctx) => {
102
+ if (args.trim()) return ctx.ui.notify("用法:/quota", "warning");
103
+ if (inFlight) return ctx.ui.notify("额度正在查询,请稍候", "info");
104
+ // 在首个 await 前占住唯一请求槽,重复命令不会叠加抓取。
105
+ const owner = new AbortController();
106
+ inFlight = owner;
107
+ const { ui, modelRegistry } = ctx;
108
+ ui.notify("正在查询订阅额度…", "info");
109
+ try {
110
+ const results = await Promise.all(PROVIDERS.map((provider) => query(provider, modelRegistry, owner.signal, fetcher)));
111
+ if (inFlight !== owner) return;
112
+ const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
113
+ ui.notify(`订阅剩余额度 · 查询于 ${time}\n${results.join("\n")}`, "info");
114
+ } finally {
115
+ if (inFlight === owner) inFlight = undefined;
116
+ }
117
+ },
118
+ });
119
+ pi.on("session_shutdown", () => {
120
+ inFlight?.abort();
121
+ inFlight = undefined;
122
+ });
123
+ }
package/session/stats.ts CHANGED
@@ -8,6 +8,8 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
8
8
  import { DynamicBorder, getAgentDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
9
9
  import { type AutocompleteItem, Container, Markdown, Text, matchesKey } from "@earendil-works/pi-tui";
10
10
 
11
+ import { registerQuota } from "./quota.js";
12
+
11
13
  const DEFAULT_DAYS = 30;
12
14
  const DAY_MS = 24 * 60 * 60 * 1000;
13
15
 
@@ -277,7 +279,7 @@ async function show(markdown: string, ctx: ExtensionCommandContext): Promise<voi
277
279
  }
278
280
 
279
281
  /** 支持 `/tokens 7`、`/tokens --days 7`、`/tokens 0`(全部)。 */
280
- export function parseDays(args: string | undefined): number {
282
+ function parseDays(args: string | undefined): number {
281
283
  const text = (args ?? "").trim();
282
284
  if (!text) return DEFAULT_DAYS;
283
285
 
@@ -293,6 +295,7 @@ const dayCompletions: AutocompleteItem[] = [
293
295
  ];
294
296
 
295
297
  export function registerStats(pi: ExtensionAPI): void {
298
+ registerQuota(pi);
296
299
  pi.registerCommand("tokens", {
297
300
  description: "统计会话 token 与成本,参数为天数:默认 30,0 表示全部",
298
301
  getArgumentCompletions: (prefix: string) => {