Compare commits

..

10 Commits

Author SHA1 Message Date
Chris Farhood 5179544fd6 docs: mark repo as abandoned in favor of paperclip-plugin-k8s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 08:46:55 -04:00
Chris Farhood 160d6b49e9 0.2.5 2026-04-30 09:06:19 -04:00
Chris Farhood 9007762390 chore(deps): bump @paperclipai/adapter-utils from canary.7 pin to ^2026.428.0
The peerDep floor and devDep were pinned to a pre-release canary from
April 15, 13 days behind the current stable. Move both to the latest
stable 2026.428.0. All 328 tests pass against the new types; the
imported surface (asString, parseObject, runChildProcess,
AdapterExecutionContext, etc.) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:06:12 -04:00
Chris Farhood 506007984c 0.2.4 2026-04-30 08:46:57 -04:00
Chris Farhood 7a6d1a44f2 fix(ui-parser): restore esbuild CJS bundle step lost in PR #11 merge
Commit 0e43811 added an esbuild step to bundle src/ui-parser.ts as CJS
because the UI's sandboxed worker can't evaluate ESM `export` syntax.
PR #11 (filesystem-log-tail) was based on a commit predating that fix,
so the merge clobbered both the build:ui-parser script and the esbuild
devDependency. Every release since has shipped a tsc-emitted ESM
ui-parser.js that the worker silently fails to load — parseStdoutLine
never registers and the run transcript falls back to dumping raw
stream-json lines as plain text instead of rendering structured
assistant/thinking/tool_call/tool_result entries.

Restore the script and dep verbatim from 0e43811.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:46:46 -04:00
Chris Farhood 3960d746f4 ci: serialize publish jobs sharing the same SHA to fix race
When a tagged release lands on master, both the master-push and tag-push
events trigger the publish job. The skip-on-exists check (`npm view`)
runs concurrently on both, both see the version as not-yet-published,
and both proceed to `npm publish`. The first wins; the second gets
E403 ("cannot publish over previously published versions") and reds
out the run.

Fixes the race by adding a publish-${{ github.sha }} concurrency group
so the second run queues until the first finishes — by then npm view
sees the published version and the skip path takes over cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:28:01 -04:00
Chris Farhood cc942ca818 0.2.3 2026-04-30 08:03:08 -04:00
Chris Farhood 83a2d25062 fix(execute): assign captured stdout to outer binding so parse sees it
The filesystem-tail rewrite (8bd5042) declared `const stdout` inside the
try block, shadowing the outer `let stdout = ""`. parseClaudeStreamJson
then ran on the empty outer binding, so every run failed with "Failed to
parse Claude JSON output" and resultJson={stdout:""} despite live
log-streaming working fine. Drop the `const` so the assignment lands on
the outer let.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:03:05 -04:00
Chris Farhood c8429cfde1 fix: write logs to /paperclip/instances/default/data/run-logs/ to match server PVC layout
v0.2.1 introduced filesystem-tail log delivery with buildPodLogPath()
returning /paperclip/instances/default/run-logs/... but the paperclip
server creates and tails from /paperclip/instances/default/data/run-logs/
on the shared PVC. The missing /data/ segment meant:

  1. The init container's mkdir -p /paperclip/instances/... ran in a
     directory busybox UID 1000 can't write to — it's the init
     container's ephemeral rootfs, since the PVC is only mounted in
     the main container. Init exited 1, the && short-circuited, and
     the prompt copy never happened. Job failed with "Init container
     'write-prompt' failed with exit code 1".
  2. Even if the mkdir had worked, the main container's tee would
     have written to a path the server doesn't tail.

Fix: drop the misplaced mkdir from both init container variants and
correct buildPodLogPath() to include /data/. The directory already
exists on the PVC because the paperclip server creates it; both
containers run as UID 1000 with fsGroup 1000, so the main container's
tee writes to the pre-existing path with no setup needed.

