feat: add code mode settings tab with agent install/auth checks

This commit is contained in:
Gagancreates 2026-05-19 22:43:52 +05:30
parent 530d807fab
commit ad1b949262
10 changed files with 497 additions and 23 deletions

View file

@ -30,6 +30,9 @@ import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
import { checkCodeModeAgentStatus } from '@x/core/dist/code-mode/status.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
@ -600,6 +603,20 @@ export function setupIpcHandlers() {
const config = await repo.getConfig();
return { enabled: config.enabled };
},
'codeMode:getConfig': async () => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
const config = await repo.getConfig();
return { enabled: config.enabled };
},
'codeMode:setConfig': async (_event, args) => {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
await repo.setConfig({ enabled: args.enabled });
invalidateCopilotInstructionsCache();
return { success: true };
},
'codeMode:checkAgentStatus': async () => {
return await checkCodeModeAgentStatus();
},
'granola:setConfig': async (_event, args) => {
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
await repo.setConfig({ enabled: args.enabled });

View file

@ -177,6 +177,7 @@ function ChatInputInner({
const [workDir, setWorkDir] = useState<string | null>(null)
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
// When a run exists, freeze the dropdown to the run's resolved model+provider.
useEffect(() => {
@ -259,6 +260,25 @@ function ChatInputInner({
return () => window.removeEventListener('models-config-changed', handler)
}, [loadModelConfig])
// Load the global code-mode feature flag (from settings) and stay in sync.
useEffect(() => {
const load = () => {
window.ipc.invoke('codeMode:getConfig', null)
.then((r) => setCodeModeFeatureEnabled(r.enabled))
.catch(() => setCodeModeFeatureEnabled(false))
}
load()
window.addEventListener('code-mode-config-changed', load)
return () => window.removeEventListener('code-mode-config-changed', load)
}, [])
// If the feature is turned off in settings, also turn off any per-conversation chip.
useEffect(() => {
if (!codeModeFeatureEnabled && codeModeEnabled) {
setCodeModeEnabled(false)
}
}, [codeModeFeatureEnabled, codeModeEnabled])
// Listen for coding-agent runs that were triggered without the explicit code-mode
// toggle. App.tsx dispatches this when it sees an acpx executeCommand fire. We
// flip the pill on with the detected agent so the UI reflects what's happening.
@ -712,7 +732,7 @@ function ChatInputInner({
</Tooltip>
)
)}
{codeModeEnabled ? (
{codeModeFeatureEnabled && (codeModeEnabled ? (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip>
<TooltipTrigger asChild>
@ -757,7 +777,7 @@ function ChatInputInner({
</TooltipTrigger>
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip>
)}
))}
<div className="flex-1" />
{lockedModel ? (
<span

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw } from "lucide-react"
import {
Dialog,
@ -26,7 +26,7 @@ import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "appearance" | "note-tagging" | "help"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -70,6 +70,12 @@ const tabs: TabConfig[] = [
path: "config/security.json",
description: "Configure allowed shell commands",
},
{
id: "code-mode",
label: "Code Mode",
icon: Terminal,
description: "Delegate coding tasks to Claude Code or Codex",
},
{
id: "appearance",
label: "Appearance",
@ -1648,6 +1654,189 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Code Mode Settings ---
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
function AgentStatusRow({
name,
installLink,
status,
}: {
name: string
installLink: string
status: AgentStatus | null
}) {
const ready = status?.installed && status?.signedIn
return (
<div className="rounded-md border px-3 py-2.5 flex items-center gap-3">
<Terminal className="size-4 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{name}</div>
<div className="text-xs text-muted-foreground mt-0.5 flex items-center gap-3">
<span className={cn("inline-flex items-center gap-1", status?.installed ? "text-green-600" : "text-muted-foreground")}>
{status?.installed ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Installed
</span>
<span className={cn("inline-flex items-center gap-1", status?.signedIn ? "text-green-600" : "text-muted-foreground")}>
{status?.signedIn ? <CheckCircle2 className="size-3" /> : <X className="size-3" />}
Signed in
</span>
</div>
</div>
{ready ? (
<span className="rounded-full bg-green-500/10 px-2 py-0.5 text-[10px] font-medium leading-none text-green-600">
Ready
</span>
) : (
<a
href={installLink}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline shrink-0"
>
Install &amp; sign in
</a>
)}
</div>
)
}
function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [enabled, setEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [status, setStatus] = useState<CodeModeAgentStatus | null>(null)
const [statusLoading, setStatusLoading] = useState(false)
const loadStatus = useCallback(async () => {
setStatusLoading(true)
try {
const result = await window.ipc.invoke("codeMode:checkAgentStatus", null)
setStatus(result)
} catch {
setStatus(null)
} finally {
setStatusLoading(false)
}
}, [])
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
async function load() {
setLoading(true)
try {
const result = await window.ipc.invoke("codeMode:getConfig", null)
if (!cancelled) setEnabled(result.enabled)
} catch {
if (!cancelled) setEnabled(false)
} finally {
if (!cancelled) setLoading(false)
}
}
load()
loadStatus()
return () => { cancelled = true }
}, [dialogOpen, loadStatus])
const handleToggle = useCallback(async (next: boolean) => {
setSaving(true)
setEnabled(next)
try {
await window.ipc.invoke("codeMode:setConfig", { enabled: next })
window.dispatchEvent(new Event("code-mode-config-changed"))
toast.success(next ? "Code mode enabled" : "Code mode disabled")
} catch {
setEnabled(!next)
toast.error("Failed to update code mode")
} finally {
setSaving(false)
}
}, [])
const anyReady = status?.claude.installed && status?.claude.signedIn
|| status?.codex.installed && status?.codex.signedIn
if (loading) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
<Loader2 className="size-4 animate-spin mr-2" />
Loading...
</div>
)
}
return (
<div className="space-y-5">
<div className="space-y-2 text-sm text-muted-foreground leading-relaxed">
<p>
<strong className="text-foreground">Code mode</strong> lets the assistant delegate coding tasks
to <strong className="text-foreground">Claude Code</strong> or <strong className="text-foreground">Codex</strong> running
on your machine. Pick the agent inline from the composer; the assistant calls it via
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">acpx</code>
and streams results back into chat.
</p>
<p>
Requires an active <strong className="text-foreground">Claude Code</strong> subscription or
a <strong className="text-foreground">ChatGPT/Codex</strong> subscription. You can have one or both.
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Agent status</span>
<button
onClick={() => { void loadStatus() }}
disabled={statusLoading}
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{statusLoading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
Re-check
</button>
</div>
<div className="space-y-2">
<AgentStatusRow
name="Claude Code"
installLink="https://claude.ai/code"
status={status?.claude ?? null}
/>
<AgentStatusRow
name="Codex"
installLink="https://developers.openai.com/codex/cli"
status={status?.codex ?? null}
/>
</div>
</div>
<div className="rounded-md border px-3 py-3 flex items-start gap-3">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">Enable code mode</div>
<div className="text-xs text-muted-foreground mt-0.5">
Shows the code mode chip in the composer and lets the assistant delegate to your installed agents.
</div>
</div>
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={saving}
/>
</div>
{enabled && status && !anyReady && (
<div className="rounded-md border border-amber-500/40 bg-amber-50/60 dark:bg-amber-950/20 px-3 py-2.5 flex items-start gap-2 text-xs">
<AlertTriangle className="size-4 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
<div className="text-amber-900 dark:text-amber-200">
Neither Claude Code nor Codex is ready. Install at least one and sign in with a subscription
account, then click Re-check.
</div>
</div>
)}
</div>
)
}
// --- Main Settings Dialog ---
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
@ -1695,7 +1884,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
}
const loadConfig = useCallback(async (tab: ConfigTab) => {
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help") return
if (tab === "appearance" || tab === "models" || tab === "note-tagging" || tab === "account" || tab === "connections" || tab === "help" || tab === "code-mode") return
const tabConfig = tabs.find((t) => t.id === tab)!
if (!tabConfig.path) return
setLoading(true)
@ -1803,7 +1992,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (
@ -1828,6 +2017,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<AppearanceSettings />
) : activeTab === "help" ? (
<HelpSettings />
) : activeTab === "code-mode" ? (
<CodeModeSettings dialogOpen={open} />
) : loading ? (
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
Loading...

View file

@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ICodeModeConfigRepo } from "../../code-mode/repo.js";
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@ -29,7 +31,7 @@ Load the \`composio-integration\` skill when the user asks to interact with any
`;
}
function buildStaticInstructions(composioEnabled: boolean, catalog: string): string {
function buildStaticInstructions(composioEnabled: boolean, catalog: string, codeModeEnabled: boolean = true): string {
// Conditionally include Composio-related instruction sections
const emailDraftSuffix = composioEnabled
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
@ -80,7 +82,9 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
**Document Collaboration:** When users ask you to work on a document, collaborate on writing, create a new document, edit/refine existing notes, or say things like "let's work on [X]", "help me write [X]", "create a doc for [X]", or "let's draft [X]", you MUST load the \`doc-collab\` skill first. **This applies even for small one-off edits** — the skill carries the canonical *terse-and-scannable* writing style for the knowledge base, and that style applies whether you're authoring a fresh note or fixing a single section. Load it before writing anything into a note.
**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task **including simple things like "create a .c file" or "write a hello-world in Python"** your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.
${codeModeEnabled
? `**Code with Agents:** When users ask you to write code, build a project, create a script, fix a bug, or do any software development task — **including simple things like "create a .c file" or "write a hello-world in Python"** — your FIRST action MUST be \`loadSkill('code-with-agents')\`. Do NOT reach for \`executeCommand\` (PowerShell / bash / shell) or any workspace file tool to do code work yourself before loading this skill. The skill decides whether to delegate to Claude Code / Codex (via acpx) or hand control back to you, and it presents the user a one-click choice when needed. Paths outside the Rowboat workspace root (e.g. \`G:/...\`, \`~/projects/...\`) are NORMAL for coding tasks — do NOT raise "outside workspace" concerns or fall back to your own tools.`
: `**Code with Agents (disabled):** Code mode is currently OFF in the user's settings. Do NOT load \`code-with-agents\` and do NOT call acpx. Handle coding requests yourself with your normal tools if you can. After answering, add a final line letting the user know they can delegate coding to Claude Code or Codex by enabling Code Mode in Settings → Code Mode.`}
**App Control:** When users ask you to open notes, show the bases or graph view, filter or search notes, or manage saved views, load the \`app-navigation\` skill first. It provides structured guidance for navigating the app UI and controlling the knowledge base view.
@ -312,30 +316,29 @@ Never output raw file paths in plain text when they could be wrapped in a filepa
/** Keep backward-compatible export for any external consumers */
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
/**
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
*/
let cachedInstructions: string | null = null;
/**
* Invalidate the cached instructions so the next buildCopilotInstructions() call
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
*/
export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null;
}
/**
* Build full copilot instructions with dynamic Composio tools section.
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
*/
export async function buildCopilotInstructions(): Promise<string> {
if (cachedInstructions !== null) return cachedInstructions;
const composioEnabled = await isComposioConfigured();
const catalog = composioEnabled
? skillCatalog
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
let codeModeEnabled = false;
try {
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
codeModeEnabled = (await repo.getConfig()).enabled;
} catch {
// repo unavailable — default to disabled
}
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt

View file

@ -0,0 +1,3 @@
export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
export { checkCodeModeAgentStatus } from './status.js';

View file

@ -0,0 +1,42 @@
import fs from 'fs/promises';
import path from 'path';
import { WorkDir } from '../config/config.js';
import { CodeModeConfig } from './types.js';
export interface ICodeModeConfigRepo {
getConfig(): Promise<CodeModeConfig>;
setConfig(config: CodeModeConfig): Promise<void>;
}
export class FSCodeModeConfigRepo implements ICodeModeConfigRepo {
private readonly configPath = path.join(WorkDir, 'config', 'code-mode.json');
private readonly defaultConfig: CodeModeConfig = { enabled: false };
constructor() {
this.ensureConfigFile();
}
private async ensureConfigFile(): Promise<void> {
try {
await fs.access(this.configPath);
} catch {
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
await fs.writeFile(this.configPath, JSON.stringify(this.defaultConfig, null, 2));
}
}
async getConfig(): Promise<CodeModeConfig> {
try {
const content = await fs.readFile(this.configPath, 'utf8');
return CodeModeConfig.parse(JSON.parse(content));
} catch {
return this.defaultConfig;
}
}
async setConfig(config: CodeModeConfig): Promise<void> {
const validated = CodeModeConfig.parse(config);
await fs.mkdir(path.dirname(this.configPath), { recursive: true });
await fs.writeFile(this.configPath, JSON.stringify(validated, null, 2));
}
}

View file

@ -0,0 +1,157 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import os from 'os';
import path from 'path';
import fs from 'fs/promises';
import { existsSync } from 'fs';
import { CodeModeAgentStatus } from './types.js';
const execAsync = promisify(exec);
// Where claude.cmd / codex.cmd typically live when installed via npm/pnpm/yarn.
// We scan these directly because Electron's spawned shell sometimes doesn't
// inherit the user's full PATH (especially on macOS GUI launches, and even on
// Windows when global npm prefix isn't propagated to system PATH).
function commonInstallPaths(binary: string): string[] {
const home = os.homedir();
if (process.platform === 'win32') {
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
return [
path.join(appData, 'npm', `${binary}.cmd`),
path.join(appData, 'npm', `${binary}.exe`),
path.join(localAppData, 'npm', `${binary}.cmd`),
path.join(localAppData, 'pnpm', `${binary}.cmd`),
path.join(home, 'AppData', 'Roaming', 'pnpm', `${binary}.cmd`),
path.join(programFiles, 'nodejs', `${binary}.cmd`),
path.join(home, '.volta', 'bin', `${binary}.cmd`),
];
}
return [
'/usr/local/bin',
'/opt/homebrew/bin', // Apple Silicon Homebrew
'/usr/bin',
path.join(home, '.npm-global', 'bin'),
path.join(home, '.local', 'bin'),
path.join(home, '.volta', 'bin'),
path.join(home, '.nvm', 'versions', 'node'), // partial; nvm has versioned subdirs
path.join(home, 'bin'),
].map(dir => path.join(dir, binary));
}
async function probeShell(binary: string): Promise<boolean> {
try {
if (process.platform === 'win32') {
const { stdout } = await execAsync(`where ${binary}`, { timeout: 5000 });
return stdout.trim().length > 0;
}
// Login shell so ~/.zprofile / ~/.bashrc PATH additions are visible —
// essential for Homebrew, nvm, asdf, volta installs on macOS GUI launches.
const { stdout } = await execAsync(`/bin/sh -lc 'command -v ${binary}'`, { timeout: 5000 });
return stdout.trim().length > 0;
} catch {
return false;
}
}
async function isInstalled(binary: string): Promise<boolean> {
if (await probeShell(binary)) return true;
// Fallback: scan well-known install locations directly.
for (const candidate of commonInstallPaths(binary)) {
if (existsSync(candidate)) return true;
}
return false;
}
function decodeJwtPayload(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length < 2) return null;
const padded = parts[1].replace(/-/g, '+').replace(/_/g, '/');
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
const json = Buffer.from(padded + pad, 'base64').toString('utf-8');
const parsed = JSON.parse(json);
return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
// Validates Claude Code auth: ~/.claude/.credentials.json (or ~/.config fallback).
// Considered signed in if any of: valid API key, unexpired access token, or
// presence of a refresh token (which can mint a new access token transparently).
async function checkClaudeSignedIn(): Promise<boolean> {
const home = os.homedir();
const candidates = [
path.join(home, '.claude', '.credentials.json'),
path.join(home, '.config', 'claude', '.credentials.json'),
];
for (const full of candidates) {
try {
const raw = await fs.readFile(full, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const oauth = parsed.claudeAiOauth as Record<string, unknown> | undefined;
if (oauth) {
const access = typeof oauth.accessToken === 'string' ? oauth.accessToken : '';
const refresh = typeof oauth.refreshToken === 'string' ? oauth.refreshToken : '';
if (refresh.length > 0) return true;
if (access.length > 0) {
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt > 0 && oauth.expiresAt < Date.now()) {
return false;
}
return true;
}
}
if (typeof parsed.apiKey === 'string' && parsed.apiKey.length > 10) return true;
if (typeof parsed.accessToken === 'string' && parsed.accessToken.length > 10) return true;
} catch {
// try next candidate
}
}
return false;
}
// Validates Codex auth at ~/.codex/auth.json on all platforms.
// Considered signed in if API key set, or a refresh_token / access_token
// exists. id_token expiry is intentionally NOT used as a rejection signal —
// id_tokens are short-lived (~1h) but refresh_tokens persist for weeks.
async function checkCodexSignedIn(): Promise<boolean> {
const home = os.homedir();
const full = path.join(home, '.codex', 'auth.json');
try {
const raw = await fs.readFile(full, 'utf-8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
if (typeof parsed.OPENAI_API_KEY === 'string' && parsed.OPENAI_API_KEY.length > 10) return true;
const tokens = parsed.tokens as Record<string, unknown> | undefined;
if (tokens) {
const refresh = typeof tokens.refresh_token === 'string' ? tokens.refresh_token : '';
const access = typeof tokens.access_token === 'string' ? tokens.access_token : '';
const id = typeof tokens.id_token === 'string' ? tokens.id_token : '';
if (refresh.length > 0 || access.length > 0 || id.length > 0) return true;
}
} catch {
// file missing or unreadable
}
return false;
}
// Exported for diagnostics — silenced unused-var warning by re-export only.
export { decodeJwtPayload };
export async function checkCodeModeAgentStatus(): Promise<CodeModeAgentStatus> {
const [claudeInstalled, codexInstalled, claudeSignedIn, codexSignedIn] = await Promise.all([
isInstalled('claude'),
isInstalled('codex'),
checkClaudeSignedIn(),
checkCodexSignedIn(),
]);
return {
claude: { installed: claudeInstalled, signedIn: claudeSignedIn },
codex: { installed: codexInstalled, signedIn: codexSignedIn },
};
}

View file

@ -0,0 +1,18 @@
import z from "zod";
export const CodeModeConfig = z.object({
enabled: z.boolean(),
});
export type CodeModeConfig = z.infer<typeof CodeModeConfig>;
export const AgentStatus = z.object({
installed: z.boolean(),
signedIn: z.boolean(),
});
export type AgentStatus = z.infer<typeof AgentStatus>;
export const CodeModeAgentStatus = z.object({
claude: AgentStatus,
codex: AgentStatus,
});
export type CodeModeAgentStatus = z.infer<typeof CodeModeAgentStatus>;

View file

@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/repo.js";
import { FSCodeModeConfigRepo, ICodeModeConfigRepo } from "../code-mode/repo.js";
import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
@ -38,6 +39,7 @@ container.register({
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(),
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),

View file

@ -416,6 +416,27 @@ const ipcSchemas = {
enabled: z.boolean(),
}),
},
'codeMode:getConfig': {
req: z.null(),
res: z.object({
enabled: z.boolean(),
}),
},
'codeMode:setConfig': {
req: z.object({
enabled: z.boolean(),
}),
res: z.object({
success: z.literal(true),
}),
},
'codeMode:checkAgentStatus': {
req: z.null(),
res: z.object({
claude: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
codex: z.object({ installed: z.boolean(), signedIn: z.boolean() }),
}),
},
'granola:setConfig': {
req: z.object({
enabled: z.boolean(),