From 268a1f3e59e48cf9ea6abf33306c3a532c61070a Mon Sep 17 00:00:00 2001 From: dinq Date: Thu, 1 Oct 2026 04:04:48 +0800 Subject: [PATCH 1/3] fix(server): restore local Pi extension loading --- CONTEXT.md | 2 +- packages/server/src/harness/agent-service.ts | 3 +- .../src/harness/pi/list-available-commands.ts | 2 +- .../src/harness/pi/list-available-models.ts | 10 +- packages/server/src/harness/pi/process.ts | 7 + .../src/harness/pi/project-resource-policy.ts | 9 +- packages/server/src/harness/pi/rpc/main.ts | 6 +- .../server/src/harness/pi/rpc/rpc-mode.ts | 7 +- .../server/src/harness/pi/rpc/rpc-types.ts | 2 +- packages/server/test/harness/pi/agent.test.ts | 38 +++- .../pi/list-available-commands.test.ts | 11 +- .../harness/pi/list-available-models.test.ts | 121 +++++++++-- .../test/harness/pi/local-extensions.test.ts | 194 ++++++++++++++++++ 13 files changed, 376 insertions(+), 36 deletions(-) create mode 100644 packages/server/test/harness/pi/local-extensions.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index 0ca8c248f..b5318e29c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ Glossary of project-specific terms. pie integrates the Pi coding agent into the ## Session Domain **Project**: -A working directory the user has registered with the server, identified by a server-generated UUID. The single source of the projectId → directory mapping; the directory field is `path`. Registration is the trust boundary for declarative Project-local Pi resources: session children approve prompts, skills, and context from that Project. Automatic Pi extension discovery is disabled for every Pie-owned child because Pie does not yet model inputs that an extension handles without starting a model turn; this intentionally makes discovered global and Project extension commands, tools, hooks, and UI unavailable. The extension request plumbing remains at the Pi protocol seam for compatibility and future explicit extension support. Sessions always resolve their working directory through a Project, never from a caller-supplied path. A session may start without picking an existing Project: `project.allocateChatProjectDir` creates a folder under the **chat-project root**, registers it with `type: "chat"`, then `session.create` uses that id as usual. Chat projects stay off the **Projects** sidebar and picker; their sessions appear under **Recent**. +A working directory the user has registered with the server, identified by a server-generated UUID. The single source of the projectId → directory mapping; the directory field is `path`. Registration is the trust boundary for Project-local Pi resources: session children approve extensions, prompts, skills, and context from that Project. Pi loads the user's extensions and configured packages for model/command discovery and live sessions, including provider registrations. Discovery without a Project does not approve Project-local resources. Extension commands and input hooks can handle a prompt without starting a model turn; Pie acknowledges that input without creating or waiting for a turn. Supported extension UI requests use the Pi protocol seam; terminal-only custom UI remains unavailable. Sessions always resolve their working directory through a Project, never from a caller-supplied path. A session may start without picking an existing Project: `project.allocateChatProjectDir` creates a folder under the **chat-project root**, registers it with `type: "chat"`, then `session.create` uses that id as usual. Chat projects stay off the **Projects** sidebar and picker; their sessions appear under **Recent**. _Avoid_: workspace, repo, cwd (for the Project field) **Chat-project root**: diff --git a/packages/server/src/harness/agent-service.ts b/packages/server/src/harness/agent-service.ts index aef06e4cd..db55def4c 100644 --- a/packages/server/src/harness/agent-service.ts +++ b/packages/server/src/harness/agent-service.ts @@ -1,4 +1,3 @@ -import { getAgentDir } from "@earendil-works/pi-coding-agent"; import type { ListAgentCommandsOutput, ListAgentModelsOutput } from "@getpie/contract"; import { Context, Effect, Layer } from "effect"; @@ -28,7 +27,7 @@ export const makePiAgentService = (): PiAgentServiceShape => ({ }); }), listModels: Effect.fn("PiAgentService.listModels")(function* (cwd?: string) { - return yield* listAvailablePiModels(cwd ?? getAgentDir()); + return yield* listAvailablePiModels(cwd); }), }); diff --git a/packages/server/src/harness/pi/list-available-commands.ts b/packages/server/src/harness/pi/list-available-commands.ts index f44177acd..0ea8ba869 100644 --- a/packages/server/src/harness/pi/list-available-commands.ts +++ b/packages/server/src/harness/pi/list-available-commands.ts @@ -21,7 +21,7 @@ export function listAvailablePiCommands( const transport = yield* makePiTransport({ executable: resolvePiExecutable(), cwd: cwd ?? agentDir, - args: PI_PROJECT_PROCESS_ARGS, + args: cwd === undefined ? [] : PI_PROJECT_PROCESS_ARGS, env: { PI_CODING_AGENT_DIR: agentDir }, }); const data = yield* transport.command<{ commands?: AgentCommand[] }>({ diff --git a/packages/server/src/harness/pi/list-available-models.ts b/packages/server/src/harness/pi/list-available-models.ts index 02585ff51..4c5f721bd 100644 --- a/packages/server/src/harness/pi/list-available-models.ts +++ b/packages/server/src/harness/pi/list-available-models.ts @@ -19,7 +19,7 @@ const listModelsError = (cause: unknown) => * RPC `get_available_models`, without spawning pie-pi-process. */ export function listAvailablePiModels( - cwd: string, + cwd?: string, ): Effect.Effect { return Effect.gen(function* () { const { createAgentSessionServices, getAgentDir, SettingsManager } = yield* Effect.tryPromise({ @@ -29,9 +29,13 @@ export function listAvailablePiModels( const services = yield* Effect.tryPromise({ try: () => createAgentSessionServices({ - cwd, + cwd: cwd ?? getAgentDir(), agentDir: getAgentDir(), - settingsManager: SettingsManager.create(cwd, getAgentDir(), PI_PROJECT_SETTINGS_OPTIONS), + settingsManager: SettingsManager.create( + cwd ?? getAgentDir(), + getAgentDir(), + cwd === undefined ? { projectTrusted: false } : PI_PROJECT_SETTINGS_OPTIONS, + ), resourceLoaderOptions: PI_PROJECT_LOADER_OPTIONS, }), catch: listModelsError, diff --git a/packages/server/src/harness/pi/process.ts b/packages/server/src/harness/pi/process.ts index 3fb1b3e27..26878faba 100644 --- a/packages/server/src/harness/pi/process.ts +++ b/packages/server/src/harness/pi/process.ts @@ -552,6 +552,13 @@ export const makePiProcessWithDependencies = ( if (!started) { const active = yield* Ref.get(session.turnState); + if (admission?.disposition === "handled") { + return { + turnId: active._tag === "Active" ? active.turnId : uuid(), + started: false, + output: Stream.empty, + }; + } if (active._tag === "Active") { return { turnId: active.turnId, diff --git a/packages/server/src/harness/pi/project-resource-policy.ts b/packages/server/src/harness/pi/project-resource-policy.ts index 9cf64f639..e95fcd8a0 100644 --- a/packages/server/src/harness/pi/project-resource-policy.ts +++ b/packages/server/src/harness/pi/project-resource-policy.ts @@ -1,12 +1,11 @@ -// A registered Project is trusted for declarative Pi resources, but Pie does -// not model extension-handled inputs that complete without a model turn. -// Keep cold discovery and live children on the same resource policy. +// Registered Projects are trusted for Pi resources, including extensions. +// User extensions and configured packages also supply providers and commands. +// Cold discovery skips terminal themes/context, not executable extensions. export const PI_PROJECT_SETTINGS_OPTIONS = { projectTrusted: true } as const; export const PI_PROJECT_LOADER_OPTIONS = { - noExtensions: true, noThemes: true, noContextFiles: true, } as const; -export const PI_PROJECT_PROCESS_ARGS = ["--approve", "--no-extensions"] as const; +export const PI_PROJECT_PROCESS_ARGS = ["--approve"] as const; diff --git a/packages/server/src/harness/pi/rpc/main.ts b/packages/server/src/harness/pi/rpc/main.ts index 70fd173c2..ccaf778a6 100644 --- a/packages/server/src/harness/pi/rpc/main.ts +++ b/packages/server/src/harness/pi/rpc/main.ts @@ -64,14 +64,18 @@ export const main = async (): Promise => { cwd: options.cwd, agentDir: options.agentDir, modelRuntimeSignal: AbortSignal.timeout(15_000), + settingsManager: SettingsManager.create(options.cwd, options.agentDir, { + projectTrusted: parsed.projectTrustOverride ?? false, + }), extensionFlagValues: FFF_OVERRIDE_FLAGS, resourceLoaderOptions: { + noExtensions: parsed.noExtensions ?? false, extensionFactories: [ ...builtInExtensions, piBashExtension(options.cwd), sessionToolsExtensionFactory, ], - additionalExtensionPaths: [bundledFff], + additionalExtensionPaths: [bundledFff, ...(parsed.extensions ?? [])], }, }); const resolved = resolveCliModel({ diff --git a/packages/server/src/harness/pi/rpc/rpc-mode.ts b/packages/server/src/harness/pi/rpc/rpc-mode.ts index 2d0222226..de09581eb 100644 --- a/packages/server/src/harness/pi/rpc/rpc-mode.ts +++ b/packages/server/src/harness/pi/rpc/rpc-mode.ts @@ -442,10 +442,9 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { preflightSucceeded = true; - // 0.99 reports disposition instead of a boolean. isStreaming is - // already true for a prompt that just started, so it cannot mean - // "queued". - output(success(id, "prompt", { started: disposition === "started" })); + // Keep the boolean for older callers, but distinguish handled input + // from a queued prompt: a handled command need not start a model turn. + output(success(id, "prompt", { started: disposition === "started", disposition })); }, }) .catch((cause: unknown) => { diff --git a/packages/server/src/harness/pi/rpc/rpc-types.ts b/packages/server/src/harness/pi/rpc/rpc-types.ts index e22de8388..a83febf60 100644 --- a/packages/server/src/harness/pi/rpc/rpc-types.ts +++ b/packages/server/src/harness/pi/rpc/rpc-types.ts @@ -144,7 +144,7 @@ export type RpcResponse = type: "response"; command: "prompt"; success: true; - data: { started: boolean }; + data: { started: boolean; disposition?: "started" | "queued" | "handled" }; } | { id?: string; type: "response"; command: "steer"; success: true } | { id?: string; type: "response"; command: "follow_up"; success: true } diff --git a/packages/server/test/harness/pi/agent.test.ts b/packages/server/test/harness/pi/agent.test.ts index 2c6a128ee..67a74fade 100644 --- a/packages/server/test/harness/pi/agent.test.ts +++ b/packages/server/test/harness/pi/agent.test.ts @@ -93,6 +93,8 @@ rl.on("line", (line) => { } if (msg.type !== "prompt") return; const text = msg.message; + if (text === "handled") { send({ id: msg.id, type: "response", command: "prompt", success: true, data: { started: false, disposition: "handled" } }); return; } + if (text === "queued-without-turn") { send({ id: msg.id, type: "response", command: "prompt", success: true, data: { started: false, disposition: "queued" } }); return; } if (text === "fail") { send({ id: msg.id, type: "response", command: "prompt", success: false, error: "cannot prompt" }); return; } if (holding && !msg.streamingBehavior) { send({ id: msg.id, type: "response", command: "prompt", success: false, error: "Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message." }); @@ -192,6 +194,40 @@ layer(NodeServices.layer)("PiAgent", (it) => { }), ); + it.effect("accepts an extension-handled input while idle without waiting for a model turn", () => + Effect.gen(function* () { + const executable = fakeExecutable(); + const process = yield* makePiProcess({ executable }); + const pi = yield* makePiAgent(process, { executable }); + const session = yield* pi.create({ cwd: "/tmp" }); + const handled = yield* session.prompt({ parts: [{ type: "text", text: "handled" }] }); + assert.equal(handled.started, false); + assert.equal(typeof handled.turnId, "string"); + const collected = yield* Stream.runCollect( + session.events.pipe(Stream.takeUntil((event) => event.body.type === "session.turn.ended")), + ).pipe(Effect.forkChild); + const ordinary = yield* session.prompt({ parts: [{ type: "text", text: "ping" }] }); + assert.equal(ordinary.started, true); + const events = yield* Fiber.join(collected); + assert.equal(events.filter((event) => event.body.type === "session.turn.started").length, 1); + assert.equal(events.filter((event) => event.body.type === "session.turn.ended").length, 1); + assert.ok(!events.some((event) => event.body.type === "session.crashed")); + yield* session.close; + }), + ); + + it.effect("still rejects a queued admission without an active turn", () => + Effect.gen(function* () { + const process = yield* makePiProcess({ executable: fakeExecutable() }); + const { sessionId } = yield* process.session.create({ cwd: "/tmp" }); + const error = yield* process.session + .prompt({ sessionId, text: "queued-without-turn" }) + .pipe(Effect.flip); + assert.equal(error._tag, "AgentOperationError"); + yield* process.session.abort(sessionId); + }), + ); + it.effect("resume keeps the caller-provided session id", () => Effect.gen(function* () { const agent = yield* makePiProcess({ executable: { command: makeFake(), prefixArgs: [] } }); @@ -647,7 +683,7 @@ layer(NodeServices.layer)("PiAgent", (it) => { const sidIndex = process.argv.indexOf("--session-id"); const isResume = process.argv[sidIndex + 1] === "existing"; const hasModel = process.argv.includes("--provider") && process.argv.includes("p") && process.argv.includes("--model") && process.argv.includes("m"); -if (!process.argv.includes("--approve") || !process.argv.includes("--no-extensions") || (!isResume && !hasModel)) process.exit(9); +if (!process.argv.includes("--approve") || process.argv.includes("--no-extensions") || (!isResume && !hasModel)) process.exit(9); const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin }); rl.on("line", (line) => { diff --git a/packages/server/test/harness/pi/list-available-commands.test.ts b/packages/server/test/harness/pi/list-available-commands.test.ts index e2a81dd44..01fc23c86 100644 --- a/packages/server/test/harness/pi/list-available-commands.test.ts +++ b/packages/server/test/harness/pi/list-available-commands.test.ts @@ -59,9 +59,10 @@ layer(NodeServices.layer)("listAvailablePiCommands", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "pie-commands-" }); + const agentDir = yield* fs.makeTempDirectoryScoped({ prefix: "pie-agent-commands-" }); yield* writeProjectResources(cwd, "explain", "review"); - const commands = yield* Effect.promise(() => listAvailablePiCommands(cwd)); + const commands = yield* Effect.promise(() => listAvailablePiCommands(cwd, agentDir)); const explain = commands.find((command) => command.name === "explain"); const review = commands.find((command) => command.name === "skill:review"); @@ -140,6 +141,7 @@ layer(NodeServices.layer)("listAvailablePiCommands", (it) => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "pie-command-extension-" }); + const agentDir = yield* fs.makeTempDirectoryScoped({ prefix: "pie-agent-extension-" }); const extensionDirectory = path.join(cwd, ".pi", "extensions"); yield* fs.makeDirectory(extensionDirectory, { recursive: true }); yield* fs.writeFileString( @@ -153,7 +155,7 @@ layer(NodeServices.layer)("listAvailablePiCommands", (it) => { `, ); - const commands = yield* Effect.promise(() => listAvailablePiCommands(cwd)); + const commands = yield* Effect.promise(() => listAvailablePiCommands(cwd, agentDir)); assert.deepEqual( commands.find((command) => command.name === "marker"), { name: "marker", description: "Marker command", source: "extension" }, @@ -164,13 +166,14 @@ layer(NodeServices.layer)("listAvailablePiCommands", (it) => { it.effect("keeps command discovery scoped to the requested Project", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; + const agentDir = yield* fs.makeTempDirectoryScoped({ prefix: "pie-agent-scopes-" }); const first = yield* fs.makeTempDirectoryScoped({ prefix: "pie-commands-a-" }); const second = yield* fs.makeTempDirectoryScoped({ prefix: "pie-commands-b-" }); yield* writeProjectResources(first, "first-prompt", "first-skill"); yield* writeProjectResources(second, "second-prompt", "second-skill"); - const firstCommands = yield* Effect.promise(() => listAvailablePiCommands(first)); - const secondCommands = yield* Effect.promise(() => listAvailablePiCommands(second)); + const firstCommands = yield* Effect.promise(() => listAvailablePiCommands(first, agentDir)); + const secondCommands = yield* Effect.promise(() => listAvailablePiCommands(second, agentDir)); assert.ok(firstCommands.some((command) => command.name === "first-prompt")); assert.ok(firstCommands.some((command) => command.name === "skill:first-skill")); diff --git a/packages/server/test/harness/pi/list-available-models.test.ts b/packages/server/test/harness/pi/list-available-models.test.ts index aca0032ca..d3b045338 100644 --- a/packages/server/test/harness/pi/list-available-models.test.ts +++ b/packages/server/test/harness/pi/list-available-models.test.ts @@ -4,38 +4,133 @@ import os from "node:os"; import path from "node:path"; import { Effect } from "effect"; -import { describe, it } from "vitest"; +import { afterEach, describe, it, vi } from "vitest"; + +afterEach(() => vi.unstubAllEnvs()); import { listAvailablePiModels } from "../../../src/harness/pi/list-available-models"; describe("listAvailablePiModels", () => { it("returns AgentModel rows and a default from Pi ModelRuntime without pie-pi-process", async () => { - const listed = await Effect.runPromise(listAvailablePiModels(process.cwd())); - assert.ok(Array.isArray(listed.models)); - for (const model of listed.models) { - assert.equal(typeof model.provider, "string"); - assert.equal(typeof model.modelId, "string"); - if (model.name !== undefined) assert.equal(typeof model.name, "string"); - } - if (listed.defaultModel) { - assert.equal(typeof listed.defaultModel.provider, "string"); - assert.equal(typeof listed.defaultModel.modelId, "string"); + const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "pie-model-rows-")); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + vi.stubEnv("PI_OFFLINE", "1"); + fs.writeFileSync( + path.join(agentDir, "models.json"), + JSON.stringify({ + providers: { + "rows-test": { + api: "openai-responses", + baseUrl: "http://127.0.0.1:1/v1", + apiKey: "synthetic-key", + models: [{ id: "model" }], + }, + }, + }), + ); + try { + const listed = await Effect.runPromise(listAvailablePiModels()); + assert.ok(listed.models.some((model) => model.provider === "rows-test")); + for (const model of listed.models) { + assert.equal(typeof model.provider, "string"); + assert.equal(typeof model.modelId, "string"); + if (model.name !== undefined) assert.equal(typeof model.name, "string"); + } + if (listed.defaultModel) { + assert.equal(typeof listed.defaultModel.provider, "string"); + assert.equal(typeof listed.defaultModel.modelId, "string"); + } + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); } }); - it("does not execute Project extensions while reading the model list", async () => { + it.each(["directory", "package"])( + "discovers a provider from a user %s extension without static models", + async (source) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pie-user-models-")); + const agentDir = path.join(root, "agent"); + const cwd = path.join(root, "project"); + const extensionDirectory = path.join(agentDir, "extensions"); + const packageDirectory = path.join(root, "plugin"); + fs.mkdirSync(extensionDirectory, { recursive: true }); + fs.mkdirSync(packageDirectory); + fs.mkdirSync(cwd); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + vi.stubEnv("PI_OFFLINE", "1"); + fs.writeFileSync( + path.join(agentDir, "models.json"), + JSON.stringify({ + providers: { + "user-proxy": { + baseUrl: "http://127.0.0.1:1/v1", + api: "openai-responses", + apiKey: "synthetic-test-key", + }, + }, + }), + ); + fs.writeFileSync( + path.join(agentDir, "settings.json"), + JSON.stringify({ + defaultProvider: "user-proxy", + defaultModel: "discovered", + packages: source === "package" ? [packageDirectory] : [], + }), + ); + const extension = `export default async function (pi) { + pi.registerProvider("user-proxy", { + baseUrl: "http://127.0.0.1:1/v1", api: "openai-responses", + models: [{ id: "discovered", name: "Discovered Model", reasoning: false, + input: ["text"], contextWindow: 128000, maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }], + }); + }`; + if (source === "package") { + fs.writeFileSync( + path.join(packageDirectory, "package.json"), + JSON.stringify({ name: "test-user-plugin", pi: { extensions: ["provider.mjs"] } }), + ); + fs.writeFileSync(path.join(packageDirectory, "provider.mjs"), extension); + } else { + fs.writeFileSync(path.join(extensionDirectory, "provider.ts"), extension); + } + + try { + const listed = await Effect.runPromise(listAvailablePiModels(cwd)); + const model = { provider: "user-proxy", modelId: "discovered", name: "Discovered Model" }; + assert.deepEqual( + listed.models.filter((row) => row.provider === "user-proxy"), + [model], + ); + assert.deepEqual(listed.defaultModel, model); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }, + ); + + it("loads Project extensions only for an explicitly supplied Project", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "pie-model-policy-")); + vi.stubEnv("PI_CODING_AGENT_DIR", cwd); + vi.stubEnv("PI_OFFLINE", "1"); const extensionDirectory = path.join(cwd, ".pi", "extensions"); const marker = path.join(cwd, "extension-loaded"); fs.mkdirSync(extensionDirectory, { recursive: true }); + fs.writeFileSync( + path.join(cwd, "settings.json"), + JSON.stringify({ defaultProjectTrust: "always" }), + ); fs.writeFileSync( path.join(extensionDirectory, "marker.ts"), `import fs from "node:fs";\nfs.writeFileSync(${JSON.stringify(marker)}, "loaded");\nexport default function markerExtension() {}\n`, ); try { - await Effect.runPromise(listAvailablePiModels(cwd)); + await Effect.runPromise(listAvailablePiModels()); assert.equal(fs.existsSync(marker), false); + await Effect.runPromise(listAvailablePiModels(cwd)); + assert.equal(fs.existsSync(marker), true); } finally { fs.rmSync(cwd, { recursive: true, force: true }); } diff --git a/packages/server/test/harness/pi/local-extensions.test.ts b/packages/server/test/harness/pi/local-extensions.test.ts new file mode 100644 index 000000000..62ee56ed1 --- /dev/null +++ b/packages/server/test/harness/pi/local-extensions.test.ts @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import url from "node:url"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { layer } from "@effect/vitest"; +import { Effect, FileSystem, Stream } from "effect"; +import { afterEach, vi } from "vitest"; + +import { listAvailablePiCommands } from "../../../src/harness/pi/list-available-commands"; +import { listAvailablePiModels } from "../../../src/harness/pi/list-available-models"; +import { makePiProcess } from "../../../src/harness/pi/process"; +import { resolvePiExecutable } from "../../../src/harness/pi/resolve-executable"; +import { makePiTransport } from "../../../src/harness/pi/transport"; + +const provider = url.fileURLToPath( + new URL("../../../../../tools/testing/fake-e2e-provider.ts", import.meta.url), +); + +afterEach(() => vi.unstubAllEnvs()); + +const fixture = (source: "directory" | "package") => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "pie-local-extensions-" }); + const agentDir = path.join(root, "agent"); + const cwd = path.join(root, "project"); + const extensions = path.join(agentDir, "extensions"); + const plugin = path.join(root, "plugin"); + yield* fs.makeDirectory(extensions, { recursive: true }); + yield* fs.makeDirectory(cwd); + yield* fs.makeDirectory(plugin); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + vi.stubEnv("PI_OFFLINE", "1"); + yield* fs.copyFile( + provider, + path.join(source === "package" ? plugin : extensions, "provider.ts"), + ); + yield* fs.writeFileString( + path.join(plugin, "package.json"), + JSON.stringify({ name: "test-local-plugin", pi: { extensions: ["provider.ts"] } }), + ); + yield* fs.writeFileString( + path.join(agentDir, "settings.json"), + JSON.stringify({ + defaultProvider: "e2e", + defaultModel: "fake", + packages: source === "package" ? [plugin] : [], + retry: { enabled: false }, + }), + ); + yield* fs.writeFileString( + path.join(extensions, "commands.ts"), + `export default function (pi) { + pi.registerCommand("local-ping", { description: "Local ping", handler: async () => {} }); + pi.on("input", (event) => event.text === "handled-input" ? { action: "handled" } : { action: "continue" }); + }`, + ); + return { agentDir, cwd }; + }); + +layer(NodeServices.layer, { excludeTestServices: true })( + "local Pi extensions in the real bundle", + (it) => { + for (const source of ["directory", "package"] as const) { + it.effect( + `discovers and runs a user ${source} plugin across create/resume and handled inputs`, + () => + Effect.gen(function* () { + const { agentDir, cwd } = yield* fixture(source); + const listed = yield* listAvailablePiModels(cwd); + assert.deepEqual(listed.defaultModel, { + provider: "e2e", + modelId: "fake", + name: "E2E Fake", + }); + const commands = yield* Effect.promise(() => listAvailablePiCommands(cwd, agentDir)); + assert.ok( + commands.some( + (command) => command.name === "local-ping" && command.source === "extension", + ), + ); + const process = yield* makePiProcess({ executable: resolvePiExecutable() }); + const created = yield* process.session.create({ + cwd, + provider: "e2e", + modelId: "fake", + }); + for (const text of ["/local-ping", "handled-input"]) { + const handled = yield* process.session.prompt({ sessionId: created.sessionId, text }); + assert.equal(handled.started, false); + assert.deepEqual(Array.from(yield* Stream.runCollect(handled.output)), []); + } + const ordinary = yield* process.session.prompt({ + sessionId: created.sessionId, + text: "hello", + }); + assert.equal(ordinary.started, true); + const chunks = Array.from(yield* Stream.runCollect(ordinary.output)); + assert.ok( + chunks.some( + (chunk) => chunk.type === "text-delta" && chunk.delta === "E2E fake Pi reply", + ), + ); + assert.equal(chunks.at(-1)?.type, "finish"); + yield* process.session.abort(created.sessionId); + const resumed = yield* process.session.resume({ cwd, sessionId: created.sessionId }); + assert.equal(resumed.sessionId, created.sessionId); + assert.equal((yield* process.session.getModelState(resumed.sessionId)).provider, "e2e"); + const handled = yield* process.session.prompt({ + sessionId: resumed.sessionId, + text: "/local-ping", + }); + assert.equal(handled.started, false); + const followup = yield* process.session.prompt({ + sessionId: resumed.sessionId, + text: "hello again", + }); + assert.equal(followup.started, true); + assert.equal( + Array.from(yield* Stream.runCollect(followup.output)).at(-1)?.type, + "finish", + ); + yield* process.session.abort(resumed.sessionId); + }), + 60000, + ); + } + + it.effect( + "honors explicit extension opt-out and Project trust in the bundled child", + () => + Effect.gen(function* () { + const { agentDir, cwd } = yield* fixture("directory"); + const fs = yield* FileSystem.FileSystem; + const projectExtensions = path.join(cwd, ".pi", "extensions"); + const marker = path.join(cwd, "project-extension-loaded"); + yield* fs.makeDirectory(projectExtensions, { recursive: true }); + yield* fs.writeFileString( + path.join(projectExtensions, "marker.ts"), + `import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(marker)}, "loaded"); export default function () {}`, + ); + const transport = yield* makePiTransport({ + executable: resolvePiExecutable(), + cwd, + args: ["--no-approve"], + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + const untrusted = yield* transport.command<{ models: Array<{ provider: string }> }>({ + type: "get_available_models", + }); + assert.ok(untrusted.models.some((model) => model.provider === "e2e")); + assert.equal(yield* fs.exists(marker), false); + const approved = yield* makePiTransport({ + executable: resolvePiExecutable(), + cwd, + args: ["--approve"], + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + yield* approved.command({ type: "get_state" }); + assert.equal(yield* fs.exists(marker), true); + yield* fs.remove(marker); + const disabled = yield* makePiTransport({ + executable: resolvePiExecutable(), + cwd, + args: ["--approve", "--no-extensions"], + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + const without = yield* disabled.command<{ models: Array<{ provider: string }> }>({ + type: "get_available_models", + }); + assert.ok(!without.models.some((model) => model.provider === "e2e")); + assert.equal(yield* fs.exists(marker), false); + const explicit = yield* makePiTransport({ + executable: resolvePiExecutable(), + cwd, + args: [ + "--no-approve", + "--no-extensions", + "--extension", + path.join(agentDir, "extensions/provider.ts"), + ], + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + const selected = yield* explicit.command<{ models: Array<{ provider: string }> }>({ + type: "get_available_models", + }); + assert.ok(selected.models.some((model) => model.provider === "e2e")); + assert.equal(yield* fs.exists(marker), false); + }), + 60000, + ); + }, +); From 3e21e19d34c646116aeda6389f4534b2b192975f Mon Sep 17 00:00:00 2001 From: dinq Date: Thu, 1 Oct 2026 13:35:55 +0800 Subject: [PATCH 2/3] test(server): preserve extension command opt-out coverage --- .../harness/pi/list-available-commands.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/server/test/harness/pi/list-available-commands.test.ts b/packages/server/test/harness/pi/list-available-commands.test.ts index 01fc23c86..b582908fc 100644 --- a/packages/server/test/harness/pi/list-available-commands.test.ts +++ b/packages/server/test/harness/pi/list-available-commands.test.ts @@ -6,6 +6,8 @@ import { layer } from "@effect/vitest"; import { Effect, FileSystem } from "effect"; import { listAvailablePiCommands } from "../../../src/harness/pi/list-available-commands"; +import { resolvePiExecutable } from "../../../src/harness/pi/resolve-executable"; +import { makePiTransport } from "../../../src/harness/pi/transport"; const writeResources = ( promptDirectory: string, @@ -160,6 +162,20 @@ layer(NodeServices.layer)("listAvailablePiCommands", (it) => { commands.find((command) => command.name === "marker"), { name: "marker", description: "Marker command", source: "extension" }, ); + + const disabled = yield* makePiTransport({ + executable: resolvePiExecutable(), + cwd, + args: ["--approve", "--no-extensions"], + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + const hidden = yield* disabled.command<{ commands?: Array<{ name: string }> }>({ + type: "get_commands", + }); + assert.equal( + hidden.commands?.some((command) => command.name === "marker"), + false, + ); }), ); From 67aa9ee5fc9893c061851452c08ebe62a64e4f33 Mon Sep 17 00:00:00 2001 From: dinq Date: Thu, 1 Oct 2026 14:45:07 +0800 Subject: [PATCH 3/3] fix(server): unblock extension startup dialogs Read startup UI responses while gating ordinary RPC commands on binding. Start parent UI routing before readiness and explicitly decline discovery dialogs. Cancel dialogs on EOF, drain admitted commands, and propagate graceful exit through the RPC entry point. Add real bundled-child startup and trust regressions. Serialize process-heavy verifier files to avoid full-suite startup starvation without changing security assertions or timeouts. --- CONTEXT.md | 2 +- .../src/harness/pi/list-available-commands.ts | 8 +- packages/server/src/harness/pi/process.ts | 43 ++--- .../server/src/harness/pi/rpc/rpc-mode.ts | 63 +++++-- .../test/harness/pi/startup-dialogs.test.ts | 165 ++++++++++++++++++ tools/verify/vitest.config.ts | 4 + 6 files changed, 246 insertions(+), 39 deletions(-) create mode 100644 packages/server/test/harness/pi/startup-dialogs.test.ts diff --git a/CONTEXT.md b/CONTEXT.md index b5318e29c..dfb022ea6 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,7 @@ Glossary of project-specific terms. pie integrates the Pi coding agent into the ## Session Domain **Project**: -A working directory the user has registered with the server, identified by a server-generated UUID. The single source of the projectId → directory mapping; the directory field is `path`. Registration is the trust boundary for Project-local Pi resources: session children approve extensions, prompts, skills, and context from that Project. Pi loads the user's extensions and configured packages for model/command discovery and live sessions, including provider registrations. Discovery without a Project does not approve Project-local resources. Extension commands and input hooks can handle a prompt without starting a model turn; Pie acknowledges that input without creating or waiting for a turn. Supported extension UI requests use the Pi protocol seam; terminal-only custom UI remains unavailable. Sessions always resolve their working directory through a Project, never from a caller-supplied path. A session may start without picking an existing Project: `project.allocateChatProjectDir` creates a folder under the **chat-project root**, registers it with `type: "chat"`, then `session.create` uses that id as usual. Chat projects stay off the **Projects** sidebar and picker; their sessions appear under **Recent**. +A working directory the user has registered with the server, identified by a server-generated UUID. The single source of the projectId → directory mapping; the directory field is `path`. Registration is the trust boundary for Project-local Pi resources: session children approve extensions, prompts, skills, and context from that Project. Pi loads the user's extensions and configured packages for model/command discovery and live sessions, including provider registrations. Discovery without a Project does not approve Project-local resources. Extension commands and input hooks can handle a prompt without starting a model turn; Pie acknowledges that input without creating or waiting for a turn. Supported extension UI requests use the Pi protocol seam. Blocking dialogs without an active turn (including startup and noninteractive command discovery) receive an explicit decline/cancellation; they are never auto-approved or left waiting for a consumer. Terminal-only custom UI remains unavailable. Sessions always resolve their working directory through a Project, never from a caller-supplied path. A session may start without picking an existing Project: `project.allocateChatProjectDir` creates a folder under the **chat-project root**, registers it with `type: "chat"`, then `session.create` uses that id as usual. Chat projects stay off the **Projects** sidebar and picker; their sessions appear under **Recent**. _Avoid_: workspace, repo, cwd (for the Project field) **Chat-project root**: diff --git a/packages/server/src/harness/pi/list-available-commands.ts b/packages/server/src/harness/pi/list-available-commands.ts index 0ea8ba869..5b5726016 100644 --- a/packages/server/src/harness/pi/list-available-commands.ts +++ b/packages/server/src/harness/pi/list-available-commands.ts @@ -1,9 +1,10 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { AgentCommand } from "@getpie/contract"; -import { Effect } from "effect"; +import { Effect, Stream } from "effect"; import { PI_PROJECT_PROCESS_ARGS } from "./project-resource-policy"; +import { declineUiResponse } from "./request"; import { resolvePiExecutable } from "./resolve-executable"; import { makePiTransport } from "./transport"; @@ -24,6 +25,11 @@ export function listAvailablePiCommands( args: cwd === undefined ? [] : PI_PROJECT_PROCESS_ARGS, env: { PI_CODING_AGENT_DIR: agentDir }, }); + // Discovery has no interactive consumer, including during session_start. + // Always send an explicit decline rather than leave a dialog pending. + yield* Stream.runForEach(transport.uiRequests, (request) => + transport.respondUi(declineUiResponse(request)), + ).pipe(Effect.forkScoped); const data = yield* transport.command<{ commands?: AgentCommand[] }>({ type: "get_commands", }); diff --git a/packages/server/src/harness/pi/process.ts b/packages/server/src/harness/pi/process.ts index 26878faba..d0445ad56 100644 --- a/packages/server/src/harness/pi/process.ts +++ b/packages/server/src/harness/pi/process.ts @@ -417,21 +417,6 @@ export const makePiProcessWithDependencies = ( Effect.sync(() => dependencies.onExit?.(sessionId, transport.pid)), ); - // Readiness handshake: pi's CLI front-end resolves the session (and - // may exit with a human-readable error) before the RPC loop starts. - yield* transport.command({ type: "get_state" }).pipe( - Effect.timeoutOrElse({ - duration: HANDSHAKE_TIMEOUT, - orElse: () => - Effect.fail( - new PiTransportError({ - operation: "handshake-timeout", - cause: new Error("Pi RPC get_state handshake timed out"), - }), - ), - }), - ); - const session: SessionState = { sessionId, scope, @@ -449,18 +434,34 @@ export const makePiProcessWithDependencies = ( turnState: yield* Ref.make({ _tag: "Idle" }), transform: createPiTransform(sessionId), }; - yield* Ref.update(sessions, (current) => new Map(current).set(sessionId, session)); - - yield* Stream.runForEach(transport.events, (event) => routeEvent(session, event)).pipe( - Effect.catch((error) => reportCrash(session, error)), - Effect.forkIn(scope), - ); + // Startup has no active turn or question consumer. Route UI now so + // session_start receives the existing safe decline, not a deadlock. yield* Stream.runForEach(transport.uiRequests, (request) => Effect.forkIn(handleUiRequest(session, request), scope).pipe(Effect.asVoid), ).pipe( Effect.catch((error) => reportCrash(session, error)), Effect.forkIn(scope), ); + // Readiness still waits for Pi to resolve the session and finish + // extension startup before publishing it to callers. + yield* transport.command({ type: "get_state" }).pipe( + Effect.timeoutOrElse({ + duration: HANDSHAKE_TIMEOUT, + orElse: () => + Effect.fail( + new PiTransportError({ + operation: "handshake-timeout", + cause: new Error("Pi RPC get_state handshake timed out"), + }), + ), + }), + ); + yield* Ref.update(sessions, (current) => new Map(current).set(sessionId, session)); + + yield* Stream.runForEach(transport.events, (event) => routeEvent(session, event)).pipe( + Effect.catch((error) => reportCrash(session, error)), + Effect.forkIn(scope), + ); yield* transport.awaitTermination.pipe( Effect.catch((error) => reportCrash(session, error)), Effect.forkIn(scope), diff --git a/packages/server/src/harness/pi/rpc/rpc-mode.ts b/packages/server/src/harness/pi/rpc/rpc-mode.ts index de09581eb..615eebe6b 100644 --- a/packages/server/src/harness/pi/rpc/rpc-mode.ts +++ b/packages/server/src/harness/pi/rpc/rpc-mode.ts @@ -63,6 +63,14 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void) | undefined; let unsubscribeBackpressure: (() => void) | undefined; + const exit = { + reject: (_cause: unknown) => { + /* assigned synchronously by the exit promise below */ + }, + }; + const exited = new Promise((_resolve, reject) => { + exit.reject = reject; + }); const output = (frame: unknown) => { writeRawStdout(serializeJsonLine(frame)); @@ -91,6 +99,15 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise void; reject: (error: Error) => void } >(); + let inputEnded = false; + const cancelExtensionDialogs = () => { + inputEnded = true; + for (const [id, pending] of pendingExtensionRequests) { + pending.resolve({ type: "extension_ui_response", id, cancelled: true }); + } + pendingExtensionRequests.clear(); + }; + // Shutdown request flag let shutdownRequested = false; let shuttingDown = false; @@ -103,7 +120,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise, parseResponse: (response: RpcExtensionUIResponse) => T, ): Promise { - if (opts?.signal?.aborted) return Promise.resolve(defaultValue); + if (inputEnded || opts?.signal?.aborted) return Promise.resolve(defaultValue); const id = crypto.randomUUID(); return new Promise((resolve, reject) => { @@ -273,6 +290,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { + if (inputEnded) return undefined; const id = crypto.randomUUID(); return new Promise((resolve, reject) => { pendingExtensionRequests.set(id, { @@ -391,6 +409,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { @@ -412,16 +431,13 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - void shutdown(signal === "SIGHUP" ? 129 : 143, signal); + void shutdown(signal === "SIGHUP" ? 129 : 143, signal).catch(exit.reject); }; process.on(signal, handler); signalCleanupHandlers.push(() => process.off(signal, handler)); } }; - await rebindSession(); - registerSignalHandlers(); - // Handle a single command const handleCommand = async (command: RpcCommand): Promise => { const id = command.id; @@ -777,11 +793,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - if (shuttingDown) { - throw new RpcChildExitError(exitCode); - } + async function shutdown(exitCode = 0, signal?: NodeJS.Signals): Promise { + if (shuttingDown) return; shuttingDown = true; + cancelExtensionDialogs(); for (const cleanup of signalCleanupHandlers) { cleanup(); } @@ -793,7 +808,7 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { @@ -837,6 +852,10 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise>(); + const onInputEnd = () => { - void shutdown(); + cancelExtensionDialogs(); + // Finite JSONL input can arrive while extensions are still binding. Let + // admitted commands finish before removing their event subscriptions. + void binding + .then(() => Promise.all(pendingInput)) + .then(() => shutdown()) + .catch(exit.reject); }; process.stdin.on("end", onInputEnd); detachInput = (() => { const detachJsonl = attachJsonlLineReader(process.stdin, (line) => { - void handleInputLine(line); + const handling = handleInputLine(line); + pendingInput.add(handling); + void handling.finally(() => pendingInput.delete(handling)).catch(exit.reject); }); return () => { detachJsonl(); @@ -870,8 +900,9 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise { - /* the child stays alive until shutdown() exits the process */ - }); + registerSignalHandlers(); + await Promise.race([binding, exited]); + + // Propagate shutdown to entry.ts rather than throwing in a detached callback. + return exited; } diff --git a/packages/server/test/harness/pi/startup-dialogs.test.ts b/packages/server/test/harness/pi/startup-dialogs.test.ts new file mode 100644 index 000000000..854eed736 --- /dev/null +++ b/packages/server/test/harness/pi/startup-dialogs.test.ts @@ -0,0 +1,165 @@ +import assert from "node:assert/strict"; +import path from "node:path"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { layer } from "@effect/vitest"; +import { Effect, Fiber, FileSystem, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { afterEach, vi } from "vitest"; + +import { listAvailablePiCommands } from "../../../src/harness/pi/list-available-commands"; +import { makePiProcess } from "../../../src/harness/pi/process"; +import { mapUiResponse } from "../../../src/harness/pi/request"; +import { resolvePiExecutable } from "../../../src/harness/pi/resolve-executable"; +import { makePiTransport } from "../../../src/harness/pi/transport"; + +const fixture = () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "pie-startup-dialogs-" }); + const agentDir = path.join(root, "agent"); + const cwd = path.join(root, "project"); + const marker = path.join(root, "answers.json"); + yield* fs.makeDirectory(path.join(agentDir, "extensions"), { recursive: true }); + yield* fs.makeDirectory(cwd); + vi.stubEnv("PI_CODING_AGENT_DIR", agentDir); + vi.stubEnv("PI_OFFLINE", "1"); + yield* fs.writeFileString( + path.join(agentDir, "extensions", "startup.ts"), + `import fs from "node:fs"; + export default function (pi) { + pi.registerCommand("startup-done", { handler: async () => {} }); + pi.on("session_start", async (_event, ctx) => { + if (!ctx.hasUI) throw new Error("RPC UI is unavailable"); + // Bound regressions without relying on a test timeout to reap the child. + const deadline = setTimeout(() => process.exit(7), 5000); + try { + const confirmed = await ctx.ui.confirm("Startup confirmation", "Continue?"); + const selected = await ctx.ui.select("Startup selection", ["Continue"]); + const input = await ctx.ui.input("Startup input"); + const editor = await ctx.ui.editor("Startup editor"); + fs.writeFileSync(${JSON.stringify(marker)}, JSON.stringify({ + confirmed, selected: selected ?? null, input: input ?? null, editor: editor ?? null, + })); + } finally { clearTimeout(deadline); } + }); + }`, + ); + return { agentDir, cwd, marker, fs }; + }); + +const declined = { confirmed: false, selected: null, input: null, editor: null }; + +afterEach(() => vi.unstubAllEnvs()); + +layer(NodeServices.layer, { excludeTestServices: true })("Pi startup dialogs", (it) => { + it.effect("reads startup UI replies before the initial get_state completes", () => + Effect.gen(function* () { + const { agentDir, cwd, marker, fs } = yield* fixture(); + const transport = yield* makePiTransport({ + executable: resolvePiExecutable(), + cwd, + env: { PI_CODING_AGENT_DIR: agentDir }, + }); + yield* Stream.runForEach(transport.uiRequests, (request) => + transport.respondUi( + mapUiResponse(request, { + type: "question", + answers: [ + { + questionId: request.id, + values: [request.method === "confirm" ? "Yes" : "Continue"], + }, + ], + }), + ), + ).pipe(Effect.forkScoped); + yield* transport.command({ type: "get_state" }); + assert.deepEqual(JSON.parse(yield* fs.readFileString(marker)), { + confirmed: true, + selected: "Continue", + input: "Continue", + editor: "Continue", + }); + }), + ); + + it.effect( + "declines startup dialogs before the parent readiness handshake on create and resume", + () => + Effect.gen(function* () { + const { cwd, marker, fs } = yield* fixture(); + const process = yield* makePiProcess({ executable: resolvePiExecutable() }); + const created = yield* process.session.create({ cwd }); + assert.deepEqual(JSON.parse(yield* fs.readFileString(marker)), declined); + yield* process.session.abort(created.sessionId); + yield* fs.remove(marker); + const resumed = yield* process.session.resume({ cwd, sessionId: created.sessionId }); + assert.deepEqual(JSON.parse(yield* fs.readFileString(marker)), declined); + yield* process.session.abort(resumed.sessionId); + }), + ); + + it.effect( + "cancels unanswered startup dialogs on EOF and drains admitted commands before exit", + () => + Effect.gen(function* () { + const { agentDir, cwd, marker, fs } = yield* fixture(); + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const executable = resolvePiExecutable(); + const child = yield* spawner.spawn( + ChildProcess.make(executable.command, [...executable.prefixArgs, "--mode", "rpc"], { + cwd, + env: { + PATH: process.env.PATH, + HOME: agentDir, + PI_CODING_AGENT_DIR: agentDir, + PI_OFFLINE: "1", + }, + stdin: Stream.make('{"id":"state","type":"get_state"}\n').pipe(Stream.encodeText), + }), + ); + const stderr = yield* Stream.runCollect(child.stderr.pipe(Stream.decodeText())).pipe( + Effect.forkScoped, + ); + const output = (yield* Stream.runCollect(child.stdout.pipe(Stream.decodeText()))).join(""); + assert.equal(yield* child.exitCode, 0, (yield* Fiber.join(stderr)).join("")); + assert.match(output, /"id":"state","type":"response","command":"get_state","success":true/); + assert.deepEqual(JSON.parse(yield* fs.readFileString(marker)), declined); + }), + ); + + it.effect("does not approve incidental Project extensions during global command discovery", () => + Effect.gen(function* () { + const { agentDir, fs } = yield* fixture(); + const projectExtensions = path.join(agentDir, ".pi", "extensions"); + const marker = path.join(agentDir, "incidental-project-loaded"); + yield* fs.makeDirectory(projectExtensions, { recursive: true }); + yield* fs.writeFileString( + path.join(agentDir, "settings.json"), + JSON.stringify({ defaultProjectTrust: "always" }), + ); + yield* fs.writeFileString( + path.join(projectExtensions, "incidental.ts"), + `import fs from "node:fs"; + fs.writeFileSync(${JSON.stringify(marker)}, "loaded"); + export default function (pi) { + pi.registerCommand("incidental", { handler: async () => {} }); + }`, + ); + const commands = yield* Effect.promise(() => listAvailablePiCommands(undefined, agentDir)); + assert.ok(commands.some((command) => command.name === "startup-done")); + assert.ok(!commands.some((command) => command.name === "incidental")); + assert.equal(yield* fs.exists(marker), false); + }), + ); + + it.effect("declines startup dialogs during noninteractive command discovery", () => + Effect.gen(function* () { + const { agentDir, cwd, marker, fs } = yield* fixture(); + const commands = yield* Effect.promise(() => listAvailablePiCommands(cwd, agentDir)); + assert.ok(commands.some((command) => command.name === "startup-done")); + assert.deepEqual(JSON.parse(yield* fs.readFileString(marker)), declined); + }), + ); +}); diff --git a/tools/verify/vitest.config.ts b/tools/verify/vitest.config.ts index a6f0c9117..7643f6e2b 100644 --- a/tools/verify/vitest.config.ts +++ b/tools/verify/vitest.config.ts @@ -5,6 +5,10 @@ export default defineConfig({ name: "verify", environment: "node", fsModuleCache: true, + // These files spawn CLI/browser/process-tree fixtures. Concurrent files + // starve startup deadlines under the full suite; per-test isolation cases + // still exercise multiple live runs without relaxing their assertions. + fileParallelism: false, include: ["src/**/*.test.ts"], }, });