Skip to content

Commit 7c103aa

Browse files
committed
feat(agent): wire up hog (pi) as a selectable runtime adapter
1 parent 1182fdd commit 7c103aa

32 files changed

Lines changed: 1692 additions & 116 deletions

File tree

packages/agent/e2e/config.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ export const E2E = {
5555
if (adapter === "claude") {
5656
return process.env.POSTHOG_CODE_E2E_CLAUDE_MODEL || "claude-haiku-4-5";
5757
}
58+
if (adapter === "hog") {
59+
return process.env.POSTHOG_CODE_E2E_HOG_MODEL || "claude-haiku-4-5";
60+
}
5861
// gpt-5-mini is on the product block list, but that gate is only enforced in
5962
// Agent.run — the e2e drives createAcpConnection directly, so it's accepted.
6063
return process.env.POSTHOG_CODE_E2E_CODEX_MODEL || "gpt-5-mini";
@@ -71,6 +74,11 @@ export const E2E = {
7174
process.env.POSTHOG_CODE_E2E_CLAUDE_STRONG_MODEL || "claude-sonnet-4-5"
7275
);
7376
}
77+
if (adapter === "hog") {
78+
return (
79+
process.env.POSTHOG_CODE_E2E_HOG_STRONG_MODEL || "claude-sonnet-4-5"
80+
);
81+
}
7482
return process.env.POSTHOG_CODE_E2E_CODEX_STRONG_MODEL || "gpt-5.5";
7583
},
7684

