@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,120 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeQuotaWindowLabel } from "../kernel/usage-core.ts";
3
+ import type { UsageRuntimeState } from "../kernel/usage-node.ts";
4
+ import type { StatusLineSegment } from "./session-status.ts";
5
+ import { sanitizeSingleLine } from "./project-status.ts";
6
+
7
+ const PROVIDER_CORAL = {
8
+ dark: {
9
+ truecolor: "38;2;217;119;87",
10
+ "256color": "38;5;173",
11
+ },
12
+ light: {
13
+ truecolor: "38;2;168;78;51",
14
+ "256color": "38;5;130",
15
+ },
16
+ } as const;
17
+
18
+ function isLightThemeName(name: string | undefined): boolean {
19
+ return /(?:^|[-_])(light|latte|dawn)(?:$|[-_])/.test(name?.toLowerCase() ?? "");
20
+ }
21
+
22
+ function colorProviderBrand(theme: Theme, text: string): string {
23
+ const palette = isLightThemeName(theme.name) ? PROVIDER_CORAL.light : PROVIDER_CORAL.dark;
24
+ const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
25
+ return `\u001b[${palette[mode]}m${text}\u001b[39m`;
26
+ }
27
+
28
+ function formatMoney(amount: number, currency: "CNY" | "USD"): string {
29
+ const value = amount.toLocaleString("en-US", {
30
+ minimumFractionDigits: 2,
31
+ maximumFractionDigits: 2,
32
+ });
33
+ return currency === "CNY" ? `¥${value}` : `$${value}`;
34
+ }
35
+
36
+ function remainingColor(percent: number): Parameters<Theme["fg"]>[0] {
37
+ if (percent <= 10) return "error";
38
+ if (percent <= 30) return "warning";
39
+ if (percent < 60) return "accent";
40
+ return "success";
41
+ }
42
+
43
+ export function formatResetCountdown(resetMs: number | null, now = Date.now()): string {
44
+ if (resetMs === null) return "";
45
+ const remainingMinutes = Math.max(0, Math.ceil((resetMs - now) / 60_000));
46
+ if (remainingMinutes < 60) return `${remainingMinutes}m`;
47
+ const hours = Math.floor(remainingMinutes / 60);
48
+ const minutes = remainingMinutes % 60;
49
+ if (hours < 24) return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
50
+ const days = Math.floor(hours / 24);
51
+ const remainingHours = hours % 24;
52
+ return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
53
+ }
54
+
55
+ export interface EditorProviderSegments {
56
+ provider: StatusLineSegment;
57
+ balance: StatusLineSegment | null;
58
+ subscription: StatusLineSegment | null;
59
+ }
60
+
61
+ function providerBrand(brandName: string): string {
62
+ return sanitizeSingleLine(brandName);
63
+ }
64
+
65
+ /** 订阅额度窗口全部进入顶边 subscription 段;紧凑形态去掉重置倒计时。 */
66
+ function buildSubscriptionSegment(
67
+ state: UsageRuntimeState,
68
+ theme: Theme,
69
+ now = Date.now(),
70
+ ): StatusLineSegment | null {
71
+ const snapshot = state.snapshot;
72
+ if (
73
+ !snapshot ||
74
+ (snapshot.billingMode !== "subscription" && snapshot.billingMode !== "hybrid") ||
75
+ snapshot.windows.length === 0
76
+ ) {
77
+ return null;
78
+ }
79
+ const renderWindow = (withReset: boolean): string => snapshot.windows!
80
+ .map((window) => {
81
+ const label = sanitizeQuotaWindowLabel(window.label);
82
+ const percent = theme.bold(`${Math.round(window.remainingPercent)}%`);
83
+ const base = theme.fg(remainingColor(window.remainingPercent), `${label} ${percent}`);
84
+ if (!withReset) return base;
85
+ const reset = formatResetCountdown(window.resetMs, now);
86
+ return reset ? `${base} ${theme.fg("muted", reset)}` : base;
87
+ })
88
+ .join(theme.fg("muted", " · "));
89
+ return {
90
+ id: "subscription",
91
+ text: renderWindow(true),
92
+ compactText: renderWindow(false),
93
+ priority: 3,
94
+ };
95
+ }
96
+
97
+ export function buildEditorProviderSegments(
98
+ state: UsageRuntimeState,
99
+ theme: Theme,
100
+ now = Date.now(),
101
+ ): EditorProviderSegments | null {
102
+ if (!state.provider) return null;
103
+ const snapshot = state.status === "ready" ? state.snapshot : null;
104
+ const brand = providerBrand(state.provider.brandName);
105
+ return {
106
+ provider: {
107
+ id: "provider",
108
+ text: brand ? colorProviderBrand(theme, brand) : "",
109
+ priority: 4,
110
+ },
111
+ balance: snapshot?.balance && (snapshot.billingMode === "api" || snapshot.billingMode === "hybrid")
112
+ ? {
113
+ id: "balance",
114
+ text: theme.fg("warning", formatMoney(snapshot.balance.amount, snapshot.balance.currency)),
115
+ priority: 3,
116
+ }
117
+ : null,
118
+ subscription: snapshot ? buildSubscriptionSegment(state, theme, now) : null,
119
+ };
120
+ }
@@ -0,0 +1,307 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const CACHE_LIMIT = 32;
5
+ // 暂时不可用的版本命令需要恢复机会,也不能在密集刷新时反复启动进程。
6
+ const VERSION_RETRY_MS = 30_000;
7
+ const RUNTIME_ENV_KEYS = [
8
+ "PATH",
9
+ "Path",
10
+ "PATHEXT",
11
+ "NVM_BIN",
12
+ "NVM_SYMLINK",
13
+ "VOLTA_HOME",
14
+ "ASDF_DIR",
15
+ "MISE_ENV_FILE",
16
+ "PYENV_VERSION",
17
+ "VIRTUAL_ENV",
18
+ ] as const;
19
+
20
+ export interface RuntimeStatusSnapshot {
21
+ name: string;
22
+ version?: string;
23
+ }
24
+
25
+ export interface RuntimeVersionResult {
26
+ stdout: string;
27
+ stderr: string;
28
+ code: number | null;
29
+ killed: boolean;
30
+ }
31
+
32
+ export type RuntimeVersionQuery = (
33
+ command: string,
34
+ args: readonly string[],
35
+ cwd: string,
36
+ signal: AbortSignal,
37
+ ) => Promise<RuntimeVersionResult>;
38
+
39
+ export type RuntimeStatusQuery = (
40
+ cwd: string,
41
+ signal: AbortSignal,
42
+ ) => Promise<RuntimeStatusSnapshot | null | undefined>;
43
+
44
+ interface RuntimeDefinition {
45
+ id: string;
46
+ name: string;
47
+ files?: readonly string[];
48
+ folders?: readonly string[];
49
+ extensions?: readonly string[];
50
+ env?: string;
51
+ version?: {
52
+ command: string;
53
+ args: readonly string[];
54
+ pattern: RegExp;
55
+ };
56
+ }
57
+
58
+ const RUNTIMES: readonly RuntimeDefinition[] = [
59
+ { id: "nodejs", name: "Node.js", files: ["package.json", ".nvmrc", ".node-version"], version: { command: "node", args: ["--version"], pattern: /v(\d+\.\d+\.\d+)/ } },
60
+ { id: "rust", name: "Rust", files: ["Cargo.toml"], version: { command: "rustc", args: ["--version"], pattern: /rustc\s+(\d+\.\d+\.\d+)/ } },
61
+ { id: "go", name: "Go", files: ["go.mod"], version: { command: "go", args: ["version"], pattern: /go(\d+\.\d+(?:\.\d+)?)/ } },
62
+ { id: "python", name: "Python", files: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile", ".python-version"], version: { command: process.platform === "win32" ? "python" : "python3", args: ["--version"], pattern: /Python\s+(\d+\.\d+\.\d+)/ } },
63
+ { id: "ruby", name: "Ruby", files: ["Gemfile", ".ruby-version"], version: { command: "ruby", args: ["--version"], pattern: /ruby\s+(\d+\.\d+\.\d+)/ } },
64
+ { id: "java", name: "Java", files: ["pom.xml", "build.gradle", "build.gradle.kts", ".java-version"], version: { command: "java", args: ["-version"], pattern: /version\s+"(\d+\.\d+[.\d]*)"/ } },
65
+ { id: "swift", name: "Swift", files: ["Package.swift"], version: { command: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+(?:\.\d+)?)/ } },
66
+ { id: "kotlin", name: "Kotlin", files: ["settings.gradle.kts"] },
67
+ { id: "deno", name: "Deno", files: ["deno.json", "deno.jsonc", "deno.lock"], version: { command: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
68
+ { id: "bun", name: "Bun", files: ["bun.lock", "bun.lockb"], version: { command: "bun", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
69
+ { id: "php", name: "PHP", files: ["composer.json"], version: { command: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
70
+ { id: "haskell", name: "Haskell", files: ["stack.yaml", "cabal.project"], extensions: [".cabal"], version: { command: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
71
+ { id: "julia", name: "Julia", files: ["Project.toml", "Manifest.toml"], version: { command: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/i } },
72
+ { id: "lua", name: "Lua", files: ["stylua.toml", ".luarc.json"], version: { command: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+(?:\.\d+)?)/ } },
73
+ { id: "elixir", name: "Elixir", files: ["mix.exs"], version: { command: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
74
+ { id: "erlang", name: "Erlang", files: ["rebar.config", "erlang.mk"] },
75
+ { id: "gleam", name: "Gleam", files: ["gleam.toml"], version: { command: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/i } },
76
+ { id: "crystal", name: "Crystal", files: ["shard.yml"], version: { command: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
77
+ { id: "dart", name: "Dart", files: ["pubspec.yaml"], version: { command: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
78
+ { id: "nim", name: "Nim", files: ["nim.cfg"], extensions: [".nimble"] },
79
+ { id: "zig", name: "Zig", files: ["build.zig"], version: { command: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
80
+ { id: "ocaml", name: "OCaml", files: ["dune", "dune-project"], extensions: [".opam"] },
81
+ { id: "clojure", name: "Clojure", files: ["project.clj", "deps.edn"] },
82
+ { id: "scala", name: "Scala", files: ["build.sbt"], folders: [".metals"] },
83
+ { id: "perl", name: "Perl", files: ["Makefile.PL", "cpanfile"] },
84
+ { id: "r", name: "R", files: ["DESCRIPTION"], extensions: [".Rproj"] },
85
+ { id: "elm", name: "Elm", files: ["elm.json"] },
86
+ { id: "haxe", name: "Haxe", files: ["haxelib.json", ".haxerc"] },
87
+ { id: "vagrant", name: "Vagrant", files: ["Vagrantfile"] },
88
+ { id: "terraform", name: "Terraform", files: ["main.tf", "variables.tf"], folders: [".terraform"] },
89
+ { id: "helm", name: "Helm", files: ["Chart.yaml", "helmfile.yaml"] },
90
+ { id: "solidity", name: "Solidity", extensions: [".sol"] },
91
+ { id: "fortran", name: "Fortran", files: ["fpm.toml"], extensions: [".f", ".f90", ".f95"] },
92
+ { id: "mojo", name: "Mojo", extensions: [".mojo"] },
93
+ { id: "red", name: "Red", extensions: [".red", ".reds"] },
94
+ { id: "raku", name: "Raku", files: ["META6.json"], extensions: [".raku", ".rakumod"] },
95
+ { id: "purescript", name: "PureScript", files: ["spago.dhall", "spago.yaml"] },
96
+ { id: "fennel", name: "Fennel", extensions: [".fnl"] },
97
+ { id: "odin", name: "Odin", extensions: [".odin"] },
98
+ { id: "v", name: "V", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
99
+ { id: "xmake", name: "xmake", files: ["xmake.lua"] },
100
+ { id: "gradle", name: "Gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
101
+ { id: "maven", name: "Maven", files: ["pom.xml"] },
102
+ { id: "cmake", name: "CMake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
103
+ { id: "meson", name: "Meson", files: ["meson.build"], env: "MESON_DEVENV" },
104
+ { id: "nix", name: "Nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
105
+ { id: "guix", name: "Guix", env: "GUIX_ENVIRONMENT" },
106
+ { id: "conda", name: "Conda", env: "CONDA_DEFAULT_ENV" },
107
+ { id: "pixi", name: "Pixi", files: ["pixi.toml", "pixi.lock"], env: "PIXI_ENVIRONMENT_NAME" },
108
+ { id: "spack", name: "Spack", env: "SPACK_ENV" },
109
+ { id: "pulumi", name: "Pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
110
+ { id: "typst", name: "Typst", files: ["template.typ"], extensions: [".typ"] },
111
+ { id: "buf", name: "Buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
112
+ { id: "dotnet", name: ".NET", files: ["global.json", "Directory.Build.props"], extensions: [".csproj", ".fsproj"] },
113
+ { id: "cobol", name: "COBOL", extensions: [".cbl", ".cob"] },
114
+ { id: "cpp", name: "C++", files: ["CMakeLists.txt"], extensions: [".cpp", ".cc", ".cxx"] },
115
+ { id: "c", name: "C", files: ["Makefile"], extensions: [".c"] },
116
+ ] as const;
117
+
118
+ interface RuntimeCacheEntry {
119
+ fingerprint: string;
120
+ value: RuntimeStatusSnapshot;
121
+ retryAt?: number;
122
+ }
123
+
124
+ function matchesRuntime(
125
+ definition: RuntimeDefinition,
126
+ entries: ReadonlyMap<string, boolean>,
127
+ env: Readonly<Record<string, string | undefined>>,
128
+ ): boolean {
129
+ if (definition.env && env[definition.env]) return true;
130
+ if (definition.files?.some((name) => entries.has(name))) return true;
131
+ if (definition.folders?.some((name) => entries.get(name) === true)) return true;
132
+ return definition.extensions?.some((extension) =>
133
+ [...entries.keys()].some((name) => name.endsWith(extension))) ?? false;
134
+ }
135
+
136
+ async function runtimeFingerprint(
137
+ cwd: string,
138
+ definition: RuntimeDefinition,
139
+ entryNames: readonly string[],
140
+ env: Readonly<Record<string, string | undefined>>,
141
+ ): Promise<string> {
142
+ const parts = [`runtime:${definition.id}`, ...entryNames.slice().sort()];
143
+ for (const name of definition.files ?? []) {
144
+ if (!entryNames.includes(name)) continue;
145
+ try {
146
+ parts.push(`${name}:${(await stat(join(cwd, name))).mtimeMs}`);
147
+ } catch {
148
+ // 列目录和读取元数据之间文件可能被删除,下轮刷新会重新判断。
149
+ }
150
+ }
151
+ if (definition.env && env[definition.env]) {
152
+ parts.push(`${definition.env}=${env[definition.env]}`);
153
+ }
154
+ for (const name of RUNTIME_ENV_KEYS) {
155
+ if (env[name]) parts.push(`${name}=${env[name]}`);
156
+ }
157
+ return parts.join("\0");
158
+ }
159
+
160
+ async function readVersion(
161
+ definition: RuntimeDefinition,
162
+ cwd: string,
163
+ queryVersion: RuntimeVersionQuery,
164
+ signal: AbortSignal,
165
+ ): Promise<string | undefined> {
166
+ if (!definition.version) return undefined;
167
+ try {
168
+ const result = await queryVersion(
169
+ definition.version.command,
170
+ definition.version.args,
171
+ cwd,
172
+ signal,
173
+ );
174
+ signal.throwIfAborted();
175
+ if (result.killed || result.code !== 0) return undefined;
176
+ const match = `${result.stdout}\n${result.stderr}`.match(definition.version.pattern);
177
+ return match?.[1];
178
+ } catch (error) {
179
+ if (signal.aborted) throw error;
180
+ return undefined;
181
+ }
182
+ }
183
+
184
+ // 缓存与查询适配器一起归调用方持有,避免新会话或新适配器沿用旧结果。
185
+ export function createRuntimeStatusDetector(
186
+ queryVersion: RuntimeVersionQuery,
187
+ env: Readonly<Record<string, string | undefined>> = process.env,
188
+ now: () => number = Date.now,
189
+ ): RuntimeStatusQuery {
190
+ const cache = new Map<string, RuntimeCacheEntry>();
191
+ return async (cwd, signal) => {
192
+ signal.throwIfAborted();
193
+ let directoryEntries;
194
+ try {
195
+ directoryEntries = await readdir(cwd, { withFileTypes: true });
196
+ } catch {
197
+ return null;
198
+ }
199
+ signal.throwIfAborted();
200
+ const entries = new Map(directoryEntries.map((entry) => [entry.name, entry.isDirectory()]));
201
+ const definition = RUNTIMES.find((candidate) => matchesRuntime(candidate, entries, env));
202
+ if (!definition) return null;
203
+
204
+ const fingerprint = await runtimeFingerprint(cwd, definition, [...entries.keys()], env);
205
+ signal.throwIfAborted();
206
+ const cached = cache.get(cwd);
207
+ if (cached?.fingerprint === fingerprint && (cached.retryAt === undefined || now() < cached.retryAt)) {
208
+ return cached.value;
209
+ }
210
+
211
+ const version = await readVersion(definition, cwd, queryVersion, signal);
212
+ const value: RuntimeStatusSnapshot = {
213
+ name: definition.name,
214
+ ...(version ? { version } : {}),
215
+ };
216
+ cache.delete(cwd);
217
+ cache.set(cwd, {
218
+ fingerprint,
219
+ value,
220
+ ...(definition.version && !version ? { retryAt: now() + VERSION_RETRY_MS } : {}),
221
+ });
222
+ while (cache.size > CACHE_LIMIT) {
223
+ const oldest = cache.keys().next().value;
224
+ if (oldest === undefined) break;
225
+ cache.delete(oldest);
226
+ }
227
+ return value;
228
+ };
229
+ }
230
+
231
+ export class RuntimeStatusController {
232
+ private readonly cwd: string;
233
+ private readonly queryRuntimeStatus: RuntimeStatusQuery;
234
+ private readonly debounceMs: number;
235
+ private snapshot: RuntimeStatusSnapshot | null = null;
236
+ private requestRender: (() => void) | undefined;
237
+ private refreshTimer: ReturnType<typeof setTimeout> | undefined;
238
+ private refreshInFlight = false;
239
+ private refreshPending = false;
240
+ private abortController: AbortController | undefined;
241
+ private disposed = false;
242
+
243
+ constructor(cwd: string, queryRuntimeStatus: RuntimeStatusQuery, debounceMs = 120) {
244
+ this.cwd = cwd;
245
+ this.queryRuntimeStatus = queryRuntimeStatus;
246
+ this.debounceMs = debounceMs;
247
+ }
248
+
249
+ connect(requestRender: () => void): void {
250
+ if (this.disposed) return;
251
+ this.requestRender = requestRender;
252
+ this.requestRefresh(0);
253
+ }
254
+
255
+ disconnect(): void {
256
+ this.requestRender = undefined;
257
+ }
258
+
259
+ getSnapshot(): RuntimeStatusSnapshot | null {
260
+ return this.snapshot;
261
+ }
262
+
263
+ requestRefresh(delay = this.debounceMs): void {
264
+ if (this.disposed || this.refreshTimer) return;
265
+ if (this.refreshInFlight) {
266
+ this.refreshPending = true;
267
+ return;
268
+ }
269
+ this.refreshTimer = setTimeout(() => {
270
+ this.refreshTimer = undefined;
271
+ void this.refresh();
272
+ }, delay);
273
+ }
274
+
275
+ async refresh(): Promise<void> {
276
+ if (this.disposed || this.refreshInFlight) return;
277
+ this.refreshInFlight = true;
278
+ const abortController = new AbortController();
279
+ this.abortController = abortController;
280
+ try {
281
+ const result = await this.queryRuntimeStatus(this.cwd, abortController.signal);
282
+ if (this.disposed || result === undefined) return;
283
+ if (JSON.stringify(result) !== JSON.stringify(this.snapshot)) {
284
+ this.snapshot = result;
285
+ this.requestRender?.();
286
+ }
287
+ } catch {
288
+ // 查询失败时保留最后一次已识别结果,不让状态行闪烁。
289
+ } finally {
290
+ if (this.abortController === abortController) this.abortController = undefined;
291
+ this.refreshInFlight = false;
292
+ if (this.refreshPending && !this.disposed) {
293
+ this.refreshPending = false;
294
+ this.requestRefresh(0);
295
+ }
296
+ }
297
+ }
298
+
299
+ dispose(): void {
300
+ this.disposed = true;
301
+ if (this.refreshTimer) clearTimeout(this.refreshTimer);
302
+ this.refreshTimer = undefined;
303
+ this.abortController?.abort();
304
+ this.abortController = undefined;
305
+ this.disconnect();
306
+ }
307
+ }