Skip to content

Commit e8e5ed5

Browse files
authored
feat: add hog harness, a pi distribution (#3219)
1 parent 915d05c commit e8e5ed5

77 files changed

Lines changed: 11210 additions & 58 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/harness/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# @posthog/harness
2+
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.5`, `gpt-5.4`,
22+
`gpt-5.3-codex`, …) and GLM (`@cf/zai-org/glm-5.2`). Each model is routed by owner:
23+
24+
- Anthropic + Cloudflare/GLM models → pi's `anthropic-messages` API on `<gateway>/posthog_code`
25+
- OpenAI + codex models → pi's `openai-responses` API on `<gateway>/posthog_code/v1`
26+
27+
If the fetch fails, returns no models, or `PI_OFFLINE` / `HARNESS_STATIC_MODELS` is set, a bundled
28+
fallback list is used instead. Select any model with `--model posthog/<id>` (e.g.
29+
`--model posthog/gpt-5.3-codex`, `--model "posthog/@cf/zai-org/glm-5.2"`).
30+
31+
## OAuth flow and region selection
32+
33+
`harness /login` runs an Authorization-Code + PKCE flow:
34+
35+
1. Determines the region: if `POSTHOG_REGION` (or an explicit `region` option) is set, it's used
36+
directly; otherwise the login prompts interactively for the region to use, offering `United
37+
States` and `European Union` (`dev` is not offered interactively — it's reachable only via
38+
`POSTHOG_REGION=dev`).
39+
2. Generates a PKCE code verifier/challenge (`S256`) and a random `state`.
40+
3. Starts a loopback HTTP server on `127.0.0.1:<port>` at `/callback`
41+
(port from `HARNESS_OAUTH_PORT`, default `8237`).
42+
4. Builds the authorize URL for the resolved region with the same `client_id`, `scope`, and
43+
`required_access_level=project` as PostHog Code, and opens it in the default browser.
44+
5. Waits for the browser redirect to hit `/callback` with `code` and matching `state` (rejects on an
45+
`error` param, missing `code`, a `state` mismatch, a 180s timeout, or cancellation).
46+
6. Exchanges the code for tokens via `POST <cloudUrl>/oauth/token`.
47+
7. Stores `OAuthCredentials` (`access`, `refresh`, `expires`, `region`) for pi to reuse and refresh.
48+
49+
Token refresh posts `grant_type=refresh_token` to the same token endpoint, using the region stored in
50+
the credentials.
51+
52+
The provider also implements pi's `oauth.modifyModels` hook: whenever pi (re)loads models for this
53+
provider — at startup with a previously-stored credential, and again immediately after a successful
54+
login — it rewrites every model's `baseUrl` to match the region stored in that credential. This means
55+
the region chosen at login always wins for routing requests, regardless of what region the provider
56+
was initially registered with (e.g. before any login had happened).
57+
58+
## CLI
59+
60+
```bash
61+
harness # interactive pi, with the posthog provider available
62+
harness /login # sign in via the PostHog OAuth flow; prompts for a region if none is set
63+
harness -p "hi" --model posthog/claude-opus-4-8
64+
```
65+
66+
`POSTHOG_REGION` (`us` / `eu` / `dev`) is optional: if set, it's used directly (skipping the region
67+
prompt at login) and the interactive prompt is skipped entirely. If unset, the initial (pre-login)
68+
provider registration defaults to `us` for model discovery, and `/login` prompts for the actual
69+
region to authenticate against — which then takes over routing via `modifyModels` above. The OAuth
70+
loopback callback port can be overridden with `HARNESS_OAUTH_PORT` (default `8237`).
71+
72+
## Spawn the CLI as a subprocess
73+
74+
```ts
75+
import { spawnPiCli } from "@posthog/harness/spawn";
76+
77+
const child = spawnPiCli(["-p", "list the files", "--model", "posthog/claude-opus-4-8"], {
78+
env: { POSTHOG_REGION: "us" },
79+
});
80+
```
81+
82+
`spawnPiCli` launches the real `pi` binary with the PostHog provider loaded as an extension.
83+
84+
## SDK
85+
86+
```ts
87+
import { createHarnessSession } from "@posthog/harness/session";
88+
89+
const session = await createHarnessSession({ region: "us", model: "claude-opus-4-8" });
90+
session.subscribe((event) => {
91+
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
92+
process.stdout.write(event.assistantMessageEvent.delta);
93+
}
94+
});
95+
await session.prompt("What files are in the current directory?");
96+
```
97+
98+
The SDK reuses whatever credential `harness /login` stored, or accepts a static `apiKey` (a `pha_`
99+
token) for headless use.
100+
101+
## Entry points
102+
103+
| Import | What |
104+
| --- | --- |
105+
| `@posthog/harness/cli` (bin `harness`) | pi CLI in-process with the PostHog provider |
106+
| `@posthog/harness/spawn` | `spawnPiCli()` — spawn pi as a subprocess |
107+
| `@posthog/harness/session` | `createHarnessSession()` — pi SDK `AgentSession` |
108+
| `@posthog/harness/extensions` | extension registry |
109+
| `@posthog/harness/extensions/hog-branding` | startup header rebrand — `createHogBrandingExtension()` |
110+
| `@posthog/harness/extensions/posthog-provider` | default pi extension — `createPosthogProviderExtension()` |
111+
| `@posthog/harness/extensions/posthog-provider/provider` | `POSTHOG_PROVIDER_NAME`, `buildPosthogProvider()`, `resolvePosthogProvider()` |
112+
| `@posthog/harness/extensions/posthog-provider/oauth` | `loginPosthog()`, `refreshPosthog()`, `buildAuthorizeUrl()`, `getRedirectUri()`, `getCallbackPort()` |
113+
| `@posthog/harness/extensions/posthog-provider/gateway` | `getGatewayBaseUrl()`, `getLlmGatewayUrl()`, `resolveRegion()`, `GATEWAY_PRODUCT` |
114+
| `@posthog/harness/extensions/posthog-provider/models` | `resolveModelConfigs()`, `fallbackModelConfigs()`, `DEFAULT_MODEL`, `GatewayModel` |

packages/harness/package.json

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
{
2+
"name": "@posthog/harness",
3+
"version": "0.0.0-dev",
4+
"description": "Spawn the pi.dev coding agent (CLI + SDK) against the PostHog LLM gateway with PostHog OAuth",
5+
"type": "module",
6+
"bin": {
7+
"harness": "dist/cli.js"
8+
},
9+
"exports": {
10+
"./cli": {
11+
"types": "./dist/cli.d.ts",
12+
"import": "./dist/cli.js"
13+
},
14+
"./session": {
15+
"types": "./dist/session.d.ts",
16+
"import": "./dist/session.js"
17+
},
18+
"./spawn": {
19+
"types": "./dist/spawn.d.ts",
20+
"import": "./dist/spawn.js"
21+
},
22+
"./extensions": {
23+
"types": "./dist/extensions/registry.d.ts",
24+
"import": "./dist/extensions/registry.js"
25+
},
26+
"./extensions/hog-branding": {
27+
"types": "./dist/extensions/hog-branding/extension.d.ts",
28+
"import": "./dist/extensions/hog-branding/extension.js"
29+
},
30+
"./extensions/hog-branding/*": {
31+
"types": "./dist/extensions/hog-branding/*.d.ts",
32+
"import": "./dist/extensions/hog-branding/*.js"
33+
},
34+
"./extensions/posthog-provider": {
35+
"types": "./dist/extensions/posthog-provider/extension.d.ts",
36+
"import": "./dist/extensions/posthog-provider/extension.js"
37+
},
38+
"./extensions/posthog-provider/*": {
39+
"types": "./dist/extensions/posthog-provider/*.d.ts",
40+
"import": "./dist/extensions/posthog-provider/*.js"
41+
},
42+
"./extensions/web-access": {
43+
"types": "./dist/extensions/web-access/extension.d.ts",
44+
"import": "./dist/extensions/web-access/extension.js"
45+
},
46+
"./extensions/web-access/*": {
47+
"types": "./dist/extensions/web-access/*.d.ts",
48+
"import": "./dist/extensions/web-access/*.js"
49+
},
50+
"./extensions/subagent": {
51+
"types": "./dist/extensions/subagent/extension.d.ts",
52+
"import": "./dist/extensions/subagent/extension.js"
53+
},
54+
"./extensions/subagent/*": {
55+
"types": "./dist/extensions/subagent/*.d.ts",
56+
"import": "./dist/extensions/subagent/*.js"
57+
}
58+
},
59+
"scripts": {
60+
"build": "tsup",
61+
"dev": "tsup --watch",
62+
"test": "vitest run",
63+
"typecheck": "tsc --noEmit",
64+
"clean": "node ../../scripts/rimraf.mjs dist .turbo"
65+
},
66+
"dependencies": {
67+
"@earendil-works/pi-ai": "0.80.3",
68+
"@earendil-works/pi-coding-agent": "0.80.3",
69+
"@earendil-works/pi-tui": "0.80.3",
70+
"@posthog/shared": "workspace:*",
71+
"lru-cache": "^11.1.0",
72+
"pi-mcp-adapter": "2.10.0",
73+
"turndown": "^7.2.4"
74+
},
75+
"devDependencies": {
76+
"@types/node": "^22.0.0",
77+
"@types/turndown": "^5.0.6",
78+
"tsup": "^8.5.1",
79+
"typescript": "^5.5.0",
80+
"vitest": "^4.1.8"
81+
},
82+
"files": [
83+
"dist/**/*",
84+
"src/**/*"
85+
]
86+
}

packages/harness/src/cli.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env node
2+
3+
import { main } from "@earendil-works/pi-coding-agent";
4+
import { harnessExtensionFiles } from "./spawn";
5+
6+
// Load every harness extension by file path (rather than via
7+
// `extensionFactories`) so each shows its real name in the startup banner
8+
// instead of `<inline:N>`; pi's loader only has a display name to show when
9+
// an extension is loaded from a path.
10+
const extensionArgs = harnessExtensionFiles().flatMap((file) => ["-e", file]);
11+
main([...extensionArgs, ...process.argv.slice(2)]);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
# Harness extensions
2+
3+
Every harness capability is a **pi.dev extension**: a proper, first-class extension pi loads
4+
through its own extension machinery. Each one lives in its own folder here and follows the same
5+
shape, so adding the Nth extension is mechanical.
6+
7+
## Convention
8+
9+
```
10+
src/extensions/<extension-name>/
11+
extension.ts # REQUIRED — the real implementation
12+
index.ts # REQUIRED — `export { default } from "./extension";`
13+
... # any supporting modules the extension needs
14+
```
15+
16+
`index.ts` is loaded (as `dist/extensions/<name>/index.js`) by `-e`, instead of
17+
`extension.js` directly, purely for display: pi's startup banner derives an
18+
extension's name from its file path, and drops a trailing `index.ts`/`index.js`
19+
segment in favor of the parent directory name. Loading `extension.js` directly
20+
would show up as `<name>/extension.js` (and collide with any other extension
21+
also named `extension.js`, backing off to even longer paths); loading
22+
`index.js` shows the clean `<name>`.
23+
24+
`extension.ts` must:
25+
26+
1. `export default` a pi `ExtensionFactory``(pi: ExtensionAPI) => void | Promise<void>`.
27+
This is what `pi -e <path>` loads. It is zero-config; read any options from the environment.
28+
2. `export` a named `create<Name>Extension(options)` that returns an `ExtensionFactory`.
29+
This is the configurable form used programmatically (CLI + SDK).
30+
31+
```ts
32+
// src/extensions/<name>/extension.ts
33+
import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";
34+
35+
export function createExampleExtension(options: ExampleOptions = {}): ExtensionFactory {
36+
return async (pi: ExtensionAPI) => {
37+
// pi.registerProvider(...) / pi.registerTool(...) / pi.on(...)
38+
};
39+
}
40+
41+
export default function example(pi: ExtensionAPI): void | Promise<void> {
42+
return createExampleExtension()(pi);
43+
}
44+
```
45+
46+
## Registering it
47+
48+
Add one line to [`registry.ts`](./registry.ts):
49+
50+
```ts
51+
const EXTENSIONS: HarnessExtension[] = [
52+
{ name: "posthog-provider", create: createPosthogProviderExtension },
53+
{ name: "example", create: createExampleExtension },
54+
];
55+
```
56+
57+
`registry.ts` is the single source of truth. Both entry paths consume it, so a registered extension
58+
is loaded everywhere with no further wiring:
59+
60+
- **In-process CLI** (`src/cli.ts`) → one `-e dist/extensions/<name>/index.js` per extension, passed
61+
through argv (`main([...extensionArgs, ...args])`)
62+
- **Subprocess** (`src/spawn.ts`) → one `-e dist/extensions/<name>/index.js` per extension
63+
64+
Both load extensions by file path (not `extensionFactories`), so each one shows its real name in pi's
65+
startup banner instead of `<inline:N>`. `harnessExtensions()` (function factories, taking
66+
`HarnessExtensionOptions`) remains available for programmatic embedding — for example
67+
`session.ts`'s lean SDK path — where a caller needs to inject runtime options.
68+
69+
Both `-e` paths are real pi extension-loading paths, verified to register in every pi mode
70+
(interactive, print, rpc, json, and `--list-models`).

0 commit comments

Comments
 (0)