diff --git a/src/main/connector-auth-service.ts b/src/main/connector-auth-service.ts index e036ffdb..0ee39901 100644 --- a/src/main/connector-auth-service.ts +++ b/src/main/connector-auth-service.ts @@ -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 { + return false; } /* ------------------------------------------------------------------ */ diff --git a/src/main/connector-health-service.ts b/src/main/connector-health-service.ts index ae137b6c..e4add2f1 100644 --- a/src/main/connector-health-service.ts +++ b/src/main/connector-health-service.ts @@ -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 { - 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((_, 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, }); diff --git a/src/main/connector-helpers.ts b/src/main/connector-helpers.ts new file mode 100644 index 00000000..a74e502b --- /dev/null +++ b/src/main/connector-helpers.ts @@ -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(); +} diff --git a/src/main/connector-matching.ts b/src/main/connector-matching.ts index 1aef39d7..62e301d4 100644 --- a/src/main/connector-matching.ts +++ b/src/main/connector-matching.ts @@ -1,12 +1,5 @@ import type { ConnectorId, TaskType } from "@shared/types"; - -export const TASK_TYPE_PREFERENCE: Record = { - 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 = { engineering: "code", @@ -23,35 +16,61 @@ export const ROLE_DEPARTMENT_HINTS: Record = { 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 = { - 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 = { + 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(); + 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 = { + 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; } diff --git a/src/main/connector-model-policy.ts b/src/main/connector-model-policy.ts index ae14f536..76d01b3d 100644 --- a/src/main/connector-model-policy.ts +++ b/src/main/connector-model-policy.ts @@ -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(); 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 }; } diff --git a/src/main/connectors.ts b/src/main/connectors.ts index a90ca9e9..ae10b013 100644 --- a/src/main/connectors.ts +++ b/src/main/connectors.ts @@ -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": diff --git a/src/main/credentials/credential-resolver.ts b/src/main/credentials/credential-resolver.ts index 1fa75288..d30e8ca3 100644 --- a/src/main/credentials/credential-resolver.ts +++ b/src/main/credentials/credential-resolver.ts @@ -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)}`); + } } } diff --git a/src/main/database/connectors-crud.ts b/src/main/database/connectors-crud.ts index 001e6700..6d55a302 100644 --- a/src/main/database/connectors-crud.ts +++ b/src/main/database/connectors-crud.ts @@ -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>( "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}`, "[]"); } } diff --git a/src/main/hire-hook.ts b/src/main/hire-hook.ts index 6634418a..378245f5 100644 --- a/src/main/hire-hook.ts +++ b/src/main/hire-hook.ts @@ -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 { diff --git a/src/main/ipc-core-admin-handlers.ts b/src/main/ipc-core-admin-handlers.ts index 5cbb01f7..48c74a01 100644 --- a/src/main/ipc-core-admin-handlers.ts +++ b/src/main/ipc-core-admin-handlers.ts @@ -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, diff --git a/src/main/prompt-builder/api-section.ts b/src/main/prompt-builder/api-section.ts index 072abe46..304a8bca 100644 --- a/src/main/prompt-builder/api-section.ts +++ b/src/main/prompt-builder/api-section.ts @@ -41,7 +41,7 @@ const API_DOC_SECTIONS: Record = { 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) diff --git a/src/main/prompt-builder/ceo-modes/team-building.en.md b/src/main/prompt-builder/ceo-modes/team-building.en.md index 4500bc4f..790381d3 100644 --- a/src/main/prompt-builder/ceo-modes/team-building.en.md +++ b/src/main/prompt-builder/ceo-modes/team-building.en.md @@ -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: diff --git a/src/main/prompt-builder/ceo-modes/team-building.zh.md b/src/main/prompt-builder/ceo-modes/team-building.zh.md index 3176f178..e0cdc0d6 100644 --- a/src/main/prompt-builder/ceo-modes/team-building.zh.md +++ b/src/main/prompt-builder/ceo-modes/team-building.zh.md @@ -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: diff --git a/src/main/skill-types.ts b/src/main/skill-types.ts index ad1334de..04085edb 100644 --- a/src/main/skill-types.ts +++ b/src/main/skill-types.ts @@ -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> = { - 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; }