diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 28955debf7b1..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)), @@ -333,6 +337,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", @@ -347,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 e065bf55d046..9d5da57b205f 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -8,6 +8,12 @@ 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, + WindowsPersistentPath, +} from "@t3tools/shared/shell"; + import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; type EnvironmentPatch = Record; @@ -196,27 +202,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 +228,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 = {}; @@ -396,9 +370,11 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin ], { concurrency: 2 }, ); + const readPersistentPath = yield* WindowsPersistentPath; const mergedPath = mergePaths("win32", [ trimNonEmpty(profile.PATH), - trimNonEmpty(knownWindowsCliDirs(config.env).join(";")), + trimNonEmpty(readPersistentPath(config.env)), + trimNonEmpty(resolveKnownWindowsCliDirs(config.env).join(";")), trimNonEmpty(noProfile.PATH), readEnvPath(config.env), ]); 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/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 89ec055f599e..c6bba6728f7c 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -8,19 +8,24 @@ import { extractPathFromShellOutput, CommandAvailability, type CommandAvailabilityChecker, + buildWindowsEnvironmentCaptureCommand, isCommandAvailable, listLoginShellCandidates, mergePathEntries, mergePathValues, + parseWindowsRegistryPathValue, readEnvironmentFromLoginShell, readEnvironmentFromWindowsShell, readPathFromLaunchctl, readPathFromLoginShell, + readWindowsUserAndMachinePath, resolveCommandPath, resolveKnownWindowsCliDirs, resolveSpawnCommand, resolveWindowsEnvironment, SpawnExecutableResolution, + WindowsPersistentPath, + type WindowsPersistentPathReader, WindowsShellEnvironment, type WindowsShellEnvironmentReader, } from "./shell.ts"; @@ -29,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", () => { @@ -236,6 +243,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 +264,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 +317,66 @@ describe("readEnvironmentFromWindowsShell", () => { }); }); +describe("buildWindowsEnvironmentCaptureCommand", () => { + 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)"); + }); +}); + +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( @@ -336,6 +410,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 +552,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 +602,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", @@ -577,4 +654,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 da6db2166765..79bdd7b55337 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -261,7 +261,24 @@ 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") { + // 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')", + "$value = (@($process, $machine, $user) | 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 +288,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)}'`, ]; @@ -445,6 +462,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) ?? ""; } @@ -680,8 +755,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`] : []), @@ -718,11 +799,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 } : {}; 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==}