Build the current workspace snapshot on native CI runners
This branch snapshots the in-progress desktop workspace so GitHub-hosted Windows and Linux runners can package the same source tree that already passes local verification on macOS. Constraint: Local Apple Silicon Docker/Wine packaging crashes before Windows artifacts are produced Rejected: Publish from the incomplete base repo state | remote typecheck failed because current source additions were missing Confidence: medium Scope-risk: broad Directive: Treat this branch as a build snapshot only; do not merge it without reviewing the unrelated workspace changes it contains Tested: local typecheck, lint, workspace typecheck, unit tests, integration tests, e2e smoke, macOS signed release upload Not-tested: Native Windows/Linux artifacts before this CI run
This commit is contained in:
1 parent
0e497cd892
commit
b772e1c758
271 files changed
+35047
-22879
No files matched your search
@@ -0,0 +1,261 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
||||
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
|
||||
import {
|
||||
asString,
|
||||
asNumber,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
buildAgentCompanyEnv,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
createManagedSkillSymlinks,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* managed skill entries (preferred) or the repo's static `skills/` directory
|
||||
* (backward compatibility fallback), so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
export async function buildSkillsDir(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
||||
): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
|
||||
// Use managed skills when available; fall back to static repo scan
|
||||
if (managedSkills.length > 0) {
|
||||
await createManagedSkillSymlinks(managedSkills, target, onLog);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return tmp;
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await fs.symlink(
|
||||
path.join(skillsDir, entry.name),
|
||||
path.join(target, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
export interface ClaudeExecutionInput {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
export function buildLoginResult(input: {
|
||||
proc: RunProcessResult;
|
||||
loginUrl: string | null;
|
||||
}) {
|
||||
return {
|
||||
exitCode: input.proc.exitCode,
|
||||
signal: input.proc.signal,
|
||||
timedOut: input.proc.timedOut,
|
||||
stdout: input.proc.stdout,
|
||||
stderr: input.proc.stderr,
|
||||
loginUrl: input.loginUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
export async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||
const { runId, agent, config, context, authToken } = input;
|
||||
|
||||
const command = asString(config.command, "claude");
|
||||
const workspaceContext = parseObject(context.agentCompanyWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
|
||||
? context.agentCompanyWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
|
||||
? context.agentCompanyRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
|
||||
? context.agentCompanyRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (wakeTaskId) {
|
||||
env.AGENT_COMPANY_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.AGENT_COMPANY_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.AGENT_COMPANY_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
return {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
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 "@agentcompany/adapter-utils";
|
||||
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
|
||||
import type { ManagedSkillRef } from "@agentcompany/adapter-utils";
|
||||
@@ -9,18 +7,12 @@ import {
|
||||
asString,
|
||||
asNumber,
|
||||
asBoolean,
|
||||
asStringArray,
|
||||
parseObject,
|
||||
parseJson,
|
||||
buildAgentCompanyEnv,
|
||||
redactEnvForLogs,
|
||||
ensureAbsoluteDirectory,
|
||||
ensureCommandResolvable,
|
||||
ensurePathInEnv,
|
||||
filterConflictingArgs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
createManagedSkillSymlinks,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import {
|
||||
parseClaudeStreamJson,
|
||||
@@ -29,250 +21,12 @@ import {
|
||||
isClaudeMaxTurnsResult,
|
||||
isClaudeUnknownSessionError,
|
||||
} from "./parse.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tmpdir with `.claude/skills/` containing symlinks to skills from
|
||||
* managed skill entries (preferred) or the repo's static `skills/` directory
|
||||
* (backward compatibility fallback), so `--add-dir` makes Claude Code discover
|
||||
* them as proper registered skills.
|
||||
*/
|
||||
async function buildSkillsDir(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
||||
): Promise<string> {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "agent-company-skills-"));
|
||||
const target = path.join(tmp, ".claude", "skills");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
|
||||
// Use managed skills when available; fall back to static repo scan
|
||||
if (managedSkills.length > 0) {
|
||||
await createManagedSkillSymlinks(managedSkills, target, onLog);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return tmp;
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
await fs.symlink(
|
||||
path.join(skillsDir, entry.name),
|
||||
path.join(target, entry.name),
|
||||
);
|
||||
}
|
||||
}
|
||||
return tmp;
|
||||
}
|
||||
|
||||
interface ClaudeExecutionInput {
|
||||
runId: string;
|
||||
agent: AdapterExecutionContext["agent"];
|
||||
config: Record<string, unknown>;
|
||||
context: Record<string, unknown>;
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
interface ClaudeRuntimeConfig {
|
||||
command: string;
|
||||
cwd: string;
|
||||
workspaceId: string | null;
|
||||
workspaceRepoUrl: string | null;
|
||||
workspaceRepoRef: string | null;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
function buildLoginResult(input: {
|
||||
proc: RunProcessResult;
|
||||
loginUrl: string | null;
|
||||
}) {
|
||||
return {
|
||||
exitCode: input.proc.exitCode,
|
||||
signal: input.proc.signal,
|
||||
timedOut: input.proc.timedOut,
|
||||
stdout: input.proc.stdout,
|
||||
stderr: input.proc.stderr,
|
||||
loginUrl: input.loginUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Claude uses API-key auth when ANTHROPIC_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "ANTHROPIC_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<ClaudeRuntimeConfig> {
|
||||
const { runId, agent, config, context, authToken } = input;
|
||||
|
||||
const command = asString(config.command, "claude");
|
||||
const workspaceContext = parseObject(context.agentCompanyWorkspace);
|
||||
const workspaceCwd = asString(workspaceContext.cwd, "");
|
||||
const workspaceSource = asString(workspaceContext.source, "");
|
||||
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "") || null;
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "") || null;
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const workspaceHints = Array.isArray(context.agentCompanyWorkspaces)
|
||||
? context.agentCompanyWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServiceIntents = Array.isArray(context.agentCompanyRuntimeServiceIntents)
|
||||
? context.agentCompanyRuntimeServiceIntents.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimeServices = Array.isArray(context.agentCompanyRuntimeServices)
|
||||
? context.agentCompanyRuntimeServices.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
)
|
||||
: [];
|
||||
const runtimePrimaryUrl = asString(context.agentCompanyRuntimePrimaryUrl, "");
|
||||
const configuredCwd = asString(config.cwd, "");
|
||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
||||
|
||||
const envConfig = parseObject(config.env);
|
||||
const hasExplicitApiKey =
|
||||
typeof envConfig.AGENT_COMPANY_API_KEY === "string" && envConfig.AGENT_COMPANY_API_KEY.trim().length > 0;
|
||||
const env: Record<string, string> = { ...buildAgentCompanyEnv(agent) };
|
||||
env.AGENT_COMPANY_RUN_ID = runId;
|
||||
|
||||
const wakeTaskId =
|
||||
(typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) ||
|
||||
(typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) ||
|
||||
null;
|
||||
const wakeReason =
|
||||
typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0
|
||||
? context.wakeReason.trim()
|
||||
: null;
|
||||
const wakeCommentId =
|
||||
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
|
||||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.trim()) ||
|
||||
null;
|
||||
const approvalId =
|
||||
typeof context.approvalId === "string" && context.approvalId.trim().length > 0
|
||||
? context.approvalId.trim()
|
||||
: null;
|
||||
const approvalStatus =
|
||||
typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0
|
||||
? context.approvalStatus.trim()
|
||||
: null;
|
||||
const linkedIssueIds = Array.isArray(context.issueIds)
|
||||
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
||||
: [];
|
||||
|
||||
if (wakeTaskId) {
|
||||
env.AGENT_COMPANY_TASK_ID = wakeTaskId;
|
||||
}
|
||||
if (wakeReason) {
|
||||
env.AGENT_COMPANY_WAKE_REASON = wakeReason;
|
||||
}
|
||||
if (wakeCommentId) {
|
||||
env.AGENT_COMPANY_WAKE_COMMENT_ID = wakeCommentId;
|
||||
}
|
||||
if (approvalId) {
|
||||
env.AGENT_COMPANY_APPROVAL_ID = approvalId;
|
||||
}
|
||||
if (approvalStatus) {
|
||||
env.AGENT_COMPANY_APPROVAL_STATUS = approvalStatus;
|
||||
}
|
||||
if (linkedIssueIds.length > 0) {
|
||||
env.AGENT_COMPANY_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
||||
}
|
||||
if (effectiveWorkspaceCwd) {
|
||||
env.AGENT_COMPANY_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
||||
}
|
||||
if (workspaceSource) {
|
||||
env.AGENT_COMPANY_WORKSPACE_SOURCE = workspaceSource;
|
||||
}
|
||||
if (workspaceStrategy) {
|
||||
env.AGENT_COMPANY_WORKSPACE_STRATEGY = workspaceStrategy;
|
||||
}
|
||||
if (workspaceId) {
|
||||
env.AGENT_COMPANY_WORKSPACE_ID = workspaceId;
|
||||
}
|
||||
if (workspaceRepoUrl) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
}
|
||||
if (workspaceRepoRef) {
|
||||
env.AGENT_COMPANY_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (workspaceBranch) {
|
||||
env.AGENT_COMPANY_WORKSPACE_BRANCH = workspaceBranch;
|
||||
}
|
||||
if (workspaceWorktreePath) {
|
||||
env.AGENT_COMPANY_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.AGENT_COMPANY_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
if (runtimeServiceIntents.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
||||
}
|
||||
if (runtimeServices.length > 0) {
|
||||
env.AGENT_COMPANY_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
||||
}
|
||||
if (runtimePrimaryUrl) {
|
||||
env.AGENT_COMPANY_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
if (typeof value === "string") env[key] = value;
|
||||
}
|
||||
|
||||
if (!hasExplicitApiKey && authToken) {
|
||||
env.AGENT_COMPANY_API_KEY = authToken;
|
||||
}
|
||||
|
||||
const runtimeEnv = ensurePathInEnv(env);
|
||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
||||
|
||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
||||
const graceSec = asNumber(config.graceSec, 20);
|
||||
const extraArgs = (() => {
|
||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
||||
return asStringArray(config.args);
|
||||
})();
|
||||
|
||||
return {
|
||||
command,
|
||||
cwd,
|
||||
workspaceId,
|
||||
workspaceRepoUrl,
|
||||
workspaceRepoRef,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
extraArgs,
|
||||
};
|
||||
}
|
||||
import {
|
||||
buildSkillsDir,
|
||||
buildLoginResult,
|
||||
resolveClaudeBillingType,
|
||||
buildClaudeRuntimeConfig,
|
||||
} from "./execute-helpers.js";
|
||||
|
||||
export async function runClaudeLogin(input: {
|
||||
runId: string;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
createManagedSkillSymlinks,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
|
||||
export const CODEX_ROLLOUT_NOISE_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||
|
||||
export function stripCodexRolloutNoise(text: string): string {
|
||||
const parts = text.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
kept.push(part);
|
||||
continue;
|
||||
}
|
||||
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
export function codexHomeDir(): string {
|
||||
const fromEnv = process.env.CODEX_HOME;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function ensureCodexSkillsInjected(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
) {
|
||||
const skillsHome = path.join(codexHomeDir(), "skills");
|
||||
|
||||
// Use managed skills when available; fall back to static repo scan
|
||||
if (managedSkills.length > 0) {
|
||||
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
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, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asString,
|
||||
@@ -17,96 +15,14 @@ import {
|
||||
filterConflictingArgs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
createManagedSkillSymlinks,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
|
||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"), // published: <pkg>/dist/server/ -> <pkg>/skills/
|
||||
path.resolve(__moduleDir, "../../../../../skills"), // dev: src/server/ -> repo root/skills/
|
||||
];
|
||||
const CODEX_ROLLOUT_NOISE_RE =
|
||||
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
||||
|
||||
function stripCodexRolloutNoise(text: string): string {
|
||||
const parts = text.split(/\r?\n/);
|
||||
const kept: string[] = [];
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) {
|
||||
kept.push(part);
|
||||
continue;
|
||||
}
|
||||
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
||||
kept.push(part);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
|
||||
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
||||
}
|
||||
|
||||
function codexHomeDir(): string {
|
||||
const fromEnv = process.env.CODEX_HOME;
|
||||
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) return fromEnv.trim();
|
||||
return path.join(os.homedir(), ".codex");
|
||||
}
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureCodexSkillsInjected(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
) {
|
||||
const skillsHome = path.join(codexHomeDir(), "skills");
|
||||
|
||||
// Use managed skills when available; fall back to static repo scan
|
||||
if (managedSkills.length > 0) {
|
||||
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Injected Codex skill "${entry.name}" into ${skillsHome}\n`,
|
||||
);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
stripCodexRolloutNoise,
|
||||
resolveCodexBillingType,
|
||||
ensureCodexSkillsInjected,
|
||||
} from "./execute-helpers.js";
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
createManagedSkillSymlinks,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
export const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
export function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
export function renderEnvNote(env: Record<string, string>): string {
|
||||
const agentCompanyKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("AGENT_COMPANY_"))
|
||||
.sort();
|
||||
if (agentCompanyKeys.length === 0) return "";
|
||||
return [
|
||||
"Runtime note:",
|
||||
`The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return "";
|
||||
return [
|
||||
"API access note:",
|
||||
"Use run_shell_command with curl to make API requests.",
|
||||
"GET example:",
|
||||
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`,
|
||||
"POST/PATCH example:",
|
||||
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function geminiSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".gemini", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks.
|
||||
* Uses managed skills when available; falls back to static repo scan.
|
||||
*/
|
||||
export async function ensureGeminiSkillsInjected(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
): Promise<void> {
|
||||
const skillsHome = geminiSkillsHome();
|
||||
|
||||
// Use managed skills when available; fall back to static repo scan
|
||||
if (managedSkills.length > 0) {
|
||||
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AdapterExecutionContext, AdapterExecutionResult, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
||||
import {
|
||||
asBoolean,
|
||||
@@ -18,7 +16,6 @@ import {
|
||||
redactEnvForLogs,
|
||||
renderTemplate,
|
||||
runChildProcess,
|
||||
createManagedSkillSymlinks,
|
||||
} from "@agentcompany/adapter-utils/server-utils";
|
||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
||||
import {
|
||||
@@ -29,122 +26,12 @@ import {
|
||||
parseGeminiJsonl,
|
||||
} from "./parse.js";
|
||||
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
|
||||
|
||||
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const SKILLS_CANDIDATES = [
|
||||
path.resolve(__moduleDir, "../../skills"),
|
||||
path.resolve(__moduleDir, "../../../../../skills"),
|
||||
];
|
||||
|
||||
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
||||
const raw = env[key];
|
||||
return typeof raw === "string" && raw.trim().length > 0;
|
||||
}
|
||||
|
||||
function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscription" {
|
||||
return hasNonEmptyEnvValue(env, "GEMINI_API_KEY") || hasNonEmptyEnvValue(env, "GOOGLE_API_KEY")
|
||||
? "api"
|
||||
: "subscription";
|
||||
}
|
||||
|
||||
function renderEnvNote(env: Record<string, string>): string {
|
||||
const agentCompanyKeys = Object.keys(env)
|
||||
.filter((key) => key.startsWith("AGENT_COMPANY_"))
|
||||
.sort();
|
||||
if (agentCompanyKeys.length === 0) return "";
|
||||
return [
|
||||
"Runtime note:",
|
||||
`The following AGENT_COMPANY_* environment variables are available in this run: ${agentCompanyKeys.join(", ")}`,
|
||||
"Do not assume these variables are missing without checking your shell environment.",
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function renderApiAccessNote(env: Record<string, string>): string {
|
||||
if (!hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_URL") || !hasNonEmptyEnvValue(env, "AGENT_COMPANY_API_KEY")) return "";
|
||||
return [
|
||||
"API access note:",
|
||||
"Use run_shell_command with curl to make API requests.",
|
||||
"GET example:",
|
||||
` run_shell_command({ command: "curl -s -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" \\"$AGENT_COMPANY_API_URL/api/agents/me\\"" })`,
|
||||
"POST/PATCH example:",
|
||||
` run_shell_command({ command: "curl -s -X POST -H \\"Authorization: Bearer $AGENT_COMPANY_API_KEY\\" -H 'Content-Type: application/json' -H \\"X-Agent-Company-Run-Id: $AGENT_COMPANY_RUN_ID\\" -d '{...}' \\"$AGENT_COMPANY_API_URL/api/issues/{id}/checkout\\"" })`,
|
||||
"",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function resolveAgentCompanySkillsDir(): Promise<string | null> {
|
||||
for (const candidate of SKILLS_CANDIDATES) {
|
||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||
if (isDir) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function geminiSkillsHome(): string {
|
||||
return path.join(os.homedir(), ".gemini", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject AgentCompany skills directly into `~/.gemini/skills/` via symlinks.
|
||||
* Uses managed skills when available; falls back to static repo scan.
|
||||
*/
|
||||
async function ensureGeminiSkillsInjected(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
onLog: AdapterExecutionContext["onLog"],
|
||||
): Promise<void> {
|
||||
const skillsHome = geminiSkillsHome();
|
||||
|
||||
// Use managed skills when available; fall back to static repo scan
|
||||
if (managedSkills.length > 0) {
|
||||
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
|
||||
return;
|
||||
}
|
||||
|
||||
const skillsDir = await resolveAgentCompanySkillsDir();
|
||||
if (!skillsDir) return;
|
||||
|
||||
try {
|
||||
await fs.mkdir(skillsHome, { recursive: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to prepare Gemini skills directory ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(skillsDir, { withFileTypes: true });
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to read AgentCompany skills from ${skillsDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const source = path.join(skillsDir, entry.name);
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) continue;
|
||||
|
||||
try {
|
||||
await fs.symlink(source, target);
|
||||
await onLog("stderr", `[agent-company] Linked Gemini skill: ${entry.name}\n`);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to link Gemini skill "${entry.name}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
import {
|
||||
resolveGeminiBillingType,
|
||||
renderEnvNote,
|
||||
renderApiAccessNote,
|
||||
ensureGeminiSkillsInjected,
|
||||
} from "./execute-helpers.js";
|
||||
|
||||
export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
||||
const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx;
|
||||
@@ -275,25 +162,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
);
|
||||
}
|
||||
}
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = [useStdin
|
||||
? "Prompt passed via stdin (exceeds command-line length limit)."
|
||||
: "Prompt is passed to Gemini as the final positional argument."];
|
||||
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
|
||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
@@ -313,6 +181,24 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
// and pass the content via stdin to avoid ENAMETOOLONG.
|
||||
const SAFE_ARG_LIMIT = 30_000;
|
||||
const useStdin = prompt.length > SAFE_ARG_LIMIT;
|
||||
const commandNotes = (() => {
|
||||
const notes: string[] = [useStdin
|
||||
? "Prompt passed via stdin (exceeds command-line length limit)."
|
||||
: "Prompt is passed to Gemini as the final positional argument."];
|
||||
notes.push("Added --approval-mode yolo for unattended execution.");
|
||||
if (!instructionsFilePath) return notes;
|
||||
if (instructionsPrefix.length > 0) {
|
||||
notes.push(
|
||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
||||
`Prepended instructions + path directive to prompt (relative references from ${instructionsDir}).`,
|
||||
);
|
||||
return notes;
|
||||
}
|
||||
notes.push(
|
||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
||||
);
|
||||
return notes;
|
||||
})();
|
||||
let promptFilePath: string | null = null;
|
||||
if (useStdin) {
|
||||
promptFilePath = path.join(os.tmpdir(), `agentcompany-gemini-prompt-${runId}.txt`);
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
|
||||
export const MAX_EXCERPT_BYTES = 32 * 1024;
|
||||
|
||||
const SENSITIVE_ENV_KEY = /(key|token|secret|password|passwd|authorization|cookie)/i;
|
||||
|
||||
export function parseObject(value: unknown): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return {};
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function asString(value: unknown, fallback: string): string {
|
||||
return typeof value === "string" && value.length > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown, fallback: number): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||
}
|
||||
|
||||
export function asBoolean(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
|
||||
export function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
}
|
||||
|
||||
export function parseJson(value: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(value) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function appendWithCap(prev: string, chunk: string, cap = MAX_CAPTURE_BYTES) {
|
||||
const combined = prev + chunk;
|
||||
return combined.length > cap ? combined.slice(combined.length - cap) : combined;
|
||||
}
|
||||
|
||||
export function resolvePathValue(obj: Record<string, unknown>, dottedPath: string) {
|
||||
const parts = dottedPath.split(".");
|
||||
let cursor: unknown = obj;
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) {
|
||||
return "";
|
||||
}
|
||||
cursor = (cursor as Record<string, unknown>)[part];
|
||||
}
|
||||
|
||||
if (cursor === null || cursor === undefined) return "";
|
||||
if (typeof cursor === "string") return cursor;
|
||||
if (typeof cursor === "number" || typeof cursor === "boolean") return String(cursor);
|
||||
|
||||
try {
|
||||
return JSON.stringify(cursor);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function renderTemplate(template: string, data: Record<string, unknown>) {
|
||||
return template.replace(/{{\s*([a-zA-Z0-9_.-]+)\s*}}/g, (_, path) => resolvePathValue(data, path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove flags from `extraArgs` that the adapter will already add itself.
|
||||
* Handles both `--flag value` pairs and `--flag=value` forms.
|
||||
* This prevents duplicate/conflicting CLI arguments when users have shell
|
||||
* aliases or wrapper functions that inject the same flags.
|
||||
*/
|
||||
export function filterConflictingArgs(extraArgs: string[], adapterFlags: string[]): string[] {
|
||||
const flagSet = new Set(adapterFlags.map((f) => f.replace(/=.*$/, "")));
|
||||
const filtered: string[] = [];
|
||||
for (let i = 0; i < extraArgs.length; i++) {
|
||||
const arg = extraArgs[i];
|
||||
const bareFlag = arg.replace(/=.*$/, "");
|
||||
if (flagSet.has(bareFlag)) {
|
||||
// Skip this flag and its value if the value is a separate argument
|
||||
if (!arg.includes("=") && i + 1 < extraArgs.length && !extraArgs[i + 1].startsWith("-")) {
|
||||
i++; // skip the value argument too
|
||||
}
|
||||
continue;
|
||||
}
|
||||
filtered.push(arg);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function redactEnvForLogs(env: Record<string, string>): Record<string, string> {
|
||||
const redacted: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
redacted[key] = SENSITIVE_ENV_KEY.test(key) ? "***REDACTED***" : value;
|
||||
}
|
||||
return redacted;
|
||||
}
|
||||
|
||||
export function buildAgentCompanyEnv(agent: { id: string; companyId: string }): Record<string, string> {
|
||||
const resolveHostForUrl = (rawHost: string): string => {
|
||||
const host = rawHost.trim();
|
||||
if (!host || host === "0.0.0.0" || host === "::") return "localhost";
|
||||
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) return `[${host}]`;
|
||||
return host;
|
||||
};
|
||||
const vars: Record<string, string> = {
|
||||
AGENT_COMPANY_AGENT_ID: agent.id,
|
||||
AGENT_COMPANY_COMPANY_ID: agent.companyId,
|
||||
};
|
||||
const runtimeHost = resolveHostForUrl(
|
||||
process.env.AGENT_COMPANY_LISTEN_HOST ?? process.env.HOST ?? "localhost",
|
||||
);
|
||||
const runtimePort = process.env.AGENT_COMPANY_LISTEN_PORT ?? process.env.PORT ?? "3100";
|
||||
const apiUrl = process.env.AGENT_COMPANY_API_URL ?? `http://${runtimeHost}:${runtimePort}`;
|
||||
vars.AGENT_COMPANY_API_URL = apiUrl;
|
||||
return vars;
|
||||
}
|
||||
|
||||
export function defaultPathForPlatform() {
|
||||
if (process.platform === "win32") {
|
||||
const home = process.env.USERPROFILE ?? process.env.HOME ?? "";
|
||||
const appData = process.env.APPDATA ?? (home ? `${home}\\AppData\\Roaming` : "");
|
||||
const localAppData = process.env.LOCALAPPDATA ?? (home ? `${home}\\AppData\\Local` : "");
|
||||
const parts = [
|
||||
"C:\\Windows\\System32",
|
||||
"C:\\Windows",
|
||||
"C:\\Windows\\System32\\Wbem",
|
||||
appData ? `${appData}\\npm` : "",
|
||||
localAppData ? `${localAppData}\\pnpm` : "",
|
||||
home ? `${home}\\.local\\bin` : "",
|
||||
home ? `${home}\\.cargo\\bin` : "",
|
||||
"C:\\Program Files\\nodejs",
|
||||
].filter(Boolean);
|
||||
return parts.join(";");
|
||||
}
|
||||
return "/usr/local/bin:/opt/homebrew/bin:/usr/local/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
import { spawn, execSync, type ChildProcess } from "node:child_process";
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import { appendWithCap, defaultPathForPlatform } from "./server-utils-parsers.js";
|
||||
|
||||
/**
|
||||
* Decode a Buffer as GBK (CP936). Used as a fallback when UTF-8 decoding
|
||||
* produces replacement characters on Chinese Windows systems where the
|
||||
* system ANSI code page is GBK and chcp 65001 is ineffective for piped I/O.
|
||||
*/
|
||||
function decodeGbkBuffer(buf: Buffer): string {
|
||||
try {
|
||||
return new TextDecoder("gbk").decode(buf);
|
||||
} catch {
|
||||
return buf.toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunProcessResult {
|
||||
exitCode: number | null;
|
||||
signal: string | null;
|
||||
timedOut: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
interface RunningProcess {
|
||||
child: ChildProcess;
|
||||
graceSec: number;
|
||||
}
|
||||
|
||||
interface SpawnTarget {
|
||||
command: string;
|
||||
args: string[];
|
||||
}
|
||||
|
||||
type ChildProcessWithEvents = ChildProcess & {
|
||||
on(event: "error", listener: (err: Error) => void): ChildProcess;
|
||||
on(
|
||||
event: "close",
|
||||
listener: (code: number | null, signal: NodeJS.Signals | null) => void,
|
||||
): ChildProcess;
|
||||
};
|
||||
|
||||
export const runningProcesses = new Map<string, RunningProcess>();
|
||||
|
||||
export function normalizeCommandValue(command: string): string {
|
||||
let normalized = command.trim();
|
||||
while (normalized.length >= 2) {
|
||||
const quote = normalized[0];
|
||||
if ((quote !== '"' && quote !== "'") || normalized.at(-1) !== quote) {
|
||||
break;
|
||||
}
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function windowsPathExts(env: NodeJS.ProcessEnv): string[] {
|
||||
return (env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean);
|
||||
}
|
||||
|
||||
async function pathExists(candidate: string) {
|
||||
try {
|
||||
await fs.access(candidate, process.platform === "win32" ? fsConstants.F_OK : fsConstants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveCommandPath(command: string, cwd: string, env: NodeJS.ProcessEnv): Promise<string | null> {
|
||||
const normalizedCommand = normalizeCommandValue(command);
|
||||
const hasPathSeparator = normalizedCommand.includes("/") || normalizedCommand.includes("\\");
|
||||
if (hasPathSeparator) {
|
||||
const absolute = path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd, normalizedCommand);
|
||||
return (await pathExists(absolute)) ? absolute : null;
|
||||
}
|
||||
|
||||
const pathValue = env.PATH ?? env.Path ?? "";
|
||||
const delimiter = process.platform === "win32" ? ";" : ":";
|
||||
const dirs = pathValue.split(delimiter).filter(Boolean);
|
||||
const exts = process.platform === "win32" ? windowsPathExts(env) : [""];
|
||||
const hasExtension = process.platform === "win32" && path.extname(command).length > 0;
|
||||
|
||||
for (const dir of dirs) {
|
||||
const candidates =
|
||||
process.platform === "win32"
|
||||
? hasExtension
|
||||
? [path.join(dir, normalizedCommand)]
|
||||
: exts.map((ext) => path.join(dir, `${normalizedCommand}${ext}`))
|
||||
: [path.join(dir, normalizedCommand)];
|
||||
for (const candidate of candidates) {
|
||||
if (await pathExists(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function quoteForCmd(arg: string) {
|
||||
if (!arg.length) return '""';
|
||||
const escaped = arg.replace(/"/g, '""');
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
async function resolveSpawnTarget(
|
||||
command: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): Promise<SpawnTarget> {
|
||||
const normalizedCommand = normalizeCommandValue(command);
|
||||
const resolved = await resolveCommandPath(normalizedCommand, cwd, env);
|
||||
const executable = resolved ?? normalizedCommand;
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return { command: executable, args };
|
||||
}
|
||||
|
||||
// On Windows, ALL spawned processes need UTF-8 code page (65001) to prevent
|
||||
// Chinese/CJK character corruption. The default Windows code page (e.g. 936/GBK)
|
||||
// corrupts multi-byte UTF-8 characters passed through cmd.exe, curl, etc.
|
||||
const shell = env.ComSpec || process.env.ComSpec || "cmd.exe";
|
||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
||||
return {
|
||||
command: shell,
|
||||
args: ["/d", "/s", "/c", `chcp 65001 >nul && ${commandLine}`],
|
||||
};
|
||||
}
|
||||
|
||||
export function ensurePathInEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
if (typeof env.PATH === "string" && env.PATH.length > 0) return env;
|
||||
if (typeof env.Path === "string" && env.Path.length > 0) return env;
|
||||
return { ...env, PATH: defaultPathForPlatform() };
|
||||
}
|
||||
|
||||
export async function ensureAbsoluteDirectory(
|
||||
cwd: string,
|
||||
opts: { createIfMissing?: boolean } = {},
|
||||
) {
|
||||
if (!path.isAbsolute(cwd)) {
|
||||
throw new Error(`Working directory must be an absolute path: "${cwd}"`);
|
||||
}
|
||||
|
||||
const assertDirectory = async () => {
|
||||
const stats = await fs.stat(cwd);
|
||||
if (!stats.isDirectory()) {
|
||||
throw new Error(`Working directory is not a directory: "${cwd}"`);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await assertDirectory();
|
||||
return;
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (!opts.createIfMissing || code !== "ENOENT") {
|
||||
if (code === "ENOENT") {
|
||||
throw new Error(`Working directory does not exist: "${cwd}"`);
|
||||
}
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(cwd, { recursive: true });
|
||||
await assertDirectory();
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`Could not create working directory "${cwd}": ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureCommandResolvable(command: string, cwd: string, env: NodeJS.ProcessEnv) {
|
||||
const normalizedCommand = normalizeCommandValue(command);
|
||||
const resolved = await resolveCommandPath(normalizedCommand, cwd, env);
|
||||
if (resolved) return;
|
||||
if (normalizedCommand.includes("/") || normalizedCommand.includes("\\")) {
|
||||
const absolute = path.isAbsolute(normalizedCommand) ? normalizedCommand : path.resolve(cwd, normalizedCommand);
|
||||
throw new Error(`Command is not executable: "${command}" (resolved: "${absolute}")`);
|
||||
}
|
||||
throw new Error(`Command not found in PATH: "${command}"`);
|
||||
}
|
||||
|
||||
export async function runChildProcess(
|
||||
runId: string,
|
||||
command: string,
|
||||
args: string[],
|
||||
opts: {
|
||||
cwd: string;
|
||||
env: Record<string, string>;
|
||||
timeoutSec: number;
|
||||
graceSec: number;
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
onLogError?: (err: unknown, runId: string, message: string) => void;
|
||||
stdin?: string;
|
||||
},
|
||||
): Promise<RunProcessResult> {
|
||||
const onLogError = opts.onLogError ?? ((err, id, msg) => console.warn({ err, runId: id }, msg));
|
||||
|
||||
return new Promise<RunProcessResult>((resolve, reject) => {
|
||||
const rawMerged: NodeJS.ProcessEnv = { ...opts.env };
|
||||
|
||||
// Strip Claude Code nesting-guard env vars so spawned `claude` processes
|
||||
// don't refuse to start with "cannot be launched inside another session".
|
||||
// These vars leak in when the AgentCompany server itself is started from
|
||||
// within a Claude Code session (e.g. `npx agentcompany run` in a terminal
|
||||
// owned by Claude Code) or when cron inherits a contaminated shell env.
|
||||
const CLAUDE_CODE_NESTING_VARS = [
|
||||
"CLAUDECODE",
|
||||
"CLAUDE_CODE_ENTRYPOINT",
|
||||
"CLAUDE_CODE_SESSION",
|
||||
"CLAUDE_CODE_PARENT_SESSION",
|
||||
] as const;
|
||||
for (const key of CLAUDE_CODE_NESTING_VARS) {
|
||||
delete rawMerged[key];
|
||||
}
|
||||
|
||||
const mergedEnv = ensurePathInEnv(rawMerged);
|
||||
// On Windows, force UTF-8 encoding in child processes to prevent Chinese/CJK
|
||||
// character corruption caused by the default system code page (e.g. 936/GBK).
|
||||
if (process.platform === "win32") {
|
||||
mergedEnv.PYTHONUTF8 = "1";
|
||||
mergedEnv.PYTHONIOENCODING = "utf-8";
|
||||
if (!mergedEnv.LANG) mergedEnv.LANG = "en_US.UTF-8";
|
||||
}
|
||||
|
||||
void resolveSpawnTarget(command, args, opts.cwd, mergedEnv)
|
||||
.then((target) => {
|
||||
const child = spawn(target.command, target.args, {
|
||||
cwd: opts.cwd,
|
||||
env: mergedEnv,
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: [opts.stdin != null ? "pipe" : "ignore", "pipe", "pipe"],
|
||||
}) as ChildProcessWithEvents;
|
||||
|
||||
if (opts.stdin != null && child.stdin) {
|
||||
child.stdin.write(opts.stdin);
|
||||
child.stdin.end();
|
||||
}
|
||||
|
||||
runningProcesses.set(runId, { child, graceSec: opts.graceSec });
|
||||
|
||||
let timedOut = false;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let logChain: Promise<void> = Promise.resolve();
|
||||
|
||||
// Use StringDecoder to handle multi-byte characters split across chunks.
|
||||
// On Windows, child process stdout may arrive in GBK (CP936) instead
|
||||
// of UTF-8 when chcp 65001 is ineffective (piped stdout has no console).
|
||||
// We detect this per-stream and fall back to GBK decoding if needed.
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let stdoutIsGbk = false;
|
||||
let stderrIsGbk = false;
|
||||
|
||||
const timeout =
|
||||
opts.timeoutSec > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, Math.max(1, opts.graceSec) * 1000);
|
||||
}, opts.timeoutSec * 1000)
|
||||
: null;
|
||||
|
||||
child.stdout?.on("data", (chunk: unknown) => {
|
||||
let text: string;
|
||||
if (chunk instanceof Buffer) {
|
||||
if (stdoutIsGbk) {
|
||||
text = decodeGbkBuffer(chunk);
|
||||
} else {
|
||||
text = stdoutDecoder.write(chunk);
|
||||
if (text.includes("\ufffd") && chunk.length > 0) {
|
||||
stdoutIsGbk = true;
|
||||
text = decodeGbkBuffer(chunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text = String(chunk);
|
||||
}
|
||||
stdout = appendWithCap(stdout, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stdout", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stdout log chunk"));
|
||||
});
|
||||
|
||||
child.stderr?.on("data", (chunk: unknown) => {
|
||||
let text: string;
|
||||
if (chunk instanceof Buffer) {
|
||||
if (stderrIsGbk) {
|
||||
text = decodeGbkBuffer(chunk);
|
||||
} else {
|
||||
text = stderrDecoder.write(chunk);
|
||||
if (text.includes("\ufffd") && chunk.length > 0) {
|
||||
stderrIsGbk = true;
|
||||
text = decodeGbkBuffer(chunk);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
text = String(chunk);
|
||||
}
|
||||
stderr = appendWithCap(stderr, text);
|
||||
logChain = logChain
|
||||
.then(() => opts.onLog("stderr", text))
|
||||
.catch((err) => onLogError(err, runId, "failed to append stderr log chunk"));
|
||||
});
|
||||
|
||||
child.on("error", (err: Error) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
const errno = (err as NodeJS.ErrnoException).code;
|
||||
const pathValue = mergedEnv.PATH ?? mergedEnv.Path ?? "";
|
||||
const msg =
|
||||
errno === "ENOENT"
|
||||
? `Failed to start command "${command}" in "${opts.cwd}". Verify adapter command, working directory, and PATH (${pathValue}).`
|
||||
: `Failed to start command "${command}" in "${opts.cwd}": ${err.message}`;
|
||||
reject(new Error(msg));
|
||||
});
|
||||
|
||||
child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
runningProcesses.delete(runId);
|
||||
// Flush any remaining partial multi-byte characters from the decoders.
|
||||
const stdoutTail = stdoutDecoder.end();
|
||||
const stderrTail = stderrDecoder.end();
|
||||
if (stdoutTail) stdout = appendWithCap(stdout, stdoutTail);
|
||||
if (stderrTail) stderr = appendWithCap(stderr, stderrTail);
|
||||
void logChain.finally(() => {
|
||||
resolve({
|
||||
exitCode: code,
|
||||
signal,
|
||||
timedOut,
|
||||
stdout,
|
||||
stderr,
|
||||
});
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill a process and all its descendants.
|
||||
* Windows: `taskkill /T /F` kills the full process tree.
|
||||
* Unix: SIGKILL the process directly (CLI tools are leaf processes).
|
||||
*/
|
||||
function killProcessTree(pid: number): void {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
execSync(`taskkill /T /F /PID ${pid}`, { stdio: "ignore", timeout: 5000 });
|
||||
} catch { /* process already exited */ }
|
||||
} else {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch { /* already dead */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-kill every child process tracked in `runningProcesses` and clear the map.
|
||||
* Called by the worker during shutdown to ensure no CLI processes survive app quit.
|
||||
*/
|
||||
export function shutdownAllRunning(): void {
|
||||
for (const [runId, entry] of runningProcesses) {
|
||||
const pid = entry.child.pid;
|
||||
if (pid != null) {
|
||||
killProcessTree(pid);
|
||||
}
|
||||
runningProcesses.delete(runId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { AdapterSkillEntry, AdapterSkillSnapshot } from "./types.js";
|
||||
import type { AgentCompanySkillEntry, InstalledSkillTarget } from "./server-utils-skills.js";
|
||||
|
||||
interface PersistentSkillSnapshotOptions {
|
||||
adapterType: string;
|
||||
availableEntries: AgentCompanySkillEntry[];
|
||||
desiredSkills: string[];
|
||||
installed: Map<string, InstalledSkillTarget>;
|
||||
skillsHome: string;
|
||||
locationLabel?: string | null;
|
||||
installedDetail?: string | null;
|
||||
missingDetail: string;
|
||||
externalConflictDetail: string;
|
||||
externalDetail: string;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
function normalizePathSlashes(value: string): string {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function isMaintainerOnlySkillTarget(candidate: string): boolean {
|
||||
return normalizePathSlashes(candidate).includes("/.agents/skills/");
|
||||
}
|
||||
|
||||
function skillLocationLabel(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function buildManagedSkillOrigin(entry: { required?: boolean }): Pick<
|
||||
AdapterSkillEntry,
|
||||
"origin" | "originLabel" | "readOnly"
|
||||
> {
|
||||
if (entry.required) {
|
||||
return {
|
||||
origin: "paperclip_required",
|
||||
originLabel: "Required by AgentCompany",
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
origin: "company_managed",
|
||||
originLabel: "Managed by AgentCompany",
|
||||
readOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizeDesiredAgentCompanySkillReference(
|
||||
reference: string,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
|
||||
): string {
|
||||
const normalizedReference = reference.trim().toLowerCase();
|
||||
if (!normalizedReference) return "";
|
||||
|
||||
const exactKey = availableEntries.find((entry) => entry.key.trim().toLowerCase() === normalizedReference);
|
||||
if (exactKey) return exactKey.key;
|
||||
|
||||
const byRuntimeName = availableEntries.filter((entry) =>
|
||||
typeof entry.runtimeName === "string" && entry.runtimeName.trim().toLowerCase() === normalizedReference,
|
||||
);
|
||||
if (byRuntimeName.length === 1) return byRuntimeName[0]!.key;
|
||||
|
||||
const slugMatches = availableEntries.filter((entry) =>
|
||||
entry.key.trim().toLowerCase().split("/").pop() === normalizedReference,
|
||||
);
|
||||
if (slugMatches.length === 1) return slugMatches[0]!.key;
|
||||
|
||||
return normalizedReference;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an AdapterSkillSnapshot for persistent-mode adapters (Gemini, Codex).
|
||||
* Compares available entries against installed state and desired skill list.
|
||||
*/
|
||||
export function buildPersistentSkillSnapshot(
|
||||
options: PersistentSkillSnapshotOptions,
|
||||
): AdapterSkillSnapshot {
|
||||
const {
|
||||
adapterType,
|
||||
availableEntries,
|
||||
desiredSkills,
|
||||
installed,
|
||||
skillsHome,
|
||||
locationLabel,
|
||||
installedDetail,
|
||||
missingDetail,
|
||||
externalConflictDetail,
|
||||
externalDetail,
|
||||
} = options;
|
||||
const availableByKey = new Map(availableEntries.map((entry) => [entry.key, entry]));
|
||||
const desiredSet = new Set(desiredSkills);
|
||||
const entries: AdapterSkillEntry[] = [];
|
||||
const warnings = [...(options.warnings ?? [])];
|
||||
|
||||
for (const available of availableEntries) {
|
||||
const installedEntry = installed.get(available.runtimeName) ?? null;
|
||||
const desired = desiredSet.has(available.key);
|
||||
let state: AdapterSkillEntry["state"] = "available";
|
||||
let managed = false;
|
||||
let detail: string | null = null;
|
||||
|
||||
if (installedEntry?.targetPath === available.source) {
|
||||
managed = true;
|
||||
state = desired ? "installed" : "stale";
|
||||
detail = installedDetail ?? null;
|
||||
} else if (installedEntry) {
|
||||
state = "external";
|
||||
detail = desired ? externalConflictDetail : externalDetail;
|
||||
} else if (desired) {
|
||||
state = "missing";
|
||||
detail = missingDetail;
|
||||
}
|
||||
|
||||
entries.push({
|
||||
key: available.key,
|
||||
runtimeName: available.runtimeName,
|
||||
desired,
|
||||
managed,
|
||||
state,
|
||||
sourcePath: available.source,
|
||||
targetPath: path.join(skillsHome, available.runtimeName),
|
||||
detail,
|
||||
required: Boolean(available.required),
|
||||
requiredReason: available.requiredReason ?? null,
|
||||
...buildManagedSkillOrigin(available),
|
||||
});
|
||||
}
|
||||
|
||||
for (const desiredSkill of desiredSkills) {
|
||||
if (availableByKey.has(desiredSkill)) continue;
|
||||
warnings.push(`Desired skill "${desiredSkill}" is not available from the AgentCompany skills directory.`);
|
||||
entries.push({
|
||||
key: desiredSkill,
|
||||
runtimeName: null,
|
||||
desired: true,
|
||||
managed: true,
|
||||
state: "missing",
|
||||
sourcePath: null,
|
||||
targetPath: null,
|
||||
detail: "AgentCompany cannot find this skill in the local runtime skills directory.",
|
||||
origin: "external_unknown",
|
||||
originLabel: "External or unavailable",
|
||||
readOnly: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, installedEntry] of installed.entries()) {
|
||||
if (availableEntries.some((entry) => entry.runtimeName === name)) continue;
|
||||
entries.push({
|
||||
key: name,
|
||||
runtimeName: name,
|
||||
desired: false,
|
||||
managed: false,
|
||||
state: "external",
|
||||
origin: "user_installed",
|
||||
originLabel: "User-installed",
|
||||
locationLabel: skillLocationLabel(locationLabel),
|
||||
readOnly: true,
|
||||
sourcePath: null,
|
||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
||||
detail: externalDetail,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
||||
|
||||
return {
|
||||
adapterType,
|
||||
supported: true,
|
||||
mode: "persistent",
|
||||
desiredSkills,
|
||||
entries,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the desired skill sync preference from adapter config.
|
||||
*/
|
||||
export function readAgentCompanySkillSyncPreference(config: Record<string, unknown>): {
|
||||
explicit: boolean;
|
||||
desiredSkills: string[];
|
||||
} {
|
||||
const raw = config.agentCompanySkillSync;
|
||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
||||
return { explicit: false, desiredSkills: [] };
|
||||
}
|
||||
const syncConfig = raw as Record<string, unknown>;
|
||||
const desiredValues = syncConfig.desiredSkills;
|
||||
const desired = Array.isArray(desiredValues)
|
||||
? desiredValues
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
return {
|
||||
explicit: Object.prototype.hasOwnProperty.call(raw, "desiredSkills"),
|
||||
desiredSkills: Array.from(new Set(desired)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the canonical desired skill list from config and available entries.
|
||||
* Required skills are always included; explicit preferences are canonicalized.
|
||||
*/
|
||||
export function resolveAgentCompanyDesiredSkillNames(
|
||||
config: Record<string, unknown>,
|
||||
availableEntries: Array<{ key: string; runtimeName?: string | null; required?: boolean }>,
|
||||
): string[] {
|
||||
const preference = readAgentCompanySkillSyncPreference(config);
|
||||
const requiredSkills = availableEntries
|
||||
.filter((entry) => entry.required)
|
||||
.map((entry) => entry.key);
|
||||
if (!preference.explicit) {
|
||||
return Array.from(new Set(requiredSkills));
|
||||
}
|
||||
const desiredSkills = preference.desiredSkills
|
||||
.map((reference) => canonicalizeDesiredAgentCompanySkillReference(reference, availableEntries))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set([...requiredSkills, ...desiredSkills]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write desired skills back to adapter config.
|
||||
*/
|
||||
export function writeAgentCompanySkillSyncPreference(
|
||||
config: Record<string, unknown>,
|
||||
desiredSkills: string[],
|
||||
): Record<string, unknown> {
|
||||
const next = { ...config };
|
||||
const raw = next.agentCompanySkillSync;
|
||||
const current =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? { ...(raw as Record<string, unknown>) }
|
||||
: {};
|
||||
current.desiredSkills = Array.from(
|
||||
new Set(
|
||||
desiredSkills
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
next.agentCompanySkillSync = current;
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or repair a symlink from source to target skill directory.
|
||||
* Returns the action taken: "created", "repaired", or "skipped".
|
||||
*/
|
||||
export async function ensureAgentCompanySkillSymlink(
|
||||
source: string,
|
||||
target: string,
|
||||
linkSkill: (source: string, target: string) => Promise<void> = (linkSource, linkTarget) =>
|
||||
fs.symlink(linkSource, linkTarget),
|
||||
): Promise<"created" | "repaired" | "skipped"> {
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing) {
|
||||
await linkSkill(source, target);
|
||||
return "created";
|
||||
}
|
||||
|
||||
if (!existing.isSymbolicLink()) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) return "skipped";
|
||||
|
||||
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
||||
if (resolvedLinkedPath === source) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
const linkedPathExists = await fs.stat(resolvedLinkedPath).then(() => true).catch(() => false);
|
||||
if (linkedPathExists) {
|
||||
return "skipped";
|
||||
}
|
||||
|
||||
await fs.unlink(target);
|
||||
await linkSkill(source, target);
|
||||
return "repaired";
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove symlinks in skillsHome that point to maintainer-only paths
|
||||
* (.agents/skills/) and are not in the allowed set.
|
||||
*/
|
||||
export async function removeMaintainerOnlySkillSymlinks(
|
||||
skillsHome: string,
|
||||
allowedSkillNames: Iterable<string>,
|
||||
): Promise<string[]> {
|
||||
const allowed = new Set(Array.from(allowedSkillNames));
|
||||
try {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true });
|
||||
const removed: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (allowed.has(entry.name)) continue;
|
||||
|
||||
const target = path.join(skillsHome, entry.name);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (!existing?.isSymbolicLink()) continue;
|
||||
|
||||
const linkedPath = await fs.readlink(target).catch(() => null);
|
||||
if (!linkedPath) continue;
|
||||
|
||||
const resolvedLinkedPath = path.isAbsolute(linkedPath)
|
||||
? linkedPath
|
||||
: path.resolve(path.dirname(target), linkedPath);
|
||||
if (
|
||||
!isMaintainerOnlySkillTarget(linkedPath) &&
|
||||
!isMaintainerOnlySkillTarget(resolvedLinkedPath)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await fs.unlink(target);
|
||||
removed.push(entry.name);
|
||||
}
|
||||
|
||||
return removed;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { promises as fs, type Dirent } from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ManagedSkillRef } from "./types.js";
|
||||
import { parseObject, asString, asBoolean } from "./server-utils-parsers.js";
|
||||
|
||||
/**
|
||||
* Create symlinks for managed skills in the target directory.
|
||||
* Per D-13: uses 'junction' type on Windows to avoid Developer Mode requirement.
|
||||
* Individual skill symlink failures are logged and reported but do not block other skills.
|
||||
*/
|
||||
export async function createManagedSkillSymlinks(
|
||||
managedSkills: ManagedSkillRef[],
|
||||
targetDir: string,
|
||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>,
|
||||
): Promise<void> {
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined;
|
||||
for (const skill of managedSkills) {
|
||||
const target = path.join(targetDir, skill.runtimeName);
|
||||
const existing = await fs.lstat(target).catch(() => null);
|
||||
if (existing) {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}
|
||||
try {
|
||||
await fs.symlink(skill.sourcePath, target, symlinkType);
|
||||
await onLog("stderr", `[agent-company] Injected managed skill "${skill.runtimeName}"\n`);
|
||||
} catch (err) {
|
||||
await onLog(
|
||||
"stderr",
|
||||
`[agent-company] Failed to inject skill "${skill.runtimeName}": ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill resolution helpers -- ported from Paperclip reference with
|
||||
// AgentCompany naming. These utilities let each adapter discover, list,
|
||||
// resolve, and synchronize skills from a shared skills directory.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES = [
|
||||
"../../skills",
|
||||
"../../../../../skills",
|
||||
];
|
||||
|
||||
export interface AgentCompanySkillEntry {
|
||||
key: string;
|
||||
runtimeName: string;
|
||||
source: string;
|
||||
required?: boolean;
|
||||
requiredReason?: string | null;
|
||||
}
|
||||
|
||||
export interface InstalledSkillTarget {
|
||||
targetPath: string | null;
|
||||
kind: "symlink" | "directory" | "file";
|
||||
}
|
||||
|
||||
export function resolveInstalledEntryTarget(
|
||||
skillsHome: string,
|
||||
entryName: string,
|
||||
dirent: Dirent,
|
||||
linkedPath: string | null,
|
||||
): InstalledSkillTarget {
|
||||
const fullPath = path.join(skillsHome, entryName);
|
||||
if (dirent.isSymbolicLink()) {
|
||||
return {
|
||||
targetPath: linkedPath ? path.resolve(path.dirname(fullPath), linkedPath) : null,
|
||||
kind: "symlink",
|
||||
};
|
||||
}
|
||||
if (dirent.isDirectory()) {
|
||||
return { targetPath: fullPath, kind: "directory" };
|
||||
}
|
||||
return { targetPath: fullPath, kind: "file" };
|
||||
}
|
||||
|
||||
export function normalizeConfiguredAgentCompanyRuntimeSkills(value: unknown): AgentCompanySkillEntry[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const out: AgentCompanySkillEntry[] = [];
|
||||
for (const rawEntry of value) {
|
||||
const entry = parseObject(rawEntry);
|
||||
const key = asString(entry.key, asString(entry.name, "")).trim();
|
||||
const runtimeName = asString(entry.runtimeName, asString(entry.name, "")).trim();
|
||||
const source = asString(entry.source, "").trim();
|
||||
if (!key || !runtimeName || !source) continue;
|
||||
out.push({
|
||||
key,
|
||||
runtimeName,
|
||||
source,
|
||||
required: asBoolean(entry.required, false),
|
||||
requiredReason:
|
||||
typeof entry.requiredReason === "string" && entry.requiredReason.trim().length > 0
|
||||
? entry.requiredReason.trim()
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the AgentCompany skills root directory from relative path candidates.
|
||||
*/
|
||||
export async function resolveAgentCompanySkillsDir(
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<string | null> {
|
||||
const candidates = [
|
||||
...AGENTCOMPANY_SKILL_ROOT_RELATIVE_CANDIDATES.map((relativePath) => path.resolve(moduleDir, relativePath)),
|
||||
...additionalCandidates.map((candidate) => path.resolve(candidate)),
|
||||
];
|
||||
const seenRoots = new Set<string>();
|
||||
|
||||
for (const root of candidates) {
|
||||
if (seenRoots.has(root)) continue;
|
||||
seenRoots.add(root);
|
||||
const isDirectory = await fs.stat(root).then((stats) => stats.isDirectory()).catch(() => false);
|
||||
if (isDirectory) return root;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List skill entries from the AgentCompany skills directory.
|
||||
*/
|
||||
export async function listAgentCompanySkillEntries(
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<AgentCompanySkillEntry[]> {
|
||||
const root = await resolveAgentCompanySkillsDir(moduleDir, additionalCandidates);
|
||||
if (!root) return [];
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => ({
|
||||
key: `agentcompany/agentcompany/${entry.name}`,
|
||||
runtimeName: entry.name,
|
||||
source: path.join(root, entry.name),
|
||||
required: true,
|
||||
requiredReason: "Bundled AgentCompany skills are always available for local adapters.",
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read installed skill targets from a skill home directory (symlinks, dirs, files).
|
||||
*/
|
||||
export async function readInstalledSkillTargets(skillsHome: string): Promise<Map<string, InstalledSkillTarget>> {
|
||||
const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []);
|
||||
const out = new Map<string, InstalledSkillTarget>();
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(skillsHome, entry.name);
|
||||
const linkedPath = entry.isSymbolicLink() ? await fs.readlink(fullPath).catch(() => null) : null;
|
||||
out.set(entry.name, resolveInstalledEntryTarget(skillsHome, entry.name, entry, linkedPath));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read runtime skill entries from adapter config or discover from module directory.
|
||||
*/
|
||||
export async function readAgentCompanyRuntimeSkillEntries(
|
||||
config: Record<string, unknown>,
|
||||
moduleDir: string,
|
||||
additionalCandidates: string[] = [],
|
||||
): Promise<AgentCompanySkillEntry[]> {
|
||||
const configuredEntries = normalizeConfiguredAgentCompanyRuntimeSkills(config.agentCompanyRuntimeSkills);
|
||||
if (configuredEntries.length > 0) return configuredEntries;
|
||||
return listAgentCompanySkillEntries(moduleDir, additionalCandidates);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
import path from "node:path";
|
||||
import type { AdapterEnvironmentCheck, AdapterEnvironmentTestResult } from "./types.js";
|
||||
import { normalizeCommandValue } from "./server-utils-process.js";
|
||||
|
||||
export function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||
if (checks.some((check) => check.level === "error")) return "fail";
|
||||
@@ -21,7 +22,8 @@ export function firstNonEmptyLine(text: string): string {
|
||||
}
|
||||
|
||||
export function commandLooksLike(command: string, expected: string): boolean {
|
||||
const base = path.basename(command).toLowerCase();
|
||||
const normalized = normalizeCommandValue(command).replace(/\\/g, "/");
|
||||
const base = path.posix.basename(normalized).toLowerCase();
|
||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user