Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:
Expand Down
3 changes: 1 addition & 2 deletions packages/server/src/harness/agent-service.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
}),
});

Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/harness/pi/list-available-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }>({
Expand Down
10 changes: 7 additions & 3 deletions packages/server/src/harness/pi/list-available-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ListAgentModelsOutput, AgentOperationError> {
return Effect.gen(function* () {
const { createAgentSessionServices, getAgentDir, SettingsManager } = yield* Effect.tryPromise({
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions packages/server/src/harness/pi/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,13 @@ export const makePiProcessWithDependencies = <R>(

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,
Expand Down
9 changes: 4 additions & 5 deletions packages/server/src/harness/pi/project-resource-policy.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 5 additions & 1 deletion packages/server/src/harness/pi/rpc/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,18 @@ export const main = async (): Promise<void> => {
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({
Expand Down
7 changes: 3 additions & 4 deletions packages/server/src/harness/pi/rpc/rpc-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,9 @@ export async function runRpcMode(runtimeHost: AgentSessionRuntime): Promise<neve
source: "rpc",
preflightResult: (disposition) => {
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) => {
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/harness/pi/rpc/rpc-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
29 changes: 28 additions & 1 deletion packages/server/test/harness/pi/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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." });
Expand Down Expand Up @@ -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: [] } });
Expand Down Expand Up @@ -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) => {
Expand Down
19 changes: 18 additions & 1 deletion packages/server/test/harness/pi/list-available-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
);
}),
);

Expand Down
94 changes: 82 additions & 12 deletions packages/server/test/harness/pi/list-available-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
Loading