Skip to content

Commit 84dccb9

Browse files
committed
feat(harness): expose native Pi runtime
1 parent 6306e29 commit 84dccb9

25 files changed

Lines changed: 424 additions & 310 deletions

.github/workflows/build.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ jobs:
7777
- name: Build enricher
7878
run: pnpm --filter @posthog/enricher build
7979

80+
- name: Build harness
81+
run: pnpm --filter @posthog/harness build
82+
8083
- name: Build agent
8184
run: pnpm --filter agent build
8285

.github/workflows/code-release.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,9 @@ jobs:
140140
- name: Build enricher package
141141
run: pnpm --filter @posthog/enricher run build
142142

143+
- name: Build harness package
144+
run: pnpm --filter @posthog/harness run build
145+
143146
- name: Build agent package
144147
run: pnpm --filter @posthog/agent run build
145148

@@ -331,6 +334,9 @@ jobs:
331334
- name: Build enricher package
332335
run: pnpm --filter @posthog/enricher run build
333336

337+
- name: Build harness package
338+
run: pnpm --filter @posthog/harness run build
339+
334340
- name: Build agent package
335341
run: pnpm --filter @posthog/agent run build
336342

@@ -457,6 +463,9 @@ jobs:
457463
- name: Build enricher package
458464
run: pnpm --filter @posthog/enricher run build
459465

466+
- name: Build harness package
467+
run: pnpm --filter @posthog/harness run build
468+
460469
- name: Build agent package
461470
run: pnpm --filter @posthog/agent run build
462471

.github/workflows/test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,9 @@ jobs:
140140
pnpm --filter @posthog/shared build
141141
pnpm --filter @posthog/git build
142142
pnpm --filter @posthog/enricher build
143-
pnpm --filter agent build &
144143
wait
144+
pnpm --filter @posthog/harness build
145+
pnpm --filter agent build
145146
146147
- name: Package Electron app
147148
run: pnpm --filter code run package

