refactor(connectors): remove adapter module references from services
Drops every `definition.module.*` call and legacy id hardcode (claude_local, codex_local, gemini_local, claude_compat_*) from the connector services. Health checks now evaluate stored credentials against the declarative auth descriptor, model policy reads provider models via pi-ai, matching keys off provider ids, and hire-hook logs activity without invoking an adapter hook. Introduces a local normalizeCommandValue helper to replace the deleted @agentcompany/adapter-utils/server-utils import.
This commit is contained in:
1 parent
60178ceafa
commit
06a3ba603e
14 files changed
+267
-377
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
|
||||
function quoteShell(value: string) {
|
||||
@@ -14,15 +14,8 @@ function escapeAppleScript(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
|
||||
function quoteCmd(value: string) {
|
||||
const normalized = normalizeCommandValue(value);
|
||||
if (!normalized.length) return '""';
|
||||
const escaped = normalized.replace(/"/g, '""');
|
||||
return /[\s"&<>|^()]/.test(escaped) ? `"${escaped}"` : escaped;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shared terminal spawning — used by both auth and generic commands */
|
||||
/* Shared terminal spawning — used by generic install flows */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function writeWindowsBatchFile(lines: string[]): string {
|
||||
@@ -95,72 +88,22 @@ function spawnTerminalWithScript(script: string, _title: string, _cwd: string):
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Connector auth terminal (existing API — unchanged behavior) */
|
||||
/* Connector auth terminal — deprecated under pi-mono */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function connectorAuthSpec(connectorId: ConnectorId, command: string) {
|
||||
const normalizedCommand = normalizeCommandValue(command);
|
||||
switch (connectorId) {
|
||||
case "codex_local":
|
||||
return {
|
||||
title: "Codex Login",
|
||||
shellCommand: `${quoteShell(normalizedCommand)} login`,
|
||||
windowsCommand: `${quoteCmd(normalizedCommand)} login`,
|
||||
intro: "AgentCompany is opening the Codex login flow.",
|
||||
};
|
||||
case "claude_local":
|
||||
return {
|
||||
title: "Claude Code Login",
|
||||
shellCommand: `${quoteShell(normalizedCommand)} auth login`,
|
||||
windowsCommand: `${quoteCmd(normalizedCommand)} auth login`,
|
||||
intro: "AgentCompany is opening the Claude Code login flow.",
|
||||
};
|
||||
case "gemini_local":
|
||||
return {
|
||||
title: "Gemini CLI Login",
|
||||
shellCommand: `${quoteShell(normalizedCommand)}`,
|
||||
windowsCommand: quoteCmd(normalizedCommand),
|
||||
intro: "AgentCompany is opening Gemini CLI. If authentication is required, use /auth in the Gemini session.",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function openConnectorAuthTerminal(connectorId: ConnectorId, command: string, cwd?: string) {
|
||||
const targetCwd = cwd || process.env.HOME || homedir() || process.cwd();
|
||||
const spec = connectorAuthSpec(connectorId, command);
|
||||
if (!spec) return false;
|
||||
|
||||
if (process.platform === "win32") {
|
||||
// Windows: emit a real .bat file instead of chaining commands with " & ".
|
||||
// Chaining via " & " means any unescaped "&" in an embedded field (title,
|
||||
// command, intro) would open a shell operator injection. A batch file has
|
||||
// one command per line and is not subject to that risk.
|
||||
const batchPath = writeWindowsBatchFile([
|
||||
`@echo off`,
|
||||
`echo ${spec.intro}`,
|
||||
`echo.`,
|
||||
`cd /d "${targetCwd}"`,
|
||||
spec.windowsCommand,
|
||||
`echo.`,
|
||||
`echo You can close this terminal after the login flow completes.`,
|
||||
`pause`,
|
||||
]);
|
||||
return spawnTerminalWithScript(`"${batchPath}"`, spec.title, targetCwd);
|
||||
}
|
||||
|
||||
// Unix: build a shell script
|
||||
const shell = process.env.SHELL || "/bin/zsh";
|
||||
const script = [
|
||||
"clear",
|
||||
`echo ${quoteShell(spec.intro)}`,
|
||||
"echo",
|
||||
`cd ${quoteShell(targetCwd)}`,
|
||||
spec.shellCommand,
|
||||
"echo",
|
||||
`echo ${quoteShell("You can close this terminal after the login flow completes.")}`,
|
||||
`exec ${quoteShell(shell)} -l`,
|
||||
].join("; ");
|
||||
return spawnTerminalWithScript(script, spec.title, targetCwd);
|
||||
/**
|
||||
* Pi-mono has no CLI auth flow. OAuth runs through `oauth-login-service`,
|
||||
* API keys are entered via the Settings form, and cloud providers (AWS,
|
||||
* Azure, GCP) use environment-driven credentials. The IPC handler still
|
||||
* calls this function for legacy UI compatibility, so we keep the signature
|
||||
* but treat it as a no-op that reports "not available".
|
||||
*/
|
||||
export async function openConnectorAuthTerminal(
|
||||
_connectorId: ConnectorId,
|
||||
_command: string,
|
||||
_cwd?: string,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import { getEnvApiKey } from "@mariozechner/pi-ai";
|
||||
import type { AppDatabase } from "./database";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import { buildSanitizedRuntimeEnv } from "./runtime-env";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
import type { CredentialStore } from "./credentials";
|
||||
|
||||
interface ConnectorHealthDependencies {
|
||||
db: AppDatabase;
|
||||
@@ -13,149 +11,117 @@ interface ConnectorHealthDependencies {
|
||||
error: (...args: unknown[]) => void;
|
||||
};
|
||||
publishDomainChanged: () => void;
|
||||
credentialStore: CredentialStore;
|
||||
}
|
||||
|
||||
type HealthState = {
|
||||
status: "ready" | "auth_required" | "not_installed" | "error";
|
||||
authState: "ok" | "required" | "error" | "unknown";
|
||||
lastError: string | null;
|
||||
};
|
||||
|
||||
export function createConnectorHealthService({
|
||||
db,
|
||||
logger: _logger,
|
||||
logger,
|
||||
publishDomainChanged,
|
||||
credentialStore,
|
||||
}: ConnectorHealthDependencies) {
|
||||
function randomFallbackCompanyId() {
|
||||
const snapshot = db.listSnapshot();
|
||||
return snapshot.companies[0]?.id ?? "00000000-0000-0000-0000-000000000000";
|
||||
}
|
||||
|
||||
function resolveProbeCwd() {
|
||||
const snapshot = db.listSnapshot();
|
||||
const currentCompanyId = db.getCurrentCompanyId();
|
||||
const preferredWorkspace = snapshot.workspaces.find((workspace) => workspace.companyId === currentCompanyId)
|
||||
?? snapshot.workspaces[0]
|
||||
?? null;
|
||||
return preferredWorkspace?.localPath ?? process.env.USERPROFILE ?? process.env.HOME ?? homedir() ?? process.cwd();
|
||||
}
|
||||
function evaluateCredential(id: ConnectorId): HealthState {
|
||||
const definition = getConnectorDefinition(id);
|
||||
const stored = credentialStore.read(id);
|
||||
|
||||
function parseVersion(command: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const normalizedCommand = normalizeCommandValue(command);
|
||||
const child = spawn(normalizedCommand, ["--version"], {
|
||||
env: buildSanitizedRuntimeEnv(process.env, {}),
|
||||
shell: false,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += String(chunk);
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
buffer += String(chunk);
|
||||
});
|
||||
child.on("error", () => {
|
||||
if (!resolved) { resolved = true; resolve(null); }
|
||||
});
|
||||
child.on("close", () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
const versionLine = buffer.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? null;
|
||||
resolve(versionLine);
|
||||
switch (definition.auth.type) {
|
||||
case "oauth": {
|
||||
if (stored?.kind !== "oauth") {
|
||||
return { status: "auth_required", authState: "required", lastError: "OAuth login required." };
|
||||
}
|
||||
});
|
||||
// Kill the child after 8 seconds to avoid blocking startup.
|
||||
setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
try { child.kill("SIGKILL"); } catch { /* process may have already exited */ }
|
||||
resolve(null);
|
||||
const creds = stored.value;
|
||||
const expires = typeof creds.expires === "number" ? creds.expires : null;
|
||||
if (expires !== null && expires <= Date.now()) {
|
||||
return { status: "auth_required", authState: "required", lastError: "OAuth token expired." };
|
||||
}
|
||||
}, 8_000);
|
||||
});
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
case "api_key": {
|
||||
if (stored?.kind === "api_key") {
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
if (definition.provider && getEnvApiKey(definition.provider as never)) {
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
return {
|
||||
status: "auth_required",
|
||||
authState: "required",
|
||||
lastError: `API key required (${definition.auth.envKey}).`,
|
||||
};
|
||||
}
|
||||
case "aws": {
|
||||
if (stored?.kind !== "aws") {
|
||||
return { status: "auth_required", authState: "required", lastError: "AWS credentials not configured." };
|
||||
}
|
||||
const cfg = stored.value;
|
||||
if (cfg.authMode === "profile" && !cfg.awsProfile) {
|
||||
return { status: "error", authState: "error", lastError: "AWS profile missing." };
|
||||
}
|
||||
if (cfg.authMode === "iam" && (!cfg.accessKeyId || !cfg.secretAccessKey)) {
|
||||
return { status: "error", authState: "error", lastError: "AWS IAM keys missing." };
|
||||
}
|
||||
if (cfg.authMode === "bearer" && !cfg.bearerToken) {
|
||||
return { status: "error", authState: "error", lastError: "AWS bearer token missing." };
|
||||
}
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
case "azure": {
|
||||
if (stored?.kind !== "azure") {
|
||||
return { status: "auth_required", authState: "required", lastError: "Azure credentials not configured." };
|
||||
}
|
||||
const cfg = stored.value;
|
||||
if (!cfg.apiKey) {
|
||||
return { status: "error", authState: "error", lastError: "Azure OpenAI API key missing." };
|
||||
}
|
||||
if (!cfg.baseUrl && !cfg.resourceName) {
|
||||
return { status: "error", authState: "error", lastError: "Azure requires baseUrl or resourceName." };
|
||||
}
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
case "gcp_adc": {
|
||||
if (stored?.kind !== "gcp_adc") {
|
||||
return { status: "auth_required", authState: "required", lastError: "GCP ADC not configured." };
|
||||
}
|
||||
const cfg = stored.value;
|
||||
if (!cfg.project || !cfg.location) {
|
||||
return { status: "error", authState: "error", lastError: "GCP project/location missing." };
|
||||
}
|
||||
return { status: "ready", authState: "ok", lastError: null };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkConnector(id: ConnectorId) {
|
||||
const connector = db.getConnector(id);
|
||||
const definition = getConnectorDefinition(id);
|
||||
const companyId = db.getCurrentCompanyId() ?? randomFallbackCompanyId();
|
||||
const probeCwd = resolveProbeCwd();
|
||||
const runtimeOverrides = db.resolveSecretEnv(connector.envBindingText, companyId);
|
||||
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status: "detected",
|
||||
authState: connector.authState,
|
||||
version: connector.version,
|
||||
authState: db.getConnector(id).authState,
|
||||
version: null,
|
||||
lastError: null,
|
||||
});
|
||||
publishDomainChanged();
|
||||
|
||||
try {
|
||||
const testPromise = definition.module.testEnvironment({
|
||||
companyId,
|
||||
adapterType: id,
|
||||
config: {
|
||||
command: connector.command,
|
||||
model: connector.model,
|
||||
cwd: probeCwd,
|
||||
env: buildSanitizedRuntimeEnv(process.env, runtimeOverrides),
|
||||
approvalMode: "default",
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
const result = await Promise.race([
|
||||
testPromise,
|
||||
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Connector health check timed out after 45 seconds")), 45_000)),
|
||||
]);
|
||||
|
||||
// Check if the CLI command itself is missing before inspecting auth status.
|
||||
// Without this, info-level hints like "run `gemini auth login`" would wrongly
|
||||
// trigger auth_required even when the CLI binary doesn't exist.
|
||||
const commandMissing = result.checks.some((check) =>
|
||||
/command_unresolvable|command_not_found/i.test(check.code) && check.level === "error",
|
||||
);
|
||||
|
||||
// Only consider warn/error-level checks for auth detection.
|
||||
// Info-level checks (like gemini_api_key_missing) carry advisory hints
|
||||
// that match auth patterns but do NOT indicate actual auth failure.
|
||||
const authRequired = !commandMissing && result.checks.some((check) =>
|
||||
check.level !== "info" && (
|
||||
/auth_required|login_required/i.test(check.code) ||
|
||||
/login is required|authentication is not ready|auth(?:entication)? required/i.test(check.message) ||
|
||||
/run .*login|use \/auth|configure .*api key/i.test(check.hint ?? "")
|
||||
),
|
||||
);
|
||||
let authState: "ok" | "required" | "error" = "ok";
|
||||
if (commandMissing) {
|
||||
authState = "error";
|
||||
} else if (authRequired) {
|
||||
authState = "required";
|
||||
} else if (result.status === "fail") {
|
||||
authState = "error";
|
||||
}
|
||||
const version = commandMissing ? null : await parseVersion(connector.command);
|
||||
|
||||
// claude_compatible requires explicit env bindings (API key + base URL) to be
|
||||
// considered ready. Without them, it just uses the official claude CLI auth
|
||||
// which is not what the user intends for a compatible provider.
|
||||
const needsCompatConfig = id.startsWith("claude_compat")
|
||||
&& !connector.envBindingText.includes("ANTHROPIC_BASE_URL");
|
||||
|
||||
const result = evaluateCredential(id);
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status:
|
||||
commandMissing
|
||||
? "not_installed"
|
||||
: needsCompatConfig
|
||||
? "auth_required"
|
||||
: authRequired
|
||||
? "auth_required"
|
||||
: result.status === "pass" || result.status === "warn"
|
||||
? "ready"
|
||||
: "error",
|
||||
authState,
|
||||
version,
|
||||
lastError:
|
||||
result.status === "fail" || result.status === "warn"
|
||||
? result.checks.map((check) => check.message).join(" | ")
|
||||
: null,
|
||||
status: result.status,
|
||||
authState: result.authState,
|
||||
version: null,
|
||||
lastError: result.lastError,
|
||||
});
|
||||
db.addActivity({
|
||||
companyId,
|
||||
@@ -165,15 +131,14 @@ export function createConnectorHealthService({
|
||||
entityId: id,
|
||||
detail: result.status,
|
||||
});
|
||||
logger.info(`[connector] Health check for ${id}: ${result.status}`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
_logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
|
||||
const isNotInstalled = /command.*not found|not executable|ENOENT/i.test(errorMessage);
|
||||
const status = isNotInstalled ? "not_installed" : "error";
|
||||
logger.error(`[connector] Health check failed for ${id}: ${errorMessage}`);
|
||||
db.updateConnectorHealth({
|
||||
id,
|
||||
status,
|
||||
authState: isNotInstalled ? "error" : "unknown",
|
||||
status: "error",
|
||||
authState: "unknown",
|
||||
version: null,
|
||||
lastError: errorMessage,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Normalise a CLI command value read from the connectors table or a user form.
|
||||
*
|
||||
* Collapses surrounding whitespace so CLI dispatch does not fork an empty
|
||||
* argument or pass leading/trailing spaces to spawn().
|
||||
*/
|
||||
export function normalizeCommandValue(value: string | null | undefined): string {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { ConnectorId, TaskType } from "@shared/types";
|
||||
|
||||
export const TASK_TYPE_PREFERENCE: Record<TaskType, ConnectorId[]> = {
|
||||
code: ["claude_local", "codex_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local"],
|
||||
research: ["gemini_local", "claude_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "codex_local"],
|
||||
content: ["claude_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local", "codex_local"],
|
||||
management: ["claude_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local", "codex_local"],
|
||||
general: ["claude_local", "codex_local", "claude_compatible", "claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5", "claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10", "gemini_local"],
|
||||
};
|
||||
import { CONNECTORS } from "./connectors";
|
||||
|
||||
export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
|
||||
engineering: "code",
|
||||
@@ -23,35 +16,61 @@ export const ROLE_DEPARTMENT_HINTS: Record<string, TaskType> = {
|
||||
legal: "management",
|
||||
};
|
||||
|
||||
const COMPAT_STRENGTH = "Claude Code CLI with third-party API provider (DeepSeek, OpenRouter, Ollama, etc.). Cost-effective for worker agents.";
|
||||
|
||||
export const CONNECTOR_STRENGTHS: Record<ConnectorId, string> = {
|
||||
claude_local:
|
||||
"Best for code generation, content writing, complex reasoning. Supports session resume, cost tracking.",
|
||||
claude_compatible: COMPAT_STRENGTH,
|
||||
claude_compat_2: COMPAT_STRENGTH,
|
||||
claude_compat_3: COMPAT_STRENGTH,
|
||||
claude_compat_4: COMPAT_STRENGTH,
|
||||
claude_compat_5: COMPAT_STRENGTH,
|
||||
claude_compat_6: COMPAT_STRENGTH,
|
||||
claude_compat_7: COMPAT_STRENGTH,
|
||||
claude_compat_8: COMPAT_STRENGTH,
|
||||
claude_compat_9: COMPAT_STRENGTH,
|
||||
claude_compat_10: COMPAT_STRENGTH,
|
||||
codex_local:
|
||||
"Optimized for code editing and generation. Supports session resume, structured output.",
|
||||
gemini_local:
|
||||
"Strong at research, analysis, and large-context tasks. Good for data processing.",
|
||||
/**
|
||||
* Preferred provider ordering per task type. Resolved against `CONNECTORS` at
|
||||
* module load so `TASK_TYPE_PREFERENCE` stays in sync with the pi-mono catalog
|
||||
* without hard-coded id lists.
|
||||
*/
|
||||
const PROVIDER_PREFERENCE: Record<TaskType, string[]> = {
|
||||
code: ["anthropic", "openai", "xai", "groq", "google", "openrouter", "mistral", "cerebras"],
|
||||
research: ["google", "anthropic", "openai", "openrouter", "xai", "mistral", "groq"],
|
||||
content: ["anthropic", "openai", "google", "mistral", "openrouter", "groq", "xai"],
|
||||
management: ["anthropic", "openai", "google", "mistral", "openrouter", "groq", "xai"],
|
||||
general: ["anthropic", "openai", "google", "openrouter", "mistral", "xai", "groq"],
|
||||
};
|
||||
|
||||
export function inferTaskType(
|
||||
department: string | null | undefined,
|
||||
): TaskType {
|
||||
function connectorsByProvider(provider: string): ConnectorId[] {
|
||||
return CONNECTORS.filter((c) => c.provider === provider).map((c) => c.id);
|
||||
}
|
||||
|
||||
function buildPreference(taskType: TaskType): ConnectorId[] {
|
||||
const preferred: ConnectorId[] = [];
|
||||
const seen = new Set<ConnectorId>();
|
||||
for (const provider of PROVIDER_PREFERENCE[taskType]) {
|
||||
for (const id of connectorsByProvider(provider)) {
|
||||
if (!seen.has(id)) {
|
||||
preferred.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ensure every catalog entry appears in the preference list so callers can
|
||||
// always resolve to *some* connector even when the provider is not ranked.
|
||||
for (const connector of CONNECTORS) {
|
||||
if (!seen.has(connector.id)) {
|
||||
preferred.push(connector.id);
|
||||
seen.add(connector.id);
|
||||
}
|
||||
}
|
||||
return preferred;
|
||||
}
|
||||
|
||||
export const TASK_TYPE_PREFERENCE: Record<TaskType, ConnectorId[]> = {
|
||||
code: buildPreference("code"),
|
||||
research: buildPreference("research"),
|
||||
content: buildPreference("content"),
|
||||
management: buildPreference("management"),
|
||||
general: buildPreference("general"),
|
||||
};
|
||||
|
||||
export function inferTaskType(department: string | null | undefined): TaskType {
|
||||
if (!department) return "general";
|
||||
const lower = department.toLowerCase();
|
||||
return ROLE_DEPARTMENT_HINTS[lower] ?? "general";
|
||||
}
|
||||
|
||||
export function getConnectorStrengths(connectorId: ConnectorId): string {
|
||||
return CONNECTOR_STRENGTHS[connectorId] ?? "General-purpose AI connector.";
|
||||
const definition = CONNECTORS.find((c) => c.id === connectorId);
|
||||
if (!definition) return "General-purpose AI connector.";
|
||||
return definition.description;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getModels } from "@mariozechner/pi-ai";
|
||||
import type { ConnectorId } from "@shared/types";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
|
||||
@@ -13,15 +14,6 @@ export interface ConnectorModelPolicyResult {
|
||||
defaultModel: string | null;
|
||||
}
|
||||
|
||||
export function parseCompatConnectorModelCatalog(model: string | null | undefined): string[] {
|
||||
return uniqueModels(
|
||||
(model ?? "")
|
||||
.split(",")
|
||||
.map((entry) => ({ id: entry.trim(), label: entry.trim(), enabled: true }))
|
||||
.filter((entry) => entry.id.length > 0),
|
||||
).map((entry) => entry.id);
|
||||
}
|
||||
|
||||
function uniqueModels(models: Array<{ id: string; label: string }>) {
|
||||
const byId = new Map<string, { id: string; label: string }>();
|
||||
for (const model of models) {
|
||||
@@ -34,6 +26,15 @@ function uniqueModels(models: Array<{ id: string; label: string }>) {
|
||||
return Array.from(byId.values());
|
||||
}
|
||||
|
||||
function listProviderModels(provider: string): Array<{ id: string; label: string }> {
|
||||
try {
|
||||
const models = getModels(provider as never);
|
||||
return models.map((model) => ({ id: model.id, label: model.name ?? model.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getConnectorModelPolicy(
|
||||
deps: ConnectorModelPolicyDeps,
|
||||
connectorId: ConnectorId,
|
||||
@@ -42,19 +43,14 @@ export function getConnectorModelPolicy(
|
||||
const definition = getConnectorDefinition(connectorId);
|
||||
const disabledModels = new Set(deps.getDisabledModels(connectorId).map((model) => model.id));
|
||||
|
||||
const catalog = connectorId.startsWith("claude_compat")
|
||||
? parseCompatConnectorModelCatalog(connector.model).map((model) => ({ id: model, label: model }))
|
||||
: (definition.module.models ?? []).map((model) => ({ id: model.id, label: model.label }));
|
||||
|
||||
const catalog = listProviderModels(definition.provider);
|
||||
const enabledModels = uniqueModels(catalog).filter((model) => !disabledModels.has(model.id));
|
||||
|
||||
return {
|
||||
connectorId,
|
||||
connectorLabel: connector.label?.trim() || definition.label,
|
||||
enabledModels,
|
||||
defaultModel: connectorId.startsWith("claude_compat")
|
||||
? null
|
||||
: (connector.model?.trim() || definition.defaultModel),
|
||||
defaultModel: connector.model?.trim() || definition.defaultModel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -90,22 +86,14 @@ export function validateConnectorModelSelection(
|
||||
return { ok: true, effectiveModel: policy.enabledModels[0]?.id ?? null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pi-mono catalog does not have free-form compat connectors; every connector
|
||||
* has a fixed pi-ai provider. The configuration-level model validator is a
|
||||
* no-op retained for IPC contract compatibility.
|
||||
*/
|
||||
export function validateConnectorConfigurationModel(
|
||||
connectorId: ConnectorId,
|
||||
model: string | null | undefined,
|
||||
_connectorId: ConnectorId,
|
||||
_model: string | null | undefined,
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
if (!connectorId.startsWith("claude_compat")) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (model == null) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const catalog = parseCompatConnectorModelCatalog(model);
|
||||
if (catalog.length === 0) {
|
||||
return { ok: false, error: "Compatible connectors require at least one model in the configured model catalog." };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -66,6 +66,12 @@ export function listInvalidEnvBindingKeys(text: string): string[] {
|
||||
});
|
||||
}
|
||||
|
||||
export { PI_MONO_CAPABILITIES };
|
||||
|
||||
export function renderConnectorConfigurationDoc(definition: ConnectorDefinition): string {
|
||||
return renderConfigurationDoc(definition);
|
||||
}
|
||||
|
||||
function renderConfigurationDoc(definition: ConnectorDefinition): string {
|
||||
switch (definition.auth.type) {
|
||||
case "oauth":
|
||||
|
||||
@@ -122,8 +122,10 @@ export class CredentialResolver {
|
||||
if (cfg.credentialsPath) env.GOOGLE_APPLICATION_CREDENTIALS = cfg.credentialsPath;
|
||||
return { apiKey: "", extraEnv: env };
|
||||
}
|
||||
default:
|
||||
throw new CredentialError("CREDENTIAL_INVALID", connectorId, `Unsupported auth kind: ${String(auth.type)}`);
|
||||
default: {
|
||||
const exhaustive: never = auth;
|
||||
throw new CredentialError("CREDENTIAL_INVALID", connectorId, `Unsupported auth descriptor: ${JSON.stringify(exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { normalizeCommandValue } from "@agentcompany/adapter-utils/server-utils";
|
||||
import type { ConnectorId, ConnectorRecord } from "@shared/types";
|
||||
import type { ReadAll } from "./helpers";
|
||||
import { nowIso } from "./helpers";
|
||||
import { getConnectorDefinition, buildDefaultConnectorRows } from "../connectors";
|
||||
import { normalizeCommandValue } from "../connector-helpers";
|
||||
import {
|
||||
CONNECTORS,
|
||||
getConnectorDefinition,
|
||||
buildDefaultConnectorRows,
|
||||
renderConnectorConfigurationDoc,
|
||||
PI_MONO_CAPABILITIES,
|
||||
} from "../connectors";
|
||||
|
||||
|
||||
export function buildConnectorMethods(
|
||||
@@ -40,8 +46,8 @@ export function buildConnectorMethods(
|
||||
model: row.model,
|
||||
envBindingText: row.env_binding_text,
|
||||
notes: row.notes,
|
||||
configurationDoc: definition.configurationDoc,
|
||||
capabilityMatrix: definition.capabilityMatrix,
|
||||
configurationDoc: renderConnectorConfigurationDoc(definition),
|
||||
capabilityMatrix: PI_MONO_CAPABILITIES,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
@@ -120,8 +126,8 @@ export function buildConnectorMethods(
|
||||
model: row.model,
|
||||
envBindingText: row.env_binding_text,
|
||||
notes: row.notes,
|
||||
configurationDoc: definition.configurationDoc,
|
||||
capabilityMatrix: definition.capabilityMatrix,
|
||||
configurationDoc: renderConnectorConfigurationDoc(definition),
|
||||
capabilityMatrix: PI_MONO_CAPABILITIES,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -132,56 +138,13 @@ export function buildConnectorMethods(
|
||||
},
|
||||
|
||||
seedConnectors() {
|
||||
getDb().prepare("delete from connectors where id not in ('codex_local', 'claude_local', 'gemini_local') and id not like 'claude_compat%'").run();
|
||||
// Drop any connector row that is not declared in the pi-mono catalog.
|
||||
const validIds = CONNECTORS.map((c) => `'${c.id}'`).join(",");
|
||||
getDb().prepare(`delete from connectors where id not in (${validIds})`).run();
|
||||
|
||||
const currentRows = readAll<Partial<ConnectorRecord>>(
|
||||
"select id, label, description, status, command, version, auth_state as authState, last_checked_at as lastCheckedAt, last_error as lastError, model, env_binding_text as envBindingText, notes from connectors",
|
||||
);
|
||||
// Migrate first compat slot if its definition defaults have changed (edition upgrade).
|
||||
// When the definition ships with preset notes that differ from the DB, relocate the
|
||||
// old user config to the next empty compat slot so definition defaults take effect.
|
||||
const firstCompat = currentRows.find((r) => r.id === "claude_compatible");
|
||||
if (firstCompat?.envBindingText && firstCompat.notes) {
|
||||
let firstDef: { notes: string } | undefined;
|
||||
try { firstDef = getConnectorDefinition("claude_compatible"); } catch { /* skip */ }
|
||||
if (firstDef?.notes && firstCompat.notes !== firstDef.notes) {
|
||||
const migrationTargets: ConnectorId[] = [
|
||||
"claude_compat_2", "claude_compat_3", "claude_compat_4", "claude_compat_5",
|
||||
"claude_compat_6", "claude_compat_7", "claude_compat_8", "claude_compat_9", "claude_compat_10",
|
||||
];
|
||||
const occupied = new Set(
|
||||
currentRows
|
||||
.filter((r) => r.id !== "claude_compatible" && r.id?.startsWith("claude_compat") && r.envBindingText)
|
||||
.map((r) => r.id),
|
||||
);
|
||||
const targetId = migrationTargets.find((id) => !occupied.has(id));
|
||||
if (targetId) {
|
||||
const migrated = {
|
||||
envBindingText: firstCompat.envBindingText,
|
||||
notes: firstCompat.notes,
|
||||
model: firstCompat.model,
|
||||
status: firstCompat.status,
|
||||
authState: firstCompat.authState,
|
||||
version: firstCompat.version,
|
||||
lastCheckedAt: firstCompat.lastCheckedAt,
|
||||
lastError: firstCompat.lastError,
|
||||
};
|
||||
const targetRow = currentRows.find((r) => r.id === targetId);
|
||||
if (targetRow) {
|
||||
Object.assign(targetRow, migrated);
|
||||
} else {
|
||||
currentRows.push({ id: targetId as ConnectorId, ...migrated });
|
||||
}
|
||||
firstCompat.envBindingText = undefined;
|
||||
firstCompat.notes = undefined;
|
||||
firstCompat.model = undefined;
|
||||
firstCompat.status = undefined;
|
||||
firstCompat.authState = undefined;
|
||||
firstCompat.version = undefined;
|
||||
firstCompat.lastCheckedAt = undefined;
|
||||
firstCompat.lastError = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = buildDefaultConnectorRows(currentRows);
|
||||
const statement = getDb().prepare(
|
||||
@@ -196,11 +159,11 @@ export function buildConnectorMethods(
|
||||
);
|
||||
}
|
||||
|
||||
// One-time migration: mark native connectors that are actively used by
|
||||
// agents as model-pool-managed so they remain visible to the CEO after
|
||||
// the opt-in policy change. Connectors not used by any agent stay
|
||||
// unmanaged and will not appear in CEO prompts until the user explicitly
|
||||
// enables models for them in Settings > Model Management.
|
||||
// One-time migration: mark connectors actively used by agents as
|
||||
// model-pool-managed so they remain visible to the CEO after the opt-in
|
||||
// policy change. Connectors not used by any agent stay unmanaged and
|
||||
// will not appear in CEO prompts until the user explicitly enables
|
||||
// models for them in Settings > Model Management.
|
||||
const migrationKey = "migration:model_pool_opt_in_v1";
|
||||
const migrated = readAll<{ value: string }>("SELECT value FROM settings WHERE key = ?", migrationKey);
|
||||
if (migrated.length === 0) {
|
||||
@@ -209,16 +172,15 @@ export function buildConnectorMethods(
|
||||
"SELECT DISTINCT connector_id FROM agents WHERE status NOT IN ('terminated')",
|
||||
).map((r) => r.connector_id),
|
||||
);
|
||||
const nativeIds = ["codex_local", "claude_local", "gemini_local"];
|
||||
const upsert = getDb().prepare(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
);
|
||||
for (const id of nativeIds) {
|
||||
for (const id of usedIds) {
|
||||
const hasSettings = readAll<{ key: string }>(
|
||||
"SELECT key FROM settings WHERE key = ?",
|
||||
`disabled_models:${id}`,
|
||||
);
|
||||
if (hasSettings.length === 0 && usedIds.has(id)) {
|
||||
if (hasSettings.length === 0) {
|
||||
upsert.run(`disabled_models:${id}`, "[]");
|
||||
}
|
||||
}
|
||||
|
||||
+28
-35
@@ -1,14 +1,26 @@
|
||||
import log from "electron-log/main.js";
|
||||
import type { HireApprovedPayload, HireApprovedHookResult } from "@agentcompany/adapter-utils";
|
||||
import { getConnectorDefinition } from "./connectors";
|
||||
import type { AppDatabase } from "./database";
|
||||
|
||||
export type { HireApprovedPayload, HireApprovedHookResult };
|
||||
/**
|
||||
* Payload captured when an agent hire is approved. Kept for call-sites that
|
||||
* want to surface the approval in activity feeds or future integrations.
|
||||
*/
|
||||
export interface HireApprovedPayload {
|
||||
companyId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
connectorId: string;
|
||||
source: "join_request" | "approval";
|
||||
sourceId: string;
|
||||
approvedAt: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the adapter that an agent hire was approved.
|
||||
* Record that an agent hire was approved. Pi-mono does not expose an adapter
|
||||
* hook, so this records an activity row for the company timeline.
|
||||
* Non-fatal: failures are logged but never thrown.
|
||||
* Follows the Paperclip hire-hook.ts pattern.
|
||||
*/
|
||||
export async function notifyHireApproved(
|
||||
db: AppDatabase,
|
||||
@@ -37,49 +49,30 @@ export async function notifyHireApproved(
|
||||
return;
|
||||
}
|
||||
|
||||
const adapter = connectorDef.module;
|
||||
if (!adapter.onHireApproved) {
|
||||
// Adapter doesn't implement the hook — this is normal, not an error
|
||||
return;
|
||||
}
|
||||
|
||||
const locale = (db.queryAll<{ value: string }>("select value from settings where key = 'locale' limit 1")[0]?.value ?? "en");
|
||||
const payload: HireApprovedPayload = {
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
agentName: agent.name,
|
||||
adapterType: connectorDef.id,
|
||||
connectorId: connectorDef.id,
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
approvedAt: input.approvedAt ?? new Date().toISOString(),
|
||||
message: (db.queryAll<{ value: string }>("select value from settings where key = 'locale' limit 1")[0]?.value ?? "en") === "zh"
|
||||
message: locale === "zh"
|
||||
? `你已被批准为"${agent.title}"。你现在已激活,可以接收任务。`
|
||||
: `Your hire as "${agent.title}" in the company has been approved. You are now active and ready to receive tasks.`,
|
||||
};
|
||||
|
||||
const result = await adapter.onHireApproved(payload, {});
|
||||
if (result.ok) {
|
||||
log.info(`[hire-hook] Successfully notified adapter for agent ${agent.name} (${input.agentId})`);
|
||||
db.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: "system",
|
||||
action: "hire_hook.success",
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
detail: `Hire notification sent to ${connectorDef.id} adapter`,
|
||||
});
|
||||
} else {
|
||||
log.warn(`[hire-hook] Adapter returned error for agent ${agent.name}: ${result.error}`);
|
||||
db.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: "system",
|
||||
action: "hire_hook.failed",
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
detail: `Adapter error: ${result.error ?? "unknown"}`,
|
||||
});
|
||||
}
|
||||
log.info(`[hire-hook] Hire approved for agent ${agent.name} (${input.agentId}) on connector ${connectorDef.id}`);
|
||||
db.addActivity({
|
||||
companyId: input.companyId,
|
||||
actor: "system",
|
||||
action: "hire_hook.success",
|
||||
entityType: "agent",
|
||||
entityId: input.agentId,
|
||||
detail: `Hire approved: ${payload.agentName} via ${connectorDef.id}`,
|
||||
});
|
||||
} catch (error) {
|
||||
// Non-fatal: log and continue
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[hire-hook] Exception notifying hire for agent ${input.agentId}: ${message}`);
|
||||
try {
|
||||
|
||||
@@ -74,11 +74,9 @@ export function registerCoreAdminHandlers({
|
||||
if (!connectorConfigValidation.ok) {
|
||||
return fail("CONNECTOR_MODEL_NOT_ALLOWED", connectorConfigValidation.error);
|
||||
}
|
||||
if (!parsed.id.startsWith("claude_compat")) {
|
||||
const modelSelection = validateConnectorModelSelection(db, parsed.id, parsed.model ?? null);
|
||||
if (!modelSelection.ok) {
|
||||
return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error);
|
||||
}
|
||||
const modelSelection = validateConnectorModelSelection(db, parsed.id, parsed.model ?? null);
|
||||
if (!modelSelection.ok) {
|
||||
return fail("CONNECTOR_MODEL_NOT_ALLOWED", modelSelection.error);
|
||||
}
|
||||
db.saveConnector({
|
||||
...parsed,
|
||||
|
||||
@@ -41,7 +41,7 @@ const API_DOC_SECTIONS: Record<AppLocale, string[]> = {
|
||||
function render(template: string, ctx: PromptContext): string {
|
||||
const connectorIds = ctx.allConnectors && ctx.allConnectors.length > 0
|
||||
? ctx.allConnectors.map((c) => c.id).join("|")
|
||||
: "claude_local";
|
||||
: "claude-pro";
|
||||
return template
|
||||
.replace(/\{\{apiUrl\}\}/g, ctx.apiUrl)
|
||||
.replace(/\{\{companyId\}\}/g, ctx.agent.companyId)
|
||||
|
||||
@@ -30,7 +30,7 @@ For EACH role, use POST /api/companies/{companyId}/agents with:
|
||||
- Specific role and title
|
||||
- Detailed capabilities describing what they should own
|
||||
- reportsTo: an explicit manager agent ID (do not omit this field)
|
||||
- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude_local.
|
||||
- connectorId: Use the connector recommended in the "Available Connectors" section above. If no section is shown, use claude-pro.
|
||||
- budgetMonthlyUsd: Appropriate budget (recommend $50-250/agent)
|
||||
- model: Best model for the role's complexity
|
||||
- After EACH hire is approved and the agent is created, assign relevant skills to them:
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
- 具体的 role 和 title
|
||||
- 详尽的 capabilities 描述他们应拥有什么
|
||||
- reportsTo:明确的管理者 agent ID(不要省略)
|
||||
- connectorId:使用上方 "Available Connectors" 推荐的 connector。若无该章节,使用 claude_local。
|
||||
- connectorId:使用上方 "Available Connectors" 推荐的 connector。若无该章节,使用 claude-pro。
|
||||
- budgetMonthlyUsd:合适的预算(推荐 $50-250/agent)
|
||||
- model:与角色复杂度匹配的最佳模型
|
||||
- 每个招聘被批准并创建 agent 后,为其分配相关 skill:
|
||||
|
||||
+17
-12
@@ -51,23 +51,28 @@ export interface SkillAssignment {
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-adapter token budgets for skill content injection.
|
||||
* Default token budget used when a connector does not declare an override.
|
||||
*/
|
||||
const DEFAULT_TOKEN_BUDGET = 30000;
|
||||
|
||||
/**
|
||||
* Per-connector token budgets for skill content injection.
|
||||
* Values represent the maximum token count of skill content that can be
|
||||
* included in a single prompt for each adapter type.
|
||||
* included in a single prompt for a given connector.
|
||||
*/
|
||||
export const TOKEN_BUDGETS: Readonly<Record<string, number>> = {
|
||||
claude_local: 50000,
|
||||
codex_local: 30000,
|
||||
gemini_local: 50000,
|
||||
claude_compatible: 50000,
|
||||
// High-context Anthropic-style connectors run with a larger injection budget.
|
||||
"claude-pro": 50000,
|
||||
"anthropic": 50000,
|
||||
"amazon-bedrock": 50000,
|
||||
"gemini-cli": 50000,
|
||||
"google": 50000,
|
||||
"google-vertex": 50000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves the token budget for a given adapter type.
|
||||
* Handles claude_compat_2..10 variants by mapping them to claude_compatible.
|
||||
* Resolves the token budget for a given connector id.
|
||||
*/
|
||||
export function resolveTokenBudget(adapterType: string): number {
|
||||
if (TOKEN_BUDGETS[adapterType] !== undefined) return TOKEN_BUDGETS[adapterType];
|
||||
if (adapterType.startsWith("claude_compat")) return TOKEN_BUDGETS.claude_compatible;
|
||||
return 30000;
|
||||
export function resolveTokenBudget(connectorId: string): number {
|
||||
return TOKEN_BUDGETS[connectorId] ?? DEFAULT_TOKEN_BUDGET;
|
||||
}
|
||||
Reference in new issue
Block a user