Skip to content

Commit 1e4cd6d

Browse files
committed
feat(harness): expose native Pi runtime
1 parent fc33dfb commit 1e4cd6d

30 files changed

Lines changed: 627 additions & 321 deletions

.github/workflows/build.yml

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

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

.github/workflows/code-build-test.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ jobs:
8888
pnpm --filter @posthog/shared run build
8989
pnpm --filter @posthog/git run build
9090
pnpm --filter @posthog/enricher run build
91+
pnpm --filter @posthog/harness run build
9192
pnpm --filter @posthog/agent run build
9293
9394
# build/Assets.car is gitignored; regenerate it so the packaged app ships
@@ -205,6 +206,7 @@ jobs:
205206
pnpm --filter @posthog/shared run build
206207
pnpm --filter @posthog/git run build
207208
pnpm --filter @posthog/enricher run build
209+
pnpm --filter @posthog/harness run build
208210
pnpm --filter @posthog/agent run build
209211
210212
- name: Build app
@@ -275,6 +277,7 @@ jobs:
275277
pnpm --filter @posthog/shared run build
276278
pnpm --filter @posthog/git run build
277279
pnpm --filter @posthog/enricher run build
280+
pnpm --filter @posthog/harness run build
278281
pnpm --filter @posthog/agent run build
279282
280283
- name: Build app

.github/workflows/code-release.yml

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

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

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

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

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

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

.github/workflows/code-storybook.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,20 @@ jobs:
149149
pid2=$!
150150
wait_all "$pid1" "$pid2"
151151
152-
# @posthog/agent imports @posthog/git's dist, so git must finish
153-
# before the last group starts.
154-
pnpm --filter @posthog/git build
155-
156-
pnpm --filter @posthog/enricher build &
152+
# @posthog/agent imports the dist of @posthog/git and
153+
# @posthog/harness, so both must finish before the last group starts.
154+
pnpm --filter @posthog/git build &
157155
pid3=$!
158-
pnpm --filter @posthog/agent build &
156+
pnpm --filter @posthog/harness build &
159157
pid4=$!
160158
wait_all "$pid3" "$pid4"
161159
160+
pnpm --filter @posthog/enricher build &
161+
pid5=$!
162+
pnpm --filter @posthog/agent build &
163+
pid6=$!
164+
wait_all "$pid5" "$pid6"
165+
162166
- name: Build Storybook
163167
working-directory: apps/code
164168
run: pnpm build-storybook

.github/workflows/code-update-e2e.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ jobs:
106106
pnpm --filter @posthog/shared run build
107107
pnpm --filter @posthog/git run build
108108
pnpm --filter @posthog/enricher run build
109+
pnpm --filter @posthog/harness run build
109110
pnpm --filter @posthog/agent run build
110111
111112
- name: Build old + new update pair

.github/workflows/pr-build-installer.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ jobs:
9393
pnpm --filter @posthog/shared run build
9494
pnpm --filter @posthog/git run build
9595
pnpm --filter @posthog/enricher run build
96+
pnpm --filter @posthog/harness run build
9697
pnpm --filter @posthog/agent run build
9798
9899
# build/Assets.car is gitignored; regenerate it so the packaged app ships

.github/workflows/test.yml

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

