| 1 | /** |
| 2 | * PiProvider — snapshot/probe layer for the Pi coding agent. |
| 3 | * |
| 4 | * Health is probed with `pi --version`. Models, the user's default model, and |
| 5 | * the user's commands (extension slash commands, prompt templates, skills) |
| 6 | * are discovered through a short-lived ephemeral RPC session |
| 7 | * (`pi --mode rpc --no-session`), so everything the user configured in |
| 8 | * `~/.pi/agent` — custom providers, models.json entries, extensions, skills — |
| 9 | * shows up in T3 without any hardcoded catalog. |
| 10 | */ |
| 11 | import { |
| 12 | type CustomModelSetting, |
| 13 | type ServerProvider, |
| 14 | type ServerProviderModel, |
| 15 | } from "@t3tools/contracts"; |
| 16 | import type { PiSettings } from "../settings.ts"; |
| 17 | import { causeErrorTag } from "@t3tools/shared/observability"; |
| 18 | import { resolveSpawnCommand } from "@t3tools/shared/shell"; |
| 19 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 20 | import * as DateTime from "effect/DateTime"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as Stream from "effect/Stream"; |
| 23 | import * as Exit from "effect/Exit"; |
| 24 | import * as Option from "effect/Option"; |
| 25 | import * as Result from "effect/Result"; |
| 26 | import * as HttpClient from "effect/http/HttpClient"; |
| 27 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 28 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 29 | |
| 30 | import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts"; |
| 31 | import { |
| 32 | makePiRpcConnection, |
| 33 | PiRpcError, |
| 34 | PiRpcTimeoutError, |
| 35 | piRecordField as recordField, |
| 36 | piRecordString as recordString, |
| 37 | } from "./rpc.ts"; |
| 38 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 39 | import { |
| 40 | buildServerProvider, |
| 41 | isCommandMissingCause, |
| 42 | parseGenericCliVersion, |
| 43 | providerModelsFromSettings, |
| 44 | spawnAndCollect, |
| 45 | type ServerProviderDraft, |
| 46 | } from "@t3tools/provider-core/server/snapshotProbe"; |
| 47 | import { |
| 48 | enrichProviderSnapshotWithVersionAdvisory, |
| 49 | type ProviderMaintenanceCapabilities, |
| 50 | } from "@t3tools/provider-core/server/maintenanceResolver"; |
| 51 | import { |
| 52 | EMPTY_PI_MODEL_CAPABILITIES, |
| 53 | thinkingCapabilitiesForPiModel, |
| 54 | } from "./thinkingCapabilities.ts"; |
| 55 | import { |
| 56 | parsePiDiscoveredCommands, |
| 57 | withPiBuiltinSlashCommands, |
| 58 | type PiDiscoveredCommands, |
| 59 | } from "./commands.ts"; |
| 60 | |
| 61 | const PI_PRESENTATION = { |
| 62 | displayName: "Pi", |
| 63 | showInteractionModeToggle: false, |
| 64 | supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"], |
| 65 | // The adapter reports context usage from Pi's streaming usage while a |
| 66 | // turn runs, so clients can reserve the meter before the first settle. |
| 67 | reportsContextWindow: true, |
| 68 | requiresNewThreadForModelChange: false, |
| 69 | } as const; |
| 70 | |
| 71 | const VERSION_PROBE_TIMEOUT_MS = 4_000; |
| 72 | const PI_RPC_DISCOVERY_TIMEOUT_MS = 15_000; |
| 73 | /** |
| 74 | * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. |
| 75 | * Version 0.80.5 was the first published package containing both hooks. T3 |
| 76 | * needs them for rollback boundaries and reliable turn terminalization. |
| 77 | */ |
| 78 | export const MINIMUM_PI_VERSION = "0.80.5"; |
| 79 | |
| 80 | /** Deferring to the user's own settings.json default model. */ |
| 81 | const PI_DEFAULT_MODEL: ServerProviderModel = { |
| 82 | slug: "default", |
| 83 | name: "Pi default", |
| 84 | isCustom: false, |
| 85 | capabilities: EMPTY_PI_MODEL_CAPABILITIES, |
| 86 | }; |
| 87 | |
| 88 | interface PiDiscovery extends PiDiscoveredCommands { |
| 89 | readonly models: ReadonlyArray<ServerProviderModel>; |
| 90 | readonly authenticated: boolean; |
| 91 | } |
| 92 | |
| 93 | function piModelsFromSettings( |
| 94 | customModels: ReadonlyArray<CustomModelSetting> | undefined, |
| 95 | discovered: ReadonlyArray<ServerProviderModel> = [], |
| 96 | ): ReadonlyArray<ServerProviderModel> { |
| 97 | return providerModelsFromSettings( |
| 98 | [PI_DEFAULT_MODEL, ...discovered], |
| 99 | customModels ?? [], |
| 100 | EMPTY_PI_MODEL_CAPABILITIES, |
| 101 | ); |
| 102 | } |
| 103 | |
| 104 | function parseDiscoveredModels(data: unknown, state: unknown): ReadonlyArray<ServerProviderModel> { |
| 105 | const models = recordField(data, "models"); |
| 106 | if (!Array.isArray(models)) return []; |
| 107 | const activeModel = recordField(state, "model"); |
| 108 | const activeProvider = recordString(activeModel, "provider"); |
| 109 | const activeModelId = recordString(activeModel, "id"); |
| 110 | const seen = new Set<string>(); |
| 111 | const parsed: Array<ServerProviderModel> = []; |
| 112 | for (const model of models) { |
| 113 | const provider = recordString(model, "provider"); |
| 114 | const id = recordString(model, "id"); |
| 115 | if (provider === undefined || id === undefined) continue; |
| 116 | const slug = `${provider}/${id}`; |
| 117 | if (seen.has(slug)) continue; |
| 118 | seen.add(slug); |
| 119 | parsed.push({ |
| 120 | slug, |
| 121 | name: recordString(model, "name") ?? slug, |
| 122 | subProvider: provider, |
| 123 | isCustom: false, |
| 124 | capabilities: thinkingCapabilitiesForPiModel( |
| 125 | model, |
| 126 | provider === activeProvider && id === activeModelId |
| 127 | ? recordString(state, "thinkingLevel") |
| 128 | : undefined, |
| 129 | ), |
| 130 | }); |
| 131 | } |
| 132 | return parsed; |
| 133 | } |
| 134 | |
| 135 | const makePiDiscoveryConnection = Effect.fnUntraced(function* ( |
| 136 | piSettings: PiSettings, |
| 137 | environment: NodeJS.ProcessEnv, |
| 138 | launchArgs: ReadonlyArray<string>, |
| 139 | cwd?: string, |
| 140 | ) { |
| 141 | const launch = buildPiRpcLaunch({ |
| 142 | launchArgs, |
| 143 | environment, |
| 144 | mcpSession: undefined, |
| 145 | extensionPath: undefined, |
| 146 | ephemeral: true, |
| 147 | }); |
| 148 | const connection = yield* makePiRpcConnection({ |
| 149 | command: piSettings.binaryPath || "pi", |
| 150 | args: launch.args, |
| 151 | cwd, |
| 152 | env: launch.env, |
| 153 | }); |
| 154 | yield* Stream.fromQueue(connection.events).pipe( |
| 155 | Stream.runDrain, |
| 156 | Effect.ignore, |
| 157 | Effect.forkScoped, |
| 158 | ); |
| 159 | return connection; |
| 160 | }); |
| 161 | |
| 162 | const discoverPiViaRpc = ( |
| 163 | piSettings: PiSettings, |
| 164 | environment: NodeJS.ProcessEnv, |
| 165 | launchArgs: ReadonlyArray<string>, |
| 166 | cwd?: string, |
| 167 | ) => |
| 168 | Effect.gen(function* () { |
| 169 | const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); |
| 170 | const stateData = yield* connection.request({ type: "get_state" }); |
| 171 | const modelsData = yield* connection.request({ type: "get_available_models" }); |
| 172 | const commandsData = yield* connection |
| 173 | .request({ type: "get_commands" }) |
| 174 | .pipe(Effect.orElseSucceed(() => undefined)); |
| 175 | const discoveredModels = parseDiscoveredModels(modelsData, stateData); |
| 176 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); |
| 177 | return { |
| 178 | models: discoveredModels, |
| 179 | slashCommands: withPiBuiltinSlashCommands(slashCommands), |
| 180 | skills, |
| 181 | authenticated: discoveredModels.length > 0, |
| 182 | } satisfies PiDiscovery; |
| 183 | }).pipe(Effect.scoped); |
| 184 | |
| 185 | /** Probe the full command catalog Pi exposes in a workspace without changing machine health. */ |
| 186 | export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")( |
| 187 | function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) { |
| 188 | const launchArgs = resolvePiLaunchArgs(piSettings.launchArgs); |
| 189 | if (!launchArgs.ok) { |
| 190 | return yield* new PiRpcError({ operation: "launch", detail: launchArgs.message }); |
| 191 | } |
| 192 | const connection = yield* makePiDiscoveryConnection( |
| 193 | piSettings, |
| 194 | environment, |
| 195 | launchArgs.args, |
| 196 | cwd, |
| 197 | ); |
| 198 | // A failed read must not replace a previously usable workspace catalog with an empty one. |
| 199 | const commandsData = yield* connection.request({ type: "get_commands" }); |
| 200 | const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); |
| 201 | return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills }; |
| 202 | }, |
| 203 | Effect.scoped, |
| 204 | Effect.timeoutOrElse({ |
| 205 | duration: PI_RPC_DISCOVERY_TIMEOUT_MS, |
| 206 | orElse: () => |
| 207 | Effect.fail( |
| 208 | new PiRpcTimeoutError({ operation: "discovery", timeoutMs: PI_RPC_DISCOVERY_TIMEOUT_MS }), |
| 209 | ), |
| 210 | }), |
| 211 | ); |
| 212 | |
| 213 | const runPiVersionCommand = (piSettings: PiSettings, environment: NodeJS.ProcessEnv) => |
| 214 | Effect.gen(function* () { |
| 215 | const command = piSettings.binaryPath || "pi"; |
| 216 | const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], { |
| 217 | env: environment, |
| 218 | }); |
| 219 | return yield* spawnAndCollect( |
| 220 | command, |
| 221 | ChildProcess.make(spawnCommand.command, spawnCommand.args, { |
| 222 | env: environment, |
| 223 | shell: spawnCommand.shell, |
| 224 | }), |
| 225 | ); |
| 226 | }); |
| 227 | |
| 228 | export function buildInitialPiProviderSnapshot( |
| 229 | piSettings: PiSettings, |
| 230 | ): Effect.Effect<ServerProviderDraft> { |
| 231 | return Effect.gen(function* () { |
| 232 | const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); |
| 233 | const models = piModelsFromSettings(piSettings.customModels); |
| 234 | if (!piSettings.enabled) { |
| 235 | return buildServerProvider({ |
| 236 | presentation: PI_PRESENTATION, |
| 237 | enabled: false, |
| 238 | checkedAt, |
| 239 | models, |
| 240 | probe: { |
| 241 | installed: false, |
| 242 | version: null, |
| 243 | status: "warning", |
| 244 | auth: { status: "unknown" }, |
| 245 | message: "Pi is disabled in T3 Code settings.", |
| 246 | }, |
| 247 | }); |
| 248 | } |
| 249 | return buildServerProvider({ |
| 250 | presentation: PI_PRESENTATION, |
| 251 | enabled: true, |
| 252 | checkedAt, |
| 253 | models, |
| 254 | probe: { |
| 255 | installed: true, |
| 256 | version: null, |
| 257 | status: "warning", |
| 258 | auth: { status: "unknown" }, |
| 259 | message: "Checking Pi CLI availability...", |
| 260 | }, |
| 261 | }); |
| 262 | }); |
| 263 | } |
| 264 | |
| 265 | export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* ( |
| 266 | piSettings: PiSettings, |
| 267 | environment: NodeJS.ProcessEnv = process.env, |
| 268 | cwd?: string, |
| 269 | ): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> { |
| 270 | const checkedAt = DateTime.formatIso(yield* DateTime.now); |
| 271 | const fallbackModels = piModelsFromSettings(piSettings.customModels); |
| 272 | |
| 273 | if (!piSettings.enabled) { |
| 274 | return buildServerProvider({ |
| 275 | presentation: PI_PRESENTATION, |
| 276 | enabled: false, |
| 277 | checkedAt, |
| 278 | models: fallbackModels, |
| 279 | probe: { |
| 280 | installed: false, |
| 281 | version: null, |
| 282 | status: "warning", |
| 283 | auth: { status: "unknown" }, |
| 284 | message: "Pi is disabled in T3 Code settings.", |
| 285 | }, |
| 286 | }); |
| 287 | } |
| 288 | |
| 289 | const versionResult = yield* runPiVersionCommand(piSettings, environment).pipe( |
| 290 | Effect.timeoutOption(VERSION_PROBE_TIMEOUT_MS), |
| 291 | Effect.result, |
| 292 | ); |
| 293 | |
| 294 | if (Result.isFailure(versionResult)) { |
| 295 | const error = versionResult.failure; |
| 296 | yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag }); |
| 297 | return buildServerProvider({ |
| 298 | presentation: PI_PRESENTATION, |
| 299 | enabled: piSettings.enabled, |
| 300 | checkedAt, |
| 301 | models: fallbackModels, |
| 302 | probe: { |
| 303 | installed: !isCommandMissingCause(error), |
| 304 | version: null, |
| 305 | status: "error", |
| 306 | auth: { status: "unknown" }, |
| 307 | message: isCommandMissingCause(error) |
| 308 | ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`." |
| 309 | : "Failed to execute Pi CLI health check.", |
| 310 | }, |
| 311 | }); |
| 312 | } |
| 313 | |
| 314 | if (Option.isNone(versionResult.success)) { |
| 315 | return buildServerProvider({ |
| 316 | presentation: PI_PRESENTATION, |
| 317 | enabled: piSettings.enabled, |
| 318 | checkedAt, |
| 319 | models: fallbackModels, |
| 320 | probe: { |
| 321 | installed: true, |
| 322 | version: null, |
| 323 | status: "error", |
| 324 | auth: { status: "unknown" }, |
| 325 | message: "Pi CLI is installed but timed out while running `pi --version`.", |
| 326 | }, |
| 327 | }); |
| 328 | } |
| 329 | |
| 330 | const versionOutput = versionResult.success.value; |
| 331 | const version = parseGenericCliVersion(`${versionOutput.stdout}\n${versionOutput.stderr}`); |
| 332 | if (versionOutput.code !== 0) { |
| 333 | return buildServerProvider({ |
| 334 | presentation: PI_PRESENTATION, |
| 335 | enabled: piSettings.enabled, |
| 336 | checkedAt, |
| 337 | models: fallbackModels, |
| 338 | probe: { |
| 339 | installed: true, |
| 340 | version, |
| 341 | status: "error", |
| 342 | auth: { status: "unknown" }, |
| 343 | message: "Pi CLI is installed but failed to run.", |
| 344 | }, |
| 345 | }); |
| 346 | } |
| 347 | |
| 348 | if (version === null) { |
| 349 | return buildServerProvider({ |
| 350 | presentation: PI_PRESENTATION, |
| 351 | enabled: piSettings.enabled, |
| 352 | checkedAt, |
| 353 | models: fallbackModels, |
| 354 | probe: { |
| 355 | installed: true, |
| 356 | version: null, |
| 357 | status: "error", |
| 358 | auth: { status: "unknown" }, |
| 359 | message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`, |
| 360 | }, |
| 361 | }); |
| 362 | } |
| 363 | |
| 364 | if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) { |
| 365 | return buildServerProvider({ |
| 366 | presentation: PI_PRESENTATION, |
| 367 | enabled: piSettings.enabled, |
| 368 | checkedAt, |
| 369 | models: fallbackModels, |
| 370 | probe: { |
| 371 | installed: true, |
| 372 | version, |
| 373 | status: "error", |
| 374 | auth: { status: "unknown" }, |
| 375 | message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`, |
| 376 | }, |
| 377 | }); |
| 378 | } |
| 379 | |
| 380 | const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs); |
| 381 | if (!resolvedLaunchArgs.ok) { |
| 382 | return buildServerProvider({ |
| 383 | presentation: PI_PRESENTATION, |
| 384 | enabled: piSettings.enabled, |
| 385 | checkedAt, |
| 386 | models: fallbackModels, |
| 387 | probe: { |
| 388 | installed: true, |
| 389 | version, |
| 390 | status: "error", |
| 391 | auth: { status: "unknown" }, |
| 392 | message: resolvedLaunchArgs.message, |
| 393 | }, |
| 394 | }); |
| 395 | } |
| 396 | |
| 397 | const discoveryExit = yield* discoverPiViaRpc( |
| 398 | piSettings, |
| 399 | environment, |
| 400 | resolvedLaunchArgs.args, |
| 401 | cwd, |
| 402 | ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); |
| 403 | if (Exit.isFailure(discoveryExit)) { |
| 404 | yield* Effect.logWarning("Pi RPC discovery failed.", { |
| 405 | errorTag: causeErrorTag(discoveryExit.cause), |
| 406 | }); |
| 407 | return buildServerProvider({ |
| 408 | presentation: PI_PRESENTATION, |
| 409 | enabled: piSettings.enabled, |
| 410 | checkedAt, |
| 411 | models: fallbackModels, |
| 412 | probe: { |
| 413 | installed: true, |
| 414 | version, |
| 415 | status: "ready", |
| 416 | auth: { status: "unknown" }, |
| 417 | message: |
| 418 | "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.", |
| 419 | }, |
| 420 | }); |
| 421 | } |
| 422 | if (Option.isNone(discoveryExit.value)) { |
| 423 | return buildServerProvider({ |
| 424 | presentation: PI_PRESENTATION, |
| 425 | enabled: piSettings.enabled, |
| 426 | checkedAt, |
| 427 | models: fallbackModels, |
| 428 | probe: { |
| 429 | installed: true, |
| 430 | version, |
| 431 | status: "ready", |
| 432 | auth: { status: "unknown" }, |
| 433 | message: |
| 434 | "Pi is available, but model and command discovery needs interactive input. The live session will handle it.", |
| 435 | }, |
| 436 | }); |
| 437 | } |
| 438 | |
| 439 | const discovery = discoveryExit.value.value; |
| 440 | const models = piModelsFromSettings(piSettings.customModels, discovery.models); |
| 441 | return buildServerProvider({ |
| 442 | presentation: PI_PRESENTATION, |
| 443 | enabled: piSettings.enabled, |
| 444 | checkedAt, |
| 445 | models, |
| 446 | slashCommands: discovery.slashCommands, |
| 447 | skills: discovery.skills, |
| 448 | probe: { |
| 449 | installed: true, |
| 450 | version, |
| 451 | status: discovery.authenticated ? "ready" : "warning", |
| 452 | auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, |
| 453 | ...(discovery.authenticated |
| 454 | ? {} |
| 455 | : { |
| 456 | message: |
| 457 | "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent.", |
| 458 | }), |
| 459 | }, |
| 460 | }); |
| 461 | }); |
| 462 | |
| 463 | export const enrichPiSnapshot = (input: { |
| 464 | readonly snapshot: ServerProvider; |
| 465 | readonly maintenanceCapabilities: ProviderMaintenanceCapabilities; |
| 466 | readonly enableProviderUpdateChecks?: boolean; |
| 467 | readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>; |
| 468 | }): Effect.Effect< |
| 469 | void, |
| 470 | never, |
| 471 | HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions |
| 472 | > => { |
| 473 | const { snapshot, publishSnapshot } = input; |
| 474 | return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, { |
| 475 | enableProviderUpdateChecks: input.enableProviderUpdateChecks, |
| 476 | }).pipe( |
| 477 | Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)), |
| 478 | Effect.catchCause((cause) => |
| 479 | Effect.logWarning("Pi version advisory enrichment failed", { |
| 480 | errorTag: causeErrorTag(cause), |
| 481 | }), |
| 482 | ), |
| 483 | Effect.asVoid, |
| 484 | ); |
| 485 | }; |