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..6d30d490a 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. `handled` is an extension + // command that finished without 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..0146294af 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,31 @@ layer(NodeServices.layer)("PiAgent", (it) => { }), ); + it.effect("accepts an extension-handled input while idle without waiting for a model turn", () => + Effect.gen(function* () { + const agent = yield* makePiProcess({ executable: fakeExecutable() }); + const { sessionId } = yield* agent.session.create({ cwd: "/tmp" }); + const handled = yield* agent.session.prompt({ sessionId, text: "handled" }); + assert.equal(handled.started, false); + assert.deepEqual(Array.from(yield* Stream.runCollect(handled.output)), []); + const ordinary = yield* agent.session.prompt({ sessionId, text: "ping" }); + assert.equal(ordinary.started, true); + yield* agent.session.abort(sessionId); + }), + ); + + it.effect("still rejects a queued admission without an active turn", () => + Effect.gen(function* () { + const agent = yield* makePiProcess({ executable: fakeExecutable() }); + const { sessionId } = yield* agent.session.create({ cwd: "/tmp" }); + const error = yield* agent.session + .prompt({ sessionId, text: "queued-without-turn" }) + .pipe(Effect.flip); + assert.equal(error._tag, "AgentOperationError"); + yield* agent.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 +674,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..865c9eae4 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, @@ -140,6 +142,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,11 +156,25 @@ 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" }, ); + + 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, + ); }), ); 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..6f0307c33 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,108 @@ 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"; import { listAvailablePiModels } from "../../../src/harness/pi/list-available-models"; +afterEach(() => vi.unstubAllEnvs()); + 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"); + 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"); + } + } finally { + fs.rmSync(agentDir, { recursive: true, force: true }); } - if (listed.defaultModel) { - assert.equal(typeof listed.defaultModel.provider, "string"); - assert.equal(typeof listed.defaultModel.modelId, "string"); + }); + + it("discovers a provider from a user extension without static models", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "pie-user-models-")); + const agentDir = path.join(root, "agent"); + const cwd = path.join(root, "project"); + fs.mkdirSync(path.join(agentDir, "extensions"), { recursive: true }); + 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: "k" }, + }, + }), + ); + fs.writeFileSync( + path.join(agentDir, "settings.json"), + JSON.stringify({ defaultProvider: "user-proxy", defaultModel: "discovered" }), + ); + fs.writeFileSync( + path.join(agentDir, "extensions", "provider.ts"), + `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 } }], + }); + }`, + ); + 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("does not execute Project extensions while reading the model list", async () => { + 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 }); }