Skip to content

Commit 343e391

Browse files
committed
feat: show server version in UI + resolve codex CLI before launch
Two user-facing improvements for the Windows test path: 1. Show the running agent's git short-SHA in the desktop console sidebar so it is obvious whether a 'git pull' actually landed. - New server-version.ts: tries 'git rev-parse --short HEAD', falls back to reading .git/HEAD and packed-refs, caches the result on first read. Degrades to 'unknown' if git / .git are unavailable. - /api/config now returns 'serverVersion'. - mac-web desktop sidebar renders a small pill under the Screen Pilot title showing 'version: <sha>'. 2. Resolve the codex binary BEFORE opening a new cmd window. Previously, clicking codex authenticate on Windows would: a) spawn 'cmd /c start "" <temp.bat>' b) the bat would try to run 'codex' which was not on PATH c) user saw ''codex' 不是内部或外部命令' buried inside a popup window and the server thought launch succeeded. New flow: resolveCodexBinaryOnWindows() checks absolute path, 'where.exe codex' for PATH, and the %APPDATA%\npm global install location before writing the bat. If nothing is found, the launcher throws a clear Chinese error that surfaces in the UI telling the user to install codex or set CODEX_BIN. No more mystery popup.
1 parent 3bf5df9 commit 343e391

7 files changed

Lines changed: 167 additions & 3 deletions

File tree

