Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a9b04fc
feat(agent): bundle rtk with the agent package
pauldambra Jul 4, 2026
0146ce3
fix(agent): verify rtk archive checksum and harden test teardown
pauldambra Jul 4, 2026
28c5868
refactor(agent): tidy rtk bundling from review feedback
pauldambra Jul 4, 2026
c016f35
fix(agent): preserve explicit POSTHOG_RTK over the desktop bundle def…
pauldambra Jul 4, 2026
2168f7e
feat(agent): emit rtk token-savings telemetry at end of cloud run (#3…
pauldambra Jul 4, 2026
4e5352d
fix(agent): stop rtk savings tests probing the machine's real rtk
pauldambra Jul 4, 2026
1fced7a
fix(agent): clarify at-most-once emit semantics and harden rtk binary…
pauldambra Jul 4, 2026
20cb39c
refactor: apply simplify pass
pauldambra Jul 4, 2026
1bcf3c5
fix(agent): keep rtk cache rename on one filesystem and pin error-pat…
pauldambra Jul 4, 2026
258e019
refactor: reuse signClaudeBinary for the bundled rtk binary
pauldambra Jul 4, 2026
99e4db6
fix(agent): download rtk into the per-process temp dir and polish rev…
pauldambra Jul 4, 2026
4763eb5
docs(agent): correct two review-pass comments
pauldambra Jul 4, 2026
5fe748e
feat(agent): emit rtk savings as a gauge snapshot, not a per-run delta
pauldambra Jul 4, 2026
2516812
fix(agent): key the rtk savings counter to the task and drop the pct …
pauldambra Jul 4, 2026
e6bd533
feat(agent): report the rtk savings gauge from desktop daily
pauldambra Jul 4, 2026
7b77dfd
refactor(agent): register the rtk gauge in the typed analytics event map
pauldambra Jul 4, 2026
36ed5d2
fix(agent): polish the desktop rtk gauge wiring from review
pauldambra Jul 4, 2026
b9c14cc
fix(agent): scrub the env passed to rtk gain
pauldambra Jul 4, 2026
bc53fab
fix(agent): probe rtk before rewriting commands through it
pauldambra Jul 4, 2026
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
1 change: 1 addition & 0 deletions apps/code/electron-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ const config: Configuration = {
"**/*.node",
"**/spawn-helper",
".vite/build/claude-cli/**",
".vite/build/rtk/**",
".vite/build/plugins/posthog/**",
".vite/build/codex-acp/**",
".vite/build/grammars/**",
Expand Down
2 changes: 2 additions & 0 deletions apps/code/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
copyDrizzleMigrations,
copyEnricherGrammars,
copyPosthogPlugin,
copyRtkBinary,
fixFilenameCircularRef,
getBuildDate,
getGitCommit,
Expand Down Expand Up @@ -92,6 +93,7 @@ export default defineConfig(({ mode }) => {
autoServicesPlugin(path.join(__dirname, "src/main/services")),
fixFilenameCircularRef(),
copyClaudeExecutable(),
copyRtkBinary(),
copyPosthogPlugin(isDev),
copyDrizzleMigrations(),
copyCodexAcpBinaries(),
Expand Down
62 changes: 62 additions & 0 deletions apps/code/vite-main-plugins.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
targetArch,
targetPlatform,
} from "../../packages/agent/build/native-binary.mjs";
// @ts-expect-error - plain ESM helper shared with packages/agent/tsup.config.ts
import { rtkBinName } from "../../packages/agent/build/rtk-binary.mjs";

export function getGitCommit(): string {
if (process.env.BUILD_COMMIT) return process.env.BUILD_COMMIT;
Expand Down Expand Up @@ -62,6 +64,7 @@ export function fixFilenameCircularRef(): Plugin {
}

let claudeCliCopied = false;
let rtkCopied = false;

function verifyBinaryArch(destPath: string): void {
// Best-effort: parse the binary's magic bytes and confirm the embedded arch
Expand Down Expand Up @@ -218,6 +221,65 @@ export function copyClaudeExecutable(): Plugin {
};
}

// Stages the RTK binary the agent package vendored into its dist/rtk/ into the
// Electron app's .vite/build/rtk/, mirroring copyClaudeExecutable so both hosts
// ship one consistent rtk version. Best-effort: warns and skips if the agent
// package hasn't bundled rtk (e.g. an offline agent build), and the agent then
// falls back to whatever rtk is on PATH.
export function copyRtkBinary(): Plugin {
return {
name: "copy-rtk-binary",
writeBundle() {
const binName = rtkBinName();
const destDir = join(__dirname, ".vite/build/rtk");
const destBinary = join(destDir, binName);

if (rtkCopied && existsSync(destBinary)) {
return;
}

if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}

const packageCandidates = [
join(__dirname, "node_modules/@posthog/agent/dist/rtk", binName),
join(__dirname, "../../node_modules/@posthog/agent/dist/rtk", binName),
join(__dirname, "../../packages/agent/dist/rtk", binName),
];

const source = packageCandidates.find((p: string) => existsSync(p));
if (!source) {
console.warn(
`[copy-rtk-binary] rtk binary not bundled for ${targetPlatform()}-${targetArch()}; skipping (agent falls back to PATH).`,
);
return;
}

if (isStagedCopyCurrent(source, destBinary)) {
rtkCopied = true;
return;
}

copyFileSync(source, destBinary);
if (targetPlatform() !== "win32") {
execSync(`chmod +x "${destBinary}"`);
Comment thread
pauldambra marked this conversation as resolved.
}
if (targetPlatform() === "darwin") {
try {
execSync(`xattr -cr "${destBinary}"`, { stdio: "inherit" });
execSync(`codesign --force --sign - "${destBinary}"`, {
stdio: "inherit",
});
} catch (err) {
console.warn("[copy-rtk-binary] Failed to sign rtk binary:", err);
}
}
rtkCopied = true;
},
};
}

function getFilesRecursive(dir: string): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir)) {
Expand Down
191 changes: 191 additions & 0 deletions packages/agent/build/rtk-binary.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { createHash } from "node:crypto";
import {
chmodSync,
copyFileSync,
createReadStream,
createWriteStream,
existsSync,
mkdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { dirname, join, resolve } from "node:path";
import { pipeline } from "node:stream/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { fileURLToPath } from "node:url";
import { unzipSync } from "fflate";
import { extract } from "tar";
import { targetArch, targetPlatform } from "./native-binary.mjs";

/**
* Shared build-time helper for vendoring the RTK binary
* (https://github.com/rtk-ai/rtk) into the agent package's `dist/rtk/`.
*
* RTK compresses the output of common dev commands before it reaches the model
* (see `src/adapters/claude/session/rtk.ts`). We pin a single version here so
* every host — desktop and cloud runs — uses the same RTK, rather than
* depending on whatever happens to be on the machine's PATH.
*
* There is no npm package for RTK, so we download the pinned release from
* GitHub at build time and cache it under `node_modules/.cache`. Bundling is
* best-effort: if the download fails (e.g. offline build) the caller warns and
* continues, and the runtime resolver falls back to PATH.
*/

export const RTK_VERSION = "0.43.0";

const __dirname = dirname(fileURLToPath(import.meta.url));

// Rust target triples for the RTK GitHub release assets. Linux x64 ships musl
// (static, portable); linux arm64 ships gnu — RTK publishes no arm64 musl or
// Windows arm64 asset, so those combinations resolve to undefined and skip.
const RTK_TARGETS = {
darwin: { arm64: "aarch64-apple-darwin", x64: "x86_64-apple-darwin" },
linux: {
arm64: "aarch64-unknown-linux-gnu",
x64: "x86_64-unknown-linux-musl",
},
win32: { x64: "x86_64-pc-windows-msvc" },
};

// SHA-256 of each release archive (from the release's checksums.txt), pinned
// alongside RTK_VERSION so a swapped release asset or a corrupt/truncated
// download fails the build instead of being cached and bundled. Update both
// RTK_VERSION and these hashes together when bumping rtk.
const RTK_SHA256 = {
"aarch64-apple-darwin":
"8a17e49acbd378997eb21d0eb6f7f861111f35b4fc9b1c74edf4c7448e576c65",
"aarch64-unknown-linux-gnu":
"5519f7ca12e5c143a609f0d28a0a77b97413a8dce31c2681f1a41c24519a8731",
"x86_64-apple-darwin":
"a85f60e2637811be68366208b8d8b9c5ba1b748cb5df4477ab20cd73d3c5d9f8",
"x86_64-pc-windows-msvc":
"7c5e4a2ef816a4d4ed947ddd74ca3df851fc39ea87d49a3ca2bf3abc515a016b",
"x86_64-unknown-linux-musl":
"ff8a1e7766496e175291a85aeca1dc97c9ff6df33e51e5893d1fbc78fea2a609",
};

export function rtkBinName(platform = targetPlatform()) {
return platform === "win32" ? "rtk.exe" : "rtk";
}

export function rtkReleaseTarget(
platform = targetPlatform(),
arch = targetArch(),
) {
return RTK_TARGETS[platform]?.[arch];
}

function rtkAssetUrl(target) {
const ext = target.includes("windows") ? "zip" : "tar.gz";
return `https://github.com/rtk-ai/rtk/releases/download/v${RTK_VERSION}/rtk-${target}.${ext}`;
Comment thread
pauldambra marked this conversation as resolved.
}

const MAX_DOWNLOAD_ATTEMPTS = 5;
const RETRIABLE_HTTP_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);

class NonRetriableError extends Error {}

function backoffDelayMs(attempt) {
// Deterministic backoff — build scripts avoid Math.random for reproducibility.
return Math.min(1000 * 2 ** (attempt - 1), 15000);
}

async function downloadFile(url, destPath) {
for (let attempt = 1; attempt <= MAX_DOWNLOAD_ATTEMPTS; attempt++) {
try {
const response = await fetch(url, { redirect: "follow" });
if (!response.ok) {
const message = `HTTP ${response.status}: ${response.statusText}`;
if (RETRIABLE_HTTP_STATUSES.has(response.status))
throw new Error(message);
throw new NonRetriableError(message);
}
await pipeline(response.body, createWriteStream(destPath));
return;
} catch (error) {
if (
error instanceof NonRetriableError ||
attempt === MAX_DOWNLOAD_ATTEMPTS
) {
throw error;
}
await sleep(backoffDelayMs(attempt));
}
}
}

async function verifyChecksum(archivePath, target) {
const expected = RTK_SHA256[target];
if (!expected) {
throw new Error(`No pinned checksum for rtk target ${target}`);
}
const hash = createHash("sha256");
await pipeline(createReadStream(archivePath), hash);
const actual = hash.digest("hex");
if (actual !== expected) {
throw new Error(
`rtk checksum mismatch for ${target}: expected ${expected}, got ${actual}`,
);
}
}

async function extractArchive(archivePath, destDir, target) {
if (target.includes("windows")) {
const entries = unzipSync(readFileSync(archivePath));
const bin = rtkBinName("win32");
const data = entries[bin] ?? entries[`rtk-${target}/${bin}`];
if (!data) throw new Error(`rtk binary not found in ${archivePath}`);
writeFileSync(join(destDir, bin), data);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
} else {
await extract({ file: archivePath, cwd: destDir });
}
}

/**
* Downloads and caches the pinned RTK binary for the build target, then copies
* it into `destDir`. Returns the path to the bundled binary, or null when RTK
* publishes no asset for the current platform/arch. Throws on download failure.
*/
export async function ensureRtkBinary(destDir) {
const target = rtkReleaseTarget();
if (!target) return null;

const binName = rtkBinName();
const cacheDir = resolve(
__dirname,
"../../../node_modules/.cache/posthog-rtk",
RTK_VERSION,
target,
);
const cachedBinary = join(cacheDir, binName);

if (!existsSync(cachedBinary)) {
mkdirSync(cacheDir, { recursive: true });
const url = rtkAssetUrl(target);
const archivePath = join(
cacheDir,
url.endsWith(".zip") ? "rtk.zip" : "rtk.tar.gz",
);
await downloadFile(url, archivePath);
try {
await verifyChecksum(archivePath, target);
} catch (error) {
// Don't leave an unverified archive on disk to be reused.
rmSync(archivePath, { force: true });
throw error;
}
await extractArchive(archivePath, cacheDir, target);
Comment thread
pauldambra marked this conversation as resolved.
Outdated
rmSync(archivePath, { force: true });
if (!existsSync(cachedBinary)) {
throw new Error(`rtk binary missing after extraction: ${cachedBinary}`);
}
}

mkdirSync(destDir, { recursive: true });
const dest = join(destDir, binName);
copyFileSync(cachedBinary, dest);
if (targetPlatform() !== "win32") chmodSync(dest, 0o755);
return dest;
}
19 changes: 19 additions & 0 deletions packages/agent/src/server/bin.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,27 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { Command } from "commander";
import { z } from "zod/v4";
import { isSupportedReasoningEffort } from "../adapters/reasoning-effort";
import { AgentServer } from "./agent-server";
import { claudeCodeConfigSchema, mcpServersSchema } from "./schemas";

/**
* Point RTK output-compression at the binary we vendor into `dist/rtk/`
* (built here at dist/server/bin.cjs, so the sibling is `../rtk`). This makes
* RTK available for cloud runs with a consistent version instead of depending
* on rtk being on the sandbox PATH. Respect an existing POSTHOG_RTK (explicit
* path or `0`/`false` opt-out) and stay silent when the bundle is absent so the
* runtime resolver falls back to PATH.
*/
function applyBundledRtkDefault(): void {
if (process.env.POSTHOG_RTK) return;
const binName = process.platform === "win32" ? "rtk.exe" : "rtk";
const bundled = resolve(__dirname, "..", "rtk", binName);
if (existsSync(bundled)) process.env.POSTHOG_RTK = bundled;
Comment thread
pauldambra marked this conversation as resolved.
}

const envSchema = z.object({
JWT_PUBLIC_KEY: z
.string({
Expand Down Expand Up @@ -116,6 +133,8 @@ program
"Comma-separated list of domains allowed for web tools (WebFetch, WebSearch)",
)
.action(async (options) => {
applyBundledRtkDefault();

const envResult = envSchema.safeParse(process.env);

if (!envResult.success) {
Expand Down
31 changes: 31 additions & 0 deletions packages/agent/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import {
targetArch,
targetPlatform,
} from "./build/native-binary.mjs";
import {
ensureRtkBinary,
RTK_VERSION,
rtkReleaseTarget,
} from "./build/rtk-binary.mjs";

function nativeBinarySourcePath(): string | undefined {
const candidates = claudeExecutableCandidates(
Expand Down Expand Up @@ -78,6 +83,31 @@ function copyAssets() {
);
}

// Vendor the pinned RTK binary into dist/rtk/ so cloud runs and the desktop app
// use one consistent version instead of relying on rtk being on PATH. Downloads
// on first build and caches under node_modules/.cache; best-effort so an
// offline build still succeeds (runtime then falls back to PATH).
async function copyRtkAsset() {
const rtkDir = resolve(import.meta.dirname, "dist", "rtk");
try {
const dest = await ensureRtkBinary(rtkDir);
if (dest) {
console.log(
`[agent/tsup] Bundled rtk ${RTK_VERSION} (${rtkReleaseTarget()})`,
);
} else {
console.warn(
`[agent/tsup] No rtk release for ${targetPlatform()}-${targetArch()}; skipping bundle (runtime falls back to PATH)`,
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[agent/tsup] Failed to bundle rtk: ${message}. Cloud runs will fall back to PATH.`,
);
}
}

const sharedOptions = {
sourcemap: true,
splitting: false,
Expand Down Expand Up @@ -135,6 +165,7 @@ export default defineConfig([
...sharedOptions,
onSuccess: async () => {
copyAssets();
await copyRtkAsset();
console.log("Assets copied successfully");

// Touch a trigger file to signal electron-forge to restart
Expand Down
Loading
Loading