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 */
11import {
12 type CustomModelSetting,
13 type ServerProvider,
14 type ServerProviderModel,
15} from "@t3tools/contracts";
16import type { PiSettings } from "../settings.ts";
17import { causeErrorTag } from "@t3tools/shared/observability";
18import { resolveSpawnCommand } from "@t3tools/shared/shell";
19import { compareSemverVersions } from "@t3tools/shared/semver";
20import * as DateTime from "effect/DateTime";
21import * as Effect from "effect/Effect";
22import * as Stream from "effect/Stream";
23import * as Exit from "effect/Exit";
24import * as Option from "effect/Option";
25import * as Result from "effect/Result";
26import * as HttpClient from "effect/http/HttpClient";
27import * as ChildProcess from "effect/process/ChildProcess";
28import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
29
30import { buildPiRpcLaunch, resolvePiLaunchArgs } from "./mcpInjection.ts";
31import {
32 makePiRpcConnection,
33 PiRpcError,
34 PiRpcTimeoutError,
35 piRecordField as recordField,
36 piRecordString as recordString,
37} from "./rpc.ts";
38import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
39import {
40 buildServerProvider,
41 isCommandMissingCause,
42 parseGenericCliVersion,
43 providerModelsFromSettings,
44 spawnAndCollect,
45 type ServerProviderDraft,
46} from "@t3tools/provider-core/server/snapshotProbe";
47import {
48 enrichProviderSnapshotWithVersionAdvisory,
49 type ProviderMaintenanceCapabilities,
50} from "@t3tools/provider-core/server/maintenanceResolver";
51import {
52 EMPTY_PI_MODEL_CAPABILITIES,
53 thinkingCapabilitiesForPiModel,
54} from "./thinkingCapabilities.ts";
55import {
56 parsePiDiscoveredCommands,
57 withPiBuiltinSlashCommands,
58 type PiDiscoveredCommands,
59} from "./commands.ts";
60
61const 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
71const VERSION_PROBE_TIMEOUT_MS = 4_000;
72const 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 */
78export const MINIMUM_PI_VERSION = "0.80.5";
79
80/** Deferring to the user's own settings.json default model. */
81const PI_DEFAULT_MODEL: ServerProviderModel = {
82 slug: "default",
83 name: "Pi default",
84 isCustom: false,
85 capabilities: EMPTY_PI_MODEL_CAPABILITIES,
86};
87
88interface PiDiscovery extends PiDiscoveredCommands {
89 readonly models: ReadonlyArray<ServerProviderModel>;
90 readonly authenticated: boolean;
91}
92
93function 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
104function 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
135const 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
162const 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. */
186export 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
213const 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
228export 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
265export 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
463export 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};