pi-open-tui 0.1.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.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/extensions/open-tui/config.ts +109 -0
- package/extensions/open-tui/editor.ts +101 -0
- package/extensions/open-tui/footer.ts +267 -0
- package/extensions/open-tui/git.ts +154 -0
- package/extensions/open-tui/header.ts +177 -0
- package/extensions/open-tui/icons.ts +194 -0
- package/extensions/open-tui/index.ts +241 -0
- package/extensions/open-tui/runtime.ts +174 -0
- package/extensions/open-tui/session-lifecycle.ts +43 -0
- package/extensions/open-tui/settings-command.ts +258 -0
- package/extensions/open-tui/state.ts +90 -0
- package/extensions/open-tui/utils.ts +157 -0
- package/package.json +53 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
|
|
3
|
+
import { installEditor } from "./editor.ts";
|
|
4
|
+
import { installFooter } from "./footer.ts";
|
|
5
|
+
import { installHeader } from "./header.ts";
|
|
6
|
+
import { readGitStatus } from "./git.ts";
|
|
7
|
+
import { readRuntimeInfo } from "./runtime.ts";
|
|
8
|
+
import { SessionLifecycle } from "./session-lifecycle.ts";
|
|
9
|
+
import { registerSettingsCommand } from "./settings-command.ts";
|
|
10
|
+
import {
|
|
11
|
+
createInitialState,
|
|
12
|
+
getModelMeta,
|
|
13
|
+
invalidateUsageCache,
|
|
14
|
+
type FooterState,
|
|
15
|
+
} from "./state.ts";
|
|
16
|
+
|
|
17
|
+
function isInteractiveLaunch(): boolean {
|
|
18
|
+
if (!process.stdout.isTTY) return false;
|
|
19
|
+
const args = process.argv.slice(2);
|
|
20
|
+
const nonInteractiveFlags = ["-p", "--print", "--help", "-h", "--version", "-v", "--list-models", "--export"];
|
|
21
|
+
for (const arg of args) {
|
|
22
|
+
if (nonInteractiveFlags.includes(arg)) return false;
|
|
23
|
+
if (arg.startsWith("--mode")) return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function clearVisibleScreen(): void {
|
|
29
|
+
if (process.stdout.isTTY) {
|
|
30
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isTuiContext(ctx: ExtensionContext): boolean {
|
|
35
|
+
try {
|
|
36
|
+
const mode = (ctx as ExtensionContext & { mode?: string }).mode;
|
|
37
|
+
return ctx.hasUI && (mode === undefined || mode === "tui");
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function (pi: ExtensionAPI) {
|
|
44
|
+
const sessionLifecycle = new SessionLifecycle();
|
|
45
|
+
const state: FooterState = createInitialState();
|
|
46
|
+
|
|
47
|
+
let config: OpenTuiConfig = structuredClone(DEFAULT_CONFIG);
|
|
48
|
+
let active = false;
|
|
49
|
+
let lastCtx: ExtensionContext | undefined;
|
|
50
|
+
let requestFooterRender: (() => void) | undefined;
|
|
51
|
+
let workingTimer: ReturnType<typeof setInterval> | undefined;
|
|
52
|
+
let cleanupHeader: (() => void) | undefined;
|
|
53
|
+
let cleanupFooter: (() => void) | undefined;
|
|
54
|
+
let cleanupEditor: (() => void) | undefined;
|
|
55
|
+
|
|
56
|
+
const getThinkingLevel = () => (sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : "off");
|
|
57
|
+
|
|
58
|
+
const applyUi = (ctx: ExtensionContext) => {
|
|
59
|
+
if (!isTuiContext(ctx)) return;
|
|
60
|
+
if (!config.enabled) {
|
|
61
|
+
uninstallUi(ctx);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!active) {
|
|
65
|
+
cleanupHeader = installHeader(pi, ctx);
|
|
66
|
+
cleanupFooter = installFooter(
|
|
67
|
+
ctx,
|
|
68
|
+
() => state,
|
|
69
|
+
() => config,
|
|
70
|
+
() => getModelMeta(ctx, getThinkingLevel),
|
|
71
|
+
{
|
|
72
|
+
setRequestRender: (fn) => {
|
|
73
|
+
requestFooterRender = fn ?? undefined;
|
|
74
|
+
},
|
|
75
|
+
scheduleGitRefresh: () => {
|
|
76
|
+
void scheduleGitRefresh(ctx);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
cleanupEditor = installEditor(pi, ctx);
|
|
81
|
+
active = true;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const uninstallUi = (ctx: ExtensionContext) => {
|
|
86
|
+
if (!isTuiContext(ctx)) return;
|
|
87
|
+
if (active) {
|
|
88
|
+
cleanupHeader?.();
|
|
89
|
+
cleanupFooter?.();
|
|
90
|
+
cleanupEditor?.();
|
|
91
|
+
cleanupHeader = undefined;
|
|
92
|
+
cleanupFooter = undefined;
|
|
93
|
+
cleanupEditor = undefined;
|
|
94
|
+
requestFooterRender = undefined;
|
|
95
|
+
active = false;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const scheduleGitRefresh = async (ctx: ExtensionContext) => {
|
|
100
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
101
|
+
const generation = sessionLifecycle.currentGeneration();
|
|
102
|
+
const cwd = ctx.cwd;
|
|
103
|
+
const git = await readGitStatus(cwd, {
|
|
104
|
+
readCommit: config.footerSegments.gitCommit,
|
|
105
|
+
readTag: config.footerSegments.gitCommit,
|
|
106
|
+
});
|
|
107
|
+
if (!sessionLifecycle.isCurrent(generation)) return;
|
|
108
|
+
state.git = git;
|
|
109
|
+
requestFooterRender?.();
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const refreshRuntime = async (ctx: ExtensionContext) => {
|
|
113
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
114
|
+
const generation = sessionLifecycle.currentGeneration();
|
|
115
|
+
const cwd = ctx.cwd;
|
|
116
|
+
const runtime = await readRuntimeInfo(cwd);
|
|
117
|
+
if (!sessionLifecycle.isCurrent(generation)) return;
|
|
118
|
+
state.runtime = runtime;
|
|
119
|
+
requestFooterRender?.();
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
|
|
123
|
+
if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
|
|
124
|
+
if (project) {
|
|
125
|
+
void scheduleGitRefresh(ctx);
|
|
126
|
+
void refreshRuntime(ctx);
|
|
127
|
+
}
|
|
128
|
+
requestFooterRender?.();
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const startWorkingTimer = () => {
|
|
132
|
+
stopWorkingTimer();
|
|
133
|
+
const tick = () => {
|
|
134
|
+
if (!sessionLifecycle.isCurrent() || !active) return;
|
|
135
|
+
requestFooterRender?.();
|
|
136
|
+
};
|
|
137
|
+
tick();
|
|
138
|
+
workingTimer = setInterval(tick, 250);
|
|
139
|
+
workingTimer.unref?.();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const stopWorkingTimer = () => {
|
|
143
|
+
if (workingTimer) {
|
|
144
|
+
clearInterval(workingTimer);
|
|
145
|
+
workingTimer = undefined;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
150
|
+
sessionLifecycle.start();
|
|
151
|
+
lastCtx = ctx;
|
|
152
|
+
state.sessionStartEpoch = Date.now();
|
|
153
|
+
state.workingSince = undefined;
|
|
154
|
+
state.lastDoneIn = undefined;
|
|
155
|
+
invalidateUsageCache();
|
|
156
|
+
|
|
157
|
+
ensureConfigExists();
|
|
158
|
+
config = loadConfig((msg, level) => ctx.ui.notify(msg, level));
|
|
159
|
+
|
|
160
|
+
if (isInteractiveLaunch() && config.enabled) {
|
|
161
|
+
clearVisibleScreen();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
applyUi(ctx);
|
|
165
|
+
|
|
166
|
+
refreshInteractiveState(ctx, true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
170
|
+
sessionLifecycle.shutdown();
|
|
171
|
+
stopWorkingTimer();
|
|
172
|
+
if (active) {
|
|
173
|
+
uninstallUi(ctx);
|
|
174
|
+
}
|
|
175
|
+
lastCtx = undefined;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
pi.on("agent_start", (_event, _ctx) => {
|
|
179
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
180
|
+
state.workingSince = Date.now();
|
|
181
|
+
state.lastDoneIn = undefined;
|
|
182
|
+
startWorkingTimer();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
pi.on("agent_end", (_event, _ctx) => {
|
|
186
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
187
|
+
stopWorkingTimer();
|
|
188
|
+
if (state.workingSince !== undefined) {
|
|
189
|
+
state.lastDoneIn = Date.now() - state.workingSince;
|
|
190
|
+
state.workingSince = undefined;
|
|
191
|
+
}
|
|
192
|
+
requestFooterRender?.();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
pi.on("model_select", (_event, ctx) => {
|
|
196
|
+
refreshInteractiveState(ctx);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
pi.on("thinking_level_select", (_event, ctx) => {
|
|
200
|
+
refreshInteractiveState(ctx);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
pi.on("message_end", (_event, ctx) => {
|
|
204
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
205
|
+
invalidateUsageCache();
|
|
206
|
+
refreshInteractiveState(ctx);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
pi.on("tool_execution_end", (_event, ctx) => {
|
|
210
|
+
refreshInteractiveState(ctx);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
pi.on("session_compact", (_event, ctx) => {
|
|
214
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
215
|
+
invalidateUsageCache();
|
|
216
|
+
refreshInteractiveState(ctx);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
220
|
+
if (!sessionLifecycle.isCurrent()) return;
|
|
221
|
+
invalidateUsageCache();
|
|
222
|
+
refreshInteractiveState(ctx);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
registerSettingsCommand(pi, {
|
|
226
|
+
getConfig: () => config,
|
|
227
|
+
onConfigChanged: (newConfig) => {
|
|
228
|
+
const wasEnabled = config.enabled;
|
|
229
|
+
saveConfig(newConfig);
|
|
230
|
+
config = newConfig;
|
|
231
|
+
if (lastCtx && wasEnabled !== newConfig.enabled) {
|
|
232
|
+
if (newConfig.enabled) {
|
|
233
|
+
applyUi(lastCtx);
|
|
234
|
+
} else {
|
|
235
|
+
uninstallUi(lastCtx);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
requestFooterRender?.();
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const VERSION_TIMEOUT_MS = 2500;
|
|
8
|
+
|
|
9
|
+
export interface RuntimeInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RuntimeDef {
|
|
15
|
+
name: string;
|
|
16
|
+
files: readonly string[];
|
|
17
|
+
folders?: readonly string[];
|
|
18
|
+
extensions?: readonly string[];
|
|
19
|
+
env?: string;
|
|
20
|
+
versionCommand?: { cmd: string; args?: string[]; pattern?: RegExp };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RUNTIMES: readonly RuntimeDef[] = [
|
|
24
|
+
{ name: "nodejs", files: ["package.json", ".nvmrc", ".node-version"], versionCommand: { cmd: "node", args: ["--version"], pattern: /v(\d+\.\d+\.\d+)/ } },
|
|
25
|
+
{ name: "rust", files: ["Cargo.toml"], versionCommand: { cmd: "rustc", args: ["--version"], pattern: /rustc\s+(\d+\.\d+\.\d+)/ } },
|
|
26
|
+
{ name: "go", files: ["go.mod"], versionCommand: { cmd: "go", args: ["version"], pattern: /go(\d+\.\d+\.\d+)/ } },
|
|
27
|
+
{ name: "python", files: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile", ".python-version"], versionCommand: { cmd: "python3", args: ["--version"], pattern: /Python\s+(\d+\.\d+\.\d+)/ } },
|
|
28
|
+
{ name: "ruby", files: ["Gemfile", ".ruby-version"], versionCommand: { cmd: "ruby", args: ["--version"], pattern: /ruby\s+(\d+\.\d+\.\d+)/ } },
|
|
29
|
+
{ name: "java", files: ["pom.xml", "build.gradle", "build.gradle.kts", ".java-version"], versionCommand: { cmd: "java", args: ["-version"], pattern: /version\s+"(\d+\.\d+[\.\d]*)"/ } },
|
|
30
|
+
{ name: "swift", files: ["Package.swift"], versionCommand: { cmd: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+)/ } },
|
|
31
|
+
{ name: "kotlin", files: ["build.gradle.kts", "settings.gradle.kts"] },
|
|
32
|
+
{ name: "cpp", files: ["CMakeLists.txt", "Makefile"] },
|
|
33
|
+
{ name: "c", files: ["Makefile", "CMakeLists.txt"] },
|
|
34
|
+
{ name: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"], versionCommand: { cmd: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
|
|
35
|
+
{ name: "bun", files: ["bun.lock", "bun.lockb"], versionCommand: { cmd: "bun", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
36
|
+
{ name: "php", files: ["composer.json"], versionCommand: { cmd: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
|
|
37
|
+
{ name: "haskell", files: ["stack.yaml", "cabal.project", ".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
38
|
+
{ name: "julia", files: ["Project.toml", "Manifest.toml"], versionCommand: { cmd: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/ } },
|
|
39
|
+
{ name: "lua", files: ["stylua.toml", ".luarc.json"], versionCommand: { cmd: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+)/ } },
|
|
40
|
+
{ name: "elixir", files: ["mix.exs"], versionCommand: { cmd: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
|
|
41
|
+
{ name: "erlang", files: ["rebar.config", "erlang.mk"] },
|
|
42
|
+
{ name: "gleam", files: ["gleam.toml"], versionCommand: { cmd: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/ } },
|
|
43
|
+
{ name: "crystal", files: ["shard.yml"], versionCommand: { cmd: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
|
|
44
|
+
{ name: "dart", files: ["pubspec.yaml"], versionCommand: { cmd: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
|
|
45
|
+
{ name: "nim", files: ["nim.cfg", ".nimble"] },
|
|
46
|
+
{ name: "zig", files: ["build.zig"], versionCommand: { cmd: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
47
|
+
{ name: "ocaml", files: [".opam", "dune", "dune-project"] },
|
|
48
|
+
{ name: "clojure", files: ["project.clj", "deps.edn"] },
|
|
49
|
+
{ name: "scala", files: ["build.sbt", ".scala", ".metals"] },
|
|
50
|
+
{ name: "perl", files: ["Makefile.PL", "cpanfile"] },
|
|
51
|
+
{ name: "r", files: [".Rproj", "DESCRIPTION"] },
|
|
52
|
+
{ name: "elm", files: ["elm.json"] },
|
|
53
|
+
{ name: "haxe", files: ["haxelib.json", ".haxerc"] },
|
|
54
|
+
{ name: "vagrant", files: ["Vagrantfile"] },
|
|
55
|
+
{ name: "terraform", files: ["main.tf", "variables.tf"], folders: [".terraform"] },
|
|
56
|
+
{ name: "helm", files: ["Chart.yaml", "helmfile.yaml"] },
|
|
57
|
+
{ name: "solidity", files: [], extensions: [".sol"] },
|
|
58
|
+
{ name: "fortran", files: ["fpm.toml"], extensions: [".f", ".f90", ".f95"] },
|
|
59
|
+
{ name: "mojo", files: [], extensions: [".mojo"] },
|
|
60
|
+
{ name: "red", files: [], extensions: [".red", ".reds"] },
|
|
61
|
+
{ name: "raku", files: ["META6.json"], extensions: [".raku", ".rakumod"] },
|
|
62
|
+
{ name: "purescript", files: ["spago.dhall", "spago.yaml"] },
|
|
63
|
+
{ name: "fennel", files: [], extensions: [".fnl"] },
|
|
64
|
+
{ name: "odin", files: [], extensions: [".odin"] },
|
|
65
|
+
{ name: "v", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
|
|
66
|
+
{ name: "xmake", files: ["xmake.lua"] },
|
|
67
|
+
{ name: "gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
|
|
68
|
+
{ name: "maven", files: ["pom.xml"] },
|
|
69
|
+
{ name: "cmake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
|
|
70
|
+
{ name: "meson", files: ["meson.build"], env: "MESON_DEVENV" },
|
|
71
|
+
{ name: "nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
|
|
72
|
+
{ name: "guix", files: [], env: "GUIX_ENVIRONMENT" },
|
|
73
|
+
{ name: "conda", files: [], env: "CONDA_DEFAULT_ENV" },
|
|
74
|
+
{ name: "pixi", files: ["pixi.toml", "pixi.lock"], env: "PIXI_ENVIRONMENT_NAME" },
|
|
75
|
+
{ name: "spack", files: [], env: "SPACK_ENV" },
|
|
76
|
+
{ name: "pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
|
|
77
|
+
{ name: "typst", files: ["template.typ"], extensions: [".typ"] },
|
|
78
|
+
{ name: "buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
|
|
79
|
+
{ name: "dotnet", files: [".csproj", ".fsproj", "global.json", "Directory.Build.props"] },
|
|
80
|
+
{ name: "cobol", files: [], extensions: [".cbl", ".cob"] },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
interface CacheEntry {
|
|
84
|
+
fingerprint: string;
|
|
85
|
+
runtime: RuntimeInfo | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cache = new Map<string, CacheEntry>();
|
|
89
|
+
const CACHE_MAX = 32;
|
|
90
|
+
|
|
91
|
+
function fingerprint(cwd: string, def: RuntimeDef): string {
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
for (const f of def.files) {
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(join(cwd, f));
|
|
96
|
+
parts.push(`${f}:${stat.mtimeMs}`);
|
|
97
|
+
} catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
if (def.extensions || def.folders) {
|
|
100
|
+
try {
|
|
101
|
+
const entries = readdirSync(cwd);
|
|
102
|
+
parts.push(...entries.slice().sort());
|
|
103
|
+
} catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
if (def.env && process.env[def.env]) {
|
|
106
|
+
parts.push(`${def.env}=${process.env[def.env]}`);
|
|
107
|
+
}
|
|
108
|
+
return parts.join("\0");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchesDef(cwd: string, def: RuntimeDef): boolean {
|
|
112
|
+
if (def.env && process.env[def.env]) return true;
|
|
113
|
+
if (def.files.some((f) => existsSync(join(cwd, f)))) return true;
|
|
114
|
+
if (def.folders?.some((f) => existsSync(join(cwd, f)))) return true;
|
|
115
|
+
if (def.extensions) {
|
|
116
|
+
try {
|
|
117
|
+
const entries = readdirSync(cwd);
|
|
118
|
+
if (entries.some((e) => def.extensions!.some((ext) => e.endsWith(ext)))) return true;
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function fetchVersion(def: RuntimeDef, cwd: string): Promise<string | undefined> {
|
|
125
|
+
if (!def.versionCommand) return undefined;
|
|
126
|
+
try {
|
|
127
|
+
const { stdout } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
|
|
128
|
+
cwd,
|
|
129
|
+
timeout: VERSION_TIMEOUT_MS,
|
|
130
|
+
maxBuffer: 64 * 1024,
|
|
131
|
+
});
|
|
132
|
+
if (def.versionCommand.pattern) {
|
|
133
|
+
const match = stdout.match(def.versionCommand.pattern);
|
|
134
|
+
return match?.[1];
|
|
135
|
+
}
|
|
136
|
+
return stdout.trim() || undefined;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function readRuntimeInfo(cwd: string): Promise<RuntimeInfo | null> {
|
|
143
|
+
for (const def of RUNTIMES) {
|
|
144
|
+
if (!matchesDef(cwd, def)) continue;
|
|
145
|
+
const fp = fingerprint(cwd, def);
|
|
146
|
+
const cacheKey = `${cwd}\0${def.name}`;
|
|
147
|
+
const cached = cache.get(cacheKey);
|
|
148
|
+
if (cached && cached.fingerprint === fp) {
|
|
149
|
+
return cached.runtime;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const key of cache.keys()) {
|
|
153
|
+
if (key === cacheKey || key.startsWith(`${cwd}\0`)) cache.delete(key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const version = await fetchVersion(def, cwd);
|
|
157
|
+
const info: RuntimeInfo = {
|
|
158
|
+
name: def.name,
|
|
159
|
+
version,
|
|
160
|
+
};
|
|
161
|
+
cache.set(cacheKey, { fingerprint: fp, runtime: info });
|
|
162
|
+
while (cache.size > CACHE_MAX) {
|
|
163
|
+
const oldest = cache.keys().next().value;
|
|
164
|
+
if (oldest === undefined) break;
|
|
165
|
+
cache.delete(oldest);
|
|
166
|
+
}
|
|
167
|
+
return info;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function clearRuntimeCache(): void {
|
|
173
|
+
cache.clear();
|
|
174
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class SessionLifecycle {
|
|
2
|
+
private current = 0;
|
|
3
|
+
private deferred: Array<() => void> = [];
|
|
4
|
+
private shutDown = false;
|
|
5
|
+
|
|
6
|
+
start(): void {
|
|
7
|
+
this.current++;
|
|
8
|
+
this.shutDown = false;
|
|
9
|
+
this.flushDeferred();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
shutdown(): void {
|
|
13
|
+
this.shutDown = true;
|
|
14
|
+
this.deferred = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isCurrent(generation?: number): boolean {
|
|
18
|
+
if (this.shutDown) return false;
|
|
19
|
+
if (generation === undefined) return true;
|
|
20
|
+
return generation === this.current;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
currentGeneration(): number {
|
|
24
|
+
return this.current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
defer(fn: () => void): void {
|
|
28
|
+
if (this.shutDown) return;
|
|
29
|
+
this.deferred.push(fn);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private flushDeferred(): void {
|
|
33
|
+
const pending = this.deferred;
|
|
34
|
+
this.deferred = [];
|
|
35
|
+
for (const fn of pending) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
} catch {
|
|
39
|
+
// ponytail: silent fallback — deferred tasks are best-effort
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|