diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index 5ebaaf9d..7cc07130 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -109,6 +109,9 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => Effect.gen(function* () { const createdAt = nowIso(); const provider = harness.adapterHarness?.provider ?? "codex"; + if (provider === "pi") { + throw new Error("Pi integration tests require an explicit model selection."); + } const defaultModel = DEFAULT_MODEL_BY_PROVIDER[provider]; yield* harness.engine.dispatch({ diff --git a/apps/server/package.json b/apps/server/package.json index 9c944c1a..20035e80 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -24,6 +24,9 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.111", + "@earendil-works/pi-agent-core": "^0.74.0", + "@earendil-works/pi-ai": "^0.74.0", + "@earendil-works/pi-coding-agent": "^0.74.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@opencode-ai/sdk": "^1.14.48", diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index dc5e2ffa..f5deb2d8 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -2043,6 +2043,39 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect("explains local changes that block pull", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); + + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(clone, "README.md"), "remote change\n"); + yield* git(clone, ["add", "README.md"]); + yield* git(clone, ["commit", "-m", "remote update"]); + yield* git(clone, ["push", "origin", initialBranch]); + + yield* writeTextFile(path.join(source, "README.md"), "local change\n"); + + const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(source)); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure.detail).toContain("Local changes block pull"); + expect(result.failure.detail).toContain("README.md"); + } + }), + ); + it.effect("lists branches when recency lookup fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 2ece8379..c424deca 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -376,7 +376,7 @@ function createGitCommandError( const DIRTY_WORKTREE_PATTERN = /Your local changes to the following files would be overwritten by (?:checkout|merge):\s*([\s\S]*?)Please commit your changes or stash them/; const UNTRACKED_OVERWRITE_PATTERN = - /The following untracked working tree files would be overwritten by checkout:\s*([\s\S]*?)Please move or remove them/; + /The following untracked working tree files would be overwritten by (?:checkout|merge):\s*([\s\S]*?)Please move or remove them/; function parseDirtyWorktreeFiles(stderr: string): string[] | null { const match = DIRTY_WORKTREE_PATTERN.exec(stderr) ?? UNTRACKED_OVERWRITE_PATTERN.exec(stderr); @@ -388,6 +388,13 @@ function parseDirtyWorktreeFiles(stderr: string): string[] | null { return files.length > 0 ? files : null; } +function explainPullBlockedByLocalChanges(error: GitCommandError): string | null { + const files = parseDirtyWorktreeFiles(error.detail); + if (!files) return null; + const fileList = files.map((file) => ` - ${file}`).join("\n"); + return `Local changes block pull. Commit or stash these files first:\n${fileList}`; +} + function parseNonEmptyLineList(input: string): string[] { return input .trim() @@ -1709,7 +1716,19 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { timeoutMs: 30_000, fallbackErrorMessage: "git pull failed", - }); + }).pipe( + Effect.mapError((error) => { + const friendlyDetail = explainPullBlockedByLocalChanges(error); + if (!friendlyDetail) return error; + return createGitCommandError( + "GitCore.pullCurrentBranch.pull", + cwd, + ["pull", "--ff-only"], + friendlyDetail, + error, + ); + }), + ); const afterSha = yield* runGitStdout( "GitCore.pullCurrentBranch.afterSha", cwd, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts index 73aedaa0..f18e76f9 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -1,8 +1,9 @@ import type { GitStatusResult, GitStatusStreamEvent } from "@t3tools/contracts"; import { Deferred, Effect, Layer, Scope, Stream } from "effect"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { GitManagerServiceError } from "../Errors"; +import { GitCore, type GitCoreShape, type GitStatusDetails } from "../Services/GitCore"; import { GitManager, type GitManagerShape } from "../Services/GitManager"; import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster"; import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster"; @@ -17,7 +18,29 @@ const baseStatus: GitStatusResult = { pr: null, }; -function makeTestLayer(state: { currentStatus: GitStatusResult; statusCalls: number }) { +const baseDetails: GitStatusDetails = { + branch: baseStatus.branch, + upstreamRef: "origin/feature/status-broadcast", + hasWorkingTreeChanges: baseStatus.hasWorkingTreeChanges, + workingTree: baseStatus.workingTree, + hasUpstream: baseStatus.hasUpstream, + aheadCount: baseStatus.aheadCount, + behindCount: baseStatus.behindCount, +}; + +function makeTestLayer(state: { + currentDetails: GitStatusDetails; + currentStatus: GitStatusResult; + detailsCalls: number; + statusCalls: number; +}) { + const gitCore = { + statusDetails: () => + Effect.sync(() => { + state.detailsCalls += 1; + return state.currentDetails; + }), + } as unknown as GitCoreShape; const gitManager: GitManagerShape = { status: () => Effect.sync(() => { @@ -33,17 +56,35 @@ function makeTestLayer(state: { currentStatus: GitStatusResult; statusCalls: num runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), }; - return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager))); + return GitStatusBroadcasterLive.pipe( + Layer.provide( + Layer.mergeAll(Layer.succeed(GitCore, gitCore), Layer.succeed(GitManager, gitManager)), + ), + ); } const runBroadcasterTest = ( - state: { currentStatus: GitStatusResult; statusCalls: number }, + state: { + currentDetails: GitStatusDetails; + currentStatus: GitStatusResult; + detailsCalls: number; + statusCalls: number; + }, effect: Effect.Effect, ) => effect.pipe(Effect.provide(makeTestLayer(state)), Effect.scoped, Effect.runPromise); +afterEach(() => { + vi.useRealTimers(); +}); + describe("GitStatusBroadcasterLive", () => { - it("reuses the cached git status across repeated reads", async () => { - const state = { currentStatus: baseStatus, statusCalls: 0 }; + it("refreshes local git status on repeated reads without repeating PR lookup", async () => { + const state = { + currentDetails: baseDetails, + currentStatus: baseStatus, + detailsCalls: 0, + statusCalls: 0, + }; await runBroadcasterTest( state, @@ -51,17 +92,110 @@ describe("GitStatusBroadcasterLive", () => { const broadcaster = yield* GitStatusBroadcaster; const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + state.currentDetails = { + ...baseDetails, + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "src/app.ts", insertions: 5, deletions: 1 }], + insertions: 5, + deletions: 1, + }, + }; const second = yield* broadcaster.getStatus({ cwd: "/repo" }); expect(first).toEqual(baseStatus); - expect(second).toEqual(baseStatus); + expect(second).toEqual({ + ...baseStatus, + hasWorkingTreeChanges: true, + workingTree: state.currentDetails.workingTree, + }); expect(state.statusCalls).toBe(1); + expect(state.detailsCalls).toBe(1); + }), + ); + }); + + it("refreshes full status when cached remote metadata expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const state = { + currentDetails: baseDetails, + currentStatus: baseStatus, + detailsCalls: 0, + statusCalls: 0, + }; + + await runBroadcasterTest( + state, + Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + + const first = yield* broadcaster.getStatus({ cwd: "/repo" }); + vi.setSystemTime(31_000); + state.currentStatus = { + ...baseStatus, + pr: { + number: 42, + title: "Open PR", + url: "https://github.com/acme/repo/pull/42", + state: "open", + }, + }; + const second = yield* broadcaster.getStatus({ cwd: "/repo" }); + + expect(first.pr).toBeNull(); + expect(second.pr?.number).toBe(42); + expect(state.statusCalls).toBe(2); + expect(state.detailsCalls).toBe(1); + }), + ); + }); + + it("does not extend the remote metadata TTL when reusing cached remote status", async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const state = { + currentDetails: baseDetails, + currentStatus: baseStatus, + detailsCalls: 0, + statusCalls: 0, + }; + + await runBroadcasterTest( + state, + Effect.gen(function* () { + const broadcaster = yield* GitStatusBroadcaster; + + yield* broadcaster.getStatus({ cwd: "/repo" }); + vi.setSystemTime(20_000); + yield* broadcaster.getStatus({ cwd: "/repo" }); + + vi.setSystemTime(31_000); + state.currentStatus = { + ...baseStatus, + pr: { + number: 43, + title: "Fresh PR", + url: "https://github.com/acme/repo/pull/43", + state: "open", + }, + }; + const third = yield* broadcaster.getStatus({ cwd: "/repo" }); + + expect(third.pr?.number).toBe(43); + expect(state.statusCalls).toBe(2); + expect(state.detailsCalls).toBe(2); }), ); }); it("refreshes the cached snapshot after explicit invalidation", async () => { - const state = { currentStatus: baseStatus, statusCalls: 0 }; + const state = { + currentDetails: baseDetails, + currentStatus: baseStatus, + detailsCalls: 0, + statusCalls: 0, + }; await runBroadcasterTest( state, @@ -74,6 +208,11 @@ describe("GitStatusBroadcasterLive", () => { branch: "feature/updated-status", aheadCount: 2, }; + state.currentDetails = { + ...baseDetails, + branch: "feature/updated-status", + aheadCount: 2, + }; const refreshed = yield* broadcaster.refreshStatus("/repo"); const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); @@ -81,12 +220,18 @@ describe("GitStatusBroadcasterLive", () => { expect(refreshed).toEqual(state.currentStatus); expect(cached).toEqual(state.currentStatus); expect(state.statusCalls).toBe(2); + expect(state.detailsCalls).toBe(1); }), ); }); it("streams a status snapshot first and later refresh updates", async () => { - const state = { currentStatus: baseStatus, statusCalls: 0 }; + const state = { + currentDetails: baseDetails, + currentStatus: baseStatus, + detailsCalls: 0, + statusCalls: 0, + }; await runBroadcasterTest( state, diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.ts index ccb73251..e2fe87e2 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.ts @@ -9,27 +9,27 @@ import type { } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { GitCore } from "../Services/GitCore"; import { GitManager } from "../Services/GitManager"; import { GitStatusBroadcaster, type GitStatusBroadcasterShape, } from "../Services/GitStatusBroadcaster"; +import { + canReuseCachedRemoteStatus, + type CachedGitStatus, + makeCachedStatusValue, + splitLocalStatus, + splitLocalStatusDetails, + splitRemoteStatus, + splitRemoteStatusDetails, +} from "../gitStatusCache"; interface GitStatusChange { readonly cwd: string; readonly event: GitStatusStreamEvent; } -interface CachedValue { - readonly fingerprint: string; - readonly value: T; -} - -interface CachedGitStatus { - readonly local: CachedValue | null; - readonly remote: CachedValue | null; -} - function normalizeCwd(cwd: string): string { try { return realpathSync.native(cwd); @@ -38,30 +38,10 @@ function normalizeCwd(cwd: string): string { } } -function fingerprintStatusPart(status: unknown): string { - return JSON.stringify(status); -} - -function splitLocalStatus(status: GitStatusResult): GitStatusLocalResult { - return { - branch: status.branch, - hasWorkingTreeChanges: status.hasWorkingTreeChanges, - workingTree: status.workingTree, - }; -} - -function splitRemoteStatus(status: GitStatusResult): GitStatusRemoteResult { - return { - hasUpstream: status.hasUpstream, - aheadCount: status.aheadCount, - behindCount: status.behindCount, - pr: status.pr, - }; -} - export const GitStatusBroadcasterLive = Layer.effect( GitStatusBroadcaster, Effect.gen(function* () { + const gitCore = yield* GitCore; const gitManager = yield* GitManager; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), @@ -78,10 +58,7 @@ export const GitStatusBroadcasterLive = Layer.effect( options?: { readonly publish?: boolean }, ) => Effect.gen(function* () { - const nextLocal = { - fingerprint: fingerprintStatusPart(local), - value: local, - } satisfies CachedValue; + const nextLocal = makeCachedStatusValue(local); const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); @@ -105,10 +82,7 @@ export const GitStatusBroadcasterLive = Layer.effect( options?: { readonly publish?: boolean }, ) => Effect.gen(function* () { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; + const nextRemote = makeCachedStatusValue(remote); const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); @@ -139,7 +113,15 @@ export const GitStatusBroadcasterLive = Layer.effect( const normalizedCwd = normalizeCwd(input.cwd); const cached = yield* getCachedStatus(normalizedCwd); if (cached?.local && cached.remote) { - return mergeGitStatusParts(cached.local.value, cached.remote.value) as GitStatusResult; + const details = yield* gitCore.statusDetails(normalizedCwd); + if (canReuseCachedRemoteStatus({ cached, details })) { + const local = yield* updateCachedLocalStatus( + normalizedCwd, + splitLocalStatusDetails(details), + ); + const remote = splitRemoteStatusDetails(details, cached.remote.value); + return mergeGitStatusParts(local, remote) as GitStatusResult; + } } return yield* loadStatus(normalizedCwd); }); diff --git a/apps/server/src/git/gitStatusCache.ts b/apps/server/src/git/gitStatusCache.ts new file mode 100644 index 00000000..03cbd675 --- /dev/null +++ b/apps/server/src/git/gitStatusCache.ts @@ -0,0 +1,79 @@ +import type { + GitStatusLocalResult, + GitStatusRemoteResult, + GitStatusResult, +} from "@t3tools/contracts"; + +import type { GitStatusDetails } from "./Services/GitCore"; + +export interface CachedValue { + readonly fingerprint: string; + readonly updatedAt: number; + readonly value: T; +} + +export interface CachedGitStatus { + readonly local: CachedValue | null; + readonly remote: CachedValue | null; +} + +export const REMOTE_STATUS_CACHE_TTL_MS = 30_000; + +export function makeCachedStatusValue(value: T): CachedValue { + return { + fingerprint: JSON.stringify(value), + updatedAt: Date.now(), + value, + }; +} + +export function splitLocalStatus(status: GitStatusResult): GitStatusLocalResult { + return { + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + workingTree: status.workingTree, + }; +} + +export function splitLocalStatusDetails(status: GitStatusDetails): GitStatusLocalResult { + return { + branch: status.branch, + hasWorkingTreeChanges: status.hasWorkingTreeChanges, + workingTree: status.workingTree, + }; +} + +export function splitRemoteStatus(status: GitStatusResult): GitStatusRemoteResult { + return { + hasUpstream: status.hasUpstream, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pr: status.pr, + }; +} + +export function splitRemoteStatusDetails( + status: GitStatusDetails, + cachedRemote: GitStatusRemoteResult | null, +): GitStatusRemoteResult { + return { + hasUpstream: status.hasUpstream, + aheadCount: status.aheadCount, + behindCount: status.behindCount, + pr: cachedRemote?.pr ?? null, + }; +} + +export function canReuseCachedRemoteStatus(input: { + readonly cached: CachedGitStatus; + readonly details: GitStatusDetails; + readonly now?: number; + readonly ttlMs?: number; +}): boolean { + if (!input.cached.local || !input.cached.remote) return false; + if (input.details.branch !== input.cached.local.value.branch) return false; + return ( + (input.now ?? Date.now()) - input.cached.remote.updatedAt < + (input.ttlMs ?? REMOTE_STATUS_CACHE_TTL_MS) + ); +} diff --git a/apps/server/src/git/runtimeLayer.ts b/apps/server/src/git/runtimeLayer.ts index 15afbc95..1cce2d16 100644 --- a/apps/server/src/git/runtimeLayer.ts +++ b/apps/server/src/git/runtimeLayer.ts @@ -23,7 +23,7 @@ export const GitManagerLayerLive = GitManagerLive.pipe( ); export const GitStatusBroadcasterLayerLive = GitStatusBroadcasterLive.pipe( - Layer.provide(GitManagerLayerLive), + Layer.provide(Layer.mergeAll(GitCoreLive, GitManagerLayerLive)), ); export const GitLayerLive = Layer.mergeAll( diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 84d99369..23358ca4 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -25,6 +25,7 @@ import * as SqlitePersistence from "./persistence/Layers/Sqlite"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery"; import { ProviderHealthLive } from "./provider/Layers/ProviderHealth"; +import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper"; import { Server } from "./effectServer"; import { ServerLoggerLive } from "./serverLogger"; import { formatHostForUrl, isWildcardHost } from "./startupAccess"; @@ -223,16 +224,32 @@ const ServerConfigLive = (input: CliInput) => }), ); -const LayerLive = (input: CliInput) => - Layer.empty.pipe( - Layer.provideMerge(makeServerRuntimeServicesLayer()), - Layer.provideMerge(makeServerProviderLayer()), - Layer.provideMerge(ProviderHealthLive), +const LayerLive = (input: CliInput) => { + const runtimeServicesLayer = makeServerRuntimeServicesLayer(); + const providerLayer = makeServerProviderLayer(); + const providerHealthLayer = ProviderHealthLive.pipe( + // Provider health reads persisted provider settings while constructing its + // cache, so build it with the same runtime services layer exposed to Server. + Layer.provideMerge(runtimeServicesLayer), + ); + const providerSessionReaperLayer = ProviderSessionReaperLive.pipe( + // The reaper coordinates orchestration state with live provider sessions, + // so it belongs at the top level where both layers are available. + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(providerLayer), + ); + + return Layer.empty.pipe( + Layer.provideMerge(runtimeServicesLayer), + Layer.provideMerge(providerLayer), + Layer.provideMerge(providerHealthLayer), + Layer.provideMerge(providerSessionReaperLayer), Layer.provideMerge(SqlitePersistence.layerConfig), Layer.provideMerge(ServerLoggerLive), Layer.provideMerge(AnalyticsServiceLayerLive), Layer.provideMerge(ServerConfigLive(input)), ); +}; export const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1c91ca95..3faff191 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -168,6 +168,112 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { } }), ); + + it.effect("persists turn-start thread settings into projection rows", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-02-26T13:00:00.000Z"; + const turnRequestedAt = "2026-02-26T13:00:05.000Z"; + + yield* eventStore.append({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-turn-settings-project"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-turn-settings"), + occurredAt: createdAt, + commandId: CommandId.makeUnsafe("cmd-turn-settings-project"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-turn-settings-project"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-turn-settings"), + title: "Project", + workspaceRoot: "/tmp/project-turn-settings", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-turn-settings-thread"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-turn-settings"), + occurredAt: createdAt, + commandId: CommandId.makeUnsafe("cmd-turn-settings-thread"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-turn-settings-thread"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-turn-settings"), + projectId: ProjectId.makeUnsafe("project-turn-settings"), + title: "Thread", + modelSelection: { + provider: "pi", + model: "openai/gpt-5.1", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* eventStore.append({ + type: "thread.turn-start-requested", + eventId: EventId.makeUnsafe("evt-turn-settings-start"), + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-turn-settings"), + occurredAt: turnRequestedAt, + commandId: CommandId.makeUnsafe("cmd-turn-settings-start"), + causationEventId: null, + correlationId: CommandId.makeUnsafe("cmd-turn-settings-start"), + metadata: {}, + payload: { + threadId: ThreadId.makeUnsafe("thread-turn-settings"), + messageId: MessageId.makeUnsafe("message-turn-settings"), + modelSelection: { + provider: "pi", + model: "openai/gpt-5.5", + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: turnRequestedAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const rows = yield* sql<{ + readonly modelSelectionJson: string; + readonly runtimeMode: string; + readonly interactionMode: string; + readonly updatedAt: string; + }>` + SELECT + model_selection_json AS "modelSelectionJson", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + updated_at AS "updatedAt" + FROM projection_threads + WHERE thread_id = 'thread-turn-settings' + `; + + assert.equal(rows.length, 1); + assert.deepEqual(JSON.parse(rows[0]!.modelSelectionJson), { + provider: "pi", + model: "openai/gpt-5.5", + }); + assert.equal(rows[0]!.runtimeMode, "approval-required"); + assert.equal(rows[0]!.interactionMode, "default"); + assert.equal(rows[0]!.updatedAt, turnRequestedAt); + }), + ); }); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ee38ad64..743ba60e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -718,6 +718,28 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + case "thread.turn-start-requested": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + const modelSelectionPatch = + event.payload.modelSelection !== undefined && + event.payload.modelSelection.provider === existingRow.value.modelSelection.provider + ? { modelSelection: event.payload.modelSelection } + : {}; + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + ...modelSelectionPatch, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + updatedAt: event.payload.createdAt, + }); + return; + } + case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); const existingRow = yield* projectionThreadRepository.getById({ diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index b0b9f6d0..8e56f1ba 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -468,15 +468,17 @@ function runtimeEventToActivities( if (!message) { return []; } + const errorClass = asString(runtimePayloadRecord(event)?.class); return [ { id: event.eventId, createdAt: event.createdAt, tone: "error", kind: "runtime.error", - summary: "Runtime error", + summary: "Provider runtime error", payload: toActivityPayload({ - message: truncateDetail(message), + message: truncateDetail(message, 500), + ...(errorClass ? { class: errorClass } : {}), }), turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, @@ -779,15 +781,16 @@ function runtimeEventToActivities( } case "turn.completed": { + const state = runtimeTurnState(event); return [ { id: event.eventId, createdAt: event.createdAt, - tone: "info" as const, + tone: state === "failed" ? "error" : "info", kind: "turn.completed", - summary: "Turn completed", + summary: state === "failed" ? "Turn failed" : "Turn completed", payload: toActivityPayload({ - state: runtimeTurnState(event), + state, ...(typeof event.payload.totalCostUsd === "number" ? { totalCostUsd: event.payload.totalCostUsd } : {}), diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index c5832499..2f3af4ae 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -112,6 +112,73 @@ describe("orchestration projector", () => { ]); }); + it("updates thread settings from turn start events", async () => { + const createdAt = "2026-02-23T08:00:00.000Z"; + const turnRequestedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: createdAt, + commandId: "cmd-create", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "demo", + modelSelection: { + provider: "pi", + model: "openai/gpt-5.1", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const next = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.turn-start-requested", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: turnRequestedAt, + commandId: "cmd-turn-start", + payload: { + threadId: "thread-1", + messageId: "message-1", + modelSelection: { + provider: "pi", + model: "openai/gpt-5.5", + }, + runtimeMode: "approval-required", + interactionMode: "default", + createdAt: turnRequestedAt, + }, + }), + ), + ); + + expect(next.threads[0]?.modelSelection).toEqual({ + provider: "pi", + model: "openai/gpt-5.5", + }); + expect(next.threads[0]?.runtimeMode).toBe("approval-required"); + expect(next.threads[0]?.interactionMode).toBe("default"); + expect(next.threads[0]?.updatedAt).toBe(turnRequestedAt); + }); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = new Date().toISOString(); const model = createEmptyReadModel(now); @@ -155,7 +222,7 @@ describe("orchestration projector", () => { model, makeEvent({ sequence: 7, - type: "thread.turn-start-requested", + type: "thread.turn-interrupt-requested", aggregateKind: "thread", aggregateId: "thread-1", occurredAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0ddb9038..1efadf1c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -26,6 +26,7 @@ import { ThreadRevertedPayload, ThreadSessionSetPayload, ThreadTurnDiffCompletedPayload, + ThreadTurnStartRequestedPayload, } from "./Schemas.ts"; type ThreadPatch = Partial>; @@ -452,6 +453,35 @@ export function projectEvent( })), ); + case "thread.turn-start-requested": + return decodeForEvent( + ThreadTurnStartRequestedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => { + const thread = nextBase.threads.find((entry) => entry.id === payload.threadId); + if (!thread) { + return nextBase; + } + const modelSelectionPatch = + payload.modelSelection !== undefined && + payload.modelSelection.provider === thread.modelSelection.provider + ? { modelSelection: payload.modelSelection } + : {}; + return { + ...nextBase, + threads: updateThread(nextBase.threads, payload.threadId, { + ...modelSelectionPatch, + runtimeMode: payload.runtimeMode, + interactionMode: payload.interactionMode, + updatedAt: payload.createdAt, + }), + }; + }), + ); + case "thread.message-sent": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/modelSelectionCompatibility.test.ts b/apps/server/src/persistence/modelSelectionCompatibility.test.ts new file mode 100644 index 00000000..0a6e3ce0 --- /dev/null +++ b/apps/server/src/persistence/modelSelectionCompatibility.test.ts @@ -0,0 +1,23 @@ +import { assert, it } from "@effect/vitest"; + +import { normalizePersistedModelSelection } from "./modelSelectionCompatibility.ts"; + +it("preserves canonical Pi model selections", () => { + assert.deepEqual(normalizePersistedModelSelection({ provider: "pi", model: "openai/gpt-5.5" }), { + provider: "pi", + model: "openai/gpt-5.5", + }); +}); + +it("infers Pi from persisted instance labels", () => { + assert.deepEqual( + normalizePersistedModelSelection({ + instanceId: "local-pi-runtime-instance", + model: "openai/gpt-5.5", + }), + { + provider: "pi", + model: "openai/gpt-5.5", + }, + ); +}); diff --git a/apps/server/src/persistence/modelSelectionCompatibility.ts b/apps/server/src/persistence/modelSelectionCompatibility.ts index 7ccf308d..e9965c56 100644 --- a/apps/server/src/persistence/modelSelectionCompatibility.ts +++ b/apps/server/src/persistence/modelSelectionCompatibility.ts @@ -3,7 +3,7 @@ // Layer: Persistence compatibility helper // Exports: normalizeLegacyModelSelection, normalizePersistedModelSelection -type ModelProviderKind = "codex" | "claudeAgent" | "cursor" | "gemini" | "opencode"; +type ModelProviderKind = "codex" | "claudeAgent" | "cursor" | "gemini" | "opencode" | "pi"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -21,6 +21,9 @@ function readTrimmedString(record: Record, key: string): string // Imported instance ids may be runtime names rather than DP Code provider literals. function inferProviderFromLabel(label: string): ModelProviderKind | undefined { const lowerLabel = label.toLowerCase(); + if (/(^|[^a-z0-9])pi([^a-z0-9]|$)/u.test(lowerLabel)) { + return "pi"; + } if (lowerLabel.includes("opencode")) { return "opencode"; } @@ -45,7 +48,8 @@ function inferLegacyModelProvider(provider: unknown, model: string): ModelProvid provider === "claudeAgent" || provider === "cursor" || provider === "gemini" || - provider === "opencode" + provider === "opencode" || + provider === "pi" ) { return provider; } diff --git a/apps/server/src/provider/Layers/PiAdapter.ts b/apps/server/src/provider/Layers/PiAdapter.ts new file mode 100644 index 00000000..492ad42c --- /dev/null +++ b/apps/server/src/provider/Layers/PiAdapter.ts @@ -0,0 +1,1819 @@ +import crypto from "node:crypto"; +import path from "node:path"; + +import { + AuthStorage, + ModelRegistry, + SessionManager, + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, + getAgentDir, + type AgentSession as PiAgentSession, + type AgentSessionEvent, + type CreateAgentSessionRuntimeFactory, +} from "@earendil-works/pi-coding-agent"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import type { Api, ImageContent, Model, TextContent } from "@earendil-works/pi-ai"; +import { + type ChatAttachment, + EventId, + type ProviderComposerCapabilities, + type ProviderListCommandsResult, + type ProviderListModelsResult, + type ProviderListSkillsResult, + ProviderItemId, + type ProviderRuntimeEvent, + type ProviderSession, + RuntimeItemId, + ThreadId, + type ThreadTokenUsageSnapshot, + TurnId, +} from "@t3tools/contracts"; +import { Effect, FileSystem, Layer, Queue, Stream } from "effect"; + +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionClosedError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, +} from "../Errors.ts"; +import { PiAdapter, type PiAdapterShape } from "../Services/PiAdapter.ts"; +import type { ProviderThreadSnapshot } from "../Services/ProviderAdapter.ts"; +import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; + +const PROVIDER = "pi" as const; +const DEFAULT_PI_THINKING_LEVEL: ThinkingLevel = "medium"; +const PI_THINKING_OPTIONS: ReadonlyArray<{ + readonly value: ThinkingLevel; + readonly label: string; + readonly description: string; + readonly isDefault?: true; +}> = [ + { value: "off", label: "Off", description: "No extra reasoning" }, + { value: "minimal", label: "Minimal", description: "Light reasoning" }, + { value: "low", label: "Low", description: "Faster reasoning" }, + { value: "medium", label: "Medium", description: "Balanced reasoning", isDefault: true }, + { value: "high", label: "High", description: "Deeper reasoning" }, + { value: "xhigh", label: "Extra High", description: "Maximum reasoning" }, +]; + +type PiModelRegistry = Pick; + +interface PiSessionContext { + runtime: Awaited>; + modelRegistry: PiModelRegistry; + session: ProviderSession; + turns: PiStoredTurn[]; + activeTurnId: TurnId | undefined; + activeAssistantItemId: RuntimeItemId | undefined; + activeReasoningItemId: RuntimeItemId | undefined; + activeToolItems: Map; + stopped: boolean; + lastKnownTokenUsage: ThreadTokenUsageSnapshot | undefined; + unsubscribe: (() => void) | undefined; +} + +interface PiStoredTurn { + readonly id: TurnId; + readonly items: unknown[]; + leafId?: string | null; +} + +interface PiTrackedToolCall { + readonly toolCallId: string; + readonly toolName: string; + readonly args: unknown; + readonly itemId: RuntimeItemId; + readonly itemType: "command_execution" | "file_change" | "dynamic_tool_call" | "web_search"; +} + +export interface PiAdapterLiveOptions { + readonly nativeEventLogPath?: string; + readonly nativeEventLogger?: EventNdjsonLogger; +} + +function toMessage(cause: unknown, fallback: string): string { + if (cause instanceof Error && cause.message.trim().length > 0) { + return cause.message; + } + return fallback; +} + +function trimToUndefined(value: string | null | undefined): string | undefined { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed.length > 0 ? trimmed : undefined; +} + +function isPiThinkingLevel(value: string | null | undefined): value is ThinkingLevel { + return ( + value === "off" || + value === "minimal" || + value === "low" || + value === "medium" || + value === "high" || + value === "xhigh" + ); +} + +function normalizePiThinkingLevel(value: string | null | undefined): ThinkingLevel | undefined { + return isPiThinkingLevel(value) ? value : undefined; +} + +function parseModelReference( + modelId: string | null | undefined, +): { readonly provider?: string; readonly id: string } | undefined { + const trimmed = trimToUndefined(modelId); + if (!trimmed) { + return undefined; + } + if (trimmed.includes("/")) { + const [provider, ...rest] = trimmed.split("/"); + const id = rest.join("/"); + if (provider && id) { + return { provider, id }; + } + } + if (trimmed.includes(":")) { + const [provider, ...rest] = trimmed.split(":"); + const id = rest.join(":"); + if (provider && id) { + return { provider, id }; + } + } + return { id: trimmed }; +} + +function createProviderModelFallback( + registry: PiModelRegistry, + parsed: { readonly provider: string; readonly id: string }, +): Model | undefined { + const providerDefault = registry.getAll().find((model) => model.provider === parsed.provider); + if (!providerDefault) { + return undefined; + } + return { + id: parsed.id, + name: parsed.id, + api: providerDefault.api, + provider: parsed.provider, + baseUrl: providerDefault.baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + ...(providerDefault.compat ? { compat: providerDefault.compat } : {}), + }; +} + +function findModelInRegistry( + registry: PiModelRegistry, + modelId: string | null | undefined, +): Model | undefined { + const parsed = parseModelReference(modelId); + if (!parsed) { + return undefined; + } + if (parsed.provider) { + return ( + registry.find(parsed.provider, parsed.id) ?? + createProviderModelFallback(registry, { provider: parsed.provider, id: parsed.id }) + ); + } + return registry + .getAll() + .find((model) => model.id === parsed.id || `${model.provider}/${model.id}` === parsed.id); +} + +function extractResumeSessionFile(resumeCursor: unknown): string | undefined { + if (typeof resumeCursor === "string" && resumeCursor.trim().length > 0) { + return resumeCursor; + } + if (!resumeCursor || typeof resumeCursor !== "object") { + return undefined; + } + const record = resumeCursor as Record; + for (const key of ["sessionFile", "sessionFilePath", "nativeHandle", "path"]) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function getSessionFile(session: PiAgentSession): string | undefined { + return session.sessionFile ?? session.sessionManager.getSessionFile(); +} + +function makeSessionSnapshot(context: PiSessionContext): ProviderSession { + const resumeCursor = getSessionFile(context.runtime.session); + return { + provider: PROVIDER, + status: context.stopped ? "closed" : context.activeTurnId ? "running" : "ready", + runtimeMode: context.session.runtimeMode, + threadId: context.session.threadId, + createdAt: context.session.createdAt, + updatedAt: new Date().toISOString(), + ...(context.session.cwd ? { cwd: context.session.cwd } : {}), + ...(context.session.model ? { model: context.session.model } : {}), + ...(resumeCursor ? { resumeCursor } : {}), + ...(context.activeTurnId ? { activeTurnId: context.activeTurnId } : {}), + ...(context.session.lastError ? { lastError: context.session.lastError } : {}), + }; +} + +function normalizeTokenUsage( + stats: ReturnType, + contextWindow?: number | null, +): ThreadTokenUsageSnapshot | undefined { + const inputTokens = stats.tokens.input; + const cachedInputTokens = stats.tokens.cacheRead; + const outputTokens = stats.tokens.output; + const totalProcessedTokens = stats.tokens.total; + const contextUsage = stats.contextUsage; + const contextUsageWindow = + typeof contextUsage?.contextWindow === "number" && + Number.isFinite(contextUsage.contextWindow) && + contextUsage.contextWindow > 0 + ? Math.floor(contextUsage.contextWindow) + : undefined; + const fallbackWindow = + typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0 + ? Math.floor(contextWindow) + : undefined; + const maxTokens = contextUsageWindow ?? fallbackWindow; + const contextUsageTokens = + typeof contextUsage?.tokens === "number" && + Number.isFinite(contextUsage.tokens) && + contextUsage.tokens >= 0 + ? Math.round(contextUsage.tokens) + : undefined; + const usedPercent = + typeof contextUsage?.percent === "number" && Number.isFinite(contextUsage.percent) + ? Math.max(0, Math.min(100, contextUsage.percent)) + : undefined; + const usedTokensFromPercent = + contextUsageTokens === undefined && usedPercent !== undefined && maxTokens !== undefined + ? Math.round((usedPercent / 100) * maxTokens) + : undefined; + const usedTokens = + contextUsageTokens ?? + usedTokensFromPercent ?? + (contextUsage + ? 0 + : maxTokens !== undefined + ? Math.min(totalProcessedTokens, maxTokens) + : totalProcessedTokens); + if ( + usedTokens <= 0 && + inputTokens <= 0 && + cachedInputTokens <= 0 && + outputTokens <= 0 && + maxTokens === undefined && + usedPercent === undefined + ) { + return undefined; + } + return { + usedTokens, + ...(usedPercent !== undefined ? { usedPercent } : {}), + ...(totalProcessedTokens > usedTokens ? { totalProcessedTokens } : {}), + inputTokens, + cachedInputTokens, + outputTokens, + ...(maxTokens !== undefined ? { maxTokens } : {}), + lastUsedTokens: usedTokens, + lastInputTokens: inputTokens, + lastCachedInputTokens: cachedInputTokens, + lastOutputTokens: outputTokens, + }; +} + +function isPiReloadCommand(text: string): boolean { + return /^\/reload(?:\s|$)/iu.test(text.trim()); +} + +function classifyPiRuntimeError( + message: string, +): "provider_error" | "transport_error" | "permission_error" | "validation_error" | "unknown" { + const normalized = message.toLowerCase(); + if ( + normalized.includes("network") || + normalized.includes("connection") || + normalized.includes("timeout") || + normalized.includes("econn") || + normalized.includes("fetch failed") + ) { + return "transport_error"; + } + if ( + normalized.includes("api key") || + normalized.includes("auth") || + normalized.includes("unauthorized") || + normalized.includes("forbidden") || + normalized.includes("permission") + ) { + return "permission_error"; + } + if ( + normalized.includes("invalid") || + normalized.includes("validation") || + normalized.includes("not available") + ) { + return "validation_error"; + } + if ( + normalized.includes("rate limit") || + normalized.includes("quota") || + normalized.includes("usage limit") || + normalized.includes("overloaded") || + normalized.includes("provider") + ) { + return "provider_error"; + } + return "unknown"; +} + +function runtimeErrorDetail(cause: unknown): unknown { + if (cause instanceof Error) { + return { + name: cause.name, + message: cause.message, + ...(cause.stack ? { stack: cause.stack } : {}), + }; + } + return cause; +} + +function textFromContent(content: string | (TextContent | ImageContent)[]): string { + if (typeof content === "string") { + return content; + } + return content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n\n"); +} + +function toolRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function firstStringValue( + record: Record | undefined, + keys: readonly string[], +): string | undefined { + if (!record) return undefined; + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value; + } + } + return undefined; +} + +function textFromToolResult(result: unknown): string | undefined { + if (typeof result === "string") { + return result; + } + const record = toolRecord(result); + if (!record) { + return undefined; + } + const directText = firstStringValue(record, [ + "output", + "stdout", + "stderr", + "text", + "summary", + "message", + "error", + ]); + if (directText) { + return directText; + } + const content = Array.isArray(record.content) ? record.content : []; + const parts = content.flatMap((block) => { + const blockRecord = toolRecord(block); + return blockRecord?.type === "text" && typeof blockRecord.text === "string" + ? [blockRecord.text] + : []; + }); + return parts.length > 0 ? parts.join("\n") : undefined; +} + +function toolExitCode(result: unknown): number | null | undefined { + const record = toolRecord(result); + if (!record) return undefined; + const exitCode = record.exitCode; + if (typeof exitCode === "number" && Number.isFinite(exitCode)) return exitCode; + const code = record.code; + if (typeof code === "number" && Number.isFinite(code)) return code; + return null; +} + +function toolRawOutput(result: unknown): Record | undefined { + if (result === undefined) return undefined; + const text = textFromToolResult(result); + const exitCode = toolExitCode(result); + if (typeof result === "string") { + return { stdout: result, content: result }; + } + if (result === null) { + return {}; + } + const record = toolRecord(result); + if (!record) { + return text ? { stdout: text, content: text } : undefined; + } + return { + ...record, + ...(text ? { stdout: text, content: text } : {}), + ...(exitCode !== undefined ? { exitCode } : {}), + }; +} + +function toolPath(args: unknown): string | undefined { + return firstStringValue(toolRecord(args), ["path", "filePath", "file", "relativePath"]); +} + +function toolCommand(args: unknown): string | undefined { + return firstStringValue(toolRecord(args), ["command", "cmd"]); +} + +function toolSearchQuery(toolName: string, args: unknown): string | undefined { + const record = toolRecord(args); + if (!record) return undefined; + if (toolName === "grep" || toolName === "find") { + return firstStringValue(record, ["pattern", "query"]); + } + return firstStringValue(record, ["query", "pattern"]); +} + +function toolEditEntries(args: unknown): ReadonlyArray> | undefined { + const record = toolRecord(args); + if (!record) return undefined; + if (Array.isArray(record.edits)) { + return record.edits.flatMap((edit) => { + const editRecord = toolRecord(edit); + return editRecord ? [editRecord] : []; + }); + } + const oldText = firstStringValue(record, ["oldText", "old_string", "oldString"]); + const newText = firstStringValue(record, ["newText", "new_string", "newString"]); + if (oldText !== undefined || newText !== undefined) { + return [ + { + ...(oldText !== undefined ? { oldText } : {}), + ...(newText !== undefined ? { newText } : {}), + }, + ]; + } + return undefined; +} + +function toolItemType(toolName: string): PiTrackedToolCall["itemType"] { + switch (toolName) { + case "bash": + return "command_execution"; + case "edit": + case "write": + return "file_change"; + case "grep": + case "find": + return "web_search"; + default: + return "dynamic_tool_call"; + } +} + +function toolTitle(toolName: string, args: unknown): string { + const command = toolName === "bash" ? toolCommand(args) : undefined; + if (command) return command; + const filePath = toolPath(args); + if ( + filePath && + (toolName === "read" || toolName === "edit" || toolName === "write" || toolName === "ls") + ) { + return `${toolName} ${filePath}`; + } + const query = toolSearchQuery(toolName, args); + if (query && (toolName === "find" || toolName === "grep")) { + return `${toolName} ${query}`; + } + return toolName; +} + +function toolLifecycleData(input: { + toolCallId: string; + toolName: string; + args: unknown; + result?: unknown; + partialResult?: unknown; + isError?: boolean; +}): Record { + const { toolCallId, toolName, args } = input; + const rawOutput = toolRawOutput(input.result ?? input.partialResult); + const path = toolPath(args); + const query = toolSearchQuery(toolName, args); + const command = toolCommand(args); + const edits = toolEditEntries(args); + const content = toolRecord(args)?.content; + const outputDetails = toolRecord(rawOutput?.details); + const unifiedDiff = firstStringValue(outputDetails, ["diff"]); + const base: Record = { + toolCallId, + callId: toolCallId, + toolName, + name: toolName, + tool: toolName, + kind: toolName, + args, + input: args, + rawInput: args, + ...(rawOutput ? { rawOutput } : {}), + ...(input.partialResult !== undefined ? { partialResult: input.partialResult } : {}), + ...(input.result !== undefined ? { result: input.result } : {}), + ...(input.isError !== undefined ? { isError: input.isError } : {}), + }; + + switch (toolName) { + case "bash": + return { + ...base, + kind: "execute", + ...(command ? { command } : {}), + ...(rawOutput?.exitCode !== undefined ? { exitCode: rawOutput.exitCode } : {}), + }; + case "read": + return { + ...base, + kind: "read", + ...(path + ? { + path, + filePath: path, + files: [{ path }], + commandActions: [{ type: "read", name: "read", path }], + } + : {}), + }; + case "edit": + return { + ...base, + kind: "edit", + ...(path ? { path, filePath: path, files: [{ path }], changes: [{ path }] } : {}), + ...(edits ? { edits: edits.map((edit) => ({ ...edit, ...(path ? { path } : {}) })) } : {}), + ...(unifiedDiff ? { unifiedDiff } : {}), + }; + case "write": + return { + ...base, + kind: "write", + ...(path ? { path, filePath: path, files: [{ path }], changes: [{ path }] } : {}), + ...(typeof content === "string" ? { content } : {}), + }; + case "find": + return { + ...base, + kind: "search", + searchKind: "find", + ...(query ? { query } : {}), + ...(path ? { path } : {}), + ...(query || path + ? { commandActions: [{ type: "search", name: "find", query, path }] } + : {}), + }; + case "grep": + return { + ...base, + kind: "search", + searchKind: "grep", + ...(query ? { query } : {}), + ...(path ? { path } : {}), + ...(query || path + ? { commandActions: [{ type: "search", name: "grep", query, path }] } + : {}), + }; + case "ls": + return { + ...base, + kind: "listFiles", + ...(path + ? { + path, + query: path, + commandActions: [{ type: "listFiles", name: "ls", path }], + } + : {}), + }; + default: + return base; + } +} + +function mapMessageHistory(session: PiAgentSession): unknown[] { + const items: unknown[] = []; + const pendingTools = new Map(); + for (const message of session.messages) { + if (message.role === "user") { + const text = textFromContent(message.content); + if (text) items.push({ type: "user_message", text }); + continue; + } + if (message.role === "assistant") { + for (const content of message.content) { + if (content.type === "text" && content.text) { + items.push({ type: "assistant_message", text: content.text }); + continue; + } + if (content.type === "thinking" && content.thinking) { + items.push({ type: "reasoning", text: content.thinking }); + continue; + } + if (content.type === "toolCall") { + pendingTools.set(content.id, { toolName: content.name, args: content.arguments }); + items.push({ + type: "tool_call", + status: "started", + callId: content.id, + toolName: content.name, + itemType: toolItemType(content.name), + title: toolTitle(content.name, content.arguments), + args: content.arguments, + data: toolLifecycleData({ + toolCallId: content.id, + toolName: content.name, + args: content.arguments, + }), + }); + } + } + continue; + } + if (message.role === "toolResult") { + const pending = pendingTools.get(message.toolCallId); + pendingTools.delete(message.toolCallId); + const toolName = pending?.toolName ?? message.toolName; + const args = pending?.args; + const result = { content: message.content }; + items.push({ + type: "tool_call", + status: message.isError ? "failed" : "completed", + callId: message.toolCallId, + toolName, + itemType: toolItemType(toolName), + title: toolTitle(toolName, args), + output: textFromContent(message.content), + isError: message.isError, + data: toolLifecycleData({ + toolCallId: message.toolCallId, + toolName, + args, + result, + isError: message.isError, + }), + }); + } + } + return items; +} + +function makeAgentDir(agentDir: string | undefined): string { + return trimToUndefined(agentDir) ?? getAgentDir(); +} + +function extensionDisplayName(extension: { + readonly path: string; + readonly sourceInfo?: { readonly source?: string }; +}): string { + const source = trimToUndefined(extension.sourceInfo?.source); + if (source) return source; + const extensionPath = trimToUndefined(extension.path); + return extensionPath ? path.basename(extensionPath).replace(/\.(?:ts|js)$/u, "") : "extension"; +} + +const makePiAdapter = (options?: PiAdapterLiveOptions) => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const runtimeEventQueue = yield* Queue.unbounded(); + const sessions = new Map(); + const modelRegistries = new Map(); + const ownsNativeEventLogger = options?.nativeEventLogger === undefined; + const nativeEventLogger = + options?.nativeEventLogger ?? + (options?.nativeEventLogPath !== undefined + ? yield* makeEventNdjsonLogger(options.nativeEventLogPath, { stream: "native" }) + : undefined); + + const getModelRegistry = (agentDir: string): ModelRegistry => { + const existing = modelRegistries.get(agentDir); + if (existing) return existing; + const authStorage = AuthStorage.create(path.join(agentDir, "auth.json")); + const registry = ModelRegistry.create(authStorage, path.join(agentDir, "models.json")); + modelRegistries.set(agentDir, registry); + return registry; + }; + + const makeEventBase = ( + context: PiSessionContext, + options?: { readonly includeTurnId?: boolean }, + ) => ({ + eventId: EventId.makeUnsafe(crypto.randomUUID()), + provider: PROVIDER, + threadId: context.session.threadId, + createdAt: new Date().toISOString(), + ...(options?.includeTurnId !== false && context.activeTurnId + ? { turnId: context.activeTurnId } + : {}), + }); + + const offerRuntimeEvent = (event: ProviderRuntimeEvent) => { + Effect.runPromise(Queue.offer(runtimeEventQueue, event)).catch(() => undefined); + if (nativeEventLogger && event.raw) { + Effect.runPromise(nativeEventLogger.write(event.raw, event.threadId)).catch( + () => undefined, + ); + } + }; + + const offerRuntimeError = ( + context: PiSessionContext, + input: { + readonly message: string; + readonly cause?: unknown; + readonly method: string; + readonly messageType?: string; + }, + ) => { + offerRuntimeEvent({ + ...makeEventBase(context, { includeTurnId: false }), + type: "runtime.error", + payload: { + message: input.message, + class: classifyPiRuntimeError(input.message), + ...(input.cause !== undefined ? { detail: runtimeErrorDetail(input.cause) } : {}), + }, + raw: { + source: "pi.sdk.event", + method: input.method, + ...(input.messageType ? { messageType: input.messageType } : {}), + payload: input.cause ?? { message: input.message }, + }, + } satisfies ProviderRuntimeEvent); + }; + + const recordItem = (context: PiSessionContext, item: unknown) => { + const turn = context.activeTurnId + ? context.turns.find((candidate) => candidate.id === context.activeTurnId) + : context.turns.at(-1); + turn?.items.push(item); + }; + + const requireSession = Effect.fn("PiAdapter.requireSession")(function* (threadId: ThreadId) { + const context = sessions.get(threadId); + if (!context) { + return yield* new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId }); + } + if (context.stopped) { + return yield* new ProviderAdapterSessionClosedError({ provider: PROVIDER, threadId }); + } + return context; + }); + + const disposeSessionContext = async (context: PiSessionContext) => { + context.unsubscribe?.(); + context.unsubscribe = undefined; + context.stopped = true; + await context.runtime.dispose(); + }; + + const handleMessageUpdate = ( + context: PiSessionContext, + event: Extract, + ) => { + if (event.message.role !== "assistant") return; + const update = event.assistantMessageEvent; + if (update.type === "text_delta") { + if (!context.activeAssistantItemId) { + context.activeAssistantItemId = RuntimeItemId.makeUnsafe( + `pi-assistant-${crypto.randomUUID()}`, + ); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeAssistantItemId, + type: "item.started", + payload: { itemType: "assistant_message", status: "inProgress", title: "Assistant" }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + recordItem(context, { type: "assistant_message", delta: update.delta }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeAssistantItemId, + type: "content.delta", + payload: { + streamKind: "assistant_text", + delta: update.delta, + contentIndex: update.contentIndex, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + if (update.type === "thinking_delta") { + if (!context.activeReasoningItemId) { + context.activeReasoningItemId = RuntimeItemId.makeUnsafe( + `pi-reasoning-${crypto.randomUUID()}`, + ); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeReasoningItemId, + type: "item.started", + payload: { itemType: "reasoning", status: "inProgress", title: "Reasoning" }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + recordItem(context, { type: "reasoning", delta: update.delta }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeReasoningItemId, + type: "content.delta", + payload: { + streamKind: "reasoning_text", + delta: update.delta, + contentIndex: update.contentIndex, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + }; + + const handleSessionEvent = (context: PiSessionContext, event: AgentSessionEvent) => { + switch (event.type) { + case "agent_start": + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.state.changed", + payload: { state: "active" }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + case "turn_start": + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.started", + payload: { + ...(context.runtime.session.model + ? { + model: `${context.runtime.session.model.provider}/${context.runtime.session.model.id}`, + } + : {}), + effort: context.runtime.session.thinkingLevel, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + case "message_update": + handleMessageUpdate(context, event); + return; + case "tool_execution_start": { + const itemId = RuntimeItemId.makeUnsafe(`pi-tool-${event.toolCallId}`); + const tracked: PiTrackedToolCall = { + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + itemId, + itemType: toolItemType(event.toolName), + }; + context.activeToolItems.set(event.toolCallId, tracked); + const title = toolTitle(event.toolName, event.args); + recordItem(context, { + type: "tool_call", + status: "started", + toolName: event.toolName, + args: event.args, + }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId, + providerRefs: { providerItemId: ProviderItemId.makeUnsafe(event.toolCallId) }, + type: "item.started", + payload: { + itemType: tracked.itemType, + status: "inProgress", + title, + data: toolLifecycleData({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: event.args, + }), + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "tool_execution_update": { + const tracked = context.activeToolItems.get(event.toolCallId); + if (!tracked) return; + const detail = textFromToolResult(event.partialResult); + recordItem(context, { + type: "tool_call", + status: "updated", + toolName: event.toolName, + output: detail, + }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: tracked.itemId, + providerRefs: { providerItemId: ProviderItemId.makeUnsafe(event.toolCallId) }, + type: "item.updated", + payload: { + itemType: tracked.itemType, + status: "inProgress", + title: toolTitle(event.toolName, tracked.args), + ...(detail ? { detail } : {}), + data: toolLifecycleData({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: tracked.args, + partialResult: event.partialResult, + }), + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "tool_execution_end": { + const tracked = context.activeToolItems.get(event.toolCallId) ?? { + toolCallId: event.toolCallId, + toolName: event.toolName, + args: undefined, + itemId: RuntimeItemId.makeUnsafe(`pi-tool-${event.toolCallId}`), + itemType: toolItemType(event.toolName), + }; + context.activeToolItems.delete(event.toolCallId); + const detail = textFromToolResult(event.result); + recordItem(context, { + type: "tool_call", + status: event.isError ? "failed" : "completed", + toolName: event.toolName, + output: detail, + result: event.result, + }); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: tracked.itemId, + providerRefs: { providerItemId: ProviderItemId.makeUnsafe(event.toolCallId) }, + type: "item.completed", + payload: { + itemType: tracked.itemType, + status: event.isError ? "failed" : "completed", + title: toolTitle(event.toolName, tracked.args), + ...(detail ? { detail } : {}), + data: toolLifecycleData({ + toolCallId: event.toolCallId, + toolName: event.toolName, + args: tracked.args, + result: event.result, + isError: event.isError, + }), + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "compaction_start": { + const itemId = RuntimeItemId.makeUnsafe(`pi-compaction-${crypto.randomUUID()}`); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId, + type: "item.updated", + payload: { + itemType: "context_compaction", + status: "inProgress", + title: "Compacting context", + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "compaction_end": { + const itemId = RuntimeItemId.makeUnsafe(`pi-compaction-${crypto.randomUUID()}`); + offerRuntimeEvent({ + ...makeEventBase(context), + itemId, + type: "item.completed", + payload: { + itemType: "context_compaction", + status: event.aborted ? "failed" : "completed", + title: "Context compacted", + data: event, + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + return; + } + case "agent_end": { + const stats = context.runtime.session.getSessionStats(); + const usage = normalizeTokenUsage(stats, context.runtime.session.model?.contextWindow); + context.lastKnownTokenUsage = usage; + const turnId = context.activeTurnId; + const errorMessage = context.runtime.session.agent.state.errorMessage; + const leafId = context.runtime.session.sessionManager.getLeafId(); + const turn = turnId + ? context.turns.find((candidate) => candidate.id === turnId) + : undefined; + if (turn) turn.leafId = leafId; + if (context.activeAssistantItemId) { + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeAssistantItemId, + type: "item.completed", + payload: { + itemType: "assistant_message", + status: errorMessage ? "failed" : "completed", + title: "Assistant", + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + if (context.activeReasoningItemId) { + offerRuntimeEvent({ + ...makeEventBase(context), + itemId: context.activeReasoningItemId, + type: "item.completed", + payload: { + itemType: "reasoning", + status: errorMessage ? "failed" : "completed", + title: "Reasoning", + }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + if (usage) { + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.token-usage.updated", + payload: { usage }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + } + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: errorMessage + ? { state: "failed", stopReason: "error", errorMessage, usage: stats } + : { state: "completed", stopReason: null, usage: stats }, + raw: { source: "pi.sdk.event", messageType: event.type, payload: event }, + } satisfies ProviderRuntimeEvent); + if (errorMessage) { + offerRuntimeError(context, { + message: errorMessage, + method: "prompt", + messageType: event.type, + cause: event, + }); + } + context.activeTurnId = undefined; + context.activeAssistantItemId = undefined; + context.activeReasoningItemId = undefined; + context.session = makeSessionSnapshot(context); + return; + } + default: + return; + } + }; + + const createSdkRuntime = async (input: { + cwd: string; + agentDir: string; + sessionManager: SessionManager; + modelId?: string; + thinkingLevel?: ThinkingLevel; + }) => { + const registry = getModelRegistry(input.agentDir); + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ + cwd, + agentDir, + sessionManager, + sessionStartEvent, + }) => { + const services = await createAgentSessionServices({ + cwd, + agentDir, + modelRegistry: registry, + }); + const model = findModelInRegistry(services.modelRegistry, input.modelId); + if (input.modelId && !model) { + throw new Error( + `Pi model '${input.modelId}' is not available. Use a discovered model or a provider-qualified custom model slug like 'openai/gpt-5.5'.`, + ); + } + return { + ...(await createAgentSessionFromServices({ + services, + sessionManager, + ...(sessionStartEvent ? { sessionStartEvent } : {}), + ...(model ? { model } : {}), + thinkingLevel: input.thinkingLevel ?? DEFAULT_PI_THINKING_LEVEL, + })), + services, + diagnostics: services.diagnostics, + }; + }; + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: input.sessionManager.getCwd(), + agentDir: input.agentDir, + sessionManager: input.sessionManager, + }); + await runtime.session.bindExtensions({}); + return { runtime, modelRegistry: runtime.services.modelRegistry }; + }; + + const startSession: PiAdapterShape["startSession"] = (input) => + Effect.gen(function* () { + const cwd = trimToUndefined(input.cwd) ?? serverConfig.cwd; + const agentDir = makeAgentDir(input.providerOptions?.pi?.agentDir); + const sessionFile = extractResumeSessionFile(input.resumeCursor); + const sessionManager = sessionFile + ? SessionManager.open(sessionFile, undefined, cwd) + : SessionManager.create(cwd); + const modelId = + input.modelSelection?.provider === "pi" ? input.modelSelection.model : undefined; + const thinkingLevel = + input.modelSelection?.provider === "pi" + ? normalizePiThinkingLevel(input.modelSelection.options?.thinkingLevel) + : undefined; + const existingContext = sessions.get(input.threadId); + if (existingContext) { + sessions.delete(input.threadId); + yield* Effect.tryPromise({ + try: () => disposeSessionContext(existingContext), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/restart", + detail: toMessage(cause, "Failed to dispose previous Pi session."), + cause, + }), + }); + } + const { runtime, modelRegistry } = yield* Effect.tryPromise({ + try: () => + createSdkRuntime({ + cwd, + agentDir, + sessionManager, + ...(modelId ? { modelId } : {}), + ...(thinkingLevel ? { thinkingLevel } : {}), + }), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/start", + detail: toMessage(cause, "Failed to start Pi session."), + cause, + }), + }); + const now = new Date().toISOString(); + const model = runtime.session.model + ? `${runtime.session.model.provider}/${runtime.session.model.id}` + : modelId; + const resumeCursor = getSessionFile(runtime.session); + const session: ProviderSession = { + provider: PROVIDER, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + threadId: input.threadId, + createdAt: now, + updatedAt: now, + ...(model ? { model } : {}), + ...(resumeCursor ? { resumeCursor } : {}), + }; + const context: PiSessionContext = { + runtime, + modelRegistry, + session, + turns: [], + activeTurnId: undefined, + activeAssistantItemId: undefined, + activeReasoningItemId: undefined, + activeToolItems: new Map(), + stopped: false, + lastKnownTokenUsage: undefined, + unsubscribe: undefined, + }; + context.unsubscribe = runtime.session.subscribe((event) => + handleSessionEvent(context, event), + ); + sessions.set(input.threadId, context); + const loadedExtensions = runtime.session.resourceLoader.getExtensions().extensions; + if (loadedExtensions.length > 0) { + const extensionNames = loadedExtensions.map(extensionDisplayName); + offerRuntimeEvent({ + ...makeEventBase(context, { includeTurnId: false }), + type: "runtime.warning", + payload: { + message: + "Pi extensions are loaded, but DP Code does not yet support Pi extension UI APIs. Non-UI extension behavior should work, but extensions that call ctx.ui.* for prompts, widgets, confirmations, or status updates may not behave correctly.", + detail: { + extensionCount: loadedExtensions.length, + extensions: extensionNames, + }, + }, + raw: { + source: "pi.sdk.event", + method: "extension/ui-unsupported-warning", + payload: { extensionCount: loadedExtensions.length, extensions: extensionNames }, + }, + } satisfies ProviderRuntimeEvent); + } + offerRuntimeEvent({ + ...makeEventBase(context), + type: "session.started", + payload: { message: "Pi session started", resume: session.resumeCursor }, + } satisfies ProviderRuntimeEvent); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.started", + payload: { providerThreadId: runtime.session.sessionId }, + } satisfies ProviderRuntimeEvent); + const initialUsage = normalizeTokenUsage( + runtime.session.getSessionStats(), + runtime.session.model?.contextWindow, + ); + context.lastKnownTokenUsage = initialUsage; + if (initialUsage) { + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.token-usage.updated", + payload: { usage: initialUsage }, + } satisfies ProviderRuntimeEvent); + } + return session; + }); + + const buildPromptPayload = (input: { + readonly input?: string | undefined; + readonly attachments?: ReadonlyArray | undefined; + }) => + Effect.gen(function* () { + const text = input.input ?? ""; + const images = yield* Effect.forEach( + input.attachments ?? [], + (attachment) => + Effect.gen(function* () { + if (attachment.type !== "image" || !attachment.mimeType) return undefined; + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment, + }); + if (!attachmentPath) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "turn/start", + issue: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/start", + detail: toMessage(cause, "Failed to read attachment file."), + cause, + }), + ), + ); + return { + type: "image" as const, + data: Buffer.from(bytes).toString("base64"), + mimeType: attachment.mimeType, + }; + }), + { concurrency: 1 }, + ); + return { + text, + images: images.filter((image): image is ImageContent => image !== undefined), + }; + }); + + const sendTurn: PiAdapterShape["sendTurn"] = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + if (context.activeTurnId) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "A Pi turn is already active for this thread.", + }); + } + if (input.modelSelection?.provider === "pi") { + const model = findModelInRegistry(context.modelRegistry, input.modelSelection.model); + if (!model) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "model/set", + issue: `Pi model '${input.modelSelection.model}' is not available. Use a discovered model or a provider-qualified custom model slug like 'openai/gpt-5.5'.`, + }); + } + yield* Effect.tryPromise({ + try: () => context.runtime.session.setModel(model), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "model/set", + detail: toMessage(cause, "Failed to set Pi model."), + cause, + }), + }); + const thinkingLevel = normalizePiThinkingLevel( + input.modelSelection.options?.thinkingLevel, + ); + if (thinkingLevel) { + context.runtime.session.setThinkingLevel(thinkingLevel); + } + } + const payload = yield* buildPromptPayload(input); + const turnId = TurnId.makeUnsafe(crypto.randomUUID()); + context.activeTurnId = turnId; + context.turns.push({ id: turnId, items: [] }); + context.session = makeSessionSnapshot(context); + if (payload.images.length === 0 && isPiReloadCommand(payload.text)) { + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.started", + payload: { + ...(context.runtime.session.model + ? { + model: `${context.runtime.session.model.provider}/${context.runtime.session.model.id}`, + } + : {}), + effort: context.runtime.session.thinkingLevel, + }, + raw: { source: "pi.sdk.event", method: "reload", payload: { command: payload.text } }, + } satisfies ProviderRuntimeEvent); + yield* Effect.tryPromise({ + try: () => context.runtime.session.reload(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/reload", + detail: toMessage(cause, "Failed to reload Pi resources."), + cause, + }), + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const message = error.message; + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "failed", stopReason: "error", errorMessage: message }, + raw: { source: "pi.sdk.event", method: "reload", payload: error }, + } satisfies ProviderRuntimeEvent); + offerRuntimeError(context, { + message, + method: "session/reload", + cause: error, + }); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + return yield* Effect.fail(error); + }), + ), + ); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "completed", stopReason: "reload" }, + raw: { source: "pi.sdk.event", method: "reload", payload: { command: payload.text } }, + } satisfies ProviderRuntimeEvent); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + return { + threadId: input.threadId, + turnId, + resumeCursor: getSessionFile(context.runtime.session), + }; + } + void context.runtime.session + .prompt(payload.text, payload.images.length > 0 ? { images: payload.images } : undefined) + .catch((cause) => { + const message = toMessage(cause, "Pi turn failed."); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "failed", stopReason: "error", errorMessage: message }, + raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, + } satisfies ProviderRuntimeEvent); + offerRuntimeError(context, { message, method: "prompt", cause }); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + }); + return { + threadId: input.threadId, + turnId, + resumeCursor: getSessionFile(context.runtime.session), + }; + }); + + const steerTurn: NonNullable = (input) => + Effect.gen(function* () { + const context = yield* requireSession(input.threadId); + const payload = yield* buildPromptPayload(input); + const turnId = context.activeTurnId ?? TurnId.makeUnsafe(crypto.randomUUID()); + if (!context.activeTurnId) { + context.activeTurnId = turnId; + context.turns.push({ id: turnId, items: [] }); + } + if (context.runtime.session.isStreaming) { + yield* Effect.tryPromise({ + try: () => context.runtime.session.steer(payload.text, payload.images), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: toMessage(cause, "Failed to steer Pi turn."), + cause, + }), + }); + } else { + void context.runtime.session + .prompt( + payload.text, + payload.images.length > 0 ? { images: payload.images } : undefined, + ) + .catch((cause) => { + const message = toMessage(cause, "Pi turn failed."); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "turn.completed", + payload: { state: "failed", stopReason: "error", errorMessage: message }, + raw: { source: "pi.sdk.event", method: "prompt", payload: cause }, + } satisfies ProviderRuntimeEvent); + offerRuntimeError(context, { message, method: "prompt", cause }); + context.activeTurnId = undefined; + context.session = makeSessionSnapshot(context); + }); + } + return { + threadId: input.threadId, + turnId, + resumeCursor: getSessionFile(context.runtime.session), + }; + }); + + const interruptTurn: PiAdapterShape["interruptTurn"] = (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: () => context.runtime.session.abort(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/interrupt", + detail: toMessage(cause, "Failed to interrupt Pi turn."), + cause, + }), + }), + ), + Effect.asVoid, + ); + + const respondUnsupported = (threadId: ThreadId, method: string) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `Pi does not expose DP Code approval/user-input requests for thread ${threadId}.`, + }), + ); + + const stopSession: PiAdapterShape["stopSession"] = (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: () => disposeSessionContext(context), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/stop", + detail: toMessage(cause, "Failed to stop Pi session."), + cause, + }), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + context.stopped = true; + sessions.delete(threadId); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "thread.state.changed", + payload: { state: "closed", detail: { reason: "stopped" } }, + } satisfies ProviderRuntimeEvent); + offerRuntimeEvent({ + ...makeEventBase(context), + type: "session.exited", + payload: { reason: "stopped", exitKind: "graceful" }, + } satisfies ProviderRuntimeEvent); + }), + ), + ), + ), + Effect.asVoid, + ); + + const listSessions: PiAdapterShape["listSessions"] = () => + Effect.sync(() => Array.from(sessions.values()).map(makeSessionSnapshot)); + + const hasSession: PiAdapterShape["hasSession"] = (threadId) => + Effect.sync(() => sessions.has(threadId)); + + const snapshotThread = (context: PiSessionContext): ProviderThreadSnapshot => { + const historyItems = mapMessageHistory(context.runtime.session); + const activeTurn = context.activeTurnId + ? context.turns.find((turn) => turn.id === context.activeTurnId) + : undefined; + const turns = [ + ...(historyItems.length > 0 + ? [ + { + id: TurnId.makeUnsafe(`pi-history-${context.runtime.session.sessionId}`), + items: historyItems, + }, + ] + : []), + ...(activeTurn ? [{ id: activeTurn.id, items: [...activeTurn.items] }] : []), + ]; + return { + threadId: context.session.threadId, + ...(context.session.cwd ? { cwd: context.session.cwd } : {}), + turns: + turns.length > 0 + ? turns + : context.turns.map((turn) => ({ id: turn.id, items: [...turn.items] })), + }; + }; + + const readThread: PiAdapterShape["readThread"] = (threadId) => + requireSession(threadId).pipe(Effect.map(snapshotThread)); + + const rollbackThread: PiAdapterShape["rollbackThread"] = (threadId, numTurns) => + Effect.gen(function* () { + const context = yield* requireSession(threadId); + const nextLength = Math.max(0, context.turns.length - Math.max(0, numTurns)); + context.turns.splice(nextLength); + const leafId = context.turns.at(-1)?.leafId; + if (leafId) { + context.runtime.session.sessionManager.branch(leafId); + } else if (nextLength === 0) { + context.runtime.session.sessionManager.resetLeaf(); + } + return snapshotThread(context); + }); + + const compactThread: NonNullable = (threadId) => + requireSession(threadId).pipe( + Effect.flatMap((context) => + Effect.tryPromise({ + try: () => context.runtime.session.compact(), + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "thread/compact", + detail: toMessage(cause, "Failed to compact Pi thread."), + cause, + }), + }), + ), + Effect.asVoid, + ); + + const stopAll: PiAdapterShape["stopAll"] = () => + Effect.forEach(Array.from(sessions.keys()), (threadId) => stopSession(threadId), { + concurrency: "unbounded", + discard: true, + }).pipe(Effect.asVoid); + + const listModels: NonNullable = (input) => + Effect.tryPromise({ + try: async () => { + const agentDir = makeAgentDir(input.agentDir); + const registry = getModelRegistry(agentDir); + registry.refresh(); + const models = registry.getAvailable().map((model) => ({ + slug: `${model.provider}/${model.id}`, + name: model.name, + upstreamProviderId: model.provider, + upstreamProviderName: registry.getProviderDisplayName(model.provider), + ...(model.reasoning + ? { + supportedReasoningEfforts: PI_THINKING_OPTIONS.map((option) => ({ + value: option.value, + label: option.label, + description: option.description, + })), + defaultReasoningEffort: DEFAULT_PI_THINKING_LEVEL, + } + : {}), + })); + return { models, source: "pi.sdk", cached: false } satisfies ProviderListModelsResult; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "model/list", + detail: toMessage(cause, "Failed to list Pi models."), + cause, + }), + }); + + const listSkills: NonNullable = (input) => + Effect.tryPromise({ + try: async () => { + const active = input.threadId + ? sessions.get(ThreadId.makeUnsafe(input.threadId)) + : undefined; + const loader = active?.runtime.session.resourceLoader; + if (active && input.forceReload) { + await active.runtime.session.reload(); + } + const services = loader + ? undefined + : await createAgentSessionServices({ + cwd: input.cwd, + agentDir: makeAgentDir(input.agentDir), + }); + if (services && input.forceReload) { + await services.resourceLoader.reload(); + } + const result = (loader ?? services!.resourceLoader).getSkills(); + return { + skills: result.skills.map((skill) => { + const description = trimToUndefined(skill.description); + const scope = trimToUndefined(skill.sourceInfo.source); + return { + name: skill.name, + ...(description ? { description } : {}), + path: skill.filePath, + enabled: !skill.disableModelInvocation, + ...(scope ? { scope } : {}), + }; + }), + source: "pi.sdk", + cached: false, + } satisfies ProviderListSkillsResult; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "skill/list", + detail: toMessage(cause, "Failed to list Pi skills."), + cause, + }), + }); + + const listCommands: NonNullable = (input) => + Effect.tryPromise({ + try: async () => { + const active = input.threadId + ? sessions.get(ThreadId.makeUnsafe(input.threadId)) + : undefined; + const session = active?.runtime.session; + const reloadCommand = { + name: "reload", + description: "Reload Pi extensions, skills, prompts, themes, tools, and settings", + }; + if (session) { + if (input.forceReload) { + await session.reload(); + } + const extensionCommands = session.extensionRunner + .getRegisteredCommands() + .map((command) => ({ + name: command.invocationName, + description: trimToUndefined(command.description) ?? "Extension command", + })); + const promptCommands = session.promptTemplates.map((template) => ({ + name: template.name, + description: trimToUndefined(template.description) ?? "Prompt template", + })); + const skillCommands = session.resourceLoader.getSkills().skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: trimToUndefined(skill.description) ?? "Skill", + })); + return { + commands: [reloadCommand, ...extensionCommands, ...promptCommands, ...skillCommands], + source: "pi.sdk", + cached: false, + } satisfies ProviderListCommandsResult; + } + const services = await createAgentSessionServices({ + cwd: input.cwd, + agentDir: makeAgentDir(input.agentDir), + }); + if (input.forceReload) { + await services.resourceLoader.reload(); + } + const promptCommands = services.resourceLoader.getPrompts().prompts.map((template) => ({ + name: template.name, + description: trimToUndefined(template.description) ?? "Prompt template", + })); + const skillCommands = services.resourceLoader.getSkills().skills.map((skill) => ({ + name: `skill:${skill.name}`, + description: trimToUndefined(skill.description) ?? "Skill", + })); + return { + commands: [reloadCommand, ...promptCommands, ...skillCommands], + source: "pi.sdk", + cached: false, + } satisfies ProviderListCommandsResult; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "command/list", + detail: toMessage(cause, "Failed to list Pi commands."), + cause, + }), + }); + + const getComposerCapabilities: NonNullable = () => + Effect.succeed({ + provider: PROVIDER, + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsNativeSlashCommandDiscovery: true, + supportsPluginMentions: false, + supportsPluginDiscovery: false, + supportsRuntimeModelList: true, + supportsThreadCompaction: true, + supportsThreadImport: false, + } satisfies ProviderComposerCapabilities); + + yield* Effect.addFinalizer(() => + stopAll().pipe( + Effect.ignore, + Effect.andThen( + ownsNativeEventLogger && nativeEventLogger + ? nativeEventLogger.close().pipe(Effect.ignore) + : Effect.void, + ), + Effect.andThen(Queue.shutdown(runtimeEventQueue)), + ), + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "in-session", + supportsSkillMentions: true, + supportsSkillDiscovery: true, + supportsNativeSlashCommandDiscovery: true, + supportsPluginMentions: false, + supportsPluginDiscovery: false, + supportsRuntimeModelList: true, + supportsTurnSteering: true, + }, + startSession, + sendTurn, + steerTurn, + interruptTurn, + respondToRequest: (threadId) => respondUnsupported(threadId, "request/respond"), + respondToUserInput: (threadId) => respondUnsupported(threadId, "user-input/respond"), + stopSession, + listSessions, + hasSession, + readThread, + rollbackThread, + compactThread, + stopAll, + listModels, + listSkills, + listCommands, + getComposerCapabilities, + get streamEvents() { + return Stream.fromQueue(runtimeEventQueue); + }, + } satisfies PiAdapterShape; + }); + +export const PiAdapterLive = Layer.effect(PiAdapter, makePiAdapter()); + +export function makePiAdapterLive(options?: PiAdapterLiveOptions) { + return Layer.effect(PiAdapter, makePiAdapter(options)); +} diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts index 37a23ebf..6ca33cd5 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts @@ -9,6 +9,7 @@ import { CodexAdapter, CodexAdapterShape } from "../Services/CodexAdapter.ts"; import { CursorAdapter, CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { GeminiAdapter, GeminiAdapterShape } from "../Services/GeminiAdapter.ts"; import { OpenCodeAdapter, OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { PiAdapter, PiAdapterShape } from "../Services/PiAdapter.ts"; import { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; import { ProviderAdapterRegistryLive } from "./ProviderAdapterRegistry.ts"; import { ProviderUnsupportedError } from "../Errors.ts"; @@ -99,6 +100,23 @@ const fakeOpenCodeAdapter: OpenCodeAdapterShape = { streamEvents: Stream.empty, }; +const fakePiAdapter: PiAdapterShape = { + provider: "pi", + capabilities: { sessionModelSwitch: "in-session" }, + startSession: vi.fn(), + sendTurn: vi.fn(), + interruptTurn: vi.fn(), + respondToRequest: vi.fn(), + respondToUserInput: vi.fn(), + stopSession: vi.fn(), + listSessions: vi.fn(), + hasSession: vi.fn(), + readThread: vi.fn(), + rollbackThread: vi.fn(), + stopAll: vi.fn(), + streamEvents: Stream.empty, +}; + const layer = it.layer( Layer.mergeAll( Layer.provide( @@ -109,6 +127,7 @@ const layer = it.layer( Layer.succeed(CursorAdapter, fakeCursorAdapter), Layer.succeed(GeminiAdapter, fakeGeminiAdapter), Layer.succeed(OpenCodeAdapter, fakeOpenCodeAdapter), + Layer.succeed(PiAdapter, fakePiAdapter), ), ), NodeServices.layer, @@ -124,14 +143,16 @@ layer("ProviderAdapterRegistryLive", (it) => { const cursor = yield* registry.getByProvider("cursor"); const gemini = yield* registry.getByProvider("gemini"); const opencode = yield* registry.getByProvider("opencode"); + const pi = yield* registry.getByProvider("pi"); assert.equal(codex, fakeCodexAdapter); assert.equal(claude, fakeClaudeAdapter); assert.equal(cursor, fakeCursorAdapter); assert.equal(gemini, fakeGeminiAdapter); assert.equal(opencode, fakeOpenCodeAdapter); + assert.equal(pi, fakePiAdapter); const providers = yield* registry.listProviders(); - assert.deepEqual(providers, ["codex", "claudeAgent", "cursor", "gemini", "opencode"]); + assert.deepEqual(providers, ["codex", "claudeAgent", "cursor", "gemini", "opencode", "pi"]); }), ); diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 8df5e976..fea71823 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -20,6 +20,7 @@ import { CodexAdapter } from "../Services/CodexAdapter.ts"; import { CursorAdapter } from "../Services/CursorAdapter.ts"; import { GeminiAdapter } from "../Services/GeminiAdapter.ts"; import { OpenCodeAdapter } from "../Services/OpenCodeAdapter.ts"; +import { PiAdapter } from "../Services/PiAdapter.ts"; export interface ProviderAdapterRegistryLiveOptions { readonly adapters?: ReadonlyArray>; @@ -36,6 +37,7 @@ const makeProviderAdapterRegistry = (options?: ProviderAdapterRegistryLiveOption yield* CursorAdapter, yield* GeminiAdapter, yield* OpenCodeAdapter, + yield* PiAdapter, ]; const byProvider = new Map(adapters.map((adapter) => [adapter.provider, adapter])); diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 6a24812b..14e0136e 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -9,6 +9,7 @@ * @module ProviderHealthLive */ import * as OS from "node:os"; +import * as nodePath from "node:path"; import type { ServerProviderAuthStatus, ServerProviderStatus, @@ -17,6 +18,7 @@ import type { import { parseCodexConfigModelProvider } from "@t3tools/shared/codexConfig"; import { decodeJsonResult } from "@t3tools/shared/schemaJson"; import { query as claudeQuery, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { Array, Cache, @@ -43,6 +45,7 @@ import { parseCodexCliVersion, } from "../codexCliVersion"; import { ServerConfig } from "../../config"; +import { ServerSettingsService } from "../../serverSettings"; import { isWindowsShellCommandMissingResult } from "../../shell-command-detection"; import { normalizeGeminiCapabilityProbeResult, probeGeminiCapabilities } from "../geminiAcpProbe"; import { ProviderHealth, type ProviderHealthShape } from "../Services/ProviderHealth"; @@ -59,6 +62,7 @@ const CLAUDE_AGENT_PROVIDER = "claudeAgent" as const; const CURSOR_PROVIDER = "cursor" as const; const GEMINI_PROVIDER = "gemini" as const; const OPENCODE_PROVIDER = "opencode" as const; +const PI_PROVIDER = "pi" as const; type ProviderStatuses = ReadonlyArray; // ── Pure helpers ──────────────────────────────────────────────────── @@ -1184,6 +1188,49 @@ export const checkOpenCodeProviderStatus: Effect.Effect< } satisfies ServerProviderStatus; }); +// ── Pi health check ───────────────────────────────────────────── + +export const checkPiProviderStatus = ( + agentDir?: string, +): Effect.Effect => + Effect.sync(() => { + const checkedAt = new Date().toISOString(); + try { + const trimmedAgentDir = nonEmptyTrimmed(agentDir); + const authStorage = trimmedAgentDir + ? AuthStorage.create(nodePath.join(trimmedAgentDir, "auth.json")) + : AuthStorage.create(); + const registry = trimmedAgentDir + ? ModelRegistry.create(authStorage, nodePath.join(trimmedAgentDir, "models.json")) + : ModelRegistry.create(authStorage); + registry.refresh(); + const modelCount = registry.getAvailable().length; + const authPath = trimmedAgentDir + ? nodePath.join(trimmedAgentDir, "auth.json") + : "~/.pi/agent/auth.json"; + return { + provider: PI_PROVIDER, + status: modelCount > 0 ? "ready" : "warning", + available: modelCount > 0, + authStatus: modelCount > 0 ? "authenticated" : "unknown", + checkedAt, + message: + modelCount > 0 + ? `Pi SDK is available with ${modelCount} authenticated model${modelCount === 1 ? "" : "s"}.` + : `Pi SDK is available, but no authenticated models were found in ${authPath}.`, + } satisfies ServerProviderStatus; + } catch (cause) { + return { + provider: PI_PROVIDER, + status: "error" as const, + available: false, + authStatus: "unknown" as const, + checkedAt, + message: `Failed to read Pi auth/model registry: ${cause instanceof Error ? cause.message : String(cause)}.`, + } satisfies ServerProviderStatus; + } + }); + // ── Cursor health check ───────────────────────────────────────────── export const checkCursorProviderStatus: Effect.Effect< @@ -1280,6 +1327,7 @@ export const ProviderHealthLive = Layer.effect( const path = yield* Path.Path; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; + const serverSettings = yield* ServerSettingsService; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded>(), PubSub.shutdown, @@ -1294,6 +1342,7 @@ export const ProviderHealthLive = Layer.effect( CURSOR_PROVIDER, GEMINI_PROVIDER, OPENCODE_PROVIDER, + PI_PROVIDER, ].map( (provider) => [ @@ -1313,6 +1362,7 @@ export const ProviderHealthLive = Layer.effect( CURSOR_PROVIDER, GEMINI_PROVIDER, OPENCODE_PROVIDER, + PI_PROVIDER, ] as const, (provider) => readProviderStatusCache(cachePathByProvider.get(provider)!).pipe( @@ -1345,17 +1395,22 @@ export const ProviderHealthLive = Layer.effect( const checkClaude = makeCheckClaudeProviderStatus(resolveClaudeSubscription); - const loadProviderStatuses = Effect.all( - [ - checkCodexProviderStatus, - checkClaude, - checkCursorProviderStatus, - checkGeminiProviderStatus, - checkOpenCodeProviderStatus, - ], - { - concurrency: "unbounded", - }, + const loadProviderStatuses = serverSettings.getSettings.pipe( + Effect.flatMap((settings) => + Effect.all( + [ + checkCodexProviderStatus, + checkClaude, + checkCursorProviderStatus, + checkGeminiProviderStatus, + checkOpenCodeProviderStatus, + checkPiProviderStatus(settings.providers.pi.agentDir), + ], + { + concurrency: "unbounded", + }, + ), + ), ).pipe( Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 0cd5e912..40e2b233 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -236,4 +236,34 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL fs.rmSync(tempDir, { recursive: true, force: true }); })); + + it("skips legacy bindings with unknown provider names when listing all bindings", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntimeRepository; + + const legacyThreadId = ThreadId.makeUnsafe("thread-legacy-provider"); + const codexThreadId = ThreadId.makeUnsafe("thread-known-provider"); + + yield* runtimeRepository.upsert({ + threadId: legacyThreadId, + providerName: "kilo", + adapterKey: "kilo", + runtimeMode: "full-access", + status: "running", + lastSeenAt: new Date().toISOString(), + resumeCursor: null, + runtimePayload: null, + }); + yield* directory.upsert({ + provider: "codex", + threadId: codexThreadId, + }); + + const bindings = yield* directory.listBindings(); + assert.deepEqual( + bindings.map((binding) => binding.threadId), + [codexThreadId], + ); + })); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index d0b7de64..9f3947c5 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -27,7 +27,8 @@ function decodeProviderKind( providerName === "claudeAgent" || providerName === "cursor" || providerName === "gemini" || - providerName === "opencode" + providerName === "opencode" || + providerName === "pi" ) { return Effect.succeed(providerName); } @@ -158,19 +159,29 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Effect.flatMap( Effect.forEach((row) => decodeProviderKind(row.providerName, "ProviderSessionDirectory.listBindings").pipe( - Effect.map((provider) => ({ - threadId: row.threadId, - provider, - adapterKey: row.adapterKey, - runtimeMode: row.runtimeMode, - status: row.status, - lastSeenAt: row.lastSeenAt, - resumeCursor: row.resumeCursor, - runtimePayload: row.runtimePayload, - })), + Effect.map((provider) => + Option.some({ + threadId: row.threadId, + provider, + adapterKey: row.adapterKey, + runtimeMode: row.runtimeMode, + status: row.status, + lastSeenAt: row.lastSeenAt, + resumeCursor: row.resumeCursor, + runtimePayload: row.runtimePayload, + }), + ), + Effect.catchTag("ProviderSessionDirectoryPersistenceError", (error) => + Effect.logDebug("provider session directory skipped unknown persisted provider", { + threadId: row.threadId, + providerName: row.providerName, + detail: error.detail, + }).pipe(Effect.as(Option.none())), + ), ), ), ), + Effect.map((bindings) => bindings.filter(Option.isSome).map((binding) => binding.value)), ); return { diff --git a/apps/server/src/provider/Services/PiAdapter.ts b/apps/server/src/provider/Services/PiAdapter.ts new file mode 100644 index 00000000..7eee8967 --- /dev/null +++ b/apps/server/src/provider/Services/PiAdapter.ts @@ -0,0 +1,20 @@ +/** + * PiAdapter - Pi direct SDK implementation of the generic provider adapter contract. + * + * Pi is intentionally treated as an unopinionated harness: DP Code does not add + * permissions or plan-mode semantics on top of it. + * + * @module PiAdapter + */ +import { ServiceMap } from "effect"; + +import type { ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "./ProviderAdapter.ts"; + +export interface PiAdapterShape extends ProviderAdapterShape { + readonly provider: "pi"; +} + +export class PiAdapter extends ServiceMap.Service()( + "t3/provider/Services/PiAdapter", +) {} diff --git a/apps/server/src/provider/providerStatusCache.ts b/apps/server/src/provider/providerStatusCache.ts index 5faa8cce..536573d6 100644 --- a/apps/server/src/provider/providerStatusCache.ts +++ b/apps/server/src/provider/providerStatusCache.ts @@ -15,6 +15,7 @@ const PROVIDER_STATUS_CACHE_IDS = [ "cursor", "gemini", "opencode", + "pi", ] as const satisfies ReadonlyArray; const decodeProviderStatusCache = Schema.decodeUnknownEffect( diff --git a/apps/server/src/provider/runtimeLayer.ts b/apps/server/src/provider/runtimeLayer.ts index c2ff76bc..445316d3 100644 --- a/apps/server/src/provider/runtimeLayer.ts +++ b/apps/server/src/provider/runtimeLayer.ts @@ -11,6 +11,7 @@ import { makeCursorAdapterLive } from "./Layers/CursorAdapter"; import { makeEventNdjsonLogger } from "./Layers/EventNdjsonLogger"; import { makeGeminiAdapterLive } from "./Layers/GeminiAdapter"; import { makeOpenCodeAdapterLive } from "./Layers/OpenCodeAdapter"; +import { makePiAdapterLive } from "./Layers/PiAdapter"; import { ProviderAdapterRegistryLive } from "./Layers/ProviderAdapterRegistry"; import { ProviderDiscoveryServiceLive } from "./Layers/ProviderDiscoveryService"; import { makeProviderServiceLive } from "./Layers/ProviderService"; @@ -61,12 +62,14 @@ export function makeServerProviderLayer(): Layer.Layer< {}, nativeEventLogger ? { nativeEventLogger } : undefined, ); + const piAdapterLayer = makePiAdapterLive(nativeEventLogger ? { nativeEventLogger } : undefined); const adapterRegistryLayer = ProviderAdapterRegistryLive.pipe( Layer.provide(codexAdapterLayer), Layer.provide(claudeAdapterLayer), Layer.provide(cursorAdapterLayer), Layer.provide(geminiAdapterLayer), Layer.provide(openCodeAdapterLayer), + Layer.provide(piAdapterLayer), Layer.provideMerge(providerSessionDirectoryLayer), ); const providerServiceLayer = makeProviderServiceLive( diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index 0679e26c..7ec19d8b 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -15,7 +15,6 @@ import { KeybindingsLive } from "./keybindings"; import { GitCoreLive } from "./git/Layers/GitCore"; import { GitLayerLive, TextGenerationLayerLive } from "./git/runtimeLayer"; import { TerminalLayerLive } from "./terminal/runtimeLayer"; -import { ProviderSessionReaperLive } from "./provider/Layers/ProviderSessionReaper"; import { AuthControlPlaneLive } from "./auth/Layers/AuthControlPlane"; import { BootstrapCredentialServiceLive } from "./auth/Layers/BootstrapCredentialService"; import { ServerAuthLive } from "./auth/Layers/ServerAuth"; @@ -65,9 +64,6 @@ export function makeServerRuntimeServicesLayer() { Layer.provideMerge(OrchestrationLayerLive), Layer.provideMerge(TerminalLayerLive), ); - const providerSessionReaperLayer = ProviderSessionReaperLive.pipe( - Layer.provideMerge(OrchestrationLayerLive), - ); const sessionCredentialLayer = SessionCredentialServiceLive.pipe( Layer.provide(ServerSecretStoreLive), ); @@ -93,7 +89,6 @@ export function makeServerRuntimeServicesLayer() { return Layer.mergeAll( orchestrationReactorLayer, threadDeletionReactorLayer, - providerSessionReaperLayer, GitLayerLive, TerminalLayerLive, KeybindingsLive, diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 7aa2f18d..db0084fb 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -9,7 +9,7 @@ import { DEFAULT_MODEL_BY_PROVIDER, DEFAULT_SERVER_SETTINGS, type ModelSelection, - type ProviderKind, + type ProviderWithDefaultModel, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -79,7 +79,12 @@ export class ServerSettingsService extends ServiceMap.Service< ); } -const PROVIDER_ORDER: readonly ProviderKind[] = ["codex", "claudeAgent", "gemini", "opencode"]; +const PROVIDER_ORDER: readonly ProviderWithDefaultModel[] = [ + "codex", + "claudeAgent", + "gemini", + "opencode", +]; function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings { const selection = settings.textGenerationModelSelection; diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 58ef328c..c6ac6c43 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -137,7 +137,14 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: ["galapagos-alpha"], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { + codex: ["galapagos-alpha"], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, "galapagos-alpha", ), ).toBe("galapagos-alpha"); @@ -147,7 +154,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "", ), ).toBe("gpt-5.5"); @@ -157,7 +164,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "GPT-5.3 Codex", ), ).toBe("gpt-5.3-codex"); @@ -167,7 +174,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "claudeAgent", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "sonnet", ), ).toBe("claude-sonnet-4-6"); @@ -177,7 +184,7 @@ describe("resolveAppModelSelection", () => { expect( resolveAppModelSelection( "codex", - { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [], pi: [] }, "custom/selected-model", ), ).toBe("custom/selected-model"); @@ -263,6 +270,8 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + piAgentDir: "", + piBinaryPath: "", }), ).toEqual({ claudeAgent: { @@ -293,6 +302,8 @@ describe("getProviderStartOptions", () => { openCodeBinaryPath: "", openCodeServerPassword: "", openCodeServerUrl: "", + piAgentDir: "", + piBinaryPath: "", }), ).toBeUndefined(); }); @@ -305,6 +316,7 @@ describe("provider-indexed custom model settings", () => { customCursorModels: ["cursor/custom-model"], customGeminiModels: ["gemini/custom-flash"], customOpenCodeModels: ["openrouter/gpt-oss-120b"], + customPiModels: ["anthropic/custom-pi"], } as const; it("exports one provider config per provider", () => { @@ -314,6 +326,7 @@ describe("provider-indexed custom model settings", () => { "cursor", "gemini", "opencode", + "pi", ]); }); @@ -323,6 +336,7 @@ describe("provider-indexed custom model settings", () => { expect(getCustomModelsForProvider(settings, "cursor")).toEqual(["cursor/custom-model"]); expect(getCustomModelsForProvider(settings, "gemini")).toEqual(["gemini/custom-flash"]); expect(getCustomModelsForProvider(settings, "opencode")).toEqual(["openrouter/gpt-oss-120b"]); + expect(getCustomModelsForProvider(settings, "pi")).toEqual(["anthropic/custom-pi"]); }); it("reads default custom models for each provider", () => { @@ -332,6 +346,7 @@ describe("provider-indexed custom model settings", () => { customCursorModels: ["cursor/default-model"], customGeminiModels: ["gemini/default-flash"], customOpenCodeModels: ["openai/gpt-5"], + customPiModels: ["anthropic/default-pi"], } as const; expect(getDefaultCustomModelsForProvider(defaults, "codex")).toEqual(["default/codex-model"]); @@ -341,6 +356,7 @@ describe("provider-indexed custom model settings", () => { expect(getDefaultCustomModelsForProvider(defaults, "cursor")).toEqual(["cursor/default-model"]); expect(getDefaultCustomModelsForProvider(defaults, "gemini")).toEqual(["gemini/default-flash"]); expect(getDefaultCustomModelsForProvider(defaults, "opencode")).toEqual(["openai/gpt-5"]); + expect(getDefaultCustomModelsForProvider(defaults, "pi")).toEqual(["anthropic/default-pi"]); }); it("patches custom models for codex", () => { @@ -373,6 +389,12 @@ describe("provider-indexed custom model settings", () => { }); }); + it("patches custom models for pi", () => { + expect(patchCustomModels("pi", ["anthropic/custom-pi"])).toEqual({ + customPiModels: ["anthropic/custom-pi"], + }); + }); + it("builds a complete provider-indexed custom model record", () => { expect(getCustomModelsByProvider(settings)).toEqual({ codex: ["custom/codex-model"], @@ -380,6 +402,7 @@ describe("provider-indexed custom model settings", () => { cursor: ["cursor/custom-model"], gemini: ["gemini/custom-flash"], opencode: ["openrouter/gpt-oss-120b"], + pi: ["anthropic/custom-pi"], }); }); @@ -401,6 +424,9 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.opencode.some((option) => option.slug === "openrouter/gpt-oss-120b"), ).toBe(true); + expect(modelOptionsByProvider.pi.some((option) => option.slug === "anthropic/custom-pi")).toBe( + true, + ); }); it("normalizes and deduplicates custom model options per provider", () => { @@ -414,6 +440,11 @@ describe("provider-indexed custom model settings", () => { "openrouter/gpt-oss-120b", "openrouter/gpt-oss-120b", ], + customPiModels: [ + " anthropic/claude-sonnet-4-5 ", + "anthropic/custom-pi", + "anthropic/custom-pi", + ], }); expect( @@ -438,6 +469,9 @@ describe("provider-indexed custom model settings", () => { expect( modelOptionsByProvider.opencode.filter((option) => option.slug === "openrouter/gpt-oss-120b"), ).toHaveLength(1); + expect( + modelOptionsByProvider.pi.filter((option) => option.slug === "anthropic/custom-pi"), + ).toHaveLength(1); }); }); @@ -470,6 +504,7 @@ describe("AppSettingsSchema", () => { customCursorModels: [], customGeminiModels: [], customOpenCodeModels: [], + customPiModels: [], }); }); }); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index ad2365e0..91b7af23 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -52,7 +52,8 @@ type CustomModelSettingsKey = | "customClaudeModels" | "customCursorModels" | "customGeminiModels" - | "customOpenCodeModels"; + | "customOpenCodeModels" + | "customPiModels"; export type ProviderCustomModelConfig = { provider: ProviderKind; settingsKey: CustomModelSettingsKey; @@ -69,6 +70,7 @@ const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record cursor: new Set(getModelOptions("cursor").map((option) => option.slug)), gemini: new Set(getModelOptions("gemini").map((option) => option.slug)), opencode: new Set(getModelOptions("opencode").map((option) => option.slug)), + pi: new Set(getModelOptions("pi").map((option) => option.slug)), }; const withDefaults = @@ -94,6 +96,8 @@ export const AppSettingsSchema = Schema.Struct({ cursorApiEndpoint: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), geminiBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openCodeBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + piBinaryPath: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), + piAgentDir: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openCodeServerUrl: Schema.String.check(Schema.isMaxLength(4096)).pipe(withDefaults(() => "")), openCodeServerPassword: Schema.String.check(Schema.isMaxLength(4096)).pipe( withDefaults(() => ""), @@ -120,6 +124,7 @@ export const AppSettingsSchema = Schema.Struct({ customCursorModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customGeminiModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), customOpenCodeModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), + customPiModels: Schema.Array(Schema.String).pipe(withDefaults(() => [])), textGenerationModel: Schema.optional(TrimmedNonEmptyString), uiFontFamily: Schema.String.check(Schema.isMaxLength(256)).pipe(withDefaults(() => "")), defaultProvider: ProviderKind.pipe(withDefaults(() => "codex" as const)), @@ -182,6 +187,15 @@ const PROVIDER_CUSTOM_MODEL_CONFIG: Record): Ser : {}), }; } + if ( + hasOwn(patch, "piAgentDir") || + hasOwn(patch, "piBinaryPath") || + hasOwn(patch, "customPiModels") + ) { + providers.pi = { + ...(hasOwn(patch, "piAgentDir") ? { agentDir: patch.piAgentDir ?? "" } : {}), + ...(hasOwn(patch, "piBinaryPath") ? { binaryPath: patch.piBinaryPath ?? "" } : {}), + ...(hasOwn(patch, "customPiModels") ? { customModels: patch.customPiModels ?? [] } : {}), + }; + } if (Object.keys(providers).length > 0) { serverPatch.providers = providers; @@ -371,6 +400,8 @@ function buildInitialServerSettingsMigrationPatch(settings: AppSettings): Server "openCodeBinaryPath", "openCodeServerPassword", "openCodeServerUrl", + "piAgentDir", + "piBinaryPath", "textGenerationModel", ] as const) { if (settings[key] !== defaults[key]) { @@ -384,6 +415,7 @@ function buildInitialServerSettingsMigrationPatch(settings: AppSettings): Server "customCursorModels", "customGeminiModels", "customOpenCodeModels", + "customPiModels", ] as const) { if (settings[key].length > 0) { patch[key] = settings[key] as never; @@ -429,6 +461,7 @@ export function getCustomModelsByProvider( cursor: getCustomModelsForProvider(settings, "cursor"), gemini: getCustomModelsForProvider(settings, "gemini"), opencode: getCustomModelsForProvider(settings, "opencode"), + pi: getCustomModelsForProvider(settings, "pi"), }; } @@ -514,7 +547,7 @@ export function resolveAppModelSelection( ): string { const customModelsForProvider = customModels[provider]; const options = getAppModelOptions(provider, customModelsForProvider, selectedModel); - return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider); + return resolveSelectableModel(provider, selectedModel, options) ?? getDefaultModel(provider) ?? ""; } export function getCustomModelOptionsByProvider( @@ -527,6 +560,7 @@ export function getCustomModelOptionsByProvider( cursor: getAppModelOptions("cursor", customModelsByProvider.cursor), gemini: getAppModelOptions("gemini", customModelsByProvider.gemini), opencode: getAppModelOptions("opencode", customModelsByProvider.opencode), + pi: getAppModelOptions("pi", customModelsByProvider.pi), }; } @@ -542,6 +576,8 @@ export function getProviderStartOptions( | "openCodeBinaryPath" | "openCodeServerPassword" | "openCodeServerUrl" + | "piAgentDir" + | "piBinaryPath" >, ): ProviderStartOptions | undefined { const providerOptions: ProviderStartOptions = { @@ -586,6 +622,14 @@ export function getProviderStartOptions( }, } : {}), + ...(settings.piBinaryPath || settings.piAgentDir + ? { + pi: { + ...(settings.piBinaryPath ? { binaryPath: settings.piBinaryPath } : {}), + ...(settings.piAgentDir ? { agentDir: settings.piAgentDir } : {}), + }, + } + : {}), }; return Object.keys(providerOptions).length > 0 ? providerOptions : undefined; @@ -599,6 +643,7 @@ export function getCustomBinaryPathForProvider( | "cursorBinaryPath" | "geminiBinaryPath" | "openCodeBinaryPath" + | "piBinaryPath" >, provider: ProviderKind, ): string { @@ -613,6 +658,8 @@ export function getCustomBinaryPathForProvider( return settings.geminiBinaryPath; case "opencode": return settings.openCodeBinaryPath; + case "pi": + return settings.piBinaryPath; } } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 035ed8de..fb28fc26 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -452,6 +452,8 @@ function getProviderStartOptionsCustomBinaryPath( return normalizeCustomBinaryPath(providerOptions?.opencode?.binaryPath); case "cursor": return normalizeCustomBinaryPath(providerOptions?.cursor?.binaryPath); + case "pi": + return normalizeCustomBinaryPath(providerOptions?.pi?.binaryPath); } } @@ -628,13 +630,17 @@ function mergeDynamicModelOptions(input: { } function skillMentionPrefix(provider: string): string { + if (provider === "pi") return "/skill:"; return provider === "claudeAgent" ? "/" : "$"; } function promptIncludesSkillMention(prompt: string, skillName: string, provider: string): boolean { - const prefix = escapeRegExp(skillMentionPrefix(provider)); - const pattern = new RegExp(`(^|\\s)${prefix}${escapeRegExp(skillName)}(?=\\s|$)`, "i"); - return pattern.test(prompt); + const escapedSkillName = escapeRegExp(skillName); + const prefixes = provider === "pi" ? ["/skill:"] : [skillMentionPrefix(provider)]; + return prefixes.some((prefix) => { + const pattern = new RegExp(`(^|\\s)${escapeRegExp(prefix)}${escapedSkillName}(?=\\s|$)`, "i"); + return pattern.test(prompt); + }); } const PROMPT_MENTION_NAME_REGEX = createComposerMentionTokenRegex({ @@ -1402,6 +1408,7 @@ export default function ChatView({ cursor: resolveHint("cursor"), gemini: resolveHint("gemini"), opencode: resolveHint("opencode"), + pi: resolveHint("pi"), }; }, [ activeProject?.defaultModelSelection, @@ -1433,6 +1440,14 @@ export default function ChatView({ binaryPath: settings.openCodeBinaryPath || null, }), ); + const piDynamicModelsQuery = useQuery( + providerModelsQueryOptions({ + provider: "pi", + binaryPath: settings.piBinaryPath || null, + agentDir: settings.piAgentDir || null, + enabled: selectedProvider === "pi" || lockedProvider === "pi" || isModelPickerOpen, + }), + ); const claudeDynamicAgentsQuery = useQuery( providerAgentsQueryOptions({ provider: "claudeAgent" }), ); @@ -1481,6 +1496,7 @@ export default function ChatView({ customModelsByProvider.opencode, composerModelHintByProvider.opencode, ), + pi: getAppModelOptions("pi", customModelsByProvider.pi, composerModelHintByProvider.pi), }; const result: Record< ProviderKind, @@ -1496,9 +1512,17 @@ export default function ChatView({ : { ...cursorDynamicModelsQuery.data, models: cursorRuntimeModels }, gemini: geminiModelsQuery.data, opencode: openCodeDynamicModelsQuery.data, + pi: piDynamicModelsQuery.data, }; - for (const provider of ["claudeAgent", "codex", "cursor", "gemini", "opencode"] as const) { + for (const provider of [ + "claudeAgent", + "codex", + "cursor", + "gemini", + "opencode", + "pi", + ] as const) { const dynamicModels = dynamicSources[provider]?.models; if (dynamicModels && dynamicModels.length > 0) { result[provider] = mergeDynamicModelOptions({ @@ -1528,6 +1552,7 @@ export default function ChatView({ customModelsByProvider, geminiModelsQuery.data, openCodeDynamicModelsQuery.data, + piDynamicModelsQuery.data, ]); const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadId, @@ -1544,6 +1569,7 @@ export default function ChatView({ cursor: cursorRuntimeModels, gemini: geminiModelsQuery.data?.models ?? [], opencode: openCodeDynamicModelsQuery.data?.models ?? [], + pi: piDynamicModelsQuery.data?.models ?? [], }), [ claudeDynamicModelsQuery.data?.models, @@ -1551,6 +1577,7 @@ export default function ChatView({ cursorRuntimeModels, geminiModelsQuery.data?.models, openCodeDynamicModelsQuery.data?.models, + piDynamicModelsQuery.data?.models, ], ); const providerModelsQueryByProvider = { @@ -1559,6 +1586,7 @@ export default function ChatView({ cursor: cursorDynamicModelsQuery, gemini: geminiModelsQuery, opencode: openCodeDynamicModelsQuery, + pi: piDynamicModelsQuery, } as const; const selectedRuntimeModel = useMemo( () => @@ -1582,12 +1610,28 @@ export default function ChatView({ ); const selectedPromptEffort = composerProviderState.promptEffort; const selectedModelOptionsForDispatch = composerProviderState.modelOptionsForDispatch; - const selectedModelSelection = useMemo( - () => buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch), - [selectedModel, selectedModelOptionsForDispatch, selectedProvider], - ); + const draftModelSelectionForSelectedProvider = + composerDraft.modelSelectionByProvider[selectedProvider] ?? null; + const selectedModelSelection = useMemo(() => { + if (selectedProvider === "pi" && draftModelSelectionForSelectedProvider?.provider === "pi") { + return buildModelSelection( + selectedProvider, + draftModelSelectionForSelectedProvider.model, + selectedModelOptionsForDispatch ?? draftModelSelectionForSelectedProvider.options, + ); + } + return buildModelSelection(selectedProvider, selectedModel, selectedModelOptionsForDispatch); + }, [ + draftModelSelectionForSelectedProvider, + selectedModel, + selectedModelOptionsForDispatch, + selectedProvider, + ]); const providerOptionsForDispatch = useMemo(() => getProviderStartOptions(settings), [settings]); - const selectedModelForPicker = selectedModel; + const selectedModelForPicker = + selectedModelSelection.provider === selectedProvider + ? selectedModelSelection.model + : selectedModel; const selectedModelForPickerWithCustomFallback = useMemo(() => { const currentOptions = modelOptionsByProvider[selectedProvider]; return currentOptions.some((option) => option.slug === selectedModelForPicker) @@ -1595,9 +1639,11 @@ export default function ChatView({ : (normalizeModelSlug(selectedModelForPicker, selectedProvider) ?? selectedModelForPicker); }, [modelOptionsByProvider, selectedModelForPicker, selectedProvider]); const persistedComposerModelSelection = - activeThread?.modelSelection ?? activeProject?.defaultModelSelection ?? null; - const draftModelSelectionForSelectedProvider = - composerDraft.modelSelectionByProvider[selectedProvider] ?? null; + sessionProvider && activeThread?.modelSelection.provider !== sessionProvider + ? activeProject?.defaultModelSelection?.provider === selectedProvider + ? activeProject.defaultModelSelection + : null + : (activeThread?.modelSelection ?? activeProject?.defaultModelSelection ?? null); const selectedProviderModelsQuery = providerModelsQueryByProvider[selectedProvider]; const providerModelsLoading = selectedProvider === "cursor" @@ -2242,6 +2288,7 @@ export default function ChatView({ provider: selectedProvider, cwd: composerSkillCwd, threadId, + agentDir: selectedProvider === "pi" ? settings.piAgentDir || null : null, query: composerTriggerKind === "slash-command" || composerTriggerKind === "slash-model" ? (composerTrigger?.query ?? "") @@ -2252,15 +2299,18 @@ export default function ChatView({ composerSkillCwd !== null, }), ); + const canDiscoverProviderSkills = + selectedProvider === "pi" || supportsSkillDiscovery(providerComposerCapabilitiesQuery.data); const providerSkillsQuery = useQuery( providerSkillsQueryOptions({ provider: selectedProvider, cwd: composerSkillCwd, threadId, + agentDir: selectedProvider === "pi" ? settings.piAgentDir || null : null, query: skillTriggerQuery, enabled: - isSkillTrigger && - supportsSkillDiscovery(providerComposerCapabilitiesQuery.data) && + (isSkillTrigger || selectedProvider === "pi") && + canDiscoverProviderSkills && composerSkillCwd !== null, }), ); @@ -5443,9 +5493,11 @@ export default function ChatView({ const title = buildPromptThreadTitleFallback(titleSeed); const threadCreateModelSelection: ModelSelection = buildModelSelection( selectedProviderForSend, - selectedModelForSend || - targetProjectDefaultModelSelectionForSend?.model || - DEFAULT_MODEL_BY_PROVIDER.codex, + selectedModelSelectionForSend.provider === selectedProviderForSend + ? selectedModelSelectionForSend.model + : selectedModelForSend || + targetProjectDefaultModelSelectionForSend?.model || + DEFAULT_MODEL_BY_PROVIDER.codex, selectedModelSelectionForSend.options, ); @@ -6859,7 +6911,12 @@ export default function ChatView({ providerPluginsQuery.isLoading || providerPluginsQuery.isFetching)) || (composerTriggerKind === "slash-command" && - (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching)); + (providerCommandsQuery.isLoading || providerCommandsQuery.isFetching)) || + (composerTriggerKind === "skill" && + (providerComposerCapabilitiesQuery.isLoading || + providerComposerCapabilitiesQuery.isFetching || + providerSkillsQuery.isLoading || + providerSkillsQuery.isFetching)); const onPromptChange = useCallback( ( @@ -7716,7 +7773,7 @@ export default function ChatView({ activeThreadId={activeThread.id} activeThreadTitle={activeThreadDisplayTitle} activeThreadEntryPoint={terminalState.entryPoint} - activeProvider={activeThread.modelSelection.provider} + activeProvider={activeThread.session?.provider ?? activeThread.modelSelection.provider} activeProjectName={activeProjectDisplayName} threadBreadcrumbs={threadBreadcrumbs} isSidechat={Boolean(activeThread.sidechatSourceThreadId)} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index 4d3fa6fb..4c6a95da 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -600,15 +600,15 @@ describe("when: on default branch without open PR", () => { }); describe("when: working tree has local changes and branch is behind upstream", () => { - it("resolveQuickAction still prefers commit, push, and create PR", () => { + it("resolveQuickAction automatically prefers pulling before commit or push flows", () => { const quick = resolveQuickAction( status({ hasWorkingTreeChanges: true, behindCount: 1 }), false, ); assert.deepInclude(quick, { - kind: "run_action", - action: "commit_push_pr", - label: "Commit, push & PR", + kind: "run_pull", + label: "Pull", + disabled: false, }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 998f8b43..f7627ceb 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -264,6 +264,25 @@ export function resolveQuickAction( }; } + if (gitStatus.hasUpstream) { + if (isDiverged) { + return { + label: "Sync branch", + disabled: true, + kind: "show_hint", + hint: "Branch has diverged from upstream. Rebase/merge first.", + }; + } + + if (isBehind) { + return { + label: "Pull", + disabled: false, + kind: "run_pull", + }; + } + } + if (hasChanges) { if (!gitStatus.hasUpstream && !hasOriginRemote) { return { label: "Commit", disabled: false, kind: "run_action", action: "commit" }; @@ -318,23 +337,6 @@ export function resolveQuickAction( }; } - if (isDiverged) { - return { - label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }; - } - - if (isBehind) { - return { - label: "Pull", - disabled: false, - kind: "run_pull", - }; - } - if (isAhead) { if (hasOpenPr || isDefaultBranch) { return { diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 2c60f718..94c69b13 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -294,6 +294,18 @@ export const AntigravityIcon: Icon = (props) => ( ); +export const PiIcon: Icon = (props) => ( + + + + +); + export const OpenCodeIcon: Icon = (props) => ( diff --git a/apps/web/src/components/PluginLibrary.tsx b/apps/web/src/components/PluginLibrary.tsx index 5f7b4385..a75233ec 100644 --- a/apps/web/src/components/PluginLibrary.tsx +++ b/apps/web/src/components/PluginLibrary.tsx @@ -26,7 +26,7 @@ import { SiStripe, SiVercel, } from "react-icons/si"; -import { ClaudeAI, CursorIcon, Gemini, OpenCodeIcon } from "./Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenCodeIcon, PiIcon } from "./Icons"; import { useStore } from "~/store"; import { buildPluginSearchBlob, @@ -82,6 +82,7 @@ const PROVIDER_ICON: Record cursor: CursorIcon, gemini: Gemini, opencode: OpenCodeIcon, + pi: PiIcon, }; const PROVIDER_DISCOVERY_ORDER: ReadonlyArray = [ "codex", @@ -89,6 +90,7 @@ const PROVIDER_DISCOVERY_ORDER: ReadonlyArray = [ "cursor", "gemini", "opencode", + "pi", ]; const KNOWN_PLUGIN_BRANDS: Record = { canva: { icon: SiCanva, color: "#00C4CC" }, @@ -390,6 +392,7 @@ export function PluginLibrary() { const cursorCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("cursor")); const geminiCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("gemini")); const openCodeCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("opencode")); + const piCapabilitiesQuery = useQuery(providerComposerCapabilitiesQueryOptions("pi")); const providerCapabilities = useMemo>( () => ({ @@ -413,6 +416,10 @@ export function PluginLibrary() { plugins: supportsPluginDiscovery(openCodeCapabilitiesQuery.data), skills: supportsSkillDiscovery(openCodeCapabilitiesQuery.data), }, + pi: { + plugins: supportsPluginDiscovery(piCapabilitiesQuery.data), + skills: supportsSkillDiscovery(piCapabilitiesQuery.data), + }, }), [ claudeCapabilitiesQuery.data, @@ -420,6 +427,7 @@ export function PluginLibrary() { cursorCapabilitiesQuery.data, geminiCapabilitiesQuery.data, openCodeCapabilitiesQuery.data, + piCapabilitiesQuery.data, ], ); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index bdc95abd..f41784f3 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -52,7 +52,6 @@ import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd- import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { - DEFAULT_MODEL_BY_PROVIDER, type DesktopUpdateState, type OrchestrationReadModel, PROVIDER_DISPLAY_NAMES, @@ -63,6 +62,7 @@ import { type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; import { isGenericChatThreadTitle } from "@t3tools/shared/chatThreads"; +import { getDefaultModel } from "@t3tools/shared/model"; import { resolveThreadWorkspaceCwd } from "@t3tools/shared/threadEnvironment"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useLocation, useNavigate, useParams, useSearch } from "@tanstack/react-router"; @@ -112,7 +112,7 @@ import { dispatchThreadRename } from "../lib/threadRename"; import { quotePosixShellArgument } from "../lib/shellQuote"; import { DEFAULT_THREAD_TERMINAL_ID, type SidebarThreadSummary, type Thread } from "../types"; import { shouldRenderTerminalWorkspace } from "./ChatView.logic"; -import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon } from "./Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon, PiIcon } from "./Icons"; import { AppNavigationButtons } from "./AppNavigationButtons"; import { ProjectSidebarIcon } from "./ProjectSidebarIcon"; import { ThreadPinToggleButton } from "./ThreadPinToggleButton"; @@ -370,6 +370,9 @@ function ProviderGlyph({ provider, className }: { provider: ProviderKind; classN ); } + if (provider === "pi") { + return ; + } return ; } function WorktreeBadgeGlyph({ className }: { className?: string }) { @@ -1880,7 +1883,7 @@ export default function Sidebar() { createWorkspaceRootIfMissing: options.createIfMissing === true, defaultModelSelection: { provider: "codex", - model: DEFAULT_MODEL_BY_PROVIDER.codex, + model: getDefaultModel("codex"), }, createdAt, }); @@ -2052,13 +2055,19 @@ export default function Sidebar() { throw new Error("The target project could not be resolved."); } + const providerDefaultModel = getDefaultModel(provider); const modelSelection = activeProject.defaultModelSelection?.provider === provider ? activeProject.defaultModelSelection - : { - provider, - model: DEFAULT_MODEL_BY_PROVIDER[provider], - }; + : providerDefaultModel + ? { + provider, + model: providerDefaultModel, + } + : null; + if (!modelSelection) { + throw new Error("Select a Pi model before importing a Pi thread."); + } const threadId = newThreadId(); const createdAt = new Date().toISOString(); const trimmedExternalId = externalId.trim(); @@ -4072,7 +4081,7 @@ export default function Sidebar() { ) : showThreadProviderAvatar ? ( ) : showThreadProviderAvatar ? ( ) : props.provider === "opencode" ? ( + ) : props.provider === "pi" ? ( + ) : ( )} diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index b18f9dba..09c259de 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -35,7 +35,7 @@ import { readNativeApi } from "~/nativeApi"; import { resolveEditorIcon } from "../../editorMetadata"; import { usePreferredEditor } from "../../editorPreferences"; import { useIsDisposableThread } from "~/hooks/useIsDisposableThread"; -import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon, PiIcon } from "../Icons"; import { gitWorkingTreeDiffQueryOptions } from "~/lib/gitReactQuery"; import { summarizePatchStats } from "~/lib/diffRendering"; @@ -201,6 +201,9 @@ export const ChatHeader = memo(function ChatHeader({ if (provider === "opencode") { return ; } + if (provider === "pi") { + return ; + } if (provider === "codex") { return ; } diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index f18e10fa..05d5da00 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -1,4 +1,5 @@ -import { DEFAULT_MODEL_BY_PROVIDER, ModelSelection, ThreadId } from "@t3tools/contracts"; +import { ModelSelection, ThreadId } from "@t3tools/contracts"; +import { getDefaultModel } from "@t3tools/shared/model"; import "../../index.css"; import { page } from "vitest/browser"; @@ -20,7 +21,7 @@ async function mountMenu(props?: { const draftsByThreadId = {} as ReturnType< typeof useComposerDraftStore.getState >["draftsByThreadId"]; - const model = props?.modelSelection?.model ?? DEFAULT_MODEL_BY_PROVIDER[provider]; + const model = props?.modelSelection?.model ?? getDefaultModel(provider) ?? getDefaultModel("codex"); draftsByThreadId[threadId] = { prompt: props?.prompt ?? "", diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 5dd5ae03..01580381 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -384,7 +384,9 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { {props.isLoading ? props.triggerKind === "mention" ? "Searching mentions..." - : "Loading commands..." + : props.triggerKind === "skill" + ? "Loading skills..." + : "Loading commands..." : (props.emptyStateText ?? (props.triggerKind === "mention" ? "No matching plugin or file." diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx index aca40bcd..67f32136 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.tsx +++ b/apps/web/src/components/chat/ContextWindowMeter.tsx @@ -92,6 +92,11 @@ export function ContextWindowMeter(props: { {display.tokenUsageLabel} tokens used so far )} + {usage.maxTokens !== null ? ( +
+ Model window: {formatContextWindowTokens(usage.maxTokens)} tokens +
+ ) : null} {pendingWindowLabel ? (
Next turn: {pendingWindowLabel}
) : null} diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 952fccba..89c9c15c 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -38,6 +38,14 @@ const MODEL_OPTIONS_BY_PROVIDER = { upstreamProviderName: "OpenAI", }, ], + pi: [ + { + slug: "anthropic/claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + upstreamProviderId: "anthropic", + upstreamProviderName: "Anthropic", + }, + ], } as const satisfies Record>; const MANY_OPENCODE_MODELS = Array.from({ length: 16 }, (_, index) => ({ @@ -84,6 +92,21 @@ const CURSOR_FAVORITE_SORT_MODELS = [ }, ] satisfies ReadonlyArray; +const PI_FAVORITE_SORT_MODELS = [ + { + slug: "anthropic/claude-pi-favorite-sort" as ModelSlug, + name: "Claude Pi Favorite Sort", + upstreamProviderId: "anthropic", + upstreamProviderName: "Anthropic", + }, + { + slug: "openai/gpt-pi-favorite-sort" as ModelSlug, + name: "GPT Pi Favorite Sort", + upstreamProviderId: "openai", + upstreamProviderName: "OpenAI", + }, +] satisfies ReadonlyArray; + async function mountPicker(props: { provider: ProviderKind; model: ModelSlug; @@ -376,6 +399,46 @@ describe("ProviderModelPicker", () => { } }); + it("shows favourited Pi models in their own top category", async () => { + const mounted = await mountPicker({ + provider: "pi", + model: "anthropic/claude-pi-favorite-sort", + lockedProvider: "pi", + modelOptionsByProvider: { + ...MODEL_OPTIONS_BY_PROVIDER, + pi: PI_FAVORITE_SORT_MODELS, + }, + }); + + try { + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text.indexOf("Anthropic")).toBeLessThan(text.indexOf("OpenAI")); + }); + + await page.getByRole("button", { name: "Add GPT Pi Favorite Sort to favourites" }).click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text.indexOf("Favourites")).toBeLessThan(text.indexOf("Anthropic")); + expect(text.indexOf("GPT Pi Favorite Sort")).toBeGreaterThan(text.indexOf("Favourites")); + expect(text.indexOf("GPT Pi Favorite Sort")).toBeLessThan(text.indexOf("Anthropic")); + }); + await expect + .element(page.getByRole("menuitemradio", { name: "GPT Pi Favorite Sort" })) + .toBeInTheDocument(); + expect( + Array.from(document.querySelectorAll('[role="menuitemradio"]')).filter((element) => + element.textContent?.includes("GPT Pi Favorite Sort"), + ), + ).toHaveLength(1); + } finally { + await mounted.cleanup(); + } + }); + it("shows a loading skeleton instead of fallback models for loading providers", async () => { const mounted = await mountPicker({ provider: "cursor", diff --git a/apps/web/src/components/chat/ProviderModelPicker.tsx b/apps/web/src/components/chat/ProviderModelPicker.tsx index 373ec271..024eb5b8 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.tsx @@ -23,7 +23,7 @@ import { MenuSubTrigger, MenuTrigger, } from "../ui/menu"; -import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { ClaudeAI, CursorIcon, Gemini, Icon, OpenAI, OpenCodeIcon, PiIcon } from "../Icons"; import { cn } from "~/lib/utils"; import { PickerPanelShell } from "./PickerPanelShell"; import { PickerTriggerButton } from "./PickerTriggerButton"; @@ -52,6 +52,7 @@ const PROVIDER_ICON_BY_PROVIDER: Record = { cursor: CursorIcon, gemini: Gemini, opencode: OpenCodeIcon, + pi: PiIcon, }; function resolveLiveProviderAvailability(provider: ServerProviderStatus | undefined): { @@ -92,7 +93,7 @@ function providerIconClassName( provider: ProviderKind | ProviderPickerKind, fallbackClassName: string, ): string { - return provider === "claudeAgent" || provider === "gemini" + return provider === "claudeAgent" || provider === "gemini" || provider === "pi" ? "text-foreground" : fallbackClassName; } @@ -101,12 +102,13 @@ const SEARCHABLE_MODEL_PICKER_THRESHOLD = 15; const FAVORITE_MODEL_STORAGE_KEYS = { cursor: "dpcode:cursor-favourite-models:v1", opencode: "dpcode:opencode-favourite-models:v1", + pi: "dpcode:pi-favourite-models:v1", } as const; const FavoriteModelSlugs = Schema.Array(Schema.String); type FavoriteModelProvider = keyof typeof FAVORITE_MODEL_STORAGE_KEYS; function supportsModelFavorites(provider: ProviderKind): provider is FavoriteModelProvider { - return provider === "cursor" || provider === "opencode"; + return provider === "cursor" || provider === "opencode" || provider === "pi"; } // Keeps persisted favorite slugs compact and stable while preserving the user's order. @@ -180,6 +182,11 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { [], FavoriteModelSlugs, ); + const [piFavoriteModelSlugs, setPiFavoriteModelSlugs] = useLocalStorage( + FAVORITE_MODEL_STORAGE_KEYS.pi, + [], + FavoriteModelSlugs, + ); const deferredModelSearchQuery = useDeferredValue(modelSearchQuery); const activeProvider = props.lockedProvider ?? props.provider; const isMenuOpen = open ?? uncontrolledMenuOpen; @@ -191,12 +198,17 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { () => new Set(cursorFavoriteModelSlugs), [cursorFavoriteModelSlugs], ); + const piFavoriteModelSlugSet = useMemo( + () => new Set(piFavoriteModelSlugs), + [piFavoriteModelSlugs], + ); const favoriteModelSlugSets = useMemo( () => ({ cursor: cursorFavoriteModelSlugSet, opencode: openCodeFavoriteModelSlugSet, + pi: piFavoriteModelSlugSet, }), - [cursorFavoriteModelSlugSet, openCodeFavoriteModelSlugSet], + [cursorFavoriteModelSlugSet, openCodeFavoriteModelSlugSet, piFavoriteModelSlugSet], ); const selectedProviderOptions = props.modelOptionsByProvider[activeProvider]; const selectedModelLabel = resolveSelectedModelLabel({ @@ -232,10 +244,14 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const toggleFavoriteModel = useCallback( (provider: FavoriteModelProvider, slug: string) => { const setFavoriteModelSlugs = - provider === "cursor" ? setCursorFavoriteModelSlugs : setOpenCodeFavoriteModelSlugs; + provider === "cursor" + ? setCursorFavoriteModelSlugs + : provider === "pi" + ? setPiFavoriteModelSlugs + : setOpenCodeFavoriteModelSlugs; setFavoriteModelSlugs((current) => toggleFavoriteModelSlug(current, slug)); }, - [setCursorFavoriteModelSlugs, setOpenCodeFavoriteModelSlugs], + [setCursorFavoriteModelSlugs, setOpenCodeFavoriteModelSlugs, setPiFavoriteModelSlugs], ); const renderModelRadioGroup = (provider: ProviderKind) => { @@ -254,7 +270,7 @@ export const ProviderModelPicker = memo(function ProviderModelPicker(props: { const providerOptions = props.modelOptionsByProvider[provider]; const shouldShowSearch = - (provider === "opencode" || provider === "cursor") && + (provider === "opencode" || provider === "cursor" || provider === "pi") && providerOptions.length >= SEARCHABLE_MODEL_PICKER_THRESHOLD; const normalizedModelSearchQuery = deferredModelSearchQuery.trim().toLowerCase(); const filteredOptions = diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 9769c225..4dd1bac9 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -39,7 +39,14 @@ function ClaudeTraitsPickerHarness(props: { selectedProvider: "claudeAgent", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { @@ -560,7 +567,14 @@ function OpenCodeTraitsPickerHarness(props: { selectedProvider: "opencode", threadModelSelection: props.fallbackModelSelection, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, }); const handlePromptChange = useCallback( (nextPrompt: string) => { diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index f3af3faa..7bb07bac 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -95,9 +95,11 @@ export const TraitsMenuContent = memo(function TraitsMenuContentImpl({ ? (geminiModelOptionsFromEffortValue(nextOption.value) ?? {}) : provider === "opencode" ? { variant: nextOption.value } - : provider === "codex" - ? { reasoningEffort: nextOption.value } - : { effort: nextOption.value }; + : provider === "pi" + ? { thinkingLevel: nextOption.value } + : provider === "codex" + ? { reasoningEffort: nextOption.value } + : { effort: nextOption.value }; setProviderModelOptions( threadId, provider, diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index 441f65ac..c3ba1b63 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -20,6 +20,7 @@ import { normalizeClaudeModelOptions, normalizeGeminiModelOptions, normalizeOpenCodeModelOptions, + normalizePiModelOptions, resolveLabeledOptionValue, trimOrNull, } from "@t3tools/shared/model"; @@ -197,6 +198,12 @@ function getProviderStateFromCapabilities( normalizedOptions = normalizeOpenCodeModelOptions(providerOptions); break; } + case "pi": { + const providerOptions = modelOptions?.pi; + rawEffort = trimOrNull(providerOptions?.thinkingLevel); + normalizedOptions = normalizePiModelOptions(providerOptions); + break; + } } const draftEffort = trimOrNull(rawEffort); @@ -254,6 +261,11 @@ const composerProviderRegistry: Record = { renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("opencode", input), renderTraitsPicker: (input) => renderTraitsPickerForProvider("opencode", input), }, + pi: { + getState: (input) => getProviderStateFromCapabilities(input), + renderTraitsMenuContent: (input) => renderTraitsMenuContentForProvider("pi", input), + renderTraitsPicker: (input) => renderTraitsPickerForProvider("pi", input), + }, }; export function getComposerProviderState(input: ComposerProviderStateInput): ComposerProviderState { diff --git a/apps/web/src/components/chat/composerTraits.ts b/apps/web/src/components/chat/composerTraits.ts index 5ebfe7f3..5e954d77 100644 --- a/apps/web/src/components/chat/composerTraits.ts +++ b/apps/web/src/components/chat/composerTraits.ts @@ -9,6 +9,7 @@ import { type CursorModelOptions, type GeminiModelOptions, type OpenCodeModelOptions, + type PiModelOptions, type ProviderKind, type ProviderModelDescriptor, } from "@t3tools/contracts"; @@ -44,6 +45,9 @@ function getRawEffort( if (provider === "opencode") { return trimOrNull((modelOptions as OpenCodeModelOptions | undefined)?.variant); } + if (provider === "pi") { + return trimOrNull((modelOptions as PiModelOptions | undefined)?.thinkingLevel); + } const caps = getModelCapabilities(provider, model); return getGeminiThinkingSelectionValue(caps, modelOptions as GeminiModelOptions | undefined); } diff --git a/apps/web/src/components/chat/runtimeModelCapabilities.ts b/apps/web/src/components/chat/runtimeModelCapabilities.ts index 4f8c11f9..be25e460 100644 --- a/apps/web/src/components/chat/runtimeModelCapabilities.ts +++ b/apps/web/src/components/chat/runtimeModelCapabilities.ts @@ -91,7 +91,10 @@ export function getRuntimeAwareModelCapabilities(input: { })) ?? staticCapabilities.contextWindowOptions; const runtimeEfforts = input.runtimeModel?.supportedReasoningEfforts; if ( - (input.provider !== "codex" && input.provider !== "cursor" && input.provider !== "opencode") || + (input.provider !== "codex" && + input.provider !== "cursor" && + input.provider !== "opencode" && + input.provider !== "pi") || !runtimeEfforts || runtimeEfforts.length === 0 ) { diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index e40311bf..80d6eafa 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -126,7 +126,7 @@ function resetComposerDraftStore() { } function modelSelection( - provider: "codex" | "claudeAgent" | "opencode", + provider: ModelSelection["provider"], model: string, options?: ModelSelection["options"], ): ModelSelection { @@ -1111,7 +1111,14 @@ describe("composerDraftStore modelSelection", () => { selectedProvider: "opencode", threadModelSelection: modelSelection("opencode", "opencode/gpt-5-nano"), projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, availableModelOptionsByProvider: { opencode: [{ slug: "opencode/gpt-5-nano", name: "GPT-5 Nano" }], }, @@ -1129,7 +1136,14 @@ describe("composerDraftStore modelSelection", () => { selectedProvider: "opencode", threadModelSelection: modelSelection("opencode", "openai/gpt-5.4"), projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, availableModelOptionsByProvider: { opencode: [ { slug: "openai/gpt-5-codex", name: "GPT-5-Codex" }, @@ -1152,7 +1166,14 @@ describe("composerDraftStore modelSelection", () => { selectedProvider: "opencode", threadModelSelection: null, projectModelSelection: null, - customModelsByProvider: { codex: [], claudeAgent: [], cursor: [], gemini: [], opencode: [] }, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, availableModelOptionsByProvider: { opencode: [ { slug: "opencode/gpt-5-nano", name: "GPT-5 Nano" }, @@ -1164,6 +1185,36 @@ describe("composerDraftStore modelSelection", () => { expect(state.selectedModel).toBe("opencode/gpt-5-nano"); }); + it("preserves a selected Pi custom model when discovery omits it", () => { + const state = deriveEffectiveComposerModelState({ + draft: { + modelSelectionByProvider: { + pi: modelSelection("pi", "openai/gpt-5.5"), + }, + activeProvider: "pi", + }, + selectedProvider: "pi", + threadModelSelection: null, + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + gemini: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + pi: [ + { slug: "openai/gpt-5.1", name: "GPT-5.1" }, + { slug: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + ], + }, + }); + + expect(state.selectedModel).toBe("openai/gpt-5.5"); + }); + it("updates only the draft when sticky persistence is disabled", () => { const store = useComposerDraftStore.getState(); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 941943e7..8d0b635e 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -5,6 +5,7 @@ import { type GeminiThinkingBudget, type GeminiThinkingLevel, type ModelSlug, + type PiThinkingLevel, ModelSelection, OrchestrationThreadPullRequest, ProjectId, @@ -670,7 +671,8 @@ function normalizeProviderKind(value: unknown): ProviderKind | null { value === "claudeAgent" || value === "cursor" || value === "gemini" || - value === "opencode" + value === "opencode" || + value === "pi" ? value : null; } @@ -731,6 +733,14 @@ function makeModelSelection( ? { options: options as Extract["options"] } : {}), }; + case "pi": + return { + provider, + model, + ...(options + ? { options: options as Extract["options"] } + : {}), + }; } } @@ -760,6 +770,10 @@ function normalizeProviderModelOptions( candidate?.opencode && typeof candidate.opencode === "object" ? (candidate.opencode as Record) : null; + const piCandidate = + candidate?.pi && typeof candidate.pi === "object" + ? (candidate.pi as Record) + : null; const codexReasoningEffort: CodexReasoningEffort | undefined = codexCandidate?.reasoningEffort === "low" || @@ -890,7 +904,17 @@ function normalizeProviderModelOptions( ...(openCodeAgent !== undefined ? { agent: openCodeAgent } : {}), } : undefined; - if (!codex && !claude && !cursor && !gemini && !opencode) { + const piThinkingLevel: PiThinkingLevel | undefined = + piCandidate?.thinkingLevel === "off" || + piCandidate?.thinkingLevel === "minimal" || + piCandidate?.thinkingLevel === "low" || + piCandidate?.thinkingLevel === "medium" || + piCandidate?.thinkingLevel === "high" || + piCandidate?.thinkingLevel === "xhigh" + ? piCandidate.thinkingLevel + : undefined; + const pi = piThinkingLevel !== undefined ? { thinkingLevel: piThinkingLevel } : undefined; + if (!codex && !claude && !cursor && !gemini && !opencode && !pi) { return null; } return { @@ -899,6 +923,7 @@ function normalizeProviderModelOptions( ...(cursor ? { cursor } : {}), ...(gemini ? { gemini } : {}), ...(opencode ? { opencode } : {}), + ...(pi ? { pi } : {}), }; } @@ -948,7 +973,9 @@ function normalizeModelSelection( ? modelOptions?.cursor : provider === "opencode" ? modelOptions?.opencode - : undefined; + : provider === "pi" + ? modelOptions?.pi + : undefined; return makeModelSelection(provider, model, options); } @@ -1006,14 +1033,21 @@ function legacyToModelSelectionByProvider( const result: Partial> = {}; // Add entries from the options bag (for non-active providers) if (modelOptions) { - for (const provider of ["codex", "claudeAgent", "cursor", "gemini", "opencode"] as const) { + for (const provider of [ + "codex", + "claudeAgent", + "cursor", + "gemini", + "opencode", + "pi", + ] as const) { const options = modelOptions[provider]; if (options && Object.keys(options).length > 0) { - result[provider] = makeModelSelection( - provider, - modelSelection?.provider === provider ? modelSelection.model : getDefaultModel(provider), - options, - ); + const model = + modelSelection?.provider === provider ? modelSelection.model : getDefaultModel(provider); + if (model) { + result[provider] = makeModelSelection(provider, model, options); + } } } } @@ -1046,8 +1080,12 @@ export function deriveEffectiveComposerModelState(input: { }; const baseModel = resolveModelSlugForProvider( input.selectedProvider, - input.threadModelSelection?.model ?? - input.projectModelSelection?.model ?? + (input.threadModelSelection?.provider === input.selectedProvider + ? input.threadModelSelection.model + : null) ?? + (input.projectModelSelection?.provider === input.selectedProvider + ? input.projectModelSelection.model + : null) ?? getDefaultModel(input.selectedProvider), ); const persistedThreadModel = @@ -1068,6 +1106,7 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, ) : null; + const unlistedDraftModel = input.selectedProvider === "pi" ? selectedDraftModel : null; const selectedModel = resolveAvailableModel(activeSelection?.model) ?? resolveAvailableModel( @@ -1083,9 +1122,11 @@ export function deriveEffectiveComposerModelState(input: { resolveAvailableModel(selectedDraftModel) ?? persistedThreadModel ?? persistedProjectModel ?? + unlistedDraftModel ?? input.availableModelOptionsByProvider?.[input.selectedProvider]?.[0]?.slug ?? selectedDraftModel ?? - baseModel; + baseModel ?? + ("" as ModelSlug); const modelOptions = modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? providerModelOptionsFromSelection(input.threadModelSelection) ?? @@ -1110,7 +1151,7 @@ export function resolvePreferredComposerModelSelection(input: { defaultProvider?: ProviderKind | null | undefined; }): ModelSelection { const draftProviderWithSelection = - (["codex", "claudeAgent", "cursor", "gemini", "opencode"] as const).find( + (["codex", "claudeAgent", "cursor", "gemini", "opencode", "pi"] as const).find( (provider) => input.draft?.modelSelectionByProvider?.[provider] !== undefined, ) ?? null; const preferredProvider = @@ -1129,8 +1170,8 @@ export function resolvePreferredComposerModelSelection(input: { (input.projectModelSelection?.provider === preferredProvider ? input.projectModelSelection : null) ?? { - provider: preferredProvider, - model: getDefaultModel(preferredProvider), + provider: preferredProvider === "pi" ? "codex" : preferredProvider, + model: getDefaultModel(preferredProvider) ?? getDefaultModel("codex"), } ); } @@ -2644,15 +2685,18 @@ export const useComposerDraftStore = create()( "cursor", "gemini", "opencode", + "pi", ] as const) { // Only touch providers explicitly present in the input if (!normalizedOpts || !(provider in normalizedOpts)) continue; const opts = normalizedOpts[provider]; const current = nextMap[provider]; if (opts) { + const model = current?.model ?? getDefaultModel(provider); + if (!model) continue; nextMap[provider] = makeModelSelection( provider, - current?.model ?? getDefaultModel(provider), + model, opts, ); } else if (current?.options) { @@ -2702,9 +2746,13 @@ export const useComposerDraftStore = create()( const nextMap = { ...base.modelSelectionByProvider }; const currentForProvider = nextMap[normalizedProvider]; if (providerOpts) { + const nextModel = currentForProvider?.model ?? fallbackModel; + if (!nextModel) { + return state; + } nextMap[normalizedProvider] = makeModelSelection( normalizedProvider, - currentForProvider?.model ?? fallbackModel, + nextModel, providerOpts, ); } else if (currentForProvider?.options) { @@ -2722,7 +2770,10 @@ export const useComposerDraftStore = create()( const stickyBase = nextStickyMap[normalizedProvider] ?? base.modelSelectionByProvider[normalizedProvider] ?? - makeModelSelection(normalizedProvider, fallbackModel); + (fallbackModel ? makeModelSelection(normalizedProvider, fallbackModel) : null); + if (!stickyBase) { + return state; + } if (providerOpts) { nextStickyMap[normalizedProvider] = makeModelSelection( normalizedProvider, diff --git a/apps/web/src/hooks/useComposerSlashCommands.ts b/apps/web/src/hooks/useComposerSlashCommands.ts index e00aacf5..504d65ce 100644 --- a/apps/web/src/hooks/useComposerSlashCommands.ts +++ b/apps/web/src/hooks/useComposerSlashCommands.ts @@ -164,11 +164,6 @@ export function useComposerSlashCommands(input: { : "An error occurred while compacting context.", }); }); - toastManager.add({ - type: "success", - title: "Compaction started", - description: "The current provider is compacting the thread context.", - }); return true; } catch (error) { toastManager.add({ diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 4a9dc5d0..40093038 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -1,4 +1,5 @@ -import { type ProjectId, ThreadId, DEFAULT_MODEL_BY_PROVIDER } from "@t3tools/contracts"; +import { type ProjectId, ThreadId } from "@t3tools/contracts"; +import { getDefaultModel } from "@t3tools/shared/model"; import { useNavigate } from "@tanstack/react-router"; import { useCallback } from "react"; import { useAppSettings } from "../appSettings"; @@ -42,9 +43,13 @@ export function useHandleNewThread() { if (!options?.provider) { return; } + const defaultModel = getDefaultModel(options.provider); + if (!defaultModel) { + return; + } setModelSelection(threadId, { provider: options.provider, - model: DEFAULT_MODEL_BY_PROVIDER[options.provider], + model: defaultModel, }); }; const restoreComposerDraft = ( diff --git a/apps/web/src/lib/providerDiscoveryReactQuery.ts b/apps/web/src/lib/providerDiscoveryReactQuery.ts index 4a98613d..3c42f1f1 100644 --- a/apps/web/src/lib/providerDiscoveryReactQuery.ts +++ b/apps/web/src/lib/providerDiscoveryReactQuery.ts @@ -48,16 +48,20 @@ export const providerDiscoveryQueryKeys = { all: ["provider-discovery"] as const, composerCapabilities: (provider: ProviderKind) => ["provider-discovery", "composer-capabilities", provider] as const, - commands: (provider: ProviderKind, cwd: string | null, query: string) => - ["provider-discovery", "commands", provider, cwd, query] as const, - skills: (provider: ProviderKind, cwd: string | null, query: string) => - ["provider-discovery", "skills", provider, cwd, query] as const, + commands: (provider: ProviderKind, cwd: string | null, query: string, agentDir: string | null) => + ["provider-discovery", "commands", provider, cwd, query, agentDir] as const, + skills: (provider: ProviderKind, cwd: string | null, query: string, agentDir: string | null) => + ["provider-discovery", "skills", provider, cwd, query, agentDir] as const, plugins: (provider: ProviderKind, cwd: string | null) => ["provider-discovery", "plugins", provider, cwd] as const, plugin: (provider: ProviderKind, marketplacePath: string, pluginName: string) => ["provider-discovery", "plugin", provider, marketplacePath, pluginName] as const, - models: (provider: ProviderKind, binaryPath: string | null, apiEndpoint: string | null) => - ["provider-discovery", "models", provider, binaryPath, apiEndpoint] as const, + models: ( + provider: ProviderKind, + binaryPath: string | null, + apiEndpoint: string | null, + agentDir: string | null, + ) => ["provider-discovery", "models", provider, binaryPath, apiEndpoint, agentDir] as const, agents: (provider: ProviderKind) => ["provider-discovery", "agents", provider] as const, }; @@ -76,11 +80,17 @@ export function providerSkillsQueryOptions(input: { provider: ProviderKind; cwd: string | null; threadId?: string | null; + agentDir?: string | null; query: string; enabled?: boolean; }) { return queryOptions({ - queryKey: providerDiscoveryQueryKeys.skills(input.provider, input.cwd, input.query), + queryKey: providerDiscoveryQueryKeys.skills( + input.provider, + input.cwd, + input.query, + input.agentDir ?? null, + ), queryFn: async () => { const api = ensureNativeApi(); if (!input.cwd) { @@ -90,6 +100,7 @@ export function providerSkillsQueryOptions(input: { provider: input.provider, cwd: input.cwd, ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.agentDir ? { agentDir: input.agentDir } : {}), }); }, enabled: (input.enabled ?? true) && input.cwd !== null, @@ -102,11 +113,17 @@ export function providerCommandsQueryOptions(input: { provider: ProviderKind; cwd: string | null; threadId?: string | null; + agentDir?: string | null; query: string; enabled?: boolean; }) { return queryOptions({ - queryKey: providerDiscoveryQueryKeys.commands(input.provider, input.cwd, input.query), + queryKey: providerDiscoveryQueryKeys.commands( + input.provider, + input.cwd, + input.query, + input.agentDir ?? null, + ), queryFn: async () => { const api = ensureNativeApi(); if (!input.cwd) { @@ -116,6 +133,7 @@ export function providerCommandsQueryOptions(input: { provider: input.provider, cwd: input.cwd, ...(input.threadId ? { threadId: input.threadId } : {}), + ...(input.agentDir ? { agentDir: input.agentDir } : {}), }); }, enabled: (input.enabled ?? true) && input.cwd !== null, @@ -128,6 +146,7 @@ export function providerModelsQueryOptions(input: { provider: ProviderKind; binaryPath?: string | null; apiEndpoint?: string | null; + agentDir?: string | null; enabled?: boolean; }) { return queryOptions({ @@ -135,6 +154,7 @@ export function providerModelsQueryOptions(input: { input.provider, input.binaryPath ?? null, input.apiEndpoint ?? null, + input.agentDir ?? null, ), queryFn: async () => { const api = ensureNativeApi(); @@ -142,6 +162,7 @@ export function providerModelsQueryOptions(input: { provider: input.provider, ...(input.binaryPath ? { binaryPath: input.binaryPath } : {}), ...(input.apiEndpoint ? { apiEndpoint: input.apiEndpoint } : {}), + ...(input.agentDir ? { agentDir: input.agentDir } : {}), }); }, enabled: input.enabled ?? true, diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 867f3d7a..15178761 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -12,30 +12,42 @@ describe("threadHandoff", () => { "cursor", "gemini", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("claudeAgent")).toEqual([ "codex", "cursor", "gemini", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("cursor")).toEqual([ "codex", "claudeAgent", "gemini", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("gemini")).toEqual([ "codex", "claudeAgent", "cursor", "opencode", + "pi", ]); expect(resolveAvailableHandoffTargetProviders("opencode")).toEqual([ "codex", "claudeAgent", "cursor", "gemini", + "pi", + ]); + expect(resolveAvailableHandoffTargetProviders("pi")).toEqual([ + "codex", + "claudeAgent", + "cursor", + "gemini", + "opencode", ]); }); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 0e320016..6163d881 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -1,5 +1,4 @@ import { - DEFAULT_MODEL_BY_PROVIDER, EventId, MessageId, type OrchestrationThreadActivity, @@ -8,6 +7,7 @@ import { type ProviderKind, type ThreadHandoffImportedMessage, } from "@t3tools/contracts"; +import { getDefaultModel } from "@t3tools/shared/model"; import { type Thread } from "../types"; import { stripEmbeddedAssistantSelections } from "./assistantSelections"; import { randomUUID } from "./utils"; @@ -18,6 +18,7 @@ const HANDOFF_PROVIDER_ORDER: ReadonlyArray = [ "cursor", "gemini", "opencode", + "pi", ]; const IMPORTABLE_THREAD_ACTIVITY_KINDS = new Set([ "account.rate-limits.updated", @@ -148,8 +149,12 @@ export function resolveThreadHandoffModelSelection(input: { if (input.projectDefaultModelSelection?.provider === input.targetProvider) { return input.projectDefaultModelSelection; } + const defaultModel = getDefaultModel(input.targetProvider); + if (!defaultModel) { + throw new Error("Select a Pi model before handing off to Pi."); + } return { provider: input.targetProvider, - model: DEFAULT_MODEL_BY_PROVIDER[input.targetProvider], + model: defaultModel, }; } diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index ff0a04d9..5288b9f7 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -11,6 +11,8 @@ import type { ModelSelection, OpenCodeModelOptions, OpenCodeModelSelection, + PiModelOptions, + PiModelSelection, ProviderKind, ProviderModelOptions, } from "@t3tools/contracts"; @@ -48,7 +50,7 @@ export function formatProviderModelOptionName(input: { return trimmedSlug; } - if (input.provider === "opencode") { + if (input.provider === "opencode" || input.provider === "pi") { const modelIdentifier = trimmedSlug.includes("/") ? trimmedSlug.slice(trimmedSlug.lastIndexOf("/") + 1) : trimmedSlug; @@ -165,10 +167,16 @@ export function buildNextProviderOptions( ...patch, } as GeminiModelOptions; } + if (provider === "opencode") { + return { + ...(modelOptions as OpenCodeModelOptions | undefined), + ...patch, + } as OpenCodeModelOptions; + } return { - ...(modelOptions as OpenCodeModelOptions | undefined), + ...(modelOptions as PiModelOptions | undefined), ...patch, - } as OpenCodeModelOptions; + } as PiModelOptions; } export function buildModelSelection( @@ -196,6 +204,11 @@ export function buildModelSelection( model: string, options?: OpenCodeModelOptions | null | undefined, ): OpenCodeModelSelection; +export function buildModelSelection( + provider: "pi", + model: string, + options?: PiModelOptions | null | undefined, +): PiModelSelection; export function buildModelSelection( provider: ProviderKind, model: string, @@ -247,5 +260,13 @@ export function buildModelSelection( options: options as OpenCodeModelOptions, } : { provider, model }; + case "pi": + return options + ? { + provider, + model, + options: options as PiModelOptions, + } + : { provider, model }; } } diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 70d46afa..97cf6a80 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -28,7 +28,7 @@ import { Schema } from "effect"; import ChatView from "../components/ChatView"; import BrowserPanel from "../components/BrowserPanel"; -import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon } from "../components/Icons"; +import { ClaudeAI, CursorIcon, Gemini, OpenAI, OpenCodeIcon, PiIcon } from "../components/Icons"; import { ChatPaneDropOverlay } from "../components/chat-drop-overlay/ChatPaneDropOverlay"; import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import { @@ -657,6 +657,9 @@ function PickerProviderGlyph(props: { provider: ProviderKind; className?: string /> ); } + if (props.provider === "pi") { + return