From 7da5a39664c38f588e098cb5b7868802c4234a43 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Mon, 6 Jul 2026 14:26:22 +0200 Subject: [PATCH 1/4] feat: @posthog/harness --- packages/harness/README.md | 113 ++ packages/harness/package.json | 57 + packages/harness/src/cli.ts | 8 + packages/harness/src/extensions/README.md | 55 + .../posthog-provider/extension.test.ts | 79 ++ .../extensions/posthog-provider/extension.ts | 26 + .../posthog-provider/gateway.test.ts | 116 +++ .../extensions/posthog-provider/gateway.ts | 36 + .../posthog-provider/models.test.ts | 209 ++++ .../src/extensions/posthog-provider/models.ts | 194 ++++ .../extensions/posthog-provider/oauth.test.ts | 601 +++++++++++ .../src/extensions/posthog-provider/oauth.ts | 252 +++++ .../posthog-provider/provider.test.ts | 227 ++++ .../extensions/posthog-provider/provider.ts | 75 ++ packages/harness/src/extensions/registry.ts | 24 + packages/harness/src/session.ts | 54 + packages/harness/src/spawn.ts | 42 + packages/harness/tsconfig.json | 21 + packages/harness/tsup.config.ts | 22 + pnpm-lock.yaml | 974 +++++++++++++++++- pnpm-workspace.yaml | 4 + 21 files changed, 3186 insertions(+), 3 deletions(-) create mode 100644 packages/harness/README.md create mode 100644 packages/harness/package.json create mode 100644 packages/harness/src/cli.ts create mode 100644 packages/harness/src/extensions/README.md create mode 100644 packages/harness/src/extensions/posthog-provider/extension.test.ts create mode 100644 packages/harness/src/extensions/posthog-provider/extension.ts create mode 100644 packages/harness/src/extensions/posthog-provider/gateway.test.ts create mode 100644 packages/harness/src/extensions/posthog-provider/gateway.ts create mode 100644 packages/harness/src/extensions/posthog-provider/models.test.ts create mode 100644 packages/harness/src/extensions/posthog-provider/models.ts create mode 100644 packages/harness/src/extensions/posthog-provider/oauth.test.ts create mode 100644 packages/harness/src/extensions/posthog-provider/oauth.ts create mode 100644 packages/harness/src/extensions/posthog-provider/provider.test.ts create mode 100644 packages/harness/src/extensions/posthog-provider/provider.ts create mode 100644 packages/harness/src/extensions/registry.ts create mode 100644 packages/harness/src/session.ts create mode 100644 packages/harness/src/spawn.ts create mode 100644 packages/harness/tsconfig.json create mode 100644 packages/harness/tsup.config.ts diff --git a/packages/harness/README.md b/packages/harness/README.md new file mode 100644 index 0000000000..ed21bfa8de --- /dev/null +++ b/packages/harness/README.md @@ -0,0 +1,113 @@ +# @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.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). + +## 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/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` | diff --git a/packages/harness/package.json b/packages/harness/package.json new file mode 100644 index 0000000000..08ae32e4ca --- /dev/null +++ b/packages/harness/package.json @@ -0,0 +1,57 @@ +{ + "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/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" + } + }, + "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", + "@posthog/shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsup": "^8.5.1", + "typescript": "^5.5.0", + "vitest": "^4.1.8" + }, + "files": [ + "dist/**/*", + "src/**/*" + ] +} diff --git a/packages/harness/src/cli.ts b/packages/harness/src/cli.ts new file mode 100644 index 0000000000..266566968a --- /dev/null +++ b/packages/harness/src/cli.ts @@ -0,0 +1,8 @@ +#!/usr/bin/env node + +import { main } from "@earendil-works/pi-coding-agent"; +import { harnessExtensions } from "./extensions/registry"; + +main(process.argv.slice(2), { + extensionFactories: harnessExtensions(), +}); diff --git a/packages/harness/src/extensions/README.md b/packages/harness/src/extensions/README.md new file mode 100644 index 0000000000..e599485186 --- /dev/null +++ b/packages/harness/src/extensions/README.md @@ -0,0 +1,55 @@ +# 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.ts # REQUIRED — the pi entry point + ... # any supporting modules the extension needs +``` + +`extension.ts` must: + +1. `export default` a pi `ExtensionFactory` — `(pi: ExtensionAPI) => void | Promise`. + This is what `pi -e ` loads. It is zero-config; read any options from the environment. +2. `export` a named `createExtension(options)` that returns an `ExtensionFactory`. + This is the configurable form used programmatically (CLI + SDK). + +```ts +// src/extensions//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 { + 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`) → `main(argv, { extensionFactories: harnessExtensions() })` +- **Subprocess** (`src/spawn.ts`) → one `-e dist/extensions//extension.js` per extension + +Both are real pi extension-loading paths, verified to register in every pi mode (interactive, print, +rpc, json, and `--list-models`). diff --git a/packages/harness/src/extensions/posthog-provider/extension.test.ts b/packages/harness/src/extensions/posthog-provider/extension.test.ts new file mode 100644 index 0000000000..4c09cfffda --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/extension.test.ts @@ -0,0 +1,79 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import posthogProvider, { createPosthogProviderExtension } from "./extension"; +import { getLlmGatewayUrl } from "./gateway"; +import { POSTHOG_PROVIDER_NAME } from "./provider"; + +function fakeApi(): ExtensionAPI { + return { + registerProvider: vi.fn(), + } as unknown as ExtensionAPI; +} + +describe("createPosthogProviderExtension", () => { + const originalOffline = process.env.PI_OFFLINE; + + beforeEach(() => { + process.env.PI_OFFLINE = "1"; + }); + + afterEach(() => { + if (originalOffline === undefined) { + delete process.env.PI_OFFLINE; + } else { + process.env.PI_OFFLINE = originalOffline; + } + }); + + it("registers the posthog provider with a resolved config", async () => { + const pi = fakeApi(); + const factory = createPosthogProviderExtension({ region: "us" }); + + await factory(pi); + + expect(pi.registerProvider).toHaveBeenCalledTimes(1); + const [name, config] = (pi.registerProvider as ReturnType) + .mock.calls[0]; + expect(name).toBe(POSTHOG_PROVIDER_NAME); + expect(config.baseUrl).toBe(getLlmGatewayUrl("us")); + expect(config.models?.length).toBeGreaterThan(0); + }); + + it("forwards a static api key option through to the registered config", async () => { + const pi = fakeApi(); + const factory = createPosthogProviderExtension({ apiKey: "pha_test" }); + + await factory(pi); + + const [, config] = (pi.registerProvider as ReturnType).mock + .calls[0]; + expect(config.apiKey).toBe("pha_test"); + }); +}); + +describe("posthogProvider default export", () => { + const originalOffline = process.env.PI_OFFLINE; + + beforeEach(() => { + process.env.PI_OFFLINE = "1"; + }); + + afterEach(() => { + if (originalOffline === undefined) { + delete process.env.PI_OFFLINE; + } else { + process.env.PI_OFFLINE = originalOffline; + } + }); + + it("registers the posthog provider using default options", async () => { + const pi = fakeApi(); + + await posthogProvider(pi); + + expect(pi.registerProvider).toHaveBeenCalledTimes(1); + const [name] = (pi.registerProvider as ReturnType).mock + .calls[0]; + expect(name).toBe(POSTHOG_PROVIDER_NAME); + }); +}); diff --git a/packages/harness/src/extensions/posthog-provider/extension.ts b/packages/harness/src/extensions/posthog-provider/extension.ts new file mode 100644 index 0000000000..8663bf8fbe --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/extension.ts @@ -0,0 +1,26 @@ +import type { + ExtensionAPI, + ExtensionFactory, +} from "@earendil-works/pi-coding-agent"; +import { + POSTHOG_PROVIDER_NAME, + type PosthogProviderOptions, + resolvePosthogProvider, +} from "./provider"; + +export function createPosthogProviderExtension( + options: PosthogProviderOptions = {}, +): ExtensionFactory { + return async (pi: ExtensionAPI) => { + pi.registerProvider( + POSTHOG_PROVIDER_NAME, + await resolvePosthogProvider(options), + ); + }; +} + +export default function posthogProvider( + pi: ExtensionAPI, +): void | Promise { + return createPosthogProviderExtension()(pi); +} diff --git a/packages/harness/src/extensions/posthog-provider/gateway.test.ts b/packages/harness/src/extensions/posthog-provider/gateway.test.ts new file mode 100644 index 0000000000..0491186207 --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/gateway.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + GATEWAY_PRODUCT, + getGatewayBaseUrl, + getLlmGatewayUrl, + resolveExplicitRegion, + resolveRegion, +} from "./gateway"; + +describe("getGatewayBaseUrl", () => { + it("maps each region to its own gateway host", () => { + expect(getGatewayBaseUrl("us")).toBe("https://gateway.us.posthog.com"); + expect(getGatewayBaseUrl("eu")).toBe("https://gateway.eu.posthog.com"); + expect(getGatewayBaseUrl("dev")).toBe("http://localhost:3308"); + }); +}); + +describe("getLlmGatewayUrl", () => { + it("appends the product path to the region gateway host", () => { + expect(getLlmGatewayUrl("us")).toBe( + `https://gateway.us.posthog.com/${GATEWAY_PRODUCT}`, + ); + expect(getLlmGatewayUrl("eu")).toBe( + `https://gateway.eu.posthog.com/${GATEWAY_PRODUCT}`, + ); + expect(getLlmGatewayUrl("dev")).toBe( + `http://localhost:3308/${GATEWAY_PRODUCT}`, + ); + }); +}); + +describe("resolveRegion", () => { + const originalRegion = process.env.POSTHOG_REGION; + + beforeEach(() => { + delete process.env.POSTHOG_REGION; + }); + + afterEach(() => { + if (originalRegion === undefined) { + delete process.env.POSTHOG_REGION; + } else { + process.env.POSTHOG_REGION = originalRegion; + } + }); + + it("prefers the explicit region over the environment", () => { + process.env.POSTHOG_REGION = "eu"; + expect(resolveRegion("dev")).toBe("dev"); + }); + + it("falls back to a valid POSTHOG_REGION environment variable", () => { + process.env.POSTHOG_REGION = "eu"; + expect(resolveRegion()).toBe("eu"); + }); + + it.each(["us", "eu", "dev"] as const)( + "accepts %s from the environment", + (region) => { + process.env.POSTHOG_REGION = region; + expect(resolveRegion()).toBe(region); + }, + ); + + it("defaults to us when nothing is set", () => { + expect(resolveRegion()).toBe("us"); + }); + + it("defaults to us when the environment value is not a known region", () => { + process.env.POSTHOG_REGION = "not-a-region"; + expect(resolveRegion()).toBe("us"); + }); + + it("defaults to us when explicit and environment are both unset", () => { + expect(resolveRegion(undefined)).toBe("us"); + }); +}); + +describe("resolveExplicitRegion", () => { + const originalRegion = process.env.POSTHOG_REGION; + + beforeEach(() => { + delete process.env.POSTHOG_REGION; + }); + + afterEach(() => { + if (originalRegion === undefined) { + delete process.env.POSTHOG_REGION; + } else { + process.env.POSTHOG_REGION = originalRegion; + } + }); + + it("returns undefined when nothing was configured", () => { + expect(resolveExplicitRegion()).toBeUndefined(); + }); + + it("returns undefined when the environment value is not a known region", () => { + process.env.POSTHOG_REGION = "not-a-region"; + expect(resolveExplicitRegion()).toBeUndefined(); + }); + + it("returns the explicit option when given", () => { + expect(resolveExplicitRegion("dev")).toBe("dev"); + }); + + it("prefers the explicit option over the environment", () => { + process.env.POSTHOG_REGION = "eu"; + expect(resolveExplicitRegion("dev")).toBe("dev"); + }); + + it("falls back to a valid POSTHOG_REGION when no explicit option is given", () => { + process.env.POSTHOG_REGION = "dev"; + expect(resolveExplicitRegion()).toBe("dev"); + }); +}); diff --git a/packages/harness/src/extensions/posthog-provider/gateway.ts b/packages/harness/src/extensions/posthog-provider/gateway.ts new file mode 100644 index 0000000000..bb56be3fdd --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/gateway.ts @@ -0,0 +1,36 @@ +import type { CloudRegion } from "@posthog/shared"; + +export const GATEWAY_PRODUCT = "posthog_code"; + +const GATEWAY_HOSTS: Record = { + us: "https://gateway.us.posthog.com", + eu: "https://gateway.eu.posthog.com", + dev: "http://localhost:3308", +}; + +export function getGatewayBaseUrl(region: CloudRegion): string { + return GATEWAY_HOSTS[region]; +} + +export function getLlmGatewayUrl(region: CloudRegion): string { + return `${getGatewayBaseUrl(region)}/${GATEWAY_PRODUCT}`; +} + +/** + * Returns the region only when one was actually configured (an explicit + * option or a valid `POSTHOG_REGION`), or `undefined` when the caller should + * decide the region some other way (e.g. prompting interactively at login). + */ +export function resolveExplicitRegion( + explicit?: CloudRegion, +): CloudRegion | undefined { + const candidate = explicit ?? process.env.POSTHOG_REGION; + if (candidate === "us" || candidate === "eu" || candidate === "dev") { + return candidate; + } + return undefined; +} + +export function resolveRegion(explicit?: CloudRegion): CloudRegion { + return resolveExplicitRegion(explicit) ?? "us"; +} diff --git a/packages/harness/src/extensions/posthog-provider/models.test.ts b/packages/harness/src/extensions/posthog-provider/models.test.ts new file mode 100644 index 0000000000..048e088e8d --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/models.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getLlmGatewayUrl } from "./gateway"; +import { + fallbackModelConfigs, + gatewayBaseUrlForApi, + resolveModelConfigs, +} from "./models"; + +describe("gatewayBaseUrlForApi", () => { + it("routes openai-responses through the gateway's /v1 surface", () => { + expect(gatewayBaseUrlForApi("openai-responses", "us")).toBe( + `${getLlmGatewayUrl("us")}/v1`, + ); + }); + + it("routes every other api through the gateway's product root", () => { + expect(gatewayBaseUrlForApi("anthropic-messages", "eu")).toBe( + getLlmGatewayUrl("eu"), + ); + expect(gatewayBaseUrlForApi("openai-completions", "dev")).toBe( + getLlmGatewayUrl("dev"), + ); + }); +}); + +describe("resolveModelConfigs", () => { + const originalFetch = global.fetch; + const originalOffline = process.env.PI_OFFLINE; + const originalStatic = process.env.HARNESS_STATIC_MODELS; + + beforeEach(() => { + delete process.env.PI_OFFLINE; + delete process.env.HARNESS_STATIC_MODELS; + }); + + afterEach(() => { + global.fetch = originalFetch; + if (originalOffline === undefined) { + delete process.env.PI_OFFLINE; + } else { + process.env.PI_OFFLINE = originalOffline; + } + if (originalStatic === undefined) { + delete process.env.HARNESS_STATIC_MODELS; + } else { + process.env.HARNESS_STATIC_MODELS = originalStatic; + } + vi.restoreAllMocks(); + }); + + it("returns fallback models when PI_OFFLINE is set", async () => { + process.env.PI_OFFLINE = "1"; + const fetchSpy = vi.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(configs).toEqual(fallbackModelConfigs("us")); + }); + + it("returns fallback models when HARNESS_STATIC_MODELS is set", async () => { + process.env.HARNESS_STATIC_MODELS = "1"; + const fetchSpy = vi.fn(); + global.fetch = fetchSpy as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(configs).toEqual(fallbackModelConfigs("us")); + }); + + it("returns fallback models when the gateway request throws", async () => { + global.fetch = vi + .fn() + .mockRejectedValue(new Error("network down")) as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(configs).toEqual(fallbackModelConfigs("us")); + }); + + it("returns fallback models when the gateway responds with a non-ok status", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + }) as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(configs).toEqual(fallbackModelConfigs("us")); + }); + + it("returns fallback models when the gateway response has no data array", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({}), + }) as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(configs).toEqual(fallbackModelConfigs("us")); + }); + + it("maps live gateway models into provider model configs, requesting the /v1/models endpoint", async () => { + const fetchSpy = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { + id: "claude-opus-4-8", + owned_by: "anthropic", + display_name: "Claude Opus", + context_window: 500000, + supports_vision: true, + }, + { + id: "gpt-5.5", + owned_by: "openai", + context_window: 1000, + supports_vision: false, + }, + { id: "" }, + ], + }), + }); + global.fetch = fetchSpy as unknown as typeof fetch; + + const configs = await resolveModelConfigs("dev"); + + expect(fetchSpy).toHaveBeenCalledWith( + `${getLlmGatewayUrl("dev")}/v1/models`, + expect.objectContaining({ signal: expect.anything() }), + ); + expect(configs).toHaveLength(2); + + const opus = configs.find((model) => model.id === "claude-opus-4-8"); + expect(opus?.name).toBe("Claude Opus"); + expect(opus?.api).toBe("anthropic-messages"); + expect(opus?.input).toEqual(["text", "image"]); + expect(opus?.contextWindow).toBe(500000); + expect(opus?.compat).toEqual({ forceAdaptiveThinking: true }); + + const gpt = configs.find((model) => model.id === "gpt-5.5"); + expect(gpt?.api).toBe("openai-responses"); + expect(gpt?.input).toEqual(["text"]); + expect(gpt?.baseUrl).toBe(`${getLlmGatewayUrl("dev")}/v1`); + }); + + it("falls back to the model id as the display name and default context window", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: "some-unknown-model" }], + }), + }) as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(configs).toHaveLength(1); + expect(configs[0]?.name).toBe("some-unknown-model"); + expect(configs[0]?.contextWindow).toBe(200000); + expect(configs[0]?.api).toBe("anthropic-messages"); + expect(configs[0]?.compat).toBeUndefined(); + }); + + it("detects the cloudflare family from the model id prefix", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: "@cf/some/model" }], + }), + }) as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(configs[0]?.api).toBe("anthropic-messages"); + expect(configs[0]?.maxTokens).toBe(32000); + }); + + it("detects the openai family from the gpt- id prefix even without owned_by", async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: "gpt-custom" }], + }), + }) as unknown as typeof fetch; + + const configs = await resolveModelConfigs("us"); + + expect(configs[0]?.api).toBe("openai-responses"); + expect(configs[0]?.maxTokens).toBe(128000); + }); +}); + +describe("fallbackModelConfigs", () => { + it("produces a non-empty, region-scoped model list", () => { + const configs = fallbackModelConfigs("us"); + expect(configs.length).toBeGreaterThan(0); + for (const config of configs) { + expect(config.id).toBeTruthy(); + expect(config.cost).toEqual({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }); + } + }); +}); diff --git a/packages/harness/src/extensions/posthog-provider/models.ts b/packages/harness/src/extensions/posthog-provider/models.ts new file mode 100644 index 0000000000..4c091a656a --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/models.ts @@ -0,0 +1,194 @@ +import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent"; +import type { CloudRegion } from "@posthog/shared"; +import { getLlmGatewayUrl } from "./gateway"; + +export const DEFAULT_MODEL = "claude-opus-4-8"; + +const MODELS_FETCH_TIMEOUT_MS = 5_000; + +export interface GatewayModel { + id: string; + owned_by?: string; + display_name?: string; + context_window?: number; + supports_vision?: boolean; +} + +type ModelFamily = "anthropic" | "openai" | "cloudflare"; + +const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; + +function detectFamily(model: GatewayModel): ModelFamily { + if (model.owned_by === "openai" || model.id.startsWith("gpt-")) { + return "openai"; + } + if (model.owned_by === "cloudflare" || model.id.startsWith("@cf/")) { + return "cloudflare"; + } + return "anthropic"; +} + +/** + * The gateway URL a model of the given pi `api` should be routed through for + * a given region. `openai-responses` models are served off the gateway's + * `/v1` surface; every other API this provider uses is served off the + * product root. + */ +export function gatewayBaseUrlForApi(api: string, region: CloudRegion): string { + return api === "openai-responses" + ? `${getLlmGatewayUrl(region)}/v1` + : getLlmGatewayUrl(region); +} + +function toModelConfig( + model: GatewayModel, + region: CloudRegion, +): ProviderModelConfig { + const family = detectFamily(model); + const name = model.display_name ?? model.id; + const contextWindow = model.context_window ?? 200000; + const input: ("text" | "image")[] = model.supports_vision + ? ["text", "image"] + : ["text"]; + + if (family === "openai") { + return { + id: model.id, + name, + api: "openai-responses", + baseUrl: gatewayBaseUrlForApi("openai-responses", region), + reasoning: true, + input, + cost: ZERO_COST, + contextWindow, + maxTokens: 128000, + }; + } + + if (family === "cloudflare") { + return { + id: model.id, + name, + api: "anthropic-messages", + reasoning: false, + input, + cost: ZERO_COST, + contextWindow, + maxTokens: 32000, + }; + } + + const adaptiveThinking = /opus|sonnet|fable/.test(model.id); + return { + id: model.id, + name, + api: "anthropic-messages", + reasoning: true, + input, + cost: ZERO_COST, + contextWindow, + maxTokens: 64000, + ...(adaptiveThinking ? { compat: { forceAdaptiveThinking: true } } : {}), + }; +} + +const FALLBACK_GATEWAY_MODELS: GatewayModel[] = [ + { + id: "claude-opus-4-8", + owned_by: "anthropic", + context_window: 1000000, + supports_vision: true, + }, + { + id: "claude-opus-4-7", + owned_by: "anthropic", + context_window: 1000000, + supports_vision: true, + }, + { + id: "claude-sonnet-5", + owned_by: "anthropic", + context_window: 1000000, + supports_vision: true, + }, + { + id: "claude-sonnet-4-6", + owned_by: "anthropic", + context_window: 1000000, + supports_vision: true, + }, + { + id: "claude-haiku-4-5", + owned_by: "anthropic", + context_window: 200000, + supports_vision: true, + }, + { + id: "gpt-5.5", + owned_by: "openai", + context_window: 1050000, + supports_vision: true, + }, + { + id: "gpt-5.4", + owned_by: "openai", + context_window: 1050000, + supports_vision: true, + }, + { + id: "gpt-5.3-codex", + owned_by: "openai", + context_window: 272000, + supports_vision: true, + }, + { + id: "gpt-5-mini", + owned_by: "openai", + context_window: 272000, + supports_vision: true, + }, + { + id: "@cf/zai-org/glm-5.2", + owned_by: "cloudflare", + context_window: 128000, + supports_vision: false, + }, +]; + +export function fallbackModelConfigs( + region: CloudRegion, +): ProviderModelConfig[] { + return FALLBACK_GATEWAY_MODELS.map((model) => toModelConfig(model, region)); +} + +async function fetchGatewayModels( + region: CloudRegion, +): Promise { + if (process.env.PI_OFFLINE || process.env.HARNESS_STATIC_MODELS) { + return []; + } + try { + const response = await fetch(`${getLlmGatewayUrl(region)}/v1/models`, { + signal: AbortSignal.timeout(MODELS_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + return []; + } + const body = (await response.json()) as { data?: GatewayModel[] }; + return Array.isArray(body.data) ? body.data : []; + } catch { + return []; + } +} + +export async function resolveModelConfigs( + region: CloudRegion, +): Promise { + const live = await fetchGatewayModels(region); + if (live.length === 0) { + return fallbackModelConfigs(region); + } + return live + .filter((model) => Boolean(model.id)) + .map((model) => toModelConfig(model, region)); +} diff --git a/packages/harness/src/extensions/posthog-provider/oauth.test.ts b/packages/harness/src/extensions/posthog-provider/oauth.test.ts new file mode 100644 index 0000000000..11cd271d29 --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/oauth.test.ts @@ -0,0 +1,601 @@ +import { spawn } from "node:child_process"; +import http from "node:http"; +import { + getCloudUrlFromRegion, + getOauthClientIdFromRegion, + OAUTH_SCOPES, +} from "@posthog/shared"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; +import { + buildAuthorizeUrl, + getCallbackPort, + getRedirectUri, + loginPosthog, + refreshPosthog, +} from "./oauth"; + +vi.mock("node:child_process", async () => { + const actual = + await vi.importActual( + "node:child_process", + ); + return { + ...actual, + spawn: vi.fn(() => ({ unref: vi.fn() })), + }; +}); + +function fakeCallbacks( + overrides: Partial<{ + signal: AbortSignal; + }> = {}, +) { + return { + onAuth: vi.fn(), + onDeviceCode: vi.fn(), + onPrompt: vi.fn(), + onSelect: vi.fn(), + ...overrides, + }; +} + +function hitPath(port: number, path: string): Promise { + return new Promise((resolve) => { + const req = http.get({ host: "localhost", port, path }, (res) => { + res.resume(); + res.on("end", resolve); + }); + req.on("error", () => resolve()); + }); +} + +function hitCallback(port: number, query: string): Promise { + return hitPath(port, `/callback${query}`); +} + +describe("buildAuthorizeUrl", () => { + it("targets the same authorize endpoint and client as PostHog Code", () => { + const url = buildAuthorizeUrl("us", "challenge123", getRedirectUri(8237)); + + expect(url.origin + url.pathname).toBe( + `${getCloudUrlFromRegion("us")}/oauth/authorize`, + ); + expect(url.searchParams.get("client_id")).toBe( + getOauthClientIdFromRegion("us"), + ); + }); + + it("uses PKCE S256 and the PostHog Code scope + access level", () => { + const url = buildAuthorizeUrl("eu", "challenge123", getRedirectUri()); + + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("code_challenge")).toBe("challenge123"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("scope")).toBe(OAUTH_SCOPES.join(" ")); + expect(url.searchParams.get("required_access_level")).toBe("project"); + }); + + it("routes each region to its own cloud host", () => { + for (const region of ["us", "eu", "dev"] as const) { + const url = buildAuthorizeUrl(region, "c", getRedirectUri()); + expect(url.origin).toBe(new URL(getCloudUrlFromRegion(region)).origin); + expect(url.searchParams.get("client_id")).toBe( + getOauthClientIdFromRegion(region), + ); + } + }); +}); + +describe("getRedirectUri", () => { + it("is a loopback callback the CLI can capture", () => { + expect(getRedirectUri(8237)).toBe("http://localhost:8237/callback"); + }); +}); + +describe("getCallbackPort", () => { + const originalPort = process.env.HARNESS_OAUTH_PORT; + + afterEach(() => { + if (originalPort === undefined) { + delete process.env.HARNESS_OAUTH_PORT; + } else { + process.env.HARNESS_OAUTH_PORT = originalPort; + } + }); + + it("defaults to 8237 when unset", () => { + delete process.env.HARNESS_OAUTH_PORT; + expect(getCallbackPort()).toBe(8237); + }); + + it("uses a valid HARNESS_OAUTH_PORT override", () => { + process.env.HARNESS_OAUTH_PORT = "9999"; + expect(getCallbackPort()).toBe(9999); + }); + + it.each(["not-a-number", "0", "-5", ""])( + "falls back to the default for invalid value %j", + (value) => { + process.env.HARNESS_OAUTH_PORT = value; + expect(getCallbackPort()).toBe(8237); + }, + ); +}); + +describe("loginPosthog region selection", () => { + const originalPort = process.env.HARNESS_OAUTH_PORT; + let fetchSpy: MockInstance; + let port: number; + + beforeEach(() => { + port = 18500 + Math.floor(Math.random() * 500); + process.env.HARNESS_OAUTH_PORT = String(port); + fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "Bearer", + }), + } as Response); + }); + + afterEach(() => { + if (originalPort === undefined) { + delete process.env.HARNESS_OAUTH_PORT; + } else { + process.env.HARNESS_OAUTH_PORT = originalPort; + } + fetchSpy.mockRestore(); + }); + + it("uses the explicit region directly and never prompts", async () => { + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "eu"); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + + expect(callbacks.onSelect).not.toHaveBeenCalled(); + expect(new URL(authUrl).origin).toBe( + new URL(getCloudUrlFromRegion("eu")).origin, + ); + + await hitCallback(port, `?code=abc123&state=${state}`); + const credentials = await loginPromise; + expect(credentials.region).toBe("eu"); + }); + + it("prompts for a region with US/EU options when none is explicit", async () => { + const callbacks = fakeCallbacks(); + callbacks.onSelect.mockResolvedValue("eu"); + + const loginPromise = loginPosthog(callbacks); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + + expect(callbacks.onSelect).toHaveBeenCalledWith({ + message: "Select your PostHog region", + options: [ + { id: "us", label: "United States" }, + { id: "eu", label: "European Union" }, + ], + }); + expect(new URL(authUrl).origin).toBe( + new URL(getCloudUrlFromRegion("eu")).origin, + ); + + await hitCallback(port, `?code=abc123&state=${state}`); + const credentials = await loginPromise; + expect(credentials.region).toBe("eu"); + }); + + it("does not offer dev as an interactive region option", async () => { + const callbacks = fakeCallbacks(); + callbacks.onSelect.mockResolvedValue("us"); + + const loginPromise = loginPosthog(callbacks); + await vi.waitFor(() => expect(callbacks.onSelect).toHaveBeenCalled()); + + const options = callbacks.onSelect.mock.calls[0]?.[0]?.options as { + id: string; + }[]; + expect(options.map((option) => option.id)).toEqual(["us", "eu"]); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + await hitCallback(port, `?code=abc123&state=${state}`); + await loginPromise; + }); + + it("rejects when region selection is cancelled", async () => { + const callbacks = fakeCallbacks(); + callbacks.onSelect.mockResolvedValue(undefined); + + await expect(loginPosthog(callbacks)).rejects.toThrow( + /region selection cancelled/, + ); + expect(callbacks.onAuth).not.toHaveBeenCalled(); + }); +}); + +describe("loginPosthog", () => { + const originalPort = process.env.HARNESS_OAUTH_PORT; + let fetchSpy: MockInstance; + let port: number; + + beforeEach(() => { + port = 18000 + Math.floor(Math.random() * 1000); + process.env.HARNESS_OAUTH_PORT = String(port); + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + if (originalPort === undefined) { + delete process.env.HARNESS_OAUTH_PORT; + } else { + process.env.HARNESS_OAUTH_PORT = originalPort; + } + fetchSpy.mockRestore(); + vi.mocked(spawn).mockClear(); + }); + + it("ignores requests to unrelated paths and still resolves on the real callback", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "Bearer", + }), + } as Response); + + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + + await hitPath(port, "/unrelated"); + await hitCallback(port, `?code=abc123&state=${state}`); + + const credentials = await loginPromise; + expect(credentials.access).toBe("access-1"); + }); + + it("rejects with a timeout error when no callback arrives in time", async () => { + vi.useFakeTimers(); + try { + let resolveListening: () => void = () => {}; + const listening = new Promise((resolve) => { + resolveListening = resolve; + }); + const callbacks = fakeCallbacks(); + callbacks.onAuth.mockImplementation(() => resolveListening()); + + const loginPromise = loginPosthog(callbacks, "us"); + const assertion = expect(loginPromise).rejects.toThrow(/timed out/); + + await listening; + await vi.advanceTimersByTimeAsync(180_000); + + await assertion; + } finally { + vi.useRealTimers(); + } + }); + + it("rejects when the callback server fails to start", async () => { + const blocker = http.createServer(); + await new Promise((resolve) => { + blocker.listen(port, "127.0.0.1", resolve); + }); + try { + const callbacks = fakeCallbacks(); + await expect(loginPosthog(callbacks, "us")).rejects.toThrow(); + } finally { + await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it("opens the browser, captures the callback code, and exchanges it for credentials", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "access-1", + refresh_token: "refresh-1", + expires_in: 3600, + token_type: "Bearer", + }), + } as Response); + + const callbacks = fakeCallbacks(); + let authUrl = ""; + callbacks.onAuth.mockImplementation((info: { url: string }) => { + authUrl = info.url; + }); + + const loginPromise = loginPosthog(callbacks, "us"); + + await vi.waitFor(() => { + expect(callbacks.onAuth).toHaveBeenCalled(); + }); + expect(spawn).toHaveBeenCalled(); + + const state = new URL(authUrl).searchParams.get("state") ?? ""; + await hitCallback(port, `?code=abc123&state=${state}`); + + const credentials = await loginPromise; + expect(credentials.access).toBe("access-1"); + expect(credentials.refresh).toBe("refresh-1"); + expect(credentials.region).toBe("us"); + expect(credentials.expires).toBeGreaterThan(Date.now()); + + expect(fetchSpy).toHaveBeenCalledWith( + `${getCloudUrlFromRegion("us")}/oauth/token`, + expect.objectContaining({ + method: "POST", + body: expect.stringContaining('"grant_type":"authorization_code"'), + }), + ); + }); + + it("rejects when the callback reports an OAuth error", async () => { + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + const assertion = + expect(loginPromise).rejects.toThrow(/PostHog OAuth error/); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + await hitCallback(port, "?error=access_denied"); + + await assertion; + }); + + it("rejects when the callback is missing a code", async () => { + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + const assertion = expect(loginPromise).rejects.toThrow(/missing code/); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + await hitCallback(port, "?state=whatever"); + + await assertion; + }); + + it("rejects on a state mismatch", async () => { + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + const assertion = expect(loginPromise).rejects.toThrow(/state mismatch/); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + await hitCallback(port, "?code=abc123&state=wrong-state"); + + await assertion; + }); + + it("rejects when the abort signal fires before the callback arrives", async () => { + const controller = new AbortController(); + const callbacks = fakeCallbacks({ signal: controller.signal }); + + const loginPromise = loginPosthog(callbacks, "us"); + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + + controller.abort(); + + await expect(loginPromise).rejects.toThrow(/cancelled/); + }); + + it("rejects immediately when the signal is already aborted", async () => { + const controller = new AbortController(); + controller.abort(); + const callbacks = fakeCallbacks({ signal: controller.signal }); + + await expect(loginPosthog(callbacks, "us")).rejects.toThrow(/cancelled/); + }); + + it("surfaces token endpoint failures with response detail", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 400, + statusText: "Bad Request", + text: async () => "invalid_grant", + } as Response); + + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + const assertion = expect(loginPromise).rejects.toThrow( + /PostHog token request failed: 400 Bad Request invalid_grant/, + ); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + await hitCallback(port, `?code=abc123&state=${state}`); + + await assertion; + }); +}); + +describe("openBrowser (via loginPosthog)", () => { + const originalPort = process.env.HARNESS_OAUTH_PORT; + const originalPlatform = process.platform; + let fetchSpy: MockInstance; + let port: number; + + beforeEach(() => { + port = 19000 + Math.floor(Math.random() * 1000); + process.env.HARNESS_OAUTH_PORT = String(port); + fetchSpy = vi.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "a", + refresh_token: "r", + expires_in: 3600, + token_type: "Bearer", + }), + } as Response); + }); + + afterEach(() => { + if (originalPort === undefined) { + delete process.env.HARNESS_OAUTH_PORT; + } else { + process.env.HARNESS_OAUTH_PORT = originalPort; + } + fetchSpy.mockRestore(); + vi.mocked(spawn).mockClear(); + Object.defineProperty(process, "platform", { value: originalPlatform }); + }); + + it.each([ + ["darwin", "open"], + ["win32", "cmd"], + ["linux", "xdg-open"], + ] as const)( + "uses the right opener command on %s", + async (platform, command) => { + Object.defineProperty(process, "platform", { value: platform }); + + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + + expect(spawn).toHaveBeenCalledWith( + command, + expect.any(Array), + expect.objectContaining({ stdio: "ignore", detached: true }), + ); + + await hitCallback(port, `?code=abc123&state=${state}`); + await loginPromise; + }, + ); + + it("swallows errors thrown while spawning the opener", async () => { + vi.mocked(spawn).mockImplementationOnce(() => { + throw new Error("spawn failed"); + }); + + const callbacks = fakeCallbacks(); + const loginPromise = loginPosthog(callbacks, "us"); + + await vi.waitFor(() => expect(callbacks.onAuth).toHaveBeenCalled()); + const authUrl = (callbacks.onAuth.mock.calls[0]?.[0] as { url: string }) + .url; + const state = new URL(authUrl).searchParams.get("state") ?? ""; + + await hitCallback(port, `?code=abc123&state=${state}`); + await expect(loginPromise).resolves.toBeDefined(); + }); +}); + +describe("refreshPosthog", () => { + let fetchSpy: MockInstance; + + beforeEach(() => { + fetchSpy = vi.spyOn(global, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("exchanges a refresh token for new credentials on the same region", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 7200, + token_type: "Bearer", + }), + } as Response); + + const credentials = await refreshPosthog("us", { + access: "old-access", + refresh: "old-refresh", + expires: 0, + }); + + expect(credentials.access).toBe("new-access"); + expect(credentials.refresh).toBe("new-refresh"); + expect(credentials.region).toBe("us"); + expect(fetchSpy).toHaveBeenCalledWith( + `${getCloudUrlFromRegion("us")}/oauth/token`, + expect.objectContaining({ + body: expect.stringContaining('"grant_type":"refresh_token"'), + }), + ); + }); + + it("prefers the region embedded in the stored credentials over the passed-in default", async () => { + fetchSpy.mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: "new-access", + refresh_token: "new-refresh", + expires_in: 7200, + token_type: "Bearer", + }), + } as Response); + + const credentials = await refreshPosthog("us", { + access: "old-access", + refresh: "old-refresh", + expires: 0, + region: "eu", + }); + + expect(credentials.region).toBe("eu"); + expect(fetchSpy).toHaveBeenCalledWith( + `${getCloudUrlFromRegion("eu")}/oauth/token`, + expect.objectContaining({ + body: expect.stringContaining( + `"client_id":"${getOauthClientIdFromRegion("eu")}"`, + ), + }), + ); + }); + + it("throws when the refresh request fails", async () => { + fetchSpy.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "expired", + } as Response); + + await expect( + refreshPosthog("us", { + access: "old-access", + refresh: "old-refresh", + expires: 0, + }), + ).rejects.toThrow(/PostHog token request failed: 401 Unauthorized expired/); + }); +}); diff --git a/packages/harness/src/extensions/posthog-provider/oauth.ts b/packages/harness/src/extensions/posthog-provider/oauth.ts new file mode 100644 index 0000000000..42af0568de --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/oauth.ts @@ -0,0 +1,252 @@ +import { spawn } from "node:child_process"; +import { createHash, randomBytes } from "node:crypto"; +import { createServer } from "node:http"; +import type { + OAuthCredentials, + OAuthLoginCallbacks, +} from "@earendil-works/pi-ai"; +import { + type CloudRegion, + getCloudUrlFromRegion, + getOauthClientIdFromRegion, + OAUTH_SCOPES, +} from "@posthog/shared"; + +const OAUTH_TIMEOUT_MS = 180_000; +const TOKEN_FETCH_TIMEOUT_MS = 30_000; +const TOKEN_EXPIRY_SKEW_MS = 60_000; +const DEFAULT_CALLBACK_PORT = 8237; +const CALLBACK_PATH = "/callback"; + +interface OAuthTokenResponse { + access_token: string; + refresh_token: string; + expires_in: number; + token_type: string; + scope?: string; +} + +export function getCallbackPort(): number { + const raw = process.env.HARNESS_OAUTH_PORT; + const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN; + return Number.isInteger(parsed) && parsed > 0 + ? parsed + : DEFAULT_CALLBACK_PORT; +} + +export function getRedirectUri(port: number = getCallbackPort()): string { + return `http://localhost:${port}${CALLBACK_PATH}`; +} + +function generateCodeVerifier(): string { + return randomBytes(32).toString("base64url"); +} + +function generateCodeChallenge(verifier: string): string { + return createHash("sha256").update(verifier).digest("base64url"); +} + +export function buildAuthorizeUrl( + region: CloudRegion, + codeChallenge: string, + redirectUri: string, +): URL { + const cloudUrl = getCloudUrlFromRegion(region); + const authUrl = new URL(`${cloudUrl}/oauth/authorize`); + authUrl.searchParams.set("client_id", getOauthClientIdFromRegion(region)); + authUrl.searchParams.set("redirect_uri", redirectUri); + authUrl.searchParams.set("response_type", "code"); + authUrl.searchParams.set("code_challenge", codeChallenge); + authUrl.searchParams.set("code_challenge_method", "S256"); + authUrl.searchParams.set("scope", OAUTH_SCOPES.join(" ")); + authUrl.searchParams.set("required_access_level", "project"); + return authUrl; +} + +function toCredentials( + response: OAuthTokenResponse, + region: CloudRegion, +): OAuthCredentials { + return { + access: response.access_token, + refresh: response.refresh_token, + expires: Date.now() + response.expires_in * 1000 - TOKEN_EXPIRY_SKEW_MS, + region, + }; +} + +async function postToken( + region: CloudRegion, + body: Record, +): Promise { + const cloudUrl = getCloudUrlFromRegion(region); + const response = await fetch(`${cloudUrl}/oauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(TOKEN_FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error( + `PostHog token request failed: ${response.status} ${response.statusText} ${detail}`.trim(), + ); + } + return (await response.json()) as OAuthTokenResponse; +} + +function openBrowser(url: string): void { + const platform = process.platform; + const command = + platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + try { + spawn(command, args, { stdio: "ignore", detached: true }).unref(); + } catch {} +} + +const SUCCESS_PAGE = `PostHog

