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:
ZacharyZhang-NY committed 2026-04-17 02:20:56 -04:00
1 parent 60178ceafa
commit 06a3ba603e
14 files changed
+267 -377

No files matched your search

+17 -74
View File
@@ -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;
}
/* ------------------------------------------------------------------ */
+88 -123
View File
@@ -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,
});
+9
View File
@@ -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();
}
+50 -31
View File
@@ -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;
}
+19 -31
View File
@@ -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 };
}
+6
View File
@@ -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":
+4 -2
View File
@@ -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)}`);
}
}
}
+23 -61
View File
@@ -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
View File
@@ -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 {
+3 -5
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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;
}