diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd5fe6db48f..8fc9dba9284 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -183,6 +183,8 @@ jobs: if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' && needs.build.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true steps: - name: Checkout uses: actions/checkout@v6 diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 1c2a060f11f..ef61a5c6829 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -1,4 +1,5 @@ import { PiSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,7 +13,12 @@ import { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; import { ServerConfig } from "../../config.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makePiAdapter } from "../Layers/PiAdapter.ts"; -import { checkPiProviderStatus, makePendingPiProvider } from "../Layers/PiProvider.ts"; +import { + checkPiProviderStatus, + enrichPiSnapshot, + makePendingPiProvider, + probePiCapabilities, +} from "../Layers/PiProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -23,16 +29,16 @@ import { import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { - enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; -import { makePiContinuationGroupKey } from "./PiHome.ts"; +import { makePiCapabilitiesCacheKey, makePiContinuationGroupKey } from "./PiHome.ts"; const decodePiSettings = Schema.decodeSync(PiSettings); const DRIVER_KIND = ProviderDriverKind.make("piAgent"); const SNAPSHOT_REFRESH_INTERVAL = Duration.minutes(5); +const CAPABILITIES_PROBE_TTL = Duration.minutes(5); const UPDATE = makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, @@ -76,7 +82,7 @@ export const PiDriver: ProviderDriver = { create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; - const eventLoggers = yield* ProviderEventLoggers; + const _eventLoggers = yield* ProviderEventLoggers; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -106,16 +112,23 @@ export const PiDriver: ProviderDriver = { }); const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); + const capabilitiesProbeCache = yield* Cache.make({ + capacity: 1, + timeToLive: CAPABILITIES_PROBE_TTL, + lookup: () => + probePiCapabilities(effectiveConfig, serverConfig.cwd, processEnv).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + }); + const capabilitiesCacheKey = yield* makePiCapabilitiesCacheKey(effectiveConfig); + const checkProvider = checkPiProviderStatus( effectiveConfig, - serverConfig.cwd, + () => Cache.get(capabilitiesProbeCache, capabilitiesCacheKey), processEnv, - ).pipe( - Effect.map(stampIdentity), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - ); + ).pipe(Effect.map(stampIdentity)); const snapshot = yield* makeManagedServerProvider({ maintenanceCapabilities, @@ -126,9 +139,18 @@ export const PiDriver: ProviderDriver = { makePendingPiProvider(settings).pipe(Effect.map(stampIdentity)), checkProvider, enrichSnapshot: ({ snapshot, publishSnapshot }) => - enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities).pipe( - Effect.provideService(HttpClient.HttpClient, httpClient), - Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), + enrichPiSnapshot({ + settings: effectiveConfig, + environment: processEnv, + snapshot, + maintenanceCapabilities, + publishSnapshot, + stampIdentity, + httpClient, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), ), refreshInterval: SNAPSHOT_REFRESH_INTERVAL, }).pipe( diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 161dc54f600..edc010b51d1 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -4,12 +4,10 @@ import { type CanonicalItemType, EventId, type PiSettings, - type ProviderApprovalDecision, ProviderDriverKind, ProviderInstanceId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, - type ProviderSendTurnInput, type ProviderSession, type ProviderUserInputAnswers, RuntimeItemId, @@ -116,6 +114,8 @@ interface PiTurnState { readonly turnId: TurnId; readonly startedAt: string; readonly items: Array; + activeTextItemId: RuntimeItemId | undefined; + activeReasoningItemId: RuntimeItemId | undefined; } interface PendingExtensionUI { @@ -296,6 +296,39 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( const turnState = context.turnState; if (!turnState) return; + if (turnState.activeTextItemId) { + const closingTextId = turnState.activeTextItemId; + turnState.activeTextItemId = undefined; + const closingStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + eventId: closingStamp.eventId, + provider: PROVIDER, + createdAt: closingStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: closingTextId, + type: "item.completed", + payload: { itemType: "assistant_message", status: "completed" }, + providerRefs: {}, + }); + } + if (turnState.activeReasoningItemId) { + const closingReasoningId = turnState.activeReasoningItemId; + turnState.activeReasoningItemId = undefined; + const closingStamp = yield* makeEventStamp(); + yield* offerRuntimeEvent({ + eventId: closingStamp.eventId, + provider: PROVIDER, + createdAt: closingStamp.createdAt, + threadId: context.session.threadId, + turnId: turnState.turnId, + itemId: closingReasoningId, + type: "item.completed", + payload: { itemType: "reasoning", status: "completed" }, + providerRefs: {}, + }); + } + context.turnState = undefined; context.turns.push({ id: turnState.turnId, @@ -358,7 +391,13 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( if (!context.turnState) { const turnId = TurnId.make(yield* Random.nextUUIDv4); const startedAt = yield* nowIso; - context.turnState = { turnId, startedAt, items: [] }; + context.turnState = { + turnId, + startedAt, + items: [], + activeTextItemId: undefined, + activeReasoningItemId: undefined, + }; const updatedAt = yield* nowIso; context.session = { ...context.session, @@ -381,9 +420,21 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( const assistantEvent = event.assistantMessageEvent; if (!assistantEvent) return; if (assistantEvent.type === "text_delta") { + if (!context.turnState.activeTextItemId) { + const textItemId = RuntimeItemId.make(yield* Random.nextUUIDv4); + context.turnState.activeTextItemId = textItemId; + yield* offerRuntimeEvent({ + ...base, + turnId: context.turnState.turnId, + itemId: textItemId, + type: "item.started", + payload: { itemType: "assistant_message" }, + }); + } yield* offerRuntimeEvent({ ...base, turnId: context.turnState.turnId, + itemId: context.turnState.activeTextItemId, type: "content.delta", payload: { streamKind: "assistant_text", @@ -391,9 +442,21 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( }, }); } else if (assistantEvent.type === "thinking_delta") { + if (!context.turnState.activeReasoningItemId) { + const reasoningItemId = RuntimeItemId.make(yield* Random.nextUUIDv4); + context.turnState.activeReasoningItemId = reasoningItemId; + yield* offerRuntimeEvent({ + ...base, + turnId: context.turnState.turnId, + itemId: reasoningItemId, + type: "item.started", + payload: { itemType: "reasoning" }, + }); + } yield* offerRuntimeEvent({ ...base, turnId: context.turnState.turnId, + itemId: context.turnState.activeReasoningItemId, type: "content.delta", payload: { streamKind: "reasoning_text", @@ -406,6 +469,28 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( case "tool_execution_start": { if (!context.turnState) return; + if (context.turnState.activeTextItemId) { + const closingTextId = context.turnState.activeTextItemId; + context.turnState.activeTextItemId = undefined; + yield* offerRuntimeEvent({ + ...base, + turnId: context.turnState.turnId, + itemId: closingTextId, + type: "item.completed", + payload: { itemType: "assistant_message", status: "completed" }, + }); + } + if (context.turnState.activeReasoningItemId) { + const closingReasoningId = context.turnState.activeReasoningItemId; + context.turnState.activeReasoningItemId = undefined; + yield* offerRuntimeEvent({ + ...base, + turnId: context.turnState.turnId, + itemId: closingReasoningId, + type: "item.completed", + payload: { itemType: "reasoning", status: "completed" }, + }); + } const itemId = RuntimeItemId.make(event.toolCallId); const itemType = classifyToolItemType(event.toolName); const detail = summarizePiToolArgs(event.args); @@ -1024,7 +1109,13 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( const turnId = TurnId.make(yield* Random.nextUUIDv4); const turnStartedAt = yield* nowIso; - context.turnState = { turnId, startedAt: turnStartedAt, items: [] }; + context.turnState = { + turnId, + startedAt: turnStartedAt, + items: [], + activeTextItemId: undefined, + activeReasoningItemId: undefined, + }; context.session = { ...context.session, status: "running", @@ -1044,15 +1135,24 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( providerRefs: {}, }); - const promptText = typeof input.input === "string" ? input.input : ""; + const rawPromptText = typeof input.input === "string" ? input.input : ""; + const trimmed = rawPromptText.trim(); - yield* context - .writeCommand({ - type: "prompt", - message: promptText, - ...(piImages.length > 0 ? { images: piImages } : {}), - }) - .pipe(Effect.catchDefect(() => completeTurn(context, "failed", "Failed to send prompt."))); + if (trimmed === "/compact" || trimmed.startsWith("/compact ")) { + const customInstructions = trimmed.slice("/compact".length).trim() || undefined; + yield* context + .writeCommand({ type: "compact", ...(customInstructions ? { customInstructions } : {}) }) + .pipe(Effect.catchDefect(() => completeTurn(context, "failed", "Compaction failed."))); + } else { + const promptText = rawPromptText.replace(/^\$([a-zA-Z][\w:.-]*)/, "/skill:$1"); + yield* context + .writeCommand({ + type: "prompt", + message: promptText, + ...(piImages.length > 0 ? { images: piImages } : {}), + }) + .pipe(Effect.catchDefect(() => completeTurn(context, "failed", "Failed to send prompt."))); + } return { threadId: context.session.threadId, diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 75d91af3a8b..ff3c7d7763d 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -1,22 +1,28 @@ import { type PiSettings, type ModelCapabilities, + type ServerProvider, type ServerProviderModel, type ServerProviderSkill, + type ServerProviderSlashCommand, ProviderDriverKind, } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; +import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; import { buildSelectOptionDescriptor, buildServerProvider, + collectStreamAsString, DEFAULT_TIMEOUT_MS, detailFromResult, isCommandMissingCause, @@ -25,6 +31,10 @@ import { spawnAndCollect, type ServerProviderDraft, } from "../providerSnapshot.ts"; +import { + enrichProviderSnapshotWithVersionAdvisory, + type ProviderMaintenanceCapabilities, +} from "../providerMaintenance.ts"; import { makePiEnvironment, resolvePiHomePath } from "../Drivers/PiHome.ts"; const DEFAULT_PI_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ @@ -37,75 +47,187 @@ const PI_PRESENTATION = { showInteractionModeToggle: true, } as const; -const BUILT_IN_MODELS: ReadonlyArray = [ - { - slug: "claude-sonnet-4-6", - name: "Claude Sonnet 4.6", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "thinking", - label: "Thinking", - options: [ - { value: "off", label: "Off" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium", isDefault: true }, - { value: "high", label: "High" }, - { value: "xhigh", label: "Extra High" }, - ], - }), - ], - }), - }, - { - slug: "claude-opus-4-7", - name: "Claude Opus 4.7", - isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "thinking", - label: "Thinking", - options: [ - { value: "off", label: "Off" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High", isDefault: true }, - { value: "xhigh", label: "Extra High" }, - ], - }), - ], - }), - }, - { - slug: "claude-haiku-4-5", - name: "Claude Haiku 4.5", +/** + * Capabilities for any model whose slug is not otherwise known. Used as a + * safe fallback so callers never need to handle undefined. + */ +export function getPiModelCapabilities(_model: string | null | undefined): ModelCapabilities { + // Model capabilities are now discovered dynamically from `pi --list-models` + // and embedded directly in each `ServerProviderModel`. This function is + // retained for callers that need a fallback when the model isn't found in + // the live snapshot. + return DEFAULT_PI_MODEL_CAPABILITIES; +} + +const PI_LIST_MODELS_TIMEOUT_MS = 12_000; + +/** + * Parse the tabular output of `pi --list-models` into structured model rows. + * + * Expected header + data format: + * ``` + * provider model context max-out thinking images + * anthropic claude-sonnet-4-6 1M 64K yes yes + * cursor claude-sonnet-4-6@1m 1M 16.4K yes yes + * ``` + */ +export function parsePiListModelsOutput(stdout: string): ReadonlyArray<{ + readonly provider: string; + readonly model: string; + readonly thinking: boolean; +}> { + const lines = stdout.split("\n"); + const results: Array<{ provider: string; model: string; thinking: boolean }> = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + if (!line || !line.trim()) continue; + // Columns are whitespace-separated; model names never contain spaces. + const fields = line.trim().split(/\s+/); + if (fields.length < 5) continue; + const provider = fields[0]; + const model = fields[1]; + // "thinking" is the 5th column (index 4) + const thinking = fields[4] === "yes"; + if (!provider || !model) continue; + results.push({ provider, model, thinking }); + } + + return results; +} + +/** Build the thinking-enabled `ModelCapabilities` descriptor for discovered models. */ +function buildThinkingCapabilities(): ModelCapabilities { + return createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "thinking", + label: "Thinking", + options: [ + { value: "off", label: "Off" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Medium", isDefault: true }, + { value: "high", label: "High" }, + ], + }), + ], + }); +} + +/** + * Discover Pi models dynamically by running `pi --list-models`. Returns every + * model reported by the CLI using `provider/model` slugs (e.g. + * `anthropic/claude-sonnet-4-6`, `cursor/claude-sonnet-4-6@1m`). The list is + * authoritative — nothing is hardcoded in t3code. + */ +const discoverPiModels = Effect.fn("discoverPiModels")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const result = yield* runPiCommand(piSettings, ["--list-models"], environment).pipe( + Effect.timeoutOption(PI_LIST_MODELS_TIMEOUT_MS), + Effect.result, + ); + + if (Result.isFailure(result) || Option.isNone(result.success)) { + return []; + } + + const commandResult = result.success.value; + if (commandResult.code !== 0) return []; + + // `pi --list-models` writes its table to stderr, not stdout. + const rows = parsePiListModelsOutput(commandResult.stderr); + const thinkingCaps = buildThinkingCapabilities(); + + return rows.map((row) => ({ + slug: `${row.provider}/${row.model}`, + name: `${row.provider}/${row.model}`, isCustom: false, - capabilities: createModelCapabilities({ - optionDescriptors: [ - buildSelectOptionDescriptor({ - id: "thinking", - label: "Thinking", - options: [ - { value: "off", label: "Off" }, - { value: "low", label: "Low", isDefault: true }, - { value: "medium", label: "Medium" }, - { value: "high", label: "High" }, - ], + capabilities: row.thinking ? thinkingCaps : DEFAULT_PI_MODEL_CAPABILITIES, + })); +}); + +/** + * Background snapshot enrichment hook for the Pi Agent provider. + * + * Chains two passes: + * 1. Version-advisory enrichment (checks npm/Homebrew for updates). + * 2. Dynamic model discovery via `pi --list-models`, which surfaces cursor and + * other provider models that become available after login without restarting + * t3code. + * + * Mirrors the `enrichCursorSnapshot` pattern so both providers stay consistent. + */ +export const enrichPiSnapshot = (input: { + readonly settings: PiSettings; + readonly environment?: NodeJS.ProcessEnv; + readonly snapshot: ServerProvider; + readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; + readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect; + readonly stampIdentity?: (snapshot: ServerProvider) => ServerProvider; + readonly httpClient: HttpClient.HttpClient; +}): Effect.Effect< + void, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> => { + const { settings, snapshot, publishSnapshot } = input; + const stampIdentity = input.stampIdentity ?? ((value: ServerProvider) => value); + const environment = input.environment ?? process.env; + + const enrichVersionAdvisory = enrichProviderSnapshotWithVersionAdvisory( + snapshot, + input.maintenanceCapabilities, + ).pipe( + Effect.provideService(HttpClient.HttpClient, input.httpClient), + Effect.flatMap((enrichedSnapshot) => + publishSnapshot(stampIdentity(enrichedSnapshot)).pipe(Effect.as(enrichedSnapshot)), + ), + Effect.catchCause((cause) => + Effect.logWarning("Pi version advisory enrichment failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.as(snapshot)), + ), + ); + + return enrichVersionAdvisory.pipe( + Effect.flatMap((baseSnapshot) => { + if (!settings.enabled || !baseSnapshot.installed) { + return Effect.void; + } + + return discoverPiModels(settings, environment).pipe( + Effect.flatMap((discoveredModels) => { + if (discoveredModels.length === 0) return Effect.void; + + const models = providerModelsFromSettings( + discoveredModels, + PROVIDER, + settings.customModels, + DEFAULT_PI_MODEL_CAPABILITIES, + ); + + return publishSnapshot( + stampIdentity({ + ...baseSnapshot, + models, + }), + ); }), - ], + Effect.catchCause((cause) => + Effect.logWarning("Pi model discovery failed", { + cause: Cause.pretty(cause), + }).pipe(Effect.asVoid), + ), + ); }), - }, -]; - -export function getPiModelCapabilities(model: string | null | undefined): ModelCapabilities { - const slug = model?.trim(); - return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? - DEFAULT_PI_MODEL_CAPABILITIES ); -} +}; const runPiCommand = Effect.fn("runPiCommand")(function* ( piSettings: PiSettings, @@ -120,6 +242,77 @@ const runPiCommand = Effect.fn("runPiCommand")(function* ( return yield* spawnAndCollect(piSettings.binaryPath || "pi", command); }); +interface RpcSlashCommand { + readonly name: string; + readonly description?: string; + readonly source: "extension" | "prompt" | "skill"; + readonly sourceInfo?: { readonly path?: string; readonly scope?: string }; +} + +const PI_RPC_PROBE_TIMEOUT_MS = 8_000; + +function parseRpcCommandsResponse(line: string): ReadonlyArray | null { + // eslint-disable-next-line no-restricted-syntax -- parsing external RPC JSON + let msg: Record; + try { + msg = JSON.parse(line) as Record; // @effect-diagnostics-ignore preferSchemaOverJson + } catch { + return null; + } + if (msg["type"] === "response" && msg["command"] === "get_commands" && msg["success"] === true) { + const data = msg["data"] as { commands?: unknown[] } | undefined; + if (Array.isArray(data?.commands)) { + return data.commands.filter( + (c): c is RpcSlashCommand => + typeof c === "object" && + c !== null && + typeof (c as Record)["name"] === "string", + ); + } + } + return null; +} + +const probePiRpcCommands = Effect.fn("probePiRpcCommands")(function* ( + piSettings: PiSettings, + cwd: string, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return< + ReadonlyArray, + never, + ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path +> { + const piEnvironment = yield* makePiEnvironment(piSettings, environment); + const binaryPath = piSettings.binaryPath || "pi"; + + const command = ChildProcess.make(binaryPath, ["--mode", "rpc"], { + env: piEnvironment, + cwd, + shell: false, + }); + return yield* Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(command); + + const requestPayload = `{"type":"get_commands","id":"probe"}\n`; + yield* Stream.make(new TextEncoder().encode(requestPayload)).pipe(Stream.run(child.stdin)); + + const stdout = yield* collectStreamAsString(child.stdout); + + const commands: RpcSlashCommand[] = []; + for (const line of stdout.split("\n")) { + const parsed = parseRpcCommandsResponse(line.trim()); + if (parsed) { + commands.push(...parsed); + break; + } + } + return commands as ReadonlyArray; + }), + ).pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); +}); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function parseSkillFrontmatter(content: string): { @@ -233,18 +426,54 @@ const discoverPiSkills = Effect.fn("discoverPiSkills")(function* ( return deduped; }); -export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( +export type PiCapabilitiesProbe = { + readonly versionResult: Result.Result< + Option.Option<{ code: number; stdout: string; stderr: string }>, + { readonly message: string } + >; + readonly skills: ReadonlyArray; + readonly rpcCommands: ReadonlyArray; +}; + +export const probePiCapabilities = Effect.fn("probePiCapabilities")(function* ( piSettings: PiSettings, cwd: string, - environment: NodeJS.ProcessEnv = process.env, + environment: NodeJS.ProcessEnv, ): Effect.fn.Return< - ServerProviderDraft, + PiCapabilitiesProbe, never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { + const [versionResult, skills, rpcCommands] = yield* Effect.all( + [ + runPiCommand(piSettings, ["--version"], environment).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ), + discoverPiSkills(cwd, piSettings).pipe( + Effect.orElseSucceed(() => [] as ServerProviderSkill[]), + ), + probePiRpcCommands(piSettings, cwd, environment).pipe( + Effect.timeoutOption(PI_RPC_PROBE_TIMEOUT_MS), + Effect.map((opt) => (Option.isSome(opt) ? opt.value : [])), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ), + ], + { concurrency: "unbounded" }, + ); + return { versionResult, skills, rpcCommands }; +}); + +export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( + piSettings: PiSettings, + resolveProbe: () => Effect.Effect, + environment: NodeJS.ProcessEnv = process.env, +): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); - const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + // Models are populated by the background `enrichPiSnapshot` pass via + // `pi --list-models`; use an empty list here to avoid stale hardcoded data. + const customOnlyModels = providerModelsFromSettings( + [], PROVIDER, piSettings.customModels, DEFAULT_PI_MODEL_CAPABILITIES, @@ -255,7 +484,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: false, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: false, version: null, @@ -266,10 +495,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } - const versionProbe = yield* runPiCommand(piSettings, ["--version"], environment).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); + const { versionResult: versionProbe, skills, rpcCommands } = yield* resolveProbe(); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; @@ -277,7 +503,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: !isCommandMissingCause(error), version: null, @@ -295,7 +521,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: true, version: null, @@ -315,7 +541,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: true, version: parsedVersion, @@ -326,16 +552,46 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } - const skills = yield* discoverPiSkills(cwd, piSettings).pipe( - Effect.orElseSucceed(() => [] as ServerProviderSkill[]), - ); + const seen = new Set(); + const slashCommands: ServerProviderSlashCommand[] = [ + { name: "compact", description: "Manually compact the session context" }, + ]; + seen.add("compact"); + + const skillNames = new Set(skills.map((s) => s.name)); + const allSkills = [...skills]; + + for (const cmd of rpcCommands) { + if (cmd.source === "skill") { + const skillName = cmd.name.replace(/^skill:/, ""); + if (!skillNames.has(skillName)) { + skillNames.add(skillName); + allSkills.push({ + name: skillName, + path: cmd.sourceInfo?.path ?? skillName, + enabled: true, + ...(cmd.sourceInfo?.scope ? { scope: cmd.sourceInfo.scope } : {}), + ...(cmd.description ? { description: cmd.description } : {}), + }); + } + continue; + } + if (!seen.has(cmd.name)) { + seen.add(cmd.name); + slashCommands.push({ + name: cmd.name, + ...(cmd.description ? { description: cmd.description } : {}), + }); + } + } return buildServerProvider({ presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, - skills, + models: customOnlyModels, + slashCommands, + skills: allSkills, probe: { installed: true, version: parsedVersion, @@ -348,8 +604,8 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function export const makePendingPiProvider = (piSettings: PiSettings): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; - const models = providerModelsFromSettings( - BUILT_IN_MODELS, + const customOnlyModels = providerModelsFromSettings( + [], PROVIDER, piSettings.customModels, DEFAULT_PI_MODEL_CAPABILITIES, @@ -360,7 +616,7 @@ export const makePendingPiProvider = (piSettings: PiSettings): Effect.Effect { composerRef.current?.focusAtEnd(); - }, []); + }, [composerRef]); const scheduleComposerFocus = useCallback(() => { window.requestAnimationFrame(() => { focusComposer(); }); }, [focusComposer]); - const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => { - composerRef.current?.addTerminalContext(selection); - }, []); + const addTerminalContextToDraft = useCallback( + (selection: TerminalContextSelection) => { + composerRef.current?.addTerminalContext(selection); + }, + [composerRef], + ); const setTerminalOpen = useCallback( (open: boolean) => { if (!activeThreadRef) return; @@ -2547,6 +2550,7 @@ export default function ChatView(props: ChatViewProps) { keybindings, onToggleDiff, toggleTerminalVisibility, + composerRef, ]); const onRevertToTurnCount = useCallback( @@ -3021,7 +3025,7 @@ export default function ChatView(props: ChatViewProps) { promptRef.current = ""; composerRef.current?.resetCursorState({ cursor: 0 }); }, - [activePendingProgress?.activeQuestion, activePendingUserInput], + [activePendingProgress?.activeQuestion, activePendingUserInput, composerRef], ); const onChangeActivePendingUserInputCustomAnswer = useCallback( @@ -3055,7 +3059,7 @@ export default function ChatView(props: ChatViewProps) { composerRef.current?.focusAt(nextCursor); } }, - [activePendingUserInput], + [activePendingUserInput, composerRef], ); const onAdvanceActivePendingUserInput = useCallback(() => { @@ -3227,6 +3231,7 @@ export default function ChatView(props: ChatViewProps) { setThreadError, autoOpenPlanSidebar, environmentId, + composerRef, ], ); @@ -3364,6 +3369,7 @@ export default function ChatView(props: ChatViewProps) { runtimeMode, autoOpenPlanSidebar, environmentId, + composerRef, ]); const onProviderModelSelect = useCallback( diff --git a/apps/web/src/environments/runtime/catalog.test.ts b/apps/web/src/environments/runtime/catalog.test.ts index f078129463a..24a811b4557 100644 --- a/apps/web/src/environments/runtime/catalog.test.ts +++ b/apps/web/src/environments/runtime/catalog.test.ts @@ -13,6 +13,10 @@ import { waitForSavedEnvironmentRegistryHydration, } from "./catalog"; +function throwRegistryReadUninitialized(): never { + throw new Error("Registry read resolver was not initialized."); +} + describe("environment runtime catalog stores", () => { beforeEach(async () => { vi.stubGlobal("window", { @@ -95,9 +99,7 @@ describe("environment runtime catalog stores", () => { }); it("does not let stale hydration overwrite records added while hydration is in flight", async () => { - let resolveRegistryRead: () => void = () => { - throw new Error("Registry read resolver was not initialized."); - }; + let resolveRegistryRead: () => void = throwRegistryReadUninitialized; vi.stubGlobal("window", { nativeApi: {