From 8a4526d3d02abd44defd4d31c8dc5dc6e4f89fd9 Mon Sep 17 00:00:00 2001 From: Prem Chaitanya Prathi Date: Fri, 9 Oct 2026 16:04:33 +0530 Subject: [PATCH 1/4] fix(provider-pi): machine probe no longer dies on a project startup dialog --- .../provider-pi/src/server/status.test.ts | 32 +++++++++++++++++++ packages/provider-pi/src/server/status.ts | 24 +++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index b8559d1ab7d8a..89352d51bebe3 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -16,6 +16,13 @@ const decodeRpcRequest = Schema.decodeSync( Schema.fromJsonString(Schema.Struct({ id: Schema.String, type: Schema.String })), ); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const STARTUP_DIALOG = `${encodeJsonLine({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Allow project MCP server “github”?", + options: ["Don't allow", "Allow"], +})}\n`; function processHandle(input: { readonly stdout?: string; @@ -59,6 +66,8 @@ const piDiscoverySpawner = ( readonly version?: string; readonly state?: unknown; readonly authCheck?: string | undefined; + /** Without `--no-approve`, print a project-trust dialog and exit 0 as Pi 1.x does. */ + readonly startupDialog?: boolean; } = {}, ) => Effect.gen(function* () { @@ -102,6 +111,9 @@ const piDiscoverySpawner = ( ? Effect.die(`Unexpected pi ${args.join(" ")}`) : Effect.succeed(processHandle({ stdout: options.authCheck, exitCode: 1 })); } + if (options.startupDialog === true && !args.includes("--no-approve")) { + return Effect.succeed(processHandle({ stdout: STARTUP_DIALOG })); + } return Effect.succeed( ChildProcessSpawner.makeHandle({ ...processHandle({}), @@ -229,6 +241,26 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { launchArgs: "", models: ["default", "anthropic/claude-opus"] }, + { launchArgs: "--approve", models: ["default"] }, + ])("probes past a project startup dialog with launch args '$launchArgs'", (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "anthropic", id: "claude-opus" }] }, + { startupDialog: true }, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: row.launchArgs, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + row.models, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + // regression: #18498 a malformed model inventory must not restore stale Pi models it.effect.each([ ["missing models", {}], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index a180ab9c41a3b..0228ea313a32f 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -7,6 +7,7 @@ import type { PiSettings } from "../settings.ts"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; @@ -164,13 +165,31 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); +const PI_PROJECT_TRUST_ARGUMENTS = new Set(["--approve", "-a", "--no-approve", "-na"]); + +/** + * The machine probe skips project-local files unless the user picked a trust mode. Pi's RPC mode + * reads stdin only after session_start, so an extension dialog raised there (an MCP adapter asking + * to approve a project server) can never be answered and Pi exits 0 without replying. + */ +function machineProbeLaunchArgs(launchArgs: ReadonlyArray): ReadonlyArray { + return launchArgs.some((arg) => PI_PROJECT_TRUST_ARGUMENTS.has(arg)) + ? launchArgs + : [...launchArgs, "--no-approve"]; +} + const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, cwd?: string, ) { - const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); + const connection = yield* makePiDiscoveryConnection( + piSettings, + environment, + machineProbeLaunchArgs(launchArgs), + cwd, + ); const stateData = yield* connection.request({ type: "get_state" }); const modelsData = yield* connection.request({ type: "get_available_models" }); const commandsData = yield* connection @@ -465,8 +484,11 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function cwd, ).pipe(Effect.timeoutOption(PI_RPC_DISCOVERY_TIMEOUT_MS), Effect.exit); if (Exit.isFailure(discoveryExit)) { + const failure = Cause.findErrorOption(discoveryExit.cause); yield* Effect.logWarning("Pi RPC discovery failed.", { errorTag: causeErrorTag(discoveryExit.cause), + // Pi RPC error messages are bounded and carry no stderr. + ...(Option.isSome(failure) ? { detail: failure.value.message } : {}), }); return buildServerProvider({ presentation: PI_PRESENTATION, From d1ec9313267ccad203f659542f4c03eb4f2dcb83 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 05:27:55 +0000 Subject: [PATCH 2/4] fix(provider-pi): log the failed RPC operation instead of Pi's error text --- packages/provider-pi/src/server/status.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 6c71617083784..3384062bdbadb 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -486,8 +486,7 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function const failure = Cause.findErrorOption(discoveryExit.cause); yield* Effect.logWarning("Pi RPC discovery failed.", { errorTag: causeErrorTag(discoveryExit.cause), - // Pi RPC error messages are bounded and carry no stderr. - ...(Option.isSome(failure) ? { detail: failure.value.message } : {}), + ...(Option.isSome(failure) ? { operation: failure.value.operation } : {}), }); return buildServerProvider({ presentation: PI_PRESENTATION, From f6e7d2a53d2ac8d15f8095a4e72e8e1d5b12b9ac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 12:19:06 +0000 Subject: [PATCH 3/4] fix(provider-pi): log only known Pi RPC operation names Co-Authored-By: Claude Opus 5.5 --- packages/provider-pi/src/server/status.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 21a7e51ead6f6..f8497cbb0aca5 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -168,6 +168,18 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); +// A failed response echoes Pi's own `command` field as the operation, so log only names we sent. +const PI_LOGGED_RPC_OPERATIONS = new Set([ + "spawn", + "read", + "write", + "exit", + "terminate", + "get_state", + "get_available_models", + "get_commands", +]); + const PI_PROJECT_TRUST_ARGUMENTS = new Set(["--approve", "-a", "--no-approve", "-na"]); /** @@ -554,7 +566,9 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function const failure = Cause.findErrorOption(discoveryExit.cause); yield* Effect.logWarning("Pi RPC discovery failed.", { errorTag: causeErrorTag(discoveryExit.cause), - ...(Option.isSome(failure) ? { operation: failure.value.operation } : {}), + ...(Option.isSome(failure) && PI_LOGGED_RPC_OPERATIONS.has(failure.value.operation) + ? { operation: failure.value.operation } + : {}), }); return buildServerProvider({ presentation: PI_PRESENTATION, From 7a8b95ca41ad0fabcae56cb1d7c35f2f9b469434 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 12:35:44 +0000 Subject: [PATCH 4/4] fix(provider-pi): decline project trust only when a startup dialog blocks discovery Trusted projects keep their project-local extension models: the machine probe now retries with --no-approve only after Pi exits before answering. Co-Authored-By: Claude Opus 5.5 --- .../provider-pi/src/server/status.test.ts | 61 +++++++++++++------ packages/provider-pi/src/server/status.ts | 47 ++++++++------ 2 files changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 4058557979a42..3c562b461579d 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -127,6 +127,8 @@ const piDiscoverySpawner = ( readonly authCheck?: string | undefined; /** Without `--no-approve`, print a project-trust dialog and exit 0 as Pi 1.x does. */ readonly startupDialog?: boolean; + /** What `--no-approve` lists once project-local extensions are skipped. */ + readonly declinedProjectInventory?: unknown; } = {}, ) => Effect.succeed( @@ -154,7 +156,11 @@ const piDiscoverySpawner = ( ); } return piRpcProcess( - args.includes("--no-extensions") ? extensionFreeInventory : inventory, + args.includes("--no-extensions") + ? extensionFreeInventory + : args.includes("--no-approve") && options.declinedProjectInventory !== undefined + ? options.declinedProjectInventory + : inventory, args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void, options.state, ); @@ -440,25 +446,40 @@ describe("PiProvider", () => { ); it.effect.each([ - { launchArgs: "", models: ["default", "anthropic/claude-opus"] }, - { launchArgs: "--approve", models: ["default"] }, - ])("probes past a project startup dialog with launch args '$launchArgs'", (row) => - Effect.gen(function* () { - const spawner = yield* piDiscoverySpawner( - { models: [{ provider: "anthropic", id: "claude-opus" }] }, - undefined, - undefined, - { startupDialog: true }, - ); - const snapshot = yield* checkPiProviderStatus({ - ...settings, - launchArgs: row.launchArgs, - }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); - assert.deepEqual( - snapshot.models.map((model) => model.slug), - row.models, - ); - }).pipe(Effect.provide(NodeServices.layer)), + { launchArgs: "", startupDialog: true, models: ["default", "anthropic/claude-opus"] }, + { launchArgs: "--approve", startupDialog: true, models: ["default"] }, + { + launchArgs: "", + startupDialog: false, + models: ["default", "anthropic/claude-opus", "project/local-model"], + }, + ])( + "keeps project trust unless a startup dialog blocks discovery (dialog: $startupDialog, launch args '$launchArgs')", + (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-opus" }, + { provider: "project", id: "local-model" }, + ], + }, + undefined, + undefined, + { + startupDialog: row.startupDialog, + declinedProjectInventory: { models: [{ provider: "anthropic", id: "claude-opus" }] }, + }, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: row.launchArgs, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + row.models, + ); + }).pipe(Effect.provide(NodeServices.layer)), ); // regression: #18498 a malformed model inventory must not restore stale Pi models diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index f8497cbb0aca5..86a8e63de753c 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -182,16 +182,21 @@ const PI_LOGGED_RPC_OPERATIONS = new Set([ const PI_PROJECT_TRUST_ARGUMENTS = new Set(["--approve", "-a", "--no-approve", "-na"]); -/** - * The machine probe skips project-local files unless the user picked a trust mode. Pi's RPC mode - * reads stdin only after session_start, so an extension dialog raised there (an MCP adapter asking - * to approve a project server) can never be answered and Pi exits 0 without replying. - */ -function machineProbeLaunchArgs(launchArgs: ReadonlyArray): ReadonlyArray { - return launchArgs.some((arg) => PI_PROJECT_TRUST_ARGUMENTS.has(arg)) - ? launchArgs - : [...launchArgs, "--no-approve"]; -} +const readPiInventory = Effect.fn("PiProviderStatus.readPiInventory")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + timeoutMs: number, + cwd?: string, +) { + const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); + const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); + const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); + const commandsData = yield* connection + .request({ type: "get_commands" }, timeoutMs) + .pipe(Effect.orElseSucceed(() => undefined)); + return { stateData, modelsData, commandsData }; +}, Effect.scoped); // Text generation runs Pi with --no-extensions, so it cannot load models that an // extension or package registers. Listing models with that launch tells them apart. @@ -243,17 +248,25 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function }), Effect.forkScoped, ); - const connection = yield* makePiDiscoveryConnection( + const { stateData, modelsData, commandsData } = yield* readPiInventory( piSettings, environment, - machineProbeLaunchArgs(launchArgs), + launchArgs, + timeoutMs, cwd, + ).pipe( + // Pi's RPC mode reads stdin only after session_start, so a startup dialog (an MCP adapter asking + // to approve a project server) can never be answered and Pi exits. Retry declining project trust + // unless the user chose a trust mode, so trusted projects keep their project-local models. + Effect.catchIf( + (error) => + error._tag === "PiRpcError" && + (error.operation === "read" || error.operation === "exit") && + !launchArgs.some((arg) => PI_PROJECT_TRUST_ARGUMENTS.has(arg)), + () => + readPiInventory(piSettings, environment, [...launchArgs, "--no-approve"], timeoutMs, cwd), + ), ); - const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); - const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); - const commandsData = yield* connection - .request({ type: "get_commands" }, timeoutMs) - .pipe(Effect.orElseSucceed(() => undefined)); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); const textGenerationModelSlugs = yield* Fiber.join(textGenerationModelSlugsFiber);