@buyi1net/pi-toolkit 0.0.1

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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/assembler.ts +127 -0
  4. package/config.ts +201 -0
  5. package/i18n.ts +693 -0
  6. package/index.ts +99 -0
  7. package/menu/items.ts +264 -0
  8. package/menu/panels.ts +91 -0
  9. package/menu/settings-list.ts +84 -0
  10. package/menu/theme.ts +31 -0
  11. package/menu/toolkit-menu.ts +139 -0
  12. package/module.ts +156 -0
  13. package/modules/eyes/chain.ts +173 -0
  14. package/modules/eyes/config.ts +319 -0
  15. package/modules/eyes/index.ts +161 -0
  16. package/modules/eyes/menu.ts +828 -0
  17. package/modules/eyes/pi-model-backend.ts +245 -0
  18. package/modules/eyes/resilience.ts +161 -0
  19. package/modules/eyes/vision-bridge.ts +232 -0
  20. package/modules/eyes/vision-cache.ts +86 -0
  21. package/modules/eyes/vision-json.ts +19 -0
  22. package/modules/eyes/vision-preprocess.ts +192 -0
  23. package/modules/eyes/vision-probe.ts +21 -0
  24. package/modules/eyes/vision-prompt.ts +90 -0
  25. package/modules/eyes/vision-tool.ts +59 -0
  26. package/modules/eyes/vision-types.ts +33 -0
  27. package/modules/index.ts +10 -0
  28. package/modules/subagents/agents/researcher.md +51 -0
  29. package/modules/subagents/agents/scout.md +40 -0
  30. package/modules/subagents/agents/worker.md +79 -0
  31. package/modules/subagents/config.json.example +6 -0
  32. package/modules/subagents/config.ts +144 -0
  33. package/modules/subagents/index.ts +91 -0
  34. package/modules/subagents/menu.ts +388 -0
  35. package/modules/subagents/src/activity.ts +511 -0
  36. package/modules/subagents/src/agents.ts +126 -0
  37. package/modules/subagents/src/command.ts +37 -0
  38. package/modules/subagents/src/dependencies.ts +246 -0
  39. package/modules/subagents/src/diagnostics.ts +13 -0
  40. package/modules/subagents/src/display.ts +94 -0
  41. package/modules/subagents/src/headless.ts +342 -0
  42. package/modules/subagents/src/herdr.ts +203 -0
  43. package/modules/subagents/src/index.ts +2798 -0
  44. package/modules/subagents/src/inspect-tool.ts +839 -0
  45. package/modules/subagents/src/launch-config.ts +196 -0
  46. package/modules/subagents/src/layout-budget.ts +26 -0
  47. package/modules/subagents/src/list-tool.ts +292 -0
  48. package/modules/subagents/src/message-tool.ts +808 -0
  49. package/modules/subagents/src/names.ts +17 -0
  50. package/modules/subagents/src/pane-layout.ts +49 -0
  51. package/modules/subagents/src/params.ts +145 -0
  52. package/modules/subagents/src/renderers.ts +181 -0
  53. package/modules/subagents/src/result.ts +43 -0
  54. package/modules/subagents/src/retention.ts +114 -0
  55. package/modules/subagents/src/route-error.ts +212 -0
  56. package/modules/subagents/src/routing.ts +235 -0
  57. package/modules/subagents/src/runtime-registry.ts +159 -0
  58. package/modules/subagents/src/session.ts +801 -0
  59. package/modules/subagents/src/status.ts +513 -0
  60. package/modules/subagents/src/stop-tool.ts +235 -0
  61. package/modules/subagents/src/subagent-done.ts +590 -0
  62. package/modules/subagents/src/subagent-tool.ts +1155 -0
  63. package/modules/subagents/src/surface.ts +355 -0
  64. package/modules/subagents/src/team-dispatch-tool.ts +219 -0
  65. package/modules/subagents/src/team.ts +232 -0
  66. package/modules/subagents/src/tmux.ts +210 -0
  67. package/modules/subagents/src/tools/safe-bash.ts +72 -0
  68. package/modules/subagents/src/types.ts +131 -0
  69. package/modules/tui/adapter/provider-usage.ts +143 -0
  70. package/modules/tui/config.ts +50 -0
  71. package/modules/tui/index.ts +120 -0
  72. package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
  73. package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
  74. package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
  75. package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
  76. package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
  77. package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
  78. package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
  79. package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
  80. package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
  81. package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
  82. package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
  83. package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
  84. package/modules/tui/kernel/usage-core.ts +2 -0
  85. package/modules/tui/kernel/usage-node.ts +2 -0
  86. package/modules/tui/menu.ts +418 -0
  87. package/modules/tui/plugin/editor.ts +396 -0
  88. package/modules/tui/plugin/footer.ts +161 -0
  89. package/modules/tui/plugin/index.ts +30 -0
  90. package/modules/tui/plugin/lifecycle.ts +637 -0
  91. package/modules/tui/plugin/package-order.ts +169 -0
  92. package/modules/tui/plugin/screen-transition.ts +203 -0
  93. package/modules/tui/plugin/settings-config.ts +297 -0
  94. package/modules/tui/plugin/status-sources.ts +49 -0
  95. package/modules/tui/plugin/transition-gate.ts +261 -0
  96. package/modules/tui/renderer/custom-header.ts +157 -0
  97. package/modules/tui/renderer/editor.ts +70 -0
  98. package/modules/tui/renderer/header.ts +72 -0
  99. package/modules/tui/renderer/icons.ts +149 -0
  100. package/modules/tui/renderer/pi-installer-logo.ts +194 -0
  101. package/modules/tui/status/auto-compaction.ts +67 -0
  102. package/modules/tui/status/project-status.ts +655 -0
  103. package/modules/tui/status/provider-status.ts +120 -0
  104. package/modules/tui/status/runtime-status.ts +307 -0
  105. package/modules/tui/status/session-status.ts +325 -0
  106. package/modules/tui/status/status-config.ts +95 -0
  107. package/modules/tui/status/status-segments.ts +263 -0
  108. package/modules/tui/status/turn-telemetry.ts +466 -0
  109. package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
  110. package/modules/tui/themes/UPSTREAM.md +7 -0
  111. package/modules/tui/themes/catppuccin-latte.json +80 -0
  112. package/modules/tui/themes/catppuccin-mocha.json +79 -0
  113. package/modules/tui/themes/crimson-noir.json +85 -0
  114. package/modules/tui/themes/dracula.json +79 -0
  115. package/modules/tui/themes/everforest-dark.json +85 -0
  116. package/modules/tui/themes/gruvbox-dark.json +85 -0
  117. package/modules/tui/themes/gruvbox-light.json +85 -0
  118. package/modules/tui/themes/matrix.json +85 -0
  119. package/modules/tui/themes/nord.json +85 -0
  120. package/modules/tui/themes/one-dark.json +85 -0
  121. package/modules/tui/themes/rose-pine-dawn.json +85 -0
  122. package/modules/tui/themes/rose-pine.json +85 -0
  123. package/modules/tui/themes/solarized-dark.json +85 -0
  124. package/modules/tui/themes/solarized-light.json +85 -0
  125. package/modules/tui/themes/tokyo-night-storm.json +79 -0
  126. package/modules/tui/themes/tokyo-night.json +79 -0
  127. package/package.json +28 -0
  128. package/services.ts +38 -0
  129. package/toolkit.ts +147 -0
