diff --git a/contracts/acp.ts b/contracts/acp.ts index 7c55f3a..ac1a403 100644 --- a/contracts/acp.ts +++ b/contracts/acp.ts @@ -40,6 +40,13 @@ export type AcpAgentInstallKind = "binary" | "npx" | "mock"; /** Registry entry describing an ACP agent Pipper can spawn. */ export interface AcpAgentDescriptor { id: string; + /** + * The underlying provider/driver this descriptor belongs to. For a plain + * driver descriptor this is omitted and `id` is the driver id; for a + * materialized account instance (`AcpAgentInstance`) `id` is the instance id + * and this points back at the driver for display/analytics/install metadata. + */ + driverId?: string; name: string; /** Display name shown in the UI. */ displayName: string; @@ -48,6 +55,13 @@ export interface AcpAgentDescriptor { command: string; args: string[]; env?: Record; + /** + * Names to delete from the child's environment before spawn. Used to drop + * ambient provider credentials (e.g. OPENAI_API_KEY) for an isolated account + * so its process cannot silently authenticate as the machine's default + * account. + */ + unsetEnv?: string[]; /** Optional icon key for tab indicators. */ icon?: string; /** Short description for onboarding. */ @@ -87,6 +101,67 @@ export interface AgentProbeResult { authMethods?: AuthMethod[]; } +/** + * A single environment variable supplied to a provider instance's child + * process. Values never leave the main process in cleartext: a `sensitive` + * value is redacted before an instance is sent to the renderer. + */ +export interface AcpAgentInstanceEnvVar { + name: string; + value: string; + /** When true, the value is redacted in renderer-facing copies. */ + sensitive?: boolean; +} + +/** + * One named account/configuration of a provider driver. Pipper keeps a + * separate child process, environment, and credential root per instance so + * two accounts of the same driver (e.g. personal + work Codex) can coexist and + * run side by side. An instance with `id === driverId` is the legacy/default + * configuration that uses the ambient CLI login. + */ +export interface AcpAgentInstance { + /** Routing key passed to {@link AcpAgentDescriptor.id} when spawning. */ + id: string; + /** The provider driver this instance is a configuration of. */ + driverId: string; + /** User-facing label, e.g. "Codex — Work". */ + displayName: string; + enabled: boolean; + /** Per-instance environment overrides, merged over the driver defaults. */ + env?: AcpAgentInstanceEnvVar[]; + /** Driver-specific, opaque configuration (e.g. a profile directory). */ + config?: Record; + createdAt?: number; + updatedAt?: number; +} + +/** Create/update payload for a provider instance. */ +export interface AcpAgentInstanceInput { + driverId: string; + displayName: string; + /** Optional explicit id; generated from driver + label when omitted. */ + id?: string; + enabled?: boolean; + env?: AcpAgentInstanceEnvVar[]; + config?: Record; +} + +/** Per-driver account-creation capabilities, surfaced to the settings UI. */ +export interface AgentAccountSchema { + driverId: string; + displayName: string; + icon?: string; + /** Env var that points the CLI at an isolated credential root, if any. */ + profileEnvVar: string | null; + /** Env var that carries an explicit credential value, if any. */ + authEnvVar: string | null; + /** True when a driver beyond the ambient default can be configured. */ + supportsMultipleAccounts: boolean; + /** True when the driver has an interactive sign-in command to launch. */ + supportsLogin: boolean; +} + /** * Account-level subscription rate limit for a session, when the agent reports * one. Distinct from `used`/`size` (per-turn context window): this describes the diff --git a/contracts/remote.ts b/contracts/remote.ts index f535efb..c743ff0 100644 --- a/contracts/remote.ts +++ b/contracts/remote.ts @@ -11,6 +11,8 @@ export interface RemoteProject { export interface RemoteModel { id: string; name: string; + /** Provider/driver display name; used to group accounts on the phone. */ + provider?: string; } export interface RemoteThreadSummary { diff --git a/contracts/threads.ts b/contracts/threads.ts index b093b5a..5fef4f6 100644 --- a/contracts/threads.ts +++ b/contracts/threads.ts @@ -1,7 +1,11 @@ export interface Thread { id: string; project_id: string; - /** Which ACP agent owns this thread (e.g. "cursor-acp@1.0"). */ + /** + * Which ACP provider instance owns this thread — the spawn routing key. + * Equals the driver id for a driver's default account, or an instance id + * (e.g. "codex-acp:work") for an additional account. + */ agent_id: string; /** ACP session.id from session/new (or session/resume). */ agent_session_id: string; diff --git a/electron/agent-connection-manager.ts b/electron/agent-connection-manager.ts index 4a27af9..fe2ffa6 100644 --- a/electron/agent-connection-manager.ts +++ b/electron/agent-connection-manager.ts @@ -19,7 +19,7 @@ import type { import type { OpenTabsState, Thread } from "../contracts/threads.ts"; import { readOpenTabsState, recordThreadSwitch } from "./open-tabs.ts"; import { getProject } from "./projects.ts"; -import { getSelectedAgentIds } from "./db.ts"; +import { getAppSetting, setAppSetting, getSelectedAgentIds } from "./db.ts"; import { setActiveProjectId } from "./session.ts"; import { getThread, @@ -39,6 +39,7 @@ import { import { normalizeWorkspacePath, pickWorkspaceThread } from "../contracts/workspace-scope.ts"; import { isLiveWorktree } from "./worktree-manager.ts"; import { getAgentDescriptor, getDefaultAgentId, listRegisteredAgents } from "./agents/registry.ts"; +import { listAgentInstanceDescriptors, hasAgentInstances } from "./agent-instances.ts"; import { ACP_SWITCH_PHASE_TIMEOUT_MS, ConnectionLifecycle, @@ -92,6 +93,27 @@ import type { MonitorSwitchRecord, } from "../contracts/monitor.ts"; +/** Persisted pointer to the last-used provider instance. */ +const PREFERRED_INSTANCE_KEY = "preferred_agent_instance"; + +function loadPreferredAgentId(): string { + try { + const stored = getAppSetting(PREFERRED_INSTANCE_KEY); + if (stored && getAgentDescriptor(stored)) return stored; + } catch { + // Database may not be ready in some embedding contexts; fall back. + } + return getDefaultAgentId(); +} + +function persistPreferredAgentId(agentId: string): void { + try { + setAppSetting(PREFERRED_INSTANCE_KEY, agentId); + } catch { + // Non-fatal: preference simply won't survive a restart. + } +} + function modelOptionsFromConfig( options: SessionConfigOption[] | undefined, ): Array<{ modelId: string; name: string; provider?: string }> { @@ -219,7 +241,7 @@ export class AgentConnectionManager { private connecting: Promise | null = null; private activeProjectId: string | null = null; private activeThreadId: string | null = null; - private preferredAgentId: string = getDefaultAgentId(); + private preferredAgentId: string = loadPreferredAgentId(); private readonly sessions = new ThreadSessionRegistry(); /** * Session replay is delivered as session/update notifications while @@ -371,10 +393,40 @@ export class AgentConnectionManager { } listAgents(): AcpAgentDescriptor[] { - // Always re-probe PATH so onboarding reflects install state. + // Once instance storage is seeded, it is the source of truth: return the + // enabled instances even when that list is empty (all accounts disabled). + // Only fall back to the raw driver catalog in the uninitialized/legacy + // state, so a disabled default account is never re-exposed as selectable. + if (hasAgentInstances()) return listAgentInstanceDescriptors(); return listRegisteredAgents(); } + /** + * Reconcile a removed account: drop its cached sessions, close its process, + * and move the preferred pointer off it. Thread rows are re-pointed at the + * driver's default instance by `deleteAgentInstance`. + */ + async removeAgentInstance(instanceId: string): Promise { + // Collect first: removing entries while iterating the registry would skip + // siblings when several threads share the account. + const ownedThreadIds: string[] = []; + for (const [threadId, runtime] of this.sessions.entries()) { + if (runtime.agentId === instanceId) ownedThreadIds.push(threadId); + } + for (const threadId of ownedThreadIds) { + const runtime = this.sessions.get(threadId); + if (!runtime) continue; + this.permissions.cancelForSession(runtime.agentSessionId); + this.prompts.cancelInFlight(threadId, "account removed"); + this.sessions.remove(threadId); + } + await this.lifecycle.close(instanceId); + if (this.preferredAgentId === instanceId) { + this.preferredAgentId = getDefaultAgentId(); + persistPreferredAgentId(this.preferredAgentId); + } + } + async getModelCatalogs(): Promise< Record> > { @@ -384,8 +436,18 @@ export class AgentConnectionManager { // needs authentication is skipped; its catalog can still be populated by // a later successful session. const selectedAgentIds = getSelectedAgentIds(); + // Selections are provider-level (a driver id or a default instance id), but + // catalogs are keyed per instance. Warm every enabled instance whose driver + // is selected so accounts added later in Settings have catalogs too. + const selectedSet = new Set(selectedAgentIds); + const warmIds = new Set(selectedAgentIds); + for (const instance of listAgentInstanceDescriptors()) { + const driver = instance.driverId ?? instance.id; + if (selectedSet.has(instance.id) || selectedSet.has(driver)) warmIds.add(instance.id); + } + const agentsToWarm = [...warmIds]; await Promise.all( - selectedAgentIds.map(async (agentId) => { + agentsToWarm.map(async (agentId) => { try { await this.acquireConnection(agentId); } catch { @@ -434,7 +496,7 @@ export class AgentConnectionManager { ? (getProject(this.activeProjectId)?.path ?? process.cwd()) : process.cwd()); await Promise.all( - selectedAgentIds.map(async (agentId) => { + agentsToWarm.map(async (agentId) => { if (result[agentId]?.length) return; const live = this.lifecycle.getCached(agentId); if (!live) return; @@ -794,6 +856,7 @@ export class AgentConnectionManager { ); } this.preferredAgentId = agentId; + persistPreferredAgentId(agentId); } /** Bridge-event output goes through RendererBroadcaster (see that module). */ @@ -1112,6 +1175,7 @@ export class AgentConnectionManager { const live = await this.acquireConnection(agentId); this.lifecycle.setActive(live); this.preferredAgentId = agentId; + persistPreferredAgentId(agentId); if (previousAgentId && previousAgentId !== live.agentId) { this.captureAnalytics?.("agent_switched", { from_agent_id: previousAgentId, diff --git a/electron/agent-instances.test.ts b/electron/agent-instances.test.ts new file mode 100644 index 0000000..b4e470d --- /dev/null +++ b/electron/agent-instances.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { AcpAgentDescriptor } from "../contracts/acp.ts"; + +const safeStorageState = vi.hoisted(() => ({ available: true })); + +vi.mock("electron", () => ({ + app: { getPath: () => process.env.PIPPER_LIBRARY_PATH ?? process.env.TMPDIR ?? "/tmp" }, + // Reversible stand-in so the encryption round-trip is observable without a + // real OS keychain. `available` is togglable to exercise the refusal path. + safeStorage: { + isEncryptionAvailable: () => safeStorageState.available, + encryptString: (value: string) => Buffer.from(`sealed:${value}`, "utf8"), + decryptString: (buffer: Buffer) => buffer.toString("utf8").replace(/^sealed:/, ""), + }, +})); + +// Deterministic driver catalog; the real registry probes PATH, which we don't +// want to depend on here. Mirrors the shape agent-instances consumes. +vi.mock("./agents/registry.ts", () => { + const drivers: AcpAgentDescriptor[] = [ + { + id: "codex-acp", + name: "codex-cli", + displayName: "Codex", + command: "codex-acp", + args: [], + env: { SHARED: "driver" }, + }, + { + id: "claude-agent-acp", + name: "claude-code", + displayName: "Claude", + command: "npx", + args: [], + }, + { + id: "cursor-acp", + name: "cursor", + displayName: "Cursor", + command: "agent", + args: ["acp"], + }, + ]; + return { + listRegisteredAgents: () => drivers.map((driver) => ({ ...driver })), + setInstanceDescriptorProvider: () => {}, + descriptorDriverId: (descriptor: AcpAgentDescriptor) => descriptor.driverId ?? descriptor.id, + }; +}); + +let root: string | null = null; + +beforeEach(() => { + vi.resetModules(); + safeStorageState.available = true; + root = mkdtempSync(join(tmpdir(), "pipper-agent-instances-")); + process.env.PIPPER_LIBRARY_PATH = root; +}); + +afterEach(async () => { + const { closeDb } = await import("./db.ts"); + closeDb(); + delete process.env.PIPPER_LIBRARY_PATH; + if (root) rmSync(root, { recursive: true, force: true }); + root = null; +}); + +async function load() { + const db = await import("./db.ts"); + db.getDb(); + const mod = await import("./agent-instances.ts"); + return mod; +} + +describe("agent instances", () => { + test("seeds one default instance per driver", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const ids = mod.listAgentInstances().map((instance) => instance.id); + expect(ids).toContain("codex-acp"); + expect(ids).toContain("claude-agent-acp"); + // Defaults reuse the driver id so legacy threads/selections resolve. + const codex = mod.getAgentInstance("codex-acp"); + expect(codex?.driverId).toBe("codex-acp"); + }); + + test("creates a named account with a distinct id", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "codex-acp", + displayName: "Work", + env: [{ name: "CODEX_HOME", value: "/tmp/work" }], + }); + expect(created.id).toBe("codex-acp:work"); + expect(created.driverId).toBe("codex-acp"); + }); + + test("auto-isolates a new account's credential root when env is omitted", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const profileVar = (created.env ?? []).find((entry) => entry.name === "CODEX_HOME"); + expect(profileVar?.value).toContain("accounts"); + // The CLI refuses to start if the credential root doesn't exist, so the + // directory must be materialized at creation time. + expect(existsSync(profileVar?.value ?? "")).toBe(true); + // The default instance keeps the ambient login (no forced profile dir). + const defaultInstance = mod.getAgentInstance("codex-acp"); + expect(defaultInstance?.env).toBeUndefined(); + }); + + test("resolves an instance descriptor with merged env", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + mod.createAgentInstance({ + driverId: "codex-acp", + displayName: "Work", + env: [{ name: "CODEX_HOME", value: "/tmp/work" }], + }); + const descriptor = mod.resolveAgentInstanceDescriptor("codex-acp:work"); + expect(descriptor?.id).toBe("codex-acp:work"); + expect(descriptor?.driverId).toBe("codex-acp"); + // Driver env survives; instance env overlays it. + expect(descriptor?.env).toEqual({ SHARED: "driver", CODEX_HOME: "/tmp/work" }); + }); + + test("returns null for an unknown instance", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + expect(mod.resolveAgentInstanceDescriptor("does-not-exist")).toBeNull(); + }); + + test("redacts sensitive env values for the renderer", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "codex-acp", + displayName: "Work", + env: [ + { name: "CODEX_HOME", value: "/tmp/work" }, + { name: "CODEX_API_KEY", value: "secret", sensitive: true }, + ], + }); + const redacted = mod.redactInstance(created); + const byName = Object.fromEntries((redacted.env ?? []).map((e) => [e.name, e.value])); + expect(byName.CODEX_HOME).toBe("/tmp/work"); + expect(byName.CODEX_API_KEY).toBe(""); + }); + + test("refuses to delete a driver's default instance", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + expect(() => mod.deleteAgentInstance("codex-acp")).toThrow(/default instance/); + }); + + test("encrypts sensitive env at rest and decrypts on read", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "cursor-acp", + displayName: "Work", + env: [{ name: "CURSOR_API_KEY", value: "super-secret", sensitive: true }], + }); + // Raw row holds ciphertext (safeStorage marker), never the cleartext. + const db = (await import("./db.ts")).getDb(); + const row = db.prepare("SELECT env_json FROM agent_instances WHERE id = ?").get(created.id) as { + env_json: string; + }; + expect(row.env_json).toContain("enc:"); + expect(row.env_json).not.toContain("super-secret"); + // Round-trips back to the original value for spawn env. + const read = mod.getAgentInstance(created.id); + expect((read?.env ?? []).find((e) => e.name === "CURSOR_API_KEY")?.value).toBe("super-secret"); + }); + + test("builds a login command that exports the account's credential root", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const command = mod.buildInstanceLoginCommand(created); + expect(command).toContain("mkdir -p"); + expect(command).toContain("CODEX_HOME="); + expect(command).toContain("codex login"); + // API-key providers have no interactive login. + expect(mod.buildInstanceLoginCommand({ ...created, driverId: "cursor-acp" })).toBeNull(); + }); + + test("builds a Windows-compatible login command", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const command = mod.buildInstanceLoginCommand(created, "win32"); + expect(command).toMatch(/^if not exist ".*" mkdir ".*" && set "CODEX_HOME=.*" && codex login$/); + }); + + test("rejects an unknown driverId", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + expect(() => mod.createAgentInstance({ driverId: "nope", displayName: "X" })).toThrow( + /Unknown driverId/, + ); + }); + + test("refuses to store a secret when encryption is unavailable", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + safeStorageState.available = false; + expect(() => + mod.createAgentInstance({ + driverId: "cursor-acp", + displayName: "Work", + env: [{ name: "CURSOR_API_KEY", value: "plaintext", sensitive: true }], + }), + ).toThrow(/plaintext/); + }); + + test("preserves a stored secret when a redacted empty value round-trips", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ + driverId: "cursor-acp", + displayName: "Work", + env: [{ name: "CURSOR_API_KEY", value: "keep-me", sensitive: true }], + }); + // Renderer got "", then sends the account back while editing another field. + mod.updateAgentInstance(created.id, { + displayName: "Work Renamed", + env: [{ name: "CURSOR_API_KEY", value: "", sensitive: true }], + }); + const stored = mod.getAgentInstance(created.id); + expect(stored?.displayName).toBe("Work Renamed"); + expect((stored?.env ?? []).find((e) => e.name === "CURSOR_API_KEY")?.value).toBe("keep-me"); + }); + + test("strips ambient credentials for a non-default account", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const descriptor = mod.resolveAgentInstanceDescriptor("codex-acp:work"); + expect(descriptor?.unsetEnv).toContain("OPENAI_API_KEY"); + expect(descriptor?.unsetEnv).toContain("CODEX_API_KEY"); + // The default instance keeps the ambient environment. + expect(mod.resolveAgentInstanceDescriptor("codex-acp")?.unsetEnv).toBeUndefined(); + }); + + test("re-points threads at the driver default when an account is removed", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const db = (await import("./db.ts")).getDb(); + db.prepare("INSERT INTO projects (id, path, name) VALUES (?, ?, ?)").run("p1", "/repo", "Repo"); + db.prepare( + "INSERT INTO threads (id, project_id, agent_id, agent_session_id) VALUES (?, ?, ?, ?)", + ).run("t1", "p1", created.id, "s1"); + + mod.deleteAgentInstance(created.id); + + const row = db.prepare("SELECT agent_id FROM threads WHERE id = ?").get("t1") as { + agent_id: string; + }; + expect(row.agent_id).toBe("codex-acp"); + }); + + test("pins file-based credential storage in a Codex account home", async () => { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + const created = mod.createAgentInstance({ driverId: "codex-acp", displayName: "Work" }); + const dir = (created.env ?? []).find((entry) => entry.name === "CODEX_HOME")?.value ?? ""; + const config = readFileSync(join(dir, "config.toml"), "utf8"); + expect(config).toContain('cli_auth_credentials_store = "file"'); + }); + + test("pins file-based credential storage for the default Codex home", async () => { + const codexHome = mkdtempSync(join(tmpdir(), "pipper-codex-home-")); + process.env.CODEX_HOME = codexHome; + try { + const mod = await load(); + mod.ensureDefaultAgentInstances(); + mod.ensureAmbientCodexFileStore(); + const config = readFileSync(join(codexHome, "config.toml"), "utf8"); + expect(config).toContain('cli_auth_credentials_store = "file"'); + } finally { + delete process.env.CODEX_HOME; + rmSync(codexHome, { recursive: true, force: true }); + } + }); +}); diff --git a/electron/agent-instances.ts b/electron/agent-instances.ts new file mode 100644 index 0000000..a4f5786 --- /dev/null +++ b/electron/agent-instances.ts @@ -0,0 +1,526 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { app, safeStorage } from "electron"; +import type { + AcpAgentDescriptor, + AcpAgentInstance, + AcpAgentInstanceEnvVar, + AcpAgentInstanceInput, + AgentAccountSchema, +} from "../contracts/acp.ts"; +import { getDb } from "./db.ts"; +import { listRegisteredAgents, setInstanceDescriptorProvider } from "./agents/registry.ts"; + +interface AgentInstanceRow { + id: string; + driver_id: string; + display_name: string; + enabled: number; + env_json: string | null; + config_json: string | null; + created_at: number; + updated_at: number; +} + +/** + * The environment variable that points a given CLI at an isolated credential + * root. Providers absent from this map are isolated with explicit keys/tokens + * instead of a directory (e.g. Cursor's CURSOR_API_KEY, Gemini's API key). + */ +export const PROFILE_ENV_BY_DRIVER: Record = { + "codex-acp": "CODEX_HOME", + "claude-agent-acp": "CLAUDE_CONFIG_DIR", + "grok-acp": "GROK_HOME", + "copilot-acp": "COPILOT_HOME", + "opencode-acp": "XDG_DATA_HOME", + "antigravity-acp": "AGY_HOME", +}; + +/** + * Drivers that are isolated with an explicit key/token rather than a profile + * directory. Used to prompt for a credential when adding an account. + */ +export const AUTH_ENV_BY_DRIVER: Record = { + "cursor-acp": "CURSOR_API_KEY", + "gemini-acp": "GEMINI_API_KEY", +}; + +/** + * Ambient credentials a driver may pick up from the parent environment. For a + * non-default (isolated) account these are removed before spawn so the child + * cannot fall back to the machine's default login instead of the account the + * user configured. + */ +const CREDENTIAL_ENV_BY_DRIVER: Record = { + "codex-acp": ["OPENAI_API_KEY", "CODEX_API_KEY"], + "claude-agent-acp": ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"], + "grok-acp": ["XAI_API_KEY"], + "copilot-acp": ["GH_TOKEN", "GITHUB_TOKEN"], + "gemini-acp": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + "cursor-acp": ["CURSOR_API_KEY"], + "antigravity-acp": ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"], +}; + +/** + * Interactive sign-in command per driver, run once per account inside the + * user's terminal (never in-process) so the CLI performs its own OAuth flow + * and owns its credentials. Drivers that authenticate purely via an API key + * are absent. Best-effort: verify against each CLI's current docs. + */ +export const LOGIN_COMMAND_BY_DRIVER: Record = { + "codex-acp": "codex login", + "claude-agent-acp": "claude auth login", + "grok-acp": "grok login", + "copilot-acp": "copilot", + "opencode-acp": "opencode auth login", + "antigravity-acp": "agy", +}; + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * The shell command a user runs to sign an account in, with its credential + * root exported inline. Returns null when the driver has no interactive login + * (API-key providers) so the UI can prompt for a key instead. Windows shells + * don't accept the POSIX `VAR=value cmd` prefix, so build `set "VAR=value" && cmd`. + */ +export function buildInstanceLoginCommand( + instance: AcpAgentInstance, + platform: NodeJS.Platform = process.platform, +): string | null { + const login = LOGIN_COMMAND_BY_DRIVER[instance.driverId]; + if (!login) return null; + const profileVar = PROFILE_ENV_BY_DRIVER[instance.driverId]; + if (!profileVar) return login; + const entry = (instance.env ?? []).find((item) => item.name === profileVar); + if (!entry?.value) return login; + if (platform === "win32") { + // cmd has no POSIX env prefix; create the dir and set the variable first. + return `if not exist "${entry.value}" mkdir "${entry.value}" && set "${profileVar}=${entry.value}" && ${login}`; + } + // Create the credential root too: some CLIs (Codex) refuse to start when the + // directory is missing, and this must work even for accounts created before + // the app materialized the directory. + return `mkdir -p ${shellQuote(entry.value)} && ${profileVar}=${shellQuote(entry.value)} ${login}`; +} + +function parseEnv(raw: string | null): AcpAgentInstanceEnvVar[] | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as AcpAgentInstanceEnvVar[]; + if (!Array.isArray(parsed)) return undefined; + return parsed.map((entry) => + entry.sensitive ? { ...entry, value: decryptSecret(entry.value) } : entry, + ); + } catch { + return undefined; + } +} + +/** Marker for values protected with the OS keychain via `safeStorage`. */ +const ENC_PREFIX = "enc:"; + +function encryptSecret(value: string): string { + if (!value) return value; + let encrypted: Buffer | null = null; + try { + if (safeStorage?.isEncryptionAvailable()) encrypted = safeStorage.encryptString(value); + } catch { + encrypted = null; + } + if (!encrypted) { + // Never silently downgrade to plaintext: refuse the write instead. + throw new Error( + "Secure credential storage is unavailable on this device; refusing to save the secret in plaintext.", + ); + } + return ENC_PREFIX + encrypted.toString("base64"); +} + +function decryptSecret(value: string): string { + if (!value.startsWith(ENC_PREFIX)) return value; + try { + return safeStorage.decryptString(Buffer.from(value.slice(ENC_PREFIX.length), "base64")); + } catch { + return ""; + } +} + +/** Encrypt sensitive values before they touch disk. */ +function serializeEnv(env: AcpAgentInstanceEnvVar[] | undefined): string | null { + if (!env?.length) return null; + return JSON.stringify( + env.map((entry) => (entry.sensitive ? { ...entry, value: encryptSecret(entry.value) } : entry)), + ); +} + +function parseConfig(raw: string | null): Record | undefined { + if (!raw) return undefined; + try { + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === "object" ? parsed : undefined; + } catch { + return undefined; + } +} + +function rowToInstance(row: AgentInstanceRow): AcpAgentInstance { + return { + id: row.id, + driverId: row.driver_id, + displayName: row.display_name, + enabled: row.enabled !== 0, + env: parseEnv(row.env_json), + config: parseConfig(row.config_json), + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function slug(input: string): string { + const value = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + return value || "account"; +} + +/** Redact sensitive env values before sending an instance to the renderer. */ +export function redactInstance(instance: AcpAgentInstance): AcpAgentInstance { + if (!instance.env?.some((entry) => entry.sensitive)) return instance; + return { + ...instance, + env: instance.env.map((entry) => (entry.sensitive ? { ...entry, value: "" } : entry)), + }; +} + +export function listAgentInstances(): AcpAgentInstance[] { + const rows = getDb() + .prepare("SELECT * FROM agent_instances ORDER BY driver_id ASC, created_at ASC, rowid ASC") + .all() as unknown as AgentInstanceRow[]; + return rows.map(rowToInstance); +} + +/** Renderer-facing copy with sensitive env values redacted. */ +export function listAgentInstancesForRenderer(): AcpAgentInstance[] { + return listAgentInstances().map(redactInstance); +} + +export function getAgentInstance(id: string): AcpAgentInstance | null { + const row = getDb().prepare("SELECT * FROM agent_instances WHERE id = ?").get(id) as + | AgentInstanceRow + | undefined; + return row ? rowToInstance(row) : null; +} + +function instanceIdFor(driverId: string, label: string): string { + const base = `${driverId}:${slug(label)}`; + const db = getDb(); + let candidate = base; + let suffix = 2; + // Ids are the spawn routing key; collisions would alias two accounts. + while (db.prepare("SELECT 1 FROM agent_instances WHERE id = ?").get(candidate)) { + candidate = `${base}-${suffix++}`; + } + return candidate; +} + +/** Directory an account's CLI should treat as its isolated credential root. */ +export function profileDirForInstance(driverId: string, instanceId: string): string { + return join(app.getPath("userData"), "accounts", driverId, slug(instanceId)); +} + +/** + * Codex defaults to storing its session in the OS keychain on macOS + * (`cli_auth_credentials_store = "auto"`), which the headless ACP adapter + * Pipper spawns cannot read — so a successful `codex login` looks + * unauthenticated to the app. Force file storage so the login writes + * `auth.json` inside the account's CODEX_HOME and the adapter (and probe) see + * it. The key is top-level, so it must precede any `[section]` header. + */ +const CODEX_CRED_STORE_LINE = 'cli_auth_credentials_store = "file"'; + +function ensureCodexFileStore(profileDir: string): void { + try { + const configPath = join(profileDir, "config.toml"); + let existing = ""; + try { + existing = readFileSync(configPath, "utf8"); + } catch { + existing = ""; + } + if (/^\s*cli_auth_credentials_store\s*=/m.test(existing)) return; + const prefix = existing.endsWith("\n") || existing === "" ? existing : `${existing}\n`; + writeFileSync(configPath, `${CODEX_CRED_STORE_LINE}\n${prefix}`, "utf8"); + } catch { + // Surfaced by the CLI at login/spawn time with a clearer message. + } +} + +/** + * Some CLIs (notably Codex) refuse to start when their credential-root env var + * points at a path that doesn't exist yet. Create it eagerly on account + * creation and again before spawn/login so older accounts self-heal. For Codex + * also pin file-based credential storage in the account's config. + */ +export function ensureInstanceProfileDirs(instance: AcpAgentInstance): void { + const profileVar = PROFILE_ENV_BY_DRIVER[instance.driverId]; + const entry = profileVar + ? (instance.env ?? []).find((item) => item.name === profileVar) + : undefined; + if (!entry?.value) return; + try { + mkdirSync(entry.value, { recursive: true }); + } catch { + // Surfaced by the CLI at login/spawn time with a clearer message. + } + if (instance.driverId === "codex-acp") ensureCodexFileStore(entry.value); +} + +/** + * Pin file-based credential storage in the ambient Codex home (the default + * account, which has no isolated profile). Only call this on an explicit user + * action (sign-in) — never at startup or in tests — so the machine's global + * Codex config is not touched implicitly. + */ +export function ensureAmbientCodexFileStore(): void { + const dir = process.env.CODEX_HOME || join(homedir(), ".codex"); + try { + mkdirSync(dir, { recursive: true }); + } catch { + // Surfaced by the CLI at login time. + } + ensureCodexFileStore(dir); +} + +/** + * Default env for a fresh non-default account: point the driver's credential + * root at a Pipper-owned directory. Returns `[]` for key/token-based providers. + */ +export function suggestProfileEnv(driverId: string, instanceId: string): AcpAgentInstanceEnvVar[] { + const envVar = PROFILE_ENV_BY_DRIVER[driverId]; + if (!envVar) return []; + return [{ name: envVar, value: profileDirForInstance(driverId, instanceId), sensitive: false }]; +} + +export function createAgentInstance(input: AcpAgentInstanceInput): AcpAgentInstance { + const driverId = input.driverId.trim(); + if (!driverId) throw new Error("driverId is required"); + if (!listRegisteredAgents().some((driver) => driver.id === driverId)) { + throw new Error(`Unknown driverId: ${driverId}`); + } + const displayName = input.displayName.trim() || driverId; + const id = (input.id?.trim() || instanceIdFor(driverId, displayName)).trim(); + // Additional accounts default to an isolated credential root so two logins + // of the same driver cannot share the ambient config. The default instance + // (id === driverId) intentionally keeps the ambient login. + const env = + input.env && input.env.length + ? input.env + : id === driverId + ? undefined + : suggestProfileEnv(driverId, id); + const now = Date.now(); + const row: AgentInstanceRow = { + id, + driver_id: driverId, + display_name: displayName, + enabled: input.enabled === false ? 0 : 1, + env_json: serializeEnv(env), + config_json: input.config ? JSON.stringify(input.config) : null, + created_at: now, + updated_at: now, + }; + getDb() + .prepare( + `INSERT INTO agent_instances (id, driver_id, display_name, enabled, env_json, config_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + row.id, + row.driver_id, + row.display_name, + row.enabled, + row.env_json, + row.config_json, + row.created_at, + row.updated_at, + ); + const instance = rowToInstance(row); + ensureInstanceProfileDirs(instance); + return instance; +} + +export function updateAgentInstance( + id: string, + input: Partial, +): AcpAgentInstance | null { + const existing = getAgentInstance(id); + if (!existing) return null; + const updated: AcpAgentInstance = { + ...existing, + displayName: input.displayName?.trim() || existing.displayName, + enabled: input.enabled ?? existing.enabled, + // A redacted list response carries sensitive values as ""; treat an empty + // sensitive value as "unchanged" so a round-trip edit can't erase the + // stored credential. Omitting the entry still removes it. + env: input.env + ? input.env.map((entry) => { + if (!entry.sensitive || entry.value) return entry; + const prior = existing.env?.find((candidate) => candidate.name === entry.name); + return prior ? { ...entry, value: prior.value } : entry; + }) + : existing.env, + config: input.config ?? existing.config, + updatedAt: Date.now(), + }; + getDb() + .prepare( + `UPDATE agent_instances SET display_name = ?, enabled = ?, env_json = ?, config_json = ?, updated_at = ? + WHERE id = ?`, + ) + .run( + updated.displayName, + updated.enabled ? 1 : 0, + serializeEnv(updated.env), + updated.config ? JSON.stringify(updated.config) : null, + updated.updatedAt ?? Date.now(), + id, + ); + ensureInstanceProfileDirs(updated); + return updated; +} + +export function deleteAgentInstance(id: string): void { + const existing = getAgentInstance(id); + if (!existing) return; + // The default instance (id === driver id) is structurally required: it backs + // the driver's ambient login and legacy thread rows. + if (existing.id === existing.driverId) { + throw new Error("Cannot delete a driver's default instance"); + } + const db = getDb(); + db.exec("BEGIN IMMEDIATE;"); + try { + // Re-point threads/snapshots that referenced the removed account at the + // driver's default instance so they stay resolvable (and become explicit + // rather than silently falling back to whatever resolves first). + db.prepare("UPDATE threads SET agent_id = ? WHERE agent_id = ?").run(existing.driverId, id); + db.prepare("UPDATE thread_snapshots SET agent_id = ? WHERE agent_id = ?").run( + existing.driverId, + id, + ); + db.prepare("DELETE FROM agent_instances WHERE id = ?").run(id); + db.exec("COMMIT;"); + } catch (error) { + db.exec("ROLLBACK;"); + throw error; + } +} + +/** True once the instance table has been seeded (initialized state). */ +export function hasAgentInstances(): boolean { + return Boolean(getDb().prepare("SELECT 1 FROM agent_instances LIMIT 1").get()); +} + +function driverDescriptorById(): Map { + return new Map(listRegisteredAgents().map((descriptor) => [descriptor.id, descriptor])); +} + +function materialize(instance: AcpAgentInstance, driver: AcpAgentDescriptor): AcpAgentDescriptor { + const env: Record = { ...driver.env }; + for (const entry of instance.env ?? []) { + if (entry.name) env[entry.name] = entry.value; + } + const descriptor: AcpAgentDescriptor = { + ...driver, + id: instance.id, + driverId: instance.driverId, + displayName: instance.displayName, + env, + }; + // Isolated accounts must not inherit the machine's ambient provider keys; + // strip them unless the account explicitly sets that same variable. + if (instance.id !== instance.driverId) { + const setNames = new Set((instance.env ?? []).map((entry) => entry.name)); + const unsetEnv = (CREDENTIAL_ENV_BY_DRIVER[instance.driverId] ?? []).filter( + (name) => !setNames.has(name), + ); + if (unsetEnv.length) descriptor.unsetEnv = unsetEnv; + } + return descriptor; +} + +/** Resolve an instance id to a spawnable descriptor with merged env. */ +export function resolveAgentInstanceDescriptor(instanceId: string): AcpAgentDescriptor | null { + const instance = getAgentInstance(instanceId); + if (!instance || !instance.enabled) return null; + const driver = driverDescriptorById().get(instance.driverId); + if (!driver) return null; + // Self-heal the credential root for accounts created before dirs were + // materialized, so the CLI doesn't refuse to start. + ensureInstanceProfileDirs(instance); + return materialize(instance, driver); +} + +/** Every enabled instance as a descriptor, for agent/account pickers. */ +export function listAgentInstanceDescriptors(): AcpAgentDescriptor[] { + const drivers = driverDescriptorById(); + const out: AcpAgentDescriptor[] = []; + for (const instance of listAgentInstances()) { + if (!instance.enabled) continue; + const driver = drivers.get(instance.driverId); + if (!driver) continue; + out.push(materialize(instance, driver)); + } + return out; +} + +/** Per-driver account-creation capabilities, for the settings UI. */ +export function listAgentAccountSchemas(): AgentAccountSchema[] { + return listRegisteredAgents().map((driver) => ({ + driverId: driver.id, + displayName: driver.displayName, + icon: driver.icon, + profileEnvVar: PROFILE_ENV_BY_DRIVER[driver.id] ?? null, + authEnvVar: AUTH_ENV_BY_DRIVER[driver.id] ?? null, + supportsMultipleAccounts: Boolean( + PROFILE_ENV_BY_DRIVER[driver.id] ?? AUTH_ENV_BY_DRIVER[driver.id], + ), + supportsLogin: Boolean(LOGIN_COMMAND_BY_DRIVER[driver.id]), + })); +} + +/** + * Backfill the required default instance for every known driver. The default + * instance reuses the driver id so existing threads, selections, and ambient + * CLI logins keep working with no migration. + */ +export function ensureDefaultAgentInstances(): void { + const db = getDb(); + const insert = db.prepare( + `INSERT OR IGNORE INTO agent_instances (id, driver_id, display_name, enabled, env_json, config_json, created_at, updated_at) + VALUES (?, ?, ?, 1, NULL, NULL, ?, ?)`, + ); + const now = Date.now(); + for (const driver of listRegisteredAgents()) { + insert.run(driver.id, driver.id, driver.displayName, now, now); + } +} + +/** + * Seed defaults and register the instance resolver with the registry. Call + * once after the database is ready (agent-instances.ts owns the only writer). + */ +export function installAgentInstanceProvider(): void { + ensureDefaultAgentInstances(); + // Materialize credential roots for every existing account so a directory + // missed by an earlier version doesn't make the CLI refuse to start. + for (const instance of listAgentInstances()) { + ensureInstanceProfileDirs(instance); + } + setInstanceDescriptorProvider((instanceId) => resolveAgentInstanceDescriptor(instanceId)); +} diff --git a/electron/agents/registry.ts b/electron/agents/registry.ts index 443b13b..84e7767 100644 --- a/electron/agents/registry.ts +++ b/electron/agents/registry.ts @@ -13,6 +13,27 @@ interface RegistryFile { /** Directory of this module (avoid naming `__dirname` — electron-vite injects that). */ const registryDir = dirname(fileURLToPath(import.meta.url)); +/** + * Resolves a *provider instance* id (e.g. `codex-acp:work`) to a materialized + * descriptor whose `id` is the instance id and whose `env` carries the + * instance's isolated credential root. Installed by `agent-instances.ts` at + * startup so this module stays free of a database dependency (and stays + * unit-testable). + */ +export type AgentInstanceDescriptorProvider = (instanceId: string) => AcpAgentDescriptor | null; +let instanceDescriptorProvider: AgentInstanceDescriptorProvider | null = null; + +export function setInstanceDescriptorProvider( + provider: AgentInstanceDescriptorProvider | null, +): void { + instanceDescriptorProvider = provider; +} + +/** The driver id behind a descriptor (instance descriptors carry `driverId`). */ +export function descriptorDriverId(descriptor: AcpAgentDescriptor): string { + return descriptor.driverId ?? descriptor.id; +} + /** Built-in catalog — used when config.json is missing or incomplete. */ export const BUILTIN_ACP_AGENTS: AcpAgentDescriptor[] = [ { @@ -420,6 +441,13 @@ export function listRegisteredAgents(): AcpAgentDescriptor[] { } export function getAgentDescriptor(agentId: string): AcpAgentDescriptor | null { + const fromInstance = instanceDescriptorProvider?.(agentId); + if (fromInstance) return fromInstance; + // Once instance storage is configured, a miss means the instance is unknown + // or disabled — do NOT fall back to the driver (that would re-enable a + // disabled default account). The driver fallback is only for the + // uninitialized/legacy state with no provider installed. + if (instanceDescriptorProvider) return null; return listRegisteredAgents().find((a) => a.id === agentId) ?? null; } @@ -444,6 +472,9 @@ export function resolveAgentSpawn(agent: AcpAgentDescriptor): { env: Record; } { const env = { ...process.env, ...agent.env } as Record; + // Drop ambient provider credentials for isolated accounts so the child can't + // authenticate as the machine's default login instead of the chosen account. + for (const name of agent.unsetEnv ?? []) delete env[name]; if (agent.id === "pipper-mock" || agent.installKind === "mock") { const mockPath = join(registryDir, "mock-agent.mjs"); @@ -456,8 +487,12 @@ export function resolveAgentSpawn(agent: AcpAgentDescriptor): { }; } - // Re-probe so spawn uses latest PATH resolution - const probed = probeAgentAvailability(agent); + // Re-probe so spawn uses latest PATH resolution. Probe the *driver* + // descriptor (id = driverId) rather than the instance id, so driver-specific + // binary resolution (e.g. Cursor's `agent` disambiguation) still applies; + // the instance only contributes `env`. + const probeBase: AcpAgentDescriptor = agent.driverId ? { ...agent, id: agent.driverId } : agent; + const probed = probeAgentAvailability(probeBase); if (!probed.available) { throw new Error( probed.statusMessage ?? diff --git a/electron/connection-lifecycle.ts b/electron/connection-lifecycle.ts index 99e15b0..257bf82 100644 --- a/electron/connection-lifecycle.ts +++ b/electron/connection-lifecycle.ts @@ -121,7 +121,10 @@ async function terminateChildProcess(child: ChildProcessWithoutNullStreams): Pro export interface LiveConnection { connectionId: string; + /** Spawn routing key: the provider *instance* id (driver id when default). */ agentId: string; + /** The underlying driver/provider id this instance is a configuration of. */ + driverId: string; agentInfoName: string; process: ChildProcessWithoutNullStreams; connection: acp.ClientConnection; @@ -275,6 +278,25 @@ export class ConnectionLifecycle { ); } + /** + * Close and forget one instance's transport (e.g. its account was removed), + * without touching other instances of the same driver. + */ + async close(agentId: string): Promise { + const live = this.connections.get(agentId); + if (!live) return; + this.connections.delete(agentId); + this.intentionalConnectionIds.add(live.connectionId); + if (this.activeConnection === live) this.activeConnection = null; + try { + live.connection.close(); + } catch { + // ignore + } + await terminateChildProcess(live.process); + this.deps.invalidateAgentSessions(agentId); + } + private async spawnAndInitialize(descriptor: AcpAgentDescriptor): Promise { const { command, args, env } = resolveAgentSpawn(descriptor); const useShell = process.platform === "win32" && /\.cmd$/i.test(command); @@ -481,6 +503,7 @@ export class ConnectionLifecycle { return { connectionId, agentId: descriptor.id, + driverId: descriptor.driverId ?? descriptor.id, agentInfoName, process: child, connection, diff --git a/electron/db.ts b/electron/db.ts index c0851fc..14766fa 100644 --- a/electron/db.ts +++ b/electron/db.ts @@ -234,6 +234,34 @@ export function getDb(): DatabaseSync { ); `); + // One row per provider instance (account/configuration). The default + // instance for a driver reuses the driver id as its primary key, so legacy + // threads and selections resolve unchanged; additional accounts get ids like + // `codex-acp:work`. `env_json` holds per-instance environment overrides + // (including credential-root paths such as CODEX_HOME). + db.exec(` + CREATE TABLE IF NOT EXISTS agent_instances ( + id TEXT PRIMARY KEY, + driver_id TEXT NOT NULL, + display_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + env_json TEXT, + config_json TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_agent_instances_driver ON agent_instances(driver_id); + `); + + // Small key/value store for app-level pointers that don't deserve a column + // (e.g. the last-used provider instance). + db.exec(` + CREATE TABLE IF NOT EXISTS app_settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + `); + // Last-known-good GitHub PR data. This is a display cache: local git stays // authoritative, while the cached remote slice keeps the workflow panel // useful during network/auth outages and across app restarts. @@ -460,3 +488,19 @@ export function getMostRecentAuthUser(): AuthUserRecord | null { .get() as AuthUserRecord | undefined; return row ?? null; } + +export function getAppSetting(key: string): string | null { + const row = getDb().prepare("SELECT value FROM app_settings WHERE key = ?").get(key) as + | { value: string | null } + | undefined; + return row?.value ?? null; +} + +export function setAppSetting(key: string, value: string | null): void { + getDb() + .prepare( + `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ) + .run(key, value); +} diff --git a/electron/main.ts b/electron/main.ts index 9f24543..058d969 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -62,6 +62,19 @@ import { import { getThread, listThreads, listThreadsByIds, listProjectThreads } from "./threads"; import type { OpenTabsState } from "../contracts/threads.ts"; import { listMcpServers, createMcpServer, updateMcpServer, deleteMcpServer } from "./mcp-servers"; +import { + installAgentInstanceProvider, + listAgentInstancesForRenderer, + listAgentAccountSchemas, + getAgentInstance, + redactInstance, + ensureInstanceProfileDirs, + ensureAmbientCodexFileStore, + buildInstanceLoginCommand, + createAgentInstance, + updateAgentInstance, + deleteAgentInstance, +} from "./agent-instances"; import { AgentManager } from "./agent"; import { createElectronOsNotifier } from "./os-notifications"; import { WindowVisibilityGate } from "./window-visibility"; @@ -448,6 +461,61 @@ function requireLauncherUpdateManager(): LauncherUpdateManager { return launcherUpdateManager; } +/** + * Launch a provider account's interactive sign-in inside the user's terminal, + * with its isolated credential root exported. Pipper never handles the + * credentials — the CLI runs its own OAuth flow and owns its storage. Returns + * the command so the UI can offer a manual fallback if no terminal opened. + */ +async function launchInstanceLogin( + instanceId: string, +): Promise<{ command: string; opened: boolean }> { + const instance = getAgentInstance(instanceId); + if (!instance) throw new Error(`Unknown account: ${instanceId}`); + // The CLI (e.g. Codex) errors if its credential root doesn't exist, so make + // sure the profile directory is present before launching sign-in. + ensureInstanceProfileDirs(instance); + // The default Codex account uses the ambient ~/.codex; pin file-based + // credential storage there so login writes auth.json the adapter can read. + if (instance.driverId === "codex-acp" && instance.id === instance.driverId) { + ensureAmbientCodexFileStore(); + } + const command = buildInstanceLoginCommand(instance); + if (!command) { + throw new Error("This provider signs in with an API key, not a browser login."); + } + try { + if (process.platform === "darwin") { + await execFileAsync("osascript", [ + "-e", + `tell application "Terminal" to do script ${JSON.stringify(command)}`, + "-e", + `tell application "Terminal" to activate`, + ]); + return { command, opened: true }; + } + if (process.platform === "win32") { + await execFileAsync("cmd", ["/c", "start", "", "cmd", "/k", command]); + return { command, opened: true }; + } + // Linux: `spawn` reports a missing terminal via an async 'error' event, not + // a throw, so wait for spawn/error before claiming the terminal opened. + return await new Promise<{ command: string; opened: boolean }>((resolve) => { + const child = spawn("x-terminal-emulator", ["-e", "sh", "-c", command], { + detached: true, + stdio: "ignore", + }); + child.once("spawn", () => { + child.unref(); + resolve({ command, opened: true }); + }); + child.once("error", () => resolve({ command, opened: false })); + }); + } catch { + return { command, opened: false }; + } +} + /** * Enter a workspace (the project root or a linked worktree): persist it as * the project's canonical workspace, then restore that workspace's own last @@ -2208,9 +2276,15 @@ function registerIpc(): void { ); ipcMain.handle("agent:listAgents", () => requireAgentManager().listAgents()); ipcMain.handle("agent:getModelCatalogs", () => requireAgentManager().getModelCatalogs()); - ipcMain.handle("agent:probeAgent", (_event, agentId: string) => - probeAgentById(agentId, { clientVersion: app.getVersion() }), - ); + ipcMain.handle("agent:probeAgent", async (_event, agentId: string) => { + const result = await probeAgentById(agentId, { clientVersion: app.getVersion() }); + if (result.status !== "ready") { + console.warn( + `[probe] ${agentId}: ${result.status}${result.message ? ` — ${result.message}` : ""}`, + ); + } + return result; + }); ipcMain.handle("agent:switchAgent", (_event, agentId: string) => requireAgentManager().switchAgent(agentId), ); @@ -2222,6 +2296,27 @@ function registerIpc(): void { ipcMain.handle("agent:setSelectedAgentIds", (_event, agentIds: string[]) => { setSelectedAgentIds(agentIds); }); + ipcMain.handle("agent:listInstances", () => listAgentInstancesForRenderer()); + ipcMain.handle("agent:getAccountSchemas", () => listAgentAccountSchemas()); + ipcMain.handle("agent:createInstance", (_event, input) => { + const created = createAgentInstance(input); + broadcastToWindows("agent:instancesChanged", {}); + // Never ship decrypted secrets back to the renderer. + return redactInstance(created); + }); + ipcMain.handle("agent:updateInstance", (_event, id: string, input) => { + const updated = updateAgentInstance(id, input); + broadcastToWindows("agent:instancesChanged", {}); + return updated ? redactInstance(updated) : null; + }); + ipcMain.handle("agent:deleteInstance", async (_event, id: string) => { + // Reconcile live sessions/connection and the preferred pointer, then + // delete (which re-points any threads at the driver's default instance). + await agentManager?.removeAgentInstance(id); + deleteAgentInstance(id); + broadcastToWindows("agent:instancesChanged", {}); + }); + ipcMain.handle("agent:launchInstanceLogin", (_event, id: string) => launchInstanceLogin(id)); ipcMain.handle("agent:closeThreadSession", (_event, threadId: string) => requireAgentManager().closeThreadSession(threadId), ); @@ -2622,6 +2717,9 @@ app.whenReady().then(async () => { write: saveGithubPrSnapshot, }); logStartupMilestone("database:init:complete"); + // Seed per-driver default instances and wire instance→descriptor resolution + // into the agent registry before anything spawns an agent. + installAgentInstanceProvider(); await prepareBenchmarkLaunchState(); const authUser = getAuthenticatedUserForLaunch(); if (authUser) { diff --git a/electron/preload.ts b/electron/preload.ts index a04d019..5ecff7d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -17,6 +17,9 @@ import type { AvailableCommand, SessionConfigOption, AcpAgentDescriptor, + AcpAgentInstance, + AcpAgentInstanceInput, + AgentAccountSchema, AgentProbeResult, SubagentConfig, SubagentRunSnapshot, @@ -363,6 +366,25 @@ const api = { getSelectedAgentIds: (): Promise => ipcRenderer.invoke("agent:getSelectedAgentIds"), setSelectedAgentIds: (agentIds: string[]): Promise => ipcRenderer.invoke("agent:setSelectedAgentIds", agentIds), + listInstances: (): Promise => ipcRenderer.invoke("agent:listInstances"), + getAccountSchemas: (): Promise => + ipcRenderer.invoke("agent:getAccountSchemas"), + createInstance: (input: AcpAgentInstanceInput): Promise => + ipcRenderer.invoke("agent:createInstance", input), + updateInstance: ( + id: string, + input: Partial, + ): Promise => ipcRenderer.invoke("agent:updateInstance", id, input), + deleteInstance: (id: string): Promise => ipcRenderer.invoke("agent:deleteInstance", id), + launchInstanceLogin: (id: string): Promise<{ command: string; opened: boolean }> => + ipcRenderer.invoke("agent:launchInstanceLogin", id), + onInstancesChanged: (callback: () => void) => { + const listener = () => callback(); + ipcRenderer.on("agent:instancesChanged", listener); + return () => { + ipcRenderer.removeListener("agent:instancesChanged", listener); + }; + }, setConfigOption: (configId: string, value: string | boolean): Promise => ipcRenderer.invoke("agent:setConfigOption", configId, value), respondToPermission: (response: { diff --git a/electron/remote-server.ts b/electron/remote-server.ts index 0b8ee4e..ab402cc 100644 --- a/electron/remote-server.ts +++ b/electron/remote-server.ts @@ -8,6 +8,7 @@ import { join } from "node:path"; import type { AgentManager } from "./agent-connection-manager.ts"; import { listProjects, getProject } from "./projects.ts"; import { listRegisteredAgents } from "./agents/registry.ts"; +import { listAgentInstanceDescriptors } from "./agent-instances.ts"; import { getThread, listThreads } from "./threads.ts"; import { createWorktree, gitBinary, removeWorktreeBestEffort } from "./worktree-manager.ts"; import type { @@ -331,9 +332,17 @@ export class RemoteServer { return send(res, 200, { projects }); } if (req.method === "GET" && path === "/api/remote/models") { - const models: RemoteModel[] = listRegisteredAgents().map((a) => ({ + // Offer provider instances (accounts), not just drivers, so a phone can + // route a task to a specific account. Default instances reuse the + // driver id, so single-account setups see exactly the same list. + // `provider` groups accounts under their driver on the phone. + const driverNames = new Map( + listRegisteredAgents().map((driver) => [driver.id, driver.displayName ?? driver.name]), + ); + const models: RemoteModel[] = listAgentInstanceDescriptors().map((a) => ({ id: a.id, name: a.displayName ?? a.name ?? a.id, + provider: driverNames.get(a.driverId ?? a.id) ?? a.driverId ?? a.id, })); return send(res, 200, { models }); } @@ -400,10 +409,11 @@ export class RemoteServer { console.log( `[Remote] new phone thread project=${project.id} worktree=${worktreePath ?? ""} promptLen=${body.prompt.length}`, ); - // modelId from the phone is an *agent* id (listRegisteredAgents). - // Use it to pick the connection, but never as a model name — the - // agent's own default model applies (e.g. antigravity has no implicit - // default; the user's desktop default is used). + // modelId from the phone is a provider *instance* id + // (listAgentInstanceDescriptors; driver id when default). Use it to pick + // the connection, but never as a model name — the agent's own default + // model applies (e.g. antigravity has no implicit default; the user's + // desktop default is used). try { const thread = await am.createThread( project.id, diff --git a/src/components/agent-accounts-settings.tsx b/src/components/agent-accounts-settings.tsx new file mode 100644 index 0000000..0d028f7 --- /dev/null +++ b/src/components/agent-accounts-settings.tsx @@ -0,0 +1,442 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + BadgeCheck, + CircleAlert, + CircleDashed, + CircleX, + Loader2, + Plus, + RefreshCw, + Trash2, + type LucideIcon, +} from "lucide-react"; +import { ProviderLogo } from "@/components/provider-logos"; +import { useAgentInstancesStore } from "@/store/agent-instances-store"; +import type { + AcpAgentInstance, + AgentAccountSchema, + AgentProbeResult, +} from "../../contracts/acp.ts"; + +interface StatusView { + label: string; + textClass: string; + icon: LucideIcon; +} + +function statusView(result: AgentProbeResult | undefined, probing: boolean): StatusView { + if (probing) { + return { label: "Checking…", textClass: "text-muted-foreground", icon: Loader2 }; + } + if (!result) { + return { label: "Not checked", textClass: "text-muted-foreground/70", icon: CircleDashed }; + } + switch (result.status) { + case "ready": + return { label: "Signed in", textClass: "text-emerald-500", icon: BadgeCheck }; + case "needs-auth": + return { label: "Sign-in required", textClass: "text-amber-500", icon: CircleAlert }; + case "needs-install": + return { label: "Not installed", textClass: "text-amber-500", icon: CircleAlert }; + case "error": + return { label: "Check failed", textClass: "text-destructive", icon: CircleX }; + default: + return { label: "Unknown", textClass: "text-muted-foreground/70", icon: CircleDashed }; + } +} + +function StatusIcon({ result, probing }: { result?: AgentProbeResult; probing: boolean }) { + const view = statusView(result, probing); + const Icon = view.icon; + return ( + + + + ); +} + +function AccountRow({ + instance, + schema, + result, + probing, + onRemove, + onSignIn, + onCheck, +}: { + instance: AcpAgentInstance; + schema: AgentAccountSchema; + result?: AgentProbeResult; + probing: boolean; + onRemove: (id: string) => void; + onSignIn: (id: string) => void; + onCheck: (id: string) => void; +}) { + const isDefault = instance.id === instance.driverId; + return ( +
+ {instance.displayName} + +
+ + {schema.supportsLogin ? ( + + ) : null} + {isDefault ? ( +
+ ) : ( + + )} +
+
+ ); +} + +function AddAccountForm({ + schema, + onSubmit, + onCancel, +}: { + schema: AgentAccountSchema; + onSubmit: (input: { displayName: string; secret?: string }) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const [secret, setSecret] = useState(""); + const canSubmit = name.trim().length > 0 && (!schema.authEnvVar || secret.trim().length > 0); + + return ( +
+ setName(event.target.value)} + placeholder="Account name (e.g. Work)" + className="h-8 rounded-lg border border-border/70 bg-surface-1 px-2.5 text-[12px] text-foreground outline-none focus:border-border" + /> + {schema.authEnvVar ? ( + setSecret(event.target.value)} + placeholder={schema.authEnvVar} + type="password" + className="h-8 rounded-lg border border-border/70 bg-surface-1 px-2.5 text-[12px] text-foreground outline-none focus:border-border" + /> + ) : null} +
+ + +
+
+ ); +} + +/** How long to keep polling for sign-in completion after launching a login. */ +const SIGNIN_POLL_INTERVAL_MS = 3_000; +const SIGNIN_POLL_MAX_ATTEMPTS = 40; + +/** + * Settings section for managing provider accounts. Lets the user add a second + * account for a driver (isolated via its credential-root env var) without + * touching the default ambient login. Each account shows a live sign-in status + * (probed via a throwaway ACP session), and is polled after launching login so + * completion is visible in the app rather than only in the terminal. + */ +export function AgentAccountsSettings() { + const { instances, schemas, error, load, create, remove, launchLogin } = useAgentInstancesStore(); + const [addingDriverId, setAddingDriverId] = useState(null); + const [notice, setNotice] = useState(null); + const [probeResults, setProbeResults] = useState>({}); + const [probingIds, setProbingIds] = useState>(() => new Set()); + const probedRef = useRef>(new Set()); + const pollTimers = useRef>>(new Map()); + const inFlight = useRef>>(new Map()); + /** Serializes probes so only one provider CLI is spawned at a time. */ + const probeQueue = useRef>(Promise.resolve()); + + useEffect(() => { + void load(); + }, [load]); + + const check = useCallback((id: string): Promise => { + const existing = inFlight.current.get(id); + if (existing) return existing; + const run = async (): Promise => { + if (!window.omni?.agent?.probeAgent) return null; + const probeOnce = async (): Promise => { + try { + return await window.omni.agent.probeAgent(id); + } catch (err) { + return { + agentId: id, + status: "error", + message: err instanceof Error ? err.message : "Check failed", + }; + } + }; + setProbingIds((prev) => new Set(prev).add(id)); + try { + let result = await probeOnce(); + // A CLI that was mid-restart (or briefly contended) can close the ACP + // connection; retry once before reporting a failure. + if (result.status === "error") { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + result = await probeOnce(); + } + setProbeResults((prev) => ({ ...prev, [id]: result })); + return result; + } finally { + setProbingIds((prev) => { + const next = new Set(prev); + next.delete(id); + return next; + }); + } + }; + // Queue behind any in-flight probe: concurrent agent spawns race on shared + // resources (auth files, login ports) and produce spurious failures. + const promise = probeQueue.current.then(run, run); + probeQueue.current = promise.then( + () => undefined, + () => undefined, + ); + inFlight.current.set(id, promise); + void promise.finally(() => { + if (inFlight.current.get(id) === promise) inFlight.current.delete(id); + }); + return promise; + }, []); + + const stopPolling = useCallback((id: string) => { + const timer = pollTimers.current.get(id); + if (timer) { + clearTimeout(timer); + pollTimers.current.delete(id); + } + }, []); + + const startPolling = useCallback( + (id: string) => { + stopPolling(id); + let attempts = 0; + // Self-scheduling: wait for each probe to finish before the next, so a + // slow agent can't stack up overlapping processes. + const tick = async () => { + attempts += 1; + const result = await check(id); + if (result?.status === "ready" || attempts >= SIGNIN_POLL_MAX_ATTEMPTS) { + stopPolling(id); + return; + } + const timer = setTimeout(() => void tick(), SIGNIN_POLL_INTERVAL_MS); + pollTimers.current.set(id, timer); + }; + const timer = setTimeout(() => void tick(), 1_500); + pollTimers.current.set(id, timer); + }, + [check, stopPolling], + ); + + // Stop any in-flight polling when the component unmounts. + useEffect(() => { + const timers = pollTimers.current; + return () => { + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + }, []); + + // Probe each account once so its status is visible without manual action. + useEffect(() => { + void (async () => { + for (const instance of instances) { + if (probedRef.current.has(instance.id)) continue; + probedRef.current.add(instance.id); + await check(instance.id); + } + })(); + }, [instances, check]); + + // Re-check when the user comes back to the app — e.g. after finishing a + // sign-in that was started outside Pipper's own "Sign in" button. + useEffect(() => { + const onFocus = () => { + for (const instance of instances) { + if (probeResults[instance.id]?.status === "ready") continue; + void check(instance.id); + } + }; + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [instances, probeResults, check]); + + const multiAccountSchemas = useMemo( + () => schemas.filter((schema) => schema.supportsMultipleAccounts), + [schemas], + ); + + const handleRemove = async (id: string) => { + stopPolling(id); + try { + await remove(id); + } catch { + // Store surfaces the error. + } + }; + + const handleSignIn = async (id: string) => { + try { + const result = await launchLogin(id); + if (result.opened) { + setNotice("Finish signing in the terminal window — status updates here automatically."); + startPolling(id); + } else { + try { + await navigator.clipboard?.writeText(result.command); + setNotice(`Sign-in command copied to clipboard: ${result.command}`); + } catch { + setNotice(`Run this in your terminal: ${result.command}`); + } + } + } catch (err) { + setNotice(err instanceof Error ? err.message : "Could not start sign-in"); + } + }; + + const handleAdd = async ( + schema: AgentAccountSchema, + input: { displayName: string; secret?: string }, + ) => { + try { + const created = await create({ + driverId: schema.driverId, + displayName: input.displayName, + env: + schema.authEnvVar && input.secret + ? [{ name: schema.authEnvVar, value: input.secret, sensitive: true }] + : undefined, + }); + setAddingDriverId(null); + setNotice( + schema.supportsLogin + ? "Account added. Click Sign in to authenticate it." + : "Account added.", + ); + // A newly created account has no probe result yet. + void check(created.id); + probedRef.current.add(created.id); + } catch { + // Store surfaces the error. + } + }; + + return ( +
+ {error ?
{error}
: null} + {notice ? ( +
{notice}
+ ) : null} + {multiAccountSchemas.map((schema, index) => { + const accounts = instances.filter((instance) => instance.driverId === schema.driverId); + const primary = + accounts.find((instance) => instance.id === instance.driverId) ?? accounts[0]; + return ( +
+ {index > 0 ?
: null} +
+ + + {schema.displayName} + + {primary ? ( + + ) : null} + +
+
+ {accounts.map((instance) => ( + void check(id)} + /> + ))} + {addingDriverId === schema.driverId ? ( + void handleAdd(schema, input)} + onCancel={() => setAddingDriverId(null)} + /> + ) : null} +
+
+ ); + })} + {multiAccountSchemas.length === 0 ? ( +
+ No providers with multi-account support are available. +
+ ) : null} +
+ ); +} diff --git a/src/components/agent-panel.tsx b/src/components/agent-panel.tsx index 6d34c16..784ab95 100644 --- a/src/components/agent-panel.tsx +++ b/src/components/agent-panel.tsx @@ -53,6 +53,7 @@ import { AgentSlashCommandMenu } from "@/components/agent-slash-command-menu"; import { AgentContinueMenu } from "@/components/agent-continue-menu"; import { AgentQuestionCard, AgentQuestionDock } from "@/components/agent-question"; import { cn } from "@/lib/utils"; +import { isInstanceSelected } from "@/lib/agent-selection"; import { beginRendererInteraction } from "@/lib/monitor-runtime-observer"; import { toast } from "@/components/ui/toast"; import type { AgentPanelSnapshot } from "@/store/agent-store"; @@ -815,7 +816,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { // Prefer the currently connected agent when it is in the user's pool. const registry = useAgentRegistryStore.getState(); const availableAgents = registry.agents.filter( - (a) => registry.selectedAgentIds.includes(a.id) && a.available !== false, + (a) => isInstanceSelected(a, registry.selectedAgentIds) && a.available !== false, ); const pool = availableAgents.length > 0 @@ -857,7 +858,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { const pool = registry.agents.filter( (a) => (registry.selectedAgentIds.length === 0 || - registry.selectedAgentIds.includes(a.id)) && + isInstanceSelected(a, registry.selectedAgentIds)) && a.available !== false, ); setDraftAgent(pool[0]?.id ?? null); @@ -2061,7 +2062,7 @@ export function AgentPanel({ demoInputValue }: AgentPanelProps = {}) { // late or selectedAgentIds is still empty). const draftAgentItems = useMemo(() => { const available = registryAgents.filter((a) => a.available !== false); - const selected = available.filter((a) => selectedAgentIds.includes(a.id)); + const selected = available.filter((a) => isInstanceSelected(a, selectedAgentIds)); const pool = selected.length > 0 ? selected : available; return pool.map((a) => ({ id: a.id, diff --git a/src/components/agent-selector.tsx b/src/components/agent-selector.tsx index c7dc0c3..12b9896 100644 --- a/src/components/agent-selector.tsx +++ b/src/components/agent-selector.tsx @@ -16,6 +16,7 @@ import { } from "@/components/ui/card"; import { createProviderLogoIcon } from "@/components/provider-logos"; import { cn } from "@/lib/utils"; +import { isDefaultInstance, isInstanceSelected } from "@/lib/agent-selection"; import type { AcpAgentDescriptor, AgentProbeResult } from "../../contracts/acp.ts"; /** @@ -143,13 +144,16 @@ export function AgentSelector({ } const visibleAgents = agents.filter((agent) => { + // Onboarding is provider-level: show one card per driver. Extra accounts + // are added later from Settings, not during first-run setup. + if (!isDefaultInstance(agent)) return false; if (agent.installKind !== "mock") return true; const anyReady = agents.some((a) => a.available && a.installKind !== "mock"); return !anyReady; }); const selectedAgentNames = agents - .filter((a) => selectedAgentIds.includes(a.id)) + .filter((a) => isInstanceSelected(a, selectedAgentIds)) .map((a) => a.displayName); return ( @@ -169,7 +173,7 @@ export function AgentSelector({ { await toggleAgent(agent.id); const next = useAgentRegistryStore.getState().selectedAgentIds; @@ -338,7 +342,8 @@ function AgentSetupCard({ result.status === "needs-auth" ? `Sign in required for ${descriptor.displayName}. Retry after authenticating` : `Retry ${descriptor.displayName}`; - const guideUrl = status === "ready" ? null : setupGuideUrl(descriptor.id); + // Instances carry `driverId`; the setup guide is keyed by driver. + const guideUrl = status === "ready" ? null : setupGuideUrl(descriptor.driverId ?? descriptor.id); const openSetupGuide = async () => { if (!guideUrl || !window.omni?.shell?.openExternal) return; diff --git a/src/electron.d.ts b/src/electron.d.ts index 2b7b20e..8652c41 100644 --- a/src/electron.d.ts +++ b/src/electron.d.ts @@ -8,6 +8,9 @@ import type { import type { OpenTabsState, Thread, ThreadPage } from "../../contracts/threads.ts"; import type { AcpAgentDescriptor, + AcpAgentInstance, + AcpAgentInstanceInput, + AgentAccountSchema, AcpBridgeEvent, AcpPromptInput, AcpReplacePromptInput, @@ -250,6 +253,16 @@ declare global { ) => Promise; getSelectedAgentIds: () => Promise; setSelectedAgentIds: (agentIds: string[]) => Promise; + listInstances: () => Promise; + getAccountSchemas: () => Promise; + createInstance: (input: AcpAgentInstanceInput) => Promise; + updateInstance: ( + id: string, + input: Partial, + ) => Promise; + deleteInstance: (id: string) => Promise; + launchInstanceLogin: (id: string) => Promise<{ command: string; opened: boolean }>; + onInstancesChanged: (callback: () => void) => () => void; setConfigOption: ( configId: string, value: string | boolean, diff --git a/src/lib/agent-selection.ts b/src/lib/agent-selection.ts new file mode 100644 index 0000000..f527f6f --- /dev/null +++ b/src/lib/agent-selection.ts @@ -0,0 +1,24 @@ +import type { AcpAgentDescriptor } from "../../contracts/acp.ts"; + +/** + * True when a descriptor is a driver's default instance (id === driver id). + * Onboarding operates at this level: users pick providers, then add extra + * accounts later from Settings. + */ +export function isDefaultInstance(agent: AcpAgentDescriptor): boolean { + return (agent.driverId ?? agent.id) === agent.id; +} + +/** + * An agent (possibly a non-default account) counts as selected when either its + * own instance id or its provider's driver id is in the selected set. This + * keeps onboarding's provider-level selection meaningful for accounts the user + * adds afterward. + */ +export function isInstanceSelected( + agent: AcpAgentDescriptor, + selectedIds: readonly string[], +): boolean { + if (selectedIds.includes(agent.id)) return true; + return agent.driverId != null && selectedIds.includes(agent.driverId); +} diff --git a/src/remote/App.tsx b/src/remote/App.tsx index f15810f..d4bcda0 100644 --- a/src/remote/App.tsx +++ b/src/remote/App.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { AnimatePresence, motion } from "framer-motion"; import { List, PaperPlaneTilt, Plus, QrCode } from "@phosphor-icons/react"; import { PhoneMarkdown } from "./markdown.tsx"; +import { groupModelsByProvider } from "./model-groups.ts"; import type { RemoteModel, RemoteProject, @@ -387,11 +388,23 @@ export function RemoteApp() { aria-label="Model" > - {models.map((m) => ( - - ))} + {groupModelsByProvider(models).map((group) => + group.provider ? ( + + {group.models.map((m) => ( + + ))} + + ) : ( + group.models.map((m) => ( + + )) + ), + )}

diff --git a/src/remote/model-groups.test.ts b/src/remote/model-groups.test.ts new file mode 100644 index 0000000..14c5e9d --- /dev/null +++ b/src/remote/model-groups.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "vitest"; +import { groupModelsByProvider } from "./model-groups.ts"; + +describe("groupModelsByProvider", () => { + test("groups accounts under their provider, preserving order", () => { + const groups = groupModelsByProvider([ + { id: "codex-acp", name: "Codex", provider: "Codex" }, + { id: "codex-acp:work", name: "Work", provider: "Codex" }, + { id: "claude-agent-acp", name: "Claude", provider: "Claude" }, + ]); + expect(groups).toHaveLength(2); + expect(groups[0]).toEqual({ + provider: "Codex", + models: [ + { id: "codex-acp", name: "Codex", provider: "Codex" }, + { id: "codex-acp:work", name: "Work", provider: "Codex" }, + ], + }); + expect(groups[1].provider).toBe("Claude"); + }); + + test("keeps unlabeled models in an ungrouped bucket", () => { + const groups = groupModelsByProvider([{ id: "x", name: "X" }]); + expect(groups).toEqual([{ provider: null, models: [{ id: "x", name: "X" }] }]); + }); + + test("returns an empty list for no models", () => { + expect(groupModelsByProvider([])).toEqual([]); + }); +}); diff --git a/src/remote/model-groups.ts b/src/remote/model-groups.ts new file mode 100644 index 0000000..f2c5550 --- /dev/null +++ b/src/remote/model-groups.ts @@ -0,0 +1,22 @@ +import type { RemoteModel } from "../../contracts/remote.ts"; + +/** + * Group accounts under their provider for the phone's model picker. A keyed + * map keeps insertion order; a null provider means the model had no provider + * label and should render ungrouped. + */ +export function groupModelsByProvider( + models: RemoteModel[], +): Array<{ provider: string | null; models: RemoteModel[] }> { + const groups = new Map(); + for (const model of models) { + const key = model.provider ?? ""; + const list = groups.get(key) ?? []; + list.push(model); + groups.set(key, list); + } + return [...groups.entries()].map(([provider, items]) => ({ + provider: provider || null, + models: items, + })); +} diff --git a/src/settings/app.tsx b/src/settings/app.tsx index 0676be0..f266274 100644 --- a/src/settings/app.tsx +++ b/src/settings/app.tsx @@ -6,6 +6,7 @@ import { GearSix, WarningCircle, } from "@phosphor-icons/react"; +import { AgentAccountsSettings } from "@/components/agent-accounts-settings"; import { RemoteAccessSettings } from "@/components/remote-access-settings"; import { SleeplessControl } from "@/components/sleepless-control"; import { ThemePicker } from "@/components/theme-picker"; @@ -46,11 +47,12 @@ function modifierSymbol(): string { : "Ctrl"; } -type SectionId = "appearance" | "agents" | "keyboard" | "power" | "remote"; +type SectionId = "appearance" | "agents" | "accounts" | "keyboard" | "power" | "remote"; const NAV_ITEMS: Array<{ id: SectionId; label: string }> = [ { id: "appearance", label: "Appearance" }, { id: "agents", label: "Agents" }, + { id: "accounts", label: "Account" }, { id: "keyboard", label: "Keyboard" }, { id: "power", label: "Power" }, { id: "remote", label: "Remote" }, @@ -333,6 +335,10 @@ const SECTION_META: Record = { title: "Agents", blurb: "Choose which coding agents Pipper can use.", }, + accounts: { + title: "Account", + blurb: "", + }, keyboard: { title: "Keyboard", blurb: "Shortcuts for moving fast around Pipper.", @@ -363,6 +369,14 @@ function AppearanceView() { ); } +function AccountsView() { + return ( + + + + ); +} + function KeyboardView() { const mod = modifierSymbol(); return ( @@ -482,11 +496,14 @@ export function SettingsApp() {

{meta.title}

-

{meta.blurb}

+ {meta.blurb ? ( +

{meta.blurb}

+ ) : null}
{section === "appearance" && } {section === "agents" && } + {section === "accounts" && } {section === "keyboard" && } {section === "power" && } {section === "remote" && } diff --git a/src/store/agent-instances-store.ts b/src/store/agent-instances-store.ts new file mode 100644 index 0000000..089b634 --- /dev/null +++ b/src/store/agent-instances-store.ts @@ -0,0 +1,89 @@ +import { create } from "zustand"; +import type { + AcpAgentInstance, + AcpAgentInstanceInput, + AgentAccountSchema, +} from "../../contracts/acp.ts"; + +interface AgentInstancesState { + instances: AcpAgentInstance[]; + schemas: AgentAccountSchema[]; + loading: boolean; + error: string | null; + load: () => Promise; + create: (input: AcpAgentInstanceInput) => Promise; + update: (id: string, input: Partial) => Promise; + remove: (id: string) => Promise; + launchLogin: (id: string) => Promise<{ command: string; opened: boolean }>; +} + +/** + * Provider accounts/instances. One default instance per driver (id === driver + * id) always exists; additional accounts are user-created and carry their own + * environment (e.g. an isolated CODEX_HOME). + */ +export const useAgentInstancesStore = create((set, get) => ({ + instances: [], + schemas: [], + loading: false, + error: null, + + load: async () => { + if (!window.omni?.agent?.listInstances) return; + set({ loading: true, error: null }); + try { + const [instances, schemas] = await Promise.all([ + window.omni.agent.listInstances(), + window.omni.agent.getAccountSchemas?.() ?? Promise.resolve([]), + ]); + set({ instances, schemas, loading: false }); + } catch (err) { + set({ + loading: false, + error: err instanceof Error ? err.message : "Failed to load accounts", + }); + } + }, + + create: async (input) => { + try { + const created = await window.omni.agent.createInstance(input); + await get().load(); + return created; + } catch (err) { + set({ error: err instanceof Error ? err.message : "Failed to add account" }); + throw err; + } + }, + + update: async (id, input) => { + try { + await window.omni.agent.updateInstance(id, input); + await get().load(); + } catch (err) { + set({ error: err instanceof Error ? err.message : "Failed to update account" }); + throw err; + } + }, + + remove: async (id) => { + try { + await window.omni.agent.deleteInstance(id); + await get().load(); + } catch (err) { + set({ error: err instanceof Error ? err.message : "Failed to remove account" }); + throw err; + } + }, + + launchLogin: async (id) => window.omni.agent.launchInstanceLogin(id), +})); + +// Accounts are managed from the Settings window but consumed by every window +// (the main window's composer picker). Reload on a cross-window change so a +// newly added or removed account shows up without a restart. +if (typeof window !== "undefined" && window.omni?.agent?.onInstancesChanged) { + window.omni.agent.onInstancesChanged(() => { + void useAgentInstancesStore.getState().load(); + }); +} diff --git a/src/store/agent-registry-store.ts b/src/store/agent-registry-store.ts index 36c5a23..1edbfeb 100644 --- a/src/store/agent-registry-store.ts +++ b/src/store/agent-registry-store.ts @@ -132,3 +132,11 @@ export const useAgentRegistryStore = create((set, get) => ({ set({ probeResults: {}, skippedAgentIds: [], setupSkipped: false }); }, })); + +// The main window's agent picker must reflect accounts added or removed in the +// Settings window. Reload the registry whenever instances change. +if (typeof window !== "undefined" && window.omni?.agent?.onInstancesChanged) { + window.omni.agent.onInstancesChanged(() => { + void useAgentRegistryStore.getState().load(); + }); +}