Authentication complete

You can close this window and return to your terminal.

`; + +function waitForCallbackCode(options: { + port: number; + expectedState: string; + signal?: AbortSignal; + onListening: () => void; +}): Promise { + const { port, expectedState, signal, onListening } = options; + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + const url = new URL(req.url ?? "", `http://localhost:${port}`); + if (url.pathname !== CALLBACK_PATH) { + res.writeHead(404); + res.end(); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(SUCCESS_PAGE); + cleanup(); + if (error) { + reject(new Error(`PostHog OAuth error: ${error}`)); + } else if (!code) { + reject(new Error("PostHog OAuth callback missing code")); + } else if (expectedState && state !== expectedState) { + reject(new Error("PostHog OAuth state mismatch")); + } else { + resolve(code); + } + }); + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error("PostHog OAuth timed out")); + }, OAUTH_TIMEOUT_MS); + + const onAbort = () => { + cleanup(); + reject(new Error("PostHog OAuth cancelled")); + }; + + function cleanup(): void { + clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + server.close(); + } + + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort); + + server.on("error", (err) => { + cleanup(); + reject(err); + }); + server.listen(port, "127.0.0.1", onListening); + }); +} + +const REGION_LOGIN_OPTIONS: { id: CloudRegion; label: string }[] = [ + { id: "us", label: "United States" }, + { id: "eu", label: "European Union" }, +]; + +/** + * Prompts the user to pick their PostHog region via the login callbacks' + * selector. `dev` is intentionally not offered here; it stays reachable only + * through an explicit `POSTHOG_REGION=dev`. + */ +async function selectRegion( + callbacks: OAuthLoginCallbacks, +): Promise { + const picked = await callbacks.onSelect({ + message: "Select your PostHog region", + options: REGION_LOGIN_OPTIONS, + }); + if (picked !== "us" && picked !== "eu") { + throw new Error("PostHog region selection cancelled"); + } + return picked; +} + +/** + * Logs in to PostHog. When `explicitRegion` is provided (an explicit option + * or `POSTHOG_REGION`), that region is used directly; otherwise the user is + * prompted to choose between the supported regions. + */ +export async function loginPosthog( + callbacks: OAuthLoginCallbacks, + explicitRegion?: CloudRegion, +): Promise { + const region = explicitRegion ?? (await selectRegion(callbacks)); + const port = getCallbackPort(); + const redirectUri = getRedirectUri(port); + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = randomBytes(16).toString("base64url"); + + const authUrl = buildAuthorizeUrl(region, codeChallenge, redirectUri); + authUrl.searchParams.set("state", state); + const authUrlString = authUrl.toString(); + + const code = await waitForCallbackCode({ + port, + expectedState: state, + signal: callbacks.signal, + onListening: () => { + callbacks.onAuth({ + url: authUrlString, + instructions: + "Opening your browser to sign in to PostHog. If it doesn't open, visit the URL above.", + }); + openBrowser(authUrlString); + }, + }); + + const tokens = await postToken(region, { + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: getOauthClientIdFromRegion(region), + code_verifier: codeVerifier, + }); + + return toCredentials(tokens, region); +} + +export async function refreshPosthog( + region: CloudRegion, + credentials: OAuthCredentials, +): Promise { + const effectiveRegion = + (credentials.region as CloudRegion | undefined) ?? region; + const tokens = await postToken(effectiveRegion, { + grant_type: "refresh_token", + refresh_token: credentials.refresh, + client_id: getOauthClientIdFromRegion(effectiveRegion), + }); + return toCredentials(tokens, effectiveRegion); +} diff --git a/packages/harness/src/extensions/posthog-provider/provider.test.ts b/packages/harness/src/extensions/posthog-provider/provider.test.ts new file mode 100644 index 0000000000..ab5cc804a4 --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/provider.test.ts @@ -0,0 +1,227 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { getLlmGatewayUrl } from "./gateway"; +import { DEFAULT_MODEL, fallbackModelConfigs } from "./models"; +import * as oauth from "./oauth"; +import { + buildPosthogProvider, + POSTHOG_PROVIDER_NAME, + resolvePosthogProvider, +} from "./provider"; + +function makeModel( + overrides: Partial> & { id: string }, +): Model { + return { + name: overrides.id, + api: "anthropic-messages", + provider: POSTHOG_PROVIDER_NAME, + baseUrl: "https://gateway.us.posthog.com/posthog_code", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 64000, + ...overrides, + }; +} + +describe("buildPosthogProvider", () => { + const models = fallbackModelConfigs("us"); + + it("defaults the provider to the anthropic-messages gateway surface", () => { + const config = buildPosthogProvider(models, { region: "us" }); + + expect(config.api).toBe("anthropic-messages"); + expect(config.baseUrl).toBe(getLlmGatewayUrl("us")); + expect(config.baseUrl).toBe("https://gateway.us.posthog.com/posthog_code"); + }); + + it("uses the PostHog OAuth token as the gateway credential", () => { + const config = buildPosthogProvider(models); + + expect(config.oauth?.name).toBe("PostHog"); + expect( + config.oauth?.getApiKey({ access: "pha_abc", refresh: "r", expires: 0 }), + ).toBe("pha_abc"); + }); + + it("accepts a static api key that overrides OAuth for headless use", () => { + const config = buildPosthogProvider(models, { apiKey: "pha_static" }); + expect(config.apiKey).toBe("pha_static"); + }); + + it("omits apiKey entirely when none is provided", () => { + const config = buildPosthogProvider(models); + expect(config.apiKey).toBeUndefined(); + }); + + it("uses a stable provider name", () => { + expect(POSTHOG_PROVIDER_NAME).toBe("posthog"); + }); + + it("delegates login and refresh to the oauth module for the resolved region", async () => { + const loginSpy = vi + .spyOn(oauth, "loginPosthog") + .mockResolvedValue({ access: "a", refresh: "r", expires: 1 }); + const refreshSpy = vi + .spyOn(oauth, "refreshPosthog") + .mockResolvedValue({ access: "a2", refresh: "r2", expires: 2 }); + + const config = buildPosthogProvider(models, { region: "eu" }); + const callbacks = { + onAuth: vi.fn(), + onDeviceCode: vi.fn(), + onPrompt: vi.fn(), + onSelect: vi.fn(), + }; + + await config.oauth?.login(callbacks); + expect(loginSpy).toHaveBeenCalledWith(callbacks, "eu"); + + await config.oauth?.refreshToken({ + access: "old", + refresh: "old-r", + expires: 0, + }); + expect(refreshSpy).toHaveBeenCalledWith("eu", { + access: "old", + refresh: "old-r", + expires: 0, + }); + + loginSpy.mockRestore(); + refreshSpy.mockRestore(); + }); +}); + +describe("oauth.modifyModels", () => { + const models = fallbackModelConfigs("us"); + + it("remaps this provider's anthropic-messages models to the credential's region gateway", () => { + const config = buildPosthogProvider(models, { region: "us" }); + const runtimeModels = [ + makeModel({ id: "claude-opus-4-8", api: "anthropic-messages" }), + ]; + + const result = config.oauth?.modifyModels?.(runtimeModels, { + access: "a", + refresh: "r", + expires: 0, + region: "eu", + }); + + expect(result?.[0]?.baseUrl).toBe(getLlmGatewayUrl("eu")); + }); + + it("remaps this provider's openai-responses models to the region's /v1 surface", () => { + const config = buildPosthogProvider(models, { region: "us" }); + const runtimeModels = [ + makeModel({ + id: "gpt-5.5", + api: "openai-responses", + baseUrl: `${getLlmGatewayUrl("us")}/v1`, + }), + ]; + + const result = config.oauth?.modifyModels?.(runtimeModels, { + access: "a", + refresh: "r", + expires: 0, + region: "eu", + }); + + expect(result?.[0]?.baseUrl).toBe(`${getLlmGatewayUrl("eu")}/v1`); + }); + + it("leaves other providers' models untouched", () => { + const config = buildPosthogProvider(models, { region: "us" }); + const otherModel = makeModel({ id: "gpt-4o", provider: "openai" }); + + const result = config.oauth?.modifyModels?.([otherModel], { + access: "a", + refresh: "r", + expires: 0, + region: "eu", + }); + + expect(result?.[0]?.baseUrl).toBe(otherModel.baseUrl); + }); + + it("falls back to the provider's configured region when credentials have none", () => { + const config = buildPosthogProvider(models, { region: "eu" }); + const runtimeModels = [makeModel({ id: "claude-opus-4-8" })]; + + const result = config.oauth?.modifyModels?.(runtimeModels, { + access: "a", + refresh: "r", + expires: 0, + }); + + expect(result?.[0]?.baseUrl).toBe(getLlmGatewayUrl("eu")); + }); +}); + +describe("resolvePosthogProvider", () => { + const originalOffline = process.env.PI_OFFLINE; + + beforeEach(() => { + process.env.PI_OFFLINE = "1"; + }); + + afterEach(() => { + if (originalOffline === undefined) { + delete process.env.PI_OFFLINE; + } else { + process.env.PI_OFFLINE = originalOffline; + } + }); + + it("resolves a full provider config using fallback models when offline", async () => { + const config = await resolvePosthogProvider({ region: "us" }); + expect(config.name).toBe("PostHog"); + expect(config.models?.length).toBeGreaterThan(0); + expect(config.baseUrl).toBe(getLlmGatewayUrl("us")); + }); + + it("defaults the region when none is provided", async () => { + delete process.env.POSTHOG_REGION; + const config = await resolvePosthogProvider(); + expect(config.baseUrl).toBe(getLlmGatewayUrl("us")); + }); +}); + +describe("model classification", () => { + const byId = (region: "us" | "eu" | "dev") => + new Map(fallbackModelConfigs(region).map((model) => [model.id, model])); + + it("routes Claude models through anthropic-messages on the product base", () => { + const model = byId("us").get(DEFAULT_MODEL); + expect(model?.api).toBe("anthropic-messages"); + expect(model?.baseUrl).toBeUndefined(); + }); + + it("routes OpenAI + codex models through openai-responses on the /v1 surface", () => { + const models = byId("us"); + for (const id of ["gpt-5.5", "gpt-5.4", "gpt-5.3-codex"]) { + const model = models.get(id); + expect(model, id).toBeDefined(); + expect(model?.api).toBe("openai-responses"); + expect(model?.baseUrl).toBe( + "https://gateway.us.posthog.com/posthog_code/v1", + ); + } + }); + + it("exposes the GLM model via the anthropic-messages surface", () => { + const glm = byId("us").get("@cf/zai-org/glm-5.2"); + expect(glm).toBeDefined(); + expect(glm?.api).toBe("anthropic-messages"); + expect(glm?.input).toEqual(["text"]); + }); + + it("points OpenAI models at the region-specific gateway", () => { + const eu = byId("eu").get("gpt-5.5"); + expect(eu?.baseUrl).toBe("https://gateway.eu.posthog.com/posthog_code/v1"); + }); +}); diff --git a/packages/harness/src/extensions/posthog-provider/provider.ts b/packages/harness/src/extensions/posthog-provider/provider.ts new file mode 100644 index 0000000000..856b8834bc --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/provider.ts @@ -0,0 +1,75 @@ +import type { Api, Model, OAuthCredentials } from "@earendil-works/pi-ai"; +import type { + ProviderConfig, + ProviderModelConfig, +} from "@earendil-works/pi-coding-agent"; +import type { CloudRegion } from "@posthog/shared"; +import { + getLlmGatewayUrl, + resolveExplicitRegion, + resolveRegion, +} from "./gateway"; +import { gatewayBaseUrlForApi, resolveModelConfigs } from "./models"; +import { loginPosthog, refreshPosthog } from "./oauth"; + +export const POSTHOG_PROVIDER_NAME = "posthog"; + +export interface PosthogProviderOptions { + region?: CloudRegion; + apiKey?: string; +} + +/** + * Re-routes this provider's already-resolved models to the gateway for the + * region baked into `credentials` (set at login time). Called by pi whenever + * it (re)loads models for a provider with stored OAuth credentials, so a + * user's login region always wins over whatever region the provider was + * initially registered with. + */ +function remapModelsToCredentialRegion( + models: Model[], + credentials: OAuthCredentials, + fallbackRegion: CloudRegion, +): Model[] { + const region = + (credentials.region as CloudRegion | undefined) ?? fallbackRegion; + return models.map((model) => + model.provider === POSTHOG_PROVIDER_NAME + ? { ...model, baseUrl: gatewayBaseUrlForApi(model.api, region) } + : model, + ); +} + +export function buildPosthogProvider( + models: ProviderModelConfig[], + options: PosthogProviderOptions = {}, +): ProviderConfig { + const region = resolveRegion(options.region); + const explicitRegion = resolveExplicitRegion(options.region); + const config: ProviderConfig = { + name: "PostHog", + baseUrl: getLlmGatewayUrl(region), + api: "anthropic-messages", + models, + oauth: { + name: "PostHog", + login: (callbacks) => loginPosthog(callbacks, explicitRegion), + refreshToken: (credentials) => refreshPosthog(region, credentials), + getApiKey: (credentials) => String(credentials.access), + modifyModels: (models, credentials) => + remapModelsToCredentialRegion(models, credentials, region), + }, + }; + if (options.apiKey) { + config.apiKey = options.apiKey; + } + return config; +} + +export async function resolvePosthogProvider( + options: PosthogProviderOptions = {}, +): Promise { + const region = resolveRegion(options.region); + const models = await resolveModelConfigs(region); + return buildPosthogProvider(models, options); +} diff --git a/packages/harness/src/extensions/registry.ts b/packages/harness/src/extensions/registry.ts new file mode 100644 index 0000000000..baa38d58cc --- /dev/null +++ b/packages/harness/src/extensions/registry.ts @@ -0,0 +1,24 @@ +import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import { createPosthogProviderExtension } from "./posthog-provider/extension"; +import type { PosthogProviderOptions } from "./posthog-provider/provider"; + +export type HarnessExtensionOptions = PosthogProviderOptions; + +interface HarnessExtension { + name: string; + create: (options: HarnessExtensionOptions) => ExtensionFactory; +} + +const EXTENSIONS: HarnessExtension[] = [ + { name: "posthog-provider", create: createPosthogProviderExtension }, +]; + +export const HARNESS_EXTENSION_NAMES: readonly string[] = EXTENSIONS.map( + (extension) => extension.name, +); + +export function harnessExtensions( + options: HarnessExtensionOptions = {}, +): ExtensionFactory[] { + return EXTENSIONS.map((extension) => extension.create(options)); +} diff --git a/packages/harness/src/session.ts b/packages/harness/src/session.ts new file mode 100644 index 0000000000..2b062d6c6a --- /dev/null +++ b/packages/harness/src/session.ts @@ -0,0 +1,54 @@ +import { + type AgentSession, + AuthStorage, + createAgentSession, + DefaultResourceLoader, + getAgentDir, + ModelRegistry, +} from "@earendil-works/pi-coding-agent"; +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 { + 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(), + 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 new file mode 100644 index 0000000000..db72701ccf --- /dev/null +++ b/packages/harness/src/spawn.ts @@ -0,0 +1,42 @@ +import { + type ChildProcess, + type SpawnOptions, + spawn, +} from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { HARNESS_EXTENSION_NAMES } from "./extensions/registry"; + +export function resolvePiCli(): string { + const mainEntry = fileURLToPath( + import.meta.resolve("@earendil-works/pi-coding-agent"), + ); + return join(dirname(mainEntry), "cli.js"); +} + +export function harnessExtensionFiles(): string[] { + return HARNESS_EXTENSION_NAMES.map((name) => + fileURLToPath( + new URL(`./extensions/${name}/extension.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]) + : []; + return spawn(process.execPath, [resolvePiCli(), ...extensionArgs, ...args], { + stdio, + ...rest, + env: { ...process.env, ...env }, + }); +} diff --git a/packages/harness/tsconfig.json b/packages/harness/tsconfig.json new file mode 100644 index 0000000000..606083a82c --- /dev/null +++ b/packages/harness/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ES2022", + "module": "ESNext", + "moduleDetection": "force", + "allowJs": true, + "types": ["node"], + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/harness/tsup.config.ts b/packages/harness/tsup.config.ts new file mode 100644 index 0000000000..2c9aee7f84 --- /dev/null +++ b/packages/harness/tsup.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: [ + "src/cli.ts", + "src/session.ts", + "src/spawn.ts", + "src/extensions/registry.ts", + "src/extensions/posthog-provider/extension.ts", + "src/extensions/posthog-provider/provider.ts", + "src/extensions/posthog-provider/models.ts", + "src/extensions/posthog-provider/oauth.ts", + "src/extensions/posthog-provider/gateway.ts", + ], + format: ["esm"], + dts: true, + sourcemap: true, + clean: true, + splitting: false, + outDir: "dist", + target: "node20", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1cb2f3bb09..8a25d687e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -950,6 +950,31 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.2.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/harness: + dependencies: + '@earendil-works/pi-ai': + specifier: 0.80.3 + 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.3 + version: 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + '@posthog/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.20.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.5.0 + version: 5.9.3 + vitest: + specifier: ^4.1.8 + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + packages/host-router: dependencies: '@agentclientprotocol/sdk': @@ -1667,6 +1692,15 @@ packages: zod: optional: true + '@anthropic-ai/sdk@0.91.1': + resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + '@apidevtools/json-schema-ref-parser@14.0.1': resolution: {integrity: sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw==} engines: {node: '>= 16'} @@ -1703,6 +1737,107 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + resolution: {integrity: sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.23': + resolution: {integrity: sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.49': + resolution: {integrity: sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.51': + resolution: {integrity: sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.56': + resolution: {integrity: sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.55': + resolution: {integrity: sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.58': + resolution: {integrity: sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.49': + resolution: {integrity: sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.55': + resolution: {integrity: sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.55': + resolution: {integrity: sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.22': + resolution: {integrity: sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.18': + resolution: {integrity: sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.31': + resolution: {integrity: sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.23': + resolution: {integrity: sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.35': + resolution: {integrity: sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1048.0': + resolution: {integrity: sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1074.0': + resolution: {integrity: sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.13': + resolution: {integrity: sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.8': + resolution: {integrity: sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.31': + resolution: {integrity: sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.10.4': resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} @@ -2482,6 +2617,24 @@ packages: '@drizzle-team/brocli@0.10.2': resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@earendil-works/pi-agent-core@0.80.3': + resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.80.3': + resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.80.3': + resolution: {integrity: sha512-TIggw9gCXpA+Ph7OjdTA7ka2NPwTVuPmy39KDSyUzaKq8VvHfMGR7vtRz4JB7Um/RMRblmzhu4p9tUCk6MTgGA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-tui@0.80.3': + resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} + engines: {node: '>=22.19.0'} + '@ecies/ciphers@0.2.6': resolution: {integrity: sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g==} engines: {bun: '>=1', deno: '>=2.7.10', node: '>=16'} @@ -3347,6 +3500,15 @@ packages: '@fontsource-variable/inter@5.2.8': resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + '@google/genai@1.52.0': + resolution: {integrity: sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -3849,12 +4011,88 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@mariozechner/clipboard-darwin-arm64@0.3.9': + resolution: {integrity: sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@mariozechner/clipboard-darwin-universal@0.3.9': + resolution: {integrity: sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==} + engines: {node: '>= 10'} + os: [darwin] + + '@mariozechner/clipboard-darwin-x64@0.3.9': + resolution: {integrity: sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + resolution: {integrity: sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + resolution: {integrity: sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@mariozechner/clipboard@0.3.9': + resolution: {integrity: sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==} + engines: {node: '>= 10'} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: '@types/react': '>=16' react: '>=16' + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -6323,6 +6561,9 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + '@sinclair/typebox-codegen@0.11.1': resolution: {integrity: sha512-Bckbrf1sJFTIVD88PvI0vWUfE3Sh/6pwu6Jov+6xyMrEqnabOxEFAmPSDWjB1FGPL5C1/HfdScwa1imwAtGi9w==} @@ -6349,6 +6590,46 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@smithy/core@3.26.0': + resolution: {integrity: sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.4.2': + resolution: {integrity: sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.5.2': + resolution: {integrity: sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.3': + resolution: {integrity: sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.8.2': + resolution: {integrity: sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.5.2': + resolution: {integrity: sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.15.0': + resolution: {integrity: sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -7216,6 +7497,9 @@ packages: '@types/node@20.19.41': resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==} + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + '@types/node@24.12.0': resolution: {integrity: sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==} @@ -7243,6 +7527,9 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} @@ -7836,6 +8123,9 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -7868,6 +8158,9 @@ packages: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + bplist-creator@0.1.0: resolution: {integrity: sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==} @@ -8559,6 +8852,10 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + dir-compare@4.2.0: resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} @@ -9646,6 +9943,14 @@ packages: fzf@0.5.2: resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + gaxios@7.1.5: + resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -9773,6 +10078,14 @@ packages: peerDependencies: csstype: ^3.0.10 + google-auth-library@10.9.0: + resolution: {integrity: sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -9862,6 +10175,9 @@ packages: hermes-parser@0.32.0: resolution: {integrity: sha512-g4nBOWFpuiTqjR3LZdRxKUkij9iyveWeuks7INEsMX741f3r9xxrOe8TeQfUxtda0eXmiIFiMQzoeSQEno33Hw==} + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} @@ -9878,6 +10194,10 @@ packages: resolution: {integrity: sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==} engines: {node: ^16.14.0 || >=18.0.0} + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + html-encoding-sniffer@4.0.0: resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} engines: {node: '>=18'} @@ -10335,6 +10655,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -10799,6 +11122,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + marky@1.3.0: resolution: {integrity: sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==} @@ -11432,6 +11760,18 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openai@6.26.0: + resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -11521,6 +11861,10 @@ packages: resolution: {integrity: sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==} engines: {node: '>=18'} + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -11579,6 +11923,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + pastable@2.2.1: resolution: {integrity: sha512-K4ClMxRKpgN4sXj6VIPPrvor/TMp2yPNCGtfhvV106C73SwefQ3FuegURsH7AQHpqu0WwbvKXRl1HQxF6qax9w==} engines: {node: '>=14.x'} @@ -12472,6 +12819,10 @@ packages: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + rettime@0.10.1: resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} @@ -12635,6 +12986,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -13402,6 +13758,9 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} + typebox@1.1.38: + resolution: {integrity: sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==} + typed-openapi@2.2.7: resolution: {integrity: sha512-iTDy0V9Wc8kuRBeGxJKNhZk3Gye3kqux4gUrMD0grf9h9TQcJ/EI7EGpyXd7PdbuGrsQcTX2t5q0S+vwlzubsA==} hasBin: true @@ -13452,6 +13811,10 @@ packages: resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} engines: {node: '>=20.18.1'} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + engines: {node: '>=22.19.0'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -14203,6 +14566,12 @@ snapshots: optionalDependencies: zod: 4.4.3 + '@anthropic-ai/sdk@0.91.1(zod@4.4.3)': + dependencies: + json-schema-to-ts: 3.1.1 + optionalDependencies: + zod: 4.4.3 + '@apidevtools/json-schema-ref-parser@14.0.1': dependencies: '@types/json-schema': 7.0.15 @@ -14260,6 +14629,228 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + '@aws-sdk/util-locate-window': 3.965.8 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.13 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1048.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/credential-provider-node': 3.972.58 + '@aws-sdk/eventstream-handler-node': 3.972.22 + '@aws-sdk/middleware-eventstream': 3.972.18 + '@aws-sdk/middleware-websocket': 3.972.31 + '@aws-sdk/token-providers': 3.1048.0 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.7.3 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.23': + dependencies: + '@aws-sdk/types': 3.973.13 + '@aws-sdk/xml-builder': 3.972.31 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.26.0 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.51': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.56': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/credential-provider-env': 3.972.49 + '@aws-sdk/credential-provider-http': 3.972.51 + '@aws-sdk/credential-provider-login': 3.972.55 + '@aws-sdk/credential-provider-process': 3.972.49 + '@aws-sdk/credential-provider-sso': 3.972.55 + '@aws-sdk/credential-provider-web-identity': 3.972.55 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/credential-provider-imds': 4.4.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.58': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.49 + '@aws-sdk/credential-provider-http': 3.972.51 + '@aws-sdk/credential-provider-ini': 3.972.56 + '@aws-sdk/credential-provider-process': 3.972.49 + '@aws-sdk/credential-provider-sso': 3.972.55 + '@aws-sdk/credential-provider-web-identity': 3.972.55 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/credential-provider-imds': 4.4.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/token-providers': 3.1074.0 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.55': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.22': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.18': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.31': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.23': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.23 + '@aws-sdk/signature-v4-multi-region': 3.996.35 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/fetch-http-handler': 5.5.2 + '@smithy/node-http-handler': 4.8.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.35': + dependencies: + '@aws-sdk/types': 3.973.13 + '@smithy/signature-v4': 5.5.2 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1048.0': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1074.0': + dependencies: + '@aws-sdk/core': 3.974.23 + '@aws-sdk/nested-clients': 3.997.23 + '@aws-sdk/types': 3.973.13 + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.13': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.8': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.31': + dependencies: + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.10.4': dependencies: '@babel/highlight': 7.25.9 @@ -15282,6 +15873,76 @@ snapshots: '@drizzle-team/brocli@0.10.2': {} + '@earendil-works/pi-agent-core@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.19.0)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.3 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-tui@0.80.3': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@ecies/ciphers@0.2.6(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 @@ -16107,6 +16768,19 @@ snapshots: '@fontsource-variable/inter@5.2.8': {} + '@google/genai@1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))': + dependencies: + google-auth-library: 10.9.0 + p-retry: 4.6.2 + protobufjs: 7.5.4 + ws: 8.19.0 + optionalDependencies: + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@hono/node-server@1.19.9(hono@4.11.7)': dependencies: hono: 4.11.7 @@ -16146,6 +16820,14 @@ snapshots: '@types/node': 20.19.41 optional: true + '@inquirer/confirm@5.1.21(@types/node@22.20.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.0) + '@inquirer/type': 3.0.10(@types/node@22.20.0) + optionalDependencies: + '@types/node': 22.20.0 + optional: true + '@inquirer/confirm@5.1.21(@types/node@24.12.0)': dependencies: '@inquirer/core': 10.3.2(@types/node@24.12.0) @@ -16174,6 +16856,20 @@ snapshots: '@types/node': 20.19.41 optional: true + '@inquirer/core@10.3.2(@types/node@22.20.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.0 + optional: true + '@inquirer/core@10.3.2(@types/node@24.12.0)': dependencies: '@inquirer/ansi': 1.0.2 @@ -16207,6 +16903,11 @@ snapshots: '@types/node': 20.19.41 optional: true + '@inquirer/type@3.0.10(@types/node@22.20.0)': + optionalDependencies: + '@types/node': 22.20.0 + optional: true + '@inquirer/type@3.0.10(@types/node@24.12.0)': optionalDependencies: '@types/node': 24.12.0 @@ -16841,12 +17542,68 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@mariozechner/clipboard-darwin-arm64@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-universal@0.3.9': + optional: true + + '@mariozechner/clipboard-darwin-x64@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-arm64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-gnu@0.3.9': + optional: true + + '@mariozechner/clipboard-linux-x64-musl@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard-win32-x64-msvc@0.3.9': + optional: true + + '@mariozechner/clipboard@0.3.9': + optionalDependencies: + '@mariozechner/clipboard-darwin-arm64': 0.3.9 + '@mariozechner/clipboard-darwin-universal': 0.3.9 + '@mariozechner/clipboard-darwin-x64': 0.3.9 + '@mariozechner/clipboard-linux-arm64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-arm64-musl': 0.3.9 + '@mariozechner/clipboard-linux-riscv64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-gnu': 0.3.9 + '@mariozechner/clipboard-linux-x64-musl': 0.3.9 + '@mariozechner/clipboard-win32-arm64-msvc': 0.3.9 + '@mariozechner/clipboard-win32-x64-msvc': 0.3.9 + optional: true + '@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.6)': dependencies: '@types/mdx': 2.0.13 '@types/react': 19.2.17 react: 19.2.6 + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.41.1 + ws: 8.19.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.1(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@mixmark-io/domino@2.2.0': {} '@modelcontextprotocol/ext-apps@1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3)': @@ -18975,6 +19732,8 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@silvia-odwyer/photon-node@0.3.4': {} + '@sinclair/typebox-codegen@0.11.1': dependencies: '@sinclair/typebox': 0.33.22 @@ -18999,6 +19758,60 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@smithy/core@3.26.0': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.4.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.5.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.3': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.8.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.5.2': + dependencies: + '@smithy/core': 3.26.0 + '@smithy/types': 4.15.0 + tslib: 2.8.1 + + '@smithy/types@4.15.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -19712,7 +20525,7 @@ snapshots: '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 - minimatch: 10.1.2 + minimatch: 10.2.5 path-browserify: 1.0.1 '@turbo/darwin-64@2.9.18': @@ -19880,6 +20693,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + '@types/node@24.12.0': dependencies: undici-types: 7.16.0 @@ -19910,6 +20727,8 @@ snapshots: dependencies: '@types/node': 24.12.0 + '@types/retry@0.12.0': {} + '@types/semver@7.7.1': {} '@types/stack-utils@2.0.3': {} @@ -20062,6 +20881,15 @@ snapshots: msw: 2.12.8(@types/node@20.19.41)(typescript@5.9.3) vite: 7.3.5(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/mocker@4.1.8(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.8 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + msw: 2.12.8(@types/node@22.20.0)(typescript@5.9.3) + vite: 7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + '@vitest/mocker@4.1.8(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.8 @@ -20735,6 +21563,8 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + binary-extensions@2.3.0: {} bindings@1.5.0: @@ -20774,6 +21604,8 @@ snapshots: boolean@3.2.0: optional: true + bowser@2.14.1: {} + bplist-creator@0.1.0: dependencies: stream-buffers: 2.2.0 @@ -21431,6 +22263,8 @@ snapshots: diff@8.0.3: {} + diff@8.0.4: {} + dir-compare@4.2.0: dependencies: minimatch: 3.1.2 @@ -22606,6 +23440,22 @@ snapshots: fzf@0.5.2: {} + gaxios@7.1.5: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.1.5 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -22696,7 +23546,7 @@ snapshots: glob@13.0.1: dependencies: - minimatch: 10.1.2 + minimatch: 10.2.5 minipass: 7.1.2 path-scurry: 2.0.1 @@ -22741,6 +23591,19 @@ snapshots: dependencies: csstype: 3.2.3 + google-auth-library@10.9.0: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.1.5 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} got@11.8.6: @@ -22894,6 +23757,8 @@ snapshots: dependencies: hermes-estree: 0.32.0 + highlight.js@10.7.3: {} + highlight.js@11.11.1: {} hono@4.11.7: {} @@ -22906,6 +23771,10 @@ snapshots: dependencies: lru-cache: 10.4.3 + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.2.5 + html-encoding-sniffer@4.0.0: dependencies: whatwg-encoding: 3.1.1 @@ -23073,7 +23942,7 @@ snapshots: is-fullwidth-code-point@5.1.0: dependencies: - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.6.0 is-generator-function@1.1.2: dependencies: @@ -23383,6 +24252,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} @@ -23789,6 +24662,8 @@ snapshots: markdown-table@3.0.4: {} + marked@18.0.5: {} + marky@1.3.0: {} matcher@3.0.0: @@ -24488,6 +25363,32 @@ snapshots: - '@types/node' optional: true + msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@22.20.0) + '@mswjs/interceptors': 0.41.0 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.12.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.3 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + optional: true + msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3): dependencies: '@inquirer/confirm': 5.1.21(@types/node@24.12.0) @@ -24772,6 +25673,11 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openai@6.26.0(ws@8.19.0)(zod@4.4.3): + optionalDependencies: + ws: 8.19.0 + zod: 4.4.3 + openapi-types@12.1.3: {} openapi3-ts@4.5.0: @@ -25016,6 +25922,11 @@ snapshots: p-map@7.0.4: {} + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + p-try@2.2.0: {} package-json-from-dist@1.0.1: {} @@ -25077,6 +25988,8 @@ snapshots: parseurl@1.3.3: {} + partial-json@0.1.7: {} + pastable@2.2.1(react@19.2.6): dependencies: '@babel/core': 7.29.0 @@ -26186,6 +27099,8 @@ snapshots: retry@0.12.0: {} + retry@0.13.1: {} + rettime@0.10.1: {} reusify@1.1.0: {} @@ -26402,6 +27317,8 @@ snapshots: semver@7.7.3: {} + semver@7.8.0: {} + semver@7.8.4: {} send@0.19.2: @@ -27296,6 +28213,8 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.2 + typebox@1.1.38: {} + typed-openapi@2.2.7(openapi-types@12.1.3)(react@19.2.6): dependencies: '@apidevtools/swagger-parser': 12.1.0(openapi-types@12.1.3) @@ -27338,6 +28257,8 @@ snapshots: undici@7.27.2: optional: true + undici@8.5.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -27537,6 +28458,23 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 + vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.15 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + terser: 5.46.0 + tsx: 4.22.4 + yaml: 2.9.0 + vite@7.3.5(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.2 @@ -27600,6 +28538,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.8 + '@vitest/mocker': 4.1.8(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.8 + '@vitest/runner': 4.1.8 + '@vitest/snapshot': 4.1.8 + '@vitest/spy': 4.1.8 + '@vitest/utils': 4.1.8 + es-module-lexer: 2.0.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.0 + '@types/node': 22.20.0 + '@vitest/ui': 4.1.8(vitest@4.1.8) + jsdom: 26.1.0 + transitivePeerDependencies: + - msw + vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@20.19.41)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@20.19.41)(typescript@5.9.3))(vite@7.3.5(@types/node@20.19.41)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 55a5113348..45a646a55a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -48,6 +48,10 @@ minimumReleaseAgeExclude: - '@anthropic-ai/claude-agent-sdk-win32-arm64' - '@anthropic-ai/claude-agent-sdk-win32-x64' - '@anthropic-ai/sdk' + - '@earendil-works/pi-agent-core' + - '@earendil-works/pi-ai' + - '@earendil-works/pi-coding-agent' + - '@earendil-works/pi-tui' - '@pierre/diffs' - '@pierre/theming' - '@posthog/quill' From f59fc86692597fc4711a7185fe9c2dc1eff27cac Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Mon, 6 Jul 2026 15:39:31 +0200 Subject: [PATCH 2/4] web fetch --- packages/harness/package.json | 14 +- packages/harness/src/cli.ts | 11 +- packages/harness/src/extensions/README.md | 25 +- .../posthog-provider/gateway-auth.test.ts | 63 ++++ .../posthog-provider/gateway-auth.ts | 58 +++ .../src/extensions/posthog-provider/index.ts | 9 + packages/harness/src/extensions/registry.ts | 8 + .../src/extensions/web-access/extension.ts | 22 ++ .../src/extensions/web-access/index.ts | 9 + .../extensions/web-access/web-fetch.test.ts | 232 ++++++++++++ .../src/extensions/web-access/web-fetch.ts | 346 ++++++++++++++++++ .../extensions/web-access/web-search.test.ts | 216 +++++++++++ .../src/extensions/web-access/web-search.ts | 143 ++++++++ packages/harness/src/session.ts | 5 + packages/harness/src/spawn.ts | 25 +- packages/harness/tsup.config.ts | 6 + pnpm-lock.yaml | 194 ++++++++-- 17 files changed, 1334 insertions(+), 52 deletions(-) create mode 100644 packages/harness/src/extensions/posthog-provider/gateway-auth.test.ts create mode 100644 packages/harness/src/extensions/posthog-provider/gateway-auth.ts create mode 100644 packages/harness/src/extensions/posthog-provider/index.ts create mode 100644 packages/harness/src/extensions/web-access/extension.ts create mode 100644 packages/harness/src/extensions/web-access/index.ts create mode 100644 packages/harness/src/extensions/web-access/web-fetch.test.ts create mode 100644 packages/harness/src/extensions/web-access/web-fetch.ts create mode 100644 packages/harness/src/extensions/web-access/web-search.test.ts create mode 100644 packages/harness/src/extensions/web-access/web-search.ts diff --git a/packages/harness/package.json b/packages/harness/package.json index 08ae32e4ca..f530a306b1 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -30,6 +30,14 @@ "./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" } }, "scripts": { @@ -42,10 +50,14 @@ "dependencies": { "@earendil-works/pi-ai": "0.80.3", "@earendil-works/pi-coding-agent": "0.80.3", - "@posthog/shared": "workspace:*" + "@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" diff --git a/packages/harness/src/cli.ts b/packages/harness/src/cli.ts index 266566968a..0bce202754 100644 --- a/packages/harness/src/cli.ts +++ b/packages/harness/src/cli.ts @@ -1,8 +1,11 @@ #!/usr/bin/env node import { main } from "@earendil-works/pi-coding-agent"; -import { harnessExtensions } from "./extensions/registry"; +import { harnessExtensionFiles } from "./spawn"; -main(process.argv.slice(2), { - extensionFactories: harnessExtensions(), -}); +// Load every harness extension by file path (rather than via +// `extensionFactories`) so each shows its real name in the startup banner +// instead of ``; 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)]); diff --git a/packages/harness/src/extensions/README.md b/packages/harness/src/extensions/README.md index e599485186..278a3e3295 100644 --- a/packages/harness/src/extensions/README.md +++ b/packages/harness/src/extensions/README.md @@ -8,10 +8,19 @@ shape, so adding the Nth extension is mechanical. ``` src/extensions// - extension.ts # REQUIRED — the pi entry point + 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//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 `/extension.js` (and collide with any other extension +also named `extension.js`, backing off to even longer paths); loading +`index.js` shows the clean ``. + `extension.ts` must: 1. `export default` a pi `ExtensionFactory` — `(pi: ExtensionAPI) => void | Promise`. @@ -48,8 +57,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: -- **In-process CLI** (`src/cli.ts`) → `main(argv, { extensionFactories: harnessExtensions() })` -- **Subprocess** (`src/spawn.ts`) → one `-e dist/extensions//extension.js` per extension +- **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 + +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 are real pi extension-loading paths, verified to register in every pi mode (interactive, print, -rpc, json, and `--list-models`). +Both `-e` paths are real pi extension-loading paths, verified to register in every pi mode +(interactive, print, rpc, json, and `--list-models`). diff --git a/packages/harness/src/extensions/posthog-provider/gateway-auth.test.ts b/packages/harness/src/extensions/posthog-provider/gateway-auth.test.ts new file mode 100644 index 0000000000..9264f9879c --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/gateway-auth.test.ts @@ -0,0 +1,63 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { getLlmGatewayUrl } from "./gateway"; +import { resolveGatewayAuth, tryResolveGatewayAuth } from "./gateway-auth"; + +function fakeCtx(apiKey: string | undefined): ExtensionContext { + return { + modelRegistry: { + getApiKeyForProvider: vi.fn().mockResolvedValue(apiKey), + }, + } as unknown as ExtensionContext; +} + +describe("resolveGatewayAuth", () => { + it("uses a static apiKey option over the model registry", async () => { + const auth = await resolveGatewayAuth( + { region: "us", apiKey: "pha_static" }, + fakeCtx("pha_from_registry"), + ); + expect(auth).toEqual({ + baseUrl: getLlmGatewayUrl("us"), + apiKey: "pha_static", + }); + }); + + it("falls back to the posthog provider's resolved api key", async () => { + const auth = await resolveGatewayAuth( + { region: "eu" }, + fakeCtx("pha_from_registry"), + ); + expect(auth).toEqual({ + baseUrl: getLlmGatewayUrl("eu"), + apiKey: "pha_from_registry", + }); + }); + + it("throws a descriptive error when no credentials are available", async () => { + await expect( + resolveGatewayAuth({ region: "us" }, fakeCtx(undefined)), + ).rejects.toThrow(/No PostHog gateway credentials/); + }); +}); + +describe("tryResolveGatewayAuth", () => { + it("resolves to undefined instead of throwing when no credentials exist", async () => { + const auth = await tryResolveGatewayAuth( + { region: "us" }, + fakeCtx(undefined), + ); + expect(auth).toBeUndefined(); + }); + + it("resolves normally when credentials are available", async () => { + const auth = await tryResolveGatewayAuth( + { region: "us", apiKey: "pha_static" }, + fakeCtx(undefined), + ); + expect(auth).toEqual({ + baseUrl: getLlmGatewayUrl("us"), + apiKey: "pha_static", + }); + }); +}); diff --git a/packages/harness/src/extensions/posthog-provider/gateway-auth.ts b/packages/harness/src/extensions/posthog-provider/gateway-auth.ts new file mode 100644 index 0000000000..3e37b16ef7 --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/gateway-auth.ts @@ -0,0 +1,58 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { getLlmGatewayUrl, resolveRegion } from "./gateway"; +import { POSTHOG_PROVIDER_NAME, type PosthogProviderOptions } from "./provider"; + +export interface GatewayAuth { + baseUrl: string; + apiKey: string; +} + +/** + * Resolves the PostHog LLM gateway base URL and bearer token for callers that + * need to hit the gateway directly over `fetch`, outside pi's own + * model-call machinery (e.g. the `web-access` extension's tools). + * + * `options.apiKey` (a static override, e.g. for headless use) wins when set. + * Otherwise this reuses whatever credential the `posthog` provider already + * has configured — an OAuth access token from `/login`, refreshed + * automatically by pi, or that provider's own static `apiKey` — resolved + * fresh on every call so token refresh is picked up automatically. This + * keeps other gateway callers in sync with `posthog-provider` without + * duplicating auth. + */ +export async function resolveGatewayAuth( + options: PosthogProviderOptions, + ctx: ExtensionContext, +): Promise { + const region = resolveRegion(options.region); + const baseUrl = getLlmGatewayUrl(region); + + const apiKey = + options.apiKey ?? + (await ctx.modelRegistry.getApiKeyForProvider(POSTHOG_PROVIDER_NAME)); + + if (!apiKey) { + throw new Error( + 'No PostHog gateway credentials available. Run "/login" and choose PostHog, or pass an explicit apiKey.', + ); + } + + return { baseUrl, apiKey }; +} + +/** + * Same as {@link resolveGatewayAuth}, but resolves to `undefined` instead of + * throwing when no credentials are configured. Use this for tools that can + * degrade gracefully without gateway access (e.g. web_fetch returning raw + * markdown instead of a summarized response). + */ +export async function tryResolveGatewayAuth( + options: PosthogProviderOptions, + ctx: ExtensionContext, +): Promise { + try { + return await resolveGatewayAuth(options, ctx); + } catch { + return undefined; + } +} diff --git a/packages/harness/src/extensions/posthog-provider/index.ts b/packages/harness/src/extensions/posthog-provider/index.ts new file mode 100644 index 0000000000..9c569e0d9a --- /dev/null +++ b/packages/harness/src/extensions/posthog-provider/index.ts @@ -0,0 +1,9 @@ +// Thin `index.ts` re-export used only as pi's `-e` extension entry point. +// +// pi's startup banner derives an extension's display name from its file +// path: a trailing `index.ts`/`index.js` segment is dropped in favor of the +// parent directory name, so loading this file (instead of `./extension.ts` +// directly) makes the extension show as `posthog-provider` instead of +// `posthog-provider/extension.js`. `./extension.ts` remains the real +// implementation per the convention in `../README.md`. +export { default } from "./extension"; diff --git a/packages/harness/src/extensions/registry.ts b/packages/harness/src/extensions/registry.ts index baa38d58cc..3c237afb94 100644 --- a/packages/harness/src/extensions/registry.ts +++ b/packages/harness/src/extensions/registry.ts @@ -1,6 +1,7 @@ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; import { createPosthogProviderExtension } from "./posthog-provider/extension"; import type { PosthogProviderOptions } from "./posthog-provider/provider"; +import { createWebAccessExtension } from "./web-access/extension"; export type HarnessExtensionOptions = PosthogProviderOptions; @@ -9,8 +10,15 @@ interface HarnessExtension { create: (options: HarnessExtensionOptions) => ExtensionFactory; } +// `pi-mcp-adapter` ships raw, untranspiled TypeScript with no compiled entry +// point or `main`/`exports` field: it is only designed to be loaded by pi's +// own jiti-based extension loader via file path (the `-e` CLI flag, or +// `additionalExtensionPaths` in the SDK), not statically imported here. See +// `mcpAdapterExtensionFile()` in `spawn.ts` and its use in `cli.ts` and +// `session.ts` for how it is wired in as a file path instead of a factory. const EXTENSIONS: HarnessExtension[] = [ { name: "posthog-provider", create: createPosthogProviderExtension }, + { name: "web-access", create: createWebAccessExtension }, ]; export const HARNESS_EXTENSION_NAMES: readonly string[] = EXTENSIONS.map( diff --git a/packages/harness/src/extensions/web-access/extension.ts b/packages/harness/src/extensions/web-access/extension.ts new file mode 100644 index 0000000000..3be39ff21d --- /dev/null +++ b/packages/harness/src/extensions/web-access/extension.ts @@ -0,0 +1,22 @@ +import type { + ExtensionAPI, + ExtensionFactory, +} from "@earendil-works/pi-coding-agent"; +import type { PosthogProviderOptions } from "../posthog-provider/provider"; +import { createWebFetchTool } from "./web-fetch"; +import { createWebSearchTool } from "./web-search"; + +export type WebAccessOptions = PosthogProviderOptions; + +export function createWebAccessExtension( + options: WebAccessOptions = {}, +): ExtensionFactory { + return (pi: ExtensionAPI) => { + pi.registerTool(createWebSearchTool(options)); + pi.registerTool(createWebFetchTool(options)); + }; +} + +export default function webAccess(pi: ExtensionAPI): void | Promise { + return createWebAccessExtension()(pi); +} diff --git a/packages/harness/src/extensions/web-access/index.ts b/packages/harness/src/extensions/web-access/index.ts new file mode 100644 index 0000000000..5ff7decc14 --- /dev/null +++ b/packages/harness/src/extensions/web-access/index.ts @@ -0,0 +1,9 @@ +// Thin `index.ts` re-export used only as pi's `-e` extension entry point. +// +// pi's startup banner derives an extension's display name from its file +// path: a trailing `index.ts`/`index.js` segment is dropped in favor of the +// parent directory name, so loading this file (instead of `./extension.ts` +// directly) makes the extension show as `web-access` instead of +// `web-access/extension.js`. `./extension.ts` remains the real +// implementation per the convention in `../README.md`. +export { default } from "./extension"; diff --git a/packages/harness/src/extensions/web-access/web-fetch.test.ts b/packages/harness/src/extensions/web-access/web-fetch.test.ts new file mode 100644 index 0000000000..2d5f44be38 --- /dev/null +++ b/packages/harness/src/extensions/web-access/web-fetch.test.ts @@ -0,0 +1,232 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createWebFetchTool, + isPermittedRedirect, + makeSummarizationPrompt, + validateUrl, +} from "./web-fetch"; + +describe("validateUrl", () => { + it.each([ + ["https://example.com/page", true], + ["http://example.com", true], + [ + `https://example.com/${"a".repeat(2000 - "https://example.com/".length)}`, + true, + ], + ])("accepts %s", (url, expected) => { + expect(validateUrl(url).valid).toBe(expected); + }); + + it.each([ + [`https://example.com/${"a".repeat(2000)}`, "maximum length"], + ["not a url", "Invalid URL"], + ["https://user@example.com/page", "credentials"], + ["https://user:pass@example.com/page", "credentials"], + ["https://localhost/page", "public hostname"], + ])("rejects %s with reason containing '%s'", (url, reason) => { + const result = validateUrl(url); + expect(result.valid).toBe(false); + if (!result.valid) expect(result.reason).toContain(reason); + }); + + it("boundary: accepts at exactly 2000 chars, rejects at 2001", () => { + const base = "https://example.com/"; + const atLimit = `${base}${"a".repeat(2000 - base.length)}`; + const overLimit = `${base}${"a".repeat(2001 - base.length)}`; + expect(atLimit.length).toBe(2000); + expect(overLimit.length).toBe(2001); + expect(validateUrl(atLimit).valid).toBe(true); + expect(validateUrl(overLimit).valid).toBe(false); + }); +}); + +describe("isPermittedRedirect", () => { + it.each([ + [ + "same host, path change", + "https://example.com/a", + "https://example.com/b", + true, + ], + ["adding www", "https://example.com/p", "https://www.example.com/p", true], + [ + "removing www", + "https://www.example.com/p", + "https://example.com/p", + true, + ], + ["cross-host", "https://example.com/p", "https://evil.com/p", false], + ["protocol change", "https://example.com/p", "http://example.com/p", false], + [ + "port change", + "https://example.com/p", + "https://example.com:8080/p", + false, + ], + [ + "credentials injected", + "https://example.com/p", + "https://user:pass@example.com/p", + false, + ], + [ + "subdomain beyond www", + "https://example.com/p", + "https://api.example.com/p", + false, + ], + ["invalid URLs", "not a url", "also not a url", false], + ])("%s → %s", (_label, from, to, expected) => { + expect(isPermittedRedirect(from, to)).toBe(expected); + }); +}); + +describe("makeSummarizationPrompt", () => { + it("includes content and prompt in output", () => { + const result = makeSummarizationPrompt("# Hello World", "Summarize this"); + expect(result).toContain("# Hello World"); + expect(result).toContain("Summarize this"); + expect(result).toContain("Web page content:"); + }); + + it("truncates content exceeding 100K chars", () => { + const result = makeSummarizationPrompt("x".repeat(150_000), "Summarize"); + expect(result).toContain("[Content truncated due to length...]"); + expect(result.split("---")[1]?.length).toBeLessThan(110_000); + }); + + it("does not truncate content under 100K chars", () => { + const content = "x".repeat(50_000); + const result = makeSummarizationPrompt(content, "Summarize"); + expect(result).not.toContain("[Content truncated"); + expect(result).toContain(content); + }); + + it("handles empty content", () => { + const result = makeSummarizationPrompt("", "Extract the title"); + expect(result).toContain("Extract the title"); + expect(result).toContain("---\n\n---"); + }); +}); + +function fakeCtx(apiKey: string | undefined): ExtensionContext { + return { + modelRegistry: { + getApiKeyForProvider: vi.fn().mockResolvedValue(apiKey), + }, + } as unknown as ExtensionContext; +} + +describe("createWebFetchTool", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.useRealTimers(); + }); + + it("rejects invalid URLs before attempting a fetch", async () => { + const tool = createWebFetchTool({ region: "us", apiKey: "pha_test" }); + await expect( + tool.execute( + "call-1", + { url: "not a url", prompt: "summarize" }, + undefined, + undefined, + fakeCtx(undefined), + ), + ).rejects.toThrow(/Invalid URL/); + }); + + it("returns raw markdown without gateway credentials", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers({ "content-type": "text/html" }), + text: async () => "

