Skip to content
Merged
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
114 changes: 114 additions & 0 deletions packages/harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# @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.<region>.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.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 `<gateway>/posthog_code`
- OpenAI + codex models → pi's `openai-responses` API on `<gateway>/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/<id>` (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:<port>` 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 <cloudUrl>/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).

## 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
```

`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`).

## Spawn the CLI as a subprocess

```ts
import { spawnPiCli } from "@posthog/harness/spawn";

const child = spawnPiCli(["-p", "list the files", "--model", "posthog/claude-opus-4-8"], {
env: { POSTHOG_REGION: "us" },
});
```

`spawnPiCli` launches the real `pi` binary with the PostHog provider loaded as an extension.

## SDK

```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?");
```

The SDK reuses whatever credential `harness /login` stored, or accepts a static `apiKey` (a `pha_`
token) for headless use.

## Entry points

| Import | What |
| --- | --- |
| `@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` |
86 changes: 86 additions & 0 deletions packages/harness/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"name": "@posthog/harness",
"version": "0.0.0-dev",
"description": "Spawn the pi.dev coding agent (CLI + SDK) against the PostHog LLM gateway with PostHog OAuth",
"type": "module",
"bin": {
"harness": "dist/cli.js"
},
"exports": {
"./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"
},
"./extensions": {
"types": "./dist/extensions/registry.d.ts",
"import": "./dist/extensions/registry.js"
},
"./extensions/hog-branding": {
"types": "./dist/extensions/hog-branding/extension.d.ts",
"import": "./dist/extensions/hog-branding/extension.js"
},
"./extensions/hog-branding/*": {
"types": "./dist/extensions/hog-branding/*.d.ts",
"import": "./dist/extensions/hog-branding/*.js"
},
"./extensions/posthog-provider": {
"types": "./dist/extensions/posthog-provider/extension.d.ts",
"import": "./dist/extensions/posthog-provider/extension.js"
},
"./extensions/posthog-provider/*": {
"types": "./dist/extensions/posthog-provider/*.d.ts",
"import": "./dist/extensions/posthog-provider/*.js"
},
"./extensions/web-access": {
"types": "./dist/extensions/web-access/extension.d.ts",
"import": "./dist/extensions/web-access/extension.js"
},
"./extensions/web-access/*": {
"types": "./dist/extensions/web-access/*.d.ts",
"import": "./dist/extensions/web-access/*.js"
},
"./extensions/subagent": {
"types": "./dist/extensions/subagent/extension.d.ts",
"import": "./dist/extensions/subagent/extension.js"
},
"./extensions/subagent/*": {
"types": "./dist/extensions/subagent/*.d.ts",
"import": "./dist/extensions/subagent/*.js"
}
},
"scripts": {
"build": "tsup",
"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.3",
"@earendil-works/pi-tui": "0.80.3",
"@posthog/shared": "workspace:*",
"lru-cache": "^11.1.0",
"pi-mcp-adapter": "2.10.0",
"turndown": "^7.2.4"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/turndown": "^5.0.6",
"tsup": "^8.5.1",
"typescript": "^5.5.0",
"vitest": "^4.1.8"
},
"files": [
"dist/**/*",
"src/**/*"
]
}
11 changes: 11 additions & 0 deletions packages/harness/src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env node

import { main } from "@earendil-works/pi-coding-agent";
import { harnessExtensionFiles } from "./spawn";

// Load every harness extension by file path (rather than via
// `extensionFactories`) so each shows its real name in the startup banner
// instead of `<inline:N>`; pi's loader only has a display name to show when
// an extension is loaded from a path.
const extensionArgs = harnessExtensionFiles().flatMap((file) => ["-e", file]);
main([...extensionArgs, ...process.argv.slice(2)]);
70 changes: 70 additions & 0 deletions packages/harness/src/extensions/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Harness extensions

Every harness capability is a **pi.dev extension**: a proper, first-class extension pi loads
through its own extension machinery. Each one lives in its own folder here and follows the same
shape, so adding the Nth extension is mechanical.

## Convention

```
src/extensions/<extension-name>/
extension.ts # REQUIRED — the real implementation
index.ts # REQUIRED — `export { default } from "./extension";`
... # any supporting modules the extension needs
```

`index.ts` is loaded (as `dist/extensions/<name>/index.js`) by `-e`, instead of
`extension.js` directly, purely for display: pi's startup banner derives an
extension's name from its file path, and drops a trailing `index.ts`/`index.js`
segment in favor of the parent directory name. Loading `extension.js` directly
would show up as `<name>/extension.js` (and collide with any other extension
also named `extension.js`, backing off to even longer paths); loading
`index.js` shows the clean `<name>`.

`extension.ts` must:

1. `export default` a pi `ExtensionFactory` — `(pi: ExtensionAPI) => void | Promise<void>`.
This is what `pi -e <path>` loads. It is zero-config; read any options from the environment.
2. `export` a named `create<Name>Extension(options)` that returns an `ExtensionFactory`.
This is the configurable form used programmatically (CLI + SDK).

```ts
// src/extensions/<name>/extension.ts
import type { ExtensionAPI, ExtensionFactory } from "@earendil-works/pi-coding-agent";

export function createExampleExtension(options: ExampleOptions = {}): ExtensionFactory {
return async (pi: ExtensionAPI) => {
// pi.registerProvider(...) / pi.registerTool(...) / pi.on(...)
};
}

export default function example(pi: ExtensionAPI): void | Promise<void> {
return createExampleExtension()(pi);
}
```

## Registering it

Add one line to [`registry.ts`](./registry.ts):

```ts
const EXTENSIONS: HarnessExtension[] = [
{ name: "posthog-provider", create: createPosthogProviderExtension },
{ name: "example", create: createExampleExtension },
];
```

`registry.ts` is the single source of truth. Both 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/<name>/index.js` per extension, passed
through argv (`main([...extensionArgs, ...args])`)
- **Subprocess** (`src/spawn.ts`) → one `-e dist/extensions/<name>/index.js` per extension

Both load extensions by file path (not `extensionFactories`), so each one shows its real name in pi's
startup banner instead of `<inline:N>`. `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`).
Loading
Loading