337 lines
11 KiB
TypeScript
337 lines
11 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
|
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asBoolean,
|
|
parseObject,
|
|
parseJson,
|
|
redactEnvForLogs,
|
|
filterConflictingArgs,
|
|
renderTemplate,
|
|
runChildProcess,
|
|
} from "@agentcompany/adapter-utils/server-utils";
|
|
import {
|
|
parseClaudeStreamJson,
|
|
describeClaudeFailure,
|
|
detectClaudeLoginRequired,
|
|
isClaudeMaxTurnsResult,
|
|
isClaudeUnknownSessionError,
|
|
} from "./parse.js";
|
|
import {
|
|
buildSkillsDir,
|
|
buildLoginResult,
|
|
resolveClaudeBillingType,
|
|
buildClaudeRuntimeConfig,
|
|
parseManagedSkills,
|
|
resolveSessionResumeState,
|
|
} from "./execute-helpers.js";
|
|
|
|
export async function runClaudeLogin(input: {
|
|
runId: string;
|
|
agent: AdapterExecutionContext["agent"];
|
|
config: Record<string, unknown>;
|
|
context?: Record<string, unknown>;
|
|
authToken?: string;
|
|
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
}) {
|
|
const onLog = input.onLog ?? (async () => {});
|
|
const runtime = await buildClaudeRuntimeConfig({
|
|
runId: input.runId,
|
|
agent: input.agent,
|
|
config: input.config,
|
|
context: input.context ?? {},
|
|
authToken: input.authToken,
|
|
});
|
|
|
|
const proc = await runChildProcess(input.runId, runtime.command, ["login"], {
|
|
cwd: runtime.cwd,
|
|
env: runtime.env,
|
|
timeoutSec: runtime.timeoutSec,
|
|
graceSec: runtime.graceSec,
|
|
onLog,
|
|
});
|
|
|
|
const loginMeta = detectClaudeLoginRequired({
|
|
parsed: null,
|
|
stdout: proc.stdout,
|
|
stderr: proc.stderr,
|
|
});
|
|
|
|
return buildLoginResult({
|
|
proc,
|
|
loginUrl: loginMeta.loginUrl,
|
|
});
|
|
}
|
|
|
|
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
|
|
|
const promptTemplate = asString(
|
|
config.promptTemplate,
|
|
"{{context.taskTitle}}\n\n{{context.taskDescription}}",
|
|
);
|
|
const model = asString(config.model, "");
|
|
const effort = asString(config.effort, "");
|
|
const chrome = asBoolean(config.chrome, false);
|
|
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
|
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
const commandNotes = instructionsFilePath
|
|
? [
|
|
`Injected agent instructions via --append-system-prompt-file ${instructionsFilePath} (with path directive appended)`,
|
|
]
|
|
: [];
|
|
|
|
const runtimeConfig = await buildClaudeRuntimeConfig({
|
|
runId,
|
|
agent,
|
|
config,
|
|
context,
|
|
authToken,
|
|
});
|
|
const {
|
|
command,
|
|
cwd,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
extraArgs,
|
|
} = runtimeConfig;
|
|
const billingType = resolveClaudeBillingType(env);
|
|
const managedSkills = parseManagedSkills(config);
|
|
const skillsDir = await buildSkillsDir(managedSkills, onLog);
|
|
|
|
// When instructionsFilePath is configured, create a combined temp file that
|
|
// includes both the file content and the path directive, so we only need
|
|
// --append-system-prompt-file (Claude CLI forbids using both flags together).
|
|
let effectiveInstructionsFilePath = instructionsFilePath;
|
|
if (instructionsFilePath) {
|
|
const instructionsContent = await fs.readFile(instructionsFilePath, "utf-8");
|
|
const pathDirective = `\nThe above agent instructions were loaded from ${instructionsFilePath}. Resolve any relative file references from ${instructionsFileDir}.`;
|
|
const combinedPath = path.join(skillsDir, "agent-instructions.md");
|
|
await fs.writeFile(combinedPath, instructionsContent + pathDirective, "utf-8");
|
|
effectiveInstructionsFilePath = combinedPath;
|
|
}
|
|
|
|
const sessionResume = resolveSessionResumeState(runtime, cwd);
|
|
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } = sessionResume;
|
|
if (runtimeSessionId && !canResumeSession) {
|
|
await onLog(
|
|
"stderr",
|
|
`[agent-company] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
);
|
|
}
|
|
const prompt = renderTemplate(promptTemplate, {
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
runId,
|
|
company: { id: agent.companyId },
|
|
agent,
|
|
run: { id: runId, source: "on_demand" },
|
|
context,
|
|
});
|
|
|
|
const buildClaudeArgs = (resumeSessionId: string | null) => {
|
|
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
|
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
|
if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions");
|
|
if (chrome) args.push("--chrome");
|
|
if (model) args.push("--model", model);
|
|
if (effort) args.push("--effort", effort);
|
|
if (maxTurns > 0) args.push("--max-turns", String(maxTurns));
|
|
if (effectiveInstructionsFilePath) {
|
|
args.push("--append-system-prompt-file", effectiveInstructionsFilePath);
|
|
}
|
|
args.push("--add-dir", skillsDir);
|
|
if (extraArgs.length > 0) {
|
|
// Filter out flags the adapter already manages to prevent duplicates
|
|
// (e.g. user shell aliases that inject --dangerously-skip-permissions)
|
|
const managedFlags = [
|
|
"--dangerously-skip-permissions",
|
|
"--output-format",
|
|
"--model",
|
|
"--effort",
|
|
"--max-turns",
|
|
"--add-dir",
|
|
"--append-system-prompt-file",
|
|
"--teammate-mode",
|
|
];
|
|
const cleaned = filterConflictingArgs(extraArgs, managedFlags);
|
|
if (cleaned.length > 0) args.push(...cleaned);
|
|
}
|
|
return args;
|
|
};
|
|
|
|
const parseFallbackErrorMessage = (proc: RunProcessResult) => {
|
|
const stderrLine =
|
|
proc.stderr
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean) ?? "";
|
|
|
|
if ((proc.exitCode ?? 0) === 0) {
|
|
return "Failed to parse claude JSON output";
|
|
}
|
|
|
|
return stderrLine
|
|
? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}`
|
|
: `Claude exited with code ${proc.exitCode ?? -1}`;
|
|
};
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
const args = buildClaudeArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "claude_local",
|
|
command,
|
|
cwd,
|
|
commandArgs: args,
|
|
commandNotes,
|
|
env: redactEnvForLogs(env),
|
|
prompt,
|
|
context,
|
|
});
|
|
}
|
|
|
|
const proc = await runChildProcess(runId, command, args, {
|
|
cwd,
|
|
env,
|
|
stdin: prompt,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
|
|
const parsedStream = parseClaudeStreamJson(proc.stdout);
|
|
const parsed = parsedStream.resultJson ?? parseJson(proc.stdout);
|
|
return { proc, parsedStream, parsed };
|
|
};
|
|
|
|
const toAdapterResult = (
|
|
attempt: {
|
|
proc: RunProcessResult;
|
|
parsedStream: ReturnType<typeof parseClaudeStreamJson>;
|
|
parsed: Record<string, unknown> | null;
|
|
},
|
|
opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean },
|
|
): AdapterExecutionResult => {
|
|
const { proc, parsedStream, parsed } = attempt;
|
|
const loginMeta = detectClaudeLoginRequired({
|
|
parsed,
|
|
stdout: proc.stdout,
|
|
stderr: proc.stderr,
|
|
});
|
|
const errorMeta =
|
|
loginMeta.loginUrl != null
|
|
? {
|
|
loginUrl: loginMeta.loginUrl,
|
|
}
|
|
: undefined;
|
|
|
|
if (proc.timedOut) {
|
|
return {
|
|
exitCode: proc.exitCode,
|
|
signal: proc.signal,
|
|
timedOut: true,
|
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
errorCode: "timeout",
|
|
errorMeta,
|
|
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
|
};
|
|
}
|
|
|
|
if (!parsed) {
|
|
return {
|
|
exitCode: proc.exitCode,
|
|
signal: proc.signal,
|
|
timedOut: false,
|
|
errorMessage: parseFallbackErrorMessage(proc),
|
|
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
|
errorMeta,
|
|
resultJson: {
|
|
stdout: proc.stdout,
|
|
stderr: proc.stderr,
|
|
},
|
|
clearSession: Boolean(opts.clearSessionOnMissingSession),
|
|
};
|
|
}
|
|
|
|
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 resolvedSessionId =
|
|
parsedStream.sessionId ??
|
|
(asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId);
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? ({
|
|
sessionId: resolvedSessionId,
|
|
cwd,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
} as Record<string, unknown>)
|
|
: null;
|
|
const clearSessionForMaxTurns = isClaudeMaxTurnsResult(parsed);
|
|
|
|
return {
|
|
exitCode: proc.exitCode,
|
|
signal: proc.signal,
|
|
timedOut: false,
|
|
errorMessage:
|
|
(proc.exitCode ?? 0) === 0
|
|
? null
|
|
: describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`,
|
|
errorCode: loginMeta.requiresLogin ? "claude_auth_required" : null,
|
|
errorMeta,
|
|
usage,
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: "anthropic",
|
|
model: parsedStream.model || asString(parsed.model, model),
|
|
billingType,
|
|
costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0),
|
|
resultJson: parsed,
|
|
summary: parsedStream.summary || asString(parsed.result, ""),
|
|
clearSession: clearSessionForMaxTurns || Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId),
|
|
};
|
|
};
|
|
|
|
try {
|
|
const initial = await runAttempt(sessionId ?? null);
|
|
if (
|
|
sessionId &&
|
|
!initial.proc.timedOut &&
|
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
initial.parsed &&
|
|
isClaudeUnknownSessionError(initial.parsed)
|
|
) {
|
|
await onLog(
|
|
"stderr",
|
|
`[agent-company] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const retry = await runAttempt(null);
|
|
return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true });
|
|
}
|
|
|
|
return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId });
|
|
} finally {
|
|
fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|