Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/agent/src/adapters/claude/session/rtk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,17 @@ import { gitSubcommand } from "../git-command";

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

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

function shQuote(value: string): string {
// Exported so the instruction-level Codex guidance quotes the prefix the same way.
export function shQuote(value: string): string {
if (/^[\w./-]+$/.test(value)) return value;
return `'${value.replace(/'/g, `'\\''`)}'`;
}
Expand Down
97 changes: 97 additions & 0 deletions packages/agent/src/adapters/rtk-guidance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import {
GIT_COMPRESSIBLE_SUBCOMMANDS,
RTK_PLAIN_COMMANDS,
} from "./claude/session/rtk";
import { appendRtkGuidanceForCodex, buildRtkGuidance } from "./rtk-guidance";

describe("rtk guidance for codex", () => {
let dir: string;
let binary: string;

beforeAll(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "rtk-guidance-test-"));
binary = path.join(dir, "rtk");
fs.writeFileSync(binary, "#!/bin/sh\n");
});

afterAll(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

describe("buildRtkGuidance", () => {
// The guidance must advertise exactly the Claude hook's eligibility sets,
// so the token-usage cohorts stay comparable across adapters.
test("advertises every command the Claude hook rewrites", () => {
const guidance = buildRtkGuidance("/usr/local/bin/rtk");
for (const command of RTK_PLAIN_COMMANDS) {
expect(guidance).toContain(command);
}
for (const sub of GIT_COMPRESSIBLE_SUBCOMMANDS) {
expect(guidance).toContain(sub);
}
});

test("uses the resolved binary path in the examples", () => {
const guidance = buildRtkGuidance("/usr/local/bin/rtk");
expect(guidance).toContain("`/usr/local/bin/rtk git status`");
});

// A desktop install can resolve a path with spaces; unquoted it would
// split into multiple shell tokens and every guided command would fail.
test("shell-quotes a binary path containing spaces", () => {
const guidance = buildRtkGuidance("/Apps/PostHog Code/rtk");
expect(guidance).toContain("`'/Apps/PostHog Code/rtk' git status`");
expect(guidance).not.toContain("`/Apps/PostHog Code/rtk git status`");
});

// Parity with the Claude hook's exclusion: prefixing commit/push would
// hide the leading `git` token from the cloud signed-commit guard.
test("forbids prefixing git commit and git push", () => {
const guidance = buildRtkGuidance("rtk");
expect(guidance).toContain("Never prefix `git commit`, `git push`");
});
});

describe("appendRtkGuidanceForCodex", () => {
test("appends guidance when rtk is on PATH", () => {
const result = appendRtkGuidanceForCodex("base instructions", {
PATH: dir,
});
expect(result.startsWith("base instructions\n\n")).toBe(true);
expect(result).toContain("rtk command-output compression");
expect(result).toContain(binary);
});

// POSTHOG_RTK=0 is set per run from the cloud kill-switch flag; it must
// silence the guidance too, which is why the gate is resolveRtkPrefix
// rather than detectRtkBinary.
test.each([["0"], ["false"]])(
"returns instructions unchanged when POSTHOG_RTK is %s",
(value) => {
expect(
appendRtkGuidanceForCodex("base instructions", {
POSTHOG_RTK: value,
PATH: dir,
}),
).toBe("base instructions");
},
);

test("returns instructions unchanged when rtk is not installed", () => {
expect(
appendRtkGuidanceForCodex("base instructions", {
PATH: "/nonexistent",
}),
).toBe("base instructions");
});

test("does not leave a leading separator when instructions are empty", () => {
const result = appendRtkGuidanceForCodex("", { PATH: dir });
expect(result.startsWith("## rtk")).toBe(true);
});
});
});
59 changes: 59 additions & 0 deletions packages/agent/src/adapters/rtk-guidance.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import {
GIT_COMPRESSIBLE_SUBCOMMANDS,
RTK_PLAIN_COMMANDS,
resolveRtkPrefix,
shQuote,
} from "./claude/session/rtk";

/**
* Instruction-level RTK integration for Codex sessions.
*
* The Claude adapter routes eligible commands through RTK deterministically
* with a PreToolUse hook that rewrites the Bash input. Codex executes shell
* commands internally over JSON-RPC and its app-server protocol has no
* command-rewrite channel — the adapter can only approve or deny — so the
* only integration point is the developer instructions: tell the model to
* prefix eligible commands itself.
*
* The advertised command set and rules mirror the Claude hook exactly
* (RTK_PLAIN_COMMANDS + GIT_COMPRESSIBLE_SUBCOMMANDS, bare invocations only,
* never commit/push), so token-usage cohorts stay comparable across adapters.
*/
export function buildRtkGuidance(rtkPrefix: string): string {
// Same quoting as the Claude rewrite hook: a resolved path containing
// spaces must stay one shell token in the commands the model copies.
const prefix = shQuote(rtkPrefix);
const plainCommands = [...RTK_PLAIN_COMMANDS].join("`, `");
const gitSubcommands = [...GIT_COMPRESSIBLE_SUBCOMMANDS].join(", ");

return `## rtk command-output compression

\`${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}\`:

- \`${plainCommands}\`
- these git subcommands: ${gitSubcommands}

Examples: \`${prefix} git status\`, \`${prefix} grep -rn "foo" src\`, \`${prefix} ls -la\`.

Rules:
- 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.
- Never prefix \`git commit\`, \`git push\`, or any other command not listed above.
- Skip the prefix when you need the exact, complete output (for example, copying a diff verbatim).`;
}

/**
* Appends the RTK guidance to Codex developer instructions when an RTK binary
* is usable. Gated on `resolveRtkPrefix` — not `detectRtkBinary` — so the
* per-run `POSTHOG_RTK=0` opt-out (the cloud kill-switch flag) disables the
* guidance along with everything else.
*/
export function appendRtkGuidanceForCodex(
instructions: string,
env: NodeJS.ProcessEnv = process.env,
): string {
const rtkPrefix = resolveRtkPrefix(env);
if (!rtkPrefix) return instructions;
return [instructions, buildRtkGuidance(rtkPrefix)]
.filter(Boolean)
.join("\n\n");
}
9 changes: 6 additions & 3 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
classifyAgentError,
isPromptTooLongError,
} from "../adapters/error-classification";
import { appendRtkGuidanceForCodex } from "../adapters/rtk-guidance";
import {
SIGNED_COMMIT_QUALIFIED_TOOL_NAME,
SIGNED_MERGE_QUALIFIED_TOOL_NAME,
Expand Down Expand Up @@ -2941,9 +2942,11 @@ export class AgentServer {
private buildCodexInstructions(
systemPrompt: string | { append: string },
): string {
return typeof systemPrompt === "string"
? systemPrompt
: systemPrompt.append;
const instructions =
typeof systemPrompt === "string" ? systemPrompt : systemPrompt.append;
// Codex has no command-rewrite hook (see rtk-guidance.ts), so RTK is
// adopted through the developer instructions instead.
Comment thread
tatoalo marked this conversation as resolved.
return appendRtkGuidanceForCodex(instructions);
}

/**
Expand Down
Loading