From aad625ec7786e5353170ec3bd5d607ec646eae20 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Mon, 17 Aug 2026 22:19:42 -0500 Subject: [PATCH 1/4] fix(windows): find Cursor CLI after the official installer Electron often inherits a stale User PATH, so Settings still reports cursor-agent missing after the irm install. Read Machine+User PATH and include the official cursor-agent directory. Co-authored-by: Cursor --- .../src/shell/DesktopShellEnvironment.test.ts | 1 + .../src/shell/DesktopShellEnvironment.ts | 41 ++++--------------- packages/shared/src/shell.test.ts | 20 +++++++++ packages/shared/src/shell.ts | 29 +++++++++++-- 4 files changed, 53 insertions(+), 38 deletions(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 28955debf7b1..baa77fa95610 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -333,6 +333,7 @@ describe("DesktopShellEnvironment", () => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index e065bf55d046..1a39820b7de4 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -8,6 +8,11 @@ import * as Schema from "effect/Schema"; import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { + buildWindowsEnvironmentCaptureCommand, + resolveKnownWindowsCliDirs, +} from "@t3tools/shared/shell"; + import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; type EnvironmentPatch = Record; @@ -196,27 +201,6 @@ const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray return candidates; }; -const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray => [ - ...trimNonEmpty(env.APPDATA).pipe( - Option.match({ - onNone: () => [], - onSome: (value) => [`${value}\\npm`], - }), - ), - ...trimNonEmpty(env.LOCALAPPDATA).pipe( - Option.match({ - onNone: () => [], - onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`], - }), - ), - ...trimNonEmpty(env.USERPROFILE).pipe( - Option.match({ - onNone: () => [], - onSome: (value) => [`${value}\\.local\\bin`, `${value}\\.bun\\bin`, `${value}\\scoop\\shims`], - }), - ), -]; - const startMarker = (name: string) => `__T3CODE_ENV_${name}_START__`; const endMarker = (name: string) => `__T3CODE_ENV_${name}_END__`; @@ -243,18 +227,7 @@ const capturePosixEnvironmentCommand = (names: ReadonlyArray) => }) .join("; "); -const captureWindowsEnvironmentCommand = (names: ReadonlyArray) => - [ - "$ErrorActionPreference = 'Stop'", - ...names.flatMap((name) => { - return [ - `Write-Output '${startMarker(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, - "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", - `Write-Output '${endMarker(name)}'`, - ]; - }), - ].join("; "); +const captureWindowsEnvironmentCommand = buildWindowsEnvironmentCaptureCommand; const extractEnvironment = (output: string, names: ReadonlyArray): EnvironmentPatch => { const environment: EnvironmentPatch = {}; @@ -398,7 +371,7 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin ); const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), - trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), + trimNonEmpty(resolveKnownWindowsCliDirs(config.env).join(";")), trimNonEmpty(noProfile.PATH), readEnvPath(config.env), ]); diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 89ec055f599e..e25fd60e0c04 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -8,6 +8,7 @@ import { extractPathFromShellOutput, CommandAvailability, type CommandAvailabilityChecker, + buildWindowsEnvironmentCaptureCommand, isCommandAvailable, listLoginShellCandidates, mergePathEntries, @@ -236,6 +237,10 @@ describe("readEnvironmentFromWindowsShell", () => { expect.arrayContaining(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]), { encoding: "utf8", timeout: 5000 }, ); + const command = execFile.mock.calls[0]?.[1]?.at(-1); + expect(command).toContain("GetEnvironmentVariable('Path', 'Machine')"); + expect(command).toContain("GetEnvironmentVariable('Path', 'User')"); + expect(command).toContain("GetEnvironmentVariable('Path', 'Process')"); }); it("strips CRLF delimiters from captured PowerShell values", () => { @@ -253,6 +258,9 @@ describe("readEnvironmentFromWindowsShell", () => { expect(readEnvironmentFromWindowsShell(["FNM_DIR"], execFile)).toEqual({ FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm", }); + const command = execFile.mock.calls[0]?.[1]?.at(-1); + expect(command).toContain("GetEnvironmentVariable('FNM_DIR')"); + expect(command).not.toContain("GetEnvironmentVariable('Path', 'User')"); }); it("omits -NoProfile when loadProfile is enabled", () => { @@ -303,6 +311,15 @@ describe("readEnvironmentFromWindowsShell", () => { }); }); +describe("buildWindowsEnvironmentCaptureCommand", () => { + it("merges Machine, User, and Process PATH from the Windows registry", () => { + const command = buildWindowsEnvironmentCaptureCommand(["PATH"]); + expect(command).toContain("GetEnvironmentVariable('Path', 'Machine')"); + expect(command).toContain("GetEnvironmentVariable('Path', 'User')"); + expect(command).toContain("GetEnvironmentVariable('Path', 'Process')"); + }); +}); + describe("mergePathValues", () => { it("dedupes case-insensitively on Windows while preserving preferred order", () => { expect( @@ -336,6 +353,7 @@ describe("resolveKnownWindowsCliDirs", () => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", @@ -477,6 +495,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", @@ -526,6 +545,7 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", "C:\\Users\\testuser\\.local\\bin", "C:\\Users\\testuser\\.bun\\bin", "C:\\Users\\testuser\\scoop\\shims", diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index da6db2166765..4f05b4382e89 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -261,7 +261,22 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { .join("; "); } -function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { +function windowsEnvironmentValueAssignment(name: string): string { + // One-arg GetEnvironmentVariable reads the process env. GUI apps often + // inherit a stale User PATH, so merge Machine + User + Process for PATH. + if (name === "PATH") { + return [ + "$machine = [Environment]::GetEnvironmentVariable('Path', 'Machine')", + "$user = [Environment]::GetEnvironmentVariable('Path', 'User')", + "$process = [Environment]::GetEnvironmentVariable('Path', 'Process')", + "$value = (@($machine, $user, $process) | Where-Object { $_ -and $_.Length -gt 0 }) -join ';'", + ].join("; "); + } + + return `$value = [Environment]::GetEnvironmentVariable('${name}')`; +} + +export function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): string { return [ "$ErrorActionPreference = 'Stop'", ...names.flatMap((name) => { @@ -271,7 +286,7 @@ function buildWindowsEnvironmentCaptureCommand(names: ReadonlyArray): st return [ `Write-Output '${envCaptureStart(name)}'`, - `$value = [Environment]::GetEnvironmentVariable('${name}')`, + windowsEnvironmentValueAssignment(name), "if ($null -ne $value -and $value.Length -gt 0) { Write-Output $value }", `Write-Output '${envCaptureEnd(name)}'`, ]; @@ -680,8 +695,14 @@ export function resolveKnownWindowsCliDirs(env: NodeJS.ProcessEnv): ReadonlyArra return [ ...(appData ? [`${appData}\\npm`] : []), - ...(localAppData ? [`${localAppData}\\Programs\\nodejs`, `${localAppData}\\Volta\\bin`] : []), - ...(localAppData ? [`${localAppData}\\pnpm`] : []), + ...(localAppData + ? [ + `${localAppData}\\Programs\\nodejs`, + `${localAppData}\\Volta\\bin`, + `${localAppData}\\pnpm`, + `${localAppData}\\cursor-agent`, + ] + : []), ...(userProfile ? [`${userProfile}\\.local\\bin`, `${userProfile}\\.bun\\bin`, `${userProfile}\\scoop\\shims`] : []), From 31d03d73dd9ccf9d9dd5c12468f601a82b4e8406 Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Tue, 18 Aug 2026 00:22:10 -0500 Subject: [PATCH 2/4] fix(windows): merge User PATH for every provider CLI Electron's process PATH and the PowerShell one-arg probe miss User/Machine registry Path, so every Settings detector stays Not found. Read HKCU/HKLM Path via reg.exe and merge it in desktop and server PATH repair. Co-authored-by: Cursor --- .../src/shell/DesktopShellEnvironment.test.ts | 30 ++++++ .../src/shell/DesktopShellEnvironment.ts | 3 + packages/shared/src/shell.test.ts | 98 +++++++++++++++++++ packages/shared/src/shell.ts | 66 ++++++++++++- 4 files changed, 196 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index baa77fa95610..f47c5bf3d271 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -1,5 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import { WindowsPersistentPath } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; @@ -69,6 +70,7 @@ function runShellEnvironment(input: { readonly platform: NodeJS.Platform; readonly handler: (command: ChildProcess.Command) => string; readonly failure?: PlatformError.PlatformError; + readonly persistentPath?: string; }) { const environmentLayer = Layer.succeed( DesktopEnvironment.DesktopEnvironment, @@ -84,11 +86,13 @@ function runShellEnvironment(input: { : Effect.fail(input.failure), ), ); + const persistentPathLayer = Layer.succeed(WindowsPersistentPath, () => input.persistentPath); const program = Effect.gen(function* () { const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; yield* shellEnvironment.installIntoProcess; }).pipe( + Effect.provide(persistentPathLayer), Effect.provide( DesktopShellEnvironment.layer.pipe( Layer.provide(Layer.mergeAll(environmentLayer, NodeServices.layer, spawnerLayer)), @@ -348,6 +352,32 @@ describe("DesktopShellEnvironment", () => { }), ); + it.effect("merges persistent User PATH when PowerShell only returns a stale process PATH", () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }; + + yield* runShellEnvironment({ + env, + platform: "win32", + persistentPath: [ + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", + ].join(";"), + handler: () => envOutput({ PATH: "C:\\Windows\\System32" }), + }); + + assert.ok( + env.PATH?.includes("C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links"), + ); + assert.ok(env.PATH?.includes("C:\\Users\\testuser\\AppData\\Local\\cursor-agent")); + }), + ); + it.effect("prefers login-shell desktop session hints over inherited values on linux", () => Effect.gen(function* () { const env: NodeJS.ProcessEnv = { diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 1a39820b7de4..9d5da57b205f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -11,6 +11,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import { buildWindowsEnvironmentCaptureCommand, resolveKnownWindowsCliDirs, + WindowsPersistentPath, } from "@t3tools/shared/shell"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -369,8 +370,10 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin ], { concurrency: 2 }, ); + const readPersistentPath = yield* WindowsPersistentPath; const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), + trimNonEmpty(readPersistentPath(config.env)), trimNonEmpty(resolveKnownWindowsCliDirs(config.env).join(";")), trimNonEmpty(noProfile.PATH), readEnvPath(config.env), diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e25fd60e0c04..e53f4a0ae03e 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -13,15 +13,19 @@ import { listLoginShellCandidates, mergePathEntries, mergePathValues, + parseWindowsRegistryPathValue, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, + readWindowsUserAndMachinePath, resolveCommandPath, resolveKnownWindowsCliDirs, resolveSpawnCommand, resolveWindowsEnvironment, SpawnExecutableResolution, + WindowsPersistentPath, + type WindowsPersistentPathReader, WindowsShellEnvironment, type WindowsShellEnvironmentReader, } from "./shell.ts"; @@ -30,10 +34,12 @@ const withWindowsEnvironmentMocks = ( effect: Effect.Effect, readEnvironment: WindowsShellEnvironmentReader, commandAvailable: CommandAvailabilityChecker, + readPersistentPath: WindowsPersistentPathReader = () => undefined, ) => effect.pipe( Effect.provideService(WindowsShellEnvironment, readEnvironment), Effect.provideService(CommandAvailability, commandAvailable), + Effect.provideService(WindowsPersistentPath, readPersistentPath), ); describe("extractPathFromShellOutput", () => { @@ -320,6 +326,55 @@ describe("buildWindowsEnvironmentCaptureCommand", () => { }); }); +describe("parseWindowsRegistryPathValue", () => { + it("reads REG_EXPAND_SZ Path values from reg.exe output", () => { + expect( + parseWindowsRegistryPathValue( + [ + "HKEY_CURRENT_USER\\Environment", + "", + " Path REG_EXPAND_SZ C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links;C:\\Users\\testuser\\AppData\\Local\\cursor-agent", + "", + ].join("\r\n"), + ), + ).toBe( + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links;C:\\Users\\testuser\\AppData\\Local\\cursor-agent", + ); + }); +}); + +describe("readWindowsUserAndMachinePath", () => { + it("merges User PATH ahead of Machine PATH from reg.exe", () => { + const execFile = vi.fn< + ( + file: string, + args: ReadonlyArray, + options: { encoding: "utf8"; timeout: number }, + ) => string + >((_file, args) => { + const key = args[1] ?? ""; + if (key.startsWith("HKCU\\")) { + return " Path REG_EXPAND_SZ %LOCALAPPDATA%\\Microsoft\\WinGet\\Links;%LOCALAPPDATA%\\cursor-agent\n"; + } + return " Path REG_SZ C:\\Windows\\System32\n"; + }); + + expect( + readWindowsUserAndMachinePath( + { LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", SystemRoot: "C:\\Windows" }, + execFile, + ), + ).toBe( + [ + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", + "C:\\Windows\\System32", + ].join(";"), + ); + expect(execFile.mock.calls[0]?.[0]).toBe("C:\\Windows\\System32\\reg.exe"); + }); +}); + describe("mergePathValues", () => { it("dedupes case-insensitively on Windows while preserving preferred order", () => { expect( @@ -597,4 +652,47 @@ effectIt.layer(NodeServices.layer)("resolveWindowsEnvironment", (it) => { expect(commandAvailable).toHaveBeenCalledTimes(1); }), ); + + it.effect( + "merges persistent User PATH when the PowerShell probe only sees a stale process PATH", + () => + Effect.gen(function* () { + const readEnvironment = vi.fn( + (_names: ReadonlyArray, options?: { loadProfile?: boolean }) => + options?.loadProfile ? {} : { PATH: "C:\\Windows\\System32" }, + ); + const commandAvailable = vi.fn(() => Effect.succeed(true)); + + expect( + yield* withWindowsEnvironmentMocks( + resolveWindowsEnvironment({ + PATH: "C:\\Windows\\System32", + APPDATA: "C:\\Users\\testuser\\AppData\\Roaming", + LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local", + USERPROFILE: "C:\\Users\\testuser", + }), + readEnvironment, + commandAvailable, + () => + [ + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", + ].join(";"), + ), + ).toEqual({ + PATH: [ + "C:\\Users\\testuser\\AppData\\Roaming\\npm", + "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs", + "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin", + "C:\\Users\\testuser\\AppData\\Local\\pnpm", + "C:\\Users\\testuser\\AppData\\Local\\cursor-agent", + "C:\\Users\\testuser\\.local\\bin", + "C:\\Users\\testuser\\.bun\\bin", + "C:\\Users\\testuser\\scoop\\shims", + "C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WinGet\\Links", + "C:\\Windows\\System32", + ].join(";"), + }); + }), + ); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 4f05b4382e89..abc4ebab22e2 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -460,6 +460,64 @@ function readEnvPath(env: NodeJS.ProcessEnv): string | undefined { return env.PATH ?? env.Path ?? env.path; } +export function parseWindowsRegistryPathValue(output: string): string | undefined { + const match = output.match(/^\s*Path\s+REG_(?:EXPAND_)?SZ\s+(.*)$/im); + const value = match?.[1]?.replace(/\r$/, "").trim(); + return value && value.length > 0 ? value : undefined; +} + +function expandWindowsRegistryPathValue(value: string, env: NodeJS.ProcessEnv): string { + return value.replace(/%([^%]+)%/gi, (match, name: string) => { + if (name.toUpperCase() === "PATH") return match; + const resolved = env[name] ?? env[name.toUpperCase()] ?? env[name.toLowerCase()]; + return resolved && resolved.length > 0 ? resolved : match; + }); +} + +function windowsRegExePath(env: NodeJS.ProcessEnv): string { + const root = env.SystemRoot?.trim() || env.SYSTEMROOT?.trim() || "C:\\Windows"; + return `${root}\\System32\\reg.exe`; +} + +function readWindowsRegistryPath( + hive: "HKCU" | "HKLM", + env: NodeJS.ProcessEnv, + execFile: ExecFileSyncLike, +): string | undefined { + const key = + hive === "HKCU" + ? "HKCU\\Environment" + : "HKLM\\SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment"; + try { + const output = execFile(windowsRegExePath(env), ["query", key, "/v", "Path"], { + encoding: "utf8", + timeout: 2000, + }); + const raw = parseWindowsRegistryPathValue(output); + return raw ? expandWindowsRegistryPathValue(raw, env) : undefined; + } catch { + return undefined; + } +} + +export function readWindowsUserAndMachinePath( + env: NodeJS.ProcessEnv = process.env, + execFile: ExecFileSyncLike = NodeChildProcess.execFileSync as ExecFileSyncLike, +): string | undefined { + const user = readWindowsRegistryPath("HKCU", env, execFile); + const machine = readWindowsRegistryPath("HKLM", env, execFile); + return mergePathValues(user, machine, "win32"); +} + +export type WindowsPersistentPathReader = (env: NodeJS.ProcessEnv) => string | undefined; + +export const WindowsPersistentPath = Context.Reference( + "@t3tools/shared/shell/WindowsPersistentPath", + { + defaultValue: () => readWindowsUserAndMachinePath, + }, +); + function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string { return readEnvPath(env) ?? ""; } @@ -739,11 +797,17 @@ export const resolveWindowsEnvironment = Effect.fn("shell.resolveWindowsEnvironm ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const readEnvironment = yield* WindowsShellEnvironment; const commandAvailable = yield* CommandAvailability; + const readPersistentPath = yield* WindowsPersistentPath; const inheritedPath = readEnvPath(env); + const persistentPath = readPersistentPath(env); const shellPath = readWindowsEnvironmentSafely(readEnvironment, ["PATH"], { loadProfile: false, }).PATH; - const mergedPath = mergePathValues(shellPath, inheritedPath, "win32"); + const mergedPath = mergePathValues( + persistentPath, + mergePathValues(shellPath, inheritedPath, "win32"), + "win32", + ); const knownCliPath = resolveKnownWindowsCliDirs(env).join(WINDOWS_PATH_DELIMITER); const baselinePath = mergePathValues(knownCliPath, mergedPath, "win32"); const baselinePatch: Partial = baselinePath ? { PATH: baselinePath } : {}; From cbbba1232df4464528f1105241aa068a15aea0ac Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 25 Aug 2026 09:59:08 +0000 Subject: [PATCH 3/4] fix(windows): keep profile PATH prepends ahead of registry PATH PowerShell PATH capture joined Machine, User, then Process, so profile scripts that prepend toolchain dirs (fnm, etc.) lost to system installs. Join Process first, then Machine and User as supplements. Co-authored-by: Ryan Johnson --- packages/shared/src/shell.test.ts | 4 +++- packages/shared/src/shell.ts | 6 ++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index e53f4a0ae03e..c6bba6728f7c 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -318,11 +318,13 @@ describe("readEnvironmentFromWindowsShell", () => { }); describe("buildWindowsEnvironmentCaptureCommand", () => { - it("merges Machine, User, and Process PATH from the Windows registry", () => { + it("merges Process, Machine, and User PATH with profile prepends first", () => { const command = buildWindowsEnvironmentCaptureCommand(["PATH"]); expect(command).toContain("GetEnvironmentVariable('Path', 'Machine')"); expect(command).toContain("GetEnvironmentVariable('Path', 'User')"); expect(command).toContain("GetEnvironmentVariable('Path', 'Process')"); + expect(command).toContain("@($process, $machine, $user)"); + expect(command).not.toContain("@($machine, $user, $process)"); }); }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index abc4ebab22e2..79bdd7b55337 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -265,11 +265,13 @@ function windowsEnvironmentValueAssignment(name: string): string { // One-arg GetEnvironmentVariable reads the process env. GUI apps often // inherit a stale User PATH, so merge Machine + User + Process for PATH. if (name === "PATH") { + // Profile probes prepend toolchain dirs onto the process PATH (fnm, nvm, etc.). + // Keep Process first so those entries win over Machine/User registry PATH. return [ + "$process = [Environment]::GetEnvironmentVariable('Path', 'Process')", "$machine = [Environment]::GetEnvironmentVariable('Path', 'Machine')", "$user = [Environment]::GetEnvironmentVariable('Path', 'User')", - "$process = [Environment]::GetEnvironmentVariable('Path', 'Process')", - "$value = (@($machine, $user, $process) | Where-Object { $_ -and $_.Length -gt 0 }) -join ';'", + "$value = (@($process, $machine, $user) | Where-Object { $_ -and $_.Length -gt 0 }) -join ';'", ].join("; "); } From 8e7b59d62fcead29a7ab72128b9a188284334b4b Mon Sep 17 00:00:00 2001 From: Ryan Johnson Date: Sun, 30 Aug 2026 08:40:38 -0500 Subject: [PATCH 4/4] feat(provider): add HornetDriver for local Mixr-routed Hornet server. Wire contracts, server adapter, and web icon so t3code can use Hornet as an interim multi-machine provider while desktop runs hornet serve. Co-authored-by: Cursor --- .../src/provider/Drivers/HornetDriver.ts | 123 ++++++ .../src/provider/Layers/HornetAdapter.ts | 352 ++++++++++++++++++ .../src/provider/Layers/HornetProvider.ts | 178 +++++++++ .../src/provider/Services/HornetAdapter.ts | 9 + apps/server/src/provider/builtInDrivers.ts | 3 + .../textGeneration/HornetTextGeneration.ts | 56 +++ .../src/textGeneration/TextGeneration.ts | 8 +- apps/web/src/components/Icons.tsx | 13 + .../src/components/chat/providerIconUtils.ts | 3 +- apps/web/src/session-logic.ts | 6 + docs/internals/providers.md | 4 +- packages/contracts/src/model.ts | 10 + packages/contracts/src/providerRuntime.ts | 1 + packages/contracts/src/settings.ts | 44 +++ pnpm-lock.yaml | 2 + 15 files changed, 809 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/provider/Drivers/HornetDriver.ts create mode 100644 apps/server/src/provider/Layers/HornetAdapter.ts create mode 100644 apps/server/src/provider/Layers/HornetProvider.ts create mode 100644 apps/server/src/provider/Services/HornetAdapter.ts create mode 100644 apps/server/src/textGeneration/HornetTextGeneration.ts diff --git a/apps/server/src/provider/Drivers/HornetDriver.ts b/apps/server/src/provider/Drivers/HornetDriver.ts new file mode 100644 index 000000000000..b88a5397b006 --- /dev/null +++ b/apps/server/src/provider/Drivers/HornetDriver.ts @@ -0,0 +1,123 @@ +import { HornetSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeHornetTextGeneration } from "../../textGeneration/HornetTextGeneration.ts"; +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeHornetAdapter } from "../Layers/HornetAdapter.ts"; +import { checkHornetProviderStatus, makePendingHornetProvider } from "../Layers/HornetProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeHornetSettings = Schema.decodeSync(HornetSettings); + +const DRIVER_KIND = ProviderDriverKind.make("hornet"); + +export type HornetDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto + | HttpClient.HttpClient + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const HornetDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hornet", + supportsMultipleInstances: true, + }, + configSchema: HornetSettings, + defaultConfig: (): HornetSettings => decodeHornetSettings({}), + create: ({ instanceId, displayName, accentColor, enabled, config }) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies HornetSettings; + const maintenanceCapabilities = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + }); + + const adapter = yield* makeHornetAdapter(effectiveConfig, { instanceId }); + const textGeneration = yield* makeHornetTextGeneration(effectiveConfig); + + const checkProvider = checkHornetProviderStatus(effectiveConfig).pipe( + Effect.map(stampIdentity), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingHornetProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => publishSnapshot(snapshot), + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Hornet snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/HornetAdapter.ts b/apps/server/src/provider/Layers/HornetAdapter.ts new file mode 100644 index 000000000000..35d5841ecffc --- /dev/null +++ b/apps/server/src/provider/Layers/HornetAdapter.ts @@ -0,0 +1,352 @@ +import { + EventId, + type HornetSettings, + ProviderDriverKind, + ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderSessionStartInput, + type ProviderSendTurnInput, + type ProviderTurnStartResult, + RuntimeItemId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Stream from "effect/Stream"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, +} from "../Errors.ts"; +import type { HornetAdapterShape } from "../Services/HornetAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("hornet"); +const HORNET_RESUME_VERSION = 1 as const; + +function normalizeBaseUrl(serverUrl: string): string { + const trimmed = serverUrl.trim().replace(/\/+$/, ""); + return trimmed.length > 0 ? trimmed : "http://127.0.0.1:8765"; +} + +function parseResume(raw: unknown): { readonly nodeId: string } | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + if (record.schemaVersion !== HORNET_RESUME_VERSION) { + return undefined; + } + if (typeof record.nodeId !== "string" || record.nodeId.trim().length === 0) { + return undefined; + } + return { nodeId: record.nodeId.trim() }; +} + +interface HornetSessionContext { + readonly threadId: ThreadId; + nodeId: string; + session: ProviderSession; + turns: Array<{ id: TurnId; items: Array }>; + stopped: boolean; +} + +export interface HornetAdapterLiveOptions { + readonly instanceId?: ProviderInstanceId; +} + +export const makeHornetAdapter = Effect.fn("makeHornetAdapter")(function* ( + settings: HornetSettings, + options?: HornetAdapterLiveOptions, +) { + const crypto = yield* Crypto.Crypto; + const http = yield* HttpClient.HttpClient; + const baseUrl = normalizeBaseUrl(settings.serverUrl); + const sessionsRef = yield* Ref.make(new Map()); + const eventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const randomUUIDv4 = crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Hornet runtime identifier.", + cause, + }), + ), + ); + const nextEventId = Effect.map(randomUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const publish = (event: ProviderRuntimeEvent) => + PubSub.publish(eventPubSub, event).pipe(Effect.asVoid); + + const requireSession = (threadId: ThreadId) => + Ref.get(sessionsRef).pipe( + Effect.flatMap((sessions) => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId, + }); + } + return Effect.succeed(ctx); + }), + ); + + const postJson = (path: string, body: unknown, threadId: ThreadId) => + http + .execute( + HttpClientRequest.post(`${baseUrl}${path}`).pipe( + HttpClientRequest.bodyText(JSON.stringify(body), "application/json"), + ), + ) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId, + detail: `Hornet request failed: ${String(cause)}`, + cause, + }), + ), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? response.json.pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: path, + detail: `Hornet ${path} returned invalid JSON`, + cause, + }), + ), + ) + : new ProviderAdapterRequestError({ + provider: PROVIDER, + method: path, + detail: `Hornet ${path} returned HTTP ${response.status}`, + }), + ), + Effect.map((json) => json as T), + ); + + const adapter: HornetAdapterShape = { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "unsupported" }, + + startSession: (input: ProviderSessionStartInput) => + Effect.gen(function* () { + const resume = parseResume(input.resumeCursor); + const payload = yield* postJson<{ + readonly sessionId: string; + readonly nodeId: string; + readonly title?: string; + }>( + "/api/provider/session", + { + threadId: input.threadId, + title: input.title ?? "t3-thread", + cwd: input.cwd, + ...(resume ? { resumeNodeId: resume.nodeId } : {}), + }, + input.threadId, + ); + + const createdAt = yield* nowIso; + const session: ProviderSession = { + threadId: input.threadId, + provider: PROVIDER, + ...(options?.instanceId ? { providerInstanceId: options.instanceId } : {}), + status: "ready", + runtimeMode: input.runtimeMode, + createdAt, + updatedAt: createdAt, + ...(input.cwd ? { cwd: input.cwd } : {}), + resumeCursor: { + schemaVersion: HORNET_RESUME_VERSION, + nodeId: payload.nodeId, + }, + }; + + const ctx: HornetSessionContext = { + threadId: input.threadId, + nodeId: payload.nodeId, + session, + turns: [], + stopped: false, + }; + yield* Ref.update(sessionsRef, (map) => { + const next = new Map(map); + next.set(input.threadId, ctx); + return next; + }); + + const stamp = yield* makeEventStamp(); + yield* publish({ + type: "session.started", + ...stamp, + provider: PROVIDER, + threadId: input.threadId, + payload: { message: "Hornet session ready" }, + raw: { source: "hornet.http", method: "session", payload }, + }); + return session; + }), + + sendTurn: (input: ProviderSendTurnInput) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(yield* randomUUIDv4); + const text = input.input?.trim() ?? ""; + if (!text) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "Hornet turn requires non-empty input.", + }); + } + + ctx.turns = [...ctx.turns, { id: turnId, items: [] }]; + const startedStamp = yield* makeEventStamp(); + yield* publish({ + type: "turn.started", + ...startedStamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: {}, + raw: { source: "hornet.http", method: "turn.started", payload: { turnId } }, + }); + + const result = yield* postJson<{ + readonly assistantText: string; + readonly route?: unknown; + }>( + "/api/provider/turn", + { + sessionId: ctx.nodeId, + input: text, + }, + input.threadId, + ); + + const itemId = RuntimeItemId.make(yield* randomUUIDv4); + const deltaStamp = yield* makeEventStamp(); + yield* publish({ + type: "content.delta", + ...deltaStamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + itemId, + payload: { + streamKind: "assistant_text", + delta: result.assistantText, + }, + raw: { + source: "hornet.http", + method: "turn", + payload: result, + }, + }); + + const completedStamp = yield* makeEventStamp(); + yield* publish({ + type: "turn.completed", + ...completedStamp, + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { state: "completed" as const }, + raw: { source: "hornet.http", method: "turn.completed", payload: result.route ?? {} }, + }); + + const turnResult: ProviderTurnStartResult = { + threadId: input.threadId, + turnId, + resumeCursor: { + schemaVersion: HORNET_RESUME_VERSION, + nodeId: ctx.nodeId, + }, + }; + return turnResult; + }), + + interruptTurn: (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((ctx) => + Effect.gen(function* () { + const stamp = yield* makeEventStamp(); + yield* publish({ + type: "turn.aborted", + ...stamp, + provider: PROVIDER, + threadId, + payload: { reason: "interrupted" }, + raw: { + source: "hornet.http", + method: "interrupt", + payload: { nodeId: ctx.nodeId }, + }, + }); + }), + ), + ), + + respondToRequest: () => Effect.void, + respondToUserInput: () => Effect.void, + + stopSession: (threadId) => + Ref.update(sessionsRef, (map) => { + const next = new Map(map); + next.delete(threadId); + return next; + }), + + listSessions: () => + Ref.get(sessionsRef).pipe( + Effect.map((sessions) => + Array.from(sessions.values()) + .filter((ctx) => !ctx.stopped) + .map((ctx) => ctx.session), + ), + ), + + hasSession: (threadId) => + Ref.get(sessionsRef).pipe(Effect.map((sessions) => sessions.has(threadId))), + + readThread: (threadId) => + requireSession(threadId).pipe( + Effect.map((ctx) => ({ + threadId, + turns: ctx.turns, + })), + ), + + rollbackThread: (threadId, numTurns) => + requireSession(threadId).pipe( + Effect.map((ctx) => { + ctx.turns = ctx.turns.slice(0, Math.max(0, ctx.turns.length - numTurns)); + return { threadId, turns: ctx.turns }; + }), + ), + + stopAll: () => Ref.set(sessionsRef, new Map()), + + streamEvents: Stream.fromPubSub(eventPubSub), + }; + + return adapter; +}); diff --git a/apps/server/src/provider/Layers/HornetProvider.ts b/apps/server/src/provider/Layers/HornetProvider.ts new file mode 100644 index 000000000000..3d88587ce041 --- /dev/null +++ b/apps/server/src/provider/Layers/HornetProvider.ts @@ -0,0 +1,178 @@ +import { + type HornetSettings, + type ModelCapabilities, + type ServerProviderModel, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; + +const HORNET_PRESENTATION = { + displayName: "Hornet", + badgeLabel: "Early Access", + showInteractionModeToggle: false, +} as const; + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); + +const HORNET_BUILT_IN_MODELS: ReadonlyArray = [ + { + slug: "z-ai/glm-5.3-flash", + name: "GLM 5.3 Flash", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "z-ai/glm-5.3", + name: "GLM 5.3", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "minimax/minimax-m3", + name: "MiniMax M3", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "moonshotai/kimi-k3", + name: "Kimi K3", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, + { + slug: "deepseek/deepseek-chat", + name: "DeepSeek Chat", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; + +function normalizeBaseUrl(serverUrl: string): string { + const trimmed = serverUrl.trim().replace(/\/+$/, ""); + return trimmed.length > 0 ? trimmed : "http://127.0.0.1:8765"; +} + +function hornetModels(settings: HornetSettings): ReadonlyArray { + return providerModelsFromSettings( + HORNET_BUILT_IN_MODELS, + settings.customModels ?? [], + EMPTY_CAPABILITIES, + ); +} + +export function makePendingHornetProvider( + settings: HornetSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = hornetModels(settings); + if (!settings.enabled) { + return buildServerProvider({ + presentation: HORNET_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hornet is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + presentation: HORNET_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Hornet desk…", + }, + }); + }); +} + +export function checkHornetProviderStatus( + settings: HornetSettings, +): Effect.Effect { + return Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = hornetModels(settings); + const baseUrl = normalizeBaseUrl(settings.serverUrl); + + if (!settings.enabled) { + return buildServerProvider({ + presentation: HORNET_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hornet is disabled in T3 Code settings.", + }, + }); + } + + const unreachable = buildServerProvider({ + presentation: HORNET_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: `Couldn't reach Hornet at ${baseUrl}. Run \`hornet serve \` on this machine.`, + }, + }); + + const body = yield* http.execute(HttpClientRequest.get(`${baseUrl}/api/health`)).pipe( + Effect.flatMap((response) => response.json), + Effect.timeout("3 seconds"), + Effect.orElseSucceed(() => null), + ); + + if (body === null || typeof body !== "object") { + return unreachable; + } + + const record = body as { readonly ok?: boolean; readonly version?: string }; + const version = typeof record.version === "string" ? record.version : null; + return buildServerProvider({ + presentation: HORNET_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version, + status: record.ok === true ? "ready" : "warning", + auth: { status: "authenticated", type: "hornet" }, + message: + record.ok === true + ? `Hornet desk reachable at ${baseUrl}.` + : `Hornet responded unexpectedly from ${baseUrl}.`, + }, + }); + }); +} diff --git a/apps/server/src/provider/Services/HornetAdapter.ts b/apps/server/src/provider/Services/HornetAdapter.ts new file mode 100644 index 000000000000..c788def99781 --- /dev/null +++ b/apps/server/src/provider/Services/HornetAdapter.ts @@ -0,0 +1,9 @@ +/** + * HornetAdapter — shape type for the Hornet HTTP provider adapter. + * + * @module HornetAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface HornetAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..89d0057195fb 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -24,6 +24,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; import { GrokDriver, type GrokDriverEnv } from "./Drivers/GrokDriver.ts"; +import { HornetDriver, type HornetDriverEnv } from "./Drivers/HornetDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -37,6 +38,7 @@ export type BuiltInDriversEnv = | CodexDriverEnv | CursorDriverEnv | GrokDriverEnv + | HornetDriverEnv | OpenCodeDriverEnv; /** @@ -49,5 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray { + const line = message + .split(/\r?\n/) + .map((part) => part.trim()) + .find((part) => part.length > 0); + return line ?? "update"; + }; + + return TextGeneration.TextGeneration.of({ + generateCommitMessage: (input) => + Effect.sync(() => { + const subject = sanitizeCommitSubject(firstLine(input.stagedSummary || input.stagedPatch)); + return { + subject, + body: "", + ...(input.includeBranch ? { branch: sanitizeFeatureBranchName(subject) } : {}), + }; + }), + generatePrContent: (input) => + Effect.sync(() => { + const title = sanitizePrTitle(firstLine(input.commitSummary || input.diffSummary)); + return { + title, + body: input.diffSummary.slice(0, 4_000), + }; + }), + generateBranchName: (input) => + Effect.sync(() => ({ + branch: sanitizeFeatureBranchName(firstLine(input.message)), + })), + generateThreadTitle: (input) => + Effect.sync(() => ({ + title: sanitizeThreadTitle(firstLine(input.message)), + })), + }); +}); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 66b7ccd465f1..534d1513dbff 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -8,7 +8,13 @@ import * as ProviderInstanceRegistry from "../provider/Services/ProviderInstance import type { ProviderInstance } from "../provider/ProviderDriver.ts"; import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export type TextGenerationProvider = "codex" | "claudeAgent" | "cursor" | "grok" | "opencode"; +export type TextGenerationProvider = + | "codex" + | "claudeAgent" + | "cursor" + | "grok" + | "hornet" + | "opencode"; export interface CommitMessageGenerationInput { cwd: string; diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..457d3576fdcb 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -663,6 +663,19 @@ export const OpenCodeIcon: Icon = (props) => ( ); +/** DevCentr Hornet harness mark — simple stinger hex for the provider picker. */ +export const HornetIcon: Icon = (props) => ( + + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( > = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("hornet")]: HornetIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index cd5e2aa11d74..9c2281983ad1 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("hornet"), + label: "Hornet", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/docs/internals/providers.md b/docs/internals/providers.md index a309d70f03de..cb4bc019ff4b 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -7,7 +7,7 @@ orchestration layer does not know which one is behind a thread. ## Built-in drivers -[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with five entries: +[`builtInDrivers.ts`][drivers] exports `BUILT_IN_DRIVERS` with six entries: | Driver kind | Driver source | | ------------- | --------------------------------------- | @@ -15,6 +15,7 @@ orchestration layer does not know which one is behind a thread. | `claudeAgent` | [`Drivers/ClaudeDriver.ts`][claude] | | `cursor` | [`Drivers/CursorDriver.ts`][cursor] | | `grok` | [`Drivers/GrokDriver.ts`][grok] | +| `hornet` | [`Drivers/HornetDriver.ts`][hornet] | | `opencode` | [`Drivers/OpenCodeDriver.ts`][opencode] | Each driver declares its `driverKind`, a `configSchema`, and a `create` function that builds an @@ -80,6 +81,7 @@ when a request opens (approval) or user input is requested, via [claude]: ../../apps/server/src/provider/Drivers/ClaudeDriver.ts [cursor]: ../../apps/server/src/provider/Drivers/CursorDriver.ts [grok]: ../../apps/server/src/provider/Drivers/GrokDriver.ts +[hornet]: ../../apps/server/src/provider/Drivers/HornetDriver.ts [opencode]: ../../apps/server/src/provider/Drivers/OpenCodeDriver.ts [adapter]: ../../apps/server/src/provider/Services/ProviderAdapter.ts [instances]: ../../apps/server/src/provider/Services/ProviderInstanceRegistry.ts diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..2b84f7c0fcc2 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const HORNET_DRIVER_KIND = ProviderDriverKind.make("hornet"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [HORNET_DRIVER_KIND]: "Hornet", }; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..740061c17c52 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -26,6 +26,7 @@ const RuntimeEventRawSource = Schema.Union([ Schema.Literal("claude.sdk.permission"), Schema.Literal("codex.sdk.thread-event"), Schema.Literal("opencode.sdk.event"), + Schema.Literal("hornet.http"), Schema.Literal("acp.jsonrpc"), Schema.TemplateLiteral(["acp.", Schema.String, ".extension"]), ]); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 143087b35430..1c84e8c0460c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -460,6 +460,41 @@ export const GrokSettings = makeProviderSettingsSchema( ); export type GrokSettings = typeof GrokSettings.Type; +export const HornetSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("hornet").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Hornet binary (optional when serverUrl is set).", + providerSettingsForm: { placeholder: "hornet", clearWhenEmpty: "omit" }, + }), + ), + serverUrl: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("http://127.0.0.1:8765")), + Schema.annotateKey({ + title: "Server URL", + description: "Hornet desk base URL (`hornet serve`). Required for the HornetDriver.", + providerSettingsForm: { + placeholder: "http://127.0.0.1:8765", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["serverUrl", "binaryPath"], + }, +); +export type HornetSettings = typeof HornetSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -651,6 +686,7 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), grok: GrokSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hornet: HornetSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -747,6 +783,13 @@ const GrokSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HornetSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + serverUrl: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -795,6 +838,7 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), grok: Schema.optionalKey(GrokSettingsPatch), + hornet: Schema.optionalKey(HornetSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c79aea36a0e..8465aa5d4a76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5184,10 +5184,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==}