packages/agent/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@
8080
"types": "./dist/handoff-checkpoint.d.ts",
8181
"import": "./dist/handoff-checkpoint.js"
8282
},
83+
"./pi": {
84+
"types": "./dist/pi/rpc-client.d.ts",
85+
"import": "./dist/pi/rpc-client.js"
86+
},
8387
"./server": {
8488
"types": "./dist/server/agent-server.d.ts",
8589
"import": "./dist/server/agent-server.js"
@@ -132,13 +136,15 @@
132136
"@agentclientprotocol/sdk": "1.1.0",
133137
"@anthropic-ai/claude-agent-sdk": "0.3.197",
134138
"@anthropic-ai/sdk": "0.109.0",
139+
"@earendil-works/pi-coding-agent": "catalog:",
135140
"@hono/node-server": "^1.19.9",
136141
"@openai/codex": "0.140.0",
137142
"@opentelemetry/api-logs": "^0.208.0",
138143
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
139144
"@opentelemetry/resources": "^2.0.0",
140145
"@opentelemetry/sdk-logs": "^0.208.0",
141146
"@opentelemetry/semantic-conventions": "^1.28.0",
147+
"@posthog/harness": "workspace:*",
142148
"@types/jsonwebtoken": "^9.0.10",
143149
"commander": "^14.0.2",
144150
"fflate": "^0.8.2",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { RpcClient } from "@earendil-works/pi-coding-agent";
2+
import { describe, expect, it } from "vitest";
3+
import { createPiRpcClient } from "./rpc-client";
4+
5+
describe("createPiRpcClient", () => {
6+
it("creates Pi's native RPC client for the harness entry", () => {
7+
const client = createPiRpcClient({
8+
cwd: "/workspace",
9+
model: "claude-opus-4-8",
10+
apiKey: "token",
11+
env: { POSTHOG_REGION: "us" },
12+
});
13+
14+
expect(client).toBeInstanceOf(RpcClient);
15+
expect(client).toMatchObject({
16+
options: {
17+
cwd: "/workspace",
18+
model: "claude-opus-4-8",
19+
env: {
20+
POSTHOG_API_KEY: "token",
21+
POSTHOG_REGION: "us",
22+
},
23+
provider: "posthog",
24+
},
25+
});
26+
});
27+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { fileURLToPath } from "node:url";
2+
import {
3+
RpcClient,
4+
type RpcClientOptions,
5+
} from "@earendil-works/pi-coding-agent";
6+
7+
export type PiRpcClient = RpcClient;
8+
9+
export type PiRpcClientOptions = Pick<
10+
RpcClientOptions,
11+
"cwd" | "env" | "model"
12+
> & {
13+
/** PostHog token passed only to the isolated RPC host process. */
14+
apiKey?: string;
15+
};
16+
17+
/**
18+
* Create a native Pi RPC client backed by the PostHog harness.
19+
*
20+
* The client owns an isolated child process. Call `start()` before sending
21+
* commands and `stop()` when the run is finished.
22+
*/
23+
export function createPiRpcClient(
24+
options: PiRpcClientOptions = {},
25+
): PiRpcClient {
26+
const { apiKey, env, ...rpcOptions } = options;
27+
28+
return new RpcClient({
29+
...rpcOptions,
30+
cliPath: fileURLToPath(new URL("./rpc-host.js", import.meta.url)),
31+
env: {
32+
...env,
33+
...(apiKey ? { POSTHOG_API_KEY: apiKey } : {}),
34+
},
35+
provider: "posthog",
36+
});
37+
}

packages/agent/src/pi/rpc-host.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { createHarnessRuntime, runRpcMode } from "@posthog/harness";
2+
3+
function argumentValue(name: string): string | undefined {
4+
const index = process.argv.indexOf(name);
5+
return index === -1 ? undefined : process.argv[index + 1];
6+
}
7+
8+
const runtime = await createHarnessRuntime({
9+
cwd: process.cwd(),
10+
apiKey: process.env.POSTHOG_API_KEY ?? process.env.POSTHOG_PERSONAL_API_KEY,
11+
});
12+
13+
const requestedModel = argumentValue("--model")?.replace(/^posthog\//, "");
14+
if (requestedModel) {
15+
const model = runtime.services.modelRegistry.find("posthog", requestedModel);
16+
if (!model) {
17+
throw new Error(`PostHog model not found: ${requestedModel}`);
18+
}
19+
await runtime.session.setModel(model);
20+
}
21+
22+
await runRpcMode(runtime);

packages/agent/tsup.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ export default defineConfig([
112112
"src/posthog-api.ts",
113113
"src/posthog-products.ts",
114114
"src/pr-url-detector.ts",
115+
"src/pi/rpc-client.ts",
116+
"src/pi/rpc-host.ts",
115117
"src/resume.ts",
116118
"src/types.ts",
117119
"src/adapters/claude/questions/utils.ts",

packages/harness/README.md

Lines changed: 34 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,149 +1,58 @@
11
# @posthog/harness
22

3-
Spawn the [pi.dev](https://pi.dev) coding agent — both its **CLI** and its **SDK** — against the
4-
PostHog LLM gateway, authenticated with the same OAuth flow as PostHog Code.
5-
6-
Harness registers a pi provider named `posthog` that:
7-
8-
- points pi's `anthropic-messages` API at the region's LLM gateway
9-
(`https://gateway.<region>.posthog.com/posthog_code`),
10-
- authenticates with a PostHog OAuth access token (`pha_…`), obtained through the same
11-
Authorization-Code + PKCE flow PostHog Code uses (same client IDs, scopes, and `/oauth/authorize`
12-
+ `/oauth/token` endpoints from `@posthog/shared`), and
13-
- lets pi own credential storage and refresh via its provider `oauth` hooks.
14-
15-
Because the token, OAuth client, and gateway product (`posthog_code`) are identical to PostHog Code,
16-
gateway results are identical as well.
17-
18-
## Models
19-
20-
The model list is fetched from the gateway's `/{product}/v1/models` at startup, so harness exposes
21-
whatever models the gateway currently serves — including OpenAI + codex (`gpt-5.6-sol`,
22-
`gpt-5.6-terra`, `gpt-5.6-luna`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3-codex`, …) and GLM
23-
(`@cf/zai-org/glm-5.2`). Each model is routed by owner:
24-
25-
- Anthropic + Cloudflare/GLM models → pi's `anthropic-messages` API on `<gateway>/posthog_code`
26-
- OpenAI + codex models → pi's `openai-responses` API on `<gateway>/posthog_code/v1`
27-
28-
If the fetch fails, returns no models, or `PI_OFFLINE` / `HARNESS_STATIC_MODELS` is set, a bundled
29-
fallback list is used instead. Select any model with `--model posthog/<id>` (e.g.
30-
`--model posthog/gpt-5.3-codex`, `--model "posthog/@cf/zai-org/glm-5.2"`).
31-
32-
## OAuth flow and region selection
33-
34-
`harness /login` runs an Authorization-Code + PKCE flow:
35-
36-
1. Determines the region: if `POSTHOG_REGION` (or an explicit `region` option) is set, it's used
37-
directly; otherwise the login prompts interactively for the region to use, offering `United
38-
States` and `European Union` (`dev` is not offered interactively — it's reachable only via
39-
`POSTHOG_REGION=dev`).
40-
2. Generates a PKCE code verifier/challenge (`S256`) and a random `state`.
41-
3. Starts a loopback HTTP server on `127.0.0.1:<port>` at `/callback`
42-
(port from `HARNESS_OAUTH_PORT`, default `8237`).
43-
4. Builds the authorize URL for the resolved region with the same `client_id`, `scope`, and
44-
`required_access_level=project` as PostHog Code, and opens it in the default browser.
45-
5. Waits for the browser redirect to hit `/callback` with `code` and matching `state` (rejects on an
46-
`error` param, missing `code`, a `state` mismatch, a 180s timeout, or cancellation).
47-
6. Exchanges the code for tokens via `POST <cloudUrl>/oauth/token`.
48-
7. Stores `OAuthCredentials` (`access`, `refresh`, `expires`, `region`) for pi to reuse and refresh.
49-
50-
Token refresh posts `grant_type=refresh_token` to the same token endpoint, using the region stored in
51-
the credentials.
52-
53-
The provider also implements pi's `oauth.modifyModels` hook: whenever pi (re)loads models for this
54-
provider — at startup with a previously-stored credential, and again immediately after a successful
55-
login — it rewrites every model's `baseUrl` to match the region stored in that credential. This means
56-
the region chosen at login always wins for routing requests, regardless of what region the provider
57-
was initially registered with (e.g. before any login had happened).
3+
PostHog's [Pi](https://pi.dev) distribution.
4+
5+
It adds the PostHog LLM provider and the extensions in `src/extensions/` while keeping Pi's native runtime, sessions, tools, and RPC protocol.
586

597
## CLI
608

619
```bash
62-
harness # interactive pi, with the posthog provider available
63-
harness /login # sign in via the PostHog OAuth flow; prompts for a region if none is set
64-
harness -p "hi" --model posthog/claude-opus-4-8
10+
hog
11+
hog /login
12+
hog -p "Fix the tests" --model posthog/claude-opus-4-8
6513
```
6614

67-
`POSTHOG_REGION` (`us` / `eu` / `dev`) is optional: if set, it's used directly (skipping the region
68-
prompt at login) and the interactive prompt is skipped entirely. If unset, the initial (pre-login)
69-
provider registration defaults to `us` for model discovery, and `/login` prompts for the actual
70-
region to authenticate against — which then takes over routing via `modifyModels` above. The OAuth
71-
loopback callback port can be overridden with `HARNESS_OAUTH_PORT` (default `8237`).
15+
`harness` is an alias for `hog`.
7216

73-
## Spawn the CLI as a subprocess
17+
## Runtime
7418

7519
```ts
76-
import { spawnPiCli } from "@posthog/harness/spawn";
20+
import { createHarnessRuntime } from "@posthog/harness";
21+
22+
const runtime = await createHarnessRuntime({ cwd: "/workspace" });
7723

78-
const child = spawnPiCli(["-p", "list the files", "--model", "posthog/claude-opus-4-8"], {
79-
env: { POSTHOG_REGION: "us" },
80-
});
24+
await runtime.session.prompt("Fix the tests");
8125
```
8226

83-
`spawnPiCli` launches the real `pi` binary with the PostHog provider loaded as an extension.
27+
`createHarnessRuntime()` returns Pi's native `AgentSessionRuntime`.
8428

85-
## SDK
29+
## RPC
8630

8731
```ts
88-
import { createHarnessSession } from "@posthog/harness/session";
89-
90-
const session = await createHarnessSession({ region: "us", model: "claude-opus-4-8" });
91-
session.subscribe((event) => {
92-
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
93-
process.stdout.write(event.assistantMessageEvent.delta);
94-
}
95-
});
96-
await session.prompt("What files are in the current directory?");
32+
import { createHarnessRuntime, runRpcMode } from "@posthog/harness";
33+
34+
const runtime = await createHarnessRuntime({ cwd: "/workspace" });
35+
await runRpcMode(runtime);
9736
```
9837

99-
The SDK reuses whatever credential `harness /login` stored, or accepts a static `apiKey` (a `pha_`
100-
token) for headless use.
101-
102-
## MCP servers
103-
104-
The bundled `mcp` extension (see [`src/extensions/mcp/README.md`](./src/extensions/mcp/README.md))
105-
connects pi to [Model Context Protocol](https://modelcontextprotocol.io) servers and registers
106-
their tools as pi tools named `mcp_<server>_<tool>`.
107-
108-
Servers are configured in `mcp.json` — global (`~/.pi/agent/mcp.json`) and/or project-local
109-
(`.pi/mcp.json`, honored only for trusted projects; project entries override global ones per key):
110-
111-
```json
112-
{
113-
"mcpServers": {
114-
"filesystem": {
115-
"command": "npx",
116-
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
117-
},
118-
"linear": {
119-
"transport": "streamable-http",
120-
"url": "https://mcp.linear.app/mcp",
121-
"auth": { "type": "oauth" }
122-
}
123-
}
124-
}
38+
RPC is Pi's JSONL protocol over stdin/stdout. Harness does not define another protocol.
39+
40+
## Authentication
41+
42+
Run `hog /login`, or pass a PostHog personal API key:
43+
44+
```ts
45+
const runtime = await createHarnessRuntime({ apiKey: "pha_…" });
12546
```
12647

127-
Supports stdio, streamable-http, and SSE transports; eager/lazy startup; automatic reconnect;
128-
live tool refresh on `tools/list_changed`; static header auth; and full OAuth
129-
(authorization-code + PKCE with discovery, dynamic client registration, silent token refresh, and
130-
credentials stored under `~/.pi/agent/mcp-auth/`). Commands: `/mcp` (status), `/mcp:start`,
131-
`/mcp:stop`, `/mcp:auth [server] [reset]` (browser flow).
48+
Set `POSTHOG_REGION` to `us`, `eu`, or `dev` when needed.
13249

133-
## Entry points
50+
## Public API
13451

135-
| Import | What |
52+
| Import | Purpose |
13653
| --- | --- |
137-
| `@posthog/harness/cli` (bin `harness`) | pi CLI in-process with the PostHog provider |
138-
| `@posthog/harness/spawn` | `spawnPiCli()` — spawn pi as a subprocess |
139-
| `@posthog/harness/session` | `createHarnessSession()` — pi SDK `AgentSession` |
140-
| `@posthog/harness/extensions` | extension registry |
141-
| `@posthog/harness/extensions/hog-branding` | startup header rebrand — `createHogBrandingExtension()` |
142-
| `@posthog/harness/extensions/posthog-provider` | default pi extension — `createPosthogProviderExtension()` |
143-
| `@posthog/harness/extensions/posthog-provider/provider` | `POSTHOG_PROVIDER_NAME`, `buildPosthogProvider()`, `resolvePosthogProvider()` |
144-
| `@posthog/harness/extensions/posthog-provider/oauth` | `loginPosthog()`, `refreshPosthog()`, `buildAuthorizeUrl()`, `getRedirectUri()`, `getCallbackPort()` |
145-
| `@posthog/harness/extensions/posthog-provider/gateway` | `getGatewayBaseUrl()`, `getLlmGatewayUrl()`, `resolveRegion()`, `GATEWAY_PRODUCT` |
146-
| `@posthog/harness/extensions/posthog-provider/models` | `resolveModelConfigs()`, `fallbackModelConfigs()`, `DEFAULT_MODEL`, `GatewayModel` |
147-
| `@posthog/harness/extensions/web-access` | web search + fetch tools — `createWebAccessExtension()` |
148-
| `@posthog/harness/extensions/subagent` | subagent orchestration — `createSubagentExtension()` |
149-
| `@posthog/harness/extensions/mcp` | MCP client extension — `createMcpExtension()` |
54+
| `@posthog/harness` | Runtime creation and RPC mode |
55+
| `@posthog/harness/runtime` | Runtime creation only |
56+
| `@posthog/harness/extensions` | Harness extension registry |
57+
58+
Individual extensions are also exported under `@posthog/harness/extensions/*`.

packages/harness/package.json

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@
88
"hog": "dist/cli.js"
99
},
1010
"exports": {
11+
".": {
12+
"types": "./dist/index.d.ts",
13+
"import": "./dist/index.js"
14+
},
1115
"./cli": {
1216
"types": "./dist/cli.d.ts",
1317
"import": "./dist/cli.js"
1418
},
15-
"./session": {
16-
"types": "./dist/session.d.ts",
17-
"import": "./dist/session.js"
18-
},
19-
"./spawn": {
20-
"types": "./dist/spawn.d.ts",
21-
"import": "./dist/spawn.js"
19+
"./runtime": {
20+
"types": "./dist/runtime.d.ts",
21+
"import": "./dist/runtime.js"
2222
},
2323
"./extensions": {
2424
"types": "./dist/extensions/registry.d.ts",
@@ -89,9 +89,9 @@
8989
"clean": "node ../../scripts/rimraf.mjs dist .turbo"
9090
},
9191
"dependencies": {
92-
"@earendil-works/pi-ai": "0.80.3",
93-
"@earendil-works/pi-coding-agent": "0.80.6",
94-
"@earendil-works/pi-tui": "0.80.6",
92+
"@earendil-works/pi-ai": "catalog:",
93+
"@earendil-works/pi-coding-agent": "catalog:",
94+
"@earendil-works/pi-tui": "catalog:",
9595
"@modelcontextprotocol/sdk": "^1.29.0",
9696
"@posthog/shared": "workspace:*",
9797
"lru-cache": "^11.1.0",

0 commit comments

Comments
 (0)