Skip to content
Source
Sign inSign up

status.ts

557 lines18.5 KB

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