Hello

", + }) as unknown as typeof fetch; + + const tool = createWebFetchTool({ region: "us" }); + const result = await tool.execute( + "call-1", + { url: "https://example.com/page", prompt: "summarize" }, + undefined, + undefined, + fakeCtx(undefined), + ); + + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("Hello"), + }); + }); + + it("summarizes via the gateway when credentials are available", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.includes("/v1/chat/completions")) { + return { + ok: true, + json: async () => ({ + choices: [{ message: { content: "Summary text" } }], + }), + } as unknown as Response; + } + return { + status: 200, + ok: true, + headers: new Headers({ "content-type": "text/html" }), + text: async () => "

Some content

", + } as unknown as Response; + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const tool = createWebFetchTool({ region: "us", apiKey: "pha_test" }); + const result = await tool.execute( + "call-1", + { url: "https://example.com/other-page", prompt: "extract title" }, + undefined, + undefined, + fakeCtx(undefined), + ); + + expect(result.content[0]).toMatchObject({ + type: "text", + text: "Summary text", + }); + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes("/v1/chat/completions"), + ), + ).toBe(true); + }); + + it("reports cross-host redirects instead of following them", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + status: 302, + headers: new Headers({ location: "https://evil.com/other" }), + }) as unknown as typeof fetch; + + const tool = createWebFetchTool({ region: "us" }); + const result = await tool.execute( + "call-1", + { url: "https://example.com/redirecting-page", prompt: "x" }, + undefined, + undefined, + fakeCtx(undefined), + ); + + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("REDIRECT DETECTED"), + }); + expect(result.details).toMatchObject({ + redirectUrl: "https://evil.com/other", + }); + }); +}); diff --git a/packages/harness/src/extensions/web-access/web-fetch.ts b/packages/harness/src/extensions/web-access/web-fetch.ts new file mode 100644 index 0000000000..de25a241c4 --- /dev/null +++ b/packages/harness/src/extensions/web-access/web-fetch.ts @@ -0,0 +1,346 @@ +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { LRUCache } from "lru-cache"; +import TurndownService from "turndown"; +import { Type } from "typebox"; +import { + type GatewayAuth, + tryResolveGatewayAuth, +} from "../posthog-provider/gateway-auth"; +import type { PosthogProviderOptions } from "../posthog-provider/provider"; + +const MAX_URL_LENGTH = 2000; +const MAX_CONTENT_LENGTH = 10 * 1024 * 1024; +const MAX_MARKDOWN_LENGTH = 100_000; +const FETCH_TIMEOUT_MS = 60_000; +const MAX_REDIRECTS = 10; +const SUMMARIZATION_MODEL = "claude-haiku-4-5"; + +const turndown = new TurndownService(); + +export function validateUrl( + url: string, +): { valid: true; url: URL } | { valid: false; reason: string } { + if (url.length > MAX_URL_LENGTH) { + return { + valid: false, + reason: `URL exceeds maximum length of ${MAX_URL_LENGTH} characters`, + }; + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { valid: false, reason: `Invalid URL: ${url}` }; + } + + if (parsed.username || parsed.password) { + return { + valid: false, + reason: "URLs with embedded credentials are not supported", + }; + } + + const parts = parsed.hostname.split("."); + if (parts.length < 2) { + return { valid: false, reason: "URL must have a public hostname" }; + } + + return { valid: true, url: parsed }; +} + +interface CacheEntry { + bytes: number; + code: number; + content: string; + contentType: string; +} + +const urlCache = new LRUCache({ + maxSize: 50 * 1024 * 1024, + sizeCalculation: (entry) => Math.max(1, entry.content.length), + ttl: 15 * 60 * 1000, +}); + +export function isPermittedRedirect( + original: string, + redirect: string, +): boolean { + try { + const a = new URL(original); + const b = new URL(redirect); + if (b.protocol !== a.protocol || b.port !== a.port) return false; + if (b.username || b.password) return false; + const strip = (h: string) => h.replace(/^www\./, ""); + return strip(a.hostname) === strip(b.hostname); + } catch { + return false; + } +} + +async function fetchWithRedirects( + url: string, + signal: AbortSignal, + depth = 0, +): Promise< + | { type: "response"; response: Response; finalUrl: string } + | { type: "cross_host_redirect"; from: string; to: string; status: number } +> { + if (depth > MAX_REDIRECTS) { + throw new Error(`Too many redirects (exceeded ${MAX_REDIRECTS})`); + } + + const response = await fetch(url, { + signal, + redirect: "manual", + headers: { + Accept: "text/markdown, text/html, */*", + "User-Agent": "PostHog-Harness/1.0 (web-fetch)", + }, + }); + + if ([301, 302, 307, 308].includes(response.status)) { + const location = response.headers.get("location"); + if (!location) throw new Error("Redirect missing Location header"); + const redirectUrl = new URL(location, url).toString(); + + if (isPermittedRedirect(url, redirectUrl)) { + return fetchWithRedirects(redirectUrl, signal, depth + 1); + } + return { + type: "cross_host_redirect", + from: url, + to: redirectUrl, + status: response.status, + }; + } + + return { type: "response", response, finalUrl: url }; +} + +export function makeSummarizationPrompt( + markdownContent: string, + prompt: string, +): string { + const truncated = + markdownContent.length > MAX_MARKDOWN_LENGTH + ? `${markdownContent.slice(0, MAX_MARKDOWN_LENGTH)}\n\n[Content truncated due to length...]` + : markdownContent; + + return `Web page content:\n---\n${truncated}\n---\n\n${prompt}\n\nProvide a concise response based on the content above. Include relevant details, code examples, and documentation excerpts as needed.`; +} + +async function summarize( + markdown: string, + prompt: string, + gateway: GatewayAuth, + signal: AbortSignal | undefined, +): Promise { + const baseUrl = gateway.baseUrl.replace(/\/+$/, ""); + + const response = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${gateway.apiKey}`, + }, + body: JSON.stringify({ + model: SUMMARIZATION_MODEL, + messages: [ + { role: "user", content: makeSummarizationPrompt(markdown, prompt) }, + ], + }), + signal, + }); + + if (!response.ok) { + const err = await response.text().catch(() => ""); + throw new Error( + `Summarization failed (${response.status}): ${err || response.statusText}`, + ); + } + + const data = (await response.json()) as { + choices: Array<{ message: { content: string } }>; + }; + + return data.choices[0]?.message?.content ?? "No response from summarizer."; +} + +function returnMarkdown( + markdown: string, + details: Record, +): { + content: { type: "text"; text: string }[]; + details: Record; +} { + const truncated = + markdown.length > MAX_MARKDOWN_LENGTH + ? `${markdown.slice(0, MAX_MARKDOWN_LENGTH)}\n\n[Content truncated due to length...]` + : markdown; + return { + content: [{ type: "text" as const, text: truncated }], + details, + }; +} + +export function createWebFetchTool(options: PosthogProviderOptions = {}) { + return defineTool({ + name: "web_fetch", + label: "Web Fetch", + description: + "Fetch and extract content from a web page URL. Converts HTML to markdown and summarizes it based on your prompt. Use for reading documentation, READMEs, API references, or any public web page.", + promptSnippet: "Fetch and read content from a URL", + promptGuidelines: [ + "Use web_fetch to read documentation, READMEs, changelogs, or any public web page.", + "web_fetch will fail for authenticated or private URLs — only use it for public content.", + "The prompt parameter tells the summarizer what to extract — be specific about what you need.", + ], + parameters: Type.Object({ + url: Type.String({ + description: "The URL to fetch content from", + }), + prompt: Type.String({ + description: "What information to extract from the page content", + }), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const validation = validateUrl(params.url); + if (!validation.valid) { + throw new Error(validation.reason); + } + + const gateway = await tryResolveGatewayAuth(options, ctx); + + const parsed = validation.url; + if (parsed.protocol === "http:") { + parsed.protocol = "https:"; + } + const url = parsed.toString(); + + const cached = urlCache.get(url); + if (cached) { + const details = { + code: cached.code, + bytes: cached.bytes, + url, + cached: true, + }; + + if ( + cached.contentType.includes("text/markdown") && + cached.content.length < MAX_MARKDOWN_LENGTH + ) { + return returnMarkdown(cached.content, details); + } + + if (gateway) { + const summary = await summarize( + cached.content, + params.prompt, + gateway, + signal, + ); + return { + content: [{ type: "text" as const, text: summary }], + details, + }; + } + + return returnMarkdown(cached.content, details); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const combinedSignal = signal + ? AbortSignal.any([signal, controller.signal]) + : controller.signal; + + try { + const result = await fetchWithRedirects(url, combinedSignal); + + if (result.type === "cross_host_redirect") { + return { + content: [ + { + type: "text" as const, + text: `REDIRECT DETECTED: The URL redirects to a different host.\n\nOriginal URL: ${result.from}\nRedirect URL: ${result.to}\nStatus: ${result.status}\n\nTo fetch the redirected content, call web_fetch again with the redirect URL.`, + }, + ], + details: { + code: result.status, + url: result.from, + redirectUrl: result.to, + }, + }; + } + + const { response, finalUrl } = result; + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `HTTP ${response.status}: ${body || response.statusText}`, + ); + } + + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = contentLengthHeader + ? Number(contentLengthHeader) + : 0; + if (contentLength > MAX_CONTENT_LENGTH) { + throw new Error( + `Content too large (${contentLength} bytes, max ${MAX_CONTENT_LENGTH})`, + ); + } + + const rawText = await response.text(); + const contentType = response.headers.get("content-type") ?? ""; + + let markdown: string; + if (contentType.includes("text/html")) { + markdown = turndown.turndown(rawText); + } else { + markdown = rawText; + } + + urlCache.set(url, { + bytes: rawText.length, + code: response.status, + content: markdown, + contentType, + }); + + const details = { + code: response.status, + bytes: rawText.length, + url: finalUrl, + }; + + if ( + contentType.includes("text/markdown") && + markdown.length < MAX_MARKDOWN_LENGTH + ) { + return returnMarkdown(markdown, details); + } + + if (gateway) { + const summary = await summarize( + markdown, + params.prompt, + gateway, + signal, + ); + return { + content: [{ type: "text" as const, text: summary }], + details, + }; + } + + return returnMarkdown(markdown, details); + } finally { + clearTimeout(timeout); + } + }, + }); +} diff --git a/packages/harness/src/extensions/web-access/web-search.test.ts b/packages/harness/src/extensions/web-access/web-search.test.ts new file mode 100644 index 0000000000..3de9bd5af5 --- /dev/null +++ b/packages/harness/src/extensions/web-access/web-search.test.ts @@ -0,0 +1,216 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createWebSearchTool, + formatSearchResult, + parseSearchContextSize, +} from "./web-search"; + +describe("parseSearchContextSize", () => { + it.each([ + ["low", "low"], + ["medium", "medium"], + ["high", "high"], + [undefined, "medium"], + ["xlarge", "medium"], + ["", "medium"], + ])("parseSearchContextSize(%j) → %s", (input, expected) => { + expect(parseSearchContextSize(input as string | undefined)).toBe(expected); + }); +}); + +describe("formatSearchResult", () => { + it.each([ + ["empty annotations", "Hello world", [], "Hello world", 0], + ["undefined annotations", "Hello world", undefined, "Hello world", 0], + ])( + "%s → no sources block", + (_label, text, annotations, expectedText, expectedCitations) => { + const result = formatSearchResult( + text as string, + annotations as + | Array<{ type: string; url: string; title: string }> + | undefined, + ); + expect(result.formatted).toBe(expectedText); + expect(result.citations).toHaveLength(expectedCitations as number); + }, + ); + + it("appends deduplicated source links", () => { + const result = formatSearchResult("Answer text", [ + { type: "url_citation", url: "https://a.com", title: "Source A" }, + { type: "url_citation", url: "https://b.com", title: "Source B" }, + ]); + expect(result.formatted).toContain("Sources:"); + expect(result.formatted).toContain("- [Source A](https://a.com)"); + expect(result.formatted).toContain("- [Source B](https://b.com)"); + expect(result.citations).toHaveLength(2); + }); + + it("deduplicates citations with the same URL", () => { + const result = formatSearchResult("Text", [ + { type: "url_citation", url: "https://a.com", title: "First" }, + { type: "url_citation", url: "https://a.com", title: "Duplicate" }, + { type: "url_citation", url: "https://b.com", title: "Second" }, + ]); + expect(result.formatted).toContain("- [First](https://a.com)"); + expect(result.formatted).not.toContain("Duplicate"); + expect(result.formatted).toContain("- [Second](https://b.com)"); + }); + + it("filters out non-url_citation annotations", () => { + const result = formatSearchResult("Text", [ + { type: "url_citation", url: "https://a.com", title: "Real" }, + { type: "other_type", url: "https://b.com", title: "Not a citation" }, + ]); + expect(result.citations).toHaveLength(1); + expect(result.formatted).not.toContain("Not a citation"); + }); + + it("preserves all citations in array while deduping in formatted output", () => { + const result = formatSearchResult("Text", [ + { type: "url_citation", url: "https://a.com", title: "First" }, + { type: "url_citation", url: "https://a.com", title: "Second mention" }, + ]); + expect(result.citations).toHaveLength(2); + const sourceLines = + result.formatted + .split("Sources:\n")[1] + ?.split("\n") + .filter((l) => l.startsWith("- ")) ?? []; + expect(sourceLines).toHaveLength(1); + }); +}); + +function fakeCtx(apiKey: string | undefined): ExtensionContext { + return { + modelRegistry: { + getApiKeyForProvider: vi.fn().mockResolvedValue(apiKey), + }, + } as unknown as ExtensionContext; +} + +describe("createWebSearchTool", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("throws when no gateway credentials are available", async () => { + const tool = createWebSearchTool({ region: "us" }); + await expect( + tool.execute( + "call-1", + { query: "hello" }, + undefined, + undefined, + fakeCtx(undefined), + ), + ).rejects.toThrow(/No PostHog gateway credentials/); + }); + + it("calls the gateway responses API and formats the result", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + output: [ + { + type: "message", + content: [ + { + type: "output_text", + text: "The answer", + annotations: [ + { type: "url_citation", url: "https://a.com", title: "A" }, + ], + }, + ], + }, + ], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, + }), + }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const tool = createWebSearchTool({ region: "us", apiKey: "pha_test" }); + const result = await tool.execute( + "call-1", + { query: "what happened today" }, + undefined, + undefined, + fakeCtx(undefined), + ); + + expect(fetchMock).toHaveBeenCalledWith( + "https://gateway.us.posthog.com/posthog_code/v1/responses", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ Authorization: "Bearer pha_test" }), + }), + ); + expect(result.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("The answer"), + }); + expect(result.details).toMatchObject({ + citations: [{ type: "url_citation", url: "https://a.com", title: "A" }], + }); + }); + + it("throws a descriptive error on a non-ok response", async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + text: async () => "boom", + }) as unknown as typeof fetch; + + const tool = createWebSearchTool({ region: "us", apiKey: "pha_test" }); + await expect( + tool.execute( + "call-1", + { query: "x" }, + undefined, + undefined, + fakeCtx(undefined), + ), + ).rejects.toThrow(/Web search failed \(500\)/); + }); +}); + +describe("createWebSearchTool with dynamic auth", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ output: [] }), + }) as unknown as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("falls back to the posthog provider's resolved api key", async () => { + const tool = createWebSearchTool({ region: "eu" }); + await tool.execute( + "call-1", + { query: "x" }, + undefined, + undefined, + fakeCtx("pha_from_registry"), + ); + + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://gateway.eu.posthog.com/posthog_code/v1/responses", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer pha_from_registry", + }), + }), + ); + }); +}); diff --git a/packages/harness/src/extensions/web-access/web-search.ts b/packages/harness/src/extensions/web-access/web-search.ts new file mode 100644 index 0000000000..198d2206b2 --- /dev/null +++ b/packages/harness/src/extensions/web-access/web-search.ts @@ -0,0 +1,143 @@ +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { resolveGatewayAuth } from "../posthog-provider/gateway-auth"; +import type { PosthogProviderOptions } from "../posthog-provider/provider"; + +const SEARCH_MODEL = "gpt-5.5"; + +const SEARCH_CONTEXT_SIZES = ["low", "medium", "high"] as const; +type SearchContextSize = (typeof SEARCH_CONTEXT_SIZES)[number]; + +interface UrlCitation { + type: "url_citation"; + url: string; + title: string; +} + +interface ResponsesApiOutput { + type: string; + content?: Array<{ + type: string; + text?: string; + annotations?: UrlCitation[]; + }>; +} + +interface ResponsesApiResponse { + output: ResponsesApiOutput[]; + usage?: { + input_tokens: number; + output_tokens: number; + total_tokens: number; + }; +} + +export function parseSearchContextSize( + raw: string | undefined, +): SearchContextSize { + if (raw && (SEARCH_CONTEXT_SIZES as readonly string[]).includes(raw)) { + return raw as SearchContextSize; + } + return "medium"; +} + +export function formatSearchResult( + text: string, + annotations?: Array<{ type: string; url: string; title: string }>, +): { formatted: string; citations: UrlCitation[] } { + const citations = + annotations?.filter((a): a is UrlCitation => a.type === "url_citation") ?? + []; + + const parts: string[] = [text]; + if (citations.length > 0) { + const seen = new Set(); + const unique = citations.filter((c) => { + if (seen.has(c.url)) return false; + seen.add(c.url); + return true; + }); + parts.push( + "", + "Sources:", + ...unique.map((c) => `- [${c.title}](${c.url})`), + ); + } + + return { formatted: parts.join("\n"), citations }; +} + +export function createWebSearchTool(options: PosthogProviderOptions = {}) { + return defineTool({ + name: "web_search", + label: "Web Search", + description: + "Search the web for real-time information using OpenAI's web search. Returns a synthesized answer with source citations. Use for current events, documentation lookups, or any question that benefits from live web data.", + promptSnippet: "Search the web for current information", + promptGuidelines: [ + "Use web_search when you need up-to-date information that may not be in your training data.", + "Use web_search for current events, recent releases, live documentation, or verifying facts.", + "Prefer a specific, well-formed query over a vague one — treat it like a search engine query.", + ], + parameters: Type.Object({ + query: Type.String({ + description: "The search query to execute", + }), + search_context_size: Type.Optional( + Type.String({ + description: + 'How much search context to gather: "low" for quick facts, "medium" (default) for balanced, "high" for in-depth research', + }), + ), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + const { baseUrl, apiKey } = await resolveGatewayAuth(options, ctx); + const contextSize = parseSearchContextSize(params.search_context_size); + + const response = await fetch(`${baseUrl}/v1/responses`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: SEARCH_MODEL, + tools: [{ type: "web_search", search_context_size: contextSize }], + input: params.query, + }), + signal, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw new Error( + `Web search failed (${response.status}): ${body || response.statusText}`, + ); + } + + const data = (await response.json()) as ResponsesApiResponse; + + const messageOutput = data.output.find((o) => o.type === "message"); + const textBlock = messageOutput?.content?.find( + (c) => c.type === "output_text", + ); + + if (!textBlock?.text) { + return { + content: [{ type: "text" as const, text: "No results found." }], + details: {}, + }; + } + + const { formatted, citations } = formatSearchResult( + textBlock.text, + textBlock.annotations, + ); + + return { + content: [{ type: "text" as const, text: formatted }], + details: { citations, usage: data.usage }, + }; + }, + }); +} diff --git a/packages/harness/src/session.ts b/packages/harness/src/session.ts index 2b062d6c6a..d23c6fad7a 100644 --- a/packages/harness/src/session.ts +++ b/packages/harness/src/session.ts @@ -12,6 +12,7 @@ import { type PosthogProviderOptions, resolvePosthogProvider, } from "./extensions/posthog-provider/provider"; +import { mcpAdapterExtensionFile } from "./spawn"; export interface HarnessSessionOptions extends PosthogProviderOptions { cwd?: string; @@ -39,6 +40,10 @@ export async function createHarnessSession( cwd, agentDir: getAgentDir(), extensionFactories: [], + // `pi-mcp-adapter` ships raw TypeScript with no compiled entry point, so + // it must be loaded by file path (see mcpAdapterExtensionFile) rather + // than as an extension factory. + additionalExtensionPaths: [mcpAdapterExtensionFile()], }); await resourceLoader.reload(); diff --git a/packages/harness/src/spawn.ts b/packages/harness/src/spawn.ts index db72701ccf..dbfb54351b 100644 --- a/packages/harness/src/spawn.ts +++ b/packages/harness/src/spawn.ts @@ -14,12 +14,29 @@ export function resolvePiCli(): string { return join(dirname(mainEntry), "cli.js"); } +/** + * `pi-mcp-adapter` ships raw TypeScript with no compiled entry point or + * `main`/`exports` field, so it can only be loaded by file path through + * pi's own extension loader (the `-e` CLI flag, or `additionalExtensionPaths` + * in the SDK) rather than statically imported. Resolve its declared + * extension entry (`./index.ts`, per its `pi.extensions` manifest) from + * wherever npm installed it. + */ +export function mcpAdapterExtensionFile(): string { + const pkgJsonPath = fileURLToPath( + import.meta.resolve("pi-mcp-adapter/package.json"), + ); + return join(dirname(pkgJsonPath), "index.ts"); +} + export function harnessExtensionFiles(): string[] { - return HARNESS_EXTENSION_NAMES.map((name) => - fileURLToPath( - new URL(`./extensions/${name}/extension.js`, import.meta.url), - ), + // `./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`. + const localFiles = HARNESS_EXTENSION_NAMES.map((name) => + fileURLToPath(new URL(`./extensions/${name}/index.js`, import.meta.url)), ); + return [...localFiles, mcpAdapterExtensionFile()]; } export interface SpawnPiOptions extends SpawnOptions { diff --git a/packages/harness/tsup.config.ts b/packages/harness/tsup.config.ts index 2c9aee7f84..19241d6025 100644 --- a/packages/harness/tsup.config.ts +++ b/packages/harness/tsup.config.ts @@ -7,10 +7,16 @@ export default defineConfig({ "src/spawn.ts", "src/extensions/registry.ts", "src/extensions/posthog-provider/extension.ts", + "src/extensions/posthog-provider/index.ts", "src/extensions/posthog-provider/provider.ts", "src/extensions/posthog-provider/models.ts", "src/extensions/posthog-provider/oauth.ts", "src/extensions/posthog-provider/gateway.ts", + "src/extensions/posthog-provider/gateway-auth.ts", + "src/extensions/web-access/extension.ts", + "src/extensions/web-access/index.ts", + "src/extensions/web-access/web-search.ts", + "src/extensions/web-access/web-fetch.ts", ], format: ["esm"], dts: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a25d687e5..4feb2d7602 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -961,10 +961,22 @@ importers: '@posthog/shared': specifier: workspace:* version: link:../shared + lru-cache: + specifier: ^11.1.0 + version: 11.2.5 + pi-mcp-adapter: + specifier: 2.10.0 + version: 2.10.0(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.19.0)(zod@4.4.3) + turndown: + specifier: ^7.2.4 + version: 7.2.4 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.20.0 + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) @@ -2621,6 +2633,11 @@ packages: resolution: {integrity: sha512-3qw0/GeRQBU/nlGjDe5Yb7ePKTmoxefx2YxyKMFAviFUMXpFexBG/hS7mBtwFahFvzrrTPPoRT6sFIDjwoDWPQ==} engines: {node: '>=22.19.0'} + '@earendil-works/pi-ai@0.74.2': + resolution: {integrity: sha512-ukQBHGDm20k9ZUS2cGjNN9vDJp/48r35xmvgSx3paCaC06r2N/PLuRZoJmwQ1ZM7f8T3072odv9YPWn+77w0LA==} + engines: {node: '>=20.0.0'} + hasBin: true + '@earendil-works/pi-ai@0.80.3': resolution: {integrity: sha512-jPZLMeGL5kkMSEAwAklfXTMHqZvfhsJtCCpKGIr5Duk7mc0n4skjB1dugk7y0z3z8ZHIUCmPAWHdyDqgUz5vdA==} engines: {node: '>=22.19.0'} @@ -2631,6 +2648,10 @@ packages: engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-tui@0.74.2': + resolution: {integrity: sha512-valQPz74qbdydRqII6t9rJ46YANMOOJeDhKm25a1ZrWvWwdjAaAEu6s3ur/LWz84Wkkwcbub2ZkVjzCZi8gFGA==} + engines: {node: '>=20.0.0'} + '@earendil-works/pi-tui@0.80.3': resolution: {integrity: sha512-2BJI6qwRQfnM0Q7seL1+SbacU/jRRjBnN7Hu3n9BjAn7/s5FaBNnvdD1qBQYRsFTHfjqMaDsjYqanPyqwXj99w==} engines: {node: '>=22.19.0'} @@ -5276,6 +5297,10 @@ packages: '@pixi/colord@2.9.6': resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@pkgr/core@0.1.2': + resolution: {integrity: sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@playwright/test@1.60.0': resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} @@ -9963,10 +9988,6 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} - get-east-asian-width@1.4.0: - resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} - engines: {node: '>=18'} - get-east-asian-width@1.6.0: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} @@ -10726,6 +10747,9 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4 <7' + koffi@2.16.2: + resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==} + lan-network@0.1.7: resolution: {integrity: sha512-mnIlAEMu4OyEvUNdzco9xpuB9YVcPkQec+QsgycBCtPZvEqWPCDPfbAE4OJMdBBWpZWtpCn1xw9jJYlwjWI5zQ==} hasBin: true @@ -11122,6 +11146,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.12: + resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==} + engines: {node: '>= 18'} + hasBin: true + marked@18.0.5: resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} engines: {node: '>= 20'} @@ -11999,6 +12028,12 @@ packages: react-native: '*' react-native-svg: '*' + pi-mcp-adapter@2.10.0: + resolution: {integrity: sha512-fSCLimNbR71/VboE1q5zcfauthNDPkOBO/b59xoISF+cSiaxOwd+CzhYclVDRVb3Nwukh3XLhEPQKkzyWkgzCQ==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -12693,6 +12728,33 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + recheck-jar@4.5.0: + resolution: {integrity: sha512-Ad7oCQmY8cQLzd3QVNXjzZ+S6MbImGhR4AaW2yiGzteOfMV45522rt6nSzFyt8p3mCEaMcm/4MoZrMSxUcCbrA==} + + recheck-linux-x64@4.5.0: + resolution: {integrity: sha512-52kXsR/v+IbGIKYYFZfSZcgse/Ci9IA2HnuzrtvRRcfODkcUGe4n72ESQ8nOPwrdHFg9i4j9/YyPh1HWWgpJ6A==} + cpu: [x64] + os: [linux] + + recheck-macos-arm64@4.5.0: + resolution: {integrity: sha512-qIyK3dRuLkORQvv0b59fZZRXweSmjjWaoA4K8Kgifz0anMBH4pqsDV6plBlgjcRmW9yC12wErIRzifREaKnk2w==} + cpu: [arm64] + os: [darwin] + + recheck-macos-x64@4.5.0: + resolution: {integrity: sha512-1wp/eiLxcjC/Ex4wurlrS/LGzt8IiF4TiK5sEjldu4HVAKdNCnnmsS9a5vFpfcikDz4ZuZlLlTi1VbQTxHlwZg==} + cpu: [x64] + os: [darwin] + + recheck-windows-x64@4.5.0: + resolution: {integrity: sha512-ekBKwAp0oKkMULn5zgmHEYLwSJfkfb95AbTtbDkQazNkqYw9PRD/mVyFUR6Ff2IeRyZI0gxy+N2AKBISWydhug==} + cpu: [x64] + os: [win32] + + recheck@4.5.0: + resolution: {integrity: sha512-kPnbOV6Zfx9a25AZ++28fI1q78L/UVRQmmuazwVRPfiiqpMs+WbOU69Shx820XgfKWfak0JH75PUvZMFtRGSsw==} + engines: {node: '>=20'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -13401,6 +13463,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} + engines: {node: ^14.18.0 || >=16.0.0} + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -15887,6 +15953,26 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(@opentelemetry/api@1.9.0)(ws@8.19.0)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3)) + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.19.0)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - '@opentelemetry/api' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.80.3(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.19.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -15938,6 +16024,13 @@ snapshots: - ws - zod + '@earendil-works/pi-tui@0.74.2': + dependencies: + get-east-asian-width: 1.6.0 + marked: 15.0.12 + optionalDependencies: + koffi: 2.16.2 + '@earendil-works/pi-tui@0.80.3': dependencies: get-east-asian-width: 1.6.0 @@ -18384,6 +18477,8 @@ snapshots: '@pixi/colord@2.9.6': {} + '@pkgr/core@0.1.2': {} + '@playwright/test@1.60.0': dependencies: playwright: 1.60.0 @@ -20974,7 +21069,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@22.20.0)(typescript@5.9.3))(vite@7.3.5(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: @@ -23462,8 +23557,6 @@ snapshots: get-caller-file@2.0.5: {} - get-east-asian-width@1.4.0: {} - get-east-asian-width@1.6.0: {} get-intrinsic@1.3.0: @@ -24339,6 +24432,9 @@ snapshots: typescript: 6.0.3 zod: 4.4.3 + koffi@2.16.2: + optional: true + lan-network@0.1.7: {} lazy-val@1.0.5: {} @@ -24662,6 +24758,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.12: {} + marked@18.0.5: {} marky@1.3.0: {} @@ -26042,6 +26140,26 @@ snapshots: react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.17)(react@19.2.6) react-native-svg: 15.15.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.17)(react@19.2.6))(react@19.2.6) + pi-mcp-adapter@2.10.0(@opentelemetry/api@1.9.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(ws@8.19.0)(zod@4.4.3): + dependencies: + '@earendil-works/pi-ai': 0.74.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(@opentelemetry/api@1.9.0)(ws@8.19.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.74.2 + '@modelcontextprotocol/ext-apps': 1.2.2(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(zod@4.4.3) + '@modelcontextprotocol/sdk': 1.29.0(zod@4.4.3) + open: 10.2.0 + recheck: 4.5.0 + typebox: 1.1.38 + zod: 4.4.3 + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@opentelemetry/api' + - bufferutil + - react + - react-dom + - supports-color + - utf-8-validate + - ws + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -26949,6 +27067,31 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + recheck-jar@4.5.0: + optional: true + + recheck-linux-x64@4.5.0: + optional: true + + recheck-macos-arm64@4.5.0: + optional: true + + recheck-macos-x64@4.5.0: + optional: true + + recheck-windows-x64@4.5.0: + optional: true + + recheck@4.5.0: + dependencies: + synckit: 0.9.2 + optionalDependencies: + recheck-jar: 4.5.0 + recheck-linux-x64: 4.5.0 + recheck-macos-arm64: 4.5.0 + recheck-macos-x64: 4.5.0 + recheck-windows-x64: 4.5.0 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -27673,7 +27816,7 @@ snapshots: string-width@7.2.0: dependencies: emoji-regex: 10.6.0 - get-east-asian-width: 1.4.0 + get-east-asian-width: 1.6.0 strip-ansi: 7.1.2 string-width@8.2.1: @@ -27807,6 +27950,11 @@ snapshots: symbol-tree@3.2.4: {} + synckit@0.9.2: + dependencies: + '@pkgr/core': 0.1.2 + tslib: 2.8.1 + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -28629,36 +28777,6 @@ snapshots: transitivePeerDependencies: - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.12.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)): - dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(msw@2.12.8(@types/node@24.12.0)(typescript@5.9.3))(rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 - es-module-lexer: 2.0.0 - expect-type: 1.3.0 - magic-string: 0.30.21 - obug: 2.1.1 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 4.1.0 - tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.1.0 - vite: rolldown-vite@7.3.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@types/node@24.12.0)(esbuild@0.27.2)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) - why-is-node-running: 2.3.0 - optionalDependencies: - '@opentelemetry/api': 1.9.1 - '@types/node': 24.12.0 - '@vitest/ui': 4.1.8(vitest@4.1.8) - jsdom: 26.1.0 - transitivePeerDependencies: - - msw - vitest@4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.2.0)(@vitest/ui@4.1.8)(jsdom@26.1.0)(msw@2.12.8(@types/node@25.2.0)(typescript@5.9.3))(vite@7.3.5(@types/node@25.2.0)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 From a3440c18c976481673fc73932a4093b188864142 Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Mon, 6 Jul 2026 18:25:53 +0200 Subject: [PATCH 3/4] more extensions --- packages/harness/README.md | 1 + packages/harness/package.json | 17 + .../extensions/hog-branding/extension.test.ts | 200 +++++++ .../src/extensions/hog-branding/extension.ts | 125 +++++ .../src/extensions/hog-branding/index.ts | 9 + packages/harness/src/extensions/registry.ts | 8 +- .../src/extensions/subagent/agents.test.ts | 31 ++ .../harness/src/extensions/subagent/agents.ts | 112 ++++ .../src/extensions/subagent/auth.test.ts | 163 ++++++ .../harness/src/extensions/subagent/auth.ts | 154 ++++++ .../subagent/background-runner.test.ts | 116 ++++ .../extensions/subagent/background-runner.ts | 103 ++++ .../src/extensions/subagent/chain.test.ts | 142 +++++ .../harness/src/extensions/subagent/chain.ts | 83 +++ .../src/extensions/subagent/context.test.ts | 138 +++++ .../src/extensions/subagent/context.ts | 96 ++++ .../src/extensions/subagent/discovery.test.ts | 178 +++++++ .../src/extensions/subagent/discovery.ts | 175 ++++++ .../src/extensions/subagent/extension.test.ts | 393 ++++++++++++++ .../src/extensions/subagent/extension.ts | 497 ++++++++++++++++++ .../src/extensions/subagent/format.test.ts | 154 ++++++ .../harness/src/extensions/subagent/format.ts | 134 +++++ .../harness/src/extensions/subagent/index.ts | 9 + .../src/extensions/subagent/lifecycle.test.ts | 141 +++++ .../src/extensions/subagent/lifecycle.ts | 201 +++++++ .../src/extensions/subagent/policy.test.ts | 77 +++ .../harness/src/extensions/subagent/policy.ts | 61 +++ .../subagent/process/child-process.test.ts | 81 +++ .../subagent/process/child-process.ts | 87 +++ .../extensions/subagent/process/pool.test.ts | 158 ++++++ .../src/extensions/subagent/process/pool.ts | 70 +++ .../subagent/prompts/implement-and-review.md | 10 + .../subagent/prompts/parallel-review.md | 13 + .../subagent/prompts/scout-and-plan.md | 10 + .../src/extensions/subagent/render.test.ts | 209 ++++++++ .../harness/src/extensions/subagent/render.ts | 234 +++++++++ .../src/extensions/subagent/rpc.test.ts | 233 ++++++++ .../harness/src/extensions/subagent/rpc.ts | 166 ++++++ .../src/extensions/subagent/run-agent.test.ts | 165 ++++++ .../src/extensions/subagent/run-agent.ts | 349 ++++++++++++ .../src/extensions/subagent/settings.test.ts | 155 ++++++ .../src/extensions/subagent/settings.ts | 137 +++++ .../skills/subagent-orchestration/SKILL.md | 92 ++++ .../extensions/subagent/supervisor.test.ts | 189 +++++++ .../src/extensions/subagent/supervisor.ts | 255 +++++++++ .../src/extensions/web-access/render.ts | 44 ++ .../src/extensions/web-access/web-fetch.ts | 9 + .../src/extensions/web-access/web-search.ts | 4 + packages/harness/src/pi-cli.test.ts | 53 ++ packages/harness/src/pi-cli.ts | 51 ++ packages/harness/src/spawn.ts | 16 +- packages/harness/tsup.config.ts | 42 ++ pnpm-lock.yaml | 3 + 53 files changed, 6344 insertions(+), 9 deletions(-) create mode 100644 packages/harness/src/extensions/hog-branding/extension.test.ts create mode 100644 packages/harness/src/extensions/hog-branding/extension.ts create mode 100644 packages/harness/src/extensions/hog-branding/index.ts create mode 100644 packages/harness/src/extensions/subagent/agents.test.ts create mode 100644 packages/harness/src/extensions/subagent/agents.ts create mode 100644 packages/harness/src/extensions/subagent/auth.test.ts create mode 100644 packages/harness/src/extensions/subagent/auth.ts create mode 100644 packages/harness/src/extensions/subagent/background-runner.test.ts create mode 100644 packages/harness/src/extensions/subagent/background-runner.ts create mode 100644 packages/harness/src/extensions/subagent/chain.test.ts create mode 100644 packages/harness/src/extensions/subagent/chain.ts create mode 100644 packages/harness/src/extensions/subagent/context.test.ts create mode 100644 packages/harness/src/extensions/subagent/context.ts create mode 100644 packages/harness/src/extensions/subagent/discovery.test.ts create mode 100644 packages/harness/src/extensions/subagent/discovery.ts create mode 100644 packages/harness/src/extensions/subagent/extension.test.ts create mode 100644 packages/harness/src/extensions/subagent/extension.ts create mode 100644 packages/harness/src/extensions/subagent/format.test.ts create mode 100644 packages/harness/src/extensions/subagent/format.ts create mode 100644 packages/harness/src/extensions/subagent/index.ts create mode 100644 packages/harness/src/extensions/subagent/lifecycle.test.ts create mode 100644 packages/harness/src/extensions/subagent/lifecycle.ts create mode 100644 packages/harness/src/extensions/subagent/policy.test.ts create mode 100644 packages/harness/src/extensions/subagent/policy.ts create mode 100644 packages/harness/src/extensions/subagent/process/child-process.test.ts create mode 100644 packages/harness/src/extensions/subagent/process/child-process.ts create mode 100644 packages/harness/src/extensions/subagent/process/pool.test.ts create mode 100644 packages/harness/src/extensions/subagent/process/pool.ts create mode 100644 packages/harness/src/extensions/subagent/prompts/implement-and-review.md create mode 100644 packages/harness/src/extensions/subagent/prompts/parallel-review.md create mode 100644 packages/harness/src/extensions/subagent/prompts/scout-and-plan.md create mode 100644 packages/harness/src/extensions/subagent/render.test.ts create mode 100644 packages/harness/src/extensions/subagent/render.ts create mode 100644 packages/harness/src/extensions/subagent/rpc.test.ts create mode 100644 packages/harness/src/extensions/subagent/rpc.ts create mode 100644 packages/harness/src/extensions/subagent/run-agent.test.ts create mode 100644 packages/harness/src/extensions/subagent/run-agent.ts create mode 100644 packages/harness/src/extensions/subagent/settings.test.ts create mode 100644 packages/harness/src/extensions/subagent/settings.ts create mode 100644 packages/harness/src/extensions/subagent/skills/subagent-orchestration/SKILL.md create mode 100644 packages/harness/src/extensions/subagent/supervisor.test.ts create mode 100644 packages/harness/src/extensions/subagent/supervisor.ts create mode 100644 packages/harness/src/extensions/web-access/render.ts create mode 100644 packages/harness/src/pi-cli.test.ts create mode 100644 packages/harness/src/pi-cli.ts diff --git a/packages/harness/README.md b/packages/harness/README.md index ed21bfa8de..d28c5d6744 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -106,6 +106,7 @@ token) for headless use. | `@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()` | diff --git a/packages/harness/package.json b/packages/harness/package.json index f530a306b1..5d16a0c4f9 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -23,6 +23,14 @@ "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" @@ -38,6 +46,14 @@ "./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": { @@ -50,6 +66,7 @@ "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", diff --git a/packages/harness/src/extensions/hog-branding/extension.test.ts b/packages/harness/src/extensions/hog-branding/extension.test.ts new file mode 100644 index 0000000000..a892afe6e4 --- /dev/null +++ b/packages/harness/src/extensions/hog-branding/extension.test.ts @@ -0,0 +1,200 @@ +import type { + ExtensionAPI, + ExtensionContext, + SessionInfoChangedEvent, + SessionStartEvent, + Theme, +} from "@earendil-works/pi-coding-agent"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { createHogBrandingExtension } from "./extension"; + +// `keyHint`/`keyText`/`rawKeyHint` read the app's active keybindings and +// current theme through internal singletons initialized by the TUI runtime +// before `session_start` fires. Initialize it here so the header factory can +// be exercised outside of a running TUI session. +initTheme(); + +function fakeTheme(): Theme { + return { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, + } as unknown as Theme; +} + +type Handler = (event: E, ctx: ExtensionContext) => void | Promise; + +function fakePi() { + const handlers = { + session_start: [] as Array>, + session_info_changed: [] as Array>, + }; + const pi = { + on: vi.fn((eventName: string, handler: never) => { + if (eventName === "session_start") { + handlers.session_start.push(handler as never); + } + if (eventName === "session_info_changed") { + handlers.session_info_changed.push(handler as never); + } + }), + } as unknown as ExtensionAPI; + return { pi, handlers }; +} + +function fakeCtx(options: { + mode?: "tui" | "rpc" | "json" | "print"; + cwd?: string; + sessionName?: string; + setHeader?: (factory: unknown) => void; + setTitle?: (title: string) => void; +}): ExtensionContext { + return { + mode: options.mode ?? "tui", + ui: { + setHeader: options.setHeader ?? vi.fn(), + setTitle: options.setTitle ?? vi.fn(), + }, + sessionManager: { + getCwd: () => options.cwd ?? "/home/user/my-project", + getSessionName: () => options.sessionName, + }, + } as unknown as ExtensionContext; +} + +describe("createHogBrandingExtension", () => { + it("registers a session_start handler that sets a custom header in TUI mode", async () => { + const { pi, handlers } = fakePi(); + const setHeader = vi.fn(); + + const extension = createHogBrandingExtension({ version: "9.9.9" }); + await extension(pi); + + expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function)); + + const ctx = fakeCtx({ mode: "tui", setHeader }); + await handlers.session_start[0]?.({} as SessionStartEvent, ctx); + + expect(setHeader).toHaveBeenCalledTimes(1); + const factory = setHeader.mock.calls[0]?.[0]; + const component = factory(undefined, fakeTheme()); + const lines = component.render(80); + + expect(lines[0]).toContain("Hog"); + expect(lines[0]).toContain("A Pi distribution by PostHog"); + expect(lines[0]).toContain("v9.9.9"); + }); + + it("does not set a header outside TUI mode", async () => { + const { pi, handlers } = fakePi(); + const setHeader = vi.fn(); + + const extension = createHogBrandingExtension(); + await extension(pi); + + const ctx = fakeCtx({ mode: "print", setHeader }); + await handlers.session_start[0]?.({} as SessionStartEvent, ctx); + + expect(setHeader).not.toHaveBeenCalled(); + }); + + it("toggles between compact and expanded instructions via setExpanded", async () => { + const { pi, handlers } = fakePi(); + const setHeader = vi.fn(); + + const extension = createHogBrandingExtension(); + await extension(pi); + + const ctx = fakeCtx({ mode: "tui", setHeader }); + await handlers.session_start[0]?.({} as SessionStartEvent, ctx); + + const factory = setHeader.mock.calls[0]?.[0]; + const component = factory(undefined, fakeTheme()); + + const compactLines = component.render(80); + component.setExpanded(true); + const expandedLines = component.render(80); + + expect(expandedLines.length).not.toEqual(compactLines.length); + }); + + it("sets a Hog-branded terminal title on session_start, with and without a session name", async () => { + const { pi, handlers } = fakePi(); + + const extension = createHogBrandingExtension(); + await extension(pi); + + const setTitleNoName = vi.fn(); + await handlers.session_start[0]?.( + {} as SessionStartEvent, + fakeCtx({ cwd: "/home/user/my-project", setTitle: setTitleNoName }), + ); + expect(setTitleNoName).toHaveBeenCalledWith("hog - my-project"); + + const setTitleWithName = vi.fn(); + await handlers.session_start[0]?.( + {} as SessionStartEvent, + fakeCtx({ + cwd: "/home/user/my-project", + sessionName: "fix-bug", + setTitle: setTitleWithName, + }), + ); + expect(setTitleWithName).toHaveBeenCalledWith("hog - fix-bug - my-project"); + }); + + it("updates the terminal title on session_info_changed", async () => { + const { pi, handlers } = fakePi(); + + const extension = createHogBrandingExtension(); + await extension(pi); + + expect(pi.on).toHaveBeenCalledWith( + "session_info_changed", + expect.any(Function), + ); + + const setTitle = vi.fn(); + await handlers.session_info_changed[0]?.( + { + type: "session_info_changed", + name: "renamed", + } as SessionInfoChangedEvent, + fakeCtx({ + cwd: "/home/user/my-project", + sessionName: "renamed", + setTitle, + }), + ); + + expect(setTitle).toHaveBeenCalledWith("hog - renamed - my-project"); + }); + + it("re-applies the branded title on the next tick to win the race against pi's own updateTerminalTitle()", async () => { + vi.useFakeTimers(); + try { + const { pi, handlers } = fakePi(); + const extension = createHogBrandingExtension(); + await extension(pi); + + const setTitle = vi.fn(); + const ctx = fakeCtx({ cwd: "/home/user/my-project", setTitle }); + await handlers.session_start[0]?.({} as SessionStartEvent, ctx); + + // Set immediately... + expect(setTitle).toHaveBeenCalledTimes(1); + expect(setTitle).toHaveBeenLastCalledWith("hog - my-project"); + + // Simulate pi's own interactive-mode stomping the title synchronously + // right after our handler resolves, as it does in practice. + setTitle("\u03c0 - my-project"); + expect(setTitle).toHaveBeenLastCalledWith("\u03c0 - my-project"); + + // ...and re-applied on the next tick, winning the race. + vi.runAllTimers(); + expect(setTitle).toHaveBeenLastCalledWith("hog - my-project"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/harness/src/extensions/hog-branding/extension.ts b/packages/harness/src/extensions/hog-branding/extension.ts new file mode 100644 index 0000000000..b7f4f10b06 --- /dev/null +++ b/packages/harness/src/extensions/hog-branding/extension.ts @@ -0,0 +1,125 @@ +import * as path from "node:path"; +import type { + ExtensionAPI, + ExtensionContext, + ExtensionFactory, + Theme, +} from "@earendil-works/pi-coding-agent"; +import { + keyHint, + keyText, + rawKeyHint, + VERSION, +} from "@earendil-works/pi-coding-agent"; +import type { Component } from "@earendil-works/pi-tui"; + +export interface HogBrandingOptions { + /** Override the version string shown in the header (defaults to the pi package version). */ + version?: string; +} + +const BRAND_NAME = "hog"; +const BRAND_TAGLINE = "A Pi distribution by PostHog"; + +type ExpandableHeaderComponent = Component & { + setExpanded(expanded: boolean): void; + dispose?(): void; +}; + +function brandLine(theme: Theme, version: string): string { + const brand = theme.bold(theme.fg("accent", BRAND_NAME)); + const tagline = theme.fg("dim", ` (${BRAND_TAGLINE})`); + const versionText = theme.fg("dim", ` v${version}`); + return `${brand}${tagline}${versionText}`; +} + +function createHeaderComponent( + theme: Theme, + version: string, +): ExpandableHeaderComponent { + let expanded = false; + const logo = brandLine(theme, version); + + const compactInstructions = [ + keyHint("app.interrupt", "to interrupt"), + rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), + rawKeyHint("/", "for commands"), + rawKeyHint("!", "to run bash"), + keyHint("app.tools.expand", "more"), + ].join(theme.fg("muted", " · ")); + + const expandedInstructions = [ + keyHint("app.interrupt", "to interrupt"), + keyHint("app.clear", "to clear"), + rawKeyHint(`${keyText("app.clear")} twice`, "to exit"), + keyHint("app.exit", "to exit (empty)"), + keyHint("app.suspend", "to suspend"), + keyHint("app.thinking.cycle", "to cycle thinking level"), + rawKeyHint( + `${keyText("app.model.cycleForward")}/${keyText("app.model.cycleBackward")}`, + "to cycle models", + ), + keyHint("app.model.select", "to select model"), + keyHint("app.tools.expand", "to collapse"), + rawKeyHint("/", "for commands"), + rawKeyHint("!", "to run bash"), + rawKeyHint("!!", "to run bash (no context)"), + ].join("\n"); + + const compactHint = theme.fg( + "dim", + `Press ${keyText("app.tools.expand")} to show full startup help.`, + ); + + return { + render(_width: number): string[] { + return expanded + ? [logo, expandedInstructions] + : [logo, compactInstructions, compactHint]; + }, + invalidate(): void {}, + setExpanded(next: boolean): void { + expanded = next; + }, + }; +} + +function terminalTitle(ctx: ExtensionContext): string { + const cwdBasename = path.basename(ctx.sessionManager.getCwd()); + const sessionName = ctx.sessionManager.getSessionName(); + return sessionName + ? `${BRAND_NAME} - ${sessionName} - ${cwdBasename}` + : `${BRAND_NAME} - ${cwdBasename}`; +} + +// pi's own interactive mode calls its internal `updateTerminalTitle()` +// (which uses the built-in `π`/`APP_TITLE` prefix) synchronously right +// after `session_start`/`session_info_changed` extension handlers resolve, +// so setting the title inline here loses the race and gets stomped on the +// same tick. Deferring to the next tick lets our title win. +function setBrandedTitle(ctx: ExtensionContext): void { + const title = terminalTitle(ctx); + ctx.ui.setTitle(title); + setTimeout(() => ctx.ui.setTitle(title), 0); +} + +export function createHogBrandingExtension( + options: HogBrandingOptions = {}, +): ExtensionFactory { + return async (pi: ExtensionAPI) => { + pi.on("session_start", async (_event, ctx) => { + setBrandedTitle(ctx); + if (ctx.mode !== "tui") return; + const version = options.version ?? VERSION; + ctx.ui.setHeader((_tui, theme) => createHeaderComponent(theme, version)); + }); + + pi.on("session_info_changed", async (_event, ctx) => { + setBrandedTitle(ctx); + }); + }; +} + +export default function hogBranding(pi: ExtensionAPI): void | Promise { + return createHogBrandingExtension()(pi); +} diff --git a/packages/harness/src/extensions/hog-branding/index.ts b/packages/harness/src/extensions/hog-branding/index.ts new file mode 100644 index 0000000000..909b3b8d73 --- /dev/null +++ b/packages/harness/src/extensions/hog-branding/index.ts @@ -0,0 +1,9 @@ +// Thin `index.ts` re-export used only as pi's `-e` extension entry point. +// +// pi's startup banner derives an extension's display name from its file +// path: a trailing `index.ts`/`index.js` segment is dropped in favor of the +// parent directory name, so loading this file (instead of `./extension.ts` +// directly) makes the extension show as `hog-branding` instead of +// `hog-branding/extension.js`. `./extension.ts` remains the real +// implementation per the convention in `../README.md`. +export { default } from "./extension"; diff --git a/packages/harness/src/extensions/registry.ts b/packages/harness/src/extensions/registry.ts index 3c237afb94..35d368f093 100644 --- a/packages/harness/src/extensions/registry.ts +++ b/packages/harness/src/extensions/registry.ts @@ -1,9 +1,13 @@ import type { ExtensionFactory } from "@earendil-works/pi-coding-agent"; +import type { HogBrandingOptions } from "./hog-branding/extension"; +import { createHogBrandingExtension } from "./hog-branding/extension"; import { createPosthogProviderExtension } from "./posthog-provider/extension"; import type { PosthogProviderOptions } from "./posthog-provider/provider"; +import { createSubagentExtension } from "./subagent/extension"; import { createWebAccessExtension } from "./web-access/extension"; -export type HarnessExtensionOptions = PosthogProviderOptions; +export type HarnessExtensionOptions = PosthogProviderOptions & + HogBrandingOptions; interface HarnessExtension { name: string; @@ -17,8 +21,10 @@ interface HarnessExtension { // `mcpAdapterExtensionFile()` in `spawn.ts` and its use in `cli.ts` and // `session.ts` for how it is wired in as a file path instead of a factory. const EXTENSIONS: HarnessExtension[] = [ + { name: "hog-branding", create: createHogBrandingExtension }, { name: "posthog-provider", create: createPosthogProviderExtension }, { name: "web-access", create: createWebAccessExtension }, + { name: "subagent", create: createSubagentExtension }, ]; export const HARNESS_EXTENSION_NAMES: readonly string[] = EXTENSIONS.map( diff --git a/packages/harness/src/extensions/subagent/agents.test.ts b/packages/harness/src/extensions/subagent/agents.test.ts new file mode 100644 index 0000000000..0b23628dd9 --- /dev/null +++ b/packages/harness/src/extensions/subagent/agents.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + BUNDLED_AGENTS, + findBundledAgent, + listBundledAgentNames, +} from "./agents"; + +describe("agents", () => { + it("ships exactly the five expected bundled agents", () => { + expect(listBundledAgentNames()).toEqual([ + "scout", + "planner", + "reviewer", + "worker", + "oracle", + ]); + }); + + it.each(BUNDLED_AGENTS.map((agent) => [agent.name, agent] as const))( + "%s has a non-empty description and system prompt", + (_name, agent) => { + expect(agent.description.length).toBeGreaterThan(0); + expect(agent.systemPrompt.trim().length).toBeGreaterThan(0); + }, + ); + + it("findBundledAgent resolves a known agent and returns undefined for an unknown one", () => { + expect(findBundledAgent("scout")?.name).toBe("scout"); + expect(findBundledAgent("does-not-exist")).toBeUndefined(); + }); +}); diff --git a/packages/harness/src/extensions/subagent/agents.ts b/packages/harness/src/extensions/subagent/agents.ts new file mode 100644 index 0000000000..a4ae57ae14 --- /dev/null +++ b/packages/harness/src/extensions/subagent/agents.ts @@ -0,0 +1,112 @@ +/** + * Bundled subagent definitions. Pure data — no filesystem or network I/O. + * Project-local `.pi/agents/*.md` overrides are loaded by `discovery.ts`, + * which merges them with `BUNDLED_AGENTS`. + */ + +export type AgentSource = "bundled" | "project"; + +export interface AgentConfig { + name: string; + description: string; + tools?: string[]; + model?: string; + systemPrompt: string; + source: AgentSource; +} + +const scout: AgentConfig = { + name: "scout", + description: + "Fast, read-only codebase reconnaissance. Finds relevant files, entry points, and data flow, and reports back compressed context.", + tools: ["read", "grep", "find", "ls", "bash"], + source: "bundled", + systemPrompt: `You are "scout", a fast reconnaissance subagent. + +Your job is to explore a codebase and report back a compressed, structured summary — not to make changes. + +- Find the files, functions, and data flow relevant to the task. +- Prefer grep/find/ls over reading whole directories; read only the files that matter. +- Do not edit anything. You have no write access. +- Report file paths (with line numbers where useful), a short description of what each does, and any risks or open questions. +- Keep your final answer dense: bullet points over prose.`, +}; + +const planner: AgentConfig = { + name: "planner", + description: + "Turns existing context into a concrete, ordered implementation plan. Does not write code.", + tools: ["read", "grep", "find", "ls"], + source: "bundled", + systemPrompt: `You are "planner", a planning subagent. + +Your job is to produce a concrete, ordered implementation plan from the context and task you are given. + +- Break the task into small, sequential, independently verifiable steps. +- Call out files that need to change and what changes in each. +- Flag ambiguous requirements or decisions that need the orchestrator's input instead of guessing. +- Do not write or edit code. Output only the plan.`, +}; + +const reviewer: AgentConfig = { + name: "reviewer", + description: + "Reviews a diff or change set for correctness, tests, and cleanup, and can apply small fixes.", + tools: ["read", "grep", "find", "ls", "bash"], + source: "bundled", + systemPrompt: `You are "reviewer", a code review subagent. + +Your job is to review the change described in your task for correctness, missing tests, and cleanup opportunities. + +- Read the relevant diff/files before commenting. +- Call out concrete issues with file:line references, not vague feedback. +- Distinguish must-fix issues from nice-to-haves. +- If asked to fix, make the smallest change that addresses the issue. +- End with a clear verdict: approve, approve with nits, or changes requested.`, +}; + +const worker: AgentConfig = { + name: "worker", + description: + "General-purpose implementation subagent with full tool access. Escalates unapproved decisions.", + source: "bundled", + systemPrompt: `You are "worker", a general-purpose implementation subagent. + +Your job is to carry out the task you are given directly, using the tools available to you. + +- Follow any plan or context you are given; do not re-derive it from scratch. +- Make the change, run any relevant checks, and report what you did. +- If you hit a decision the task doesn't cover, state your assumption clearly in the final answer rather than silently guessing on something risky. +- You are a subagent, not the orchestrator: do not try to delegate to other subagents yourself.`, +}; + +const oracle: AgentConfig = { + name: "oracle", + description: + "Second opinion. Challenges assumptions and reasons about a plan or bug without editing anything.", + tools: ["read", "grep", "find", "ls"], + source: "bundled", + systemPrompt: `You are "oracle", a second-opinion subagent. + +Your job is to critically evaluate the plan, diff, or problem you are given — not to implement anything. + +- Challenge assumptions. Look for edge cases, race conditions, and simpler alternatives. +- Be direct about disagreement; do not just validate what you were given. +- You have no write access. Output analysis and a recommendation only.`, +}; + +export const BUNDLED_AGENTS: readonly AgentConfig[] = [ + scout, + planner, + reviewer, + worker, + oracle, +]; + +export function findBundledAgent(name: string): AgentConfig | undefined { + return BUNDLED_AGENTS.find((agent) => agent.name === name); +} + +export function listBundledAgentNames(): string[] { + return BUNDLED_AGENTS.map((agent) => agent.name); +} diff --git a/packages/harness/src/extensions/subagent/auth.test.ts b/packages/harness/src/extensions/subagent/auth.test.ts new file mode 100644 index 0000000000..983118ea80 --- /dev/null +++ b/packages/harness/src/extensions/subagent/auth.test.ts @@ -0,0 +1,163 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it, vi } from "vitest"; +import { + resolveModelAuth, + resolveModelAuthWithFallback, + SubagentAuthError, + writeAuthBridgeExtension, +} from "./auth"; + +function makeModel(overrides: Partial> = {}): Model { + return { + id: "sonnet", + name: "Sonnet", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + ...overrides, + } as Model; +} + +function makeCtx(options: { + model?: Model; + allModels?: Model[]; + apiKeyResult?: { + ok: boolean; + apiKey?: string; + headers?: Record; + }; +}): ExtensionContext { + const apiKeyResult = options.apiKeyResult ?? { ok: true, apiKey: "test-key" }; + return { + model: options.model, + modelRegistry: { + find: vi.fn((provider: string, id: string) => + (options.allModels ?? []).find( + (m) => m.provider === provider && m.id === id, + ), + ), + getAll: vi.fn(() => options.allModels ?? []), + getApiKeyAndHeaders: vi.fn(async () => apiKeyResult), + }, + } as unknown as ExtensionContext; +} + +describe("resolveModelAuth", () => { + it("uses the parent's current model when request.model is unset", async () => { + const model = makeModel(); + const ctx = makeCtx({ model }); + const result = await resolveModelAuth(ctx, { requestedBy: "worker" }); + expect(result.model).toBe(model); + expect(result.apiKey).toBe("test-key"); + }); + + it("resolves request.model as 'provider/id' via modelRegistry.find", async () => { + const wanted = makeModel({ provider: "openai", id: "gpt-5" }); + const ctx = makeCtx({ model: makeModel(), allModels: [wanted] }); + const result = await resolveModelAuth(ctx, { + requestedBy: "worker", + model: "openai/gpt-5", + }); + expect(result.model).toBe(wanted); + }); + + it("resolves a bare request.model id against the parent's current provider", async () => { + const current = makeModel({ provider: "anthropic", id: "haiku" }); + const wanted = makeModel({ provider: "anthropic", id: "opus" }); + const ctx = makeCtx({ model: current, allModels: [wanted] }); + const result = await resolveModelAuth(ctx, { + requestedBy: "worker", + model: "opus", + }); + expect(result.model).toBe(wanted); + }); + + it("throws SubagentAuthError for an unknown request.model", async () => { + const ctx = makeCtx({ model: makeModel() }); + await expect( + resolveModelAuth(ctx, { requestedBy: "worker", model: "nope/nope" }), + ).rejects.toThrow(SubagentAuthError); + }); + + it("throws SubagentAuthError when there is no active model at all", async () => { + const ctx = makeCtx({ model: undefined }); + await expect( + resolveModelAuth(ctx, { requestedBy: "worker" }), + ).rejects.toThrow(SubagentAuthError); + }); + + it("throws SubagentAuthError when credentials can't be resolved", async () => { + const ctx = makeCtx({ model: makeModel(), apiKeyResult: { ok: false } }); + await expect( + resolveModelAuth(ctx, { requestedBy: "worker" }), + ).rejects.toThrow(SubagentAuthError); + }); +}); + +describe("resolveModelAuthWithFallback", () => { + it("falls back to the next model when the primary has no credentials", async () => { + const primary = makeModel({ provider: "anthropic", id: "opus" }); + const fallback = makeModel({ provider: "anthropic", id: "haiku" }); + const ctx = makeCtx({ model: primary, allModels: [primary, fallback] }); + ctx.modelRegistry.getApiKeyAndHeaders = vi.fn(async (model: Model) => + model.id === "opus" + ? { ok: false as const, error: "no auth" } + : { ok: true as const, apiKey: "fallback-key" }, + ); + + const result = await resolveModelAuthWithFallback( + ctx, + "worker", + "anthropic/opus", + ["anthropic/haiku"], + ); + expect(result.model.id).toBe("haiku"); + expect(result.apiKey).toBe("fallback-key"); + }); + + it("throws the last error when nothing in the fallback chain works", async () => { + const ctx = makeCtx({ model: makeModel(), apiKeyResult: { ok: false } }); + await expect( + resolveModelAuthWithFallback(ctx, "worker", undefined, []), + ).rejects.toThrow(SubagentAuthError); + }); +}); + +describe("writeAuthBridgeExtension", () => { + it("writes a module that registers the resolved provider/model/apiKey", async () => { + const model = makeModel({ provider: "posthog", id: "claude-sonnet" }); + const { dir, filePath } = await writeAuthBridgeExtension({ + model, + apiKey: "secret-key", + }); + try { + const mod = (await import(filePath)) as { + default: (pi: { + registerProvider: (...args: unknown[]) => void; + }) => void; + }; + const registerProvider = vi.fn(); + mod.default({ registerProvider }); + + expect(registerProvider).toHaveBeenCalledTimes(1); + const [providerName, config] = registerProvider.mock.calls[0] as [ + string, + Record, + ]; + expect(providerName).toBe("posthog"); + expect(config.apiKey).toBe("secret-key"); + expect((config.models as Array<{ id: string }>)[0].id).toBe( + "claude-sonnet", + ); + } finally { + const fs = await import("node:fs/promises"); + await fs.rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/harness/src/extensions/subagent/auth.ts b/packages/harness/src/extensions/subagent/auth.ts new file mode 100644 index 0000000000..fc0b591200 --- /dev/null +++ b/packages/harness/src/extensions/subagent/auth.ts @@ -0,0 +1,154 @@ +/** + * Resolves which model/credentials a child subagent process should use, and + * generates the tiny static-provider extension that hands those credentials + * to the child without it ever re-running OAuth/gateway resolution itself. + * + * No dependency on any specific provider extension (e.g. `posthog-provider`): + * this reads whatever the parent session already has configured, for + * whichever provider that happens to be. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; + +export interface ResolvedModelAuth { + model: Model; + apiKey: string; + headers?: Record; +} + +export class SubagentAuthError extends Error {} + +export interface ModelRequest { + /** Name used only for error messages (e.g. the requesting agent's name). */ + requestedBy: string; + /** "provider/id", a bare model id, or `undefined` to use the parent's current model. */ + model?: string; +} + +/** + * Picks the model the child should use — `request.model` ("provider/id") + * when set, otherwise the parent's current model — and resolves its + * credentials via `ctx.modelRegistry`. Never performs a login/refresh + * network call itself; `ctx.modelRegistry` already owns that for the parent + * session. + */ +export async function resolveModelAuth( + ctx: ExtensionContext, + request: ModelRequest, +): Promise { + let model: Model | undefined = ctx.model; + + if (request.model) { + const slash = request.model.indexOf("/"); + model = + slash > 0 + ? ctx.modelRegistry.find( + request.model.slice(0, slash), + request.model.slice(slash + 1), + ) + : ctx.modelRegistry + .getAll() + .find( + (candidate) => + candidate.id === request.model && + (!ctx.model || candidate.provider === ctx.model.provider), + ); + + if (!model) { + throw new SubagentAuthError( + `Unknown model "${request.model}" requested by "${request.requestedBy}".`, + ); + } + } + + if (!model) { + throw new SubagentAuthError( + "No active model to delegate to. Select a model first (/model).", + ); + } + + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok || !auth.apiKey) { + throw new SubagentAuthError( + `No credentials available for model "${model.provider}/${model.id}".`, + ); + } + + return { model, apiKey: auth.apiKey, headers: auth.headers }; +} + +/** + * Tries `primary`, then each of `fallbacks` in order, resolving to the first + * model with usable credentials. Throws the last error if none work. + */ +export async function resolveModelAuthWithFallback( + ctx: ExtensionContext, + requestedBy: string, + primary: string | undefined, + fallbacks: string[] = [], +): Promise { + const candidates = [primary, ...fallbacks]; + let lastError: unknown; + for (const model of candidates) { + try { + return await resolveModelAuth(ctx, { requestedBy, model }); + } catch (error) { + lastError = error; + } + } + throw lastError instanceof Error + ? lastError + : new SubagentAuthError("No model could be resolved."); +} + +/** + * Writes a generated extension file that statically registers the resolved + * provider/model/API key, so the child process never has to resolve auth on + * its own. Caller owns cleanup of the returned directory. + */ +export async function writeAuthBridgeExtension( + auth: ResolvedModelAuth, +): Promise<{ dir: string; filePath: string }> { + const tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "posthog-subagent-auth-"), + ); + const filePath = path.join(tmpDir, "auth-bridge.mjs"); + const { model, apiKey, headers } = auth; + + const providerConfig = { + baseUrl: model.baseUrl, + apiKey, + api: model.api, + headers, + models: [ + { + id: model.id, + name: model.name, + api: model.api, + baseUrl: model.baseUrl, + reasoning: model.reasoning, + thinkingLevelMap: model.thinkingLevelMap, + input: model.input, + cost: model.cost, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + headers: model.headers, + compat: model.compat, + }, + ], + }; + + const source = `export default function (pi) {\n pi.registerProvider(${JSON.stringify(model.provider)}, ${JSON.stringify(providerConfig, null, 2)});\n}\n`; + + await withFileMutationQueue(filePath, async () => { + await fs.promises.writeFile(filePath, source, { + encoding: "utf-8", + mode: 0o600, + }); + }); + return { dir: tmpDir, filePath }; +} diff --git a/packages/harness/src/extensions/subagent/background-runner.test.ts b/packages/harness/src/extensions/subagent/background-runner.test.ts new file mode 100644 index 0000000000..9f05af5f97 --- /dev/null +++ b/packages/harness/src/extensions/subagent/background-runner.test.ts @@ -0,0 +1,116 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { BackgroundRunRegistry } from "./background-runner"; +import { readStatus } from "./lifecycle"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("BackgroundRunRegistry", () => { + let originalHome: string | undefined; + let tmpHome: string; + + beforeEach(() => { + originalHome = process.env.HOME; + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "posthog-subagent-bg-")); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("tracks a run as isRunning until it settles, then marks lifecycle 'completed'", async () => { + const registry = new BackgroundRunRegistry(); + const gate = deferred(); + + const handle = registry.start( + { mode: "single", agents: ["scout"] }, + async () => { + await gate.promise; + return { model: "anthropic/opus", totalTokens: 10, totalCost: 0.01 }; + }, + ); + + expect(handle.isRunning()).toBe(true); + expect(registry.get(handle.runId)).toBe(handle); + + gate.resolve(); + await handle.done; + + expect(handle.isRunning()).toBe(false); + const status = readStatus(handle.runId); + expect(status?.state).toBe("completed"); + expect(status?.model).toBe("anthropic/opus"); + expect(status?.totalTokens).toBe(10); + }); + + it("marks lifecycle 'failed' with the error message when fn rejects", async () => { + const registry = new BackgroundRunRegistry(); + const handle = registry.start( + { mode: "single", agents: ["scout"] }, + async () => { + throw new Error("boom"); + }, + ); + + await handle.done; + const status = readStatus(handle.runId); + expect(status?.state).toBe("failed"); + expect(status?.error).toBe("boom"); + }); + + it("interrupt() aborts the run's signal and marks lifecycle 'aborted'", async () => { + const registry = new BackgroundRunRegistry(); + const started = deferred(); + + const handle = registry.start( + { mode: "single", agents: ["scout"] }, + async (signal) => { + started.resolve(); + await new Promise((resolve) => { + if (signal.aborted) return resolve(); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + return {}; + }, + ); + + await started.promise; + expect(registry.interrupt(handle.runId)).toBe(true); + await handle.done; + + expect(readStatus(handle.runId)?.state).toBe("aborted"); + }); + + it("interrupt() returns false for an unknown runId", () => { + const registry = new BackgroundRunRegistry(); + expect(registry.interrupt("not-a-real-run")).toBe(false); + }); + + it("list() reflects all runs started on this registry", () => { + const registry = new BackgroundRunRegistry(); + const a = registry.start( + { mode: "single", agents: ["scout"] }, + async () => ({}), + ); + const b = registry.start( + { mode: "single", agents: ["worker"] }, + async () => ({}), + ); + expect( + registry + .list() + .map((r) => r.runId) + .sort(), + ).toEqual([a.runId, b.runId].sort()); + }); +}); diff --git a/packages/harness/src/extensions/subagent/background-runner.ts b/packages/harness/src/extensions/subagent/background-runner.ts new file mode 100644 index 0000000000..1479f69ee4 --- /dev/null +++ b/packages/harness/src/extensions/subagent/background-runner.ts @@ -0,0 +1,103 @@ +/** + * Tracks detached (non-awaited) subagent runs for the lifetime of this pi + * process, so a tool call can start a run and return immediately while + * `/subagents-fleet` (or another tool call) checks on or interrupts it later. + * + * `lifecycle.ts`'s on-disk artifacts are the durable source of truth (they + * survive this registry being empty, e.g. after a restart); this registry is + * what makes `interrupt`/`stop` possible, since only the process that spawned + * a child can kill it directly. + */ +import { + createRunId, + type EndRunExtra, + endRun, + type RunMode, + runsDirectory, + startRun, +} from "./lifecycle"; + +export interface BackgroundRunHandle { + runId: string; + mode: RunMode; + agents: string[]; + startedAt: number; + /** Resolved once the underlying run settles, successfully or not. */ + done: Promise; + /** Aborts the run's shared signal. Idempotent. */ + interrupt: () => void; + isRunning: () => boolean; +} + +export class BackgroundRunRegistry { + private readonly runs = new Map(); + + /** + * Starts `fn` detached, tracking it under a new runId. `fn` is given the + * abort signal to pass down to `runAgent`/`runPool`/`runChain`, and must + * resolve/reject only once the underlying child process(es) have exited. + */ + start( + meta: { mode: RunMode; agents: string[] }, + fn: (signal: AbortSignal) => Promise, + ): BackgroundRunHandle { + const runId = createRunId(); + const controller = new AbortController(); + let running = true; + + const status = startRun({ runId, mode: meta.mode, agents: meta.agents }); + + const done = fn(controller.signal) + .then((extra) => { + endRun( + status, + controller.signal.aborted ? "aborted" : "completed", + undefined, + extra, + ); + }) + .catch((error: unknown) => { + endRun( + status, + controller.signal.aborted ? "aborted" : "failed", + error instanceof Error ? error.message : String(error), + ); + }) + .finally(() => { + running = false; + }); + + const handle: BackgroundRunHandle = { + runId, + mode: meta.mode, + agents: meta.agents, + startedAt: status.startedAt, + done, + interrupt: () => controller.abort(), + isRunning: () => running, + }; + + this.runs.set(runId, handle); + return handle; + } + + get(runId: string): BackgroundRunHandle | undefined { + return this.runs.get(runId); + } + + list(): BackgroundRunHandle[] { + return Array.from(this.runs.values()); + } + + interrupt(runId: string): boolean { + const handle = this.runs.get(runId); + if (!handle) return false; + handle.interrupt(); + return true; + } +} + +/** Process-wide singleton: one registry per pi process (one per parent session). */ +export const backgroundRuns = new BackgroundRunRegistry(); + +export { runsDirectory }; diff --git a/packages/harness/src/extensions/subagent/chain.test.ts b/packages/harness/src/extensions/subagent/chain.test.ts new file mode 100644 index 0000000000..348ddc2efe --- /dev/null +++ b/packages/harness/src/extensions/subagent/chain.test.ts @@ -0,0 +1,142 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { runAgentMock } = vi.hoisted(() => ({ runAgentMock: vi.fn() })); +vi.mock("./run-agent", async () => { + const actual = + await vi.importActual("./run-agent"); + return { ...actual, runAgent: runAgentMock }; +}); + +import type { AgentConfig } from "./agents"; +import { runChain } from "./chain"; +import type { SingleRunResult } from "./run-agent"; + +const scout: AgentConfig = { + name: "scout", + description: "scout", + systemPrompt: "scout", + source: "bundled", +}; +const planner: AgentConfig = { + name: "planner", + description: "planner", + systemPrompt: "planner", + source: "bundled", +}; + +function findAgent(name: string): AgentConfig | undefined { + return [scout, planner].find((a) => a.name === name); +} + +function successResult(task: string, text: string): SingleRunResult { + return { + runId: "test-run-id", + agent: "scout", + task, + exitCode: 0, + messages: [ + { role: "assistant", content: [{ type: "text", text }] } as never, + ], + stderr: "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 1, + }, + }; +} + +const fakeCtx = { cwd: "/repo" } as unknown as ExtensionContext; + +describe("runChain", () => { + beforeEach(() => { + runAgentMock.mockReset(); + }); + + it("runs steps in order, substituting {previous} with the prior step's output", async () => { + runAgentMock + .mockImplementationOnce(async ({ task }: { task: string }) => + successResult(task, "scout output"), + ) + .mockImplementationOnce(async ({ task }: { task: string }) => + successResult(task, "planner output"), + ); + + const outcome = await runChain({ + ctx: fakeCtx, + steps: [ + { agent: "scout", task: "find the auth code" }, + { agent: "planner", task: "plan based on: {previous}" }, + ], + findAgent, + }); + + expect(outcome.results).toHaveLength(2); + expect(runAgentMock.mock.calls[1][0].task).toBe( + "plan based on: scout output", + ); + expect(outcome.failedAtStep).toBeUndefined(); + }); + + it("substitutes {previous} literally even when the prior output contains $-replacement patterns", async () => { + const trickyOutput = 'ran: echo "done: $&" && echo $$ && backref $1'; + runAgentMock + .mockImplementationOnce(async ({ task }: { task: string }) => + successResult(task, trickyOutput), + ) + .mockImplementationOnce(async ({ task }: { task: string }) => + successResult(task, "done"), + ); + + await runChain({ + ctx: fakeCtx, + steps: [ + { agent: "scout", task: "a" }, + { agent: "planner", task: "continue from: {previous}" }, + ], + findAgent, + }); + + expect(runAgentMock.mock.calls[1][0].task).toBe( + `continue from: ${trickyOutput}`, + ); + }); + + it("stops at the first failing step and does not run later steps", async () => { + runAgentMock.mockImplementationOnce(async ({ task }: { task: string }) => ({ + ...successResult(task, ""), + exitCode: 1, + stopReason: "error", + errorMessage: "boom", + })); + + const outcome = await runChain({ + ctx: fakeCtx, + steps: [ + { agent: "scout", task: "a" }, + { agent: "planner", task: "b" }, + ], + findAgent, + }); + + expect(outcome.failedAtStep).toBe(1); + expect(outcome.results).toHaveLength(1); + expect(runAgentMock).toHaveBeenCalledTimes(1); + }); + + it("reports an unknown agent without calling runAgent for that step", async () => { + const outcome = await runChain({ + ctx: fakeCtx, + steps: [{ agent: "not-real", task: "a" }], + findAgent, + }); + + expect(outcome.unknownAgent).toEqual({ step: 1, name: "not-real" }); + expect(runAgentMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/harness/src/extensions/subagent/chain.ts b/packages/harness/src/extensions/subagent/chain.ts new file mode 100644 index 0000000000..fa503e47dd --- /dev/null +++ b/packages/harness/src/extensions/subagent/chain.ts @@ -0,0 +1,83 @@ +/** + * Sequential subagent execution with `{previous}` substitution. Thin — reuses + * `run-agent.ts` per step; no process/concurrency logic of its own. Stops at + * the first failing step. + */ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import type { AgentConfig } from "./agents"; +import { getFinalOutput } from "./format"; +import { + isFailedResult, + type OnSupervisorRequest, + runAgent, + type SingleRunResult, +} from "./run-agent"; + +export interface ChainStep { + agent: string; + task: string; + cwd?: string; + context?: string; +} + +export interface RunChainOptions { + ctx: ExtensionContext; + steps: ChainStep[]; + findAgent: (name: string) => AgentConfig | undefined; + signal?: AbortSignal; + onUpdate?: (results: SingleRunResult[]) => void; + onSupervisorRequest?: OnSupervisorRequest; +} + +export interface ChainRunOutcome { + results: SingleRunResult[]; + /** 1-based step number the chain stopped at, if it failed before completing all steps. */ + failedAtStep?: number; + /** Set when a step referenced an agent name that doesn't exist. */ + unknownAgent?: { step: number; name: string }; +} + +export async function runChain( + options: RunChainOptions, +): Promise { + const { ctx, steps, findAgent, signal, onUpdate, onSupervisorRequest } = + options; + const results: SingleRunResult[] = []; + let previousOutput = ""; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const agent = findAgent(step.agent); + if (!agent) { + return { results, unknownAgent: { step: i + 1, name: step.agent } }; + } + + // Use a replacer *function*, not a plain string: `String.replace` treats a + // string replacement's `$&`, `` $` ``, `$'`, `$$`, `$1`-`$99` as special + // patterns, and `previousOutput` is arbitrary LLM-generated text that can + // easily contain a literal "$$" or "$&" and get silently mangled. + const taskWithContext = step.task.replace( + /\{previous\}/g, + () => previousOutput, + ); + const result = await runAgent({ + ctx, + agent, + task: taskWithContext, + cwd: step.cwd, + context: step.context, + step: i + 1, + signal, + onUpdate: (partial) => onUpdate?.([...results, partial]), + onSupervisorRequest, + }); + results.push(result); + + if (isFailedResult(result)) { + return { results, failedAtStep: i + 1 }; + } + previousOutput = getFinalOutput(result.messages); + } + + return { results }; +} diff --git a/packages/harness/src/extensions/subagent/context.test.ts b/packages/harness/src/extensions/subagent/context.test.ts new file mode 100644 index 0000000000..92846624f6 --- /dev/null +++ b/packages/harness/src/extensions/subagent/context.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from "vitest"; +import { + buildAutoContext, + composeTaskWithContext, + resolveContext, +} from "./context"; + +function makeCtx( + entries: Array<{ + type: string; + message?: { role: string; content: Array<{ type: string; text?: string }> }; + }>, +) { + return { + sessionManager: { getBranch: () => entries }, + } as unknown as Parameters[0]; +} + +describe("buildAutoContext", () => { + it("returns an empty string for a fresh session with no messages", () => { + expect(buildAutoContext(makeCtx([]))).toBe(""); + }); + + it("extracts text from user/assistant messages, oldest first", () => { + const ctx = makeCtx([ + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "find the bug" }], + }, + }, + { + type: "message", + message: { + role: "assistant", + content: [{ type: "text", text: "found it in auth.ts" }], + }, + }, + ]); + const digest = buildAutoContext(ctx); + expect(digest).toBe("User: find the bug\n\nAssistant: found it in auth.ts"); + }); + + it("skips non-message entries and tool-only messages with no text", () => { + const ctx = makeCtx([ + { type: "thinking_level_change" }, + { + type: "message", + message: { + role: "toolResult", + content: [{ type: "text", text: "ignored: not user/assistant" }], + }, + }, + { + type: "message", + message: { role: "assistant", content: [{ type: "toolCall" }] }, + }, + { + type: "message", + message: { role: "user", content: [{ type: "text", text: "kept" }] }, + }, + ]); + expect(buildAutoContext(ctx)).toBe("User: kept"); + }); + + it("caps to maxMessages, keeping the most recent turns", () => { + const entries = Array.from({ length: 10 }, (_, i) => ({ + type: "message", + message: { + role: "user" as const, + content: [{ type: "text", text: `turn-${i}` }], + }, + })); + const digest = buildAutoContext(makeCtx(entries), { maxMessages: 2 }); + expect(digest).toBe("User: turn-8\n\nUser: turn-9"); + }); + + it("caps to maxChars, truncating from the front", () => { + const ctx = makeCtx([ + { + type: "message", + message: { + role: "user", + content: [{ type: "text", text: "x".repeat(100) }], + }, + }, + ]); + const digest = buildAutoContext(ctx, { maxChars: 20 }); + expect(digest.length).toBeLessThanOrEqual( + 20 + "\n\n[earlier context truncated]".length, + ); + expect(digest).toMatch(/^\[earlier context truncated\]/); + }); +}); + +describe("resolveContext", () => { + it("prefers explicit context over the auto digest", () => { + const ctx = makeCtx([ + { + type: "message", + message: { role: "user", content: [{ type: "text", text: "auto" }] }, + }, + ]); + expect(resolveContext(ctx, "explicit")).toBe("explicit"); + }); + + it("trims explicit context", () => { + const ctx = makeCtx([]); + expect(resolveContext(ctx, " padded ")).toBe("padded"); + }); + + it("falls back to auto context when explicit is empty/whitespace/undefined", () => { + const ctx = makeCtx([ + { + type: "message", + message: { role: "user", content: [{ type: "text", text: "auto" }] }, + }, + ]); + expect(resolveContext(ctx, undefined)).toBe("User: auto"); + expect(resolveContext(ctx, " ")).toBe("User: auto"); + }); +}); + +describe("composeTaskWithContext", () => { + it("returns just the task when there's no context", () => { + expect(composeTaskWithContext("do the thing", "")).toBe( + "Task: do the thing", + ); + }); + + it("appends a context section when context is present", () => { + const composed = composeTaskWithContext("do the thing", "some context"); + expect(composed).toBe( + "Task: do the thing\n\nContext from the orchestrating session:\nsome context", + ); + }); +}); diff --git a/packages/harness/src/extensions/subagent/context.ts b/packages/harness/src/extensions/subagent/context.ts new file mode 100644 index 0000000000..54f0aa9dbb --- /dev/null +++ b/packages/harness/src/extensions/subagent/context.ts @@ -0,0 +1,96 @@ +/** + * Builds the context a child subagent process receives beyond its bare task + * string. Two sources, both explicit and auditable rather than a raw session + * dump: + * + * - `explicitContext`: whatever the orchestrating LLM chose to pass in the + * tool call's `context` field. This is the primary mechanism — the skill + * (see `skills/subagent-orchestration/SKILL.md`) instructs the parent to + * fill this in with whatever the child actually needs (file paths already + * found, decisions already made, constraints). + * - `buildAutoContext`: a small, capped digest of the last few *text-only* + * parent turns (no tool calls/results, no huge payloads), used only as a + * fallback when the caller didn't pass explicit context. This mirrors + * "forked context filtering" without forwarding the raw session tree. + */ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const DEFAULT_MAX_AUTO_CONTEXT_CHARS = 4000; +const DEFAULT_MAX_AUTO_CONTEXT_MESSAGES = 6; + +interface MinimalMessage { + role: string; + content?: Array<{ type: string; text?: string }>; +} + +interface MinimalSessionEntry { + type: string; + message?: MinimalMessage; +} + +function textOf(message: MinimalMessage): string { + return (message.content ?? []) + .filter((part) => part.type === "text" && part.text) + .map((part) => part.text as string) + .join("\n") + .trim(); +} + +/** + * Extracts the last few user/assistant text turns from the parent session, + * oldest first, capped to `maxMessages` turns and `maxChars` total. Returns + * an empty string when there's nothing usable (fresh session, tool-only + * turns, etc.) — callers should treat that as "no auto context available". + */ +export function buildAutoContext( + ctx: Pick, + options: { maxMessages?: number; maxChars?: number } = {}, +): string { + const maxMessages = options.maxMessages ?? DEFAULT_MAX_AUTO_CONTEXT_MESSAGES; + const maxChars = options.maxChars ?? DEFAULT_MAX_AUTO_CONTEXT_CHARS; + + const branch = + ctx.sessionManager.getBranch() as unknown as MinimalSessionEntry[]; + const turns: string[] = []; + + for (let i = branch.length - 1; i >= 0 && turns.length < maxMessages; i--) { + const entry = branch[i]; + if (entry.type !== "message" || !entry.message) continue; + const { role } = entry.message; + if (role !== "user" && role !== "assistant") continue; + + const text = textOf(entry.message); + if (!text) continue; + turns.push(`${role === "user" ? "User" : "Assistant"}: ${text}`); + } + + turns.reverse(); + const digest = turns.join("\n\n"); + if (!digest) return ""; + + if (digest.length <= maxChars) return digest; + // Keeps the tail (most recent turns) and drops the head (earliest ones), so + // the omission notice belongs at the *start* of what's returned — it + // explains why the digest picks up mid-conversation, not that something + // was cut off after the shown text. + return `[earlier context truncated]\n\n${digest.slice(digest.length - maxChars)}`; +} + +/** + * Resolves the final context string to send to a child: explicit context + * wins verbatim; otherwise falls back to `buildAutoContext`. Returns an + * empty string (not undefined) when there's nothing to forward, so callers + * can just check truthiness. + */ +export function resolveContext( + ctx: Pick, + explicitContext: string | undefined, +): string { + if (explicitContext?.trim()) return explicitContext.trim(); + return buildAutoContext(ctx); +} + +export function composeTaskWithContext(task: string, context: string): string { + if (!context) return `Task: ${task}`; + return `Task: ${task}\n\nContext from the orchestrating session:\n${context}`; +} diff --git a/packages/harness/src/extensions/subagent/discovery.test.ts b/packages/harness/src/extensions/subagent/discovery.test.ts new file mode 100644 index 0000000000..2629e3ca9e --- /dev/null +++ b/packages/harness/src/extensions/subagent/discovery.test.ts @@ -0,0 +1,178 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentConfig } from "./agents"; +import { discoverAgents, gateProjectAgents } from "./discovery"; + +describe("discoverAgents", () => { + let tmpProject: string; + + beforeEach(() => { + tmpProject = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-discovery-"), + ); + }); + + afterEach(() => { + fs.rmSync(tmpProject, { recursive: true, force: true }); + }); + + it("returns only bundled agents for scope 'bundled'", () => { + const result = discoverAgents(tmpProject, "bundled"); + expect(result.agents.every((a) => a.source === "bundled")).toBe(true); + expect(result.agents.map((a) => a.name)).toContain("scout"); + }); + + it("returns nothing for scope 'project' when there is no .pi/agents dir", () => { + const result = discoverAgents(tmpProject, "project"); + expect(result.agents).toEqual([]); + expect(result.projectAgentsDir).toBeNull(); + }); + + it("loads project agents and merges them with bundled ones for scope 'both'", () => { + const agentsDir = path.join(tmpProject, ".pi", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, "custom.md"), + "---\nname: custom\ndescription: A custom project agent\ntools: read, grep\n---\nBe a custom agent.\n", + ); + + const result = discoverAgents(tmpProject, "both"); + const custom = result.agents.find((a) => a.name === "custom"); + expect(custom).toMatchObject({ + source: "project", + description: "A custom project agent", + tools: ["read", "grep"], + }); + expect(result.agents.some((a) => a.name === "scout")).toBe(true); + expect(result.projectAgentsDir).toBe(agentsDir); + }); + + it("lets a project agent override a bundled agent of the same name", () => { + const agentsDir = path.join(tmpProject, ".pi", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, "scout.md"), + "---\nname: scout\ndescription: Project-overridden scout\n---\nOverridden.\n", + ); + + const result = discoverAgents(tmpProject, "both"); + const scout = result.agents.find((a) => a.name === "scout"); + expect(scout?.source).toBe("project"); + expect(scout?.description).toBe("Project-overridden scout"); + }); + + it("ignores markdown files missing required frontmatter", () => { + const agentsDir = path.join(tmpProject, ".pi", "agents"); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync( + path.join(agentsDir, "broken.md"), + "---\nname: broken\n---\nNo description.\n", + ); + + const result = discoverAgents(tmpProject, "project"); + expect(result.agents.find((a) => a.name === "broken")).toBeUndefined(); + }); +}); + +const projectAgent: AgentConfig = { + name: "custom", + description: "custom", + systemPrompt: "custom", + source: "project", +}; + +function makeCtx( + overrides: { hasUI?: boolean; trusted?: boolean; confirm?: boolean } = {}, +) { + return { + hasUI: overrides.hasUI ?? true, + isProjectTrusted: vi.fn(() => overrides.trusted ?? true), + ui: { confirm: vi.fn(async () => overrides.confirm ?? true) }, + }; +} + +describe("gateProjectAgents", () => { + it("allows immediately when no project agents were requested", async () => { + const ctx = makeCtx({ trusted: false }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [], + projectAgentsDir: null, + confirmProjectAgents: undefined, + }); + expect(result.allowed).toBe(true); + expect(ctx.isProjectTrusted).not.toHaveBeenCalled(); + }); + + it("refuses when the project is not trusted, regardless of other flags", async () => { + const ctx = makeCtx({ trusted: false }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [projectAgent], + projectAgentsDir: "/repo/.pi/agents", + confirmProjectAgents: false, + }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/not trusted/); + }); + + it("prompts via ctx.ui.confirm when trusted and UI is available", async () => { + const ctx = makeCtx({ trusted: true, hasUI: true, confirm: true }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [projectAgent], + projectAgentsDir: "/repo/.pi/agents", + confirmProjectAgents: undefined, + }); + expect(ctx.ui.confirm).toHaveBeenCalledTimes(1); + expect(result.allowed).toBe(true); + }); + + it("refuses when the user declines the confirm prompt", async () => { + const ctx = makeCtx({ trusted: true, hasUI: true, confirm: false }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [projectAgent], + projectAgentsDir: "/repo/.pi/agents", + confirmProjectAgents: undefined, + }); + expect(result.allowed).toBe(false); + }); + + it("skips the prompt when confirmProjectAgents is explicitly false, with UI available", async () => { + const ctx = makeCtx({ trusted: true, hasUI: true }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [projectAgent], + projectAgentsDir: "/repo/.pi/agents", + confirmProjectAgents: false, + }); + expect(ctx.ui.confirm).not.toHaveBeenCalled(); + expect(result.allowed).toBe(true); + }); + + it("defaults to refusing in headless mode even when trusted", async () => { + const ctx = makeCtx({ trusted: true, hasUI: false }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [projectAgent], + projectAgentsDir: "/repo/.pi/agents", + confirmProjectAgents: undefined, + }); + expect(result.allowed).toBe(false); + expect(result.reason).toMatch(/without a UI/); + }); + + it("allows in headless mode only with an explicit confirmProjectAgents: false", async () => { + const ctx = makeCtx({ trusted: true, hasUI: false }); + const result = await gateProjectAgents({ + ctx, + requestedAgents: [projectAgent], + projectAgentsDir: "/repo/.pi/agents", + confirmProjectAgents: false, + }); + expect(result.allowed).toBe(true); + }); +}); diff --git a/packages/harness/src/extensions/subagent/discovery.ts b/packages/harness/src/extensions/subagent/discovery.ts new file mode 100644 index 0000000000..e457b06ef7 --- /dev/null +++ b/packages/harness/src/extensions/subagent/discovery.ts @@ -0,0 +1,175 @@ +/** + * Merges `BUNDLED_AGENTS` with project-local `.pi/agents/*.md`, and gates + * running project-sourced agents behind trust + confirmation. + * + * Project agents are repo-controlled prompts: a hostile or compromised repo + * could ship one that instructs the model to exfiltrate secrets or run + * destructive commands. The gate below is deliberately conservative: + * + * - Untrusted project (`!ctx.isProjectTrusted()`): always refused, no matter + * what flags the caller passes. + * - Trusted project, interactive/RPC UI available: confirmed via + * `ctx.ui.confirm()` unless the caller explicitly passes + * `confirmProjectAgents: false`. + * - Trusted project, no UI available (print/headless/RPC-without-UI): there + * is nobody to confirm through, so project agents are refused *unless* the + * caller explicitly passed `confirmProjectAgents: false` — an intentional + * opt-in for trusted, non-interactive automation, never a silent default. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { + CONFIG_DIR_NAME, + type ExtensionContext, + parseFrontmatter, +} from "@earendil-works/pi-coding-agent"; +import { type AgentConfig, BUNDLED_AGENTS } from "./agents"; + +export type AgentScope = "bundled" | "project" | "both"; + +export interface AgentDiscoveryResult { + agents: AgentConfig[]; + projectAgentsDir: string | null; +} + +function isDirectory(candidate: string): boolean { + try { + return fs.statSync(candidate).isDirectory(); + } catch { + return false; + } +} + +function findNearestProjectAgentsDir(cwd: string): string | null { + let currentDir = cwd; + for (;;) { + const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents"); + if (isDirectory(candidate)) return candidate; + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +function loadProjectAgents(dir: string): AgentConfig[] { + const agents: AgentConfig[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return agents; + } + + for (const entry of entries) { + if (!entry.name.endsWith(".md")) continue; + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + + let content: string; + try { + content = fs.readFileSync(path.join(dir, entry.name), "utf-8"); + } catch { + continue; + } + + const { frontmatter, body } = + parseFrontmatter>(content); + if (!frontmatter.name || !frontmatter.description) continue; + + const tools = frontmatter.tools + ?.split(",") + .map((t: string) => t.trim()) + .filter(Boolean); + + agents.push({ + name: frontmatter.name, + description: frontmatter.description, + tools: tools && tools.length > 0 ? tools : undefined, + model: frontmatter.model, + systemPrompt: body, + source: "project", + }); + } + + return agents; +} + +export function discoverAgents( + cwd: string, + scope: AgentScope, +): AgentDiscoveryResult { + const projectAgentsDir = findNearestProjectAgentsDir(cwd); + const bundled = scope === "project" ? [] : BUNDLED_AGENTS; + const project = + scope === "bundled" || !projectAgentsDir + ? [] + : loadProjectAgents(projectAgentsDir); + + const agentMap = new Map(); + for (const agent of bundled) agentMap.set(agent.name, agent); + for (const agent of project) agentMap.set(agent.name, agent); + + return { agents: Array.from(agentMap.values()), projectAgentsDir }; +} + +export interface ProjectAgentGateOptions { + ctx: Pick & { + ui: Pick; + }; + requestedAgents: AgentConfig[]; + projectAgentsDir: string | null; + /** Caller's explicit choice. `undefined` means "use the default (confirm when possible)". */ + confirmProjectAgents: boolean | undefined; +} + +export interface ProjectAgentGateResult { + allowed: boolean; + reason?: string; +} + +/** + * Gates running any project-sourced agents in `requestedAgents`. Returns + * `{ allowed: true }` immediately if none of the requested agents are + * project-sourced. + */ +export async function gateProjectAgents( + options: ProjectAgentGateOptions, +): Promise { + const { ctx, requestedAgents, projectAgentsDir, confirmProjectAgents } = + options; + const projectAgents = requestedAgents.filter( + (agent) => agent.source === "project", + ); + if (projectAgents.length === 0) return { allowed: true }; + + if (!ctx.isProjectTrusted()) { + return { + allowed: false, + reason: + "This project is not trusted, so project-local subagents cannot run. Trust the project first.", + }; + } + + const names = projectAgents.map((agent) => agent.name).join(", "); + const dir = projectAgentsDir ?? "(unknown)"; + + if (!ctx.hasUI) { + if (confirmProjectAgents === false) return { allowed: true }; + return { + allowed: false, + reason: `Refusing to run project-local agents (${names}) without a UI to confirm through. Pass confirmProjectAgents: false explicitly to allow this in trusted, non-interactive contexts.`, + }; + } + + if (confirmProjectAgents === false) return { allowed: true }; + + const ok = await ctx.ui.confirm( + "Run project-local subagents?", + `Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled prompts. Only continue for trusted repositories.`, + ); + return ok + ? { allowed: true } + : { + allowed: false, + reason: "Canceled: project-local agents not approved.", + }; +} diff --git a/packages/harness/src/extensions/subagent/extension.test.ts b/packages/harness/src/extensions/subagent/extension.test.ts new file mode 100644 index 0000000000..0fc7b02324 --- /dev/null +++ b/packages/harness/src/extensions/subagent/extension.test.ts @@ -0,0 +1,393 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +const { runAgentMock, runPoolMock } = vi.hoisted(() => ({ + runAgentMock: vi.fn(), + runPoolMock: vi.fn(), +})); + +vi.mock("./run-agent", async () => { + const actual = + await vi.importActual("./run-agent"); + return { ...actual, runAgent: runAgentMock }; +}); +vi.mock("./process/pool", () => ({ runPool: runPoolMock })); + +import { createSubagentExtension } from "./extension"; +import type { SingleRunResult } from "./run-agent"; + +function successResult( + overrides: Partial = {}, +): SingleRunResult { + return { + runId: "test-run-id", + agent: "scout", + task: "look around", + exitCode: 0, + messages: [ + { role: "assistant", content: [{ type: "text", text: "done" }] } as never, + ], + stderr: "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 1, + }, + ...overrides, + }; +} + +async function getExecute() { + let registered: + | { execute: (...args: unknown[]) => Promise } + | undefined; + const pi = { + registerTool: (tool: { + execute: (...args: unknown[]) => Promise; + }) => { + registered = tool; + }, + registerCommand: () => {}, + on: () => {}, + events: { on: () => {}, emit: () => {} }, + } as unknown as ExtensionAPI; + createSubagentExtension()(pi); + if (!registered) throw new Error("subagent tool was not registered"); + return registered.execute; +} + +const fakeCtx = { + cwd: "/repo", + hasUI: true, + isProjectTrusted: () => true, + ui: { confirm: async () => true, input: vi.fn(async () => "human reply") }, +}; + +describe("subagent tool", () => { + beforeEach(() => { + runAgentMock.mockReset(); + runPoolMock.mockReset(); + }); + + it("errors when neither single nor parallel params are provided", async () => { + const execute = await getExecute(); + const result = (await execute("id", {}, undefined, undefined, fakeCtx)) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Provide exactly one of/); + expect(runAgentMock).not.toHaveBeenCalled(); + }); + + it("errors when both single and parallel params are provided", async () => { + const execute = await getExecute(); + const result = (await execute( + "id", + { agent: "scout", task: "x", tasks: [{ agent: "scout", task: "y" }] }, + undefined, + undefined, + fakeCtx, + )) as { isError?: boolean }; + expect(result.isError).toBe(true); + }); + + it("errors on an unknown agent name in single mode", async () => { + const execute = await getExecute(); + const result = (await execute( + "id", + { agent: "not-real", task: "x" }, + undefined, + undefined, + fakeCtx, + )) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Unknown agent/); + }); + + it("errors when parallel tasks exceed the max count", async () => { + const execute = await getExecute(); + const tasks = Array.from({ length: 9 }, () => ({ + agent: "scout", + task: "x", + })); + const result = (await execute( + "id", + { tasks }, + undefined, + undefined, + fakeCtx, + )) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Too many parallel tasks/); + expect(runPoolMock).not.toHaveBeenCalled(); + }); + + it("errors on an unknown agent name in a parallel task", async () => { + const execute = await getExecute(); + const result = (await execute( + "id", + { + tasks: [ + { agent: "scout", task: "x" }, + { agent: "not-real", task: "y" }, + ], + }, + undefined, + undefined, + fakeCtx, + )) as { isError?: boolean; content: Array<{ text: string }> }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Unknown agent/); + expect(runPoolMock).not.toHaveBeenCalled(); + }); + + it("dispatches single mode to runAgent and reports success", async () => { + runAgentMock.mockResolvedValue(successResult()); + const execute = await getExecute(); + const result = (await execute( + "id", + { agent: "scout", task: "find auth code" }, + undefined, + undefined, + fakeCtx, + )) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + expect(runAgentMock).toHaveBeenCalledTimes(1); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe("done"); + }); + + it("reports failure when runAgent returns a failed result", async () => { + runAgentMock.mockResolvedValue( + successResult({ exitCode: 1, stopReason: "error", errorMessage: "boom" }), + ); + const execute = await getExecute(); + const result = (await execute( + "id", + { agent: "scout", task: "x" }, + undefined, + undefined, + fakeCtx, + )) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/boom/); + }); + + it("errors when chain has more steps than the max", async () => { + const execute = await getExecute(); + const chain = Array.from({ length: 9 }, () => ({ + agent: "scout", + task: "x", + })); + const result = (await execute( + "id", + { chain }, + undefined, + undefined, + fakeCtx, + )) as { isError?: boolean; content: Array<{ text: string }> }; + expect(result.isError).toBe(true); + expect(result.content[0].text).toMatch(/Too many chain steps/); + }); + + it("dispatches chain mode sequentially with {previous} substitution", async () => { + runAgentMock + .mockImplementationOnce(async ({ task }: { task: string }) => + successResult({ task, agent: "scout" }), + ) + .mockImplementationOnce(async ({ task }: { task: string }) => + successResult({ + task, + agent: "planner", + messages: [ + { + role: "assistant", + content: [{ type: "text", text: task }], + } as never, + ], + }), + ); + + const execute = await getExecute(); + const result = (await execute( + "id", + { + chain: [ + { agent: "scout", task: "look around" }, + { agent: "planner", task: "plan for: {previous}" }, + ], + }, + undefined, + undefined, + fakeCtx, + )) as { isError?: boolean; content: Array<{ text: string }> }; + + expect(runAgentMock).toHaveBeenCalledTimes(2); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toBe("plan for: done"); + }); + + it("dispatches parallel mode through runPool", async () => { + const tasks = [ + { agent: "scout", task: "a" }, + { agent: "reviewer", task: "b" }, + ]; + runPoolMock.mockImplementation( + async ( + items: typeof tasks, + _opts: unknown, + fn: (item: unknown, i: number, s: AbortSignal) => unknown, + ) => { + return Promise.all( + items.map((item, i) => fn(item, i, new AbortController().signal)), + ); + }, + ); + runAgentMock.mockImplementation(async ({ task }: { task: string }) => + successResult({ task }), + ); + + const execute = await getExecute(); + const result = (await execute( + "id", + { tasks }, + undefined, + undefined, + fakeCtx, + )) as { + isError?: boolean; + content: Array<{ text: string }>; + }; + + expect(runPoolMock).toHaveBeenCalledTimes(1); + expect(runAgentMock).toHaveBeenCalledTimes(2); + expect(result.isError).toBeUndefined(); + expect(result.content[0].text).toMatch(/Parallel: 2\/2 succeeded/); + }); + + it("background: true returns immediately with a runId instead of waiting for completion", async () => { + const originalHome = process.env.HOME; + const tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-ext-bg-"), + ); + process.env.HOME = tmpHome; + + try { + const gate = deferred(); + runAgentMock.mockImplementation(async () => { + await gate.promise; + return successResult(); + }); + + const execute = await getExecute(); + const result = (await execute( + "id", + { agent: "scout", task: "find auth code", background: true }, + undefined, + undefined, + fakeCtx, + )) as { content: Array<{ text: string }>; details: { runId?: string } }; + + expect(result.content[0].text).toMatch(/Started in background as run/); + expect(result.details.runId).toBeTruthy(); + + gate.resolve(); + } finally { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); + + it("wires a live onSupervisorRequest through to ctx.ui.input in foreground single mode", async () => { + fakeCtx.ui.input.mockClear(); + runAgentMock.mockImplementation( + async ({ + onSupervisorRequest, + }: { + onSupervisorRequest?: (r: { + reason: string; + message: string; + }) => Promise; + }) => { + const reply = await onSupervisorRequest?.({ + reason: "need_decision", + message: "proceed?", + }); + expect(reply).toBe("human reply"); + return successResult(); + }, + ); + + const execute = await getExecute(); + await execute( + "id", + { agent: "scout", task: "x" }, + undefined, + undefined, + fakeCtx, + ); + + expect(fakeCtx.ui.input).toHaveBeenCalledTimes(1); + }); + + it("does not pass onSupervisorRequest through for background runs", async () => { + const originalHome = process.env.HOME; + const tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-ext-bg-sup-"), + ); + process.env.HOME = tmpHome; + fakeCtx.ui.input.mockClear(); + + try { + const gate = deferred(); + runAgentMock.mockImplementation( + async ({ onSupervisorRequest }: { onSupervisorRequest?: unknown }) => { + expect(onSupervisorRequest).toBeUndefined(); + await gate.promise; + return successResult(); + }, + ); + + const execute = await getExecute(); + await execute( + "id", + { agent: "scout", task: "x", background: true }, + undefined, + undefined, + fakeCtx, + ); + gate.resolve(); + expect(fakeCtx.ui.input).not.toHaveBeenCalled(); + } finally { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/harness/src/extensions/subagent/extension.ts b/packages/harness/src/extensions/subagent/extension.ts new file mode 100644 index 0000000000..e483400c48 --- /dev/null +++ b/packages/harness/src/extensions/subagent/extension.ts @@ -0,0 +1,497 @@ +import { fileURLToPath } from "node:url"; +import { StringEnum } from "@earendil-works/pi-ai"; +import type { + ExtensionAPI, + ExtensionFactory, +} from "@earendil-works/pi-coding-agent"; +import { defineTool } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import type { PosthogProviderOptions } from "../posthog-provider/provider"; +import type { AgentConfig } from "./agents"; +import { backgroundRuns } from "./background-runner"; +import { runChain } from "./chain"; +import { + type AgentScope, + discoverAgents, + gateProjectAgents, +} from "./discovery"; +import { + aggregateUsage, + formatParallelSummary, + getFinalOutput, + getResultOutput, + truncateForModel, +} from "./format"; +import { listRuns, transcriptPath } from "./lifecycle"; +import { runPool } from "./process/pool"; +import { renderSubagentCall, renderSubagentResult } from "./render"; +import { registerSubagentRpc } from "./rpc"; +import { isFailedResult, runAgent, type SingleRunResult } from "./run-agent"; + +export type SubagentOptions = PosthogProviderOptions; + +const MAX_PARALLEL_TASKS = 8; +const MAX_CONCURRENCY = 4; +const MAX_CHAIN_STEPS = 8; + +const CONTEXT_FIELD_DESCRIPTION = + "Context the agent needs beyond the task itself: file paths already found, decisions already made, constraints. Falls back to a short auto-digest of recent parent turns when omitted, but explicit context is more reliable — prefer passing it."; + +const TaskItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ description: "Task to delegate to the agent" }), + context: Type.Optional( + Type.String({ description: CONTEXT_FIELD_DESCRIPTION }), + ), + cwd: Type.Optional( + Type.String({ description: "Working directory for the agent process" }), + ), +}); + +const ChainItem = Type.Object({ + agent: Type.String({ description: "Name of the agent to invoke" }), + task: Type.String({ + description: + "Task with an optional {previous} placeholder for the prior step's output", + }), + context: Type.Optional( + Type.String({ description: CONTEXT_FIELD_DESCRIPTION }), + ), + cwd: Type.Optional( + Type.String({ description: "Working directory for the agent process" }), + ), +}); + +const AgentScopeSchema = StringEnum(["bundled", "project", "both"] as const, { + description: + 'Which agent definitions to use. Default: "bundled" (scout, planner, reviewer, worker, oracle). Use "both" to also include project-local .pi/agents/*.md (gated by trust + confirmation).', + default: "bundled", +}); + +const SubagentParams = Type.Object({ + agent: Type.Optional( + Type.String({ description: "Name of the agent to invoke (single mode)" }), + ), + task: Type.Optional( + Type.String({ description: "Task to delegate (single mode)" }), + ), + context: Type.Optional( + Type.String({ description: `${CONTEXT_FIELD_DESCRIPTION} (single mode)` }), + ), + tasks: Type.Optional( + Type.Array(TaskItem, { + description: "Tasks to run concurrently (parallel mode)", + }), + ), + chain: Type.Optional( + Type.Array(ChainItem, { + description: "Steps to run in order (chain mode)", + }), + ), + agentScope: Type.Optional(AgentScopeSchema), + confirmProjectAgents: Type.Optional( + Type.Boolean({ + description: + "Whether to prompt before running project-local agents. Default: true. Set false only for trusted, already-confirmed automation.", + default: true, + }), + ), + background: Type.Optional( + Type.Boolean({ + description: + "Run detached and return immediately with a runId instead of waiting for completion. Check progress with /subagents-fleet.", + default: false, + }), + ), + cwd: Type.Optional( + Type.String({ + description: "Working directory for the agent process (single mode)", + }), + ), +}); + +interface SubagentToolDetails { + mode: "single" | "parallel" | "chain"; + results: SingleRunResult[]; + runId?: string; +} + +type SubagentToolResult = { + content: Array<{ type: "text"; text: string }>; + details: SubagentToolDetails; + isError?: boolean; +}; + +function errorResult( + text: string, + mode: SubagentToolDetails["mode"], +): SubagentToolResult { + return { + content: [{ type: "text" as const, text }], + details: { mode, results: [] }, + isError: true, + }; +} + +function resultText(result: SubagentToolResult): string { + return result.content[0]?.text ?? ""; +} + +export function createSubagentExtension( + options: SubagentOptions = {}, +): ExtensionFactory { + return (pi: ExtensionAPI) => { + void options; + + registerSubagentRpc(pi); + + // Saved orchestration shortcuts (/parallel-review, /scout-and-plan, + // /implement-and-review) that just call the `subagent` tool in the + // appropriate mode — pure data, no new runtime behavior. + pi.on("resources_discover", () => ({ + promptPaths: [fileURLToPath(new URL("./prompts", import.meta.url))], + skillPaths: [fileURLToPath(new URL("./skills", import.meta.url))], + })); + + pi.registerTool( + defineTool({ + name: "subagent", + label: "Subagent", + description: [ + "Delegate a task to a focused subagent running in its own isolated pi process/context window.", + "Modes: single ({agent, task}), parallel ({tasks:[...]}, max 8 tasks / 4 concurrent), chain ({chain:[...]}, sequential with a {previous} placeholder for the prior step's output, max 8 steps).", + "Bundled agents: scout (fast read-only recon), planner (implementation plan, no edits), reviewer (reviews a diff/change, can apply small fixes), worker (general-purpose implementation), oracle (second opinion, no edits).", + 'Set agentScope: "both" to also allow project-local .pi/agents/*.md (gated by trust + confirmation).', + "Set background: true to start the run detached and check on it later with /subagents-fleet.", + ].join(" "), + promptSnippet: + "Delegate a task to a focused subagent (scout, planner, reviewer, worker, oracle)", + promptGuidelines: [ + "Use subagent to delegate scoped work (recon, planning, review, a second opinion) to an isolated context instead of doing it inline.", + "Use subagent's parallel mode to run several independent scouts/reviewers concurrently rather than sequentially.", + "Use subagent's chain mode for a fixed pipeline (e.g. scout -> planner -> worker) where each step needs the previous step's output.", + "Use subagent's background mode for long-running work you don't need to block on; check it later with /subagents-fleet.", + "Always pass subagent's context field with file paths already found, decisions already made, and constraints — a subagent otherwise only sees its bare task text plus a small auto-generated digest of recent turns.", + "Subagents cannot themselves call subagent; keep orchestration in the parent session.", + ], + parameters: SubagentParams, + renderCall: renderSubagentCall, + renderResult: renderSubagentResult, + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const agentScope: AgentScope = + (params.agentScope as AgentScope | undefined) ?? "bundled"; + const discovery = discoverAgents(ctx.cwd, agentScope); + const findAgent = (name: string): AgentConfig | undefined => + discovery.agents.find((a) => a.name === name); + const listAvailable = () => + discovery.agents.map((a) => `${a.name} (${a.source})`).join(", ") || + "none"; + + const hasChain = (params.chain?.length ?? 0) > 0; + const hasTasks = (params.tasks?.length ?? 0) > 0; + const hasSingle = Boolean(params.agent && params.task); + const modeCount = + Number(hasChain) + Number(hasTasks) + Number(hasSingle); + const mode: SubagentToolDetails["mode"] = hasChain + ? "chain" + : hasTasks + ? "parallel" + : "single"; + + if (modeCount !== 1) { + return errorResult( + `Provide exactly one of agent+task, tasks, or chain. Available agents: ${listAvailable()}`, + "single", + ); + } + + const requestedNames = new Set(); + for (const step of params.chain ?? []) requestedNames.add(step.agent); + for (const t of params.tasks ?? []) requestedNames.add(t.agent); + if (params.agent) requestedNames.add(params.agent); + + const requestedAgents = Array.from(requestedNames) + .map(findAgent) + .filter((a): a is AgentConfig => Boolean(a)); + + const gate = await gateProjectAgents({ + ctx, + requestedAgents, + projectAgentsDir: discovery.projectAgentsDir, + confirmProjectAgents: params.confirmProjectAgents, + }); + if (!gate.allowed) { + return errorResult( + gate.reason ?? "Refused to run project-local agents.", + mode, + ); + } + + if ( + hasChain && + params.chain && + params.chain.length > MAX_CHAIN_STEPS + ) { + return errorResult( + `Too many chain steps (${params.chain.length}). Max is ${MAX_CHAIN_STEPS}.`, + "chain", + ); + } + if ( + hasTasks && + params.tasks && + params.tasks.length > MAX_PARALLEL_TASKS + ) { + return errorResult( + `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`, + "parallel", + ); + } + if (hasTasks && params.tasks) { + const unknown = params.tasks.filter((t) => !findAgent(t.agent)); + if (unknown.length > 0) { + return errorResult( + `Unknown agent(s): ${unknown.map((t) => t.agent).join(", ")}. Available agents: ${listAvailable()}`, + "parallel", + ); + } + } + if (hasSingle) { + if (!findAgent(params.agent as string)) { + return errorResult( + `Unknown agent "${params.agent}". Available agents: ${listAvailable()}`, + "single", + ); + } + } + + type UpdateFn = typeof onUpdate; + + // Live human-in-the-loop only: background runs have no UI once the + // tool call has already returned, so they don't get this callback + // and their `contact_supervisor` calls simply time out. + const onSupervisorRequest = ctx.hasUI + ? async (request: { reason: string; message: string }) => { + const reply = await ctx.ui.input( + `Subagent needs input (${request.reason})`, + request.message, + ); + return ( + reply ?? + "(the supervisor didn't respond; proceed using your best judgment)" + ); + } + : undefined; + + const runDispatch = async ( + dispatchSignal: AbortSignal | undefined, + dispatchOnUpdate: UpdateFn, + live: boolean, + ): Promise => { + if (hasChain && params.chain) { + const outcome = await runChain({ + ctx, + steps: params.chain, + findAgent, + signal: dispatchSignal, + onSupervisorRequest: live ? onSupervisorRequest : undefined, + onUpdate: (results) => + dispatchOnUpdate?.({ + content: [ + { + type: "text", + text: + getFinalOutput( + results[results.length - 1]?.messages ?? [], + ) || "(running...)", + }, + ], + details: { mode: "chain", results }, + }), + }); + + if (outcome.unknownAgent) { + return errorResult( + `Chain stopped at step ${outcome.unknownAgent.step}: unknown agent "${outcome.unknownAgent.name}". Available agents: ${listAvailable()}`, + "chain", + ); + } + if (outcome.failedAtStep) { + const failedResult = outcome.results[outcome.failedAtStep - 1]; + return { + content: [ + { + type: "text", + text: `Chain stopped at step ${outcome.failedAtStep} (${failedResult.agent}): ${getResultOutput(failedResult)}`, + }, + ], + details: { mode: "chain", results: outcome.results }, + isError: true, + }; + } + + const last = outcome.results[outcome.results.length - 1]; + return { + content: [ + { + type: "text", + text: + (last && getFinalOutput(last.messages)) || "(no output)", + }, + ], + details: { mode: "chain", results: outcome.results }, + }; + } + + if (hasTasks && params.tasks) { + const results = await runPool( + params.tasks, + { concurrency: MAX_CONCURRENCY, signal: dispatchSignal }, + (t, _index, taskSignal) => { + const agent = findAgent(t.agent) as AgentConfig; + return runAgent({ + ctx, + agent, + task: t.task, + cwd: t.cwd, + context: t.context, + signal: taskSignal, + onSupervisorRequest: live ? onSupervisorRequest : undefined, + }); + }, + ); + + return { + content: [ + { type: "text", text: formatParallelSummary(results) }, + ], + details: { mode: "parallel", results }, + }; + } + + const agent = findAgent(params.agent as string) as AgentConfig; + const result = await runAgent({ + ctx, + agent, + task: params.task as string, + cwd: params.cwd, + context: params.context, + signal: dispatchSignal, + onSupervisorRequest: live ? onSupervisorRequest : undefined, + onUpdate: (partial) => + dispatchOnUpdate?.({ + content: [ + { + type: "text", + text: getFinalOutput(partial.messages) || "(running...)", + }, + ], + details: { mode: "single", results: [partial] }, + }), + }); + + if (isFailedResult(result)) { + return { + content: [ + { + type: "text", + text: `Agent ${result.stopReason || "failed"}: ${getResultOutput(result)}`, + }, + ], + details: { mode: "single", results: [result] }, + isError: true, + }; + } + + return { + content: [ + { + type: "text", + text: getFinalOutput(result.messages) || "(no output)", + }, + ], + details: { mode: "single", results: [result] }, + }; + }; + + if (params.background) { + const handle = backgroundRuns.start( + { mode, agents: Array.from(requestedNames) }, + async (bgSignal) => { + const result = await runDispatch(bgSignal, undefined, false); + const usage = aggregateUsage(result.details.results); + return { + model: result.details.results[0]?.model, + totalTokens: usage.totalTokens, + totalCost: usage.totalCost, + resultSummary: truncateForModel(resultText(result), 2000), + childRunIds: result.details.results.map((r) => r.runId), + }; + }, + ); + + return { + content: [ + { + type: "text" as const, + text: `Started in background as run ${handle.runId}. Check progress with /subagents-fleet.`, + }, + ], + details: { mode, results: [], runId: handle.runId }, + }; + } + + return runDispatch(signal, onUpdate, true); + }, + }), + ); + + pi.registerCommand("subagents-fleet", { + description: + "List subagent runs (background and recently completed) and their state", + handler: async (args, ctx) => { + const [action, runId] = args.trim().split(/\s+/, 2); + + if (action === "interrupt" && runId) { + const interrupted = backgroundRuns.interrupt(runId); + ctx.ui.notify( + interrupted + ? `Interrupt requested for run ${runId}.` + : `No active in-memory run found for ${runId}.`, + "info", + ); + return; + } + + const runs = listRuns(); + if (runs.length === 0) { + ctx.ui.notify("No subagent runs recorded yet.", "info"); + return; + } + + const lines = runs.slice(0, 20).map((run) => { + const inMemory = backgroundRuns.get(run.runId); + const liveTag = inMemory?.isRunning() ? " [interruptible]" : ""; + const duration = run.durationMs + ? `${Math.round(run.durationMs / 1000)}s` + : "running"; + const header = `${run.state.padEnd(9)} ${run.runId.slice(0, 8)} ${run.mode.padEnd(8)} ${run.agents.join(",")} ${duration}${liveTag}`; + // Job-level records (background dispatch of parallel/chain/single) + // don't have their own transcript — each `childRunIds` entry does. + if (run.childRunIds && run.childRunIds.length > 0) { + const children = run.childRunIds + .map((id) => ` ${id.slice(0, 8)}: ${transcriptPath(id)}`) + .join("\n"); + return `${header}\n children:\n${children}`; + } + return `${header}\n transcript: ${transcriptPath(run.runId)}`; + }); + ctx.ui.notify(lines.join("\n"), "info"); + }, + }); + }; +} + +export default function subagent(pi: ExtensionAPI): void | Promise { + return createSubagentExtension()(pi); +} diff --git a/packages/harness/src/extensions/subagent/format.test.ts b/packages/harness/src/extensions/subagent/format.test.ts new file mode 100644 index 0000000000..2da29ba129 --- /dev/null +++ b/packages/harness/src/extensions/subagent/format.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + aggregateUsage, + renderTranscriptMarkdown, + truncateForModel, +} from "./format"; +import type { SingleRunResult } from "./run-agent"; + +function baseResult(overrides: Partial = {}): SingleRunResult { + return { + runId: "run-abc", + agent: "scout", + task: "find the auth code", + exitCode: 0, + messages: [], + stderr: "", + usage: { + input: 10, + output: 20, + cacheRead: 0, + cacheWrite: 0, + cost: 0.02, + contextTokens: 30, + turns: 1, + }, + model: "anthropic/opus", + ...overrides, + }; +} + +describe("truncateForModel", () => { + it("passes short output through unchanged", () => { + expect(truncateForModel("short", 100)).toBe("short"); + }); + + it("truncates and reports omitted byte count", () => { + const result = truncateForModel("x".repeat(200), 50); + expect(result.length).toBeLessThan(250); + expect(result).toMatch(/Output truncated: \d+ bytes omitted/); + }); +}); + +describe("aggregateUsage", () => { + it("sums tokens and cost across results", () => { + const results = [ + baseResult(), + baseResult({ + usage: { + input: 5, + output: 5, + cacheRead: 0, + cacheWrite: 0, + cost: 0.01, + contextTokens: 10, + turns: 1, + }, + }), + ]; + expect(aggregateUsage(results)).toEqual({ + totalTokens: 40, + totalCost: 0.03, + }); + }); + + it("falls back to input+output when contextTokens is 0", () => { + const results = [ + baseResult({ + usage: { + input: 5, + output: 5, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 1, + }, + }), + ]; + expect(aggregateUsage(results).totalTokens).toBe(10); + }); +}); + +describe("renderTranscriptMarkdown", () => { + it("includes header metadata, task, and error section for a failed run", () => { + const result = baseResult({ + exitCode: 1, + stopReason: "error", + errorMessage: "boom", + stderr: "stack trace", + }); + const md = renderTranscriptMarkdown(result); + expect(md).toContain("# Subagent run: scout"); + expect(md).toContain("- runId: run-abc"); + expect(md).toContain("- stopReason: error"); + expect(md).toContain("find the auth code"); + expect(md).toContain("## Error"); + expect(md).toContain("boom"); + expect(md).toContain("## stderr"); + expect(md).toContain("stack trace"); + }); + + it("renders assistant text, tool calls, and tool results in order", () => { + const result = baseResult({ + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "Let me check the file." }, + { + type: "toolCall", + id: "1", + name: "read", + arguments: { path: "auth.ts" }, + }, + ], + } as never, + { + role: "toolResult", + toolCallId: "1", + toolName: "read", + isError: false, + content: [{ type: "text", text: "file contents here" }], + } as never, + ], + }); + + const md = renderTranscriptMarkdown(result); + const textIdx = md.indexOf("Let me check the file."); + const callIdx = md.indexOf("Tool call: `read`"); + const resultIdx = md.indexOf("Tool result: `read`"); + expect(textIdx).toBeGreaterThanOrEqual(0); + expect(callIdx).toBeGreaterThan(textIdx); + expect(resultIdx).toBeGreaterThan(callIdx); + expect(md).toContain('"path": "auth.ts"'); + expect(md).toContain("file contents here"); + }); + + it("marks a failed tool result distinctly", () => { + const result = baseResult({ + messages: [ + { + role: "toolResult", + toolCallId: "1", + toolName: "bash", + isError: true, + content: [{ type: "text", text: "command not found" }], + } as never, + ], + }); + expect(renderTranscriptMarkdown(result)).toContain( + "Tool result (error): `bash`", + ); + }); +}); diff --git a/packages/harness/src/extensions/subagent/format.ts b/packages/harness/src/extensions/subagent/format.ts new file mode 100644 index 0000000000..b24d34a89f --- /dev/null +++ b/packages/harness/src/extensions/subagent/format.ts @@ -0,0 +1,134 @@ +/** + * Pure formatting helpers over `SingleRunResult`. No I/O, no process/agent + * knowledge beyond the result shape itself. + */ +import type { Message } from "@earendil-works/pi-ai"; +import { isFailedResult, type SingleRunResult } from "./run-agent"; + +const PER_TASK_OUTPUT_CAP = 50 * 1024; + +export function getFinalOutput(messages: Message[]): string { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (msg.role === "assistant") { + for (const part of msg.content) { + if (part.type === "text") return part.text; + } + } + } + return ""; +} + +export function getResultOutput(result: SingleRunResult): string { + if (isFailedResult(result)) { + return ( + result.errorMessage || + result.stderr || + getFinalOutput(result.messages) || + "(no output)" + ); + } + return getFinalOutput(result.messages) || "(no output)"; +} + +export function truncateForModel( + output: string, + cap: number = PER_TASK_OUTPUT_CAP, +): string { + const byteLength = Buffer.byteLength(output, "utf8"); + if (byteLength <= cap) return output; + let truncated = output.slice(0, cap); + while (Buffer.byteLength(truncated, "utf8") > cap) + truncated = truncated.slice(0, -1); + return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`; +} + +export function aggregateUsage(results: SingleRunResult[]): { + totalTokens: number; + totalCost: number; +} { + let totalTokens = 0; + let totalCost = 0; + for (const r of results) { + totalTokens += r.usage.contextTokens || r.usage.input + r.usage.output; + totalCost += r.usage.cost; + } + return { totalTokens, totalCost }; +} + +function truncateInline(text: string, maxLen = 400): string { + const trimmed = text.trim(); + return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}...` : trimmed; +} + +/** + * Renders one run's full message history as a readable markdown transcript, + * persisted alongside its `lifecycle.ts` status for later inspection + * (`transcript.md`). Not truncated — this is the durable record, distinct + * from the capped summaries surfaced back to the calling model. + */ +export function renderTranscriptMarkdown(result: SingleRunResult): string { + const lines: string[] = [ + `# Subagent run: ${result.agent}`, + "", + `- runId: ${result.runId}`, + `- model: ${result.model ?? "(unknown)"}`, + `- exitCode: ${result.exitCode}`, + result.stopReason ? `- stopReason: ${result.stopReason}` : undefined, + "", + "## Task", + "", + result.task, + "", + "## Transcript", + "", + ].filter((line): line is string => line !== undefined); + + for (const message of result.messages) { + if (message.role === "assistant") { + for (const part of message.content) { + if (part.type === "text") { + lines.push(part.text, ""); + } else if (part.type === "toolCall") { + lines.push( + `**Tool call: \`${part.name}\`**`, + "", + "```json", + JSON.stringify(part.arguments, null, 2), + "```", + "", + ); + } + } + } else if (message.role === "toolResult") { + const text = message.content + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"); + lines.push( + `**Tool result${message.isError ? " (error)" : ""}: \`${message.toolName}\`**`, + "", + truncateInline(text, 2000), + "", + ); + } + } + + if (result.errorMessage) lines.push("## Error", "", result.errorMessage, ""); + if (result.stderr.trim()) + lines.push("## stderr", "", "```", result.stderr.trim(), "```", ""); + + return lines.join("\n"); +} + +export function formatParallelSummary(results: SingleRunResult[]): string { + const successCount = results.filter((r) => !isFailedResult(r)).length; + const summaries = results.map((r) => { + const output = truncateForModel(getResultOutput(r)); + const status = isFailedResult(r) + ? `failed${r.stopReason && r.stopReason !== "end" ? ` (${r.stopReason})` : ""}` + : "completed"; + return `### [${r.agent}] ${status}\n\n${output}`; + }); + return `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n---\n\n")}`; +} diff --git a/packages/harness/src/extensions/subagent/index.ts b/packages/harness/src/extensions/subagent/index.ts new file mode 100644 index 0000000000..d2227dba7e --- /dev/null +++ b/packages/harness/src/extensions/subagent/index.ts @@ -0,0 +1,9 @@ +// Thin `index.ts` re-export used only as pi's `-e` extension entry point. +// +// pi's startup banner derives an extension's display name from its file +// path: a trailing `index.ts`/`index.js` segment is dropped in favor of the +// parent directory name, so loading this file (instead of `./extension.ts` +// directly) makes the extension show as `subagent` instead of +// `subagent/extension.js`. `./extension.ts` remains the real implementation +// per the convention in `../README.md`. +export { default } from "./extension"; diff --git a/packages/harness/src/extensions/subagent/lifecycle.test.ts b/packages/harness/src/extensions/subagent/lifecycle.test.ts new file mode 100644 index 0000000000..1877f76865 --- /dev/null +++ b/packages/harness/src/extensions/subagent/lifecycle.test.ts @@ -0,0 +1,141 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + appendEvent, + createRunId, + endRun, + listRuns, + readStatus, + readTranscript, + runDirectory, + startRun, + writeTranscript, +} from "./lifecycle"; + +describe("lifecycle", () => { + let originalHome: string | undefined; + let tmpHome: string; + + beforeEach(() => { + originalHome = process.env.HOME; + tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-lifecycle-"), + ); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("createRunId returns distinct ids", () => { + expect(createRunId()).not.toBe(createRunId()); + }); + + it("startRun writes an initial 'running' status and a 'started' event", () => { + const runId = createRunId(); + const status = startRun({ runId, mode: "single", agents: ["scout"] }); + expect(status.state).toBe("running"); + expect(status.lifecycleArtifactVersion).toBe(1); + + const readBack = readStatus(runId); + expect(readBack).toMatchObject({ + runId, + mode: "single", + agents: ["scout"], + state: "running", + }); + + const events = fs + .readFileSync(path.join(runDirectory(runId), "events.jsonl"), "utf-8") + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + expect(events).toHaveLength(1); + expect(events[0].type).toBe("started"); + }); + + it("endRun transitions state and records durationMs + extra fields", () => { + const runId = createRunId(); + const status = startRun({ + runId, + mode: "parallel", + agents: ["scout", "reviewer"], + }); + const final = endRun(status, "completed", undefined, { + totalTokens: 123, + totalCost: 0.5, + model: "anthropic/opus", + }); + + expect(final.state).toBe("completed"); + expect(final.durationMs).toBeGreaterThanOrEqual(0); + expect(final.totalTokens).toBe(123); + expect(final.totalCost).toBe(0.5); + expect(readStatus(runId)?.state).toBe("completed"); + }); + + it("endRun records an error message for failed runs", () => { + const runId = createRunId(); + const status = startRun({ runId, mode: "single", agents: ["scout"] }); + const final = endRun(status, "failed", "boom"); + expect(final.state).toBe("failed"); + expect(final.error).toBe("boom"); + }); + + it("appendEvent appends additional lines without clobbering earlier ones", () => { + const runId = createRunId(); + startRun({ runId, mode: "single", agents: ["scout"] }); + appendEvent(runId, { + type: "progress", + timestamp: Date.now(), + note: "halfway", + }); + + const events = fs + .readFileSync(path.join(runDirectory(runId), "events.jsonl"), "utf-8") + .trim() + .split("\n"); + expect(events).toHaveLength(2); + }); + + it("readStatus returns undefined for a run that doesn't exist", () => { + expect(readStatus("does-not-exist")).toBeUndefined(); + }); + + it("listRuns returns all known runs, most recently started first", async () => { + const first = createRunId(); + startRun({ runId: first, mode: "single", agents: ["scout"] }); + await new Promise((r) => setTimeout(r, 2)); + const second = createRunId(); + startRun({ runId: second, mode: "single", agents: ["worker"] }); + + const runs = listRuns(); + expect(runs.map((r) => r.runId)).toEqual([second, first]); + }); + + it("listRuns returns an empty array when the runs directory doesn't exist", () => { + expect(listRuns()).toEqual([]); + }); + + it("writeTranscript then readTranscript round-trips short content", () => { + const runId = createRunId(); + writeTranscript(runId, "# hello\n\nsome transcript content"); + expect(readTranscript(runId)).toBe("# hello\n\nsome transcript content"); + }); + + it("readTranscript returns undefined when there's no transcript yet", () => { + expect(readTranscript(createRunId())).toBeUndefined(); + }); + + it("writeTranscript truncates content exceeding maxBytes and appends a notice", () => { + const runId = createRunId(); + writeTranscript(runId, "x".repeat(1000), 100); + const stored = readTranscript(runId) ?? ""; + expect(Buffer.byteLength(stored, "utf-8")).toBeLessThan(1000); + expect(stored).toMatch(/transcript truncated: exceeded 100 bytes/); + }); +}); diff --git a/packages/harness/src/extensions/subagent/lifecycle.ts b/packages/harness/src/extensions/subagent/lifecycle.ts new file mode 100644 index 0000000000..140163c42e --- /dev/null +++ b/packages/harness/src/extensions/subagent/lifecycle.ts @@ -0,0 +1,201 @@ +/** + * Versioned on-disk run artifacts, written for every run (foreground and + * background alike) so background/fleet monitoring (this phase) and any + * future external observability tooling can read a run's state without + * needing the parent process's in-memory state. + * + * Layout: `//status.json`, `/events.jsonl`. + */ + +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; + +export const LIFECYCLE_ARTIFACT_VERSION = 1; + +export type RunMode = "single" | "parallel" | "chain"; +export type RunState = "running" | "completed" | "failed" | "aborted"; + +export interface RunStatus { + lifecycleArtifactVersion: number; + runId: string; + sessionId?: string; + mode: RunMode; + agents: string[]; + state: RunState; + startedAt: number; + endedAt?: number; + durationMs?: number; + totalTokens?: number; + totalCost?: number; + model?: string; + error?: string; + /** Truncated final output text, for a quick fleet-view summary without replaying the full transcript. */ + resultSummary?: string; + /** + * For a job-level record (a `background-runner.ts` wrapper around + * parallel/chain/single dispatch), the runIds of the individual + * `runAgent()` calls it fanned out to — each of those has its own + * `RunStatus` + `transcript.md` under `runDirectory(childRunId)`. Absent on + * a per-agent-call record itself. + */ + childRunIds?: string[]; +} + +export interface RunEvent { + type: string; + timestamp: number; + [key: string]: unknown; +} + +export function runsDirectory(): string { + return path.join(getAgentDir(), "subagent-runs"); +} + +export function runDirectory(runId: string): string { + return path.join(runsDirectory(), runId); +} + +export function createRunId(): string { + return randomUUID(); +} + +function writeJsonFileSync(filePath: string, data: unknown): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp-${process.pid}`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2)); + fs.renameSync(tmpPath, filePath); +} + +export function startRun( + status: Omit, +): RunStatus { + const fullStatus: RunStatus = { + lifecycleArtifactVersion: LIFECYCLE_ARTIFACT_VERSION, + state: "running", + startedAt: Date.now(), + ...status, + }; + writeStatus(fullStatus); + appendEvent(status.runId, { + type: "started", + timestamp: fullStatus.startedAt, + }); + return fullStatus; +} + +export function writeStatus(status: RunStatus): void { + writeJsonFileSync( + path.join(runDirectory(status.runId), "status.json"), + status, + ); +} + +export function appendEvent(runId: string, event: RunEvent): void { + const dir = runDirectory(runId); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync( + path.join(dir, "events.jsonl"), + `${JSON.stringify(event)}\n`, + ); +} + +export interface EndRunExtra { + totalTokens?: number; + totalCost?: number; + model?: string; + resultSummary?: string; + childRunIds?: string[]; +} + +export function endRun( + status: RunStatus, + state: Exclude, + error?: string, + extra?: EndRunExtra, +): RunStatus { + const endedAt = Date.now(); + const finalStatus: RunStatus = { + ...status, + ...extra, + state, + endedAt, + durationMs: endedAt - status.startedAt, + error, + }; + writeStatus(finalStatus); + appendEvent(status.runId, { type: state, timestamp: endedAt, error }); + return finalStatus; +} + +export function readStatus(runId: string): RunStatus | undefined { + try { + const raw = fs.readFileSync( + path.join(runDirectory(runId), "status.json"), + "utf-8", + ); + return JSON.parse(raw) as RunStatus; + } catch { + return undefined; + } +} + +export function transcriptPath(runId: string): string { + return path.join(runDirectory(runId), "transcript.md"); +} + +const DEFAULT_MAX_TRANSCRIPT_BYTES = 5 * 1024 * 1024; + +/** + * Writes the run's transcript, capped so a very long-running or verbose + * subagent can't grow an unbounded file on disk. + */ +export function writeTranscript( + runId: string, + markdown: string, + maxBytes: number = DEFAULT_MAX_TRANSCRIPT_BYTES, +): void { + const filePath = transcriptPath(runId); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + + const byteLength = Buffer.byteLength(markdown, "utf-8"); + if (byteLength <= maxBytes) { + fs.writeFileSync(filePath, markdown); + return; + } + + let truncated = markdown.slice(0, maxBytes); + while (Buffer.byteLength(truncated, "utf-8") > maxBytes) + truncated = truncated.slice(0, -1); + fs.writeFileSync( + filePath, + `${truncated}\n\n[transcript truncated: exceeded ${maxBytes} bytes; ${byteLength - Buffer.byteLength(truncated, "utf-8")} bytes omitted]\n`, + ); +} + +export function readTranscript(runId: string): string | undefined { + try { + return fs.readFileSync(transcriptPath(runId), "utf-8"); + } catch { + return undefined; + } +} + +export function listRuns(): RunStatus[] { + const dir = runsDirectory(); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + const runs: RunStatus[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const status = readStatus(entry.name); + if (status) runs.push(status); + } + return runs.sort((a, b) => b.startedAt - a.startedAt); +} diff --git a/packages/harness/src/extensions/subagent/policy.test.ts b/packages/harness/src/extensions/subagent/policy.test.ts new file mode 100644 index 0000000000..0937c40532 --- /dev/null +++ b/packages/harness/src/extensions/subagent/policy.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + applyModelScope, + checkModelScope, + matchesAllowList, + SubagentPolicyError, +} from "./policy"; + +describe("matchesAllowList", () => { + it("matches an exact model key", () => { + expect( + matchesAllowList("anthropic/claude-sonnet-4-5", [ + "anthropic/claude-sonnet-4-5", + ]), + ).toBe(true); + }); + + it("matches a trailing-glob pattern", () => { + expect( + matchesAllowList("anthropic/claude-sonnet-4-5", ["anthropic/*"]), + ).toBe(true); + expect(matchesAllowList("openai/gpt-5-mini", ["openai/gpt-5-*"])).toBe( + true, + ); + }); + + it("does not match outside the allow list", () => { + expect(matchesAllowList("openai/gpt-4o", ["anthropic/*"])).toBe(false); + }); +}); + +describe("checkModelScope", () => { + it("allows everything when there's no allow list configured", () => { + expect(checkModelScope("anything/whatever", undefined)).toEqual({ + allowed: true, + enforced: false, + }); + expect(checkModelScope("anything/whatever", { enforce: true })).toEqual({ + allowed: true, + enforced: true, + }); + }); + + it("reports enforced+disallowed", () => { + const check = checkModelScope("openai/gpt-4o", { + enforce: true, + allow: ["anthropic/*"], + }); + expect(check.allowed).toBe(false); + expect(check.enforced).toBe(true); + expect(check.reason).toMatch(/not in the configured modelScope/); + }); +}); + +describe("applyModelScope", () => { + it("returns undefined (no warning) for an allowed model", () => { + expect( + applyModelScope("anthropic/opus", { allow: ["anthropic/*"] }), + ).toBeUndefined(); + }); + + it("returns a warning string for a disallowed model when not enforced", () => { + const warning = applyModelScope("openai/gpt-4o", { + allow: ["anthropic/*"], + }); + expect(warning).toMatch(/not in the configured modelScope/); + }); + + it("throws SubagentPolicyError for a disallowed model when enforced", () => { + expect(() => + applyModelScope("openai/gpt-4o", { + enforce: true, + allow: ["anthropic/*"], + }), + ).toThrow(SubagentPolicyError); + }); +}); diff --git a/packages/harness/src/extensions/subagent/policy.ts b/packages/harness/src/extensions/subagent/policy.ts new file mode 100644 index 0000000000..d6af35d89b --- /dev/null +++ b/packages/harness/src/extensions/subagent/policy.ts @@ -0,0 +1,61 @@ +/** + * Enforces `settings.subagents.modelScope` (an allow-list of model glob + * patterns) before a subagent is allowed to spawn against a given model. + */ +import type { ModelScopeConfig } from "./settings"; + +export class SubagentPolicyError extends Error {} + +function patternToRegExp(pattern: string): RegExp { + const escaped = pattern + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`); +} + +export function matchesAllowList(modelKey: string, allow: string[]): boolean { + return allow.some((pattern) => patternToRegExp(pattern).test(modelKey)); +} + +export interface ModelScopeCheck { + allowed: boolean; + enforced: boolean; + reason?: string; +} + +/** + * Checks `modelKey` ("provider/id") against `modelScope`. When there's no + * `allow` list configured, everything is allowed regardless of `enforce`. + */ +export function checkModelScope( + modelKey: string, + modelScope: ModelScopeConfig | undefined, +): ModelScopeCheck { + const allow = modelScope?.allow; + if (!allow || allow.length === 0) + return { allowed: true, enforced: Boolean(modelScope?.enforce) }; + + const allowed = matchesAllowList(modelKey, allow); + return { + allowed, + enforced: Boolean(modelScope?.enforce), + reason: allowed + ? undefined + : `Model "${modelKey}" is not in the configured modelScope.allow list (${allow.join(", ")}).`, + }; +} + +/** + * Throws `SubagentPolicyError` when `modelScope.enforce` is set and the model + * is disallowed. Otherwise returns a warning message (or `undefined`) for the + * caller to surface non-fatally. + */ +export function applyModelScope( + modelKey: string, + modelScope: ModelScopeConfig | undefined, +): string | undefined { + const check = checkModelScope(modelKey, modelScope); + if (check.allowed) return undefined; + if (check.enforced) throw new SubagentPolicyError(check.reason); + return check.reason; +} diff --git a/packages/harness/src/extensions/subagent/process/child-process.test.ts b/packages/harness/src/extensions/subagent/process/child-process.test.ts new file mode 100644 index 0000000000..e30a9f7060 --- /dev/null +++ b/packages/harness/src/extensions/subagent/process/child-process.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import { spawnChildProcess } from "./child-process"; + +describe("spawnChildProcess", () => { + it("line-buffers stdout and propagates the exit code", async () => { + const lines: string[] = []; + const handle = spawnChildProcess({ + command: process.execPath, + args: ["-e", "process.stdout.write('a\\nb\\n'); process.exitCode = 3;"], + onStdoutLine: (line) => lines.push(line), + }); + + const code = await handle.exited; + expect(code).toBe(3); + expect(lines).toEqual(["a", "b"]); + }); + + it("flushes a trailing unterminated line on exit", async () => { + const lines: string[] = []; + const handle = spawnChildProcess({ + command: process.execPath, + args: ["-e", "process.stdout.write('no-newline')"], + onStdoutLine: (line) => lines.push(line), + }); + + await handle.exited; + expect(lines).toEqual(["no-newline"]); + }); + + it("captures stderr chunks", async () => { + let stderr = ""; + const handle = spawnChildProcess({ + command: process.execPath, + args: ["-e", "process.stderr.write('oops')"], + onStderrChunk: (chunk) => { + stderr += chunk; + }, + }); + + await handle.exited; + expect(stderr).toBe("oops"); + }); + + it("kill() terminates a long-running process and is idempotent", async () => { + const handle = spawnChildProcess({ + command: process.execPath, + args: ["-e", "setTimeout(() => {}, 60_000)"], + }); + + handle.kill(); + handle.kill(); // must not throw when called again + + const code = await handle.exited; + expect(code).not.toBe(0); + }); + + it("resolves 1 instead of throwing when the command doesn't exist", async () => { + const handle = spawnChildProcess({ + command: "posthog-subagent-definitely-not-a-real-binary", + args: [], + }); + const code = await handle.exited; + expect(code).toBe(1); + }); + + it("resolves 1 instead of throwing when spawn() itself throws synchronously (e.g. an invalid cwd type)", async () => { + let handle: ReturnType | undefined; + expect(() => { + handle = spawnChildProcess({ + command: process.execPath, + args: ["-e", "1"], + // biome-ignore lint/suspicious/noExplicitAny: intentionally invalid to trigger spawn()'s synchronous validation throw + cwd: 123 as any, + }); + }).not.toThrow(); + + const code = await handle?.exited; + expect(code).toBe(1); + expect(() => handle?.kill()).not.toThrow(); + }); +}); diff --git a/packages/harness/src/extensions/subagent/process/child-process.ts b/packages/harness/src/extensions/subagent/process/child-process.ts new file mode 100644 index 0000000000..5d8e4fef1a --- /dev/null +++ b/packages/harness/src/extensions/subagent/process/child-process.ts @@ -0,0 +1,87 @@ +/** + * Owns exactly one raw child process: spawning it, line-buffering its + * stdout/stderr, and killing it. Agent-agnostic — knows nothing about pi + * sessions, agents, or tasks. This is the only module in the extension that + * imports `node:child_process`. + */ +import { type ChildProcess, spawn } from "node:child_process"; + +export interface SpawnChildProcessOptions { + command: string; + args: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + onStdoutLine?: (line: string) => void; + onStderrChunk?: (chunk: string) => void; +} + +export interface ChildProcessHandle { + /** Resolves with the exit code once the process has exited, for any reason. */ + exited: Promise; + /** Idempotent: safe to call multiple times, and safe to call after exit. */ + kill: () => void; +} + +const KILL_GRACE_PERIOD_MS = 5000; + +export function spawnChildProcess( + options: SpawnChildProcessOptions, +): ChildProcessHandle { + const { command, args, cwd, env, onStdoutLine, onStderrChunk } = options; + + let proc: ChildProcess; + try { + proc = spawn(command, args, { + cwd, + env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch { + // `spawn()` can throw synchronously for some invalid inputs (e.g. a + // malformed `cwd`/`env`) instead of emitting an async "error" event. + // Callers (`run-agent.ts`, `process/pool.ts`, `chain.ts`) all rely on + // `ChildProcessHandle` never throwing — only ever resolving `exited` — + // so treat a synchronous spawn failure the same as the async "error" + // case below: never started, exit code 1. + return { exited: Promise.resolve(1), kill: () => {} }; + } + + let stdoutBuffer = ""; + proc.stdout?.on("data", (data: Buffer) => { + stdoutBuffer += data.toString(); + const lines = stdoutBuffer.split("\n"); + stdoutBuffer = lines.pop() ?? ""; + for (const line of lines) onStdoutLine?.(line); + }); + + proc.stderr?.on("data", (data: Buffer) => { + onStderrChunk?.(data.toString()); + }); + + let killed = false; + let killTimer: ReturnType | undefined; + + const kill = (): void => { + if (killed) return; + killed = true; + proc.kill("SIGTERM"); + killTimer = setTimeout(() => { + if (!proc.killed) proc.kill("SIGKILL"); + }, KILL_GRACE_PERIOD_MS); + }; + + const exited = new Promise((resolve) => { + proc.on("close", (code) => { + if (killTimer) clearTimeout(killTimer); + if (stdoutBuffer.trim()) onStdoutLine?.(stdoutBuffer); + resolve(code ?? (killed ? 143 : 0)); + }); + proc.on("error", () => { + if (killTimer) clearTimeout(killTimer); + resolve(1); + }); + }); + + return { exited, kill }; +} diff --git a/packages/harness/src/extensions/subagent/process/pool.test.ts b/packages/harness/src/extensions/subagent/process/pool.test.ts new file mode 100644 index 0000000000..b7c9b3815b --- /dev/null +++ b/packages/harness/src/extensions/subagent/process/pool.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { runPool } from "./pool"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +describe("runPool", () => { + it("returns an empty array for no items without touching fn", async () => { + const results = await runPool( + [], + { concurrency: 4 }, + () => { + throw new Error("should not be called"); + }, + ); + expect(results).toEqual([]); + }); + + it("preserves result order regardless of completion order", async () => { + const delays = [30, 10, 20]; + const results = await runPool( + delays, + { concurrency: 3 }, + async (delay, index) => { + await new Promise((r) => setTimeout(r, delay)); + return index; + }, + ); + expect(results).toEqual([0, 1, 2]); + }); + + it("never runs more than `concurrency` tasks at once", async () => { + let inFlight = 0; + let maxInFlight = 0; + const items = Array.from({ length: 8 }, (_, i) => i); + + await runPool(items, { concurrency: 3 }, async () => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + await new Promise((r) => setTimeout(r, 5)); + inFlight--; + }); + + expect(maxInFlight).toBeLessThanOrEqual(3); + }); + + it("aborts every outstanding task's own signal when the caller's signal aborts", async () => { + const controller = new AbortController(); + const abortedFlags: boolean[] = [false, false, false]; + const started = [deferred(), deferred(), deferred()]; + + const run = runPool( + [0, 1, 2], + { concurrency: 3, signal: controller.signal }, + async (item, index, taskSignal) => { + started[index].resolve(); + await new Promise((resolve) => { + if (taskSignal.aborted) { + abortedFlags[index] = true; + resolve(); + return; + } + taskSignal.addEventListener( + "abort", + () => { + abortedFlags[index] = true; + resolve(); + }, + { once: true }, + ); + }); + return item; + }, + ); + + await Promise.all(started.map((d) => d.promise)); + controller.abort(); + await run; + + expect(abortedFlags).toEqual([true, true, true]); + }); + + it("aborts every other in-flight task's signal when one task's fn throws, and rejects with that error", async () => { + const abortedFlags: boolean[] = [false, false, false]; + const started = [deferred(), deferred(), deferred()]; + + const run = runPool( + [0, 1, 2], + { concurrency: 3 }, + async (item, index, taskSignal) => { + started[index].resolve(); + if (index === 0) { + await new Promise((r) => setTimeout(r, 10)); + throw new Error("boom"); + } + await new Promise((resolve) => { + if (taskSignal.aborted) { + abortedFlags[index] = true; + resolve(); + return; + } + taskSignal.addEventListener( + "abort", + () => { + abortedFlags[index] = true; + resolve(); + }, + { once: true }, + ); + }); + return item; + }, + ); + + await Promise.all(started.map((d) => d.promise)); + await expect(run).rejects.toThrow("boom"); + expect(abortedFlags).toEqual([false, true, true]); + }); + + it("surfaces only the first error when multiple tasks fail, without unhandled rejections", async () => { + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => + unhandledRejections.push(reason); + process.on("unhandledRejection", onUnhandledRejection); + + try { + const run = runPool( + [0, 1, 2], + { concurrency: 3 }, + async (_item, index) => { + await new Promise((r) => setTimeout(r, index * 5)); + throw new Error(`fail-${index}`); + }, + ); + await expect(run).rejects.toThrow("fail-0"); + await new Promise((r) => setTimeout(r, 20)); + expect(unhandledRejections).toEqual([]); + } finally { + process.removeListener("unhandledRejection", onUnhandledRejection); + } + }); + + it("does not run further queued items once a task has failed", async () => { + const started: number[] = []; + await runPool([0, 1, 2, 3, 4], { concurrency: 1 }, async (item, index) => { + started.push(index); + if (index === 1) throw new Error("boom"); + return item; + }).catch(() => {}); + + expect(started).toEqual([0, 1]); + }); +}); diff --git a/packages/harness/src/extensions/subagent/process/pool.ts b/packages/harness/src/extensions/subagent/process/pool.ts new file mode 100644 index 0000000000..5c6db0475d --- /dev/null +++ b/packages/harness/src/extensions/subagent/process/pool.ts @@ -0,0 +1,70 @@ +/** + * Bounded concurrent execution over an arbitrary set of abortable async + * tasks. Knows nothing about agents, tasks, or processes — `run-agent.ts`'s + * `runAgent` already kills its own child process when the signal it's given + * aborts, so `runPool` only has to fan a single abort out to every in-flight + * task's own signal; it doesn't need to track child-process handles itself. + * That's what makes "abort kills every outstanding child" hold: each task is + * individually responsible for its own cleanup on abort, and every task here + * is always given a signal that aborts together. + */ + +export interface RunPoolOptions { + concurrency: number; + signal?: AbortSignal; +} + +export async function runPool( + items: TIn[], + options: RunPoolOptions, + fn: (item: TIn, index: number, signal: AbortSignal) => Promise, +): Promise { + if (items.length === 0) return []; + + const controller = new AbortController(); + const forwardAbort = () => controller.abort(options.signal?.reason); + if (options.signal) { + if (options.signal.aborted) forwardAbort(); + else options.signal.addEventListener("abort", forwardAbort, { once: true }); + } + + const limit = Math.max(1, Math.min(options.concurrency, items.length)); + const results: TOut[] = new Array(items.length); + let nextIndex = 0; + // If any task's `fn` throws, we abort every other still-running task before + // surfacing the error, so a single failure can't leave orphaned work (and + // orphaned child processes, for `run-agent.ts`'s use of this) running + // unobserved in the background. Workers never reject themselves — errors + // are captured here and re-thrown once, after every worker has actually + // finished — so `Promise.all` always resolves and we don't risk additional + // unhandled rejections from other workers failing after the first one. + let firstError: unknown; + let hasError = false; + + const worker = async (): Promise => { + for (;;) { + if (hasError) return; + const current = nextIndex++; + if (current >= items.length) return; + try { + results[current] = await fn(items[current], current, controller.signal); + } catch (error) { + if (!hasError) { + hasError = true; + firstError = error; + controller.abort(); + } + return; + } + } + }; + + try { + await Promise.all(new Array(limit).fill(null).map(() => worker())); + } finally { + options.signal?.removeEventListener("abort", forwardAbort); + } + + if (hasError) throw firstError; + return results; +} diff --git a/packages/harness/src/extensions/subagent/prompts/implement-and-review.md b/packages/harness/src/extensions/subagent/prompts/implement-and-review.md new file mode 100644 index 0000000000..cb43627f70 --- /dev/null +++ b/packages/harness/src/extensions/subagent/prompts/implement-and-review.md @@ -0,0 +1,10 @@ +--- +description: Implement a task with worker, then have reviewer check the change before you report back +argument-hint: "" +--- +Use the `subagent` tool in chain mode: + +1. `worker` — implement: $ARGUMENTS +2. `reviewer` — review the change described in `{previous}` for correctness, missing tests, and cleanup; apply small fixes directly if needed + +If the reviewer's verdict is "changes requested" for anything non-trivial, summarize the outstanding issues for me instead of silently looping. diff --git a/packages/harness/src/extensions/subagent/prompts/parallel-review.md b/packages/harness/src/extensions/subagent/prompts/parallel-review.md new file mode 100644 index 0000000000..55b02d362d --- /dev/null +++ b/packages/harness/src/extensions/subagent/prompts/parallel-review.md @@ -0,0 +1,13 @@ +--- +description: Run several fresh reviewer subagents in parallel, each with a distinct angle, then synthesize +argument-hint: "[diff or change description]" +--- +Use the `subagent` tool in parallel mode to run three `reviewer` subagents concurrently against the following change, each with a distinct angle: + +- correctness and edge cases +- test coverage +- cleanup / style / dead code + +Change to review: ${1:-the current diff} + +After all three complete, synthesize their findings into one prioritized list (must-fix vs. nice-to-have) instead of repeating each reviewer's output verbatim. diff --git a/packages/harness/src/extensions/subagent/prompts/scout-and-plan.md b/packages/harness/src/extensions/subagent/prompts/scout-and-plan.md new file mode 100644 index 0000000000..7f7bad9f9e --- /dev/null +++ b/packages/harness/src/extensions/subagent/prompts/scout-and-plan.md @@ -0,0 +1,10 @@ +--- +description: Scout the codebase, then have planner turn the findings into an implementation plan +argument-hint: "" +--- +Use the `subagent` tool in chain mode to research and plan the following task, without implementing anything yet: + +1. `scout` — find the files, entry points, and data flow relevant to: $ARGUMENTS +2. `planner` — using `{previous}` (scout's findings), produce a concrete, ordered implementation plan for: $ARGUMENTS + +Report the plan back to me. Do not start implementing until I confirm it. diff --git a/packages/harness/src/extensions/subagent/render.test.ts b/packages/harness/src/extensions/subagent/render.test.ts new file mode 100644 index 0000000000..43aa0b0091 --- /dev/null +++ b/packages/harness/src/extensions/subagent/render.test.ts @@ -0,0 +1,209 @@ +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { describe, expect, it } from "vitest"; +import { + formatUsageStats, + renderSubagentCall, + renderSubagentResult, +} from "./render"; +import type { SingleRunResult } from "./run-agent"; + +function makeTheme(): Theme { + return { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + underline: (text: string) => text, + inverse: (text: string) => text, + strikethrough: (text: string) => text, + } as unknown as Theme; +} + +function successResult( + overrides: Partial = {}, +): SingleRunResult { + return { + runId: "run-1", + agent: "scout", + task: "look around", + exitCode: 0, + messages: [ + { + role: "assistant", + content: [{ type: "text", text: "found it" }], + } as never, + ], + stderr: "", + usage: { + input: 100, + output: 50, + cacheRead: 0, + cacheWrite: 0, + cost: 0.01, + contextTokens: 150, + turns: 1, + }, + ...overrides, + }; +} + +describe("formatUsageStats", () => { + it("formats a full set of usage fields", () => { + const text = formatUsageStats( + { + input: 1200, + output: 500, + cacheRead: 0, + cacheWrite: 0, + cost: 0.0123, + contextTokens: 2000, + turns: 2, + }, + "anthropic/opus", + ); + expect(text).toContain("2 turns"); + expect(text).toContain("$0.0123"); + expect(text).toContain("anthropic/opus"); + }); + + it("returns an empty string when there's nothing to show", () => { + expect( + formatUsageStats({ + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 0, + }), + ).toBe(""); + }); +}); + +describe("renderSubagentCall", () => { + const theme = makeTheme(); + + it("renders single mode", () => { + const component = renderSubagentCall( + { agent: "scout", task: "find the auth code" }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); + + it("renders parallel mode with a task count", () => { + const component = renderSubagentCall( + { + tasks: [ + { agent: "scout", task: "a" }, + { agent: "reviewer", task: "b" }, + ], + }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); + + it("renders chain mode with a step count", () => { + const component = renderSubagentCall( + { chain: [{ agent: "scout", task: "a" }] }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); +}); + +describe("renderSubagentResult", () => { + const theme = makeTheme(); + + it("falls back to plain text when there are no results", () => { + const component = renderSubagentResult( + { + content: [{ type: "text", text: "nothing" }], + details: { mode: "single", results: [] }, + }, + { expanded: false, isPartial: false }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); + + it("renders a collapsed single result", () => { + const component = renderSubagentResult( + { + content: [{ type: "text", text: "done" }], + details: { mode: "single", results: [successResult()] }, + }, + { expanded: false, isPartial: false }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); + + it("renders an expanded single result as a Container with Markdown output", () => { + const component = renderSubagentResult( + { + content: [{ type: "text", text: "done" }], + details: { mode: "single", results: [successResult()] }, + }, + { expanded: true, isPartial: false }, + theme, + ); + expect(component.constructor.name).toBe("Container"); + }); + + it("renders parallel results without a stale runId hint (runId only ever accompanies empty results)", () => { + const component = renderSubagentResult( + { + content: [{ type: "text", text: "done" }], + details: { + mode: "parallel", + results: [successResult(), successResult({ agent: "reviewer" })], + }, + }, + { expanded: false, isPartial: false }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); + + it("renders the runId hint for a background-dispatch result (runId set, results empty)", () => { + const component = renderSubagentResult( + { + content: [ + { + type: "text", + text: "Started in background as run abcdef123456. Check progress with /subagents-fleet.", + }, + ], + details: { mode: "parallel", results: [], runId: "abcdef123456" }, + }, + { expanded: false, isPartial: false }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + const rendered = component.render(200).join("\n"); + expect(rendered).toContain("started in background"); + expect(rendered).toContain("run abcdef12"); + expect(rendered).toContain("/subagents-fleet"); + }); + + it("marks a failed result distinctly from a running one", () => { + const failed = successResult({ + exitCode: 1, + stopReason: "error", + errorMessage: "boom", + }); + const running = successResult({ exitCode: -1, agent: "worker" }); + const component = renderSubagentResult( + { + content: [{ type: "text", text: "..." }], + details: { mode: "parallel", results: [failed, running] }, + }, + { expanded: false, isPartial: false }, + theme, + ); + expect(component.constructor.name).toBe("Text"); + }); +}); diff --git a/packages/harness/src/extensions/subagent/render.ts b/packages/harness/src/extensions/subagent/render.ts new file mode 100644 index 0000000000..580eb12066 --- /dev/null +++ b/packages/harness/src/extensions/subagent/render.ts @@ -0,0 +1,234 @@ +/** + * Custom `renderCall`/`renderResult` for the `subagent` tool: collapsed + * (default) and expanded (Ctrl+O) views, live progress for running parallel + * tasks, and per-run usage stats. Purely presentational over `format.ts`'s + * pure data — no behavior change to any other module. + */ + +import type { + AgentToolResult, + Theme, + ToolRenderResultOptions, +} from "@earendil-works/pi-coding-agent"; +import { getMarkdownTheme } from "@earendil-works/pi-coding-agent"; +import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; +import { getFinalOutput } from "./format"; +import { + isFailedResult, + type SingleRunResult, + type UsageStats, +} from "./run-agent"; + +const COLLAPSED_RESULT_LINES = 3; + +function formatTokens(count: number): string { + if (count < 1000) return count.toString(); + if (count < 10_000) return `${(count / 1000).toFixed(1)}k`; + if (count < 1_000_000) return `${Math.round(count / 1000)}k`; + return `${(count / 1_000_000).toFixed(1)}M`; +} + +export function formatUsageStats(usage: UsageStats, model?: string): string { + const parts: string[] = []; + if (usage.turns) + parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`); + if (usage.input) parts.push(`\u2191${formatTokens(usage.input)}`); + if (usage.output) parts.push(`\u2193${formatTokens(usage.output)}`); + if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`); + if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`); + if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`); + if (usage.contextTokens > 0) + parts.push(`ctx:${formatTokens(usage.contextTokens)}`); + if (model) parts.push(model); + return parts.join(" "); +} + +function statusIcon(theme: Theme, result: SingleRunResult): string { + if (result.exitCode === -1) return theme.fg("warning", "\u23f3"); + return isFailedResult(result) + ? theme.fg("error", "\u2717") + : theme.fg("success", "\u2713"); +} + +interface SubagentRenderDetails { + mode: "single" | "parallel" | "chain"; + results: SingleRunResult[]; + runId?: string; +} + +export function renderSubagentCall( + args: { + agent?: string; + task?: string; + tasks?: Array<{ agent: string; task: string }>; + chain?: Array<{ agent: string; task: string }>; + background?: boolean; + }, + theme: Theme, +): InstanceType { + const bgTag = args.background ? theme.fg("muted", " [background]") : ""; + + if (args.chain && args.chain.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `chain (${args.chain.length} steps)`) + + bgTag; + for (const [i, step] of args.chain.slice(0, 3).entries()) { + const preview = step.task.replace(/\{previous\}/g, "").trim(); + text += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", step.agent)} ${theme.fg("dim", preview.slice(0, 40))}`; + } + return new Text(text, 0, 0); + } + + if (args.tasks && args.tasks.length > 0) { + let text = + theme.fg("toolTitle", theme.bold("subagent ")) + + theme.fg("accent", `parallel (${args.tasks.length} tasks)`) + + bgTag; + for (const task of args.tasks.slice(0, 3)) { + text += `\n ${theme.fg("accent", task.agent)} ${theme.fg("dim", task.task.slice(0, 40))}`; + } + return new Text(text, 0, 0); + } + + const agentName = args.agent ?? "..."; + const preview = args.task ? args.task.slice(0, 60) : "..."; + return new Text( + `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", agentName)}${bgTag}\n ${theme.fg("dim", preview)}`, + 0, + 0, + ); +} + +function renderSingle( + result: SingleRunResult, + theme: Theme, + expanded: boolean, +) { + const icon = statusIcon(theme, result); + const finalOutput = getFinalOutput(result.messages); + const usageStr = formatUsageStats(result.usage, result.model); + + if (!expanded) { + let text = `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`; + if (isFailedResult(result) && result.errorMessage) { + text += `\n${theme.fg("error", result.errorMessage)}`; + } else { + const preview = finalOutput + .split("\n") + .slice(0, COLLAPSED_RESULT_LINES) + .join("\n"); + text += preview + ? `\n${theme.fg("toolOutput", preview)}` + : `\n${theme.fg("muted", "(no output)")}`; + } + if (usageStr) text += `\n${theme.fg("dim", usageStr)}`; + return new Text(text, 0, 0); + } + + const container = new Container(); + container.addChild( + new Text( + `${icon} ${theme.fg("toolTitle", theme.bold(result.agent))}`, + 0, + 0, + ), + ); + container.addChild(new Spacer(1)); + container.addChild( + new Text( + theme.fg("muted", "\u2500\u2500\u2500 Task \u2500\u2500\u2500"), + 0, + 0, + ), + ); + container.addChild(new Text(theme.fg("dim", result.task), 0, 0)); + container.addChild(new Spacer(1)); + container.addChild( + new Text( + theme.fg("muted", "\u2500\u2500\u2500 Output \u2500\u2500\u2500"), + 0, + 0, + ), + ); + if (isFailedResult(result) && result.errorMessage) { + container.addChild(new Text(theme.fg("error", result.errorMessage), 0, 0)); + } else if (finalOutput) { + container.addChild( + new Markdown(finalOutput.trim(), 0, 0, getMarkdownTheme()), + ); + } else { + container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0)); + } + if (usageStr) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("dim", usageStr), 0, 0)); + } + return container; +} + +export function renderSubagentResult( + result: AgentToolResult, + options: ToolRenderResultOptions, + theme: Theme, +): InstanceType | InstanceType { + const details = result.details; + + // Background dispatch: `extension.ts` returns `{ mode, results: [], runId }` + // immediately, before any child has produced output. Render the runId hint + // here explicitly — this combination (runId set, results empty) never + // reaches the mode-specific branches below, since those all assume at + // least one result to render. + if (details?.runId && details.results.length === 0) { + const text = result.content[0]; + return new Text( + `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", "started in background")}\n${ + text?.type === "text" ? text.text : "" + }\n${theme.fg("muted", `run ${details.runId.slice(0, 8)} \u2014 /subagents-fleet`)}`, + 0, + 0, + ); + } + + if (!details || details.results.length === 0) { + const text = result.content[0]; + return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0); + } + + if (details.mode === "single") { + return renderSingle(details.results[0], theme, options.expanded); + } + + const label = details.mode === "chain" ? "chain" : "parallel"; + const successCount = details.results.filter( + (r) => !isFailedResult(r) && r.exitCode !== -1, + ).length; + const running = details.results.filter((r) => r.exitCode === -1).length; + const status = + running > 0 + ? `${successCount}/${details.results.length} done, ${running} running` + : `${successCount}/${details.results.length} succeeded`; + + if (!options.expanded) { + let text = `${theme.fg("toolTitle", theme.bold(`${label} `))}${theme.fg("accent", status)}`; + for (const r of details.results) { + text += `\n${statusIcon(theme, r)} ${theme.fg("accent", r.agent)}`; + } + text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`; + return new Text(text, 0, 0); + } + + const container = new Container(); + container.addChild( + new Text( + `${theme.fg("toolTitle", theme.bold(`${label} `))}${theme.fg("accent", status)}`, + 0, + 0, + ), + ); + for (const r of details.results) { + container.addChild(new Spacer(1)); + container.addChild(renderSingle(r, theme, true)); + } + return container; +} diff --git a/packages/harness/src/extensions/subagent/rpc.test.ts b/packages/harness/src/extensions/subagent/rpc.test.ts new file mode 100644 index 0000000000..dc1232f0a3 --- /dev/null +++ b/packages/harness/src/extensions/subagent/rpc.test.ts @@ -0,0 +1,233 @@ +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { runAgentMock } = vi.hoisted(() => ({ runAgentMock: vi.fn() })); +vi.mock("./run-agent", async () => { + const actual = + await vi.importActual("./run-agent"); + return { ...actual, runAgent: runAgentMock }; +}); + +import { + registerSubagentRpc, + SUBAGENT_RPC_REQUEST_CHANNEL, + subagentRpcReplyChannel, +} from "./rpc"; + +class FakeEventBus { + private handlers = new Map void>>(); + on(channel: string, handler: (data: unknown) => void): () => void { + const list = this.handlers.get(channel) ?? []; + list.push(handler); + this.handlers.set(channel, list); + return () => {}; + } + emit(channel: string, data: unknown): void { + for (const handler of this.handlers.get(channel) ?? []) handler(data); + } +} + +function makePi() { + const events = new FakeEventBus(); + const lifecycleHandlers: Record< + string, + (event: unknown, ctx: unknown) => void + > = {}; + const pi = { + events, + on: (event: string, handler: (event: unknown, ctx: unknown) => void) => { + lifecycleHandlers[event] = handler; + }, + } as unknown as ExtensionAPI; + return { pi, events, lifecycleHandlers }; +} + +function waitForReply( + events: FakeEventBus, + requestId: string, +): Promise { + return new Promise((resolve) => { + events.on(subagentRpcReplyChannel(requestId), resolve); + }); +} + +describe("registerSubagentRpc", () => { + beforeEach(() => { + runAgentMock.mockReset(); + }); + + it("replies to ping", async () => { + const { pi, events } = makePi(); + registerSubagentRpc(pi); + + const replyPromise = waitForReply(events, "req-1"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-1", + method: "ping", + }); + + expect(await replyPromise).toMatchObject({ + success: true, + data: { pong: true, version: 1 }, + }); + }); + + it("ignores malformed requests (wrong version, missing fields)", async () => { + const { pi, events } = makePi(); + const onRequest = vi.fn(); + registerSubagentRpc(pi); + events.on(SUBAGENT_RPC_REQUEST_CHANNEL, onRequest); + + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 2, + requestId: "x", + method: "ping", + }); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { requestId: "x" }); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, null); + + // These reach the raw listener (proving emit works) but produce no replies. + expect(onRequest).toHaveBeenCalledTimes(3); + }); + + it("replies with an error for an unknown method", async () => { + const { pi, events } = makePi(); + registerSubagentRpc(pi); + + const replyPromise = waitForReply(events, "req-2"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-2", + method: "not-a-method", + }); + + expect(await replyPromise).toMatchObject({ + success: false, + error: expect.stringMatching(/Unknown method/), + }); + }); + + it("status returns the (empty) run list when there are no runs", async () => { + const { pi, events } = makePi(); + registerSubagentRpc(pi); + + const replyPromise = waitForReply(events, "req-3"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-3", + method: "status", + }); + + const reply = (await replyPromise) as { + success: boolean; + data: { runs: unknown[] }; + }; + expect(reply.success).toBe(true); + expect(Array.isArray(reply.data.runs)).toBe(true); + }); + + it("interrupt reports interrupted: false for an unknown runId", async () => { + const { pi, events } = makePi(); + registerSubagentRpc(pi); + + const replyPromise = waitForReply(events, "req-4"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-4", + method: "interrupt", + params: { runId: "nope" }, + }); + + expect(await replyPromise).toMatchObject({ + success: true, + data: { interrupted: false }, + }); + }); + + it("spawn errors when there's no active session context yet", async () => { + const { pi, events } = makePi(); + registerSubagentRpc(pi); + + const replyPromise = waitForReply(events, "req-5"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-5", + method: "spawn", + params: { agent: "scout", task: "look around" }, + }); + + expect(await replyPromise).toMatchObject({ + success: false, + error: expect.stringMatching(/No active session context/), + }); + }); + + it("spawn errors for an unknown agent", async () => { + const { pi, events, lifecycleHandlers } = makePi(); + registerSubagentRpc(pi); + lifecycleHandlers.session_start({}, { cwd: "/repo" }); + + const replyPromise = waitForReply(events, "req-6"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-6", + method: "spawn", + params: { agent: "not-real", task: "x" }, + }); + + expect(await replyPromise).toMatchObject({ + success: false, + error: expect.stringMatching(/Unknown agent/), + }); + }); + + it("spawn starts a background run and replies with a runId once a session context is known", async () => { + const { pi, events, lifecycleHandlers } = makePi(); + registerSubagentRpc(pi); + lifecycleHandlers.session_start({}, { cwd: "/repo" }); + + runAgentMock.mockImplementation(async ({ task }: { task: string }) => ({ + runId: "child-run", + agent: "scout", + task, + exitCode: 0, + messages: [], + stderr: "", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 0, + }, + model: "anthropic/opus", + })); + + const replyPromise = waitForReply(events, "req-7"); + events.emit(SUBAGENT_RPC_REQUEST_CHANNEL, { + version: 1, + requestId: "req-7", + method: "spawn", + params: { + agent: "scout", + task: "find auth code", + context: "extra context", + }, + }); + + const reply = (await replyPromise) as { + success: boolean; + data: { runId: string }; + }; + expect(reply.success).toBe(true); + expect(reply.data.runId).toBeTruthy(); + + await new Promise((r) => setTimeout(r, 10)); + expect(runAgentMock).toHaveBeenCalledTimes(1); + expect(runAgentMock.mock.calls[0][0].task).toBe("find auth code"); + expect(runAgentMock.mock.calls[0][0].context).toBe("extra context"); + }); +}); diff --git a/packages/harness/src/extensions/subagent/rpc.ts b/packages/harness/src/extensions/subagent/rpc.ts new file mode 100644 index 0000000000..153226f20e --- /dev/null +++ b/packages/harness/src/extensions/subagent/rpc.ts @@ -0,0 +1,166 @@ +/** + * Lets other in-process extensions spawn/interrupt/stop subagents and check + * status through pi's shared event bus (`pi.events`), without depending on + * this package. Versioned so the wire format can evolve independently of + * this extension's internal modules. + * + * `pi.events` handlers only receive plain data, not an `ExtensionContext` — + * `spawn` needs one (to resolve the caller's model/credentials via + * `run-agent.ts`). We cache the most recently seen `ExtensionContext` from + * broad, frequent lifecycle events (`session_start`, `turn_start`) so a + * reasonably fresh one is available whenever an RPC `spawn` request arrives. + */ +import type { + ExtensionAPI, + ExtensionContext, +} from "@earendil-works/pi-coding-agent"; +import { findBundledAgent, listBundledAgentNames } from "./agents"; +import { backgroundRuns } from "./background-runner"; +import { listRuns } from "./lifecycle"; + +export const SUBAGENT_RPC_VERSION = 1; +export const SUBAGENT_RPC_REQUEST_CHANNEL = "subagents:rpc:v1:request"; + +export function subagentRpcReplyChannel(requestId: string): string { + return `subagents:rpc:v1:reply:${requestId}`; +} + +export type SubagentRpcMethod = + | "ping" + | "status" + | "spawn" + | "interrupt" + | "stop"; + +export interface SubagentRpcRequest { + version: 1; + requestId: string; + method: SubagentRpcMethod; + params?: { + agent?: string; + task?: string; + context?: string; + runId?: string; + }; +} + +export type SubagentRpcReply = + | { version: 1; requestId: string; success: true; data: unknown } + | { version: 1; requestId: string; success: false; error: string }; + +function isSubagentRpcRequest(data: unknown): data is SubagentRpcRequest { + if (!data || typeof data !== "object") return false; + const candidate = data as Partial; + return ( + candidate.version === SUBAGENT_RPC_VERSION && + typeof candidate.requestId === "string" && + typeof candidate.method === "string" + ); +} + +async function handleRequest( + pi: ExtensionAPI, + getCtx: () => ExtensionContext | undefined, + request: SubagentRpcRequest, +): Promise { + const reply = (data: unknown) => { + const message: SubagentRpcReply = { + version: SUBAGENT_RPC_VERSION, + requestId: request.requestId, + success: true, + data, + }; + pi.events.emit(subagentRpcReplyChannel(request.requestId), message); + }; + const replyError = (error: string) => { + const message: SubagentRpcReply = { + version: SUBAGENT_RPC_VERSION, + requestId: request.requestId, + success: false, + error, + }; + pi.events.emit(subagentRpcReplyChannel(request.requestId), message); + }; + + try { + switch (request.method) { + case "ping": { + reply({ pong: true, version: SUBAGENT_RPC_VERSION }); + return; + } + case "status": { + const runId = request.params?.runId; + if (runId) { + const run = listRuns().find((r) => r.runId === runId); + if (!run) return replyError(`Unknown runId "${runId}".`); + return reply(run); + } + reply({ runs: listRuns() }); + return; + } + case "interrupt": + case "stop": { + const runId = request.params?.runId; + if (!runId) return replyError("interrupt/stop requires params.runId."); + const interrupted = backgroundRuns.interrupt(runId); + reply({ interrupted }); + return; + } + case "spawn": { + const { agent: agentName, task } = request.params ?? {}; + if (!agentName || !task) + return replyError("spawn requires params.agent and params.task."); + + const agent = findBundledAgent(agentName); + if (!agent) + return replyError( + `Unknown agent "${agentName}". Available: ${listBundledAgentNames().join(", ")}`, + ); + + const ctx = getCtx(); + if (!ctx) + return replyError( + "No active session context available to spawn a subagent from yet.", + ); + + // Lazy import to avoid a module cycle with run-agent.ts at module-eval time. + const { runAgent } = await import("./run-agent"); + const handle = backgroundRuns.start( + { mode: "single", agents: [agentName] }, + async (signal) => { + const result = await runAgent({ + ctx, + agent, + task, + context: request.params?.context, + signal, + }); + return { model: result.model, childRunIds: [result.runId] }; + }, + ); + reply({ runId: handle.runId }); + return; + } + default: + replyError(`Unknown method "${request.method}".`); + } + } catch (error) { + replyError(error instanceof Error ? error.message : String(error)); + } +} + +/** Registers the RPC listener. Call once from the extension factory. */ +export function registerSubagentRpc(pi: ExtensionAPI): void { + let latestCtx: ExtensionContext | undefined; + pi.on("session_start", (_event, ctx) => { + latestCtx = ctx; + }); + pi.on("turn_start", (_event, ctx) => { + latestCtx = ctx; + }); + + pi.events.on(SUBAGENT_RPC_REQUEST_CHANNEL, (data) => { + if (!isSubagentRpcRequest(data)) return; + void handleRequest(pi, () => latestCtx, data); + }); +} diff --git a/packages/harness/src/extensions/subagent/run-agent.test.ts b/packages/harness/src/extensions/subagent/run-agent.test.ts new file mode 100644 index 0000000000..98df869249 --- /dev/null +++ b/packages/harness/src/extensions/subagent/run-agent.test.ts @@ -0,0 +1,165 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { spawnChildProcessMock } = vi.hoisted(() => ({ + spawnChildProcessMock: vi.fn(), +})); +vi.mock("./process/child-process", () => ({ + spawnChildProcess: spawnChildProcessMock, +})); + +import type { AgentConfig } from "./agents"; +import { readStatus, readTranscript } from "./lifecycle"; +import { runAgent } from "./run-agent"; + +function makeModel(): Model { + return { + id: "sonnet", + name: "Sonnet", + api: "anthropic-messages", + provider: "anthropic", + baseUrl: "https://api.anthropic.com", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 8192, + } as Model; +} + +function makeCtx(cwd: string): ExtensionContext { + const model = makeModel(); + return { + cwd, + model, + isProjectTrusted: () => false, + sessionManager: { getBranch: () => [] }, + modelRegistry: { + find: () => model, + getAll: () => [model], + getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test-key" }), + }, + } as unknown as ExtensionContext; +} + +const agent: AgentConfig = { + name: "scout", + description: "test agent", + systemPrompt: "be a scout", + source: "bundled", +}; + +describe("runAgent lifecycle persistence", () => { + let originalHome: string | undefined; + let tmpHome: string; + + beforeEach(() => { + originalHome = process.env.HOME; + tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-run-agent-"), + ); + process.env.HOME = tmpHome; + spawnChildProcessMock.mockReset(); + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("writes its own status.json (completed) and transcript.md for a successful run", async () => { + spawnChildProcessMock.mockImplementation( + ({ onStdoutLine }: { onStdoutLine: (line: string) => void }) => { + const message = { + role: "assistant", + content: [{ type: "text", text: "found the bug" }], + usage: { + input: 10, + output: 5, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 15, + cost: { total: 0.001 }, + }, + stopReason: "end", + }; + onStdoutLine(JSON.stringify({ type: "message_end", message })); + return { exited: Promise.resolve(0), kill: vi.fn() }; + }, + ); + + const result = await runAgent({ + ctx: makeCtx("/repo"), + agent, + task: "find it", + }); + + expect(result.exitCode).toBe(0); + + const status = readStatus(result.runId); + expect(status?.state).toBe("completed"); + expect(status?.model).toBe("anthropic/sonnet"); + expect(status?.mode).toBe("single"); + expect(status?.agents).toEqual(["scout"]); + expect(status?.resultSummary).toContain("found the bug"); + + const transcript = readTranscript(result.runId); + expect(transcript).toContain("found the bug"); + expect(transcript).toContain(`runId: ${result.runId}`); + }); + + it("writes status.json (failed) with the error when auth resolution fails", async () => { + const ctx = makeCtx("/repo"); + ctx.modelRegistry.getApiKeyAndHeaders = vi.fn(async () => ({ + ok: false, + error: "no creds", + })); + + const result = await runAgent({ ctx, agent, task: "find it" }); + + expect(result.exitCode).toBe(1); + expect(spawnChildProcessMock).not.toHaveBeenCalled(); + + const status = readStatus(result.runId); + expect(status?.state).toBe("failed"); + expect(status?.error).toMatch(/No credentials available/); + }); + + it("writes status.json (failed) when the child process exits non-zero", async () => { + spawnChildProcessMock.mockReturnValue({ + exited: Promise.resolve(1), + kill: vi.fn(), + }); + + const result = await runAgent({ + ctx: makeCtx("/repo"), + agent, + task: "find it", + }); + + expect(result.exitCode).toBe(1); + expect(readStatus(result.runId)?.state).toBe("failed"); + }); + + it("writes status.json (aborted) when the signal aborts", async () => { + const controller = new AbortController(); + spawnChildProcessMock.mockImplementation(() => { + controller.abort(); + return { exited: Promise.resolve(1), kill: vi.fn() }; + }); + + const result = await runAgent({ + ctx: makeCtx("/repo"), + agent, + task: "find it", + signal: controller.signal, + }); + + expect(result.stopReason).toBe("aborted"); + expect(readStatus(result.runId)?.state).toBe("aborted"); + }); +}); diff --git a/packages/harness/src/extensions/subagent/run-agent.ts b/packages/harness/src/extensions/subagent/run-agent.ts new file mode 100644 index 0000000000..1ba2ccfee6 --- /dev/null +++ b/packages/harness/src/extensions/subagent/run-agent.ts @@ -0,0 +1,349 @@ +/** + * Turns an `AgentConfig` + task into one concrete child pi process run: + * resolves effective settings/overrides, auth, and policy (`settings.ts`, + * `auth.ts`, `policy.ts`), builds the child's argv and generated support + * files (auth bridge, system prompt), spawns it (`process/child-process.ts`), + * and parses its `--mode json` stdout into a `SingleRunResult`. + * + * This is the only module that knows how to go from "an agent and a task" to + * "a running child process" — `process/pool.ts` and `chain.ts` only ever call + * `runAgent`, never touch `process/child-process.ts` or `auth.ts` directly. + */ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +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"; +import { + getResultOutput, + renderTranscriptMarkdown, + truncateForModel, +} from "./format"; +import { createRunId, endRun, startRun, writeTranscript } from "./lifecycle"; +import { applyModelScope, SubagentPolicyError } from "./policy"; +import { + type ChildProcessHandle, + spawnChildProcess, +} from "./process/child-process"; +import { applyAgentOverrides, loadSubagentSettings } from "./settings"; +import { + pollSupervisorRequests, + type SupervisorRequest, + writeSupervisorBridgeExtension, +} from "./supervisor"; + +export interface UsageStats { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + cost: number; + contextTokens: number; + turns: number; +} + +export interface SingleRunResult { + runId: string; + agent: string; + task: string; + exitCode: number; + messages: Message[]; + stderr: string; + usage: UsageStats; + model?: string; + stopReason?: string; + errorMessage?: string; + warning?: string; + step?: number; +} + +function emptyUsage(): UsageStats { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + cost: 0, + contextTokens: 0, + turns: 0, + }; +} + +export function isFailedResult(result: SingleRunResult): boolean { + return ( + result.exitCode !== 0 || + result.stopReason === "error" || + result.stopReason === "aborted" + ); +} + +/** 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. */ +function siblingExtensionFile(name: string): string { + return fileURLToPath(new URL(`../${name}/index.js`, import.meta.url)); +} + +async function writePromptToTempFile( + agentName: string, + prompt: string, +): Promise<{ dir: string; filePath: string }> { + const tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "posthog-subagent-prompt-"), + ); + const safeName = agentName.replace(/[^\w.-]+/g, "_"); + const filePath = path.join(tmpDir, `prompt-${safeName}.md`); + await withFileMutationQueue(filePath, async () => { + await fs.promises.writeFile(filePath, prompt, { + encoding: "utf-8", + mode: 0o600, + }); + }); + return { dir: tmpDir, filePath }; +} + +async function rmTempDir(dir: string | null): Promise { + if (!dir) return; + await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}); +} + +export type OnRunUpdate = (partial: SingleRunResult) => void; + +/** + * Called when the child asks the supervisor (parent) something via the + * `contact_supervisor` tool. Only invoked for runs where the caller is still + * around to answer live (foreground/parallel/chain) — background runs don't + * pass this, since there's no live UI once the tool call has already + * returned. + */ +export type OnSupervisorRequest = ( + request: SupervisorRequest, +) => Promise | string; + +export interface RunAgentOptions { + ctx: ExtensionContext; + agent: AgentConfig; + task: string; + cwd?: string; + step?: number; + signal?: AbortSignal; + /** Explicit context to forward to the child, on top of `task`. Falls back to a small auto-digest of recent parent turns when unset. */ + context?: string; + onUpdate?: OnRunUpdate; + onSupervisorRequest?: OnSupervisorRequest; + /** Also load `web-access` (web_search/web_fetch) in the child. Default: inferred from `agent.tools`. */ + includeWebAccess?: boolean; +} + +function parseStdoutLine( + result: SingleRunResult, + line: string, + emitUpdate: () => void, +): void { + if (!line.trim()) return; + let event: { type?: string; message?: Message }; + try { + event = JSON.parse(line); + } catch { + return; + } + + if ( + (event.type !== "message_end" && event.type !== "tool_result_end") || + !event.message + ) + return; + + const msg = event.message; + result.messages.push(msg); + + if (msg.role === "assistant") { + result.usage.turns++; + const usage = msg.usage; + if (usage) { + result.usage.input += usage.input || 0; + result.usage.output += usage.output || 0; + result.usage.cacheRead += usage.cacheRead || 0; + result.usage.cacheWrite += usage.cacheWrite || 0; + result.usage.cost += usage.cost?.total || 0; + result.usage.contextTokens = usage.totalTokens || 0; + } + if (msg.stopReason) result.stopReason = msg.stopReason; + if (msg.errorMessage) result.errorMessage = msg.errorMessage; + } + emitUpdate(); +} + +/** + * Runs one agent against one task in an isolated child pi process and + * resolves once the child exits. Foreground-only: callers await this + * directly. (Phase 3 layers background/detached execution on top without + * changing this function's contract.) + */ +export async function runAgent( + options: RunAgentOptions, +): Promise { + const { ctx, agent, task, cwd, step, signal, onUpdate } = options; + const runId = createRunId(); + // Every `runAgent` call — whether it's a plain foreground single run, one + // task in a parallel fan-out, or one step in a chain — gets its own + // lifecycle status + transcript. `background-runner.ts` additionally wraps + // a whole dispatch (single/parallel/chain) in its own job-level record + // whose `childRunIds` point back at these. + const lifecycleStatus = startRun({ + runId, + mode: "single", + agents: [agent.name], + }); + + const result: SingleRunResult = { + runId, + agent: agent.name, + task, + exitCode: 0, + messages: [], + stderr: "", + usage: emptyUsage(), + step, + }; + + let authBridgeDir: string | null = null; + let supervisorBridgeDir: string | null = null; + let tmpPromptDir: string | null = null; + let handle: ChildProcessHandle | undefined; + let supervisorPoller: { stop: () => void } | undefined; + const onAbort = () => handle?.kill(); + + try { + const settings = loadSubagentSettings(ctx.cwd, ctx.isProjectTrusted()); + const effectiveAgent = applyAgentOverrides(agent, settings); + + const modelAuth = await resolveModelAuthWithFallback( + ctx, + effectiveAgent.name, + effectiveAgent.model, + effectiveAgent.fallbackModels, + ).catch((error: unknown) => { + result.exitCode = 1; + result.stopReason = "error"; + result.errorMessage = + error instanceof Error ? error.message : String(error); + return undefined; + }); + if (!modelAuth) return result; + result.model = `${modelAuth.model.provider}/${modelAuth.model.id}`; + + try { + result.warning = applyModelScope(result.model, settings.modelScope); + } catch (error) { + result.exitCode = 1; + result.stopReason = "error"; + result.errorMessage = + error instanceof SubagentPolicyError ? error.message : String(error); + return result; + } + + const authBridge = await writeAuthBridgeExtension(modelAuth); + authBridgeDir = authBridge.dir; + + const args: string[] = ["--mode", "json", "-p", "--no-session"]; + args.push("-e", authBridge.filePath); + + const supervisorBridge = await writeSupervisorBridgeExtension(runId); + supervisorBridgeDir = supervisorBridge.dir; + args.push("-e", supervisorBridge.filePath); + + const wantsWebAccess = + options.includeWebAccess ?? + effectiveAgent.tools?.some( + (t) => t === "web_search" || t === "web_fetch", + ) ?? + false; + if (wantsWebAccess) args.push("-e", siblingExtensionFile("web-access")); + + args.push("--model", result.model); + if (effectiveAgent.tools && effectiveAgent.tools.length > 0) + args.push("--tools", effectiveAgent.tools.join(",")); + if (effectiveAgent.thinking) + args.push("--thinking", effectiveAgent.thinking); + + if (effectiveAgent.systemPrompt.trim()) { + const tmp = await writePromptToTempFile( + effectiveAgent.name, + effectiveAgent.systemPrompt, + ); + tmpPromptDir = tmp.dir; + args.push("--append-system-prompt", tmp.filePath); + } + + const forwardedContext = resolveContext(ctx, options.context); + args.push(composeTaskWithContext(task, forwardedContext)); + + const invocation = piCliInvocation(args); + const emitUpdate = () => onUpdate?.(result); + + handle = spawnChildProcess({ + command: invocation.command, + args: invocation.args, + cwd: cwd ?? ctx.cwd, + env: invocation.env, + onStdoutLine: (line) => parseStdoutLine(result, line, emitUpdate), + onStderrChunk: (chunk) => { + result.stderr += chunk; + }, + }); + + if (signal) { + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + } + + if (options.onSupervisorRequest) { + supervisorPoller = pollSupervisorRequests( + runId, + options.onSupervisorRequest, + ); + } + + result.exitCode = await handle.exited; + if (signal?.aborted) { + result.stopReason = "aborted"; + result.errorMessage = "Subagent was aborted"; + } + return result; + } finally { + signal?.removeEventListener("abort", onAbort); + supervisorPoller?.stop(); + try { + writeTranscript(runId, renderTranscriptMarkdown(result)); + endRun( + lifecycleStatus, + result.stopReason === "aborted" + ? "aborted" + : isFailedResult(result) + ? "failed" + : "completed", + result.errorMessage, + { + model: result.model, + totalTokens: + result.usage.contextTokens || + result.usage.input + result.usage.output, + totalCost: result.usage.cost, + resultSummary: truncateForModel(getResultOutput(result), 2000), + }, + ); + } catch { + /* lifecycle/transcript persistence is best-effort; never fail the run over it */ + } + await rmTempDir(authBridgeDir); + await rmTempDir(supervisorBridgeDir); + await rmTempDir(tmpPromptDir); + } +} diff --git a/packages/harness/src/extensions/subagent/settings.test.ts b/packages/harness/src/extensions/subagent/settings.test.ts new file mode 100644 index 0000000000..b8d3dfb696 --- /dev/null +++ b/packages/harness/src/extensions/subagent/settings.test.ts @@ -0,0 +1,155 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AgentConfig } from "./agents"; +import { applyAgentOverrides, loadSubagentSettings } from "./settings"; + +const agent: AgentConfig = { + name: "worker", + description: "test agent", + systemPrompt: "test", + source: "bundled", +}; + +describe("applyAgentOverrides", () => { + it("returns the agent unchanged when there are no settings", () => { + const effective = applyAgentOverrides(agent, {}); + expect(effective.model).toBeUndefined(); + expect(effective.tools).toBeUndefined(); + expect(effective.thinking).toBeUndefined(); + expect(effective.fallbackModels).toBeUndefined(); + }); + + it("falls back to settings.defaultModel when the agent has no model", () => { + const effective = applyAgentOverrides(agent, { + defaultModel: "anthropic/opus", + }); + expect(effective.model).toBe("anthropic/opus"); + }); + + it("prefers a per-agent override over settings.defaultModel", () => { + const effective = applyAgentOverrides(agent, { + defaultModel: "anthropic/opus", + agentOverrides: { worker: { model: "openai/gpt-5" } }, + }); + expect(effective.model).toBe("openai/gpt-5"); + }); + + it("parses a comma-separated tools override", () => { + const effective = applyAgentOverrides(agent, { + agentOverrides: { worker: { tools: "read, grep ,find" } }, + }); + expect(effective.tools).toEqual(["read", "grep", "find"]); + }); + + it("forces thinking off when settings.disableThinking is set, ignoring a per-agent thinking override", () => { + const effective = applyAgentOverrides(agent, { + disableThinking: true, + agentOverrides: { worker: { thinking: "high" } }, + }); + expect(effective.thinking).toBe("off"); + }); + + it("passes through fallbackModels from a per-agent override", () => { + const effective = applyAgentOverrides(agent, { + agentOverrides: { worker: { fallbackModels: ["anthropic/haiku"] } }, + }); + expect(effective.fallbackModels).toEqual(["anthropic/haiku"]); + }); +}); + +describe("loadSubagentSettings", () => { + let originalHome: string | undefined; + let tmpHome: string; + let tmpProject: string; + + beforeEach(() => { + originalHome = process.env.HOME; + tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-settings-home-"), + ); + tmpProject = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-settings-project-"), + ); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpProject, { recursive: true, force: true }); + }); + + it("returns an empty object when no settings files exist", () => { + expect(loadSubagentSettings(tmpProject)).toEqual({ agentOverrides: {} }); + }); + + it("tolerates invalid JSON without throwing", () => { + const dir = path.join(tmpHome, ".pi", "agent"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "settings.json"), "{ not valid json"); + expect(() => loadSubagentSettings(tmpProject)).not.toThrow(); + }); + + it("merges project settings over user settings, deep-merging agentOverrides", () => { + const userDir = path.join(tmpHome, ".pi", "agent"); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync( + path.join(userDir, "settings.json"), + JSON.stringify({ + subagents: { + defaultModel: "anthropic/opus", + agentOverrides: { scout: { model: "anthropic/haiku" } }, + }, + }), + ); + + const projectConfigDir = path.join(tmpProject, ".pi"); + fs.mkdirSync(projectConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(projectConfigDir, "settings.json"), + JSON.stringify({ + subagents: { + agentOverrides: { worker: { model: "openai/gpt-5" } }, + }, + }), + ); + + const trustedSettings = loadSubagentSettings(tmpProject, true); + expect(trustedSettings.defaultModel).toBe("anthropic/opus"); + expect(trustedSettings.agentOverrides?.scout?.model).toBe( + "anthropic/haiku", + ); + expect(trustedSettings.agentOverrides?.worker?.model).toBe("openai/gpt-5"); + + // Untrusted (or omitted, default false): project settings are ignored + // entirely, not merged — only the user-scope override survives. + const untrustedSettings = loadSubagentSettings(tmpProject); + expect(untrustedSettings.defaultModel).toBe("anthropic/opus"); + expect(untrustedSettings.agentOverrides?.scout?.model).toBe( + "anthropic/haiku", + ); + expect(untrustedSettings.agentOverrides?.worker).toBeUndefined(); + }); + + it("never reads project settings.json when the project isn't trusted, even without a user settings file", () => { + const projectConfigDir = path.join(tmpProject, ".pi"); + fs.mkdirSync(projectConfigDir, { recursive: true }); + fs.writeFileSync( + path.join(projectConfigDir, "settings.json"), + JSON.stringify({ + subagents: { + agentOverrides: { oracle: { tools: "read,grep,find,ls,bash,write" } }, + }, + }), + ); + + expect(loadSubagentSettings(tmpProject, false)).toEqual({ + agentOverrides: {}, + }); + expect( + loadSubagentSettings(tmpProject, true).agentOverrides?.oracle?.tools, + ).toBe("read,grep,find,ls,bash,write"); + }); +}); diff --git a/packages/harness/src/extensions/subagent/settings.ts b/packages/harness/src/extensions/subagent/settings.ts new file mode 100644 index 0000000000..3481c41710 --- /dev/null +++ b/packages/harness/src/extensions/subagent/settings.ts @@ -0,0 +1,137 @@ +/** + * Reads the `subagents` section of pi's settings.json (user + nearest + * project), merged project-over-user. Tolerant of missing/invalid files — + * always returns a usable (possibly empty) settings object rather than + * throwing, since a malformed settings file should never break subagent + * delegation entirely. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; +import type { AgentConfig } from "./agents"; + +export type ThinkingLevel = + | "off" + | "minimal" + | "low" + | "medium" + | "high" + | "xhigh"; + +export interface AgentOverride { + model?: string; + thinking?: ThinkingLevel; + tools?: string; + fallbackModels?: string[]; +} + +export interface ModelScopeConfig { + enforce?: boolean; + allow?: string[]; +} + +export interface SubagentSettings { + defaultModel?: string; + disableThinking?: boolean; + agentOverrides?: Record; + modelScope?: ModelScopeConfig; +} + +function readJsonFile(filePath: string): Record | undefined { + try { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function findNearestProjectSettingsFile(cwd: string): string | null { + let currentDir = cwd; + for (;;) { + const candidate = path.join(currentDir, CONFIG_DIR_NAME, "settings.json"); + if (fs.existsSync(candidate)) return candidate; + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir) return null; + currentDir = parentDir; + } +} + +function extractSubagentSettings( + raw: Record | undefined, +): SubagentSettings { + const section = raw?.subagents; + return section && typeof section === "object" + ? (section as SubagentSettings) + : {}; +} + +function mergeSettings( + base: SubagentSettings, + override: SubagentSettings, +): SubagentSettings { + return { + defaultModel: override.defaultModel ?? base.defaultModel, + disableThinking: override.disableThinking ?? base.disableThinking, + agentOverrides: { ...base.agentOverrides, ...override.agentOverrides }, + modelScope: override.modelScope ?? base.modelScope, + }; +} + +/** + * Project-local `.pi/settings.json` can widen a bundled agent's tools (e.g. + * grant a normally read-only agent `bash`/write access) or point it at a + * different model via `agentOverrides` — the same trust concern as + * `.pi/agents/*.md` in `discovery.ts`. `projectTrusted` must reflect + * `ctx.isProjectTrusted()`; project settings are ignored entirely (not just + * unconfirmed) for untrusted projects, since there's no per-call confirm + * step for settings the way there is for project agent files. + */ +export function loadSubagentSettings( + cwd: string, + projectTrusted = false, +): SubagentSettings { + const userSettingsPath = path.join(getAgentDir(), "settings.json"); + const userSettings = extractSubagentSettings(readJsonFile(userSettingsPath)); + + if (!projectTrusted) return mergeSettings(userSettings, {}); + + const projectSettingsPath = findNearestProjectSettingsFile(cwd); + const projectSettings = projectSettingsPath + ? extractSubagentSettings(readJsonFile(projectSettingsPath)) + : {}; + + return mergeSettings(userSettings, projectSettings); +} + +export interface EffectiveAgent extends AgentConfig { + thinking?: ThinkingLevel; + fallbackModels?: string[]; +} + +/** + * Applies `settings.subagents.agentOverrides[agent.name]` and + * `settings.subagents.defaultModel` on top of a static `AgentConfig`. Never + * mutates the input. + */ +export function applyAgentOverrides( + agent: AgentConfig, + settings: SubagentSettings, +): EffectiveAgent { + const override = settings.agentOverrides?.[agent.name]; + const tools = override?.tools + ?.split(",") + .map((t) => t.trim()) + .filter(Boolean); + + return { + ...agent, + model: override?.model ?? agent.model ?? settings.defaultModel, + tools: tools && tools.length > 0 ? tools : agent.tools, + thinking: settings.disableThinking ? "off" : override?.thinking, + fallbackModels: override?.fallbackModels, + }; +} diff --git a/packages/harness/src/extensions/subagent/skills/subagent-orchestration/SKILL.md b/packages/harness/src/extensions/subagent/skills/subagent-orchestration/SKILL.md new file mode 100644 index 0000000000..733b84a01b --- /dev/null +++ b/packages/harness/src/extensions/subagent/skills/subagent-orchestration/SKILL.md @@ -0,0 +1,92 @@ +--- +name: subagent-orchestration +description: How and when to delegate work to subagents via the `subagent` tool (scout, planner, reviewer, worker, oracle). Use when a task involves codebase recon, planning, implementation, review, or a second opinion that would benefit from an isolated context window instead of doing it all inline. +--- + +# Subagent Orchestration + +You (the parent session) can delegate scoped work to focused subagents, each running in +its own isolated pi process with its own context window. Use this to keep your own +context clean and to parallelize independent work. + +## When to delegate + +Delegate when a piece of work is: +- **Self-contained**: it doesn't need your full conversation history, just a task and + some context you can state explicitly. +- **Isolable**: it would otherwise burn a lot of your context window (e.g. broad codebase + search, reading many files) for a result you can summarize down to a few paragraphs. +- **Parallelizable**: several independent instances of it can run at once (e.g. reviewing + three different concerns on the same diff). +- **A second opinion**: you want a fresh, less-anchored perspective before committing to + a plan or a fix. + +Do not delegate trivial one-line changes, or work that fundamentally needs your full +conversation context to do correctly — that's what `context` (below) is for, but if +almost everything is relevant, delegation adds overhead for no benefit. + +## Bundled agents + +| Agent | Use for | Tools | Notes | +|-------|---------|-------|-------| +| `scout` | Fast, read-only recon: find files, entry points, data flow | read, grep, find, ls, bash | Reports compressed findings, never edits | +| `planner` | Turn scout's findings (or your own) into a concrete implementation plan | read, grep, find, ls | Never edits | +| `worker` | General-purpose implementation | full default toolset | Only agent that writes by default | +| `reviewer` | Review a diff/change for correctness, tests, cleanup | read, grep, find, ls, bash | Can apply small fixes | +| `oracle` | Second opinion / challenge assumptions before a risky decision | read, grep, find, ls | Never edits | + +Subagents cannot themselves call `subagent` — they are leaves, not orchestrators. Keep +all delegation decisions in your own (parent) session. + +## The `context` field — always fill it in + +A subagent gets **only** its `task` string, plus a small automatic digest of your last +few conversation turns (as a fallback, not a substitute). It does not see the files +you've already read, tool results you've already seen, or decisions you've already made +unless you put them in `context`. + +**Always pass `context`** with whatever the subagent actually needs: +- File paths and line numbers you already found. +- Decisions already made ("use approach B, not A, because..."). +- Constraints ("don't touch files under vendor/"). + +A subagent given a bare one-line `task` and no `context` will waste its own turns +re-discovering things you already know. + +## Modes + +- **single** — one agent, one task. Default choice. +- **parallel** — `tasks: [...]`, up to 8 tasks / 4 concurrent. Use for independent work + that can run at once, e.g. three reviewers each checking a different concern on the + same diff. +- **chain** — `chain: [...]`, sequential steps where each step's task can reference + `{previous}` (the prior step's final output). Use for a fixed pipeline like + scout → planner → worker. +- **background: true** — any of the above, but returns immediately with a `runId` + instead of waiting. Check on it later with `/subagents-fleet`, or ask "check run + ``" / "interrupt run ``". Only use this for genuinely long-running work you + don't need to block on — you cannot answer the child's `contact_supervisor` + questions once it's running in the background. + +## Recommended pattern + +``` +clarify -> scout -> planner -> worker -> fresh reviewer(s) -> worker (if changes requested) +``` + +This is guidance, not a rigid workflow — decide per task whether you need all of these +steps. For small changes, `worker` alone (or `worker` then one `reviewer`) is enough. + +## Observability + +- `/subagents-fleet` lists recent and in-progress runs (state, duration, agents). +- `/subagents-fleet interrupt ` aborts a background run. +- Every run writes `status.json`, `events.jsonl`, and a full `transcript.md` to + `~/.pi/agent/subagent-runs//` for later inspection. + +## If a subagent contacts you + +A running subagent (foreground/parallel/chain, not background) may pause and ask you a +question via `contact_supervisor` if it's blocked or needs a decision it isn't confident +making on its own. When this happens you'll be prompted for a reply inline — answer +directly; there's no special syntax needed. diff --git a/packages/harness/src/extensions/subagent/supervisor.test.ts b/packages/harness/src/extensions/subagent/supervisor.test.ts new file mode 100644 index 0000000000..c2989986e2 --- /dev/null +++ b/packages/harness/src/extensions/subagent/supervisor.test.ts @@ -0,0 +1,189 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRunId } from "./lifecycle"; +import { + listPendingSupervisorRequests, + pollSupervisorRequests, + replyToSupervisorRequest, + waitForSupervisorReply, + writeSupervisorBridgeExtension, + writeSupervisorRequest, +} from "./supervisor"; + +describe("supervisor mailbox", () => { + let originalHome: string | undefined; + let tmpHome: string; + + beforeEach(() => { + originalHome = process.env.HOME; + tmpHome = fs.mkdtempSync( + path.join(os.tmpdir(), "posthog-subagent-supervisor-"), + ); + process.env.HOME = tmpHome; + }); + + afterEach(() => { + process.env.HOME = originalHome; + fs.rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("writeSupervisorRequest then replyToSupervisorRequest round-trips through waitForSupervisorReply", async () => { + const runId = createRunId(); + const request = writeSupervisorRequest( + runId, + "need_decision", + "Should I proceed?", + ); + + const waitPromise = waitForSupervisorReply(runId, request.requestId, { + pollIntervalMs: 10, + timeoutMs: 2000, + }); + await new Promise((r) => setTimeout(r, 20)); + replyToSupervisorRequest(runId, request.requestId, "Yes, proceed."); + + await expect(waitPromise).resolves.toBe("Yes, proceed."); + }); + + it("waitForSupervisorReply resolves undefined on timeout", async () => { + const runId = createRunId(); + const request = writeSupervisorRequest(runId, "blocked", "stuck"); + await expect( + waitForSupervisorReply(runId, request.requestId, { + pollIntervalMs: 5, + timeoutMs: 20, + }), + ).resolves.toBeUndefined(); + }); + + it("waitForSupervisorReply resolves undefined when the signal aborts", async () => { + const runId = createRunId(); + const request = writeSupervisorRequest(runId, "blocked", "stuck"); + const controller = new AbortController(); + const waitPromise = waitForSupervisorReply(runId, request.requestId, { + pollIntervalMs: 5, + signal: controller.signal, + }); + controller.abort(); + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it("listPendingSupervisorRequests excludes already-replied requests", () => { + const runId = createRunId(); + const a = writeSupervisorRequest(runId, "clarify", "a?"); + const b = writeSupervisorRequest(runId, "clarify", "b?"); + replyToSupervisorRequest(runId, a.requestId, "answered"); + + const pending = listPendingSupervisorRequests(runId); + expect(pending.map((r) => r.requestId)).toEqual([b.requestId]); + }); + + it("listPendingSupervisorRequests returns an empty array when there's no mailbox yet", () => { + expect(listPendingSupervisorRequests(createRunId())).toEqual([]); + }); + + it("pollSupervisorRequests calls onRequest once per new request and writes its reply", async () => { + const runId = createRunId(); + const onRequest = vi.fn(async () => "auto-reply"); + const poller = pollSupervisorRequests(runId, onRequest, 5); + + try { + const request = writeSupervisorRequest( + runId, + "need_decision", + "continue?", + ); + const reply = await waitForSupervisorReply(runId, request.requestId, { + pollIntervalMs: 5, + timeoutMs: 2000, + }); + expect(reply).toBe("auto-reply"); + expect(onRequest).toHaveBeenCalledTimes(1); + } finally { + poller.stop(); + } + }); + + it("keeps polling (does not die or throw unhandled) after onRequest rejects, and still answers a later request", async () => { + const runId = createRunId(); + let callCount = 0; + const onRequest = vi.fn(async () => { + callCount++; + if (callCount === 1) throw new Error("ui.input blew up"); + return "second reply"; + }); + const unhandledRejections: unknown[] = []; + const onUnhandledRejection = (reason: unknown) => + unhandledRejections.push(reason); + process.on("unhandledRejection", onUnhandledRejection); + + const poller = pollSupervisorRequests(runId, onRequest, 5); + try { + const first = writeSupervisorRequest(runId, "need_decision", "first?"); + const firstReply = await waitForSupervisorReply(runId, first.requestId, { + pollIntervalMs: 5, + timeoutMs: 200, + }); + expect(firstReply).toBeUndefined(); // onRequest threw; left unanswered rather than crashing + + const second = writeSupervisorRequest(runId, "need_decision", "second?"); + const secondReply = await waitForSupervisorReply( + runId, + second.requestId, + { pollIntervalMs: 5, timeoutMs: 2000 }, + ); + expect(secondReply).toBe("second reply"); // poller kept running after the first failure + + expect(unhandledRejections).toEqual([]); + } finally { + process.removeListener("unhandledRejection", onUnhandledRejection); + poller.stop(); + } + }); + + it("writeSupervisorBridgeExtension generates a tool that round-trips a request/reply", async () => { + const runId = createRunId(); + const { dir, filePath } = await writeSupervisorBridgeExtension(runId, 2000); + + try { + const mod = (await import(filePath)) as { + default: (pi: { + registerTool: (tool: { + execute: (...args: unknown[]) => Promise; + }) => void; + }) => void; + }; + let registeredTool: + | { execute: (...args: unknown[]) => Promise } + | undefined; + mod.default({ + registerTool: (tool) => { + registeredTool = tool; + }, + }); + if (!registeredTool) + throw new Error("contact_supervisor was not registered"); + + const executePromise = registeredTool.execute( + "call-id", + { reason: "clarify", message: "which approach?" }, + undefined, + ); + + await new Promise((r) => setTimeout(r, 50)); + const pending = listPendingSupervisorRequests(runId); + expect(pending).toHaveLength(1); + expect(pending[0].message).toBe("which approach?"); + replyToSupervisorRequest(runId, pending[0].requestId, "use approach B"); + + const result = (await executePromise) as { + content: Array<{ text: string }>; + }; + expect(result.content[0].text).toBe("use approach B"); + } finally { + await fs.promises.rm(dir, { recursive: true, force: true }); + } + }, 10_000); +}); diff --git a/packages/harness/src/extensions/subagent/supervisor.ts b/packages/harness/src/extensions/subagent/supervisor.ts new file mode 100644 index 0000000000..6578df64fd --- /dev/null +++ b/packages/harness/src/extensions/subagent/supervisor.ts @@ -0,0 +1,255 @@ +/** + * A blocked child can "contact the supervisor" (the parent session) and wait + * for a reply. Children run as separate OS processes with no shared memory + * or `pi.events` bus with the parent, so this is necessarily file-based: a + * per-run mailbox directory (colocated with that run's `lifecycle.ts` + * artifacts) that the child polls for a reply and the parent writes into. + */ +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { withFileMutationQueue } from "@earendil-works/pi-coding-agent"; +import { runDirectory } from "./lifecycle"; + +const DEFAULT_SUPERVISOR_TIMEOUT_MS = 15 * 60_000; + +export type SupervisorReason = "need_decision" | "blocked" | "clarify"; + +export interface SupervisorRequest { + requestId: string; + runId: string; + reason: SupervisorReason; + message: string; + createdAt: number; +} + +function mailboxDir(runId: string): string { + return path.join(runDirectory(runId), "supervisor"); +} + +function requestPath(runId: string, requestId: string): string { + return path.join(mailboxDir(runId), `request-${requestId}.json`); +} + +function replyPath(runId: string, requestId: string): string { + return path.join(mailboxDir(runId), `reply-${requestId}.json`); +} + +// --- Child side ------------------------------------------------------- + +export function writeSupervisorRequest( + runId: string, + reason: SupervisorReason, + message: string, +): SupervisorRequest { + const dir = mailboxDir(runId); + fs.mkdirSync(dir, { recursive: true }); + const request: SupervisorRequest = { + requestId: randomUUID(), + runId, + reason, + message, + createdAt: Date.now(), + }; + fs.writeFileSync( + requestPath(runId, request.requestId), + JSON.stringify(request), + ); + return request; +} + +export interface WaitForSupervisorReplyOptions { + pollIntervalMs?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +/** Polls for a reply, returning `undefined` on timeout or abort rather than throwing. */ +export async function waitForSupervisorReply( + runId: string, + requestId: string, + options: WaitForSupervisorReplyOptions = {}, +): Promise { + const pollIntervalMs = options.pollIntervalMs ?? 500; + const deadline = + options.timeoutMs !== undefined + ? Date.now() + options.timeoutMs + : undefined; + const path_ = replyPath(runId, requestId); + + for (;;) { + if (options.signal?.aborted) return undefined; + if (fs.existsSync(path_)) { + try { + const data = JSON.parse(fs.readFileSync(path_, "utf-8")) as { + message: string; + }; + return data.message; + } catch { + return undefined; + } + } + if (deadline !== undefined && Date.now() >= deadline) return undefined; + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } +} + +// --- Parent side ------------------------------------------------------- + +export function listPendingSupervisorRequests( + runId: string, +): SupervisorRequest[] { + const dir = mailboxDir(runId); + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + const requests: SupervisorRequest[] = []; + for (const entry of entries) { + if (!entry.name.startsWith("request-") || !entry.name.endsWith(".json")) + continue; + const requestId = entry.name.slice("request-".length, -".json".length); + if (fs.existsSync(replyPath(runId, requestId))) continue; + try { + requests.push( + JSON.parse( + fs.readFileSync(requestPath(runId, requestId), "utf-8"), + ) as SupervisorRequest, + ); + } catch { + /* ignore malformed request files */ + } + } + return requests.sort((a, b) => a.createdAt - b.createdAt); +} + +export function replyToSupervisorRequest( + runId: string, + requestId: string, + message: string, +): void { + const dir = mailboxDir(runId); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + replyPath(runId, requestId), + JSON.stringify({ message, repliedAt: Date.now() }), + ); +} + +/** + * Writes a generated child-side extension that registers a `contact_supervisor` + * tool: the child writes a request into this run's mailbox and blocks (up to + * `timeoutMs`) waiting for the parent to reply. Resolves `supervisor.js` and + * `typebox` by absolute path so the generated file works from any tmp + * directory, regardless of the child's own module resolution. + */ +export async function writeSupervisorBridgeExtension( + runId: string, + timeoutMs: number = DEFAULT_SUPERVISOR_TIMEOUT_MS, +): Promise<{ dir: string; filePath: string }> { + const supervisorModulePath = fileURLToPath( + new URL("./supervisor.js", import.meta.url), + ); + const typeboxModulePath = fileURLToPath(import.meta.resolve("typebox")); + + const tmpDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "posthog-subagent-supervisor-"), + ); + const filePath = path.join(tmpDir, "supervisor-bridge.mjs"); + + const source = `import { Type } from ${JSON.stringify(typeboxModulePath)}; +import { writeSupervisorRequest, waitForSupervisorReply } from ${JSON.stringify(supervisorModulePath)}; + +export default function (pi) { + pi.registerTool({ + name: "contact_supervisor", + label: "Contact Supervisor", + description: "Ask the orchestrating parent session a question or report a blocker, and wait for its reply. Use sparingly — the parent may be unavailable and this can time out.", + parameters: Type.Object({ + reason: Type.Union([Type.Literal("need_decision"), Type.Literal("blocked"), Type.Literal("clarify")]), + message: Type.String({ description: "The question or blocker to report to the parent." }), + }), + async execute(_toolCallId, params, signal) { + const request = writeSupervisorRequest(${JSON.stringify(runId)}, params.reason, params.message); + const reply = await waitForSupervisorReply(${JSON.stringify(runId)}, request.requestId, { timeoutMs: ${JSON.stringify(timeoutMs)}, signal }); + return { + content: [{ type: "text", text: reply ?? "(no reply from the supervisor within the timeout; proceed using your best judgment)" }], + details: {}, + }; + }, + }); +} +`; + + await withFileMutationQueue(filePath, async () => { + await fs.promises.writeFile(filePath, source, { + encoding: "utf-8", + mode: 0o600, + }); + }); + return { dir: tmpDir, filePath }; +} + +export interface SupervisorPoller { + stop: () => void; +} + +/** + * Polls a run's mailbox and calls `onRequest` for each new pending request, + * writing whatever it resolves to back as the reply. Used by `run-agent.ts` + * to surface a live child's questions to `ctx.ui` while the parent tool call + * is still active (foreground/parallel/chain). Not used for `background` + * runs — there is no live UI to ask once the tool call has already returned. + */ +export function pollSupervisorRequests( + runId: string, + onRequest: (request: SupervisorRequest) => Promise | string, + intervalMs = 500, +): SupervisorPoller { + const seen = new Set(); + let stopped = false; + let timer: ReturnType | undefined; + + // `tick` is invoked via bare `setTimeout`, whose callback return value is + // ignored — an uncaught rejection here would both (a) silently stop the + // poller forever (the reschedule line below would never run) and (b) + // surface as an unhandled promise rejection in the parent process. Both are + // real risks: `onRequest` can call `ctx.ui.input(...)`, a real UI prompt + // that can reject. Catch everything so a single bad request never takes + // down the poller or the process. + const tick = async () => { + if (stopped) return; + try { + const pending = listPendingSupervisorRequests(runId); + for (const request of pending) { + if (seen.has(request.requestId) || stopped) continue; + seen.add(request.requestId); + try { + const reply = await onRequest(request); + replyToSupervisorRequest(runId, request.requestId, reply); + } catch { + /* onRequest/reply failed for this request; leave it unanswered so + * the child's own wait times out rather than hanging forever or + * crashing the poller. */ + } + } + } catch { + /* listing the mailbox failed (e.g. transient fs error); try again next tick. */ + } finally { + if (!stopped) timer = setTimeout(tick, intervalMs); + } + }; + + timer = setTimeout(tick, intervalMs); + return { + stop: () => { + stopped = true; + if (timer) clearTimeout(timer); + }, + }; +} diff --git a/packages/harness/src/extensions/web-access/render.ts b/packages/harness/src/extensions/web-access/render.ts new file mode 100644 index 0000000000..7343818d28 --- /dev/null +++ b/packages/harness/src/extensions/web-access/render.ts @@ -0,0 +1,44 @@ +/** + * Custom `renderCall` for `web_search`/`web_fetch` so the tool call header + * shows the query/URL being requested instead of a bare `web_search` / + * `web_fetch` label. + */ + +import type { Theme } from "@earendil-works/pi-coding-agent"; +import { Text } from "@earendil-works/pi-tui"; + +const MAX_PREVIEW_LENGTH = 80; + +function truncate(text: string, max = MAX_PREVIEW_LENGTH): string { + return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text; +} + +export function renderWebSearchCall( + args: { query?: string; search_context_size?: string }, + theme: Theme, +): InstanceType { + const query = args?.query; + let text = theme.fg("toolTitle", theme.bold("web_search")); + text += query + ? ` ${theme.fg("accent", truncate(query))}` + : ` ${theme.fg("muted", "...")}`; + if (args?.search_context_size) { + text += theme.fg("dim", ` (${args.search_context_size})`); + } + return new Text(text, 0, 0); +} + +export function renderWebFetchCall( + args: { url?: string; prompt?: string }, + theme: Theme, +): InstanceType { + const url = args?.url; + let text = theme.fg("toolTitle", theme.bold("web_fetch")); + text += url + ? ` ${theme.fg("accent", truncate(url))}` + : ` ${theme.fg("muted", "...")}`; + if (args?.prompt) { + text += `\n ${theme.fg("dim", truncate(args.prompt))}`; + } + return new Text(text, 0, 0); +} diff --git a/packages/harness/src/extensions/web-access/web-fetch.ts b/packages/harness/src/extensions/web-access/web-fetch.ts index de25a241c4..7acecf5d35 100644 --- a/packages/harness/src/extensions/web-access/web-fetch.ts +++ b/packages/harness/src/extensions/web-access/web-fetch.ts @@ -7,6 +7,7 @@ import { tryResolveGatewayAuth, } from "../posthog-provider/gateway-auth"; import type { PosthogProviderOptions } from "../posthog-provider/provider"; +import { renderWebFetchCall } from "./render"; const MAX_URL_LENGTH = 2000; const MAX_CONTENT_LENGTH = 10 * 1024 * 1024; @@ -16,6 +17,11 @@ const MAX_REDIRECTS = 10; const SUMMARIZATION_MODEL = "claude-haiku-4-5"; const turndown = new TurndownService(); +// Strip non-content elements before conversion. Without this, boilerplate +// (inline fonts as base64,