chore(adapter): delete adapter packages and drop build wiring
Removes the four workspace adapter packages (claude-local, codex-local, gemini-local, adapter-utils) now that all runtime provider dispatch lives in @mariozechner/pi-ai + pi-agent-core + pi-coding-agent. Refreshes the lockfile, drops bundledMainDependencies from the Vite config, and adds pi-coding-agent as a direct dependency for the worker's coding tools.
This commit is contained in:
1 parent
4a120f4fc6
commit
60178ceafa
60 files changed
+2067
-6014
No files matched your search
@@ -3,16 +3,9 @@ import react from "@vitejs/plugin-react";
|
|||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||||
|
|
||||||
const bundledMainDependencies = [
|
|
||||||
"@agentcompany/adapter-claude-local",
|
|
||||||
"@agentcompany/adapter-codex-local",
|
|
||||||
"@agentcompany/adapter-gemini-local",
|
|
||||||
"@agentcompany/adapter-utils",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
plugins: [externalizeDepsPlugin({ exclude: bundledMainDependencies })],
|
plugins: [externalizeDepsPlugin()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@shared": resolve("src/shared"),
|
"@shared": resolve("src/shared"),
|
||||||
|
|||||||
+3
-4
@@ -34,10 +34,9 @@
|
|||||||
"postinstall": "node scripts/postinstall.mjs"
|
"postinstall": "node scripts/postinstall.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentcompany/adapter-claude-local": "workspace:*",
|
"@mariozechner/pi-agent-core": "^0.67.6",
|
||||||
"@agentcompany/adapter-codex-local": "workspace:*",
|
"@mariozechner/pi-ai": "^0.67.6",
|
||||||
"@agentcompany/adapter-gemini-local": "workspace:*",
|
"@mariozechner/pi-coding-agent": "^0.67.6",
|
||||||
"@agentcompany/adapter-utils": "workspace:*",
|
|
||||||
"@phosphor-icons/react": "2.1.10",
|
"@phosphor-icons/react": "2.1.10",
|
||||||
"@tanstack/react-virtual": "^3.13.23",
|
"@tanstack/react-virtual": "^3.13.23",
|
||||||
"electron-log": "5.4.3",
|
"electron-log": "5.4.3",
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@agentcompany/adapter-claude-local",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./server": "./src/server/index.ts",
|
|
||||||
"./ui": "./src/ui/index.ts",
|
|
||||||
"./cli": "./src/cli/index.ts"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./server": {
|
|
||||||
"types": "./dist/server/index.d.ts",
|
|
||||||
"import": "./dist/server/index.js"
|
|
||||||
},
|
|
||||||
"./ui": {
|
|
||||||
"types": "./dist/ui/index.d.ts",
|
|
||||||
"import": "./dist/ui/index.js"
|
|
||||||
},
|
|
||||||
"./cli": {
|
|
||||||
"types": "./dist/cli/index.d.ts",
|
|
||||||
"import": "./dist/cli/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"skills"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@agentcompany/adapter-utils": "workspace:*",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import pc from "picocolors";
|
|
||||||
import { errorText as asErrorText } from "@agentcompany/adapter-utils/parse-utils";
|
|
||||||
|
|
||||||
export function printClaudeStreamEvent(raw: string, debug: boolean): void {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
let parsed: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
console.log(line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
||||||
|
|
||||||
if (type === "system" && parsed.subtype === "init") {
|
|
||||||
const model = typeof parsed.model === "string" ? parsed.model : "unknown";
|
|
||||||
const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : "";
|
|
||||||
console.log(pc.blue(`Claude initialized (model: ${model}${sessionId ? `, session: ${sessionId}` : ""})`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
const message =
|
|
||||||
typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message)
|
|
||||||
? (parsed.message as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const blockRaw of content) {
|
|
||||||
if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue;
|
|
||||||
const block = blockRaw as Record<string, unknown>;
|
|
||||||
const blockType = typeof block.type === "string" ? block.type : "";
|
|
||||||
if (blockType === "text") {
|
|
||||||
const text = typeof block.text === "string" ? block.text : "";
|
|
||||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
|
||||||
} else if (blockType === "tool_use") {
|
|
||||||
const name = typeof block.name === "string" ? block.name : "unknown";
|
|
||||||
console.log(pc.yellow(`tool_call: ${name}`));
|
|
||||||
if (block.input !== undefined) {
|
|
||||||
console.log(pc.gray(JSON.stringify(block.input, null, 2)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
const usage =
|
|
||||||
typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage)
|
|
||||||
? (parsed.usage as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const input = Number(usage.input_tokens ?? 0);
|
|
||||||
const output = Number(usage.output_tokens ?? 0);
|
|
||||||
const cached = Number(usage.cache_read_input_tokens ?? 0);
|
|
||||||
const cost = Number(parsed.total_cost_usd ?? 0);
|
|
||||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
|
||||||
const isError = parsed.is_error === true;
|
|
||||||
const resultText = typeof parsed.result === "string" ? parsed.result : "";
|
|
||||||
if (resultText) {
|
|
||||||
console.log(pc.green("result:"));
|
|
||||||
console.log(resultText);
|
|
||||||
}
|
|
||||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(asErrorText).filter(Boolean) : [];
|
|
||||||
if (subtype.startsWith("error") || isError || errors.length > 0) {
|
|
||||||
console.log(pc.red(`claude_result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`));
|
|
||||||
if (errors.length > 0) {
|
|
||||||
console.log(pc.red(`claude_errors: ${errors.join(" | ")}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.log(
|
|
||||||
pc.blue(
|
|
||||||
`tokens: in=${Number.isFinite(input) ? input : 0} out=${Number.isFinite(output) ? output : 0} cached=${Number.isFinite(cached) ? cached : 0} cost=$${Number.isFinite(cost) ? cost.toFixed(6) : "0.000000"}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log(pc.gray(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { printClaudeStreamEvent } from "./format-event.js";
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
export const type = "claude_local";
|
|
||||||
export const label = "Claude Code (local)";
|
|
||||||
|
|
||||||
export const models = [
|
|
||||||
{ id: "claude-opus-4-6", label: "Claude Opus 4.6" },
|
|
||||||
{ id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
|
|
||||||
{ id: "claude-haiku-4-6", label: "Claude Haiku 4.6" },
|
|
||||||
{ id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
|
|
||||||
{ id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const agentConfigurationDoc = `# claude_local agent configuration
|
|
||||||
|
|
||||||
Adapter: claude_local
|
|
||||||
|
|
||||||
Core fields:
|
|
||||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
|
||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file injected at runtime
|
|
||||||
- model (string, optional): Claude model id
|
|
||||||
- effort (string, optional): reasoning effort passed via --effort (low|medium|high)
|
|
||||||
- chrome (boolean, optional): pass --chrome when running Claude
|
|
||||||
- promptTemplate (string, optional): run prompt template
|
|
||||||
- maxTurnsPerRun (number, optional): max turns for one run
|
|
||||||
- dangerouslySkipPermissions (boolean, optional): pass --dangerously-skip-permissions to claude
|
|
||||||
- command (string, optional): defaults to "claude"
|
|
||||||
- extraArgs (string[], optional): additional CLI args
|
|
||||||
- env (object, optional): KEY=VALUE environment variables
|
|
||||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
|
||||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Claude starts and exposed back via context/env
|
|
||||||
|
|
||||||
Operational fields:
|
|
||||||
- timeoutSec (number, optional): run timeout in seconds
|
|
||||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- When AgentCompany realizes a workspace/runtime for a run, it injects AGENT_COMPANY_WORKSPACE_* and AGENT_COMPANY_RUNTIME_* env vars for agent-side tooling.
|
|
||||||
`;
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
|
||||||
import type { RunProcessResult } from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import {
|
|
||||||
asString,
|
|
||||||
asNumber,
|
|
||||||
ensureAbsoluteDirectory,
|
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
|
||||||
createManagedSkillSymlinks,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
buildSkillsCandidates,
|
|
||||||
resolveSkillsDir,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
|
|
||||||
export {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
buildSkillsCandidates,
|
|
||||||
resolveSkillsDir as resolveAgentCompanySkillsDir,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 });
|
|
||||||
|
|
||||||
if (managedSkills.length > 0) {
|
|
||||||
await createManagedSkillSymlinks(managedSkills, target, onLog);
|
|
||||||
return tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
|
|
||||||
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 resolveClaudeBillingType(env: Record<string, string>): "api" | "subscription" {
|
|
||||||
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 { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } = await buildCommonAgentCompanyEnv(
|
|
||||||
{ runId, agent, config, context, authToken },
|
|
||||||
{ ensureAbsoluteDirectory },
|
|
||||||
);
|
|
||||||
|
|
||||||
const runtimeEnv = ensurePathInEnv(env);
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
|
||||||
const extraArgs = parseExtraArgs(config);
|
|
||||||
|
|
||||||
return {
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
workspaceId,
|
|
||||||
workspaceRepoUrl,
|
|
||||||
workspaceRepoRef,
|
|
||||||
env,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
extraArgs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
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(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
export { execute, runClaudeLogin } from "./execute.js";
|
|
||||||
export { testEnvironment } from "./test.js";
|
|
||||||
export {
|
|
||||||
parseClaudeStreamJson,
|
|
||||||
describeClaudeFailure,
|
|
||||||
isClaudeMaxTurnsResult,
|
|
||||||
isClaudeUnknownSessionError,
|
|
||||||
} from "./parse.js";
|
|
||||||
export { listClaudeSkills, syncClaudeSkills } from "./skills.js";
|
|
||||||
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
|
|
||||||
|
|
||||||
export const sessionCodec = createStandardSessionCodec();
|
|
||||||
@@ -1,179 +0,0 @@
|
|||||||
import type { UsageSummary } from "@agentcompany/adapter-utils";
|
|
||||||
import { asString, asNumber, parseObject, parseJson } from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
const CLAUDE_AUTH_REQUIRED_RE = /(?:not\s+logged\s+in|please\s+log\s+in|please\s+run\s+`?claude\s+login`?|login\s+required|requires\s+login|unauthorized|authentication\s+required)/i;
|
|
||||||
const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi;
|
|
||||||
|
|
||||||
export function parseClaudeStreamJson(stdout: string) {
|
|
||||||
let sessionId: string | null = null;
|
|
||||||
let model = "";
|
|
||||||
let finalResult: Record<string, unknown> | null = null;
|
|
||||||
const assistantTexts: string[] = [];
|
|
||||||
|
|
||||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
const event = parseJson(line);
|
|
||||||
if (!event) continue;
|
|
||||||
|
|
||||||
const type = asString(event.type, "");
|
|
||||||
if (type === "system" && asString(event.subtype, "") === "init") {
|
|
||||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
||||||
model = asString(event.model, model);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
||||||
const message = parseObject(event.message);
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const entry of content) {
|
|
||||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
||||||
const block = entry as Record<string, unknown>;
|
|
||||||
if (asString(block.type, "") === "text") {
|
|
||||||
const text = asString(block.text, "");
|
|
||||||
if (text) assistantTexts.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
finalResult = event;
|
|
||||||
sessionId = asString(event.session_id, sessionId ?? "") || sessionId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalResult) {
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
costUsd: null as number | null,
|
|
||||||
usage: null as UsageSummary | null,
|
|
||||||
summary: assistantTexts.join("\n\n").trim(),
|
|
||||||
resultJson: null as Record<string, unknown> | null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const usageObj = parseObject(finalResult.usage);
|
|
||||||
const usage: UsageSummary = {
|
|
||||||
inputTokens: asNumber(usageObj.input_tokens, 0),
|
|
||||||
cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0),
|
|
||||||
outputTokens: asNumber(usageObj.output_tokens, 0),
|
|
||||||
};
|
|
||||||
const costRaw = finalResult.total_cost_usd;
|
|
||||||
const costUsd = typeof costRaw === "number" && Number.isFinite(costRaw) ? costRaw : null;
|
|
||||||
const summary = asString(finalResult.result, assistantTexts.join("\n\n")).trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
model,
|
|
||||||
costUsd,
|
|
||||||
usage,
|
|
||||||
summary,
|
|
||||||
resultJson: finalResult,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractClaudeErrorMessages(parsed: Record<string, unknown>): string[] {
|
|
||||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
||||||
const messages: string[] = [];
|
|
||||||
|
|
||||||
for (const entry of raw) {
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
const msg = entry.trim();
|
|
||||||
if (msg) messages.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const obj = entry as Record<string, unknown>;
|
|
||||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
|
||||||
if (msg) {
|
|
||||||
messages.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
messages.push(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
// skip non-serializable entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractClaudeLoginUrl(text: string): string | null {
|
|
||||||
const match = text.match(URL_RE);
|
|
||||||
if (!match || match.length === 0) return null;
|
|
||||||
for (const rawUrl of match) {
|
|
||||||
const cleaned = rawUrl.replace(/[\])}.!,?;:'"]+$/g, "");
|
|
||||||
if (cleaned.includes("claude") || cleaned.includes("anthropic") || cleaned.includes("auth")) {
|
|
||||||
return cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return match[0]?.replace(/[\])}.!,?;:'"]+$/g, "") ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectClaudeLoginRequired(input: {
|
|
||||||
parsed: Record<string, unknown> | null;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}): { requiresLogin: boolean; loginUrl: string | null } {
|
|
||||||
const resultText = asString(input.parsed?.result, "").trim();
|
|
||||||
const messages = [resultText, ...extractClaudeErrorMessages(input.parsed ?? {}), input.stdout, input.stderr]
|
|
||||||
.join("\n")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const requiresLogin = messages.some((line) => CLAUDE_AUTH_REQUIRED_RE.test(line));
|
|
||||||
return {
|
|
||||||
requiresLogin,
|
|
||||||
loginUrl: extractClaudeLoginUrl([input.stdout, input.stderr].join("\n")),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeClaudeFailure(parsed: Record<string, unknown>): string | null {
|
|
||||||
const subtype = asString(parsed.subtype, "");
|
|
||||||
const resultText = asString(parsed.result, "").trim();
|
|
||||||
const errors = extractClaudeErrorMessages(parsed);
|
|
||||||
|
|
||||||
let detail = resultText;
|
|
||||||
if (!detail && errors.length > 0) {
|
|
||||||
detail = errors[0] ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
const parts = ["Claude run failed"];
|
|
||||||
if (subtype) parts.push(`subtype=${subtype}`);
|
|
||||||
if (detail) parts.push(detail);
|
|
||||||
return parts.length > 1 ? parts.join(": ") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isClaudeMaxTurnsResult(parsed: Record<string, unknown> | null | undefined): boolean {
|
|
||||||
if (!parsed) return false;
|
|
||||||
|
|
||||||
const subtype = asString(parsed.subtype, "").trim().toLowerCase();
|
|
||||||
if (subtype === "error_max_turns") return true;
|
|
||||||
|
|
||||||
const stopReason = asString(parsed.stop_reason, "").trim().toLowerCase();
|
|
||||||
if (stopReason === "max_turns") return true;
|
|
||||||
|
|
||||||
const resultText = asString(parsed.result, "").trim();
|
|
||||||
return /max(?:imum)?\s+turns?/i.test(resultText);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): boolean {
|
|
||||||
const resultText = asString(parsed.result, "").trim();
|
|
||||||
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]
|
|
||||||
.map((msg) => msg.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return allMessages.some((msg) =>
|
|
||||||
/no conversation found with session id|unknown session|session .* not found/i.test(msg),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Claude adapter skill listing / sync -- ephemeral mode
|
|
||||||
// Skills are mounted into a temp dir via --add-dir at run time, not persisted.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
import type {
|
|
||||||
AdapterSkillContext,
|
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
parseObject,
|
|
||||||
asString,
|
|
||||||
readAgentCompanyRuntimeSkillEntries,
|
|
||||||
readInstalledSkillTargets,
|
|
||||||
resolveAgentCompanyDesiredSkillNames,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
function resolveClaudeSkillsHome(config: Record<string, unknown>): string {
|
|
||||||
const env = parseObject(config.env);
|
|
||||||
const home = asString(env.HOME, asString(env.USERPROFILE, ""));
|
|
||||||
const base = home || (process.env.HOME ?? process.env.USERPROFILE ?? "");
|
|
||||||
return path.join(base, ".claude", "skills");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildClaudeSkillSnapshot(
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
const moduleDir = path.resolve(import.meta.dirname ?? __dirname);
|
|
||||||
const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir);
|
|
||||||
const desiredSkills = resolveAgentCompanyDesiredSkillNames(config, availableEntries);
|
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const entries: AdapterSkillEntry[] = [];
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
entries.push({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed: true,
|
|
||||||
state: desired ? "configured" : "available",
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: null,
|
|
||||||
detail: desired
|
|
||||||
? "Will be mounted into the ephemeral Claude skill directory on the next run."
|
|
||||||
: null,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
origin: available.required ? "paperclip_required" : "company_managed",
|
|
||||||
originLabel: available.required ? "Required by AgentCompany" : "Managed by AgentCompany",
|
|
||||||
readOnly: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan installed Claude skills for external entries
|
|
||||||
const skillsHome = resolveClaudeSkillsHome(config);
|
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
|
||||||
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: "~/.claude/skills",
|
|
||||||
readOnly: true,
|
|
||||||
sourcePath: null,
|
|
||||||
targetPath: installedEntry.targetPath ?? path.join(skillsHome, name),
|
|
||||||
detail: "Installed outside AgentCompany management.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for desired skills not in available
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableEntries.some((entry) => entry.key === desiredSkill)) continue;
|
|
||||||
if (entries.some((entry) => entry.key === 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "claude_local",
|
|
||||||
supported: true,
|
|
||||||
mode: "ephemeral",
|
|
||||||
desiredSkills,
|
|
||||||
entries,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the current skill state for the Claude adapter.
|
|
||||||
* Ephemeral mode: skills are injected at run time via --add-dir, not persisted.
|
|
||||||
*/
|
|
||||||
export async function listClaudeSkills(
|
|
||||||
ctx: AdapterSkillContext,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
return buildClaudeSkillSnapshot(ctx.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync skills for the Claude adapter.
|
|
||||||
* Ephemeral mode: no on-disk sync needed; skills are injected at run time.
|
|
||||||
* Returns the current snapshot for informational purposes.
|
|
||||||
*/
|
|
||||||
export async function syncClaudeSkills(
|
|
||||||
ctx: AdapterSkillContext,
|
|
||||||
_desiredSkills: string[],
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
return buildClaudeSkillSnapshot(ctx.config);
|
|
||||||
}
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import type {
|
|
||||||
AdapterEnvironmentCheck,
|
|
||||||
AdapterEnvironmentTestContext,
|
|
||||||
AdapterEnvironmentTestResult,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
asString,
|
|
||||||
asBoolean,
|
|
||||||
asNumber,
|
|
||||||
asStringArray,
|
|
||||||
parseObject,
|
|
||||||
ensureAbsoluteDirectory,
|
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
|
||||||
runChildProcess,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import { summarizeStatus, isNonEmpty, commandLooksLike, summarizeProbeDetail } from "@agentcompany/adapter-utils/test-utils";
|
|
||||||
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
|
|
||||||
|
|
||||||
export async function testEnvironment(
|
|
||||||
ctx: AdapterEnvironmentTestContext,
|
|
||||||
): Promise<AdapterEnvironmentTestResult> {
|
|
||||||
const checks: AdapterEnvironmentCheck[] = [];
|
|
||||||
const config = parseObject(ctx.config);
|
|
||||||
const command = asString(config.command, "claude");
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
||||||
checks.push({ code: "claude_cwd_valid", level: "info", message: `Working directory is valid: ${cwd}` });
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({ code: "claude_cwd_invalid", level: "error", message: err instanceof Error ? err.message : "Invalid working directory", detail: cwd });
|
|
||||||
}
|
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
|
||||||
if (typeof value === "string") env[key] = value;
|
|
||||||
}
|
|
||||||
const runtimeEnv = ensurePathInEnv(env);
|
|
||||||
try {
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
checks.push({ code: "claude_command_resolvable", level: "info", message: `Command is executable: ${command}` });
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({ code: "claude_command_unresolvable", level: "error", message: err instanceof Error ? err.message : "Command is not executable", detail: command });
|
|
||||||
}
|
|
||||||
|
|
||||||
const configApiKey = env.ANTHROPIC_API_KEY;
|
|
||||||
if (isNonEmpty(configApiKey)) {
|
|
||||||
checks.push({ code: "claude_anthropic_api_key_overrides_subscription", level: "info", message: "ANTHROPIC_API_KEY is set. Claude will use API-key auth." });
|
|
||||||
} else {
|
|
||||||
checks.push({ code: "claude_subscription_mode_possible", level: "info", message: "ANTHROPIC_API_KEY is not set; subscription-based auth can be used if Claude is logged in." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hello probe: wrapped in try-catch so probe failures never crash the test.
|
|
||||||
// If the command is resolvable, that is sufficient for "pass" status.
|
|
||||||
const canRunProbe = checks.every((c) => c.code !== "claude_cwd_invalid" && c.code !== "claude_command_unresolvable");
|
|
||||||
if (canRunProbe && commandLooksLike(command, "claude")) {
|
|
||||||
try {
|
|
||||||
const model = asString(config.model, "").trim();
|
|
||||||
const effort = asString(config.effort, "").trim();
|
|
||||||
const chrome = asBoolean(config.chrome, false);
|
|
||||||
const maxTurns = asNumber(config.maxTurnsPerRun, 0);
|
|
||||||
const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false);
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const args = ["--print", "-", "--output-format", "stream-json", "--verbose"];
|
|
||||||
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 (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
|
|
||||||
const probe = await runChildProcess(
|
|
||||||
`claude-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
{ cwd, env, timeoutSec: 45, graceSec: 5, stdin: "Respond with hello.", onLog: async () => {} },
|
|
||||||
);
|
|
||||||
|
|
||||||
const parsedStream = parseClaudeStreamJson(probe.stdout);
|
|
||||||
const parsed = parsedStream.resultJson;
|
|
||||||
const loginMeta = detectClaudeLoginRequired({ parsed, stdout: probe.stdout, stderr: probe.stderr });
|
|
||||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr);
|
|
||||||
|
|
||||||
if (probe.timedOut) {
|
|
||||||
checks.push({ code: "claude_hello_probe_timed_out", level: "info", message: "Claude hello probe timed out." });
|
|
||||||
} else if (loginMeta.requiresLogin) {
|
|
||||||
checks.push({
|
|
||||||
code: "claude_hello_probe_auth_required", level: "warn",
|
|
||||||
message: "Claude CLI is installed, but login is required.",
|
|
||||||
...(detail ? { detail } : {}),
|
|
||||||
hint: loginMeta.loginUrl ? `Run \`claude login\` at ${loginMeta.loginUrl}` : "Run `claude login` first.",
|
|
||||||
});
|
|
||||||
} else if ((probe.exitCode ?? 1) === 0) {
|
|
||||||
const summary = parsedStream.summary.trim();
|
|
||||||
const hasHello = /\bhello\b/i.test(summary);
|
|
||||||
checks.push({
|
|
||||||
code: hasHello ? "claude_hello_probe_passed" : "claude_hello_probe_unexpected_output",
|
|
||||||
level: "info",
|
|
||||||
message: hasHello ? "Claude hello probe succeeded." : "Claude probe completed.",
|
|
||||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({ code: "claude_hello_probe_failed", level: "info", message: "Claude hello probe exited with non-zero code.", ...(detail ? { detail } : {}) });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Probe failed to run — command is still resolvable so this is non-fatal.
|
|
||||||
checks.push({ code: "claude_hello_probe_error", level: "info", message: "Claude hello probe could not run." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: ctx.adapterType,
|
|
||||||
status: summarizeStatus(checks),
|
|
||||||
checks,
|
|
||||||
testedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
|
||||||
import { parseCommaArgs, parseEnvVars, parseEnvBindings, parseJsonObject } from "@agentcompany/adapter-utils/config-utils";
|
|
||||||
|
|
||||||
export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
|
||||||
const ac: Record<string, unknown> = {};
|
|
||||||
if (v.cwd) ac.cwd = v.cwd;
|
|
||||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
|
||||||
if (v.model) ac.model = v.model;
|
|
||||||
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
|
|
||||||
if (v.chrome) ac.chrome = true;
|
|
||||||
ac.timeoutSec = 0;
|
|
||||||
ac.graceSec = 15;
|
|
||||||
const env = parseEnvBindings(v.envBindings);
|
|
||||||
const legacy = parseEnvVars(v.envVars);
|
|
||||||
for (const [key, value] of Object.entries(legacy)) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
|
||||||
env[key] = { type: "plain", value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(env).length > 0) ac.env = env;
|
|
||||||
ac.maxTurnsPerRun = v.maxTurnsPerRun;
|
|
||||||
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
|
|
||||||
if (v.workspaceStrategyType === "git_worktree") {
|
|
||||||
ac.workspaceStrategy = {
|
|
||||||
type: "git_worktree",
|
|
||||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
|
||||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
|
||||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
|
||||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
|
||||||
ac.workspaceRuntime = runtimeServices;
|
|
||||||
}
|
|
||||||
if (v.command) ac.command = v.command;
|
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
|
||||||
return ac;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { parseClaudeStdoutLine } from "./parse-stdout.js";
|
|
||||||
export { buildClaudeLocalConfig } from "./build-config.js";
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
|
|
||||||
import { asRecord, safeJsonParse, errorText } from "@agentcompany/adapter-utils/parse-utils";
|
|
||||||
|
|
||||||
function asNumber(value: unknown): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseClaudeStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|
||||||
const parsed = asRecord(safeJsonParse(line));
|
|
||||||
if (!parsed) {
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = typeof parsed.type === "string" ? parsed.type : "";
|
|
||||||
if (type === "system" && parsed.subtype === "init") {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
kind: "init",
|
|
||||||
ts,
|
|
||||||
model: typeof parsed.model === "string" ? parsed.model : "unknown",
|
|
||||||
sessionId: typeof parsed.session_id === "string" ? parsed.session_id : "",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
const message = asRecord(parsed.message) ?? {};
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
for (const blockRaw of content) {
|
|
||||||
const block = asRecord(blockRaw);
|
|
||||||
if (!block) continue;
|
|
||||||
const blockType = typeof block.type === "string" ? block.type : "";
|
|
||||||
if (blockType === "text") {
|
|
||||||
const text = typeof block.text === "string" ? block.text : "";
|
|
||||||
if (text) entries.push({ kind: "assistant", ts, text });
|
|
||||||
} else if (blockType === "thinking") {
|
|
||||||
const text = typeof block.thinking === "string" ? block.thinking : "";
|
|
||||||
if (text) entries.push({ kind: "thinking", ts, text });
|
|
||||||
} else if (blockType === "tool_use") {
|
|
||||||
entries.push({
|
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name: typeof block.name === "string" ? block.name : "unknown",
|
|
||||||
toolUseId:
|
|
||||||
typeof block.id === "string"
|
|
||||||
? block.id
|
|
||||||
: typeof block.tool_use_id === "string"
|
|
||||||
? block.tool_use_id
|
|
||||||
: undefined,
|
|
||||||
input: block.input ?? {},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries.length > 0 ? entries : [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "user") {
|
|
||||||
const message = asRecord(parsed.message) ?? {};
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
for (const blockRaw of content) {
|
|
||||||
const block = asRecord(blockRaw);
|
|
||||||
if (!block) continue;
|
|
||||||
const blockType = typeof block.type === "string" ? block.type : "";
|
|
||||||
if (blockType === "text") {
|
|
||||||
const text = typeof block.text === "string" ? block.text : "";
|
|
||||||
if (text) entries.push({ kind: "user", ts, text });
|
|
||||||
} else if (blockType === "tool_result") {
|
|
||||||
const toolUseId = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
||||||
const isError = block.is_error === true;
|
|
||||||
let text = "";
|
|
||||||
if (typeof block.content === "string") {
|
|
||||||
text = block.content;
|
|
||||||
} else if (Array.isArray(block.content)) {
|
|
||||||
const parts: string[] = [];
|
|
||||||
for (const part of block.content) {
|
|
||||||
const p = asRecord(part);
|
|
||||||
if (p && typeof p.text === "string") parts.push(p.text);
|
|
||||||
}
|
|
||||||
text = parts.join("\n");
|
|
||||||
}
|
|
||||||
entries.push({ kind: "tool_result", ts, toolUseId, content: text, isError });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (entries.length > 0) return entries;
|
|
||||||
// fall through to stdout for user messages without recognized blocks
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
const usage = asRecord(parsed.usage) ?? {};
|
|
||||||
const inputTokens = asNumber(usage.input_tokens);
|
|
||||||
const outputTokens = asNumber(usage.output_tokens);
|
|
||||||
const cachedTokens = asNumber(usage.cache_read_input_tokens);
|
|
||||||
const costUsd = asNumber(parsed.total_cost_usd);
|
|
||||||
const subtype = typeof parsed.subtype === "string" ? parsed.subtype : "";
|
|
||||||
const isError = parsed.is_error === true;
|
|
||||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
|
||||||
const text = typeof parsed.result === "string" ? parsed.result : "";
|
|
||||||
return [{
|
|
||||||
kind: "result",
|
|
||||||
ts,
|
|
||||||
text,
|
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
|
||||||
cachedTokens,
|
|
||||||
costUsd,
|
|
||||||
subtype,
|
|
||||||
isError,
|
|
||||||
errors,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@agentcompany/adapter-codex-local",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./server": "./src/server/index.ts",
|
|
||||||
"./ui": "./src/ui/index.ts",
|
|
||||||
"./cli": "./src/cli/index.ts"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./server": {
|
|
||||||
"types": "./dist/server/index.d.ts",
|
|
||||||
"import": "./dist/server/index.js"
|
|
||||||
},
|
|
||||||
"./ui": {
|
|
||||||
"types": "./dist/ui/index.d.ts",
|
|
||||||
"import": "./dist/ui/index.js"
|
|
||||||
},
|
|
||||||
"./cli": {
|
|
||||||
"types": "./dist/cli/index.d.ts",
|
|
||||||
"import": "./dist/cli/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"skills"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@agentcompany/adapter-utils": "workspace:*",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
import pc from "picocolors";
|
|
||||||
import { asRecord, errorText, asString, asNumber } from "@agentcompany/adapter-utils/parse-utils";
|
|
||||||
|
|
||||||
function printItemStarted(item: Record<string, unknown>): boolean {
|
|
||||||
const itemType = asString(item.type);
|
|
||||||
if (itemType === "command_execution") {
|
|
||||||
const command = asString(item.command);
|
|
||||||
console.log(pc.yellow("tool_call: command_execution"));
|
|
||||||
if (command) console.log(pc.gray(command));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "tool_use") {
|
|
||||||
const name = asString(item.name, "unknown");
|
|
||||||
console.log(pc.yellow(`tool_call: ${name}`));
|
|
||||||
if (item.input !== undefined) {
|
|
||||||
try {
|
|
||||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
|
||||||
} catch {
|
|
||||||
console.log(pc.gray(String(item.input)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function printItemCompleted(item: Record<string, unknown>): boolean {
|
|
||||||
const itemType = asString(item.type);
|
|
||||||
|
|
||||||
if (itemType === "agent_message") {
|
|
||||||
const text = asString(item.text);
|
|
||||||
if (text) console.log(pc.green(`assistant: ${text}`));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "reasoning") {
|
|
||||||
const text = asString(item.text);
|
|
||||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "tool_use") {
|
|
||||||
const name = asString(item.name, "unknown");
|
|
||||||
console.log(pc.yellow(`tool_call: ${name}`));
|
|
||||||
if (item.input !== undefined) {
|
|
||||||
try {
|
|
||||||
console.log(pc.gray(JSON.stringify(item.input, null, 2)));
|
|
||||||
} catch {
|
|
||||||
console.log(pc.gray(String(item.input)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "command_execution") {
|
|
||||||
const command = asString(item.command);
|
|
||||||
const status = asString(item.status);
|
|
||||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
|
||||||
const output = asString(item.aggregated_output).replace(/\s+$/, "");
|
|
||||||
const isError =
|
|
||||||
(exitCode !== null && exitCode !== 0) ||
|
|
||||||
status === "failed" ||
|
|
||||||
status === "errored" ||
|
|
||||||
status === "error" ||
|
|
||||||
status === "cancelled";
|
|
||||||
|
|
||||||
const summaryParts = [
|
|
||||||
"tool_result: command_execution",
|
|
||||||
command ? `command="${command}"` : "",
|
|
||||||
status ? `status=${status}` : "",
|
|
||||||
exitCode !== null ? `exit_code=${exitCode}` : "",
|
|
||||||
].filter(Boolean);
|
|
||||||
console.log((isError ? pc.red : pc.cyan)(summaryParts.join(" ")));
|
|
||||||
if (output) console.log((isError ? pc.red : pc.gray)(output));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "file_change") {
|
|
||||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
||||||
const entries = changes
|
|
||||||
.map((changeRaw) => asRecord(changeRaw))
|
|
||||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
|
||||||
.map((change) => {
|
|
||||||
const kind = asString(change.kind, "update");
|
|
||||||
const path = asString(change.path, "unknown");
|
|
||||||
return `${kind} ${path}`;
|
|
||||||
});
|
|
||||||
const preview = entries.length > 0 ? entries.slice(0, 6).join(", ") : "none";
|
|
||||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
|
||||||
console.log(pc.cyan(`file_change: ${preview}${more}`));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "error") {
|
|
||||||
const message = errorText(item.message ?? item.error ?? item);
|
|
||||||
if (message) console.log(pc.red(`error: ${message}`));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "tool_result") {
|
|
||||||
const isError = item.is_error === true || asString(item.status) === "error";
|
|
||||||
const text = asString(item.content) || asString(item.result) || asString(item.output);
|
|
||||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
|
||||||
if (text) console.log((isError ? pc.red : pc.gray)(text));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function printCodexStreamEvent(raw: string, debug: boolean): void {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
let parsed: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
console.log(line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = asString(parsed.type);
|
|
||||||
|
|
||||||
if (type === "thread.started") {
|
|
||||||
const threadId = asString(parsed.thread_id);
|
|
||||||
const model = asString(parsed.model);
|
|
||||||
const details = [threadId ? `session: ${threadId}` : "", model ? `model: ${model}` : ""].filter(Boolean).join(", ");
|
|
||||||
console.log(pc.blue(`Codex thread started${details ? ` (${details})` : ""}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.started") {
|
|
||||||
console.log(pc.blue("turn started"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "item.started" || type === "item.completed") {
|
|
||||||
const item = asRecord(parsed.item);
|
|
||||||
if (item) {
|
|
||||||
const handled =
|
|
||||||
type === "item.started"
|
|
||||||
? printItemStarted(item)
|
|
||||||
: printItemCompleted(item);
|
|
||||||
if (!handled) {
|
|
||||||
const itemType = asString(item.type, "unknown");
|
|
||||||
const id = asString(item.id);
|
|
||||||
const status = asString(item.status);
|
|
||||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
|
||||||
console.log(pc.gray(`${type}: ${itemType}${meta ? ` (${meta})` : ""}`));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log(pc.gray(type));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.completed") {
|
|
||||||
const usage = asRecord(parsed.usage);
|
|
||||||
const input = asNumber(usage?.input_tokens);
|
|
||||||
const output = asNumber(usage?.output_tokens);
|
|
||||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
|
||||||
const cost = asNumber(parsed.total_cost_usd);
|
|
||||||
const isError = parsed.is_error === true;
|
|
||||||
const subtype = asString(parsed.subtype);
|
|
||||||
const errors = Array.isArray(parsed.errors) ? parsed.errors.map(errorText).filter(Boolean) : [];
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`),
|
|
||||||
);
|
|
||||||
if (subtype || isError || errors.length > 0) {
|
|
||||||
console.log(
|
|
||||||
pc.red(`result: subtype=${subtype || "unknown"} is_error=${isError ? "true" : "false"}`),
|
|
||||||
);
|
|
||||||
if (errors.length > 0) console.log(pc.red(`errors: ${errors.join(" | ")}`));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.failed") {
|
|
||||||
const usage = asRecord(parsed.usage);
|
|
||||||
const input = asNumber(usage?.input_tokens);
|
|
||||||
const output = asNumber(usage?.output_tokens);
|
|
||||||
const cached = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
|
||||||
const message = errorText(parsed.error ?? parsed.message);
|
|
||||||
console.log(pc.red(`turn failed${message ? `: ${message}` : ""}`));
|
|
||||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
|
||||||
if (message) console.log(pc.red(`error: ${message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log(pc.gray(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { printCodexStreamEvent } from "./format-event.js";
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
export const type = "codex_local";
|
|
||||||
export const label = "Codex (local)";
|
|
||||||
export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex";
|
|
||||||
export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true;
|
|
||||||
|
|
||||||
export const models = [
|
|
||||||
{ id: "gpt-5.4", label: "gpt-5.4" },
|
|
||||||
{ id: DEFAULT_CODEX_LOCAL_MODEL, label: DEFAULT_CODEX_LOCAL_MODEL },
|
|
||||||
{ id: "gpt-5.3-codex-spark", label: "gpt-5.3-codex-spark" },
|
|
||||||
{ id: "gpt-5", label: "gpt-5" },
|
|
||||||
{ id: "o3", label: "o3" },
|
|
||||||
{ id: "o4-mini", label: "o4-mini" },
|
|
||||||
{ id: "gpt-5-mini", label: "gpt-5-mini" },
|
|
||||||
{ id: "gpt-5-nano", label: "gpt-5-nano" },
|
|
||||||
{ id: "o3-mini", label: "o3-mini" },
|
|
||||||
{ id: "codex-mini-latest", label: "Codex Mini" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const agentConfigurationDoc = `# codex_local agent configuration
|
|
||||||
|
|
||||||
Adapter: codex_local
|
|
||||||
|
|
||||||
Core fields:
|
|
||||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
|
||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to stdin prompt at runtime
|
|
||||||
- model (string, optional): Codex model id
|
|
||||||
- modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high) passed via -c model_reasoning_effort=...
|
|
||||||
- promptTemplate (string, optional): run prompt template
|
|
||||||
- search (boolean, optional): run codex with --search
|
|
||||||
- dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag
|
|
||||||
- command (string, optional): defaults to "codex"
|
|
||||||
- extraArgs (string[], optional): additional CLI args
|
|
||||||
- env (object, optional): KEY=VALUE environment variables
|
|
||||||
- workspaceStrategy (object, optional): execution workspace strategy; currently supports { type: "git_worktree", baseRef?, branchTemplate?, worktreeParentDir? }
|
|
||||||
- workspaceRuntime (object, optional): workspace runtime service intents; local host-managed services are realized before Codex starts and exposed back via context/env
|
|
||||||
|
|
||||||
Operational fields:
|
|
||||||
- timeoutSec (number, optional): run timeout in seconds
|
|
||||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Prompts are piped via stdin (Codex receives "-" prompt argument).
|
|
||||||
- AgentCompany auto-injects local skills into Codex personal skills dir ("$CODEX_HOME/skills" or "~/.codex/skills") when missing, so Codex can discover "$agentcompany" and related skills.
|
|
||||||
- Some model/tool combinations reject certain effort levels (for example minimal with web search enabled).
|
|
||||||
- When AgentCompany realizes a workspace/runtime for a run, it injects AGENT_COMPANY_WORKSPACE_* and AGENT_COMPANY_RUNTIME_* env vars for agent-side tooling.
|
|
||||||
`;
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
buildSkillsCandidates,
|
|
||||||
resolveSkillsDir,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
createManagedSkillSymlinks,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
|
|
||||||
export {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
|
|
||||||
|
|
||||||
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 resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
|
|
||||||
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> {
|
|
||||||
return resolveSkillsDir(SKILLS_CANDIDATES);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureCodexSkillsInjected(
|
|
||||||
managedSkills: ManagedSkillRef[],
|
|
||||||
onLog: AdapterExecutionContext["onLog"],
|
|
||||||
) {
|
|
||||||
const skillsHome = path.join(codexHomeDir(), "skills");
|
|
||||||
|
|
||||||
if (managedSkills.length > 0) {
|
|
||||||
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
|
|
||||||
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,258 +0,0 @@
|
|||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
asString,
|
|
||||||
asNumber,
|
|
||||||
asBoolean,
|
|
||||||
redactEnvForLogs,
|
|
||||||
ensureAbsoluteDirectory,
|
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
|
||||||
filterConflictingArgs,
|
|
||||||
renderTemplate,
|
|
||||||
runChildProcess,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
|
|
||||||
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
|
||||||
import {
|
|
||||||
stripCodexRolloutNoise,
|
|
||||||
resolveCodexBillingType,
|
|
||||||
ensureCodexSkillsInjected,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
} from "./execute-helpers.js";
|
|
||||||
|
|
||||||
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 command = asString(config.command, "codex");
|
|
||||||
const model = asString(config.model, "");
|
|
||||||
const modelReasoningEffort = asString(
|
|
||||||
config.modelReasoningEffort,
|
|
||||||
asString(config.reasoningEffort, ""),
|
|
||||||
);
|
|
||||||
const search = asBoolean(config.search, false);
|
|
||||||
const bypass = asBoolean(
|
|
||||||
config.dangerouslyBypassApprovalsAndSandbox,
|
|
||||||
asBoolean(config.dangerouslyBypassSandbox, false),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
|
|
||||||
await buildCommonAgentCompanyEnv(
|
|
||||||
{ runId, agent, config, context, authToken },
|
|
||||||
{ ensureAbsoluteDirectory },
|
|
||||||
);
|
|
||||||
|
|
||||||
const managedSkills = parseManagedSkills(config);
|
|
||||||
await ensureCodexSkillsInjected(managedSkills, onLog);
|
|
||||||
|
|
||||||
const billingType = resolveCodexBillingType(env);
|
|
||||||
const runtimeEnv = ensurePathInEnv(env);
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
|
||||||
const extraArgs = parseExtraArgs(config);
|
|
||||||
|
|
||||||
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
|
|
||||||
resolveSessionResumeState(runtime, cwd);
|
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
||||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
||||||
let instructionsPrefix = "";
|
|
||||||
if (instructionsFilePath) {
|
|
||||||
try {
|
|
||||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
|
||||||
instructionsPrefix =
|
|
||||||
`${instructionsContents}\n\n` +
|
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const commandNotes = (() => {
|
|
||||||
if (!instructionsFilePath) return [] as string[];
|
|
||||||
if (instructionsPrefix.length > 0) {
|
|
||||||
return [
|
|
||||||
`Loaded agent instructions from ${instructionsFilePath}`,
|
|
||||||
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
||||||
];
|
|
||||||
})();
|
|
||||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
|
||||||
agentId: agent.id,
|
|
||||||
companyId: agent.companyId,
|
|
||||||
runId,
|
|
||||||
company: { id: agent.companyId },
|
|
||||||
agent,
|
|
||||||
run: { id: runId, source: "on_demand" },
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
const prompt = `${instructionsPrefix}${renderedPrompt}`;
|
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
|
||||||
const args = ["exec", "--json"];
|
|
||||||
if (search) args.unshift("--search");
|
|
||||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
||||||
if (model) args.push("--model", model);
|
|
||||||
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
|
||||||
if (extraArgs.length > 0) {
|
|
||||||
const managedFlags = [
|
|
||||||
"--dangerously-bypass-approvals-and-sandbox",
|
|
||||||
"--json",
|
|
||||||
"--model",
|
|
||||||
"--sandbox",
|
|
||||||
"--skip-git-repo-check",
|
|
||||||
"--ask-for-approval",
|
|
||||||
];
|
|
||||||
const cleaned = filterConflictingArgs(extraArgs, managedFlags);
|
|
||||||
if (cleaned.length > 0) args.push(...cleaned);
|
|
||||||
}
|
|
||||||
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
|
|
||||||
else args.push("-");
|
|
||||||
return args;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAttempt = async (resumeSessionId: string | null) => {
|
|
||||||
const args = buildArgs(resumeSessionId);
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "codex_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandNotes,
|
|
||||||
commandArgs: args.map((value, idx) => {
|
|
||||||
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
|
||||||
return value;
|
|
||||||
}),
|
|
||||||
env: redactEnvForLogs(env),
|
|
||||||
prompt,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
stdin: prompt,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onLog: async (stream, chunk) => {
|
|
||||||
if (stream !== "stderr") {
|
|
||||||
await onLog(stream, chunk);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const cleaned = stripCodexRolloutNoise(chunk);
|
|
||||||
if (!cleaned.trim()) return;
|
|
||||||
await onLog(stream, cleaned);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
|
||||||
return {
|
|
||||||
proc: {
|
|
||||||
...proc,
|
|
||||||
stderr: cleanedStderr,
|
|
||||||
},
|
|
||||||
rawStderr: proc.stderr,
|
|
||||||
parsed: parseCodexJsonl(proc.stdout),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toResult = (
|
|
||||||
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: string; parsed: ReturnType<typeof parseCodexJsonl> },
|
|
||||||
clearSessionOnMissingSession = false,
|
|
||||||
): AdapterExecutionResult => {
|
|
||||||
if (attempt.proc.timedOut) {
|
|
||||||
return {
|
|
||||||
exitCode: attempt.proc.exitCode,
|
|
||||||
signal: attempt.proc.signal,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
||||||
clearSession: clearSessionOnMissingSession,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
|
||||||
const resolvedSessionParams = resolvedSessionId
|
|
||||||
? ({
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
cwd,
|
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
||||||
} as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
||||||
const fallbackErrorMessage =
|
|
||||||
parsedError ||
|
|
||||||
stderrLine ||
|
|
||||||
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: attempt.proc.exitCode,
|
|
||||||
signal: attempt.proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage:
|
|
||||||
(attempt.proc.exitCode ?? 0) === 0
|
|
||||||
? null
|
|
||||||
: fallbackErrorMessage,
|
|
||||||
usage: attempt.parsed.usage,
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
sessionParams: resolvedSessionParams,
|
|
||||||
sessionDisplayId: resolvedSessionId,
|
|
||||||
provider: "openai",
|
|
||||||
model,
|
|
||||||
billingType,
|
|
||||||
costUsd: attempt.parsed.costUsd,
|
|
||||||
resultJson: {
|
|
||||||
stdout: attempt.proc.stdout,
|
|
||||||
stderr: attempt.proc.stderr,
|
|
||||||
},
|
|
||||||
summary: attempt.parsed.summary,
|
|
||||||
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const initial = await runAttempt(sessionId);
|
|
||||||
if (
|
|
||||||
sessionId &&
|
|
||||||
!initial.proc.timedOut &&
|
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
||||||
isCodexUnknownSessionError(initial.proc.stdout, initial.rawStderr)
|
|
||||||
) {
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
||||||
);
|
|
||||||
const retry = await runAttempt(null);
|
|
||||||
return toResult(retry, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return toResult(initial);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export { execute } from "./execute.js";
|
|
||||||
export { testEnvironment } from "./test.js";
|
|
||||||
export { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
|
||||||
export { listCodexSkills, syncCodexSkills } from "./skills.js";
|
|
||||||
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
|
|
||||||
|
|
||||||
export const sessionCodec = createStandardSessionCodec();
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { asString, asNumber, parseObject, parseJson } from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
export function parseCodexJsonl(stdout: string) {
|
|
||||||
let sessionId: string | null = null;
|
|
||||||
const messages: string[] = [];
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let costUsd: number | null = null;
|
|
||||||
const usage = {
|
|
||||||
inputTokens: 0,
|
|
||||||
cachedInputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
const event = parseJson(line);
|
|
||||||
if (!event) continue;
|
|
||||||
|
|
||||||
const type = asString(event.type, "");
|
|
||||||
if (type === "thread.started") {
|
|
||||||
sessionId = asString(event.thread_id, sessionId ?? "") || sessionId;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
const msg = asString(event.message, "").trim();
|
|
||||||
if (msg) errorMessage = msg;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "item.completed") {
|
|
||||||
const item = parseObject(event.item);
|
|
||||||
if (asString(item.type, "") === "agent_message") {
|
|
||||||
const text = asString(item.text, "");
|
|
||||||
if (text) messages.push(text);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.completed") {
|
|
||||||
const usageObj = parseObject(event.usage);
|
|
||||||
usage.inputTokens = asNumber(usageObj.input_tokens, usage.inputTokens);
|
|
||||||
usage.cachedInputTokens = asNumber(usageObj.cached_input_tokens, usage.cachedInputTokens);
|
|
||||||
usage.outputTokens = asNumber(usageObj.output_tokens, usage.outputTokens);
|
|
||||||
const rawCost = event.total_cost_usd;
|
|
||||||
if (typeof rawCost === "number" && Number.isFinite(rawCost)) costUsd = rawCost;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.failed") {
|
|
||||||
const err = parseObject(event.error);
|
|
||||||
const msg = asString(err.message, "").trim();
|
|
||||||
if (msg) errorMessage = msg;
|
|
||||||
const rawCost = event.total_cost_usd;
|
|
||||||
if (typeof rawCost === "number" && Number.isFinite(rawCost)) costUsd = rawCost;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
summary: messages.join("\n\n").trim(),
|
|
||||||
usage,
|
|
||||||
costUsd,
|
|
||||||
errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isCodexUnknownSessionError(stdout: string, stderr: string): boolean {
|
|
||||||
const haystack = `${stdout}\n${stderr}`
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
return /unknown (session|thread)|session .* not found|thread .* not found|conversation .* not found|missing rollout path for thread|state db missing rollout path/i.test(
|
|
||||||
haystack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Codex adapter skill listing / sync -- ephemeral mode
|
|
||||||
// Skills are linked into the workspace .agents/skills directory at run time.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import path from "node:path";
|
|
||||||
import type {
|
|
||||||
AdapterSkillContext,
|
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
readAgentCompanyRuntimeSkillEntries,
|
|
||||||
resolveAgentCompanyDesiredSkillNames,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
async function buildCodexSkillSnapshot(
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
const moduleDir = path.resolve(import.meta.dirname ?? __dirname);
|
|
||||||
const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir);
|
|
||||||
const desiredSkills = resolveAgentCompanyDesiredSkillNames(config, availableEntries);
|
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
const entries: AdapterSkillEntry[] = [];
|
|
||||||
const warnings: string[] = [];
|
|
||||||
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
const desired = desiredSet.has(available.key);
|
|
||||||
entries.push({
|
|
||||||
key: available.key,
|
|
||||||
runtimeName: available.runtimeName,
|
|
||||||
desired,
|
|
||||||
managed: true,
|
|
||||||
state: desired ? "configured" : "available",
|
|
||||||
sourcePath: available.source,
|
|
||||||
targetPath: null,
|
|
||||||
detail: desired
|
|
||||||
? "Will be linked into the workspace .agents/skills directory on the next run."
|
|
||||||
: null,
|
|
||||||
required: Boolean(available.required),
|
|
||||||
requiredReason: available.requiredReason ?? null,
|
|
||||||
origin: available.required ? "paperclip_required" : "company_managed",
|
|
||||||
originLabel: available.required ? "Required by AgentCompany" : "Managed by AgentCompany",
|
|
||||||
readOnly: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for desired skills not in available
|
|
||||||
for (const desiredSkill of desiredSkills) {
|
|
||||||
if (availableEntries.some((entry) => entry.key === 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.sort((left, right) => left.key.localeCompare(right.key));
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: "codex_local",
|
|
||||||
supported: true,
|
|
||||||
mode: "ephemeral",
|
|
||||||
desiredSkills,
|
|
||||||
entries,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the current skill state for the Codex adapter.
|
|
||||||
* Ephemeral mode: skills are linked into workspace at run time.
|
|
||||||
*/
|
|
||||||
export async function listCodexSkills(
|
|
||||||
ctx: AdapterSkillContext,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
return buildCodexSkillSnapshot(ctx.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync skills for the Codex adapter.
|
|
||||||
* Ephemeral mode: no on-disk sync needed; skills are linked at run time.
|
|
||||||
* Returns the current snapshot for informational purposes.
|
|
||||||
*/
|
|
||||||
export async function syncCodexSkills(
|
|
||||||
ctx: AdapterSkillContext,
|
|
||||||
_desiredSkills: string[],
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
return buildCodexSkillSnapshot(ctx.config);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import type {
|
|
||||||
AdapterEnvironmentCheck,
|
|
||||||
AdapterEnvironmentTestContext,
|
|
||||||
AdapterEnvironmentTestResult,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
asString,
|
|
||||||
asBoolean,
|
|
||||||
asStringArray,
|
|
||||||
parseObject,
|
|
||||||
ensureAbsoluteDirectory,
|
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
|
||||||
runChildProcess,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import { summarizeStatus, isNonEmpty, commandLooksLike, summarizeProbeDetail } from "@agentcompany/adapter-utils/test-utils";
|
|
||||||
import { parseCodexJsonl } from "./parse.js";
|
|
||||||
|
|
||||||
const CODEX_AUTH_REQUIRED_RE =
|
|
||||||
/(?:not\s+logged\s+in|login\s+required|authentication\s+required|unauthorized|invalid(?:\s+or\s+missing)?\s+api(?:[_\s-]?key)?|openai[_\s-]?api[_\s-]?key|api[_\s-]?key.*required|please\s+run\s+`?codex\s+login`?)/i;
|
|
||||||
|
|
||||||
export async function testEnvironment(
|
|
||||||
ctx: AdapterEnvironmentTestContext,
|
|
||||||
): Promise<AdapterEnvironmentTestResult> {
|
|
||||||
const checks: AdapterEnvironmentCheck[] = [];
|
|
||||||
const config = parseObject(ctx.config);
|
|
||||||
const command = asString(config.command, "codex");
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
||||||
checks.push({ code: "codex_cwd_valid", level: "info", message: `Working directory is valid: ${cwd}` });
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({ code: "codex_cwd_invalid", level: "error", message: err instanceof Error ? err.message : "Invalid working directory", detail: cwd });
|
|
||||||
}
|
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
|
||||||
if (typeof value === "string") env[key] = value;
|
|
||||||
}
|
|
||||||
const runtimeEnv = ensurePathInEnv(env);
|
|
||||||
try {
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
checks.push({ code: "codex_command_resolvable", level: "info", message: `Command is executable: ${command}` });
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({ code: "codex_command_unresolvable", level: "error", message: err instanceof Error ? err.message : "Command is not executable", detail: command });
|
|
||||||
}
|
|
||||||
|
|
||||||
const configOpenAiKey = env.OPENAI_API_KEY;
|
|
||||||
if (isNonEmpty(configOpenAiKey)) {
|
|
||||||
checks.push({ code: "codex_openai_api_key_present", level: "info", message: "OPENAI_API_KEY is set for Codex authentication." });
|
|
||||||
} else {
|
|
||||||
checks.push({ code: "codex_openai_api_key_missing", level: "info", message: "OPENAI_API_KEY is not set. Codex may use its own auth configuration." });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hello probe: wrapped in try-catch so probe failures never crash the test.
|
|
||||||
const canRunProbe = checks.every((c) => c.code !== "codex_cwd_invalid" && c.code !== "codex_command_unresolvable");
|
|
||||||
if (canRunProbe && commandLooksLike(command, "codex")) {
|
|
||||||
try {
|
|
||||||
const model = asString(config.model, "").trim();
|
|
||||||
const modelReasoningEffort = asString(config.modelReasoningEffort, asString(config.reasoningEffort, "")).trim();
|
|
||||||
const search = asBoolean(config.search, false);
|
|
||||||
const bypass = asBoolean(config.dangerouslyBypassApprovalsAndSandbox, asBoolean(config.dangerouslyBypassSandbox, false));
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const args = ["exec", "--json"];
|
|
||||||
args.push("--skip-git-repo-check");
|
|
||||||
if (search) args.unshift("--search");
|
|
||||||
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
||||||
if (model) args.push("--model", model);
|
|
||||||
if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
args.push("-");
|
|
||||||
|
|
||||||
const probe = await runChildProcess(
|
|
||||||
`codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
{ cwd, env, timeoutSec: 45, graceSec: 5, stdin: "Respond with hello.", onLog: async () => {} },
|
|
||||||
);
|
|
||||||
const parsed = parseCodexJsonl(probe.stdout);
|
|
||||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
|
||||||
const authEvidence = `${parsed.errorMessage ?? ""}\n${probe.stdout}\n${probe.stderr}`.trim();
|
|
||||||
|
|
||||||
if (probe.timedOut) {
|
|
||||||
checks.push({ code: "codex_hello_probe_timed_out", level: "info", message: "Codex hello probe timed out." });
|
|
||||||
} else if ((probe.exitCode ?? 1) === 0) {
|
|
||||||
const summary = parsed.summary.trim();
|
|
||||||
const hasHello = /\bhello\b/i.test(summary);
|
|
||||||
checks.push({
|
|
||||||
code: hasHello ? "codex_hello_probe_passed" : "codex_hello_probe_unexpected_output",
|
|
||||||
level: "info",
|
|
||||||
message: hasHello ? "Codex hello probe succeeded." : "Codex probe completed.",
|
|
||||||
...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}),
|
|
||||||
});
|
|
||||||
} else if (CODEX_AUTH_REQUIRED_RE.test(authEvidence)) {
|
|
||||||
checks.push({
|
|
||||||
code: "codex_hello_probe_auth_required", level: "warn",
|
|
||||||
message: "Codex CLI is installed, but authentication is not ready.",
|
|
||||||
...(detail ? { detail } : {}),
|
|
||||||
hint: "Configure OPENAI_API_KEY or run `codex login`.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({ code: "codex_hello_probe_failed", level: "info", message: "Codex hello probe exited with non-zero code.", ...(detail ? { detail } : {}) });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
checks.push({ code: "codex_hello_probe_error", level: "info", message: "Codex hello probe could not run." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: ctx.adapterType,
|
|
||||||
status: summarizeStatus(checks),
|
|
||||||
checks,
|
|
||||||
testedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
|
||||||
import { parseCommaArgs, parseEnvVars, parseEnvBindings, parseJsonObject } from "@agentcompany/adapter-utils/config-utils";
|
|
||||||
import {
|
|
||||||
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
|
|
||||||
DEFAULT_CODEX_LOCAL_MODEL,
|
|
||||||
} from "../index.js";
|
|
||||||
|
|
||||||
export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
|
||||||
const ac: Record<string, unknown> = {};
|
|
||||||
if (v.cwd) ac.cwd = v.cwd;
|
|
||||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
|
||||||
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
|
|
||||||
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
|
|
||||||
ac.timeoutSec = 0;
|
|
||||||
ac.graceSec = 15;
|
|
||||||
const env = parseEnvBindings(v.envBindings);
|
|
||||||
const legacy = parseEnvVars(v.envVars);
|
|
||||||
for (const [key, value] of Object.entries(legacy)) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
|
||||||
env[key] = { type: "plain", value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(env).length > 0) ac.env = env;
|
|
||||||
ac.search = v.search;
|
|
||||||
ac.dangerouslyBypassApprovalsAndSandbox =
|
|
||||||
typeof v.dangerouslyBypassSandbox === "boolean"
|
|
||||||
? v.dangerouslyBypassSandbox
|
|
||||||
: DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX;
|
|
||||||
if (v.workspaceStrategyType === "git_worktree") {
|
|
||||||
ac.workspaceStrategy = {
|
|
||||||
type: "git_worktree",
|
|
||||||
...(v.workspaceBaseRef ? { baseRef: v.workspaceBaseRef } : {}),
|
|
||||||
...(v.workspaceBranchTemplate ? { branchTemplate: v.workspaceBranchTemplate } : {}),
|
|
||||||
...(v.worktreeParentDir ? { worktreeParentDir: v.worktreeParentDir } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const runtimeServices = parseJsonObject(v.runtimeServicesJson ?? "");
|
|
||||||
if (runtimeServices && Array.isArray(runtimeServices.services)) {
|
|
||||||
ac.workspaceRuntime = runtimeServices;
|
|
||||||
}
|
|
||||||
if (v.command) ac.command = v.command;
|
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
|
||||||
return ac;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { parseCodexStdoutLine } from "./parse-stdout.js";
|
|
||||||
export { buildCodexLocalConfig } from "./build-config.js";
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import {
|
|
||||||
redactHomePathUserSegments,
|
|
||||||
redactHomePathUserSegmentsInValue,
|
|
||||||
type TranscriptEntry,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import { asRecord, safeJsonParse, errorText } from "@agentcompany/adapter-utils/parse-utils";
|
|
||||||
|
|
||||||
function asString(value: unknown, fallback = ""): string {
|
|
||||||
return typeof value === "string" ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown, fallback = 0): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyUnknown(value: unknown): string {
|
|
||||||
if (typeof value === "string") return redactHomePathUserSegments(value);
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
try {
|
|
||||||
return JSON.stringify(redactHomePathUserSegmentsInValue(value), null, 2);
|
|
||||||
} catch {
|
|
||||||
return redactHomePathUserSegments(String(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCommandExecutionItem(
|
|
||||||
item: Record<string, unknown>,
|
|
||||||
ts: string,
|
|
||||||
phase: "started" | "completed",
|
|
||||||
): TranscriptEntry[] {
|
|
||||||
const id = asString(item.id);
|
|
||||||
const command = asString(item.command);
|
|
||||||
const status = asString(item.status);
|
|
||||||
const exitCode = typeof item.exit_code === "number" && Number.isFinite(item.exit_code) ? item.exit_code : null;
|
|
||||||
const safeCommand = redactHomePathUserSegments(command);
|
|
||||||
const output = redactHomePathUserSegments(asString(item.aggregated_output)).replace(/\s+$/, "");
|
|
||||||
|
|
||||||
if (phase === "started") {
|
|
||||||
return [{
|
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name: "command_execution",
|
|
||||||
toolUseId: id || command || "command_execution",
|
|
||||||
input: {
|
|
||||||
id,
|
|
||||||
command: safeCommand,
|
|
||||||
},
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
if (safeCommand) lines.push(`command: ${safeCommand}`);
|
|
||||||
if (status) lines.push(`status: ${status}`);
|
|
||||||
if (exitCode !== null) lines.push(`exit_code: ${exitCode}`);
|
|
||||||
if (output) {
|
|
||||||
if (lines.length > 0) lines.push("");
|
|
||||||
lines.push(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isError =
|
|
||||||
(exitCode !== null && exitCode !== 0) ||
|
|
||||||
status === "failed" ||
|
|
||||||
status === "errored" ||
|
|
||||||
status === "error" ||
|
|
||||||
status === "cancelled";
|
|
||||||
|
|
||||||
return [{
|
|
||||||
kind: "tool_result",
|
|
||||||
ts,
|
|
||||||
toolUseId: id || command || "command_execution",
|
|
||||||
content: lines.join("\n").trim() || "command completed",
|
|
||||||
isError,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseFileChangeItem(item: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
|
||||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
|
||||||
const entries = changes
|
|
||||||
.map((changeRaw) => asRecord(changeRaw))
|
|
||||||
.filter((change): change is Record<string, unknown> => Boolean(change))
|
|
||||||
.map((change) => {
|
|
||||||
const kind = asString(change.kind, "update");
|
|
||||||
const path = redactHomePathUserSegments(asString(change.path, "unknown"));
|
|
||||||
return `${kind} ${path}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return [{ kind: "system", ts, text: "file changes applied" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const preview = entries.slice(0, 6).join(", ");
|
|
||||||
const more = entries.length > 6 ? ` (+${entries.length - 6} more)` : "";
|
|
||||||
return [{ kind: "system", ts, text: `file changes: ${preview}${more}` }];
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseCodexItem(
|
|
||||||
item: Record<string, unknown>,
|
|
||||||
ts: string,
|
|
||||||
phase: "started" | "completed",
|
|
||||||
): TranscriptEntry[] {
|
|
||||||
const itemType = asString(item.type);
|
|
||||||
|
|
||||||
if (itemType === "agent_message") {
|
|
||||||
const text = asString(item.text);
|
|
||||||
if (text) return [{ kind: "assistant", ts, text: redactHomePathUserSegments(text) }];
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "reasoning") {
|
|
||||||
const text = asString(item.text);
|
|
||||||
if (text) return [{ kind: "thinking", ts, text: redactHomePathUserSegments(text) }];
|
|
||||||
return [{ kind: "system", ts, text: phase === "started" ? "reasoning started" : "reasoning completed" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "command_execution") {
|
|
||||||
return parseCommandExecutionItem(item, ts, phase);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "file_change" && phase === "completed") {
|
|
||||||
return parseFileChangeItem(item, ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "tool_use") {
|
|
||||||
return [{
|
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name: redactHomePathUserSegments(asString(item.name, "unknown")),
|
|
||||||
toolUseId: asString(item.id),
|
|
||||||
input: redactHomePathUserSegmentsInValue(item.input ?? {}),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "tool_result" && phase === "completed") {
|
|
||||||
const toolUseId = asString(item.tool_use_id, asString(item.id));
|
|
||||||
const content =
|
|
||||||
asString(item.content) ||
|
|
||||||
asString(item.output) ||
|
|
||||||
asString(item.result) ||
|
|
||||||
stringifyUnknown(item.content ?? item.output ?? item.result);
|
|
||||||
const isError = item.is_error === true || asString(item.status) === "error";
|
|
||||||
return [{ kind: "tool_result", ts, toolUseId, content: redactHomePathUserSegments(content), isError }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemType === "error" && phase === "completed") {
|
|
||||||
const text = errorText(item.message ?? item.error ?? item);
|
|
||||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(text || "error") }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = asString(item.id);
|
|
||||||
const status = asString(item.status);
|
|
||||||
const meta = [id ? `id=${id}` : "", status ? `status=${status}` : ""].filter(Boolean).join(" ");
|
|
||||||
return [{
|
|
||||||
kind: "system",
|
|
||||||
ts,
|
|
||||||
text: redactHomePathUserSegments(`item ${phase}: ${itemType || "unknown"}${meta ? ` (${meta})` : ""}`),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCodexStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|
||||||
const parsed = asRecord(safeJsonParse(line));
|
|
||||||
if (!parsed) {
|
|
||||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = asString(parsed.type);
|
|
||||||
|
|
||||||
if (type === "thread.started") {
|
|
||||||
const threadId = asString(parsed.thread_id);
|
|
||||||
return [{
|
|
||||||
kind: "init",
|
|
||||||
ts,
|
|
||||||
model: redactHomePathUserSegments(asString(parsed.model, "codex")),
|
|
||||||
sessionId: redactHomePathUserSegments(threadId),
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.started") {
|
|
||||||
return [{ kind: "system", ts, text: "turn started" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "item.started" || type === "item.completed") {
|
|
||||||
const item = asRecord(parsed.item);
|
|
||||||
if (!item) return [{ kind: "system", ts, text: type.replace(".", " ") }];
|
|
||||||
return parseCodexItem(item, ts, type === "item.started" ? "started" : "completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.completed") {
|
|
||||||
const usage = asRecord(parsed.usage);
|
|
||||||
const inputTokens = asNumber(usage?.input_tokens);
|
|
||||||
const outputTokens = asNumber(usage?.output_tokens);
|
|
||||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
|
||||||
return [{
|
|
||||||
kind: "result",
|
|
||||||
ts,
|
|
||||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
|
||||||
cachedTokens,
|
|
||||||
costUsd: asNumber(parsed.total_cost_usd),
|
|
||||||
subtype: redactHomePathUserSegments(asString(parsed.subtype)),
|
|
||||||
isError: parsed.is_error === true,
|
|
||||||
errors: Array.isArray(parsed.errors)
|
|
||||||
? parsed.errors.map(errorText).map(redactHomePathUserSegments).filter(Boolean)
|
|
||||||
: [],
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "turn.failed") {
|
|
||||||
const usage = asRecord(parsed.usage);
|
|
||||||
const inputTokens = asNumber(usage?.input_tokens);
|
|
||||||
const outputTokens = asNumber(usage?.output_tokens);
|
|
||||||
const cachedTokens = asNumber(usage?.cached_input_tokens, asNumber(usage?.cache_read_input_tokens));
|
|
||||||
const message = errorText(parsed.error ?? parsed.message);
|
|
||||||
return [{
|
|
||||||
kind: "result",
|
|
||||||
ts,
|
|
||||||
text: redactHomePathUserSegments(asString(parsed.result)),
|
|
||||||
inputTokens,
|
|
||||||
outputTokens,
|
|
||||||
cachedTokens,
|
|
||||||
costUsd: asNumber(parsed.total_cost_usd),
|
|
||||||
subtype: redactHomePathUserSegments(asString(parsed.subtype, "turn.failed")),
|
|
||||||
isError: true,
|
|
||||||
errors: message ? [redactHomePathUserSegments(message)] : [],
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
const message = errorText(parsed.message ?? parsed.error ?? parsed);
|
|
||||||
return [{ kind: "stderr", ts, text: redactHomePathUserSegments(message || line) }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ kind: "stdout", ts, text: redactHomePathUserSegments(line) }];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@agentcompany/adapter-gemini-local",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./server": "./src/server/index.ts",
|
|
||||||
"./ui": "./src/ui/index.ts",
|
|
||||||
"./cli": "./src/cli/index.ts"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./server": {
|
|
||||||
"types": "./dist/server/index.d.ts",
|
|
||||||
"import": "./dist/server/index.js"
|
|
||||||
},
|
|
||||||
"./ui": {
|
|
||||||
"types": "./dist/ui/index.d.ts",
|
|
||||||
"import": "./dist/ui/index.js"
|
|
||||||
},
|
|
||||||
"./cli": {
|
|
||||||
"types": "./dist/cli/index.d.ts",
|
|
||||||
"import": "./dist/cli/index.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist",
|
|
||||||
"skills"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@agentcompany/adapter-utils": "workspace:*",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import pc from "picocolors";
|
|
||||||
import { asRecord, errorText, asString, asNumber, stringifyUnknown } from "@agentcompany/adapter-utils/parse-utils";
|
|
||||||
|
|
||||||
function printTextMessage(prefix: string, colorize: (text: string) => string, messageRaw: unknown): void {
|
|
||||||
if (typeof messageRaw === "string") {
|
|
||||||
const text = messageRaw.trim();
|
|
||||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = asRecord(messageRaw);
|
|
||||||
if (!message) return;
|
|
||||||
|
|
||||||
const directText = asString(message.text).trim();
|
|
||||||
if (directText) console.log(colorize(`${prefix}: ${directText}`));
|
|
||||||
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const partRaw of content) {
|
|
||||||
const part = asRecord(partRaw);
|
|
||||||
if (!part) continue;
|
|
||||||
const type = asString(part.type).trim();
|
|
||||||
|
|
||||||
if (type === "output_text" || type === "text" || type === "content") {
|
|
||||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
|
||||||
if (text) console.log(colorize(`${prefix}: ${text}`));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "thinking") {
|
|
||||||
const text = asString(part.text).trim();
|
|
||||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tool_call") {
|
|
||||||
const name = asString(part.name, asString(part.tool, "tool"));
|
|
||||||
console.log(pc.yellow(`tool_call: ${name}`));
|
|
||||||
const input = part.input ?? part.arguments ?? part.args;
|
|
||||||
if (input !== undefined) console.log(pc.gray(stringifyUnknown(input)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tool_result" || type === "tool_response") {
|
|
||||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
|
||||||
const contentText =
|
|
||||||
asString(part.output) ||
|
|
||||||
asString(part.text) ||
|
|
||||||
asString(part.result) ||
|
|
||||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
|
||||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
|
||||||
if (contentText) console.log((isError ? pc.red : pc.gray)(contentText));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function printUsage(parsed: Record<string, unknown>) {
|
|
||||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
|
||||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
|
||||||
const source = usageMetadata ?? usage ?? {};
|
|
||||||
const input = asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount)));
|
|
||||||
const output = asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount)));
|
|
||||||
const cached = asNumber(
|
|
||||||
source.cached_input_tokens,
|
|
||||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
|
||||||
);
|
|
||||||
const cost = asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost)));
|
|
||||||
console.log(pc.blue(`tokens: in=${input} out=${output} cached=${cached} cost=$${cost.toFixed(6)}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function printGeminiStreamEvent(raw: string, debug: boolean): void {
|
|
||||||
const line = raw.trim();
|
|
||||||
if (!line) return;
|
|
||||||
|
|
||||||
let parsed: Record<string, unknown> | null = null;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
console.log(line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = asString(parsed.type);
|
|
||||||
|
|
||||||
if (type === "system") {
|
|
||||||
const subtype = asString(parsed.subtype);
|
|
||||||
if (subtype === "init") {
|
|
||||||
const sessionId =
|
|
||||||
asString(parsed.session_id) ||
|
|
||||||
asString(parsed.sessionId) ||
|
|
||||||
asString(parsed.sessionID) ||
|
|
||||||
asString(parsed.checkpoint_id);
|
|
||||||
const model = asString(parsed.model);
|
|
||||||
const details = [sessionId ? `session: ${sessionId}` : "", model ? `model: ${model}` : ""]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ");
|
|
||||||
console.log(pc.blue(`Gemini init${details ? ` (${details})` : ""}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (subtype === "error") {
|
|
||||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
|
||||||
if (text) console.log(pc.red(`error: ${text}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(pc.blue(`system: ${subtype || "event"}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
printTextMessage("assistant", pc.green, parsed.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "user") {
|
|
||||||
printTextMessage("user", pc.gray, parsed.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "thinking") {
|
|
||||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
|
||||||
if (text) console.log(pc.gray(`thinking: ${text}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tool_call") {
|
|
||||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
|
||||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
|
||||||
const [toolName] = toolCall ? Object.keys(toolCall) : [];
|
|
||||||
if (!toolCall || !toolName) {
|
|
||||||
console.log(pc.yellow(`tool_call${subtype ? `: ${subtype}` : ""}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
|
||||||
if (subtype === "started" || subtype === "start") {
|
|
||||||
console.log(pc.yellow(`tool_call: ${toolName}`));
|
|
||||||
console.log(pc.gray(stringifyUnknown(payload.args ?? payload.input ?? payload.arguments ?? payload)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
|
||||||
const isError =
|
|
||||||
parsed.is_error === true ||
|
|
||||||
payload.is_error === true ||
|
|
||||||
payload.error !== undefined ||
|
|
||||||
asString(payload.status).toLowerCase() === "error";
|
|
||||||
console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`));
|
|
||||||
console.log((isError ? pc.red : pc.gray)(stringifyUnknown(payload.result ?? payload.output ?? payload.error)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(pc.yellow(`tool_call: ${toolName}${subtype ? ` (${subtype})` : ""}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
printUsage(parsed);
|
|
||||||
const subtype = asString(parsed.subtype, "result");
|
|
||||||
const isError = parsed.is_error === true;
|
|
||||||
if (subtype || isError) {
|
|
||||||
console.log((isError ? pc.red : pc.blue)(`result: subtype=${subtype} is_error=${isError ? "true" : "false"}`));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
|
||||||
if (text) console.log(pc.red(`error: ${text}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (debug) {
|
|
||||||
console.log(pc.gray(line));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { printGeminiStreamEvent } from "./format-event.js";
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
export const type = "gemini_local";
|
|
||||||
export const label = "Gemini CLI (local)";
|
|
||||||
export const DEFAULT_GEMINI_LOCAL_MODEL = "auto";
|
|
||||||
|
|
||||||
export const models = [
|
|
||||||
{ id: DEFAULT_GEMINI_LOCAL_MODEL, label: "Auto" },
|
|
||||||
{ id: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
|
|
||||||
{ id: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
|
|
||||||
{ id: "gemini-2.5-flash-lite", label: "Gemini 2.5 Flash Lite" },
|
|
||||||
{ id: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
|
|
||||||
{ id: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const agentConfigurationDoc = `# gemini_local agent configuration
|
|
||||||
|
|
||||||
Adapter: gemini_local
|
|
||||||
|
|
||||||
Use when:
|
|
||||||
- You want AgentCompany to run the Gemini CLI locally on the host machine
|
|
||||||
- You want Gemini chat sessions resumed across heartbeats with --resume
|
|
||||||
- You want AgentCompany skills injected locally without polluting the global environment
|
|
||||||
|
|
||||||
Don't use when:
|
|
||||||
- You need webhook-style external invocation (use http or openclaw_gateway)
|
|
||||||
- You only need a one-shot script without an AI coding agent loop (use process)
|
|
||||||
- Gemini CLI is not installed on the machine that runs AgentCompany
|
|
||||||
|
|
||||||
Core fields:
|
|
||||||
- cwd (string, optional): default absolute working directory fallback for the agent process (created if missing when possible)
|
|
||||||
- instructionsFilePath (string, optional): absolute path to a markdown instructions file prepended to the run prompt
|
|
||||||
- promptTemplate (string, optional): run prompt template
|
|
||||||
- model (string, optional): Gemini model id. Defaults to auto.
|
|
||||||
- sandbox (boolean, optional): run in sandbox mode (default: false, passes --sandbox=none)
|
|
||||||
- command (string, optional): defaults to "gemini"
|
|
||||||
- extraArgs (string[], optional): additional CLI args
|
|
||||||
- env (object, optional): KEY=VALUE environment variables
|
|
||||||
|
|
||||||
Operational fields:
|
|
||||||
- timeoutSec (number, optional): run timeout in seconds
|
|
||||||
- graceSec (number, optional): SIGTERM grace period in seconds
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Runs use positional prompt arguments, not stdin.
|
|
||||||
- Sessions resume with --resume when stored session cwd matches the current cwd.
|
|
||||||
- AgentCompany auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
|
|
||||||
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
|
|
||||||
`;
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import type { Dirent } from "node:fs";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { AdapterExecutionContext, ManagedSkillRef } from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
buildSkillsCandidates,
|
|
||||||
resolveSkillsDir,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
createManagedSkillSymlinks,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
export {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SKILLS_CANDIDATES = buildSkillsCandidates(import.meta.url);
|
|
||||||
|
|
||||||
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> {
|
|
||||||
return resolveSkillsDir(SKILLS_CANDIDATES);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
if (managedSkills.length > 0) {
|
|
||||||
await createManagedSkillSymlinks(managedSkills, skillsHome, onLog);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const skillsDir = await resolveSkillsDir(SKILLS_CANDIDATES);
|
|
||||||
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,294 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { AdapterExecutionContext, AdapterExecutionResult } from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
asBoolean,
|
|
||||||
asNumber,
|
|
||||||
asString,
|
|
||||||
ensureAbsoluteDirectory,
|
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
|
||||||
filterConflictingArgs,
|
|
||||||
redactEnvForLogs,
|
|
||||||
renderTemplate,
|
|
||||||
runChildProcess,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
|
||||||
import {
|
|
||||||
describeGeminiFailure,
|
|
||||||
detectGeminiAuthRequired,
|
|
||||||
isGeminiTurnLimitResult,
|
|
||||||
isGeminiUnknownSessionError,
|
|
||||||
parseGeminiJsonl,
|
|
||||||
} from "./parse.js";
|
|
||||||
import { firstNonEmptyLine } from "@agentcompany/adapter-utils/test-utils";
|
|
||||||
import {
|
|
||||||
resolveGeminiBillingType,
|
|
||||||
renderEnvNote,
|
|
||||||
renderApiAccessNote,
|
|
||||||
ensureGeminiSkillsInjected,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
} from "./execute-helpers.js";
|
|
||||||
|
|
||||||
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 command = asString(config.command, "gemini");
|
|
||||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
|
||||||
const sandbox = asBoolean(config.sandbox, false);
|
|
||||||
|
|
||||||
const { env, cwd, workspaceId, workspaceRepoUrl, workspaceRepoRef } =
|
|
||||||
await buildCommonAgentCompanyEnv(
|
|
||||||
{ runId, agent, config, context, authToken },
|
|
||||||
{ ensureAbsoluteDirectory },
|
|
||||||
);
|
|
||||||
|
|
||||||
const managedSkills = parseManagedSkills(config);
|
|
||||||
await ensureGeminiSkillsInjected(managedSkills, onLog);
|
|
||||||
|
|
||||||
const billingType = resolveGeminiBillingType(env);
|
|
||||||
const runtimeEnv = ensurePathInEnv(env);
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
|
|
||||||
const timeoutSec = asNumber(config.timeoutSec, 0);
|
|
||||||
const graceSec = asNumber(config.graceSec, 20);
|
|
||||||
const extraArgs = parseExtraArgs(config);
|
|
||||||
|
|
||||||
const { sessionId, runtimeSessionId, runtimeSessionCwd, canResumeSession } =
|
|
||||||
resolveSessionResumeState(runtime, cwd);
|
|
||||||
if (runtimeSessionId && !canResumeSession) {
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Gemini session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
||||||
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
||||||
let instructionsPrefix = "";
|
|
||||||
if (instructionsFilePath) {
|
|
||||||
try {
|
|
||||||
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
|
||||||
instructionsPrefix =
|
|
||||||
`${instructionsContents}\n\n` +
|
|
||||||
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
||||||
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const renderedPrompt = renderTemplate(promptTemplate, {
|
|
||||||
agentId: agent.id,
|
|
||||||
companyId: agent.companyId,
|
|
||||||
runId,
|
|
||||||
company: { id: agent.companyId },
|
|
||||||
agent,
|
|
||||||
run: { id: runId, source: "on_demand" },
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
const envNote = renderEnvNote(env);
|
|
||||||
const apiAccessNote = renderApiAccessNote(env);
|
|
||||||
const prompt = `${instructionsPrefix}${envNote}${apiAccessNote}${renderedPrompt}`;
|
|
||||||
|
|
||||||
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`);
|
|
||||||
await fs.writeFile(promptFilePath, prompt, "utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildArgs = (resumeSessionId: string | null) => {
|
|
||||||
const args = ["--output-format", "stream-json"];
|
|
||||||
if (resumeSessionId) args.push("--resume", resumeSessionId);
|
|
||||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
|
||||||
args.push("--approval-mode", "yolo");
|
|
||||||
if (sandbox) {
|
|
||||||
args.push("--sandbox");
|
|
||||||
} else {
|
|
||||||
args.push("--sandbox=none");
|
|
||||||
}
|
|
||||||
if (extraArgs.length > 0) {
|
|
||||||
const managedFlags = [
|
|
||||||
"--approval-mode",
|
|
||||||
"--output-format",
|
|
||||||
"--model",
|
|
||||||
"--sandbox",
|
|
||||||
];
|
|
||||||
const cleaned = filterConflictingArgs(extraArgs, managedFlags);
|
|
||||||
if (cleaned.length > 0) args.push(...cleaned);
|
|
||||||
}
|
|
||||||
if (!useStdin) {
|
|
||||||
args.push(prompt);
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAttempt = async (resumeSessionId: string | null) => {
|
|
||||||
const args = buildArgs(resumeSessionId);
|
|
||||||
if (onMeta) {
|
|
||||||
await onMeta({
|
|
||||||
adapterType: "gemini_local",
|
|
||||||
command,
|
|
||||||
cwd,
|
|
||||||
commandNotes,
|
|
||||||
commandArgs: args.map((value, index) => (
|
|
||||||
index === args.length - 1 ? `<prompt ${prompt.length} chars>` : value
|
|
||||||
)),
|
|
||||||
env: redactEnvForLogs(env),
|
|
||||||
prompt,
|
|
||||||
context,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = await runChildProcess(runId, command, args, {
|
|
||||||
cwd,
|
|
||||||
env,
|
|
||||||
timeoutSec,
|
|
||||||
graceSec,
|
|
||||||
onLog,
|
|
||||||
...(useStdin ? { stdin: prompt } : {}),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
proc,
|
|
||||||
parsed: parseGeminiJsonl(proc.stdout),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const toResult = (
|
|
||||||
attempt: {
|
|
||||||
proc: {
|
|
||||||
exitCode: number | null;
|
|
||||||
signal: string | null;
|
|
||||||
timedOut: boolean;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
};
|
|
||||||
parsed: ReturnType<typeof parseGeminiJsonl>;
|
|
||||||
},
|
|
||||||
clearSessionOnMissingSession = false,
|
|
||||||
isRetry = false,
|
|
||||||
): AdapterExecutionResult => {
|
|
||||||
const authMeta = detectGeminiAuthRequired({
|
|
||||||
parsed: attempt.parsed.resultEvent,
|
|
||||||
stdout: attempt.proc.stdout,
|
|
||||||
stderr: attempt.proc.stderr,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (attempt.proc.timedOut) {
|
|
||||||
return {
|
|
||||||
exitCode: attempt.proc.exitCode,
|
|
||||||
signal: attempt.proc.signal,
|
|
||||||
timedOut: true,
|
|
||||||
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
||||||
errorCode: authMeta.requiresAuth ? "gemini_auth_required" : null,
|
|
||||||
clearSession: clearSessionOnMissingSession,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSessionForTurnLimit = isGeminiTurnLimitResult(attempt.parsed.resultEvent, attempt.proc.exitCode);
|
|
||||||
|
|
||||||
const canFallbackToRuntimeSession = !isRetry;
|
|
||||||
const resolvedSessionId = attempt.parsed.sessionId
|
|
||||||
?? (canFallbackToRuntimeSession ? (runtimeSessionId ?? runtime.sessionId ?? null) : null);
|
|
||||||
const resolvedSessionParams = resolvedSessionId
|
|
||||||
? ({
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
cwd,
|
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
|
||||||
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
||||||
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
||||||
} as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
||||||
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
||||||
const structuredFailure = attempt.parsed.resultEvent
|
|
||||||
? describeGeminiFailure(attempt.parsed.resultEvent)
|
|
||||||
: null;
|
|
||||||
const fallbackErrorMessage =
|
|
||||||
parsedError ||
|
|
||||||
structuredFailure ||
|
|
||||||
stderrLine ||
|
|
||||||
`Gemini exited with code ${attempt.proc.exitCode ?? -1}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
exitCode: attempt.proc.exitCode,
|
|
||||||
signal: attempt.proc.signal,
|
|
||||||
timedOut: false,
|
|
||||||
errorMessage: (attempt.proc.exitCode ?? 0) === 0 ? null : fallbackErrorMessage,
|
|
||||||
errorCode: (attempt.proc.exitCode ?? 0) !== 0 && authMeta.requiresAuth ? "gemini_auth_required" : null,
|
|
||||||
usage: attempt.parsed.usage,
|
|
||||||
sessionId: resolvedSessionId,
|
|
||||||
sessionParams: resolvedSessionParams,
|
|
||||||
sessionDisplayId: resolvedSessionId,
|
|
||||||
provider: "google",
|
|
||||||
model,
|
|
||||||
billingType,
|
|
||||||
costUsd: attempt.parsed.costUsd,
|
|
||||||
resultJson: attempt.parsed.resultEvent ?? {
|
|
||||||
stdout: attempt.proc.stdout,
|
|
||||||
stderr: attempt.proc.stderr,
|
|
||||||
},
|
|
||||||
summary: attempt.parsed.summary,
|
|
||||||
question: attempt.parsed.question,
|
|
||||||
clearSession: clearSessionForTurnLimit || Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const initial = await runAttempt(sessionId);
|
|
||||||
if (
|
|
||||||
sessionId &&
|
|
||||||
!initial.proc.timedOut &&
|
|
||||||
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
||||||
isGeminiUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
|
||||||
) {
|
|
||||||
await onLog(
|
|
||||||
"stderr",
|
|
||||||
`[agent-company] Gemini resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
||||||
);
|
|
||||||
const retry = await runAttempt(null);
|
|
||||||
return toResult(retry, true, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return toResult(initial);
|
|
||||||
} finally {
|
|
||||||
if (promptFilePath) {
|
|
||||||
await fs.unlink(promptFilePath).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export { execute } from "./execute.js";
|
|
||||||
export { testEnvironment } from "./test.js";
|
|
||||||
export {
|
|
||||||
parseGeminiJsonl,
|
|
||||||
isGeminiUnknownSessionError,
|
|
||||||
describeGeminiFailure,
|
|
||||||
detectGeminiAuthRequired,
|
|
||||||
isGeminiTurnLimitResult,
|
|
||||||
} from "./parse.js";
|
|
||||||
export { listGeminiSkills, syncGeminiSkills } from "./skills.js";
|
|
||||||
import { createStandardSessionCodec } from "@agentcompany/adapter-utils/session-codec";
|
|
||||||
|
|
||||||
export const sessionCodec = createStandardSessionCodec({ extraSessionIdKeys: ["sessionID"] });
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
import { asNumber, asString, parseJson, parseObject } from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
function collectMessageText(message: unknown): string[] {
|
|
||||||
if (typeof message === "string") {
|
|
||||||
const trimmed = message.trim();
|
|
||||||
return trimmed ? [trimmed] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const record = parseObject(message);
|
|
||||||
const direct = asString(record.text, "").trim();
|
|
||||||
const lines: string[] = direct ? [direct] : [];
|
|
||||||
const content = Array.isArray(record.content) ? record.content : [];
|
|
||||||
|
|
||||||
for (const partRaw of content) {
|
|
||||||
const part = parseObject(partRaw);
|
|
||||||
const type = asString(part.type, "").trim();
|
|
||||||
if (type === "output_text" || type === "text" || type === "content") {
|
|
||||||
const text = asString(part.text, "").trim() || asString(part.content, "").trim();
|
|
||||||
if (text) lines.push(text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionId(event: Record<string, unknown>): string | null {
|
|
||||||
return (
|
|
||||||
asString(event.session_id, "").trim() ||
|
|
||||||
asString(event.sessionId, "").trim() ||
|
|
||||||
asString(event.sessionID, "").trim() ||
|
|
||||||
asString(event.checkpoint_id, "").trim() ||
|
|
||||||
asString(event.thread_id, "").trim() ||
|
|
||||||
null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function asErrorText(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
const rec = parseObject(value);
|
|
||||||
const message =
|
|
||||||
asString(rec.message, "") ||
|
|
||||||
asString(rec.error, "") ||
|
|
||||||
asString(rec.code, "") ||
|
|
||||||
asString(rec.detail, "");
|
|
||||||
if (message) return message;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(rec);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function accumulateUsage(
|
|
||||||
target: { inputTokens: number; cachedInputTokens: number; outputTokens: number },
|
|
||||||
usageRaw: unknown,
|
|
||||||
) {
|
|
||||||
const usage = parseObject(usageRaw);
|
|
||||||
const usageMetadata = parseObject(usage.usageMetadata);
|
|
||||||
const source = Object.keys(usageMetadata).length > 0 ? usageMetadata : usage;
|
|
||||||
|
|
||||||
target.inputTokens += asNumber(
|
|
||||||
source.input_tokens,
|
|
||||||
asNumber(source.inputTokens, asNumber(source.promptTokenCount, 0)),
|
|
||||||
);
|
|
||||||
target.cachedInputTokens += asNumber(
|
|
||||||
source.cached_input_tokens,
|
|
||||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount, 0)),
|
|
||||||
);
|
|
||||||
target.outputTokens += asNumber(
|
|
||||||
source.output_tokens,
|
|
||||||
asNumber(source.outputTokens, asNumber(source.candidatesTokenCount, 0)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseGeminiJsonl(stdout: string) {
|
|
||||||
let sessionId: string | null = null;
|
|
||||||
const messages: string[] = [];
|
|
||||||
let errorMessage: string | null = null;
|
|
||||||
let costUsd: number | null = null;
|
|
||||||
let resultEvent: Record<string, unknown> | null = null;
|
|
||||||
let question: { prompt: string; choices: Array<{ key: string; label: string; description?: string }> } | null = null;
|
|
||||||
const usage = {
|
|
||||||
inputTokens: 0,
|
|
||||||
cachedInputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const rawLine of stdout.split(/\r?\n/)) {
|
|
||||||
const line = rawLine.trim();
|
|
||||||
if (!line) continue;
|
|
||||||
|
|
||||||
const event = parseJson(line);
|
|
||||||
if (!event) continue;
|
|
||||||
|
|
||||||
const foundSessionId = readSessionId(event);
|
|
||||||
if (foundSessionId) sessionId = foundSessionId;
|
|
||||||
|
|
||||||
const type = asString(event.type, "").trim();
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
messages.push(...collectMessageText(event.message));
|
|
||||||
const messageObj = parseObject(event.message);
|
|
||||||
const content = Array.isArray(messageObj.content) ? messageObj.content : [];
|
|
||||||
for (const partRaw of content) {
|
|
||||||
const part = parseObject(partRaw);
|
|
||||||
if (asString(part.type, "").trim() === "question") {
|
|
||||||
question = {
|
|
||||||
prompt: asString(part.prompt, "").trim(),
|
|
||||||
choices: (Array.isArray(part.choices) ? part.choices : []).map((choiceRaw) => {
|
|
||||||
const choice = parseObject(choiceRaw);
|
|
||||||
return {
|
|
||||||
key: asString(choice.key, "").trim(),
|
|
||||||
label: asString(choice.label, "").trim(),
|
|
||||||
description: asString(choice.description, "").trim() || undefined,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
break; // only one question per message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
resultEvent = event;
|
|
||||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
|
||||||
const resultText =
|
|
||||||
asString(event.result, "").trim() ||
|
|
||||||
asString(event.text, "").trim() ||
|
|
||||||
asString(event.response, "").trim();
|
|
||||||
if (resultText && messages.length === 0) messages.push(resultText);
|
|
||||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
|
||||||
const isError = event.is_error === true || asString(event.subtype, "").toLowerCase() === "error";
|
|
||||||
if (isError) {
|
|
||||||
const text = asErrorText(event.error ?? event.message ?? event.result).trim();
|
|
||||||
if (text) errorMessage = text;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
|
||||||
if (text) errorMessage = text;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "system") {
|
|
||||||
const subtype = asString(event.subtype, "").trim().toLowerCase();
|
|
||||||
if (subtype === "error") {
|
|
||||||
const text = asErrorText(event.error ?? event.message ?? event.detail).trim();
|
|
||||||
if (text) errorMessage = text;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "text") {
|
|
||||||
const part = parseObject(event.part);
|
|
||||||
const text = asString(part.text, "").trim();
|
|
||||||
if (text) messages.push(text);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "step_finish" || event.usage || event.usageMetadata) {
|
|
||||||
accumulateUsage(usage, event.usage ?? event.usageMetadata);
|
|
||||||
costUsd = asNumber(event.total_cost_usd, asNumber(event.cost_usd, asNumber(event.cost, costUsd ?? 0))) || costUsd;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
summary: messages.join("\n\n").trim(),
|
|
||||||
usage,
|
|
||||||
costUsd,
|
|
||||||
errorMessage,
|
|
||||||
resultEvent,
|
|
||||||
question,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isGeminiUnknownSessionError(stdout: string, stderr: string): boolean {
|
|
||||||
const haystack = `${stdout}\n${stderr}`
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
return /unknown\s+session|session\s+.*\s+not\s+found|resume\s+.*\s+not\s+found|checkpoint\s+.*\s+not\s+found|cannot\s+resume|failed\s+to\s+resume/i.test(
|
|
||||||
haystack,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractGeminiErrorMessages(parsed: Record<string, unknown>): string[] {
|
|
||||||
const messages: string[] = [];
|
|
||||||
const errorMsg = asString(parsed.error, "").trim();
|
|
||||||
if (errorMsg) messages.push(errorMsg);
|
|
||||||
|
|
||||||
const raw = Array.isArray(parsed.errors) ? parsed.errors : [];
|
|
||||||
for (const entry of raw) {
|
|
||||||
if (typeof entry === "string") {
|
|
||||||
const msg = entry.trim();
|
|
||||||
if (msg) messages.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
|
|
||||||
const obj = entry as Record<string, unknown>;
|
|
||||||
const msg = asString(obj.message, "") || asString(obj.error, "") || asString(obj.code, "");
|
|
||||||
if (msg) {
|
|
||||||
messages.push(msg);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
messages.push(JSON.stringify(obj));
|
|
||||||
} catch {
|
|
||||||
// skip non-serializable entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeGeminiFailure(parsed: Record<string, unknown>): string | null {
|
|
||||||
const status = asString(parsed.status, "");
|
|
||||||
const errors = extractGeminiErrorMessages(parsed);
|
|
||||||
|
|
||||||
const detail = errors[0] ?? "";
|
|
||||||
const parts = ["Gemini run failed"];
|
|
||||||
if (status) parts.push(`status=${status}`);
|
|
||||||
if (detail) parts.push(detail);
|
|
||||||
return parts.length > 1 ? parts.join(": ") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
|
|
||||||
|
|
||||||
export function detectGeminiAuthRequired(input: {
|
|
||||||
parsed: Record<string, unknown> | null;
|
|
||||||
stdout: string;
|
|
||||||
stderr: string;
|
|
||||||
}): { requiresAuth: boolean } {
|
|
||||||
const errors = extractGeminiErrorMessages(input.parsed ?? {});
|
|
||||||
const messages = [...errors, input.stdout, input.stderr]
|
|
||||||
.join("\n")
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const requiresAuth = messages.some((line) => GEMINI_AUTH_REQUIRED_RE.test(line));
|
|
||||||
return { requiresAuth };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isGeminiTurnLimitResult(
|
|
||||||
parsed: Record<string, unknown> | null | undefined,
|
|
||||||
exitCode?: number | null,
|
|
||||||
): boolean {
|
|
||||||
if (exitCode === 53) return true;
|
|
||||||
if (!parsed) return false;
|
|
||||||
|
|
||||||
const status = asString(parsed.status, "").trim().toLowerCase();
|
|
||||||
if (status === "turn_limit" || status === "max_turns") return true;
|
|
||||||
|
|
||||||
const error = asString(parsed.error, "").trim();
|
|
||||||
return /turn\s*limit|max(?:imum)?\s+turns?/i.test(error);
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Gemini adapter skill listing / sync -- persistent mode
|
|
||||||
// Skills are symlinked into ~/.gemini/skills/ and persist across runs.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
import { promises as fs } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import type {
|
|
||||||
AdapterSkillContext,
|
|
||||||
AdapterSkillSnapshot,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
parseObject,
|
|
||||||
asString,
|
|
||||||
readAgentCompanyRuntimeSkillEntries,
|
|
||||||
readInstalledSkillTargets,
|
|
||||||
resolveAgentCompanyDesiredSkillNames,
|
|
||||||
buildPersistentSkillSnapshot,
|
|
||||||
ensureAgentCompanySkillSymlink,
|
|
||||||
removeMaintainerOnlySkillSymlinks,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
|
|
||||||
function resolveGeminiSkillsHome(config: Record<string, unknown>): string {
|
|
||||||
const env = parseObject(config.env);
|
|
||||||
const home = asString(env.HOME, asString(env.USERPROFILE, ""));
|
|
||||||
const base = home || (process.env.HOME ?? process.env.USERPROFILE ?? "");
|
|
||||||
return path.join(base, ".gemini", "skills");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildGeminiSkillSnapshot(
|
|
||||||
config: Record<string, unknown>,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
const moduleDir = path.resolve(import.meta.dirname ?? __dirname);
|
|
||||||
const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir);
|
|
||||||
const desiredSkills = resolveAgentCompanyDesiredSkillNames(config, availableEntries);
|
|
||||||
const skillsHome = resolveGeminiSkillsHome(config);
|
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
|
||||||
|
|
||||||
return buildPersistentSkillSnapshot({
|
|
||||||
adapterType: "gemini_local",
|
|
||||||
availableEntries,
|
|
||||||
desiredSkills,
|
|
||||||
installed,
|
|
||||||
skillsHome,
|
|
||||||
locationLabel: "~/.gemini/skills",
|
|
||||||
missingDetail: "Configured but not currently linked into the Gemini skills home.",
|
|
||||||
externalConflictDetail: "Skill name is occupied by an external installation.",
|
|
||||||
externalDetail: "Installed outside AgentCompany management.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List the current skill state for the Gemini adapter.
|
|
||||||
* Persistent mode: reports actual installed state in ~/.gemini/skills/.
|
|
||||||
*/
|
|
||||||
export async function listGeminiSkills(
|
|
||||||
ctx: AdapterSkillContext,
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
return buildGeminiSkillSnapshot(ctx.config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sync skills for the Gemini adapter.
|
|
||||||
* Persistent mode: creates symlinks for desired skills, removes stale ones,
|
|
||||||
* then returns a fresh snapshot.
|
|
||||||
*/
|
|
||||||
export async function syncGeminiSkills(
|
|
||||||
ctx: AdapterSkillContext,
|
|
||||||
desiredSkills: string[],
|
|
||||||
): Promise<AdapterSkillSnapshot> {
|
|
||||||
const moduleDir = path.resolve(import.meta.dirname ?? __dirname);
|
|
||||||
const config = ctx.config;
|
|
||||||
const availableEntries = await readAgentCompanyRuntimeSkillEntries(config, moduleDir);
|
|
||||||
const skillsHome = resolveGeminiSkillsHome(config);
|
|
||||||
|
|
||||||
// Ensure the skills home directory exists
|
|
||||||
await fs.mkdir(skillsHome, { recursive: true });
|
|
||||||
|
|
||||||
// Build a set of desired skill keys for quick lookup
|
|
||||||
const desiredSet = new Set(desiredSkills);
|
|
||||||
|
|
||||||
// Symlink type for Windows compatibility
|
|
||||||
const symlinkType = process.platform === "win32" ? ("junction" as const) : undefined;
|
|
||||||
const linkSkill = async (source: string, target: string) => {
|
|
||||||
await fs.symlink(source, target, symlinkType);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create/repair symlinks for desired skills
|
|
||||||
const syncedRuntimeNames = new Set<string>();
|
|
||||||
for (const available of availableEntries) {
|
|
||||||
if (!desiredSet.has(available.key)) continue;
|
|
||||||
const target = path.join(skillsHome, available.runtimeName);
|
|
||||||
await ensureAgentCompanySkillSymlink(available.source, target, linkSkill);
|
|
||||||
syncedRuntimeNames.add(available.runtimeName);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stale maintainer-only symlinks that are no longer desired
|
|
||||||
await removeMaintainerOnlySkillSymlinks(skillsHome, syncedRuntimeNames);
|
|
||||||
|
|
||||||
// Return fresh snapshot reflecting the new state
|
|
||||||
const installed = await readInstalledSkillTargets(skillsHome);
|
|
||||||
return buildPersistentSkillSnapshot({
|
|
||||||
adapterType: "gemini_local",
|
|
||||||
availableEntries,
|
|
||||||
desiredSkills,
|
|
||||||
installed,
|
|
||||||
skillsHome,
|
|
||||||
locationLabel: "~/.gemini/skills",
|
|
||||||
missingDetail: "Configured but not currently linked into the Gemini skills home.",
|
|
||||||
externalConflictDetail: "Skill name is occupied by an external installation.",
|
|
||||||
externalDetail: "Installed outside AgentCompany management.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import type {
|
|
||||||
AdapterEnvironmentCheck,
|
|
||||||
AdapterEnvironmentTestContext,
|
|
||||||
AdapterEnvironmentTestResult,
|
|
||||||
} from "@agentcompany/adapter-utils";
|
|
||||||
import {
|
|
||||||
asBoolean,
|
|
||||||
asString,
|
|
||||||
asStringArray,
|
|
||||||
ensureAbsoluteDirectory,
|
|
||||||
ensureCommandResolvable,
|
|
||||||
ensurePathInEnv,
|
|
||||||
parseObject,
|
|
||||||
runChildProcess,
|
|
||||||
} from "@agentcompany/adapter-utils/server-utils";
|
|
||||||
import { summarizeStatus, isNonEmpty, commandLooksLike, summarizeProbeDetail } from "@agentcompany/adapter-utils/test-utils";
|
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
|
||||||
import { detectGeminiAuthRequired, parseGeminiJsonl } from "./parse.js";
|
|
||||||
|
|
||||||
export async function testEnvironment(
|
|
||||||
ctx: AdapterEnvironmentTestContext,
|
|
||||||
): Promise<AdapterEnvironmentTestResult> {
|
|
||||||
const checks: AdapterEnvironmentCheck[] = [];
|
|
||||||
const config = parseObject(ctx.config);
|
|
||||||
const command = asString(config.command, "gemini");
|
|
||||||
const cwd = asString(config.cwd, process.cwd());
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_cwd_valid",
|
|
||||||
level: "info",
|
|
||||||
message: `Working directory is valid: ${cwd}`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_cwd_invalid",
|
|
||||||
level: "error",
|
|
||||||
message: err instanceof Error ? err.message : "Invalid working directory",
|
|
||||||
detail: cwd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const envConfig = parseObject(config.env);
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
|
||||||
if (typeof value === "string") env[key] = value;
|
|
||||||
}
|
|
||||||
const runtimeEnv = ensurePathInEnv(env);
|
|
||||||
try {
|
|
||||||
await ensureCommandResolvable(command, cwd, runtimeEnv);
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_command_resolvable",
|
|
||||||
level: "info",
|
|
||||||
message: `Command is executable: ${command}`,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_command_unresolvable",
|
|
||||||
level: "error",
|
|
||||||
message: err instanceof Error ? err.message : "Command is not executable",
|
|
||||||
detail: command,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const configGeminiApiKey = env.GEMINI_API_KEY;
|
|
||||||
const configGoogleApiKey = env.GOOGLE_API_KEY;
|
|
||||||
const hasGca = env.GOOGLE_GENAI_USE_GCA === "true";
|
|
||||||
if (
|
|
||||||
isNonEmpty(configGeminiApiKey) ||
|
|
||||||
isNonEmpty(configGoogleApiKey) ||
|
|
||||||
hasGca
|
|
||||||
) {
|
|
||||||
const source = hasGca
|
|
||||||
? "Google account login (GCA)"
|
|
||||||
: "adapter runtime env";
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_api_key_present",
|
|
||||||
level: "info",
|
|
||||||
message: "Gemini API credentials are set for CLI authentication.",
|
|
||||||
detail: `Detected in ${source}.`,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_api_key_missing",
|
|
||||||
level: "info",
|
|
||||||
message: "No explicit API key detected. Gemini CLI may still authenticate via `gemini auth login` (OAuth).",
|
|
||||||
hint: "If the hello probe fails with an auth error, set GEMINI_API_KEY or GOOGLE_API_KEY in connector secret bindings, or run `gemini auth login`.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const canRunProbe =
|
|
||||||
checks.every((check) => check.code !== "gemini_cwd_invalid" && check.code !== "gemini_command_unresolvable");
|
|
||||||
if (canRunProbe && commandLooksLike(command, "gemini")) {
|
|
||||||
try {
|
|
||||||
const model = asString(config.model, DEFAULT_GEMINI_LOCAL_MODEL).trim();
|
|
||||||
const approvalMode = asString(config.approvalMode, asBoolean(config.yolo, false) ? "yolo" : "default");
|
|
||||||
const sandbox = asBoolean(config.sandbox, false);
|
|
||||||
const extraArgs = (() => {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const args = ["--output-format", "stream-json"];
|
|
||||||
if (model && model !== DEFAULT_GEMINI_LOCAL_MODEL) args.push("--model", model);
|
|
||||||
if (approvalMode !== "default") args.push("--approval-mode", approvalMode);
|
|
||||||
if (sandbox) {
|
|
||||||
args.push("--sandbox");
|
|
||||||
} else {
|
|
||||||
args.push("--sandbox=none");
|
|
||||||
}
|
|
||||||
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
||||||
args.push("Respond with hello.");
|
|
||||||
|
|
||||||
const probe = await runChildProcess(
|
|
||||||
`gemini-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
||||||
command,
|
|
||||||
args,
|
|
||||||
{ cwd, env, timeoutSec: 45, graceSec: 5, onLog: async () => { } },
|
|
||||||
);
|
|
||||||
const parsed = parseGeminiJsonl(probe.stdout);
|
|
||||||
const detail = summarizeProbeDetail(probe.stdout, probe.stderr, parsed.errorMessage);
|
|
||||||
const authMeta = detectGeminiAuthRequired({ parsed: parsed.resultEvent, stdout: probe.stdout, stderr: probe.stderr });
|
|
||||||
|
|
||||||
if (probe.timedOut) {
|
|
||||||
checks.push({ code: "gemini_hello_probe_timed_out", level: "info", message: "Gemini hello probe timed out." });
|
|
||||||
} else if ((probe.exitCode ?? 1) === 0) {
|
|
||||||
const summary = parsed.summary.trim();
|
|
||||||
checks.push({ code: "gemini_hello_probe_passed", level: "info", message: "Gemini probe completed.", ...(summary ? { detail: summary.replace(/\s+/g, " ").trim().slice(0, 240) } : {}) });
|
|
||||||
} else if (authMeta.requiresAuth) {
|
|
||||||
checks.push({
|
|
||||||
code: "gemini_hello_probe_auth_required", level: "warn",
|
|
||||||
message: "Gemini CLI is installed, but authentication is not ready.",
|
|
||||||
...(detail ? { detail } : {}),
|
|
||||||
hint: "Run `gemini auth` or configure GEMINI_API_KEY / GOOGLE_API_KEY.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
checks.push({ code: "gemini_hello_probe_failed", level: "info", message: "Gemini hello probe exited with non-zero code.", ...(detail ? { detail } : {}) });
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
checks.push({ code: "gemini_hello_probe_error", level: "info", message: "Gemini hello probe could not run." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
adapterType: ctx.adapterType,
|
|
||||||
status: summarizeStatus(checks),
|
|
||||||
checks,
|
|
||||||
testedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import type { CreateConfigValues } from "@agentcompany/adapter-utils";
|
|
||||||
import { parseCommaArgs, parseEnvVars, parseEnvBindings } from "@agentcompany/adapter-utils/config-utils";
|
|
||||||
import { DEFAULT_GEMINI_LOCAL_MODEL } from "../index.js";
|
|
||||||
|
|
||||||
export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, unknown> {
|
|
||||||
const ac: Record<string, unknown> = {};
|
|
||||||
if (v.cwd) ac.cwd = v.cwd;
|
|
||||||
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
|
|
||||||
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
|
|
||||||
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
|
|
||||||
ac.timeoutSec = 0;
|
|
||||||
ac.graceSec = 15;
|
|
||||||
const env = parseEnvBindings(v.envBindings);
|
|
||||||
const legacy = parseEnvVars(v.envVars);
|
|
||||||
for (const [key, value] of Object.entries(legacy)) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(env, key)) {
|
|
||||||
env[key] = { type: "plain", value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Object.keys(env).length > 0) ac.env = env;
|
|
||||||
ac.sandbox = !v.dangerouslyBypassSandbox;
|
|
||||||
|
|
||||||
if (v.command) ac.command = v.command;
|
|
||||||
if (v.extraArgs) ac.extraArgs = parseCommaArgs(v.extraArgs);
|
|
||||||
return ac;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { parseGeminiStdoutLine } from "./parse-stdout.js";
|
|
||||||
export { buildGeminiLocalConfig } from "./build-config.js";
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
import type { TranscriptEntry } from "@agentcompany/adapter-utils";
|
|
||||||
import { asRecord, safeJsonParse, errorText } from "@agentcompany/adapter-utils/parse-utils";
|
|
||||||
|
|
||||||
function asString(value: unknown, fallback = ""): string {
|
|
||||||
return typeof value === "string" ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown, fallback = 0): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifyUnknown(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectTextEntries(messageRaw: unknown, ts: string, kind: "assistant" | "user"): TranscriptEntry[] {
|
|
||||||
if (typeof messageRaw === "string") {
|
|
||||||
const text = messageRaw.trim();
|
|
||||||
return text ? [{ kind, ts, text }] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = asRecord(messageRaw);
|
|
||||||
if (!message) return [];
|
|
||||||
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
const directText = asString(message.text).trim();
|
|
||||||
if (directText) entries.push({ kind, ts, text: directText });
|
|
||||||
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const partRaw of content) {
|
|
||||||
const part = asRecord(partRaw);
|
|
||||||
if (!part) continue;
|
|
||||||
const type = asString(part.type).trim();
|
|
||||||
if (type !== "output_text" && type !== "text" && type !== "content") continue;
|
|
||||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
|
||||||
if (text) entries.push({ kind, ts, text });
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseAssistantMessage(messageRaw: unknown, ts: string): TranscriptEntry[] {
|
|
||||||
if (typeof messageRaw === "string") {
|
|
||||||
const text = messageRaw.trim();
|
|
||||||
return text ? [{ kind: "assistant", ts, text }] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = asRecord(messageRaw);
|
|
||||||
if (!message) return [];
|
|
||||||
|
|
||||||
const entries: TranscriptEntry[] = [];
|
|
||||||
const directText = asString(message.text).trim();
|
|
||||||
if (directText) entries.push({ kind: "assistant", ts, text: directText });
|
|
||||||
|
|
||||||
const content = Array.isArray(message.content) ? message.content : [];
|
|
||||||
for (const partRaw of content) {
|
|
||||||
const part = asRecord(partRaw);
|
|
||||||
if (!part) continue;
|
|
||||||
const type = asString(part.type).trim();
|
|
||||||
|
|
||||||
if (type === "output_text" || type === "text" || type === "content") {
|
|
||||||
const text = asString(part.text).trim() || asString(part.content).trim();
|
|
||||||
if (text) entries.push({ kind: "assistant", ts, text });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "thinking") {
|
|
||||||
const text = asString(part.text).trim();
|
|
||||||
if (text) entries.push({ kind: "thinking", ts, text });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tool_call") {
|
|
||||||
const name = asString(part.name, asString(part.tool, "tool"));
|
|
||||||
entries.push({
|
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name,
|
|
||||||
input: part.input ?? part.arguments ?? part.args ?? {},
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tool_result" || type === "tool_response") {
|
|
||||||
const toolUseId =
|
|
||||||
asString(part.tool_use_id) ||
|
|
||||||
asString(part.toolUseId) ||
|
|
||||||
asString(part.call_id) ||
|
|
||||||
asString(part.id) ||
|
|
||||||
"tool_result";
|
|
||||||
const contentText =
|
|
||||||
asString(part.output) ||
|
|
||||||
asString(part.text) ||
|
|
||||||
asString(part.result) ||
|
|
||||||
stringifyUnknown(part.output ?? part.result ?? part.text ?? part.response);
|
|
||||||
const isError = part.is_error === true || asString(part.status).toLowerCase() === "error";
|
|
||||||
entries.push({
|
|
||||||
kind: "tool_result",
|
|
||||||
ts,
|
|
||||||
toolUseId,
|
|
||||||
content: contentText,
|
|
||||||
isError,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseTopLevelToolEvent(parsed: Record<string, unknown>, ts: string): TranscriptEntry[] {
|
|
||||||
const subtype = asString(parsed.subtype).trim().toLowerCase();
|
|
||||||
const callId = asString(parsed.call_id, asString(parsed.callId, asString(parsed.id, "tool_call")));
|
|
||||||
const toolCall = asRecord(parsed.tool_call ?? parsed.toolCall);
|
|
||||||
if (!toolCall) {
|
|
||||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const [toolName] = Object.keys(toolCall);
|
|
||||||
if (!toolName) {
|
|
||||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}` }];
|
|
||||||
}
|
|
||||||
const payload = asRecord(toolCall[toolName]) ?? {};
|
|
||||||
|
|
||||||
if (subtype === "started" || subtype === "start") {
|
|
||||||
return [{
|
|
||||||
kind: "tool_call",
|
|
||||||
ts,
|
|
||||||
name: toolName,
|
|
||||||
input: payload.args ?? payload.input ?? payload.arguments ?? payload,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subtype === "completed" || subtype === "complete" || subtype === "finished") {
|
|
||||||
const result = payload.result ?? payload.output ?? payload.error;
|
|
||||||
const isError =
|
|
||||||
parsed.is_error === true ||
|
|
||||||
payload.is_error === true ||
|
|
||||||
payload.error !== undefined ||
|
|
||||||
asString(payload.status).toLowerCase() === "error";
|
|
||||||
return [{
|
|
||||||
kind: "tool_result",
|
|
||||||
ts,
|
|
||||||
toolUseId: callId,
|
|
||||||
content: result !== undefined ? stringifyUnknown(result) : `${toolName} completed`,
|
|
||||||
isError,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ kind: "system", ts, text: `tool_call${subtype ? ` (${subtype})` : ""}: ${toolName}` }];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionId(parsed: Record<string, unknown>): string {
|
|
||||||
return (
|
|
||||||
asString(parsed.session_id) ||
|
|
||||||
asString(parsed.sessionId) ||
|
|
||||||
asString(parsed.sessionID) ||
|
|
||||||
asString(parsed.checkpoint_id) ||
|
|
||||||
asString(parsed.thread_id)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUsage(parsed: Record<string, unknown>) {
|
|
||||||
const usage = asRecord(parsed.usage) ?? asRecord(parsed.usageMetadata);
|
|
||||||
const usageMetadata = asRecord(usage?.usageMetadata);
|
|
||||||
const source = usageMetadata ?? usage ?? {};
|
|
||||||
return {
|
|
||||||
inputTokens: asNumber(source.input_tokens, asNumber(source.inputTokens, asNumber(source.promptTokenCount))),
|
|
||||||
outputTokens: asNumber(source.output_tokens, asNumber(source.outputTokens, asNumber(source.candidatesTokenCount))),
|
|
||||||
cachedTokens: asNumber(
|
|
||||||
source.cached_input_tokens,
|
|
||||||
asNumber(source.cachedInputTokens, asNumber(source.cachedContentTokenCount)),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseGeminiStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|
||||||
const parsed = asRecord(safeJsonParse(line));
|
|
||||||
if (!parsed) {
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = asString(parsed.type);
|
|
||||||
|
|
||||||
if (type === "system") {
|
|
||||||
const subtype = asString(parsed.subtype);
|
|
||||||
if (subtype === "init") {
|
|
||||||
const sessionId = readSessionId(parsed);
|
|
||||||
return [{ kind: "init", ts, model: asString(parsed.model, "gemini"), sessionId }];
|
|
||||||
}
|
|
||||||
if (subtype === "error") {
|
|
||||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
|
||||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
|
||||||
}
|
|
||||||
return [{ kind: "system", ts, text: `system: ${subtype || "event"}` }];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "assistant") {
|
|
||||||
return parseAssistantMessage(parsed.message, ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "user") {
|
|
||||||
return collectTextEntries(parsed.message, ts, "user");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "thinking") {
|
|
||||||
const text = asString(parsed.text).trim() || asString(asRecord(parsed.delta)?.text).trim();
|
|
||||||
return text ? [{ kind: "thinking", ts, text }] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "tool_call") {
|
|
||||||
return parseTopLevelToolEvent(parsed, ts);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "result") {
|
|
||||||
const usage = readUsage(parsed);
|
|
||||||
const errors = parsed.is_error === true
|
|
||||||
? [errorText(parsed.error ?? parsed.message ?? parsed.result)].filter(Boolean)
|
|
||||||
: [];
|
|
||||||
return [{
|
|
||||||
kind: "result",
|
|
||||||
ts,
|
|
||||||
text: asString(parsed.result) || asString(parsed.text) || asString(parsed.response),
|
|
||||||
inputTokens: usage.inputTokens,
|
|
||||||
outputTokens: usage.outputTokens,
|
|
||||||
cachedTokens: usage.cachedTokens,
|
|
||||||
costUsd: asNumber(parsed.total_cost_usd, asNumber(parsed.cost_usd, asNumber(parsed.cost))),
|
|
||||||
subtype: asString(parsed.subtype, "result"),
|
|
||||||
isError: parsed.is_error === true,
|
|
||||||
errors,
|
|
||||||
}];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === "error") {
|
|
||||||
const text = errorText(parsed.error ?? parsed.message ?? parsed.detail);
|
|
||||||
return [{ kind: "stderr", ts, text: text || "error" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{ kind: "stdout", ts, text: line }];
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@agentcompany/adapter-utils",
|
|
||||||
"version": "0.3.0",
|
|
||||||
"type": "module",
|
|
||||||
"exports": {
|
|
||||||
".": "./src/index.ts",
|
|
||||||
"./*": "./src/*.ts"
|
|
||||||
},
|
|
||||||
"publishConfig": {
|
|
||||||
"access": "public",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./dist/index.d.ts",
|
|
||||||
"import": "./dist/index.js"
|
|
||||||
},
|
|
||||||
"./*": {
|
|
||||||
"types": "./dist/*.d.ts",
|
|
||||||
"import": "./dist/*.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"main": "./dist/index.js",
|
|
||||||
"types": "./dist/index.d.ts"
|
|
||||||
},
|
|
||||||
"files": [
|
|
||||||
"dist"
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"clean": "rm -rf dist",
|
|
||||||
"typecheck": "tsc --noEmit"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^24.6.0",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
export function parseCommaArgs(value: string): string[] {
|
|
||||||
return value
|
|
||||||
.split(",")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEnvVars(text: string): Record<string, string> {
|
|
||||||
const env: Record<string, string> = {};
|
|
||||||
for (const line of text.split(/\r?\n/)) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
||||||
const eq = trimmed.indexOf("=");
|
|
||||||
if (eq <= 0) continue;
|
|
||||||
const key = trimmed.slice(0, eq).trim();
|
|
||||||
const value = trimmed.slice(eq + 1);
|
|
||||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
||||||
env[key] = value;
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseEnvBindings(bindings: unknown): Record<string, unknown> {
|
|
||||||
if (typeof bindings !== "object" || bindings === null || Array.isArray(bindings)) return {};
|
|
||||||
const env: Record<string, unknown> = {};
|
|
||||||
for (const [key, raw] of Object.entries(bindings)) {
|
|
||||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) continue;
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
env[key] = { type: "plain", value: raw };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) continue;
|
|
||||||
const rec = raw as Record<string, unknown>;
|
|
||||||
if (rec.type === "plain" && typeof rec.value === "string") {
|
|
||||||
env[key] = { type: "plain", value: rec.value };
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (rec.type === "secret_ref" && typeof rec.secretId === "string") {
|
|
||||||
env[key] = {
|
|
||||||
type: "secret_ref",
|
|
||||||
secretId: rec.secretId,
|
|
||||||
...(typeof rec.version === "number" || rec.version === "latest"
|
|
||||||
? { version: rec.version }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return env;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseJsonObject(text: string): Record<string, unknown> | null {
|
|
||||||
const trimmed = text.trim();
|
|
||||||
if (!trimmed) return null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
||||||
return parsed as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import { fileURLToPath } from "node:url";
|
|
||||||
import path from "node:path";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import type { ManagedSkillRef } from "./types.js";
|
|
||||||
import { asString, asStringArray, parseObject } from "./server-utils-parsers.js";
|
|
||||||
import { buildAgentCompanyEnv } from "./server-utils-parsers.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// hasNonEmptyEnvValue
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
|
||||||
const raw = env[key];
|
|
||||||
return typeof raw === "string" && raw.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Skills directory resolution
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the standard two-candidate list for a given adapter package.
|
|
||||||
* `importMetaUrl` should be `import.meta.url` from the adapter's execute-helpers.ts.
|
|
||||||
* - published: <pkg>/dist/server/ -> <pkg>/skills/
|
|
||||||
* - dev: src/server/ -> repo root/skills/
|
|
||||||
*/
|
|
||||||
export function buildSkillsCandidates(importMetaUrl: string): string[] {
|
|
||||||
const moduleDir = path.dirname(fileURLToPath(importMetaUrl));
|
|
||||||
return [
|
|
||||||
path.resolve(moduleDir, "../../skills"),
|
|
||||||
path.resolve(moduleDir, "../../../../../skills"),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the first existing skills directory from the given candidates.
|
|
||||||
*/
|
|
||||||
export async function resolveSkillsDir(candidates: string[]): Promise<string | null> {
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
|
||||||
if (isDir) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ManagedSkills type guard + parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function parseManagedSkills(config: Record<string, unknown>): ManagedSkillRef[] {
|
|
||||||
if (!Array.isArray(config.managedSkills)) return [];
|
|
||||||
return (config.managedSkills as ManagedSkillRef[]).filter(
|
|
||||||
(s) => typeof s.runtimeName === "string" && typeof s.sourcePath === "string",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// extraArgs parser
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function parseExtraArgs(config: Record<string, unknown>): string[] {
|
|
||||||
const fromExtraArgs = asStringArray(config.extraArgs);
|
|
||||||
if (fromExtraArgs.length > 0) return fromExtraArgs;
|
|
||||||
return asStringArray(config.args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Common env building
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface CommonAgentCompanyEnvInput {
|
|
||||||
runId: string;
|
|
||||||
agent: { id: string; companyId: string };
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
context: Record<string, unknown>;
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommonAgentCompanyEnvResult {
|
|
||||||
env: Record<string, string>;
|
|
||||||
cwd: string;
|
|
||||||
effectiveWorkspaceCwd: string;
|
|
||||||
workspaceId: string | null;
|
|
||||||
workspaceRepoUrl: string | null;
|
|
||||||
workspaceRepoRef: string | null;
|
|
||||||
hasExplicitApiKey: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the AGENT_COMPANY_* environment block common to all local adapters.
|
|
||||||
* Returns the env map plus resolved workspace fields needed for session params.
|
|
||||||
*/
|
|
||||||
export async function buildCommonAgentCompanyEnv(
|
|
||||||
input: CommonAgentCompanyEnvInput,
|
|
||||||
opts: {
|
|
||||||
ensureAbsoluteDirectory: (dir: string, options: { createIfMissing: boolean }) => Promise<void>;
|
|
||||||
},
|
|
||||||
): Promise<CommonAgentCompanyEnvResult> {
|
|
||||||
const { runId, agent, config, context, authToken } = input;
|
|
||||||
|
|
||||||
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 as unknown, "");
|
|
||||||
|
|
||||||
const configuredCwd = asString(config.cwd, "");
|
|
||||||
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
||||||
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
||||||
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
||||||
await opts.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;
|
|
||||||
|
|
||||||
// Merge caller-configured env overrides
|
|
||||||
for (const [key, value] of Object.entries(envConfig)) {
|
|
||||||
if (typeof value === "string") env[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasExplicitApiKey && authToken) {
|
|
||||||
env.AGENT_COMPANY_API_KEY = authToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
env,
|
|
||||||
cwd,
|
|
||||||
effectiveWorkspaceCwd,
|
|
||||||
workspaceId,
|
|
||||||
workspaceRepoUrl,
|
|
||||||
workspaceRepoRef,
|
|
||||||
hasExplicitApiKey,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Session resume validation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface SessionResumeState {
|
|
||||||
sessionId: string | null;
|
|
||||||
runtimeSessionId: string;
|
|
||||||
runtimeSessionCwd: string;
|
|
||||||
canResumeSession: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSessionResumeState(
|
|
||||||
runtime: { sessionId?: string | null; sessionParams?: Record<string, unknown> | null },
|
|
||||||
cwd: string,
|
|
||||||
): SessionResumeState {
|
|
||||||
const runtimeSessionParams = parseObject(runtime.sessionParams);
|
|
||||||
const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? "");
|
|
||||||
const runtimeSessionCwd = asString(runtimeSessionParams.cwd, "");
|
|
||||||
const canResumeSession =
|
|
||||||
runtimeSessionId.length > 0 &&
|
|
||||||
(runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd));
|
|
||||||
return {
|
|
||||||
sessionId: canResumeSession ? runtimeSessionId : null,
|
|
||||||
runtimeSessionId,
|
|
||||||
runtimeSessionCwd,
|
|
||||||
canResumeSession,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export type {
|
|
||||||
AdapterAgent,
|
|
||||||
AdapterRuntime,
|
|
||||||
UsageSummary,
|
|
||||||
AdapterBillingType,
|
|
||||||
AdapterRuntimeServiceReport,
|
|
||||||
AdapterExecutionResult,
|
|
||||||
AdapterInvocationMeta,
|
|
||||||
AdapterExecutionContext,
|
|
||||||
AdapterEnvironmentCheckLevel,
|
|
||||||
AdapterEnvironmentCheck,
|
|
||||||
AdapterEnvironmentTestStatus,
|
|
||||||
AdapterEnvironmentTestResult,
|
|
||||||
AdapterEnvironmentTestContext,
|
|
||||||
AdapterSkillSyncMode,
|
|
||||||
AdapterSkillState,
|
|
||||||
AdapterSkillOrigin,
|
|
||||||
AdapterSkillEntry,
|
|
||||||
AdapterSkillSnapshot,
|
|
||||||
AdapterSkillContext,
|
|
||||||
AdapterSessionCodec,
|
|
||||||
AdapterModel,
|
|
||||||
HireApprovedPayload,
|
|
||||||
HireApprovedHookResult,
|
|
||||||
ServerAdapterModule,
|
|
||||||
TranscriptEntry,
|
|
||||||
StdoutLineParser,
|
|
||||||
CLIAdapterModule,
|
|
||||||
CreateConfigValues,
|
|
||||||
ManagedSkillRef,
|
|
||||||
} from "./types.js";
|
|
||||||
export {
|
|
||||||
REDACTED_HOME_PATH_USER,
|
|
||||||
redactHomePathUserSegments,
|
|
||||||
redactHomePathUserSegmentsInValue,
|
|
||||||
} from "./log-redaction.js";
|
|
||||||
export type {
|
|
||||||
CommonAgentCompanyEnvInput,
|
|
||||||
CommonAgentCompanyEnvResult,
|
|
||||||
SessionResumeState,
|
|
||||||
} from "./execute-helpers.js";
|
|
||||||
export {
|
|
||||||
hasNonEmptyEnvValue,
|
|
||||||
buildSkillsCandidates,
|
|
||||||
resolveSkillsDir,
|
|
||||||
parseManagedSkills,
|
|
||||||
parseExtraArgs,
|
|
||||||
buildCommonAgentCompanyEnv,
|
|
||||||
resolveSessionResumeState,
|
|
||||||
} from "./execute-helpers.js";
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
export const REDACTED_HOME_PATH_USER = "[]";
|
|
||||||
|
|
||||||
const HOME_PATH_PATTERNS = [
|
|
||||||
{
|
|
||||||
regex: /\/Users\/[^/\\\s]+/g,
|
|
||||||
replace: `/Users/${REDACTED_HOME_PATH_USER}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /\/home\/[^/\\\s]+/g,
|
|
||||||
replace: `/home/${REDACTED_HOME_PATH_USER}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
regex: /([A-Za-z]:\\Users\\)[^\\/\s]+/g,
|
|
||||||
replace: `$1${REDACTED_HOME_PATH_USER}`,
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
||||||
const proto = Object.getPrototypeOf(value);
|
|
||||||
return proto === Object.prototype || proto === null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function redactHomePathUserSegments(text: string): string {
|
|
||||||
let result = text;
|
|
||||||
for (const pattern of HOME_PATH_PATTERNS) {
|
|
||||||
result = result.replace(pattern.regex, pattern.replace);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function redactHomePathUserSegmentsInValue<T>(value: T): T {
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return redactHomePathUserSegments(value) as T;
|
|
||||||
}
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value.map((entry) => redactHomePathUserSegmentsInValue(entry)) as T;
|
|
||||||
}
|
|
||||||
if (!isPlainObject(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const redacted: Record<string, unknown> = {};
|
|
||||||
for (const [key, entry] of Object.entries(value)) {
|
|
||||||
redacted[key] = redactHomePathUserSegmentsInValue(entry);
|
|
||||||
}
|
|
||||||
return redacted as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function safeJsonParse(text: string): unknown {
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorText(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
const rec = asRecord(value);
|
|
||||||
if (!rec) return "";
|
|
||||||
const msg =
|
|
||||||
(typeof rec.message === "string" && rec.message) ||
|
|
||||||
(typeof rec.error === "string" && rec.error) ||
|
|
||||||
(typeof rec.code === "string" && rec.code) ||
|
|
||||||
"";
|
|
||||||
if (msg) return msg;
|
|
||||||
try {
|
|
||||||
return JSON.stringify(rec);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asString(value: unknown, fallback = ""): string {
|
|
||||||
return typeof value === "string" ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function asNumber(value: unknown, fallback = 0): number {
|
|
||||||
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stringifyUnknown(value: unknown): string {
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
if (value === null || value === undefined) return "";
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value, null, 2);
|
|
||||||
} catch {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
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";
|
|
||||||
}
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
import { spawn, spawnSync, 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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const WINDOWS_CMD_SCRIPT_EXTENSIONS = new Set([".bat", ".cmd"]);
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildWindowsSpawnTarget(
|
|
||||||
executable: string,
|
|
||||||
args: string[],
|
|
||||||
shell: string,
|
|
||||||
): SpawnTarget {
|
|
||||||
const extension = path.extname(executable).toLowerCase();
|
|
||||||
if (!WINDOWS_CMD_SCRIPT_EXTENSIONS.has(extension)) {
|
|
||||||
return { command: executable, args };
|
|
||||||
}
|
|
||||||
const commandLine = [quoteForCmd(executable), ...args.map(quoteForCmd)].join(" ");
|
|
||||||
return {
|
|
||||||
command: shell,
|
|
||||||
args: ["/d", "/c", `chcp 65001 >nul && ${commandLine}`],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export 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";
|
|
||||||
return buildWindowsSpawnTarget(executable, args, shell);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
spawnSync("taskkill", ["/T", "/F", "/PID", String(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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
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]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from "./server-utils-parsers.js";
|
|
||||||
export * from "./server-utils-process.js";
|
|
||||||
export * from "./server-utils-skills.js";
|
|
||||||
export * from "./server-utils-skills-sync.js";
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import type { AdapterSessionCodec } from "./types.js";
|
|
||||||
|
|
||||||
function readNonEmptyString(value: unknown): string | null {
|
|
||||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SessionCodecOptions {
|
|
||||||
/** Additional key aliases for sessionId beyond "sessionId" and "session_id" (e.g. "sessionID" for Gemini/OpenCode). */
|
|
||||||
extraSessionIdKeys?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a standard session codec shared across adapters.
|
|
||||||
* All adapters use the same serialize/deserialize/getDisplayId logic,
|
|
||||||
* differing only in which keys they accept for sessionId.
|
|
||||||
*/
|
|
||||||
export function createStandardSessionCodec(options?: SessionCodecOptions): AdapterSessionCodec {
|
|
||||||
const extraKeys = options?.extraSessionIdKeys ?? [];
|
|
||||||
|
|
||||||
function readSessionId(record: Record<string, unknown>): string | null {
|
|
||||||
return (
|
|
||||||
readNonEmptyString(record.sessionId) ??
|
|
||||||
readNonEmptyString(record.session_id) ??
|
|
||||||
extraKeys.reduce<string | null>((found, key) => found ?? readNonEmptyString(record[key]), null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractFields(record: Record<string, unknown>) {
|
|
||||||
const sessionId = readSessionId(record);
|
|
||||||
if (!sessionId) return null;
|
|
||||||
const cwd =
|
|
||||||
readNonEmptyString(record.cwd) ??
|
|
||||||
readNonEmptyString(record.workdir) ??
|
|
||||||
readNonEmptyString(record.folder);
|
|
||||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
|
||||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
|
||||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
|
||||||
return {
|
|
||||||
sessionId,
|
|
||||||
...(cwd ? { cwd } : {}),
|
|
||||||
...(workspaceId ? { workspaceId } : {}),
|
|
||||||
...(repoUrl ? { repoUrl } : {}),
|
|
||||||
...(repoRef ? { repoRef } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
deserialize(raw: unknown) {
|
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null;
|
|
||||||
return extractFields(raw as Record<string, unknown>);
|
|
||||||
},
|
|
||||||
serialize(params: Record<string, unknown> | null) {
|
|
||||||
if (!params) return null;
|
|
||||||
return extractFields(params);
|
|
||||||
},
|
|
||||||
getDisplayId(params: Record<string, unknown> | null) {
|
|
||||||
if (!params) return null;
|
|
||||||
return readSessionId(params);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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";
|
|
||||||
if (checks.some((check) => check.level === "warn")) return "warn";
|
|
||||||
return "pass";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNonEmpty(value: unknown): value is string {
|
|
||||||
return typeof value === "string" && value.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firstNonEmptyLine(text: string): string {
|
|
||||||
return (
|
|
||||||
text
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.find(Boolean) ?? ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commandLooksLike(command: string, expected: string): boolean {
|
|
||||||
const normalized = normalizeCommandValue(command).replace(/\\/g, "/");
|
|
||||||
const base = path.posix.basename(normalized).toLowerCase();
|
|
||||||
return base === expected || base === `${expected}.cmd` || base === `${expected}.exe`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeProbeDetail(stdout: string, stderr: string, parsedError?: string | null): string | null {
|
|
||||||
const raw = parsedError?.trim() || firstNonEmptyLine(stderr) || firstNonEmptyLine(stdout);
|
|
||||||
if (!raw) return null;
|
|
||||||
const clean = raw.replace(/\s+/g, " ").trim();
|
|
||||||
const max = 240;
|
|
||||||
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
export interface AdapterAgent {
|
|
||||||
id: string;
|
|
||||||
companyId: string;
|
|
||||||
name: string;
|
|
||||||
adapterType: string | null;
|
|
||||||
adapterConfig: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterRuntime {
|
|
||||||
/**
|
|
||||||
* Legacy single session id view. Prefer `sessionParams` + `sessionDisplayId`.
|
|
||||||
*/
|
|
||||||
sessionId: string | null;
|
|
||||||
sessionParams: Record<string, unknown> | null;
|
|
||||||
sessionDisplayId: string | null;
|
|
||||||
taskKey: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageSummary {
|
|
||||||
inputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
cachedInputTokens?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdapterBillingType = "api" | "subscription" | "unknown";
|
|
||||||
|
|
||||||
export interface AdapterRuntimeServiceReport {
|
|
||||||
id?: string | null;
|
|
||||||
projectId?: string | null;
|
|
||||||
projectWorkspaceId?: string | null;
|
|
||||||
issueId?: string | null;
|
|
||||||
scopeType?: "project_workspace" | "execution_workspace" | "run" | "agent";
|
|
||||||
scopeId?: string | null;
|
|
||||||
serviceName: string;
|
|
||||||
status?: "starting" | "running" | "stopped" | "failed";
|
|
||||||
lifecycle?: "shared" | "ephemeral";
|
|
||||||
reuseKey?: string | null;
|
|
||||||
command?: string | null;
|
|
||||||
cwd?: string | null;
|
|
||||||
port?: number | null;
|
|
||||||
url?: string | null;
|
|
||||||
providerRef?: string | null;
|
|
||||||
ownerAgentId?: string | null;
|
|
||||||
stopPolicy?: Record<string, unknown> | null;
|
|
||||||
healthStatus?: "unknown" | "healthy" | "unhealthy";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterExecutionResult {
|
|
||||||
exitCode: number | null;
|
|
||||||
signal: string | null;
|
|
||||||
timedOut: boolean;
|
|
||||||
errorMessage?: string | null;
|
|
||||||
errorCode?: string | null;
|
|
||||||
errorMeta?: Record<string, unknown>;
|
|
||||||
usage?: UsageSummary;
|
|
||||||
/**
|
|
||||||
* Legacy single session id output. Prefer `sessionParams` + `sessionDisplayId`.
|
|
||||||
*/
|
|
||||||
sessionId?: string | null;
|
|
||||||
sessionParams?: Record<string, unknown> | null;
|
|
||||||
sessionDisplayId?: string | null;
|
|
||||||
provider?: string | null;
|
|
||||||
model?: string | null;
|
|
||||||
billingType?: AdapterBillingType | null;
|
|
||||||
costUsd?: number | null;
|
|
||||||
resultJson?: Record<string, unknown> | null;
|
|
||||||
runtimeServices?: AdapterRuntimeServiceReport[];
|
|
||||||
summary?: string | null;
|
|
||||||
clearSession?: boolean;
|
|
||||||
question?: {
|
|
||||||
prompt: string;
|
|
||||||
choices: Array<{
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
description?: string;
|
|
||||||
}>;
|
|
||||||
} | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterSessionCodec {
|
|
||||||
deserialize(raw: unknown): Record<string, unknown> | null;
|
|
||||||
serialize(params: Record<string, unknown> | null): Record<string, unknown> | null;
|
|
||||||
getDisplayId?: (params: Record<string, unknown> | null) => string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterInvocationMeta {
|
|
||||||
adapterType: string;
|
|
||||||
command: string;
|
|
||||||
cwd?: string;
|
|
||||||
commandArgs?: string[];
|
|
||||||
commandNotes?: string[];
|
|
||||||
env?: Record<string, string>;
|
|
||||||
prompt?: string;
|
|
||||||
context?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterExecutionContext {
|
|
||||||
runId: string;
|
|
||||||
agent: AdapterAgent;
|
|
||||||
runtime: AdapterRuntime;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
context: Record<string, unknown>;
|
|
||||||
onLog: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
|
||||||
onMeta?: (meta: AdapterInvocationMeta) => Promise<void>;
|
|
||||||
authToken?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterModel {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdapterEnvironmentCheckLevel = "info" | "warn" | "error";
|
|
||||||
|
|
||||||
export interface AdapterEnvironmentCheck {
|
|
||||||
code: string;
|
|
||||||
level: AdapterEnvironmentCheckLevel;
|
|
||||||
message: string;
|
|
||||||
detail?: string | null;
|
|
||||||
hint?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdapterEnvironmentTestStatus = "pass" | "warn" | "fail";
|
|
||||||
|
|
||||||
export interface AdapterEnvironmentTestResult {
|
|
||||||
adapterType: string;
|
|
||||||
status: AdapterEnvironmentTestStatus;
|
|
||||||
checks: AdapterEnvironmentCheck[];
|
|
||||||
testedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AdapterSkillSyncMode = "unsupported" | "persistent" | "ephemeral";
|
|
||||||
|
|
||||||
export type AdapterSkillState =
|
|
||||||
| "available"
|
|
||||||
| "configured"
|
|
||||||
| "installed"
|
|
||||||
| "missing"
|
|
||||||
| "stale"
|
|
||||||
| "external";
|
|
||||||
|
|
||||||
export type AdapterSkillOrigin =
|
|
||||||
| "company_managed"
|
|
||||||
| "paperclip_required"
|
|
||||||
| "user_installed"
|
|
||||||
| "external_unknown";
|
|
||||||
|
|
||||||
export interface AdapterSkillEntry {
|
|
||||||
key: string;
|
|
||||||
runtimeName: string | null;
|
|
||||||
desired: boolean;
|
|
||||||
managed: boolean;
|
|
||||||
required?: boolean;
|
|
||||||
requiredReason?: string | null;
|
|
||||||
state: AdapterSkillState;
|
|
||||||
origin?: AdapterSkillOrigin;
|
|
||||||
originLabel?: string | null;
|
|
||||||
locationLabel?: string | null;
|
|
||||||
readOnly?: boolean;
|
|
||||||
sourcePath?: string | null;
|
|
||||||
targetPath?: string | null;
|
|
||||||
detail?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterSkillSnapshot {
|
|
||||||
adapterType: string;
|
|
||||||
supported: boolean;
|
|
||||||
mode: AdapterSkillSyncMode;
|
|
||||||
desiredSkills: string[];
|
|
||||||
entries: AdapterSkillEntry[];
|
|
||||||
warnings: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterSkillContext {
|
|
||||||
agentId: string;
|
|
||||||
companyId: string;
|
|
||||||
adapterType: string;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdapterEnvironmentTestContext {
|
|
||||||
companyId: string;
|
|
||||||
adapterType: string;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
deployment?: {
|
|
||||||
mode?: "local_trusted" | "authenticated";
|
|
||||||
exposure?: "private" | "public";
|
|
||||||
bindHost?: string | null;
|
|
||||||
allowedHostnames?: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Payload for the onHireApproved adapter lifecycle hook (e.g. join-request or hire_agent approval). */
|
|
||||||
export interface HireApprovedPayload {
|
|
||||||
companyId: string;
|
|
||||||
agentId: string;
|
|
||||||
agentName: string;
|
|
||||||
adapterType: string;
|
|
||||||
/** "join_request" | "approval" */
|
|
||||||
source: "join_request" | "approval";
|
|
||||||
sourceId: string;
|
|
||||||
approvedAt: string;
|
|
||||||
/** Canonical operator-facing message for cloud adapters to show the user. */
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of onHireApproved hook; failures are non-fatal to the approval flow. */
|
|
||||||
export interface HireApprovedHookResult {
|
|
||||||
ok: boolean;
|
|
||||||
error?: string;
|
|
||||||
detail?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerAdapterModule {
|
|
||||||
type: string;
|
|
||||||
execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
|
|
||||||
testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
|
|
||||||
listSkills?: (ctx: AdapterSkillContext) => Promise<AdapterSkillSnapshot>;
|
|
||||||
syncSkills?: (ctx: AdapterSkillContext, desiredSkills: string[]) => Promise<AdapterSkillSnapshot>;
|
|
||||||
sessionCodec?: AdapterSessionCodec;
|
|
||||||
supportsLocalAgentJwt?: boolean;
|
|
||||||
models?: AdapterModel[];
|
|
||||||
listModels?: () => Promise<AdapterModel[]>;
|
|
||||||
agentConfigurationDoc?: string;
|
|
||||||
/** Per D-04: If true, adapter manages its own context window (e.g. Claude, Codex). Session rotation is skipped but usage is still tracked. */
|
|
||||||
nativeContextManagement?: boolean;
|
|
||||||
/**
|
|
||||||
* Optional lifecycle hook when an agent is approved/hired (join-request or hire_agent approval).
|
|
||||||
* adapterConfig is the agent's adapter config so the adapter can e.g. send a callback to a configured URL.
|
|
||||||
*/
|
|
||||||
onHireApproved?: (
|
|
||||||
payload: HireApprovedPayload,
|
|
||||||
adapterConfig: Record<string, unknown>,
|
|
||||||
) => Promise<HireApprovedHookResult>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TranscriptEntry =
|
|
||||||
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
|
||||||
| { kind: "thinking"; ts: string; text: string; delta?: boolean }
|
|
||||||
| { kind: "user"; ts: string; text: string }
|
|
||||||
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
|
|
||||||
| { kind: "tool_result"; ts: string; toolUseId: string; content: string; isError: boolean }
|
|
||||||
| { kind: "init"; ts: string; model: string; sessionId: string }
|
|
||||||
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
|
|
||||||
| { kind: "stderr"; ts: string; text: string }
|
|
||||||
| { kind: "system"; ts: string; text: string }
|
|
||||||
| { kind: "stdout"; ts: string; text: string };
|
|
||||||
|
|
||||||
export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];
|
|
||||||
|
|
||||||
export interface CLIAdapterModule {
|
|
||||||
type: string;
|
|
||||||
formatStdoutEvent: (line: string, debug: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Skill reference passed from main process to worker for adapter injection. */
|
|
||||||
export interface ManagedSkillRef {
|
|
||||||
/** Skill slug used as directory name in the adapter's skill directory */
|
|
||||||
runtimeName: string;
|
|
||||||
/** Absolute path to the materialized skill directory on disk */
|
|
||||||
sourcePath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateConfigValues {
|
|
||||||
adapterType: string;
|
|
||||||
cwd: string;
|
|
||||||
instructionsFilePath?: string;
|
|
||||||
promptTemplate: string;
|
|
||||||
model: string;
|
|
||||||
thinkingEffort: string;
|
|
||||||
chrome: boolean;
|
|
||||||
dangerouslySkipPermissions: boolean;
|
|
||||||
search: boolean;
|
|
||||||
dangerouslyBypassSandbox: boolean;
|
|
||||||
command: string;
|
|
||||||
args: string;
|
|
||||||
extraArgs: string;
|
|
||||||
envVars: string;
|
|
||||||
envBindings: Record<string, unknown>;
|
|
||||||
url: string;
|
|
||||||
bootstrapPrompt: string;
|
|
||||||
payloadTemplateJson?: string;
|
|
||||||
workspaceStrategyType?: string;
|
|
||||||
workspaceBaseRef?: string;
|
|
||||||
workspaceBranchTemplate?: string;
|
|
||||||
worktreeParentDir?: string;
|
|
||||||
runtimeServicesJson?: string;
|
|
||||||
maxTurnsPerRun: number;
|
|
||||||
heartbeatEnabled: boolean;
|
|
||||||
intervalSec: number;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../../tsconfig.base.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src"
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
Generated
+2063
-98
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user