@@ -85,7 +93,7 @@ export const E2E = {
8593

8694
/** Point the adapter at the gateway as the host's `configureEnvironment` does. */
8795
configureEnv(adapter: Adapter): void {
88-
if (adapter === "claude") {
96+
if (adapter === "claude" || adapter === "hog") {
8997
process.env.ANTHROPIC_BASE_URL = GATEWAY_URL;
9098
process.env.ANTHROPIC_AUTH_TOKEN = TOKEN;
9199
return;
@@ -94,6 +102,14 @@ export const E2E = {
94102
process.env.OPENAI_API_KEY = TOKEN;
95103
},
96104

105+
/** The hogGateway config the hog arm passes through `createAcpConnection`. */
106+
hogGateway(): { gatewayUrl: string; apiKey: string } {
107+
return {
108+
gatewayUrl: GATEWAY_URL,
109+
apiKey: TOKEN,
110+
};
111+
},
112+
97113
/** The codexOptions the codex arm passes through `createAcpConnection`. */
98114
codexOptions(
99115
cwd: string,

packages/agent/e2e/driver.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export function openConnection(opts: {
8686
adapter: Adapter;
8787
cwd: string;
8888
codexOptions?: Record<string, unknown>;
89+
hogGateway?: { gatewayUrl: string; apiKey: string };
8990
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
9091
}): E2EConnection {
9192
const { adapter, cwd } = opts;
@@ -142,6 +143,7 @@ export function openConnection(opts: {
142143
const acp = createAcpConnection({
143144
adapter,
144145
codexOptions: opts.codexOptions as any,
146+
hogGateway: opts.hogGateway,
145147
onStructuredOutput: opts.onStructuredOutput,
146148
logger,
147149
});
@@ -196,6 +198,7 @@ export async function openSession(opts: {
196198
adapter: Adapter;
197199
cwd: string;
198200
codexOptions?: Record<string, unknown>;
201+
hogGateway?: { gatewayUrl: string; apiKey: string };
199202
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
200203
meta: Record<string, unknown>;
201204
}): Promise<OpenSession> {

packages/agent/e2e/session-lifecycle.e2e.test.ts

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
* invariants + the on-disk edit, never model prose. Opt-in: each arm self-skips
2121
* unless `POSTHOG_CODE_E2E_GATEWAY_PERSONAL_API_KEY` is set (codex also needs the native binary).
2222
*/
23-
const ADAPTERS: Adapter[] = ["claude", "codex"];
23+
const ADAPTERS: Adapter[] = ["claude", "codex", "hog"];
2424

2525
const EDIT_PROMPT =
2626
"Do exactly these steps and nothing else: 1) Read the file target.txt. " +
@@ -31,8 +31,10 @@ const EDIT_PROMPT =
3131
for (const adapter of ADAPTERS) {
3232
const skip = E2E.skipReason(adapter);
3333
const title = `session lifecycle (${adapter})${skip ? ` — SKIPPED (${skip})` : ""}`;
34-
// Codex-only; skipped on the claude arm so the gap is visible.
34+
// Adapter-specific coverage where the other harnesses do not implement the feature.
3535
const itCodex = adapter === "codex" ? it : it.skip;
36+
const itWithModes = adapter !== "hog" ? it : it.skip;
37+
const itNativeSteer = adapter === "codex" || adapter === "hog" ? it : it.skip;
3638
// Read-only profile only tightens per-turn on macOS + non-cloud (elsewhere the
3739
// spawn is danger-full-access / no profile), so gate to where it actually applies.
3840
const itCodexSandbox =
@@ -46,6 +48,7 @@ for (const adapter of ADAPTERS) {
4648
let repo: string;
4749
const codexOptions = () =>
4850
adapter === "codex" ? E2E.codexOptions(repo) : undefined;
51+
const hogGateway = () => (adapter === "hog" ? E2E.hogGateway() : undefined);
4952
const meta = (extra: Record<string, unknown> = {}) => ({
5053
systemPrompt: "You are a coding assistant in a tiny test repo.",
5154
model: E2E.model(adapter),
@@ -69,6 +72,7 @@ for (const adapter of ADAPTERS) {
6972
adapter,
7073
cwd: repo,
7174
codexOptions: codexOptions(),
75+
hogGateway: hogGateway(),
7276
meta: meta(),
7377
});
7478
sessionId = s.sessionId;
@@ -153,6 +157,7 @@ for (const adapter of ADAPTERS) {
153157
adapter,
154158
cwd: repo,
155159
codexOptions: codexOptions(),
160+
hogGateway: hogGateway(),
156161
meta: meta(),
157162
});
158163
try {
@@ -190,37 +195,42 @@ for (const adapter of ADAPTERS) {
190195
}
191196
}, 90_000);
192197

193-
// Cloud host switches mode only via setSessionConfigOption(configId:"mode"), so exercise both arms.
194-
it("emits current_mode_update when the mode is switched via setSessionConfigOption", async () => {
195-
const s = await openSession({
196-
adapter,
197-
cwd: repo,
198-
codexOptions: codexOptions(),
199-
meta: meta(),
200-
});
201-
try {
202-
// codex synthesizes modes; claude exposes a "mode" configOption — pick an alternate value.
203-
let value = "read-only";
204-
if (adapter === "claude") {
205-
const modeOpt = (s.newSession.configOptions ?? []).find(
206-
(o) => o.id === "mode",
207-
);
208-
value =
209-
(modeOpt?.options?.find((v) => v.value !== modeOpt.currentValue)
210-
?.value as string) ?? "plan";
211-
}
212-
await s.conn.setSessionConfigOption({
213-
sessionId: s.sessionId,
214-
configId: "mode",
215-
value,
198+
// Cloud host switches mode only via setSessionConfigOption(configId:"mode"), so exercise every harness that exposes a mode picker.
199+
itWithModes(
200+
"emits current_mode_update when the mode is switched via setSessionConfigOption",
201+
async () => {
202+
const s = await openSession({
203+
adapter,
204+
cwd: repo,
205+
codexOptions: codexOptions(),
206+
hogGateway: hogGateway(),
207+
meta: meta(),
216208
});
217-
expect(s.capture.updates("current_mode_update").length).toBeGreaterThan(
218-
0,
219-
);
220-
} finally {
221-
await s.cleanup();
222-
}
223-
}, 60_000);
209+
try {
210+
// codex synthesizes modes; claude exposes a "mode" configOption — pick an alternate value.
211+
let value = "read-only";
212+
if (adapter === "claude") {
213+
const modeOpt = (s.newSession.configOptions ?? []).find(
214+
(o) => o.id === "mode",
215+
);
216+
value =
217+
(modeOpt?.options?.find((v) => v.value !== modeOpt.currentValue)
218+
?.value as string) ?? "plan";
219+
}
220+
await s.conn.setSessionConfigOption({
221+
sessionId: s.sessionId,
222+
configId: "mode",
223+
value,
224+
});
225+
expect(
226+
s.capture.updates("current_mode_update").length,
227+
).toBeGreaterThan(0);
228+
} finally {
229+
await s.cleanup();
230+
}
231+
},
232+
60_000,
233+
);
224234

225235
// Proves the mode picker isn't cosmetic: read-only maps to an OS-level
226236
// :read-only profile that blocks the write even though the host auto-approves.
@@ -322,6 +332,7 @@ for (const adapter of ADAPTERS) {
322332
adapter,
323333
cwd: repo,
324334
codexOptions: codexOptions(),
335+
hogGateway: hogGateway(),
325336
meta: meta(),
326337
});
327338
try {
@@ -351,6 +362,7 @@ for (const adapter of ADAPTERS) {
351362
adapter,
352363
cwd: repo,
353364
codexOptions: codexOptions(),
365+
hogGateway: hogGateway(),
354366
meta: meta(),
355367
});
356368
try {
@@ -377,7 +389,7 @@ for (const adapter of ADAPTERS) {
377389
}
378390
}, 120_000);
379391

380-
itCodex(
392+
itNativeSteer(
381393
"folds a mid-turn prompt into the running turn via steering",
382394
async () => {
383395
const s = await openSession({
@@ -431,13 +443,14 @@ for (const adapter of ADAPTERS) {
431443
120_000,
432444
);
433445

434-
itCodex(
446+
itNativeSteer(
435447
"lists the session and forks it",
436448
async () => {
437449
const b = openConnection({
438450
adapter,
439451
cwd: repo,
440452
codexOptions: codexOptions(),
453+
hogGateway: hogGateway(),
441454
});
442455
try {
443456
await b.conn.initialize(INIT_PARAMS);
@@ -468,6 +481,7 @@ for (const adapter of ADAPTERS) {
468481
adapter,
469482
cwd: repo,
470483
codexOptions: codexOptions(),
484+
hogGateway: hogGateway(),
471485
meta: meta(),
472486
});
473487
try {
@@ -509,6 +523,7 @@ for (const adapter of ADAPTERS) {
509523
adapter,
510524
cwd: repo,
511525
codexOptions: codexOptions(),
526+
hogGateway: hogGateway(),
512527
});
513528
try {
514529
await b.conn.initialize(INIT_PARAMS);
@@ -530,6 +545,7 @@ for (const adapter of ADAPTERS) {
530545
adapter,
531546
cwd: repo,
532547
codexOptions: codexOptions(),
548+
hogGateway: hogGateway(),
533549
});
534550
try {
535551
await b.conn.initialize(INIT_PARAMS);

packages/agent/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@
146146
"jsonwebtoken": "^9.0.2",
147147
"minimatch": "^10.0.3",
148148
"@modelcontextprotocol/sdk": "1.29.0",
149+
"@posthog/harness": "workspace:*",
149150
"tar": "^7.5.0",
150151
"uuid": "13.0.0",
151152
"yoga-wasm-web": "^0.3.3",

packages/agent/src/adapters/acp-connection.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ import type { GatewayEnv } from "./claude/session/options";
1313
import { nativeCodexBinaryPath } from "./codex-app-server/binary-path";
1414
import { CodexAppServerAgent } from "./codex-app-server/codex-app-server-agent";
1515
import type { CodexOptions } from "./codex-app-server/spawn";
16+
import { HarnessAcpAgent } from "./harness/harness-agent";
17+
18+
export interface HogGatewayOptions {
19+
gatewayUrl?: string;
20+
apiKey?: string;
21+
}
1622

1723
export type AcpConnectionConfig = {
1824
adapter?: Adapter;
@@ -33,6 +39,8 @@ export type AcpConnectionConfig = {
3339
enricherEnabled?: boolean;
3440
/** Explicit gateway config for the Claude adapter — prevents global process.env mutation. */
3541
claudeGatewayEnv?: GatewayEnv;
42+
/** Explicit gateway config for the hog adapter. */
43+
hogGateway?: HogGatewayOptions;
3644
};
3745

3846
export type AcpConnection = {
@@ -58,6 +66,10 @@ export function createAcpConnection(
5866
return createCodexConnection(config);
5967
}
6068

69+
if (adapterType === "hog") {
70+
return createHogConnection(config);
71+
}
72+
6173
return createClaudeConnection(config);
6274
}
6375

@@ -68,6 +80,86 @@ function resolveEnricherApiConfig(
6880
return enabled ? config.posthogApiConfig : undefined;
6981
}
7082

83+
function createHogConnection(config: AcpConnectionConfig): AcpConnection {
84+
const logger =
85+
config.logger?.child("HogConnection") ??
86+
new Logger({ debug: true, prefix: "[HogConnection]" });
87+
const streams = createBidirectionalStreams();
88+
89+
const { logWriter } = config;
90+
91+
let agentWritable = streams.agent.writable;
92+
let clientWritable = streams.client.writable;
93+
94+
if (config.taskRunId && logWriter) {
95+
if (!logWriter.isRegistered(config.taskRunId)) {
96+
logWriter.register(config.taskRunId, {
97+
taskId: config.taskId ?? config.taskRunId,
98+
runId: config.taskRunId,
99+
deviceType: config.deviceType,
100+
});
101+
}
102+
103+
const taskRunId = config.taskRunId;
104+
agentWritable = createTappedWritableStream(streams.agent.writable, {
105+
onMessage: (line) => {
106+
logWriter.appendRawLine(taskRunId, line);
107+
},
108+
logger,
109+
});
110+
111+
clientWritable = createTappedWritableStream(streams.client.writable, {
112+
onMessage: (line) => {
113+
logWriter.appendRawLine(taskRunId, line);
114+
},
115+
logger,
116+
});
117+
} else {
118+
logger.info("Tapped streams NOT enabled for Hog", {
119+
hasTaskRunId: !!config.taskRunId,
120+
hasLogWriter: !!logWriter,
121+
});
122+
}
123+
124+
const agentStream = ndJsonStream(agentWritable, streams.agent.readable);
125+
126+
let agent: HarnessAcpAgent | null = null;
127+
const agentConnection = new AgentSideConnection((client) => {
128+
agent = new HarnessAcpAgent(client, {
129+
allowedModelIds: config.allowedModelIds,
130+
gatewayUrl: config.hogGateway?.gatewayUrl,
131+
apiKey: config.hogGateway?.apiKey,
132+
});
133+
return agent;
134+
}, agentStream);
135+
136+
return {
137+
agentConnection,
138+
clientStreams: {
139+
readable: streams.client.readable,
140+
writable: clientWritable,
141+
},
142+
cleanup: async () => {
143+
logger.info("Cleaning up Hog connection");
144+
145+
if (agent) {
146+
await agent.closeSession();
147+
}
148+
149+
try {
150+
await streams.client.writable.close();
151+
} catch {
152+
// Stream may already be closed
153+
}
154+
try {
155+
await streams.agent.writable.close();
156+
} catch {
157+
// Stream may already be closed
158+
}
159+
},
160+
};
161+
}
162+
71163
function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
72164
const logger =
73165
config.logger?.child("AcpConnection") ??

0 commit comments

Comments
 (0)