Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/olive-hounds-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@executor-js/pi": minor
---

Add `@executor-js/pi`, a first-party extension that connects the Pi coding agent to Executor.

Pi ships no MCP client, so Pi users previously needed a third-party bridge to reach Executor. This package registers Executor's core tools — `executor_execute`, `executor_skills`, and `executor_resume` — as native Pi tools and forwards each call over MCP.

Install it with `pi install npm:@executor-js/pi`, then point it at Executor with `EXECUTOR_MCP_URL` and a bearer: `EXECUTOR_API_KEY` for a hosted Executor, or `EXECUTOR_AUTH_TOKEN` for a local or desktop one. `/executor` reports the resolved endpoint and checks the connection.
19 changes: 19 additions & 0 deletions .oxlintrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,25 @@
"executor/no-unknown-error-message": "off",
},
},
{
// boundary: the Pi extension runs inside Pi's runtime, not ours. There
// is no Effect runtime in that process, and throwing is Pi's error
// PROTOCOL, not a shortcut: `AgentToolResult` has no error field, and the
// harness marks a tool call failed exactly when `execute` throws
// (pi-agent-core, harness/execution/tools.js). The MCP client it drives
// is likewise promise-native. Scoped to this package because every file
// in it sits on that boundary; the rules that keep our own failures
// typed (no-double-cast, no-json-parse, no-explicit-any, …) stay on.
"files": ["packages/hosts/pi/**/*.ts"],
"rules": {
"executor/no-error-constructor": "off",
"executor/no-instanceof-error": "off",
"executor/no-promise-catch": "off",
"executor/no-promise-reject": "off",
"executor/no-try-catch-or-throw": "off",
"executor/no-unknown-error-message": "off",
},
},
{
"files": ["packages/kernel/core/src/code-recovery.ts"],
"rules": {
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ executor.
React, API, and testing helpers.
- `packages/react`: shared React UI and client/atom integration.
- `packages/hosts/mcp`: MCP host surface.
- `packages/hosts/pi`: the Pi extension (`@executor-js/pi`). Pi ships no MCP
client, so this bridges Executor's core tools into it.
- `packages/kernel/*`: execution runtimes and code-execution substrate.
- `apps/{local,cloud,cli,desktop}`: product composition roots.

Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,41 @@ differs) already filled in. Most MCP clients only load servers at startup, so
you may need to restart the client or open a new chat before the Executor tools
appear.

### Use with Pi

[Pi](https://pi.dev) ships no MCP client, so `add-mcp` does not apply to it.
Install the first-party extension instead — it needs an Executor to talk to, so
set one up first (any of the forms above):

```bash
pi install npm:@executor-js/pi
```

Point it at your Executor with two environment variables — the endpoint, and a
bearer for it. Which bearer depends on where that Executor runs:

```bash
# Hosted (executor.sh, or a deployment of your own)
export EXECUTOR_MCP_URL=https://executor.example/acme/mcp # the URL from the Connect card
export EXECUTOR_API_KEY=… # from Executor's API Keys page

# Local CLI service or desktop app — loopback is not a free pass, auth is on
export EXECUTOR_MCP_URL=http://127.0.0.1:4788/mcp # the URL from the Connect card
export EXECUTOR_AUTH_TOKEN=… # the server's own bearer token
```

A local server mints that token on first run and keeps it, so it stays valid
across restarts. The Connect card's `add-mcp` command carries it in the
`Authorization: Bearer …` header it prints; on disk it is the `token` in
`~/.executor/server-control/auth.json` (or `$EXECUTOR_DATA_DIR/server-control/auth.json`).
`EXECUTOR_API_KEY` wins when both are set.

Then run `/executor` in Pi: it prints the endpoint it resolved and the tools it
can see. Executor arrives as `executor_execute`, `executor_skills`, and
`executor_resume` — the same small surface every other agent gets, so Pi's
context stays clear of your individual tool schemas. Start with
`executor_skills` for the guide to writing `executor_execute` code.

## Add an integration

From the web UI, click **Add Integration**, paste an OpenAPI, GraphQL, or MCP URL,
Expand Down
250 changes: 238 additions & 12 deletions bun.lock

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions e2e/local/pi-extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// The local half of the Pi setup the README documents. The hosted half is
// covered cross-target (scenarios/pi-extension.test.ts) with an API key in
// `EXECUTOR_API_KEY`; a local or desktop Executor has no API keys page, and
// hands out its own bearer token instead — `EXECUTOR_AUTH_TOKEN`.
//
// Nothing about that token is special to the extension, which is the point: the
// only way to know a local user can follow the README is to boot a real
// `executor web`, take the token it prints, and drive the installed extension
// against it exactly as the docs say.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { installPi, piToolText } from "../src/clients/pi";
import { scenario } from "../src/scenario";
import { Cli, RunDir } from "../src/services";
import { withLocalServer } from "./local-server";

scenario(
"Local · the Pi extension reaches a local Executor with EXECUTOR_AUTH_TOKEN",
// Strictly greater than withLocalServer's 240s boot wait, so a stuck boot
// surfaces its terminal tail instead of vitest's generic timeout.
{ timeout: 300_000 },
Effect.gen(function* () {
const cli = yield* Cli;
const runDir = yield* RunDir;

yield* withLocalServer(cli, runDir, (server) =>
Effect.gen(function* () {
const pi = yield* Effect.promise(() =>
installPi({
EXECUTOR_MCP_URL: new URL("/mcp", server.origin).toString(),
EXECUTOR_AUTH_TOKEN: server.token,
}),
);
yield* Effect.addFinalizer(() => Effect.promise(() => pi.close()));

// `/executor` is the check a user runs after following the README.
const [status] = yield* Effect.promise(() => pi.runCommand("/executor"));
expect(status?.type, `the local server accepted the token: ${status?.message}`).toBe(
"info",
);
expect(status?.message, "it reports the local endpoint").toContain(server.origin);

const executed = yield* Effect.promise(() =>
pi.callTool("executor_execute", { code: "return 21 * 2;" }),
);
expect(piToolText(executed), "the local sandbox ran the code").toContain("42");
}).pipe(Effect.scoped),
);
}),
);
2 changes: 2 additions & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
"motel": "MOTEL_OTEL_BASE_URL=http://127.0.0.1:4796 MOTEL_OTEL_DB_PATH=runs/.motel/telemetry.sqlite motel server"
},
"dependencies": {
"@earendil-works/pi-coding-agent": "^0.85.1",
"@executor-js/api": "workspace:*",
"@executor-js/emulate": "^0.14.1",
"@executor-js/mcporter": "^0.11.4",
"@executor-js/pi": "workspace:*",
"@executor-js/plugin-graphql": "workspace:*",
"@executor-js/plugin-mcp": "workspace:*",
"@executor-js/plugin-openapi": "workspace:*",
Expand Down
83 changes: 83 additions & 0 deletions e2e/scenarios/pi-extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Cross-target: the published Pi extension against a real Executor. Nothing
// here is target-specific — the extension only needs an MCP endpoint and a
// bearer, which is exactly the claim worth testing on every target that serves
// them (cloud's org-scoped /{org}/mcp path included).
//
// The package is packed and installed through Pi's own package manager, then
// discovered, loaded, and wrapped by Pi's own loader (src/clients/pi.ts). So
// what runs is the whole user path — `pi install npm:@executor-js/pi`, the
// `pi` manifest, the default export, `/executor`, Pi's tool wrapper — with only
// the LLM left out: a model picks these tools, it does not make them work.
import { expect } from "@effect/vitest";
import { Effect } from "effect";

import { installPi, piToolText } from "../src/clients/pi";
import { scenario } from "../src/scenario";
import { Mcp, Target } from "../src/services";
import type { Identity } from "../src/target";

const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label;

scenario(
"Pi · the installed Executor extension drives execute, skills, and resume over MCP",
{ timeout: 300_000 },
Effect.gen(function* () {
const target = yield* Target;
const mcp = yield* Mcp;
const identity = yield* target.newIdentity();
const bearer = yield* mcp.mintBearer(emailOf(identity));

// The environment a user copies out of Executor's Connect card and API
// Keys page — the extension's only configuration.
const pi = yield* Effect.promise(() =>
installPi({ EXECUTOR_MCP_URL: mcp.url, EXECUTOR_API_KEY: bearer }),
);
yield* Effect.addFinalizer(() => Effect.promise(() => pi.close()));

expect(
pi.loadedExtensions.some((path) => path.includes("@executor-js/pi")),
"Pi installed the package and loaded its extension from node_modules",
).toBe(true);
expect(pi.toolNames, "Executor's three tools joined Pi's registry").toEqual(
expect.arrayContaining(["executor_execute", "executor_skills", "executor_resume"]),
);

// `/executor` is the one thing a Pi user runs before trusting the install.
const [status] = yield* Effect.promise(() => pi.runCommand("/executor"));
expect(status?.type, "the connection check succeeded").toBe("info");
expect(status?.message, "it reports the endpoint it resolved").toContain(mcp.url);
expect(status?.message, "pinned to the elicitation mode the resume schema assumes").toContain(
"elicitation_mode=model",
);
expect(status?.message, "and the tools Executor actually serves").toContain("execute");

const executed = yield* Effect.promise(() =>
pi.callTool("executor_execute", { code: "return 21 * 2;" }),
);
expect(piToolText(executed), "the sandbox ran the code and returned it").toContain("42");

const documented = yield* Effect.promise(() =>
pi.callTool("executor_skills", { name: "execute" }),
);
expect(
piToolText(documented).length,
"the execute guide comes back for the model to read",
).toBeGreaterThan(0);

// Resume needs a paused execution to actually resume, which needs a policy
// that gates a tool — far more setup than this scenario is for. What is
// worth proving here is the part that silently breaks: that the server
// ACCEPTS the arguments this package advertises. A schema mismatch fails
// as an MCP argument-validation error; an accepted call fails on the
// unknown id instead, which is what we assert.
const refused = yield* Effect.promise(() =>
pi.callTool("executor_resume", { executionId: "exec_does_not_exist", action: "accept" }).then(
() => "resolved",
(error: unknown) => (error instanceof Error ? error.message : String(error)),
),
);
expect(refused, "the server took the arguments and answered about the id").toContain(
"exec_does_not_exist",
);
}).pipe(Effect.scoped),
);
Loading
Loading