diff --git a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts
index c3bf2f3da..c46dc4ca3 100644
--- a/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts
+++ b/src/renderer/views/SettingsOverlay/parts/agentRegistryNative.ts
@@ -4,6 +4,7 @@ import type { MessageDescriptor } from "@lingui/core";
import type { AgentKind, AgentProviderMetadata, AgentStatus, Project } from "@/shared/contracts";
import { isMac, isWindows, readBridge } from "@/renderer/bridge";
import { ClaudeAgentSettingsPanel } from "./ClaudeProfileSettings";
+import { createHomeProfileSettingsPanel } from "./HomeProfileSettings";
import { OpenCodeProviderSettings } from "./OpenCodeProviderSettings";
/**
@@ -41,6 +42,8 @@ export interface NativeAgentRegistryEntry {
* (Claude profiles) render their base provider's panel.
*/
settingsPanel?: ComponentType
;
+ /** Provider label used for instance-scoped profile pages before detection completes. */
+ profileProviderName?: MessageDescriptor;
/**
* The provider's `settingsPanel` owns sign-in UI, so `SingleAgentSettings`
* suppresses its generic auth section (e.g. OpenCode authenticates per AI
@@ -56,6 +59,23 @@ export interface NativeAgentRegistryEntry {
accountResolver?: (wslDistros: string[]) => Promise;
}
+const CodexProfileSettingsPanel = createHomeProfileSettingsPanel({
+ driver: "codex",
+ providerName: msg`Codex`,
+});
+const CopilotProfileSettingsPanel = createHomeProfileSettingsPanel({
+ driver: "copilot",
+ providerName: msg`GitHub Copilot`,
+});
+const GeminiProfileSettingsPanel = createHomeProfileSettingsPanel({
+ driver: "gemini",
+ providerName: msg`Gemini`,
+});
+const GrokProfileSettingsPanel = createHomeProfileSettingsPanel({
+ driver: "grok",
+ providerName: msg`Grok Build`,
+});
+
const POSIX_MISSING_CURL_NPM_MESSAGE =
"printf 'No supported installer found. Install curl or Node.js/npm first, then refresh detected agents.\\n'";
const MAC_MISSING_CURL_BREW_NPM_MESSAGE =
@@ -106,6 +126,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [
windows:
"if (Get-Command powershell -ErrorAction SilentlyContinue) { powershell -ExecutionPolicy ByPass -c \"irm https://chatgpt.com/codex/install.ps1 | iex\" } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @openai/codex } else { Write-Host 'No supported installer found. Install Windows PowerShell or Node.js/npm first, then refresh detected agents.' }",
}),
+ settingsPanel: CodexProfileSettingsPanel,
+ profileProviderName: msg`Codex`,
},
{
id: "claude",
@@ -127,6 +149,7 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [
"if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://claude.ai/install.ps1 | iex } elseif (Get-Command curl.exe -ErrorAction SilentlyContinue) { cmd /c \"curl -fsSL https://claude.ai/install.cmd -o install.cmd && install.cmd && del install.cmd\" } elseif (Get-Command winget -ErrorAction SilentlyContinue) { winget install Anthropic.ClaudeCode } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod, curl, or WinGet first, then refresh detected agents.' }",
}),
settingsPanel: ClaudeAgentSettingsPanel,
+ profileProviderName: msg`Claude`,
},
{
id: "opencode",
@@ -168,6 +191,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [
windows:
"if (Get-Command irm -ErrorAction SilentlyContinue) { irm https://x.ai/cli/install.ps1 | iex } else { Write-Host 'No supported installer found. Install PowerShell Invoke-RestMethod first, then refresh detected agents.' }",
}),
+ settingsPanel: GrokProfileSettingsPanel,
+ profileProviderName: msg`Grok Build`,
},
{
id: "factory",
@@ -244,6 +269,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [
"; fi",
"if (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @google/gemini-cli } else { Write-Host 'No supported installer found. Install Node.js/npm first, then refresh detected agents.' }",
),
+ settingsPanel: GeminiProfileSettingsPanel,
+ profileProviderName: msg`Gemini`,
},
{
id: "copilot",
@@ -270,6 +297,8 @@ export const NATIVE_AGENT_REGISTRY_ENTRIES: NativeAgentRegistryEntry[] = [
windows:
"if (Get-Command winget -ErrorAction SilentlyContinue) { winget install GitHub.Copilot } elseif (Get-Command npm -ErrorAction SilentlyContinue) { npm install -g @github/copilot } else { Write-Host 'No supported installer found. Install WinGet or Node.js/npm first, then refresh detected agents.' }",
}),
+ settingsPanel: CopilotProfileSettingsPanel,
+ profileProviderName: msg`GitHub Copilot`,
},
];
diff --git a/src/renderer/views/SettingsOverlay/parts/profileStatusRefresh.ts b/src/renderer/views/SettingsOverlay/parts/profileStatusRefresh.ts
new file mode 100644
index 000000000..a403ad0fc
--- /dev/null
+++ b/src/renderer/views/SettingsOverlay/parts/profileStatusRefresh.ts
@@ -0,0 +1,18 @@
+import { toast } from "@heroui/react";
+import type { MessageDescriptor } from "@lingui/core";
+import { readBridge } from "@/renderer/bridge";
+import { i18n } from "@/renderer/i18n/i18n";
+import { currentWslDistros } from "@/renderer/utils/acpRegistryAuth";
+
+export function refreshProfileStatuses(
+ kind: string | undefined,
+ fallbackError: MessageDescriptor,
+): void {
+ window.setTimeout(() => {
+ void readBridge()
+ .refreshAgentStatuses(currentWslDistros(), kind ? { agentKinds: [kind] } : undefined)
+ .catch((error) =>
+ toast.danger(error instanceof Error ? error.message : i18n._(fallbackError)),
+ );
+ }, 50);
+}
diff --git a/src/shared/contracts/agentInstance.test.ts b/src/shared/contracts/agentInstance.test.ts
index 7c104fcf7..1bec16e80 100644
--- a/src/shared/contracts/agentInstance.test.ts
+++ b/src/shared/contracts/agentInstance.test.ts
@@ -2,15 +2,21 @@ import { describe, expect, it } from "vitest";
import {
ACP_GENERIC_KIND_PREFIX,
acpGenericKind,
+ agentKindsSharingBinary,
agentInstanceConfigSchema,
baseAgentKind,
claudeProfileKind,
extractAcpGenericInstanceId,
extractClaudeProfileInstanceId,
+ extractHomeProfileInstanceId,
+ homeProfileKind,
isAcpGenericKind,
isClaudeProfileKind,
+ isHomeProfileDriver,
+ isHomeProfileKind,
parseAcpGenericInstanceConfig,
parseClaudeProfileInstanceConfig,
+ parseHomeProfileInstanceConfig,
} from "./agentInstance";
/**
@@ -107,6 +113,80 @@ describe("Claude profile instance helpers", () => {
});
});
+describe("home profile instance helpers", () => {
+ it("parses a provider home directory", () => {
+ expect(
+ parseHomeProfileInstanceConfig({
+ homeDir: "~/.poracode/codex-profiles/work",
+ }),
+ ).toEqual({ homeDir: "~/.poracode/codex-profiles/work" });
+ });
+
+ it("rejects an empty provider home directory", () => {
+ expect(() => parseHomeProfileInstanceConfig({ homeDir: "" })).toThrow(Error);
+ });
+
+ it("recognizes only providers with first-class home isolation", () => {
+ expect(isHomeProfileDriver("codex")).toBe(true);
+ expect(isHomeProfileDriver("copilot")).toBe(true);
+ expect(isHomeProfileDriver("gemini")).toBe(true);
+ expect(isHomeProfileDriver("grok")).toBe(true);
+ expect(isHomeProfileDriver("claude")).toBe(false);
+ expect(isHomeProfileDriver("opencode")).toBe(false);
+ });
+
+ it("round-trips profile ids through each provider namespace", () => {
+ expect(homeProfileKind("codex", "work")).toBe("codex:work");
+ expect(homeProfileKind("copilot", "personal")).toBe("copilot:personal");
+ expect(homeProfileKind("gemini", "enterprise")).toBe("gemini:enterprise");
+ expect(homeProfileKind("grok", "team")).toBe("grok:team");
+ expect(isHomeProfileKind("codex:work")).toBe(true);
+ expect(extractHomeProfileInstanceId("codex:work")).toBe("work");
+ });
+
+ it("rejects base and unsupported provider kinds", () => {
+ expect(isHomeProfileKind("codex")).toBe(false);
+ expect(isHomeProfileKind("codex:")).toBe(false);
+ expect(isHomeProfileKind("claude:work")).toBe(false);
+ expect(isHomeProfileKind("opencode:work")).toBe(false);
+ expect(extractHomeProfileInstanceId("codex")).toBeUndefined();
+ });
+});
+
+describe("agentKindsSharingBinary", () => {
+ const registeredKinds = [
+ "claude",
+ "claude:work",
+ "codex",
+ "codex:work",
+ "codex:personal",
+ "acp-generic:custom",
+ ];
+
+ it("groups a base provider with every profile sharing its CLI", () => {
+ expect(agentKindsSharingBinary("codex", registeredKinds)).toEqual([
+ "codex",
+ "codex:work",
+ "codex:personal",
+ ]);
+ expect(agentKindsSharingBinary("codex:work", registeredKinds)).toEqual([
+ "codex",
+ "codex:work",
+ "codex:personal",
+ ]);
+ expect(agentKindsSharingBinary("claude:work", registeredKinds)).toEqual([
+ "claude",
+ "claude:work",
+ ]);
+ });
+
+ it("does not group unrelated colon-scoped adapters", () => {
+ expect(agentKindsSharingBinary("acp-generic:custom", registeredKinds)).toEqual([
+ "acp-generic:custom",
+ ]);
+ });
+});
+
describe("agentInstanceConfigSchema", () => {
it("validates a registered instance shape", () => {
const result = agentInstanceConfigSchema.parse({
diff --git a/src/shared/contracts/agentInstance.ts b/src/shared/contracts/agentInstance.ts
index d20c973f0..1b6d8271d 100644
--- a/src/shared/contracts/agentInstance.ts
+++ b/src/shared/contracts/agentInstance.ts
@@ -24,6 +24,9 @@ export type AgentDriverKind = z.infer;
export const CLAUDE_PROFILE_KIND_PREFIX = "claude:";
+export const HOME_PROFILE_DRIVERS = ["codex", "copilot", "gemini", "grok"] as const;
+export type HomeProfileDriver = (typeof HOME_PROFILE_DRIVERS)[number];
+
export const agentInstanceIdSchema = z
.string()
.min(1)
@@ -157,6 +160,42 @@ export function parseClaudeProfileInstanceConfig(value: unknown): ClaudeProfileI
return claudeProfileInstanceConfigSchema.parse(value ?? {});
}
+// ─── home profile driver config ──────────────────────────────────────
+
+export const homeProfileInstanceConfigSchema = z.object({
+ /**
+ * Provider home directory selected for this profile. A leading "~/" is
+ * resolved against the target runtime environment (native home or WSL home).
+ */
+ homeDir: z.string().min(1),
+});
+export type HomeProfileInstanceConfig = z.infer;
+
+export function parseHomeProfileInstanceConfig(value: unknown): HomeProfileInstanceConfig {
+ return homeProfileInstanceConfigSchema.parse(value ?? {});
+}
+
+export function isHomeProfileDriver(driver: string): driver is HomeProfileDriver {
+ return HOME_PROFILE_DRIVERS.some((candidate) => candidate === driver);
+}
+
+export function homeProfileKind(driver: HomeProfileDriver, instanceId: string): AgentDriverKind {
+ return `${driver}:${instanceId}` as AgentDriverKind;
+}
+
+export function isHomeProfileKind(kind: string): boolean {
+ const separatorIndex = kind.indexOf(":");
+ return (
+ separatorIndex > 0 &&
+ separatorIndex < kind.length - 1 &&
+ isHomeProfileDriver(kind.slice(0, separatorIndex))
+ );
+}
+
+export function extractHomeProfileInstanceId(kind: string): string | undefined {
+ return isHomeProfileKind(kind) ? kind.slice(kind.indexOf(":") + 1) : undefined;
+}
+
/**
* Payload for the `setClaudeProfileEnvironment` main-local IPC. The renderer
* sends the full desired environment (plaintext for freshly-entered values,
@@ -191,3 +230,18 @@ export function baseAgentKind(kind: string): string {
const separatorIndex = kind.indexOf(":");
return separatorIndex > 0 ? kind.slice(0, separatorIndex) : kind;
}
+
+/**
+ * Returns every registered adapter backed by the same provider binary. Profile
+ * adapters isolate account state and sessions, but share the base CLI install.
+ */
+export function agentKindsSharingBinary(kind: string, registeredKinds: Iterable): string[] {
+ const profileKind = isClaudeProfileKind(kind) || isHomeProfileKind(kind);
+ const binaryKind = profileKind ? baseAgentKind(kind) : kind;
+ return [...registeredKinds].filter(
+ (candidate) =>
+ candidate === binaryKind ||
+ ((isClaudeProfileKind(candidate) || isHomeProfileKind(candidate)) &&
+ baseAgentKind(candidate) === binaryKind),
+ );
+}
diff --git a/src/supervisor/agents/claude/index.ts b/src/supervisor/agents/claude/index.ts
index 274b1d66a..105cfc5f4 100644
--- a/src/supervisor/agents/claude/index.ts
+++ b/src/supervisor/agents/claude/index.ts
@@ -25,6 +25,7 @@ import {
type CreateStructuredSessionInput,
type DetectProbeCtx,
} from "../base";
+import { resolveAgentInstanceEnv } from "../homeProfile";
import { buildClaudeArgs, claudeExtraArgsPosition, rewriteClaudeLaunchArgsForConfig } from "./argv";
import { claudeCapabilities, claudeDetectionSpec, probeClaudeStatus } from "./detection";
import { probeClaudeCapabilities } from "./probe";
@@ -90,22 +91,6 @@ function profileEnvForLocation(
return Object.keys(env).length > 0 ? env : undefined;
}
-/**
- * Flatten an instance's `environment` map (values already decrypted by the
- * supervisor's settings read) into a plain name→value map for spawning.
- */
-function resolveInstanceEnv(
- environment: AgentInstanceConfig["environment"],
-): Record | undefined {
- if (!environment) return undefined;
- const resolved: Record = {};
- for (const [name, variable] of Object.entries(environment)) {
- if (name.trim().length === 0) continue;
- resolved[name] = variable.value;
- }
- return Object.keys(resolved).length > 0 ? resolved : undefined;
-}
-
/**
* Apply a profile's optional model additions / effort allow-list on top of the
* built-in Claude capabilities. A no-op when neither override is set (so the
@@ -162,7 +147,7 @@ function overrideProfileCapabilities(
export function createClaudeProfileAdapter(instance: AgentInstanceConfig): AgentAdapter {
const cfg = parseClaudeProfileInstanceConfig(instance.config);
const profileLabel = instance.displayName ?? instance.id;
- const customEnv = resolveInstanceEnv(instance.environment);
+ const customEnv = resolveAgentInstanceEnv(instance.environment);
return createClaudeAdapter({
kind: claudeProfileKind(instance.id),
label: `Claude ${profileLabel}`,
diff --git a/src/supervisor/agents/codex/acp.ts b/src/supervisor/agents/codex/acp.ts
index 576bb9bbd..a4c69d126 100644
--- a/src/supervisor/agents/codex/acp.ts
+++ b/src/supervisor/agents/codex/acp.ts
@@ -392,6 +392,7 @@ export class CodexStructuredSession implements StructuredSessionHandle {
static async create(
input: CreateStructuredSessionInput,
wslExecPath?: string,
+ env?: Record,
): Promise {
const wslNodePath =
input.projectLocation.kind === "wsl"
@@ -411,6 +412,7 @@ export class CodexStructuredSession implements StructuredSessionHandle {
...(input.chromeMcp !== undefined ? { chromeMcp: input.chromeMcp } : {}),
...(input.appControlsMcp !== undefined ? { appControlsMcp: input.appControlsMcp } : {}),
...(input.mcpServers !== undefined ? { mcpServers: input.mcpServers } : {}),
+ ...(env ? { env } : {}),
}),
);
const transport = new CodexStdioTransport(appServer);
diff --git a/src/supervisor/agents/codex/argv.ts b/src/supervisor/agents/codex/argv.ts
index d779f3b1f..e511be91b 100644
--- a/src/supervisor/agents/codex/argv.ts
+++ b/src/supervisor/agents/codex/argv.ts
@@ -132,6 +132,7 @@ export function buildCodexArgvFor(
prompt: string,
sessionRef?: SessionRef,
launchOptions?: AgentLaunchOptions,
+ profileEnv?: Record,
): AgentArgvSpec {
const binary = resolveCodexWindowsLaunchBinary(location) ?? "codex";
const userMcp = buildCodexUserMcp(launchOptions?.mcpServers ?? []);
@@ -143,7 +144,8 @@ export function buildCodexArgvFor(
...buildCodexChromeMcpEnv(launchOptions?.chromeMcp),
...buildCodexAppControlsMcpEnv(launchOptions?.appControlsMcp),
};
- const hasMcpEnv = Object.keys(mcpEnv).length > 0;
+ const env = { ...profileEnv, ...mcpEnv };
+ const hasEnv = Object.keys(env).length > 0;
const enableGoals = isCodexGoalsSupported(location);
const baseArgsOptions: BuildCodexArgsOptions = {
config,
@@ -168,7 +170,7 @@ export function buildCodexArgvFor(
return {
binary,
args,
- ...(hasMcpEnv ? { env: mcpEnv } : {}),
+ ...(hasEnv ? { env } : {}),
};
}
@@ -185,7 +187,7 @@ export function buildCodexArgvFor(
return {
binary,
args,
- ...(hasMcpEnv ? { env: mcpEnv } : {}),
+ ...(hasEnv ? { env } : {}),
};
}
@@ -204,6 +206,7 @@ export function buildCodexAppServerCommand(
chromeMcp?: ChromeMcpHttpConfig;
appControlsMcp?: AppControlsMcpHttpConfig;
mcpServers?: McpServer[];
+ env?: Record;
},
): CommandSpec {
const wslExecPath = options?.wslExecPath;
@@ -239,7 +242,8 @@ export function buildCodexAppServerCommand(
...buildCodexChromeMcpEnv(options?.chromeMcp),
...buildCodexAppControlsMcpEnv(options?.appControlsMcp),
};
- const hasMcpEnv = Object.keys(mcpEnv).length > 0;
+ const env = { ...options?.env, ...mcpEnv };
+ const hasEnv = Object.keys(env).length > 0;
const args = [
...(isCodexGoalsSupported(location, wslExecPath) ? ["--enable", CODEX_GOALS_FEATURE_FLAG] : []),
...userMcp.args,
@@ -266,7 +270,7 @@ export function buildCodexAppServerCommand(
"--",
"/usr/bin/env",
`PATH=${pathSegments.join(":")}`,
- ...(hasMcpEnv ? Object.entries(mcpEnv).map(([name, value]) => `${name}=${value}`) : []),
+ ...(hasEnv ? Object.entries(env).map(([name, value]) => `${name}=${value}`) : []),
wslExecPath ?? "codex",
...args,
],
@@ -277,7 +281,7 @@ export function buildCodexAppServerCommand(
"codex",
args,
resolveCodexWindowsLaunchBinary(location) ?? wslExecPath,
- hasMcpEnv ? mcpEnv : undefined,
+ hasEnv ? env : undefined,
);
}
diff --git a/src/supervisor/agents/codex/codex.test.ts b/src/supervisor/agents/codex/codex.test.ts
index abd72bf4b..a286e59ac 100644
--- a/src/supervisor/agents/codex/codex.test.ts
+++ b/src/supervisor/agents/codex/codex.test.ts
@@ -1,8 +1,13 @@
import { EventEmitter } from "node:events";
+import { mkdtempSync, rmSync } from "node:fs";
+import { homedir, tmpdir } from "node:os";
+import { join, resolve as resolvePath } from "node:path";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import {
+ buildCodexAppServerCommand,
createCodexAdapter,
+ createCodexProfileAdapter,
deriveCodexStructuredState,
detectCodexReadyForInitialPrompt,
detectCodexUpdatePrompt,
@@ -16,7 +21,12 @@ import {
import { CodexStructuredSession } from "./acp";
import type { CodexAppServerRpcListener } from "./appServerRpc";
import type { OscNotification, OscTitle } from "@/shared/osc";
-import type { RuntimeEvent, ToolCallPayload } from "@/shared/contracts";
+import type {
+ ProjectLocation,
+ RuntimeEvent,
+ ThreadConfig,
+ ToolCallPayload,
+} from "@/shared/contracts";
import { codexIntentFor } from "./plugin/intentMap";
import {
mapCodexModels,
@@ -29,6 +39,101 @@ import { buildCodexTurnInput } from "./acpTurn";
import { CodexStdioTransport } from "./stdioTransport";
import { CodexSubAgentRouter } from "./subAgentRouting";
import type { StructuredSessionUpdate } from "../base";
+import { resolveCodexHomeForLocation } from "./profile";
+
+describe("createCodexProfileAdapter", () => {
+ it("scopes launch, logout, one-shot, and skills to the selected CODEX_HOME", async () => {
+ const homeDir = mkdtempSync(join(tmpdir(), "poracode-codex-profile-"));
+ const location: ProjectLocation = { kind: "posix", path: homeDir };
+ const config: ThreadConfig = {
+ model: "gpt-5.4",
+ effort: "high",
+ mode: "agent",
+ approvalPolicy: "on-request",
+ sandboxMode: "workspace-write",
+ };
+
+ try {
+ const adapter = createCodexProfileAdapter({
+ id: "work",
+ driver: "codex",
+ displayName: "Work",
+ config: { homeDir },
+ });
+
+ expect(adapter.kind).toBe("codex:work");
+ expect(adapter.label).toBe("Codex Work");
+ expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe(homeDir);
+ expect(adapter.buildLaunchArgv(location, config, "hello").env).toEqual({
+ CODEX_HOME: homeDir,
+ OPENAI_API_KEY: "",
+ CODEX_API_KEY: "",
+ CODEX_ACCESS_TOKEN: "",
+ });
+ expect(adapter.buildOneShotCommand?.("gpt-5.4", "high", undefined, location)?.env).toEqual({
+ CODEX_HOME: homeDir,
+ OPENAI_API_KEY: "",
+ CODEX_API_KEY: "",
+ CODEX_ACCESS_TOKEN: "",
+ });
+ await expect(adapter.buildAcpLogoutCommand?.({ envKind: "posix" })).resolves.toMatchObject({
+ env: {
+ CODEX_HOME: homeDir,
+ OPENAI_API_KEY: "",
+ CODEX_API_KEY: "",
+ CODEX_ACCESS_TOKEN: "",
+ },
+ });
+ const hookExtras = await adapter.pluginLaunchExtras?.({ envKind: "posix", baseDir: homeDir });
+ expect(hookExtras?.env).toBeUndefined();
+ expect(hookExtras?.args).toHaveLength(2);
+ expect(hookExtras?.args).not.toContain("-c");
+ } finally {
+ rmSync(homeDir, { recursive: true, force: true });
+ }
+ });
+
+ it("resolves relative profile homes against the target user home", () => {
+ expect(
+ resolveCodexHomeForLocation("profiles/work", {
+ kind: "posix",
+ path: "/tmp/project",
+ }),
+ ).toBe(resolvePath(homedir(), "profiles/work"));
+ });
+
+ it("preserves profile auth isolation in native and WSL app-server commands", () => {
+ const env = {
+ CODEX_HOME: "/home/demo/.poracode/codex-profiles/work",
+ OPENAI_API_KEY: "",
+ CODEX_API_KEY: "",
+ CODEX_ACCESS_TOKEN: "",
+ };
+ const native = buildCodexAppServerCommand(
+ { kind: "posix", path: "/home/demo/project" },
+ { env },
+ );
+ const wsl = buildCodexAppServerCommand(
+ {
+ kind: "wsl",
+ distro: "Ubuntu",
+ linuxPath: "/home/demo/project",
+ uncPath: "\\\\wsl.localhost\\Ubuntu\\home\\demo\\project",
+ },
+ { env, wslExecPath: "/usr/bin/codex" },
+ );
+
+ expect(native.env).toEqual(env);
+ expect(wsl.args).toEqual(
+ expect.arrayContaining([
+ `CODEX_HOME=${env.CODEX_HOME}`,
+ "OPENAI_API_KEY=",
+ "CODEX_API_KEY=",
+ "CODEX_ACCESS_TOKEN=",
+ ]),
+ );
+ });
+});
describe("deriveCodexStructuredState", () => {
it("maps active approval state to needs_approval", () => {
diff --git a/src/supervisor/agents/codex/detection.ts b/src/supervisor/agents/codex/detection.ts
index 26dd27f5c..d412f0268 100644
--- a/src/supervisor/agents/codex/detection.ts
+++ b/src/supervisor/agents/codex/detection.ts
@@ -1,7 +1,7 @@
import {
compactAgentProviderMetadata,
- type AgentAuthMethod,
type AgentCapability,
+ type AgentTerminalAuthMethod,
} from "@/shared/contracts";
import {
configFileAuthProbe,
@@ -295,6 +295,7 @@ async function probeCodexStatus(ctx: Parameters): AgentTerminalAuthMethod {
+ return env ? { ...CODEX_TERMINAL_AUTH_METHOD, env } : CODEX_TERMINAL_AUTH_METHOD;
+}
+
export const codexDetectionSpec: DetectionSpec = {
kind: "codex",
label: "Codex",
@@ -360,6 +368,7 @@ export const codexDetectionSpec: DetectionSpec = {
? { wslExecPath: ctx.executablePath }
: {}),
timeoutMs: 12_000,
+ ...(ctx.probeEnv ? { env: ctx.probeEnv } : {}),
label:
ctx.location.kind === "wsl"
? `codex:wsl:${ctx.location.distro}`
@@ -371,7 +380,7 @@ export const codexDetectionSpec: DetectionSpec = {
// `buildAcpLogoutCommand` to invoke `codex logout`. Mirrors Claude.
return {
...(probe ? probeResultToCapabilityPartial(probe) : {}),
- authMethods: [CODEX_TERMINAL_AUTH_METHOD],
+ authMethods: [codexTerminalAuthMethod(ctx.probeEnv)],
authLogoutSupported: true,
};
},
diff --git a/src/supervisor/agents/codex/index.ts b/src/supervisor/agents/codex/index.ts
index 18cf36863..de49753b8 100644
--- a/src/supervisor/agents/codex/index.ts
+++ b/src/supervisor/agents/codex/index.ts
@@ -1,9 +1,17 @@
-import type { AgentCapability, ProjectLocation } from "@/shared/contracts";
+import {
+ homeProfileKind,
+ parseHomeProfileInstanceConfig,
+ type AgentCapability,
+ type AgentInstanceConfig,
+ type ProjectLocation,
+} from "@/shared/contracts";
import type { OscNotification } from "@/shared/osc";
import {
batchWslCommandsAsync,
brailleSpinnerOscTitleHint,
+ buildAgentCommand,
buildAgentLogoutCommand,
+ configFileAuthProbe,
createKnownSessionRef,
detectAgentInstall,
detectProbeLocation,
@@ -17,11 +25,11 @@ import { resolveAgentBinaryPath } from "../binaryResolver";
import { CodexStructuredSession } from "./acp";
import { buildCodexArgvFor, codexExtraArgsPosition, primeCodexGoalsSupport } from "./argv";
import { codexDefaultCapabilities, codexDetectionSpec } from "./detection";
+import { codexHomeEnvForLocation } from "./profile";
import { detectRateLimitPrompt } from "./rateLimitPrompt";
import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase";
import {
codexHooksFeatureFlagForSemver,
- getCodexPluginPaths,
installCodexPlugin,
isCodexPluginInstalled,
isCodexSemverSupportedForHooks,
@@ -42,6 +50,7 @@ import {
resolveCodexSessionWatchPaths,
} from "./session";
import type { CodexRolloutMeta } from "./sessionFiles";
+import { codexAuthPath } from "./sessionFiles";
import { detectCodexReadyForInitialPrompt } from "./terminal";
export { buildCodexAppServerCommand } from "./argv";
@@ -101,22 +110,43 @@ async function resolveCodexHooksFeatureFlag(ctx: {
return codexHooksFeatureFlagForSemver(probeCodexCliSemver());
}
-export function createCodexAdapter(): AgentAdapter {
+interface CodexAdapterOptions {
+ kind?: string;
+ label?: string;
+ homeDir?: string;
+}
+
+export function createCodexProfileAdapter(instance: AgentInstanceConfig): AgentAdapter {
+ const config = parseHomeProfileInstanceConfig(instance.config);
+ const profileLabel = instance.displayName ?? instance.id;
+ return createCodexAdapter({
+ kind: homeProfileKind("codex", instance.id),
+ label: `Codex ${profileLabel}`,
+ homeDir: config.homeDir,
+ });
+}
+
+export function createCodexAdapter(options: CodexAdapterOptions = {}): AgentAdapter {
+ const kind = options.kind ?? codexDetectionSpec.kind;
+ const label = options.label ?? codexDetectionSpec.label;
+ const profileEnv = (location: ProjectLocation) =>
+ codexHomeEnvForLocation(options.homeDir, location);
let capabilities: AgentCapability = codexDefaultCapabilities;
let preSpawnRolloutIds = new Set();
let preSpawnStartedAt = 0;
return {
- kind: codexDetectionSpec.kind,
- label: codexDetectionSpec.label,
+ kind,
+ label,
binary: codexDetectionSpec.binary,
skillSupport: {
roots: [
{
id: "codex",
- label: codexDetectionSpec.label,
+ label,
globalPath: ".codex/skills",
builtInPath: ".system",
+ ...(options.homeDir ? { globalBasePath: options.homeDir } : {}),
globalOverride: { env: "CODEX_HOME", path: "skills" },
},
],
@@ -160,42 +190,60 @@ export function createCodexAdapter(): AgentAdapter {
return isCodexVersionSupportedForHooks();
},
isPluginInstalled(ctx) {
- return isCodexPluginInstalled(ctx);
+ return isCodexPluginInstalled(ctx, options.homeDir);
},
async installPlugin(ctx) {
const node = await resolveInstallNodePath(ctx);
if (!node.ok) return node;
- const result = await installCodexPlugin(ctx, { resolvedNodePath: node.nodePath });
+ const result = await installCodexPlugin(ctx, {
+ resolvedNodePath: node.nodePath,
+ ...(options.homeDir ? { profileHomeDir: options.homeDir } : {}),
+ });
if (!result.ok) return result;
return { ok: true, version: result.version };
},
async uninstallPlugin(ctx) {
- uninstallCodexPlugin(ctx);
+ await uninstallCodexPlugin(ctx, options.homeDir);
},
async pluginLaunchExtras(ctx) {
- const paths = getCodexPluginPaths(ctx);
const hooksFeatureFlag = await resolveCodexHooksFeatureFlag(ctx);
return {
args: ["--enable", hooksFeatureFlag],
- env: { CODEX_HOME: paths.codexHomeDir },
};
},
handleOscNotification: codexOscHint,
handleOscTitle: brailleSpinnerOscTitleHint,
oscHintsDeferToHookPlugin: true,
async detectInstall(ctx) {
- const status = await detectAgentInstall(ctx, codexDetectionSpec);
- primeCodexGoalsSupport(detectProbeLocation(ctx), status.version, status.executablePath);
+ const location = detectProbeLocation(ctx);
+ const env = profileEnv(location);
+ const detectionSpec = env
+ ? {
+ ...codexDetectionSpec,
+ kind,
+ label,
+ probeEnv: env,
+ authProbes: [
+ configFileAuthProbe((probeLocation) =>
+ probeLocation.kind === "wsl" ? undefined : codexAuthPath(env.CODEX_HOME),
+ ),
+ ],
+ }
+ : codexDetectionSpec;
+ const status = await detectAgentInstall(ctx, detectionSpec);
+ primeCodexGoalsSupport(location, status.version, status.executablePath);
capabilities = status.capabilities;
return status;
},
buildLaunchArgv(location: ProjectLocation, config, prompt, sessionRef, launchOptions) {
+ const env = profileEnv(location);
+ const codexHome = env?.CODEX_HOME;
preSpawnStartedAt = Date.now();
if (location.kind === "wsl") {
preSpawnRolloutIds = new Set();
} else {
- const sessions = readCodexSessionIndexForLocation(location);
- const rollouts = readCodexRolloutsForLocation(location);
+ const sessions = readCodexSessionIndexForLocation(location, codexHome);
+ const rollouts = readCodexRolloutsForLocation(location, codexHome);
preSpawnRolloutIds = new Set(rollouts.map((rollout) => rollout.id));
console.log(
[
@@ -206,10 +254,17 @@ export function createCodexAdapter(): AgentAdapter {
].join("\n"),
);
}
- return buildCodexArgvFor(location, config, prompt, sessionRef, launchOptions);
+ return buildCodexArgvFor(location, config, prompt, sessionRef, launchOptions, env);
},
buildResumeArgv(location, config, prompt, sessionRef, launchOptions) {
- return buildCodexArgvFor(location, config, prompt, sessionRef, launchOptions);
+ return buildCodexArgvFor(
+ location,
+ config,
+ prompt,
+ sessionRef,
+ launchOptions,
+ profileEnv(location),
+ );
},
extraArgsPosition: codexExtraArgsPosition,
createInitialSessionRef() {
@@ -225,9 +280,20 @@ export function createCodexAdapter(): AgentAdapter {
return undefined;
}
const wslExecPath = resolveAgentBinaryPath(input.projectLocation, "codex");
- return CodexStructuredSession.create(input, wslExecPath);
+ return CodexStructuredSession.create(input, wslExecPath, profileEnv(input.projectLocation));
},
- buildAcpLogoutCommand: buildAgentLogoutCommand("codex", ["logout"]),
+ buildAcpLogoutCommand: options.homeDir
+ ? async (ctx) => {
+ const location = detectProbeLocation(ctx);
+ return buildAgentCommand(
+ location,
+ "codex",
+ ["logout"],
+ resolveAgentBinaryPath(location, "codex"),
+ profileEnv(location),
+ );
+ }
+ : buildAgentLogoutCommand("codex", ["logout"]),
buildDirectInput(prompt) {
return [prompt, "@wait:160", "\r"];
},
@@ -240,7 +306,7 @@ export function createCodexAdapter(): AgentAdapter {
},
initialSessionRefDiscoveryDelayMs: 1000,
watchSessionRef(location, onChanged) {
- const paths = resolveCodexSessionWatchPaths(location);
+ const paths = resolveCodexSessionWatchPaths(location, profileEnv(location)?.CODEX_HOME);
if (paths.length === 0) return undefined;
return watchSessionPaths(
location,
@@ -251,9 +317,10 @@ export function createCodexAdapter(): AgentAdapter {
},
async discoverSessionRef(location) {
try {
+ const codexHome = profileEnv(location)?.CODEX_HOME;
const [sessions, rollouts] = await Promise.all([
- readCodexSessionIndexForLocationAsync(location),
- readCodexRolloutsForLocationAsync(location),
+ readCodexSessionIndexForLocationAsync(location, codexHome),
+ readCodexRolloutsForLocationAsync(location, codexHome),
]);
const newRollouts = rollouts
.filter((rollout) => !preSpawnRolloutIds.has(rollout.id))
@@ -300,7 +367,7 @@ export function createCodexAdapter(): AgentAdapter {
}
},
defaultOneShotModel: "gpt-5.5",
- buildOneShotCommand(model, effort) {
+ buildOneShotCommand(model, effort, _prompt, location) {
// `--skip-git-repo-check` lets `codex exec` run from worktrees or other
// directories not on codex's trust list. Title generation only reads
// the user's prompt from stdin and emits a short string — it never
@@ -310,7 +377,8 @@ export function createCodexAdapter(): AgentAdapter {
args.push("-c", `model_reasoning_effort="${effort}"`);
}
args.push("-");
- return { command: "codex", args };
+ const env = profileEnv(location ?? detectProbeLocation(undefined));
+ return { command: "codex", args, ...(env ? { env } : {}) };
},
buildContextExtractionCommand(_sessionRef, _location, _model) {
return undefined;
diff --git a/src/supervisor/agents/codex/plugin/install.test.ts b/src/supervisor/agents/codex/plugin/install.test.ts
index 7a7017719..b7aa0f90d 100644
--- a/src/supervisor/agents/codex/plugin/install.test.ts
+++ b/src/supervisor/agents/codex/plugin/install.test.ts
@@ -1,4 +1,4 @@
-import { mkdtempSync } from "node:fs";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -23,31 +23,13 @@ import {
mergeCodexHooksDocument,
parseCodexVersionLine,
probeCodexCliSemver,
+ removeManagedCodexHooksDocument,
+ resolveCodexHooksPath,
+ uninstallCodexPlugin,
} from "./install";
-import { buildNativeHookCommandHead } from "../../plugin/installerBase";
-
-const forwardPath = "C:\\Users\\demo\\.poracode\\agent-plugins\\codex\\forward.mjs";
-const forwardPathUnix = "/home/demo/.poracode/agent-plugins/codex/forward.mjs";
-
-/**
- * Test helpers build a `commandHead` matching one of the two shapes
- * `mergeCodexHooksDocument` accepts: WSL (` `)
- * or native (``). The merger doesn't care which shape it
- * gets — it just appends ` `.
- */
-function wslCommandHead(fp: string): string {
- return `${JSON.stringify("/home/demo/.nvm/versions/node/v22.11.0/bin/node")} ${JSON.stringify(fp)}`;
-}
-
-function nativeCommandHead(wrapperPath: string): string {
- return buildNativeHookCommandHead(wrapperPath);
-}
-
-function commandFor(head: string, event: string): string {
- return `${head} ${event}`;
-}
const originalPlatform = process.platform;
+const originalCodexHome = process.env.CODEX_HOME;
beforeEach(() => {
mockExecFileSync.mockReset();
@@ -55,17 +37,37 @@ beforeEach(() => {
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
+ if (originalCodexHome === undefined) delete process.env.CODEX_HOME;
+ else process.env.CODEX_HOME = originalCodexHome;
});
describe("getCodexPluginPaths", () => {
- it("places Codex hooks under Poracode's private CODEX_HOME", () => {
+ it("stages hook runtime assets outside CODEX_HOME", () => {
const baseDir = mkdtempSync(join(tmpdir(), "poracode-codex-paths-"));
const paths = getCodexPluginPaths({ envKind: "posix", baseDir });
expect(paths.pluginDir).toBe(join(baseDir, "agent-plugins", "codex"));
- expect(paths.codexHomeDir).toBe(join(baseDir, "agent-plugins", "codex", "home"));
- expect(paths.codexHooksPath).toBe(
- join(baseDir, "agent-plugins", "codex", "home", "hooks.json"),
+ expect(paths.forwardPath).toBe(join(baseDir, "agent-plugins", "codex", "forward.mjs"));
+ expect(paths.nativeWrapperPath).toBe(
+ join(
+ baseDir,
+ "agent-plugins",
+ "codex",
+ process.platform === "win32" ? "poracode-hook.cmd" : "poracode-hook.sh",
+ ),
+ );
+ });
+
+ it("uses inherited CODEX_HOME for base native hooks and profile home when selected", async () => {
+ const inheritedHome = mkdtempSync(join(tmpdir(), "poracode-codex-inherited-"));
+ const profileHome = mkdtempSync(join(tmpdir(), "poracode-codex-profile-"));
+ process.env.CODEX_HOME = inheritedHome;
+
+ await expect(resolveCodexHooksPath({ envKind: "posix" })).resolves.toBe(
+ join(inheritedHome, "hooks.json"),
+ );
+ await expect(resolveCodexHooksPath({ envKind: "posix" }, profileHome)).resolves.toBe(
+ join(profileHome, "hooks.json"),
);
});
});
@@ -123,102 +125,74 @@ describe("parseCodexVersionLine + isCodexSemverSupportedForHooks", () => {
});
});
-describe("mergeCodexHooksDocument", () => {
- it("creates only Poracode entries when hooks.json was absent (WSL shape)", () => {
- const head = wslCommandHead(forwardPath);
- const doc = mergeCodexHooksDocument(null, head);
- expect(Object.keys(doc.hooks)).toEqual([
- "SessionStart",
- "UserPromptSubmit",
- "PreToolUse",
- "PostToolUse",
- "PermissionRequest",
- "Stop",
- ]);
- const stop = doc.hooks.Stop as unknown[];
- expect(stop).toHaveLength(1);
- const stopHook = (stop[0] as { hooks: { command: string }[] }).hooks[0];
- expect(stopHook?.command).toBe(commandFor(head, "Stop"));
- });
+describe("Codex hooks document management", () => {
+ const commandHead = '"C:\\Users\\demo\\.poracode\\agent-plugins\\codex\\poracode-hook.cmd"';
- it("preserves user matcher groups and appends Poracode", () => {
- const head = wslCommandHead(forwardPath);
- const userGroup = {
- matcher: "*",
- hooks: [{ type: "command", command: "node user-script.js" }],
- };
- const existing = {
- hooks: {
- Stop: [userGroup],
- SessionStart: [],
- },
- };
- const doc = mergeCodexHooksDocument(existing, head);
- const stop = doc.hooks.Stop as unknown[];
- expect(stop).toHaveLength(2);
- expect(stop[0]).toEqual(userGroup);
- const lc = (stop[1] as { hooks: { command: string }[] }).hooks[0];
- expect(lc?.command).toBe(commandFor(head, "Stop"));
+ it("preserves user hooks and unrelated top-level fields while adding Poracode", () => {
+ const userGroup = { hooks: [{ type: "command", command: "node user-hook.js" }] };
+ const existing = { version: 1, hooks: { Stop: [userGroup] } };
+
+ const merged = mergeCodexHooksDocument(existing, commandHead);
+
+ expect(merged.version).toBe(1);
+ expect(merged.hooks.Stop?.[0]).toEqual(userGroup);
+ expect(merged.hooks.Stop?.[1]).toEqual({
+ hooks: [{ type: "command", command: `${commandHead} Stop` }],
+ });
+ expect(merged.hooks.SessionStart?.[0]).toMatchObject({ matcher: "*" });
});
- it("prunes stale Poracode groups by forward.mjs path fingerprint and replaces", () => {
- const head = wslCommandHead(forwardPath);
- const stale = {
- hooks: [
- {
- type: "command",
- command: `node "C:\\old\\.poracode\\agent-plugins\\codex\\forward.mjs" Stop`,
- },
- ],
- };
- const existing = { hooks: { Stop: [stale] } };
- const doc = mergeCodexHooksDocument(existing, head);
- const stop = doc.hooks.Stop as unknown[];
- expect(stop).toHaveLength(1);
- const h = (stop[0] as { hooks: { command: string }[] }).hooks[0];
- expect(h?.command).toBe(commandFor(head, "Stop"));
+ it("replaces stale managed hooks without duplicating them", () => {
+ const first = mergeCodexHooksDocument({}, commandHead);
+ const second = mergeCodexHooksDocument(first, commandHead);
+
+ expect(second).toEqual(first);
+ expect(second.hooks.Stop).toHaveLength(1);
});
- it("prunes legacy Lightcode groups by native wrapper fingerprint", () => {
- const head = nativeCommandHead(
- "C:\\Users\\demo\\.poracode\\agent-plugins\\codex\\poracode-hook.cmd",
- );
- const stale = {
+ it("removes only managed hooks and preserves user hooks in the same group", () => {
+ const mixedGroup = {
+ matcher: "*",
hooks: [
- {
- type: "command",
- command: `"C:\\old\\.poracode\\agent-plugins\\codex\\lightcode-hook.cmd" Stop`,
- },
+ { type: "command", command: "node user-hook.js" },
+ { type: "command", command: `${commandHead} Stop` },
],
};
- const existing = { hooks: { Stop: [stale] } };
- const doc = mergeCodexHooksDocument(existing, head);
- const stop = doc.hooks.Stop as unknown[];
- expect(stop).toHaveLength(1);
- const h = (stop[0] as { hooks: { command: string }[] }).hooks[0];
- expect(h?.command).toBe(commandFor(head, "Stop"));
- });
+ const existing = {
+ version: 1,
+ hooks: { Stop: [mixedGroup], Custom: [{ hooks: [{ command: "custom" }] }] },
+ };
- it("is idempotent when re-run with the same command head", () => {
- const head = wslCommandHead(forwardPathUnix);
- const first = mergeCodexHooksDocument(null, head);
- const second = mergeCodexHooksDocument(first, head);
- expect(second).toEqual(first);
- });
+ const removed = removeManagedCodexHooksDocument(existing);
- it("is idempotent when re-run with the same Windows forward path", () => {
- const first = mergeCodexHooksDocument(null, forwardPath);
- const second = mergeCodexHooksDocument(first, forwardPath);
- expect(second).toEqual(first);
+ expect(removed.version).toBe(1);
+ expect(removed.hooks.Stop).toEqual([
+ { matcher: "*", hooks: [{ type: "command", command: "node user-hook.js" }] },
+ ]);
+ expect(removed.hooks.Custom).toEqual(existing.hooks.Custom);
});
- it("uses matcher only for SessionStart, PreToolUse, PostToolUse", () => {
- const doc = mergeCodexHooksDocument(null, forwardPath);
- expect((doc.hooks.SessionStart as { matcher?: string }[])[0]).toMatchObject({
- matcher: "*",
- });
- expect((doc.hooks.UserPromptSubmit as { matcher?: string }[])[0]?.matcher).toBeUndefined();
- expect((doc.hooks.PermissionRequest as { matcher?: string }[])[0]?.matcher).toBeUndefined();
- expect((doc.hooks.Stop as { matcher?: string }[])[0]?.matcher).toBeUndefined();
+ it("uninstalls managed hooks from only the selected profile home", async () => {
+ const baseHome = mkdtempSync(join(tmpdir(), "poracode-codex-base-hooks-"));
+ const profileHome = mkdtempSync(join(tmpdir(), "poracode-codex-profile-hooks-"));
+ process.env.CODEX_HOME = baseHome;
+ const baseHooks = mergeCodexHooksDocument({}, commandHead);
+ const profileHooks = mergeCodexHooksDocument({}, commandHead);
+ writeFileSync(join(baseHome, "hooks.json"), JSON.stringify(baseHooks));
+ writeFileSync(join(profileHome, "hooks.json"), JSON.stringify(profileHooks));
+
+ try {
+ await uninstallCodexPlugin({ envKind: "posix" }, profileHome);
+
+ expect(JSON.parse(readFileSync(join(baseHome, "hooks.json"), "utf8"))).toEqual(baseHooks);
+ expect(
+ JSON.parse(readFileSync(join(profileHome, "hooks.json"), "utf8")) as {
+ hooks: Record;
+ },
+ ).toEqual({ hooks: {} });
+ } finally {
+ rmSync(baseHome, { force: true, recursive: true });
+ rmSync(profileHome, { force: true, recursive: true });
+ }
});
});
diff --git a/src/supervisor/agents/codex/plugin/install.ts b/src/supervisor/agents/codex/plugin/install.ts
index b53f8c23a..386737fd7 100644
--- a/src/supervisor/agents/codex/plugin/install.ts
+++ b/src/supervisor/agents/codex/plugin/install.ts
@@ -1,48 +1,39 @@
import { execFileSync } from "node:child_process";
-import {
- copyFileSync as fsCopyFileSync,
- existsSync,
- mkdirSync,
- readFileSync,
- writeFileSync,
-} from "node:fs";
+import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { toWslUncPath } from "@/shared/wsl";
import type { AgentEnvContext } from "../../base";
-import { buildAgentCommand, execInWsl, quotePosixShellArg } from "../../base";
+import { buildAgentCommand, resolveWslHomeDirectoryAsync } from "../../base";
import { resolveAgentBinaryPath } from "../../binaryResolver";
import {
FORWARD_RUNTIME_FILE,
buildNativeHookCommandHeads,
+ buildWslHookCommandHead,
copyForwardRuntimeFile,
copyPluginAssetsIfStale,
createPluginSourceResolver,
- ctxCacheKey,
- ensureNativeStateLink,
+ getNativeHookWrapperFilename,
getNativePluginBaseDir,
getWslPluginBaseDirs,
hasNativeHookWrapper,
isWslPluginContext,
- memoByCtx,
parseExistingHooksJson,
readBundledPluginVersion,
readPluginManifest,
- removeStagedPluginDir,
stagePluginAssetsToWsl,
writeHooksJsonFile,
writeNativeHookWrapper,
type PluginManifest,
} from "../../plugin/installerBase";
+import { resolveCodexHomeFromBase } from "../profile";
import { resolveCodexNativeExecutableForWindows } from "../windowsExecutable";
export interface CodexPluginPaths {
pluginDir: string;
- /** Private CODEX_HOME used only for Codex processes spawned by Poracode. */
- codexHomeDir: string;
- /** Path to hooks.json inside the private CODEX_HOME. */
- codexHooksPath: string;
+ forwardPath: string;
+ nativeWrapperPath?: string;
version: string;
}
@@ -55,12 +46,6 @@ const CODEX_HOOK_EVENTS = [
"Stop",
] as const;
-/**
- * Match any Poracode-staged Codex hook command in hooks.json. Covers both
- * the WSL shape (where `forward.mjs` is invoked directly via an absolute
- * node path) and the native shape (where `poracode-hook.{sh,cmd,ps1}` is the
- * entry point).
- */
const PORACODE_FORWARD_RE =
/agent-plugins(?:[/\\]+)codex(?:[/\\]+)(?:forward\.mjs|poracode-hook\.(?:sh|cmd|ps1))/;
const MANAGED_FORWARD_RE =
@@ -85,9 +70,8 @@ function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths {
if (isWslPluginContext(ctx)) {
const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex");
if (!wsl) {
- return { pluginDir: "", codexHomeDir: "", codexHooksPath: "", version: "0.0.0" };
+ return { pluginDir: "", forwardPath: "", version: "0.0.0" };
}
- const linuxCodexHome = `${wsl.linuxBase}/home`;
let version = "0.0.0";
try {
version = readPluginManifest(wsl.uncBase).version;
@@ -96,13 +80,11 @@ function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths {
}
return {
pluginDir: wsl.linuxBase,
- codexHomeDir: linuxCodexHome,
- codexHooksPath: `${linuxCodexHome}/hooks.json`,
+ forwardPath: `${wsl.linuxBase}/forward.mjs`,
version,
};
}
const pluginDir = getNativePluginBaseDir("codex", ctx?.baseDir);
- const codexHomeDir = join(pluginDir, "home");
let version = "0.0.0";
try {
version = readPluginManifest(pluginDir).version;
@@ -111,147 +93,145 @@ function computeCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths {
}
return {
pluginDir,
- codexHomeDir,
- codexHooksPath: join(codexHomeDir, "hooks.json"),
+ forwardPath: join(pluginDir, "forward.mjs"),
+ nativeWrapperPath: join(pluginDir, getNativeHookWrapperFilename()),
version,
};
}
-const codexPluginPathsMemo = memoByCtx(computeCodexPluginPaths, ctxCacheKey);
-
export function getCodexPluginPaths(ctx?: AgentEnvContext): CodexPluginPaths {
- return codexPluginPathsMemo.call(ctx);
+ return computeCodexPluginPaths(ctx);
}
-function prunePoracodeGroups(groups: unknown): unknown[] {
+function recordOrEmpty(value: unknown): Record {
+ return value && typeof value === "object" && !Array.isArray(value)
+ ? { ...(value as Record) }
+ : {};
+}
+
+function pruneManagedHookGroups(groups: unknown): unknown[] {
if (!Array.isArray(groups)) return [];
- return groups.filter((g) => {
- if (!g || typeof g !== "object") return true;
- const rec = g as { hooks?: unknown };
- const hooks = rec.hooks;
- if (!Array.isArray(hooks)) return true;
- return !hooks.some((h) => {
- if (!h || typeof h !== "object") return false;
- const cmd = (h as { type?: string; command?: string }).command;
- return typeof cmd === "string" && MANAGED_FORWARD_RE.test(cmd);
+ return groups.flatMap((group) => {
+ if (!group || typeof group !== "object" || Array.isArray(group)) return [group];
+ const hooks = (group as { hooks?: unknown }).hooks;
+ if (!Array.isArray(hooks)) return [group];
+ const retained = hooks.filter((hook) => {
+ if (!hook || typeof hook !== "object") return true;
+ const command = (hook as { command?: unknown }).command;
+ return typeof command !== "string" || !MANAGED_FORWARD_RE.test(command);
});
+ if (retained.length === hooks.length) return [group];
+ return retained.length > 0 ? [{ ...(group as Record), hooks: retained }] : [];
});
}
-function commandForEvent(commandHead: string, event: string): string {
- return `${commandHead} ${event}`;
+function buildPoracodeHookGroup(event: string, commandHead: string): Record {
+ const hook = { type: "command", command: `${commandHead} ${event}` };
+ return event === "SessionStart" || event === "PreToolUse" || event === "PostToolUse"
+ ? { matcher: "*", hooks: [hook] }
+ : { hooks: [hook] };
}
-function buildPoracodeGroup(event: string, commandHead: string): Record {
- const command = commandForEvent(commandHead, event);
- const hook = { type: "command", command };
- if (event === "SessionStart" || event === "PreToolUse" || event === "PostToolUse") {
- return { matcher: "*", hooks: [hook] };
- }
- return { hooks: [hook] };
+export interface CodexHooksDocument extends Record {
+ hooks: Record;
}
-/**
- * Merge Poracode Codex hook matcher groups into a parsed `hooks.json`
- * document. `commandHead` is the entire pre-event portion of each hook
- * command — for WSL it's `"" ""`;
- * for native it's just `""`. Exported for unit tests.
- */
export function mergeCodexHooksDocument(
existingParsed: unknown,
commandHead: string,
-): { hooks: Record } {
- let hooksRoot: Record = {};
- if (
- existingParsed &&
- typeof existingParsed === "object" &&
- "hooks" in existingParsed &&
- (existingParsed as { hooks: unknown }).hooks &&
- typeof (existingParsed as { hooks: unknown }).hooks === "object"
- ) {
- hooksRoot = { ...(existingParsed as { hooks: Record }).hooks };
+): CodexHooksDocument {
+ const document = recordOrEmpty(existingParsed);
+ const hooksRoot = recordOrEmpty(document.hooks);
+ for (const event of CODEX_HOOK_EVENTS) {
+ hooksRoot[event] = [
+ ...pruneManagedHookGroups(hooksRoot[event]),
+ buildPoracodeHookGroup(event, commandHead),
+ ];
}
+ return { ...document, hooks: hooksRoot as Record };
+}
+export function removeManagedCodexHooksDocument(existingParsed: unknown): CodexHooksDocument {
+ const document = recordOrEmpty(existingParsed);
+ const hooksRoot = recordOrEmpty(document.hooks);
for (const event of CODEX_HOOK_EVENTS) {
- const prev = hooksRoot[event];
- const pruned = prunePoracodeGroups(prev);
- pruned.push(buildPoracodeGroup(event, commandHead));
- hooksRoot[event] = pruned;
+ if (!Array.isArray(hooksRoot[event])) continue;
+ const retained = pruneManagedHookGroups(hooksRoot[event]);
+ if (retained.length > 0) hooksRoot[event] = retained;
+ else delete hooksRoot[event];
}
+ return { ...document, hooks: hooksRoot as Record };
+}
- return { hooks: hooksRoot as Record };
+interface ResolvedCodexHome {
+ runtimePath: string;
+ readablePath: string;
}
-const CODEX_LINK_TARGETS = [
- { name: "sessions", kind: "dir" as const },
- { name: "session_index.jsonl", kind: "file" as const },
- { name: "auth.json", kind: "file" as const },
- { name: "config.toml", kind: "file" as const },
-];
-
-function seedNativeCodexHome(codexHomeDir: string): void {
- mkdirSync(codexHomeDir, { recursive: true });
- const globalCodexHome = join(homedir(), ".codex");
- mkdirSync(join(globalCodexHome, "sessions"), { recursive: true });
- if (!existsSync(join(globalCodexHome, "session_index.jsonl"))) {
- writeFileSync(join(globalCodexHome, "session_index.jsonl"), "", { flag: "a" });
- }
- restorePrivateStateFile(codexHomeDir, globalCodexHome, "auth.json");
- restorePrivateStateFile(codexHomeDir, globalCodexHome, "config.toml");
+function resolveNativeCodexHome(rawHomeDir?: string): ResolvedCodexHome {
+ const nativeHome = homedir();
+ const configuredHome = rawHomeDir ?? (process.env.CODEX_HOME?.trim() || undefined);
+ const runtimePath = resolveCodexHomeFromBase(configuredHome, nativeHome, "native");
+ return { runtimePath, readablePath: runtimePath };
+}
- for (const { name, kind } of CODEX_LINK_TARGETS) {
- ensureNativeStateLink(join(globalCodexHome, name), join(codexHomeDir, name), kind);
+function resolveWslCodexHome(
+ distro: string,
+ wslHome: string,
+ rawHomeDir?: string,
+): ResolvedCodexHome {
+ const runtimePath = resolveCodexHomeFromBase(rawHomeDir, wslHome, "posix");
+ return { runtimePath, readablePath: toWslUncPath(distro, runtimePath) };
+}
+
+async function resolveCodexHome(
+ ctx: AgentEnvContext | undefined,
+ rawHomeDir?: string,
+ knownWslHome?: string,
+): Promise {
+ if (!isWslPluginContext(ctx)) return resolveNativeCodexHome(rawHomeDir);
+ const wslHome = knownWslHome ?? (await resolveWslHomeDirectoryAsync(ctx.wslDistro));
+ if (!wslHome) {
+ throw new Error(`Unable to resolve the WSL home directory for ${ctx.wslDistro}.`);
}
+ return resolveWslCodexHome(ctx.wslDistro, wslHome, rawHomeDir);
}
-function restorePrivateStateFile(
- codexHomeDir: string,
- globalCodexHome: string,
- file: "auth.json" | "config.toml",
-): void {
- const source = join(codexHomeDir, file);
- const target = join(globalCodexHome, file);
- if (existsSync(target) || !existsSync(source)) return;
- try {
- fsCopyFileSync(source, target);
- } catch {
- // Best-effort recovery for Windows when file symlinks were unavailable.
+export async function resolveCodexHooksPath(
+ ctx?: AgentEnvContext,
+ profileHomeDir?: string,
+): Promise {
+ const home = await resolveCodexHome(ctx, profileHomeDir);
+ return join(home.readablePath, "hooks.json");
+}
+
+function parseHooksDocument(hooksPath: string): unknown {
+ const existing = parseExistingHooksJson(hooksPath);
+ if (existing === null && existsSync(hooksPath)) {
+ throw new Error(`Malformed Codex hooks file: ${hooksPath}`);
}
+ return existing;
}
-async function seedWslCodexHome(
- distro: string,
- home: string,
- linuxCodexHome: string,
-): Promise {
- const uncCodexHome = toWslUncPath(distro, linuxCodexHome);
- mkdirSync(uncCodexHome, { recursive: true });
- const globalCodexHome = `${home}/.codex`;
- const linkExists = (path: string) =>
- `[ -e ${quotePosixShellArg(path)} ] || [ -L ${quotePosixShellArg(path)} ]`;
- // ln -s can fail on Windows-mounted filesystems (9p / DrvFs). For files,
- // fall back to hardlink, then copy. Dirs only get the symlink attempt.
- const linkLine = (name: string, kind: "dir" | "file") => {
- const target = quotePosixShellArg(`${linuxCodexHome}/${name}`);
- const source = quotePosixShellArg(`${globalCodexHome}/${name}`);
- const attempts = [
- linkExists(`${linuxCodexHome}/${name}`),
- `ln -s ${source} ${target}`,
- ...(kind === "file" ? [`ln ${source} ${target}`, `cp ${source} ${target}`] : []),
- ];
- return attempts.join(" || ");
- };
- const script = [
- [
- "mkdir -p",
- quotePosixShellArg(linuxCodexHome),
- quotePosixShellArg(`${globalCodexHome}/sessions`),
- ].join(" "),
- `touch ${quotePosixShellArg(`${globalCodexHome}/session_index.jsonl`)}`,
- ...CODEX_LINK_TARGETS.map(({ name, kind }) => linkLine(name, kind)),
- ].join("\n");
- await execInWsl(distro, "/", "sh", ["-lc", script], { timeout: 15_000 }).catch((error) => {
- console.warn(`[codex] WSL plugin install failed for distro ${distro}:`, error);
+function hasManagedHookForEveryEvent(existingParsed: unknown): boolean {
+ const hooksRoot = recordOrEmpty(recordOrEmpty(existingParsed).hooks);
+ return CODEX_HOOK_EVENTS.every((event) => {
+ const groups = hooksRoot[event];
+ return (
+ Array.isArray(groups) &&
+ groups.some((group) => {
+ if (!group || typeof group !== "object") return false;
+ const hooks = (group as { hooks?: unknown }).hooks;
+ return (
+ Array.isArray(hooks) &&
+ hooks.some((hook) => {
+ if (!hook || typeof hook !== "object") return false;
+ const command = (hook as { command?: unknown }).command;
+ return typeof command === "string" && PORACODE_FORWARD_RE.test(command);
+ })
+ );
+ })
+ );
});
}
@@ -333,6 +313,8 @@ export interface InstallCodexPluginOptions {
* `ELECTRON_RUN_AS_NODE=1` against the bundled Electron binary.
*/
resolvedNodePath?: string | undefined;
+ /** Selected profile home. Omit for the base Codex home. */
+ profileHomeDir?: string | undefined;
}
export async function installCodexPlugin(
@@ -361,109 +343,81 @@ export async function installCodexPlugin(
"WSL Codex plugin install requires a resolved node path; the adapter must call resolveNodeForDistro before installing.",
};
}
- return installCodexPluginWsl(ctx.wslDistro, sourceDir, manifest, options.resolvedNodePath);
+ return installCodexPluginWsl(
+ ctx,
+ sourceDir,
+ manifest,
+ options.resolvedNodePath,
+ options.profileHomeDir,
+ );
}
- const pluginDir = getNativePluginBaseDir("codex", ctx?.baseDir);
- const codexHomeDir = join(pluginDir, "home");
+ const paths = getCodexPluginPaths(ctx);
+ const { pluginDir } = paths;
mkdirSync(pluginDir, { recursive: true });
- seedNativeCodexHome(codexHomeDir);
copyPluginAssetsIfStale(sourceDir, pluginDir);
copyForwardRuntimeFile(pluginDir);
const wrapperPath = writeNativeHookWrapper(pluginDir, {
...(options?.resolvedNodePath ? { nodePath: options.resolvedNodePath } : {}),
});
- const hooksPath = join(codexHomeDir, "hooks.json");
- const existing = parseExistingHooksJson(hooksPath);
- if (existing === null && existsSync(hooksPath)) {
- return { ok: false, reason: "malformed private Codex hooks.json (invalid JSON)" };
- }
-
- // Native command shape: ` `. The wrapper sets
- // ELECTRON_RUN_AS_NODE=1 and execs the bundled Electron Node on
- // forward.mjs (which lives next to the wrapper).
- const commandHead = buildNativeHookCommandHeads(wrapperPath).command;
-
try {
- const merged = mergeCodexHooksDocument(existing, commandHead);
+ const home = await resolveCodexHome(ctx, options?.profileHomeDir);
+ const hooksPath = join(home.readablePath, "hooks.json");
+ const merged = mergeCodexHooksDocument(
+ parseHooksDocument(hooksPath),
+ buildNativeHookCommandHeads(wrapperPath).command,
+ );
writeHooksJsonFile(hooksPath, merged);
} catch (error) {
- return {
- ok: false,
- reason: `failed to write private Codex hooks.json: ${
- error instanceof Error ? error.message : String(error)
- }`,
- };
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
}
console.log(
[
`[supervisor] Codex hook plugin staged v${manifest.version}`,
` pluginDir: ${pluginDir}`,
- ` CODEX_HOME: ${codexHomeDir}`,
].join("\n"),
);
return {
ok: true,
version: manifest.version,
- paths: {
- pluginDir,
- codexHomeDir,
- codexHooksPath: hooksPath,
- version: manifest.version,
- },
+ paths: { ...paths, version: manifest.version },
};
}
async function installCodexPluginWsl(
- distro: string,
+ ctx: AgentEnvContext & { envKind: "wsl"; wslDistro: string },
sourceDir: string,
manifest: PluginManifest,
resolvedNodePath: string,
+ profileHomeDir?: string,
): Promise<{ ok: true; paths: CodexPluginPaths; version: string } | { ok: false; reason: string }> {
- const staged = stagePluginAssetsToWsl(distro, sourceDir, "codex", {
+ const staged = stagePluginAssetsToWsl(ctx.wslDistro, sourceDir, "codex", {
includeForwardRuntime: true,
});
if (!staged.ok) return staged;
- const linuxForward = `${staged.linuxPluginDir}/forward.mjs`;
- const linuxCodexHome = `${staged.linuxPluginDir}/home`;
- await seedWslCodexHome(distro, staged.deploy.home, linuxCodexHome);
- const linuxHooksPath = `${linuxCodexHome}/hooks.json`;
- const uncHooks = toWslUncPath(distro, linuxHooksPath);
-
- const existing = parseExistingHooksJson(uncHooks);
- if (existing === null && existsSync(uncHooks)) {
- return {
- ok: false,
- reason: `malformed private Codex hooks.json in wsl distro ${distro}`,
- };
- }
-
- // WSL command shape: `"" "" `.
- // /bin/sh -c never has to resolve `node` from PATH because both are
- // absolute paths.
- const commandHead = `${JSON.stringify(resolvedNodePath)} ${JSON.stringify(linuxForward)}`;
-
try {
- const merged = mergeCodexHooksDocument(existing, commandHead);
- writeHooksJsonFile(uncHooks, merged);
+ const home = await resolveCodexHome(ctx, profileHomeDir, staged.deploy.home);
+ const hooksPath = join(home.readablePath, "hooks.json");
+ const commandHead = buildWslHookCommandHead(
+ resolvedNodePath,
+ `${staged.linuxPluginDir}/forward.mjs`,
+ );
+ writeHooksJsonFile(
+ hooksPath,
+ mergeCodexHooksDocument(parseHooksDocument(hooksPath), commandHead),
+ );
} catch (error) {
- return {
- ok: false,
- reason: `failed to write hooks.json in wsl distro ${distro}: ${
- error instanceof Error ? error.message : String(error)
- }`,
- };
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
}
console.log(
[
- `[supervisor] Codex hook plugin staged v${manifest.version} (wsl:${distro})`,
+ `[supervisor] Codex hook plugin staged v${manifest.version} (wsl:${ctx.wslDistro})`,
` pluginDir: ${staged.linuxPluginDir}`,
- ` CODEX_HOME: ${linuxCodexHome}`,
].join("\n"),
);
@@ -472,63 +426,50 @@ async function installCodexPluginWsl(
version: manifest.version,
paths: {
pluginDir: staged.linuxPluginDir,
- codexHomeDir: linuxCodexHome,
- codexHooksPath: linuxHooksPath,
+ forwardPath: `${staged.linuxPluginDir}/forward.mjs`,
version: manifest.version,
},
};
}
-export function isCodexPluginInstalled(
+export async function isCodexPluginInstalled(
ctx?: AgentEnvContext,
+ profileHomeDir?: string,
): Promise<{ installed: boolean; version?: string }> {
+ let hooksPath: string;
+ try {
+ hooksPath = await resolveCodexHooksPath(ctx, profileHomeDir);
+ } catch {
+ return { installed: false };
+ }
if (isWslPluginContext(ctx)) {
const wsl = getWslPluginBaseDirs(ctx.wslDistro, "codex");
- if (!wsl) return Promise.resolve({ installed: false });
- return Promise.resolve(verifyCodexInstallAt(wsl.uncBase, "wsl"));
+ if (!wsl) return { installed: false };
+ return verifyCodexInstallAt(wsl.uncBase, hooksPath, "wsl");
}
- return Promise.resolve(
- verifyCodexInstallAt(getNativePluginBaseDir("codex", ctx?.baseDir), "native"),
- );
+ return verifyCodexInstallAt(getNativePluginBaseDir("codex", ctx?.baseDir), hooksPath, "native");
}
-export function uninstallCodexPlugin(ctx?: AgentEnvContext): void {
- removeStagedPluginDir("codex", ctx);
+export async function uninstallCodexPlugin(
+ ctx?: AgentEnvContext,
+ profileHomeDir?: string,
+): Promise {
+ const hooksPath = await resolveCodexHooksPath(ctx, profileHomeDir);
+ if (!existsSync(hooksPath)) return;
+ writeHooksJsonFile(hooksPath, removeManagedCodexHooksDocument(parseHooksDocument(hooksPath)));
}
function verifyCodexInstallAt(
readableDir: string,
+ hooksPath: string,
target: "native" | "wsl",
): { installed: boolean; version?: string } {
- const hooksPath = join(readableDir, "home", "hooks.json");
if (!existsSync(join(readableDir, "plugin.json"))) return { installed: false };
if (!existsSync(join(readableDir, "forward.mjs"))) return { installed: false };
if (!existsSync(join(readableDir, FORWARD_RUNTIME_FILE))) return { installed: false };
if (!hasNativeHookWrapper(readableDir, target)) return { installed: false };
- if (!existsSync(hooksPath)) return { installed: false };
+ if (!hasManagedHookForEveryEvent(parseExistingHooksJson(hooksPath))) return { installed: false };
try {
- const raw = readFileSync(hooksPath, "utf8");
- const doc = JSON.parse(raw) as { hooks?: Record };
- if (!doc.hooks) return { installed: false };
- let found = false;
- for (const event of CODEX_HOOK_EVENTS) {
- const groups = doc.hooks[event];
- if (!Array.isArray(groups)) continue;
- for (const g of groups) {
- if (!g || typeof g !== "object") continue;
- const hooks = (g as { hooks?: unknown }).hooks;
- if (!Array.isArray(hooks)) continue;
- for (const h of hooks) {
- if (!h || typeof h !== "object") continue;
- const cmd = (h as { command?: string }).command;
- if (typeof cmd === "string" && PORACODE_FORWARD_RE.test(cmd)) {
- found = true;
- break;
- }
- }
- }
- }
- if (!found) return { installed: false };
const version = readPluginManifest(readableDir).version;
return { installed: true, version };
} catch {
diff --git a/src/supervisor/agents/codex/probe.ts b/src/supervisor/agents/codex/probe.ts
index b205ef7f0..c18ccc65e 100644
--- a/src/supervisor/agents/codex/probe.ts
+++ b/src/supervisor/agents/codex/probe.ts
@@ -421,6 +421,7 @@ interface RunWithCodexAppServerOptions {
wslExecPath?: string;
timeoutMs?: number;
label?: string;
+ env?: Record;
}
/**
@@ -445,6 +446,7 @@ async function runWithCodexAppServer(
const cmd = buildCodexAppServerCommand(location, {
...(options?.wslExecPath !== undefined ? { wslExecPath: options.wslExecPath } : {}),
...(wslNodePath !== undefined ? { wslNodePath } : {}),
+ ...(options?.env ? { env: options.env } : {}),
});
const spawnCwd = resolveProbeSpawnCwd(location, cmd.cwd);
@@ -539,7 +541,7 @@ export async function probeCodexAccount(
*/
export async function probeCodexCapabilities(
location: ProjectLocation,
- options?: { wslExecPath?: string; timeoutMs?: number; label?: string },
+ options?: RunWithCodexAppServerOptions,
): Promise {
const result = await runWithCodexAppServer(location, options, async ({ client, initResult }) => {
const [modelResult, requirementsResult, skillsResult] = await Promise.all([
diff --git a/src/supervisor/agents/codex/profile.ts b/src/supervisor/agents/codex/profile.ts
new file mode 100644
index 000000000..9b8766c0d
--- /dev/null
+++ b/src/supervisor/agents/codex/profile.ts
@@ -0,0 +1,56 @@
+import { mkdirSync } from "node:fs";
+import { homedir } from "node:os";
+import path from "node:path";
+import posixPath from "node:path/posix";
+import type { ProjectLocation } from "@/shared/contracts";
+import { toWslUncPath } from "@/shared/wsl";
+import { resolveWslHomeDirectory } from "../base";
+
+export function resolveCodexHomeFromBase(
+ rawHomeDir: string | undefined,
+ baseHome: string,
+ pathKind: "native" | "posix",
+): string {
+ const trimmed = rawHomeDir?.trim();
+ if (rawHomeDir !== undefined && !trimmed) throw new Error("Codex profile home is empty.");
+ const paths = pathKind === "posix" ? posixPath : path;
+ if (!trimmed) return paths.join(baseHome, ".codex");
+ if (trimmed === "~" || trimmed.startsWith("~/")) {
+ return paths.join(baseHome, trimmed === "~" ? "" : trimmed.slice(2));
+ }
+ return paths.isAbsolute(trimmed) ? paths.normalize(trimmed) : paths.resolve(baseHome, trimmed);
+}
+
+export function resolveCodexHomeForLocation(rawHomeDir: string, location: ProjectLocation): string {
+ if (location.kind === "wsl") {
+ const home = resolveWslHomeDirectory(location.distro);
+ if (!home) {
+ throw new Error(`Unable to resolve the WSL home directory for ${location.distro}.`);
+ }
+ return resolveCodexHomeFromBase(rawHomeDir, home, "posix");
+ }
+ return resolveCodexHomeFromBase(rawHomeDir, homedir(), "native");
+}
+
+/** Ensure Codex's custom home exists before invoking the CLI. */
+export function ensureCodexHomeForLocation(rawHomeDir: string, location: ProjectLocation): string {
+ const homeDir = resolveCodexHomeForLocation(rawHomeDir, location);
+ const fsHome = location.kind === "wsl" ? toWslUncPath(location.distro, homeDir) : homeDir;
+ mkdirSync(path.join(fsHome, "sessions"), { recursive: true });
+ return homeDir;
+}
+
+export function codexHomeEnvForLocation(
+ rawHomeDir: string | undefined,
+ location: ProjectLocation,
+): Record | undefined {
+ if (!rawHomeDir) return undefined;
+ return {
+ CODEX_HOME: ensureCodexHomeForLocation(rawHomeDir, location),
+ // Account profiles must authenticate from their own CODEX_HOME. Empty
+ // values deliberately shadow host-level credentials inherited by spawn.
+ OPENAI_API_KEY: "",
+ CODEX_API_KEY: "",
+ CODEX_ACCESS_TOKEN: "",
+ };
+}
diff --git a/src/supervisor/agents/codex/session.test.ts b/src/supervisor/agents/codex/session.test.ts
new file mode 100644
index 000000000..e170c08b9
--- /dev/null
+++ b/src/supervisor/agents/codex/session.test.ts
@@ -0,0 +1,45 @@
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import type { ProjectLocation } from "@/shared/contracts";
+import {
+ readCodexRolloutsForLocation,
+ readCodexSessionIndexForLocation,
+ resolveCodexSessionWatchPaths,
+} from "./session";
+
+describe("Codex profile session discovery", () => {
+ it("reads and watches only the selected CODEX_HOME", () => {
+ const homeDir = mkdtempSync(join(tmpdir(), "poracode-codex-session-profile-"));
+ const sessionsDir = join(homeDir, "sessions", "2026", "07", "15");
+ const projectPath = join(homeDir, "project");
+ const location: ProjectLocation = { kind: "posix", path: projectPath };
+ const id = "019f-profile-session";
+
+ try {
+ mkdirSync(sessionsDir, { recursive: true });
+ writeFileSync(
+ join(homeDir, "session_index.jsonl"),
+ `${JSON.stringify({ id, updated_at: "2026-07-15T01:02:03.000Z", thread_name: "Work" })}\n`,
+ );
+ writeFileSync(
+ join(sessionsDir, `rollout-2026-07-15T01-02-03-${id}.jsonl`),
+ `${JSON.stringify({
+ type: "session_meta",
+ payload: { id, cwd: projectPath, originator: "codex-tui", source: "cli" },
+ })}\n`,
+ );
+
+ expect(readCodexSessionIndexForLocation(location, homeDir)).toEqual([
+ { id, updatedAt: Date.parse("2026-07-15T01:02:03.000Z"), threadName: "Work" },
+ ]);
+ expect(readCodexRolloutsForLocation(location, homeDir).map((rollout) => rollout.id)).toEqual([
+ id,
+ ]);
+ expect(resolveCodexSessionWatchPaths(location, homeDir)).toEqual([join(homeDir, "sessions")]);
+ } finally {
+ rmSync(homeDir, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/supervisor/agents/codex/session.ts b/src/supervisor/agents/codex/session.ts
index 5a4376b93..65891f803 100644
--- a/src/supervisor/agents/codex/session.ts
+++ b/src/supervisor/agents/codex/session.ts
@@ -66,11 +66,19 @@ export function describeCodexLocation(location: ProjectLocation): string {
}
}
-export function readCodexSessionIndexForLocation(location: ProjectLocation) {
+export function readCodexSessionIndexForLocation(location: ProjectLocation, codexHome?: string) {
if (location.kind === "wsl") {
return [];
}
+ if (codexHome) {
+ try {
+ return parseCodexSessionIndex(readFileSync(join(codexHome, "session_index.jsonl"), "utf8"));
+ } catch {
+ return [];
+ }
+ }
+
const sessions = readCodexSessionIndex();
const privateIndexPath = join(nativePrivateCodexHome(), "session_index.jsonl");
let privateRaw: string;
@@ -90,16 +98,19 @@ export function readCodexSessionIndexForLocation(location: ProjectLocation) {
*/
export async function readCodexSessionIndexForLocationAsync(
location: ProjectLocation,
+ codexHome?: string,
): Promise> {
if (location.kind !== "wsl") {
- return readCodexSessionIndexForLocation(location);
+ return readCodexSessionIndexForLocation(location, codexHome);
}
const home = await resolveWslHomeDirectoryAsync(location.distro);
const privateHome = codexPrivateHomeFrom(home);
- const paths = [
- home ? `${home}/.codex/session_index.jsonl` : undefined,
- privateHome ? `${privateHome}/session_index.jsonl` : undefined,
- ].filter((p): p is string => Boolean(p));
+ const paths = codexHome
+ ? [`${codexHome}/session_index.jsonl`]
+ : [
+ home ? `${home}/.codex/session_index.jsonl` : undefined,
+ privateHome ? `${privateHome}/session_index.jsonl` : undefined,
+ ].filter((p): p is string => Boolean(p));
const reads = await Promise.all(paths.map((p) => readSessionFileText(location, p)));
const parts = reads.filter((r): r is string => typeof r === "string" && r.length > 0);
if (parts.length === 0) return [];
@@ -129,7 +140,10 @@ export function isInteractiveCodexRollout(
}
}
-export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRolloutMeta[] {
+export function readCodexRolloutsForLocation(
+ location: ProjectLocation,
+ codexHome?: string,
+): CodexRolloutMeta[] {
if (location.kind === "wsl") {
return [];
}
@@ -173,7 +187,7 @@ export function readCodexRolloutsForLocation(location: ProjectLocation): CodexRo
}
}
};
- for (const home of nativeCodexHomeCandidates()) {
+ for (const home of codexHome ? [codexHome] : nativeCodexHomeCandidates()) {
walk(join(home, "sessions"));
}
return dedupeRollouts(rollouts);
@@ -210,16 +224,19 @@ export async function readCodexRolloutMetaForLocationAsync(
export async function readCodexRolloutsForLocationAsync(
location: ProjectLocation,
+ codexHome?: string,
): Promise {
if (location.kind !== "wsl") {
- return readCodexRolloutsForLocation(location);
+ return readCodexRolloutsForLocation(location, codexHome);
}
const home = await resolveWslHomeDirectoryAsync(location.distro);
const privateHome = codexPrivateHomeFrom(home);
- const roots = [
- home ? `${home}/.codex/sessions` : undefined,
- privateHome ? `${privateHome}/sessions` : undefined,
- ].filter((r): r is string => Boolean(r));
+ const roots = codexHome
+ ? [`${codexHome}/sessions`]
+ : [
+ home ? `${home}/.codex/sessions` : undefined,
+ privateHome ? `${privateHome}/sessions` : undefined,
+ ].filter((r): r is string => Boolean(r));
const accept = (name: string): boolean => name.startsWith("rollout-") && name.endsWith(".jsonl");
const found = (
@@ -249,7 +266,15 @@ export async function readCodexRolloutsForLocationAsync(
* paths for windows/posix; Linux paths inside the distro for WSL (consumed
* by the in-distro bridge watch subscription, NOT UNC `\\wsl.localhost\…`).
*/
-export function resolveCodexSessionWatchPaths(location: ProjectLocation): string[] {
+export function resolveCodexSessionWatchPaths(
+ location: ProjectLocation,
+ codexHome?: string,
+): string[] {
+ if (codexHome) {
+ const sessions =
+ location.kind === "wsl" ? `${codexHome}/sessions` : join(codexHome, "sessions");
+ return location.kind === "wsl" || existsSync(sessions) ? [sessions] : [];
+ }
if (location.kind === "wsl") {
const home = getCachedWslHomeDirectory(location.distro);
const privateHome = wslPrivateCodexHome(location.distro);
diff --git a/src/supervisor/agents/codex/sessionFiles.ts b/src/supervisor/agents/codex/sessionFiles.ts
index 8b7dcaa74..3afc459f4 100644
--- a/src/supervisor/agents/codex/sessionFiles.ts
+++ b/src/supervisor/agents/codex/sessionFiles.ts
@@ -104,6 +104,6 @@ export function parseCodexRolloutMeta(
}
}
-export function codexAuthPath(): string {
- return join(homedir(), ".codex", "auth.json");
+export function codexAuthPath(codexHome = join(homedir(), ".codex")): string {
+ return join(codexHome, "auth.json");
}
diff --git a/src/supervisor/agents/copilot/copilot.test.ts b/src/supervisor/agents/copilot/copilot.test.ts
index 7610791d9..8daca51ab 100644
--- a/src/supervisor/agents/copilot/copilot.test.ts
+++ b/src/supervisor/agents/copilot/copilot.test.ts
@@ -1,18 +1,98 @@
import { existsSync, readFileSync } from "node:fs";
+import { homedir } from "node:os";
+import { join } from "node:path";
import { describe, expect, it } from "vitest";
-import type { McpServer } from "@/shared/contracts";
+import type { McpServer, ProjectLocation } from "@/shared/contracts";
import type { OscNotification, OscShellEvent } from "@/shared/osc";
import { buildCopilotArgs } from "./argv";
import { copilotDetectionSpec } from "./detection";
import { buildCopilotMcpLaunchConfig } from "./mcp";
import {
createCopilotAdapter,
+ createCopilotProfileAdapter,
detectCopilotInvalidSessionRef,
detectCopilotModelEffort,
detectCopilotStatusLineModel,
detectCopilotTerminalStatus,
} from "./index";
+describe("createCopilotProfileAdapter", () => {
+ const location: ProjectLocation = { kind: "posix", path: "/repo" };
+
+ it("routes terminal, ACP, one-shot, context, and skills through COPILOT_HOME", async () => {
+ const adapter = createCopilotProfileAdapter({
+ id: "work",
+ driver: "copilot",
+ displayName: "Work",
+ config: { homeDir: "/profiles/copilot" },
+ environment: {
+ GH_TOKEN: { value: "profile-token", sensitive: true },
+ COPILOT_HOME: { value: "/ignored" },
+ },
+ });
+
+ expect(adapter.kind).toBe("copilot:work");
+ expect(adapter.label).toBe("GitHub Copilot Work");
+ expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe("/profiles/copilot");
+ expect(adapter.buildLaunchArgv(location, { model: "gpt-5" }, "hello").env).toMatchObject({
+ COPILOT_HOME: "/profiles/copilot",
+ GH_TOKEN: "profile-token",
+ GITHUB_TOKEN: "",
+ GH_ENTERPRISE_TOKEN: "",
+ GITHUB_ENTERPRISE_TOKEN: "",
+ COPILOT_GITHUB_TOKEN: "",
+ COPILOT_API_TOKEN: "",
+ });
+ expect(adapter.buildOneShotCommand?.("gpt-5", undefined, "title", location)?.env).toMatchObject(
+ {
+ COPILOT_HOME: "/profiles/copilot",
+ },
+ );
+ expect(
+ adapter.buildContextExtractionCommand?.(
+ { providerSessionId: "session", discoveredAt: "test" },
+ location,
+ )?.env,
+ ).toMatchObject({ COPILOT_HOME: "/profiles/copilot" });
+
+ const nativeAuth = await adapter.buildAcpAuthCommand?.({ envKind: "posix" });
+ expect(nativeAuth?.env).toMatchObject({
+ COPILOT_HOME: "/profiles/copilot",
+ GH_TOKEN: "profile-token",
+ GITHUB_TOKEN: "",
+ GH_ENTERPRISE_TOKEN: "",
+ GITHUB_ENTERPRISE_TOKEN: "",
+ COPILOT_GITHUB_TOKEN: "",
+ COPILOT_API_TOKEN: "",
+ });
+
+ const wslAuth = await adapter.buildAcpAuthCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" });
+ const wslScript = wslAuth?.args.join(" ") ?? "";
+ expect(wslScript).toContain("export COPILOT_HOME='/profiles/copilot'");
+ expect(wslScript).toContain("export GH_TOKEN='profile-token'");
+ expect(wslScript).toContain("export GITHUB_TOKEN=''");
+ expect(wslScript).toContain("export GH_ENTERPRISE_TOKEN=''");
+ expect(wslScript).toContain("export GITHUB_ENTERPRISE_TOKEN=''");
+ expect(wslScript).toContain("export COPILOT_GITHUB_TOKEN=''");
+ expect(wslScript).toContain("export COPILOT_API_TOKEN=''");
+ expect(
+ createCopilotAdapter().buildOneShotCommand?.("gpt-5", undefined, "title", location)?.env,
+ ).toBeUndefined();
+ });
+
+ it("resolves a relative profile home against the target user home", () => {
+ const adapter = createCopilotProfileAdapter({
+ id: "relative",
+ driver: "copilot",
+ config: { homeDir: "profiles/copilot" },
+ });
+
+ expect(adapter.buildLaunchArgv(location, { model: "gpt-5" }, "hello").env?.COPILOT_HOME).toBe(
+ join(homedir(), "profiles/copilot"),
+ );
+ });
+});
+
describe("copilotDetectionSpec", () => {
it("uses Copilot CLI login for terminal authentication", () => {
expect(copilotDetectionSpec.loginCommand).toBe("copilot login");
diff --git a/src/supervisor/agents/copilot/detection.ts b/src/supervisor/agents/copilot/detection.ts
index 8f3a86415..2d35dd498 100644
--- a/src/supervisor/agents/copilot/detection.ts
+++ b/src/supervisor/agents/copilot/detection.ts
@@ -49,8 +49,9 @@ export function buildCopilotCommand(
location: ProjectLocation,
args: string[],
wslExecPath?: string,
+ env?: Record,
) {
- return buildAgentCommand(location, "copilot", args, wslExecPath);
+ return buildAgentCommand(location, "copilot", args, wslExecPath, env);
}
/**
@@ -75,13 +76,15 @@ async function probeCopilotModelEfforts(
location: ProjectLocation,
executablePath: string | undefined,
models: { id: string }[],
+ env?: Record,
): Promise<{ defaultEffort?: string; modelEfforts?: Record }> {
- const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath);
+ const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath, env);
const sessionCwd = getAgentProbeCwd(location);
const spawnCwd = resolveProbeSpawnCwd(location, spec.cwd);
const child = spawn(spec.command, spec.args, {
...(spawnCwd ? { cwd: spawnCwd } : {}),
stdio: ["pipe", "pipe", "pipe"],
+ ...(spec.env ? { env: { ...process.env, ...spec.env } } : {}),
shell: false,
windowsHide: true,
});
@@ -249,19 +252,21 @@ function withCopilotModelRates(
async function probeCapabilities(
location: ProjectLocation,
executablePath?: string,
+ env?: Record,
): Promise {
- const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath);
+ const spec = buildCopilotCommand(location, ["--acp", "--stdio"], executablePath, env);
const sessionCwd = getAgentProbeCwd(location);
const processCwd = resolveProbeSpawnCwd(location, spec.cwd);
const probe = await probeAcpCapabilities(spec.command, spec.args, sessionCwd, {
...(processCwd ? { processCwd } : {}),
+ ...(spec.env ? { env: spec.env } : {}),
timeoutMs: 15_000,
label: location.kind === "wsl" ? `copilot:wsl:${location.distro}` : `copilot:${location.kind}`,
});
const modelEffortProbe =
probe?.models?.length && executablePath !== undefined
- ? await probeCopilotModelEfforts(location, executablePath, probe.models)
+ ? await probeCopilotModelEfforts(location, executablePath, probe.models, env)
: {};
// Merge probe approval policies with defaults (probe labels take precedence,
@@ -310,6 +315,6 @@ export const copilotDetectionSpec: DetectionSpec = {
authProbes: [envVarAuthProbe(["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]), ghAuthProbe],
async capabilitiesProbe(ctx) {
if (!ctx.executablePath) return undefined;
- return probeCapabilities(ctx.location, ctx.executablePath);
+ return probeCapabilities(ctx.location, ctx.executablePath, ctx.probeEnv);
},
};
diff --git a/src/supervisor/agents/copilot/index.ts b/src/supervisor/agents/copilot/index.ts
index 7940cfce3..a9860e741 100644
--- a/src/supervisor/agents/copilot/index.ts
+++ b/src/supervisor/agents/copilot/index.ts
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
-import type { PromptSegment } from "@/shared/contracts";
+import type { AgentInstanceConfig, ProjectLocation, PromptSegment } from "@/shared/contracts";
+import { homeProfileKind, parseHomeProfileInstanceConfig } from "@/shared/contracts";
import { inlinePromptSegmentText } from "@/shared/promptContent";
import { createAcpStructuredSession } from "../acp";
import {
@@ -13,8 +14,10 @@ import {
type AgentAdapter,
type AgentEnvContext,
type CreateStructuredSessionInput,
+ type DetectionSpec,
} from "../base";
import { resolveAgentBinaryPath } from "../binaryResolver";
+import { withTerminalAuthEnv } from "../homeProfile";
import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase";
import { buildCopilotArgs } from "./argv";
import { buildCopilotCommand, copilotDefaultCapabilities, copilotDetectionSpec } from "./detection";
@@ -31,6 +34,11 @@ import {
READY_RE,
resolveModelId,
} from "./terminal";
+import {
+ copilotProfileEnvForLocation,
+ resolveCopilotHomeForLocation,
+ resolveCopilotInstanceEnv,
+} from "./profile";
export {
detectCopilotInvalidSessionRef,
@@ -48,20 +56,47 @@ warnIfPluginManifestMissing(
"resources/agent-plugins/copilot/ (packaged, staged by scripts/prepare-agent-plugins.mjs).",
);
-export function createCopilotAdapter(): AgentAdapter {
+interface CopilotAdapterOptions {
+ kind?: string;
+ label?: string;
+ homeDir?: string;
+ customEnv?: Record;
+}
+
+export function createCopilotProfileAdapter(instance: AgentInstanceConfig): AgentAdapter {
+ const config = parseHomeProfileInstanceConfig(instance.config);
+ const customEnv = resolveCopilotInstanceEnv(instance.environment);
+ return createCopilotAdapter({
+ kind: homeProfileKind("copilot", instance.id),
+ label: `GitHub Copilot ${instance.displayName ?? instance.id}`,
+ homeDir: config.homeDir,
+ ...(customEnv ? { customEnv } : {}),
+ });
+}
+
+export function createCopilotAdapter(options: CopilotAdapterOptions = {}): AgentAdapter {
let capabilities = copilotDefaultCapabilities;
+ const kind = options.kind ?? copilotDetectionSpec.kind;
+ const label = options.label ?? copilotDetectionSpec.label;
+ const profileEnv = (location: ProjectLocation) =>
+ copilotProfileEnvForLocation(options.homeDir, options.customEnv, location);
+ const profileHome = (ctx?: AgentEnvContext) =>
+ options.homeDir
+ ? resolveCopilotHomeForLocation(options.homeDir, detectProbeLocation(ctx))
+ : undefined;
return {
- kind: copilotDetectionSpec.kind,
- label: copilotDetectionSpec.label,
+ kind,
+ label,
binary: copilotDetectionSpec.binary,
skillSupport: {
roots: [
{
id: "copilot",
- label: copilotDetectionSpec.label,
+ label,
globalPath: ".copilot/skills",
projectPath: ".github/skills",
+ ...(options.homeDir ? { globalBasePath: options.homeDir } : {}),
globalOverride: { env: "COPILOT_HOME", path: "skills" },
},
{
@@ -90,7 +125,34 @@ export function createCopilotAdapter(): AgentAdapter {
},
spawnEnv: { wsl: { BROWSER: "/bin/true" } },
async detectInstall(ctx) {
- const status = await detectAgentInstall(ctx, copilotDetectionSpec);
+ const location = detectProbeLocation(ctx);
+ const env = profileEnv(location);
+ const spec: DetectionSpec = options.homeDir
+ ? {
+ ...copilotDetectionSpec,
+ kind,
+ label,
+ ...(env ? { probeEnv: env } : {}),
+ authProbes: [
+ async () =>
+ [
+ "COPILOT_GITHUB_TOKEN",
+ "COPILOT_API_TOKEN",
+ "GH_TOKEN",
+ "GITHUB_TOKEN",
+ "GH_ENTERPRISE_TOKEN",
+ "GITHUB_ENTERPRISE_TOKEN",
+ ].some((name) => env?.[name]?.trim())
+ ? "authenticated"
+ : undefined,
+ ],
+ }
+ : copilotDetectionSpec;
+ const status = withTerminalAuthEnv(await detectAgentInstall(ctx, spec), env, {
+ id: "copilot-terminal-login",
+ name: "Login",
+ type: "terminal",
+ });
capabilities = status.capabilities;
return status;
},
@@ -104,17 +166,23 @@ export function createCopilotAdapter(): AgentAdapter {
// L2 OSC parsing running for the working->idle edge.
partialL1: true,
async isPluginInstalled(ctx) {
- return isCopilotPluginInstalled(ctx);
+ return isCopilotPluginInstalled(ctx, profileHome(ctx));
},
async installPlugin(ctx) {
const node = await resolveInstallNodePath(ctx);
if (!node.ok) return node;
- const result = installCopilotPlugin(ctx, { resolvedNodePath: node.nodePath });
+ const home = profileHome(ctx);
+ const result = installCopilotPlugin(ctx, {
+ resolvedNodePath: node.nodePath,
+ ...(home ? { globalCopilotDirOverride: home } : {}),
+ });
if (!result.ok) return result;
return { ok: true, version: result.version };
},
async uninstallPlugin(ctx) {
- uninstallCopilotPlugin(ctx);
+ // Remove only this adapter's hook file. Staged assets are shared by the
+ // base adapter and every profile and are inert without that hook file.
+ uninstallCopilotPlugin(ctx, profileHome(ctx), false);
},
// No `pluginLaunchExtras` needed — Copilot CLI auto-loads
// `${COPILOT_HOME ?? ~/.copilot}/hooks/poracode-status.json` written at
@@ -122,10 +190,11 @@ export function createCopilotAdapter(): AgentAdapter {
buildLaunchArgv(location, config, prompt, _sessionRef, launchOptions) {
const sessionId = launchOptions?.resumeThreadId ?? randomUUID();
const mcp = writeCopilotMcpConfig(location, sessionId, launchOptions?.mcpServers ?? []);
+ const env = { ...(mcp?.env ?? {}), ...(profileEnv(location) ?? {}) };
return {
binary: "copilot",
args: buildCopilotArgs(config, prompt, sessionId, launchOptions, mcp?.argument),
- ...(mcp && Object.keys(mcp.env).length > 0 ? { env: mcp.env } : {}),
+ ...(Object.keys(env).length > 0 ? { env } : {}),
...(mcp ? { cleanup: mcp.cleanup } : {}),
sessionRef: createKnownSessionRef(sessionId),
};
@@ -133,10 +202,11 @@ export function createCopilotAdapter(): AgentAdapter {
buildResumeArgv(location, config, prompt, sessionRef, launchOptions) {
const sessionId = launchOptions?.resumeThreadId ?? sessionRef.providerSessionId;
const mcp = writeCopilotMcpConfig(location, sessionId, launchOptions?.mcpServers ?? []);
+ const env = { ...(mcp?.env ?? {}), ...(profileEnv(location) ?? {}) };
return {
binary: "copilot",
args: buildCopilotArgs(config, prompt, sessionId, launchOptions, mcp?.argument),
- ...(mcp && Object.keys(mcp.env).length > 0 ? { env: mcp.env } : {}),
+ ...(Object.keys(env).length > 0 ? { env } : {}),
...(mcp ? { cleanup: mcp.cleanup } : {}),
};
},
@@ -152,6 +222,7 @@ export function createCopilotAdapter(): AgentAdapter {
input.projectLocation,
args,
resolveAgentBinaryPath(input.projectLocation, "copilot"),
+ profileEnv(input.projectLocation),
);
return createAcpStructuredSession(command, input);
},
@@ -161,6 +232,7 @@ export function createCopilotAdapter(): AgentAdapter {
location,
["--acp", "--stdio"],
resolveAgentBinaryPath(location, "copilot"),
+ profileEnv(location),
);
},
createInitialSessionRef() {
@@ -201,7 +273,7 @@ export function createCopilotAdapter(): AgentAdapter {
},
syncConfigFromTerminalState: applyTerminalHintToConfig,
defaultOneShotModel: "",
- buildOneShotCommand(model, effort, prompt) {
+ buildOneShotCommand(model, effort, prompt, location) {
if (!prompt) {
return undefined;
}
@@ -214,9 +286,10 @@ export function createCopilotAdapter(): AgentAdapter {
args.push("--effort", effort);
}
- return { command: "copilot", args, stdin: "" };
+ const env = location ? profileEnv(location) : undefined;
+ return { command: "copilot", args, stdin: "", ...(env ? { env } : {}) };
},
- buildContextExtractionCommand(sessionRef, _location, model) {
+ buildContextExtractionCommand(sessionRef, location, model) {
// Copilot's -p flag takes the prompt inline as an arg.
// The orchestrator pipes the extraction prompt via stdin,
// so we pass a brief directive via -p and let stdin carry the full prompt.
@@ -230,7 +303,8 @@ export function createCopilotAdapter(): AgentAdapter {
if (model) {
args.push("--model", model);
}
- return { command: "copilot", args, stdin: "" };
+ const env = profileEnv(location);
+ return { command: "copilot", args, stdin: "", ...(env ? { env } : {}) };
},
};
}
diff --git a/src/supervisor/agents/copilot/plugin/install.ts b/src/supervisor/agents/copilot/plugin/install.ts
index 340096315..c62ccfd14 100644
--- a/src/supervisor/agents/copilot/plugin/install.ts
+++ b/src/supervisor/agents/copilot/plugin/install.ts
@@ -290,14 +290,17 @@ function installCopilotPluginWsl(
const COPILOT_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const;
-export function isCopilotPluginInstalled(ctx?: AgentEnvContext): {
+export function isCopilotPluginInstalled(
+ ctx?: AgentEnvContext,
+ globalCopilotDirOverride?: string,
+): {
installed: boolean;
version?: string;
} {
if (isWslPluginContext(ctx)) {
const wsl = getWslPluginBaseDirs(ctx.wslDistro, "copilot");
if (!wsl) return { installed: false };
- const copilotDir = wslGlobalCopilotDir(ctx.wslDistro);
+ const copilotDir = globalCopilotDirOverride ?? wslGlobalCopilotDir(ctx.wslDistro);
const hookFile = copilotDir
? toWslUncPath(ctx.wslDistro, `${copilotDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`)
: "";
@@ -306,20 +309,31 @@ export function isCopilotPluginInstalled(ctx?: AgentEnvContext): {
extraCheck: () => hookFile.length > 0 && existsSync(hookFile),
});
}
- const hookFile = join(nativeGlobalCopilotDir(), GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME);
+ const hookFile = join(
+ globalCopilotDirOverride ?? nativeGlobalCopilotDir(),
+ GLOBAL_HOOK_DIR_NAME,
+ GLOBAL_HOOK_FILENAME,
+ );
return verifyStagedPluginAt(getNativePluginBaseDir("copilot", ctx?.baseDir), "native", {
assets: COPILOT_VERIFY_ASSETS,
extraCheck: () => existsSync(hookFile),
});
}
-export function uninstallCopilotPlugin(ctx?: AgentEnvContext): void {
+export function uninstallCopilotPlugin(
+ ctx?: AgentEnvContext,
+ globalCopilotDirOverride?: string,
+ removeStaged = true,
+): void {
const hookDir = isWslPluginContext(ctx)
- ? toWslUncPath(ctx.wslDistro, `${wslGlobalCopilotDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`)
- : join(nativeGlobalCopilotDir(), GLOBAL_HOOK_DIR_NAME);
+ ? toWslUncPath(
+ ctx.wslDistro,
+ `${globalCopilotDirOverride ?? wslGlobalCopilotDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`,
+ )
+ : join(globalCopilotDirOverride ?? nativeGlobalCopilotDir(), GLOBAL_HOOK_DIR_NAME);
removeHookFile(join(hookDir, GLOBAL_HOOK_FILENAME));
removeHookFile(join(hookDir, LEGACY_GLOBAL_HOOK_FILENAME));
- removeStagedPluginDir("copilot", ctx);
+ if (removeStaged) removeStagedPluginDir("copilot", ctx);
}
function removeHookFile(path: string): void {
diff --git a/src/supervisor/agents/copilot/profile.ts b/src/supervisor/agents/copilot/profile.ts
new file mode 100644
index 000000000..eaab8708a
--- /dev/null
+++ b/src/supervisor/agents/copilot/profile.ts
@@ -0,0 +1,24 @@
+import type { ProjectLocation } from "@/shared/contracts";
+import {
+ homeProfileEnvForLocation,
+ resolveAgentInstanceEnv,
+ resolveHomeProfilePathForLocation,
+} from "../homeProfile";
+
+export const resolveCopilotHomeForLocation = resolveHomeProfilePathForLocation;
+export const resolveCopilotInstanceEnv = resolveAgentInstanceEnv;
+
+export function copilotProfileEnvForLocation(
+ homeDir: string | undefined,
+ customEnv: Record | undefined,
+ location: ProjectLocation,
+): Record | undefined {
+ return homeProfileEnvForLocation(homeDir, customEnv, location, "COPILOT_HOME", [
+ "GH_TOKEN",
+ "GITHUB_TOKEN",
+ "GH_ENTERPRISE_TOKEN",
+ "GITHUB_ENTERPRISE_TOKEN",
+ "COPILOT_GITHUB_TOKEN",
+ "COPILOT_API_TOKEN",
+ ]);
+}
diff --git a/src/supervisor/agents/gemini/detection.test.ts b/src/supervisor/agents/gemini/detection.test.ts
index ac1ce12f0..9112d1224 100644
--- a/src/supervisor/agents/gemini/detection.test.ts
+++ b/src/supervisor/agents/gemini/detection.test.ts
@@ -94,6 +94,21 @@ describe("geminiDetectionSpec", () => {
expect(result?.modelContextSizes).not.toHaveProperty("auto-gemini-3");
expect(result?.modelContextSizes).not.toHaveProperty("gemini-9-pro");
});
+
+ it("does not report a process-global API key for a home profile", async () => {
+ vi.stubEnv("GEMINI_API_KEY", "global-key");
+ try {
+ await expect(
+ geminiDetectionSpec.statusProbe?.({
+ location: { kind: "posix", path: "/repo" },
+ executablePath: "/usr/local/bin/gemini",
+ probeEnv: { GEMINI_CLI_HOME: "/missing/profile/home" },
+ }),
+ ).resolves.toBeUndefined();
+ } finally {
+ vi.unstubAllEnvs();
+ }
+ });
});
describe("parseGeminiGoogleAccountsJson", () => {
diff --git a/src/supervisor/agents/gemini/detection.ts b/src/supervisor/agents/gemini/detection.ts
index 79e851746..efbb1dd10 100644
--- a/src/supervisor/agents/gemini/detection.ts
+++ b/src/supervisor/agents/gemini/detection.ts
@@ -8,6 +8,7 @@ import {
batchWslCommandsAsync,
buildAgentCommand,
envVarAuthProbe,
+ quotePosixShellArg,
type AuthProbe,
type DetectionSpec,
} from "../base";
@@ -57,11 +58,13 @@ export const defaultGeminiCapabilities: AgentCapability = {
// Gemini stores a config dir at ~/.gemini after first login; treat its
// presence as authenticated even without GEMINI_API_KEY set.
const configDirAuthProbe: AuthProbe = async (ctx) => {
+ const configuredHome = ctx.probeEnv?.GEMINI_CLI_HOME?.trim();
if (ctx.location.kind !== "wsl") {
- return existsSync(join(homedir(), ".gemini")) ? "authenticated" : "unknown";
+ return existsSync(join(configuredHome || homedir(), ".gemini")) ? "authenticated" : "unknown";
}
+ const configDir = configuredHome ? `${configuredHome}/.gemini` : "~/.gemini";
const [result] = await batchWslCommandsAsync(ctx.location.distro, [
- "test -d ~/.gemini && echo yes",
+ `test -d ${configuredHome ? quotePosixShellArg(configDir) : configDir} && echo yes`,
]);
return result?.ok && result.stdout.trim() === "yes" ? "authenticated" : "unknown";
};
@@ -84,13 +87,22 @@ export function parseGeminiGoogleAccountsJson(raw: string): string | undefined {
}
async function probeGeminiMetadata(ctx: Parameters>[0]) {
+ const configuredHome = ctx.probeEnv?.GEMINI_CLI_HOME?.trim();
+ const configuredApiKey =
+ ctx.probeEnv?.GEMINI_API_KEY?.trim() || ctx.probeEnv?.GOOGLE_API_KEY?.trim();
if (ctx.location.kind === "wsl") {
+ const configDir = configuredHome ? `${configuredHome}/.gemini` : "~/.gemini";
+ const accountsPath = `${configDir}/google_accounts.json`;
const [apiKeyResult, configDirResult, accountsResult] = await batchWslCommandsAsync(
ctx.location.distro,
[
- 'printf %s "$GEMINI_API_KEY"',
- "test -d ~/.gemini && echo yes",
- 'cat ~/.gemini/google_accounts.json 2>/dev/null || printf ""',
+ configuredApiKey
+ ? `printf %s ${quotePosixShellArg(configuredApiKey)}`
+ : ctx.probeEnv
+ ? 'printf ""'
+ : 'printf %s "$GEMINI_API_KEY"',
+ `test -d ${configuredHome ? quotePosixShellArg(configDir) : configDir} && echo yes`,
+ `cat ${configuredHome ? quotePosixShellArg(accountsPath) : accountsPath} 2>/dev/null || printf ""`,
],
);
const apiKeySet = !!(apiKeyResult?.ok && apiKeyResult.stdout.trim().length > 0);
@@ -107,9 +119,11 @@ async function probeGeminiMetadata(ctx: Parameters 0;
- const accountsPath = join(homedir(), ".gemini", "google_accounts.json");
+ const apiKeySet = ctx.probeEnv
+ ? Boolean(configuredApiKey)
+ : Boolean(process.env.GEMINI_API_KEY?.trim());
+ const home = configuredHome || homedir();
+ const accountsPath = join(home, ".gemini", "google_accounts.json");
let activeAccount: string | undefined;
if (!apiKeySet) {
try {
@@ -118,7 +132,7 @@ async function probeGeminiMetadata(ctx: Parameters {
+ const location: ProjectLocation = { kind: "posix", path: "/repo" };
+
+ it("routes terminal, ACP, one-shot, context, and skills through GEMINI_CLI_HOME", async () => {
+ const adapter = createGeminiProfileAdapter({
+ id: "work",
+ driver: "gemini",
+ displayName: "Work",
+ config: { homeDir: "/profiles/gemini" },
+ environment: {
+ GEMINI_API_KEY: { value: "profile-key", sensitive: true },
+ GOOGLE_CLOUD_PROJECT: { value: "profile-project" },
+ GEMINI_CLI_HOME: { value: "/ignored" },
+ },
+ });
+
+ expect(adapter.kind).toBe("gemini:work");
+ expect(adapter.label).toBe("Gemini Work");
+ expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe("/profiles/gemini");
+ expect(
+ adapter.buildLaunchArgv(location, { model: "gemini-2.5-pro" }, "hello").env,
+ ).toMatchObject({
+ GEMINI_CLI_HOME: "/profiles/gemini",
+ GEMINI_API_KEY: "profile-key",
+ GOOGLE_API_KEY: "",
+ GOOGLE_APPLICATION_CREDENTIALS: "",
+ GOOGLE_CLOUD_PROJECT: "profile-project",
+ GOOGLE_CLOUD_LOCATION: "",
+ GOOGLE_GENAI_USE_VERTEXAI: "",
+ GOOGLE_GENAI_USE_GCA: "",
+ });
+ expect(
+ adapter.buildOneShotCommand?.("gemini-2.5-flash", undefined, "title", location)?.env,
+ ).toMatchObject({ GEMINI_CLI_HOME: "/profiles/gemini" });
+ expect(
+ adapter.buildContextExtractionCommand?.(
+ { providerSessionId: "session", discoveredAt: "test" },
+ location,
+ )?.env,
+ ).toMatchObject({ GEMINI_CLI_HOME: "/profiles/gemini" });
+
+ const nativeAuth = await adapter.buildAcpAuthCommand?.({ envKind: "posix" });
+ expect(nativeAuth?.env).toMatchObject({
+ GEMINI_CLI_HOME: "/profiles/gemini",
+ GEMINI_API_KEY: "profile-key",
+ GOOGLE_API_KEY: "",
+ GOOGLE_APPLICATION_CREDENTIALS: "",
+ GOOGLE_CLOUD_PROJECT: "profile-project",
+ GOOGLE_CLOUD_LOCATION: "",
+ GOOGLE_GENAI_USE_VERTEXAI: "",
+ GOOGLE_GENAI_USE_GCA: "",
+ });
+
+ const wslAuth = await adapter.buildAcpAuthCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" });
+ const wslScript = wslAuth?.args.join(" ") ?? "";
+ expect(wslScript).toContain("export GEMINI_CLI_HOME='/profiles/gemini'");
+ expect(wslScript).toContain("export GEMINI_API_KEY='profile-key'");
+ expect(wslScript).toContain("export GOOGLE_API_KEY=''");
+ expect(wslScript).toContain("export GOOGLE_APPLICATION_CREDENTIALS=''");
+ expect(wslScript).toContain("export GOOGLE_CLOUD_PROJECT='profile-project'");
+ expect(wslScript).toContain("export GOOGLE_CLOUD_LOCATION=''");
+ expect(wslScript).toContain("export GOOGLE_GENAI_USE_VERTEXAI=''");
+ expect(wslScript).toContain("export GOOGLE_GENAI_USE_GCA=''");
+ expect(
+ createGeminiAdapter().buildOneShotCommand?.("gemini-2.5-flash", undefined, "title", location)
+ ?.env,
+ ).toBeUndefined();
+ expect(adapter.uninstallPlugin).toBeUndefined();
+ expect(createGeminiAdapter().uninstallPlugin).toBeTypeOf("function");
+ });
+
+ it("resolves a relative profile home against the target user home", () => {
+ const adapter = createGeminiProfileAdapter({
+ id: "relative",
+ driver: "gemini",
+ config: { homeDir: "profiles/gemini" },
+ });
+
+ expect(
+ adapter.buildLaunchArgv(location, { model: "gemini-2.5-pro" }, "hello").env?.GEMINI_CLI_HOME,
+ ).toBe(join(homedir(), "profiles/gemini"));
+ });
+});
+
describe("detectGeminiOscTitleStatus", () => {
it("detects idle from ◇ Ready title bar indicator", () => {
const text = "◇ Ready (my-project)";
diff --git a/src/supervisor/agents/gemini/index.ts b/src/supervisor/agents/gemini/index.ts
index c203be6f0..07537497a 100644
--- a/src/supervisor/agents/gemini/index.ts
+++ b/src/supervisor/agents/gemini/index.ts
@@ -1,6 +1,12 @@
import { randomUUID } from "node:crypto";
-import type { AgentCapability, ProjectLocation, PromptSegment } from "@/shared/contracts";
+import type {
+ AgentCapability,
+ AgentInstanceConfig,
+ ProjectLocation,
+ PromptSegment,
+} from "@/shared/contracts";
+import { homeProfileKind, parseHomeProfileInstanceConfig } from "@/shared/contracts";
import { inlinePromptSegmentText } from "@/shared/promptContent";
import { EXTRACTION_PROMPT } from "@/supervisor/contextExtractor";
import { createAcpStructuredSession } from "../acp";
@@ -14,6 +20,7 @@ import {
type AgentEnvContext,
type AgentLaunchOptions,
type CreateStructuredSessionInput,
+ type DetectionSpec,
type TerminalStatusHint,
} from "../base";
import { resolveAgentBinaryPath } from "../binaryResolver";
@@ -31,6 +38,7 @@ import {
} from "./plugin/install";
import { detectGeminiInvalidSessionRef } from "./session";
import { detectGeminiOscTitleStatus } from "./terminal";
+import { geminiProfileEnvForLocation, resolveGeminiInstanceEnv } from "./profile";
export { detectGeminiInvalidSessionRef } from "./session";
@@ -94,20 +102,43 @@ function prepareGeminiLaunchMcpSettings(
};
}
-export function createGeminiAdapter(): AgentAdapter {
+interface GeminiAdapterOptions {
+ kind?: string;
+ label?: string;
+ homeDir?: string;
+ customEnv?: Record;
+}
+
+export function createGeminiProfileAdapter(instance: AgentInstanceConfig): AgentAdapter {
+ const config = parseHomeProfileInstanceConfig(instance.config);
+ const customEnv = resolveGeminiInstanceEnv(instance.environment);
+ return createGeminiAdapter({
+ kind: homeProfileKind("gemini", instance.id),
+ label: `Gemini ${instance.displayName ?? instance.id}`,
+ homeDir: config.homeDir,
+ ...(customEnv ? { customEnv } : {}),
+ });
+}
+
+export function createGeminiAdapter(options: GeminiAdapterOptions = {}): AgentAdapter {
let capabilities: AgentCapability = defaultGeminiCapabilities;
+ const kind = options.kind ?? geminiDetectionSpec.kind;
+ const label = options.label ?? geminiDetectionSpec.label;
+ const profileEnv = (location: ProjectLocation) =>
+ geminiProfileEnvForLocation(options.homeDir, options.customEnv, location);
return {
- kind: geminiDetectionSpec.kind,
- label: geminiDetectionSpec.label,
+ kind,
+ label,
binary: geminiDetectionSpec.binary,
skillSupport: {
roots: [
{
id: "gemini",
- label: geminiDetectionSpec.label,
+ label,
globalPath: ".gemini/skills",
projectPath: ".gemini/skills",
+ ...(options.homeDir ? { globalBasePath: options.homeDir } : {}),
globalOverride: { env: "GEMINI_CLI_HOME", path: ".gemini/skills" },
},
],
@@ -150,17 +181,47 @@ export function createGeminiAdapter(): AgentAdapter {
if (!result.ok) return result;
return { ok: true, version: result.version };
},
- async uninstallPlugin(ctx) {
- uninstallGeminiPlugin(ctx);
- },
+ ...(options.homeDir
+ ? {}
+ : {
+ async uninstallPlugin(ctx: AgentEnvContext) {
+ // Gemini's staged system settings are shared by every profile.
+ uninstallGeminiPlugin(ctx);
+ },
+ }),
async detectInstall(ctx) {
- const status = await detectAgentInstall(ctx, geminiDetectionSpec);
+ const location = detectProbeLocation(ctx);
+ const env = profileEnv(location);
+ const spec: DetectionSpec = options.homeDir
+ ? {
+ ...geminiDetectionSpec,
+ kind,
+ label,
+ ...(env ? { probeEnv: env } : {}),
+ authProbes: [
+ async () =>
+ ["GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS"].some(
+ (name) => env?.[name]?.trim(),
+ )
+ ? "authenticated"
+ : undefined,
+ ...(geminiDetectionSpec.authProbes ?? [])
+ .slice(1)
+ .map(
+ (probe) => (probeCtx: Parameters[0]) =>
+ probe({ ...probeCtx, ...(env ? { probeEnv: env } : {}) }),
+ ),
+ ],
+ }
+ : geminiDetectionSpec;
+ const status = await detectAgentInstall(ctx, spec);
capabilities = status.capabilities;
return status;
},
buildLaunchArgv(location, config, prompt, _sessionRef, launchOptions) {
const launchSettings = prepareGeminiLaunchMcpSettings(location, launchOptions);
+ const env = { ...(launchSettings?.env ?? {}), ...(profileEnv(location) ?? {}) };
// Pre-assign the session UUID via --session-id so we know it before
// spawn. Avoids racing post-spawn discovery against one-shot `gemini -p`
// calls (title gen, commit-msg, PR summary) that also create entries in
@@ -170,18 +231,21 @@ export function createGeminiAdapter(): AgentAdapter {
return {
binary: "gemini",
args,
- ...(launchSettings ? { env: launchSettings.env, cleanup: launchSettings.cleanup } : {}),
+ ...(Object.keys(env).length > 0 ? { env } : {}),
+ ...(launchSettings ? { cleanup: launchSettings.cleanup } : {}),
sessionRef: createKnownSessionRef(assignedId),
};
},
buildResumeArgv(location, config, prompt, sessionRef, launchOptions) {
const launchSettings = prepareGeminiLaunchMcpSettings(location, launchOptions);
+ const env = { ...(launchSettings?.env ?? {}), ...(profileEnv(location) ?? {}) };
const args = buildGeminiArgs(config, prompt, sessionRef.providerSessionId);
return {
binary: "gemini",
args,
- ...(launchSettings ? { env: launchSettings.env, cleanup: launchSettings.cleanup } : {}),
+ ...(Object.keys(env).length > 0 ? { env } : {}),
+ ...(launchSettings ? { cleanup: launchSettings.cleanup } : {}),
};
},
@@ -191,7 +255,10 @@ export function createGeminiAdapter(): AgentAdapter {
"gemini",
["--acp", "--skip-trust"],
resolveAgentBinaryPath(input.projectLocation, "gemini"),
- input.projectLocation.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : undefined,
+ {
+ ...(input.projectLocation.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : {}),
+ ...(profileEnv(input.projectLocation) ?? {}),
+ },
);
return createAcpStructuredSession(command, input);
},
@@ -202,7 +269,10 @@ export function createGeminiAdapter(): AgentAdapter {
"gemini",
["--acp", "--skip-trust"],
resolveAgentBinaryPath(location, "gemini"),
- location.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : undefined,
+ {
+ ...(location.kind === "windows" ? { GEMINI_PTY_INFO: "child_process" } : {}),
+ ...(profileEnv(location) ?? {}),
+ },
);
},
@@ -234,11 +304,18 @@ export function createGeminiAdapter(): AgentAdapter {
defaultOneShotModel: "gemini-2.5-flash",
- buildOneShotCommand(model, _effort, prompt) {
+ buildOneShotCommand(model, _effort, prompt, location) {
if (!prompt) return undefined;
- return { command: "gemini", args: ["-p", prompt, "--model", model], stdin: "" };
+ const env = location ? profileEnv(location) : undefined;
+ return {
+ command: "gemini",
+ args: ["-p", prompt, "--model", model],
+ stdin: "",
+ ...(env ? { env } : {}),
+ };
},
- buildContextExtractionCommand(sessionRef, _location, model) {
+ buildContextExtractionCommand(sessionRef, location, model) {
+ const env = profileEnv(location);
return {
command: "gemini",
args: [
@@ -250,6 +327,7 @@ export function createGeminiAdapter(): AgentAdapter {
model ?? "gemini-2.5-flash",
],
stdin: "",
+ ...(env ? { env } : {}),
};
},
};
diff --git a/src/supervisor/agents/gemini/plugin/install.test.ts b/src/supervisor/agents/gemini/plugin/install.test.ts
index eff83957c..1c7dc1f86 100644
--- a/src/supervisor/agents/gemini/plugin/install.test.ts
+++ b/src/supervisor/agents/gemini/plugin/install.test.ts
@@ -17,6 +17,7 @@ import {
syncGeminiAppControlsMcpSettings,
syncGeminiSubagentMcpSettings,
} from "./install";
+import { createGeminiAdapter, createGeminiProfileAdapter } from "../index";
const tempDirs: string[] = [];
let savedBrowserMcpEnv: { url?: string; token?: string };
@@ -151,6 +152,23 @@ describe("installGeminiPlugin", () => {
: /^(?!cmd\.exe)/,
);
});
+
+ it("keeps shared staged assets when a profile is uninstalled", async () => {
+ const baseDir = makeBaseDir();
+ const ctx = { envKind: "posix" as const, baseDir };
+ expect(installGeminiPlugin(ctx).ok).toBe(true);
+
+ const profile = createGeminiProfileAdapter({
+ id: "work",
+ driver: "gemini",
+ config: { homeDir: "/profiles/gemini" },
+ });
+ await profile.uninstallPlugin?.(ctx);
+ expect(isGeminiPluginInstalled(ctx).installed).toBe(true);
+
+ await createGeminiAdapter().uninstallPlugin?.(ctx);
+ expect(isGeminiPluginInstalled(ctx).installed).toBe(false);
+ });
});
const subagentCfg: SubagentMcpHttpConfig = {
diff --git a/src/supervisor/agents/gemini/plugin/install.ts b/src/supervisor/agents/gemini/plugin/install.ts
index adb610fe4..632fc3276 100644
--- a/src/supervisor/agents/gemini/plugin/install.ts
+++ b/src/supervisor/agents/gemini/plugin/install.ts
@@ -620,8 +620,8 @@ export function isGeminiPluginInstalled(ctx?: AgentEnvContext): {
return verifyGeminiInstallAt(getNativePluginBaseDir("gemini", ctx?.baseDir), "native");
}
-export function uninstallGeminiPlugin(ctx?: AgentEnvContext): void {
- removeStagedPluginDir("gemini", ctx);
+export function uninstallGeminiPlugin(ctx?: AgentEnvContext, removeStaged = true): void {
+ if (removeStaged) removeStagedPluginDir("gemini", ctx);
}
function verifyGeminiInstallAt(
diff --git a/src/supervisor/agents/gemini/profile.ts b/src/supervisor/agents/gemini/profile.ts
new file mode 100644
index 000000000..eec1cbdbd
--- /dev/null
+++ b/src/supervisor/agents/gemini/profile.ts
@@ -0,0 +1,20 @@
+import type { ProjectLocation } from "@/shared/contracts";
+import { homeProfileEnvForLocation, resolveAgentInstanceEnv } from "../homeProfile";
+
+export const resolveGeminiInstanceEnv = resolveAgentInstanceEnv;
+
+export function geminiProfileEnvForLocation(
+ homeDir: string | undefined,
+ customEnv: Record | undefined,
+ location: ProjectLocation,
+): Record | undefined {
+ return homeProfileEnvForLocation(homeDir, customEnv, location, "GEMINI_CLI_HOME", [
+ "GEMINI_API_KEY",
+ "GOOGLE_API_KEY",
+ "GOOGLE_APPLICATION_CREDENTIALS",
+ "GOOGLE_CLOUD_PROJECT",
+ "GOOGLE_CLOUD_LOCATION",
+ "GOOGLE_GENAI_USE_VERTEXAI",
+ "GOOGLE_GENAI_USE_GCA",
+ ]);
+}
diff --git a/src/supervisor/agents/grok/detection.ts b/src/supervisor/agents/grok/detection.ts
index 996ca7914..3c9f90afd 100644
--- a/src/supervisor/agents/grok/detection.ts
+++ b/src/supervisor/agents/grok/detection.ts
@@ -12,6 +12,7 @@ import {
batchWslCommandsAsync,
buildAgentCommand,
envVarAuthProbe,
+ quotePosixShellArg,
type CapabilitiesProbeResult,
type DetectionSpec,
} from "../base";
@@ -56,19 +57,26 @@ export const grokDefaultCapabilities: AgentCapability = {
settingDefs: [],
};
-export function buildGrokCommand(location: ProjectLocation, args: string[], wslExecPath?: string) {
- return buildAgentCommand(location, "grok", args, wslExecPath);
+export function buildGrokCommand(
+ location: ProjectLocation,
+ args: string[],
+ wslExecPath?: string,
+ env?: Record,
+) {
+ return buildAgentCommand(location, "grok", args, wslExecPath, env);
}
async function probeCapabilities(
location: ProjectLocation,
executablePath?: string,
+ env?: Record,
): Promise {
- const spec = buildGrokCommand(location, ["agent", "stdio"], executablePath);
+ const spec = buildGrokCommand(location, ["agent", "stdio"], executablePath, env);
const sessionCwd = getAgentProbeCwd(location);
const processCwd = resolveProbeSpawnCwd(location, spec.cwd);
const probe = await probeAcpCapabilities(spec.command, spec.args, sessionCwd, {
...(processCwd ? { processCwd } : {}),
+ ...(spec.env ? { env: spec.env } : {}),
timeoutMs: 20_000, // grok may take a moment on first init
label: location.kind === "wsl" ? `grok:wsl:${location.distro}` : `grok:${location.kind}`,
// Grok returns identity (email, auth_mode, subscription_tier) in the
@@ -215,15 +223,19 @@ function formatGrokAuthMode(mode: string): string {
async function grokAuthFileProbe(
ctx: Parameters>[0],
): Promise<"authenticated" | "unknown"> {
- const check = (home: string) => {
- if (existsSync(join(home, ".grok", "auth.json"))) return "authenticated";
+ const configuredHome = ctx.probeEnv?.GROK_HOME?.trim();
+ const check = (home: string, isGrokHome = false) => {
+ if (existsSync(join(home, ...(isGrokHome ? [] : [".grok"]), "auth.json"))) {
+ return "authenticated";
+ }
return "unknown";
};
if (ctx.location.kind !== "wsl") {
- return check(homedir());
+ return check(configuredHome || homedir(), Boolean(configuredHome));
}
+ const authPath = configuredHome ? `${configuredHome}/auth.json` : "~/.grok/auth.json";
const [r] = await batchWslCommandsAsync(ctx.location.distro, [
- "test -f ~/.grok/auth.json && echo yes || echo no",
+ `test -f ${configuredHome ? quotePosixShellArg(authPath) : authPath} && echo yes || echo no`,
]);
return r?.ok && r.stdout.trim() === "yes" ? "authenticated" : "unknown";
}
@@ -249,6 +261,6 @@ export const grokDetectionSpec: DetectionSpec = {
authProbes: [envVarAuthProbe(["GROK_API_KEY", "XAI_API_KEY"]), grokAuthFileProbe],
async capabilitiesProbe(ctx) {
if (!ctx.executablePath) return undefined;
- return probeCapabilities(ctx.location, ctx.executablePath);
+ return probeCapabilities(ctx.location, ctx.executablePath, ctx.probeEnv);
},
};
diff --git a/src/supervisor/agents/grok/grok.test.ts b/src/supervisor/agents/grok/grok.test.ts
index 85b6203eb..a4ef98e46 100644
--- a/src/supervisor/agents/grok/grok.test.ts
+++ b/src/supervisor/agents/grok/grok.test.ts
@@ -1,12 +1,71 @@
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
-import { tmpdir } from "node:os";
+import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { McpServer, ProjectLocation, ThreadConfig } from "@/shared/contracts";
import type { OscNotification, OscTitle } from "@/shared/osc";
import { createKnownSessionRef } from "../base";
import { grokDetectionSpec } from "./detection";
-import { createGrokAdapter } from "./index";
+import { createGrokAdapter, createGrokProfileAdapter } from "./index";
+
+describe("createGrokProfileAdapter", () => {
+ const location: ProjectLocation = { kind: "posix", path: "/repo" };
+
+ it("routes terminal, ACP, logout, one-shot, sessions, and skills through GROK_HOME", async () => {
+ const adapter = createGrokProfileAdapter({
+ id: "work",
+ driver: "grok",
+ displayName: "Work",
+ config: { homeDir: "/profiles/grok" },
+ environment: {
+ XAI_API_KEY: { value: "profile-key", sensitive: true },
+ GROK_HOME: { value: "/ignored" },
+ },
+ });
+
+ expect(adapter.kind).toBe("grok:work");
+ expect(adapter.label).toBe("Grok Build Work");
+ expect(adapter.skillSupport?.roots[0]?.globalBasePath).toBe("/profiles/grok");
+ expect(adapter.buildLaunchArgv(location, { model: "grok-4.5" }, "hello").env).toMatchObject({
+ GROK_HOME: "/profiles/grok",
+ GROK_API_KEY: "",
+ XAI_API_KEY: "profile-key",
+ });
+ expect(
+ adapter.buildOneShotCommand?.("grok-4.5", undefined, "title", location)?.env,
+ ).toMatchObject({ GROK_HOME: "/profiles/grok" });
+
+ const nativeAuth = await adapter.buildAcpAuthCommand?.({ envKind: "posix" });
+ expect(nativeAuth?.env).toMatchObject({
+ GROK_HOME: "/profiles/grok",
+ GROK_API_KEY: "",
+ XAI_API_KEY: "profile-key",
+ });
+
+ const auth = await adapter.buildAcpAuthCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" });
+ const logout = await adapter.buildAcpLogoutCommand?.({ envKind: "wsl", wslDistro: "Ubuntu" });
+ const authScript = auth?.args.join(" ") ?? "";
+ expect(authScript).toContain("export GROK_HOME='/profiles/grok'");
+ expect(authScript).toContain("export GROK_API_KEY=''");
+ expect(authScript).toContain("export XAI_API_KEY='profile-key'");
+ expect(logout?.args.join(" ")).toContain("GROK_HOME='/profiles/grok'");
+ expect(
+ createGrokAdapter().buildOneShotCommand?.("grok-4.5", undefined, "title", location)?.env,
+ ).toBeUndefined();
+ });
+
+ it("resolves a relative profile home against the target user home", () => {
+ const adapter = createGrokProfileAdapter({
+ id: "relative",
+ driver: "grok",
+ config: { homeDir: "profiles/grok" },
+ });
+
+ expect(adapter.buildLaunchArgv(location, { model: "grok-4.5" }, "hello").env?.GROK_HOME).toBe(
+ join(homedir(), "profiles/grok"),
+ );
+ });
+});
function oscTitle(text: string, code: 0 | 1 | 2 = 0): OscTitle {
return { code, text };
diff --git a/src/supervisor/agents/grok/index.ts b/src/supervisor/agents/grok/index.ts
index 15293e27c..098c4765e 100644
--- a/src/supervisor/agents/grok/index.ts
+++ b/src/supervisor/agents/grok/index.ts
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
-import type { PromptSegment } from "@/shared/contracts";
+import type { AgentInstanceConfig, ProjectLocation, PromptSegment } from "@/shared/contracts";
+import { homeProfileKind, parseHomeProfileInstanceConfig } from "@/shared/contracts";
import { inlinePromptSegmentText } from "@/shared/promptContent";
import { createAcpStructuredSession } from "../acp";
import {
@@ -11,8 +12,10 @@ import {
type AgentAdapter,
type AgentEnvContext,
type CreateStructuredSessionInput,
+ type DetectionSpec,
} from "../base";
import { resolveAgentBinaryPath } from "../binaryResolver";
+import { withTerminalAuthEnv } from "../homeProfile";
import { resolveInstallNodePath, warnIfPluginManifestMissing } from "../plugin/installerBase";
import { buildGrokAcpArgs, buildGrokArgs } from "./argv";
import { buildGrokCommand, grokDefaultCapabilities, grokDetectionSpec } from "./detection";
@@ -23,11 +26,17 @@ import {
uninstallGrokPlugin,
} from "./plugin/install";
import {
+ createGrokSessionTracker,
makeGrokDiscoverSessionRef,
makeGrokWatchSessionRef,
resolveGrokSessionArg,
snapshotGrokPreSpawnSessions,
} from "./sessionFiles";
+import {
+ grokProfileEnvForLocation,
+ resolveGrokHomeForLocation,
+ resolveGrokInstanceEnv,
+} from "./profile";
const GROK_PLUGIN_VERSION = readBundledGrokPluginVersion();
@@ -39,20 +48,51 @@ warnIfPluginManifestMissing("grok", GROK_PLUGIN_VERSION);
// Modes/permissions: https://docs.x.ai/build/modes-and-commands
// Enterprise auth: https://docs.x.ai/build/enterprise
-export function createGrokAdapter(): AgentAdapter {
+interface GrokAdapterOptions {
+ kind?: string;
+ label?: string;
+ homeDir?: string;
+ customEnv?: Record;
+}
+
+export function createGrokProfileAdapter(instance: AgentInstanceConfig): AgentAdapter {
+ const config = parseHomeProfileInstanceConfig(instance.config);
+ const customEnv = resolveGrokInstanceEnv(instance.environment);
+ return createGrokAdapter({
+ kind: homeProfileKind("grok", instance.id),
+ label: `Grok Build ${instance.displayName ?? instance.id}`,
+ homeDir: config.homeDir,
+ ...(customEnv ? { customEnv } : {}),
+ });
+}
+
+export function createGrokAdapter(options: GrokAdapterOptions = {}): AgentAdapter {
let capabilities = grokDefaultCapabilities;
+ const kind = options.kind ?? grokDetectionSpec.kind;
+ const label = options.label ?? grokDetectionSpec.label;
+ const profileEnv = (location: ProjectLocation) =>
+ grokProfileEnvForLocation(options.homeDir, options.customEnv, location);
+ const profileHome = (ctx?: AgentEnvContext) =>
+ options.homeDir
+ ? resolveGrokHomeForLocation(options.homeDir, detectProbeLocation(ctx))
+ : undefined;
+ const sessionHome = options.homeDir
+ ? (location: ProjectLocation) => resolveGrokHomeForLocation(options.homeDir!, location)
+ : undefined;
+ const sessionTracker = createGrokSessionTracker();
return {
- kind: grokDetectionSpec.kind,
- label: grokDetectionSpec.label,
+ kind,
+ label,
binary: grokDetectionSpec.binary,
skillSupport: {
roots: [
{
id: "grok",
- label: grokDetectionSpec.label,
+ label,
globalPath: ".grok/skills",
projectPath: ".grok/skills",
+ ...(options.homeDir ? { globalBasePath: options.homeDir } : {}),
globalOverride: { env: "GROK_HOME", path: "skills" },
},
{
@@ -73,7 +113,7 @@ export function createGrokAdapter(): AgentAdapter {
projectionRoots: [
{
id: "grok",
- label: grokDetectionSpec.label,
+ label,
projectPath: ".grok/skills",
},
],
@@ -106,17 +146,23 @@ export function createGrokAdapter(): AgentAdapter {
return true;
},
async isPluginInstalled(ctx) {
- return isGrokPluginInstalled(ctx);
+ return isGrokPluginInstalled(ctx, profileHome(ctx));
},
async installPlugin(ctx) {
const node = await resolveInstallNodePath(ctx);
if (!node.ok) return node;
- const result = installGrokPlugin(ctx, { resolvedNodePath: node.nodePath });
+ const home = profileHome(ctx);
+ const result = installGrokPlugin(ctx, {
+ resolvedNodePath: node.nodePath,
+ ...(home ? { globalGrokDirOverride: home } : {}),
+ });
if (!result.ok) return result;
return { ok: true, version: result.version };
},
async uninstallPlugin(ctx) {
- uninstallGrokPlugin(ctx);
+ // Remove only this adapter's hook file. Staged assets are shared by the
+ // base adapter and every profile and are inert without that hook file.
+ uninstallGrokPlugin(ctx, profileHome(ctx), false);
},
// No `pluginLaunchExtras` env/args needed — Grok auto-loads
// `~/.grok/hooks/poracode-status.json` written at install time, and
@@ -126,17 +172,44 @@ export function createGrokAdapter(): AgentAdapter {
},
async detectInstall(ctx) {
- const status = await detectAgentInstall(ctx, grokDetectionSpec);
+ const location = detectProbeLocation(ctx);
+ const env = profileEnv(location);
+ const spec: DetectionSpec = options.homeDir
+ ? {
+ ...grokDetectionSpec,
+ kind,
+ label,
+ ...(env ? { probeEnv: env } : {}),
+ authProbes: [
+ async () =>
+ ["GROK_API_KEY", "XAI_API_KEY"].some((name) => env?.[name]?.trim())
+ ? "authenticated"
+ : undefined,
+ ...(grokDetectionSpec.authProbes ?? [])
+ .slice(1)
+ .map(
+ (probe) => (probeCtx: Parameters[0]) =>
+ probe({ ...probeCtx, ...(env ? { probeEnv: env } : {}) }),
+ ),
+ ],
+ }
+ : grokDetectionSpec;
+ const status = withTerminalAuthEnv(await detectAgentInstall(ctx, spec), env, {
+ id: "grok-terminal-login",
+ name: "Login",
+ type: "terminal",
+ });
capabilities = status.capabilities;
return status;
},
buildLaunchArgv(location, config, prompt, sessionRef, _launchOptions) {
const cwd = location.kind === "wsl" ? location.linuxPath : location.path;
+ const home = sessionHome?.(location);
// Snapshot existing session dirs so discoverSessionRef can identify the
// new UUID Grok creates if the pre-assigned `-s` id is ever ignored and
// the PTY ends up creating its own session.
- snapshotGrokPreSpawnSessions(location, cwd);
+ snapshotGrokPreSpawnSessions(location, cwd, home, sessionTracker);
// Resolve the session ID before spawning the PTY: resume a known one
// with `-r`, otherwise pre-assign a fresh UUID with `-s` (grok 0.2.93,
@@ -147,29 +220,33 @@ export function createGrokAdapter(): AgentAdapter {
const known = sessionRef?.providerSessionId;
const sessionId = known ?? randomUUID();
const sessionArg = known
- ? resolveGrokSessionArg(location, cwd, known)
+ ? resolveGrokSessionArg(location, cwd, known, home)
: ({ kind: "new", sessionId } as const);
const args = buildGrokArgs(config, prompt, sessionArg);
+ const env = profileEnv(location);
// Returning the id as sessionRef lets the runtime skip post-spawn
// discovery on the happy path (mirrors gemini/cursor).
// discoverSessionRef stays wired up as the fallback.
return {
binary: "grok",
args,
+ ...(env ? { env } : {}),
sessionRef: createKnownSessionRef(sessionId),
};
},
buildResumeArgv(location, config, prompt, sessionRef) {
const cwd = location.kind === "wsl" ? location.linuxPath : location.path;
+ const home = sessionHome?.(location);
const known = sessionRef?.providerSessionId;
const args = buildGrokArgs(
config,
prompt,
- known ? resolveGrokSessionArg(location, cwd, known) : undefined,
+ known ? resolveGrokSessionArg(location, cwd, known, home) : undefined,
);
- return { binary: "grok", args };
+ const env = profileEnv(location);
+ return { binary: "grok", args, ...(env ? { env } : {}) };
},
async createStructuredSession(input: CreateStructuredSessionInput) {
@@ -178,6 +255,7 @@ export function createGrokAdapter(): AgentAdapter {
input.projectLocation,
[...acpArgs, "agent", "stdio"],
resolveAgentBinaryPath(input.projectLocation, "grok"),
+ profileEnv(input.projectLocation),
);
return createAcpStructuredSession(command, input);
},
@@ -188,12 +266,18 @@ export function createGrokAdapter(): AgentAdapter {
location,
["agent", "stdio"],
resolveAgentBinaryPath(location, "grok"),
+ profileEnv(location),
);
},
async buildAcpLogoutCommand(ctx?: AgentEnvContext) {
const location = detectProbeLocation(ctx);
- return buildGrokCommand(location, ["logout"], resolveAgentBinaryPath(location, "grok"));
+ return buildGrokCommand(
+ location,
+ ["logout"],
+ resolveAgentBinaryPath(location, "grok"),
+ profileEnv(location),
+ );
},
createInitialSessionRef() {
@@ -205,8 +289,8 @@ export function createGrokAdapter(): AgentAdapter {
// stable ID). Subsequent CLI resumes then use precise `-r ` and the
// Chat (ACP) + Terminal tabs share the exact same Grok session.
initialSessionRefDiscoveryDelayMs: 1200,
- discoverSessionRef: makeGrokDiscoverSessionRef(),
- watchSessionRef: makeGrokWatchSessionRef(),
+ discoverSessionRef: makeGrokDiscoverSessionRef(sessionHome, sessionTracker),
+ watchSessionRef: makeGrokWatchSessionRef(sessionHome),
buildDirectInput(prompt) {
// Grok TUI may batch pasted input (especially on fresh/resumed sessions);
@@ -247,13 +331,14 @@ export function createGrokAdapter(): AgentAdapter {
// launch/ACP bypass in argv.ts). The default is Grok's own default model —
// fast and subscription-covered, ideal for lightweight one-shots.
defaultOneShotModel: "grok-composer-2.5-fast",
- buildOneShotCommand(model, effort, prompt) {
+ buildOneShotCommand(model, effort, prompt, location) {
if (!prompt) return undefined;
const args = ["-p", prompt];
if (model) args.push("-m", model);
if (effort) args.push("--reasoning-effort", effort);
args.push("--always-approve");
- return { command: "grok", args, stdin: "" };
+ const env = location ? profileEnv(location) : undefined;
+ return { command: "grok", args, stdin: "", ...(env ? { env } : {}) };
},
};
}
diff --git a/src/supervisor/agents/grok/plugin/install.ts b/src/supervisor/agents/grok/plugin/install.ts
index c8eb1ab53..dc62bcd87 100644
--- a/src/supervisor/agents/grok/plugin/install.ts
+++ b/src/supervisor/agents/grok/plugin/install.ts
@@ -264,14 +264,17 @@ function installGrokPluginWsl(
const GROK_VERIFY_ASSETS = ["plugin.json", "forward.mjs", FORWARD_RUNTIME_FILE] as const;
-export function isGrokPluginInstalled(ctx?: AgentEnvContext): {
+export function isGrokPluginInstalled(
+ ctx?: AgentEnvContext,
+ globalGrokDirOverride?: string,
+): {
installed: boolean;
version?: string;
} {
if (isWslPluginContext(ctx)) {
const wsl = getWslPluginBaseDirs(ctx.wslDistro, "grok");
if (!wsl) return { installed: false };
- const grokDir = wslGlobalGrokDir(ctx.wslDistro);
+ const grokDir = globalGrokDirOverride ?? wslGlobalGrokDir(ctx.wslDistro);
const hookFile = grokDir
? toWslUncPath(ctx.wslDistro, `${grokDir}/${GLOBAL_HOOK_DIR_NAME}/${GLOBAL_HOOK_FILENAME}`)
: "";
@@ -280,20 +283,31 @@ export function isGrokPluginInstalled(ctx?: AgentEnvContext): {
extraCheck: () => hookFile.length > 0 && hookFileMatchesPoracode(hookFile),
});
}
- const hookFile = join(nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME, GLOBAL_HOOK_FILENAME);
+ const hookFile = join(
+ globalGrokDirOverride ?? nativeGlobalGrokDir(),
+ GLOBAL_HOOK_DIR_NAME,
+ GLOBAL_HOOK_FILENAME,
+ );
return verifyStagedPluginAt(getNativePluginBaseDir("grok", ctx?.baseDir), "native", {
assets: GROK_VERIFY_ASSETS,
extraCheck: () => hookFileMatchesPoracode(hookFile),
});
}
-export function uninstallGrokPlugin(ctx?: AgentEnvContext): void {
+export function uninstallGrokPlugin(
+ ctx?: AgentEnvContext,
+ globalGrokDirOverride?: string,
+ removeStaged = true,
+): void {
const hookDir = isWslPluginContext(ctx)
- ? toWslUncPath(ctx.wslDistro, `${wslGlobalGrokDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`)
- : join(nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME);
+ ? toWslUncPath(
+ ctx.wslDistro,
+ `${globalGrokDirOverride ?? wslGlobalGrokDir(ctx.wslDistro)}/${GLOBAL_HOOK_DIR_NAME}`,
+ )
+ : join(globalGrokDirOverride ?? nativeGlobalGrokDir(), GLOBAL_HOOK_DIR_NAME);
removeManagedHookFile(join(hookDir, GLOBAL_HOOK_FILENAME));
removeManagedHookFile(join(hookDir, LEGACY_GLOBAL_HOOK_FILENAME));
- removeStagedPluginDir("grok", ctx);
+ if (removeStaged) removeStagedPluginDir("grok", ctx);
}
/**
diff --git a/src/supervisor/agents/grok/profile.ts b/src/supervisor/agents/grok/profile.ts
new file mode 100644
index 000000000..23ec32b2b
--- /dev/null
+++ b/src/supervisor/agents/grok/profile.ts
@@ -0,0 +1,20 @@
+import type { ProjectLocation } from "@/shared/contracts";
+import {
+ homeProfileEnvForLocation,
+ resolveAgentInstanceEnv,
+ resolveHomeProfilePathForLocation,
+} from "../homeProfile";
+
+export const resolveGrokHomeForLocation = resolveHomeProfilePathForLocation;
+export const resolveGrokInstanceEnv = resolveAgentInstanceEnv;
+
+export function grokProfileEnvForLocation(
+ homeDir: string | undefined,
+ customEnv: Record | undefined,
+ location: ProjectLocation,
+): Record | undefined {
+ return homeProfileEnvForLocation(homeDir, customEnv, location, "GROK_HOME", [
+ "GROK_API_KEY",
+ "XAI_API_KEY",
+ ]);
+}
diff --git a/src/supervisor/agents/grok/sessionFiles.test.ts b/src/supervisor/agents/grok/sessionFiles.test.ts
index 061b3842f..062e1d3ec 100644
--- a/src/supervisor/agents/grok/sessionFiles.test.ts
+++ b/src/supervisor/agents/grok/sessionFiles.test.ts
@@ -3,7 +3,13 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ProjectLocation } from "@/shared/contracts";
-import { grokSessionDirMaterialized, resolveGrokSessionArg } from "./sessionFiles";
+import {
+ createGrokSessionTracker,
+ discoverGrokSessionRef,
+ grokSessionDirMaterialized,
+ resolveGrokSessionArg,
+ snapshotGrokPreSpawnSessions,
+} from "./sessionFiles";
const SESSION_ID = "11111111-2222-4333-8444-555555555555";
@@ -51,4 +57,44 @@ describe("grok session materialization (native)", () => {
sessionId: SESSION_ID,
});
});
+
+ it("uses an explicit profile home instead of the process-wide GROK_HOME", () => {
+ const profileHome = mkdtempSync(join(tmpdir(), "grok-profile-home-"));
+ try {
+ mkdirSync(join(profileHome, "sessions", encodeURIComponent(projectDir), SESSION_ID), {
+ recursive: true,
+ });
+ expect(grokSessionDirMaterialized(location, projectDir, SESSION_ID, profileHome)).toBe(true);
+ expect(resolveGrokSessionArg(location, projectDir, SESSION_ID, profileHome)).toEqual({
+ kind: "resume",
+ sessionId: SESSION_ID,
+ });
+ } finally {
+ rmSync(profileHome, { recursive: true, force: true });
+ }
+ });
+
+ it("keeps pre-spawn snapshots isolated between adapter trackers", async () => {
+ const profileHome = mkdtempSync(join(tmpdir(), "grok-profile-home-"));
+ const sessionsDir = join(profileHome, "sessions", encodeURIComponent(projectDir));
+ const oldSession = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
+ const newSession = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
+ const firstTracker = createGrokSessionTracker();
+ const secondTracker = createGrokSessionTracker();
+ try {
+ mkdirSync(join(sessionsDir, oldSession), { recursive: true });
+ snapshotGrokPreSpawnSessions(location, projectDir, profileHome, firstTracker);
+ mkdirSync(join(sessionsDir, newSession), { recursive: true });
+ snapshotGrokPreSpawnSessions(location, projectDir, profileHome, secondTracker);
+
+ await expect(
+ discoverGrokSessionRef(location, projectDir, profileHome, firstTracker),
+ ).resolves.toMatchObject({ providerSessionId: newSession });
+ await expect(
+ discoverGrokSessionRef(location, projectDir, profileHome, secondTracker),
+ ).resolves.toBeUndefined();
+ } finally {
+ rmSync(profileHome, { recursive: true, force: true });
+ }
+ });
});
diff --git a/src/supervisor/agents/grok/sessionFiles.ts b/src/supervisor/agents/grok/sessionFiles.ts
index 3480996db..16a232e24 100644
--- a/src/supervisor/agents/grok/sessionFiles.ts
+++ b/src/supervisor/agents/grok/sessionFiles.ts
@@ -13,49 +13,60 @@ import {
import type { GrokSessionArg } from "./argv";
// Honor the same GROK_HOME override the CLI (and grokCredentials.ts) support.
-function getNativeGrokSessionsRoot(): string {
- const grokHome = process.env["GROK_HOME"];
+function getNativeGrokSessionsRoot(grokHomeOverride?: string): string {
+ const grokHome = grokHomeOverride ?? process.env["GROK_HOME"];
return join(
grokHome && grokHome.trim().length > 0 ? grokHome : join(homedir(), ".grok"),
"sessions",
);
}
-// Module-level snapshot captured right before we spawn a PTY for a fresh Grok launch.
-// This lets discoverSessionRef reliably identify the *new* session dir that Grok
-// creates for this particular launch (instead of an older one for the same cwd).
-let preSpawnSessionIds = new Set();
-let preSpawnCwdKey: string | null = null;
+export interface GrokSessionTracker {
+ preSpawnSessionIds: Set;
+ preSpawnCwdKey: string | null;
+}
+
+export function createGrokSessionTracker(): GrokSessionTracker {
+ return { preSpawnSessionIds: new Set(), preSpawnCwdKey: null };
+}
+
+const defaultSessionTracker = createGrokSessionTracker();
function encodeCwdKey(cwd: string): string {
// Grok stores sessions under ~/.grok/sessions//
return encodeURIComponent(cwd);
}
-function getGrokCwdSessionsDir(location: ProjectLocation, cwd: string): string | null {
+function getGrokCwdSessionsDir(
+ location: ProjectLocation,
+ cwd: string,
+ grokHome?: string,
+): string | null {
if (location.kind === "wsl") {
- const home = getCachedWslHomeDirectory(location.distro);
- if (!home) return null;
- return `${home}/.grok/sessions/${encodeCwdKey(cwd)}`;
+ const root = grokHome ?? getCachedWslHomeDirectory(location.distro);
+ if (!root) return null;
+ return `${root}${grokHome ? "" : "/.grok"}/sessions/${encodeCwdKey(cwd)}`;
}
- return join(getNativeGrokSessionsRoot(), encodeCwdKey(cwd));
+ return join(getNativeGrokSessionsRoot(grokHome), encodeCwdKey(cwd));
}
async function getGrokCwdSessionsDirAsync(
location: ProjectLocation,
cwd: string,
+ grokHome?: string,
): Promise {
- if (location.kind !== "wsl") return getGrokCwdSessionsDir(location, cwd);
+ if (location.kind !== "wsl" || grokHome) return getGrokCwdSessionsDir(location, cwd, grokHome);
const home = await resolveWslHomeDirectoryAsync(location.distro);
return home ? `${home}/.grok/sessions/${encodeCwdKey(cwd)}` : null;
}
-function getGrokSessionsRoot(location: ProjectLocation): string | null {
+function getGrokSessionsRoot(location: ProjectLocation, grokHome?: string): string | null {
if (location.kind === "wsl") {
+ if (grokHome) return `${grokHome}/sessions`;
const home = getCachedWslHomeDirectory(location.distro);
return home ? `${home}/.grok/sessions` : null;
}
- return getNativeGrokSessionsRoot();
+ return getNativeGrokSessionsRoot(grokHome);
}
/**
@@ -66,22 +77,27 @@ function getGrokSessionsRoot(location: ProjectLocation): string | null {
* Sync on native platforms only. WSL discovery uses the in-distro bridge
* after launch instead of doing a direct pre-spawn query.
*/
-export function snapshotGrokPreSpawnSessions(location: ProjectLocation, cwd: string): void {
- preSpawnSessionIds = new Set();
- preSpawnCwdKey = null;
+export function snapshotGrokPreSpawnSessions(
+ location: ProjectLocation,
+ cwd: string,
+ grokHome?: string,
+ tracker: GrokSessionTracker = defaultSessionTracker,
+): void {
+ tracker.preSpawnSessionIds = new Set();
+ tracker.preSpawnCwdKey = null;
if (location.kind === "wsl") return;
- const dir = getGrokCwdSessionsDir(location, cwd);
+ const dir = getGrokCwdSessionsDir(location, cwd, grokHome);
if (!dir) return;
if (!existsSync(dir)) return;
- preSpawnCwdKey = encodeCwdKey(cwd);
+ tracker.preSpawnCwdKey = encodeCwdKey(cwd);
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && isUuid(entry.name)) {
- preSpawnSessionIds.add(entry.name);
+ tracker.preSpawnSessionIds.add(entry.name);
}
}
} catch {
@@ -111,11 +127,12 @@ export function grokSessionDirMaterialized(
location: ProjectLocation,
cwd: string,
sessionId: string,
+ grokHome?: string,
): boolean | undefined {
if (location.kind === "wsl") {
- const home = getCachedWslHomeDirectory(location.distro);
- if (!home) return undefined;
- const linuxPath = `${home}/.grok/sessions/${encodeCwdKey(cwd)}/${sessionId}`;
+ const root = grokHome ?? getCachedWslHomeDirectory(location.distro);
+ if (!root) return undefined;
+ const linuxPath = `${root}${grokHome ? "" : "/.grok"}/sessions/${encodeCwdKey(cwd)}/${sessionId}`;
const uncPath = `\\\\wsl.localhost\\${location.distro}${linuxPath.replaceAll("/", "\\")}`;
try {
return existsSync(uncPath);
@@ -123,7 +140,7 @@ export function grokSessionDirMaterialized(
return undefined;
}
}
- const dir = getGrokCwdSessionsDir(location, cwd);
+ const dir = getGrokCwdSessionsDir(location, cwd, grokHome);
if (!dir) return undefined;
return existsSync(join(dir, sessionId));
}
@@ -139,8 +156,9 @@ export function resolveGrokSessionArg(
location: ProjectLocation,
cwd: string,
knownSessionId: string,
+ grokHome?: string,
): GrokSessionArg {
- const materialized = grokSessionDirMaterialized(location, cwd, knownSessionId);
+ const materialized = grokSessionDirMaterialized(location, cwd, knownSessionId, grokHome);
return materialized === false
? { kind: "new", sessionId: knownSessionId }
: { kind: "resume", sessionId: knownSessionId };
@@ -159,8 +177,10 @@ export function resolveGrokSessionArg(
export async function discoverGrokSessionRef(
location: ProjectLocation,
cwd: string,
+ grokHome?: string,
+ tracker: GrokSessionTracker = defaultSessionTracker,
): Promise {
- const dir = await getGrokCwdSessionsDirAsync(location, cwd);
+ const dir = await getGrokCwdSessionsDirAsync(location, cwd, grokHome);
if (!dir) return undefined;
const key = encodeCwdKey(cwd);
@@ -170,7 +190,7 @@ export async function discoverGrokSessionRef(
const candidateNames = entries
.filter((e) => e.type === "directory" && isUuid(e.name))
.map((e) => e.name)
- .filter((name) => !(preSpawnCwdKey === key && preSpawnSessionIds.has(name)));
+ .filter((name) => !(tracker.preSpawnCwdKey === key && tracker.preSpawnSessionIds.has(name)));
if (candidateNames.length === 0) return undefined;
@@ -184,10 +204,13 @@ export async function discoverGrokSessionRef(
return winner ? createKnownSessionRef(winner.id) : undefined;
}
-export function makeGrokDiscoverSessionRef() {
+export function makeGrokDiscoverSessionRef(
+ resolveGrokHome?: (location: ProjectLocation) => string | undefined,
+ tracker: GrokSessionTracker = defaultSessionTracker,
+) {
return async (location: ProjectLocation): Promise => {
const cwd = location.kind === "wsl" ? location.linuxPath : location.path;
- return discoverGrokSessionRef(location, cwd);
+ return discoverGrokSessionRef(location, cwd, resolveGrokHome?.(location), tracker);
};
}
@@ -198,20 +221,26 @@ export function makeGrokDiscoverSessionRef() {
* root when that subdir doesn't exist yet so the first session creation
* still wakes the watcher.
*/
-export function resolveGrokSessionsWatchPaths(location: ProjectLocation, cwd: string): string[] {
- const dir = getGrokCwdSessionsDir(location, cwd);
+export function resolveGrokSessionsWatchPaths(
+ location: ProjectLocation,
+ cwd: string,
+ grokHome?: string,
+): string[] {
+ const dir = getGrokCwdSessionsDir(location, cwd, grokHome);
if (location.kind === "wsl") {
- const root = getGrokSessionsRoot(location);
+ const root = getGrokSessionsRoot(location, grokHome);
return [dir ?? undefined, root ?? undefined].filter((p): p is string => Boolean(p));
}
if (dir && existsSync(dir)) return [dir];
- return [getNativeGrokSessionsRoot()];
+ return [getNativeGrokSessionsRoot(grokHome)];
}
-export function makeGrokWatchSessionRef() {
+export function makeGrokWatchSessionRef(
+ resolveGrokHome?: (location: ProjectLocation) => string | undefined,
+) {
return (location: ProjectLocation, onChanged: () => void): (() => void) | undefined => {
const cwd = location.kind === "wsl" ? location.linuxPath : location.path;
- const paths = resolveGrokSessionsWatchPaths(location, cwd);
+ const paths = resolveGrokSessionsWatchPaths(location, cwd, resolveGrokHome?.(location));
if (paths.length === 0) return undefined;
const label = `grok:${location.kind === "wsl" ? "wsl:" + location.distro : location.kind}`;
diff --git a/src/supervisor/agents/homeProfile.ts b/src/supervisor/agents/homeProfile.ts
new file mode 100644
index 000000000..ebe4f939d
--- /dev/null
+++ b/src/supervisor/agents/homeProfile.ts
@@ -0,0 +1,80 @@
+import { homedir } from "node:os";
+import path from "node:path";
+import posixPath from "node:path/posix";
+import type {
+ AgentInstanceConfig,
+ AgentStatus,
+ AgentTerminalAuthMethod,
+ ProjectLocation,
+} from "@/shared/contracts";
+import { resolveWslHomeDirectory } from "./base";
+
+export function resolveNativeHomeProfilePath(rawHomeDir: string): string {
+ const trimmed = rawHomeDir.trim();
+ if (trimmed !== "~" && !trimmed.startsWith("~/")) {
+ return path.isAbsolute(trimmed) || path.win32.isAbsolute(trimmed)
+ ? trimmed
+ : path.join(homedir(), trimmed);
+ }
+ const suffix = trimmed === "~" ? "" : trimmed.slice(2);
+ return path.join(homedir(), suffix);
+}
+
+export function resolveHomeProfilePathForLocation(
+ rawHomeDir: string,
+ location: ProjectLocation,
+): string {
+ if (location.kind !== "wsl") return resolveNativeHomeProfilePath(rawHomeDir);
+ const home = resolveWslHomeDirectory(location.distro);
+ if (!home) throw new Error(`Unable to resolve the WSL home directory for ${location.distro}.`);
+ const trimmed = rawHomeDir.trim();
+ if (trimmed !== "~" && !trimmed.startsWith("~/")) {
+ return posixPath.isAbsolute(trimmed) ? trimmed : posixPath.join(home, trimmed);
+ }
+ const suffix = trimmed === "~" ? "" : trimmed.slice(2);
+ return posixPath.join(home, suffix);
+}
+
+export function resolveAgentInstanceEnv(
+ environment: AgentInstanceConfig["environment"],
+): Record | undefined {
+ if (!environment) return undefined;
+ const env = Object.fromEntries(
+ Object.entries(environment)
+ .filter(([name]) => name.trim().length > 0)
+ .map(([name, variable]) => [name, variable.value]),
+ );
+ return Object.keys(env).length > 0 ? env : undefined;
+}
+
+export function homeProfileEnvForLocation(
+ homeDir: string | undefined,
+ customEnv: Record | undefined,
+ location: ProjectLocation,
+ homeVariable: string,
+ credentialVariables: readonly string[],
+): Record | undefined {
+ const env: Record = {};
+ if (homeDir) {
+ for (const name of credentialVariables) env[name] = "";
+ }
+ Object.assign(env, customEnv);
+ if (homeDir) env[homeVariable] = resolveHomeProfilePathForLocation(homeDir, location);
+ return Object.keys(env).length > 0 ? env : undefined;
+}
+
+export function withTerminalAuthEnv(
+ status: AgentStatus,
+ env: Record | undefined,
+ fallbackMethod: AgentTerminalAuthMethod,
+): AgentStatus {
+ if (!env || !status.loginCommand) return status;
+ const methods: NonNullable = (status.authMethods ?? []).map(
+ (method) =>
+ method.type === "terminal" ? { ...method, env: { ...method.env, ...env } } : method,
+ );
+ if (!methods.some((method) => method.type === "terminal")) {
+ methods.push({ ...fallbackMethod, env });
+ }
+ return { ...status, authMethods: methods };
+}
diff --git a/src/supervisor/agents/homeProfileUsage.ts b/src/supervisor/agents/homeProfileUsage.ts
new file mode 100644
index 000000000..4656505cb
--- /dev/null
+++ b/src/supervisor/agents/homeProfileUsage.ts
@@ -0,0 +1,100 @@
+import {
+ collectCodex,
+ collectCopilot,
+ collectGemini,
+ collectGrok,
+ type HostPort,
+ type OAuthToken,
+ type UsageSnapshot,
+} from "@poracode/agents-usage";
+import {
+ homeProfileKind,
+ isHomeProfileDriver,
+ parseHomeProfileInstanceConfig,
+ type HomeProfileDriver,
+} from "@/shared/contracts";
+import type { SharedSettings } from "@/shared/settings";
+import { resolveCodexToken } from "../runtime/codexCredentials";
+import { resolveCopilotToken } from "../runtime/copilotCredentials";
+import { resolveGeminiToken } from "../runtime/geminiCredentials";
+import { resolveGrokToken } from "../runtime/grokCredentials";
+import { resolveNativeHomeProfilePath } from "./homeProfile";
+
+export interface HomeUsageProfile {
+ providerId: string;
+ driver: HomeProfileDriver;
+ homeDir: string;
+}
+
+interface HomeUsageProfileSpec {
+ collect(host: HostPort): Promise;
+ resolveToken(homeDir: string): Promise;
+}
+
+const HOME_USAGE_PROFILE_SPECS: Record = {
+ codex: {
+ collect: collectCodex,
+ resolveToken: (homeDir) => resolveCodexToken({ CODEX_HOME: homeDir }),
+ },
+ copilot: {
+ collect: collectCopilot,
+ resolveToken: (homeDir) => resolveCopilotToken({ COPILOT_HOME: homeDir }),
+ },
+ gemini: {
+ collect: collectGemini,
+ resolveToken: (homeDir) => resolveGeminiToken({ GEMINI_CLI_HOME: homeDir }),
+ },
+ grok: {
+ collect: collectGrok,
+ resolveToken: (homeDir) => resolveGrokToken({ GROK_HOME: homeDir }),
+ },
+};
+
+export function readHomeUsageProfiles(settings: SharedSettings): Map {
+ const profiles = new Map();
+ for (const instance of Object.values(settings.agentInstances)) {
+ if (instance.enabled === false || !isHomeProfileDriver(instance.driver)) continue;
+ try {
+ const config = parseHomeProfileInstanceConfig(instance.config);
+ const providerId = homeProfileKind(instance.driver, instance.id);
+ profiles.set(providerId, {
+ providerId,
+ driver: instance.driver,
+ homeDir: resolveNativeHomeProfilePath(config.homeDir),
+ });
+ } catch {
+ // Malformed profile records are ignored by the agent registry too.
+ }
+ }
+ return profiles;
+}
+
+export async function collectHomeProfile(
+ profile: HomeUsageProfile,
+ host: HostPort,
+): Promise {
+ const now = host.now();
+ const spec = HOME_USAGE_PROFILE_SPECS[profile.driver];
+ const scopedHost: HostPort = {
+ http: host.http,
+ now: () => host.now(),
+ credentials: {
+ getOAuthToken: () => spec.resolveToken(profile.homeDir),
+ getSecret: async () => undefined,
+ },
+ ...(host.clientVersions ? { clientVersions: host.clientVersions } : {}),
+ ...(host.log ? { log: host.log } : {}),
+ };
+ try {
+ const snapshot = await spec.collect(scopedHost);
+ return { ...snapshot, providerId: profile.providerId };
+ } catch (error) {
+ return {
+ providerId: profile.providerId,
+ status: "error",
+ windows: [],
+ fetchedAt: now,
+ error: error instanceof Error ? error.message : String(error),
+ };
+ }
+}
diff --git a/src/supervisor/agents/registry.test.ts b/src/supervisor/agents/registry.test.ts
index 9c0460b63..6ab99ba64 100644
--- a/src/supervisor/agents/registry.test.ts
+++ b/src/supervisor/agents/registry.test.ts
@@ -1,7 +1,7 @@
import { existsSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
-import { createAgentRegistry } from "./registry";
+import { buildAgentRegistry, createAgentRegistry } from "./registry";
import { buildUnrestrictedChildConfig } from "@/supervisor/subagentMcp/types";
const EXPECTED_BUILT_IN_ORDER = [
@@ -79,3 +79,40 @@ describe("built-in agent registry", () => {
},
);
});
+
+describe("profile agent registry", () => {
+ it("registers every supported home-isolated profile as a synthetic provider", () => {
+ const adapters = buildAgentRegistry(
+ (["codex", "copilot", "gemini", "grok"] as const).map((driver) => ({
+ id: `${driver}-work`,
+ driver,
+ displayName: "Work",
+ config: { homeDir: `~/.poracode/${driver}-profiles/work` },
+ })),
+ );
+
+ expect(adapters.map((adapter) => adapter.kind)).toEqual(
+ expect.arrayContaining([
+ "codex:codex-work",
+ "copilot:copilot-work",
+ "gemini:gemini-work",
+ "grok:grok-work",
+ ]),
+ );
+ });
+
+ it("skips malformed and disabled profiles", () => {
+ const adapters = buildAgentRegistry([
+ { id: "bad", driver: "codex", config: {} },
+ {
+ id: "disabled",
+ driver: "grok",
+ enabled: false,
+ config: { homeDir: "~/.poracode/grok-profiles/disabled" },
+ },
+ ]);
+
+ expect(adapters.some((adapter) => adapter.kind === "codex:bad")).toBe(false);
+ expect(adapters.some((adapter) => adapter.kind === "grok:disabled")).toBe(false);
+ });
+});
diff --git a/src/supervisor/agents/registry.ts b/src/supervisor/agents/registry.ts
index 1531fd7a0..44d0448c8 100644
--- a/src/supervisor/agents/registry.ts
+++ b/src/supervisor/agents/registry.ts
@@ -7,9 +7,9 @@
* omits one. Renderer metadata and native install wiring remain separate; see
* .agents/docs/agent-adapters.md → "Adding a New Provider — Full Checklist".
*
- * For runtime-extensible ACP-speaking agents, pass `userInstances` to
- * `buildAgentRegistry` — each `acp-generic` instance becomes a discrete
- * adapter via `createAcpGenericAdapter`.
+ * Pass `userInstances` to `buildAgentRegistry` for provider profiles and
+ * runtime-extensible ACP agents; each enabled instance becomes a discrete
+ * adapter.
*/
import type { AgentInstanceConfig } from "@/shared/contracts";
import { createAcpGenericAdapter } from "./acp-generic";
@@ -17,14 +17,27 @@ import { createAntigravityAdapter } from "./antigravity";
import type { AgentAdapter } from "./base";
import { createClaudeAdapter, createClaudeProfileAdapter } from "./claude";
import { createCommandCodeAdapter } from "./commandcode";
-import { createCopilotAdapter } from "./copilot";
-import { createCodexAdapter } from "./codex";
+import { createCopilotAdapter, createCopilotProfileAdapter } from "./copilot";
+import { createCodexAdapter, createCodexProfileAdapter } from "./codex";
import { createCursorAdapter } from "./cursor";
import { createFactoryAdapter } from "./factory";
-import { createGeminiAdapter } from "./gemini";
-import { createGrokAdapter } from "./grok";
+import { createGeminiAdapter, createGeminiProfileAdapter } from "./gemini";
+import { createGrokAdapter, createGrokProfileAdapter } from "./grok";
import { createOpenCodeAdapter } from "./opencode";
+type ProfileAdapterFactory = {
+ label: string;
+ create(instance: AgentInstanceConfig): AgentAdapter;
+};
+
+const PROFILE_ADAPTER_FACTORIES: Readonly> = {
+ claude: { label: "Claude", create: createClaudeProfileAdapter },
+ codex: { label: "Codex", create: createCodexProfileAdapter },
+ copilot: { label: "Copilot", create: createCopilotProfileAdapter },
+ gemini: { label: "Gemini", create: createGeminiProfileAdapter },
+ grok: { label: "Grok", create: createGrokProfileAdapter },
+};
+
export function createAgentRegistry(): AgentAdapter[] {
return buildAgentRegistry([]);
}
@@ -50,21 +63,22 @@ export function buildAgentRegistry(userInstances: AgentInstanceConfig[]): AgentA
const userAdapters = userInstances
.filter((inst) => inst.enabled !== false && inst.driver === "acp-generic")
.map((inst) => createAcpGenericAdapter(inst));
- const claudeProfileAdapters = userInstances
- .filter((inst) => inst.enabled !== false && inst.driver === "claude")
- .flatMap((inst) => {
- try {
- return [createClaudeProfileAdapter(inst)];
- } catch (error) {
- console.warn(
- `[agents] skipping Claude profile ${inst.id}: ${
- error instanceof Error ? error.message : String(error)
- }`,
- );
- return [];
- }
- });
- const adapters = [...builtIns, ...claudeProfileAdapters, ...userAdapters];
+ const profileAdapters = userInstances.flatMap((inst) => {
+ if (inst.enabled === false) return [];
+ const factory = PROFILE_ADAPTER_FACTORIES[inst.driver];
+ if (!factory) return [];
+ try {
+ return [factory.create(inst)];
+ } catch (error) {
+ console.warn(
+ `[agents] skipping ${factory.label} profile ${inst.id}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ );
+ return [];
+ }
+ });
+ const adapters = [...builtIns, ...profileAdapters, ...userAdapters];
const kinds = new Set(adapters.map((a) => a.kind));
if (kinds.size !== adapters.length) {
throw new Error("Duplicate agent kind in registry");
diff --git a/src/supervisor/runtime/agentRegistryService.ts b/src/supervisor/runtime/agentRegistryService.ts
index 1b3fa8cf9..e49c37279 100644
--- a/src/supervisor/runtime/agentRegistryService.ts
+++ b/src/supervisor/runtime/agentRegistryService.ts
@@ -17,7 +17,11 @@ import type {
ResolveAgentAccountResult,
RemoveAcpRegistryAgentPayload,
} from "@/shared/contracts";
-import { acpGenericKind, extractAcpGenericInstanceId } from "@/shared/contracts";
+import {
+ acpGenericKind,
+ agentKindsSharingBinary,
+ extractAcpGenericInstanceId,
+} from "@/shared/contracts";
import { verifyAcpGenericAuthentication } from "../agents/acp-generic";
import {
dispatchAcpAuthenticate,
@@ -135,18 +139,25 @@ export class AgentRegistryService {
}
}
- private async refreshAffectedAgentStatus(agentKind: string): Promise {
+ private async refreshAffectedAgentStatuses(agentKinds: readonly string[]): Promise {
try {
const wslDistros = await this.agentStatusService.listWslDistros();
await this.agentStatusService.refreshAgentStatuses({
wslDistros,
- scope: { agentKinds: [agentKind] },
+ scope: { agentKinds: [...agentKinds] },
});
} catch (error) {
- console.warn(`[supervisor] refreshAffectedAgentStatus failed for ${agentKind}`, error);
+ console.warn(
+ `[supervisor] refreshAffectedAgentStatuses failed for ${agentKinds.join(", ")}`,
+ error,
+ );
}
}
+ private refreshAffectedAgentStatus(agentKind: string): Promise {
+ return this.refreshAffectedAgentStatuses([agentKind]);
+ }
+
async getAgentStatuses(payload: GetAgentStatusesPayload): Promise {
this.deps.sharedSettingsCache.invalidate();
this.refreshAgentRegistryAdapters();
@@ -248,7 +259,9 @@ export class AgentRegistryService {
// the new binary can land at a different prefix and the cached entry
// would resolve to a stale shim.
clearAgentBinaryPathCache();
- await this.refreshAffectedAgentStatus(payload.agentKind);
+ await this.refreshAffectedAgentStatuses(
+ agentKindsSharingBinary(payload.agentKind, this.deps.adapters.keys()),
+ );
}
return result;
}
diff --git a/src/supervisor/runtime/codexCredentials.ts b/src/supervisor/runtime/codexCredentials.ts
index da8f8d618..53a187049 100644
--- a/src/supervisor/runtime/codexCredentials.ts
+++ b/src/supervisor/runtime/codexCredentials.ts
@@ -31,15 +31,21 @@ export function parseCodexAuth(content: string): OAuthToken | undefined {
};
}
-function codexAuthFilePath(): string {
- const home = process.env.CODEX_HOME?.trim();
+export interface CodexCredentialEnv {
+ CODEX_HOME?: string | undefined;
+}
+
+function codexAuthFilePath(env: CodexCredentialEnv): string {
+ const home = env.CODEX_HOME?.trim();
return home ? join(home, "auth.json") : join(homedir(), ".codex", "auth.json");
}
-export async function resolveCodexToken(): Promise {
+export async function resolveCodexToken(
+ env: CodexCredentialEnv = process.env,
+): Promise {
// Read fresh every call — the access token is a short-lived JWT the Codex CLI
// refreshes (~5 min); a cached Bearer would go stale and 401.
- const path = codexAuthFilePath();
+ const path = codexAuthFilePath(env);
if (existsSync(path)) {
try {
const token = parseCodexAuth(readFileSync(path, "utf8"));
@@ -48,7 +54,7 @@ export async function resolveCodexToken(): Promise {
// fall through to the WSL fallback
}
}
- if (process.platform === "win32") {
+ if (!env.CODEX_HOME?.trim() && process.platform === "win32") {
const blob = await readCodexAuthFromWsl();
if (blob) {
const token = parseCodexAuth(blob);
diff --git a/src/supervisor/runtime/copilotCredentials.ts b/src/supervisor/runtime/copilotCredentials.ts
index fe34a410d..c378698bb 100644
--- a/src/supervisor/runtime/copilotCredentials.ts
+++ b/src/supervisor/runtime/copilotCredentials.ts
@@ -18,16 +18,22 @@ interface CopilotConfig {
lastLoggedInUser?: { host?: string; login?: string };
}
-function copilotTokenFromEnv(): string | undefined {
+export interface CopilotCredentialEnv {
+ COPILOT_HOME?: string | undefined;
+ COPILOT_GITHUB_TOKEN?: string | undefined;
+ COPILOT_API_TOKEN?: string | undefined;
+}
+
+function copilotTokenFromEnv(env: CopilotCredentialEnv): string | undefined {
for (const name of COPILOT_TOKEN_ENV_VARS) {
- const value = process.env[name]?.trim();
+ const value = env[name]?.trim();
if (value) return value;
}
return undefined;
}
-function copilotConfigPath(): string {
- const home = process.env.COPILOT_HOME?.trim();
+function copilotConfigPath(env: CopilotCredentialEnv): string {
+ const home = env.COPILOT_HOME?.trim();
return home ? join(home, "config.json") : join(homedir(), ".copilot", "config.json");
}
@@ -44,8 +50,8 @@ export function copilotCredentialTargetFromConfig(content: string): string | und
return `copilot-cli/${host}:${login}`;
}
-async function resolveCopilotCliToken(): Promise {
- const path = copilotConfigPath();
+async function resolveCopilotCliToken(env: CopilotCredentialEnv): Promise {
+ const path = copilotConfigPath(env);
if (!existsSync(path)) return undefined;
try {
const target = copilotCredentialTargetFromConfig(readFileSync(path, "utf8"));
@@ -57,14 +63,16 @@ async function resolveCopilotCliToken(): Promise {
}
}
-export async function resolveCopilotToken(): Promise {
- const fromEnv = copilotTokenFromEnv();
+export async function resolveCopilotToken(
+ env: CopilotCredentialEnv = process.env,
+): Promise {
+ const fromEnv = copilotTokenFromEnv(env);
if (fromEnv) return { accessToken: fromEnv };
- const fromCopilotCli = await resolveCopilotCliToken();
+ const fromCopilotCli = await resolveCopilotCliToken(env);
if (fromCopilotCli) return fromCopilotCli;
// Signed in only inside WSL? `gh auth token` works regardless of which env
// fetches with it, matching the other providers' native→WSL fallback.
- if (process.platform === "win32") {
+ if (!env.COPILOT_HOME?.trim() && process.platform === "win32") {
const wslToken = await readCopilotTokenFromWsl();
if (wslToken) return { accessToken: wslToken };
}
diff --git a/src/supervisor/runtime/geminiCredentials.ts b/src/supervisor/runtime/geminiCredentials.ts
index c4980e503..9072cddd4 100644
--- a/src/supervisor/runtime/geminiCredentials.ts
+++ b/src/supervisor/runtime/geminiCredentials.ts
@@ -53,9 +53,18 @@ export function parseGeminiCreds(content: string): OAuthToken | undefined {
};
}
-function geminiCredsFilePath(): string {
- const home = process.env.GEMINI_HOME?.trim();
- return home ? join(home, "oauth_creds.json") : join(homedir(), ".gemini", "oauth_creds.json");
+export interface GeminiCredentialEnv {
+ GEMINI_CLI_HOME?: string | undefined;
+ GEMINI_HOME?: string | undefined;
+}
+
+function geminiCredsFilePath(env: GeminiCredentialEnv): string {
+ const cliHome = env.GEMINI_CLI_HOME?.trim();
+ if (cliHome) return join(cliHome, ".gemini", "oauth_creds.json");
+ const legacyHome = env.GEMINI_HOME?.trim();
+ return legacyHome
+ ? join(legacyHome, "oauth_creds.json")
+ : join(homedir(), ".gemini", "oauth_creds.json");
}
const GEMINI_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token";
@@ -179,8 +188,10 @@ async function withFreshGeminiToken(token: OAuthToken): Promise {
return refreshed ? { ...token, ...refreshed } : token;
}
-export async function resolveGeminiToken(): Promise {
- const path = geminiCredsFilePath();
+export async function resolveGeminiToken(
+ env: GeminiCredentialEnv = process.env,
+): Promise {
+ const path = geminiCredsFilePath(env);
if (existsSync(path)) {
try {
const token = parseGeminiCreds(readFileSync(path, "utf8"));
@@ -189,7 +200,7 @@ export async function resolveGeminiToken(): Promise {
// fall through to the WSL fallback
}
}
- if (process.platform === "win32") {
+ if (!env.GEMINI_CLI_HOME?.trim() && !env.GEMINI_HOME?.trim() && process.platform === "win32") {
const blob = await readGeminiCredsFromWsl();
if (blob) {
const token = parseGeminiCreds(blob);
diff --git a/src/supervisor/runtime/grokCredentials.ts b/src/supervisor/runtime/grokCredentials.ts
index 4b1a58f40..e44dfec51 100644
--- a/src/supervisor/runtime/grokCredentials.ts
+++ b/src/supervisor/runtime/grokCredentials.ts
@@ -40,13 +40,19 @@ export function parseGrokAuth(content: string): OAuthToken | undefined {
return undefined;
}
-function grokAuthFilePath(): string {
- const home = process.env.GROK_HOME?.trim();
+export interface GrokCredentialEnv {
+ GROK_HOME?: string | undefined;
+}
+
+function grokAuthFilePath(env: GrokCredentialEnv): string {
+ const home = env.GROK_HOME?.trim();
return home ? join(home, "auth.json") : join(homedir(), ".grok", "auth.json");
}
-export async function resolveGrokToken(): Promise {
- const path = grokAuthFilePath();
+export async function resolveGrokToken(
+ env: GrokCredentialEnv = process.env,
+): Promise {
+ const path = grokAuthFilePath(env);
if (existsSync(path)) {
try {
const token = parseGrokAuth(readFileSync(path, "utf8"));
@@ -55,7 +61,7 @@ export async function resolveGrokToken(): Promise {
// fall through to the WSL fallback
}
}
- if (process.platform === "win32") {
+ if (!env.GROK_HOME?.trim() && process.platform === "win32") {
const blob = await readGrokAuthFromWsl();
if (blob) {
const token = parseGrokAuth(blob);
diff --git a/src/supervisor/runtime/usageCredentials.test.ts b/src/supervisor/runtime/usageCredentials.test.ts
index b43d3bd5c..507ea4e82 100644
--- a/src/supervisor/runtime/usageCredentials.test.ts
+++ b/src/supervisor/runtime/usageCredentials.test.ts
@@ -1,14 +1,32 @@
-import { describe, expect, it } from "vitest";
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
import { parseClaudeCredentials } from "./claudeCredentials";
import { claudeKeychainAccount, claudeKeychainServiceNames } from "./macClaudeKeychain";
-import { parseCodexAuth } from "./codexCredentials";
-import { copilotCredentialTargetFromConfig } from "./copilotCredentials";
+import { parseCodexAuth, resolveCodexToken } from "./codexCredentials";
+import { copilotCredentialTargetFromConfig, resolveCopilotToken } from "./copilotCredentials";
import {
CURSOR_CLI_KEYCHAIN_ACCOUNT,
CURSOR_CLI_KEYCHAIN_SERVICE,
cursorUserIdFromJwt,
parseCursorCliEmail,
} from "./cursorCredentials";
+import { parseGeminiCreds, resolveGeminiToken } from "./geminiCredentials";
+import { parseGrokAuth, resolveGrokToken } from "./grokCredentials";
+
+const tempPaths: string[] = [];
+
+function tempProfileDir(name: string): string {
+ const path = join(tmpdir(), `poracode-${name}-${process.pid}-${tempPaths.length}`);
+ tempPaths.push(path);
+ mkdirSync(path, { recursive: true });
+ return path;
+}
+
+afterEach(() => {
+ for (const path of tempPaths.splice(0)) rmSync(path, { force: true, recursive: true });
+});
/** Build a JWT-shaped token whose payload carries the given claims. */
function fakeJwt(claims: Record): string {
@@ -136,6 +154,20 @@ describe("parseCodexAuth", () => {
expect(parseCodexAuth(JSON.stringify({ OPENAI_API_KEY: "sk-..." }))).toBeUndefined();
expect(parseCodexAuth("nope")).toBeUndefined();
});
+
+ it("reads credentials from an explicit profile home", async () => {
+ const home = tempProfileDir("codex-profile");
+ writeFileSync(
+ join(home, "auth.json"),
+ JSON.stringify({ tokens: { access_token: "profile-access", account_id: "profile-id" } }),
+ "utf8",
+ );
+
+ await expect(resolveCodexToken({ CODEX_HOME: home })).resolves.toMatchObject({
+ accessToken: "profile-access",
+ accountId: "profile-id",
+ });
+ });
});
describe("copilotCredentialTargetFromConfig", () => {
@@ -148,4 +180,47 @@ describe("copilotCredentialTargetFromConfig", () => {
),
).toBe("copilot-cli/https://github.com:octo-dev");
});
+
+ it("uses explicitly scoped profile environment tokens", async () => {
+ await expect(
+ resolveCopilotToken({
+ COPILOT_HOME: tempProfileDir("copilot-profile"),
+ COPILOT_GITHUB_TOKEN: "profile-token",
+ }),
+ ).resolves.toEqual({ accessToken: "profile-token" });
+ });
+});
+
+describe("profile-scoped Gemini credentials", () => {
+ it("reads .gemini/oauth_creds.json beneath GEMINI_CLI_HOME", async () => {
+ const home = tempProfileDir("gemini-profile");
+ mkdirSync(join(home, ".gemini"), { recursive: true });
+ writeFileSync(
+ join(home, ".gemini", "oauth_creds.json"),
+ JSON.stringify({ access_token: "profile-access", refresh_token: "profile-refresh" }),
+ "utf8",
+ );
+
+ expect(parseGeminiCreds("not json")).toBeUndefined();
+ await expect(resolveGeminiToken({ GEMINI_CLI_HOME: home })).resolves.toMatchObject({
+ accessToken: "profile-access",
+ refreshToken: "profile-refresh",
+ });
+ });
+});
+
+describe("profile-scoped Grok credentials", () => {
+ it("reads auth.json beneath GROK_HOME", async () => {
+ const home = tempProfileDir("grok-profile");
+ writeFileSync(
+ join(home, "auth.json"),
+ JSON.stringify({ access_token: "profile-access" }),
+ "utf8",
+ );
+
+ expect(parseGrokAuth("not json")).toBeUndefined();
+ await expect(resolveGrokToken({ GROK_HOME: home })).resolves.toEqual({
+ accessToken: "profile-access",
+ });
+ });
});
diff --git a/src/supervisor/runtime/usageService.test.ts b/src/supervisor/runtime/usageService.test.ts
index e2cd11819..c8befe545 100644
--- a/src/supervisor/runtime/usageService.test.ts
+++ b/src/supervisor/runtime/usageService.test.ts
@@ -28,6 +28,14 @@ const CLAUDE_BODY = JSON.stringify({
seven_day: { utilization: 0.1 },
});
+const CODEX_BODY = JSON.stringify({
+ plan_type: "plus",
+ rate_limit: {
+ primary_window: { used_percent: 30, window_minutes: 300 },
+ secondary_window: { used_percent: 10, window_minutes: 10_080 },
+ },
+});
+
function makeHost(tokens: Record): HostPort {
return {
now: () => NOW,
@@ -353,6 +361,67 @@ describe("UsageService", () => {
expect(result.snapshots[0]?.windows.find((w) => w.id === "session-5h")?.usedPercent).toBe(0.4);
});
+ it("collects Codex profile usage from the isolated profile home", async () => {
+ const profileDir = join(tmpdir(), `poracode-usage-codex-profile-${process.pid}`);
+ cachePaths.push(profileDir);
+ mkdirSync(profileDir, { recursive: true });
+ writeFileSync(
+ join(profileDir, "auth.json"),
+ JSON.stringify({ tokens: { access_token: "profile-token", account_id: "profile-account" } }),
+ "utf8",
+ );
+
+ const settingsPath = tempCachePath();
+ writeFileSync(
+ settingsPath,
+ JSON.stringify({
+ agentInstances: {
+ work: {
+ id: "work",
+ driver: "codex",
+ displayName: "Work",
+ config: { homeDir: profileDir },
+ },
+ },
+ }),
+ "utf8",
+ );
+
+ let authorization: string | undefined;
+ let accountId: string | undefined;
+ const host: HostPort = {
+ now: () => NOW,
+ credentials: {
+ getOAuthToken: () => Promise.resolve(undefined),
+ getSecret: () => Promise.resolve(undefined),
+ },
+ http: {
+ request: (request) => {
+ authorization = request.headers?.Authorization;
+ accountId = request.headers?.["ChatGPT-Account-Id"];
+ return Promise.resolve({ status: 200, headers: {}, body: CODEX_BODY });
+ },
+ },
+ };
+ const service = new UsageService({
+ emit: () => {},
+ cachePath: tempCachePath(),
+ settingsPath,
+ host,
+ localCollectors: stubLocalCollectors(),
+ });
+
+ const result = await service.refreshProviderUsage({ providerIds: ["codex:work"] });
+
+ expect(authorization).toBe("Bearer profile-token");
+ expect(accountId).toBe("profile-account");
+ expect(result.snapshots[0]).toMatchObject({
+ providerId: "codex:work",
+ status: "ok",
+ plan: "ChatGPT Plus",
+ });
+ });
+
it("does not re-poll a rate-limited provider until its Retry-After backoff clears", async () => {
let now = NOW;
let calls = 0;
diff --git a/src/supervisor/runtime/usageService.ts b/src/supervisor/runtime/usageService.ts
index c0debd6f1..b31ce5395 100644
--- a/src/supervisor/runtime/usageService.ts
+++ b/src/supervisor/runtime/usageService.ts
@@ -22,6 +22,11 @@ import {
withClaudeEstimatedCost,
type ClaudeUsageProfile,
} from "../agents/claude/claudeUsageProfiles";
+import {
+ collectHomeProfile,
+ readHomeUsageProfiles,
+ type HomeUsageProfile,
+} from "../agents/homeProfileUsage";
import { createLocalUsageCollectors, type LocalUsageCollector } from "./localUsageCollectors";
import { createNodeUsageHost } from "./usageHost";
@@ -68,6 +73,11 @@ interface UsageCacheFile {
snapshots?: UsageSnapshot[];
}
+interface UsageProfiles {
+ claude: Map;
+ home: Map;
+}
+
function hasDisplayableUsage(snapshot: UsageSnapshot): boolean {
return (
snapshot.windows.length > 0 ||
@@ -96,10 +106,10 @@ export class UsageService {
this.loadCache();
}
- private defaultProviderIds(): string[] {
+ private defaultProviderIds(profiles: UsageProfiles): string[] {
const baseIds = [...(this.options.providerIds ?? DEFAULT_PROVIDER_IDS)];
if (this.options.providerIds) return baseIds;
- return [...baseIds, ...this.claudeUsageProfiles().keys()];
+ return [...baseIds, ...profiles.claude.keys(), ...profiles.home.keys()];
}
/** Read shared settings from disk (defaults if absent). */
@@ -112,32 +122,37 @@ export class UsageService {
}
}
- /** Read the usage policy from the shared settings file (defaults if absent). */
- private readUsageSettings(): UsageSettings {
- return this.readSharedSettings().usage;
- }
-
- private claudeUsageProfiles(): Map {
- return readClaudeUsageProfiles(this.readSharedSettings());
+ private usageProfiles(settings: SharedSettings): UsageProfiles {
+ return {
+ claude: readClaudeUsageProfiles(settings),
+ home: readHomeUsageProfiles(settings),
+ };
}
/** A provider id this service can collect (package registry or supervisor-local). */
- private isSupported(id: string): boolean {
+ private isSupported(id: string, profiles: UsageProfiles): boolean {
return (
- this.registry.has(id) || this.localCollectors.has(id) || this.claudeUsageProfiles().has(id)
+ this.registry.has(id) ||
+ this.localCollectors.has(id) ||
+ profiles.claude.has(id) ||
+ profiles.home.has(id)
);
}
/** Default providers minus the user's per-provider opt-outs, intersected with what we support. */
- private enabledProviderIds(disabled: readonly string[]): string[] {
- return this.defaultProviderIds().filter((id) => !disabled.includes(id) && this.isSupported(id));
+ private enabledProviderIds(disabled: readonly string[], profiles: UsageProfiles): string[] {
+ return this.defaultProviderIds(profiles).filter(
+ (id) => !disabled.includes(id) && this.isSupported(id, profiles),
+ );
}
private resolveIds(payload: ProviderUsagePayload): string[] {
+ const settings = this.readSharedSettings();
+ const profiles = this.usageProfiles(settings);
if (payload.providerIds?.length) {
- return [...new Set(payload.providerIds)].filter((id) => this.isSupported(id));
+ return [...new Set(payload.providerIds)].filter((id) => this.isSupported(id, profiles));
}
- return this.enabledProviderIds(this.readUsageSettings().disabledProviders);
+ return this.enabledProviderIds(settings.usage.disabledProviders, profiles);
}
/**
@@ -211,14 +226,18 @@ export class UsageService {
}
private async runRefresh(ids: string[]): Promise {
- const claudeProfiles = this.claudeUsageProfiles();
+ const settings = this.readSharedSettings();
+ const profiles = this.usageProfiles(settings);
+ const claudeProfiles = profiles.claude;
+ const homeProfiles = profiles.home;
const registryIds = ids.filter((id) => this.registry.has(id));
const localIds = ids.filter((id) => this.localCollectors.has(id));
const claudeProfileIds = ids.filter((id) => claudeProfiles.has(id));
+ const homeProfileIds = ids.filter((id) => homeProfiles.has(id));
// The registry HTTP batch and the supervisor-local collectors are independent
// of each other, so run both groups concurrently rather than waiting out the
// (rate-limited, slow) HTTP batch before starting the local scans.
- const [registrySnaps, localSnaps, claudeProfileSnaps] = await Promise.all([
+ const [registrySnaps, localSnaps, claudeProfileSnaps, homeProfileSnaps] = await Promise.all([
this.registry.collectAll(registryIds, this.host),
Promise.all(localIds.map((id) => this.collectLocal(id))),
Promise.all(
@@ -227,11 +246,20 @@ export class UsageService {
return profile ? [collectClaudeProfile(profile, this.host)] : [];
}),
),
+ Promise.all(
+ homeProfileIds.flatMap((id) => {
+ const profile = homeProfiles.get(id);
+ return profile ? [collectHomeProfile(profile, this.host)] : [];
+ }),
+ ),
]);
- let snapshots = [...registrySnaps, ...localSnaps, ...claudeProfileSnaps].map((snap) =>
- this.preserveOnTransientFailure(snap),
- );
- if (this.readUsageSettings().showEstimatedCost) {
+ let snapshots = [
+ ...registrySnaps,
+ ...localSnaps,
+ ...claudeProfileSnaps,
+ ...homeProfileSnaps,
+ ].map((snap) => this.preserveOnTransientFailure(snap));
+ if (settings.usage.showEstimatedCost) {
snapshots = await this.withEstimatedCost(snapshots, claudeProfiles);
}
for (const snapshot of snapshots) {
@@ -314,7 +342,8 @@ export class UsageService {
*/
startAutoRefresh(): void {
if (this.autoRefreshTimer || this.stopped) return;
- this.scheduleNextTick(this.nextTickDelayMs(this.readUsageSettings()));
+ const settings = this.readSharedSettings();
+ this.scheduleNextTick(this.nextTickDelayMs(settings.usage, this.usageProfiles(settings)));
}
stop(): void {
@@ -345,9 +374,9 @@ export class UsageService {
* from the snapshot's `fetchedAt`, so one tick can refresh a fast provider
* while leaving a slow one untouched.
*/
- private dueProviderIds(settings: UsageSettings): string[] {
+ private dueProviderIds(settings: UsageSettings, profiles: UsageProfiles): string[] {
const now = this.host.now();
- return this.enabledProviderIds(settings.disabledProviders).filter((id) => {
+ return this.enabledProviderIds(settings.disabledProviders, profiles).filter((id) => {
const snap = this.snapshots.get(id);
if (!snap) return true;
// Skip providers inside their rate-limit backoff so the auto-refresh tick
@@ -363,9 +392,9 @@ export class UsageService {
* are slower). Falls back to the global default when nothing is enabled, which
* keeps the loop alive so re-enabling resumes without a restart.
*/
- private nextTickDelayMs(settings: UsageSettings): number {
+ private nextTickDelayMs(settings: UsageSettings, profiles: UsageProfiles): number {
let min = Infinity;
- for (const id of this.enabledProviderIds(settings.disabledProviders)) {
+ for (const id of this.enabledProviderIds(settings.disabledProviders, profiles)) {
min = Math.min(min, this.effectiveIntervalMs(settings, id));
}
return Number.isFinite(min) ? min : this.intervalMs(settings);
@@ -378,9 +407,10 @@ export class UsageService {
*/
async refreshDueProviders(): Promise {
if (this.stopped) return [];
- const settings = this.readUsageSettings();
+ const sharedSettings = this.readSharedSettings();
+ const settings = sharedSettings.usage;
if (!settings.autoRefresh) return [];
- const ids = this.dueProviderIds(settings);
+ const ids = this.dueProviderIds(settings, this.usageProfiles(sharedSettings));
if (ids.length === 0) return [];
try {
await this.refreshProviderUsage({ providerIds: ids });
@@ -403,7 +433,8 @@ export class UsageService {
await this.refreshDueProviders();
// Keep the loop alive even when auto-refresh is off so re-enabling (or an
// interval change) resumes without a restart.
- this.scheduleNextTick(this.nextTickDelayMs(this.readUsageSettings()));
+ const settings = this.readSharedSettings();
+ this.scheduleNextTick(this.nextTickDelayMs(settings.usage, this.usageProfiles(settings)));
}
private loadCache(): void {