packages/agent/package.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@
8484
"types": "./dist/handoff-checkpoint.d.ts",
8585
"import": "./dist/handoff-checkpoint.js"
8686
},
87+
"./pi": {
88+
"types": "./dist/pi/rpc-client.d.ts",
89+
"import": "./dist/pi/rpc-client.js"
90+
},
8791
"./server": {
8892
"types": "./dist/server/agent-server.d.ts",
8993
"import": "./dist/server/agent-server.js"
@@ -136,13 +140,15 @@
136140
"@agentclientprotocol/sdk": "1.1.0",
137141
"@anthropic-ai/claude-agent-sdk": "0.3.197",
138142
"@anthropic-ai/sdk": "0.109.0",
143+
"@earendil-works/pi-coding-agent": "catalog:",
139144
"@hono/node-server": "^1.19.9",
140145
"@openai/codex": "0.140.0",
141146
"@opentelemetry/api-logs": "^0.208.0",
142147
"@opentelemetry/exporter-logs-otlp-http": "^0.208.0",
143148
"@opentelemetry/resources": "^2.0.0",
144149
"@opentelemetry/sdk-logs": "^0.208.0",
145150
"@opentelemetry/semantic-conventions": "^1.28.0",
151+
"@posthog/harness": "workspace:*",
146152
"@types/jsonwebtoken": "^9.0.10",
147153
"commander": "^14.0.2",
148154
"fflate": "^0.8.2",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { RpcClient } from "@earendil-works/pi-coding-agent";
2+
import { describe, expect, it } from "vitest";
3+
import { createPiRpcClient } from "./rpc-client";
4+
5+
describe("createPiRpcClient", () => {
6+
it("does not put provider credentials in the child environment", () => {
7+
const client = createPiRpcClient({
8+
cwd: "/workspace",
9+
model: "claude-opus-4-8",
10+
providerOptions: { apiKey: "token", region: "us" },
11+
});
12+
13+
expect(client).toBeInstanceOf(RpcClient);
14+
expect(client).toMatchObject({
15+
options: {
16+
cwd: "/workspace",
17+
model: "claude-opus-4-8",
18+
provider: "posthog",
19+
},
20+
});
21+
expect(
22+
(client as unknown as { options: { env?: Record<string, string> } })
23+
.options.env,
24+
).toBeUndefined();
25+
});
26+
});
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { type ChildProcess, spawn } from "node:child_process";
2+
import type { Writable } from "node:stream";
3+
import { StringDecoder } from "node:string_decoder";
4+
import { fileURLToPath } from "node:url";
5+
import {
6+
RpcClient,
7+
type RpcClientOptions,
8+
} from "@earendil-works/pi-coding-agent";
9+
import type { PosthogProviderOptions } from "@posthog/harness/extensions/posthog-provider/provider";
10+
import { safePiEnvironment } from "./rpc-environment";
11+
12+
export type PiRpcClient = RpcClient;
13+
14+
interface RpcClientInternals {
15+
process?: ChildProcess;
16+
stopReadingStdout?: () => void;
17+
stderr: string;
18+
exitError: Error | null;
19+
handleLine(line: string): void;
20+
createProcessExitError(
21+
code: number | null,
22+
signal: NodeJS.Signals | null,
23+
): Error;
24+
rejectPendingRequests(error: Error): void;
25+
}
26+
27+
function attachJsonlReader(
28+
stream: NodeJS.ReadableStream,
29+
onLine: (line: string) => void,
30+
): () => void {
31+
const decoder = new StringDecoder("utf8");
32+
let buffer = "";
33+
const onData = (chunk: Buffer | string) => {
34+
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
35+
let newlineIndex = buffer.indexOf("\n");
36+
while (newlineIndex !== -1) {
37+
const line = buffer.slice(0, newlineIndex);
38+
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
39+
buffer = buffer.slice(newlineIndex + 1);
40+
newlineIndex = buffer.indexOf("\n");
41+
}
42+
};
43+
stream.on("data", onData);
44+
return () => stream.off("data", onData);
45+
}
46+
47+
class SecurePiRpcClient extends RpcClient {
48+
constructor(
49+
private readonly secureOptions: RpcClientOptions,
50+
private readonly providerOptions?: PosthogProviderOptions,
51+
) {
52+
super(secureOptions);
53+
}
54+
55+
override async start(): Promise<void> {
56+
const internals = this as unknown as RpcClientInternals;
57+
if (internals.process) {
58+
throw new Error("Pi RPC client is already started");
59+
}
60+
61+
internals.exitError = null;
62+
const args = ["--mode", "rpc"];
63+
if (this.secureOptions.provider) {
64+
args.push("--provider", this.secureOptions.provider);
65+
}
66+
if (this.secureOptions.model) {
67+
args.push("--model", this.secureOptions.model);
68+
}
69+
if (this.secureOptions.args) {
70+
args.push(...this.secureOptions.args);
71+
}
72+
73+
const child = spawn(
74+
process.execPath,
75+
[this.secureOptions.cliPath ?? "dist/cli.js", ...args],
76+
{
77+
cwd: this.secureOptions.cwd,
78+
env: safePiEnvironment(process.env),
79+
stdio: ["pipe", "pipe", "pipe", "pipe"],
80+
},
81+
);
82+
internals.process = child;
83+
84+
child.stderr?.on("data", (data: Buffer) => {
85+
internals.stderr += data.toString();
86+
process.stderr.write(data);
87+
});
88+
child.once("exit", (code, signal) => {
89+
if (internals.process !== child) {
90+
return;
91+
}
92+
const error = internals.createProcessExitError(code, signal);
93+
internals.exitError = error;
94+
internals.rejectPendingRequests(error);
95+
});
96+
child.once("error", (error) => {
97+
if (internals.process !== child) {
98+
return;
99+
}
100+
const processError = new Error(
101+
`Agent process error: ${error.message}. Stderr: ${internals.stderr}`,
102+
);
103+
internals.exitError = processError;
104+
internals.rejectPendingRequests(processError);
105+
});
106+
child.stdin?.on("error", (error) => {
107+
const stdinError =
108+
internals.exitError ??
109+
new Error(
110+
`Agent process stdin error: ${error.message}. Stderr: ${internals.stderr}`,
111+
);
112+
internals.exitError = stdinError;
113+
internals.rejectPendingRequests(stdinError);
114+
});
115+
if (child.stdout) {
116+
internals.stopReadingStdout = attachJsonlReader(child.stdout, (line) =>
117+
internals.handleLine(line),
118+
);
119+
}
120+
121+
const bootstrapPipe = child.stdio[3] as Writable | null;
122+
bootstrapPipe?.end(
123+
JSON.stringify({ providerOptions: this.providerOptions }),
124+
);
125+
126+
await new Promise((resolve) => setTimeout(resolve, 100));
127+
if (child.exitCode !== null) {
128+
throw (
129+
internals.exitError ??
130+
internals.createProcessExitError(child.exitCode, child.signalCode)
131+
);
132+
}
133+
}
134+
}
135+
136+
export type PiRpcClientOptions = Pick<RpcClientOptions, "cwd" | "model"> & {
137+
providerOptions?: PosthogProviderOptions;
138+
};
139+
140+
export function createPiRpcClient(
141+
options: PiRpcClientOptions = {},
142+
): PiRpcClient {
143+
const { providerOptions, ...rpcOptions } = options;
144+
return new SecurePiRpcClient(
145+
{
146+
...rpcOptions,
147+
cliPath: fileURLToPath(new URL("./rpc-host.js", import.meta.url)),
148+
provider: "posthog",
149+
},
150+
providerOptions,
151+
);
152+
}

0 commit comments

Comments
 (0)