From 4ced5912b65e4bfd69d89c2a8f9834dcbd687031 Mon Sep 17 00:00:00 2001 From: Declan Cowen Date: Wed, 20 May 2026 13:38:40 +0100 Subject: [PATCH 01/19] feat: add Kiro CLI provider --- apps/server/src/localUserEnvironment.test.ts | 22 + apps/server/src/localUserEnvironment.ts | 27 + .../server/src/provider/Drivers/KiroDriver.ts | 166 ++++ .../src/provider/Drivers/KiroHome.test.ts | 55 ++ apps/server/src/provider/Drivers/KiroHome.ts | 49 + .../server/src/provider/Layers/KiroAdapter.ts | 31 + .../src/provider/Layers/KiroProvider.test.ts | 64 ++ .../src/provider/Layers/KiroProvider.ts | 589 +++++++++++ .../ProviderInstanceRegistryLive.test.ts | 61 +- .../src/provider/Services/KiroAdapter.ts | 12 + .../src/provider/acp/AcpSessionRuntime.ts | 36 +- .../src/provider/acp/KiroAcpSupport.test.ts | 33 + .../server/src/provider/acp/KiroAcpSupport.ts | 56 ++ .../src/provider/acp/StandardAcpAdapter.ts | 914 ++++++++++++++++++ apps/server/src/provider/builtInDrivers.ts | 3 + .../src/terminal/Layers/Manager.test.ts | 3 + apps/server/src/terminal/Layers/Manager.ts | 5 + .../src/textGeneration/KiroTextGeneration.ts | 266 +++++ .../components/KeybindingsToast.browser.tsx | 7 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/ProviderSettingsForm.test.ts | 18 + .../components/settings/providerDriverMeta.ts | 9 +- apps/web/src/session-logic.ts | 6 + packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.test.ts | 24 + packages/contracts/src/settings.ts | 56 ++ packages/shared/src/shell.test.ts | 2 +- packages/shared/src/shell.ts | 2 +- 28 files changed, 2500 insertions(+), 24 deletions(-) create mode 100644 apps/server/src/localUserEnvironment.test.ts create mode 100644 apps/server/src/localUserEnvironment.ts create mode 100644 apps/server/src/provider/Drivers/KiroDriver.ts create mode 100644 apps/server/src/provider/Drivers/KiroHome.test.ts create mode 100644 apps/server/src/provider/Drivers/KiroHome.ts create mode 100644 apps/server/src/provider/Layers/KiroAdapter.ts create mode 100644 apps/server/src/provider/Layers/KiroProvider.test.ts create mode 100644 apps/server/src/provider/Layers/KiroProvider.ts create mode 100644 apps/server/src/provider/Services/KiroAdapter.ts create mode 100644 apps/server/src/provider/acp/KiroAcpSupport.test.ts create mode 100644 apps/server/src/provider/acp/KiroAcpSupport.ts create mode 100644 apps/server/src/provider/acp/StandardAcpAdapter.ts create mode 100644 apps/server/src/textGeneration/KiroTextGeneration.ts diff --git a/apps/server/src/localUserEnvironment.test.ts b/apps/server/src/localUserEnvironment.test.ts new file mode 100644 index 00000000000..aee469087ad --- /dev/null +++ b/apps/server/src/localUserEnvironment.test.ts @@ -0,0 +1,22 @@ +import * as NodeOS from "node:os"; +import { describe, expect, it } from "vitest"; + +import { resolveLocalUserHome, withLocalUserHome } from "./localUserEnvironment.ts"; + +describe("localUserEnvironment", () => { + it("preserves normal HOME values", () => { + const env = { HOME: "/Users/someone" }; + + expect(resolveLocalUserHome(env)).toBe("/Users/someone"); + expect(withLocalUserHome(env)).toBe(env); + }); + + it("maps the Codex-launched T3 temp HOME back to the OS account home", () => { + const env = { HOME: "/private/tmp/t3code-home" }; + + expect(resolveLocalUserHome(env)).toBe(NodeOS.userInfo().homedir); + expect(withLocalUserHome(env)).toEqual({ + HOME: NodeOS.userInfo().homedir, + }); + }); +}); diff --git a/apps/server/src/localUserEnvironment.ts b/apps/server/src/localUserEnvironment.ts new file mode 100644 index 00000000000..81e41c1e123 --- /dev/null +++ b/apps/server/src/localUserEnvironment.ts @@ -0,0 +1,27 @@ +import * as NodeOS from "node:os"; + +const T3CODE_TEMP_HOME_PATTERN = /^\/(?:private\/)?tmp\/t3code-home(?:\/|$)/; + +function shouldUseOsAccountHome(home: string | undefined): boolean { + return typeof home === "string" && T3CODE_TEMP_HOME_PATTERN.test(home); +} + +export function resolveLocalUserHome(baseEnv: NodeJS.ProcessEnv = process.env): string | undefined { + if (!shouldUseOsAccountHome(baseEnv.HOME)) { + return baseEnv.HOME; + } + + return NodeOS.userInfo().homedir || baseEnv.HOME; +} + +export function withLocalUserHome(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + const localHome = resolveLocalUserHome(baseEnv); + if (!localHome || localHome === baseEnv.HOME) { + return baseEnv; + } + + return { + ...baseEnv, + HOME: localHome, + }; +} diff --git a/apps/server/src/provider/Drivers/KiroDriver.ts b/apps/server/src/provider/Drivers/KiroDriver.ts new file mode 100644 index 00000000000..7a27b9d5776 --- /dev/null +++ b/apps/server/src/provider/Drivers/KiroDriver.ts @@ -0,0 +1,166 @@ +/** + * KiroDriver — `ProviderDriver` for Kiro CLI's ACP runtime. + * + * Kiro exposes Agent Client Protocol over `kiro-cli acp`, so the driver + * composes the existing provider instance model with the shared ACP adapter + * instead of introducing a provider-specific transport path. + * + * @module provider/Drivers/KiroDriver + */ +import { KiroSettings, 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 { withLocalUserHome } from "../../localUserEnvironment.ts"; +import { makeKiroTextGeneration } from "../../textGeneration/KiroTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeKiroAdapter } from "../Layers/KiroAdapter.ts"; +import { checkKiroProviderStatus, makePendingKiroProvider } from "../Layers/KiroProvider.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 { + enrichProviderSnapshotWithVersionAdvisory, + makeStaticProviderMaintenanceResolver, + makeProviderMaintenanceCapabilities, + resolveProviderMaintenanceCapabilitiesEffect, +} from "../providerMaintenance.ts"; +import { makeKiroContinuationGroupKey, makeKiroEnvironment } from "./KiroHome.ts"; + +const decodeKiroSettings = Schema.decodeSync(KiroSettings); + +const DRIVER_KIND = ProviderDriverKind.make("kiro"); +const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const UPDATE = makeStaticProviderMaintenanceResolver( + makeProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, + updateExecutable: "kiro-cli", + updateArgs: ["update", "--non-interactive"], + updateLockKey: "kiro-cli", + }), +); + +export type KiroDriverEnv = + | 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 KiroDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Kiro", + supportsMultipleInstances: true, + }, + configSchema: KiroSettings, + defaultConfig: (): KiroSettings => decodeKiroSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const httpClient = yield* HttpClient.HttpClient; + const eventLoggers = yield* ProviderEventLoggers; + const baseEnv = withLocalUserHome(mergeProviderInstanceEnvironment(environment)); + const effectiveConfig = { ...config, enabled } satisfies KiroSettings; + const processEnv = yield* makeKiroEnvironment(effectiveConfig, baseEnv); + const fallbackContinuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const maintenanceCapabilities = yield* resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { + binaryPath: effectiveConfig.binaryPath, + env: processEnv, + }); + const continuationGroupKey = yield* makeKiroContinuationGroupKey(effectiveConfig, baseEnv); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey, + }); + + const adapter = yield* makeKiroAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeKiroTextGeneration(effectiveConfig, processEnv); + + const checkProvider = checkKiroProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + + const snapshot = yield* makeManagedServerProvider({ + maintenanceCapabilities, + getSettings: Effect.succeed(effectiveConfig), + streamSettings: Stream.never, + haveSettingsChanged: () => false, + initialSnapshot: (settings) => + makePendingKiroProvider(settings).pipe(Effect.map(stampIdentity)), + checkProvider, + enrichSnapshot: ({ snapshot, publishSnapshot }) => + enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + ), + refreshInterval: SNAPSHOT_REFRESH_INTERVAL, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Kiro snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity: { + ...fallbackContinuationIdentity, + continuationKey: continuationGroupKey, + }, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Drivers/KiroHome.test.ts b/apps/server/src/provider/Drivers/KiroHome.test.ts new file mode 100644 index 00000000000..682cac75b5c --- /dev/null +++ b/apps/server/src/provider/Drivers/KiroHome.test.ts @@ -0,0 +1,55 @@ +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { + makeKiroContinuationGroupKey, + makeKiroEnvironment, + resolveKiroHomePath, +} from "./KiroHome.ts"; + +it.layer(NodeServices.layer)("KiroHome", (it) => { + describe("Kiro home resolution", () => { + it.effect("uses the process Kiro home when no override is configured", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const resolved = path.resolve(NodeOS.userInfo().homedir, ".kiro"); + + expect(yield* resolveKiroHomePath({ homePath: "" })).toBe(resolved); + expect(yield* makeKiroEnvironment({ homePath: "" })).toBe(process.env); + expect(yield* makeKiroContinuationGroupKey({ homePath: "" })).toBe(`kiro:home:${resolved}`); + }), + ); + + it.effect( + "uses the OS account home when the server was launched with the Codex temp home", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const env = { HOME: "/private/tmp/t3code-home" }; + const resolved = path.resolve(NodeOS.userInfo().homedir, ".kiro"); + + expect(yield* resolveKiroHomePath({ homePath: "" }, env)).toBe(resolved); + expect(yield* resolveKiroHomePath({ homePath: "~/.kiro" }, env)).toBe(resolved); + expect(yield* makeKiroContinuationGroupKey({ homePath: "" }, env)).toBe( + `kiro:home:${resolved}`, + ); + }), + ); + + it.effect("sets KIRO_HOME and stamps continuation keys with the configured home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const homePath = "~/.kiro-work"; + const resolved = path.resolve(NodeOS.userInfo().homedir, ".kiro-work"); + + expect(yield* resolveKiroHomePath({ homePath })).toBe(resolved); + expect((yield* makeKiroEnvironment({ homePath })).KIRO_HOME).toBe(resolved); + expect(yield* makeKiroContinuationGroupKey({ homePath })).toBe(`kiro:home:${resolved}`); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/KiroHome.ts b/apps/server/src/provider/Drivers/KiroHome.ts new file mode 100644 index 00000000000..0565ece9823 --- /dev/null +++ b/apps/server/src/provider/Drivers/KiroHome.ts @@ -0,0 +1,49 @@ +import * as NodeOS from "node:os"; + +import type { KiroSettings } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; + +import { resolveLocalUserHome } from "../../localUserEnvironment.ts"; + +function expandKiroHomePath(value: string, baseEnv: NodeJS.ProcessEnv): string { + if (!value) return value; + const localHome = resolveLocalUserHome(baseEnv) ?? NodeOS.homedir(); + if (value === "~") return localHome; + if (value.startsWith("~/") || value.startsWith("~\\")) { + return `${localHome}/${value.slice(2)}`; + } + return value; +} + +export const resolveKiroHomePath = Effect.fn("resolveKiroHomePath")(function* ( + config: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const path = yield* Path.Path; + const homePath = config.homePath.trim(); + return homePath.length > 0 + ? path.resolve(expandKiroHomePath(homePath, baseEnv)) + : path.resolve(resolveLocalUserHome(baseEnv) ?? NodeOS.homedir(), ".kiro"); +}); + +export const makeKiroEnvironment = Effect.fn("makeKiroEnvironment")(function* ( + config: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const homePath = config.homePath.trim(); + if (homePath.length === 0) return baseEnv; + const resolvedHomePath = yield* resolveKiroHomePath(config, baseEnv); + return { + ...baseEnv, + KIRO_HOME: resolvedHomePath, + }; +}); + +export const makeKiroContinuationGroupKey = Effect.fn("makeKiroContinuationGroupKey")(function* ( + config: Pick, + baseEnv: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const resolvedHomePath = yield* resolveKiroHomePath(config, baseEnv); + return `kiro:home:${resolvedHomePath}`; +}); diff --git a/apps/server/src/provider/Layers/KiroAdapter.ts b/apps/server/src/provider/Layers/KiroAdapter.ts new file mode 100644 index 00000000000..36b2ede60a5 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroAdapter.ts @@ -0,0 +1,31 @@ +import { type KiroSettings, ProviderDriverKind, type ProviderInstanceId } from "@t3tools/contracts"; + +import { makeKiroAcpRuntime } from "../acp/KiroAcpSupport.ts"; +import { makeStandardAcpAdapter } from "../acp/StandardAcpAdapter.ts"; +import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = ProviderDriverKind.make("kiro"); + +export interface KiroAdapterLiveOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; +} + +export function makeKiroAdapter(kiroSettings: KiroSettings, options?: KiroAdapterLiveOptions) { + return makeStandardAcpAdapter({ + provider: PROVIDER, + runtimeLabel: "Kiro", + ...(options?.environment ? { environment: options.environment } : {}), + ...(options?.nativeEventLogPath ? { nativeEventLogPath: options.nativeEventLogPath } : {}), + ...(options?.nativeEventLogger ? { nativeEventLogger: options.nativeEventLogger } : {}), + ...(options?.instanceId ? { instanceId: options.instanceId } : {}), + makeRuntime: (input) => + makeKiroAcpRuntime({ + kiroSettings, + ...(options?.environment ? { environment: options.environment } : {}), + ...input, + }), + }); +} diff --git a/apps/server/src/provider/Layers/KiroProvider.test.ts b/apps/server/src/provider/Layers/KiroProvider.test.ts new file mode 100644 index 00000000000..85ce09eef75 --- /dev/null +++ b/apps/server/src/provider/Layers/KiroProvider.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { createModelCapabilities } from "@t3tools/shared/model"; + +import { parseKiroListModelsOutput } from "./KiroProvider.ts"; + +const emptyCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +describe("parseKiroListModelsOutput", () => { + it("publishes Kiro CLI model choices from chat --list-models json", () => { + expect( + parseKiroListModelsOutput({ + code: 0, + stderr: "", + stdout: JSON.stringify({ + models: [ + { + model_name: "auto", + model_id: "auto", + description: "Models chosen by task", + context_window_tokens: 1_000_000, + }, + { + model_name: "claude-opus-4.7", + model_id: "claude-opus-4.7", + description: "Experimental preview", + context_window_tokens: 1_000_000, + }, + ], + default_model: "auto", + }), + }), + ).toEqual([ + { + slug: "auto", + name: "auto", + isCustom: false, + capabilities: emptyCapabilities, + }, + { + slug: "claude-opus-4.7", + name: "claude-opus-4.7", + isCustom: false, + capabilities: emptyCapabilities, + }, + ]); + }); + + it("ignores malformed and duplicate model entries", () => { + expect( + parseKiroListModelsOutput({ + code: 0, + stderr: "", + stdout: JSON.stringify({ + models: [ + { model_id: "auto", model_name: "Auto" }, + { model_id: "auto", model_name: "Auto duplicate" }, + { model_name: "missing id" }, + null, + ], + }), + }).map((model) => model.slug), + ).toEqual(["auto"]); + }); +}); diff --git a/apps/server/src/provider/Layers/KiroProvider.ts b/apps/server/src/provider/Layers/KiroProvider.ts new file mode 100644 index 00000000000..0c32f80c59c --- /dev/null +++ b/apps/server/src/provider/Layers/KiroProvider.ts @@ -0,0 +1,589 @@ +import type { + KiroSettings, + ModelCapabilities, + ServerProviderAuth, + ServerProviderModel, + ServerProviderState, +} from "@t3tools/contracts"; +import { ProviderDriverKind } from "@t3tools/contracts"; +import { createModelCapabilities } from "@t3tools/shared/model"; +import * as Cause from "effect/Cause"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Result from "effect/Result"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { + buildServerProvider, + collectStreamAsString, + isCommandMissingCause, + parseGenericCliVersion, + providerModelsFromSettings, + type CommandResult, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { makeKiroAcpRuntime } from "../acp/KiroAcpSupport.ts"; + +const PROVIDER = ProviderDriverKind.make("kiro"); +const KIRO_PRESENTATION = { + displayName: "Kiro", + showInteractionModeToggle: true, +} as const; +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ + optionDescriptors: [], +}); +const KIRO_FALLBACK_MODELS: ReadonlyArray = [ + { + slug: "auto", + name: "Auto", + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + }, +]; +const VERSION_TIMEOUT_MS = 4_000; +const WHOAMI_TIMEOUT_MS = 8_000; +const LIST_MODELS_TIMEOUT_MS = 8_000; +const KIRO_ACP_MODEL_DISCOVERY_TIMEOUT_MS = 15_000; + +interface KiroWhoamiJsonPayload { + readonly email?: unknown; + readonly username?: unknown; + readonly profile?: unknown; + readonly authType?: unknown; + readonly authenticationMethod?: unknown; + readonly status?: unknown; +} + +interface KiroSessionSelectOption { + readonly value: string; + readonly name: string; +} + +interface KiroListModelsJsonPayload { + readonly models?: unknown; + readonly default_model?: unknown; + readonly defaultModel?: unknown; +} + +function getKiroFallbackModels( + kiroSettings: Pick, +): ReadonlyArray { + return providerModelsFromSettings( + KIRO_FALLBACK_MODELS, + PROVIDER, + kiroSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +function stripAnsi(text: string): string { + // eslint-disable-next-line no-control-regex + return text.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\].*?\x07/g, ""); +} + +function parseKiroWhoamiJsonPayload(raw: string): KiroWhoamiJsonPayload | undefined { + const trimmed = raw.trim(); + if (!trimmed.startsWith("{")) return undefined; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined; + return parsed as KiroWhoamiJsonPayload; + } catch { + return undefined; + } +} + +function readFirstString( + record: Record, + keys: ReadonlyArray, +): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +function parseKiroWhoamiOutput(result: CommandResult): { + readonly auth: ServerProviderAuth; + readonly status: Exclude; + readonly message?: string; +} { + const jsonPayload = parseKiroWhoamiJsonPayload(result.stdout); + if (jsonPayload) { + const record = jsonPayload as Record; + const email = readFirstString(record, ["email", "userEmail"]); + const username = readFirstString(record, ["username", "userName", "userId"]); + const type = readFirstString(record, ["authType", "authenticationMethod", "loginType"]); + const profile = readFirstString(record, ["profile", "profileName"]); + const normalizedStatus = readFirstString(record, ["status", "sessionStatus"])?.toLowerCase(); + if ( + result.code !== 0 || + normalizedStatus === "not logged in" || + normalizedStatus === "unauthenticated" + ) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "Kiro CLI is not authenticated. Run `kiro-cli login` and try again.", + }; + } + return { + status: "ready", + auth: { + status: "authenticated", + ...(email ? { email } : {}), + ...(type ? { type } : {}), + ...(profile ? { label: profile } : username ? { label: username } : {}), + }, + }; + } + + const combined = stripAnsi(`${result.stdout}\n${result.stderr}`); + const lowerOutput = combined.toLowerCase(); + if ( + result.code !== 0 || + lowerOutput.includes("not logged in") || + lowerOutput.includes("login required") || + lowerOutput.includes("authentication required") + ) { + return { + status: "error", + auth: { status: "unauthenticated" }, + message: "Kiro CLI is not authenticated. Run `kiro-cli login` and try again.", + }; + } + + const emailMatch = /\bEmail:\s*([^\s]+@[^\s]+)/i.exec(combined); + return { + status: "ready", + auth: { + status: "authenticated", + ...(emailMatch?.[1] ? { email: emailMatch[1].trim() } : {}), + }, + }; +} + +function flattenSessionConfigSelectOptions( + configOption: EffectAcpSchema.SessionConfigOption | undefined, +): ReadonlyArray { + if (!configOption || configOption.type !== "select") return []; + return configOption.options.flatMap((entry) => + "value" in entry + ? [ + { + value: entry.value.trim(), + name: entry.name.trim(), + }, + ] + : entry.options.map((option) => ({ + value: option.value.trim(), + name: option.name.trim(), + })), + ); +} + +function findModelConfigOption( + configOptions: ReadonlyArray, +): EffectAcpSchema.SessionConfigOption | undefined { + return configOptions.find((option) => option.category === "model"); +} + +function buildKiroModelsFromModelState( + modelState: EffectAcpSchema.SessionModelState | null | undefined, +): ReadonlyArray { + if (!modelState || modelState.availableModels.length === 0) return []; + const seen = new Set(); + return modelState.availableModels.flatMap((model) => { + const slug = model.modelId.trim(); + const name = model.name.trim(); + if (!slug || seen.has(slug)) return []; + seen.add(slug); + return [ + { + slug, + name: name || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +function buildKiroModelsFromConfigOptions( + configOptions: ReadonlyArray | null | undefined, +): ReadonlyArray { + if (!configOptions || configOptions.length === 0) return []; + const modelChoices = flattenSessionConfigSelectOptions(findModelConfigOption(configOptions)); + const seen = new Set(); + return modelChoices.flatMap((modelChoice) => { + const slug = modelChoice.value.trim(); + const name = modelChoice.name.trim(); + if (!slug || seen.has(slug)) return []; + seen.add(slug); + return [ + { + slug, + name: name || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +function readKiroListModelsArray(raw: string): ReadonlyArray { + const trimmed = raw.trim(); + if (!trimmed) return []; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (Array.isArray(parsed)) return parsed; + if (!parsed || typeof parsed !== "object") return []; + const payload = parsed as KiroListModelsJsonPayload; + return Array.isArray(payload.models) ? payload.models : []; + } catch { + return []; + } +} + +function readKiroListModelString( + record: Record, + keys: ReadonlyArray, +): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return undefined; +} + +export function parseKiroListModelsOutput( + result: CommandResult, +): ReadonlyArray { + if (result.code !== 0) return []; + const seen = new Set(); + return readKiroListModelsArray(result.stdout).flatMap((entry) => { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return []; + const record = entry as Record; + const slug = readKiroListModelString(record, ["model_id", "id", "slug", "value"]); + if (!slug || seen.has(slug)) return []; + seen.add(slug); + const name = readKiroListModelString(record, [ + "model_name", + "name", + "display_name", + "displayName", + ]); + return [ + { + slug, + name: name || slug, + isCustom: false, + capabilities: EMPTY_CAPABILITIES, + } satisfies ServerProviderModel, + ]; + }); +} + +function hasKiroModelCapabilities(model: Pick): boolean { + return (model.capabilities?.optionDescriptors?.length ?? 0) > 0; +} + +function mergeKiroDiscoveredModels( + primary: ReadonlyArray, + secondary: ReadonlyArray, +): ReadonlyArray { + if (primary.length === 0) return secondary; + if (secondary.length === 0) return primary; + + const secondaryBySlug = new Map(secondary.map((model) => [model.slug, model] as const)); + const seen = new Set(); + const merged = primary.map((model) => { + seen.add(model.slug); + const secondaryModel = secondaryBySlug.get(model.slug); + return secondaryModel && hasKiroModelCapabilities(secondaryModel) + ? { ...model, capabilities: secondaryModel.capabilities } + : model; + }); + + for (const model of secondary) { + if (!seen.has(model.slug)) { + merged.push(model); + } + } + + return merged; +} + +function buildDiscoveredKiroModels( + response: + | EffectAcpSchema.LoadSessionResponse + | EffectAcpSchema.NewSessionResponse + | EffectAcpSchema.ResumeSessionResponse, +): ReadonlyArray { + const modelStateModels = buildKiroModelsFromModelState(response.models); + if (modelStateModels.length > 0) return modelStateModels; + return buildKiroModelsFromConfigOptions(response.configOptions); +} + +const discoverKiroModelsViaAcp = ( + kiroSettings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const runtime = yield* makeKiroAcpRuntime({ + kiroSettings, + environment, + childProcessSpawner, + cwd: process.cwd(), + clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, + }); + const started = yield* runtime.start(); + return buildDiscoveredKiroModels(started.sessionSetupResult); + }).pipe(Effect.scoped); + +function buildKiroProviderSnapshot(input: { + readonly checkedAt: string; + readonly kiroSettings: KiroSettings; + readonly version: string | null; + readonly auth: ServerProviderAuth; + readonly status: Exclude; + readonly discoveredModels?: ReadonlyArray; + readonly message?: string; + readonly discoveryWarning?: string; +}): ServerProviderDraft { + const messages = [input.message, input.discoveryWarning] + .map((message) => message?.trim()) + .filter((message): message is string => Boolean(message)); + return buildServerProvider({ + driver: PROVIDER, + presentation: KIRO_PRESENTATION, + enabled: input.kiroSettings.enabled, + checkedAt: input.checkedAt, + models: providerModelsFromSettings( + input.discoveredModels && input.discoveredModels.length > 0 + ? input.discoveredModels + : KIRO_FALLBACK_MODELS, + PROVIDER, + input.kiroSettings.customModels, + EMPTY_CAPABILITIES, + ), + probe: { + installed: true, + version: input.version, + status: + input.discoveryWarning && input.status === "ready" ? ("warning" as const) : input.status, + auth: input.auth, + ...(messages.length > 0 ? { message: messages.join(" ") } : {}), + }, + }); +} + +const runKiroCommand = ( + kiroSettings: KiroSettings, + args: ReadonlyArray, + environment: NodeJS.ProcessEnv = process.env, +) => + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const command = ChildProcess.make(kiroSettings.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); + +export const makePendingKiroProvider = ( + kiroSettings: KiroSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const fallbackModels = getKiroFallbackModels(kiroSettings); + if (!kiroSettings.enabled) { + return buildServerProvider({ + driver: PROVIDER, + presentation: KIRO_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kiro is disabled in T3 Code settings.", + }, + }); + } + return buildServerProvider({ + driver: PROVIDER, + presentation: KIRO_PRESENTATION, + enabled: true, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Checking Kiro CLI availability...", + }, + }); + }); + +export const checkKiroProviderStatus = Effect.fn("checkKiroProviderStatus")(function* ( + kiroSettings: KiroSettings, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const fallbackModels = getKiroFallbackModels(kiroSettings); + if (!kiroSettings.enabled) { + return buildServerProvider({ + driver: PROVIDER, + presentation: KIRO_PRESENTATION, + enabled: false, + checkedAt, + models: fallbackModels, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Kiro is disabled in T3 Code settings.", + }, + }); + } + + const versionProbe = yield* runKiroCommand(kiroSettings, ["--version"], environment).pipe( + Effect.timeoutOption(VERSION_TIMEOUT_MS), + Effect.result, + ); + if (Result.isFailure(versionProbe)) { + const error = versionProbe.failure; + return buildServerProvider({ + driver: PROVIDER, + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: !isCommandMissingCause(error), + version: null, + status: "error", + auth: { status: "unknown" }, + message: isCommandMissingCause(error) + ? "Kiro CLI (`kiro-cli`) is not installed or not on PATH." + : `Failed to execute Kiro CLI health check: ${error instanceof Error ? error.message : String(error)}.`, + }, + }); + } + if (Option.isNone(versionProbe.success)) { + return buildServerProvider({ + driver: PROVIDER, + presentation: KIRO_PRESENTATION, + enabled: kiroSettings.enabled, + checkedAt, + models: fallbackModels, + probe: { + installed: true, + version: null, + status: "error", + auth: { status: "unknown" }, + message: "Kiro CLI is installed but timed out while running `kiro-cli --version`.", + }, + }); + } + + const version = parseGenericCliVersion( + `${versionProbe.success.value.stdout}\n${versionProbe.success.value.stderr}`, + ); + const whoamiProbe = yield* runKiroCommand( + kiroSettings, + ["whoami", "--format", "json"], + environment, + ).pipe(Effect.timeoutOption(WHOAMI_TIMEOUT_MS), Effect.result); + const parsedAuth = + Result.isSuccess(whoamiProbe) && Option.isSome(whoamiProbe.success) + ? parseKiroWhoamiOutput(whoamiProbe.success.value) + : { + status: "warning" as const, + auth: { status: "unknown" as const }, + message: + Result.isFailure(whoamiProbe) && isCommandMissingCause(whoamiProbe.failure) + ? "Kiro CLI (`kiro-cli`) is not installed or not on PATH." + : "Could not verify Kiro CLI authentication status.", + }; + + let discoveredModels: ReadonlyArray = []; + let discoveryWarning: string | undefined; + if (parsedAuth.auth.status !== "unauthenticated") { + const listModelsExit = yield* Effect.exit( + runKiroCommand(kiroSettings, ["chat", "--list-models", "--format", "json"], environment).pipe( + Effect.timeoutOption(LIST_MODELS_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(listModelsExit)) { + yield* Effect.logWarning("Kiro CLI model list failed", { + cause: Cause.pretty(listModelsExit.cause), + }); + } else if (Option.isNone(listModelsExit.value)) { + yield* Effect.logWarning("Kiro CLI model list timed out", { + timeoutMs: LIST_MODELS_TIMEOUT_MS, + }); + } else { + discoveredModels = parseKiroListModelsOutput(listModelsExit.value.value); + } + + const discoveryExit = yield* Effect.exit( + discoverKiroModelsViaAcp(kiroSettings, environment).pipe( + Effect.timeoutOption(KIRO_ACP_MODEL_DISCOVERY_TIMEOUT_MS), + ), + ); + if (Exit.isFailure(discoveryExit)) { + yield* Effect.logWarning("Kiro ACP model discovery failed", { + cause: Cause.pretty(discoveryExit.cause), + }); + if (discoveredModels.length === 0) { + discoveryWarning = "Kiro model discovery failed. Check server logs for details."; + } + } else if (Option.isNone(discoveryExit.value)) { + if (discoveredModels.length === 0) { + discoveryWarning = `Kiro model discovery timed out after ${KIRO_ACP_MODEL_DISCOVERY_TIMEOUT_MS}ms.`; + } + } else if (discoveryExit.value.value.length === 0) { + if (discoveredModels.length === 0) { + discoveryWarning = "Kiro model discovery returned no built-in models."; + } + } else { + discoveredModels = mergeKiroDiscoveredModels(discoveredModels, discoveryExit.value.value); + } + } + + return buildKiroProviderSnapshot({ + checkedAt, + kiroSettings, + version, + auth: parsedAuth.auth, + status: parsedAuth.status, + ...(parsedAuth.message ? { message: parsedAuth.message } : {}), + discoveredModels, + ...(discoveryWarning ? { discoveryWarning } : {}), + }); +}); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index 86f99c97326..42a58dab98a 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -10,7 +10,7 @@ * * 2. **Many drivers, one registry** — the "all drivers slice" describe * block below configures one instance of every shipped driver - * (`codex`, `claudeAgent`, `cursor`, `opencode`) in a single + * (`codex`, `claudeAgent`, `cursor`, `kiro`, `opencode`) in a single * `ProviderInstanceConfigMap` and asserts the registry boots them all * without cross-contamination. This proves the driver SPI is uniform * across every provider — any driver plugs into the registry through @@ -18,9 +18,9 @@ * * Every instance in these tests is configured with `enabled: false` so the * provider-status checks short-circuit to pending/disabled snapshots - * without trying to spawn real `codex` / `claude` / `agent` / `opencode` - * binaries. That keeps the assertions focused on registry routing - * behaviour rather than the runtime details of each provider. + * without trying to spawn real provider binaries. That keeps the assertions + * focused on registry routing behaviour rather than the runtime details of + * each provider. */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -28,6 +28,7 @@ import { type ClaudeSettings, type CodexSettings, type CursorSettings, + type KiroSettings, type OpenCodeSettings, ProviderDriverKind, type ProviderInstanceConfigMap, @@ -41,6 +42,7 @@ import { ServerConfig } from "../../config.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; import { CursorDriver } from "../Drivers/CursorDriver.ts"; +import { KiroDriver } from "../Drivers/KiroDriver.ts"; import { OpenCodeDriver } from "../Drivers/OpenCodeDriver.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -79,6 +81,15 @@ const makeCursorConfig = (overrides: Partial): CursorSettings => ...overrides, }); +const makeKiroConfig = (overrides: Partial): KiroSettings => ({ + enabled: false, + binaryPath: "kiro-cli", + homePath: "", + agentName: "", + customModels: [], + ...overrides, +}); + const makeOpenCodeConfig = (overrides: Partial): OpenCodeSettings => ({ enabled: false, binaryPath: "opencode", @@ -218,7 +229,7 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }); describe("ProviderInstanceRegistryLive — all drivers slice", () => { - // All four drivers need `NodeServices` (ChildProcessSpawner + FileSystem + + // All built-in drivers need `NodeServices` (ChildProcessSpawner + FileSystem + // Path). `OpenCodeDriver.create` additionally yields `OpenCodeRuntime` // at construction time, so we wire `OpenCodeRuntimeLive` into the stack. // `OpenCodeRuntimeLive` bundles its own `NetService.layer` via @@ -244,11 +255,13 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codexId = ProviderInstanceId.make("codex_default"); const claudeId = ProviderInstanceId.make("claude_default"); const cursorId = ProviderInstanceId.make("cursor_default"); + const kiroId = ProviderInstanceId.make("kiro_default"); const openCodeId = ProviderInstanceId.make("opencode_default"); const codexDriverKind = ProviderDriverKind.make("codex"); const claudeDriverKind = ProviderDriverKind.make("claudeAgent"); const cursorDriverKind = ProviderDriverKind.make("cursor"); + const kiroDriverKind = ProviderDriverKind.make("kiro"); const openCodeDriverKind = ProviderDriverKind.make("opencode"); const configMap: ProviderInstanceConfigMap = { @@ -273,6 +286,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { enabled: false, config: makeCursorConfig({}), }, + [kiroId]: { + driver: kiroDriverKind, + displayName: "Kiro", + enabled: false, + config: makeKiroConfig({ homePath: "/home/julius/.kiro-work" }), + }, [openCodeId]: { driver: openCodeDriverKind, displayName: "OpenCode", @@ -282,7 +301,7 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { }; const { registry } = yield* makeProviderInstanceRegistry({ - drivers: [CodexDriver, ClaudeDriver, CursorDriver, OpenCodeDriver], + drivers: [CodexDriver, ClaudeDriver, CursorDriver, KiroDriver, OpenCodeDriver], configMap, }); @@ -292,9 +311,9 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { expect(unavailable).toEqual([]); const instances = yield* registry.listInstances; - expect(instances).toHaveLength(4); + expect(instances).toHaveLength(5); expect(instances.map((instance) => instance.instanceId).toSorted()).toEqual( - [codexId, claudeId, cursorId, openCodeId].toSorted(), + [codexId, claudeId, cursorId, kiroId, openCodeId].toSorted(), ); // Instance lookup by id resolves each instance to its own bundle — @@ -303,14 +322,17 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { const codex = yield* registry.getInstance(codexId); const claude = yield* registry.getInstance(claudeId); const cursor = yield* registry.getInstance(cursorId); + const kiro = yield* registry.getInstance(kiroId); const openCode = yield* registry.getInstance(openCodeId); expect(codex?.driverKind).toBe(codexDriverKind); expect(claude?.driverKind).toBe(claudeDriverKind); expect(cursor?.driverKind).toBe(cursorDriverKind); + expect(kiro?.driverKind).toBe(kiroDriverKind); expect(openCode?.driverKind).toBe(openCodeDriverKind); expect(codex?.displayName).toBe("Codex"); expect(claude?.displayName).toBe("Claude"); expect(cursor?.displayName).toBe("Cursor"); + expect(kiro?.displayName).toBe("Kiro"); expect(openCode?.displayName).toBe("OpenCode"); // Every instance owns its own set of closures — no sharing across @@ -318,16 +340,29 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { // distinct references even when two instances happen to share a // trait (e.g. Cursor + others all use a stub-or-real // `textGeneration`; they must still be different object values). - const adapters = [codex!.adapter, claude!.adapter, cursor!.adapter, openCode!.adapter]; + const adapters = [ + codex!.adapter, + claude!.adapter, + cursor!.adapter, + kiro!.adapter, + openCode!.adapter, + ]; expect(new Set(adapters).size).toBe(adapters.length); const textGenerations = [ codex!.textGeneration, claude!.textGeneration, cursor!.textGeneration, + kiro!.textGeneration, openCode!.textGeneration, ]; expect(new Set(textGenerations).size).toBe(textGenerations.length); - const snapshots = [codex!.snapshot, claude!.snapshot, cursor!.snapshot, openCode!.snapshot]; + const snapshots = [ + codex!.snapshot, + claude!.snapshot, + cursor!.snapshot, + kiro!.snapshot, + openCode!.snapshot, + ]; expect(new Set(snapshots).size).toBe(snapshots.length); // Snapshots identify themselves by `instanceId` + `driver` so @@ -356,6 +391,12 @@ describe("ProviderInstanceRegistryLive — all drivers slice", () => { `${cursorDriverKind}:instance:${cursorId}`, ); + const kiroSnapshot = yield* kiro!.snapshot.getSnapshot; + expect(kiroSnapshot.instanceId).toBe(kiroId); + expect(kiroSnapshot.driver).toBe(kiroDriverKind); + expect(kiroSnapshot.enabled).toBe(false); + expect(kiroSnapshot.continuation?.groupKey).toBe("kiro:home:/home/julius/.kiro-work"); + const openCodeSnapshot = yield* openCode!.snapshot.getSnapshot; expect(openCodeSnapshot.instanceId).toBe(openCodeId); expect(openCodeSnapshot.driver).toBe(openCodeDriverKind); diff --git a/apps/server/src/provider/Services/KiroAdapter.ts b/apps/server/src/provider/Services/KiroAdapter.ts new file mode 100644 index 00000000000..157ebcd6326 --- /dev/null +++ b/apps/server/src/provider/Services/KiroAdapter.ts @@ -0,0 +1,12 @@ +/** + * KiroAdapter — shape type for the Kiro CLI provider adapter. + * + * The driver model bundles one adapter per configured instance, so this + * module is a naming anchor for the per-instance Kiro adapter contract. + * + * @module KiroAdapter + */ +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface KiroAdapterShape extends ProviderAdapterShape {} diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 8652b2cfeaf..4134c93e625 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -46,7 +46,8 @@ export interface AcpSessionRuntimeOptions { readonly name: string; readonly version: string; }; - readonly authMethodId: string; + readonly authMethodId?: string; + readonly setModelStrategy?: "config-option" | "session-set-model"; readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect; readonly protocolLogging?: { readonly logIncoming?: boolean; @@ -378,15 +379,17 @@ const makeAcpSessionRuntime = ( acp.agent.initialize(initializePayload), ); - const authenticatePayload = { - methodId: options.authMethodId, - } satisfies EffectAcpSchema.AuthenticateRequest; + if (options.authMethodId !== undefined) { + const authenticatePayload = { + methodId: options.authMethodId, + } satisfies EffectAcpSchema.AuthenticateRequest; - yield* runLoggedRequest( - "authenticate", - authenticatePayload, - acp.agent.authenticate(authenticatePayload), - ); + yield* runLoggedRequest( + "authenticate", + authenticatePayload, + acp.agent.authenticate(authenticatePayload), + ); + } let sessionId: string; let sessionSetupResult: @@ -543,7 +546,20 @@ const makeAcpSessionRuntime = ( setConfigOption, setModel: (model) => getStartedState.pipe( - Effect.flatMap((started) => setConfigOption(started.modelConfigId ?? "model", model)), + Effect.flatMap((started) => { + if (options.setModelStrategy !== "session-set-model") { + return setConfigOption(started.modelConfigId ?? "model", model); + } + const requestPayload = { + sessionId: started.sessionId, + modelId: model, + } satisfies EffectAcpSchema.SetSessionModelRequest; + return runLoggedRequest( + "session/set_model", + requestPayload, + acp.agent.setSessionModel(requestPayload), + ); + }), Effect.asVoid, ), request: (method, payload) => diff --git a/apps/server/src/provider/acp/KiroAcpSupport.test.ts b/apps/server/src/provider/acp/KiroAcpSupport.test.ts new file mode 100644 index 00000000000..5224678c2ad --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpSupport.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; + +import { buildKiroAcpSpawnInput } from "./KiroAcpSupport.ts"; + +describe("buildKiroAcpSpawnInput", () => { + it("starts Kiro ACP with the default CLI binary", () => { + expect(buildKiroAcpSpawnInput(undefined, "/repo")).toEqual({ + command: "kiro-cli", + args: ["acp"], + cwd: "/repo", + }); + }); + + it("passes a configured agent name and environment through to the ACP process", () => { + const env = { KIRO_HOME: "/tmp/kiro" }; + + expect( + buildKiroAcpSpawnInput( + { + binaryPath: "/opt/kiro/bin/kiro-cli", + agentName: "builder", + }, + "/repo", + env, + ), + ).toEqual({ + command: "/opt/kiro/bin/kiro-cli", + args: ["acp", "--agent", "builder"], + cwd: "/repo", + env, + }); + }); +}); diff --git a/apps/server/src/provider/acp/KiroAcpSupport.ts b/apps/server/src/provider/acp/KiroAcpSupport.ts new file mode 100644 index 00000000000..00b86928564 --- /dev/null +++ b/apps/server/src/provider/acp/KiroAcpSupport.ts @@ -0,0 +1,56 @@ +import { type KiroSettings } 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 KiroAcpRuntimeSettings = Pick; + +export interface KiroAcpRuntimeInput extends Omit< + AcpSessionRuntimeOptions, + "authMethodId" | "setModelStrategy" | "spawn" +> { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly environment?: NodeJS.ProcessEnv; + readonly kiroSettings: KiroAcpRuntimeSettings | null | undefined; +} + +export function buildKiroAcpSpawnInput( + kiroSettings: KiroAcpRuntimeSettings | null | undefined, + cwd: string, + environment?: NodeJS.ProcessEnv, +): AcpSpawnInput { + const agentName = kiroSettings?.agentName.trim(); + return { + command: kiroSettings?.binaryPath || "kiro-cli", + args: ["acp", ...(agentName ? (["--agent", agentName] as const) : [])], + cwd, + ...(environment ? { env: environment } : {}), + }; +} + +export const makeKiroAcpRuntime = ( + input: KiroAcpRuntimeInput, +): Effect.Effect => + Effect.gen(function* () { + const acpContext = yield* Layer.build( + AcpSessionRuntime.layer({ + ...input, + spawn: buildKiroAcpSpawnInput(input.kiroSettings, input.cwd, input.environment), + setModelStrategy: "session-set-model", + }).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/StandardAcpAdapter.ts b/apps/server/src/provider/acp/StandardAcpAdapter.ts new file mode 100644 index 00000000000..426a179e1fa --- /dev/null +++ b/apps/server/src/provider/acp/StandardAcpAdapter.ts @@ -0,0 +1,914 @@ +import { + ApprovalRequestId, + EventId, + type ProviderApprovalDecision, + type ProviderDriverKind, + type ProviderInteractionMode, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + 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 EffectAcpErrors from "effect-acp/errors"; +import type * as EffectAcpSchema from "effect-acp/schema"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterProcessError, + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import { acpPermissionOutcome, mapAcpToAdapterError } from "./AcpAdapterSupport.ts"; +import { + makeAcpAssistantItemEvent, + makeAcpContentDeltaEvent, + makeAcpPlanUpdatedEvent, + makeAcpRequestOpenedEvent, + makeAcpRequestResolvedEvent, + makeAcpToolCallEvent, +} from "./AcpCoreRuntimeEvents.ts"; +import { makeAcpNativeLoggers } from "./AcpNativeLogging.ts"; +import { + type AcpSessionMode, + type AcpSessionModeState, + parsePermissionRequest, +} from "./AcpRuntimeModel.ts"; +import type { AcpSessionRuntimeShape } from "./AcpSessionRuntime.ts"; +import type { AcpSessionRuntimeOptions } from "./AcpSessionRuntime.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "../Layers/EventNdjsonLogger.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString); +const STANDARD_ACP_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"]; + +export interface StandardAcpAdapterOptions { + readonly provider: ProviderDriverKind; + readonly runtimeLabel: string; + readonly environment?: NodeJS.ProcessEnv; + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; + readonly instanceId?: ProviderInstanceId; + readonly makeRuntime: ( + input: { + readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; + readonly cwd: string; + readonly resumeSessionId?: string; + readonly clientInfo: { readonly name: string; readonly version: string }; + } & Pick, + ) => Effect.Effect; +} + +interface PendingApproval { + readonly decision: Deferred.Deferred; + readonly kind: string | "unknown"; +} + +interface PendingUserInput { + readonly answers: Deferred.Deferred; +} + +interface StandardAcpSessionContext { + 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 encodeJsonStringForDiagnostics(input: unknown): string | undefined { + const result = encodeUnknownJsonStringExit(input); + return Exit.isSuccess(result) ? result.value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseStandardAcpResume(raw: unknown): { sessionId: string } | undefined { + if (!isRecord(raw)) return undefined; + if (raw.schemaVersion !== STANDARD_ACP_RESUME_VERSION) return undefined; + if (raw.protocol !== "acp") 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 model: string | undefined; + readonly mapError: (context: { + readonly cause: EffectAcpErrors.AcpError; + readonly method: "session/set_model" | "session/set_mode"; + }) => ProviderAdapterError; +}): Effect.Effect { + return Effect.gen(function* () { + if (input.model !== undefined) { + yield* input.runtime.setModel(input.model).pipe( + Effect.mapError((cause) => + input.mapError({ + cause, + method: "session/set_model", + }), + ), + ); + } + + 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; +} + +function settlePendingApprovalsAsCancelled( + pendingApprovals: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingApprovals.values()), + (pending) => Deferred.succeed(pending.decision, "cancel").pipe(Effect.ignore), + { discard: true }, + ); +} + +function settlePendingUserInputsAsEmptyAnswers( + pendingUserInputs: ReadonlyMap, +): Effect.Effect { + return Effect.forEach( + Array.from(pendingUserInputs.values()), + (pending) => Deferred.succeed(pending.answers, {}).pipe(Effect.ignore), + { discard: true }, + ); +} + +export function makeStandardAcpAdapter( + options: StandardAcpAdapterOptions, +): Effect.Effect< + ProviderAdapterShape, + never, + | ChildProcessSpawner.ChildProcessSpawner + | FileSystem.FileSystem + | Path.Path + | ServerConfig + | Scope.Scope +> { + return Effect.gen(function* () { + const provider = options.provider; + const boundInstanceId = options.instanceId ?? ProviderInstanceId.make(provider); + 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) => + Effect.gen(function* () { + if (!nativeEventLogger) return; + const observedAt = yield* nowIso; + yield* nativeEventLogger.write( + { + observedAt, + event: { + id: yield* Random.nextUUIDv4, + kind: "notification", + provider, + createdAt: observedAt, + method, + threadId, + payload, + }, + }, + threadId, + ); + }); + + const emitPlanUpdate = ( + ctx: StandardAcpSessionContext, + payload: { + readonly explanation?: string | null; + readonly plan: ReadonlyArray<{ + readonly step: string; + readonly status: "pending" | "inProgress" | "completed"; + }>; + }, + rawPayload: unknown, + 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, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + payload, + source: "acp.jsonrpc", + method, + rawPayload, + }), + ); + }); + + const requireSession = ( + threadId: ThreadId, + ): Effect.Effect => { + const ctx = sessions.get(threadId); + if (!ctx || ctx.stopped) { + return Effect.fail(new ProviderAdapterSessionNotFoundError({ provider, threadId })); + } + return Effect.succeed(ctx); + }; + + const stopSessionInternal = (ctx: StandardAcpSessionContext) => + 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, + threadId: ctx.threadId, + payload: { exitKind: "graceful" }, + }); + }); + + const startSession: ProviderAdapterShape["startSession"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + if (input.provider !== undefined && input.provider !== provider) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: `Expected provider '${provider}' but received '${input.provider}'.`, + }); + } + if (!input.cwd?.trim()) { + return yield* new ProviderAdapterValidationError({ + provider, + operation: "startSession", + issue: "cwd is required and must be non-empty.", + }); + } + + const cwd = path.resolve(input.cwd.trim()); + const selectedModel = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection.model + : undefined; + 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!: StandardAcpSessionContext; + + const resumeSessionId = parseStandardAcpResume(input.resumeCursor)?.sessionId; + const acpNativeLoggers = makeAcpNativeLoggers({ + nativeEventLogger, + provider, + threadId: input.threadId, + }); + const acp = yield* options + .makeRuntime({ + 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, + 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); + 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, + 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, + 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, + model: selectedModel, + mapError: ({ cause, method }) => + mapAcpToAdapterError(provider, input.threadId, method, cause), + }); + + const now = yield* nowIso; + const session: ProviderSession = { + provider, + providerInstanceId: boundInstanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model: selectedModel, + threadId: input.threadId, + resumeCursor: { + schemaVersion: STANDARD_ACP_RESUME_VERSION, + protocol: "acp", + 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, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.started", + }), + ); + return; + case "AssistantItemCompleted": + yield* offerRuntimeEvent( + makeAcpAssistantItemEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + itemId: event.itemId, + lifecycle: "item.completed", + }), + ); + return; + case "PlanUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* emitPlanUpdate(ctx, event.payload, event.rawPayload, "session/update"); + return; + case "ToolCallUpdated": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpToolCallEvent({ + stamp: yield* makeEventStamp(), + provider, + threadId: ctx.threadId, + turnId: ctx.activeTurnId, + toolCall: event.toolCall, + rawPayload: event.rawPayload, + }), + ); + return; + case "ContentDelta": + yield* logNative(ctx.threadId, "session/update", event.rawPayload); + yield* offerRuntimeEvent( + makeAcpContentDeltaEvent({ + stamp: yield* makeEventStamp(), + 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, + threadId: input.threadId, + payload: { resume: started.initializeResult }, + }); + yield* offerRuntimeEvent({ + type: "session.state.changed", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + payload: { state: "ready", reason: `${options.runtimeLabel} ACP session ready` }, + }); + yield* offerRuntimeEvent({ + type: "thread.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + payload: { providerThreadId: started.sessionId }, + }); + + return session; + }).pipe(Effect.scoped), + ); + + const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const ctx = yield* requireSession(input.threadId); + const turnId = TurnId.make(crypto.randomUUID()); + const turnModel = + input.modelSelection?.instanceId === boundInstanceId + ? input.modelSelection.model + : undefined; + const model = turnModel ?? ctx.session.model; + yield* applyRequestedSessionConfiguration({ + runtime: ctx.acp, + runtimeMode: ctx.session.runtimeMode, + interactionMode: input.interactionMode, + model, + 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, + threadId: input.threadId, + turnId, + payload: model ? { model } : {}, + }); + + 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, + method: "session/prompt", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + 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, + 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, + ...(model ? { model } : {}), + }; + + yield* offerRuntimeEvent({ + type: "turn.completed", + ...(yield* makeEventStamp()), + 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: ProviderAdapterShape["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: ProviderAdapterShape["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, + method: "session/request_permission", + detail: `Unknown pending approval request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.decision, decision); + }); + + const respondToUserInput: ProviderAdapterShape["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, + method: "elicitation", + detail: `Unknown pending user-input request: ${requestId}`, + }); + } + yield* Deferred.succeed(pending.answers, answers); + }); + + const readThread: ProviderAdapterShape["readThread"] = (threadId) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + return { threadId, turns: ctx.turns }; + }); + + const rollbackThread: ProviderAdapterShape["rollbackThread"] = ( + threadId, + numTurns, + ) => + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + if (!Number.isInteger(numTurns) || numTurns < 1) { + return yield* new ProviderAdapterValidationError({ + 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: ProviderAdapterShape["stopSession"] = (threadId) => + withThreadLock( + threadId, + Effect.gen(function* () { + const ctx = yield* requireSession(threadId); + yield* stopSessionInternal(ctx); + }), + ); + + const listSessions: ProviderAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values(), (c) => ({ ...c.session }))); + + const hasSession: ProviderAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => { + const c = sessions.get(threadId); + return c !== undefined && !c.stopped; + }); + + const stopAll: ProviderAdapterShape["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), + ), + ); + + return { + provider, + capabilities: { sessionModelSwitch: "in-session" }, + startSession, + sendTurn, + interruptTurn, + readThread, + rollbackThread, + respondToRequest, + respondToUserInput, + stopSession, + listSessions, + hasSession, + stopAll, + streamEvents: Stream.fromPubSub(runtimeEventPubSub), + } satisfies ProviderAdapterShape; + }); +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 5af56dc6b0e..4692b007a4b 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -23,6 +23,7 @@ import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; +import { KiroDriver, type KiroDriverEnv } from "./Drivers/KiroDriver.ts"; import { OpenCodeDriver, type OpenCodeDriverEnv } from "./Drivers/OpenCodeDriver.ts"; import type { AnyProviderDriver } from "./ProviderDriver.ts"; @@ -35,6 +36,7 @@ export type BuiltInDriversEnv = | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv + | KiroDriverEnv | OpenCodeDriverEnv; /** @@ -46,5 +48,6 @@ export const BUILT_IN_DRIVERS: ReadonlyArray({ + operation, + cwd, + prompt, + outputSchemaJson, + modelSelection, + }: { + operation: + | "generateCommitMessage" + | "generatePrContent" + | "generateBranchName" + | "generateThreadTitle"; + cwd: string; + prompt: string; + outputSchemaJson: S; + modelSelection: ModelSelection; + }): Effect.Effect => + Effect.gen(function* () { + const outputRef = yield* Ref.make(""); + const runtime = yield* makeKiroAcpRuntime({ + kiroSettings, + 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* runtime + .setModel(modelSelection.model) + .pipe( + Effect.mapError((cause) => + mapKiroAcpError( + operation, + "Failed to set Kiro ACP model for text generation.", + cause, + ), + ), + ); + return yield* runtime.prompt({ + prompt: [{ type: "text", text: prompt }], + }); + }).pipe( + Effect.timeoutOption(KIRO_TIMEOUT_MS), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new TextGenerationError({ + operation, + detail: "Kiro CLI request timed out.", + }), + ), + onSome: (value) => Effect.succeed(value), + }), + ), + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapKiroAcpError(operation, "Kiro ACP request failed.", cause), + ), + ); + + const rawResult = (yield* Ref.get(outputRef)).trim(); + if (!rawResult) { + return yield* new TextGenerationError({ + operation, + detail: + promptResult.stopReason === "cancelled" + ? "Kiro ACP request was cancelled." + : "Kiro CLI 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: "Kiro CLI returned invalid structured output.", + cause, + }), + ), + ), + ); + }).pipe( + Effect.mapError((cause) => + isTextGenerationError(cause) + ? cause + : mapKiroAcpError(operation, "Kiro ACP text generation failed.", cause), + ), + Effect.scoped, + ); + + const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( + "KiroTextGeneration.generateCommitMessage", + )(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + + const generated = yield* runKiroJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + 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( + "KiroTextGeneration.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* runKiroJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; + }); + + const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( + "KiroTextGeneration.generateBranchName", + )(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runKiroJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + branch: sanitizeBranchFragment(generated.branch), + }; + }); + + const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( + "KiroTextGeneration.generateThreadTitle", + )(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + + const generated = yield* runKiroJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + title: sanitizeThreadTitle(generated.title), + } satisfies ThreadTitleGenerationResult; + }); + + return { + generateCommitMessage, + generatePrContent, + generateBranchName, + generateThreadTitle, + } satisfies TextGenerationShape; +}); diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 611eaf572d0..114df5be42d 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -123,6 +123,13 @@ function createBaseServerConfig(): ServerConfig { launchArgs: "", }, cursor: { enabled: true, binaryPath: "", apiEndpoint: "", customModels: [] }, + kiro: { + enabled: false, + binaryPath: "kiro-cli", + homePath: "", + agentName: "", + customModels: [], + }, opencode: { enabled: true, binaryPath: "", diff --git a/apps/web/src/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 88b56295f36..34579f6ff68 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Icon, KiroIcon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -7,6 +7,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("claudeAgent")]: ClaudeAI, [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, + [ProviderDriverKind.make("kiro")]: KiroIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 0d3bc5ae98a..b9edc93384d 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -21,6 +21,24 @@ describe("ProviderSettingsForm helpers", () => { ]); }); + it("derives Kiro config fields without provider-specific form logic", () => { + const kiro = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("kiro")]; + + expect(kiro).toBeDefined(); + expect(deriveProviderSettingsFields(kiro!).map((field) => field.key)).toEqual([ + "binaryPath", + "homePath", + "agentName", + ]); + expect(deriveProviderSettingsFields(kiro!)).toContainEqual( + expect.objectContaining({ + key: "homePath", + label: "KIRO_HOME path", + placeholder: "~/.kiro", + }), + ); + }); + it("sources labels and descriptions from schema annotations", () => { const opencode = DRIVER_OPTION_BY_VALUE[ProviderDriverKind.make("opencode")]; expect(opencode).toBeDefined(); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index 8d3d7482f62..7cc4ec7095a 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -2,11 +2,12 @@ import { ClaudeSettings, CodexSettings, CursorSettings, + KiroSettings, OpenCodeSettings, ProviderDriverKind, } from "@t3tools/contracts"; import type * as Schema from "effect/Schema"; -import { ClaudeAI, CursorIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, KiroIcon, type Icon, OpenAI, OpenCodeIcon } from "../Icons"; type ProviderSettingsSchema = { readonly fields: Readonly>; @@ -53,6 +54,12 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = badgeLabel: "Early Access", settingsSchema: CursorSettings, }, + { + value: ProviderDriverKind.make("kiro"), + label: "Kiro", + icon: KiroIcon, + settingsSchema: KiroSettings, + }, { value: ProviderDriverKind.make("opencode"), label: "OpenCode", diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index a7767672fa1..17b6757afc4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -45,6 +45,12 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + { + value: ProviderDriverKind.make("kiro"), + label: "Kiro", + available: true, + pickerSidebarBadge: "new", + }, ]; export interface WorkLogEntry { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 8e7daaa0c79..11058ddabac 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -130,6 +130,7 @@ export type ModelCapabilities = typeof ModelCapabilities.Type; const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); +const KIRO_DRIVER_KIND = ProviderDriverKind.make("kiro"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); export const DEFAULT_MODEL = "gpt-5.4"; @@ -139,6 +140,7 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial> [CODEX_DRIVER_KIND]: "Codex", [CLAUDE_DRIVER_KIND]: "Claude", [CURSOR_DRIVER_KIND]: "Cursor", + [KIRO_DRIVER_KIND]: "Kiro", [OPENCODE_DRIVER_KIND]: "OpenCode", }; diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 39695fe3b01..c38b8ff6fea 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -19,6 +19,13 @@ describe("ServerSettings.providerInstances (slice-2 invariant)", () => { // Legacy `providers` struct is still hydrated with its per-driver defaults // so existing call sites keep working through the migration. expect(decoded.providers.codex.enabled).toBe(true); + expect(decoded.providers.kiro).toMatchObject({ + enabled: false, + binaryPath: "kiro-cli", + homePath: "", + agentName: "", + customModels: [], + }); }); it("decodes a multi-instance map mixing first-party and fork drivers", () => { @@ -107,6 +114,11 @@ describe("ServerSettingsPatch string normalization", () => { binaryPath: " /opt/homebrew/bin/codex ", homePath: " ~/.codex ", }, + kiro: { + binaryPath: " /opt/homebrew/bin/kiro-cli ", + homePath: " ~/.kiro-work ", + agentName: " builder ", + }, }, providerInstances: { codex_personal: { @@ -122,6 +134,9 @@ describe("ServerSettingsPatch string normalization", () => { expect(patch.observability?.otlpTracesUrl).toBe("http://localhost:4318/v1/traces"); expect(patch.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); expect(patch.providers?.codex?.homePath).toBe("~/.codex"); + expect(patch.providers?.kiro?.binaryPath).toBe("/opt/homebrew/bin/kiro-cli"); + expect(patch.providers?.kiro?.homePath).toBe("~/.kiro-work"); + expect(patch.providers?.kiro?.agentName).toBe("builder"); expect(patch.providerInstances?.[ProviderInstanceId.make("codex_personal")]?.driver).toBe( "codex", ); @@ -144,10 +159,19 @@ describe("ServerSettingsPatch string normalization", () => { ...defaultSettings.providers.codex, binaryPath: " /opt/homebrew/bin/codex ", }, + kiro: { + ...defaultSettings.providers.kiro, + binaryPath: " /opt/homebrew/bin/kiro-cli ", + homePath: " ~/.kiro-work ", + agentName: " builder ", + }, }, }); expect(encoded.addProjectBaseDirectory).toBe("~/Development"); expect(encoded.providers?.codex?.binaryPath).toBe("/opt/homebrew/bin/codex"); + expect(encoded.providers?.kiro?.binaryPath).toBe("/opt/homebrew/bin/kiro-cli"); + expect(encoded.providers?.kiro?.homePath).toBe("~/.kiro-work"); + expect(encoded.providers?.kiro?.agentName).toBe("builder"); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2d115eed98e..6942ba83737 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -281,6 +281,52 @@ export const CursorSettings = makeProviderSettingsSchema( ); export type CursorSettings = typeof CursorSettings.Type; +export const KiroSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + binaryPath: makeBinaryPathSetting("kiro-cli").pipe( + Schema.annotateKey({ + title: "Binary path", + description: "Path to the Kiro CLI binary.", + providerSettingsForm: { placeholder: "kiro-cli", clearWhenEmpty: "omit" }, + }), + ), + homePath: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "KIRO_HOME path", + description: "Custom Kiro home and config directory for this instance.", + providerSettingsForm: { + placeholder: "~/.kiro", + clearWhenEmpty: "omit", + }, + }), + ), + agentName: TrimmedString.pipe( + Schema.withDecodingDefault(Effect.succeed("")), + Schema.annotateKey({ + title: "Agent name", + description: "Optional custom Kiro agent configuration to start with.", + providerSettingsForm: { + placeholder: "my-agent", + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["binaryPath", "homePath", "agentName"], + }, +); +export type KiroSettings = typeof KiroSettings.Type; + export const OpenCodeSettings = makeProviderSettingsSchema( { enabled: Schema.Boolean.pipe( @@ -369,6 +415,7 @@ export const ServerSettings = Schema.Struct({ codex: CodexSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), claudeAgent: ClaudeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), cursor: CursorSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), + kiro: KiroSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), opencode: OpenCodeSettings.pipe(Schema.withDecodingDefault(Effect.succeed({}))), }).pipe(Schema.withDecodingDefault(Effect.succeed({}))), // New driver-agnostic instance map. Keyed by `ProviderInstanceId`; values @@ -437,6 +484,14 @@ const CursorSettingsPatch = Schema.Struct({ customModels: Schema.optionalKey(Schema.Array(Schema.String)), }); +const KiroSettingsPatch = Schema.Struct({ + enabled: Schema.optionalKey(Schema.Boolean), + binaryPath: Schema.optionalKey(TrimmedString), + homePath: Schema.optionalKey(TrimmedString), + agentName: Schema.optionalKey(TrimmedString), + customModels: Schema.optionalKey(Schema.Array(Schema.String)), +}); + const OpenCodeSettingsPatch = Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean), binaryPath: Schema.optionalKey(TrimmedString), @@ -463,6 +518,7 @@ export const ServerSettingsPatch = Schema.Struct({ codex: Schema.optionalKey(CodexSettingsPatch), claudeAgent: Schema.optionalKey(ClaudeSettingsPatch), cursor: Schema.optionalKey(CursorSettingsPatch), + kiro: Schema.optionalKey(KiroSettingsPatch), opencode: Schema.optionalKey(OpenCodeSettingsPatch), }), ), diff --git a/packages/shared/src/shell.test.ts b/packages/shared/src/shell.test.ts index 214c03a7e53..cf299982a19 100644 --- a/packages/shared/src/shell.test.ts +++ b/packages/shared/src/shell.test.ts @@ -62,7 +62,7 @@ describe("readPathFromLoginShell", () => { expect(shell).toBe("/opt/homebrew/bin/fish"); expect(args).toHaveLength(2); expect(args?.[0]).toBe("-ilc"); - expect(args?.[1]).toContain("printenv PATH || true"); + expect(args?.[1]).toContain("printenv PATH"); expect(args?.[1]).toContain("__T3CODE_ENV_PATH_START__"); expect(args?.[1]).toContain("__T3CODE_ENV_PATH_END__"); expect(options).toEqual({ encoding: "utf8", timeout: 5000 }); diff --git a/packages/shared/src/shell.ts b/packages/shared/src/shell.ts index 0572aaf989a..bd1b31aa846 100644 --- a/packages/shared/src/shell.ts +++ b/packages/shared/src/shell.ts @@ -135,7 +135,7 @@ function buildEnvironmentCaptureCommand(names: ReadonlyArray): string { return [ `printf '%s\\n' '${envCaptureStart(name)}'`, - `printenv ${name} || true`, + `printenv ${name}`, `printf '%s\\n' '${envCaptureEnd(name)}'`, ].join("; "); }) From b83cc43f2dd883fc344d4965e0976b2ac58cdb3c Mon Sep 17 00:00:00 2001 From: Declan Cowen Date: Wed, 20 May 2026 17:30:58 +0100 Subject: [PATCH 02/19] Add Kiro active prompt steering and appearance settings --- .reviews/kiro-provider-appearance-review.md | 41 ++ .../settings/DesktopClientSettings.test.ts | 7 +- .../server/src/provider/Layers/KiroAdapter.ts | 4 + .../provider/acp/AcpAdapterSupport.test.ts | 17 + .../src/provider/acp/AcpAdapterSupport.ts | 32 +- .../src/provider/acp/StandardAcpAdapter.ts | 135 ++++- apps/web/index.html | 19 +- apps/web/src/appearance.ts | 97 ++++ apps/web/src/components/AppSidebarLayout.tsx | 2 +- apps/web/src/components/ChatMarkdown.tsx | 2 +- .../web/src/components/ChatView.logic.test.ts | 65 +++ apps/web/src/components/ChatView.logic.ts | 24 +- apps/web/src/components/ChatView.tsx | 20 +- .../src/components/ComposerPromptEditor.tsx | 4 +- .../src/components/NoActiveThreadState.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 23 +- apps/web/src/components/chat/ChatHeader.tsx | 12 +- .../chat/ComposerPrimaryActions.tsx | 135 +++-- .../src/components/chat/MessagesTimeline.tsx | 6 +- .../settings/AppearanceSettings.tsx | 471 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 91 ++-- .../settings/SettingsSidebarNav.tsx | 7 +- apps/web/src/components/ui/sidebar.tsx | 2 +- apps/web/src/index.css | 146 +++++- apps/web/src/localApi.test.ts | 3 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 45 ++ apps/web/src/routes/settings.appearance.tsx | 7 + apps/web/src/routes/settings.tsx | 9 +- packages/contracts/src/settings.ts | 64 +++ scripts/build-desktop-artifact.ts | 26 +- 32 files changed, 1348 insertions(+), 197 deletions(-) create mode 100644 .reviews/kiro-provider-appearance-review.md create mode 100644 apps/web/src/appearance.ts create mode 100644 apps/web/src/components/settings/AppearanceSettings.tsx create mode 100644 apps/web/src/routes/settings.appearance.tsx diff --git a/.reviews/kiro-provider-appearance-review.md b/.reviews/kiro-provider-appearance-review.md new file mode 100644 index 00000000000..28f29557277 --- /dev/null +++ b/.reviews/kiro-provider-appearance-review.md @@ -0,0 +1,41 @@ +# Kiro Provider + Appearance Diff Review + +Date: 2026-05-20 +Scope: Full local diff against `main`, including Kiro ACP steering/stop behavior, appearance settings, chat typography, and desktop artifact tweaks. +Skills: `diff-review`, `architecture-standards` + +## Result + +No open blocking findings remain. + +## Findings Resolved + +- F-001: Active ACP prompt registration happened after `turn.started`, leaving a short window where a Kiro follow-up could be routed as a second `session/prompt` instead of `_message/send`. + - Fixed by validating prompt content first, then registering `ctx.activePrompt`, `ctx.activeTurnId`, and session state before emitting `turn.started`. + +- F-002: Kiro active-prompt follow-ups are intentionally attached to the existing turn, so the UI local-dispatch guard did not clear when the server acknowledged a follow-up on the same running turn. + - Fixed by treating an updated running session on the same active turn as acknowledgement for active-turn steering. + +- F-003: The mobile collapsed composer send button lost the environment-unavailable disable guard while enabling running follow-ups. + - Fixed by restoring `environmentUnavailable !== null` to the collapsed send-button disabled state. + +## Architecture Notes + +- Kiro-specific behavior remains isolated in `apps/server/src/provider/Layers/KiroAdapter.ts`. +- The shared ACP layer only knows about an optional provider-supplied active-prompt hook and a provider-supplied method name. +- Provider settings continue to use the existing schema annotation and instance-registry architecture rather than adding page-local provider form logic. +- Appearance settings are client settings in `packages/contracts`; runtime application stays in the web app bootstrap and CSS variables. + +## Validation + +- `bun fmt` +- `bun lint` (passes with 9 existing warnings) +- `bun run typecheck` +- `bun run test src/provider/acp/AcpAdapterSupport.test.ts` +- `bun run test src/provider/acp/KiroAcpSupport.test.ts src/provider/Layers/KiroProvider.test.ts src/provider/Drivers/KiroHome.test.ts src/provider/Layers/ProviderInstanceRegistryLive.test.ts` +- `bun run test src/components/ChatView.logic.test.ts` +- `bun run test -- --configLoader runner src/settings.test.ts` +- `bun run test -- --configLoader runner src/settings/DesktopClientSettings.test.ts` +- `git diff --check` + +Browser smoke was attempted, but the browser automation tool was not available in this session and sandboxed `curl` could not connect to localhost despite both local ports being open. diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..ca76214f1bf 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -1,6 +1,10 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; +import { + ClientSettingsSchema, + DEFAULT_CLIENT_SETTINGS, + type ClientSettings, +} from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -12,6 +16,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopClientSettings from "./DesktopClientSettings.ts"; const clientSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, autoOpenPlanSidebar: false, confirmThreadArchive: true, confirmThreadDelete: false, diff --git a/apps/server/src/provider/Layers/KiroAdapter.ts b/apps/server/src/provider/Layers/KiroAdapter.ts index 36b2ede60a5..fd74eda295c 100644 --- a/apps/server/src/provider/Layers/KiroAdapter.ts +++ b/apps/server/src/provider/Layers/KiroAdapter.ts @@ -5,6 +5,7 @@ import { makeStandardAcpAdapter } from "../acp/StandardAcpAdapter.ts"; import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; const PROVIDER = ProviderDriverKind.make("kiro"); +const KIRO_ACTIVE_PROMPT_MESSAGE_METHOD = "_message/send"; export interface KiroAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; @@ -21,6 +22,9 @@ export function makeKiroAdapter(kiroSettings: KiroSettings, options?: KiroAdapte ...(options?.nativeEventLogPath ? { nativeEventLogPath: options.nativeEventLogPath } : {}), ...(options?.nativeEventLogger ? { nativeEventLogger: options.nativeEventLogger } : {}), ...(options?.instanceId ? { instanceId: options.instanceId } : {}), + activePromptMessageMethod: KIRO_ACTIVE_PROMPT_MESSAGE_METHOD, + sendMessageWhilePromptActive: ({ runtime, sessionId, content }) => + runtime.request(KIRO_ACTIVE_PROMPT_MESSAGE_METHOD, { sessionId, content }), makeRuntime: (input) => makeKiroAcpRuntime({ kiroSettings, diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts index a7fcdc4c827..24a5728edfd 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.test.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.test.ts @@ -25,4 +25,21 @@ describe("AcpAdapterSupport", () => { expect(error._tag).toBe("ProviderAdapterRequestError"); expect(error.message).toContain("Invalid params"); }); + + it("surfaces ACP request error data when the provider reports a generic internal error", () => { + const error = mapAcpToAdapterError( + ProviderDriverKind.make("kiro"), + "thread-1" as never, + "session/prompt", + new EffectAcpErrors.AcpRequestError({ + code: -32603, + errorMessage: "Internal error", + data: "Prompt already in progress", + }), + ); + + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.message).toContain("Prompt already in progress"); + expect(error.message).not.toContain("Internal error"); + }); }); diff --git a/apps/server/src/provider/acp/AcpAdapterSupport.ts b/apps/server/src/provider/acp/AcpAdapterSupport.ts index cde110e6dd9..755c23588c2 100644 --- a/apps/server/src/provider/acp/AcpAdapterSupport.ts +++ b/apps/server/src/provider/acp/AcpAdapterSupport.ts @@ -14,6 +14,36 @@ import { const isAcpProcessExitedError = Schema.is(EffectAcpErrors.AcpProcessExitedError); const isAcpRequestError = Schema.is(EffectAcpErrors.AcpRequestError); +function readAcpErrorDataMessage(data: unknown): string | undefined { + if (typeof data === "string") { + const trimmed = data.trim(); + return trimmed.length > 0 ? trimmed : undefined; + } + if (data && typeof data === "object") { + const maybeRecord = data as Record; + for (const key of ["message", "detail", "error"]) { + const value = maybeRecord[key]; + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + } + try { + return JSON.stringify(data); + } catch { + return undefined; + } + } + return undefined; +} + +function formatAcpRequestErrorDetail(error: EffectAcpErrors.AcpRequestError): string { + const dataMessage = readAcpErrorDataMessage(error.data); + if (!dataMessage) { + return error.message; + } + return error.message === "Internal error" ? dataMessage : `${error.message}: ${dataMessage}`; +} + export function mapAcpToAdapterError( provider: ProviderDriverKind, threadId: ThreadId, @@ -31,7 +61,7 @@ export function mapAcpToAdapterError( return new ProviderAdapterRequestError({ provider, method, - detail: error.message, + detail: formatAcpRequestErrorDetail(error), cause: error, }); } diff --git a/apps/server/src/provider/acp/StandardAcpAdapter.ts b/apps/server/src/provider/acp/StandardAcpAdapter.ts index 426a179e1fa..b50690f1952 100644 --- a/apps/server/src/provider/acp/StandardAcpAdapter.ts +++ b/apps/server/src/provider/acp/StandardAcpAdapter.ts @@ -74,6 +74,12 @@ export interface StandardAcpAdapterOptions { readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; readonly instanceId?: ProviderInstanceId; + readonly activePromptMessageMethod?: string; + readonly sendMessageWhilePromptActive?: (input: { + readonly runtime: AcpSessionRuntimeShape; + readonly sessionId: string; + readonly content: string; + }) => Effect.Effect; readonly makeRuntime: ( input: { readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -98,12 +104,19 @@ interface StandardAcpSessionContext { session: ProviderSession; readonly scope: Scope.Closeable; readonly acp: AcpSessionRuntimeShape; + readonly acpSessionId: string; notificationFiber: Fiber.Fiber | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; readonly turns: Array<{ id: TurnId; items: Array }>; lastPlanFingerprint: string | undefined; activeTurnId: TurnId | undefined; + activePrompt: + | { + readonly turnId: TurnId; + readonly cancel: Deferred.Deferred; + } + | undefined; stopped: boolean; } @@ -226,6 +239,36 @@ function applyRequestedSessionConfiguration(input: { }); } +function readTextOnlyActivePromptMessage( + input: Parameters["sendTurn"]>[0], + context: { + readonly provider: ProviderDriverKind; + readonly runtimeLabel: string; + readonly method: string; + }, +): Effect.Effect { + const content = input.input?.trim() ?? ""; + if (!content) { + return Effect.fail( + new ProviderAdapterValidationError({ + provider: context.provider, + operation: "sendTurn", + issue: "Active prompt steering requires non-empty text.", + }), + ); + } + if (input.attachments && input.attachments.length > 0) { + return Effect.fail( + new ProviderAdapterRequestError({ + provider: context.provider, + method: context.method, + detail: `${context.runtimeLabel} can only steer an active prompt with text. Stop the current turn before sending attachments.`, + }), + ); + } + return Effect.succeed(content); +} + function selectAutoApprovedPermissionOption( request: EffectAcpSchema.RequestPermissionRequest, ): string | undefined { @@ -572,12 +615,14 @@ export function makeStandardAcpAdapter( session, scope: sessionScope, acp, + acpSessionId: started.sessionId, notificationFiber: undefined, pendingApprovals, pendingUserInputs, turns: [], lastPlanFingerprint: undefined, activeTurnId: undefined, + activePrompt: undefined, stopped: false, }; @@ -680,6 +725,48 @@ export function makeStandardAcpAdapter( const sendTurn: ProviderAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const ctx = yield* requireSession(input.threadId); + const activePrompt = ctx.activePrompt; + if (activePrompt && options.sendMessageWhilePromptActive) { + const method = options.activePromptMessageMethod ?? "session/message"; + const content = yield* readTextOnlyActivePromptMessage(input, { + provider, + runtimeLabel: options.runtimeLabel, + method, + }); + yield* options + .sendMessageWhilePromptActive({ + runtime: ctx.acp, + sessionId: ctx.acpSessionId, + content, + }) + .pipe( + Effect.mapError((error) => + mapAcpToAdapterError(provider, input.threadId, method, error), + ), + ); + + ctx.session = { + ...ctx.session, + activeTurnId: activePrompt.turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + turnId: activePrompt.turnId, + payload: {}, + }); + + return { + threadId: input.threadId, + turnId: activePrompt.turnId, + resumeCursor: ctx.session.resumeCursor, + }; + } + const turnId = TurnId.make(crypto.randomUUID()); const turnModel = input.modelSelection?.instanceId === boundInstanceId @@ -694,22 +781,6 @@ export function makeStandardAcpAdapter( 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, - threadId: input.threadId, - turnId, - payload: model ? { model } : {}, - }); const promptParts: Array = []; if (input.input?.trim()) { @@ -755,14 +826,41 @@ export function makeStandardAcpAdapter( }); } + const activePromptCancel = yield* Deferred.make(); + ctx.activePrompt = { turnId, cancel: activePromptCancel }; + ctx.activeTurnId = turnId; + ctx.lastPlanFingerprint = undefined; + ctx.session = { + ...ctx.session, + activeTurnId: turnId, + updatedAt: yield* nowIso, + }; + + yield* offerRuntimeEvent({ + type: "turn.started", + ...(yield* makeEventStamp()), + provider, + threadId: input.threadId, + turnId, + payload: model ? { model } : {}, + }); + const result = yield* ctx.acp .prompt({ prompt: promptParts, }) .pipe( + Effect.raceFirst(Deferred.await(activePromptCancel)), Effect.mapError((error) => mapAcpToAdapterError(provider, input.threadId, "session/prompt", error), ), + Effect.ensuring( + Effect.sync(() => { + if (ctx.activePrompt?.turnId === turnId) { + ctx.activePrompt = undefined; + } + }), + ), ); ctx.turns.push({ id: turnId, items: [{ prompt: promptParts, result }] }); @@ -797,6 +895,11 @@ export function makeStandardAcpAdapter( const ctx = yield* requireSession(threadId); yield* settlePendingApprovalsAsCancelled(ctx.pendingApprovals); yield* settlePendingUserInputsAsEmptyAnswers(ctx.pendingUserInputs); + if (ctx.activePrompt) { + yield* Deferred.succeed(ctx.activePrompt.cancel, { + stopReason: "cancelled", + }); + } yield* Effect.ignore( ctx.acp.cancel.pipe( Effect.mapError((error) => diff --git a/apps/web/index.html b/apps/web/index.html index 88e1c8b4f23..83d3fd719aa 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -7,14 +7,14 @@ content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - - + +