From 45aba5912c189c0ea479b22e889cb60f19af5959 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 11:39:33 -0700 Subject: [PATCH 01/12] fix: resolve lint warnings across server and web - Remove unused imports: ProviderApprovalDecision, ProviderSendTurnInput, CreateAgentSessionOptions, ProviderUserInputAnswers (PiAdapter.ts); ProviderInstanceId (serverRuntimeStartup.ts + test); HttpBody (server.test.ts) - Rename unused variables to underscore-prefixed: _piEnvironment (PiAdapter.ts), _eventLoggers (PiDriver.ts) - Remove unused encodeJsonString declaration (PiTextGeneration.ts) - Add composerRef to useCallback/useEffect deps in ChatView.tsx (exhaustive-deps: focusComposer, addTerminalContextToDraft, keyboard shortcut handler, onRespondToActivePendingUserInputChoice, onChangeActivePendingUserInputCustomAnswer, onSubmitPlanFollowUp, onImplementPlanInNewThread) - Replace spread-in-map with Object.assign in clientPersistenceStorage.ts (oxc no-map-spread) - Extract throwRegistryReadUninitialized to module scope in catalog.test.ts (unicorn consistent-function-scoping) - Opt into Node.js 24 for release job to silence action-gh-release deprecation --- .github/workflows/release.yml | 2 ++ apps/server/src/provider/Drivers/PiDriver.ts | 8 ++++++-- apps/server/src/provider/Layers/PiAdapter.ts | 6 +----- apps/server/src/server.test.ts | 8 +------- apps/server/src/serverRuntimeStartup.test.ts | 1 - apps/server/src/serverRuntimeStartup.ts | 1 - .../src/textGeneration/PiTextGeneration.ts | 2 -- apps/web/src/clientPersistenceStorage.ts | 5 ++++- apps/web/src/components/ChatView.tsx | 18 ++++++++++++------ .../src/environments/runtime/catalog.test.ts | 8 +++++--- 10 files changed, 31 insertions(+), 28 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb8c22638a8..8440b6f179f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -165,6 +165,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 89e25d5c9a0..e3cc4f8b618 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -76,7 +76,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,7 +106,11 @@ export const PiDriver: ProviderDriver = { }); const textGeneration = yield* makePiTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkPiProviderStatus(effectiveConfig, serverConfig.cwd, processEnv).pipe( + const checkProvider = checkPiProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fs), diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 21aa95b2373..ffc11b9731f 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -3,20 +3,16 @@ import { SessionManager, type AgentSession, type AgentSessionEvent, - type CreateAgentSessionOptions, } from "@earendil-works/pi-coding-agent"; import { type CanonicalItemType, EventId, type PiSettings, - type ProviderApprovalDecision, ProviderDriverKind, ProviderInstanceId, type ProviderRuntimeEvent, type ProviderRuntimeTurnStatus, - type ProviderSendTurnInput, type ProviderSession, - type ProviderUserInputAnswers, RuntimeItemId, ThreadId, TurnId, @@ -175,7 +171,7 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( ) { const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("piAgent"); const serverConfig = yield* ServerConfig; - const piEnvironment = yield* makePiEnvironment(piSettings, options?.environment); + const _piEnvironment = yield* makePiEnvironment(piSettings, options?.environment); const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index c69d6d75bc3..ebe3b83bd4b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -38,13 +38,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - FetchHttpClient, - HttpBody, - HttpClient, - HttpRouter, - HttpServer, -} from "effect/unstable/http"; +import { FetchHttpClient, HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; import * as Socket from "effect/unstable/socket/Socket"; import { vi } from "vitest"; diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 345c974bf61..d7a2705cbe4 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -4,7 +4,6 @@ import { DEFAULT_MODEL_BY_PROVIDER, ProjectId, ProviderDriverKind, - ProviderInstanceId, ThreadId, defaultInstanceIdForDriver, } from "@t3tools/contracts"; diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index af4460ed439..e9a1cb1179d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -6,7 +6,6 @@ import { type ModelSelection, ProjectId, ProviderDriverKind, - ProviderInstanceId, ThreadId, defaultInstanceIdForDriver, } from "@t3tools/contracts"; diff --git a/apps/server/src/textGeneration/PiTextGeneration.ts b/apps/server/src/textGeneration/PiTextGeneration.ts index 1219e3ab15b..8410bd908ca 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/apps/server/src/textGeneration/PiTextGeneration.ts @@ -25,8 +25,6 @@ import { makePiEnvironment } from "../provider/Drivers/PiHome.ts"; const PI_TIMEOUT_MS = 180_000; -const encodeJsonString = Schema.encodeEffect(Schema.UnknownFromJsonString); - export const makePiTextGeneration = Effect.fn("makePiTextGeneration")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv = process.env, diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 30c949b37ac..927ca25cd6b 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -185,7 +185,10 @@ export function writeBrowserSavedEnvironmentSecret( lastConnectedAt: record.lastConnectedAt, bearerToken: secret, }; - return record.desktopSsh ? { ...nextRecord, desktopSsh: record.desktopSsh } : nextRecord; + if (record.desktopSsh) { + return Object.assign(nextRecord, { desktopSsh: record.desktopSsh }); + } + return nextRecord; }), }); return found; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d66d2487ce3..d4f5eec3fdb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1781,15 +1781,18 @@ export default function ChatView(props: ChatViewProps) { const focusComposer = useCallback(() => { 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: { From 0cecccac82941ecad00ec499801fe052c833d0cc Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 18:22:49 -0700 Subject: [PATCH 02/12] fix(pi): interleave text blocks with tool calls in response display Track activeTextItemId/activeReasoningItemId on PiTurnState so text content is emitted as proper item.started/content.delta/item.completed sequences rather than bare content.delta events without an itemId. Boundaries are closed when a tool execution starts or the turn ends, giving the UI the structure it needs to display text interleaved with tool calls instead of merging all text together. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/src/provider/Layers/PiAdapter.ts | 85 +++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index ffc11b9731f..06dc855f3db 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -49,6 +49,8 @@ interface PiTurnState { readonly turnId: TurnId; readonly startedAt: string; readonly items: Array; + activeTextItemId: RuntimeItemId | undefined; + activeReasoningItemId: RuntimeItemId | undefined; } interface PiSessionContext { @@ -191,6 +193,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, @@ -253,7 +288,7 @@ 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, @@ -276,9 +311,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", @@ -286,9 +333,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", @@ -301,6 +360,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); @@ -630,7 +711,7 @@ 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", From d45ae306c6dc81939f6dcc303708f6951d506de3 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 18:28:47 -0700 Subject: [PATCH 03/12] feat(pi): dynamic model discovery via `pi --list-models` Remove hardcoded BUILT_IN_MODELS from PiProvider. Models are now discovered at runtime by running `pi --list-models` in the background enrichSnapshot hook, surfacing every provider/model pair Pi has access to (anthropic, cursor, openai, etc.) using `provider/model` slugs. - Add parsePiListModelsOutput() to parse the tabular CLI output - Add discoverPiModels effect (12s timeout) returning all rows - Add enrichPiSnapshot (mirrors enrichCursorSnapshot pattern): chains version-advisory enrichment then model discovery, publishes updated snapshot with discovered + custom models - checkPiProviderStatus and makePendingPiProvider now start with an empty built-in list; enrichment fills in the real list - Wire enrichPiSnapshot into PiDriver enrichSnapshot hook Cursor models (and any other provider models) now appear automatically after login without restarting t3code. --- apps/server/src/provider/Drivers/PiDriver.ts | 22 +- apps/server/src/provider/Layers/PiProvider.ts | 292 ++++++++++++------ 2 files changed, 217 insertions(+), 97 deletions(-) diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index e3cc4f8b618..5c78266b5ff 100644 --- a/apps/server/src/provider/Drivers/PiDriver.ts +++ b/apps/server/src/provider/Drivers/PiDriver.ts @@ -12,7 +12,11 @@ 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, +} from "../Layers/PiProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { @@ -23,7 +27,6 @@ import { import type { ServerProviderDraft } from "../providerSnapshot.ts"; import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; import { - enrichProviderSnapshotWithVersionAdvisory, makePackageManagedProviderMaintenanceResolver, resolveProviderMaintenanceCapabilitiesEffect, } from "../providerMaintenance.ts"; @@ -126,9 +129,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/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index e340fac868e..15e2ef4a3c4 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -1,16 +1,19 @@ import { type PiSettings, type ModelCapabilities, + type ServerProvider, type ServerProviderModel, type ServerProviderSkill, 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 { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -25,6 +28,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 +44,186 @@ 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 []; + + const rows = parsePiListModelsOutput(commandResult.stdout); + 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, @@ -150,11 +268,7 @@ function skillNameFromPath(filePath: string, pathSep: string): string { const scanSkillDir = Effect.fn("scanSkillDir")(function* ( dir: string, scope: string, -): Effect.fn.Return< - ServerProviderSkill[], - never, - FileSystem.FileSystem | Path.Path -> { +): Effect.fn.Return { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const skills: ServerProviderSkill[] = []; @@ -173,9 +287,7 @@ const scanSkillDir = Effect.fn("scanSkillDir")(function* ( const skillMdPath = path.join(entryPath, "SKILL.md"); const hasSkillMd = yield* fs.exists(skillMdPath).pipe(Effect.orElseSucceed(() => false)); if (hasSkillMd) { - const content = yield* fs.readFileString(skillMdPath).pipe( - Effect.orElseSucceed(() => ""), - ); + const content = yield* fs.readFileString(skillMdPath).pipe(Effect.orElseSucceed(() => "")); const frontmatter = parseSkillFrontmatter(content); const name = frontmatter.name ?? entry; skills.push({ @@ -191,9 +303,7 @@ const scanSkillDir = Effect.fn("scanSkillDir")(function* ( } if (entry.endsWith(".md")) { - const content = yield* fs.readFileString(entryPath).pipe( - Effect.orElseSucceed(() => ""), - ); + const content = yield* fs.readFileString(entryPath).pipe(Effect.orElseSucceed(() => "")); const frontmatter = parseSkillFrontmatter(content); const name = frontmatter.name ?? skillNameFromPath(entry, path.sep); skills.push({ @@ -213,11 +323,7 @@ const scanSkillDir = Effect.fn("scanSkillDir")(function* ( const discoverPiSkills = Effect.fn("discoverPiSkills")(function* ( cwd: string, piSettings: PiSettings, -): Effect.fn.Return< - ReadonlyArray, - never, - FileSystem.FileSystem | Path.Path -> { +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { const path = yield* Path.Path; const piHomePath = yield* resolvePiHomePath(piSettings); @@ -255,8 +361,10 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { 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, @@ -267,7 +375,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: false, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: false, version: null, @@ -289,7 +397,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, @@ -307,7 +415,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: true, version: null, @@ -327,7 +435,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, + models: customOnlyModels, probe: { installed: true, version: parsedVersion, @@ -346,7 +454,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, - models: allModels, + models: customOnlyModels, skills, probe: { installed: true, @@ -360,8 +468,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, @@ -372,7 +480,7 @@ export const makePendingPiProvider = (piSettings: PiSettings): Effect.Effect Date: Wed, 27 May 2026 18:44:55 -0700 Subject: [PATCH 04/12] feat(pi): surface skills and /compact as slash commands in provider snapshot Skills discovered from the filesystem are now also exposed as /skill: slash commands so they appear in the "/" menu alongside built-in commands. A /compact command is added with direct piSession.compact() dispatch, and $skill references from the "$" menu are translated to /skill: format for Pi's native expansion. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiAdapter.ts | 57 ++++++++++++++----- apps/server/src/provider/Layers/PiProvider.ts | 10 ++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 06dc855f3db..96c4607a054 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -288,7 +288,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: [], activeTextItemId: undefined, activeReasoningItemId: undefined }; + context.turnState = { + turnId, + startedAt, + items: [], + activeTextItemId: undefined, + activeReasoningItemId: undefined, + }; const updatedAt = yield* nowIso; context.session = { ...context.session, @@ -711,7 +717,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: [], activeTextItemId: undefined, activeReasoningItemId: undefined }; + context.turnState = { + turnId, + startedAt: turnStartedAt, + items: [], + activeTextItemId: undefined, + activeReasoningItemId: undefined, + }; context.session = { ...context.session, status: "running", @@ -731,21 +743,38 @@ 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 runtimeContext = yield* Effect.context(); const runFork = Effect.runForkWith(runtimeContext); - runFork( - Effect.tryPromise({ - try: () => context.piSession.prompt(promptText), - catch: (cause) => - new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "turn/prompt", - detail: toMessage(cause, "Failed to send prompt to Pi Agent."), - }), - }).pipe(Effect.catch(() => completeTurn(context, "failed", "Prompt failed."))), - ); + + if (rawPromptText.trim() === "/compact" || rawPromptText.trim().startsWith("/compact ")) { + const customInstructions = rawPromptText.trim().slice("/compact".length).trim() || undefined; + runFork( + Effect.tryPromise({ + try: () => context.piSession.compact(customInstructions), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/compact", + detail: toMessage(cause, "Failed to compact Pi Agent session."), + }), + }).pipe(Effect.catch(() => completeTurn(context, "failed", "Compaction failed."))), + ); + } else { + const promptText = rawPromptText.replace(/^\$([a-zA-Z][\w:.-]*)/, "/skill:$1"); + runFork( + Effect.tryPromise({ + try: () => context.piSession.prompt(promptText), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/prompt", + detail: toMessage(cause, "Failed to send prompt to Pi Agent."), + }), + }).pipe(Effect.catch(() => completeTurn(context, "failed", "Prompt failed."))), + ); + } return { threadId: context.session.threadId, diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 15e2ef4a3c4..820f02b917b 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -4,6 +4,7 @@ import { type ServerProvider, type ServerProviderModel, type ServerProviderSkill, + type ServerProviderSlashCommand, ProviderDriverKind, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -450,11 +451,20 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function Effect.orElseSucceed(() => [] as ServerProviderSkill[]), ); + const slashCommands: ServerProviderSlashCommand[] = [ + { name: "compact", description: "Manually compact the session context" }, + ...skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: skill.description ?? `Run ${skill.displayName ?? skill.name} skill`, + })), + ]; + return buildServerProvider({ presentation: PI_PRESENTATION, enabled: piSettings.enabled, checkedAt, models: customOnlyModels, + slashCommands, skills, probe: { installed: true, From 1fb05e0e4af43d0f77469b67421b83dda6fbdd6e Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 18:52:57 -0700 Subject: [PATCH 05/12] perf(pi): cache and parallelize provider capabilities probe Version check and skill discovery now run in parallel via Effect.all and are cached with a 5-minute TTL using the same Cache.make pattern as ClaudeDriver, eliminating redundant subprocess spawns and filesystem scans on repeated snapshot refreshes. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Drivers/PiDriver.ts | 26 +++++++---- apps/server/src/provider/Layers/PiProvider.ts | 43 ++++++++++++++----- 2 files changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Drivers/PiDriver.ts b/apps/server/src/provider/Drivers/PiDriver.ts index 5c78266b5ff..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"; @@ -16,6 +17,7 @@ import { checkPiProviderStatus, enrichPiSnapshot, makePendingPiProvider, + probePiCapabilities, } from "../Layers/PiProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; @@ -30,12 +32,13 @@ import { 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, @@ -109,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, diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 820f02b917b..68aa51fb459 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -352,15 +352,43 @@ 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; +}; + +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] = yield* Effect.all( + [ + runPiCommand(piSettings, ["--version"], environment).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ), + discoverPiSkills(cwd, piSettings).pipe( + Effect.orElseSucceed(() => [] as ServerProviderSkill[]), + ), + ], + { concurrency: "unbounded" }, + ); + return { versionResult, skills }; +}); + +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); // Models are populated by the background `enrichPiSnapshot` pass via // `pi --list-models`; use an empty list here to avoid stale hardcoded data. @@ -387,10 +415,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 } = yield* resolveProbe(); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; @@ -447,10 +472,6 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } - const skills = yield* discoverPiSkills(cwd, piSettings).pipe( - Effect.orElseSucceed(() => [] as ServerProviderSkill[]), - ); - const slashCommands: ServerProviderSlashCommand[] = [ { name: "compact", description: "Manually compact the session context" }, ...skills.map((skill) => ({ From 61a4037f48429d1b9e28f9619c22612531613031 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:04:40 -0700 Subject: [PATCH 06/12] fix(pi): intercept /compact and translate $skill in RPC-based adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter was restructured to use RPC commands but the /compact interception and $skill→/skill: translation were not carried over. This re-adds both: /compact dispatches a compact RPC command directly, and $skillname references are rewritten to /skill:skillname for Pi's native skill expansion. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiAdapter.ts | 27 ++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index de0358dd0d7..493fb0fd717 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -1136,14 +1136,25 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( }); const rawPromptText = typeof input.input === "string" ? input.input : ""; - - yield* context - .writeCommand({ - type: "prompt", - message: rawPromptText, - ...(piImages.length > 0 ? { images: piImages } : {}), - }) - .pipe(Effect.catchDefect(() => completeTurn(context, "failed", "Failed to send prompt."))); + const trimmed = rawPromptText.trim(); + + 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, From 8e73cc3a78c3f0273440b7ce0ae0b601abe55822 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:17:34 -0700 Subject: [PATCH 07/12] feat(pi): discover slash commands via RPC probe during capabilities check Spawns a short-lived `pi --mode rpc` process during the capabilities probe and sends `get_commands` to discover extension commands, skill commands, and prompt templates that Pi knows about. These are surfaced as slash commands in the provider snapshot alongside the static /compact entry. Falls back gracefully if the probe times out or fails. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiProvider.ts | 115 ++++++++++++++++-- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 0a567c97b6c..182c384a664 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -14,6 +14,7 @@ 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"; @@ -21,6 +22,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { buildSelectOptionDescriptor, buildServerProvider, + collectStreamAsString, DEFAULT_TIMEOUT_MS, detailFromResult, isCommandMissingCause, @@ -240,6 +242,80 @@ 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"; +} + +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): { @@ -359,6 +435,7 @@ export type PiCapabilitiesProbe = { { readonly message: string } >; readonly skills: ReadonlyArray; + readonly rpcCommands: ReadonlyArray; }; export const probePiCapabilities = Effect.fn("probePiCapabilities")(function* ( @@ -370,7 +447,7 @@ export const probePiCapabilities = Effect.fn("probePiCapabilities")(function* ( never, ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | Path.Path > { - const [versionResult, skills] = yield* Effect.all( + const [versionResult, skills, rpcCommands] = yield* Effect.all( [ runPiCommand(piSettings, ["--version"], environment).pipe( Effect.timeoutOption(DEFAULT_TIMEOUT_MS), @@ -379,10 +456,15 @@ export const probePiCapabilities = Effect.fn("probePiCapabilities")(function* ( 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 }; + return { versionResult, skills, rpcCommands }; }); export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( @@ -416,7 +498,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } - const { versionResult: versionProbe, skills } = yield* resolveProbe(); + const { versionResult: versionProbe, skills, rpcCommands } = yield* resolveProbe(); if (Result.isFailure(versionProbe)) { const error = versionProbe.failure; @@ -473,13 +555,32 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function }); } + const seen = new Set(); const slashCommands: ServerProviderSlashCommand[] = [ { name: "compact", description: "Manually compact the session context" }, - ...skills.map((skill) => ({ - name: `skill:${skill.name}`, - description: skill.description ?? `Run ${skill.displayName ?? skill.name} skill`, - })), ]; + seen.add("compact"); + + for (const cmd of rpcCommands) { + if (!seen.has(cmd.name)) { + seen.add(cmd.name); + slashCommands.push({ + name: cmd.source === "skill" ? `skill:${cmd.name}` : cmd.name, + ...(cmd.description ? { description: cmd.description } : {}), + }); + } + } + + for (const skill of skills) { + const key = `skill:${skill.name}`; + if (!seen.has(key)) { + seen.add(key); + slashCommands.push({ + name: key, + description: skill.description ?? `Run ${skill.displayName ?? skill.name} skill`, + }); + } + } return buildServerProvider({ presentation: PI_PRESENTATION, From e879508a1a3551739615b95d8c1b754f7825d884 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:19:33 -0700 Subject: [PATCH 08/12] fix(pi): remove double skill: prefix on RPC-discovered slash commands The RPC get_commands response already returns names like "skill:lsf", so wrapping with another "skill:" produced "skill:skill:lsf". Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 182c384a664..a28926304ba 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -565,7 +565,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function if (!seen.has(cmd.name)) { seen.add(cmd.name); slashCommands.push({ - name: cmd.source === "skill" ? `skill:${cmd.name}` : cmd.name, + name: cmd.name, ...(cmd.description ? { description: cmd.description } : {}), }); } From 86dddd38b2ff4a4549f0b96c9252a985be0f8952 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:21:25 -0700 Subject: [PATCH 09/12] style(pi): run formatter on PiAdapter and PiProvider Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiAdapter.ts | 4 +--- apps/server/src/provider/Layers/PiProvider.ts | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts index 493fb0fd717..edc010b51d1 100644 --- a/apps/server/src/provider/Layers/PiAdapter.ts +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -1142,9 +1142,7 @@ export const makePiAdapter = Effect.fn("makePiAdapter")(function* ( const customInstructions = trimmed.slice("/compact".length).trim() || undefined; yield* context .writeCommand({ type: "compact", ...(customInstructions ? { customInstructions } : {}) }) - .pipe( - Effect.catchDefect(() => completeTurn(context, "failed", "Compaction failed.")), - ); + .pipe(Effect.catchDefect(() => completeTurn(context, "failed", "Compaction failed."))); } else { const promptText = rawPromptText.replace(/^\$([a-zA-Z][\w:.-]*)/, "/skill:$1"); yield* context diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index a28926304ba..aa6f66af65f 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -258,11 +258,7 @@ function parseRpcCommandsResponse(line: string): ReadonlyArray } catch { return null; } - if ( - msg["type"] === "response" && - msg["command"] === "get_commands" && - msg["success"] === true - ) { + 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( From 1e3c273e46ee45f436cdefffc56ffb6b97971735 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:25:48 -0700 Subject: [PATCH 10/12] fix(pi): exclude skill commands from slash menu to avoid broken invocations Skill commands sent via / without instructions cause Pi to hang because the LLM receives a skill block with no user query. Skills remain discoverable via the $ menu where users naturally add instructions. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiProvider.ts | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index aa6f66af65f..8d62db45a5b 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -558,6 +558,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function seen.add("compact"); for (const cmd of rpcCommands) { + if (cmd.source === "skill") continue; if (!seen.has(cmd.name)) { seen.add(cmd.name); slashCommands.push({ @@ -567,17 +568,6 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function } } - for (const skill of skills) { - const key = `skill:${skill.name}`; - if (!seen.has(key)) { - seen.add(key); - slashCommands.push({ - name: key, - description: skill.description ?? `Run ${skill.displayName ?? skill.name} skill`, - }); - } - } - return buildServerProvider({ presentation: PI_PRESENTATION, enabled: piSettings.enabled, From a601973dd577f7bdd161a2ff84284994db122be6 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:27:15 -0700 Subject: [PATCH 11/12] feat(pi): populate skills array from RPC-discovered skill commands The $ menu reads from the skills array which was only populated by filesystem scanning. RPC-discovered skill commands are now merged into the skills array so they appear in the $ menu where users can add instructions alongside the skill invocation. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiProvider.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 8d62db45a5b..3dc1dc1bf99 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -246,6 +246,7 @@ 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; @@ -557,8 +558,23 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function ]; seen.add("compact"); + const skillNames = new Set(skills.map((s) => s.name)); + const allSkills = [...skills]; + for (const cmd of rpcCommands) { - if (cmd.source === "skill") continue; + if (cmd.source === "skill") { + if (!skillNames.has(cmd.name)) { + skillNames.add(cmd.name); + allSkills.push({ + name: cmd.name, + path: cmd.sourceInfo?.path ?? cmd.name, + 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({ @@ -574,7 +590,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function checkedAt, models: customOnlyModels, slashCommands, - skills, + skills: allSkills, probe: { installed: true, version: parsedVersion, From 4cc8d9db43e9dffc93242ac6c04f44869e6c6954 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 19:29:05 -0700 Subject: [PATCH 12/12] fix(pi): strip skill: prefix from RPC skill names for display The RPC returns names like "skill:checkin" but the frontend title-cases the name field, producing "Skill Checkin". Strip the prefix so skills display as just "Checkin", "Glab", etc. Co-Authored-By: Claude Opus 4.6 --- apps/server/src/provider/Layers/PiProvider.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Layers/PiProvider.ts b/apps/server/src/provider/Layers/PiProvider.ts index 3dc1dc1bf99..ff3c7d7763d 100644 --- a/apps/server/src/provider/Layers/PiProvider.ts +++ b/apps/server/src/provider/Layers/PiProvider.ts @@ -563,11 +563,12 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function for (const cmd of rpcCommands) { if (cmd.source === "skill") { - if (!skillNames.has(cmd.name)) { - skillNames.add(cmd.name); + const skillName = cmd.name.replace(/^skill:/, ""); + if (!skillNames.has(skillName)) { + skillNames.add(skillName); allSkills.push({ - name: cmd.name, - path: cmd.sourceInfo?.path ?? cmd.name, + name: skillName, + path: cmd.sourceInfo?.path ?? skillName, enabled: true, ...(cmd.sourceInfo?.scope ? { scope: cmd.sourceInfo.scope } : {}), ...(cmd.description ? { description: cmd.description } : {}),