Initial commit: Paperclip adapter for Claude Code on Kubernetes

Adapter plugin that runs Claude Code agents as Kubernetes Jobs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-11 23:16:31 -04:00
commit 9dbb5f337e
49 changed files with 4420 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
# paperclip-adapter-claude-k8s
Paperclip adapter plugin that runs Claude Code agents as isolated Kubernetes Jobs instead of inside the main Paperclip process.
## Features
- Spawns agent runs as K8s Jobs with full pod isolation
- Inherits container image, secrets, DNS, and PVC from the Paperclip Deployment automatically
- Real-time log streaming from Job pods back to the Paperclip UI
- Session resume via shared RWX PVC
- Per-agent concurrency guard
- Configurable resources, namespace, kubeconfig
- Bedrock model support
## Installation
### Via Paperclip Adapter Manager
```bash
curl -X POST http://localhost:3100/api/adapters \
-H "Content-Type: application/json" \
-d '{"packageName": "paperclip-adapter-claude-k8s"}'
```
### Local Development
```bash
curl -X POST http://localhost:3100/api/adapters \
-H "Content-Type: application/json" \
-d '{"localPath": "/path/to/paperclip-claude-k8s"}'
```
## Configuration
See the agent configuration documentation for all available fields:
- `namespace` — K8s namespace for Jobs (defaults to Deployment namespace)
- `image` — Override container image
- `kubeconfig` — Path to kubeconfig file
- `resources` — CPU/memory requests and limits
- `timeoutSec` — Run timeout (0 = no timeout)
- `retainJobs` — Keep completed Jobs for debugging
## Requirements
- Kubernetes cluster with RBAC permissions to create Jobs, list Pods, and read Pod logs
- Shared RWX PVC mounted at `/paperclip` for session resume and workspace access
- `@paperclipai/adapter-utils` >= 0.3.0
## License
MIT
+9
View File
@@ -0,0 +1,9 @@
export declare const type = "claude_k8s";
export declare const label = "Claude (Kubernetes)";
export declare const models: {
id: string;
label: string;
}[];
export declare const agentConfigurationDoc = "# claude_k8s agent configuration\n\nAdapter: claude_k8s\n\nRuns Claude Code inside an isolated Kubernetes Job pod instead of the main\nPaperclip process. The Job inherits the container image, imagePullSecrets,\nDNS config, and PVC from the running Paperclip Deployment automatically.\n\nCore fields:\n- model (string, optional): Claude model id\n- effort (string, optional): reasoning effort passed via --effort (low|medium|high)\n- maxTurnsPerRun (number, optional): max turns for one run\n- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude\n- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file\n- extraArgs (string[], optional): additional CLI args appended to the claude command\n- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment\n\nKubernetes fields:\n- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace\n- image (string, optional): override container image; defaults to the running Deployment image\n- imagePullPolicy (string, optional): image pull policy; default \"IfNotPresent\"\n- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth\n- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } }\n- nodeSelector (object, optional): node selector for Job pods\n- tolerations (array, optional): tolerations for Job pods\n- labels (object, optional): extra labels added to Job metadata\n- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300\n- retainJobs (boolean, optional): skip cleanup on completion for debugging\n\nOperational fields:\n- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout\n- graceSec (number, optional): additional grace before adapter gives up after Job deadline\n\nInherited from Deployment (no config needed):\n- CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_BEARER_TOKEN_BEDROCK\n- ANTHROPIC_API_KEY, OPENAI_API_KEY\n- PAPERCLIP_API_URL\n- Container image, imagePullSecrets, DNS config, PVC mount, security context\n\nNotes:\n- Session resume works via the shared /paperclip PVC (HOME=/paperclip)\n- Skills are bundled in the container image\n- Prompts are delivered via a busybox init container writing to an emptyDir volume\n";
export { createServerAdapter } from "./server/index.js";
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,IAAI,eAAe,CAAC;AACjC,eAAO,MAAM,KAAK,wBAAwB,CAAC;AAE3C,eAAO,MAAM,MAAM;;;GAclB,CAAC;AAEF,eAAO,MAAM,qBAAqB,s3EA2CjC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
+63
View File
@@ -0,0 +1,63 @@
export const type = "claude_k8s";
export const label = "Claude (Kubernetes)";
export const models = [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
// AWS Bedrock US inference profile IDs
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
{ id: "us.anthropic.claude-opus-4-5-20251101-v1:0", label: "Bedrock Opus 4.5" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
{ id: "us.anthropic.claude-opus-4-1-20250805-v1:0", label: "Bedrock Opus 4.1" },
{ id: "us.anthropic.claude-sonnet-4-20250514-v1:0", label: "Bedrock Sonnet 4" },
];
export const agentConfigurationDoc = `# claude_k8s agent configuration
Adapter: claude_k8s
Runs Claude Code inside an isolated Kubernetes Job pod instead of the main
Paperclip process. The Job inherits the container image, imagePullSecrets,
DNS config, and PVC from the running Paperclip Deployment automatically.
Core fields:
- model (string, optional): Claude model id
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- maxTurnsPerRun (number, optional): max turns for one run
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file
- extraArgs (string[], optional): additional CLI args appended to the claude command
- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment
Kubernetes fields:
- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace
- image (string, optional): override container image; defaults to the running Deployment image
- imagePullPolicy (string, optional): image pull policy; default "IfNotPresent"
- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth
- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } }
- nodeSelector (object, optional): node selector for Job pods
- tolerations (array, optional): tolerations for Job pods
- labels (object, optional): extra labels added to Job metadata
- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300
- retainJobs (boolean, optional): skip cleanup on completion for debugging
Operational fields:
- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout
- graceSec (number, optional): additional grace before adapter gives up after Job deadline
Inherited from Deployment (no config needed):
- CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_BEARER_TOKEN_BEDROCK
- ANTHROPIC_API_KEY, OPENAI_API_KEY
- PAPERCLIP_API_URL
- Container image, imagePullSecrets, DNS config, PVC mount, security context
Notes:
- Session resume works via the shared /paperclip PVC (HOME=/paperclip)
- Skills are bundled in the container image
- Prompts are delivered via a busybox init container writing to an emptyDir volume
`;
export { createServerAdapter } from "./server/index.js";
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,IAAI,GAAG,YAAY,CAAC;AACjC,MAAM,CAAC,MAAM,KAAK,GAAG,qBAAqB,CAAC;AAE3C,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,EAAE,EAAE,EAAE,iBAAiB,EAAE,KAAK,EAAE,iBAAiB,EAAE;IACnD,EAAE,EAAE,EAAE,mBAAmB,EAAE,KAAK,EAAE,mBAAmB,EAAE;IACvD,EAAE,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,kBAAkB,EAAE;IACrD,EAAE,EAAE,EAAE,4BAA4B,EAAE,KAAK,EAAE,mBAAmB,EAAE;IAChE,EAAE,EAAE,EAAE,2BAA2B,EAAE,KAAK,EAAE,kBAAkB,EAAE;IAC9D,uCAAuC;IACvC,EAAE,EAAE,EAAE,iCAAiC,EAAE,KAAK,EAAE,kBAAkB,EAAE;IACpE,EAAE,EAAE,EAAE,gCAAgC,EAAE,KAAK,EAAE,oBAAoB,EAAE;IACrE,EAAE,EAAE,EAAE,4CAA4C,EAAE,KAAK,EAAE,kBAAkB,EAAE;IAC/E,EAAE,EAAE,EAAE,8CAA8C,EAAE,KAAK,EAAE,oBAAoB,EAAE;IACnF,EAAE,EAAE,EAAE,6CAA6C,EAAE,KAAK,EAAE,mBAAmB,EAAE;IACjF,EAAE,EAAE,EAAE,4CAA4C,EAAE,KAAK,EAAE,kBAAkB,EAAE;IAC/E,EAAE,EAAE,EAAE,4CAA4C,EAAE,KAAK,EAAE,kBAAkB,EAAE;CAChF,CAAC;AAEF,MAAM,CAAC,MAAM,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CpC,CAAC;AAEF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
+3
View File
@@ -0,0 +1,3 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
export declare function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
//# sourceMappingURL=execute.d.ts.map
+1
View File
@@ -0,0 +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;AA4PlG,wBAAsB,OAAO,CAAC,GAAG,EAAE,uBAAuB,GAAG,OAAO,CAAC,sBAAsB,CAAC,CA4O3F"}
+416
View File
@@ -0,0 +1,416 @@
import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { parseClaudeStreamJson, describeClaudeFailure, isClaudeMaxTurnsResult, isClaudeUnknownSessionError, } from "./parse.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
import { buildJobManifest } from "./job-manifest.js";
import { Writable } from "node:stream";
const POLL_INTERVAL_MS = 2000;
/**
* 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.
*/
async function waitForPod(namespace, jobName, timeoutMs, onLog, kubeconfigPath) {
const coreApi = getCoreApi(kubeconfigPath);
const deadline = Date.now() + timeoutMs;
const labelSelector = `job-name=${jobName}`;
await onLog("stdout", `[paperclip] Waiting for pod to be scheduled (job: ${jobName})...\n`);
let lastStatus = "";
while (Date.now() < deadline) {
const podList = await coreApi.listNamespacedPod({
namespace,
labelSelector,
});
const pod = podList.items[0];
if (!pod) {
if (lastStatus !== "no-pod") {
await onLog("stdout", `[paperclip] Waiting for Job controller to create pod...\n`);
lastStatus = "no-pod";
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const podName = pod.metadata?.name ?? "unknown";
const phase = pod.status?.phase ?? "Unknown";
const initStatuses = pod.status?.initContainerStatuses ?? [];
const containerStatuses = pod.status?.containerStatuses ?? [];
// Log phase transitions
const statusKey = `${phase}:${initStatuses.map((s) => s.state?.waiting?.reason ?? s.state?.terminated?.reason ?? "ok").join(",")}:${containerStatuses.map((s) => s.state?.waiting?.reason ?? s.state?.running ? "running" : "waiting").join(",")}`;
if (statusKey !== lastStatus) {
const details = [`phase=${phase}`];
for (const init of initStatuses) {
if (init.state?.waiting)
details.push(`init/${init.name}: waiting (${init.state.waiting.reason ?? "unknown"})`);
else if (init.state?.running)
details.push(`init/${init.name}: running`);
else if (init.state?.terminated)
details.push(`init/${init.name}: done (exit ${init.state.terminated.exitCode})`);
}
for (const cs of containerStatuses) {
if (cs.state?.waiting)
details.push(`${cs.name}: waiting (${cs.state.waiting.reason ?? "unknown"})`);
else if (cs.state?.running)
details.push(`${cs.name}: running`);
}
await onLog("stdout", `[paperclip] Pod ${podName}: ${details.join(", ")}\n`);
lastStatus = statusKey;
}
// Ready to stream logs
if (phase === "Running" || phase === "Succeeded" || phase === "Failed") {
return podName;
}
// Init containers done + main running (phase may still say Pending briefly)
const allInitsDone = initStatuses.length > 0 && initStatuses.every((s) => s.state?.terminated?.exitCode === 0);
const mainRunning = containerStatuses.some((s) => s.state?.running);
if (allInitsDone && mainRunning) {
return podName;
}
// Check for init container failures
for (const init of initStatuses) {
const terminated = init.state?.terminated;
if (terminated && (terminated.exitCode ?? 0) !== 0) {
throw new Error(`Init container "${init.name}" failed with exit code ${terminated.exitCode}: ${terminated.reason ?? terminated.message ?? "unknown"}`);
}
const waiting = init.state?.waiting;
if (waiting?.reason === "ErrImagePull" || waiting?.reason === "ImagePullBackOff") {
throw new Error(`Init container "${init.name}" image pull failed: ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Init container "${init.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
}
// Check for unrecoverable scheduling failures
const conditions = pod.status?.conditions ?? [];
const unschedulable = conditions.find((c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable");
if (unschedulable) {
throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`);
}
// Check for main container image pull errors
for (const cs of containerStatuses) {
const waiting = cs.state?.waiting;
if (waiting?.reason === "ErrImagePull" || waiting?.reason === "ImagePullBackOff") {
throw new Error(`Image pull failed for "${cs.name}": ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
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.
*/
async function streamPodLogs(namespace, podName, onLog, kubeconfigPath) {
const logApi = getLogApi(kubeconfigPath);
const chunks = [];
const writable = new Writable({
write(chunk, _encoding, callback) {
const text = chunk.toString("utf-8");
chunks.push(text);
void onLog("stdout", text).then(() => callback(), callback);
},
});
try {
await logApi.log(namespace, podName, "claude", writable, {
follow: true,
pretty: false,
});
}
catch {
// follow may fail if the container already exited — not fatal,
// we'll try a one-shot read below
}
return chunks.join("");
}
/**
* One-shot read of pod logs (no follow). Used as fallback when the
* follow stream missed output because the container exited quickly.
*/
async function readPodLogs(namespace, podName, kubeconfigPath) {
const coreApi = getCoreApi(kubeconfigPath);
try {
const log = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
container: "claude",
});
return typeof log === "string" ? log : "";
}
catch {
return "";
}
}
/**
* Wait for the Job to reach a terminal state (Complete or Failed).
* Returns the Job's final status.
*/
async function waitForJobCompletion(namespace, jobName, timeoutMs, kubeconfigPath) {
const batchApi = getBatchApi(kubeconfigPath);
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
while (deadline === 0 || Date.now() < deadline) {
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
const conditions = job.status?.conditions ?? [];
const complete = conditions.find((c) => c.type === "Complete" && c.status === "True");
if (complete)
return { succeeded: true, timedOut: false };
const failed = conditions.find((c) => c.type === "Failed" && c.status === "True");
if (failed) {
const isDeadlineExceeded = failed.reason === "DeadlineExceeded";
return { succeeded: false, timedOut: isDeadlineExceeded };
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return { succeeded: false, timedOut: true };
}
/**
* Get the exit code from the Job's pod.
*/
async function getPodExitCode(namespace, jobName, kubeconfigPath) {
const coreApi = getCoreApi(kubeconfigPath);
const podList = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
const pod = podList.items[0];
if (!pod)
return null;
const containerStatus = pod.status?.containerStatuses?.find((s) => s.name === "claude");
return containerStatus?.state?.terminated?.exitCode ?? null;
}
/**
* Delete Job and its pods. Best-effort — failures are logged but not thrown.
*/
async function cleanupJob(namespace, jobName, onLog, kubeconfigPath) {
try {
const batchApi = getBatchApi(kubeconfigPath);
await batchApi.deleteNamespacedJob({
name: jobName,
namespace,
body: { propagationPolicy: "Background" },
});
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Warning: failed to cleanup job ${jobName}: ${msg}\n`);
}
}
export async function execute(ctx) {
const { runId, runtime, config: rawConfig, onLog, onMeta } = ctx;
const config = parseObject(rawConfig);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 60);
const retainJobs = asBoolean(config.retainJobs, false);
const kubeconfigPath = asString(config.kubeconfig, "") || undefined;
// Guard: claude_k8s must not run concurrently for the same agent (shared PVC/session)
const agentId = ctx.agent.id;
const selfPod = await getSelfPodInfo(kubeconfigPath);
const guardNamespace = asString(config.namespace, "") || selfPod.namespace;
try {
const batchApi = getBatchApi(kubeconfigPath);
const existing = await batchApi.listNamespacedJob({
namespace: guardNamespace,
labelSelector: `paperclip.io/agent-id=${agentId},paperclip.io/adapter-type=claude_k8s`,
});
const running = existing.items.filter((j) => !j.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True"));
if (running.length > 0) {
const names = running.map((j) => j.metadata?.name).join(", ");
await onLog("stderr", `[paperclip] Concurrent run blocked: existing Job(s) still running for this agent: ${names}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Concurrent run blocked: Job ${names} is still running for this agent`,
errorCode: "k8s_concurrent_run_blocked",
};
}
}
catch {
// If we can't check, proceed — the heartbeat service enforces concurrency too
}
// Build Job manifest
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics } = buildJobManifest({
ctx,
selfPod,
});
// Report invocation metadata
if (onMeta) {
await onMeta({
adapterType: "claude_k8s",
command: `kubectl job/${jobName}`,
cwd: namespace,
commandArgs: claudeArgs,
commandNotes: [
`Image: ${job.spec?.template.spec?.containers[0]?.image ?? "unknown"}`,
`Namespace: ${namespace}`,
`Timeout: ${timeoutSec}s`,
],
prompt,
...(promptMetrics ? { promptMetrics } : {}),
context: ctx.context,
});
}
// Create the Job
const batchApi = getBatchApi(kubeconfigPath);
try {
await batchApi.createNamespacedJob({ namespace, body: job });
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Failed to create K8s Job: ${msg}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Failed to create Kubernetes Job: ${msg}`,
errorCode: "k8s_job_create_failed",
};
}
await onLog("stdout", `[paperclip] Created K8s Job: ${jobName} in namespace ${namespace} (deadline: ${timeoutSec > 0 ? `${timeoutSec}s` : "none"})\n`);
let stdout = "";
let exitCode = null;
let jobTimedOut = false;
try {
// Wait for pod to be ready for log streaming
const scheduleTimeoutMs = 120_000; // 2 minutes for scheduling
let podName;
try {
podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath);
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Pod scheduling failed: ${msg}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Pod scheduling failed: ${msg}`,
errorCode: "k8s_pod_schedule_failed",
};
}
// Stream logs and wait for completion concurrently.
// The log stream will end when the container exits.
// We also poll the Job status to detect deadline exceeded.
// 0 = no timeout (run indefinitely, matching claude_local behavior)
const completionTimeoutMs = timeoutSec > 0 ? (timeoutSec + graceSec) * 1000 : 0;
const [logResult, completionResult] = await Promise.allSettled([
streamPodLogs(namespace, podName, onLog, kubeconfigPath),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath),
]);
if (logResult.status === "fulfilled") {
stdout = logResult.value;
}
// If the follow stream missed output (container exited quickly), do a
// one-shot log read as fallback before the pod is cleaned up.
if (!stdout.trim()) {
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
if (stdout.trim()) {
await onLog("stdout", stdout);
}
}
if (completionResult.status === "fulfilled") {
jobTimedOut = completionResult.value.timedOut;
}
else {
jobTimedOut = true;
}
exitCode = await getPodExitCode(namespace, jobName, kubeconfigPath);
}
finally {
if (!retainJobs) {
await cleanupJob(namespace, jobName, onLog, kubeconfigPath);
}
else {
await onLog("stdout", `[paperclip] Retaining job ${jobName} for debugging (retainJobs=true)\n`);
}
}
// Parse Claude output (reuse claude_local parsing)
if (jobTimedOut) {
return {
exitCode,
signal: null,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: "timeout",
};
}
const parsedStream = parseClaudeStreamJson(stdout);
const parsed = parsedStream.resultJson;
// If the session was stale, clear it so the next heartbeat starts fresh
if (parsed && (exitCode ?? 0) !== 0 && isClaudeUnknownSessionError(parsed)) {
await onLog("stdout", `[paperclip] Claude session is unavailable; clearing for next run.\n`);
return {
exitCode,
signal: null,
timedOut: false,
errorMessage: describeClaudeFailure(parsed) ?? "Session unavailable",
errorCode: "session_unavailable",
clearSession: true,
resultJson: parsed,
};
}
if (!parsed) {
const stderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
return {
exitCode,
signal: null,
timedOut: false,
errorMessage: exitCode === 0
? "Failed to parse Claude JSON output"
: stderrLine
? `Claude exited with code ${exitCode ?? -1}: ${stderrLine}`
: `Claude exited with code ${exitCode ?? -1}`,
resultJson: { stdout },
};
}
const usage = parsedStream.usage ?? (() => {
const usageObj = parseObject(parsed.usage);
return {
inputTokens: asNumber(usageObj.input_tokens, 0),
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
outputTokens: asNumber(usageObj.output_tokens, 0),
};
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const fallbackSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const resolvedSessionId = parsedStream.sessionId
?? (asString(parsed.session_id, fallbackSessionId) || fallbackSessionId);
const model = asString(config.model, "");
const workspaceContext = parseObject(ctx.context.paperclipWorkspace);
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const cwd = asString(workspaceContext.cwd, "");
const resolvedSessionParams = resolvedSessionId
? {
sessionId: resolvedSessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
}
: null;
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
return {
exitCode,
signal: null,
timedOut: false,
errorMessage: (exitCode ?? 0) === 0
? null
: describeClaudeFailure(parsed) ?? `Claude exited with code ${exitCode ?? -1}`,
usage,
sessionId: resolvedSessionId || null,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId || null,
provider: "anthropic",
model: parsedStream.model || asString(parsed.model, model),
billingType: "api",
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
resultJson: parsed,
summary: parsedStream.summary || asString(parsed.result, ""),
clearSession: clearSessionForMaxTurns,
};
}
//# sourceMappingURL=execute.js.map
+1
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -0,0 +1,7 @@
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
export declare function createServerAdapter(): ServerAdapterModule;
export { execute, testEnvironment, sessionCodec };
//# sourceMappingURL=index.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAEtE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,wBAAgB,mBAAmB,IAAI,mBAAmB,CAUzD;AAED,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
+17
View File
@@ -0,0 +1,17 @@
import { type, models, agentConfigurationDoc } from "../index.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
export function createServerAdapter() {
return {
type,
execute,
testEnvironment,
sessionCodec,
models,
supportsLocalAgentJwt: true,
agentConfigurationDoc,
};
}
export { execute, testEnvironment, sessionCodec };
//# sourceMappingURL=index.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAC;AACvC,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAE5C,MAAM,UAAU,mBAAmB;IACjC,OAAO;QACL,IAAI;QACJ,OAAO;QACP,eAAe;QACf,YAAY;QACZ,MAAM;QACN,qBAAqB,EAAE,IAAI;QAC3B,qBAAqB;KACtB,CAAC;AACJ,CAAC;AAED,OAAO,EAAE,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,CAAC"}
+17
View File
@@ -0,0 +1,17 @@
import type * as k8s from "@kubernetes/client-node";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import type { SelfPodInfo } from "./k8s-client.js";
export interface JobBuildInput {
ctx: AdapterExecutionContext;
selfPod: SelfPodInfo;
}
export interface JobBuildResult {
job: k8s.V1Job;
jobName: string;
namespace: string;
prompt: string;
claudeArgs: string[];
promptMetrics: Record<string, number>;
}
export declare function buildJobManifest(input: JobBuildInput): JobBuildResult;
//# sourceMappingURL=job-manifest.d.ts.map
+1
View File
@@ -0,0 +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,CAuOrE"}
+337
View File
@@ -0,0 +1,337 @@
import { asString, asNumber, asBoolean, asStringArray, parseObject, buildPaperclipEnv, renderTemplate, } from "@paperclipai/adapter-utils/server-utils";
// Inline prompt assembly — these functions are not yet in the published adapter-utils
function joinPromptSections(sections, separator = "\n\n") {
return sections.filter((s) => s.trim().length > 0).join(separator);
}
function stringifyPaperclipWakePayload(wake) {
if (!wake || typeof wake !== "object")
return null;
try {
const json = JSON.stringify(wake);
return json === "{}" ? null : json;
}
catch {
return null;
}
}
function renderPaperclipWakePrompt(wake, _opts) {
if (!wake || typeof wake !== "object")
return "";
const w = wake;
const reason = typeof w.reason === "string" ? w.reason.trim() : "";
const comments = Array.isArray(w.comments) ? w.comments : [];
if (!reason && comments.length === 0)
return "";
const parts = [];
if (reason)
parts.push(`Wake reason: ${reason}`);
for (const c of comments) {
if (typeof c === "object" && c !== null) {
const comment = c;
const body = typeof comment.body === "string" ? comment.body.trim() : "";
if (body)
parts.push(`Comment: ${body}`);
}
}
return parts.join("\n\n");
}
function sanitizeForK8sName(value) {
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
}
function buildEnvVars(ctx, selfPod, config) {
const { runId, agent, context } = ctx;
const envConfig = parseObject(config.env);
// Layer 1: PAPERCLIP_* base vars
const paperclipEnv = buildPaperclipEnv(agent);
// Layer 2: Context vars (run, wake, workspace — same as claude_local)
paperclipEnv.PAPERCLIP_RUN_ID = runId;
const setIfPresent = (envKey, value) => {
if (typeof value === "string" && value.trim().length > 0) {
paperclipEnv[envKey] = value.trim();
}
};
setIfPresent("PAPERCLIP_TASK_ID", context.taskId ?? context.issueId);
setIfPresent("PAPERCLIP_WAKE_REASON", context.wakeReason);
setIfPresent("PAPERCLIP_WAKE_COMMENT_ID", context.wakeCommentId ?? context.commentId);
setIfPresent("PAPERCLIP_APPROVAL_ID", context.approvalId);
setIfPresent("PAPERCLIP_APPROVAL_STATUS", context.approvalStatus);
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
if (wakePayloadJson) {
paperclipEnv.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
}
const workspaceContext = parseObject(context.paperclipWorkspace);
setIfPresent("PAPERCLIP_WORKSPACE_CWD", workspaceContext.cwd);
setIfPresent("PAPERCLIP_WORKSPACE_SOURCE", workspaceContext.source);
setIfPresent("PAPERCLIP_WORKSPACE_STRATEGY", workspaceContext.strategy);
setIfPresent("PAPERCLIP_WORKSPACE_ID", workspaceContext.workspaceId);
setIfPresent("PAPERCLIP_WORKSPACE_REPO_URL", workspaceContext.repoUrl);
setIfPresent("PAPERCLIP_WORKSPACE_REPO_REF", workspaceContext.repoRef);
setIfPresent("PAPERCLIP_WORKSPACE_BRANCH", workspaceContext.branchName);
setIfPresent("PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspaceContext.worktreePath);
setIfPresent("AGENT_HOME", workspaceContext.agentHome);
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((v) => typeof v === "string" && v.trim().length > 0)
: [];
if (linkedIssueIds.length > 0) {
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (Array.isArray(context.paperclipWorkspaces) && context.paperclipWorkspaces.length > 0) {
paperclipEnv.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(context.paperclipWorkspaces);
}
if (Array.isArray(context.paperclipRuntimeServiceIntents) && context.paperclipRuntimeServiceIntents.length > 0) {
paperclipEnv.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(context.paperclipRuntimeServiceIntents);
}
if (Array.isArray(context.paperclipRuntimeServices) && context.paperclipRuntimeServices.length > 0) {
paperclipEnv.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(context.paperclipRuntimeServices);
}
setIfPresent("PAPERCLIP_RUNTIME_PRIMARY_URL", context.paperclipRuntimePrimaryUrl);
// Auth token for agent callback to Paperclip API
if (ctx.authToken) {
paperclipEnv.PAPERCLIP_API_KEY = ctx.authToken;
}
// PAPERCLIP_API_URL is inherited from the Deployment env via selfPod.inheritedEnv.
// buildPaperclipEnv() sets a localhost value which is wrong for Job pods —
// the inherited value (set in the infra repo) points to the in-cluster service.
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
}
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
const merged = {
...selfPod.inheritedEnv,
...paperclipEnv,
};
// Layer 4: User-defined overrides from adapterConfig.env (wins over everything)
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string")
merged[key] = value;
}
// HOME must be /paperclip to match PVC mount and enable session resume
merged.HOME = "/paperclip";
// Convert to V1EnvVar array
const envVars = Object.entries(merged).map(([name, value]) => ({
name,
value,
}));
return envVars;
}
export function buildJobManifest(input) {
const { ctx, selfPod } = input;
const { runId, agent, runtime, config: rawConfig, context } = ctx;
const config = parseObject(rawConfig);
// Resolve config values
const namespace = asString(config.namespace, "") || selfPod.namespace;
const image = asString(config.image, "") || selfPod.image;
const model = asString(config.model, "");
const effort = asString(config.effort, "");
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
// K8s Job pods are always unattended — no one to approve permission prompts
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const extraArgs = asStringArray(config.extraArgs);
const timeoutSec = asNumber(config.timeoutSec, 0);
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
const resources = parseObject(config.resources);
const nodeSelector = parseObject(config.nodeSelector);
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
const extraLabels = parseObject(config.labels);
// Resolve working directory — use workspace cwd, fall back to /paperclip
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const configuredCwd = asString(config.cwd, "");
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
const agentSlug = sanitizeForK8sName(agent.id);
const runSlug = sanitizeForK8sName(runId);
const jobName = `agent-${agentSlug}-${runSlug}`;
// Build prompt (same logic as claude_local)
const promptTemplate = asString(config.promptTemplate, "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.");
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const renderedBootstrapPrompt = !runtimeSessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(runtimeSessionId) });
const shouldUseResumeDeltaPrompt = Boolean(runtimeSessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
// Build Claude CLI args
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (runtimeSessionId)
claudeArgs.push("--resume", runtimeSessionId);
if (dangerouslySkipPermissions)
claudeArgs.push("--dangerously-skip-permissions");
if (model)
claudeArgs.push("--model", model);
if (effort)
claudeArgs.push("--effort", effort);
if (maxTurns > 0)
claudeArgs.push("--max-turns", String(maxTurns));
if (instructionsFilePath)
claudeArgs.push("--append-system-prompt-file", instructionsFilePath);
if (extraArgs.length > 0)
claudeArgs.push(...extraArgs);
// Build env vars
const envVars = buildEnvVars(ctx, selfPod, config);
// Resource defaults
const resourceRequests = parseObject(resources.requests);
const resourceLimits = parseObject(resources.limits);
const containerResources = {
requests: {
cpu: asString(resourceRequests.cpu, "1000m"),
memory: asString(resourceRequests.memory, "2Gi"),
},
limits: {
cpu: asString(resourceLimits.cpu, "4000m"),
memory: asString(resourceLimits.memory, "8Gi"),
},
};
// Labels
const labels = {
"app.kubernetes.io/managed-by": "paperclip",
"app.kubernetes.io/component": "agent-job",
"paperclip.io/agent-id": agent.id,
"paperclip.io/run-id": runId,
"paperclip.io/company-id": agent.companyId,
"paperclip.io/adapter-type": "claude_k8s",
};
for (const [key, value] of Object.entries(extraLabels)) {
if (typeof value === "string")
labels[key] = value;
}
// Volumes
const volumes = [
{
name: "prompt",
emptyDir: {},
},
];
const volumeMounts = [
{
name: "prompt",
mountPath: "/tmp/prompt",
},
];
// Mount shared PVC for /paperclip (session state, workspaces, data)
if (selfPod.pvcClaimName) {
volumes.push({
name: "data",
persistentVolumeClaim: { claimName: selfPod.pvcClaimName },
});
volumeMounts.push({
name: "data",
mountPath: "/paperclip",
});
}
// Mount secret volumes inherited from the Deployment pod
for (const sv of selfPod.secretVolumes) {
volumes.push({
name: sv.volumeName,
secret: { secretName: sv.secretName, defaultMode: sv.defaultMode, optional: true },
});
volumeMounts.push({
name: sv.volumeName,
mountPath: sv.mountPath,
readOnly: true,
});
}
// Security context matching the main Deployment
const securityContext = {
capabilities: { drop: ["ALL"] },
readOnlyRootFilesystem: false,
runAsNonRoot: true,
runAsUser: 1000,
allowPrivilegeEscalation: false,
};
const podSecurityContext = {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
fsGroup: 1000,
};
// 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 job = {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: jobName,
namespace,
labels,
annotations: {
"paperclip.io/adapter-type": "claude_k8s",
"paperclip.io/agent-name": agent.name,
},
},
spec: {
backoffLimit: 0,
...(timeoutSec > 0 ? { activeDeadlineSeconds: timeoutSec } : {}),
ttlSecondsAfterFinished: ttlSeconds,
template: {
metadata: { labels },
spec: {
restartPolicy: "Never",
serviceAccountName: asString(config.serviceAccountName, "") || undefined,
securityContext: podSecurityContext,
...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}),
...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}),
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector: nodeSelector } : {}),
...(tolerations.length > 0 ? { tolerations: tolerations } : {}),
initContainers: [
{
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
securityContext,
resources: {
requests: { cpu: "10m", memory: "16Mi" },
limits: { cpu: "100m", memory: "64Mi" },
},
},
],
containers: [
{
name: "claude",
image,
imagePullPolicy: asString(config.imagePullPolicy, "IfNotPresent"),
workingDir,
command: ["sh", "-c", mainCommand],
env: envVars,
volumeMounts,
securityContext,
resources: containerResources,
},
],
volumes,
},
},
},
};
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics };
}
//# sourceMappingURL=job-manifest.js.map
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
import * as k8s from "@kubernetes/client-node";
/**
* Cached self-pod introspection result. Queried once on first execute(),
* then reused for all subsequent Job builds so every Job inherits the
* Deployment's image, imagePullSecrets, DNS config, and PVC claim.
*/
export interface SelfPodSecretVolume {
volumeName: string;
secretName: string;
mountPath: string;
defaultMode: number | undefined;
}
export interface SelfPodInfo {
namespace: string;
image: string;
imagePullSecrets: Array<{
name: string;
}>;
dnsConfig: k8s.V1PodDNSConfig | undefined;
pvcClaimName: string | null;
secretVolumes: SelfPodSecretVolume[];
/** Env vars inherited from the Deployment container. */
inheritedEnv: Record<string, string>;
}
export declare function getBatchApi(kubeconfigPath?: string): k8s.BatchV1Api;
export declare function getCoreApi(kubeconfigPath?: string): k8s.CoreV1Api;
export declare function getAuthzApi(kubeconfigPath?: string): k8s.AuthorizationV1Api;
export declare function getLogApi(kubeconfigPath?: string): k8s.Log;
/**
* Query the K8s API for our own pod spec and cache the result.
* Extracts image, imagePullSecrets, dnsConfig, PVC claim name,
* and environment variables to forward to Job pods.
*/
export declare function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodInfo>;
/** Reset cached state — useful for tests. */
export declare function resetCache(): void;
//# sourceMappingURL=k8s-client.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"k8s-client.d.ts","sourceRoot":"","sources":["../../src/server/k8s-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAG/C;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AAED,MAAM,WAAW,WAAW;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1C,SAAS,EAAE,GAAG,CAAC,cAAc,GAAG,SAAS,CAAC;IAC1C,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,EAAE,mBAAmB,EAAE,CAAC;IACrC,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAmCD,wBAAgB,WAAW,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,UAAU,CAEnE;AAED,wBAAgB,UAAU,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,SAAS,CAEjE;AAED,wBAAgB,WAAW,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,kBAAkB,CAE3E;AAED,wBAAgB,SAAS,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,GAAG,CAAC,GAAG,CAE1D;AAmBD;;;;GAIG;AACH,wBAAsB,cAAc,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAoElF;AAED,6CAA6C;AAC7C,wBAAgB,UAAU,IAAI,IAAI,CAGjC"}
+132
View File
@@ -0,0 +1,132 @@
import * as k8s from "@kubernetes/client-node";
import { readFileSync } from "node:fs";
/** Keys forwarded from the Deployment container env into Job pods. */
const INHERITED_ENV_KEYS = [
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_BEARER_TOKEN_BEDROCK",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"PAPERCLIP_API_URL",
];
let cachedSelfPod = null;
/**
* Cache keyed by kubeconfig path (empty string = in-cluster).
* Supports multiple agents with different kubeconfigs.
*/
const kcCache = new Map();
function getKubeConfig(kubeconfigPath) {
const key = kubeconfigPath ?? "";
let kc = kcCache.get(key);
if (!kc) {
kc = new k8s.KubeConfig();
if (kubeconfigPath) {
kc.loadFromFile(kubeconfigPath);
}
else {
kc.loadFromCluster();
}
kcCache.set(key, kc);
}
return kc;
}
export function getBatchApi(kubeconfigPath) {
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.BatchV1Api);
}
export function getCoreApi(kubeconfigPath) {
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.CoreV1Api);
}
export function getAuthzApi(kubeconfigPath) {
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.AuthorizationV1Api);
}
export function getLogApi(kubeconfigPath) {
return new k8s.Log(getKubeConfig(kubeconfigPath));
}
/**
* Read the current pod's namespace. Checks (in order):
* 1. PAPERCLIP_NAMESPACE env var (set explicitly in Deployment)
* 2. Service account namespace file (standard in-cluster path)
* 3. POD_NAMESPACE env var (Downward API convention)
* Falls back to "default" only if none of the above are available.
*/
function readInClusterNamespace() {
const fromEnv = process.env.PAPERCLIP_NAMESPACE ?? process.env.POD_NAMESPACE;
if (fromEnv?.trim())
return fromEnv.trim();
try {
return readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf-8").trim();
}
catch {
return "default";
}
}
/**
* Query the K8s API for our own pod spec and cache the result.
* Extracts image, imagePullSecrets, dnsConfig, PVC claim name,
* and environment variables to forward to Job pods.
*/
export async function getSelfPodInfo(kubeconfigPath) {
if (cachedSelfPod)
return cachedSelfPod;
const hostname = process.env.HOSTNAME;
if (!hostname) {
throw new Error("claude_k8s: HOSTNAME env var not set — cannot introspect running pod");
}
const namespace = readInClusterNamespace();
const coreApi = getCoreApi(kubeconfigPath);
const pod = await coreApi.readNamespacedPod({ name: hostname, namespace });
const spec = pod.spec;
if (!spec) {
throw new Error(`claude_k8s: pod ${hostname} has no spec`);
}
const mainContainer = spec.containers[0];
if (!mainContainer?.image) {
throw new Error(`claude_k8s: pod ${hostname} has no container image`);
}
// Find PVC claim name from volumes mounted at /paperclip
let pvcClaimName = null;
const dataMount = mainContainer.volumeMounts?.find((vm) => vm.mountPath === "/paperclip");
if (dataMount) {
const volume = spec.volumes?.find((v) => v.name === dataMount.name);
pvcClaimName = volume?.persistentVolumeClaim?.claimName ?? null;
}
// Discover secret volumes mounted on the main container
const secretVolumes = [];
for (const vm of mainContainer.volumeMounts ?? []) {
const vol = spec.volumes?.find((v) => v.name === vm.name);
if (vol?.secret?.secretName) {
secretVolumes.push({
volumeName: vm.name,
secretName: vol.secret.secretName,
mountPath: vm.mountPath,
defaultMode: vol.secret.defaultMode,
});
}
}
// Collect inherited env vars from process.env (these came from the Deployment spec)
const inheritedEnv = {};
for (const key of INHERITED_ENV_KEYS) {
const value = process.env[key];
if (value !== undefined) {
inheritedEnv[key] = value;
}
}
cachedSelfPod = {
namespace,
image: mainContainer.image,
imagePullSecrets: (spec.imagePullSecrets ?? []).map((s) => ({
name: s.name ?? "",
})).filter((s) => s.name.length > 0),
dnsConfig: spec.dnsConfig,
pvcClaimName,
secretVolumes,
inheritedEnv,
};
return cachedSelfPod;
}
/** Reset cached state — useful for tests. */
export function resetCache() {
kcCache.clear();
cachedSelfPod = null;
}
//# sourceMappingURL=k8s-client.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"k8s-client.js","sourceRoot":"","sources":["../../src/server/k8s-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,GAAG,MAAM,yBAAyB,CAAC;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAyBvC,sEAAsE;AACtE,MAAM,kBAAkB,GAAG;IACzB,yBAAyB;IACzB,YAAY;IACZ,0BAA0B;IAC1B,mBAAmB;IACnB,gBAAgB;IAChB,mBAAmB;CACpB,CAAC;AAEF,IAAI,aAAa,GAAuB,IAAI,CAAC;AAE7C;;;GAGG;AACH,MAAM,OAAO,GAAG,IAAI,GAAG,EAA0B,CAAC;AAElD,SAAS,aAAa,CAAC,cAAuB;IAC5C,MAAM,GAAG,GAAG,cAAc,IAAI,EAAE,CAAC;IACjC,IAAI,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IAC1B,IAAI,CAAC,EAAE,EAAE,CAAC;QACR,EAAE,GAAG,IAAI,GAAG,CAAC,UAAU,EAAE,CAAC;QAC1B,IAAI,cAAc,EAAE,CAAC;YACnB,EAAE,CAAC,YAAY,CAAC,cAAc,CAAC,CAAC;QAClC,CAAC;aAAM,CAAC;YACN,EAAE,CAAC,eAAe,EAAE,CAAC;QACvB,CAAC;QACD,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,cAAuB;IACjD,OAAO,aAAa,CAAC,cAAc,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,UAAU,CAAC,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,cAAuB;IAChD,OAAO,aAAa,CAAC,cAAc,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;AACpE,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,cAAuB;IACjD,OAAO,aAAa,CAAC,cAAc,CAAC,CAAC,aAAa,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC;AAC7E,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,cAAuB;IAC/C,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,aAAa,CAAC,cAAc,CAAC,CAAC,CAAC;AACpD,CAAC;AAED;;;;;;GAMG;AACH,SAAS,sBAAsB;IAC7B,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,OAAO,CAAC,GAAG,CAAC,aAAa,CAAC;IAC7E,IAAI,OAAO,EAAE,IAAI,EAAE;QAAE,OAAO,OAAO,CAAC,IAAI,EAAE,CAAC;IAC3C,IAAI,CAAC;QACH,OAAO,YAAY,CAAC,yDAAyD,EAAE,OAAO,CAAC,CAAC,IAAI,EAAE,CAAC;IACjG,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAC;IACnB,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAAC,cAAuB;IAC1D,IAAI,aAAa;QAAE,OAAO,aAAa,CAAC;IAExC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,QAAQ,CAAC;IACtC,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,sEAAsE,CAAC,CAAC;IAC1F,CAAC;IAED,MAAM,SAAS,GAAG,sBAAsB,EAAE,CAAC;IAC3C,MAAM,OAAO,GAAG,UAAU,CAAC,cAAc,CAAC,CAAC;IAC3C,MAAM,GAAG,GAAG,MAAM,OAAO,CAAC,iBAAiB,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,SAAS,EAAE,CAAC,CAAC;IAE3E,MAAM,IAAI,GAAG,GAAG,CAAC,IAAI,CAAC;IACtB,IAAI,CAAC,IAAI,EAAE,CAAC;QACV,MAAM,IAAI,KAAK,CAAC,mBAAmB,QAAQ,cAAc,CAAC,CAAC;IAC7D,CAAC;IAED,MAAM,aAAa,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IACzC,IAAI,CAAC,aAAa,EAAE,KAAK,EAAE,CAAC;QAC1B,MAAM,IAAI,KAAK,CAAC,mBAAmB,QAAQ,yBAAyB,CAAC,CAAC;IACxE,CAAC;IAED,yDAAyD;IACzD,IAAI,YAAY,GAAkB,IAAI,CAAC;IACvC,MAAM,SAAS,GAAG,aAAa,CAAC,YAAY,EAAE,IAAI,CAChD,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,SAAS,KAAK,YAAY,CACtC,CAAC;IACF,IAAI,SAAS,EAAE,CAAC;QACd,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,SAAS,CAAC,IAAI,CAAC,CAAC;QACpE,YAAY,GAAG,MAAM,EAAE,qBAAqB,EAAE,SAAS,IAAI,IAAI,CAAC;IAClE,CAAC;IAED,wDAAwD;IACxD,MAAM,aAAa,GAA0B,EAAE,CAAC;IAChD,KAAK,MAAM,EAAE,IAAI,aAAa,CAAC,YAAY,IAAI,EAAE,EAAE,CAAC;QAClD,MAAM,GAAG,GAAG,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,IAAI,CAAC,CAAC;QAC1D,IAAI,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;YAC5B,aAAa,CAAC,IAAI,CAAC;gBACjB,UAAU,EAAE,EAAE,CAAC,IAAI;gBACnB,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,UAAU;gBACjC,SAAS,EAAE,EAAE,CAAC,SAAS;gBACvB,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,WAAW;aACpC,CAAC,CAAC;QACL,CAAC;IACH,CAAC;IAED,oFAAoF;IACpF,MAAM,YAAY,GAA2B,EAAE,CAAC;IAChD,KAAK,MAAM,GAAG,IAAI,kBAAkB,EAAE,CAAC;QACrC,MAAM,KAAK,GAAG,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACxB,YAAY,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;QAC5B,CAAC;IACH,CAAC;IAED,aAAa,GAAG;QACd,SAAS;QACT,KAAK,EAAE,aAAa,CAAC,KAAK;QAC1B,gBAAgB,EAAE,CAAC,IAAI,CAAC,gBAAgB,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC1D,IAAI,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE;SACnB,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC;QACpC,SAAS,EAAE,IAAI,CAAC,SAAS;QACzB,YAAY;QACZ,aAAa;QACb,YAAY;KACb,CAAC;IAEF,OAAO,aAAa,CAAC;AACvB,CAAC;AAED,6CAA6C;AAC7C,MAAM,UAAU,UAAU;IACxB,OAAO,CAAC,KAAK,EAAE,CAAC;IAChB,aAAa,GAAG,IAAI,CAAC;AACvB,CAAC"}
+22
View File
@@ -0,0 +1,22 @@
import type { UsageSummary } from "@paperclipai/adapter-utils";
export declare function parseClaudeStreamJson(stdout: string): {
sessionId: string | null;
model: string;
costUsd: number | null;
usage: UsageSummary | null;
summary: string;
resultJson: Record<string, unknown> | null;
};
export declare function extractClaudeLoginUrl(text: string): string | null;
export declare function detectClaudeLoginRequired(input: {
parsed: Record<string, unknown> | null;
stdout: string;
stderr: string;
}): {
requiresLogin: boolean;
loginUrl: string | null;
};
export declare function describeClaudeFailure(parsed: Record<string, unknown>): string | null;
export declare function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean;
export declare function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean;
//# sourceMappingURL=parse.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/server/parse.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAM/D,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM;;;aA4C7B,MAAM,GAAG,IAAI;WACf,YAAY,GAAG,IAAI;;gBAEd,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI;EAsBvD;AAkCD,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAUjE;AAED,wBAAgB,yBAAyB,CAAC,KAAK,EAAE;IAC/C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;CAChB,GAAG;IAAE,aAAa,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAatD;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CAcpF;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAWlG;AAED,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CASpF"}
+158
View File
@@ -0,0 +1,158 @@
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
export function parseClaudeStreamJson(stdout) {
let sessionId = null;
let model = "";
let finalResult = null;
const assistantTexts = [];
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line)
continue;
const event = parseJson(line);
if (!event)
continue;
const type = asString(event.type, "");
if (type === "system" && asString(event.subtype, "") === "init") {
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
model = asString(event.model, model);
continue;
}
if (type === "assistant") {
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
const message = parseObject(event.message);
const content = Array.isArray(message.content) ? message.content : [];
for (const entry of content) {
if (typeof entry !== "object" || entry === null || Array.isArray(entry))
continue;
const block = entry;
if (asString(block.type, "") === "text") {
const text = asString(block.text, "");
if (text)
assistantTexts.push(text);
}
}
continue;
}
if (type === "result") {
finalResult = event;
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
}
}
if (!finalResult) {
return {
sessionId,
model,
costUsd: null,
usage: null,
summary: assistantTexts.join("\n\n").trim(),
resultJson: null,
};
}
const usageObj = parseObject(finalResult.usage);
const usage = {
inputTokens: asNumber(usageObj.input_tokens, 0),
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
outputTokens: asNumber(usageObj.output_tokens, 0),
};
const costRaw = finalResult.total_cost_usd;
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
return {
sessionId,
model,
costUsd,
usage,
summary,
resultJson: finalResult,
};
}
function extractClaudeErrorMessages(parsed) {
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
const messages = [];
for (const entry of raw) {
if (typeof entry === "string") {
const msg = entry.trim();
if (msg)
messages.push(msg);
continue;
}
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
continue;
}
const obj = entry;
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
if (msg) {
messages.push(msg);
continue;
}
try {
messages.push(JSON.stringify(obj));
}
catch {
// skip non-serializable entry
}
}
return messages;
}
export function extractClaudeLoginUrl(text) {
const match = text.match(URL_RE);
if (!match || match.length === 0)
return null;
for (const rawUrl of match) {
const cleaned = rawUrl.replace(/[\])}.!,?;:'\"]+$/g, "");
if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) {
return cleaned;
}
}
return match[0]?.replace(/[\])}.!,?;:'\"]+$/g, "") ?? null;
}
export function detectClaudeLoginRequired(input) {
const resultText = asString(input.parsed?.result, "").trim();
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
.join("\n")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
return {
requiresLogin,
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
};
}
export function describeClaudeFailure(parsed) {
const subtype = asString(parsed.subtype, "");
const resultText = asString(parsed.result, "").trim();
const errors = extractClaudeErrorMessages(parsed);
let detail = resultText;
if (!detail && errors.length > 0) {
detail = errors[0] ?? "";
}
const parts = ["Claude run failed"];
if (subtype)
parts.push(`subtype=${subtype}`);
if (detail)
parts.push(detail);
return parts.length > 1 ? parts.join(": ") : null;
}
export function isClaudeMaxTurnsResult(parsed) {
if (!parsed)
return false;
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
if (subtype === "error_max_turns")
return true;
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
if (stopReason === "max_turns")
return true;
const resultText = asString(parsed.result, "").trim();
return /max(?:imum)?\s+turns?/i.test(resultText);
}
export function isClaudeUnknownSessionError(parsed) {
const resultText = asString(parsed.result, "").trim();
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
.map((msg) => msg.trim())
.filter(Boolean);
return allMessages.some((msg) => /no conversation found with session id|unknown session|session .* not found/i.test(msg));
}
//# sourceMappingURL=parse.js.map
+1
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
export declare const sessionCodec: AdapterSessionCodec;
//# sourceMappingURL=session.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AAMtE,eAAO,MAAM,YAAY,EAAE,mBA4C1B,CAAC"}
+52
View File
@@ -0,0 +1,52 @@
function readNonEmptyString(value) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec = {
deserialize(raw) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
return null;
const record = raw;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId)
return null;
const cwd = readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params) {
if (!params)
return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId)
return null;
const cwd = readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params) {
if (!params)
return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};
//# sourceMappingURL=session.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"session.js","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAEA,SAAS,kBAAkB,CAAC,KAAc;IACxC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACpF,CAAC;AAED,MAAM,CAAC,MAAM,YAAY,GAAwB;IAC/C,WAAW,CAAC,GAAY;QACtB,IAAI,OAAO,GAAG,KAAK,QAAQ,IAAI,GAAG,KAAK,IAAI,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC;YAAE,OAAO,IAAI,CAAC;QAC/E,MAAM,MAAM,GAAG,GAA8B,CAAC;QAC9C,MAAM,SAAS,GAAG,kBAAkB,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAChG,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAC5B,MAAM,GAAG,GACP,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC;YAC9B,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC;YAClC,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACpC,MAAM,WAAW,GAAG,kBAAkB,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;QACtG,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC1F,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC1F,OAAO;YACL,SAAS;YACT,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvB,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/B,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAChC,CAAC;IACJ,CAAC;IACD,SAAS,CAAC,MAAsC;QAC9C,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QACzB,MAAM,SAAS,GAAG,kBAAkB,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;QAChG,IAAI,CAAC,SAAS;YAAE,OAAO,IAAI,CAAC;QAC5B,MAAM,GAAG,GACP,kBAAkB,CAAC,MAAM,CAAC,GAAG,CAAC;YAC9B,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC;YAClC,kBAAkB,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACpC,MAAM,WAAW,GAAG,kBAAkB,CAAC,MAAM,CAAC,WAAW,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;QACtG,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC1F,MAAM,OAAO,GAAG,kBAAkB,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC1F,OAAO;YACL,SAAS;YACT,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvB,GAAG,CAAC,WAAW,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YACvC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;YAC/B,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;SAChC,CAAC;IACJ,CAAC;IACD,YAAY,CAAC,MAAsC;QACjD,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QACzB,OAAO,kBAAkB,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,kBAAkB,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IACvF,CAAC;CACF,CAAC"}
+3
View File
@@ -0,0 +1,3 @@
import type { AdapterEnvironmentTestContext, AdapterEnvironmentTestResult } from "@paperclipai/adapter-utils";
export declare function testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
//# sourceMappingURL=test.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../src/server/test.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,6BAA6B,EAC7B,4BAA4B,EAC7B,MAAM,4BAA4B,CAAC;AAwMpC,wBAAsB,eAAe,CACnC,GAAG,EAAE,6BAA6B,GACjC,OAAO,CAAC,4BAA4B,CAAC,CAkCvC"}
+210
View File
@@ -0,0 +1,210 @@
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
function summarizeStatus(checks) {
if (checks.some((c) => c.level === "error"))
return "fail";
if (checks.some((c) => c.level === "warn"))
return "warn";
return "pass";
}
async function checkApiReachable(checks, kubeconfigPath) {
try {
const selfPod = await getSelfPodInfo(kubeconfigPath);
checks.push({
code: "k8s_api_reachable",
level: "info",
message: `Kubernetes API reachable; running in namespace ${selfPod.namespace}`,
detail: `Image: ${selfPod.image}`,
});
return true;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_api_unreachable",
level: "error",
message: `Cannot reach Kubernetes API: ${msg}`,
hint: "Ensure the pod has a valid service account token mounted and the API server is accessible.",
});
return false;
}
}
async function checkNamespace(namespace, selfPodNamespace, checks, kubeconfigPath) {
// If targeting the same namespace we're running in, skip the cluster-scoped
// readNamespace call — we know it exists, and the SA may lack cluster-level
// namespace get permissions.
if (namespace === selfPodNamespace) {
checks.push({
code: "k8s_namespace_exists",
level: "info",
message: `Target namespace is the pod namespace: ${namespace}`,
});
return true;
}
try {
const coreApi = getCoreApi(kubeconfigPath);
await coreApi.readNamespace({ name: namespace });
checks.push({
code: "k8s_namespace_exists",
level: "info",
message: `Target namespace exists: ${namespace}`,
});
return true;
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_namespace_check_failed",
level: "warn",
message: `Cannot verify namespace "${namespace}": ${msg}`,
hint: "The service account may lack cluster-level namespace read permissions. The namespace may still be usable — verify RBAC checks below.",
});
// Don't block on this — RBAC checks below will catch actual permission issues
return true;
}
}
async function checkRbac(namespace, checks, kubeconfigPath) {
const authzApi = getAuthzApi(kubeconfigPath);
const rbacChecks = [
{ resource: "jobs", group: "batch", verb: "create", code: "k8s_rbac_job_create", label: "create Jobs" },
{ resource: "jobs", group: "batch", verb: "delete", code: "k8s_rbac_job_delete", label: "delete Jobs" },
{ resource: "jobs", group: "batch", verb: "get", code: "k8s_rbac_job_get", label: "get Jobs" },
{ resource: "pods", group: "", verb: "list", code: "k8s_rbac_pod_list", label: "list Pods" },
{ resource: "pods/log", group: "", verb: "get", code: "k8s_rbac_pod_log", label: "get Pod logs" },
];
for (const check of rbacChecks) {
try {
const review = await authzApi.createSelfSubjectAccessReview({
body: {
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: {
resourceAttributes: {
namespace,
verb: check.verb,
resource: check.resource,
group: check.group,
},
},
},
});
if (review.status?.allowed) {
checks.push({
code: check.code,
level: "info",
message: `RBAC: allowed to ${check.label} in ${namespace}`,
});
}
else {
checks.push({
code: check.code,
level: "error",
message: `RBAC: not allowed to ${check.label} in ${namespace}`,
hint: `Grant the service account permission to ${check.verb} ${check.resource} in namespace ${namespace}.`,
});
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: check.code,
level: "warn",
message: `RBAC check failed for ${check.label}: ${msg}`,
hint: "SelfSubjectAccessReview may not be available; verify permissions manually.",
});
}
}
}
async function checkSecret(namespace, secretName, checks, kubeconfigPath) {
try {
const coreApi = getCoreApi(kubeconfigPath);
await coreApi.readNamespacedSecret({ name: secretName, namespace });
checks.push({
code: "k8s_secret_exists",
level: "info",
message: `Secret "${secretName}" exists in namespace ${namespace}`,
});
}
catch {
checks.push({
code: "k8s_secret_missing",
level: "warn",
message: `Secret "${secretName}" not found in namespace ${namespace}`,
hint: `Ensure the paperclip-secrets Secret exists with keys for ANTHROPIC_API_KEY and/or AWS_BEARER_TOKEN_BEDROCK.`,
});
}
}
async function checkPvc(selfPod, checks, kubeconfigPath) {
if (!selfPod.pvcClaimName) {
checks.push({
code: "k8s_pvc_not_detected",
level: "warn",
message: "No PVC detected on /paperclip mount — session resume and workspace sharing will not work.",
hint: "Ensure the Paperclip Deployment has a PVC mounted at /paperclip with ReadWriteMany access mode.",
});
return;
}
try {
const coreApi = getCoreApi(kubeconfigPath);
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: selfPod.pvcClaimName,
namespace: selfPod.namespace,
});
const accessModes = pvc.spec?.accessModes ?? [];
const isRwx = accessModes.includes("ReadWriteMany");
if (isRwx) {
checks.push({
code: "k8s_pvc_rwx",
level: "info",
message: `PVC "${selfPod.pvcClaimName}" has ReadWriteMany access — Job pods can mount it.`,
});
}
else {
checks.push({
code: "k8s_pvc_not_rwx",
level: "warn",
message: `PVC "${selfPod.pvcClaimName}" access modes: ${accessModes.join(", ")}. ReadWriteMany is required for Job pods to share the volume.`,
hint: "Change the PVC accessMode to ReadWriteMany in Helm values.",
});
}
}
catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_pvc_check_failed",
level: "warn",
message: `Could not read PVC "${selfPod.pvcClaimName}": ${msg}`,
});
}
}
export async function testEnvironment(ctx) {
const checks = [];
const config = parseObject(ctx.config);
const secretRef = asString(config.secretRef, "paperclip-secrets");
const kubeconfigPath = asString(config.kubeconfig, "") || undefined;
// 1. K8s API reachable + self-pod introspection
const apiOk = await checkApiReachable(checks, kubeconfigPath);
if (!apiOk) {
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
}
const selfPod = await getSelfPodInfo(kubeconfigPath);
const namespace = asString(config.namespace, "") || selfPod.namespace;
// 2. Target namespace exists
const nsOk = await checkNamespace(namespace, selfPod.namespace, checks, kubeconfigPath);
if (!nsOk) {
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
}
// 3-5. Run remaining checks in parallel
await Promise.all([
checkRbac(namespace, checks, kubeconfigPath),
checkSecret(namespace, secretRef, checks, kubeconfigPath),
checkPvc(selfPod, checks, kubeconfigPath),
]);
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
//# sourceMappingURL=test.js.map
+1
View File
File diff suppressed because one or more lines are too long
+60
View File
@@ -0,0 +1,60 @@
/**
* Self-contained stdout parser for Claude stream-json output.
* Zero external imports — required by the Paperclip adapter plugin UI parser contract.
*/
type TranscriptEntry = {
kind: "assistant";
ts: string;
text: string;
} | {
kind: "thinking";
ts: string;
text: string;
} | {
kind: "user";
ts: string;
text: string;
} | {
kind: "tool_call";
ts: string;
name: string;
input: unknown;
toolUseId?: string;
} | {
kind: "tool_result";
ts: string;
toolUseId: string;
content: string;
isError: boolean;
} | {
kind: "init";
ts: string;
model: string;
sessionId: string;
} | {
kind: "result";
ts: string;
text: string;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
costUsd: number;
subtype: string;
isError: boolean;
errors: string[];
} | {
kind: "stderr";
ts: string;
text: string;
} | {
kind: "system";
ts: string;
text: string;
} | {
kind: "stdout";
ts: string;
text: string;
};
export declare function parseStdoutLine(line: string, ts: string): TranscriptEntry[];
export {};
//# sourceMappingURL=ui-parser.d.ts.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"ui-parser.d.ts","sourceRoot":"","sources":["../src/ui-parser.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,KAAK,eAAe,GAChB;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC/C;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC9C;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC1C;IAAE,IAAI,EAAE,WAAW,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GACnF;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GACzF;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,GACnL;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAC5C;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAoCjD,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,eAAe,EAAE,CA0G3E"}
+152
View File
@@ -0,0 +1,152 @@
/**
* Self-contained stdout parser for Claude stream-json output.
* Zero external imports — required by the Paperclip adapter plugin UI parser contract.
*/
function asRecord(value) {
if (typeof value !== "object" || value === null || Array.isArray(value))
return null;
return value;
}
function asNumber(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function errorText(value) {
if (typeof value === "string")
return value;
const rec = asRecord(value);
if (!rec)
return "";
const msg = (typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg)
return msg;
try {
return JSON.stringify(rec);
}
catch {
return "";
}
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
}
catch {
return null;
}
}
export function parseStdoutLine(line, ts) {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = typeof parsed.type === "string" ? parsed.type : "";
if (type === "system" && parsed.subtype === "init") {
return [
{
kind: "init",
ts,
model: typeof parsed.model === "string" ? parsed.model : "unknown",
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
},
];
}
if (type === "assistant") {
const message = asRecord(parsed.message) ?? {};
const content = Array.isArray(message.content) ? message.content : [];
const entries = [];
for (const blockRaw of content) {
const block = asRecord(blockRaw);
if (!block)
continue;
const blockType = typeof block.type === "string" ? block.type : "";
if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : "";
if (text)
entries.push({ kind: "assistant", ts, text });
}
else if (blockType === "thinking") {
const text = typeof block.thinking === "string" ? block.thinking : "";
if (text)
entries.push({ kind: "thinking", ts, text });
}
else if (blockType === "tool_use") {
entries.push({
kind: "tool_call",
ts,
name: typeof block.name === "string" ? block.name : "unknown",
toolUseId: typeof block.id === "string"
? block.id
: typeof block.tool_use_id === "string"
? block.tool_use_id
: undefined,
input: block.input ?? {},
});
}
}
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
}
if (type === "user") {
const message = asRecord(parsed.message) ?? {};
const content = Array.isArray(message.content) ? message.content : [];
const entries = [];
for (const blockRaw of content) {
const block = asRecord(blockRaw);
if (!block)
continue;
const blockType = typeof block.type === "string" ? block.type : "";
if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : "";
if (text)
entries.push({ kind: "user", ts, text });
}
else if (blockType === "tool_result") {
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
const isError = block.is_error === true;
let text = "";
if (typeof block.content === "string") {
text = block.content;
}
else if (Array.isArray(block.content)) {
const parts = [];
for (const part of block.content) {
const p = asRecord(part);
if (p && typeof p.text === "string")
parts.push(p.text);
}
text = parts.join("\n");
}
entries.push({ kind: "tool_result", ts, toolUseId, content: text, isError });
}
}
if (entries.length > 0)
return entries;
}
if (type === "result") {
const usage = asRecord(parsed.usage) ?? {};
const inputTokens = asNumber(usage.input_tokens);
const outputTokens = asNumber(usage.output_tokens);
const cachedTokens = asNumber(usage.cache_read_input_tokens);
const costUsd = asNumber(parsed.total_cost_usd);
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
const isError = parsed.is_error === true;
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
const text = typeof parsed.result === "string" ? parsed.result : "";
return [{
kind: "result",
ts,
text,
inputTokens,
outputTokens,
cachedTokens,
costUsd,
subtype,
isError,
errors,
}];
}
return [{ kind: "stdout", ts, text: line }];
}
//# sourceMappingURL=ui-parser.js.map
+1
View File
File diff suppressed because one or more lines are too long
+834
View File
@@ -0,0 +1,834 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-claude-k8s",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0"
},
"devDependencies": {
"@paperclipai/adapter-utils": "^0.3.0",
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=0.3.0"
}
},
"node_modules/@jsep-plugin/assignment": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz",
"integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==",
"license": "MIT",
"engines": {
"node": ">= 10.16.0"
},
"peerDependencies": {
"jsep": "^0.4.0||^1.0.0"
}
},
"node_modules/@jsep-plugin/regex": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz",
"integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==",
"license": "MIT",
"engines": {
"node": ">= 10.16.0"
},
"peerDependencies": {
"jsep": "^0.4.0||^1.0.0"
}
},
"node_modules/@kubernetes/client-node": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@kubernetes/client-node/-/client-node-1.4.0.tgz",
"integrity": "sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==",
"license": "Apache-2.0",
"dependencies": {
"@types/js-yaml": "^4.0.1",
"@types/node": "^24.0.0",
"@types/node-fetch": "^2.6.13",
"@types/stream-buffers": "^3.0.3",
"form-data": "^4.0.0",
"hpagent": "^1.2.0",
"isomorphic-ws": "^5.0.0",
"js-yaml": "^4.1.0",
"jsonpath-plus": "^10.3.0",
"node-fetch": "^2.7.0",
"openid-client": "^6.1.3",
"rfc4648": "^1.3.0",
"socks-proxy-agent": "^8.0.4",
"stream-buffers": "^3.0.2",
"tar-fs": "^3.0.9",
"ws": "^8.18.2"
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-0.3.1.tgz",
"integrity": "sha512-W66k+hJkQE8ma0asM/Sd90AC8HHy/BLG/sd0aOC+rDWw+gOasQyUkTnDoPv1zhQuTyKEEvLFV6ByOOKqEiAz/A==",
"dev": true
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/stream-buffers": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/stream-buffers/-/stream-buffers-3.0.8.tgz",
"integrity": "sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/b4a": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",
"integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",
"integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.0.tgz",
"integrity": "sha512-xzqKsCFxAek9aezYhjJuJRXBIaYlg/0OGDTZp+T8eYmYMlm66cs6cYko02drIyjN2CBbi+I6L7YfXyqpqtKRXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "3.8.7",
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.7.tgz",
"integrity": "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w==",
"license": "Apache-2.0",
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.0.tgz",
"integrity": "sha512-3zAJRZMDFGjdn+RVnNpF9kuELw+0Fl3lpndM4NcEOhb9zwtSo/deETfuIwMSE5BXanA0FrN1qVjffGwAg2Y7EA==",
"license": "Apache-2.0",
"dependencies": {
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.0.tgz",
"integrity": "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/hpagent": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/hpagent/-/hpagent-1.2.0.tgz",
"integrity": "sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsep": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"license": "MIT",
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/jsonpath-plus": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz",
"integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==",
"license": "MIT",
"dependencies": {
"@jsep-plugin/assignment": "^1.3.0",
"@jsep-plugin/regex": "^1.0.4",
"jsep": "^1.4.0"
},
"bin": {
"jsonpath": "bin/jsonpath-cli.js",
"jsonpath-plus": "bin/jsonpath-cli.js"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/oauth4webapi": {
"version": "3.8.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
"integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
"integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
"license": "MIT",
"dependencies": {
"jose": "^6.1.3",
"oauth4webapi": "^3.8.4"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/rfc4648": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/rfc4648/-/rfc4648-1.5.4.tgz",
"integrity": "sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==",
"license": "MIT"
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.7",
"resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",
"integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.0.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks-proxy-agent": {
"version": "8.0.5",
"resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
"integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "^4.3.4",
"socks": "^2.8.3"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/stream-buffers": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-3.0.3.tgz",
"integrity": "sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==",
"license": "Unlicense",
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/streamx": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.25.0.tgz",
"integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/tar-fs": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",
"integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",
"integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@farhoodliquor/paperclip-adapter-claude-k8s",
"version": "0.1.0",
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
"paperclip": {
"adapterUiParser": "1.0.0"
},
"exports": {
".": "./dist/index.js",
"./server": "./dist/server/index.js",
"./ui-parser": "./dist/ui-parser.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@kubernetes/client-node": "^1.0.0"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=0.3.0"
},
"devDependencies": {
"@paperclipai/adapter-utils": "^0.3.0",
"@types/node": "^24.6.0",
"typescript": "^5.7.3"
}
}
+65
View File
@@ -0,0 +1,65 @@
export const type = "claude_k8s";
export const label = "Claude (Kubernetes)";
export const models = [
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
// AWS Bedrock US inference profile IDs
{ id: "us.anthropic.claude-opus-4-6-v1", label: "Bedrock Opus 4.6" },
{ id: "us.anthropic.claude-sonnet-4-6", label: "Bedrock Sonnet 4.6" },
{ id: "us.anthropic.claude-opus-4-5-20251101-v1:0", label: "Bedrock Opus 4.5" },
{ id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "Bedrock Sonnet 4.5" },
{ id: "us.anthropic.claude-haiku-4-5-20251001-v1:0", label: "Bedrock Haiku 4.5" },
{ id: "us.anthropic.claude-opus-4-1-20250805-v1:0", label: "Bedrock Opus 4.1" },
{ id: "us.anthropic.claude-sonnet-4-20250514-v1:0", label: "Bedrock Sonnet 4" },
];
export const agentConfigurationDoc = `# claude_k8s agent configuration
Adapter: claude_k8s
Runs Claude Code inside an isolated Kubernetes Job pod instead of the main
Paperclip process. The Job inherits the container image, imagePullSecrets,
DNS config, and PVC from the running Paperclip Deployment automatically.
Core fields:
- model (string, optional): Claude model id
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
- maxTurnsPerRun (number, optional): max turns for one run
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime via --append-system-prompt-file
- extraArgs (string[], optional): additional CLI args appended to the claude command
- env (object, optional): KEY=VALUE environment variables; overrides inherited vars from the Deployment
Kubernetes fields:
- namespace (string, optional): namespace for Jobs; defaults to the Deployment namespace
- image (string, optional): override container image; defaults to the running Deployment image
- imagePullPolicy (string, optional): image pull policy; default "IfNotPresent"
- kubeconfig (string, optional): absolute path to a kubeconfig file on disk; defaults to in-cluster service account auth
- resources (object, optional): { requests: { cpu, memory }, limits: { cpu, memory } }
- nodeSelector (object, optional): node selector for Job pods
- tolerations (array, optional): tolerations for Job pods
- labels (object, optional): extra labels added to Job metadata
- ttlSecondsAfterFinished (number, optional): auto-cleanup delay; default 300
- retainJobs (boolean, optional): skip cleanup on completion for debugging
Operational fields:
- timeoutSec (number, optional): run timeout in seconds; 0 means no timeout
- graceSec (number, optional): additional grace before adapter gives up after Job deadline
Inherited from Deployment (no config needed):
- CLAUDE_CODE_USE_BEDROCK, AWS_REGION, AWS_BEARER_TOKEN_BEDROCK
- ANTHROPIC_API_KEY, OPENAI_API_KEY
- PAPERCLIP_API_URL
- Container image, imagePullSecrets, DNS config, PVC mount, security context
Notes:
- Session resume works via the shared /paperclip PVC (HOME=/paperclip)
- Skills are bundled in the container image
- Prompts are delivered via a busybox init container writing to an emptyDir volume
`;
export { createServerAdapter } from "./server/index.js";
+489
View File
@@ -0,0 +1,489 @@
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { asString, asNumber, asBoolean, parseObject } from "@paperclipai/adapter-utils/server-utils";
import {
parseClaudeStreamJson,
describeClaudeFailure,
isClaudeMaxTurnsResult,
isClaudeUnknownSessionError,
} from "./parse.js";
import { getSelfPodInfo, getBatchApi, getCoreApi, getLogApi } from "./k8s-client.js";
import { buildJobManifest } from "./job-manifest.js";
import type * as k8s from "@kubernetes/client-node";
import { Writable } from "node:stream";
const POLL_INTERVAL_MS = 2000;
/**
* 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.
*/
async function waitForPod(
namespace: string,
jobName: string,
timeoutMs: number,
onLog: AdapterExecutionContext["onLog"],
kubeconfigPath?: string,
): Promise<string> {
const coreApi = getCoreApi(kubeconfigPath);
const deadline = Date.now() + timeoutMs;
const labelSelector = `job-name=${jobName}`;
await onLog("stdout", `[paperclip] Waiting for pod to be scheduled (job: ${jobName})...\n`);
let lastStatus = "";
while (Date.now() < deadline) {
const podList = await coreApi.listNamespacedPod({
namespace,
labelSelector,
});
const pod = podList.items[0];
if (!pod) {
if (lastStatus !== "no-pod") {
await onLog("stdout", `[paperclip] Waiting for Job controller to create pod...\n`);
lastStatus = "no-pod";
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
continue;
}
const podName = pod.metadata?.name ?? "unknown";
const phase = pod.status?.phase ?? "Unknown";
const initStatuses = pod.status?.initContainerStatuses ?? [];
const containerStatuses = pod.status?.containerStatuses ?? [];
// Log phase transitions
const statusKey = `${phase}:${initStatuses.map((s) => s.state?.waiting?.reason ?? s.state?.terminated?.reason ?? "ok").join(",")}:${containerStatuses.map((s) => s.state?.waiting?.reason ?? s.state?.running ? "running" : "waiting").join(",")}`;
if (statusKey !== lastStatus) {
const details: string[] = [`phase=${phase}`];
for (const init of initStatuses) {
if (init.state?.waiting) details.push(`init/${init.name}: waiting (${init.state.waiting.reason ?? "unknown"})`);
else if (init.state?.running) details.push(`init/${init.name}: running`);
else if (init.state?.terminated) details.push(`init/${init.name}: done (exit ${init.state.terminated.exitCode})`);
}
for (const cs of containerStatuses) {
if (cs.state?.waiting) details.push(`${cs.name}: waiting (${cs.state.waiting.reason ?? "unknown"})`);
else if (cs.state?.running) details.push(`${cs.name}: running`);
}
await onLog("stdout", `[paperclip] Pod ${podName}: ${details.join(", ")}\n`);
lastStatus = statusKey;
}
// Ready to stream logs
if (phase === "Running" || phase === "Succeeded" || phase === "Failed") {
return podName;
}
// Init containers done + main running (phase may still say Pending briefly)
const allInitsDone = initStatuses.length > 0 && initStatuses.every(
(s) => s.state?.terminated?.exitCode === 0,
);
const mainRunning = containerStatuses.some((s) => s.state?.running);
if (allInitsDone && mainRunning) {
return podName;
}
// Check for init container failures
for (const init of initStatuses) {
const terminated = init.state?.terminated;
if (terminated && (terminated.exitCode ?? 0) !== 0) {
throw new Error(`Init container "${init.name}" failed with exit code ${terminated.exitCode}: ${terminated.reason ?? terminated.message ?? "unknown"}`);
}
const waiting = init.state?.waiting;
if (waiting?.reason === "ErrImagePull" || waiting?.reason === "ImagePullBackOff") {
throw new Error(`Init container "${init.name}" image pull failed: ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Init container "${init.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
}
// Check for unrecoverable scheduling failures
const conditions = pod.status?.conditions ?? [];
const unschedulable = conditions.find(
(c) => c.type === "PodScheduled" && c.status === "False" && c.reason === "Unschedulable",
);
if (unschedulable) {
throw new Error(`Pod unschedulable: ${unschedulable.message ?? "insufficient resources"}`);
}
// Check for main container image pull errors
for (const cs of containerStatuses) {
const waiting = cs.state?.waiting;
if (waiting?.reason === "ErrImagePull" || waiting?.reason === "ImagePullBackOff") {
throw new Error(`Image pull failed for "${cs.name}": ${waiting.message ?? waiting.reason}`);
}
if (waiting?.reason === "CrashLoopBackOff") {
throw new Error(`Container "${cs.name}" crash loop: ${waiting.message ?? waiting.reason}`);
}
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
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.
*/
async function streamPodLogs(
namespace: string,
podName: string,
onLog: AdapterExecutionContext["onLog"],
kubeconfigPath?: string,
): Promise<string> {
const logApi = getLogApi(kubeconfigPath);
const chunks: string[] = [];
const writable = new Writable({
write(chunk: Buffer, _encoding, callback) {
const text = chunk.toString("utf-8");
chunks.push(text);
void onLog("stdout", text).then(() => callback(), callback);
},
});
try {
await logApi.log(namespace, podName, "claude", writable, {
follow: true,
pretty: false,
});
} catch {
// follow may fail if the container already exited — not fatal,
// we'll try a one-shot read below
}
return chunks.join("");
}
/**
* One-shot read of pod logs (no follow). Used as fallback when the
* follow stream missed output because the container exited quickly.
*/
async function readPodLogs(
namespace: string,
podName: string,
kubeconfigPath?: string,
): Promise<string> {
const coreApi = getCoreApi(kubeconfigPath);
try {
const log = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
container: "claude",
});
return typeof log === "string" ? log : "";
} catch {
return "";
}
}
/**
* Wait for the Job to reach a terminal state (Complete or Failed).
* Returns the Job's final status.
*/
async function waitForJobCompletion(
namespace: string,
jobName: string,
timeoutMs: number,
kubeconfigPath?: string,
): Promise<{ succeeded: boolean; timedOut: boolean }> {
const batchApi = getBatchApi(kubeconfigPath);
const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0;
while (deadline === 0 || Date.now() < deadline) {
const job = await batchApi.readNamespacedJob({ name: jobName, namespace });
const conditions = job.status?.conditions ?? [];
const complete = conditions.find((c) => c.type === "Complete" && c.status === "True");
if (complete) return { succeeded: true, timedOut: false };
const failed = conditions.find((c) => c.type === "Failed" && c.status === "True");
if (failed) {
const isDeadlineExceeded = failed.reason === "DeadlineExceeded";
return { succeeded: false, timedOut: isDeadlineExceeded };
}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
return { succeeded: false, timedOut: true };
}
/**
* Get the exit code from the Job's pod.
*/
async function getPodExitCode(namespace: string, jobName: string, kubeconfigPath?: string): Promise<number | null> {
const coreApi = getCoreApi(kubeconfigPath);
const podList = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
const pod = podList.items[0];
if (!pod) return null;
const containerStatus = pod.status?.containerStatuses?.find((s) => s.name === "claude");
return containerStatus?.state?.terminated?.exitCode ?? null;
}
/**
* Delete Job and its pods. Best-effort — failures are logged but not thrown.
*/
async function cleanupJob(
namespace: string,
jobName: string,
onLog: AdapterExecutionContext["onLog"],
kubeconfigPath?: string,
): Promise<void> {
try {
const batchApi = getBatchApi(kubeconfigPath);
await batchApi.deleteNamespacedJob({
name: jobName,
namespace,
body: { propagationPolicy: "Background" },
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Warning: failed to cleanup job ${jobName}: ${msg}\n`);
}
}
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
const { runId, runtime, config: rawConfig, onLog, onMeta } = ctx;
const config = parseObject(rawConfig);
const timeoutSec = asNumber(config.timeoutSec, 0);
const graceSec = asNumber(config.graceSec, 60);
const retainJobs = asBoolean(config.retainJobs, false);
const kubeconfigPath = asString(config.kubeconfig, "") || undefined;
// Guard: claude_k8s must not run concurrently for the same agent (shared PVC/session)
const agentId = ctx.agent.id;
const selfPod = await getSelfPodInfo(kubeconfigPath);
const guardNamespace = asString(config.namespace, "") || selfPod.namespace;
try {
const batchApi = getBatchApi(kubeconfigPath);
const existing = await batchApi.listNamespacedJob({
namespace: guardNamespace,
labelSelector: `paperclip.io/agent-id=${agentId},paperclip.io/adapter-type=claude_k8s`,
});
const running = existing.items.filter(
(j) => !j.status?.conditions?.some((c) => (c.type === "Complete" || c.type === "Failed") && c.status === "True"),
);
if (running.length > 0) {
const names = running.map((j) => j.metadata?.name).join(", ");
await onLog("stderr", `[paperclip] Concurrent run blocked: existing Job(s) still running for this agent: ${names}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Concurrent run blocked: Job ${names} is still running for this agent`,
errorCode: "k8s_concurrent_run_blocked",
};
}
} catch {
// If we can't check, proceed — the heartbeat service enforces concurrency too
}
// Build Job manifest
const { job, jobName, namespace, prompt, claudeArgs, promptMetrics } = buildJobManifest({
ctx,
selfPod,
});
// Report invocation metadata
if (onMeta) {
await onMeta({
adapterType: "claude_k8s",
command: `kubectl job/${jobName}`,
cwd: namespace,
commandArgs: claudeArgs,
commandNotes: [
`Image: ${job.spec?.template.spec?.containers[0]?.image ?? "unknown"}`,
`Namespace: ${namespace}`,
`Timeout: ${timeoutSec}s`,
],
prompt,
...(promptMetrics ? { promptMetrics } : {}),
context: ctx.context,
} as Parameters<typeof onMeta>[0]);
}
// Create the Job
const batchApi = getBatchApi(kubeconfigPath);
try {
await batchApi.createNamespacedJob({ namespace, body: job });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Failed to create K8s Job: ${msg}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Failed to create Kubernetes Job: ${msg}`,
errorCode: "k8s_job_create_failed",
};
}
await onLog("stdout", `[paperclip] Created K8s Job: ${jobName} in namespace ${namespace} (deadline: ${timeoutSec > 0 ? `${timeoutSec}s` : "none"})\n`);
let stdout = "";
let exitCode: number | null = null;
let jobTimedOut = false;
try {
// Wait for pod to be ready for log streaming
const scheduleTimeoutMs = 120_000; // 2 minutes for scheduling
let podName: string;
try {
podName = await waitForPod(namespace, jobName, scheduleTimeoutMs, onLog, kubeconfigPath);
await onLog("stdout", `[paperclip] Pod running: ${podName}\n`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await onLog("stderr", `[paperclip] Pod scheduling failed: ${msg}\n`);
return {
exitCode: null,
signal: null,
timedOut: false,
errorMessage: `Pod scheduling failed: ${msg}`,
errorCode: "k8s_pod_schedule_failed",
};
}
// Stream logs and wait for completion concurrently.
// The log stream will end when the container exits.
// We also poll the Job status to detect deadline exceeded.
// 0 = no timeout (run indefinitely, matching claude_local behavior)
const completionTimeoutMs = timeoutSec > 0 ? (timeoutSec + graceSec) * 1000 : 0;
const [logResult, completionResult] = await Promise.allSettled([
streamPodLogs(namespace, podName, onLog, kubeconfigPath),
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath),
]);
if (logResult.status === "fulfilled") {
stdout = logResult.value;
}
// If the follow stream missed output (container exited quickly), do a
// one-shot log read as fallback before the pod is cleaned up.
if (!stdout.trim()) {
await onLog("stdout", `[paperclip] Log stream returned empty — reading pod logs directly...\n`);
stdout = await readPodLogs(namespace, podName, kubeconfigPath);
if (stdout.trim()) {
await onLog("stdout", stdout);
}
}
if (completionResult.status === "fulfilled") {
jobTimedOut = completionResult.value.timedOut;
} else {
jobTimedOut = true;
}
exitCode = await getPodExitCode(namespace, jobName, kubeconfigPath);
} finally {
if (!retainJobs) {
await cleanupJob(namespace, jobName, onLog, kubeconfigPath);
} else {
await onLog("stdout", `[paperclip] Retaining job ${jobName} for debugging (retainJobs=true)\n`);
}
}
// Parse Claude output (reuse claude_local parsing)
if (jobTimedOut) {
return {
exitCode,
signal: null,
timedOut: true,
errorMessage: `Timed out after ${timeoutSec}s`,
errorCode: "timeout",
};
}
const parsedStream = parseClaudeStreamJson(stdout);
const parsed = parsedStream.resultJson;
// If the session was stale, clear it so the next heartbeat starts fresh
if (parsed && (exitCode ?? 0) !== 0 && isClaudeUnknownSessionError(parsed)) {
await onLog("stdout", `[paperclip] Claude session is unavailable; clearing for next run.\n`);
return {
exitCode,
signal: null,
timedOut: false,
errorMessage: describeClaudeFailure(parsed) ?? "Session unavailable",
errorCode: "session_unavailable",
clearSession: true,
resultJson: parsed,
};
}
if (!parsed) {
const stderrLine = stdout.split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? "";
return {
exitCode,
signal: null,
timedOut: false,
errorMessage: exitCode === 0
? "Failed to parse Claude JSON output"
: stderrLine
? `Claude exited with code ${exitCode ?? -1}: ${stderrLine}`
: `Claude exited with code ${exitCode ?? -1}`,
resultJson: { stdout },
};
}
const usage = parsedStream.usage ?? (() => {
const usageObj = parseObject(parsed.usage as Record<string, unknown>);
return {
inputTokens: asNumber(usageObj.input_tokens, 0),
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
outputTokens: asNumber(usageObj.output_tokens, 0),
};
})();
const runtimeSessionParams = parseObject(runtime.sessionParams);
const fallbackSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const resolvedSessionId = parsedStream.sessionId
?? (asString(parsed.session_id as string, fallbackSessionId) || fallbackSessionId);
const model = asString(config.model, "");
const workspaceContext = parseObject(ctx.context.paperclipWorkspace);
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
const cwd = asString(workspaceContext.cwd, "");
const resolvedSessionParams = resolvedSessionId
? {
sessionId: resolvedSessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
} as Record<string, unknown>
: null;
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
return {
exitCode,
signal: null,
timedOut: false,
errorMessage:
(exitCode ?? 0) === 0
? null
: describeClaudeFailure(parsed) ?? `Claude exited with code ${exitCode ?? -1}`,
usage,
sessionId: resolvedSessionId || null,
sessionParams: resolvedSessionParams,
sessionDisplayId: resolvedSessionId || null,
provider: "anthropic",
model: parsedStream.model || asString(parsed.model as string, model),
billingType: "api",
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
resultJson: parsed,
summary: parsedStream.summary || asString(parsed.result as string, ""),
clearSession: clearSessionForMaxTurns,
} as AdapterExecutionResult;
}
+19
View File
@@ -0,0 +1,19 @@
import type { ServerAdapterModule } from "@paperclipai/adapter-utils";
import { type, models, agentConfigurationDoc } from "../index.js";
import { execute } from "./execute.js";
import { testEnvironment } from "./test.js";
import { sessionCodec } from "./session.js";
export function createServerAdapter(): ServerAdapterModule {
return {
type,
execute,
testEnvironment,
sessionCodec,
models,
supportsLocalAgentJwt: true,
agentConfigurationDoc,
};
}
export { execute, testEnvironment, sessionCodec };
+391
View File
@@ -0,0 +1,391 @@
import type * as k8s from "@kubernetes/client-node";
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
import {
asString,
asNumber,
asBoolean,
asStringArray,
parseObject,
buildPaperclipEnv,
renderTemplate,
} from "@paperclipai/adapter-utils/server-utils";
// Inline prompt assembly — these functions are not yet in the published adapter-utils
function joinPromptSections(sections: string[], separator = "\n\n"): string {
return sections.filter((s) => s.trim().length > 0).join(separator);
}
function stringifyPaperclipWakePayload(wake: unknown): string | null {
if (!wake || typeof wake !== "object") return null;
try {
const json = JSON.stringify(wake);
return json === "{}" ? null : json;
} catch {
return null;
}
}
function renderPaperclipWakePrompt(wake: unknown, _opts?: { resumedSession?: boolean }): string {
if (!wake || typeof wake !== "object") return "";
const w = wake as Record<string, unknown>;
const reason = typeof w.reason === "string" ? w.reason.trim() : "";
const comments = Array.isArray(w.comments) ? w.comments : [];
if (!reason && comments.length === 0) return "";
const parts: string[] = [];
if (reason) parts.push(`Wake reason: ${reason}`);
for (const c of comments) {
if (typeof c === "object" && c !== null) {
const comment = c as Record<string, unknown>;
const body = typeof comment.body === "string" ? comment.body.trim() : "";
if (body) parts.push(`Comment: ${body}`);
}
}
return parts.join("\n\n");
}
import type { SelfPodInfo } from "./k8s-client.js";
export interface JobBuildInput {
ctx: AdapterExecutionContext;
selfPod: SelfPodInfo;
}
export interface JobBuildResult {
job: k8s.V1Job;
jobName: string;
namespace: string;
prompt: string;
claudeArgs: string[];
promptMetrics: Record<string, number>;
}
function sanitizeForK8sName(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9-]/g, "").slice(0, 8);
}
function buildEnvVars(
ctx: AdapterExecutionContext,
selfPod: SelfPodInfo,
config: Record<string, unknown>,
): k8s.V1EnvVar[] {
const { runId, agent, context } = ctx;
const envConfig = parseObject(config.env);
// Layer 1: PAPERCLIP_* base vars
const paperclipEnv = buildPaperclipEnv(agent);
// Layer 2: Context vars (run, wake, workspace — same as claude_local)
paperclipEnv.PAPERCLIP_RUN_ID = runId;
const setIfPresent = (envKey: string, value: unknown) => {
if (typeof value === "string" && value.trim().length > 0) {
paperclipEnv[envKey] = value.trim();
}
};
setIfPresent("PAPERCLIP_TASK_ID", context.taskId ?? context.issueId);
setIfPresent("PAPERCLIP_WAKE_REASON", context.wakeReason);
setIfPresent("PAPERCLIP_WAKE_COMMENT_ID", context.wakeCommentId ?? context.commentId);
setIfPresent("PAPERCLIP_APPROVAL_ID", context.approvalId);
setIfPresent("PAPERCLIP_APPROVAL_STATUS", context.approvalStatus);
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
if (wakePayloadJson) {
paperclipEnv.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
}
const workspaceContext = parseObject(context.paperclipWorkspace);
setIfPresent("PAPERCLIP_WORKSPACE_CWD", workspaceContext.cwd);
setIfPresent("PAPERCLIP_WORKSPACE_SOURCE", workspaceContext.source);
setIfPresent("PAPERCLIP_WORKSPACE_STRATEGY", workspaceContext.strategy);
setIfPresent("PAPERCLIP_WORKSPACE_ID", workspaceContext.workspaceId);
setIfPresent("PAPERCLIP_WORKSPACE_REPO_URL", workspaceContext.repoUrl);
setIfPresent("PAPERCLIP_WORKSPACE_REPO_REF", workspaceContext.repoRef);
setIfPresent("PAPERCLIP_WORKSPACE_BRANCH", workspaceContext.branchName);
setIfPresent("PAPERCLIP_WORKSPACE_WORKTREE_PATH", workspaceContext.worktreePath);
setIfPresent("AGENT_HOME", workspaceContext.agentHome);
const linkedIssueIds = Array.isArray(context.issueIds)
? context.issueIds.filter((v): v is string => typeof v === "string" && v.trim().length > 0)
: [];
if (linkedIssueIds.length > 0) {
paperclipEnv.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
}
if (Array.isArray(context.paperclipWorkspaces) && context.paperclipWorkspaces.length > 0) {
paperclipEnv.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(context.paperclipWorkspaces);
}
if (Array.isArray(context.paperclipRuntimeServiceIntents) && context.paperclipRuntimeServiceIntents.length > 0) {
paperclipEnv.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(context.paperclipRuntimeServiceIntents);
}
if (Array.isArray(context.paperclipRuntimeServices) && context.paperclipRuntimeServices.length > 0) {
paperclipEnv.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(context.paperclipRuntimeServices);
}
setIfPresent("PAPERCLIP_RUNTIME_PRIMARY_URL", context.paperclipRuntimePrimaryUrl);
// Auth token for agent callback to Paperclip API
if (ctx.authToken) {
paperclipEnv.PAPERCLIP_API_KEY = ctx.authToken;
}
// PAPERCLIP_API_URL is inherited from the Deployment env via selfPod.inheritedEnv.
// buildPaperclipEnv() sets a localhost value which is wrong for Job pods —
// the inherited value (set in the infra repo) points to the in-cluster service.
if (selfPod.inheritedEnv.PAPERCLIP_API_URL) {
paperclipEnv.PAPERCLIP_API_URL = selfPod.inheritedEnv.PAPERCLIP_API_URL;
}
// Layer 3: Inherited from Deployment (Bedrock, API keys, etc.)
const merged: Record<string, string> = {
...selfPod.inheritedEnv,
...paperclipEnv,
};
// Layer 4: User-defined overrides from adapterConfig.env (wins over everything)
for (const [key, value] of Object.entries(envConfig)) {
if (typeof value === "string") merged[key] = value;
}
// HOME must be /paperclip to match PVC mount and enable session resume
merged.HOME = "/paperclip";
// Convert to V1EnvVar array
const envVars: k8s.V1EnvVar[] = Object.entries(merged).map(([name, value]) => ({
name,
value,
}));
return envVars;
}
export function buildJobManifest(input: JobBuildInput): JobBuildResult {
const { ctx, selfPod } = input;
const { runId, agent, runtime, config: rawConfig, context } = ctx;
const config = parseObject(rawConfig);
// Resolve config values
const namespace = asString(config.namespace, "") || selfPod.namespace;
const image = asString(config.image, "") || selfPod.image;
const model = asString(config.model, "");
const effort = asString(config.effort, "");
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
// K8s Job pods are always unattended — no one to approve permission prompts
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, true);
const extraArgs = asStringArray(config.extraArgs);
const timeoutSec = asNumber(config.timeoutSec, 0);
const ttlSeconds = asNumber(config.ttlSecondsAfterFinished, 300);
const resources = parseObject(config.resources);
const nodeSelector = parseObject(config.nodeSelector);
const tolerations = Array.isArray(config.tolerations) ? config.tolerations : [];
const extraLabels = parseObject(config.labels);
// Resolve working directory — use workspace cwd, fall back to /paperclip
const workspaceContext = parseObject(context.paperclipWorkspace);
const workspaceCwd = asString(workspaceContext.cwd, "");
const configuredCwd = asString(config.cwd, "");
const workingDir = workspaceCwd || configuredCwd || "/paperclip";
const agentSlug = sanitizeForK8sName(agent.id);
const runSlug = sanitizeForK8sName(runId);
const jobName = `agent-${agentSlug}-${runSlug}`;
// Build prompt (same logic as claude_local)
const promptTemplate = asString(
config.promptTemplate,
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.",
);
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
const runtimeSessionParams = parseObject(runtime.sessionParams);
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
const templateData = {
agentId: agent.id,
companyId: agent.companyId,
runId,
company: { id: agent.companyId },
agent,
run: { id: runId, source: "on_demand" },
context,
};
const renderedBootstrapPrompt =
!runtimeSessionId && bootstrapPromptTemplate.trim().length > 0
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
: "";
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(runtimeSessionId) });
const shouldUseResumeDeltaPrompt = Boolean(runtimeSessionId) && wakePrompt.length > 0;
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
const prompt = joinPromptSections([
renderedBootstrapPrompt,
wakePrompt,
sessionHandoffNote,
renderedPrompt,
]);
const promptMetrics = {
promptChars: prompt.length,
bootstrapPromptChars: renderedBootstrapPrompt.length,
wakePromptChars: wakePrompt.length,
sessionHandoffChars: sessionHandoffNote.length,
heartbeatPromptChars: renderedPrompt.length,
};
// Build Claude CLI args
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
const claudeArgs = ["--print", "-", "--output-format", "stream-json", "--verbose"];
if (runtimeSessionId) claudeArgs.push("--resume", runtimeSessionId);
if (dangerouslySkipPermissions) claudeArgs.push("--dangerously-skip-permissions");
if (model) claudeArgs.push("--model", model);
if (effort) claudeArgs.push("--effort", effort);
if (maxTurns > 0) claudeArgs.push("--max-turns", String(maxTurns));
if (instructionsFilePath) claudeArgs.push("--append-system-prompt-file", instructionsFilePath);
if (extraArgs.length > 0) claudeArgs.push(...extraArgs);
// Build env vars
const envVars = buildEnvVars(ctx, selfPod, config);
// Resource defaults
const resourceRequests = parseObject(resources.requests);
const resourceLimits = parseObject(resources.limits);
const containerResources: k8s.V1ResourceRequirements = {
requests: {
cpu: asString(resourceRequests.cpu, "1000m"),
memory: asString(resourceRequests.memory, "2Gi"),
},
limits: {
cpu: asString(resourceLimits.cpu, "4000m"),
memory: asString(resourceLimits.memory, "8Gi"),
},
};
// Labels
const labels: Record<string, string> = {
"app.kubernetes.io/managed-by": "paperclip",
"app.kubernetes.io/component": "agent-job",
"paperclip.io/agent-id": agent.id,
"paperclip.io/run-id": runId,
"paperclip.io/company-id": agent.companyId,
"paperclip.io/adapter-type": "claude_k8s",
};
for (const [key, value] of Object.entries(extraLabels)) {
if (typeof value === "string") labels[key] = value;
}
// Volumes
const volumes: k8s.V1Volume[] = [
{
name: "prompt",
emptyDir: {},
},
];
const volumeMounts: k8s.V1VolumeMount[] = [
{
name: "prompt",
mountPath: "/tmp/prompt",
},
];
// Mount shared PVC for /paperclip (session state, workspaces, data)
if (selfPod.pvcClaimName) {
volumes.push({
name: "data",
persistentVolumeClaim: { claimName: selfPod.pvcClaimName },
});
volumeMounts.push({
name: "data",
mountPath: "/paperclip",
});
}
// Mount secret volumes inherited from the Deployment pod
for (const sv of selfPod.secretVolumes) {
volumes.push({
name: sv.volumeName,
secret: { secretName: sv.secretName, defaultMode: sv.defaultMode, optional: true },
});
volumeMounts.push({
name: sv.volumeName,
mountPath: sv.mountPath,
readOnly: true,
});
}
// Security context matching the main Deployment
const securityContext: k8s.V1SecurityContext = {
capabilities: { drop: ["ALL"] },
readOnlyRootFilesystem: false,
runAsNonRoot: true,
runAsUser: 1000,
allowPrivilegeEscalation: false,
};
const podSecurityContext: k8s.V1PodSecurityContext = {
runAsNonRoot: true,
runAsUser: 1000,
runAsGroup: 1000,
fsGroup: 1000,
};
// 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 job: k8s.V1Job = {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: jobName,
namespace,
labels,
annotations: {
"paperclip.io/adapter-type": "claude_k8s",
"paperclip.io/agent-name": agent.name,
},
},
spec: {
backoffLimit: 0,
...(timeoutSec > 0 ? { activeDeadlineSeconds: timeoutSec } : {}),
ttlSecondsAfterFinished: ttlSeconds,
template: {
metadata: { labels },
spec: {
restartPolicy: "Never",
serviceAccountName: asString(config.serviceAccountName, "") || undefined,
securityContext: podSecurityContext,
...(selfPod.imagePullSecrets.length > 0 ? { imagePullSecrets: selfPod.imagePullSecrets } : {}),
...(selfPod.dnsConfig ? { dnsConfig: selfPod.dnsConfig } : {}),
...(Object.keys(nodeSelector).length > 0 ? { nodeSelector: nodeSelector as Record<string, string> } : {}),
...(tolerations.length > 0 ? { tolerations: tolerations as k8s.V1Toleration[] } : {}),
initContainers: [
{
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", "echo \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt"],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
securityContext,
resources: {
requests: { cpu: "10m", memory: "16Mi" },
limits: { cpu: "100m", memory: "64Mi" },
},
},
],
containers: [
{
name: "claude",
image,
imagePullPolicy: asString(config.imagePullPolicy, "IfNotPresent"),
workingDir,
command: ["sh", "-c", mainCommand],
env: envVars,
volumeMounts,
securityContext,
resources: containerResources,
},
],
volumes,
},
},
},
};
return { job, jobName, namespace, prompt, claudeArgs, promptMetrics };
}
+172
View File
@@ -0,0 +1,172 @@
import * as k8s from "@kubernetes/client-node";
import { readFileSync } from "node:fs";
/**
* Cached self-pod introspection result. Queried once on first execute(),
* then reused for all subsequent Job builds so every Job inherits the
* Deployment's image, imagePullSecrets, DNS config, and PVC claim.
*/
export interface SelfPodSecretVolume {
volumeName: string;
secretName: string;
mountPath: string;
defaultMode: number | undefined;
}
export interface SelfPodInfo {
namespace: string;
image: string;
imagePullSecrets: Array<{ name: string }>;
dnsConfig: k8s.V1PodDNSConfig | undefined;
pvcClaimName: string | null;
secretVolumes: SelfPodSecretVolume[];
/** Env vars inherited from the Deployment container. */
inheritedEnv: Record<string, string>;
}
/** Keys forwarded from the Deployment container env into Job pods. */
const INHERITED_ENV_KEYS = [
"CLAUDE_CODE_USE_BEDROCK",
"AWS_REGION",
"AWS_BEARER_TOKEN_BEDROCK",
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"PAPERCLIP_API_URL",
];
let cachedSelfPod: SelfPodInfo | null = null;
/**
* Cache keyed by kubeconfig path (empty string = in-cluster).
* Supports multiple agents with different kubeconfigs.
*/
const kcCache = new Map<string, k8s.KubeConfig>();
function getKubeConfig(kubeconfigPath?: string): k8s.KubeConfig {
const key = kubeconfigPath ?? "";
let kc = kcCache.get(key);
if (!kc) {
kc = new k8s.KubeConfig();
if (kubeconfigPath) {
kc.loadFromFile(kubeconfigPath);
} else {
kc.loadFromCluster();
}
kcCache.set(key, kc);
}
return kc;
}
export function getBatchApi(kubeconfigPath?: string): k8s.BatchV1Api {
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.BatchV1Api);
}
export function getCoreApi(kubeconfigPath?: string): k8s.CoreV1Api {
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.CoreV1Api);
}
export function getAuthzApi(kubeconfigPath?: string): k8s.AuthorizationV1Api {
return getKubeConfig(kubeconfigPath).makeApiClient(k8s.AuthorizationV1Api);
}
export function getLogApi(kubeconfigPath?: string): k8s.Log {
return new k8s.Log(getKubeConfig(kubeconfigPath));
}
/**
* Read the current pod's namespace. Checks (in order):
* 1. PAPERCLIP_NAMESPACE env var (set explicitly in Deployment)
* 2. Service account namespace file (standard in-cluster path)
* 3. POD_NAMESPACE env var (Downward API convention)
* Falls back to "default" only if none of the above are available.
*/
function readInClusterNamespace(): string {
const fromEnv = process.env.PAPERCLIP_NAMESPACE ?? process.env.POD_NAMESPACE;
if (fromEnv?.trim()) return fromEnv.trim();
try {
return readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf-8").trim();
} catch {
return "default";
}
}
/**
* Query the K8s API for our own pod spec and cache the result.
* Extracts image, imagePullSecrets, dnsConfig, PVC claim name,
* and environment variables to forward to Job pods.
*/
export async function getSelfPodInfo(kubeconfigPath?: string): Promise<SelfPodInfo> {
if (cachedSelfPod) return cachedSelfPod;
const hostname = process.env.HOSTNAME;
if (!hostname) {
throw new Error("claude_k8s: HOSTNAME env var not set — cannot introspect running pod");
}
const namespace = readInClusterNamespace();
const coreApi = getCoreApi(kubeconfigPath);
const pod = await coreApi.readNamespacedPod({ name: hostname, namespace });
const spec = pod.spec;
if (!spec) {
throw new Error(`claude_k8s: pod ${hostname} has no spec`);
}
const mainContainer = spec.containers[0];
if (!mainContainer?.image) {
throw new Error(`claude_k8s: pod ${hostname} has no container image`);
}
// Find PVC claim name from volumes mounted at /paperclip
let pvcClaimName: string | null = null;
const dataMount = mainContainer.volumeMounts?.find(
(vm) => vm.mountPath === "/paperclip",
);
if (dataMount) {
const volume = spec.volumes?.find((v) => v.name === dataMount.name);
pvcClaimName = volume?.persistentVolumeClaim?.claimName ?? null;
}
// Discover secret volumes mounted on the main container
const secretVolumes: SelfPodSecretVolume[] = [];
for (const vm of mainContainer.volumeMounts ?? []) {
const vol = spec.volumes?.find((v) => v.name === vm.name);
if (vol?.secret?.secretName) {
secretVolumes.push({
volumeName: vm.name,
secretName: vol.secret.secretName,
mountPath: vm.mountPath,
defaultMode: vol.secret.defaultMode,
});
}
}
// Collect inherited env vars from process.env (these came from the Deployment spec)
const inheritedEnv: Record<string, string> = {};
for (const key of INHERITED_ENV_KEYS) {
const value = process.env[key];
if (value !== undefined) {
inheritedEnv[key] = value;
}
}
cachedSelfPod = {
namespace,
image: mainContainer.image,
imagePullSecrets: (spec.imagePullSecrets ?? []).map((s) => ({
name: s.name ?? "",
})).filter((s) => s.name.length > 0),
dnsConfig: spec.dnsConfig,
pvcClaimName,
secretVolumes,
inheritedEnv,
};
return cachedSelfPod;
}
/** Reset cached state — useful for tests. */
export function resetCache(): void {
kcCache.clear();
cachedSelfPod = null;
}
+179
View File
@@ -0,0 +1,179 @@
import type { UsageSummary } from "@paperclipai/adapter-utils";
import { asString, asNumber, parseObject, parseJson } from "@paperclipai/adapter-utils/server-utils";
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
export function parseClaudeStreamJson(stdout: string) {
let sessionId: string | null = null;
let model = "";
let finalResult: Record<string, unknown> | null = null;
const assistantTexts: string[] = [];
for (const rawLine of stdout.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const event = parseJson(line);
if (!event) continue;
const type = asString(event.type, "");
if (type === "system" && asString(event.subtype, "") === "init") {
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
model = asString(event.model, model);
continue;
}
if (type === "assistant") {
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
const message = parseObject(event.message);
const content = Array.isArray(message.content) ? message.content : [];
for (const entry of content) {
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
const block = entry as Record<string, unknown>;
if (asString(block.type, "") === "text") {
const text = asString(block.text, "");
if (text) assistantTexts.push(text);
}
}
continue;
}
if (type === "result") {
finalResult = event;
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
}
}
if (!finalResult) {
return {
sessionId,
model,
costUsd: null as number | null,
usage: null as UsageSummary | null,
summary: assistantTexts.join("\n\n").trim(),
resultJson: null as Record<string, unknown> | null,
};
}
const usageObj = parseObject(finalResult.usage);
const usage: UsageSummary = {
inputTokens: asNumber(usageObj.input_tokens, 0),
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
outputTokens: asNumber(usageObj.output_tokens, 0),
};
const costRaw = finalResult.total_cost_usd;
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
return {
sessionId,
model,
costUsd,
usage,
summary,
resultJson: finalResult,
};
}
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
const messages: string[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
const msg = entry.trim();
if (msg) messages.push(msg);
continue;
}
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
continue;
}
const obj = entry as Record<string, unknown>;
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
if (msg) {
messages.push(msg);
continue;
}
try {
messages.push(JSON.stringify(obj));
} catch {
// skip non-serializable entry
}
}
return messages;
}
export function extractClaudeLoginUrl(text: string): string | null {
const match = text.match(URL_RE);
if (!match || match.length === 0) return null;
for (const rawUrl of match) {
const cleaned = rawUrl.replace(/[\])}.!,?;:'\"]+$/g, "");
if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) {
return cleaned;
}
}
return match[0]?.replace(/[\])}.!,?;:'\"]+$/g, "") ?? null;
}
export function detectClaudeLoginRequired(input: {
parsed: Record<string, unknown> | null;
stdout: string;
stderr: string;
}): { requiresLogin: boolean; loginUrl: string | null } {
const resultText = asString(input.parsed?.result, "").trim();
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
.join("\n")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
return {
requiresLogin,
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
};
}
export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
const subtype = asString(parsed.subtype, "");
const resultText = asString(parsed.result, "").trim();
const errors = extractClaudeErrorMessages(parsed);
let detail = resultText;
if (!detail && errors.length > 0) {
detail = errors[0] ?? "";
}
const parts = ["Claude run failed"];
if (subtype) parts.push(`subtype=${subtype}`);
if (detail) parts.push(detail);
return parts.length > 1 ? parts.join(": ") : null;
}
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
if (!parsed) return false;
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
if (subtype === "error_max_turns") return true;
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
if (stopReason === "max_turns") return true;
const resultText = asString(parsed.result, "").trim();
return /max(?:imum)?\s+turns?/i.test(resultText);
}
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
const resultText = asString(parsed.result, "").trim();
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
.map((msg) => msg.trim())
.filter(Boolean);
return allMessages.some((msg) =>
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { AdapterSessionCodec } from "@paperclipai/adapter-utils";
function readNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export const sessionCodec: AdapterSessionCodec = {
deserialize(raw: unknown) {
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
const record = raw as Record<string, unknown>;
const sessionId = readNonEmptyString(record.sessionId) ?? readNonEmptyString(record.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(record.cwd) ??
readNonEmptyString(record.workdir) ??
readNonEmptyString(record.folder);
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
serialize(params: Record<string, unknown> | null) {
if (!params) return null;
const sessionId = readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
if (!sessionId) return null;
const cwd =
readNonEmptyString(params.cwd) ??
readNonEmptyString(params.workdir) ??
readNonEmptyString(params.folder);
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
return {
sessionId,
...(cwd ? { cwd } : {}),
...(workspaceId ? { workspaceId } : {}),
...(repoUrl ? { repoUrl } : {}),
...(repoRef ? { repoRef } : {}),
};
},
getDisplayId(params: Record<string, unknown> | null) {
if (!params) return null;
return readNonEmptyString(params.sessionId) ?? readNonEmptyString(params.session_id);
},
};
+241
View File
@@ -0,0 +1,241 @@
import type {
AdapterEnvironmentCheck,
AdapterEnvironmentTestContext,
AdapterEnvironmentTestResult,
} from "@paperclipai/adapter-utils";
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
import { getSelfPodInfo, getCoreApi, getAuthzApi } from "./k8s-client.js";
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
if (checks.some((c) => c.level === "error")) return "fail";
if (checks.some((c) => c.level === "warn")) return "warn";
return "pass";
}
async function checkApiReachable(checks: AdapterEnvironmentCheck[], kubeconfigPath?: string): Promise<boolean> {
try {
const selfPod = await getSelfPodInfo(kubeconfigPath);
checks.push({
code: "k8s_api_reachable",
level: "info",
message: `Kubernetes API reachable; running in namespace ${selfPod.namespace}`,
detail: `Image: ${selfPod.image}`,
});
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_api_unreachable",
level: "error",
message: `Cannot reach Kubernetes API: ${msg}`,
hint: "Ensure the pod has a valid service account token mounted and the API server is accessible.",
});
return false;
}
}
async function checkNamespace(
namespace: string,
selfPodNamespace: string,
checks: AdapterEnvironmentCheck[],
kubeconfigPath?: string,
): Promise<boolean> {
// If targeting the same namespace we're running in, skip the cluster-scoped
// readNamespace call — we know it exists, and the SA may lack cluster-level
// namespace get permissions.
if (namespace === selfPodNamespace) {
checks.push({
code: "k8s_namespace_exists",
level: "info",
message: `Target namespace is the pod namespace: ${namespace}`,
});
return true;
}
try {
const coreApi = getCoreApi(kubeconfigPath);
await coreApi.readNamespace({ name: namespace });
checks.push({
code: "k8s_namespace_exists",
level: "info",
message: `Target namespace exists: ${namespace}`,
});
return true;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_namespace_check_failed",
level: "warn",
message: `Cannot verify namespace "${namespace}": ${msg}`,
hint: "The service account may lack cluster-level namespace read permissions. The namespace may still be usable — verify RBAC checks below.",
});
// Don't block on this — RBAC checks below will catch actual permission issues
return true;
}
}
async function checkRbac(
namespace: string,
checks: AdapterEnvironmentCheck[],
kubeconfigPath?: string,
): Promise<void> {
const authzApi = getAuthzApi(kubeconfigPath);
const rbacChecks = [
{ resource: "jobs", group: "batch", verb: "create", code: "k8s_rbac_job_create", label: "create Jobs" },
{ resource: "jobs", group: "batch", verb: "delete", code: "k8s_rbac_job_delete", label: "delete Jobs" },
{ resource: "jobs", group: "batch", verb: "get", code: "k8s_rbac_job_get", label: "get Jobs" },
{ resource: "pods", group: "", verb: "list", code: "k8s_rbac_pod_list", label: "list Pods" },
{ resource: "pods/log", group: "", verb: "get", code: "k8s_rbac_pod_log", label: "get Pod logs" },
];
for (const check of rbacChecks) {
try {
const review = await authzApi.createSelfSubjectAccessReview({
body: {
apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview",
spec: {
resourceAttributes: {
namespace,
verb: check.verb,
resource: check.resource,
group: check.group,
},
},
},
});
if (review.status?.allowed) {
checks.push({
code: check.code,
level: "info",
message: `RBAC: allowed to ${check.label} in ${namespace}`,
});
} else {
checks.push({
code: check.code,
level: "error",
message: `RBAC: not allowed to ${check.label} in ${namespace}`,
hint: `Grant the service account permission to ${check.verb} ${check.resource} in namespace ${namespace}.`,
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: check.code,
level: "warn",
message: `RBAC check failed for ${check.label}: ${msg}`,
hint: "SelfSubjectAccessReview may not be available; verify permissions manually.",
});
}
}
}
async function checkSecret(
namespace: string,
secretName: string,
checks: AdapterEnvironmentCheck[],
kubeconfigPath?: string,
): Promise<void> {
try {
const coreApi = getCoreApi(kubeconfigPath);
await coreApi.readNamespacedSecret({ name: secretName, namespace });
checks.push({
code: "k8s_secret_exists",
level: "info",
message: `Secret "${secretName}" exists in namespace ${namespace}`,
});
} catch {
checks.push({
code: "k8s_secret_missing",
level: "warn",
message: `Secret "${secretName}" not found in namespace ${namespace}`,
hint: `Ensure the paperclip-secrets Secret exists with keys for ANTHROPIC_API_KEY and/or AWS_BEARER_TOKEN_BEDROCK.`,
});
}
}
async function checkPvc(
selfPod: { pvcClaimName: string | null; namespace: string },
checks: AdapterEnvironmentCheck[],
kubeconfigPath?: string,
): Promise<void> {
if (!selfPod.pvcClaimName) {
checks.push({
code: "k8s_pvc_not_detected",
level: "warn",
message: "No PVC detected on /paperclip mount — session resume and workspace sharing will not work.",
hint: "Ensure the Paperclip Deployment has a PVC mounted at /paperclip with ReadWriteMany access mode.",
});
return;
}
try {
const coreApi = getCoreApi(kubeconfigPath);
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: selfPod.pvcClaimName,
namespace: selfPod.namespace,
});
const accessModes = pvc.spec?.accessModes ?? [];
const isRwx = accessModes.includes("ReadWriteMany");
if (isRwx) {
checks.push({
code: "k8s_pvc_rwx",
level: "info",
message: `PVC "${selfPod.pvcClaimName}" has ReadWriteMany access — Job pods can mount it.`,
});
} else {
checks.push({
code: "k8s_pvc_not_rwx",
level: "warn",
message: `PVC "${selfPod.pvcClaimName}" access modes: ${accessModes.join(", ")}. ReadWriteMany is required for Job pods to share the volume.`,
hint: "Change the PVC accessMode to ReadWriteMany in Helm values.",
});
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
checks.push({
code: "k8s_pvc_check_failed",
level: "warn",
message: `Could not read PVC "${selfPod.pvcClaimName}": ${msg}`,
});
}
}
export async function testEnvironment(
ctx: AdapterEnvironmentTestContext,
): Promise<AdapterEnvironmentTestResult> {
const checks: AdapterEnvironmentCheck[] = [];
const config = parseObject(ctx.config);
const secretRef = asString(config.secretRef, "paperclip-secrets");
const kubeconfigPath = asString(config.kubeconfig, "") || undefined;
// 1. K8s API reachable + self-pod introspection
const apiOk = await checkApiReachable(checks, kubeconfigPath);
if (!apiOk) {
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
}
const selfPod = await getSelfPodInfo(kubeconfigPath);
const namespace = asString(config.namespace, "") || selfPod.namespace;
// 2. Target namespace exists
const nsOk = await checkNamespace(namespace, selfPod.namespace, checks, kubeconfigPath);
if (!nsOk) {
return { adapterType: ctx.adapterType, status: summarizeStatus(checks), checks, testedAt: new Date().toISOString() };
}
// 3-5. Run remaining checks in parallel
await Promise.all([
checkRbac(namespace, checks, kubeconfigPath),
checkSecret(namespace, secretRef, checks, kubeconfigPath),
checkPvc(selfPod, checks, kubeconfigPath),
]);
return {
adapterType: ctx.adapterType,
status: summarizeStatus(checks),
checks,
testedAt: new Date().toISOString(),
};
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Self-contained stdout parser for Claude stream-json output.
* Zero external imports — required by the Paperclip adapter plugin UI parser contract.
*/
type TranscriptEntry =
| { kind: "assistant"; ts: string; text: string }
| { kind: "thinking"; ts: string; text: string }
| { kind: "user"; ts: string; text: string }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
| { kind: "init"; ts: string; model: string; sessionId: string }
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
| { kind: "stderr"; ts: string; text: string }
| { kind: "system"; ts: string; text: string }
| { kind: "stdout"; ts: string; text: string };
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function asNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function errorText(value: unknown): string {
if (typeof value === "string") return value;
const rec = asRecord(value);
if (!rec) return "";
const msg =
(typeof rec.message === "string" && rec.message) ||
(typeof rec.error === "string" && rec.error) ||
(typeof rec.code === "string" && rec.code) ||
"";
if (msg) return msg;
try {
return JSON.stringify(rec);
} catch {
return "";
}
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return null;
}
}
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
const parsed = asRecord(safeJsonParse(line));
if (!parsed) {
return [{ kind: "stdout", ts, text: line }];
}
const type = typeof parsed.type === "string" ? parsed.type : "";
if (type === "system" && parsed.subtype === "init") {
return [
{
kind: "init",
ts,
model: typeof parsed.model === "string" ? parsed.model : "unknown",
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
},
];
}
if (type === "assistant") {
const message = asRecord(parsed.message) ?? {};
const content = Array.isArray(message.content) ? message.content : [];
const entries: TranscriptEntry[] = [];
for (const blockRaw of content) {
const block = asRecord(blockRaw);
if (!block) continue;
const blockType = typeof block.type === "string" ? block.type : "";
if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : "";
if (text) entries.push({ kind: "assistant", ts, text });
} else if (blockType === "thinking") {
const text = typeof block.thinking === "string" ? block.thinking : "";
if (text) entries.push({ kind: "thinking", ts, text });
} else if (blockType === "tool_use") {
entries.push({
kind: "tool_call",
ts,
name: typeof block.name === "string" ? block.name : "unknown",
toolUseId:
typeof block.id === "string"
? block.id
: typeof block.tool_use_id === "string"
? block.tool_use_id
: undefined,
input: block.input ?? {},
});
}
}
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
}
if (type === "user") {
const message = asRecord(parsed.message) ?? {};
const content = Array.isArray(message.content) ? message.content : [];
const entries: TranscriptEntry[] = [];
for (const blockRaw of content) {
const block = asRecord(blockRaw);
if (!block) continue;
const blockType = typeof block.type === "string" ? block.type : "";
if (blockType === "text") {
const text = typeof block.text === "string" ? block.text : "";
if (text) entries.push({ kind: "user", ts, text });
} else if (blockType === "tool_result") {
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
const isError = block.is_error === true;
let text = "";
if (typeof block.content === "string") {
text = block.content;
} else if (Array.isArray(block.content)) {
const parts: string[] = [];
for (const part of block.content) {
const p = asRecord(part);
if (p && typeof p.text === "string") parts.push(p.text);
}
text = parts.join("\n");
}
entries.push({ kind: "tool_result", ts, toolUseId, content: text, isError });
}
}
if (entries.length > 0) return entries;
}
if (type === "result") {
const usage = asRecord(parsed.usage) ?? {};
const inputTokens = asNumber(usage.input_tokens);
const outputTokens = asNumber(usage.output_tokens);
const cachedTokens = asNumber(usage.cache_read_input_tokens);
const costUsd = asNumber(parsed.total_cost_usd);
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
const isError = parsed.is_error === true;
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
const text = typeof parsed.result === "string" ? parsed.result : "";
return [{
kind: "result",
ts,
text,
inputTokens,
outputTokens,
cachedTokens,
costUsd,
subtype,
isError,
errors,
}];
}
return [{ kind: "stdout", ts, text: line }];
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src",
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src"]
}