@@ -0,0 +1,635 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ closeSync,
4
+ futimesSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ renameSync,
9
+ statSync,
10
+ unlinkSync,
11
+ writeFileSync,
12
+ } from "node:fs";
13
+ import { join } from "node:path";
14
+ import {
15
+ findProviderById,
16
+ findProviderByUrl,
17
+ type BuiltinQueryKind,
18
+ } from "../shared/provider-catalog.ts";
19
+ import {
20
+ detectProviderKind,
21
+ fetchConfiguredProviderUsage,
22
+ fetchProviderUsage,
23
+ findProviderQueryConfig,
24
+ } from "./provider-usage.ts";
25
+ import {
26
+ isLastGoodUsable,
27
+ markUsageSnapshotStale,
28
+ normalizeEndpoint,
29
+ normalizeUsageSnapshot,
30
+ planSourceRefresh,
31
+ planUsageFailure,
32
+ resolveProviderMetadata,
33
+ usageRefreshDue,
34
+ usageRetryDue,
35
+ SOURCE_RETRY_MS,
36
+ type BillingMode,
37
+ type CacheEntry,
38
+ type ProviderAccess,
39
+ type ProviderAccessOptions,
40
+ type SourceState,
41
+ type UsageSnapshot,
42
+ } from "../usage-core/index.ts";
43
+
44
+ export type UsageQueryResult =
45
+ | { status: "success"; snapshot: UsageSnapshot }
46
+ | { status: "unsupported" }
47
+ | { status: "failed" };
48
+
49
+ export type UsageRuntimeStatus =
50
+ | "idle"
51
+ | "loading"
52
+ | "ready"
53
+ | "stale"
54
+ | "error"
55
+ | "unsupported";
56
+
57
+ export interface UsageRuntimeState {
58
+ status: UsageRuntimeStatus;
59
+ provider: { id: string; brandName: string } | null;
60
+ snapshot: UsageSnapshot | null;
61
+ }
62
+
63
+ /** 两段式 begin 阶段的身份提示:凭据异步解析完成前宿主已知的供应商信息。 */
64
+ export interface ProviderIdentityHint {
65
+ providerId: string;
66
+ endpoint: string;
67
+ modelId?: string;
68
+ }
69
+
70
+ export interface CreateProviderAccessInput {
71
+ providerId: string;
72
+ modelId?: string;
73
+ endpoint: string;
74
+ credential: string;
75
+ authKind?: "api-key" | "oauth";
76
+ accountId?: string;
77
+ githubDomain?: string;
78
+ query?: ProviderAccessOptions["query"];
79
+ credentials?: ProviderAccessOptions["credentials"];
80
+ }
81
+
82
+ export interface UsageRuntimeOptions {
83
+ userAgent?: string;
84
+ keepLastGoodMs?: number;
85
+ retryBackoffMs?: number;
86
+ query?: (access: ProviderAccess) => Promise<UsageQueryResult>;
87
+ onChange?: (state: UsageRuntimeState) => void;
88
+ now?: () => number;
89
+ cache?: UsageSnapshotCache;
90
+ }
91
+
92
+ export interface UsageSnapshotCache {
93
+ read(identityKey: string): UsageSnapshot | null;
94
+ write(identityKey: string, snapshot: UsageSnapshot): void;
95
+ }
96
+
97
+ export { findProviderQueryConfig };
98
+ // 宿主适配器识别查询能力用;供应商 kind 判定随查询入口一起暴露,避免宿主深路径直连 shared。
99
+ export { detectProviderKind };
100
+ // 查询入口:kind 路由与显式中转协议两类,凭据只经 ProviderQueryCredentials 一个通道。
101
+ export { fetchProviderUsage, fetchConfiguredProviderUsage };
102
+ export type {
103
+ ConfiguredProviderUsageQuery,
104
+ ProviderKind,
105
+ ProviderUsageQuery,
106
+ } from "./provider-usage.ts";
107
+ export type { ProviderQueryCredentials } from "../shared/provider-contracts.ts";
108
+
109
+ const DEFAULT_KEEP_LAST_GOOD_MS = 10 * 60_000;
110
+ const DEFAULT_RETRY_BACKOFF_MS = 30_000;
111
+
112
+ function isUsageSnapshot(value: unknown): value is UsageSnapshot {
113
+ if (!value || typeof value !== "object") return false;
114
+ const snapshot = value as Partial<UsageSnapshot>;
115
+ const balanceValid = snapshot.balance === null || (
116
+ !!snapshot.balance &&
117
+ Number.isFinite(snapshot.balance.amount) &&
118
+ (snapshot.balance.currency === "CNY" || snapshot.balance.currency === "USD")
119
+ );
120
+ const windowsValid = Array.isArray(snapshot.windows) && snapshot.windows.every((window) => (
121
+ typeof window?.label === "string" &&
122
+ Number.isFinite(window?.remainingPercent) &&
123
+ (window?.resetMs === null || Number.isFinite(window?.resetMs))
124
+ ));
125
+ return (
126
+ typeof snapshot.provider?.id === "string" &&
127
+ typeof snapshot.provider?.brandName === "string" &&
128
+ ["subscription", "api", "hybrid", "unknown"].includes(snapshot.billingMode ?? "") &&
129
+ balanceValid &&
130
+ windowsValid &&
131
+ typeof snapshot.fetchedAt === "number" &&
132
+ Number.isFinite(snapshot.fetchedAt) &&
133
+ (snapshot.freshness === "fresh" || snapshot.freshness === "stale")
134
+ );
135
+ }
136
+
137
+ /** 原子写缓存文件:失败只影响缓存新鲜度,不影响查询与显示。 */
138
+ function writeCacheAtomically(root: string, target: string, payload: string): void {
139
+ const temporary = `${target}.${process.pid}-${Date.now()}.tmp`;
140
+ try {
141
+ mkdirSync(root, { recursive: true });
142
+ writeFileSync(temporary, payload, { encoding: "utf8", mode: 0o600 });
143
+ renameSync(temporary, target);
144
+ } catch {
145
+ try {
146
+ unlinkSync(temporary);
147
+ } catch {
148
+ // 临时文件清理失败可接受:下次写入会换新的临时文件名。
149
+ }
150
+ }
151
+ }
152
+
153
+ export class FileUsageSnapshotCache implements UsageSnapshotCache {
154
+ private readonly root: string;
155
+
156
+ constructor(root: string) {
157
+ this.root = root;
158
+ }
159
+
160
+ private path(identityKey: string): string {
161
+ const fingerprint = createHash("sha256").update(identityKey).digest("hex");
162
+ return join(this.root, `${fingerprint}.json`);
163
+ }
164
+
165
+ read(identityKey: string): UsageSnapshot | null {
166
+ try {
167
+ const value: unknown = JSON.parse(readFileSync(this.path(identityKey), "utf8"));
168
+ return isUsageSnapshot(value) ? value : null;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ write(identityKey: string, snapshot: UsageSnapshot): void {
175
+ writeCacheAtomically(this.root, this.path(identityKey), JSON.stringify(snapshot));
176
+ }
177
+ }
178
+
179
+ export function createProviderAccess(input: CreateProviderAccessInput): ProviderAccess {
180
+ const endpoint = normalizeEndpoint(input.endpoint);
181
+ const metadata = resolveProviderMetadata(endpoint, input.providerId, input.modelId);
182
+ const stableOAuthAccount = input.authKind === "oauth" && input.accountId
183
+ ? input.accountId
184
+ : null;
185
+ const accountFingerprint = createHash("sha256")
186
+ .update(JSON.stringify({
187
+ credential: stableOAuthAccount ? null : input.credential,
188
+ modelId: input.modelId ?? null,
189
+ query: input.query ?? null,
190
+ credentials: input.credentials ?? null,
191
+ accountId: stableOAuthAccount,
192
+ githubDomain: input.githubDomain ?? null,
193
+ }))
194
+ .digest("hex")
195
+ .slice(0, 16);
196
+ return {
197
+ identity: {
198
+ providerId: metadata.providerId,
199
+ endpoint,
200
+ accountFingerprint,
201
+ },
202
+ credential: input.credential,
203
+ options: input.authKind || input.query || input.credentials || input.accountId || input.githubDomain || input.modelId
204
+ ? {
205
+ modelId: input.modelId,
206
+ authKind: input.authKind,
207
+ accountId: input.accountId,
208
+ githubDomain: input.githubDomain,
209
+ query: input.query,
210
+ credentials: input.credentials,
211
+ }
212
+ : undefined,
213
+ };
214
+ }
215
+
216
+ export function providerAccessKey(access: ProviderAccess): string {
217
+ const identity = access.identity;
218
+ return `${identity.providerId}:${identity.endpoint}:${identity.accountFingerprint}`;
219
+ }
220
+
221
+ /** 粗粒度供应商身份键:供应商 + 接入地址。begin 阶段账号指纹未知,只按供应商判隔离。 */
222
+ function providerIdentityKey(identity: ProviderIdentityHint): string {
223
+ const endpoint = normalizeEndpoint(identity.endpoint);
224
+ const metadata = resolveProviderMetadata(endpoint, identity.providerId, identity.modelId ?? "");
225
+ return `${metadata.providerId}\0${endpoint}`;
226
+ }
227
+
228
+ function resolveQueryKind(access: ProviderAccess): BuiltinQueryKind | "unknown" {
229
+ const endpointKind =
230
+ findProviderByUrl(access.identity.endpoint)?.queryKind ??
231
+ detectProviderKind(access.identity.endpoint);
232
+ if (endpointKind !== "unknown") return endpointKind;
233
+
234
+ // A provider/model label is not proof that an arbitrary relay endpoint belongs to
235
+ // that provider. Never use it to redirect a relay credential to an official host.
236
+ // Provider-only routing is reserved for endpoint-less OAuth identities supplied by
237
+ // the host, where there is no inference API key to reuse across origins.
238
+ if (!access.identity.endpoint && access.options?.authKind === "oauth") {
239
+ return findProviderById(access.identity.providerId)?.queryKind ?? "unknown";
240
+ }
241
+ return "unknown";
242
+ }
243
+
244
+ export async function queryProviderUsage(
245
+ access: ProviderAccess,
246
+ request: typeof fetch = fetch,
247
+ options: { userAgent?: string } = {},
248
+ ): Promise<UsageQueryResult> {
249
+ const kind = resolveQueryKind(access);
250
+ const explicitQuery = access.options?.query ?? null;
251
+ if (!explicitQuery && kind === "unknown") return { status: "unsupported" };
252
+ if (!explicitQuery && kind.endsWith("-subscription") && access.options?.authKind !== "oauth") {
253
+ return { status: "unsupported" };
254
+ }
255
+ if (!explicitQuery && !access.credential && !kind.startsWith("volcengine-")) return { status: "unsupported" };
256
+
257
+ const usage = await fetchProviderUsage({
258
+ kind,
259
+ baseUrl: access.identity.endpoint,
260
+ credentials: {
261
+ token: access.credential,
262
+ accountId: access.options?.accountId,
263
+ githubDomain: access.options?.githubDomain,
264
+ volcengine: access.options?.credentials?.volcengine,
265
+ zhipuTeam: access.options?.credentials?.zhipuTeam,
266
+ openrouter: access.options?.credentials?.openrouter,
267
+ },
268
+ request,
269
+ userAgent: options.userAgent,
270
+ query: explicitQuery ?? null,
271
+ }).catch(() => null);
272
+ if (!usage) return { status: "failed" };
273
+
274
+ const providerId = usage.quota?.provider ?? explicitQuery?.id ?? access.identity.providerId;
275
+ const metadata = resolveProviderMetadata(
276
+ access.identity.endpoint,
277
+ providerId,
278
+ access.options?.modelId,
279
+ );
280
+ return {
281
+ status: "success",
282
+ snapshot: normalizeUsageSnapshot({
283
+ providerId: metadata.providerId,
284
+ brandName: explicitQuery?.displayName ?? metadata.brandName,
285
+ billingMode: usage.mode,
286
+ balance: usage.balance,
287
+ windows: usage.quota?.windows,
288
+ }),
289
+ };
290
+ }
291
+
292
+ export class UsageRuntime {
293
+ private readonly keepLastGoodMs: number;
294
+ private readonly retryBackoffMs: number;
295
+ private readonly query: (access: ProviderAccess) => Promise<UsageQueryResult>;
296
+ private readonly onChange: (state: UsageRuntimeState) => void;
297
+ private readonly now: () => number;
298
+ private readonly cache: UsageSnapshotCache | undefined;
299
+ private readonly inFlight = new Map<string, Promise<UsageQueryResult>>();
300
+ private readonly retryAfter = new Map<string, number>();
301
+ private state: UsageRuntimeState = { status: "idle", provider: null, snapshot: null };
302
+ private activeIdentityKey = "";
303
+ private activeKey = "";
304
+ private revision = 0;
305
+ private disposed = false;
306
+
307
+ constructor(options: UsageRuntimeOptions = {}) {
308
+ this.keepLastGoodMs = options.keepLastGoodMs ?? DEFAULT_KEEP_LAST_GOOD_MS;
309
+ this.retryBackoffMs = options.retryBackoffMs ?? DEFAULT_RETRY_BACKOFF_MS;
310
+ this.query = options.query ?? ((access) => queryProviderUsage(access, fetch, { userAgent: options.userAgent }));
311
+ this.onChange = options.onChange ?? (() => {});
312
+ this.now = options.now ?? Date.now;
313
+ this.cache = options.cache;
314
+ }
315
+
316
+ getState(): UsageRuntimeState {
317
+ return this.state;
318
+ }
319
+
320
+ private update(state: UsageRuntimeState): void {
321
+ if (this.disposed) return;
322
+ this.state = state;
323
+ this.onChange(state);
324
+ }
325
+
326
+ /** 两段式第一段:只交身份。身份变化时立即隔离旧供应商数据,凭据就绪前旧快照不可见。 */
327
+ begin(identity: ProviderIdentityHint | null): void {
328
+ if (this.disposed) return;
329
+ const identityKey = identity ? providerIdentityKey(identity) : "";
330
+ if (identityKey === this.activeIdentityKey) return;
331
+ this.isolate(identityKey);
332
+ }
333
+
334
+ /** 两段式第二段:交完整凭据发起真实查询;无凭据时保持隔离,不回放旧供应商数据。 */
335
+ async commit(access: ProviderAccess | null): Promise<void> {
336
+ if (this.disposed) return;
337
+ const revision = ++this.revision;
338
+ if (!access) {
339
+ this.isolate("");
340
+ return;
341
+ }
342
+
343
+ const key = providerAccessKey(access);
344
+ const metadata = resolveProviderMetadata(
345
+ access.identity.endpoint,
346
+ access.identity.providerId,
347
+ access.options?.modelId,
348
+ );
349
+ const provider = { id: metadata.providerId, brandName: metadata.brandName };
350
+ if (key !== this.activeKey) {
351
+ this.activeKey = key;
352
+ this.activeIdentityKey = providerIdentityKey({
353
+ providerId: access.identity.providerId,
354
+ endpoint: access.identity.endpoint,
355
+ modelId: access.options?.modelId,
356
+ });
357
+ const cached = this.cache?.read(key);
358
+ if (cached && isLastGoodUsable(cached.fetchedAt, this.keepLastGoodMs, this.now())) {
359
+ const stale = markUsageSnapshotStale(cached);
360
+ this.update({ status: "stale", provider: stale.provider, snapshot: stale });
361
+ } else {
362
+ this.update({ status: "loading", provider, snapshot: null });
363
+ }
364
+ }
365
+ if (!usageRetryDue(this.retryAfter.get(key) ?? 0, this.now())) return;
366
+
367
+ let pending = this.inFlight.get(key);
368
+ if (!pending) {
369
+ pending = this.query(access).finally(() => this.inFlight.delete(key));
370
+ this.inFlight.set(key, pending);
371
+ }
372
+ const result = await pending;
373
+ if (this.disposed || revision !== this.revision || key !== this.activeKey) return;
374
+
375
+ if (result.status === "success") {
376
+ this.retryAfter.delete(key);
377
+ this.cache?.write(key, result.snapshot);
378
+ this.update({ status: "ready", provider: result.snapshot.provider, snapshot: result.snapshot });
379
+ return;
380
+ }
381
+ if (result.status === "unsupported") {
382
+ this.retryAfter.delete(key);
383
+ this.update({ status: "unsupported", provider, snapshot: null });
384
+ return;
385
+ }
386
+
387
+ const previous = this.state.snapshot;
388
+ const failure = planUsageFailure(
389
+ previous?.fetchedAt ?? null,
390
+ this.keepLastGoodMs,
391
+ this.retryBackoffMs,
392
+ this.now(),
393
+ );
394
+ this.retryAfter.set(key, failure.retryAfter);
395
+ if (failure.status === "stale" && previous) {
396
+ const stale = markUsageSnapshotStale(previous);
397
+ this.update({ status: "stale", provider: stale.provider, snapshot: stale });
398
+ return;
399
+ }
400
+ this.update({ status: "error", provider, snapshot: null });
401
+ }
402
+
403
+ /** 隔离:作废在途结果并清空显示,旧供应商快照不再对新的身份可见。 */
404
+ private isolate(identityKey: string): void {
405
+ this.revision += 1;
406
+ this.activeIdentityKey = identityKey;
407
+ this.activeKey = "";
408
+ this.update({ status: "idle", provider: null, snapshot: null });
409
+ }
410
+
411
+ dispose(): void {
412
+ if (this.disposed) return;
413
+ this.disposed = true;
414
+ this.revision += 1;
415
+ this.inFlight.clear();
416
+ this.retryAfter.clear();
417
+ }
418
+ }
419
+
420
+ /** 通用 {ts, value} 原子 JSON 文件缓存;根目录由宿主注入,读写失败静默降级。 */
421
+ export class JsonFileCache {
422
+ private readonly root: string;
423
+
424
+ constructor(root: string) {
425
+ this.root = root;
426
+ }
427
+
428
+ read<T>(name: string): CacheEntry<T> | null {
429
+ try {
430
+ return JSON.parse(readFileSync(join(this.root, name), "utf8"));
431
+ } catch {
432
+ return null;
433
+ }
434
+ }
435
+
436
+ write<T>(name: string, value: T, now = Date.now()): void {
437
+ writeCacheAtomically(this.root, join(this.root, name), JSON.stringify({ ts: now, value }));
438
+ }
439
+ }
440
+
441
+ const SIDELINE_STATE_FILE = "source.json";
442
+
443
+ export interface SidelineUsageOptions {
444
+ userAgent?: string;
445
+ /** 缓存、状态与锁文件的根目录;由宿主注入,共享层不写死任何宿主路径。 */
446
+ cacheRoot: string;
447
+ /** 旁路刷新进程的触发方式;由宿主注入,渲染进程据状态机决策调用。 */
448
+ spawnRefresh?: () => void;
449
+ /** 快照缓存;默认在 cacheRoot 下按身份键分文件。 */
450
+ snapshotCache?: UsageSnapshotCache;
451
+ /** 失败退避时长,默认与刷新状态机一致。 */
452
+ retryMs?: number;
453
+ /** 孤儿锁判定阈值:超过后视为上次刷新进程已死亡,可覆盖。 */
454
+ orphanLockMs?: number;
455
+ /** 查询实现;默认走共享查询入口,测试可注入。 */
456
+ query?: (access: ProviderAccess) => Promise<UsageQueryResult>;
457
+ now?: () => number;
458
+ request?: typeof fetch;
459
+ }
460
+
461
+ export interface SidelineObserveOptions {
462
+ quotaTtlMs: number;
463
+ balanceTtlMs: number;
464
+ forceRefresh?: boolean;
465
+ }
466
+
467
+ export interface SidelineObserveResult {
468
+ /** 当前快照是否属于当前供应商且状态机允许用于布局。 */
469
+ usable: boolean;
470
+ snapshot: UsageSnapshot | null;
471
+ }
472
+
473
+ /**
474
+ * 旁路刷新协调:面向「短生命周期渲染进程 + detached 刷新进程」的宿主(如状态栏)。
475
+ * 渲染进程调 observe 只读缓存并推进状态机,必要时触发注入的刷新回调;
476
+ * 刷新进程调 runRefresh 抢锁、身份复核后查询并原子提交。
477
+ * 两个进程不共享内存,全部状态经由 cacheRoot 下的文件传递。
478
+ */
479
+ export class SidelineUsageStore {
480
+ private readonly root: string;
481
+ private readonly spawnRefresh: (() => void) | undefined;
482
+ private readonly snapshotCache: UsageSnapshotCache;
483
+ private readonly files: JsonFileCache;
484
+ private readonly retryMs: number;
485
+ private readonly orphanLockMs: number;
486
+ private readonly query: (access: ProviderAccess) => Promise<UsageQueryResult>;
487
+ private readonly now: () => number;
488
+ private readonly request: typeof fetch;
489
+
490
+ constructor(options: SidelineUsageOptions) {
491
+ this.root = options.cacheRoot;
492
+ this.spawnRefresh = options.spawnRefresh;
493
+ this.snapshotCache = options.snapshotCache ?? new FileUsageSnapshotCache(options.cacheRoot);
494
+ this.files = new JsonFileCache(options.cacheRoot);
495
+ this.retryMs = options.retryMs ?? SOURCE_RETRY_MS;
496
+ this.orphanLockMs = options.orphanLockMs ?? 60_000;
497
+ this.query = options.query ?? ((access) => queryProviderUsage(access, this.request, { userAgent: options.userAgent }));
498
+ this.now = options.now ?? Date.now;
499
+ this.request = options.request ?? fetch;
500
+ }
501
+
502
+ /** 渲染进程入口:读状态与快照、判定时效、必要时落盘新状态并触发旁路刷新。 */
503
+ observe(access: ProviderAccess, options: SidelineObserveOptions): SidelineObserveResult {
504
+ const now = this.now();
505
+ const key = providerAccessKey(access);
506
+ const state = this.files.read<SourceState>(SIDELINE_STATE_FILE)?.value ?? null;
507
+ const snapshot = this.snapshotCache.read(key);
508
+ const mode: BillingMode | null = snapshot?.billingMode ?? null;
509
+ // 快照单一时间戳同时服务余额与额度时效判定,口径与分文件缓存时一致(同次提交同时落盘)
510
+ const age = snapshot ? now - snapshot.fetchedAt : Number.POSITIVE_INFINITY;
511
+ const forceRefresh = options.forceRefresh === true;
512
+ const dataStale = forceRefresh || usageRefreshDue(
513
+ mode,
514
+ age > options.quotaTtlMs,
515
+ age > options.balanceTtlMs,
516
+ );
517
+ const plan = planSourceRefresh(
518
+ { providerKey: key },
519
+ state,
520
+ dataStale,
521
+ now,
522
+ this.retryMs,
523
+ forceRefresh,
524
+ );
525
+ if (plan.nextState) this.files.write(SIDELINE_STATE_FILE, plan.nextState, now);
526
+ if (plan.refresh) this.spawnRefresh?.();
527
+ return { usable: plan.usable, snapshot: plan.usable ? snapshot : null };
528
+ }
529
+
530
+ /** 刷新进程入口:抢锁、复核身份、查询并提交;身份已被切换时弃用结果。 */
531
+ async runRefresh(resolveAccess: () => ProviderAccess | null): Promise<void> {
532
+ const target = this.files.read<SourceState>(SIDELINE_STATE_FILE)?.value ?? null;
533
+ if (!target) return;
534
+ const lock = this.lockPath(target.providerKey);
535
+ const owner = randomUUID();
536
+ if (!this.acquireLock(lock, owner)) return;
537
+ try {
538
+ const access = resolveAccess();
539
+ if (!access || providerAccessKey(access) !== target.providerKey) return;
540
+ const result = await this.query(access);
541
+ // 身份复核:查询期间宿主配置可能被切换工具改写,旧供应商的结果不得提交
542
+ const latest = resolveAccess();
543
+ if (!latest || providerAccessKey(latest) !== target.providerKey) return;
544
+ this.commit(target, access, result);
545
+ } finally {
546
+ try {
547
+ // 查询超龄后可能已被接管,旧运行方不能释放新运行方的锁。
548
+ if (readFileSync(lock, "utf8") === owner) unlinkSync(lock);
549
+ } catch {
550
+ // 锁已不在或不可读时,不冒险删除其它运行方的锁。
551
+ }
552
+ }
553
+ }
554
+
555
+ private lockPath(providerKey: string): string {
556
+ const fingerprint = createHash("sha256").update(providerKey).digest("hex").slice(0, 16);
557
+ return join(this.root, `.refresh-${fingerprint}.lock`);
558
+ }
559
+
560
+ private createLock(lock: string, owner: string): void {
561
+ const fd = openSync(lock, "wx");
562
+ try {
563
+ writeFileSync(fd, owner, "utf8");
564
+ // 文件系统时钟与注入时钟可能不同,锁龄必须使用同一时间基准。
565
+ const now = this.now() / 1_000;
566
+ futimesSync(fd, now, now);
567
+ } finally {
568
+ closeSync(fd);
569
+ }
570
+ }
571
+
572
+ private acquireLock(lock: string, owner: string): boolean {
573
+ try {
574
+ mkdirSync(this.root, { recursive: true });
575
+ this.createLock(lock, owner);
576
+ return true;
577
+ } catch {
578
+ try {
579
+ // 孤儿锁:超过阈值视为上次刷新进程死亡,覆盖
580
+ if (this.now() - statSync(lock).mtimeMs > this.orphanLockMs) {
581
+ unlinkSync(lock);
582
+ this.createLock(lock, owner);
583
+ return true;
584
+ }
585
+ } catch {
586
+ // 覆盖失败
587
+ }
588
+ return false;
589
+ }
590
+ }
591
+
592
+ /** 先写快照、最后把状态置 ready,避免渲染进程读到半套新状态。 */
593
+ private commit(target: SourceState, access: ProviderAccess, result: UsageQueryResult): void {
594
+ const current = this.files.read<SourceState>(SIDELINE_STATE_FILE)?.value ?? null;
595
+ if (!current || current.providerKey !== target.providerKey) return;
596
+ if (result.status === "failed") {
597
+ this.markFailure(target);
598
+ return;
599
+ }
600
+ if (result.status === "success") {
601
+ this.snapshotCache.write(target.providerKey, result.snapshot);
602
+ } else {
603
+ // 不可查询也提交一个 unknown 快照,渲染进程据此停止反复触发刷新
604
+ const metadata = resolveProviderMetadata(
605
+ access.identity.endpoint,
606
+ access.identity.providerId,
607
+ access.options?.modelId,
608
+ );
609
+ this.snapshotCache.write(target.providerKey, {
610
+ provider: { id: metadata.providerId, brandName: metadata.brandName },
611
+ billingMode: "unknown",
612
+ balance: null,
613
+ windows: [],
614
+ fetchedAt: this.now(),
615
+ freshness: "fresh",
616
+ });
617
+ }
618
+ this.files.write(SIDELINE_STATE_FILE, { ...current, status: "ready", attemptTs: this.now() }, this.now());
619
+ }
620
+
621
+ /** 新数据源实际请求失败后才进入退避;周期刷新失败则继续显示同源旧值。 */
622
+ private markFailure(target: SourceState): void {
623
+ const current = this.files.read<SourceState>(SIDELINE_STATE_FILE)?.value ?? null;
624
+ if (!current || current.providerKey !== target.providerKey) return;
625
+ const now = this.now();
626
+ // ready 代表已经提交过同源结果;即使快照文件缺失,也不把周期刷新失败当作首次失败。
627
+ const lastGoodAt = current.status === "ready"
628
+ ? this.snapshotCache.read(target.providerKey)?.fetchedAt ?? now
629
+ : null;
630
+ const failure = planUsageFailure(lastGoodAt, Number.POSITIVE_INFINITY, this.retryMs, now);
631
+ if (failure.status === "error") {
632
+ this.files.write(SIDELINE_STATE_FILE, { ...current, status: "backoff", attemptTs: now }, now);
633
+ }
634
+ }
635
+ }