diff --git a/README.md b/README.md index c439743cea5..34ac5be1392 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,77 @@ # T3 Code -T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, and OpenCode, more coming soon). +T3 Code is a minimal web GUI for coding agents (currently Codex, Claude, OpenCode, Hermes, and Pi, more coming soon). ## Installation > [!WARNING] -> T3 Code currently supports Codex, Claude, and OpenCode. +> T3 Code currently supports Codex, Claude, OpenCode, Hermes, and Pi. > Install and authenticate at least one provider before use: > > - Codex: install [Codex CLI](https://developers.openai.com/codex/cli) and run `codex login` > - Claude: install [Claude Code](https://claude.com/product/claude-code) and run `claude auth login` > - OpenCode: install [OpenCode](https://opencode.ai) and run `opencode auth login` +> - Hermes: install [Hermes Agent](https://github.com/nousresearch/hermes-agent) and run `hermes model` +> - Pi: install [Pi Agent](https://github.com/earendil-works/pi) plus `pi-acp`, then run `pi` + +Hermes setup notes: [docs/providers/hermes.md](./docs/providers/hermes.md) +Pi setup notes: [docs/providers/pi.md](./docs/providers/pi.md) +Release readiness checklist: [docs/providers/release-readiness.md](./docs/providers/release-readiness.md) + +## Hermes Agent support + +T3 Code can run [Hermes Agent](https://github.com/nousresearch/hermes-agent) as a local ACP +provider. Enable Hermes from **Settings -> Providers**, point the binary path at your local +`hermes` executable, then select Hermes from the chat model picker. + +![Hermes themed chat surface](./docs/assets/hermes-chat-theme.jpg) + +Recommended macOS setup: + +```bash +git clone https://github.com/nousresearch/hermes-agent.git ~/Projects/hermes-agent +cd ~/Projects/hermes-agent +python3 -m venv venv +./venv/bin/pip install -e . +mkdir -p ~/.local/bin +ln -sf ~/Projects/hermes-agent/venv/bin/hermes ~/.local/bin/hermes +~/.local/bin/hermes model +``` + +T3 Code auto-detects common Hermes paths such as `~/.local/bin/hermes`, +`~/Projects/hermes-agent/venv/bin/hermes`, `/opt/homebrew/bin/hermes`, and `/usr/local/bin/hermes`. +On Windows, use a full path such as `C:\Users\you\Projects\hermes-agent\venv\Scripts\hermes.exe`. +Hermes manages authentication through its own CLI and local config; T3 Code starts `hermes acp` +only when a Hermes conversation needs it. + +Full setup and troubleshooting guide: [docs/providers/hermes.md](./docs/providers/hermes.md) + +## Pi Agent support + +T3 Code can run [Pi Agent](https://github.com/earendil-works/pi) through the +[`pi-acp`](https://github.com/svkozak/pi-acp) adapter. Enable Pi from +**Settings -> Providers**, set the ACP adapter path to `pi-acp`, set the Pi binary path to `pi` or +an absolute path, then select Pi from the chat model picker. + +![Pi themed chat surface](./docs/assets/pi-chat-theme.jpg) + +```bash +npm install -g @earendil-works/pi-coding-agent pi-acp +pi --version +pi-acp --help +``` + +T3 Code passes the configured Pi binary to the adapter with `PI_ACP_PI_COMMAND`, which keeps the +packaged desktop app working even when npm's global binary directory is not on the GUI app `PATH`. +On Windows, npm shims usually live under `C:\Users\you\AppData\Roaming\npm\pi-acp.cmd` and +`C:\Users\you\AppData\Roaming\npm\pi.cmd`. +Provider Settings also exposes a Pi update action, so users can run `pi update` from the same +place they configure the provider. + +For GPT-5.5, run `pi`, use `/login`, choose ChatGPT Plus/Pro Codex, and set Pi's defaults to +`openai-codex` with `gpt-5.5`. + +Full setup and troubleshooting guide: [docs/providers/pi.md](./docs/providers/pi.md) ### Run without installing diff --git a/apps/server/package.json b/apps/server/package.json index 387c880a9ab..2d82f8ef75f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -15,7 +15,7 @@ ], "type": "module", "scripts": { - "dev": "node --watch src/bin.ts", + "dev": "node scripts/dev-watch.mjs", "build": "node scripts/cli.ts build", "build:bundle": "tsdown", "start": "node dist/bin.mjs", diff --git a/apps/server/scripts/dev-watch.mjs b/apps/server/scripts/dev-watch.mjs new file mode 100644 index 00000000000..afd28bb3cb7 --- /dev/null +++ b/apps/server/scripts/dev-watch.mjs @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { watch } from "node:fs"; +import { stat } from "node:fs/promises"; +import { dirname, extname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const serverDir = resolve(scriptsDir, ".."); +const srcDir = resolve(serverDir, "src"); +const entry = resolve(srcDir, "bin.ts"); +const sourceExtensions = new Set([".cjs", ".cts", ".js", ".json", ".mjs", ".mts", ".ts"]); + +let child; +let restartTimer; +let stopping = false; + +function start() { + child = spawn(process.execPath, [entry], { + stdio: "inherit", + env: process.env, + cwd: serverDir, + }); + + child.on("exit", (code, signal) => { + if (stopping || restartTimer) { + return; + } + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +async function shouldRestart(filename) { + if (!filename || typeof filename !== "string") { + return false; + } + + const changedPath = resolve(srcDir, filename); + if (!changedPath.startsWith(`${srcDir}/`)) { + return false; + } + + try { + const changedStat = await stat(changedPath); + if (changedStat.isDirectory()) { + return false; + } + } catch { + // Deleted source files still need a restart. + } + + return sourceExtensions.has(extname(changedPath)); +} + +function scheduleRestart() { + clearTimeout(restartTimer); + restartTimer = setTimeout(() => { + restartTimer = undefined; + console.log("Restarting server..."); + + const previous = child; + previous.once("exit", () => { + if (!stopping) { + start(); + } + }); + previous.kill("SIGTERM"); + }, 100); +} + +const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => { + if (await shouldRestart(filename)) { + scheduleRestart(); + } +}); + +function shutdown(signal) { + stopping = true; + clearTimeout(restartTimer); + watcher.close(); + if (!child || child.killed) { + process.exit(0); + } + child.once("exit", () => process.exit(0)); + child.kill(signal); +} + +process.on("SIGINT", () => shutdown("SIGINT")); +process.on("SIGTERM", () => shutdown("SIGTERM")); + +start(); diff --git a/apps/server/src/provider/Drivers/HermesDriver.ts b/apps/server/src/provider/Drivers/HermesDriver.ts new file mode 100644 index 00000000000..46bbca42da5 --- /dev/null +++ b/apps/server/src/provider/Drivers/HermesDriver.ts @@ -0,0 +1,175 @@ +/** + * HermesDriver — `ProviderDriver` for the Hermes Agent (`hermes`) runtime. + * + * Hermes exposes an ACP-based CLI. The driver is still a plain value, but + * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` + * hook to run the slow ACP model-capability probe in the background without + * blocking the initial `ready`-state publish. + * + * Text generation is supported via the ACP runtime — `makeHermesTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/HermesDriver + */ +import { HermesSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { makeHermesTextGeneration } from "../../textGeneration/HermesTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeHermesAdapter } from "../Layers/HermesAdapter.ts"; +import { + buildInitialHermesProviderSnapshot, + checkHermesProviderStatus, + enrichHermesSnapshot, +} from "../Layers/HermesProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +const DRIVER_KIND = ProviderDriverKind.make("hermes"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: "hermes", + updateArgs: ["update"], + updateLockKey: "hermes-agent", + }), +); + +export type HermesDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +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 HermesDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Hermes", + supportsMultipleInstances: true, + }, + configSchema: HermesSettings, + defaultConfig: (): HermesSettings => decodeHermesSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies HermesSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makeHermesAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makeHermesTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkHermesProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialHermesProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + // Keep version-advisory enrichment off the initial provider snapshot + // so Hermes never blocks server startup or settings rendering. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichHermesSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + publishSnapshot, + stampIdentity, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Hermes 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/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts new file mode 100644 index 00000000000..576d1de016a --- /dev/null +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -0,0 +1,175 @@ +/** + * PiDriver — `ProviderDriver` for the Pi Agent (`pi`) runtime. + * + * Pi exposes an ACP-based CLI. The driver is still a plain value, but + * its snapshot uses `makeManagedServerProvider`'s optional `enrichSnapshot` + * hook to run the slow ACP model-capability probe in the background without + * blocking the initial `ready`-state publish. + * + * Text generation is supported via the ACP runtime — `makePiTextGeneration` + * drives `runtime.prompt` with a structured-output schema and collects the + * agent's `agent_message_chunk` stream into a single JSON blob. + * + * @module provider/Drivers/PiDriver + */ +import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { ServerConfig } from "../../config.ts"; +import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makePiAdapter } from "../Layers/PiAdapter.ts"; +import { + buildInitialPiProviderSnapshot, + checkPiProviderStatus, + enrichPiSnapshot, +} from "../Layers/PiProvider.ts"; +import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { + makeProviderMaintenanceCapabilities, + makeStaticProviderMaintenanceResolver, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +const decodePiSettings = Schema.decodeSync(PiSettings); + +const DRIVER_KIND = ProviderDriverKind.make("pi"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: "pi", + updateArgs: ["update"], + updateLockKey: "pi-agent", + }), +); + +export type PiDriverEnv = + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | HttpClient.HttpClient + | Path.Path + | ProviderEventLoggers + | ServerConfig; + +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 PiDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Pi", + supportsMultipleInstances: true, + }, + configSchema: PiSettings, + defaultConfig: (): PiSettings => decodePiSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies PiSettings; + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + + const adapter = yield* makePiAdapter(effectiveConfig, { + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + instanceId, + }); + const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkPiProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + buildInitialPiProviderSnapshot(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + // Keep version-advisory enrichment off the initial provider snapshot + // so Pi never blocks server startup or settings rendering. + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + enrichPiSnapshot({ + snapshot: currentSnapshot, + maintenanceCapabilities, + publishSnapshot, + stampIdentity, + httpClient, + }), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Pi 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/HermesAdapter.ts b/apps/server/src/provider/Layers/HermesAdapter.ts new file mode 100644 index 00000000000..82966dcb106 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesAdapter.ts @@ -0,0 +1,952 @@ +/** + * HermesAdapterLive — Hermes CLI (`hermes acp`) via ACP. + * + * @module HermesAdapterLive + */ + +import { + ApprovalRequestId, + type HermesSettings, + EventId, + type ProviderApprovalDecision, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type RuntimeMode, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { makeHermesAcpRuntime } from "../acp/HermesAcpSupport.ts"; +import { type HermesAdapterShape } from "../Services/HermesAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const PROVIDER = ProviderDriverKind.make("hermes"); +const HERMES_RESUME_VERSION = 1 as const; +const ACP_PLAN_MODE_ALIASES = ["plan", "architect"]; +const ACP_IMPLEMENT_MODE_ALIASES = ["code", "agent", "default", "chat", "implement"]; +const ACP_APPROVAL_MODE_ALIASES = ["ask"]; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface HermesAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`hermes`). + */ + readonly instanceId?: typeof ProviderInstanceId.Type; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `hermesSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface HermesSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseHermesResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== HERMES_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +function normalizeModeSearchText(mode: AcpSessionMode): string { + return [mode.id, mode.name, mode.description] + .filter((value): value is string => typeof value === "string" && value.length > 0) + .join(" ") + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim(); +} + +function findModeByAliases( + modes: ReadonlyArray, + aliases: ReadonlyArray, +): AcpSessionMode | undefined { + const normalizedAliases = aliases.map((alias) => alias.toLowerCase()); + for (const alias of normalizedAliases) { + const exact = modes.find((mode) => { + const id = mode.id.toLowerCase(); + const name = mode.name.toLowerCase(); + return id === alias || name === alias; + }); + if (exact) { + return exact; + } + } + for (const alias of normalizedAliases) { + const partial = modes.find((mode) => normalizeModeSearchText(mode).includes(alias)); + if (partial) { + return partial; + } + } + return undefined; +} + +function isPlanMode(mode: AcpSessionMode): boolean { + return findModeByAliases([mode], ACP_PLAN_MODE_ALIASES) !== undefined; +} + +function resolveRequestedModeId(input: { + readonly interactionMode: ProviderInteractionMode | undefined; + readonly runtimeMode: RuntimeMode; + readonly modeState: AcpSessionModeState | undefined; +}): string | undefined { + const modeState = input.modeState; + if (!modeState) { + return undefined; + } + + if (input.interactionMode === "plan") { + return findModeByAliases(modeState.availableModes, ACP_PLAN_MODE_ALIASES)?.id; + } + + if (input.runtimeMode === "approval-required") { + return ( + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); + } + + return ( + findModeByAliases(modeState.availableModes, ACP_IMPLEMENT_MODE_ALIASES)?.id ?? + findModeByAliases(modeState.availableModes, ACP_APPROVAL_MODE_ALIASES)?.id ?? + modeState.availableModes.find((mode) => !isPlanMode(mode))?.id ?? + modeState.currentModeId + ); +} + +function applyRequestedSessionConfiguration(input: { + readonly runtime: AcpSessionRuntimeShape; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly mapError: (context: { + readonly cause: import("effect-acp/errors").AcpError; + readonly method: "session/set_mode"; + }) => E; +}): Effect.Effect { + return Effect.gen(function* () { + const requestedModeId = resolveRequestedModeId({ + interactionMode: input.interactionMode, + runtimeMode: input.runtimeMode, + modeState: yield* input.runtime.getModeState, + }); + if (!requestedModeId) { + return; + } + + yield* input.runtime.setMode(requestedModeId).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_mode", + }), + ), + ); + }); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function makeHermesAdapter( + hermesSettings: HermesSettings, + options?: HermesAdapterLiveOptions, +) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("hermes"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.hermes.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* Random.nextUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: HermesSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.hermes.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: HermesSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: HermesAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: HermesSessionContext; + + const resumeSessionId = parseHermesResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the HermesSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { hermes: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectiveHermesSettings = options?.resolveSettings + ? yield* options.resolveSettings + : hermesSettings; + + const acp = yield* makeHermesAcpRuntime({ + hermesSettings: effectiveHermesSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + yield* applyRequestedSessionConfiguration({ + runtime: acp, + runtimeMode: input.runtimeMode, + interactionMode: undefined, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + threadId: input.threadId, + resumeCursor: { + schemaVersion: HERMES_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text: event.text, + rawPayload: event.rawPayload, + }), + ); + return; + } + }), + ), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Hermes ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: HermesAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + mapError: ({ cause, method }) => + mapAcpToAdapterError(PROVIDER, input.threadId, method, cause), + }); + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model ?? "hermes-default" }, + }); + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }); + + const interruptTurn: HermesAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: HermesAdapterShape["respondToRequest"] = ( + threadId, + requestId, + decision, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: HermesAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "hermes/ask_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: HermesAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: HermesAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: HermesAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: HermesAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: HermesAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: HermesAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies HermesAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/HermesProvider.test.ts b/apps/server/src/provider/Layers/HermesProvider.test.ts new file mode 100644 index 00000000000..ee9600059d6 --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.test.ts @@ -0,0 +1,378 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { HermesSettings } from "@t3tools/contracts"; +import { + checkHermesProviderStatus, + getHermesFallbackModels, + parseHermesConfigModelDefaults, + resolveHermesBinary, +} from "./HermesProvider.ts"; + +const encoder = new TextEncoder(); +const decodeHermesSettings = Schema.decodeSync(HermesSettings); + +function mockHandle(result: { + stdout?: string; + stderr?: string; + code?: number; +}) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.make(encoder.encode(result.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout?: string; stderr?: string; code?: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + return Effect.succeed( + mockHandle(handler(childProcess.command, childProcess.args)), + ); + }), + ); +} + +function failingSpawnerLayer(description: string) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description, + }), + ), + ), + ); +} + +const makeHermesSettings = ( + overrides?: Partial, +): HermesSettings => + decodeHermesSettings({ + enabled: true, + binaryPath: "hermes", + customModels: [], + ...overrides, + }); + +const makeTempHome = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.makeTempDirectoryScoped({ prefix: "t3-hermes-test-" }); +}); + +const writeTestFile = (filePath: string, contents: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); + }); + +describe("parseHermesConfigModelDefaults", () => { + it("reads the model.default value from Hermes config.yaml", () => { + assert.deepEqual( + parseHermesConfigModelDefaults(` +model: + provider: openai-codex + base_url: https://chatgpt.com/backend-api/codex + default: gpt-5.5 +`), + { defaultModel: "gpt-5.5", malformed: false }, + ); + }); + + it("ignores unrelated default keys outside the model block", () => { + assert.deepEqual( + parseHermesConfigModelDefaults(` +default: wrong +model: + provider: openai-codex +tools: + default: also-wrong +`), + { defaultModel: null, malformed: true }, + ); + }); + + it("parses quoted model defaults and strips inline comments", () => { + assert.deepEqual( + parseHermesConfigModelDefaults(` +model: + default: "gpt-5.5" # selected in hermes model +`), + { defaultModel: "gpt-5.5", malformed: false }, + ); + }); +}); + +describe("getHermesFallbackModels", () => { + it("keeps a built-in fallback model plus configured custom models", () => { + const models = getHermesFallbackModels( + makeHermesSettings({ customModels: ["nous/hermes-4", "hermes-default"] }), + ); + + assert.deepEqual( + models.map((model) => [model.slug, model.name, model.isCustom]), + [ + ["hermes-default", "Hermes Default", false], + ["nous/hermes-4", "nous/hermes-4", true], + ], + ); + }); +}); + +describe("checkHermesProviderStatus", () => { + it.effect("reports ready status and parses --version output", () => + Effect.gen(function* () { + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") { + assert.equal(command, "/bin/zsh"); + return { stdout: "", code: 1 }; + } + assert.deepEqual(args, ["--version"]); + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }), + ); + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: "/tmp/no-hermes-config", + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "0.11.0"); + assert.equal(snapshot.models[0]?.slug, "hermes-default"); + }), + ); + + it.effect( + "uses the detected common macOS binary path for the default hermes command", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + const binaryPath = path.join(home, ".local/bin/hermes"); + yield* writeTestFile(binaryPath, ""); + + const resolution = yield* resolveHermesBinary(makeHermesSettings(), { + HOME: home, + }); + + assert.equal(resolution.binaryPath, binaryPath); + assert.equal(resolution.suggestedBinaryPath, binaryPath); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "detects ~/.local/bin/hermes when a packaged macOS app has an empty PATH", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + const binaryPath = path.join(home, ".local/bin/hermes"); + yield* writeTestFile(binaryPath, ""); + + const layer = mockSpawnerLayer((command, args) => { + assert.equal(command, binaryPath); + assert.deepEqual(args, ["--version"]); + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }); + + const snapshot = yield* checkHermesProviderStatus( + makeHermesSettings(), + { + HOME: home, + PATH: "", + SHELL: "/bin/zsh", + }, + ).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.version, "0.11.0"); + assert.equal(snapshot.suggestedBinaryPath, binaryPath); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "suggests a detected path when an explicit binary path is invalid", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + const binaryPath = path.join(home, ".local/bin/hermes"); + yield* writeTestFile(binaryPath, ""); + + const snapshot = yield* checkHermesProviderStatus( + makeHermesSettings({ binaryPath: path.join(home, "missing/hermes") }), + { HOME: home }, + ); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.suggestedBinaryPath, binaryPath); + assert.match(snapshot.message ?? "", /Detected Hermes/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "reports missing Hermes clearly for a clean install with no CLI", + () => + Effect.gen(function* () { + const home = yield* makeTempHome; + + const snapshot = yield* checkHermesProviderStatus( + makeHermesSettings(), + { + HOME: home, + PATH: "", + SHELL: "/bin/zsh", + }, + ).pipe(Effect.provide(failingSpawnerLayer("spawn hermes ENOENT"))); + + assert.equal(snapshot.status, "error"); + assert.equal(snapshot.installed, false); + assert.equal(snapshot.suggestedBinaryPath, undefined); + assert.equal(snapshot.models[0]?.slug, "hermes-default"); + assert.match(snapshot.message ?? "", /not installed or not on PATH/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("shows the configured Hermes model from config.yaml", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + yield* writeTestFile( + path.join(home, ".hermes/config.yaml"), + ` +model: + provider: openai-codex + default: gpt-5.5 +`, + ); + const layer = mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + assert.equal(command, "hermes"); + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }); + + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: home, + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.models[0]?.slug, "gpt-5.5"); + assert.equal(snapshot.models[0]?.name, "GPT 5.5"); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "keeps a clean install with Hermes installed but no config ready with fallback model", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + const binaryPath = path.join(home, ".local/bin/hermes"); + yield* writeTestFile(binaryPath, ""); + const layer = mockSpawnerLayer((command, args) => { + assert.equal(command, binaryPath); + assert.deepEqual(args, ["--version"]); + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }); + + const snapshot = yield* checkHermesProviderStatus( + makeHermesSettings(), + { + HOME: home, + PATH: "", + }, + ).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.installed, true); + assert.equal(snapshot.models[0]?.slug, "hermes-default"); + assert.equal(snapshot.message, undefined); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("guides setup when Hermes config has no model.default", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempHome; + yield* writeTestFile( + path.join(home, ".hermes/config.yaml"), + "model:\n provider: openai-codex\n", + ); + const layer = mockSpawnerLayer((_command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + return { stdout: "Hermes Agent v0.11.0\n", code: 0 }; + }); + + const snapshot = yield* checkHermesProviderStatus(makeHermesSettings(), { + HOME: home, + }).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "ready"); + assert.match(snapshot.message ?? "", /hermes model/); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "surfaces stderr when the Hermes CLI health check exits nonzero", + () => + Effect.gen(function* () { + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((_command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + return { + stderr: "ACP failed to start: missing provider credentials", + code: 2, + }; + }), + ); + + const snapshot = yield* checkHermesProviderStatus( + makeHermesSettings(), + { + HOME: "/tmp/no-hermes-config", + }, + ).pipe(Effect.provide(layer)); + + assert.equal(snapshot.status, "warning"); + assert.match(snapshot.message ?? "", /ACP failed to start/); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/HermesProvider.ts b/apps/server/src/provider/Layers/HermesProvider.ts new file mode 100644 index 00000000000..a1049d961cb --- /dev/null +++ b/apps/server/src/provider/Layers/HermesProvider.ts @@ -0,0 +1,532 @@ +import type { + HermesSettings, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const PROVIDER = ProviderDriverKind.make("hermes"); +const HERMES_PRESENTATION = { + displayName: "Hermes", + badgeLabel: "Early Access", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const HERMES_FALLBACK_MODEL: ServerProviderModel = { + slug: "hermes-default", + name: "Hermes Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; +const HERMES_DEFAULT_MODELS: ReadonlyArray = [HERMES_FALLBACK_MODEL]; +const ABOUT_TIMEOUT_MS = 4_000; +const LOGIN_SHELL_TIMEOUT_MS = 2_000; + +export interface HermesConfigModelDefaults { + readonly defaultModel: string | null; + readonly malformed: boolean; +} + +export interface HermesBinaryResolution { + readonly binaryPath: string; + readonly suggestedBinaryPath: string | null; +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +export function parseHermesConfigModelDefaults(raw: string): HermesConfigModelDefaults { + const lines = raw.replace(/\r\n?/g, "\n").split("\n"); + let inModelBlock = false; + let modelIndent = 0; + let sawModelBlock = false; + + for (const line of lines) { + const withoutComment = line.replace(/\s*#.*$/, ""); + if (withoutComment.trim().length === 0) continue; + + const indent = withoutComment.length - withoutComment.trimStart().length; + const trimmed = withoutComment.trim(); + const topLevelModelMatch = /^model\s*:\s*$/.exec(trimmed); + if (topLevelModelMatch && indent === 0) { + inModelBlock = true; + sawModelBlock = true; + modelIndent = indent; + continue; + } + + if (inModelBlock && indent <= modelIndent) { + inModelBlock = false; + } + + if (!inModelBlock) continue; + const defaultMatch = /^default\s*:\s*(.+?)\s*$/.exec(trimmed); + if (!defaultMatch?.[1]) continue; + + const value = stripQuotes(defaultMatch[1]); + return { defaultModel: value.length > 0 ? value : null, malformed: false }; + } + + return { defaultModel: null, malformed: sawModelBlock }; +} + +function formatHermesModelName(slug: string): string { + return slug + .split(/[/-]/g) + .filter((segment) => segment.length > 0) + .map((segment) => { + if (/^gpt$/i.test(segment)) return "GPT"; + if (/^\d/.test(segment)) return segment; + return segment[0]!.toUpperCase() + segment.slice(1); + }) + .join(" "); +} + +function modelFromHermesDefault(defaultModel: string | null): ServerProviderModel { + const slug = defaultModel?.trim(); + if (!slug) return HERMES_FALLBACK_MODEL; + return { + slug, + name: formatHermesModelName(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; +} + +function readHermesConfigModelDefaults( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.HERMES_HOME?.trim() || path.join(environment.HOME || NodeOS.homedir(), ".hermes"); + const raw = yield* fs + .readFileString(path.join(home, "config.yaml")) + .pipe(Effect.catch(() => Effect.succeed(null))); + return raw ? parseHermesConfigModelDefaults(raw) : { defaultModel: null, malformed: false }; + }); +} + +function isExplicitBinaryPath(binaryPath: string): boolean { + return binaryPath.includes("/") || binaryPath.includes("\\") || binaryPath.startsWith("~"); +} + +function commonHermesBinaryCandidates( + environment: NodeJS.ProcessEnv, +): Effect.Effect, never, Path.Path> { + return Effect.gen(function* () { + const path = yield* Path.Path; + const home = environment.HOME || environment.USERPROFILE || NodeOS.homedir(); + if (process.platform === "win32") { + const appData = environment.APPDATA; + const localAppData = environment.LOCALAPPDATA; + return [ + path.join(home, ".local", "bin", "hermes.exe"), + path.join(home, ".local", "bin", "hermes.cmd"), + path.join(home, "Projects", "hermes-agent", "venv", "Scripts", "hermes.exe"), + path.join(home, "Projects", "hermes-agent", "venv", "Scripts", "hermes.cmd"), + ...(appData ? [path.join(appData, "Python", "Scripts", "hermes.exe")] : []), + ...(localAppData + ? [path.join(localAppData, "Programs", "Python", "Python312", "Scripts", "hermes.exe")] + : []), + ]; + } + return [ + path.join(home, ".local/bin/hermes"), + path.join(home, "Projects/hermes-agent/venv/bin/hermes"), + "/opt/homebrew/bin/hermes", + "/usr/local/bin/hermes", + ]; + }); +} + +function expandHomePath(input: string, environment: NodeJS.ProcessEnv): string { + const home = environment.HOME || environment.USERPROFILE || NodeOS.homedir(); + if (input === "~") return home; + if (input.startsWith("~/") || input.startsWith("~\\")) return `${home}${input.slice(1)}`; + return input; +} + +function firstExistingPath( + candidates: ReadonlyArray, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const candidate of candidates) { + const exists = yield* fs.exists(candidate).pipe(Effect.catch(() => Effect.succeed(false))); + if (exists) return candidate; + } + return null; + }); +} + +function detectHermesFromLoginShell( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + if (process.platform === "win32") { + return runRawCommand("where", ["hermes"], environment).pipe( + Effect.timeoutOption(LOGIN_SHELL_TIMEOUT_MS), + Effect.map((result) => { + if (Option.isNone(result) || result.value.code !== 0) return null; + const candidate = result.value.stdout.trim().split(/\r?\n/u)[0]?.trim(); + return candidate && candidate.length > 0 ? candidate : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); + } + + const shell = environment.SHELL?.trim() || "/bin/zsh"; + return runRawCommand(shell, ["-lc", "command -v hermes"], environment).pipe( + Effect.timeoutOption(LOGIN_SHELL_TIMEOUT_MS), + Effect.map((result) => { + if (Option.isNone(result) || result.value.code !== 0) return null; + const candidate = result.value.stdout.trim().split(/\r?\n/u)[0]?.trim(); + return candidate && candidate.length > 0 ? candidate : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); +} + +export function resolveHermesBinary( + hermesSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, +): Effect.Effect< + HermesBinaryResolution, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const configured = hermesSettings.binaryPath.trim() || "hermes"; + const explicitConfigured = isExplicitBinaryPath(configured); + const expandedConfigured = explicitConfigured + ? expandHomePath(configured, environment) + : configured; + const candidates = yield* commonHermesBinaryCandidates(environment); + const detected = + (yield* firstExistingPath(candidates)) ?? (yield* detectHermesFromLoginShell(environment)); + + if (!explicitConfigured && configured === "hermes" && detected) { + return { binaryPath: detected, suggestedBinaryPath: detected }; + } + + return { + binaryPath: expandedConfigured, + suggestedBinaryPath: detected && detected !== expandedConfigured ? detected : null, + }; + }); +} + +function getHermesModels( + hermesSettings: Pick, + defaults: HermesConfigModelDefaults, +): ReadonlyArray { + return providerModelsFromSettings( + [modelFromHermesDefault(defaults.defaultModel)], + PROVIDER, + hermesSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function getHermesFallbackModels( + hermesSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings( + HERMES_DEFAULT_MODELS, + PROVIDER, + hermesSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function buildInitialHermesProviderSnapshot( + hermesSettings: HermesSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getHermesFallbackModels(hermesSettings); + + if (!hermesSettings.enabled) { + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Hermes Agent availability...", + }, + }); + }); +} + +interface HermesAboutResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +function extractVersion(raw: string): string | null { + const plain = stripAnsi(raw); + const aboutMatch = /^CLI Version\s{2,}(.+)$/im.exec(plain); + if (aboutMatch?.[1]) return aboutMatch[1].trim(); + const versionMatch = /\b(?:hermes(?:-agent)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i.exec(plain); + return versionMatch?.[1]?.trim() ?? null; +} + +function parseHermesAboutOutput(result: CommandResult): HermesAboutResult { + const combined = `${result.stdout}\n${result.stderr}`; + const lower = combined.toLowerCase(); + const version = extractVersion(combined); + const detail = stripAnsi(combined).trim(); + + if (lower.includes("not logged in") || lower.includes("authentication required")) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: "Hermes Agent is not authenticated. Run `hermes setup` or `hermes model`.", + }; + } + + if (result.code === 0) { + return { + version, + status: "ready", + auth: { status: "unknown" }, + }; + } + + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: detail + ? `Hermes Agent CLI health check exited with code ${result.code}: ${detail}` + : "Hermes Agent is installed, but T3 Code could not verify its auth status.", + }; +} + +const runRawCommand = ( + binaryPath: string, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(binaryPath, [...args], { + env: environment, + shell: process.platform === "win32", + }); + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +const runHermesCommand = ( + binaryPath: string, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => runRawCommand(binaryPath, args, environment); + +const runHermesAboutCommand = (binaryPath: string, environment: NodeJS.ProcessEnv = process.env) => + runHermesCommand(binaryPath, ["--version"], environment).pipe( + Effect.catch(() => runHermesCommand(binaryPath, ["about"], environment)), + ); + +export const checkHermesProviderStatus = Effect.fn("checkHermesProviderStatus")(function* ( + hermesSettings: HermesSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const configDefaults = yield* readHermesConfigModelDefaults(environment); + const models = getHermesModels(hermesSettings, configDefaults); + const binaryResolution = yield* resolveHermesBinary(hermesSettings, environment); + const withHermesHints = (snapshot: ServerProviderDraft): ServerProviderDraft => ({ + ...snapshot, + ...(binaryResolution.suggestedBinaryPath + ? { suggestedBinaryPath: binaryResolution.suggestedBinaryPath } + : {}), + }); + + if (!hermesSettings.enabled) { + return withHermesHints( + buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes is disabled in T3 Code settings.", + }, + }), + ); + } + + const probe = yield* runHermesAboutCommand(binaryResolution.binaryPath, environment).pipe( + Effect.timeoutOption(ABOUT_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(probe)) { + const error = + probe.failure instanceof Error + ? probe.failure + : new Error(typeof probe.failure === "string" ? probe.failure : String(probe.failure)); + const isMissing = isCommandMissingCause(error); + const missingMessage = binaryResolution.suggestedBinaryPath + ? `Hermes Agent CLI was not found at \`${binaryResolution.binaryPath}\`. Detected Hermes at \`${binaryResolution.suggestedBinaryPath}\`; use the detected path or update Binary path.` + : "Hermes Agent CLI (`hermes`) is not installed or not on PATH."; + return withHermesHints( + buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: !isMissing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: isMissing + ? missingMessage + : `Failed to execute Hermes Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }), + ); + } + + if (Option.isNone(probe.success)) { + return withHermesHints( + buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Hermes Agent CLI is installed but timed out during the health check.", + }, + }), + ); + } + + const parsed = parseHermesAboutOutput(probe.success.value); + const configMessage = + parsed.status === "ready" && configDefaults.malformed + ? "Hermes Agent is ready, but `~/.hermes/config.yaml` has no `model.default`; run `hermes model` to choose the model T3 Code should display." + : parsed.message; + return withHermesHints( + buildServerProvider({ + presentation: HERMES_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: parsed.version, + status: parsed.status, + auth: parsed.auth, + ...(configMessage ? { message: configMessage } : {}), + }, + }), + ); +}); + +export const enrichHermesSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const stampIdentity = input.stampIdentity ?? ((value) => value); + return enrichProviderSnapshotWithVersionAdvisory( + input.snapshot, + input.maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((snapshot) => input.publishSnapshot(stampIdentity(snapshot))), + Effect.catchCause((cause) => + Effect.logWarning("Hermes version advisory enrichment failed", { + cause: Cause.pretty(cause), + }), + ), + ); +}; diff --git a/apps/server/src/provider/Layers/PiAdapter.test.ts b/apps/server/src/provider/Layers/PiAdapter.test.ts new file mode 100644 index 00000000000..e922eca7152 --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { sanitizePiAssistantTextDelta } from "./PiAdapter.ts"; + +describe("sanitizePiAssistantTextDelta", () => { + it("removes Pi CLI update notices while preserving assistant text", () => { + expect( + sanitizePiAssistantTextDelta( + "New version available: v0.75.3 (installed v0.73.1). Run: npm i -g @earendil-works/pi-coding-agent PI_OK", + ), + ).toBe("PI_OK"); + }); + + it("leaves normal assistant text untouched", () => { + expect(sanitizePiAssistantTextDelta("PI_OK")).toBe("PI_OK"); + }); +}); diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts new file mode 100644 index 00000000000..5ea36d0523c --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -0,0 +1,836 @@ +/** + * PiAdapterLive — Pi CLI (`pi acp`) via ACP. + * + * @module PiAdapterLive + */ + +import { + ApprovalRequestId, + type PiSettings, + EventId, + type ProviderApprovalDecision, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + ProviderDriverKind, + ProviderInstanceId, + RuntimeRequestId, + type ThreadId, + TurnId, +} from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Random from "effect/Random"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import * as SynchronizedRef from "effect/SynchronizedRef"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "../acp/AcpAdapterSupport.ts"; +import { type AcpSessionRuntimeShape } from "../acp/AcpSessionRuntime.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "../acp/AcpCoreRuntimeEvents.ts"; +import { parsePermissionRequest } from "../acp/AcpRuntimeModel.ts"; +import { makeAcpNativeLoggers } from "../acp/AcpNativeLogging.ts"; +import { makePiAcpRuntime } from "../acp/PiAcpSupport.ts"; +import { type PiAdapterShape } from "../Services/PiAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); + +const PROVIDER = ProviderDriverKind.make("pi"); +const PI_RESUME_VERSION = 1 as const; + +function encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +export interface PiAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + /** + * Selections are honored when `modelSelection.instanceId` matches this value. + * Defaults to the legacy built-in instance id (`pi`). + */ + readonly instanceId?: typeof ProviderInstanceId.Type; + /** + * Optional per-session settings resolver. When provided the adapter yields + * this effect at the start of every session and uses the result instead of + * the `piSettings` captured at construction. + * + * Production instances bind settings to the instance scope (the hydration + * layer rebuilds the adapter on config change) and leave this undefined. + * Test suites that mutate `ServerSettingsService` mid-flight — e.g. to + * swap `binaryPath` to a mock ACP wrapper — pass a resolver that reads + * the latest snapshot so the closure isn't stale. + */ + readonly resolveSettings?: Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface PiSessionContext { + readonly threadId: ThreadId; + session: ProviderSession; + readonly scope: Scope.Closeable; + readonly acp: AcpSessionRuntimeShape; + notificationFiber: Fiber.Fiber | undefined; + readonly pendingApprovals: Map; + readonly pendingUserInputs: Map; + readonly turns: Array<{ id: TurnId; items: Array }>; + lastPlanFingerprint: string | undefined; + activeTurnId: TurnId | undefined; + stopped: boolean; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingApprovals.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + const pendingEntries = Array.from(pendingUserInputs.values()); + return Effect.forEach( + pendingEntries, + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { + discard: true, + }, + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parsePiResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== PI_RESUME_VERSION) return undefined; + if (typeof raw.sessionId !== "string" || !raw.sessionId.trim()) return undefined; + return { sessionId: raw.sessionId.trim() }; +} + +const PI_CLI_UPDATE_NOTICE_PATTERN = + /(^|\n)New version available: v[^\n]*\(installed v[^\n]*\)\. Run: npm i -g @earendil-works\/pi-coding-agent\s*/g; + +export function sanitizePiAssistantTextDelta(text: string): string { + return text.replace(PI_CLI_UPDATE_NOTICE_PATTERN, "$1"); +} + +function selectAutoApprovedPermissionOption( + request: EffectAcpSchema.RequestPermissionRequest, +): string | undefined { + const allowAlwaysOption = request.options.find((option) => option.kind === "allow_always"); + if (typeof allowAlwaysOption?.optionId === "string" && allowAlwaysOption.optionId.trim()) { + return allowAlwaysOption.optionId.trim(); + } + + const allowOnceOption = request.options.find((option) => option.kind === "allow_once"); + if (typeof allowOnceOption?.optionId === "string" && allowOnceOption.optionId.trim()) { + return allowOnceOption.optionId.trim(); + } + + return undefined; +} + +export function makePiAdapter(piSettings: PiSettings, options?: PiAdapterLiveOptions) { + return Effect.gen(function* () { + const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("pi"); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const serverConfig = yield* Effect.service(ServerConfig); + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { + stream: "native", + }) + : undefined); + const managedNativeEventLogger = + options?.nativeEventLogger === undefined ? nativeEventLogger : undefined; + + const sessions = new Map(); + const threadLocksRef = yield* SynchronizedRef.make(new Map()); + const runtimeEventPubSub = yield* PubSub.unbounded(); + + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + const nextEventId = Effect.map(Random.nextUUIDv4, (id) => EventId.make(id)); + const makeEventStamp = () => Effect.all({ eventId: nextEventId, createdAt: nowIso }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => + PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid); + + const getThreadSemaphore = (threadId: string) => + SynchronizedRef.modifyEffect(threadLocksRef, (current) => { + const existing: Option.Option = Option.fromNullishOr( + current.get(threadId), + ); + return Option.match(existing, { + onNone: () => + Semaphore.make(1).pipe( + Effect.map((semaphore) => { + const next = new Map(current); + next.set(threadId, semaphore); + return [semaphore, next] as const; + }), + ), + onSome: (semaphore) => Effect.succeed([semaphore, current] as const), + }); + }); + + const withThreadLock = (threadId: string, effect: Effect.Effect) => + Effect.flatMap(getThreadSemaphore(threadId), (semaphore) => semaphore.withPermit(effect)); + + const logNative = ( + threadId: ThreadId, + method: string, + payload: unknown, + _source: "acp.jsonrpc" | "acp.pi.extension", + ) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* Random.nextUUIDv4, + kind: "notification", + provider: PROVIDER, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: PiSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + source: "acp.jsonrpc" | "acp.pi.extension", + method: string, + ) => + Effect.gen(function* () { + const fingerprint = `${ctx.activeTurnId ?? "no-turn"}:${encodeJsonStringForDiagnostics(payload) ?? "[unserializable payload]"}`; + if (ctx.lastPlanFingerprint === fingerprint) { + return; + } + ctx.lastPlanFingerprint = fingerprint; + yield* offerRuntimeEvent( + makeAcpPlanUpdatedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source, + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail( + new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }), + ); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: PiSessionContext) => + Effect.gen(function* () { + if (ctx.stopped) return; + ctx.stopped = true; + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.notificationFiber) { + yield* Fiber.interrupt(ctx.notificationFiber); + } + yield* Effect.ignore(Scope.close(ctx.scope, Exit.void)); + sessions.delete(ctx.threadId); + yield* offerRuntimeEvent({ + type: "session.exited", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: PiAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== PROVIDER) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Expected provider '${PROVIDER}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const existing = sessions.get(input.threadId); + if (existing && !existing.stopped) { + yield* stopSessionInternal(existing); + } + + const pendingApprovals = new Map(); + const pendingUserInputs = new Map(); + const sessionScope = yield* Scope.make("sequential"); + let sessionScopeTransferred = false; + yield* Effect.addFinalizer(() => + sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void), + ); + let ctx!: PiSessionContext; + + const resumeSessionId = parsePiResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider: PROVIDER, + threadId: input.threadId, + }); + + // Resolve the PiSettings used to spawn the ACP child. Production + // leaves `options.resolveSettings` undefined so we use the value + // captured at adapter construction — per-instance isolation is + // enforced by the hydration layer rebuilding this adapter whenever + // its config changes. Tests set `resolveSettings` to pull the latest + // snapshot from `ServerSettingsService` so that mid-suite + // `updateSettings({ providers: { pi: { binaryPath } } })` calls + // actually take effect when the next session spawns. + const effectivePiSettings = options?.resolveSettings + ? yield* options.resolveSettings + : piSettings; + + const acp = yield* makePiAcpRuntime({ + piSettings: effectivePiSettings, + ...(options?.environment ? { environment: options.environment } : {}), + childProcessSpawner, + cwd, + ...(resumeSessionId ? { resumeSessionId } : {}), + clientInfo: { name: "t3-code", version: "0.0.0" }, + ...acpNativeLoggers, + }).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: cause.message, + cause, + }), + ), + ); + const started = yield* Effect.gen(function* () { + yield* acp.handleRequestPermission((params) => + Effect.gen(function* () { + yield* logNative( + input.threadId, + "session/request_permission", + params, + "acp.jsonrpc", + ); + if (input.runtimeMode === "full-access") { + const autoApprovedOptionId = selectAutoApprovedPermissionOption(params); + if (autoApprovedOptionId !== undefined) { + return { + outcome: { + outcome: "selected" as const, + optionId: autoApprovedOptionId, + }, + }; + } + } + const permissionRequest = parsePermissionRequest(params); + const requestId = ApprovalRequestId.make(crypto.randomUUID()); + const runtimeRequestId = RuntimeRequestId.make(requestId); + const decision = yield* Deferred.make(); + pendingApprovals.set(requestId, { + decision, + kind: permissionRequest.kind, + }); + yield* offerRuntimeEvent( + makeAcpRequestOpenedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + detail: + permissionRequest.detail ?? + encodeJsonStringForDiagnostics(params)?.slice(0, 2000) ?? + "[unserializable params]", + args: params, + source: "acp.jsonrpc", + method: "session/request_permission", + rawPayload: params, + }), + ); + const resolved = yield* Deferred.await(decision); + pendingApprovals.delete(requestId); + yield* offerRuntimeEvent( + makeAcpRequestResolvedEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: input.threadId, + turnId: ctx?.activeTurnId, + requestId: runtimeRequestId, + permissionRequest, + decision: resolved, + }), + ); + return { + outcome: + resolved === "cancel" + ? ({ outcome: "cancelled" } as const) + : { + outcome: "selected" as const, + optionId: acpPermissionOutcome(resolved), + }, + }; + }), + ); + return yield* acp.start(); + }).pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), + ), + ); + + const now = yield* nowIso; + const session: ProviderSession = { + provider: PROVIDER, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + threadId: input.threadId, + resumeCursor: { + schemaVersion: PI_RESUME_VERSION, + sessionId: started.sessionId, + }, + createdAt: now, + updatedAt: now, + }; + + ctx = { + threadId: input.threadId, + session, + scope: sessionScope, + acp, + notificationFiber: undefined, + pendingApprovals, + pendingUserInputs, + turns: [], + lastPlanFingerprint: undefined, + activeTurnId: undefined, + stopped: false, + }; + + const nf = yield* Stream.runDrain( + Stream.mapEffect(acp.getEvents(), (event) => + Effect.gen(function* () { + switch (event._tag) { + case "ModeChanged": + return; + case "AssistantItemStarted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* emitPlanUpdate( + ctx, + event.payload, + event.rawPayload, + "acp.jsonrpc", + "session/update", + ); + return; + case "ToolCallUpdated": + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": { + const text = sanitizePiAssistantTextDelta(event.text); + if (text.length === 0) { + return; + } + + yield* logNative( + ctx.threadId, + "session/update", + event.rawPayload, + "acp.jsonrpc", + ); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + provider: PROVIDER, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + ...(event.itemId ? { itemId: event.itemId } : {}), + text, + rawPayload: event.rawPayload, + }), + ); + return; + } + } + }), + ), + ).pipe(Effect.forkChild); + + ctx.notificationFiber = nf; + sessions.set(input.threadId, ctx); + sessionScopeTransferred = true; + + yield* offerRuntimeEvent({ + type: "session.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { state: "ready", reason: "Pi ACP session ready" }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: PiAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { model: ctx.session.model ?? "pi-default" }, + }); + + const promptParts: Array = []; + if (input.input?.trim()) { + promptParts.push({ type: "text", text: input.input.trim() }); + } + if (input.attachments && input.attachments.length > 0) { + for (const attachment of input.attachments) { + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/prompt", + detail: cause.message, + cause, + }), + ), + ); + promptParts.push({ + type: "image", + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }); + } + } + + if (promptParts.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Turn requires non-empty text or attachments.", + }); + } + + const result = yield* ctx.acp + .prompt({ + prompt: promptParts, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, input.threadId, "session/prompt", error), + ), + ); + + ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + provider: PROVIDER, + threadId: input.threadId, + turnId, + payload: { + state: result.stopReason === "cancelled" ? "cancelled" : "completed", + stopReason: result.stopReason ?? null, + }, + }); + + return { + threadId: input.threadId, + turnId, + resumeCursor: ctx.session.resumeCursor, + }; + }); + + const interruptTurn: PiAdapterShape["interruptTurn"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); + yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + yield* Effect.ignore( + ctx.acp.cancel.pipe( + Effect.mapError((error) => + mapAcpToAdapterError(PROVIDER, threadId, "session/cancel", error), + ), + ), + ); + }); + + const respondToRequest: PiAdapterShape["respondToRequest"] = (threadId, requestId, decision) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingApprovals.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: PiAdapterShape["respondToUserInput"] = ( + threadId, + requestId, + answers, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + const pending = ctx.pendingUserInputs.get(requestId); + if (!pending) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "pi/ask_question", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: PiAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: PiAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "rollbackThread", + issue: "numTurns must be an integer >= 1.", + }); + } + const nextLength = Math.max(0, ctx.turns.length - numTurns); + ctx.turns.splice(nextLength); + return { threadId, turns: ctx.turns }; + }); + + const stopSession: PiAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: PiAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: PiAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: PiAdapterShape["stopAll"] = () => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }); + + yield* Effect.addFinalizer(() => + Effect.forEach(sessions.values(), stopSessionInternal, { discard: true }).pipe( + Effect.tap(() => PubSub.shutdown(runtimeEventPubSub)), + Effect.tap(() => managedNativeEventLogger?.close() ?? Effect.void), + ), + ); + + const streamEvents = Stream.fromPubSub(runtimeEventPubSub); + + return { + provider: PROVIDER, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents, + } satisfies PiAdapterShape; + }); +} diff --git a/apps/server/src/provider/Layers/PiProvider.test.ts b/apps/server/src/provider/Layers/PiProvider.test.ts new file mode 100644 index 00000000000..f004658d529 --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { PiSettings } from "@t3tools/contracts"; +import { + checkPiProviderStatus, + getPiFallbackModels, + parsePiConfigModelDefaults, +} from "./PiProvider.ts"; + +const encoder = new TextEncoder(); +const decodePiSettings = Schema.decodeSync(PiSettings); + +function mockHandle(result: { stdout?: string; stderr?: string; code?: number }) { + return ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.make(encoder.encode(result.stdout ?? "")), + stderr: Stream.make(encoder.encode(result.stderr ?? "")), + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }); +} + +function mockSpawnerLayer( + handler: ( + command: string, + args: ReadonlyArray, + ) => { stdout?: string; stderr?: string; code?: number }, +) { + return Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => { + const childProcess = command as unknown as { + readonly command: string; + readonly args: ReadonlyArray; + }; + return Effect.succeed(mockHandle(handler(childProcess.command, childProcess.args))); + }), + ); +} + +const makePiSettings = (overrides?: Partial): PiSettings => + decodePiSettings({ + enabled: true, + binaryPath: "pi-acp", + piBinaryPath: "pi", + customModels: [], + ...overrides, + }); + +describe("getPiFallbackModels", () => { + it("includes the fallback Pi model and custom models", () => { + const models = getPiFallbackModels(makePiSettings({ customModels: ["openai/gpt-5"] })); + expect(models.map((model) => [model.slug, model.name, model.isCustom])).toEqual([ + ["pi-default", "Pi Default", false], + ["openai/gpt-5", "openai/gpt-5", true], + ]); + }); +}); + +describe("parsePiConfigModelDefaults", () => { + it("reads defaultModel from Pi settings.json", () => { + expect( + parsePiConfigModelDefaults(`{ + "defaultProvider": "anthropic", + "defaultModel": "claude-haiku-4-5" + }`), + ).toEqual({ + defaultModel: "claude-haiku-4-5", + defaultProvider: "anthropic", + malformed: false, + }); + }); +}); + +describe("checkPiProviderStatus", () => { + it("detects pi-acp without spawning it, then verifies the Pi CLI", async () => { + const snapshot = await Effect.runPromise( + Effect.gen(function* () { + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + expect(command).toBe("/tmp/bin/pi"); + expect(args).toEqual(["--version"]); + return { stdout: "0.62.0\n", code: 0 }; + }), + ); + + return yield* checkPiProviderStatus( + makePiSettings({ + binaryPath: "/opt/homebrew/bin/pi-acp", + piBinaryPath: "/tmp/bin/pi", + }), + { + HOME: "/tmp/no-pi-config", + PATH: "", + }, + ).pipe(Effect.provide(layer)); + }), + ); + + expect(snapshot.status).toBe("ready"); + expect(snapshot.installed).toBe(true); + expect(snapshot.version).toBe("0.62.0"); + expect(snapshot.models[0]?.slug).toBe("pi-default"); + }); + + it("reports expired Pi OAuth as unauthenticated", async () => { + const snapshot = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-auth-test-" }); + const piDir = path.join(home, ".pi", "agent"); + yield* fs.makeDirectory(piDir, { recursive: true }); + yield* fs.writeFileString( + path.join(piDir, "settings.json"), + [ + "{", + ' "defaultProvider": "anthropic",', + ' "defaultModel": "claude-opus-4-6"', + "}", + ].join("\n"), + ); + yield* fs.writeFileString( + path.join(piDir, "auth.json"), + [ + "{", + ' "anthropic": {', + ' "type": "oauth",', + ' "access": "redacted",', + ' "refresh": "redacted",', + ` "expires": ${Date.UTC(2026, 2, 24)}`, + " }", + "}", + ].join("\n"), + ); + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + expect(command).toBe("/tmp/bin/pi"); + return { stdout: "0.62.0\n", code: 0 }; + }), + ); + + return yield* checkPiProviderStatus( + makePiSettings({ + binaryPath: "/opt/homebrew/bin/pi-acp", + piBinaryPath: "/tmp/bin/pi", + }), + { + HOME: home, + PATH: "", + }, + ).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("expired"); + expect(snapshot.message).toContain("anthropic"); + }); + + it("explains the Codex login path for missing GPT-5.5 Pi auth", async () => { + const snapshot = await Effect.runPromise( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pi-codex-auth-test-" }); + const piDir = path.join(home, ".pi", "agent"); + yield* fs.makeDirectory(piDir, { recursive: true }); + yield* fs.writeFileString( + path.join(piDir, "settings.json"), + [ + "{", + ' "defaultProvider": "openai-codex",', + ' "defaultModel": "gpt-5.5"', + "}", + ].join("\n"), + ); + yield* fs.writeFileString(path.join(piDir, "auth.json"), "{}"); + const layer = Layer.merge( + NodeServices.layer, + mockSpawnerLayer((command, args) => { + if (args[0] === "-lc") return { stdout: "", code: 1 }; + expect(command).toBe("/tmp/bin/pi"); + return { stdout: "0.62.0\n", code: 0 }; + }), + ); + + return yield* checkPiProviderStatus( + makePiSettings({ + binaryPath: "/opt/homebrew/bin/pi-acp", + piBinaryPath: "/tmp/bin/pi", + }), + { + HOME: home, + PATH: "", + }, + ).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + expect(snapshot.status).toBe("error"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.models[0]?.slug).toBe("gpt-5.5"); + expect(snapshot.models.map((model) => model.slug)).toContain("gpt-5.4"); + expect(snapshot.message).toContain("openai-codex"); + expect(snapshot.message).toContain("ChatGPT Plus/Pro (Codex)"); + }); +}); diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts new file mode 100644 index 00000000000..e12ebb21e2e --- /dev/null +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -0,0 +1,747 @@ +import type { + PiSettings, + ServerProvider, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import * as NodeOS from "node:os"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Result from "effect/Result"; +import { HttpClient } from "effect/unstable/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; + +const PROVIDER = ProviderDriverKind.make("pi"); +const PI_PRESENTATION = { + displayName: "Pi", + badgeLabel: "Early Access", + showInteractionModeToggle: false, +} as const; +const EMPTY_CAPABILITIES = createModelCapabilities({ optionDescriptors: [] }); +const PI_FALLBACK_MODEL: ServerProviderModel = { + slug: "pi-default", + name: "Pi Default", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +}; +const PI_OPENAI_CODEX_MODELS: ReadonlyArray = [ + "gpt-5.5", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5.1", +].map((slug) => ({ + slug, + name: formatPiModelName(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, +})); +const PI_DEFAULT_MODELS: ReadonlyArray = [PI_FALLBACK_MODEL]; +const ABOUT_TIMEOUT_MS = 4_000; +const LOGIN_SHELL_TIMEOUT_MS = 2_000; + +export interface PiConfigModelDefaults { + readonly defaultModel: string | null; + readonly defaultProvider: string | null; + readonly malformed: boolean; +} + +export interface PiAuthState { + readonly status: "valid" | "expired" | "missing" | "unknown"; + readonly provider: string | null; + readonly expiresAt: string | null; +} + +export interface PiBinaryResolution { + readonly binaryPath: string; + readonly suggestedBinaryPath: string | null; +} + +function stripQuotes(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +export function parsePiConfigModelDefaults(raw: string): PiConfigModelDefaults { + const jsonDefaultProvider = extractJsonStringField(raw, "defaultProvider"); + const jsonDefaultModel = extractJsonStringField(raw, "defaultModel"); + if (jsonDefaultModel?.trim()) { + return { + defaultModel: jsonDefaultModel.trim(), + defaultProvider: jsonDefaultProvider?.trim() || null, + malformed: false, + }; + } + + const lines = raw.replace(/\r\n?/g, "\n").split("\n"); + let inModelBlock = false; + let modelIndent = 0; + let sawModelBlock = false; + + for (const line of lines) { + const withoutComment = line.replace(/\s+#.*$/, ""); + if (withoutComment.trim().length === 0) continue; + + const indent = withoutComment.length - withoutComment.trimStart().length; + const trimmed = withoutComment.trim(); + const topLevelModelMatch = /^model\s*:\s*$/.exec(trimmed); + if (topLevelModelMatch && indent === 0) { + inModelBlock = true; + sawModelBlock = true; + modelIndent = indent; + continue; + } + + if (inModelBlock && indent <= modelIndent) { + inModelBlock = false; + } + + if (!inModelBlock) continue; + const defaultMatch = /^default\s*:\s*(.+?)\s*$/.exec(trimmed); + if (!defaultMatch?.[1]) continue; + + const value = stripQuotes(defaultMatch[1]); + return { + defaultModel: value.length > 0 ? value : null, + defaultProvider: jsonDefaultProvider?.trim() || null, + malformed: false, + }; + } + + return { + defaultModel: null, + defaultProvider: jsonDefaultProvider?.trim() || null, + malformed: sawModelBlock, + }; +} + +function formatPiModelName(slug: string): string { + return slug + .split(/[/-]/g) + .filter((segment) => segment.length > 0) + .map((segment) => { + if (/^gpt$/i.test(segment)) return "GPT"; + if (/^\d/.test(segment)) return segment; + return segment[0]!.toUpperCase() + segment.slice(1); + }) + .join(" "); +} + +function modelFromPiDefault(defaultModel: string | null): ServerProviderModel { + const slug = defaultModel?.trim(); + if (!slug) return PI_FALLBACK_MODEL; + return { + slug, + name: formatPiModelName(slug), + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }; +} + +function readPiConfigModelDefaults( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configuredHome = environment.PI_CODING_AGENT_DIR?.trim(); + const home = configuredHome + ? configuredHome + : path.join(environment.HOME || NodeOS.homedir(), ".pi", "agent"); + const raw = yield* fs + .readFileString(path.join(home, "settings.json")) + .pipe(Effect.catch(() => Effect.succeed(null))); + return raw + ? parsePiConfigModelDefaults(raw) + : { defaultModel: null, defaultProvider: null, malformed: false }; + }); +} + +function extractJsonStringField(raw: string, field: string): string | null { + const match = new RegExp(`"${field}"\\s*:\\s*"([^"]*)"`).exec(raw); + return match?.[1] ?? null; +} + +function extractProviderObject(raw: string, provider: string): string | null { + const keyIndex = raw.indexOf(`"${provider}"`); + if (keyIndex < 0) return null; + const objectStart = raw.indexOf("{", keyIndex); + if (objectStart < 0) return null; + let depth = 0; + for (let index = objectStart; index < raw.length; index++) { + const char = raw[index]; + if (char === "{") depth++; + if (char === "}") { + depth--; + if (depth === 0) return raw.slice(objectStart, index + 1); + } + } + return null; +} + +function extractJsonNumberField(raw: string, field: string): number | null { + const match = new RegExp(`"${field}"\\s*:\\s*(\\d+)`).exec(raw); + if (!match?.[1]) return null; + const value = Number(match[1]); + return Number.isFinite(value) ? value : null; +} + +function readOptionalFile( + path: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* fs.readFileString(path).pipe(Effect.catch(() => Effect.succeed(null))); + }); +} + +function readPiAuthState( + environment: NodeJS.ProcessEnv, +): Effect.Effect { + return Effect.gen(function* () { + const path = yield* Path.Path; + const configuredHome = environment.PI_CODING_AGENT_DIR?.trim(); + const home = configuredHome + ? configuredHome + : path.join(environment.HOME || NodeOS.homedir(), ".pi", "agent"); + const settings = yield* readOptionalFile(path.join(home, "settings.json")); + const defaultProvider = settings + ? (extractJsonStringField(settings, "defaultProvider") ?? "") + : ""; + const provider = defaultProvider.length > 0 ? defaultProvider : null; + const auth = yield* readOptionalFile(path.join(home, "auth.json")); + if (!provider || !auth) { + return { status: "unknown", provider, expiresAt: null }; + } + + const providerAuth = extractProviderObject(auth, provider); + if (!providerAuth) { + return { status: "missing", provider, expiresAt: null }; + } + + const expires = extractJsonNumberField(providerAuth, "expires"); + if (expires === null) { + return { status: "unknown", provider, expiresAt: null }; + } + + const expiresMs = expires > 1_000_000_000_000 ? expires : expires * 1000; + const nowMs = yield* Clock.currentTimeMillis; + const expiresAt = DateTime.formatIso(DateTime.makeUnsafe(expiresMs)); + return { + status: nowMs >= expiresMs ? "expired" : "valid", + provider, + expiresAt, + }; + }); +} + +function isExplicitBinaryPath(binaryPath: string): boolean { + return binaryPath.includes("/") || binaryPath.includes("\\") || binaryPath.startsWith("~"); +} + +function commonPiBinaryCandidates( + binaryName: "pi" | "pi-acp", + environment: NodeJS.ProcessEnv, +): Effect.Effect, never, Path.Path> { + return Effect.gen(function* () { + const path = yield* Path.Path; + const home = environment.HOME || environment.USERPROFILE || NodeOS.homedir(); + if (process.platform === "win32") { + const appData = environment.APPDATA; + return [ + ...(appData ? [path.join(appData, "npm", `${binaryName}.cmd`)] : []), + path.join(home, "AppData", "Roaming", "npm", `${binaryName}.cmd`), + path.join(home, ".local", "bin", `${binaryName}.cmd`), + path.join(home, ".local", "bin", `${binaryName}.exe`), + ]; + } + return [ + path.join(home, `.local/bin/${binaryName}`), + path.join(home, `.npm-global/bin/${binaryName}`), + `/opt/homebrew/bin/${binaryName}`, + `/usr/local/bin/${binaryName}`, + ]; + }); +} + +function expandHomePath(input: string, environment: NodeJS.ProcessEnv): string { + const home = environment.HOME || environment.USERPROFILE || NodeOS.homedir(); + if (input === "~") return home; + if (input.startsWith("~/") || input.startsWith("~\\")) return `${home}${input.slice(1)}`; + return input; +} + +function firstExistingPath( + candidates: ReadonlyArray, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + for (const candidate of candidates) { + const exists = yield* fs.exists(candidate).pipe(Effect.catch(() => Effect.succeed(false))); + if (exists) return candidate; + } + return null; + }); +} + +function detectPiFromLoginShell( + binaryName: "pi" | "pi-acp", + environment: NodeJS.ProcessEnv, +): Effect.Effect { + if (process.platform === "win32") { + return runRawCommand("where", [binaryName], environment).pipe( + Effect.timeoutOption(LOGIN_SHELL_TIMEOUT_MS), + Effect.map((result) => { + if (Option.isNone(result) || result.value.code !== 0) return null; + const candidate = result.value.stdout.trim().split(/\r?\n/u)[0]?.trim(); + return candidate && candidate.length > 0 ? candidate : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); + } + + const shell = environment.SHELL?.trim() || "/bin/zsh"; + return runRawCommand(shell, ["-lc", `command -v ${binaryName}`], environment).pipe( + Effect.timeoutOption(LOGIN_SHELL_TIMEOUT_MS), + Effect.map((result) => { + if (Option.isNone(result) || result.value.code !== 0) return null; + const candidate = result.value.stdout.trim().split(/\r?\n/u)[0]?.trim(); + return candidate && candidate.length > 0 ? candidate : null; + }), + Effect.catch(() => Effect.succeed(null)), + ); +} + +export function resolvePiBinary( + piSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, +): Effect.Effect< + PiBinaryResolution, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const configured = piSettings.binaryPath.trim() || "pi-acp"; + const explicitConfigured = isExplicitBinaryPath(configured); + const expandedConfigured = explicitConfigured + ? expandHomePath(configured, environment) + : configured; + const candidates = yield* commonPiBinaryCandidates("pi-acp", environment); + const detected = + (yield* firstExistingPath(candidates)) ?? + (yield* detectPiFromLoginShell("pi-acp", environment)); + + if (!explicitConfigured && configured === "pi-acp" && detected) { + return { binaryPath: detected, suggestedBinaryPath: detected }; + } + + return { + binaryPath: expandedConfigured, + suggestedBinaryPath: detected && detected !== expandedConfigured ? detected : null, + }; + }); +} + +export function resolvePiCliBinary( + piSettings: Pick, + environment: NodeJS.ProcessEnv = process.env, +): Effect.Effect< + PiBinaryResolution, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + return Effect.gen(function* () { + const configured = piSettings.piBinaryPath.trim() || "pi"; + const explicitConfigured = isExplicitBinaryPath(configured); + const expandedConfigured = explicitConfigured + ? expandHomePath(configured, environment) + : configured; + const candidates = yield* commonPiBinaryCandidates("pi", environment); + const detected = + (yield* firstExistingPath(candidates)) ?? (yield* detectPiFromLoginShell("pi", environment)); + + if (!explicitConfigured && configured === "pi" && detected) { + return { binaryPath: detected, suggestedBinaryPath: detected }; + } + + return { + binaryPath: expandedConfigured, + suggestedBinaryPath: detected && detected !== expandedConfigured ? detected : null, + }; + }); +} + +function getPiModels( + piSettings: Pick, + defaults: PiConfigModelDefaults, +): ReadonlyArray { + const baseModels = + defaults.defaultProvider === "openai-codex" + ? PI_OPENAI_CODEX_MODELS + : [modelFromPiDefault(defaults.defaultModel)]; + return providerModelsFromSettings( + baseModels, + PROVIDER, + piSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function getPiFallbackModels( + piSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings( + PI_DEFAULT_MODELS, + PROVIDER, + piSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +export function buildInitialPiProviderSnapshot( + piSettings: PiSettings, +): Effect.Effect { + return Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = getPiFallbackModels(piSettings); + + if (!piSettings.enabled) { + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }); + } + + return buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Pi Agent availability...", + }, + }); + }); +} + +interface PiAboutResult { + readonly version: string | null; + readonly status: Exclude; + readonly auth: ServerProviderAuth; + readonly message?: string; +} + +function stripAnsi(text: string): string { + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +function extractVersion(raw: string): string | null { + const plain = stripAnsi(raw); + const aboutMatch = /^CLI Version\s{2,}(.+)$/im.exec(plain); + if (aboutMatch?.[1]) return aboutMatch[1].trim(); + const versionMatch = /\b(?:pi(?:-agent)?\s+)?v?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/i.exec(plain); + return versionMatch?.[1]?.trim() ?? null; +} + +function parsePiAboutOutput(result: CommandResult): PiAboutResult { + const combined = `${result.stdout}\n${result.stderr}`; + const lower = combined.toLowerCase(); + const version = extractVersion(combined); + const detail = stripAnsi(combined).trim(); + + if (lower.includes("not logged in") || lower.includes("authentication required")) { + return { + version, + status: "error", + auth: { status: "unauthenticated" }, + message: + "Pi Agent is not authenticated. Run `pi` in a terminal and configure a model provider.", + }; + } + + if (result.code === 0) { + return { + version, + status: "ready", + auth: { status: "unknown" }, + }; + } + + return { + version, + status: "warning", + auth: { status: "unknown" }, + message: detail + ? `Pi Agent CLI health check exited with code ${result.code}: ${detail}` + : "Pi Agent is installed, but T3 Code could not verify its auth status.", + }; +} + +const runRawCommand = ( + binaryPath: string, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(binaryPath, [...args], { + env: environment, + shell: process.platform === "win32", + }); + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + return { stdout, stderr, code: exitCode } satisfies CommandResult; + }).pipe(Effect.scoped); + +const runPiCommand = ( + binaryPath: string, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => runRawCommand(binaryPath, args, environment); + +const runPiAboutCommand = (binaryPath: string, environment: NodeJS.ProcessEnv = process.env) => + runPiCommand(binaryPath, ["--version"], environment).pipe( + Effect.catch(() => runPiCommand(binaryPath, ["about"], environment)), + ); + +export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const configDefaults = yield* readPiConfigModelDefaults(environment); + const authState = yield* readPiAuthState(environment); + const models = getPiModels(piSettings, configDefaults); + const binaryResolution = yield* resolvePiBinary(piSettings, environment); + const piCliResolution = yield* resolvePiCliBinary(piSettings, environment); + const withPiHints = (snapshot: ServerProviderDraft): ServerProviderDraft => ({ + ...snapshot, + ...(binaryResolution.suggestedBinaryPath + ? { suggestedBinaryPath: binaryResolution.suggestedBinaryPath } + : {}), + }); + + if (!piSettings.enabled) { + return withPiHints( + buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi is disabled in T3 Code settings.", + }, + }), + ); + } + + const adapterPathIsExplicit = isExplicitBinaryPath(binaryResolution.binaryPath); + const adapterExists = adapterPathIsExplicit + ? yield* firstExistingPath([binaryResolution.binaryPath]) + : binaryResolution.suggestedBinaryPath; + if (!adapterExists) { + const missingMessage = binaryResolution.suggestedBinaryPath + ? `Pi ACP adapter was not found at \`${binaryResolution.binaryPath}\`. Detected pi-acp at \`${binaryResolution.suggestedBinaryPath}\`; use the detected path or update ACP adapter path.` + : adapterPathIsExplicit + ? `Pi ACP adapter was not found at \`${binaryResolution.binaryPath}\`. Install it with \`npm install -g pi-acp\` or update ACP adapter path.` + : "Pi ACP adapter (`pi-acp`) is not installed or not on PATH. Install it with `npm install -g pi-acp`."; + return withPiHints( + buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "error", + auth: { status: "unknown" }, + message: missingMessage, + }, + }), + ); + } + + const piProbe = yield* runPiAboutCommand(piCliResolution.binaryPath, { + ...environment, + PI_ACP_PI_COMMAND: piCliResolution.binaryPath, + }).pipe(Effect.timeoutOption(ABOUT_TIMEOUT_MS), Effect.result); + + if (Result.isFailure(piProbe)) { + const error = + piProbe.failure instanceof Error + ? piProbe.failure + : new Error( + typeof piProbe.failure === "string" ? piProbe.failure : String(piProbe.failure), + ); + const isMissing = isCommandMissingCause(error); + const missingMessage = piCliResolution.suggestedBinaryPath + ? `Pi Agent CLI was not found at \`${piCliResolution.binaryPath}\`. Detected Pi at \`${piCliResolution.suggestedBinaryPath}\`; use the detected path or update Pi binary path.` + : "Pi Agent CLI (`pi`) is not installed or not on PATH. Install it with `npm install -g @earendil-works/pi-coding-agent`."; + return withPiHints( + buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: !isMissing, + version: null, + status: "error", + auth: { status: "unknown" }, + message: isMissing + ? missingMessage + : `Failed to execute Pi Agent CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }), + ); + } + + if (Option.isNone(piProbe.success)) { + return withPiHints( + buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Pi Agent CLI is installed but timed out during the health check.", + }, + }), + ); + } + + const parsed = parsePiAboutOutput(piProbe.success.value); + if (authState.status === "expired" || authState.status === "missing") { + const providerLabel = authState.provider ? ` for ${authState.provider}` : ""; + const expiryLabel = authState.expiresAt ? ` expired on ${authState.expiresAt}` : " is missing"; + const loginHint = + authState.provider === "openai-codex" + ? " Run `pi`, use `/login`, and choose ChatGPT Plus/Pro (Codex) to enable GPT-5.5." + : " Run `pi` in a terminal to refresh the login or configure a provider API key."; + return withPiHints( + buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: parsed.version, + status: "error", + auth: { status: "unauthenticated" }, + message: `Pi authentication${providerLabel}${expiryLabel}.${loginHint}`, + }, + }), + ); + } + + const configMessage = + parsed.status === "ready" && configDefaults.malformed + ? "Pi Agent is ready, but T3 Code could not read a configured default model; add a custom model in Settings if the picker needs a specific model name." + : (parsed.message ?? + (parsed.status === "ready" && configDefaults.defaultProvider && configDefaults.defaultModel + ? `Pi Agent is ready. Auth provider: ${configDefaults.defaultProvider}. Default model: ${configDefaults.defaultModel}.` + : undefined)); + return withPiHints( + buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: parsed.version, + status: parsed.status, + auth: parsed.auth, + ...(configMessage ? { message: configMessage } : {}), + }, + }), + ); +}); + +export const enrichPiSnapshot = (input: { + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect => { + const stampIdentity = input.stampIdentity ?? ((value) => value); + return enrichProviderSnapshotWithVersionAdvisory( + input.snapshot, + input.maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((snapshot) => input.publishSnapshot(stampIdentity(snapshot))), + Effect.catchCause((cause) => + Effect.logWarning("Pi version advisory enrichment failed", { + cause: Cause.pretty(cause), + }), + ), + ); +}; diff --git a/apps/server/src/provider/Services/HermesAdapter.ts b/apps/server/src/provider/Services/HermesAdapter.ts new file mode 100644 index 00000000000..99003afa746 --- /dev/null +++ b/apps/server/src/provider/Services/HermesAdapter.ts @@ -0,0 +1,19 @@ +/** + * HermesAdapter — shape type for the Hermes provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/HermesDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module HermesAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * HermesAdapterShape — per-instance Hermes adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface HermesAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/Services/PiAdapter.ts b/apps/server/src/provider/Services/PiAdapter.ts new file mode 100644 index 00000000000..0ea07fc0bba --- /dev/null +++ b/apps/server/src/provider/Services/PiAdapter.ts @@ -0,0 +1,19 @@ +/** + * PiAdapter — shape type for the Pi provider adapter. + * + * Historically this module exposed a `Context.Service` tag so consumers + * could inject the adapter through the Effect layer graph. The driver + * model ({@link ../Drivers/PiDriver}) bundles one adapter per + * instance as a captured closure instead, so the tag is gone — we only + * retain the shape interface as a naming anchor for the driver bundle. + * + * @module PiAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +/** + * PiAdapterShape — per-instance Pi adapter contract. Carries + * a branded driver kind as the nominal discriminant. + */ +export interface PiAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/HermesAcpSupport.test.ts b/apps/server/src/provider/acp/HermesAcpSupport.test.ts new file mode 100644 index 00000000000..0d9ad370705 --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { buildHermesAcpSpawnInput } from "./HermesAcpSupport.ts"; + +describe("buildHermesAcpSpawnInput", () => { + it("builds the default Hermes ACP command", () => { + expect(buildHermesAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "hermes", + args: ["acp"], + cwd: "/tmp/project", + }); + }); + + it("uses the configured Hermes binary path and environment", () => { + const env = { HOME: "/tmp/hermes-home", PATH: "/usr/bin" }; + + expect( + buildHermesAcpSpawnInput( + { + binaryPath: "/Users/me/.local/bin/hermes", + }, + "/tmp/project", + env, + ), + ).toEqual({ + command: "/Users/me/.local/bin/hermes", + args: ["acp"], + cwd: "/tmp/project", + env, + }); + }); +}); diff --git a/apps/server/src/provider/acp/HermesAcpSupport.ts b/apps/server/src/provider/acp/HermesAcpSupport.ts new file mode 100644 index 00000000000..8c97360075e --- /dev/null +++ b/apps/server/src/provider/acp/HermesAcpSupport.ts @@ -0,0 +1,54 @@ +import { type HermesSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +type HermesAcpRuntimeHermesSettings = Pick; + +export interface HermesAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly hermesSettings: HermesAcpRuntimeHermesSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildHermesAcpSpawnInput( + hermesSettings: HermesAcpRuntimeHermesSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + return { + command: hermesSettings?.binaryPath || "hermes", + args: ["acp"], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeHermesAcpRuntime = ( + input: HermesAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildHermesAcpSpawnInput(input.hermesSettings, input.cwd, input.environment), + authMethodId: "terminal_setup", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); diff --git a/apps/server/src/provider/acp/PiAcpSupport.test.ts b/apps/server/src/provider/acp/PiAcpSupport.test.ts new file mode 100644 index 00000000000..351256eb356 --- /dev/null +++ b/apps/server/src/provider/acp/PiAcpSupport.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; + +import { buildPiAcpSpawnInput } from "./PiAcpSupport.ts"; + +describe("buildPiAcpSpawnInput", () => { + it("builds the default Pi ACP command", () => { + expect(buildPiAcpSpawnInput(undefined, "/tmp/project")).toEqual({ + command: "pi-acp", + args: [], + cwd: "/tmp/project", + }); + }); + + it("uses the configured Pi ACP adapter and Pi binary paths", () => { + const env = { HOME: "/tmp/pi-home", PATH: "/usr/bin" }; + + expect( + buildPiAcpSpawnInput( + { + binaryPath: "/Users/me/.local/bin/pi-acp", + piBinaryPath: "/opt/homebrew/bin/pi", + }, + "/tmp/project", + env, + ), + ).toEqual({ + command: "/Users/me/.local/bin/pi-acp", + args: [], + cwd: "/tmp/project", + env: { + ...env, + PATH: "/Users/me/.local/bin:/opt/homebrew/bin:/usr/bin", + PI_ACP_PI_COMMAND: "/opt/homebrew/bin/pi", + }, + }); + }); + + it("adds configured absolute binary directories when PATH is missing", () => { + expect( + buildPiAcpSpawnInput( + { + binaryPath: "/opt/homebrew/bin/pi-acp", + piBinaryPath: "/opt/homebrew/bin/pi", + }, + "/tmp/project", + { HOME: "/tmp/pi-home" }, + ).env?.PATH, + ).toBe("/opt/homebrew/bin"); + }); + + it("preserves Windows Path casing and prepends cmd directories", () => { + const env = { + USERPROFILE: "C:\\Users\\me", + Path: "C:\\Windows\\System32;C:\\Program Files\\nodejs", + }; + + expect( + buildPiAcpSpawnInput( + { + binaryPath: "C:\\Users\\me\\AppData\\Roaming\\npm\\pi-acp.cmd", + piBinaryPath: "C:\\Users\\me\\AppData\\Roaming\\npm\\pi.cmd", + }, + "C:\\work\\project", + env, + ), + ).toEqual({ + command: "C:\\Users\\me\\AppData\\Roaming\\npm\\pi-acp.cmd", + args: [], + cwd: "C:\\work\\project", + env: { + ...env, + Path: "C:\\Users\\me\\AppData\\Roaming\\npm;C:\\Windows\\System32;C:\\Program Files\\nodejs", + PI_ACP_PI_COMMAND: "C:\\Users\\me\\AppData\\Roaming\\npm\\pi.cmd", + }, + }); + }); + + it("preserves Windows root executable directories", () => { + expect( + buildPiAcpSpawnInput( + { + binaryPath: "C:\\pi-acp.cmd", + piBinaryPath: "C:\\pi.cmd", + }, + "C:\\work\\project", + {}, + ).env?.PATH, + ).toBe("C:\\"); + }); + + it("does not add an empty PATH for relative commands without an existing path", () => { + expect( + buildPiAcpSpawnInput( + { + binaryPath: "pi-acp", + piBinaryPath: "pi", + }, + "/tmp/project", + {}, + ).env, + ).toEqual({ + PI_ACP_PI_COMMAND: "pi", + }); + }); +}); diff --git a/apps/server/src/provider/acp/PiAcpSupport.ts b/apps/server/src/provider/acp/PiAcpSupport.ts new file mode 100644 index 00000000000..13506ac44c9 --- /dev/null +++ b/apps/server/src/provider/acp/PiAcpSupport.ts @@ -0,0 +1,133 @@ +import { type PiSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpErrors from "effect-acp/errors"; +import { + AcpSessionRuntime, + type AcpSessionRuntimeOptions, + type AcpSessionRuntimeShape, + type AcpSpawnInput, +} from "./AcpSessionRuntime.ts"; + +type PiAcpRuntimePiSettings = Pick; +const PATH_DELIMITER = process.platform === "win32" ? ";" : ":"; +const WINDOWS_ABSOLUTE_PATH_PATTERN = /^[a-zA-Z]:[\\/]/; + +function pathDelimiterForEnvironment(environment?: NodeJS.ProcessEnv): string { + const pathValue = environment?.PATH ?? environment?.Path ?? environment?.path; + if (pathValue?.includes(";")) { + return ";"; + } + return PATH_DELIMITER; +} + +function pathKeyForEnvironment(environment?: NodeJS.ProcessEnv): "PATH" | "Path" | "path" { + if (environment && "Path" in environment) return "Path"; + if (environment && "path" in environment) return "path"; + return "PATH"; +} + +function dirnameForExecutablePath(value: string | undefined): string | null { + if (!value) { + return null; + } + const normalized = value.replaceAll("\\", "/"); + const isWindowsAbsolute = WINDOWS_ABSOLUTE_PATH_PATTERN.test(normalized); + if (!normalized.startsWith("/") && !isWindowsAbsolute) { + return null; + } + const separatorIndex = normalized.lastIndexOf("/"); + if (isWindowsAbsolute && separatorIndex === 2) { + return value.slice(0, 3); + } + return separatorIndex > 0 ? value.slice(0, separatorIndex) : "/"; +} + +function prependUniquePathEntries( + currentPath: string | undefined, + entries: ReadonlyArray, + delimiter = PATH_DELIMITER, +): string { + const seen = new Set(); + const next: Array = []; + for (const entry of [...entries, ...(currentPath ? currentPath.split(delimiter) : [])]) { + if (!entry || seen.has(entry)) { + continue; + } + seen.add(entry); + next.push(entry); + } + return next.join(delimiter); +} + +function buildPiAcpEnvironment( + piSettings: PiAcpRuntimePiSettings | null | undefined, + environment?: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv | undefined { + if (!piSettings?.piBinaryPath && !piSettings?.binaryPath && !environment) { + return undefined; + } + + const env = { ...environment }; + const pathKey = pathKeyForEnvironment(env); + const pathDelimiter = pathDelimiterForEnvironment(env); + if (piSettings?.piBinaryPath) { + env.PI_ACP_PI_COMMAND = piSettings.piBinaryPath; + } + const nextPath = prependUniquePathEntries( + env[pathKey], + [ + dirnameForExecutablePath(piSettings?.binaryPath), + dirnameForExecutablePath(piSettings?.piBinaryPath), + ], + pathDelimiter, + ); + if (nextPath) { + env[pathKey] = nextPath; + } + return env; +} + +export interface PiAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "clientCapabilities" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly piSettings: PiAcpRuntimePiSettings | null | undefined; + readonly environment?: NodeJS.ProcessEnv; +} + +export function buildPiAcpSpawnInput( + piSettings: PiAcpRuntimePiSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + const env = buildPiAcpEnvironment(piSettings, environment); + + return { + command: piSettings?.binaryPath || "pi-acp", + args: [], + cwd, + ...(env ? { env } : {}), + }; +} + +export const makePiAcpRuntime = ( + input: PiAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildPiAcpSpawnInput(input.piSettings, input.cwd, input.environment), + authMethodId: "terminal_setup", + }).pipe( + Layer.provide( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, input.childProcessSpawner), + ), + ), + ); + return yield* Effect.service(AcpSessionRuntime).pipe(Effect.provide(acpContext)); + }); diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..2b14e717f01 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,7 +23,9 @@ 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 { HermesDriver, type HermesDriverEnv } from "./Drivers/HermesDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; +import { PiDriver, type PiDriverEnv } from "./Drivers/PiDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; /** @@ -35,7 +37,9 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv - | OpenCodeDriverEnv; + | HermesDriverEnv + | OpenCodeDriverEnv + | PiDriverEnv; /** * Ordered list of built-in drivers. Order matters only for tie-breaking in @@ -47,4 +51,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeHermesAcpRuntime({ + hermesSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(HERMES_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapHermesAcpError(operation, "Hermes ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Hermes ACP request was cancelled." + : "Hermes Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Hermes Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapHermesAcpError(operation, "Hermes ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "HermesTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runHermesJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "HermesTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runHermesJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "HermesTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "HermesTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runHermesJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts new file mode 100644 index 00000000000..0fa0a010516 --- /dev/null +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -0,0 +1,256 @@ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { type PiSettings } from "@t3tools/contracts"; +import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shared/git"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; + +import { TextGenerationError } from "@t3tools/contracts"; +import { type ThreadTitleGenerationResult, type TextGenerationShape } from "./TextGeneration.ts"; +import { + buildBranchNamePrompt, + buildCommitMessagePrompt, + buildPrContentPrompt, + buildThreadTitlePrompt, +} from "./TextGenerationPrompts.ts"; +import { + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; +import { makePiAcpRuntime } from "../provider/acp/PiAcpSupport.ts"; + +const PI_TIMEOUT_MS = 180_000; + +function mapPiAcpError( + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle", + detail: string, + cause: unknown, +): TextGenerationError { + return new TextGenerationError({ + operation, + detail, + ...(cause !== undefined ? { cause } : {}), + }); +} + +function isTextGenerationError(error: unknown): error is TextGenerationError { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + error._tag === "TextGenerationError" + ); +} + +/** + * Build a Pi text-generation closure bound to a specific `PiSettings` + * payload. See `makeCodexAdapter` for the overall per-instance rationale. + */ +export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv = process.env, +) { + const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const runPiJson = ({ + operation, + cwd, + prompt, + outputSchemaJson, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makePiAcpRuntime({ + piSettings, + environment, + childProcessSpawner: commandSpawner, + cwd, + clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + }); + + yield* runtime.handleSessionUpdate((notification) => { + const update = notification.update; + if (update.sessionUpdate !== "agent_message_chunk") { + return Effect.void; + } + const content = update.content; + if (content.type !== "text") { + return Effect.void; + } + return Ref.update(outputRef, (current) => current + content.text); + }); + + const promptResult = yield* Effect.gen(function* () { + yield* runtime.start(); + yield* Effect.ignore(runtime.setMode("ask")); + + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(PI_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi Agent request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapPiAcpError(operation, "Pi ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Pi ACP request was cancelled." + : "Pi Agent returned empty output.", + }); + } + + const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(outputSchemaJson)); + return yield* decodeOutput(extractJsonObject(rawResult)).pipe( + Effect.catchTag("SchemaError", (cause) => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Pi Agent returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapPiAcpError(operation, "Pi ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "PiTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runPiJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; + }); + + const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( + "PiTextGeneration.generatePrContent", + )(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + + const generated = yield* runPiJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "PiTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runPiJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "PiTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runPiJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index bb62d8edbc0..9c2c6ccb374 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1738,6 +1738,83 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); + it("applies the Hermes chat background only when Hermes is selected", async () => { + const hermesSelection = { + instanceId: ProviderInstanceId.make("hermes"), + model: "hermes-default", + }; + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-hermes-surface" as MessageId, + targetText: "hermes surface", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: { + ...snapshot, + projects: snapshot.projects.map((project) => + project.id === PROJECT_ID + ? { ...project, defaultModelSelection: hermesSelection } + : project, + ), + threads: snapshot.threads.map((thread) => + thread.id === THREAD_ID + ? { + ...thread, + modelSelection: hermesSelection, + session: thread.session + ? { + ...thread.session, + providerName: "hermes", + providerInstanceId: ProviderInstanceId.make("hermes"), + } + : thread.session, + } + : thread, + ), + }, + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + ...nextFixture.serverConfig.providers, + { + driver: ProviderDriverKind.make("hermes"), + instanceId: ProviderInstanceId.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: NOW_ISO, + models: [ + { + slug: "hermes-default", + name: "Hermes Default", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + }, + ], + }; + }, + }); + + try { + const surface = await waitForElement( + () => document.querySelector("[data-chat-provider-surface='hermes']"), + "Hermes chat provider surface was not applied.", + ); + + expect(surface.classList.contains("chat-surface-hermes")).toBe(true); + } finally { + await mounted.cleanup(); + } + }); + it("renders locked single-environment mobile run context as a static workspace label", async () => { const mounted = await mountChatView({ viewport: COMPACT_FOOTER_VIEWPORT, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..94d0ff4d16e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1262,6 +1262,8 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId ?? threadProvider ?? ProviderDriverKind.make("codex"), ); const selectedProvider: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; + const isHermesSelected = String(selectedProvider) === "hermes"; + const isPiSelected = String(selectedProvider) === "pi"; const phase = derivePhase(activeThread?.session ?? null); const threadActivities = activeThread?.activities ?? EMPTY_ACTIVITIES; const workLogEntries = useMemo( @@ -3551,7 +3553,16 @@ export default function ChatView(props: ChatViewProps) { {/* Chat column */}
{/* Messages Wrapper */} -
+
{/* Messages — LegendList handles virtualization and scrolling internally */} entry.instanceId === candidate && entry.enabled, + (entry) => entry.instanceId === candidate && isSelectableProviderInstance(entry), ); if (match) { // When locked to a specific driver kind, ignore persisted instance @@ -669,12 +670,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } const byKind = providerInstanceEntries.find( (entry) => - entry.enabled && + isSelectableProviderInstance(entry) && entry.driverKind === selectedProvider && (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), ); if (byKind) return byKind.instanceId; - const anyEnabled = providerInstanceEntries.find((entry) => entry.enabled); + const anyEnabled = providerInstanceEntries.find(isSelectableProviderInstance); return ( anyEnabled?.instanceId ?? providerInstanceEntries[0]?.instanceId ?? diff --git a/apps/web/src/components/chat/ModelPickerContent.tsx b/apps/web/src/components/chat/ModelPickerContent.tsx index c3468ef8c65..c2c7db67b4b 100644 --- a/apps/web/src/components/chat/ModelPickerContent.tsx +++ b/apps/web/src/components/chat/ModelPickerContent.tsx @@ -21,7 +21,7 @@ import { import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { TooltipProvider } from "../ui/tooltip"; -import type { ProviderInstanceEntry } from "../../providerInstances"; +import { isSelectableProviderInstance, type ProviderInstanceEntry } from "../../providerInstances"; import { providerModelKey, sortProviderModelItems } from "../../modelOrdering"; type ModelPickerItem = { @@ -166,15 +166,26 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { [props.lockedContinuationGroupKey, props.lockedProvider], ); - const readyInstanceSet = useMemo(() => { - const ready = new Set(); + const selectableInstanceSet = useMemo(() => { + const selectable = new Set(); for (const entry of instanceEntries) { - if (entry.status === "ready") { - ready.add(entry.instanceId); + const models = modelOptionsByInstance.get(entry.instanceId) ?? []; + if (isSelectableProviderInstance(entry) && models.length > 0) { + selectable.add(entry.instanceId); } } - return ready; - }, [instanceEntries]); + return selectable; + }, [instanceEntries, modelOptionsByInstance]); + const visibleRailInstanceSet = useMemo(() => { + const visible = new Set(); + for (const entry of instanceEntries) { + const models = modelOptionsByInstance.get(entry.instanceId) ?? []; + if (entry.enabled && entry.isAvailable && models.length > 0) { + visible.add(entry.instanceId); + } + } + return visible; + }, [instanceEntries, modelOptionsByInstance]); // Flatten models into a searchable array. One pass over the // instance-keyed map; each model carries its instance id + driver kind @@ -189,7 +200,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { // its models — stale options shouldn't appear in the picker. continue; } - if (!readyInstanceSet.has(instanceId)) { + if (!selectableInstanceSet.has(instanceId)) { continue; } for (const model of models) { @@ -209,20 +220,26 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { } } return out; - }, [modelOptionsByInstance, entryByInstanceId, readyInstanceSet]); + }, [modelOptionsByInstance, entryByInstanceId, selectableInstanceSet]); const isLocked = props.lockedProvider !== null; const isSearching = searchQuery.trim().length > 0; const lockedInstanceEntries = useMemo( () => - props.lockedProvider ? instanceEntries.filter((entry) => matchesLockedProvider(entry)) : [], - [instanceEntries, matchesLockedProvider, props.lockedProvider], + props.lockedProvider + ? instanceEntries.filter( + (entry) => selectableInstanceSet.has(entry.instanceId) && matchesLockedProvider(entry), + ) + : [], + [instanceEntries, matchesLockedProvider, props.lockedProvider, selectableInstanceSet], ); const showLockedInstanceSidebar = isLocked && lockedInstanceEntries.length > 1; const showSidebar = !isSearching && (!isLocked || showLockedInstanceSidebar); const sidebarInstanceEntries = showLockedInstanceSidebar ? lockedInstanceEntries - : instanceEntries; + : instanceEntries.filter((entry) => visibleRailInstanceSet.has(entry.instanceId)); + const selectedEntry = + selectedInstanceId === "favorites" ? null : (entryByInstanceId.get(selectedInstanceId) ?? null); const instanceOrder = useMemo( () => instanceEntries.map((entry) => entry.instanceId), [instanceEntries], @@ -408,6 +425,27 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: { (): string[] => filteredModels.map((model) => `${model.instanceId}:${model.slug}`), [filteredModels], ); + const emptyMessage = useMemo(() => { + if (searchQuery.trim().length > 0) { + return "No models match that search"; + } + if (selectedInstanceId === "favorites") { + return "No favorite models yet"; + } + if (selectedEntry && !selectedEntry.enabled) { + return `${selectedEntry.displayName} is disabled in Settings`; + } + if (selectedEntry && selectedEntry.status !== "ready") { + const detail = selectedEntry.snapshot.message?.trim(); + return detail + ? `${selectedEntry.displayName} needs setup: ${detail}` + : `${selectedEntry.displayName} needs setup in Settings`; + } + if (selectedEntry) { + return `${selectedEntry.displayName} has no models yet. Finish setup, refresh provider status, then try again.`; + } + return "No models found"; + }, [searchQuery, selectedEntry, selectedInstanceId]); const filteredModelByKey = useMemo( (): ReadonlyMap => new Map(filteredModels.map((model) => [`${model.instanceId}:${model.slug}`, model] as const)), @@ -639,7 +677,7 @@ export const ModelPickerContent = memo(function ModelPickerContent(props: {
- No models found + {emptyMessage}
diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index d3b168876a6..bd1b53709d6 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -247,6 +247,58 @@ function buildOpenCodeProvider(models: ServerProvider["models"]): ServerProvider }; } +function buildHermesProvider(overrides: Partial = {}): ServerProvider { + return { + driver: ProviderDriverKind.make("hermes"), + instanceId: ProviderInstanceId.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: new Date().toISOString(), + models: [ + { + slug: "gpt-5.5", + name: "GPT 5.5", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + ...overrides, + }; +} + +function buildPiProvider(overrides: Partial = {}): ServerProvider { + return { + driver: ProviderDriverKind.make("pi"), + instanceId: ProviderInstanceId.make("pi"), + displayName: "Pi", + enabled: true, + installed: true, + version: "0.73.1", + status: "error", + auth: { status: "unauthenticated" }, + checkedAt: new Date().toISOString(), + message: + "Pi authentication for openai-codex is missing. Run `pi`, use `/login`, and choose ChatGPT Plus/Pro (Codex) to enable GPT-5.5.", + models: [ + { + slug: "gpt-5.5", + name: "GPT 5.5", + isCustom: false, + capabilities: createModelCapabilities({ optionDescriptors: [] }), + }, + ], + slashCommands: [], + skills: [], + ...overrides, + }; +} + async function mountPicker(props: { activeInstanceId?: ProviderInstanceId; model: string; @@ -1205,6 +1257,105 @@ describe("ProviderModelPicker", () => { } }); + it("does not offer a disabled Hermes provider as an empty model source", async () => { + const providers = [ + ...TEST_PROVIDERS, + buildHermesProvider({ + enabled: false, + status: "disabled", + models: [], + }), + ]; + const mounted = await mountPicker({ + model: "gpt-5-codex", + lockedProvider: null, + providers, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("GPT-5 Codex"); + }); + + expect(document.querySelector('[data-model-picker-provider="hermes"]')).toBeNull(); + expect(document.body.textContent ?? "").not.toContain("No models found"); + } finally { + await mounted.cleanup(); + } + }); + + it("shows unauthenticated providers in the sidebar without making their models selectable", async () => { + const mounted = await mountPicker({ + model: "gpt-5-codex", + lockedProvider: null, + providers: [...TEST_PROVIDERS, buildPiProvider()], + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const piButton = document.querySelector( + '[data-model-picker-provider="pi"]', + ); + expect(piButton).not.toBeNull(); + expect(piButton?.disabled).toBe(true); + expect(getModelPickerListText()).not.toContain("GPT 5.5"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("shows the configured Hermes model when Hermes is ready", async () => { + const mounted = await mountPicker({ + activeInstanceId: ProviderInstanceId.make("hermes"), + model: "gpt-5.5", + lockedProvider: null, + providers: [...TEST_PROVIDERS, buildHermesProvider()], + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("button", { name: "Hermes", exact: true }).click(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain("GPT 5.5"); + }); + } finally { + await mounted.cleanup(); + } + }); + + it("explains provider setup when the active Hermes provider has no models", async () => { + const mounted = await mountPicker({ + activeInstanceId: ProviderInstanceId.make("hermes"), + model: "hermes-default", + lockedProvider: null, + providers: [ + ...TEST_PROVIDERS, + buildHermesProvider({ + models: [], + }), + ], + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + expect(document.body.textContent ?? "").toContain( + "Hermes has no models yet. Finish setup, refresh provider status, then try again.", + ); + }); + } finally { + await mounted.cleanup(); + } + }); + it("accepts outline trigger styling", async () => { const mounted = await mountPicker({ model: "gpt-5-codex", diff --git a/apps/web/src/components/settings/ProviderInstanceCard.test.ts b/apps/web/src/components/settings/ProviderInstanceCard.test.ts index 1aa062c9d68..f71d8d1f868 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.test.ts +++ b/apps/web/src/components/settings/ProviderInstanceCard.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; -import type { ServerProviderModel } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ServerProviderModel, +} from "@t3tools/contracts"; -import { deriveProviderModelsForDisplay } from "./ProviderInstanceCard"; +import { + buildProviderSetupChecklist, + deriveProviderModelsForDisplay, +} from "./ProviderInstanceCard"; describe("deriveProviderModelsForDisplay", () => { it("uses current config custom models instead of stale live custom rows", () => { @@ -34,3 +41,72 @@ describe("deriveProviderModelsForDisplay", () => { ).toEqual(["server-model", "kept-custom"]); }); }); + +describe("buildProviderSetupChecklist", () => { + it("treats installed Hermes with CLI-managed auth as ready for a test message", () => { + const checklist = buildProviderSetupChecklist({ + displayName: "Hermes", + isHermesDriver: true, + isPiDriver: false, + enabled: true, + configuredBinaryPath: "hermes", + configuredPiBinaryPath: null, + modelCount: 1, + liveProvider: { + driver: ProviderDriverKind.make("hermes"), + instanceId: ProviderInstanceId.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: new Date().toISOString(), + models: [], + slashCommands: [], + skills: [], + }, + }); + + expect(checklist.map((item) => [item.label, item.state])).toEqual([ + ["Enabled", "complete"], + ["CLI detected", "complete"], + ["Authentication", "complete"], + ["Model visible", "complete"], + ]); + }); + + it("points Pi users at login and model setup when auth and models are missing", () => { + const checklist = buildProviderSetupChecklist({ + displayName: "Pi", + isHermesDriver: false, + isPiDriver: true, + enabled: true, + configuredBinaryPath: "pi-acp", + configuredPiBinaryPath: null, + modelCount: 0, + liveProvider: { + driver: ProviderDriverKind.make("pi"), + instanceId: ProviderInstanceId.make("pi"), + displayName: "Pi", + enabled: true, + installed: true, + version: "0.73.1", + status: "error", + auth: { status: "unauthenticated" }, + checkedAt: new Date().toISOString(), + models: [], + slashCommands: [], + skills: [], + }, + }); + + expect(checklist.map((item) => [item.label, item.state])).toEqual([ + ["Enabled", "complete"], + ["Adapter detected", "complete"], + ["Pi binary", "pending"], + ["Authentication", "action"], + ["Model visible", "action"], + ]); + }); +}); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 430ec3637e0..d9e249fc783 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -10,7 +10,7 @@ import { Trash2Icon, XIcon, } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, type KeyboardEvent, type MouseEvent, type ReactNode } from "react"; import { isProviderDriverKind, type ProviderInstanceConfig, @@ -60,6 +60,13 @@ const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; +function isInteractiveEventTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest("a,button,input,select,textarea,[role='switch']") !== null + ); +} + type EnvironmentDraftRow = { readonly id: string; readonly name: string; @@ -68,6 +75,12 @@ type EnvironmentDraftRow = { readonly valueRedacted?: boolean; }; +type ProviderSetupChecklistItem = { + readonly label: string; + readonly state: "complete" | "action" | "pending"; + readonly detail: string; +}; + function makeEnvironmentDraftRow( variable: ProviderInstanceEnvironmentVariable, index: number, @@ -94,6 +107,80 @@ function readConfigStringArray(config: unknown, key: string): ReadonlyArray typeof entry === "string"); } +function readConfigString(config: unknown, key: string): string | null { + if (config === null || typeof config !== "object") return null; + const value = (config as Record)[key]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +export function buildProviderSetupChecklist(input: { + readonly displayName: string; + readonly isHermesDriver: boolean; + readonly isPiDriver: boolean; + readonly enabled: boolean; + readonly liveProvider: ServerProvider | undefined; + readonly configuredBinaryPath: string | null; + readonly configuredPiBinaryPath: string | null; + readonly modelCount: number; +}): ReadonlyArray { + const providerName = input.displayName; + const installed = input.liveProvider?.installed === true; + const authStatus = input.liveProvider?.auth.status ?? "unknown"; + const isCliManagedAuth = input.isHermesDriver || input.isPiDriver; + + return [ + { + label: "Enabled", + state: input.enabled ? "complete" : "action", + detail: input.enabled + ? `${providerName} is enabled for new chats.` + : `Turn on ${providerName} before selecting it in chat.`, + }, + { + label: input.isPiDriver ? "Adapter detected" : "CLI detected", + state: installed ? "complete" : "action", + detail: installed + ? `${input.configuredBinaryPath ?? (input.isPiDriver ? "pi-acp" : "hermes")} is reachable.` + : `Set an absolute ${input.isPiDriver ? "pi-acp adapter" : "Hermes"} path or install the CLI.`, + }, + ...(input.isPiDriver + ? [ + { + label: "Pi binary", + state: input.configuredPiBinaryPath ? "complete" : "pending", + detail: input.configuredPiBinaryPath + ? `${input.configuredPiBinaryPath} is configured.` + : "Using pi from PATH. Set an absolute path if the packaged app cannot find it.", + } satisfies ProviderSetupChecklistItem, + ] + : []), + { + label: "Authentication", + state: + authStatus === "authenticated" || + (installed && isCliManagedAuth && authStatus === "unknown") + ? "complete" + : authStatus === "unauthenticated" + ? "action" + : "pending", + detail: + authStatus === "authenticated" + ? "Authentication is verified." + : isCliManagedAuth && installed && authStatus === "unknown" + ? `${providerName} manages auth in its own CLI config. Send a test message after setup.` + : `Finish ${providerName} login in the provider CLI, then refresh status.`, + }, + { + label: "Model visible", + state: input.modelCount > 0 ? "complete" : "action", + detail: + input.modelCount > 0 + ? `${input.modelCount} model${input.modelCount === 1 ? "" : "s"} available in the picker.` + : `Configure a default model in ${providerName}, then refresh provider status.`, + }, + ]; +} + /** * Set `key` to an arbitrary value on the opaque config blob. Unlike * provider settings field updates, does not drop empty-looking values — the @@ -390,6 +477,39 @@ function ProviderEnvironmentSection(props: { ); } +function ProviderSetupCommandRow(props: { + readonly command: string; + readonly label: string; + readonly onCopy: (command: string, label: string) => void; +}) { + return ( +
+ + + {props.command} + + + + props.onCopy(props.command, props.label)} + aria-label={`Copy ${props.label}`} + > + + + } + /> + Copy command + +
+ ); +} + interface ProviderInstanceCardProps { readonly instanceId: ProviderInstanceId; readonly instance: ProviderInstanceConfig; @@ -483,24 +603,35 @@ export function ProviderInstanceCard({ const summary = rawSummary; const versionLabel = getProviderVersionLabel(liveProvider?.version); const versionAdvisory = getProviderVersionAdvisoryPresentation(liveProvider?.versionAdvisory); - const updateCommand = versionAdvisory?.updateCommand ?? null; + const updateCommand = + versionAdvisory?.updateCommand ?? liveProvider?.versionAdvisory?.updateCommand ?? null; + const canRunUpdate = liveProvider?.versionAdvisory?.canUpdate === true && updateCommand !== null; + const suggestedBinaryPath = liveProvider?.suggestedBinaryPath?.trim(); + const isHermesDriver = String(instance.driver) === "hermes"; + const isPiDriver = String(instance.driver) === "pi"; + const configuredBinaryPath = readConfigString(instance.config, "binaryPath"); + const configuredPiBinaryPath = readConfigString(instance.config, "piBinaryPath"); const FallbackIconComponent = driverOption?.icon; const displayName = instance.displayName?.trim() || driverOption?.label || String(instance.driver); const accentColor = normalizeProviderAccentColor(instance.accentColor); - const { copyToClipboard } = useCopyToClipboard<{ providerName: string }>({ - onCopy: ({ providerName }) => { + const { copyToClipboard } = useCopyToClipboard<{ + successTitle: string; + errorTitle: string; + description?: string; + }>({ + onCopy: ({ successTitle, description }) => { toastManager.add({ type: "success", - title: `${providerName} update command copied`, - description: "Run it in a terminal when you are ready to update.", + title: successTitle, + description: description ?? "Run it in a terminal when you are ready.", }); }, - onError: (error, { providerName }) => { + onError: (error, { errorTitle }) => { toastManager.add( stackedThreadToast({ type: "error", - title: `Could not copy ${providerName} update command`, + title: errorTitle, description: error.message, }), ); @@ -563,6 +694,12 @@ export function ProviderInstanceCard({ onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); }; + const applySuggestedBinaryPath = (binaryPath: string) => { + const nextConfig = nextConfigBlobWithValue(instance.config, "binaryPath", binaryPath); + const { config: _omit, ...rest } = instance; + onUpdate({ ...rest, config: nextConfig } as ProviderInstanceConfig); + }; + const updateEnvironment = (environment: ReadonlyArray) => { const cleaned = environment.filter((variable) => variable.name.trim().length > 0); const { environment: _omit, ...rest } = instance; @@ -573,6 +710,20 @@ export function ProviderInstanceCard({ ); }; + const toggleExpanded = () => onExpandedChange(!isExpanded); + + const handleHeaderClick = (event: MouseEvent) => { + if (isInteractiveEventTarget(event.target)) return; + toggleExpanded(); + }; + + const handleHeaderKeyDown = (event: KeyboardEvent) => { + if (event.currentTarget !== event.target) return; + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + toggleExpanded(); + }; + const titleIconNode = driverKind ? ( {versionLabel} ) : null; + const diagnosticsRows = [ + ["Status", summary.headline], + ["Auth", liveProvider?.auth.status ?? "unknown"], + ["Version", versionLabel ?? "unknown"], + ["Binary", configuredBinaryPath ?? (isHermesDriver ? "hermes" : isPiDriver ? "pi-acp" : null)], + ...(isPiDriver ? [["Pi binary", configuredPiBinaryPath ?? "pi"]] : []), + ...(suggestedBinaryPath ? [["Detected path", suggestedBinaryPath]] : []), + ].filter((row): row is [string, string] => typeof row[1] === "string" && row[1].length > 0); + + const setupChecklist = + isHermesDriver || isPiDriver + ? buildProviderSetupChecklist({ + displayName, + isHermesDriver, + isPiDriver, + enabled, + liveProvider, + configuredBinaryPath, + configuredPiBinaryPath, + modelCount: modelsForDisplay.length, + }) + : []; + + const diagnosticsText = + diagnosticsRows.length > 0 + ? [ + `${displayName} provider diagnostics`, + ...diagnosticsRows.map(([label, value]) => `${label}: ${value}`), + ...setupChecklist.map((item) => `${item.label}: ${item.state} - ${item.detail}`), + ].join("\n") + : null; + + const setupChecklistNode = + setupChecklist.length > 0 ? ( +
+
+
+ Setup checklist +

+ Complete each row before using {displayName} in a fresh chat. +

+
+
+ {setupChecklist.map((item) => ( +
+ + + {item.label} + {item.detail} + +
+ ))} +
+
+
+ ) : null; + + const diagnosticsNode = + (isHermesDriver || isPiDriver) && diagnosticsRows.length > 0 ? ( +
+
+
+ Provider diagnostics +

+ Quick facts for debugging local setup, packaged app launches, and installed versions. +

+
+
+ {diagnosticsRows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+ {diagnosticsText ? ( + + ) : null} +
+
+ ) : null; + + const hermesSetupNode = isHermesDriver ? ( +
+
+
+ Hermes setup +

+ T3 Code starts Hermes through ACP. Configure Hermes once, then verify the ACP command + before sending a turn. +

+
+ {suggestedBinaryPath ? ( +
+ + Detected Hermes at {suggestedBinaryPath} + + +
+ ) : null} +
+ + copyToClipboard(command, { + successTitle: `${label} copied`, + errorTitle: `Could not copy ${label}`, + }) + } + /> + + copyToClipboard(command, { + successTitle: `${label} copied`, + errorTitle: `Could not copy ${label}`, + }) + } + /> +
+ + Hermes setup docs + +
+
+ ) : null; + + const piSetupNode = isPiDriver ? ( +
+
+
+ Pi setup +

+ T3 Code starts Pi through the pi-acp adapter. For GPT-5.5, sign into Pi with the ChatGPT + Plus/Pro Codex provider, then verify both commands before sending a turn. +

+
+ {suggestedBinaryPath ? ( +
+ + Detected pi-acp at {suggestedBinaryPath} + + +
+ ) : null} +
+ + copyToClipboard(command, { + successTitle: `${label} copied`, + errorTitle: `Could not copy ${label}`, + }) + } + /> + + copyToClipboard(command, { + successTitle: `${label} copied`, + errorTitle: `Could not copy ${label}`, + }) + } + /> + + copyToClipboard(command, { + successTitle: `${label} copied`, + errorTitle: `Could not copy ${label}`, + }) + } + /> + + copyToClipboard(command, { + successTitle: `${label} copied`, + errorTitle: `Could not copy ${label}`, + }) + } + /> +
+ + Pi setup docs + +
+
+ ) : null; + + const providerUpdateNode = canRunUpdate ? ( +
+
+
+ Provider update +

+ Run the provider's update command, then refresh provider status. +

+
+
+ {onRunUpdate ? ( + + ) : null} +
+ + + {updateCommand} + + + + + copyToClipboard(updateCommand, { + successTitle: `${displayName} update command copied`, + errorTitle: `Could not copy ${displayName} update command`, + description: "Run it in a terminal when you are ready to update.", + }) + } + aria-label="Copy update command" + > + + + } + /> + Copy command + +
+
+
+
+ ) : null; + return (
-
+
@@ -756,7 +1230,10 @@ export function ProviderInstanceCard({ className="size-6 shrink-0 rounded-sm p-0 text-muted-foreground hover:text-foreground" onClick={() => copyToClipboard(updateCommand, { - providerName: displayName, + successTitle: `${displayName} update command copied`, + errorTitle: `Could not copy ${displayName} update command`, + description: + "Run it in a terminal when you are ready to update.", }) } aria-label="Copy update command" @@ -782,7 +1259,7 @@ export function ProviderInstanceCard({ size="sm" variant="ghost" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground" - onClick={() => onExpandedChange(!isExpanded)} + onClick={toggleExpanded} aria-label={`Toggle ${displayName} details`} >
+ {hermesSetupNode} + {piSetupNode} + {setupChecklistNode} + {diagnosticsNode} + {providerUpdateNode} + {driverOption ? ( { await expect.element(page.getByPlaceholder("Optional")).toBeInTheDocument(); }); + it("expands Hermes provider settings when the Hermes row is clicked", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [ + { + instanceId: ProviderInstanceId.make("hermes"), + driver: ProviderDriverKind.make("hermes"), + displayName: "Hermes", + enabled: true, + installed: true, + version: "0.11.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: new Date().toISOString(), + suggestedBinaryPath: "/opt/homebrew/bin/hermes", + models: [], + slashCommands: [], + skills: [], + }, + ], + settings: { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("hermes")]: { + driver: ProviderDriverKind.make("hermes"), + enabled: true, + config: { + enabled: true, + binaryPath: "/Users/me/.local/bin/hermes", + customModels: [], + }, + }, + }, + }, + }); + + mounted = await render( + + + , + ); + + await page.getByRole("button", { name: "Expand Hermes provider details" }).click(); + + await expect + .element( + page.getByText( + "Installed and ready. Hermes manages authentication through its own CLI and local config.", + ), + ) + .toBeInTheDocument(); + await expect.element(page.getByText("Hermes setup", { exact: true })).toBeInTheDocument(); + await expect.element(page.getByText("hermes model")).toBeInTheDocument(); + await expect.element(page.getByText("hermes acp")).toBeInTheDocument(); + await expect.element(page.getByText("Hermes setup docs")).toBeInTheDocument(); + await expect.element(page.getByText(/Detected Hermes at/)).toBeInTheDocument(); + await expect + .element(page.getByRole("button", { name: "Use detected Hermes path" })) + .toBeInTheDocument(); + + await vi.waitFor(() => { + const input = Array.from(document.querySelectorAll("input")).find( + (element) => element.value === "/Users/me/.local/bin/hermes", + ); + expect(input).toBeTruthy(); + }); + }); + it("runs one-click provider updates from the provider card", async () => { const updateProvider = vi.fn().mockResolvedValue({ providers: [createOutdatedProvider("codex")], @@ -1177,6 +1260,52 @@ describe("GeneralSettingsPanel observability", () => { }); }); + it("offers provider updates in expanded settings even without an outdated advisory", async () => { + let resolveUpdateProvider: + | ((value: Awaited>) => void) + | undefined; + const updateProvider = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveUpdateProvider = resolve; + }), + ); + window.nativeApi = { + persistence: { + getClientSettings: vi.fn().mockResolvedValue(null), + setClientSettings: vi.fn().mockResolvedValue(undefined), + }, + server: { + updateProvider, + }, + } as unknown as LocalApi; + + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [createCurrentUpdatableProvider("pi", "pi update")], + }); + + mounted = await render( + + + , + ); + + await page.getByRole("button", { name: "Expand Pi provider details" }).click(); + await expect.element(page.getByText("Provider update")).toBeInTheDocument(); + await expect.element(page.getByText("pi update")).toBeInTheDocument(); + await page.getByRole("button", { name: "Update provider" }).click(); + await expect.element(page.getByRole("button", { name: "Updating" })).toBeDisabled(); + + expect(updateProvider).toHaveBeenCalledWith({ + provider: ProviderDriverKind.make("pi"), + instanceId: ProviderInstanceId.make("pi"), + }); + resolveUpdateProvider?.({ + providers: [createCurrentUpdatableProvider("pi", "pi update")], + }); + }); + it("keeps long provider update commands inside the fixed-width popover", async () => { const longUpdateCommand = "npm install -g @anthropic-ai/claude-code@latest --registry=https://registry.npmjs.org --cache=/tmp/t3code-provider-update-cache"; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da4809..8f7fae7336b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -10,6 +10,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type ServerProvider, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; @@ -967,15 +968,15 @@ export function ProviderSettingsPanel() { }); }, []); - const runProviderUpdate = useCallback(async (candidate: ProviderUpdateCandidate) => { + const runProviderUpdate = useCallback(async (provider: ServerProvider) => { let started = false; setUpdatingProviderDrivers((previous) => { - if (previous.has(candidate.driver)) { + if (previous.has(provider.driver)) { return previous; } started = true; const next = new Set(previous); - next.add(candidate.driver); + next.add(provider.driver); return next; }); if (!started) { @@ -984,14 +985,14 @@ export function ProviderSettingsPanel() { try { await ensureLocalApi().server.updateProvider({ - provider: candidate.driver, - instanceId: candidate.instanceId, + provider: provider.driver, + instanceId: provider.instanceId, }); } catch (error) { toastManager.add( stackedThreadToast({ type: "error", - title: `Could not update ${PROVIDER_DISPLAY_NAMES[candidate.driver] ?? candidate.driver}`, + title: `Could not update ${PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver}`, description: error instanceof Error ? error.message @@ -1000,11 +1001,11 @@ export function ProviderSettingsPanel() { ); } finally { setUpdatingProviderDrivers((previous) => { - if (!previous.has(candidate.driver)) { + if (!previous.has(provider.driver)) { return previous; } const next = new Set(previous); - next.delete(candidate.driver); + next.delete(provider.driver); return next; }); } @@ -1232,19 +1233,25 @@ export function ProviderSettingsPanel() { ? providerUpdateCandidateByInstanceId.get(liveProvider.instanceId) : undefined; const isDriverUpdateRunning = - updateCandidate !== undefined && - (updatingProviderDrivers.has(updateCandidate.driver) || + liveProvider !== undefined && + (updatingProviderDrivers.has(liveProvider.driver) || + (updateCandidate !== undefined && + updatingProviderDrivers.has(updateCandidate.driver)) || serverProviders.some( (provider) => - provider.driver === updateCandidate.driver && isProviderUpdateActive(provider), + provider.driver === liveProvider.driver && isProviderUpdateActive(provider), )); const showInlineUpdateButton = - updateCandidate !== undefined && - hasOneClickUpdateProviderCandidate(updateCandidate, serverProviders); + liveProvider !== undefined && + liveProvider.versionAdvisory?.canUpdate === true && + liveProvider.versionAdvisory.updateCommand !== null; const canRunInlineUpdate = - updateCandidate !== undefined && - canOneClickUpdateProviderCandidate(updateCandidate, serverProviders) && - !updatingProviderDrivers.has(updateCandidate.driver); + liveProvider !== undefined && + liveProvider.versionAdvisory?.canUpdate === true && + liveProvider.versionAdvisory.updateCommand !== null && + !updatingProviderDrivers.has(liveProvider.driver) && + (updateCandidate === undefined || + canOneClickUpdateProviderCandidate(updateCandidate, serverProviders)); const modelPreferences = settings.providerModelPreferences?.[row.instanceId] ?? { hiddenModels: [], modelOrder: [], @@ -1308,12 +1315,12 @@ export function ProviderSettingsPanel() { }) } onRunUpdate={ - showInlineUpdateButton && updateCandidate + showInlineUpdateButton && liveProvider ? () => { if (!canRunInlineUpdate) { return; } - void runProviderUpdate(updateCandidate); + void runProviderUpdate(liveProvider); } : undefined } diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 8d3d7482f62..e8bf07238e9 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,7 +2,9 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + HermesSettings, OpenCodeSettings, + PiSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; @@ -59,6 +61,20 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("hermes"), + label: "Hermes", + icon: OpenCodeIcon, + badgeLabel: "Early Access", + settingsSchema: HermesSettings, + }, + { + value: ProviderDriverKind.make("pi"), + label: "Pi", + icon: OpenCodeIcon, + badgeLabel: "Early Access", + settingsSchema: PiSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 06622a761b7..8c7f15ba3d7 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -21,6 +21,14 @@ export const PROVIDER_STATUS_STYLES = { export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; +function isHermesProvider(provider: ServerProvider): boolean { + return String(provider.driver) === "hermes"; +} + +function isPiProvider(provider: ServerProvider): boolean { + return String(provider.driver) === "pi"; +} + /** * Derive the headline + detail copy shown under a provider's name in the * settings page. Prefers `provider.message` for server-supplied detail and @@ -76,7 +84,13 @@ export function getProviderSummary(provider: ServerProvider | undefined) { } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: + provider.message ?? + (isHermesProvider(provider) + ? "Installed and ready. Hermes manages authentication through its own CLI and local config." + : isPiProvider(provider) + ? "Installed and ready. Pi manages authentication through its own CLI and local config." + : "Installed and ready, but authentication could not be verified."), }; } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0f0417d438f..9a4b155e9ad 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -83,6 +83,180 @@ animation-duration: 0s !important; } +.chat-surface-hermes { + isolation: isolate; + background: + linear-gradient(90deg, color-mix(in srgb, #f5c542 16%, transparent) 0 2px, transparent 2px) 0 + 0 / 42px 42px, + linear-gradient(0deg, color-mix(in srgb, #d4961c 12%, transparent) 0 2px, transparent 2px) 0 0 / + 42px 42px, + radial-gradient( + circle at 12% 10%, + color-mix(in srgb, #f5c542 18%, transparent) 0 5px, + transparent 6px + ) + 0 0 / 96px 96px, + linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 90%, #f5c542) 0%, + color-mix(in srgb, var(--background) 94%, #d4961c) 34%, + color-mix(in srgb, var(--background) 97%, #0f172a) 68%, + var(--background) 100% + ); +} + +.chat-surface-hermes::before, +.chat-surface-hermes::after { + position: absolute; + pointer-events: none; + z-index: -1; + content: ""; +} + +.chat-surface-hermes::before { + top: max(2rem, 8%); + right: max(1.5rem, 5%); + width: min(32rem, 58vw); + aspect-ratio: 1; + opacity: 0.13; + background: + linear-gradient(#f5c542, #d4961c) 50% 12% / 3% 58% no-repeat, + radial-gradient(circle at 50% 12%, #fff8e1 0 2.5%, #f5c542 2.75% 6%, transparent 6.25%), + conic-gradient(from 300deg at 50% 28%, transparent 0 17%, #f5c542 17% 22%, transparent 22%), + conic-gradient(from 120deg at 50% 28%, transparent 0 17%, #f5c542 17% 22%, transparent 22%), + radial-gradient(ellipse at 38% 26%, transparent 0 42%, #f5c542 43% 47%, transparent 48%), + radial-gradient(ellipse at 62% 26%, transparent 0 42%, #d4961c 43% 47%, transparent 48%), + repeating-linear-gradient( + 90deg, + color-mix(in srgb, #f5c542 40%, transparent) 0 8px, + transparent 8px 16px + ); + clip-path: polygon(50% 0, 83% 22%, 67% 100%, 50% 80%, 33% 100%, 17% 22%); + filter: saturate(1.15); +} + +.chat-surface-hermes::after { + inset: 0; + opacity: 0.1; + background: + linear-gradient(90deg, transparent 0 18px, #f5c542 18px 24px, transparent 24px 42px) right 9% + top 8% / 252px 54px no-repeat, + linear-gradient(90deg, transparent 0 12px, #d4961c 12px 18px, transparent 18px 36px) right 6% + top calc(8% + 54px) / 216px 42px no-repeat, + repeating-linear-gradient( + 0deg, + color-mix(in srgb, var(--foreground) 5%, transparent) 0 1px, + transparent 1px 4px + ); + mix-blend-mode: multiply; +} + +.dark .chat-surface-hermes { + background: + linear-gradient(90deg, color-mix(in srgb, #f5c542 16%, transparent) 0 2px, transparent 2px) 0 + 0 / 42px 42px, + linear-gradient(0deg, color-mix(in srgb, #d4961c 12%, transparent) 0 2px, transparent 2px) 0 0 / + 42px 42px, + radial-gradient( + circle at 12% 10%, + color-mix(in srgb, #f5c542 16%, transparent) 0 5px, + transparent 6px + ) + 0 0 / 96px 96px, + linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 80%, #f5c542) 0%, + color-mix(in srgb, var(--background) 88%, #d4961c) 34%, + color-mix(in srgb, var(--background) 96%, #0f172a) 68%, + var(--background) 100% + ); +} + +.dark .chat-surface-hermes::after { + mix-blend-mode: screen; +} + +.chat-surface-pi { + isolation: isolate; + background: + linear-gradient(90deg, color-mix(in srgb, #22c55e 12%, transparent) 0 1px, transparent 1px) 0 + 0 / 36px 36px, + linear-gradient(0deg, color-mix(in srgb, #14b8a6 10%, transparent) 0 1px, transparent 1px) 0 0 / + 36px 36px, + radial-gradient( + circle at 18% 16%, + color-mix(in srgb, #22c55e 18%, transparent) 0 4px, + transparent 5px + ) + 0 0 / 108px 108px, + linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 91%, #22c55e) 0%, + color-mix(in srgb, var(--background) 95%, #14b8a6) 42%, + color-mix(in srgb, var(--background) 97%, #0f172a) 74%, + var(--background) 100% + ); +} + +.chat-surface-pi::before, +.chat-surface-pi::after { + position: absolute; + pointer-events: none; + z-index: -1; + content: ""; +} + +.chat-surface-pi::before { + top: max(2rem, 9%); + right: max(1.5rem, 6%); + width: min(30rem, 54vw); + aspect-ratio: 1; + opacity: 0.12; + background: + radial-gradient(circle at 50% 50%, transparent 0 35%, #22c55e 36% 39%, transparent 40%), + conic-gradient(from 45deg, transparent 0 12%, #14b8a6 12% 16%, transparent 16% 25%), + linear-gradient(90deg, transparent 0 46%, #22c55e 46% 54%, transparent 54%), + linear-gradient(0deg, transparent 0 46%, #14b8a6 46% 54%, transparent 54%); + clip-path: polygon(50% 0, 92% 26%, 76% 84%, 50% 100%, 24% 84%, 8% 26%); + filter: saturate(1.1); +} + +.chat-surface-pi::after { + inset: 0; + opacity: 0.09; + background: + repeating-linear-gradient( + 135deg, + color-mix(in srgb, #22c55e 35%, transparent) 0 2px, + transparent 2px 14px + ), + repeating-linear-gradient( + 0deg, + color-mix(in srgb, var(--foreground) 4%, transparent) 0 1px, + transparent 1px 5px + ); + mix-blend-mode: multiply; +} + +.dark .chat-surface-pi { + background: + linear-gradient(90deg, color-mix(in srgb, #22c55e 14%, transparent) 0 1px, transparent 1px) 0 + 0 / 36px 36px, + linear-gradient(0deg, color-mix(in srgb, #14b8a6 12%, transparent) 0 1px, transparent 1px) 0 0 / + 36px 36px, + linear-gradient( + 180deg, + color-mix(in srgb, var(--background) 82%, #22c55e) 0%, + color-mix(in srgb, var(--background) 90%, #14b8a6) 42%, + color-mix(in srgb, var(--background) 96%, #0f172a) 74%, + var(--background) 100% + ); +} + +.dark .chat-surface-pi::after { + mix-blend-mode: screen; +} + :root { color-scheme: light; --radius: 0.625rem; diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 6ff0bd1ab98..cfce49f5326 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -85,6 +85,10 @@ export function normalizeProviderAccentColor(value: string | undefined): string return /^#[0-9a-fA-F]{6}$/u.test(trimmed) ? trimmed : undefined; } +export function isSelectableProviderInstance(entry: ProviderInstanceEntry): boolean { + return entry.enabled && entry.isAvailable && entry.status === "ready" && entry.models.length > 0; +} + /** * Resolve an entry's displayName with a tiered priority: * @@ -217,16 +221,14 @@ export function resolveSelectableProviderInstance( instanceId: ProviderInstanceId | undefined, ): ProviderInstanceId | undefined { if (instanceId === undefined) { - return deriveProviderInstanceEntries(providers).find( - (entry) => entry.enabled && entry.isAvailable, - )?.instanceId; + return deriveProviderInstanceEntries(providers).find(isSelectableProviderInstance)?.instanceId; } const entries = deriveProviderInstanceEntries(providers); const requested = entries.find((entry) => entry.instanceId === instanceId); - if (requested && requested.enabled && requested.isAvailable) { + if (requested && isSelectableProviderInstance(requested)) { return instanceId; } - return entries.find((entry) => entry.enabled && entry.isAvailable)?.instanceId; + return entries.find(isSelectableProviderInstance)?.instanceId; } /** diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 38819e28d73..b3bed23bfa5 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" }; const port = Number(process.env.PORT ?? 5733); const host = process.env.HOST?.trim() || "localhost"; +const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim(); const configuredWsUrl = process.env.VITE_WS_URL?.trim(); const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; @@ -32,13 +33,13 @@ const buildSourcemap = ? "hidden" : true; -function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { - if (!wsUrl) { +function resolveDevProxyTarget(inputUrl: string | undefined): string | undefined { + if (!inputUrl) { return undefined; } try { - const url = new URL(wsUrl); + const url = new URL(inputUrl); if (url.protocol === "ws:") { url.protocol = "http:"; } else if (url.protocol === "wss:") { @@ -53,7 +54,7 @@ function resolveDevProxyTarget(wsUrl: string | undefined): string | undefined { } } -const devProxyTarget = resolveDevProxyTarget(configuredWsUrl); +const devProxyTarget = resolveDevProxyTarget(configuredHttpUrl) ?? resolveDevProxyTarget(configuredWsUrl); export default defineConfig({ plugins: [ diff --git a/docs/assets/hermes-chat-theme.jpg b/docs/assets/hermes-chat-theme.jpg new file mode 100644 index 00000000000..e2395f3d599 Binary files /dev/null and b/docs/assets/hermes-chat-theme.jpg differ diff --git a/docs/assets/pi-chat-theme.jpg b/docs/assets/pi-chat-theme.jpg new file mode 100644 index 00000000000..07f3537fc65 Binary files /dev/null and b/docs/assets/pi-chat-theme.jpg differ diff --git a/docs/providers/hermes.md b/docs/providers/hermes.md new file mode 100644 index 00000000000..53a1f3e8676 --- /dev/null +++ b/docs/providers/hermes.md @@ -0,0 +1,94 @@ +# Hermes + +This guide is for people who want to use Hermes Agent from T3 Code. + +Hermes runs as a local CLI process through ACP. T3 Code only starts `hermes acp` when a Hermes +conversation needs it, so provider refreshes stay fast. + +## Install Hermes + +Install and configure Hermes Agent from the upstream project: + +```bash +git clone https://github.com/nousresearch/hermes-agent.git ~/Projects/hermes-agent +cd ~/Projects/hermes-agent +python3 -m venv venv +./venv/bin/pip install -e . +``` + +Create a stable binary path that GUI apps can find: + +```bash +mkdir -p ~/.local/bin +ln -sf ~/Projects/hermes-agent/venv/bin/hermes ~/.local/bin/hermes +~/.local/bin/hermes --version +``` + +Then run Hermes setup: + +```bash +~/.local/bin/hermes model +``` + +Hermes stores its model configuration in: + +```text +~/.hermes/config.yaml +``` + +T3 Code reads `model.default` from that file and shows it in the model picker. If the config file is +missing or unreadable, T3 Code falls back to `Hermes Default`. + +## Configure T3 Code + +In Settings, enable Hermes and set: + +```text +Binary path: /Users/you/.local/bin/hermes +``` + +Using the full path is recommended on macOS because apps launched from the Dock or a desktop shell +may not inherit your terminal `PATH`. + +On Windows, use the executable or command shim from your virtual environment, for example: + +```text +Binary path: C:\Users\you\Projects\hermes-agent\venv\Scripts\hermes.exe +``` + +T3 Code also checks common Hermes locations such as `~/.local/bin/hermes`, +`~/Projects/hermes-agent/venv/bin/hermes`, Homebrew paths, and your login-shell `PATH`. If it finds +Hermes somewhere else, the provider card shows a detected path you can apply from Settings. + +This is intentional for packaged macOS builds: apps launched from Finder often do not inherit your +terminal `PATH`, so the most reliable setup is an absolute binary path such as +`/Users/you/.local/bin/hermes`. + +## Verify + +Run: + +```bash +/Users/you/.local/bin/hermes --version +/Users/you/.local/bin/hermes acp +``` + +The ACP command should stay running and print a startup message. Stop it with `Ctrl-C`. + +In T3 Code, select Hermes in the model picker and send a small prompt. If Hermes has a configured +default model, the picker should show that model name rather than only `Hermes Default`. + +## Troubleshooting + +If T3 Code says Hermes is not installed, use an absolute binary path instead of `hermes`. + +If Settings shows a detected Hermes path, click **Use detected path** and refresh provider status. + +If the model picker says no models were found, refresh provider status and confirm that Hermes is +enabled. Disabled, missing, or not-ready providers are not treated as selectable model sources. + +If Hermes asks for auth or model setup, run: + +```bash +~/.local/bin/hermes model +``` diff --git a/docs/providers/pi.md b/docs/providers/pi.md new file mode 100644 index 00000000000..168b8b91927 --- /dev/null +++ b/docs/providers/pi.md @@ -0,0 +1,104 @@ +# Pi + +This guide is for people who want to use Pi Agent from T3 Code. + +Pi does not currently ship a native ACP command. T3 Code runs Pi through the +[`pi-acp`](https://github.com/svkozak/pi-acp) adapter, which speaks ACP over stdio and starts +`pi --mode rpc --no-themes` behind the scenes. + +## Install Pi + +Install Pi Agent and the ACP adapter: + +```bash +npm install -g @earendil-works/pi-coding-agent pi-acp +``` + +Verify both commands: + +```bash +pi --version +pi-acp --help +``` + +Then run Pi once in a terminal and configure the model provider/API key you want Pi to use. + +For GPT-5.5 parity with the Hermes setup, Pi needs its own ChatGPT Plus/Pro Codex login. Start +Pi, run `/login`, choose the ChatGPT Plus/Pro Codex provider, and complete the browser sign-in. +Pi stores this under `~/.pi/agent/auth.json`; T3 Code does not reuse Hermes credentials. + +After login, Pi should have these defaults in `~/.pi/agent/settings.json`: + +```json +{ + "defaultProvider": "openai-codex", + "defaultModel": "gpt-5.5" +} +``` + +## Configure T3 Code + +In Settings, enable Pi and set: + +```text +ACP adapter path: pi-acp +Pi binary path: pi +``` + +Absolute paths are recommended for packaged macOS builds because GUI apps can have a smaller +`PATH` than your terminal. Common paths are: + +```text +/opt/homebrew/bin/pi-acp +/opt/homebrew/bin/pi +/usr/local/bin/pi-acp +/usr/local/bin/pi +/Users/you/.local/bin/pi-acp +/Users/you/.local/bin/pi +``` + +On Windows, global npm installs usually create `.cmd` shims under the roaming npm directory. Common +paths are: + +```text +C:\Users\you\AppData\Roaming\npm\pi-acp.cmd +C:\Users\you\AppData\Roaming\npm\pi.cmd +``` + +T3 Code passes the configured Pi binary to `pi-acp` through `PI_ACP_PI_COMMAND`, so the adapter can +find Pi even when the app is launched outside your shell. + +The Pi provider card also includes a provider update action. Use it after Pi reports a newer +version, or run the same update manually from a terminal: + +```bash +pi update +``` + +## Test a Chat + +In T3 Code, select Pi in the model picker and send a small prompt. Pi manages authentication and +model provider setup through its own CLI and config; T3 Code only starts `pi-acp` when a Pi +conversation needs it. + +For GPT-5.5, verify Pi can see the Codex model after login: + +```bash +pi --provider openai-codex --list-models gpt +``` + +If Settings says Pi is unavailable, verify both commands from the same environment: + +```bash +pi --version +pi-acp --help +``` + +If Pi is installed but the adapter is missing, run: + +```bash +npm install -g pi-acp +``` + +If the adapter is installed but cannot find Pi, set the Pi binary path to an absolute path such as +`/opt/homebrew/bin/pi`. diff --git a/docs/providers/release-readiness.md b/docs/providers/release-readiness.md new file mode 100644 index 00000000000..c7f4987ceb4 --- /dev/null +++ b/docs/providers/release-readiness.md @@ -0,0 +1,47 @@ +# Hermes and Pi release readiness + +Use this checklist before publishing a fork build with Hermes or Pi provider support. + +## Provider setup + +- Open **Settings -> Providers** and expand Hermes and Pi. +- Confirm each provider shows a complete setup checklist. +- Use **Copy diagnostics** if the provider is unavailable, then verify the reported binary paths in a terminal. +- Use **Update provider** when an update is offered, then refresh provider status. +- Open a fresh chat, select the provider from the model picker, and send a small prompt. + +## Mac validation + +- Build the Apple Silicon DMG: + +```sh +bun run dist:desktop:dmg:arm64 +``` + +- Mount the DMG and confirm `T3 Code (Alpha).app` appears. +- Install from the DMG on a clean macOS user account. +- Confirm Hermes and Pi are detected when installed under common paths such as `~/.local/bin`. +- Confirm absolute binary paths work when the packaged app cannot see the shell `PATH`. + +## Windows validation + +- Build the Windows installer: + +```sh +bun run dist:desktop:win:x64 +``` + +- Install the generated `.exe` on Windows. +- Confirm Hermes detection through `where hermes`. +- Confirm Pi detection through `where pi-acp` and `where pi`. +- Confirm `.cmd` paths under `%APPDATA%\npm` work from the packaged app. + +## Screenshots + +Capture fresh-chat screenshots after provider selection: + +- Hermes selected with the Hermes chat background visible. +- Pi selected with the Pi chat background visible. +- Settings -> Providers with the setup checklist and update action visible. + +Store screenshots under `docs/assets/` and keep the README image links current. diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79..b9025f5a7ab 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -131,6 +131,8 @@ const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const HERMES_DRIVER_KIND = ProviderDriverKind.make("hermes"); +const PI_DRIVER_KIND = ProviderDriverKind.make("pi"); export const DEFAULT_MODEL = "gpt-5.4"; export const DEFAULT_GIT_TEXT_GENERATION_MODEL = "gpt-5.4-mini"; @@ -140,6 +142,8 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", [OPENCODE_DRIVER_KIND]: "OpenCode", + [HERMES_DRIVER_KIND]: "Hermes", + [PI_DRIVER_KIND]: "Pi", }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 85ff4a4b2cb..1d8878b65ec 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -172,6 +172,7 @@ export const ServerProvider = Schema.Struct({ auth: ServerProviderAuth, checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), + suggestedBinaryPath: Schema.optional(TrimmedNonEmptyString), // Optional for back-compat: every legacy producer omits this field and // an absent value is interpreted as `"available"` by consumers (see // `isProviderAvailable`). New `ProviderInstanceRegistry` outputs set it diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..94fd6f43286 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -331,6 +331,70 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +export const HermesSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("hermes").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Hermes Agent binary.", + providerSettingsForm: { + placeholder: "hermes", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath"], + }, +); +export type HermesSettings = typeof HermesSettings.Type; + +export const PiSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("pi-acp").pipe( + Schema.annotateKey({ + title: "ACP adapter path", + description: "Path to the pi-acp adapter binary.", + providerSettingsForm: { + placeholder: "pi-acp", + clearWhenEmpty: "omit", + }, + }), + ), + piBinaryPath: makeBinaryPathSetting("pi").pipe( + Schema.annotateKey({ + title: "Pi binary path", + description: "Path to the Pi Agent binary used by pi-acp.", + providerSettingsForm: { + placeholder: "pi", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "piBinaryPath"], + }, +); +export type PiSettings = typeof PiSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), @@ -370,6 +434,8 @@ export const ServerSettings = Schema.Struct({ claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + hermes: HermesSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + pi: PiSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values // are `ProviderInstanceConfig` envelopes. The driver-specific config blob @@ -445,6 +511,19 @@ const OpenCodeSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const HermesSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + +const PiSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + piBinaryPath: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + export const ServerSettingsPatch = Schema.Struct({ // Server settings enableAssistantStreaming: Schema.optionalKey(Schema.Boolean), @@ -464,6 +543,8 @@ export const ServerSettingsPatch = Schema.Struct({ claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), + hermes: Schema.optionalKey(HermesSettingsPatch), + pi: Schema.optionalKey(PiSettingsPatch), }), ), // Whole-map replacement for the new instance config. Patching individual