apps/mac-web/public/app.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,10 @@ async function init() {
183183
wireEvents();
184184
state.config = await fetchJson("/api/config");
185185
serviceState.textContent = `${state.config.serviceName} 已连接,当前桌面网页控制台可用。`;
186+
const versionEl = document.querySelector("#server-version");
187+
if (versionEl) {
188+
versionEl.textContent = `version: ${state.config.serverVersion || "unknown"}`;
189+
}
186190
populateProviderOptions(state.config.modelProviders || [], state.config.defaults?.modelProvider || "codex");
187191
populateModelOptions(codexModelSelect, state.config.codexModels || [], state.config.defaults?.codexModel || "gpt-5.4");
188192
populateModelOptions(

apps/mac-web/public/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<div class="brand-block">
1313
<p class="eyebrow">Desktop Console</p>
1414
<h1>Screen Pilot</h1>
15+
<p id="server-version" class="server-version">version: &hellip;</p>
1516
<p class="muted-copy">面向 Mac 与 Windows 的网页控制台,用来做配置、认证、抓屏测试、模型测试和历史查看。</p>
1617
</div>
1718

apps/mac-web/public/styles.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ img { display: block; max-width: 100%; }
8585
line-height: 1.5;
8686
}
8787

88+
.brand-block .server-version {
89+
margin: 0 0 10px;
90+
padding: 2px 8px;
91+
display: inline-block;
92+
border-radius: 999px;
93+
background: rgba(0, 0, 0, 0.28);
94+
color: rgba(255, 255, 255, 0.95);
95+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
96+
font-size: 0.72rem;
97+
letter-spacing: 0.02em;
98+
}
99+
88100
.brand-block .eyebrow {
89101
color: rgba(255, 255, 255, 0.7);
90102
}

core/agent/src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
loadLocalVisionModelCatalog,
2323
loadModelProviderCatalog
2424
} from "./model-catalog.js";
25+
import { resolveServerVersion } from "./server-version.js";
2526
import { SessionStore } from "./session-store.js";
2627
import { SettingsStore } from "./settings-store.js";
2728
import type {
@@ -251,6 +252,7 @@ async function routeRequest(input: {
251252
if (method === "GET" && pathname === "/api/config") {
252253
const payload: AgentConfigPayload = {
253254
serviceName: input.config.serviceName,
255+
serverVersion: resolveServerVersion(input.config.workspaceRoot),
254256
auth: {
255257
pairingRequired: true
256258
},

core/agent/src/codex-login.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { spawn } from "node:child_process";
2-
import { writeFile } from "node:fs/promises";
2+
import { access, writeFile } from "node:fs/promises";
3+
import { constants as fsConstants } from "node:fs";
34
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
5+
import { isAbsolute, join } from "node:path";
56
import type { SpawnProcess, SpawnedProcessLike } from "./codex.js";
67

78
export interface CodexLoginStatus {
@@ -137,6 +138,17 @@ async function launchCodexLoginOnWindows(input: {
137138
codexBin: string;
138139
workspaceRoot: string;
139140
}): Promise<void> {
141+
// Resolve the codex binary before opening a console window, otherwise
142+
// the user just sees '"codex" 不是内部或外部命令' inside a popup and
143+
// has no signal that the problem is a missing install.
144+
const resolvedBin = await resolveCodexBinaryOnWindows(input.codexBin);
145+
if (!resolvedBin) {
146+
throw new Error(
147+
`Codex CLI 未找到。已尝试 "${input.codexBin}" 以及 PATH 和常见 Windows 安装位置。请先安装 codex CLI(https://codex.openai.com),或把 CODEX_BIN 环境变量指向 codex 可执行文件的完整路径,再重启 Screen Pilot。`
148+
);
149+
}
150+
const effectiveInput = { ...input, codexBin: resolvedBin };
151+
140152
// Why a .bat file instead of 'cmd /c start cmd /k "..."':
141153
//
142154
// The nested form forced the command line through four parsers in a
@@ -149,7 +161,7 @@ async function launchCodexLoginOnWindows(input: {
149161
// to a single layer inside the bat's own cmd.exe, where the quotes
150162
// are interpreted exactly as typed.
151163
const batPath = buildCodexLoginBatPath();
152-
const batContent = buildCodexLoginBatContent(input);
164+
const batContent = buildCodexLoginBatContent(effectiveInput);
153165
await writeFile(batPath, batContent, { encoding: "ascii" });
154166

155167
await new Promise<void>((resolve, reject) => {
@@ -190,6 +202,66 @@ export function buildCodexLoginBatPath(): string {
190202
return join(tmpdir(), "screen-pilot-codex-login.bat");
191203
}
192204

205+
export async function resolveCodexBinaryOnWindows(configuredBin: string): Promise<string | null> {
206+
// 1. If the user gave us an absolute path, honor it verbatim as long as
207+
// the file exists.
208+
if (isAbsolute(configuredBin)) {
209+
if (await fileExists(configuredBin)) return configuredBin;
210+
return null;
211+
}
212+
213+
// 2. Ask Windows where.exe to search PATH. This matches exactly what
214+
// cmd.exe would do when the user types the command manually.
215+
const fromPath = await runWhere(configuredBin);
216+
if (fromPath) return fromPath;
217+
218+
// 3. Fall back to the well-known npm global install location, which is
219+
// where '@openai/codex' ends up for most Windows users.
220+
const appdata = process.env.APPDATA;
221+
if (appdata) {
222+
for (const candidate of [
223+
join(appdata, "npm", `${configuredBin}.cmd`),
224+
join(appdata, "npm", `${configuredBin}.ps1`),
225+
join(appdata, "npm", `${configuredBin}.exe`),
226+
join(appdata, "npm", configuredBin)
227+
]) {
228+
if (await fileExists(candidate)) return candidate;
229+
}
230+
}
231+
232+
return null;
233+
}
234+
235+
async function fileExists(path: string): Promise<boolean> {
236+
try {
237+
await access(path, fsConstants.F_OK);
238+
return true;
239+
} catch {
240+
return false;
241+
}
242+
}
243+
244+
function runWhere(binaryName: string): Promise<string | null> {
245+
return new Promise((resolve) => {
246+
const child = spawn("where.exe", [binaryName], {
247+
stdio: ["ignore", "pipe", "ignore"]
248+
});
249+
let stdout = "";
250+
child.stdout.on("data", (chunk) => {
251+
stdout += chunk.toString("utf8");
252+
});
253+
child.on("error", () => resolve(null));
254+
child.on("close", (code) => {
255+
if (code !== 0) {
256+
resolve(null);
257+
return;
258+
}
259+
const firstLine = stdout.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
260+
resolve(firstLine || null);
261+
});
262+
});
263+
}
264+
193265
export function buildCodexLoginBatContent(input: {
194266
codexBin: string;
195267
workspaceRoot: string;

core/agent/src/server-version.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { execFileSync } from "node:child_process";
2+
import { readFileSync } from "node:fs";
3+
import { join } from "node:path";
4+
5+
let cached: string | null = null;
6+
7+
export function resolveServerVersion(workspaceRoot: string): string {
8+
if (cached) {
9+
return cached;
10+
}
11+
cached = readServerVersion(workspaceRoot);
12+
return cached;
13+
}
14+
15+
function readServerVersion(workspaceRoot: string): string {
16+
const viaGit = tryGitCommand(workspaceRoot);
17+
if (viaGit) return viaGit;
18+
19+
const viaHead = tryReadGitHead(workspaceRoot);
20+
if (viaHead) return viaHead;
21+
22+
return "unknown";
23+
}
24+
25+
function tryGitCommand(workspaceRoot: string): string | null {
26+
try {
27+
const out = execFileSync("git", ["rev-parse", "--short", "HEAD"], {
28+
cwd: workspaceRoot,
29+
encoding: "utf8",
30+
stdio: ["ignore", "pipe", "ignore"]
31+
});
32+
const trimmed = out.trim();
33+
return trimmed || null;
34+
} catch {
35+
return null;
36+
}
37+
}
38+
39+
function tryReadGitHead(workspaceRoot: string): string | null {
40+
try {
41+
const headPath = join(workspaceRoot, ".git", "HEAD");
42+
const headContent = readFileSync(headPath, "utf8").trim();
43+
if (headContent.startsWith("ref: ")) {
44+
const refPath = join(workspaceRoot, ".git", headContent.slice(5).trim());
45+
try {
46+
const sha = readFileSync(refPath, "utf8").trim();
47+
return sha.slice(0, 7) || null;
48+
} catch {
49+
return readPackedRef(workspaceRoot, headContent.slice(5).trim());
50+
}
51+
}
52+
return headContent.slice(0, 7) || null;
53+
} catch {
54+
return null;
55+
}
56+
}
57+
58+
function readPackedRef(workspaceRoot: string, ref: string): string | null {
59+
try {
60+
const packed = readFileSync(join(workspaceRoot, ".git", "packed-refs"), "utf8");
61+
for (const line of packed.split(/\r?\n/)) {
62+
if (line.startsWith("#") || !line.trim()) continue;
63+
const [sha, refName] = line.split(/\s+/);
64+
if (refName === ref && sha) {
65+
return sha.slice(0, 7);
66+
}
67+
}
68+
return null;
69+
} catch {
70+
return null;
71+
}
72+
}

core/agent/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ export interface SessionSummary {
108108

109109
export interface AgentConfigPayload {
110110
serviceName: string;
111+
serverVersion: string;
111112
auth: {
112113
pairingRequired: true;
113114
};

0 commit comments

Comments
 (0)