1import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
2import {
3 type CustomModelSetting,
4 type ServerProvider,
5 type ServerProviderModel,
6} from "@t3tools/contracts";
7import type { PiSettings } from "../settings.ts";
8import { causeErrorTag } from "@t3tools/shared/observability";
9import { resolveSpawnCommand } from "@t3tools/shared/shell";
10import { compareSemverVersions } from "@t3tools/shared/semver";
11import * as DateTime from "effect/DateTime";
12import * as Effect from "effect/Effect";
13import * as Stream from "effect/Stream";
14import * as Exit from "effect/Exit";
15import * as Fiber from "effect/Fiber";
16import * as Option from "effect/Option";
17import * as Result from "effect/Result";
18import * as Schema from "effect/Schema";
19import * as HttpClient from "effect/http/HttpClient";
20import * as ChildProcess from "effect/process/ChildProcess";
21import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
22
23import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts";
24import {
25 makePiRpcConnection,
26 PiRpcError,
27 PiRpcTimeoutError,
28 piRecordField as recordField,
29 piRecordString as recordString,
30} from "./rpc.ts";
31import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
32import {
33 buildServerProvider,
34 DEFAULT_TIMEOUT_MS,
35 isCommandMissingCause,
36 parseCliVersionResult,
37 providerModelsFromSettings,
38 providerProbeEnvironment,
39 spawnAndCollect,
40 type ServerProviderDraft,
41} from "@t3tools/provider-core/server/snapshotProbe";
42import {
43 enrichProviderSnapshotWithVersionAdvisory,
44 type ProviderMaintenanceCapabilities,
45} from "@t3tools/provider-core/server/maintenanceResolver";
46import { EMPTY_PI_MODEL_CAPABILITIES, piModelCapabilities } from "./modelCapabilities.ts";
47import {
48 parsePiDiscoveredCommands,
49 withPiBuiltinSlashCommands,
50 type PiDiscoveredCommands,
51} from "./commands.ts";
52
53const PI_PRESENTATION = {
54 displayName: "Pi",
55 showInteractionModeToggle: false,
56 supportedRuntimeModes: ["approval-required", "auto-accept-edits", "full-access"],
57 reportsContextWindow: true,
58} as const;
59
60const VERSION_PROBE_TIMEOUT_MS = 10_000;
61const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000;
62const PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS = 5_000;
63/**
64 * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4.
65 * Version 0.80.5 was the first published package containing both hooks.
66 */
67export const MINIMUM_PI_VERSION = "0.80.5";
68/** `pi auth check --json --no-refresh` arrived in 0.84.1. */
69const PI_AUTH_CHECK_VERSION = "0.84.1";
70
71const decodePiAuthCheck = Schema.decodeUnknownOption(
72 Schema.fromJsonString(Schema.Struct({ reason: Schema.optional(Schema.String) })),
73);
74
75const PI_DEFAULT_MODEL: ServerProviderModel = {
76 slug: "default",
77 name: "Pi default",
78 isCustom: false,
79 capabilities: EMPTY_PI_MODEL_CAPABILITIES,
80};
81
82interface PiDiscovery extends PiDiscoveredCommands {
83 readonly models: ReadonlyArray<ServerProviderModel>;
84 readonly authenticated: boolean;
85 /** Pi's active model when Pi lists no model of its provider, so it cannot start a turn. */
86 readonly unavailableActiveModel: { readonly provider: string; readonly id: string } | undefined;
87 /** Pi without extensions can load no model, so text generation fails for every entry. */
88 readonly textGenerationUnavailable: boolean;
89}
90
91function piModelsFromSettings(
92 customModels: ReadonlyArray<CustomModelSetting> | undefined,
93 discovered: ReadonlyArray<ServerProviderModel> = [],
94): ReadonlyArray<ServerProviderModel> {
95 return providerModelsFromSettings(
96 [PI_DEFAULT_MODEL, ...discovered],
97 customModels ?? [],
98 EMPTY_PI_MODEL_CAPABILITIES,
99 );
100}
101
102function parseDiscoveredModels(
103 data: unknown,
104 state: unknown,
105): Result.Result<ReadonlyArray<ServerProviderModel>, PiRpcError> {
106 const models = recordField(data, "models");
107 if (!Array.isArray(models)) {
108 return Result.fail(
109 new PiRpcError({ operation: "get_available_models", detail: "Invalid model inventory" }),
110 );
111 }
112 const activeModel = recordField(state, "model");
113 const activeProvider = recordString(activeModel, "provider");
114 const activeModelId = recordString(activeModel, "id");
115 const seen = new Set<string>();
116 const parsed: Array<ServerProviderModel> = [];
117 for (const model of models) {
118 const provider = recordString(model, "provider");
119 const id = recordString(model, "id");
120 if (provider === undefined || id === undefined || provider.trim() === "" || id.trim() === "") {
121 return Result.fail(
122 new PiRpcError({ operation: "get_available_models", detail: "Invalid model identity" }),
123 );
124 }
125 const slug = `${provider}/${id}`;
126 if (seen.has(slug)) continue;
127 seen.add(slug);
128 parsed.push({
129 slug,
130 name: recordString(model, "name") ?? slug,
131 subProvider: provider,
132 isCustom: false,
133 capabilities: piModelCapabilities(
134 model,
135 provider === activeProvider && id === activeModelId
136 ? recordString(state, "thinkingLevel")
137 : undefined,
138 ),
139 });
140 }
141 return Result.succeed(parsed);
142}
143
144const makePiDiscoveryConnection = Effect.fnUntraced(function* (
145 piSettings: PiSettings,
146 environment: NodeJS.ProcessEnv,
147 launchArgs: ReadonlyArray<string>,
148 cwd?: string,
149 textGenerationLaunch = false,
150) {
151 const launch = buildPiRpcLaunch({
152 launchArgs,
153 environment,
154 mcpSession: undefined,
155 extensionPath: undefined,
156 ephemeral: true,
157 disableExtensions: textGenerationLaunch,
158 disableTools: textGenerationLaunch,
159 shortLived: true,
160 });
161 const connection = yield* makePiRpcConnection({
162 command: piSettings.binaryPath || "pi",
163 args: launch.args,
164 cwd,
165 env: launch.env,
166 });
167 yield* Stream.fromQueue(connection.events).pipe(
168 Stream.runDrain,
169 Effect.ignore,
170 Effect.forkScoped,
171 );
172 return connection;
173});
174
175// Text generation runs Pi with --no-extensions, so it cannot load models that an
176// extension or package registers. Listing models with that launch tells them apart.
177const discoverPiTextGenerationModelSlugs = Effect.fn(
178 "PiProviderStatus.discoverPiTextGenerationModelSlugs",
179)(function* (
180 piSettings: PiSettings,
181 environment: NodeJS.ProcessEnv,
182 launchArgs: ReadonlyArray<string>,
183 cwd?: string,
184) {
185 const connection = yield* makePiDiscoveryConnection(
186 piSettings,
187 environment,
188 launchArgs,
189 cwd,
190 true,
191 );
192 const modelsData = yield* connection.request({ type: "get_available_models" });
193 const models = yield* Effect.fromResult(parseDiscoveredModels(modelsData, undefined));
194 return new Set(models.map((model) => model.slug));
195}, Effect.scoped);
196
197const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function* (
198 piSettings: PiSettings,
199 environment: NodeJS.ProcessEnv,
200 launchArgs: ReadonlyArray<string>,
201 cwd?: string,
202) {
203 const timeoutMs = resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment);
204 // This optional probe must finish before the normal discovery deadline so
205 // it cannot discard an otherwise usable catalog and authentication state.
206 const textGenerationModelSlugsFiber = yield* discoverPiTextGenerationModelSlugs(
207 piSettings,
208 environment,
209 launchArgs,
210 cwd,
211 ).pipe(
212 // Pi exits before answering when launch args name a provider only an extension registers.
213 Effect.catchIf(
214 (error) =>
215 error._tag === "PiRpcError" && (error.operation === "read" || error.operation === "exit"),
216 () => Effect.succeed(new Set<string>()),
217 ),
218 Effect.orElseSucceed(() => undefined),
219 Effect.timeoutOrElse({
220 duration: PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS,
221 orElse: () => Effect.void,
222 }),
223 Effect.forkScoped,
224 );
225 const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd);
226 const stateData = yield* connection.request({ type: "get_state" }, timeoutMs);
227 const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs);
228 const commandsData = yield* connection
229 .request({ type: "get_commands" }, timeoutMs)
230 .pipe(Effect.orElseSucceed(() => undefined));
231 const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData));
232 const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData);
233 const textGenerationModelSlugs = yield* Fiber.join(textGenerationModelSlugsFiber);
234 // Pi lists only models of providers it holds credentials for, including extension, virtual and
235 // --api-key ones. Turns need only the provider's credentials, so uncatalogued model ids still run.
236 const activeModel = recordField(stateData, "model");
237 const activeProvider = recordString(activeModel, "provider");
238 const activeModelId = recordString(activeModel, "id");
239 return {
240 models:
241 textGenerationModelSlugs === undefined
242 ? discoveredModels
243 : discoveredModels.map((model) =>
244 textGenerationModelSlugs.has(model.slug)
245 ? model
246 : { ...model, supportsTextGeneration: false },
247 ),
248 slashCommands: withPiBuiltinSlashCommands(slashCommands),
249 skills,
250 authenticated: discoveredModels.length > 0,
251 unavailableActiveModel:
252 activeProvider !== undefined &&
253 activeModelId !== undefined &&
254 !discoveredModels.some((model) => model.subProvider === activeProvider)
255 ? { provider: activeProvider, id: activeModelId }
256 : undefined,
257 textGenerationUnavailable: textGenerationModelSlugs?.size === 0,
258 } satisfies PiDiscovery;
259}, Effect.scoped);
260
261export const discoverPiCommandsForCwd = Effect.fn("discoverPiCommandsForCwd")(
262 function* (piSettings: PiSettings, environment: NodeJS.ProcessEnv, cwd: string) {
263 const launchArgs = resolvePiLaunchArgs(piSettings.launchArgs);
264 if (!launchArgs.ok) {
265 return yield* new PiRpcError({ operation: "launch", detail: launchArgs.message });
266 }
267 const connection = yield* makePiDiscoveryConnection(
268 piSettings,
269 providerProbeEnvironment(environment),
270 launchArgs.args,
271 cwd,
272 );
273 // A failed read must not replace a previously usable workspace catalog with an empty one.
274 const commandsData = yield* connection.request(
275 { type: "get_commands" },
276 resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment),
277 );
278 const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData);
279 return { slashCommands: withPiBuiltinSlashCommands(slashCommands), skills };
280 },
281 Effect.scoped,
282 (effect, _settings, environment) => {
283 const timeoutMs = resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment);
284 return effect.pipe(
285 Effect.timeoutOrElse({
286 duration: timeoutMs,
287 orElse: () => Effect.fail(new PiRpcTimeoutError({ operation: "discovery", timeoutMs })),
288 }),
289 );
290 },
291);
292
293const runPiVersionCommand = Effect.fn("PiProviderStatus.runPiVersionCommand")(function* (
294 piSettings: PiSettings,
295 environment: NodeJS.ProcessEnv,
296) {
297 const command = piSettings.binaryPath || "pi";
298 const spawnCommand = yield* resolveSpawnCommand(command, ["--version"], {
299 env: environment,
300 });
301 return yield* spawnAndCollect(
302 command,
303 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
304 env: environment,
305 shell: spawnCommand.shell,
306 }),
307 );
308});
309
310/**
311 * Explains why Pi's active model cannot start a turn. On Pi versions with `auth check`, asks it
312 * whether credentials are missing, without refreshing or printing them. That command cannot see
313 * extension providers or a runtime --api-key, so it only refines Pi's own unavailable verdict,
314 * and is skipped when launch args supply the key.
315 */
316const describeUnavailablePiModel = Effect.fn("PiProviderStatus.describeUnavailablePiModel")(
317 function* (
318 piSettings: PiSettings,
319 environment: NodeJS.ProcessEnv,
320 version: string,
321 launchArgs: ReadonlyArray<string>,
322 model: { readonly provider: string; readonly id: string },
323 ) {
324 const label = `Pi default (${model.provider}/${model.id})`;
325 const unavailable = `${label} uses ${model.provider}, which has no available models in Pi. Choose another model, or check that provider's setup and the Pi launch arguments.`;
326 if (
327 compareSemverVersions(version, PI_AUTH_CHECK_VERSION) < 0 ||
328 launchArgs.includes("--api-key")
329 ) {
330 return unavailable;
331 }
332 const command = piSettings.binaryPath || "pi";
333 const output = yield* resolveSpawnCommand(
334 command,
335 ["auth", "check", "--provider", model.provider, "--json", "--no-refresh"],
336 { env: environment },
337 ).pipe(
338 Effect.flatMap((spawnCommand) =>
339 spawnAndCollect(
340 command,
341 ChildProcess.make(spawnCommand.command, spawnCommand.args, {
342 env: environment,
343 shell: spawnCommand.shell,
344 }),
345 ),
346 ),
347 Effect.timeout(resolveProviderProbeTimeoutMs("pi", DEFAULT_TIMEOUT_MS, environment)),
348 Effect.option,
349 );
350 const missingCredentials = Option.exists(
351 Option.flatMap(output, (result) => decodePiAuthCheck(result.stdout)),
352 (check) => check.reason === "credentials_not_configured",
353 );
354 return missingCredentials
355 ? `${label} has no credentials configured for ${model.provider}. Choose another model, or run \`pi\` in a terminal and use /login or configure its API key.`
356 : unavailable;
357 },
358);
359
360export const buildInitialPiProviderSnapshot = Effect.fn(
361 "PiProviderStatus.buildInitialPiProviderSnapshot",
362)(function* (piSettings: PiSettings): Effect.fn.Return<ServerProviderDraft> {
363 const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso);
364 const models = piModelsFromSettings(piSettings.customModels);
365 if (!piSettings.enabled) {
366 return buildServerProvider({
367 presentation: PI_PRESENTATION,
368 enabled: false,
369 checkedAt,
370 models,
371 probe: {
372 installed: false,
373 version: null,
374 status: "warning",
375 auth: { status: "unknown" },
376 message: "Pi is disabled in T3 Code settings.",
377 },
378 });
379 }
380 return buildServerProvider({
381 presentation: PI_PRESENTATION,
382 enabled: true,
383 checkedAt,
384 models,
385 probe: {
386 installed: true,
387 version: null,
388 status: "warning",
389 auth: { status: "unknown" },
390 message: "Checking Pi CLI availability...",
391 },
392 });
393});
394
395export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function* (
396 piSettings: PiSettings,
397 environment: NodeJS.ProcessEnv = process.env,
398 cwd?: string,
399): Effect.fn.Return<ServerProviderDraft, never, ChildProcessSpawner.ChildProcessSpawner> {
400 const checkedAt = DateTime.formatIso(yield* DateTime.now);
401 const fallbackModels = piModelsFromSettings(piSettings.customModels);
402
403 if (!piSettings.enabled) {
404 return buildServerProvider({
405 presentation: PI_PRESENTATION,
406 enabled: false,
407 checkedAt,
408 models: fallbackModels,
409 probe: {
410 installed: false,
411 version: null,
412 status: "warning",
413 auth: { status: "unknown" },
414 message: "Pi is disabled in T3 Code settings.",
415 },
416 });
417 }
418
419 const probeEnvironment = providerProbeEnvironment(environment);
420 const versionResult = yield* runPiVersionCommand(piSettings, probeEnvironment).pipe(
421 Effect.timeoutOption(
422 resolveProviderProbeTimeoutMs("pi", VERSION_PROBE_TIMEOUT_MS, environment),
423 ),
424 Effect.result,
425 );
426
427 if (Result.isFailure(versionResult)) {
428 const error = versionResult.failure;
429 yield* Effect.logWarning("Pi CLI health check failed.", { errorTag: error._tag });
430 return buildServerProvider({
431 presentation: PI_PRESENTATION,
432 enabled: piSettings.enabled,
433 checkedAt,
434 models: fallbackModels,
435 probe: {
436 installed: !isCommandMissingCause(error),
437 version: null,
438 status: "error",
439 auth: { status: "unknown" },
440 message: isCommandMissingCause(error)
441 ? "Pi CLI (`pi`) is not installed or not on PATH. Install with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`."
442 : "Failed to execute Pi CLI health check.",
443 },
444 });
445 }
446
447 if (Option.isNone(versionResult.success)) {
448 return buildServerProvider({
449 presentation: PI_PRESENTATION,
450 enabled: piSettings.enabled,
451 checkedAt,
452 models: fallbackModels,
453 probe: {
454 installed: true,
455 version: null,
456 status: "error",
457 auth: { status: "unknown" },
458 message: "Pi CLI is installed but timed out while running `pi --version`.",
459 },
460 });
461 }
462
463 const versionOutput = versionResult.success.value;
464 const version = parseCliVersionResult(versionOutput);
465 if (versionOutput.code !== 0) {
466 return buildServerProvider({
467 presentation: PI_PRESENTATION,
468 enabled: piSettings.enabled,
469 checkedAt,
470 models: fallbackModels,
471 probe: {
472 installed: true,
473 version,
474 status: "error",
475 auth: { status: "unknown" },
476 message: "Pi CLI is installed but failed to run.",
477 },
478 });
479 }
480
481 if (version === null) {
482 return buildServerProvider({
483 presentation: PI_PRESENTATION,
484 enabled: piSettings.enabled,
485 checkedAt,
486 models: fallbackModels,
487 probe: {
488 installed: true,
489 version: null,
490 status: "error",
491 auth: { status: "unknown" },
492 message: `T3 Code could not determine the Pi version. Pi ${MINIMUM_PI_VERSION} or newer is required.`,
493 },
494 });
495 }
496
497 if (compareSemverVersions(version, MINIMUM_PI_VERSION) < 0) {
498 return buildServerProvider({
499 presentation: PI_PRESENTATION,
500 enabled: piSettings.enabled,
501 checkedAt,
502 models: fallbackModels,
503 probe: {
504 installed: true,
505 version,
506 status: "error",
507 auth: { status: "unknown" },
508 message: `Pi ${version} is unsupported. Update to Pi ${MINIMUM_PI_VERSION} or newer.`,
509 },
510 });
511 }
512
513 const resolvedLaunchArgs = resolvePiLaunchArgs(piSettings.launchArgs);
514 if (!resolvedLaunchArgs.ok) {
515 return buildServerProvider({
516 presentation: PI_PRESENTATION,
517 enabled: piSettings.enabled,
518 checkedAt,
519 models: fallbackModels,
520 probe: {
521 installed: true,
522 version,
523 status: "error",
524 auth: { status: "unknown" },
525 message: resolvedLaunchArgs.message,
526 },
527 });
528 }
529
530 const discoveryExit = yield* discoverPiViaRpc(
531 piSettings,
532 probeEnvironment,
533 resolvedLaunchArgs.args,
534 cwd,
535 ).pipe(
536 Effect.timeoutOption(
537 resolveProviderProbeTimeoutMs("pi", PI_RPC_DISCOVERY_TIMEOUT_MS, environment),
538 ),
539 Effect.exit,
540 );
541 if (Exit.isFailure(discoveryExit)) {
542 yield* Effect.logWarning("Pi RPC discovery failed.", {
543 errorTag: causeErrorTag(discoveryExit.cause),
544 });
545 return buildServerProvider({
546 presentation: PI_PRESENTATION,
547 enabled: piSettings.enabled,
548 checkedAt,
549 models: fallbackModels,
550 probe: {
551 installed: true,
552 version,
553 status: "ready",
554 auth: { status: "unknown" },
555 message:
556 "Pi is available, but T3 Code could not refresh its models and commands. The live session will retry startup.",
557 },
558 });
559 }
560 if (Option.isNone(discoveryExit.value)) {
561 return buildServerProvider({
562 presentation: PI_PRESENTATION,
563 enabled: piSettings.enabled,
564 checkedAt,
565 models: fallbackModels,
566 probe: {
567 installed: true,
568 version,
569 status: "ready",
570 auth: { status: "unknown" },
571 message:
572 "Pi is available, but model and command discovery needs interactive input. The live session will handle it.",
573 },
574 });
575 }
576
577 const discovery = discoveryExit.value.value;
578 const models = piModelsFromSettings(piSettings.customModels, discovery.models);
579 const message = !discovery.authenticated
580 ? "Pi has no usable models. Run `pi` in a terminal and use /login, or configure an API key in ~/.pi/agent."
581 : discovery.unavailableActiveModel === undefined
582 ? undefined
583 : yield* describeUnavailablePiModel(
584 piSettings,
585 probeEnvironment,
586 version,
587 resolvedLaunchArgs.args,
588 discovery.unavailableActiveModel,
589 );
590 return {
591 ...buildServerProvider({
592 presentation: PI_PRESENTATION,
593 enabled: piSettings.enabled,
594 checkedAt,
595 models,
596 slashCommands: discovery.slashCommands,
597 skills: discovery.skills,
598 probe: {
599 installed: true,
600 version,
601 // Other Pi models still work when only the default is unavailable, so keep the picker usable.
602 status: discovery.authenticated ? "ready" : "warning",
603 auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" },
604 ...(message === undefined ? {} : { message }),
605 },
606 }),
607 ...(discovery.textGenerationUnavailable ? { supportsTextGeneration: false } : {}),
608 };
609});
610
611export const enrichPiSnapshot = (input: {
612 readonly snapshot: ServerProvider;
613 readonly maintenanceCapabilities: ProviderMaintenanceCapabilities;
614 readonly enableProviderUpdateChecks?: boolean;
615 readonly publishSnapshot: (snapshot: ServerProvider) => Effect.Effect<void>;
616}): Effect.Effect<
617 void,
618 never,
619 HttpClient.HttpClient | ProviderLatestVersions.ProviderLatestVersions
620> => {
621 const { snapshot, publishSnapshot } = input;
622 return enrichProviderSnapshotWithVersionAdvisory(snapshot, input.maintenanceCapabilities, {
623 enableProviderUpdateChecks: input.enableProviderUpdateChecks,
624 }).pipe(
625 Effect.flatMap((enrichedSnapshot) => publishSnapshot(enrichedSnapshot)),
626 Effect.catchCause((cause) =>
627 Effect.logWarning("Pi version advisory enrichment failed", {
628 errorTag: causeErrorTag(cause),
629 }),
630 ),
631 Effect.asVoid,
632 );
633};