Merge branch 'main' into merge/main-into-dev
Brings in 8 main commits (0.1.48 release, provider overhaul, keyboard shortcuts, desktop login shell env, question form Enter key) with proper git history. Resolves conflicts by keeping dev branch architecture where it subsumes main's changes, and main's provider snapshot COMPAT comments.
This commit is contained in:
@@ -47,6 +47,9 @@ import type {
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ListTerminalsResponse,
|
||||
CreateTerminalResponse,
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
SubscribeTerminalResponse,
|
||||
TerminalState,
|
||||
CloseItemsResponse,
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("applyProviderEnv", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const env = applyProviderEnv(base, runtime, {});
|
||||
const env = applyProviderEnv(base, runtime);
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.HOME).toBe("/custom/home");
|
||||
@@ -74,21 +74,11 @@ describe("applyProviderEnv", () => {
|
||||
expect(Object.keys(env).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
test("shell env PATH wins over base env PATH", () => {
|
||||
const base = { PATH: "/usr/bin:/bin" };
|
||||
const shellEnv = { PATH: "/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin" };
|
||||
|
||||
const env = applyProviderEnv(base, undefined, shellEnv);
|
||||
|
||||
expect(env.PATH).toBe("/usr/local/bin:/usr/bin:/bin:/home/user/.nvm/bin");
|
||||
});
|
||||
|
||||
test("runtimeSettings env wins over shell env", () => {
|
||||
test("runtimeSettings env wins over base env", () => {
|
||||
const base = { PATH: "/usr/bin" };
|
||||
const shellEnv = { PATH: "/usr/local/bin:/usr/bin" };
|
||||
const runtime: ProviderRuntimeSettings = { env: { PATH: "/custom/path" } };
|
||||
|
||||
const env = applyProviderEnv(base, runtime, shellEnv);
|
||||
const env = applyProviderEnv(base, runtime);
|
||||
|
||||
expect(env.PATH).toBe("/custom/path");
|
||||
});
|
||||
@@ -103,7 +93,7 @@ describe("applyProviderEnv", () => {
|
||||
CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING: "true",
|
||||
};
|
||||
|
||||
const env = applyProviderEnv(base, undefined, {});
|
||||
const env = applyProviderEnv(base);
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.CLAUDECODE).toBeUndefined();
|
||||
|
||||
@@ -2,7 +2,6 @@ import { execFileSync, execSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import { shellEnvSync } from "shell-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
@@ -144,12 +143,10 @@ export function resolveProviderCommandPrefix(
|
||||
let cachedShellEnv: Record<string, string> | null = null;
|
||||
|
||||
export function resolveShellEnv(): Record<string, string> {
|
||||
if (cachedShellEnv) return cachedShellEnv;
|
||||
try {
|
||||
cachedShellEnv = shellEnvSync();
|
||||
} catch {
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
if (cachedShellEnv) {
|
||||
return cachedShellEnv;
|
||||
}
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
return cachedShellEnv;
|
||||
}
|
||||
|
||||
@@ -167,11 +164,9 @@ const PARENT_SESSION_ENV_VARS = [
|
||||
export function applyProviderEnv(
|
||||
baseEnv: Record<string, string | undefined>,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
shellEnv?: Record<string, string>,
|
||||
): Record<string, string | undefined> {
|
||||
const merged: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
...(shellEnv ?? resolveShellEnv()),
|
||||
...(runtimeSettings?.env ?? {}),
|
||||
};
|
||||
for (const key of PARENT_SESSION_ENV_VARS) {
|
||||
|
||||
@@ -0,0 +1,409 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
};
|
||||
|
||||
type MockProviderOptions = {
|
||||
provider: AgentProvider;
|
||||
isAvailable?: () => Promise<boolean>;
|
||||
fetchModels?: (cwd?: string) => Promise<AgentModelDefinition[]>;
|
||||
fetchModes?: (cwd?: string) => Promise<AgentMode[]>;
|
||||
};
|
||||
|
||||
type MockProviderHandle = {
|
||||
definition: ProviderDefinition;
|
||||
isAvailable: ReturnType<typeof vi.fn>;
|
||||
fetchModels: ReturnType<typeof vi.fn>;
|
||||
fetchModes: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
describe("ProviderSnapshotManager", () => {
|
||||
test("getSnapshot returns all providers in loading state initially and triggers warmUp", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const claudeModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => codexModels.promise,
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async () => claudeModels.promise,
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
const snapshot = manager.getSnapshot("/tmp/project");
|
||||
|
||||
expect(snapshot).toEqual([
|
||||
{ provider: "claude", status: "loading" },
|
||||
{ provider: "codex", status: "loading" },
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
codexModels.resolve([]);
|
||||
claudeModels.resolve([]);
|
||||
});
|
||||
|
||||
test("after warmUp completes, getSnapshot returns ready entries with models", async () => {
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => [createModel("codex", "gpt-5.2")],
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async () => [createModel("claude", "sonnet")],
|
||||
fetchModes: async () => [createMode("default")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "claude")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
const snapshot = manager.getSnapshot("/tmp/project");
|
||||
expect(getProviderEntry(snapshot, "codex")).toMatchObject({
|
||||
provider: "codex",
|
||||
status: "ready",
|
||||
models: [createModel("codex", "gpt-5.2")],
|
||||
modes: [createMode("auto")],
|
||||
});
|
||||
expect(getProviderEntry(snapshot, "claude")).toMatchObject({
|
||||
provider: "claude",
|
||||
status: "ready",
|
||||
models: [createModel("claude", "sonnet")],
|
||||
modes: [createMode("default")],
|
||||
});
|
||||
expect(getProviderEntry(snapshot, "codex")?.fetchedAt).toEqual(expect.any(String));
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("provider that fails isAvailable shows as unavailable", async () => {
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
isAvailable: async () => false,
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{ provider: "codex", status: "unavailable" },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(handles.codex?.fetchModels).not.toHaveBeenCalled();
|
||||
expect(handles.codex?.fetchModes).not.toHaveBeenCalled();
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("provider that fails fetchModels shows as error with error message", async () => {
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => {
|
||||
throw new Error("model lookup failed");
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
status: "error",
|
||||
error: "model lookup failed",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("change event fires for each provider as it resolves", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const claudeModels = deferred<AgentModelDefinition[]>();
|
||||
const codexModes = deferred<AgentMode[]>();
|
||||
const claudeModes = deferred<AgentMode[]>();
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => codexModels.promise,
|
||||
fetchModes: async () => codexModes.promise,
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async () => claudeModels.promise,
|
||||
fetchModes: async () => claudeModes.promise,
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
const changes: Array<{ cwd: string; entries: ProviderSnapshotEntry[] }> = [];
|
||||
const listener = (entries: ProviderSnapshotEntry[], cwd: string) => {
|
||||
changes.push({ cwd, entries });
|
||||
};
|
||||
manager.on("change", listener);
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
claudeModels.resolve([createModel("claude", "sonnet")]);
|
||||
claudeModes.resolve([createMode("default")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
expect(changes[0]?.cwd).toBe("/tmp/project");
|
||||
expect(getProviderEntry(changes[0]?.entries ?? [], "claude")?.status).toBe("ready");
|
||||
expect(getProviderEntry(changes[0]?.entries ?? [], "codex")?.status).toBe("loading");
|
||||
|
||||
codexModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
codexModes.resolve([createMode("auto")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(changes).toHaveLength(2);
|
||||
});
|
||||
|
||||
expect(getProviderEntry(changes[1]?.entries ?? [], "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(changes[1]?.entries ?? [], "claude")?.status).toBe("ready");
|
||||
|
||||
manager.off("change", listener);
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("refresh re-fetches and updates entries", async () => {
|
||||
const codexFetchModels = vi
|
||||
.fn<(options?: { cwd?: string }) => Promise<AgentModelDefinition[]>>()
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.1")])
|
||||
.mockResolvedValueOnce([createModel("codex", "gpt-5.2")]);
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => codexFetchModels({ cwd }),
|
||||
fetchModes: async () => [createMode("auto")],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.1",
|
||||
);
|
||||
});
|
||||
|
||||
manager.refresh("/tmp/project");
|
||||
expect(manager.getSnapshot("/tmp/project")).toEqual([
|
||||
{ provider: "codex", status: "loading" },
|
||||
]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.models?.[0]?.id).toBe(
|
||||
"gpt-5.2",
|
||||
);
|
||||
});
|
||||
|
||||
expect(codexFetchModels).toHaveBeenCalledTimes(2);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("multiple getSnapshot calls for same cwd do not trigger multiple warmUps", async () => {
|
||||
const codexModels = deferred<AgentModelDefinition[]>();
|
||||
const { registry, handles } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async () => codexModels.promise,
|
||||
}),
|
||||
createMockProvider({
|
||||
provider: "claude",
|
||||
fetchModels: async () => [],
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project");
|
||||
manager.getSnapshot("/tmp/project");
|
||||
manager.getSnapshot("/tmp/project");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(handles.codex?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(handles.codex?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(handles.claude?.isAvailable).toHaveBeenCalledTimes(1);
|
||||
expect(handles.claude?.fetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
codexModels.resolve([createModel("codex", "gpt-5.2")]);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project"), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
|
||||
test("different cwd keys get independent snapshots", async () => {
|
||||
const seenCwds: string[] = [];
|
||||
const { registry } = createRegistry([
|
||||
createMockProvider({
|
||||
provider: "codex",
|
||||
fetchModels: async (cwd) => {
|
||||
seenCwds.push(cwd ?? "__missing__");
|
||||
return [createModel("codex", `model:${cwd}`)];
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const manager = new ProviderSnapshotManager(registry, createTestLogger());
|
||||
|
||||
manager.getSnapshot("/tmp/project-a");
|
||||
manager.getSnapshot("/tmp/project-b");
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.status).toBe("ready");
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.status).toBe("ready");
|
||||
});
|
||||
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project-a"), "codex")?.models?.[0]?.id).toBe(
|
||||
"model:/tmp/project-a",
|
||||
);
|
||||
expect(getProviderEntry(manager.getSnapshot("/tmp/project-b"), "codex")?.models?.[0]?.id).toBe(
|
||||
"model:/tmp/project-b",
|
||||
);
|
||||
expect(seenCwds).toEqual(["/tmp/project-a", "/tmp/project-b"]);
|
||||
|
||||
manager.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function createRegistry(handles: MockProviderHandle[]): {
|
||||
registry: Record<AgentProvider, ProviderDefinition>;
|
||||
handles: Record<AgentProvider, MockProviderHandle>;
|
||||
} {
|
||||
return {
|
||||
registry: Object.fromEntries(
|
||||
handles.map((handle) => [handle.definition.id, handle.definition]),
|
||||
) as Record<AgentProvider, ProviderDefinition>,
|
||||
handles: Object.fromEntries(
|
||||
handles.map((handle) => [handle.definition.id, handle]),
|
||||
) as Record<AgentProvider, MockProviderHandle>,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockProvider(options: MockProviderOptions): MockProviderHandle {
|
||||
const isAvailable = vi.fn(async () => options.isAvailable?.() ?? true);
|
||||
const fetchModels = vi.fn(async (listOptions?: { cwd?: string }) =>
|
||||
options.fetchModels?.(listOptions?.cwd) ?? [createModel(options.provider, `${options.provider}-default`)],
|
||||
);
|
||||
const fetchModes = vi.fn(async (listOptions?: { cwd?: string }) =>
|
||||
options.fetchModes?.(listOptions?.cwd) ?? [createMode(`${options.provider}-mode`)],
|
||||
);
|
||||
|
||||
const definition: ProviderDefinition = {
|
||||
id: options.provider,
|
||||
label: options.provider,
|
||||
description: `${options.provider} test provider`,
|
||||
defaultModeId: null,
|
||||
modes: [],
|
||||
createClient: () =>
|
||||
({
|
||||
provider: options.provider,
|
||||
capabilities: TEST_CAPABILITIES,
|
||||
async createSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async resumeSession() {
|
||||
throw new Error("not implemented");
|
||||
},
|
||||
async listModels() {
|
||||
return [];
|
||||
},
|
||||
async isAvailable() {
|
||||
return isAvailable();
|
||||
},
|
||||
}) satisfies AgentClient,
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
};
|
||||
|
||||
return {
|
||||
definition,
|
||||
isAvailable,
|
||||
fetchModels,
|
||||
fetchModes,
|
||||
};
|
||||
}
|
||||
|
||||
function createModel(provider: AgentProvider, id: string): AgentModelDefinition {
|
||||
return {
|
||||
provider,
|
||||
id,
|
||||
label: id,
|
||||
};
|
||||
}
|
||||
|
||||
function createMode(id: string): AgentMode {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
};
|
||||
}
|
||||
|
||||
function getProviderEntry(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
provider: AgentProvider,
|
||||
): ProviderSnapshotEntry | undefined {
|
||||
return entries.find((entry) => entry.provider === provider);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { promises } from "node:fs";
|
||||
@@ -39,6 +39,12 @@ import {
|
||||
} from "./claude/claude-models.js";
|
||||
import { parsePartialJsonObject } from "./claude/partial-json.js";
|
||||
import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -1085,6 +1091,42 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
try {
|
||||
const resolvedBinary = findExecutable("claude") ?? "not found";
|
||||
const available = await this.isAvailable();
|
||||
const version = resolveClaudeVersion(this.runtimeSettings);
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Claude Code", [
|
||||
{ label: "Binary", value: resolvedBinary },
|
||||
...(version ? [{ label: "Version", value: version }] : []),
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostic: formatProviderDiagnosticError("Claude Code", error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private assertConfig(config: AgentSessionConfig): ClaudeAgentConfig {
|
||||
if (config.provider !== "claude") {
|
||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
||||
@@ -1093,6 +1135,31 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClaudeVersion(runtimeSettings?: ProviderRuntimeSettings): string | null {
|
||||
const command = runtimeSettings?.command;
|
||||
|
||||
try {
|
||||
if (command?.mode === "replace") {
|
||||
return execFileSync(command.argv[0]!, [...command.argv.slice(1), "--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
}).trim() || null;
|
||||
}
|
||||
|
||||
const executable = findExecutable("claude");
|
||||
if (!executable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return execFileSync(executable, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 5_000,
|
||||
}).trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ClaudeAgentSession implements AgentSession {
|
||||
readonly provider: "claude" = "claude";
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
|
||||
@@ -52,6 +52,13 @@ import {
|
||||
} from "../../../utils/executable.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||
@@ -3947,6 +3954,49 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
try {
|
||||
const available = await this.isAvailable();
|
||||
const resolvedBinary = findExecutable("codex");
|
||||
const entries: Array<{ label: string; value: string }> = [
|
||||
{
|
||||
label: "Binary",
|
||||
value: resolvedBinary ?? "not found",
|
||||
},
|
||||
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
|
||||
];
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (!available) {
|
||||
entries.push({ label: "Models", value: "Not checked" });
|
||||
} else {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
entries.push({ label: "Models", value: String(models.length) });
|
||||
} catch (error) {
|
||||
entries.push({
|
||||
label: "Models",
|
||||
value: `Error - ${toDiagnosticErrorMessage(error)}`,
|
||||
});
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.push({ label: "Status", value: status });
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Codex", entries),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostic: formatProviderDiagnosticError("Codex", error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const __codexAppServerInternals = {
|
||||
|
||||
@@ -2,7 +2,15 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentCapabilityFlags, AgentMode } from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { findExecutable } from "../../../utils/executable.js";
|
||||
import { ACPAgentClient } from "./acp-agent.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const COPILOT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -51,4 +59,53 @@ export class CopilotACPAgentClient extends ACPAgentClient {
|
||||
override async isAvailable(): Promise<boolean> {
|
||||
return super.isAvailable();
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
try {
|
||||
const available = await this.isAvailable();
|
||||
const resolvedBinary = findExecutable("copilot");
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes();
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Copilot", [
|
||||
{
|
||||
label: "Binary",
|
||||
value: resolvedBinary ?? "not found",
|
||||
},
|
||||
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostic: formatProviderDiagnosticError("Copilot", error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,13 @@ import {
|
||||
quoteWindowsCommand,
|
||||
} from "../../../utils/executable.js";
|
||||
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: true,
|
||||
@@ -615,6 +622,63 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
return true;
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
try {
|
||||
const available = await this.isAvailable();
|
||||
const resolvedBinary = findExecutable("opencode");
|
||||
let serverStatus = "Not running";
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
try {
|
||||
const { url } = await this.serverManager.ensureRunning();
|
||||
serverStatus = `Running (${url})`;
|
||||
} catch (error) {
|
||||
serverStatus = `Unavailable (${normalizeTurnFailureError(error)})`;
|
||||
}
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes();
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("OpenCode", [
|
||||
{
|
||||
label: "Binary",
|
||||
value: resolvedBinary ?? "not found",
|
||||
},
|
||||
{ label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" },
|
||||
{ label: "Server", value: serverStatus },
|
||||
{ label: "Models", value: modelsValue },
|
||||
{ label: "Status", value: status },
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostic: formatProviderDiagnosticError("OpenCode", error),
|
||||
};
|
||||
}
|
||||
}
|
||||
private assertConfig(config: AgentSessionConfig): OpenCodeAgentConfig {
|
||||
if (config.provider !== "opencode") {
|
||||
throw new Error(`OpenCodeAgentClient received config for provider '${config.provider}'`);
|
||||
|
||||
@@ -29,12 +29,19 @@ import type {
|
||||
AgentFeatureSelect,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { ProviderRuntimeSettings } from "../provider-launch-config.js";
|
||||
import { isCommandAvailable } from "../../../utils/executable.js";
|
||||
import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
|
||||
import {
|
||||
ACPAgentClient,
|
||||
type ACPToolSnapshot,
|
||||
type SessionStateResponse,
|
||||
} from "./acp-agent.js";
|
||||
import {
|
||||
formatDiagnosticStatus,
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
resolveBinaryVersion,
|
||||
toDiagnosticErrorMessage,
|
||||
} from "./diagnostic-utils.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const resolvedPiAcpPath = require.resolve("pi-acp");
|
||||
@@ -329,4 +336,85 @@ export class PiACPAgentClient extends ACPAgentClient {
|
||||
existsSync(join(homedir(), ".pi", "agent", "auth.json"))
|
||||
);
|
||||
}
|
||||
|
||||
async getDiagnostic(): Promise<{ diagnostic: string }> {
|
||||
try {
|
||||
const piCommand = process.env.PI_ACP_PI_COMMAND ?? "pi";
|
||||
const piCliPath = findExecutable(piCommand);
|
||||
const piVersion = piCliPath ? resolveBinaryVersion(piCliPath) : "unknown";
|
||||
const authConfigPath = join(homedir(), ".pi", "agent", "auth.json");
|
||||
const available = await this.isAvailable();
|
||||
let modelsValue = "Not checked";
|
||||
let status = formatDiagnosticStatus(available);
|
||||
|
||||
if (available) {
|
||||
try {
|
||||
const models = await this.listModels();
|
||||
modelsValue = String(models.length);
|
||||
} catch (error) {
|
||||
modelsValue = `Error - ${toDiagnosticErrorMessage(error)}`;
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "model fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
if (!modelsValue.startsWith("Error -")) {
|
||||
try {
|
||||
await this.listModes();
|
||||
} catch (error) {
|
||||
status = formatDiagnosticStatus(available, {
|
||||
source: "mode fetch",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostic: formatProviderDiagnostic("Pi", [
|
||||
{
|
||||
label: "pi-acp module",
|
||||
value: existsSync(resolvedPiAcpPath) ? "found" : "not found",
|
||||
},
|
||||
{
|
||||
label: "Binary",
|
||||
value: piCliPath ?? "not found",
|
||||
},
|
||||
{
|
||||
label: "Version",
|
||||
value: piVersion,
|
||||
},
|
||||
{
|
||||
label: "OPENAI_API_KEY",
|
||||
value: process.env.OPENAI_API_KEY ? "set" : "not set",
|
||||
},
|
||||
{
|
||||
label: "ANTHROPIC_API_KEY",
|
||||
value: process.env.ANTHROPIC_API_KEY ? "set" : "not set",
|
||||
},
|
||||
{
|
||||
label: "OPENROUTER_API_KEY",
|
||||
value: process.env.OPENROUTER_API_KEY ? "set" : "not set",
|
||||
},
|
||||
{
|
||||
label: "Auth config (~/.pi/agent/auth.json)",
|
||||
value: existsSync(authConfigPath) ? "found" : "not found",
|
||||
},
|
||||
{
|
||||
label: "Models",
|
||||
value: modelsValue,
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: status,
|
||||
},
|
||||
]),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostic: formatProviderDiagnosticError("Pi", error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,6 +682,7 @@ export class Session {
|
||||
}
|
||||
if (this.providerSnapshotManager) {
|
||||
const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd?: string) => {
|
||||
// COMPAT(providersSnapshot): keep provider visibility gating for older clients.
|
||||
const visibleEntries = entries.filter((entry) =>
|
||||
this.isProviderVisibleToClient(entry.provider),
|
||||
);
|
||||
@@ -3140,6 +3141,7 @@ export class Session {
|
||||
private async handleGetProvidersSnapshotRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "get_providers_snapshot_request" }>,
|
||||
): Promise<void> {
|
||||
// COMPAT(providersSnapshot): keep legacy provider-list RPCs alongside snapshot flow.
|
||||
const entries = this.providerSnapshotManager
|
||||
? this.providerSnapshotManager
|
||||
.getSnapshot(msg.cwd ? expandTilde(msg.cwd) : undefined)
|
||||
@@ -6981,6 +6983,10 @@ export class Session {
|
||||
this.unsubscribeAgentEvents();
|
||||
this.unsubscribeAgentEvents = null;
|
||||
}
|
||||
if (this.unsubscribeProviderSnapshotEvents) {
|
||||
this.unsubscribeProviderSnapshotEvents();
|
||||
this.unsubscribeProviderSnapshotEvents = null;
|
||||
}
|
||||
|
||||
// Abort any ongoing operations
|
||||
this.abortController.abort();
|
||||
@@ -7017,10 +7023,6 @@ export class Session {
|
||||
this.unsubscribeTerminalsChanged();
|
||||
this.unsubscribeTerminalsChanged = null;
|
||||
}
|
||||
if (this.unsubscribeProviderSnapshotEvents) {
|
||||
this.unsubscribeProviderSnapshotEvents();
|
||||
this.unsubscribeProviderSnapshotEvents = null;
|
||||
}
|
||||
this.subscribedTerminalDirectories.clear();
|
||||
|
||||
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
|
||||
|
||||
@@ -830,6 +830,10 @@ export class VoiceAssistantWebSocketServer {
|
||||
hostname: getHostname(),
|
||||
version: this.daemonVersion,
|
||||
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
|
||||
features: {
|
||||
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
|
||||
providersSnapshot: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1554,6 +1554,12 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
hostname: ServerInfoHostnameSchema.optional(),
|
||||
version: ServerInfoVersionSchema.optional(),
|
||||
capabilities: ServerCapabilitiesFromUnknownSchema,
|
||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||
features: z
|
||||
.object({
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.transform((payload) => ({
|
||||
@@ -2323,6 +2329,7 @@ export const ListAvailableProvidersResponseSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||
export const GetProvidersSnapshotResponseMessageSchema = z.object({
|
||||
type: z.literal("get_providers_snapshot_response"),
|
||||
payload: z.object({
|
||||
@@ -2332,6 +2339,7 @@ export const GetProvidersSnapshotResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||
export const ProvidersSnapshotUpdateMessageSchema = z.object({
|
||||
type: z.literal("providers_snapshot_update"),
|
||||
payload: z.object({
|
||||
@@ -2341,6 +2349,7 @@ export const ProvidersSnapshotUpdateMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||
export const RefreshProvidersSnapshotResponseMessageSchema = z.object({
|
||||
type: z.literal("refresh_providers_snapshot_response"),
|
||||
payload: z.object({
|
||||
@@ -2349,6 +2358,7 @@ export const RefreshProvidersSnapshotResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
|
||||
export const ProviderDiagnosticResponseMessageSchema = z.object({
|
||||
type: z.literal("provider_diagnostic_response"),
|
||||
payload: z.object({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { open as openFile, stat as statFile } from "fs/promises";
|
||||
import { TTLCache } from "@isaacs/ttlcache";
|
||||
import type { ParsedDiffFile } from "../server/utils/diff-highlighter.js";
|
||||
import { parseAndHighlightDiff } from "../server/utils/diff-highlighter.js";
|
||||
import { findExecutable, resolveShellEnv } from "./executable.js";
|
||||
import { findExecutable } from "./executable.js";
|
||||
import { isPaseoOwnedWorktreeCwd } from "./worktree.js";
|
||||
import { requirePaseoWorktreeBaseRefName } from "./worktree-metadata.js";
|
||||
|
||||
@@ -1845,7 +1845,7 @@ export async function createPullRequest(
|
||||
|
||||
await execAsync(`git push -u origin ${head}`, { cwd });
|
||||
|
||||
const ghEnv = { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" };
|
||||
const ghEnv: NodeJS.ProcessEnv = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
const args = ["api", "-X", "POST", `repos/${repo}/pulls`, "-f", `title=${options.title}`];
|
||||
args.push("-f", `head=${head}`);
|
||||
args.push("-f", `base=${normalizedBase}`);
|
||||
@@ -1912,7 +1912,7 @@ async function getPullRequestStatusUncached(cwd: string): Promise<PullRequestSta
|
||||
"--json",
|
||||
"url,title,state,baseRefName,headRefName,mergedAt",
|
||||
],
|
||||
{ cwd, env: { ...resolveShellEnv(), GIT_TERMINAL_PROMPT: "0" } },
|
||||
{ cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } },
|
||||
);
|
||||
const pr = JSON.parse(stdout.trim());
|
||||
if (!pr || typeof pr !== "object" || !pr.url || !pr.title) {
|
||||
|
||||
@@ -11,10 +11,8 @@ type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[
|
||||
function createFindExecutableDependencies(): FindExecutableDependencies {
|
||||
return {
|
||||
execFileSync: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
platform: vi.fn(() => "darwin"),
|
||||
shell: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,25 +75,25 @@ describe("findExecutable", () => {
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe("C:\\nvm4w\\nodejs\\codex");
|
||||
});
|
||||
|
||||
test("uses the last line from login-shell which output", () => {
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue(
|
||||
"echo from profile\n/usr/local/bin/codex\n",
|
||||
test("on Unix, uses the last line from which output", () => {
|
||||
findExecutableDependencies.execFileSync.mockReturnValue(
|
||||
"/usr/local/bin/codex\n",
|
||||
);
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe("/usr/local/bin/codex");
|
||||
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
|
||||
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
|
||||
expect(findExecutableDependencies.execFileSync).toHaveBeenCalledWith(
|
||||
"which",
|
||||
["codex"],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", () => {
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warnSpy).toHaveBeenCalledOnce();
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { execFileSync, execSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { platform } from "node:os";
|
||||
import path from "node:path";
|
||||
import { shellEnvSync } from "shell-env";
|
||||
|
||||
export interface FindExecutableDependencies {
|
||||
execSync: typeof execSync;
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
platform: typeof platform;
|
||||
shell: string | undefined;
|
||||
}
|
||||
|
||||
function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
|
||||
@@ -42,7 +38,7 @@ function resolveWindowsPathEntries(deps: FindExecutableDependencies): string[] {
|
||||
function resolveExecutableFromWhichOutput(
|
||||
name: string,
|
||||
output: string,
|
||||
source: "login-shell" | "which",
|
||||
source: "which",
|
||||
): string | null {
|
||||
const lines = output
|
||||
.split(/\r?\n/)
|
||||
@@ -65,10 +61,8 @@ function resolveExecutableFromWhichOutput(
|
||||
}
|
||||
|
||||
/**
|
||||
* On Unix we first try `$SHELL -lic "which <name>"` so that rc-file PATH
|
||||
* additions (asdf, nvm, homebrew, nix, etc.) are visible — exactly as if the
|
||||
* user opened a terminal and typed the command. If that fails (e.g. the login
|
||||
* shell itself errors) we fall back to a plain `which`.
|
||||
* On Unix we use plain `which` — the daemon's process.env.PATH is enriched
|
||||
* with the login shell environment at Electron desktop startup.
|
||||
*
|
||||
* On Windows we augment the daemon PATH with machine/user registry PATH values
|
||||
* and return the first `where.exe` match. Launch-time execution decides whether
|
||||
@@ -85,11 +79,9 @@ export function findExecutable(
|
||||
}
|
||||
|
||||
const deps: FindExecutableDependencies = {
|
||||
execSync,
|
||||
execFileSync,
|
||||
existsSync,
|
||||
platform,
|
||||
shell: process.env["SHELL"],
|
||||
...dependencies,
|
||||
};
|
||||
|
||||
@@ -125,25 +117,6 @@ export function findExecutable(
|
||||
}
|
||||
}
|
||||
|
||||
// Unix: try the user's login shell so rc-file PATH entries are visible.
|
||||
const shell = deps.shell;
|
||||
if (shell) {
|
||||
try {
|
||||
const out = deps
|
||||
.execSync(`${shell} -lic "which ${trimmed}"`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
})
|
||||
.trim();
|
||||
const resolved = resolveExecutableFromWhichOutput(trimmed, out, "login-shell");
|
||||
if (resolved) {
|
||||
return resolved;
|
||||
}
|
||||
} catch {
|
||||
// Login shell failed (broken rc, etc.) — fall through to plain which.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return resolveExecutableFromWhichOutput(
|
||||
trimmed,
|
||||
@@ -184,14 +157,3 @@ export function quoteWindowsArgument(argument: string): string {
|
||||
return `"${argument}"`;
|
||||
}
|
||||
|
||||
let cachedShellEnv: Record<string, string> | null = null;
|
||||
|
||||
export function resolveShellEnv(): Record<string, string> {
|
||||
if (cachedShellEnv) return cachedShellEnv;
|
||||
try {
|
||||
cachedShellEnv = shellEnvSync();
|
||||
} catch {
|
||||
cachedShellEnv = { ...process.env } as Record<string, string>;
|
||||
}
|
||||
return cachedShellEnv;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user