Add RTK integration for token-optimized command output

When enableRtk is set in adapter config, the adapter:
- Adds an init container (curlimages/curl) to download the RTK binary
- Mounts RTK binary in the main container via shared emptyDir volume
- Runs `rtk install claude-code` before invoking Claude to set up hooks
- Disables RTK telemetry (RTK_NO_TELEMETRY=1) for automated environments
- Supports optional rtkVersion config for pinning specific versions

RTK filters CLI command output before it reaches the LLM context,
reducing token consumption by ~80%.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-14 01:27:28 +00:00
parent 77ba40d9bf
commit d074cb2a8c
14 changed files with 238 additions and 16 deletions
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AA6PlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA2P3F"}
{"version":3,"file":"execute.d.ts","sourceRoot":"","sources":["../../src/server/execute.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,uBAAuB,EAAE,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AA2SlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAkQ3F"}
+47 -7
View File
@@ -5,6 +5,7 @@ import { buildJobManifest } from "./job-manifest.js";
import { Writable } from "node:stream";
const POLL_INTERVAL_MS = 2000;
const KEEPALIVE_INTERVAL_MS = 15_000;
const LOG_STREAM_RECONNECT_DELAY_MS = 3_000;
/**
* Wait for the Job's pod to reach a terminal or running state.
* Returns the pod name once logs can be streamed, or throws on failure.
@@ -99,10 +100,10 @@ async function waitForPod(namespace, jobName, timeoutMs, onLog, kubeconfigPath)
throw new Error(`Timed out waiting for pod to be scheduled (${Math.round(timeoutMs / 1000)}s)`);
}
/**
* Stream pod logs and accumulate stdout for result parsing.
* Returns accumulated stdout when the stream ends.
* Stream pod logs once via follow. Returns accumulated stdout when the
* stream ends (container exit, API disconnect, or abort signal).
*/
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath) {
async function streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds) {
const logApi = getLogApi(kubeconfigPath);
const chunks = [];
const writable = new Writable({
@@ -116,14 +117,47 @@ async function streamPodLogs(namespace, podName, onLog, kubeconfigPath) {
await logApi.log(namespace, podName, "claude", writable, {
follow: true,
pretty: false,
...(sinceSeconds ? { sinceSeconds } : {}),
});
}
catch {
// follow may fail if the container already exited — not fatal,
// we'll try a one-shot read below
// follow may fail if the container already exited or the API
// connection dropped — not fatal, caller decides whether to retry.
}
return chunks.join("");
}
/**
* Stream pod logs with automatic reconnection. Keeps retrying the log
* stream until the stop signal fires (job completed) or the container
* exits normally. This handles silent K8s API connection drops that
* would otherwise cause the UI to stop receiving real output.
*/
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath, stopSignal) {
const allChunks = [];
let attempt = 0;
const streamStartedAt = Math.floor(Date.now() / 1000);
while (!stopSignal?.stopped) {
// On reconnect, ask for logs since the stream originally started to
// avoid missing output during the reconnect gap. Duplicates are
// tolerable — the UI deduplicates log chunks.
const sinceSeconds = attempt > 0
? Math.max(1, Math.floor(Date.now() / 1000) - streamStartedAt + 5)
: undefined;
if (attempt > 0) {
await onLog("stdout", `[paperclip] Log stream disconnected — reconnecting (attempt ${attempt})...\n`);
}
const result = await streamPodLogsOnce(namespace, podName, onLog, kubeconfigPath, sinceSeconds);
if (result)
allChunks.push(result);
attempt++;
// If the job is done or the container exited, no need to reconnect.
if (stopSignal?.stopped)
break;
// Brief pause before reconnecting to avoid tight loops.
await new Promise((resolve) => setTimeout(resolve, LOG_STREAM_RECONNECT_DELAY_MS));
}
return allChunks.join("");
}
/**
* One-shot read of pod logs (no follow). Used as fallback when the
* follow stream missed output because the container exited quickly.
@@ -308,9 +342,15 @@ export async function execute(ctx) {
lastLogAt = Date.now();
return onLog(stream, chunk);
};
// Shared signal: when job completion resolves, tell the log
// streamer to stop reconnecting.
const logStopSignal = { stopped: false };
const [logResult, completionResult] = await Promise.allSettled([
streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath),
streamPodLogs(namespace, podName, wrappedOnLog, kubeconfigPath, logStopSignal),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath).then((r) => {
logStopSignal.stopped = true;
return r;
}),
]);
if (logResult.status === "fulfilled") {
stdout = logResult.value;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AA2C1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAqGD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAwOrE"}
{"version":3,"file":"job-manifest.d.ts","sourceRoot":"","sources":["../../src/server/job-manifest.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,GAAG,MAAM,yBAAyB,CAAC;AACpD,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,4BAA4B,CAAC;AA2C1E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEnD,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,uBAAuB,CAAC;IAC7B,OAAO,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,GAAG,CAAC,KAAK,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACvC;AAyGD,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,aAAa,GAAG,cAAc,CAwQrE"}
+35 -1
View File
@@ -107,6 +107,9 @@ function buildEnvVars(ctx, selfPod, config) {
}
// HOME must be /paperclip to match PVC mount and enable session resume
merged.HOME = "/paperclip";
if (asBoolean(config.enableRtk, false)) {
merged.RTK_NO_TELEMETRY = "1";
}
// Convert to V1EnvVar array
const envVars = Object.entries(merged).map(([name, value]) => ({
name,
@@ -127,6 +130,8 @@ export function buildJobManifest(input) {
// K8s Job pods are always unattended — no one to approve permission prompts
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const extraArgs = asStringArray(config.extraArgs);
const enableRtk = asBoolean(config.enableRtk, false);
const rtkVersion = asString(config.rtkVersion, "latest");
const timeoutSec = asNumber(config.timeoutSec, 0);
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
const resources = parseObject(config.resources);
@@ -233,6 +238,10 @@ export function buildJobManifest(input) {
mountPath: "/tmp/prompt",
},
];
if (enableRtk) {
volumes.push({ name: "rtk-bin", emptyDir: {} });
volumeMounts.push({ name: "rtk-bin", mountPath: "/tmp/rtk-bin" });
}
// Mount shared PVC for /paperclip (session state, workspaces, data)
if (selfPod.pvcClaimName) {
volumes.push({
@@ -273,7 +282,10 @@ export function buildJobManifest(input) {
};
// Build the claude command string for the main container
const claudeArgsEscaped = claudeArgs.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" ");
const mainCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
const claudeCommand = `cat /tmp/prompt/prompt.txt | claude ${claudeArgsEscaped}`;
const mainCommand = enableRtk
? `export PATH="/tmp/rtk-bin:$PATH" && rtk install claude-code && ${claudeCommand}`
: claudeCommand;
const job = {
apiVersion: "batch/v1",
kind: "Job",
@@ -314,6 +326,28 @@ export function buildJobManifest(input) {
limits: { cpu: "100m", memory: "64Mi" },
},
},
...(enableRtk
? [
{
name: "install-rtk",
image: "curlimages/curl:8.12.1",
imagePullPolicy: "IfNotPresent",
command: [
"sh",
"-c",
rtkVersion === "latest"
? "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | RTK_INSTALL_DIR=/tmp/rtk-bin sh"
: `curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | RTK_INSTALL_DIR=/tmp/rtk-bin RTK_VERSION=${rtkVersion} sh`,
],
volumeMounts: [{ name: "rtk-bin", mountPath: "/tmp/rtk-bin" }],
securityContext,
resources: {
requests: { cpu: "10m", memory: "32Mi" },
limits: { cpu: "200m", memory: "128Mi" },
},
},
]
: []),
],
containers: [
{
+1 -1
View File
File diff suppressed because one or more lines are too long