diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 280eaecf87..9fb00dacda 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -77,6 +77,9 @@ jobs: - name: Build enricher run: pnpm --filter @posthog/enricher build + - name: Build harness + run: pnpm --filter @posthog/harness build + - name: Build agent run: pnpm --filter agent build diff --git a/.github/workflows/code-build-test.yml b/.github/workflows/code-build-test.yml index a5dd767070..65821126c3 100644 --- a/.github/workflows/code-build-test.yml +++ b/.github/workflows/code-build-test.yml @@ -88,6 +88,7 @@ jobs: pnpm --filter @posthog/shared run build pnpm --filter @posthog/git run build pnpm --filter @posthog/enricher run build + pnpm --filter @posthog/harness run build pnpm --filter @posthog/agent run build # build/Assets.car is gitignored; regenerate it so the packaged app ships @@ -205,6 +206,7 @@ jobs: pnpm --filter @posthog/shared run build pnpm --filter @posthog/git run build pnpm --filter @posthog/enricher run build + pnpm --filter @posthog/harness run build pnpm --filter @posthog/agent run build - name: Build app @@ -275,6 +277,7 @@ jobs: pnpm --filter @posthog/shared run build pnpm --filter @posthog/git run build pnpm --filter @posthog/enricher run build + pnpm --filter @posthog/harness run build pnpm --filter @posthog/agent run build - name: Build app diff --git a/.github/workflows/code-release.yml b/.github/workflows/code-release.yml index 899fed8050..7873459447 100644 --- a/.github/workflows/code-release.yml +++ b/.github/workflows/code-release.yml @@ -140,6 +140,9 @@ jobs: - name: Build enricher package run: pnpm --filter @posthog/enricher run build + - name: Build harness package + run: pnpm --filter @posthog/harness run build + - name: Build agent package run: pnpm --filter @posthog/agent run build @@ -331,6 +334,9 @@ jobs: - name: Build enricher package run: pnpm --filter @posthog/enricher run build + - name: Build harness package + run: pnpm --filter @posthog/harness run build + - name: Build agent package run: pnpm --filter @posthog/agent run build @@ -457,6 +463,9 @@ jobs: - name: Build enricher package run: pnpm --filter @posthog/enricher run build + - name: Build harness package + run: pnpm --filter @posthog/harness run build + - name: Build agent package run: pnpm --filter @posthog/agent run build diff --git a/.github/workflows/code-storybook.yml b/.github/workflows/code-storybook.yml index c53af49c32..6b96e92983 100644 --- a/.github/workflows/code-storybook.yml +++ b/.github/workflows/code-storybook.yml @@ -149,16 +149,20 @@ jobs: pid2=$! wait_all "$pid1" "$pid2" - # @posthog/agent imports @posthog/git's dist, so git must finish - # before the last group starts. - pnpm --filter @posthog/git build - - pnpm --filter @posthog/enricher build & + # @posthog/agent imports the dist of @posthog/git and + # @posthog/harness, so both must finish before the last group starts. + pnpm --filter @posthog/git build & pid3=$! - pnpm --filter @posthog/agent build & + pnpm --filter @posthog/harness build & pid4=$! wait_all "$pid3" "$pid4" + pnpm --filter @posthog/enricher build & + pid5=$! + pnpm --filter @posthog/agent build & + pid6=$! + wait_all "$pid5" "$pid6" + - name: Build Storybook working-directory: apps/code run: pnpm build-storybook diff --git a/.github/workflows/code-update-e2e.yml b/.github/workflows/code-update-e2e.yml index e6ce9dc656..d99e76bc4c 100644 --- a/.github/workflows/code-update-e2e.yml +++ b/.github/workflows/code-update-e2e.yml @@ -106,6 +106,7 @@ jobs: pnpm --filter @posthog/shared run build pnpm --filter @posthog/git run build pnpm --filter @posthog/enricher run build + pnpm --filter @posthog/harness run build pnpm --filter @posthog/agent run build - name: Build old + new update pair diff --git a/.github/workflows/pr-build-installer.yml b/.github/workflows/pr-build-installer.yml index f98ef4fa8b..7d3cd8e3f1 100644 --- a/.github/workflows/pr-build-installer.yml +++ b/.github/workflows/pr-build-installer.yml @@ -93,6 +93,7 @@ jobs: pnpm --filter @posthog/shared run build pnpm --filter @posthog/git run build pnpm --filter @posthog/enricher run build + pnpm --filter @posthog/harness run build pnpm --filter @posthog/agent run build # build/Assets.car is gitignored; regenerate it so the packaged app ships diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4a1aa593b2..5e8328959d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,8 +140,9 @@ jobs: pnpm --filter @posthog/shared build pnpm --filter @posthog/git build pnpm --filter @posthog/enricher build - pnpm --filter agent build & wait + pnpm --filter @posthog/harness build + pnpm --filter agent build - name: Package Electron app run: pnpm --filter code run package diff --git a/packages/agent/package.json b/packages/agent/package.json index 31004ac74a..76f04bf751 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -80,6 +80,10 @@ "types": "./dist/handoff-checkpoint.d.ts", "import": "./dist/handoff-checkpoint.js" }, + "./pi": { + "types": "./dist/pi/rpc-client.d.ts", + "import": "./dist/pi/rpc-client.js" + }, "./server": { "types": "./dist/server/agent-server.d.ts", "import": "./dist/server/agent-server.js" @@ -132,6 +136,7 @@ "@agentclientprotocol/sdk": "1.1.0", "@anthropic-ai/claude-agent-sdk": "0.3.197", "@anthropic-ai/sdk": "0.109.0", + "@earendil-works/pi-coding-agent": "catalog:", "@hono/node-server": "^1.19.9", "@openai/codex": "0.140.0", "@opentelemetry/api-logs": "^0.208.0", @@ -139,6 +144,7 @@ "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": "^0.208.0", "@opentelemetry/semantic-conventions": "^1.28.0", + "@posthog/harness": "workspace:*", "@types/jsonwebtoken": "^9.0.10", "commander": "^14.0.2", "fflate": "^0.8.2", diff --git a/packages/agent/src/pi/rpc-client.test.ts b/packages/agent/src/pi/rpc-client.test.ts new file mode 100644 index 0000000000..b653484d42 --- /dev/null +++ b/packages/agent/src/pi/rpc-client.test.ts @@ -0,0 +1,27 @@ +import { RpcClient } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { createPiRpcClient } from "./rpc-client"; + +describe("createPiRpcClient", () => { + it("creates Pi's native RPC client for the harness entry", () => { + const client = createPiRpcClient({ + cwd: "/workspace", + model: "claude-opus-4-8", + apiKey: "token", + env: { POSTHOG_REGION: "us" }, + }); + + expect(client).toBeInstanceOf(RpcClient); + expect(client).toMatchObject({ + options: { + cwd: "/workspace", + model: "claude-opus-4-8", + env: { + POSTHOG_API_KEY: "token", + POSTHOG_REGION: "us", + }, + provider: "posthog", + }, + }); + }); +}); diff --git a/packages/agent/src/pi/rpc-client.ts b/packages/agent/src/pi/rpc-client.ts new file mode 100644 index 0000000000..b30a4162ff --- /dev/null +++ b/packages/agent/src/pi/rpc-client.ts @@ -0,0 +1,37 @@ +import { fileURLToPath } from "node:url"; +import { + RpcClient, + type RpcClientOptions, +} from "@earendil-works/pi-coding-agent"; + +export type PiRpcClient = RpcClient; + +export type PiRpcClientOptions = Pick< + RpcClientOptions, + "cwd" | "env" | "model" +> & { + /** PostHog token passed only to the isolated RPC host process. */ + apiKey?: string; +}; + +/** + * Create a native Pi RPC client backed by the PostHog harness. + * + * The client owns an isolated child process. Call `start()` before sending + * commands and `stop()` when the run is finished. + */ +export function createPiRpcClient( + options: PiRpcClientOptions = {}, +): PiRpcClient { + const { apiKey, env, ...rpcOptions } = options; + + return new RpcClient({ + ...rpcOptions, + cliPath: fileURLToPath(new URL("./rpc-host.js", import.meta.url)), + env: { + ...env, + ...(apiKey ? { POSTHOG_API_KEY: apiKey } : {}), + }, + provider: "posthog", + }); +} diff --git a/packages/agent/src/pi/rpc-host.ts b/packages/agent/src/pi/rpc-host.ts new file mode 100644 index 0000000000..c98d26ce87 --- /dev/null +++ b/packages/agent/src/pi/rpc-host.ts @@ -0,0 +1,22 @@ +import { createHarnessRuntime, runRpcMode } from "@posthog/harness"; + +function argumentValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index === -1 ? undefined : process.argv[index + 1]; +} + +const runtime = await createHarnessRuntime({ + cwd: process.cwd(), + apiKey: process.env.POSTHOG_API_KEY ?? process.env.POSTHOG_PERSONAL_API_KEY, +}); + +const requestedModel = argumentValue("--model")?.replace(/^posthog\//, ""); +if (requestedModel) { + const model = runtime.services.modelRegistry.find("posthog", requestedModel); + if (!model) { + throw new Error(`PostHog model not found: ${requestedModel}`); + } + await runtime.session.setModel(model); +} + +await runRpcMode(runtime); diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 3e54f6d317..09321156bf 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -112,6 +112,8 @@ export default defineConfig([ "src/posthog-api.ts", "src/posthog-products.ts", "src/pr-url-detector.ts", + "src/pi/rpc-client.ts", + "src/pi/rpc-host.ts", "src/resume.ts", "src/types.ts", "src/adapters/claude/questions/utils.ts", diff --git a/packages/harness/README.md b/packages/harness/README.md index f271fa0f04..ec2252161f 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -1,149 +1,58 @@ # @posthog/harness -Spawn the [pi.dev](https://pi.dev) coding agent — both its **CLI** and its **SDK** — against the -PostHog LLM gateway, authenticated with the same OAuth flow as PostHog Code. - -Harness registers a pi provider named `posthog` that: - -- points pi's `anthropic-messages` API at the region's LLM gateway - (`https://gateway..posthog.com/posthog_code`), -- authenticates with a PostHog OAuth access token (`pha_…`), obtained through the same - Authorization-Code + PKCE flow PostHog Code uses (same client IDs, scopes, and `/oauth/authorize` - + `/oauth/token` endpoints from `@posthog/shared`), and -- lets pi own credential storage and refresh via its provider `oauth` hooks. - -Because the token, OAuth client, and gateway product (`posthog_code`) are identical to PostHog Code, -gateway results are identical as well. - -## Models - -The model list is fetched from the gateway's `/{product}/v1/models` at startup, so harness exposes -whatever models the gateway currently serves — including OpenAI + codex (`gpt-5.6-sol`, -`gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, …) and GLM -(`@cf/zai-org/glm-5.2`). Each model is routed by owner: - -- Anthropic + Cloudflare/GLM models → pi's `anthropic-messages` API on `/posthog_code` -- OpenAI + codex models → pi's `openai-responses` API on `/posthog_code/v1` - -If the fetch fails, returns no models, or `PI_OFFLINE` / `HARNESS_STATIC_MODELS` is set, a bundled -fallback list is used instead. Select any model with `--model posthog/` (e.g. -`--model posthog/gpt-5.3-codex`, `--model "posthog/@cf/zai-org/glm-5.2"`). - -## OAuth flow and region selection - -`harness /login` runs an Authorization-Code + PKCE flow: - -1. Determines the region: if `POSTHOG_REGION` (or an explicit `region` option) is set, it's used - directly; otherwise the login prompts interactively for the region to use, offering `United - States` and `European Union` (`dev` is not offered interactively — it's reachable only via - `POSTHOG_REGION=dev`). -2. Generates a PKCE code verifier/challenge (`S256`) and a random `state`. -3. Starts a loopback HTTP server on `127.0.0.1:` at `/callback` - (port from `HARNESS_OAUTH_PORT`, default `8237`). -4. Builds the authorize URL for the resolved region with the same `client_id`, `scope`, and - `required_access_level=project` as PostHog Code, and opens it in the default browser. -5. Waits for the browser redirect to hit `/callback` with `code` and matching `state` (rejects on an - `error` param, missing `code`, a `state` mismatch, a 180s timeout, or cancellation). -6. Exchanges the code for tokens via `POST /oauth/token`. -7. Stores `OAuthCredentials` (`access`, `refresh`, `expires`, `region`) for pi to reuse and refresh. - -Token refresh posts `grant_type=refresh_token` to the same token endpoint, using the region stored in -the credentials. - -The provider also implements pi's `oauth.modifyModels` hook: whenever pi (re)loads models for this -provider — at startup with a previously-stored credential, and again immediately after a successful -login — it rewrites every model's `baseUrl` to match the region stored in that credential. This means -the region chosen at login always wins for routing requests, regardless of what region the provider -was initially registered with (e.g. before any login had happened). +PostHog's [Pi](https://pi.dev) distribution. + +It adds the PostHog LLM provider and the extensions in `src/extensions/` while keeping Pi's native runtime, sessions, tools, and RPC protocol. ## CLI ```bash -harness # interactive pi, with the posthog provider available -harness /login # sign in via the PostHog OAuth flow; prompts for a region if none is set -harness -p "hi" --model posthog/claude-opus-4-8 +hog +hog /login +hog -p "Fix the tests" --model posthog/claude-opus-4-8 ``` -`POSTHOG_REGION` (`us` / `eu` / `dev`) is optional: if set, it's used directly (skipping the region -prompt at login) and the interactive prompt is skipped entirely. If unset, the initial (pre-login) -provider registration defaults to `us` for model discovery, and `/login` prompts for the actual -region to authenticate against — which then takes over routing via `modifyModels` above. The OAuth -loopback callback port can be overridden with `HARNESS_OAUTH_PORT` (default `8237`). +`harness` is an alias for `hog`. -## Spawn the CLI as a subprocess +## Runtime ```ts -import { spawnPiCli } from "@posthog/harness/spawn"; +import { createHarnessRuntime } from "@posthog/harness"; + +const runtime = await createHarnessRuntime({ cwd: "/workspace" }); -const child = spawnPiCli(["-p", "list the files", "--model", "posthog/claude-opus-4-8"], { - env: { POSTHOG_REGION: "us" }, -}); +await runtime.session.prompt("Fix the tests"); ``` -`spawnPiCli` launches the real `pi` binary with the PostHog provider loaded as an extension. +`createHarnessRuntime()` returns Pi's native `AgentSessionRuntime`. -## SDK +## RPC ```ts -import { createHarnessSession } from "@posthog/harness/session"; - -const session = await createHarnessSession({ region: "us", model: "claude-opus-4-8" }); -session.subscribe((event) => { - if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") { - process.stdout.write(event.assistantMessageEvent.delta); - } -}); -await session.prompt("What files are in the current directory?"); +import { createHarnessRuntime, runRpcMode } from "@posthog/harness"; + +const runtime = await createHarnessRuntime({ cwd: "/workspace" }); +await runRpcMode(runtime); ``` -The SDK reuses whatever credential `harness /login` stored, or accepts a static `apiKey` (a `pha_` -token) for headless use. - -## MCP servers - -The bundled `mcp` extension (see [`src/extensions/mcp/README.md`](./src/extensions/mcp/README.md)) -connects pi to [Model Context Protocol](https://modelcontextprotocol.io) servers and registers -their tools as pi tools named `mcp__`. - -Servers are configured in `mcp.json` — global (`~/.pi/agent/mcp.json`) and/or project-local -(`.pi/mcp.json`, honored only for trusted projects; project entries override global ones per key): - -```json -{ - "mcpServers": { - "filesystem": { - "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] - }, - "linear": { - "transport": "streamable-http", - "url": "https://mcp.linear.app/mcp", - "auth": { "type": "oauth" } - } - } -} +RPC is Pi's JSONL protocol over stdin/stdout. Harness does not define another protocol. + +## Authentication + +Run `hog /login`, or pass a PostHog personal API key: + +```ts +const runtime = await createHarnessRuntime({ apiKey: "pha_…" }); ``` -Supports stdio, streamable-http, and SSE transports; eager/lazy startup; automatic reconnect; -live tool refresh on `tools/list_changed`; static header auth; and full OAuth -(authorization-code + PKCE with discovery, dynamic client registration, silent token refresh, and -credentials stored under `~/.pi/agent/mcp-auth/`). Commands: `/mcp` (status), `/mcp:start`, -`/mcp:stop`, `/mcp:auth [server] [reset]` (browser flow). +Set `POSTHOG_REGION` to `us`, `eu`, or `dev` when needed. -## Entry points +## Public API -| Import | What | +| Import | Purpose | | --- | --- | -| `@posthog/harness/cli` (bin `harness`) | pi CLI in-process with the PostHog provider | -| `@posthog/harness/spawn` | `spawnPiCli()` — spawn pi as a subprocess | -| `@posthog/harness/session` | `createHarnessSession()` — pi SDK `AgentSession` | -| `@posthog/harness/extensions` | extension registry | -| `@posthog/harness/extensions/hog-branding` | startup header rebrand — `createHogBrandingExtension()` | -| `@posthog/harness/extensions/posthog-provider` | default pi extension — `createPosthogProviderExtension()` | -| `@posthog/harness/extensions/posthog-provider/provider` | `POSTHOG_PROVIDER_NAME`, `buildPosthogProvider()`, `resolvePosthogProvider()` | -| `@posthog/harness/extensions/posthog-provider/oauth` | `loginPosthog()`, `refreshPosthog()`, `buildAuthorizeUrl()`, `getRedirectUri()`, `getCallbackPort()` | -| `@posthog/harness/extensions/posthog-provider/gateway` | `getGatewayBaseUrl()`, `getLlmGatewayUrl()`, `resolveRegion()`, `GATEWAY_PRODUCT` | -| `@posthog/harness/extensions/posthog-provider/models` | `resolveModelConfigs()`, `fallbackModelConfigs()`, `DEFAULT_MODEL`, `GatewayModel` | -| `@posthog/harness/extensions/web-access` | web search + fetch tools — `createWebAccessExtension()` | -| `@posthog/harness/extensions/subagent` | subagent orchestration — `createSubagentExtension()` | -| `@posthog/harness/extensions/mcp` | MCP client extension — `createMcpExtension()` | +| `@posthog/harness` | Runtime creation and RPC mode | +| `@posthog/harness/runtime` | Runtime creation only | +| `@posthog/harness/extensions` | Harness extension registry | + +Individual extensions are also exported under `@posthog/harness/extensions/*`. diff --git a/packages/harness/package.json b/packages/harness/package.json index fa2c8a0ad6..b0f39fd3c7 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -8,17 +8,17 @@ "hog": "dist/cli.js" }, "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, "./cli": { "types": "./dist/cli.d.ts", "import": "./dist/cli.js" }, - "./session": { - "types": "./dist/session.d.ts", - "import": "./dist/session.js" - }, - "./spawn": { - "types": "./dist/spawn.d.ts", - "import": "./dist/spawn.js" + "./runtime": { + "types": "./dist/runtime.d.ts", + "import": "./dist/runtime.js" }, "./extensions": { "types": "./dist/extensions/registry.d.ts", @@ -82,16 +82,17 @@ } }, "scripts": { - "build": "tsup", + "build": "tsup && pnpm build:types", + "build:types": "tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist --rootDir src", "dev": "tsup --watch", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "node ../../scripts/rimraf.mjs dist .turbo" }, "dependencies": { - "@earendil-works/pi-ai": "0.80.3", - "@earendil-works/pi-coding-agent": "0.80.6", - "@earendil-works/pi-tui": "0.80.6", + "@earendil-works/pi-ai": "catalog:", + "@earendil-works/pi-coding-agent": "catalog:", + "@earendil-works/pi-tui": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@posthog/shared": "workspace:*", "lru-cache": "^11.1.0", diff --git a/packages/harness/src/cli.ts b/packages/harness/src/cli.ts index 6c5879888c..1d8bb4581c 100644 --- a/packages/harness/src/cli.ts +++ b/packages/harness/src/cli.ts @@ -9,24 +9,25 @@ import { // is evaluated, so pi picks up "hog" branding when its config module first // evaluates. `installHogBrandEnv` itself only touches Node builtins, so // this static import carries no ordering risk; everything below that -// touches pi-coding-agent (directly or transitively, e.g. `./spawn`, which -// pulls in every extension) is loaded dynamically instead of via a static -// import — see `./extensions/hog-branding/brand-env` for why a static -// import here wouldn't reliably run first once bundled, and why `./spawn` -// below is imported by a *computed* (non-literal) specifier: a literal -// `import("./spawn")` is exactly as statically inlinable (and thus exactly -// as unordered) as a static `import`, since bundlers resolve and inline -// literal-specifier dynamic imports when there's no code-splitting. A -// specifier the bundler can't statically resolve forces a genuine runtime -// load of the already-separately-built `dist/spawn.js`. -import type * as SpawnModule from "./spawn"; +// touches pi-coding-agent (directly or transitively through the extension +// registry) is loaded dynamically instead of via a static import — see +// `./extensions/hog-branding/brand-env` for why a static +// import here wouldn't reliably run first once bundled, and why the extension +// registry below is imported by a *computed* (non-literal) specifier: a +// literal dynamic import is statically inlinable by a bundler. A specifier the +// bundler cannot resolve forces a genuine runtime load of the separately-built +// `dist/extensions/registry.js`. +import type * as ExtensionRegistry from "./extensions/registry"; installHogBrandEnv(); const { main, VERSION } = await import("@earendil-works/pi-coding-agent"); -const spawnModuleUrl = new URL("./spawn.js", import.meta.url).href; -const { harnessExtensionFiles }: typeof SpawnModule = await import( - spawnModuleUrl +const extensionRegistryUrl = new URL( + "./extensions/registry.js", + import.meta.url, +).href; +const { harnessExtensionFiles }: typeof ExtensionRegistry = await import( + extensionRegistryUrl ); // pi generates its own `--help` text (see `cli/args.js`'s `printHelp()`) diff --git a/packages/harness/src/extensions/README.md b/packages/harness/src/extensions/README.md index 278a3e3295..8a182876ad 100644 --- a/packages/harness/src/extensions/README.md +++ b/packages/harness/src/extensions/README.md @@ -54,17 +54,14 @@ const EXTENSIONS: HarnessExtension[] = [ ]; ``` -`registry.ts` is the single source of truth. Both entry paths consume it, so a registered extension -is loaded everywhere with no further wiring: +`registry.ts` is the single source of truth. Both harness entry paths consume it, so a registered +extension is loaded everywhere with no further wiring: -- **In-process CLI** (`src/cli.ts`) → one `-e dist/extensions//index.js` per extension, passed - through argv (`main([...extensionArgs, ...args])`) -- **Subprocess** (`src/spawn.ts`) → one `-e dist/extensions//index.js` per extension +- **Pi CLI** (`src/cli.ts`) loads each compiled extension by file path through Pi's native `-e` + argument. +- **Harness runtime** (`src/runtime.ts`) supplies named inline factories to Pi's + `createAgentSessionServices()`. -Both load extensions by file path (not `extensionFactories`), so each one shows its real name in pi's -startup banner instead of ``. `harnessExtensions()` (function factories, taking -`HarnessExtensionOptions`) remains available for programmatic embedding — for example -`session.ts`'s lean SDK path — where a caller needs to inject runtime options. - -Both `-e` paths are real pi extension-loading paths, verified to register in every pi mode -(interactive, print, rpc, json, and `--list-models`). +The CLI path shows each extension's directory name in Pi's startup banner. The runtime path uses +named `InlineExtension` values, so those extensions appear as `` rather than anonymous +`` entries. Both paths use Pi's native extension loader. diff --git a/packages/harness/src/extensions/hog-branding/brand-env.ts b/packages/harness/src/extensions/hog-branding/brand-env.ts index 92e0508223..5440d6d96c 100644 --- a/packages/harness/src/extensions/hog-branding/brand-env.ts +++ b/packages/harness/src/extensions/hog-branding/brand-env.ts @@ -33,10 +33,9 @@ * before `@earendil-works/pi-coding-agent` is evaluated, because * `APP_NAME`/`APP_TITLE`/`CONFIG_DIR_NAME` are read from `PI_PACKAGE_DIR` at * module-evaluation time, not lazily. `cli.ts`, `bin/hog.ts`, and - * `session.ts` each call it as their first statement, then `await - * import("@earendil-works/pi-coding-agent")` (and any local module that - * transitively imports it, e.g. `./spawn`) dynamically rather than via a - * static top-level `import`. + * `runtime.ts` call it before dynamically importing + * `@earendil-works/pi-coding-agent` or any local module that transitively + * imports the SDK. * * That dynamic-import requirement is not optional styling: once bundled, * a static `import "./brand-env"` followed by a static @@ -49,9 +48,9 @@ * contrast, is never hoisted ahead of preceding synchronous statements, so * it reliably runs after `installHogBrandEnv()` regardless of bundling. * - * `spawn.ts`/`pi-cli.ts` use `withHogBrandEnv()` instead, to pass - * `PI_PACKAGE_DIR` into a spawned pi subprocess's environment — no ordering - * concerns there, since it only builds a plain object for a child process. + * The subagent's `pi-subprocess.ts` uses `withHogBrandEnv()` when it spawns + * Pi subprocesses — no ordering concerns there, since it only builds a + * plain environment object for a child process. * * Deliberately keeps `configDir: ".pi"` (not `.hog`) so existing pi * credentials, sessions, and MCP auth on disk keep working unchanged for @@ -60,8 +59,8 @@ * Best-effort: inside the standalone `bun build --compile` binary (see * `bin/hog.ts`) there is no real `node_modules` tree to resolve * `@earendil-works/pi-coding-agent`'s package root against — the same - * limitation that excludes the `subagent` extension from that binary (see - * `pi-cli.ts`) — so failures here are swallowed and pi falls back to its + * limitation that excludes the `subagent` extension from that binary — so + * failures here are swallowed and Pi falls back to its * own "pi"/"π" naming rather than crashing startup. */ import { @@ -150,9 +149,8 @@ export function hogBrandManifestDir(): string | null { } /** - * Merges `PI_PACKAGE_DIR` into `env` for spawning a pi subprocess (see - * `spawn.ts`/`pi-cli.ts`), unless the caller already set one or the - * manifest directory couldn't be prepared. + * Merges `PI_PACKAGE_DIR` into `env` for spawning a Pi subprocess, unless the + * caller already set one or the manifest directory couldn't be prepared. */ export function withHogBrandEnv( env: NodeJS.ProcessEnv = process.env, diff --git a/packages/harness/src/extensions/registry.ts b/packages/harness/src/extensions/registry.ts index ef5ec1f007..1e19739ca7 100644 --- a/packages/harness/src/extensions/registry.ts +++ b/packages/harness/src/extensions/registry.ts @@ -1,4 +1,8 @@ -import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import { fileURLToPath } from "node:url"; +import type { + ExtensionFactory, + InlineExtension, +} from "@earendil-works/pi-coding-agent"; import { createBackgroundJobsExtension } from "./background-jobs/extension"; import type { HogBrandingOptions } from "./hog-branding/extension"; import { createHogBrandingExtension } from "./hog-branding/extension"; @@ -33,8 +37,25 @@ export const HARNESS_EXTENSION_NAMES: readonly string[] = EXTENSIONS.map( (extension) => extension.name, ); +export interface HarnessExtensionFilesOptions { + exclude?: string[]; +} + +/** Compiled extension entry points consumed by Pi's native CLI. */ +export function harnessExtensionFiles( + options: HarnessExtensionFilesOptions = {}, +): string[] { + const exclude = new Set(options.exclude ?? []); + return HARNESS_EXTENSION_NAMES.filter((name) => !exclude.has(name)).map( + (name) => fileURLToPath(new URL(`./${name}/index.js`, import.meta.url)), + ); +} + export function harnessExtensions( options: HarnessExtensionOptions = {}, -): ExtensionFactory[] { - return EXTENSIONS.map((extension) => extension.create(options)); +): InlineExtension[] { + return EXTENSIONS.map((extension) => ({ + name: extension.name, + factory: extension.create(options), + })); } diff --git a/packages/harness/src/pi-cli.test.ts b/packages/harness/src/extensions/subagent/process/pi-subprocess.test.ts similarity index 98% rename from packages/harness/src/pi-cli.test.ts rename to packages/harness/src/extensions/subagent/process/pi-subprocess.test.ts index 782ae0e962..be99341285 100644 --- a/packages/harness/src/pi-cli.test.ts +++ b/packages/harness/src/extensions/subagent/process/pi-subprocess.test.ts @@ -4,7 +4,7 @@ import { nodeCompatibleSpawnEnv, piCliInvocation, resolvePiCliEntry, -} from "./pi-cli"; +} from "./pi-subprocess"; describe("resolvePiCliEntry", () => { it("resolves to cli.js next to the installed @earendil-works/pi-coding-agent package", () => { diff --git a/packages/harness/src/pi-cli.ts b/packages/harness/src/extensions/subagent/process/pi-subprocess.ts similarity index 81% rename from packages/harness/src/pi-cli.ts rename to packages/harness/src/extensions/subagent/process/pi-subprocess.ts index e6391da714..94aa00a459 100644 --- a/packages/harness/src/pi-cli.ts +++ b/packages/harness/src/extensions/subagent/process/pi-subprocess.ts @@ -1,13 +1,12 @@ /** * Locates the pi CLI entry point and builds the invocation needed to run it - * as a child process. This is the single source of truth for that — both - * `spawn.ts` (spawning the interactive/print pi CLI) and the `subagent` - * extension (spawning child pi sessions) go through it, so there is exactly - * one place that knows how to invoke pi as a subprocess. + * as a child process. The `subagent` extension goes through this module, so + * there is exactly one place that knows how to invoke Pi subprocesses from + * the harness. */ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { withHogBrandEnv } from "./extensions/hog-branding/brand-env"; +import { withHogBrandEnv } from "../../hog-branding/brand-env"; export function resolvePiCliEntry(): string { const mainEntry = fileURLToPath( diff --git a/packages/harness/src/extensions/subagent/run-agent.ts b/packages/harness/src/extensions/subagent/run-agent.ts index c2d6528f1b..90bcb31802 100644 --- a/packages/harness/src/extensions/subagent/run-agent.ts +++ b/packages/harness/src/extensions/subagent/run-agent.ts @@ -16,7 +16,6 @@ import { fileURLToPath } from "node:url"; import type { Message } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; -import { piCliInvocation } from "../../pi-cli"; import type { AgentConfig } from "./agents"; import { resolveModelAuthWithFallback, writeAuthBridgeExtension } from "./auth"; import { composeTaskWithContext, resolveContext } from "./context"; @@ -31,6 +30,7 @@ import { type ChildProcessHandle, spawnChildProcess, } from "./process/child-process"; +import { piCliInvocation } from "./process/pi-subprocess"; import { applyAgentOverrides, loadSubagentSettings } from "./settings"; import { removeAgentRun, upsertAgentRun } from "./status-registry"; @@ -82,8 +82,8 @@ export function isFailedResult(result: SingleRunResult): boolean { } /** Sibling extension entry points resolved by relative path — not through - * `spawn.ts`/`registry.ts` — so this module has no dependency on the harness - * extension registry or on any specific provider extension. */ + * the registry — so this module has no dependency on the harness extension + * registry or on any specific provider extension. */ function siblingExtensionFile(name: string): string { return fileURLToPath(new URL(`../${name}/index.js`, import.meta.url)); } diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts new file mode 100644 index 0000000000..1e9865e3af --- /dev/null +++ b/packages/harness/src/index.ts @@ -0,0 +1,23 @@ +import type { AgentSessionRuntime } from "@earendil-works/pi-coding-agent"; + +export { + createHarnessRuntime, + type HarnessRuntimeOptions, +} from "./runtime"; + +/** Run a harness runtime using Pi's native JSONL RPC protocol. */ +export async function runRpcMode(runtime: AgentSessionRuntime): Promise { + const pi = await import("@earendil-works/pi-coding-agent"); + return pi.runRpcMode(runtime); +} + +export type { + AgentSession, + AgentSessionEvent, + AgentSessionRuntime, + RpcCommand, + RpcExtensionUIRequest, + RpcExtensionUIResponse, + RpcResponse, + RpcSessionState, +} from "@earendil-works/pi-coding-agent"; diff --git a/packages/harness/src/runtime.test.ts b/packages/harness/src/runtime.test.ts new file mode 100644 index 0000000000..506593ad16 --- /dev/null +++ b/packages/harness/src/runtime.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createHarnessRuntime } from "./runtime"; + +const temporaryDirectories: string[] = []; + +async function temporaryDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "posthog-harness-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + vi.unstubAllEnvs(); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), + ); +}); + +describe("createHarnessRuntime", () => { + it("returns a native Pi runtime with the PostHog model and named harness extensions", async () => { + vi.stubEnv("PI_OFFLINE", "1"); + const pi = await import("@earendil-works/pi-coding-agent"); + const cwd = await temporaryDirectory(); + const agentDir = await temporaryDirectory(); + + const runtime = await createHarnessRuntime({ + agentDir, + authStorage: pi.AuthStorage.inMemory(), + cwd, + sessionManager: pi.SessionManager.inMemory(cwd), + }); + + try { + expect(runtime).toBeInstanceOf(pi.AgentSessionRuntime); + expect(runtime.session.model?.provider).toBe("posthog"); + expect( + runtime.services.resourceLoader + .getExtensions() + .extensions.map((extension) => extension.path), + ).toEqual( + expect.arrayContaining([ + "", + "", + "", + "", + "", + "", + ]), + ); + } finally { + await runtime.dispose(); + } + }); +}); diff --git a/packages/harness/src/runtime.ts b/packages/harness/src/runtime.ts new file mode 100644 index 0000000000..bbe15307b6 --- /dev/null +++ b/packages/harness/src/runtime.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import type { + AgentSessionRuntime, + CreateAgentSessionFromServicesOptions, + CreateAgentSessionRuntimeFactory, + CreateAgentSessionServicesOptions, +} from "@earendil-works/pi-coding-agent"; +import { installHogBrandEnv } from "./extensions/hog-branding/brand-env"; +import type { HarnessExtensionOptions } from "./extensions/registry"; + +type PiRuntimeTarget = Parameters[0]; + +export type HarnessRuntimeOptions = HarnessExtensionOptions & + Partial< + Pick< + PiRuntimeTarget, + "cwd" | "agentDir" | "sessionManager" | "sessionStartEvent" + > + > & + Omit & + Omit< + CreateAgentSessionFromServicesOptions, + "services" | "sessionManager" | "sessionStartEvent" + >; + +/** + * Build the standard PostHog distribution of Pi. + * + * The returned value is Pi's native `AgentSessionRuntime`, so it can be + * passed directly to `runRpcMode`, `runPrintMode`, or `InteractiveMode`, or + * used in-process through `runtime.session`. The same factory is retained by + * Pi and recreates all cwd-bound services and harness extensions when a + * session is replaced, forked, or imported. + */ +export async function createHarnessRuntime( + options: HarnessRuntimeOptions = {}, +): Promise { + // Pi reads its application branding when the SDK is first evaluated. Keep + // every runtime import below dynamic so this always happens first. + installHogBrandEnv(); + + const pi = await import("@earendil-works/pi-coding-agent"); + const [{ harnessExtensions }, { DEFAULT_MODEL }] = await Promise.all([ + import("./extensions/registry"), + import("./extensions/posthog-provider/models"), + ]); + + const cwd = options.cwd ?? process.cwd(); + const agentDir = options.agentDir ?? pi.getAgentDir(); + + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ + cwd: runtimeCwd, + agentDir: runtimeAgentDir, + sessionManager, + sessionStartEvent, + }) => { + const authStorage = + options.authStorage ?? + pi.AuthStorage.create(join(runtimeAgentDir, "auth.json")); + + const services = await pi.createAgentSessionServices({ + ...options, + cwd: runtimeCwd, + agentDir: runtimeAgentDir, + authStorage, + resourceLoaderOptions: { + ...options.resourceLoaderOptions, + extensionFactories: [ + ...(options.resourceLoaderOptions?.extensionFactories ?? []), + ...harnessExtensions(options), + ], + }, + }); + + const created = await pi.createAgentSessionFromServices({ + ...options, + services, + sessionManager, + sessionStartEvent, + model: + options.model ?? services.modelRegistry.find("posthog", DEFAULT_MODEL), + }); + + return { + ...created, + services, + diagnostics: [ + ...services.diagnostics, + ...services.resourceLoader + .getExtensions() + .errors.map(({ path, error }) => ({ + type: "error" as const, + message: `Failed to load extension "${path}": ${error}`, + })), + ], + }; + }; + + const sessionManager = + options.sessionManager ?? pi.SessionManager.create(cwd); + + return pi.createAgentSessionRuntime(createRuntime, { + cwd: sessionManager.getCwd(), + agentDir, + sessionManager, + sessionStartEvent: options.sessionStartEvent, + }); +} diff --git a/packages/harness/src/session.ts b/packages/harness/src/session.ts deleted file mode 100644 index c2d6cc0700..0000000000 --- a/packages/harness/src/session.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AgentSession } from "@earendil-works/pi-coding-agent"; -import { installHogBrandEnv } from "./extensions/hog-branding/brand-env"; -import { DEFAULT_MODEL } from "./extensions/posthog-provider/models"; -import { - POSTHOG_PROVIDER_NAME, - type PosthogProviderOptions, - resolvePosthogProvider, -} from "./extensions/posthog-provider/provider"; - -export interface HarnessSessionOptions extends PosthogProviderOptions { - cwd?: string; - model?: string; -} - -export async function createHarnessSession( - options: HarnessSessionOptions = {}, -): Promise { - // Must finish running before `@earendil-works/pi-coding-agent` is - // evaluated, so pi picks up "hog" branding when its config module first - // evaluates. Hence the dynamic import right below, rather than a static - // top-level one — see `./extensions/hog-branding/brand-env` for why a - // static import wouldn't reliably run first once bundled. - installHogBrandEnv(); - const { - AuthStorage, - createAgentSession, - DefaultResourceLoader, - getAgentDir, - ModelRegistry, - } = await import("@earendil-works/pi-coding-agent"); - - const cwd = options.cwd ?? process.cwd(); - - const authStorage = AuthStorage.create(); - const modelRegistry = ModelRegistry.create(authStorage); - modelRegistry.registerProvider( - POSTHOG_PROVIDER_NAME, - await resolvePosthogProvider(options), - ); - - const model = modelRegistry.find( - POSTHOG_PROVIDER_NAME, - options.model ?? DEFAULT_MODEL, - ); - - const resourceLoader = new DefaultResourceLoader({ - cwd, - agentDir: getAgentDir(), - // The bundled MCP extension owns long-lived server processes and - // interactive OAuth flows, so it is only loaded by the CLI/TUI extension - // registry and not by embedded SDK sessions. - extensionFactories: [], - }); - await resourceLoader.reload(); - - const { session } = await createAgentSession({ - authStorage, - modelRegistry, - resourceLoader, - cwd, - ...(model ? { model } : {}), - }); - - return session; -} diff --git a/packages/harness/src/spawn.ts b/packages/harness/src/spawn.ts deleted file mode 100644 index 8d8f3c8765..0000000000 --- a/packages/harness/src/spawn.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - type ChildProcess, - type SpawnOptions, - spawn, -} from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { HARNESS_EXTENSION_NAMES } from "./extensions/registry"; -import { piCliInvocation, resolvePiCliEntry } from "./pi-cli"; - -export { resolvePiCliEntry as resolvePiCli }; - -export interface HarnessExtensionFilesOptions { - /** Extension names to omit — e.g. `bin/hog.ts`'s standalone binary excludes - * `subagent`, which needs a real `node_modules` tree to resolve pi's CLI - * entry against, something a single compiled executable doesn't carry. */ - exclude?: string[]; -} - -export function harnessExtensionFiles( - options: HarnessExtensionFilesOptions = {}, -): string[] { - const exclude = new Set(options.exclude ?? []); - // `./index.js` (not `./extension.js`) so pi's startup banner shows each - // extension by its directory name instead of `/extension.js`; see - // `src/extensions//index.ts`. - return HARNESS_EXTENSION_NAMES.filter((name) => !exclude.has(name)).map( - (name) => - fileURLToPath(new URL(`./extensions/${name}/index.js`, import.meta.url)), - ); -} - -export interface SpawnPiOptions extends SpawnOptions { - extensions?: boolean; -} - -export function spawnPiCli( - args: string[] = [], - options: SpawnPiOptions = {}, -): ChildProcess { - const { extensions = true, env, stdio = "inherit", ...rest } = options; - const extensionArgs = extensions - ? harnessExtensionFiles().flatMap((file) => ["-e", file]) - : []; - const invocation = piCliInvocation([...extensionArgs, ...args], { - ...process.env, - ...env, - }); - return spawn(invocation.command, invocation.args, { - stdio, - ...rest, - env: invocation.env, - }); -} diff --git a/packages/harness/tsup.config.ts b/packages/harness/tsup.config.ts index 98cb6b7a7a..fe45566598 100644 --- a/packages/harness/tsup.config.ts +++ b/packages/harness/tsup.config.ts @@ -3,9 +3,9 @@ import { defineConfig } from "tsup"; export default defineConfig({ entry: [ + "src/index.ts", "src/cli.ts", - "src/session.ts", - "src/spawn.ts", + "src/runtime.ts", "src/extensions/registry.ts", "src/extensions/hog-branding/extension.ts", "src/extensions/hog-branding/index.ts", @@ -33,6 +33,7 @@ export default defineConfig({ "src/extensions/subagent/auth.ts", "src/extensions/subagent/context.ts", "src/extensions/subagent/process/child-process.ts", + "src/extensions/subagent/process/pi-subprocess.ts", "src/extensions/subagent/run-agent.ts", "src/extensions/subagent/process/pool.ts", "src/extensions/subagent/lifecycle.ts", @@ -67,10 +68,9 @@ export default defineConfig({ "src/extensions/footer-focus-demo/footer.ts", "src/extensions/footer-focus-demo/editor.ts", "src/extensions/footer-focus-demo/overlay.ts", - "src/pi-cli.ts", ], format: ["esm"], - dts: true, + dts: false, sourcemap: true, clean: true, splitting: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcdd19bd1..62b65012f2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -712,6 +712,9 @@ importers: '@anthropic-ai/sdk': specifier: 0.109.0 version: 0.109.0(zod@4.4.3) + '@earendil-works/pi-coding-agent': + specifier: 'catalog:' + version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@hono/node-server': specifier: ^1.19.9 version: 1.19.9(hono@4.11.7) @@ -736,6 +739,9 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.28.0 version: 1.39.0 + '@posthog/harness': + specifier: workspace:* + version: link:../harness '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -977,13 +983,13 @@ importers: packages/harness: dependencies: '@earendil-works/pi-ai': - specifier: 0.80.3 + specifier: 'catalog:' version: 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': - specifier: 0.80.6 + specifier: 'catalog:' version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: 0.80.6 + specifier: 'catalog:' version: 0.80.6 '@modelcontextprotocol/sdk': specifier: ^1.29.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09e51fe577..a0bac050d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,6 +6,10 @@ packages: catalog: '@hono/node-server': ^1.13.7 '@hono/trpc-server': ^0.3.4 + '@earendil-works/pi-agent-core': 0.80.6 + '@earendil-works/pi-ai': 0.80.3 + '@earendil-works/pi-coding-agent': 0.80.6 + '@earendil-works/pi-tui': 0.80.6 '@parcel/watcher': ^2.5.6 '@phosphor-icons/react': ^2.1.10 '@posthog/quill': 0.3.0-beta.24