Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 2bcea32

Browse files
authored
feat(agent): instruction-level rtk adoption for codex sessions
The Claude adapter routes eligible commands through rtk deterministically via a PreToolUse hook, but the Codex app-server protocol has no command-rewrite channel, so cloud Codex runs never used the rtk binary installed in the sandbox. Append rtk usage guidance to the Codex developer instructions instead, mirroring the Claude hook's eligibility sets and gated on resolveRtkPrefix so the per-run POSTHOG_RTK=0 kill switch also disables it. Generated-By: PostHog Code Task-Id: b005dede-2677-4364-94a2-c2a7e90e376a
1 parent 4f27d54 commit 2bcea32

4 files changed

Lines changed: 168 additions & 6 deletions

File tree

packages/agent/src/adapters/claude/session/rtk.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,17 @@ import { gitSubcommand } from "../git-command";
1515

1616
// Commands RTK compresses faithfully and that have no side effects, so wrapping
1717
// them changes only how much output reaches the model, never what runs.
18-
const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]);
18+
// Exported so the instruction-level Codex guidance advertises the same set.
19+
export const RTK_PLAIN_COMMANDS = new Set(["grep", "find", "ls"]);
1920

2021
// Git subcommands whose output is worth compressing and that RTK handles
2122
// faithfully. The criterion is compressible output, NOT read-only: RTK never
2223
// changes what runs, so a mutating form (`git tag -d`, `git remote add`,
2324
// `git reflog expire`) still executes its write — its output is just shorter.
2425
// Excludes commit/push: negligible output to compress, and the cloud
2526
// signed-commit guard keys on a leading `git` token that `rtk git …` would hide.
26-
const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([
27+
// Exported so the instruction-level Codex guidance advertises the same set.
28+
export const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([
2729
"status",
2830
"diff",
2931
"log",
@@ -44,7 +46,8 @@ const GIT_COMPRESSIBLE_SUBCOMMANDS = new Set([
4446
// wrapping only its head would change the meaning of the rest.
4547
const SHELL_OPERATORS = /[|&;<>`\n]|\$\(/;
4648

47-
function shQuote(value: string): string {
49+
// Exported so the instruction-level Codex guidance quotes the prefix the same way.
50+
export function shQuote(value: string): string {
4851
if (/^[\w./-]+$/.test(value)) return value;
4952
return `'${value.replace(/'/g, `'\\''`)}'`;
5053
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import * as fs from "node:fs";
2+
import * as os from "node:os";
3+
import * as path from "node:path";
4+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
5+
import {
6+
GIT_COMPRESSIBLE_SUBCOMMANDS,
7+
RTK_PLAIN_COMMANDS,
8+
} from "./claude/session/rtk";
9+
import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance";
10+
11+
describe("rtk guidance for codex", () => {
12+
let dir: string;
13+
let binary: string;
14+
15+
beforeAll(() => {
16+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-"));
17+
binary = path.join(dir, "rtk");
18+
fs.writeFileSync(binary, "#!/bin/sh\n");
19+
});
20+
21+
afterAll(() => {
22+
fs.rmSync(dir, { recursive: true, force: true });
23+
});
24+
25+
describe("buildRtkGuidance", () => {
26+
// The guidance must advertise exactly the Claude hook's eligibility sets,
27+
// so the token-usage cohorts stay comparable across adapters.
28+
test("advertises every command the Claude hook rewrites", () => {
29+
const guidance = buildRtkGuidance("/usr/local/bin/rtk");
30+
for (const command of RTK_PLAIN_COMMANDS) {
31+
expect(guidance).toContain(command);
32+
}
33+
for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) {
34+
expect(guidance).toContain(sub);
35+
}
36+
});
37+
38+
test("uses the resolved binary path in the examples", () => {
39+
const guidance = buildRtkGuidance("/usr/local/bin/rtk");
40+
expect(guidance).toContain("`/usr/local/bin/rtk git status`");
41+
});
42+
43+
// A desktop install can resolve a path with spaces; unquoted it would
44+
// split into multiple shell tokens and every guided command would fail.
45+
test("shell-quotes a binary path containing spaces", () => {
46+
const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk");
47+
expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`");
48+
expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`");
49+
});
50+
51+
// Parity with the Claude hook's exclusion: prefixing commit/push would
52+
// hide the leading `git` token from the cloud signed-commit guard.
53+
test("forbids prefixing git commit and git push", () => {
54+
const guidance = buildRtkGuidance("rtk");
55+
expect(guidance).toContain("Never prefix `git commit`, `git push`");
56+
});
57+
});
58+
59+
describe("appendRtkGuidanceForCodex", () => {
60+
test("appends guidance when rtk is on PATH", () => {
61+
const result = appendRtkGuidanceForCodex("base instructions", {
62+
PATH: dir,
63+
});
64+
expect(result.startsWith("base instructions\n\n")).toBe(true);
65+
expect(result).toContain("rtk command-output compression");
66+
expect(result).toContain(binary);
67+
});
68+
69+
// POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must
70+
// silence the guidance too, which is why the gate is resolveRtkPrefix
71+
// rather than detectRtkBinary.
72+
test.each([["0"], ["false"]])(
73+
"returns instructions unchanged when POSTHOG_RTK is %s",
74+
(value) => {
75+
expect(
76+
appendRtkGuidanceForCodex("base instructions", {
77+
POSTHOG_RTK: value,
78+
PATH: dir,
79+
}),
80+
).toBe("base instructions");
81+
},
82+
);
83+
84+
test("returns instructions unchanged when rtk is not installed", () => {
85+
expect(
86+
appendRtkGuidanceForCodex("base instructions", {
87+
PATH: "/nonexistent",
88+
}),
89+
).toBe("base instructions");
90+
});
91+
92+
test("does not leave a leading separator when instructions are empty", () => {
93+
const result = appendRtkGuidanceForCodex("", { PATH: dir });
94+
expect(result.startsWith("## rtk")).toBe(true);
95+
});
96+
});
97+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import {
2+
GIT_COMPRESSIBLE_SUBCOMMANDS,
3+
RTK_PLAIN_COMMANDS,
4+
resolveRtkPrefix,
5+
shQuote,
6+
} from "./claude/session/rtk";
7+
8+
/**
9+
* Instruction-level RTK integration for Codex sessions.
10+
*
11+
* The Claude adapter routes eligible commands through RTK deterministically
12+
* with a PreToolUse hook that rewrites the Bash input. Codex executes shell
13+
* commands internally over JSON-RPC and its app-server protocol has no
14+
* command-rewrite channel — the adapter can only approve or deny — so the
15+
* only integration point is the developer instructions: tell the model to
16+
* prefix eligible commands itself.
17+
*
18+
* The advertised command set and rules mirror the Claude hook exactly
19+
* (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only,
20+
* never commit/push), so token-usage cohorts stay comparable across adapters.
21+
*/
22+
export function buildRtkGuidance(rtkPrefix: string): string {
23+
// Same quoting as the Claude rewrite hook: a resolved path containing
24+
// spaces must stay one shell token in the commands the model copies.
25+
const prefix = shQuote(rtkPrefix);
26+
const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `");
27+
const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", ");
28+
29+
return `## rtk command-output compression
30+
31+
\`${prefix}\` is installed. It runs a command unchanged and compresses its output before you read it, so prefixed commands cost far less context. When you execute one of these as a single, bare command, prefix it with \`${prefix}\`:
32+
33+
- \`${plainCommands}\`
34+
- these git subcommands: ${gitSubcommands}
35+
36+
Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`.
37+
38+
Rules:
39+
- Only prefix a single bare invocation. Never use it when the command is part of a pipe, uses \`&&\`, \`;\`, or redirection, or when another program parses the output — compression would corrupt what the consumer reads.
40+
- Never prefix \`git commit\`, \`git push\`, or any other command not listed above.
41+
- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`;
42+
}
43+
44+
/**
45+
* Appends the RTK guidance to Codex developer instructions when an RTK binary
46+
* is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the
47+
* per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the
48+
* guidance along with everything else.
49+
*/
50+
export function appendRtkGuidanceForCodex(
51+
instructions: string,
52+
env: NodeJS.ProcessEnv = process.env,
53+
): string {
54+
const rtkPrefix = resolveRtkPrefix(env);
55+
if (!rtkPrefix) return instructions;
56+
return [instructions, buildRtkGuidance(rtkPrefix)]
57+
.filter(Boolean)
58+
.join("\n\n");
59+
}

packages/agent/src/server/agent-server.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
classifyAgentError,
4444
isPromptTooLongError,
4545
} from "../adapters/error-classification";
46+
import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance";
4647
import {
4748
SIGNED_COMMIT_QUALIFIED_TOOL_NAME,
4849
SIGNED_MERGE_QUALIFIED_TOOL_NAME,
@@ -2941,9 +2942,11 @@ export class AgentServer {
29412942
private buildCodexInstructions(
29422943
systemPrompt: string | { append: string },
29432944
): string {
2944-
return typeof systemPrompt === "string"
2945-
? systemPrompt
2946-
: systemPrompt.append;
2945+
const instructions =
2946+
typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append;
2947+
// Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is
2948+
// adopted through the developer instructions instead.
2949+
return appendRtkGuidanceForCodex(instructions);
29472950
}
29482951

29492952
/**

0 commit comments

Comments
 (0)