Bump to 0.2.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 22:15:15 -04:00
Chris Farhood 1502039d70 Merge pull request #11 from farhoodlabs/feat/filesystem-log-tail
feat: replace k8s log API streaming with filesystem tailing
2026-04-27 22:26:02 -04:00
9 changed files with 166 additions and 175 deletions
+3
View File
@@ -30,6 +30,9 @@ jobs:
needs: test
runs-on: ubuntu-latest
if: (github.ref == 'refs/heads/master' && github.event_name == 'push') || startsWith(github.ref, 'refs/tags/')
concurrency:
group: publish-${{ github.sha }}
cancel-in-progress: false
permissions:
id-token: write
steps:
-15
View File
@@ -10,21 +10,6 @@ This is a Paperclip adapter plugin that runs Claude Code agents as isolated Kube
Build and publish are handled by GitHub Actions on tag push — do **not** build locally. To release a new version, bump `package.json` with `npm version` and push the tag — CI handles the rest.
## Verify everything
Never assume an action succeeded because the command exited 0 or because it "should" have worked. Verify the *outcome* of every action you take, especially anything async, anything that crosses a process/network/CI boundary, and anything that publishes or deploys.
Required checks:
- **After `git push`**: confirm the remote ref moved (`git ls-remote origin <branch>` or `gh` API) and confirm the expected workflow run was triggered. If a tag was pushed, confirm the tag exists on the remote.
- **After triggering a CI workflow**: poll `gh run view <id>` until it completes. Do not declare success until every job in the run is `completed/success`. If it fails, pull `gh run view <id> --log-failed` and diagnose before reporting back.
- **After a release/publish**: verify the artifact actually exists at its destination (`npm view <pkg>@<version>` for npm, registry/API check for others). A workflow that exited 0 is not proof — read the logs and verify the artifact.
- **After file edits**: re-read the file (or run `git diff`) to confirm the change landed exactly as intended.
- **After running tests/typecheck/build**: read the output. "It exited 0" is not enough — check that the expected number of tests ran, no suites were skipped silently, no warnings indicate the change didn't take effect.
- **After any state-changing API/CLI call** (kubectl, gh, npm, git config, etc.): query the resulting state and confirm it matches expectations.
If verification fails or is impossible, say so explicitly. Never paper over uncertainty with optimistic phrasing like "should be fine" or "looks good" — either you verified it or you didn't, and the user needs to know which.
## Common Commands
```bash
+2
View File
@@ -1,3 +1,5 @@
> **Abandoned** — This adapter is no longer maintained. The Kubernetes execution capability has moved to the sandbox plugin at [`farhoodlabs/paperclip-plugin-k8s`](https://github.com/farhoodlabs/paperclip-plugin-k8s) (`@farhoodlabs/paperclip-plugin-k8s` on npm).
# Claude (Kubernetes) Paperclip Adapter Plugin
Paperclip adapter plugin that runs Claude Code agents as isolated Kubernetes Jobs instead of inside the main Paperclip process.
+141 -137
View File
@@ -1,148 +1,152 @@
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/ui-parser.ts
var ui_parser_exports = {};
__export(ui_parser_exports, {
parseStdoutLine: () => parseStdoutLine
});
module.exports = __toCommonJS(ui_parser_exports);
/**
* 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;
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;
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 "";
}
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;
}
}
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 : void 0,
input: block.input ?? {}
});
}
try {
return JSON.parse(text);
}
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");
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 ?? {},
});
}
}
entries.push({ kind: "tool_result", ts, toolUseId, content: text, isError });
}
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
}
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 }];
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
+7 -7
View File
@@ -1,19 +1,19 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.2.1",
"version": "0.2.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperclip-adapter-claude-k8s",
"version": "0.2.1",
"version": "0.2.5",
"license": "MIT",
"dependencies": {
"@kubernetes/client-node": "^1.0.0",
"picocolors": "^1.1.1"
},
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@paperclipai/adapter-utils": "^2026.428.0",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.4",
"esbuild": "^0.24.0",
@@ -21,7 +21,7 @@
"vitest": "^4.1.4"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
"@paperclipai/adapter-utils": ">=2026.428.0"
}
},
"node_modules/@babel/helper-string-parser": {
@@ -649,9 +649,9 @@
}
},
"node_modules/@paperclipai/adapter-utils": {
"version": "2026.415.0-canary.7",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.415.0-canary.7.tgz",
"integrity": "sha512-VNzIZmu1lrK6QM8Ad9WkOihZItfkj21NHKQf+artDcbwFT2hHbDAD9hdW2W9NMVxYdFvvnws3w76FI/BUbCMbQ==",
"version": "2026.428.0",
"resolved": "https://registry.npmjs.org/@paperclipai/adapter-utils/-/adapter-utils-2026.428.0.tgz",
"integrity": "sha512-kGHpE7rhePPCbnG3OwXbNuHZZuI+XyuFgNSiDnrEeiSbkI2c5XHM2WnWDCZ/NGHULfJW3lWhSxGMFoYqiy38vQ==",
"dev": true,
"license": "MIT"
},
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "paperclip-adapter-claude-k8s",
"version": "0.2.1",
"version": "0.2.5",
"description": "Paperclip adapter plugin that runs Claude Code agents as Kubernetes Jobs",
"license": "MIT",
"repository": {
@@ -38,10 +38,10 @@
"picocolors": "^1.1.1"
},
"peerDependencies": {
"@paperclipai/adapter-utils": ">=2026.415.0-canary.7"
"@paperclipai/adapter-utils": ">=2026.428.0"
},
"devDependencies": {
"@paperclipai/adapter-utils": "2026.415.0-canary.7",
"@paperclipai/adapter-utils": "^2026.428.0",
"@types/node": "^24.6.0",
"@vitest/coverage-v8": "^4.1.4",
"esbuild": "^0.24.0",
+1 -1
View File
@@ -1234,7 +1234,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
waitForJobCompletion(namespace, jobName, completionTimeoutMs, kubeconfigPath, jobObserver).then(r => { logStopSignal.stopped = true; return r; }),
]);
const stdout = tailResult.status === "fulfilled" ? tailResult.value : "";
stdout = tailResult.status === "fulfilled" ? tailResult.value : "";
if (completionResult.status === "fulfilled") {
jobTimedOut = completionResult.value.timedOut;
+6 -9
View File
@@ -301,8 +301,7 @@ describe("buildJobManifest", () => {
const init = job.spec?.template?.spec?.initContainers?.[0];
expect(init?.command?.[0]).toBe("sh");
expect(init?.command?.[1]).toBe("-c");
expect(init?.command?.[2]).toContain("mkdir -p /paperclip/instances/default/run-logs/");
expect(init?.command?.[2]).toContain("printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt");
expect(init?.command?.[2]).toBe("printf '%s' \"$PROMPT_CONTENT\" > /tmp/prompt/prompt.txt");
});
it("write-prompt mounts prompt volume", () => {
@@ -808,28 +807,26 @@ describe("buildJobManifest", () => {
const { job } = buildJobManifest({ ctx, selfPod });
const cmd = job.spec?.template?.spec?.containers[0]?.command?.[2] ?? "";
expect(cmd).toContain("| tee");
expect(cmd).toContain("/paperclip/instances/default/run-logs/");
expect(cmd).toContain("/paperclip/instances/default/data/run-logs/");
});
it("podLogPath is returned from buildJobManifest", () => {
const result = buildJobManifest({ ctx, selfPod });
expect(result.podLogPath).toBe(
"/paperclip/instances/default/run-logs/co1/agent-abc/run-abc12345.pod.ndjson",
"/paperclip/instances/default/data/run-logs/co1/agent-abc/run-abc12345.pod.ndjson",
);
});
it("buildPodLogPath returns correctly formatted path", () => {
expect(buildPodLogPath("co1", "agent-abc", "run-abc12345")).toBe(
"/paperclip/instances/default/run-logs/co1/agent-abc/run-abc12345.pod.ndjson",
"/paperclip/instances/default/data/run-logs/co1/agent-abc/run-abc12345.pod.ndjson",
);
});
it("init container creates log directory", () => {
it("init container does not create log directory (server pre-creates it on shared PVC)", () => {
const { job } = buildJobManifest({ ctx, selfPod });
const initCmd = job.spec?.template?.spec?.initContainers?.[0]?.command;
expect(initCmd?.[0]).toBe("sh");
expect(initCmd?.[1]).toBe("-c");
expect(initCmd?.[2]).toContain("mkdir -p /paperclip/instances/default/run-logs/");
expect(initCmd?.[2]).not.toContain("mkdir -p /paperclip");
});
it("sanitizes companyId with / to valid path component for log path", () => {
+3 -3
View File
@@ -23,7 +23,7 @@ function sanitizeForK8sPath(value: string): string {
}
export function buildPodLogPath(companyId: string, agentId: string, runId: string): string {
return `/paperclip/instances/default/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
return `/paperclip/instances/default/data/run-logs/${companyId}/${agentId}/${runId}.pod.ndjson`;
}
/** Prompts above this size (bytes) are staged via a Secret instead of an
@@ -504,7 +504,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${agent.companyId}/${agent.id} && cp /tmp/prompt-secret/prompt.txt /tmp/prompt/prompt.txt`],
command: ["sh", "-c", "cp /tmp/prompt-secret/prompt.txt /tmp/prompt/prompt.txt"],
volumeMounts: [
{ name: "prompt", mountPath: "/tmp/prompt" },
{ name: "prompt-secret", mountPath: "/tmp/prompt-secret", readOnly: true },
@@ -519,7 +519,7 @@ export function buildJobManifest(input: JobBuildInput): JobBuildResult {
name: "write-prompt",
image: "busybox:1.36",
imagePullPolicy: "IfNotPresent",
command: ["sh", "-c", `mkdir -p /paperclip/instances/default/run-logs/${agent.companyId}/${agent.id} && printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`],
command: ["sh", "-c", `printf '%s' "$PROMPT_CONTENT" > /tmp/prompt/prompt.txt`],
env: [{ name: "PROMPT_CONTENT", value: prompt }],
volumeMounts: [{ name: "prompt", mountPath: "/tmp/prompt" }],
securityContext,