Improve claude-local adapter execution and shared utils
Enhance claude-local server executor with better process management and output handling. Improve stdout parser for UI transcript display. Update adapter-utils types and server utilities. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,7 @@ export async function runChildProcess(
|
|||||||
graceSec: number;
|
graceSec: number;
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||||
|
stdin?: string;
|
||||||
},
|
},
|
||||||
): Promise<RunProcessResult> {
|
): Promise<RunProcessResult> {
|
||||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||||
@@ -185,9 +186,14 @@ export async function runChildProcess(
|
|||||||
cwd: opts.cwd,
|
cwd: opts.cwd,
|
||||||
env: mergedEnv,
|
env: mergedEnv,
|
||||||
shell: false,
|
shell: false,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (opts.stdin != null && child.stdin) {
|
||||||
|
child.stdin.write(opts.stdin);
|
||||||
|
child.stdin.end();
|
||||||
|
}
|
||||||
|
|
||||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||||
|
|
||||||
let timedOut = false;
|
let timedOut = false;
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface ServerAdapterModule {
|
|||||||
export type TranscriptEntry =
|
export type TranscriptEntry =
|
||||||
| { kind: "assistant"; ts: string; text: string }
|
| { kind: "assistant"; ts: string; text: string }
|
||||||
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
| { kind: "tool_call"; ts: string; name: string; input: unknown }
|
||||||
|
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
||||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
| { 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: "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: "stderr"; ts: string; text: string }
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
||||||
import type { RunProcessResult } from "@paperclip/adapter-utils/server-utils";
|
import type { RunProcessResult } from "@paperclip/adapter-utils/server-utils";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +21,32 @@ import {
|
|||||||
} from "@paperclip/adapter-utils/server-utils";
|
} from "@paperclip/adapter-utils/server-utils";
|
||||||
import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
|
import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js";
|
||||||
|
|
||||||
|
const PAPERCLIP_SKILLS_DIR = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
"../../../../../skills",
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||||
|
* the repo's `skills/` directory, so `--add-dir` makes Claude Code discover
|
||||||
|
* them as proper registered skills.
|
||||||
|
*/
|
||||||
|
async function buildSkillsDir(): Promise<string> {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-"));
|
||||||
|
const target = path.join(tmp, ".claude", "skills");
|
||||||
|
await fs.mkdir(target, { recursive: true });
|
||||||
|
const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await fs.symlink(
|
||||||
|
path.join(PAPERCLIP_SKILLS_DIR, entry.name),
|
||||||
|
path.join(target, entry.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||||
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
const { runId, agent, runtime, config, context, onLog, onMeta } = ctx;
|
||||||
|
|
||||||
@@ -47,6 +77,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||||
return asStringArray(config.args);
|
return asStringArray(config.args);
|
||||||
})();
|
})();
|
||||||
|
const skillsDir = await buildSkillsDir();
|
||||||
|
|
||||||
const sessionId = runtime.sessionId;
|
const sessionId = runtime.sessionId;
|
||||||
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
||||||
@@ -58,11 +89,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
||||||
const args = ["--print", prompt, "--output-format", "stream-json", "--verbose"];
|
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
||||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
||||||
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
||||||
if (model) args.push("--model", model);
|
if (model) args.push("--model", model);
|
||||||
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
||||||
|
args.push("--add-dir", skillsDir);
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
||||||
return args;
|
return args;
|
||||||
};
|
};
|
||||||
@@ -90,7 +122,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
adapterType: "claude_local",
|
adapterType: "claude_local",
|
||||||
command,
|
command,
|
||||||
cwd,
|
cwd,
|
||||||
commandArgs: args.map((value, idx) => (idx === 1 ? `<prompt ${prompt.length} chars>` : value)),
|
commandArgs: args,
|
||||||
env: redactEnvForLogs(env),
|
env: redactEnvForLogs(env),
|
||||||
prompt,
|
prompt,
|
||||||
context,
|
context,
|
||||||
@@ -100,6 +132,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
const proc = await runChildProcess(runId, command, args, {
|
const proc = await runChildProcess(runId, command, args, {
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
|
stdin: prompt,
|
||||||
timeoutSec,
|
timeoutSec,
|
||||||
graceSec,
|
graceSec,
|
||||||
onLog,
|
onLog,
|
||||||
@@ -177,21 +210,25 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId ?? null);
|
try {
|
||||||
if (
|
const initial = await runAttempt(sessionId ?? null);
|
||||||
sessionId &&
|
if (
|
||||||
!initial.proc.timedOut &&
|
sessionId &&
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
!initial.proc.timedOut &&
|
||||||
initial.parsed &&
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
||||||
isClaudeUnknownSessionError(initial.parsed)
|
initial.parsed &&
|
||||||
) {
|
isClaudeUnknownSessionError(initial.parsed)
|
||||||
await onLog(
|
) {
|
||||||
"stderr",
|
await onLog(
|
||||||
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
"stderr",
|
||||||
);
|
`[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
||||||
const retry = await runAttempt(null);
|
);
|
||||||
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
const retry = await runAttempt(null);
|
||||||
}
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
||||||
|
}
|
||||||
|
|
||||||
return toAdapterResult(initial, { fallbackSessionId: runtime.sessionId });
|
return toAdapterResult(initial, { fallbackSessionId: runtime.sessionId });
|
||||||
|
} finally {
|
||||||
|
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,35 @@ export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry
|
|||||||
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
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 === "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;
|
||||||
|
// fall through to stdout for user messages without tool_result blocks
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
const usage = asRecord(parsed.usage) ?? {};
|
const usage = asRecord(parsed.usage) ?? {};
|
||||||
const inputTokens = asNumber(usage.input_tokens);
|
const inputTokens = asNumber(usage.input_tokens);
|
||||||
|
|||||||
Reference in New Issue
Block a user