mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-21 21:31:12 +02:00
feat: add code mode settings tab with agent install/auth checks
This commit is contained in:
parent
530d807fab
commit
ad1b949262
10 changed files with 497 additions and 23 deletions
|
|
@ -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 { IModelConfigRepo } from '@x/core/dist/models/repo.js';
|
||||||
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
|
||||||
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/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 { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
|
||||||
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
|
||||||
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.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();
|
const config = await repo.getConfig();
|
||||||
return { enabled: config.enabled };
|
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) => {
|
'granola:setConfig': async (_event, args) => {
|
||||||
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
const repo = container.resolve<IGranolaConfigRepo>('granolaConfigRepo');
|
||||||
await repo.setConfig({ enabled: args.enabled });
|
await repo.setConfig({ enabled: args.enabled });
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,7 @@ function ChatInputInner({
|
||||||
const [workDir, setWorkDir] = useState<string | null>(null)
|
const [workDir, setWorkDir] = useState<string | null>(null)
|
||||||
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
const [codingAgent, setCodingAgent] = useState<'claude' | 'codex'>('claude')
|
||||||
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
const [codeModeEnabled, setCodeModeEnabled] = useState(false)
|
||||||
|
const [codeModeFeatureEnabled, setCodeModeFeatureEnabled] = useState(false)
|
||||||
|
|
||||||
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
// When a run exists, freeze the dropdown to the run's resolved model+provider.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -259,6 +260,25 @@ function ChatInputInner({
|
||||||
return () => window.removeEventListener('models-config-changed', handler)
|
return () => window.removeEventListener('models-config-changed', handler)
|
||||||
}, [loadModelConfig])
|
}, [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
|
// 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
|
// 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.
|
// flip the pill on with the detected agent so the UI reflects what's happening.
|
||||||
|
|
@ -712,7 +732,7 @@ function ChatInputInner({
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
{codeModeEnabled ? (
|
{codeModeFeatureEnabled && (codeModeEnabled ? (
|
||||||
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
@ -757,7 +777,7 @@ function ChatInputInner({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
|
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
))}
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{lockedModel ? (
|
{lockedModel ? (
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { useState, useEffect, useCallback, useMemo } 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -26,7 +26,7 @@ import { toast } from "sonner"
|
||||||
import { AccountSettings } from "@/components/settings/account-settings"
|
import { AccountSettings } from "@/components/settings/account-settings"
|
||||||
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-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 {
|
interface TabConfig {
|
||||||
id: ConfigTab
|
id: ConfigTab
|
||||||
|
|
@ -70,6 +70,12 @@ const tabs: TabConfig[] = [
|
||||||
path: "config/security.json",
|
path: "config/security.json",
|
||||||
description: "Configure allowed shell commands",
|
description: "Configure allowed shell commands",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "code-mode",
|
||||||
|
label: "Code Mode",
|
||||||
|
icon: Terminal,
|
||||||
|
description: "Delegate coding tasks to Claude Code or Codex",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "appearance",
|
id: "appearance",
|
||||||
label: "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 & 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 ---
|
// --- Main Settings Dialog ---
|
||||||
|
|
||||||
export function SettingsDialog({ children, defaultTab = "account", open: controlledOpen, onOpenChange }: SettingsDialogProps) {
|
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) => {
|
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)!
|
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||||
if (!tabConfig.path) return
|
if (!tabConfig.path) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
@ -1803,7 +1992,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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" ? (
|
{activeTab === "account" ? (
|
||||||
<AccountSettings dialogOpen={open} />
|
<AccountSettings dialogOpen={open} />
|
||||||
) : activeTab === "connections" ? (
|
) : activeTab === "connections" ? (
|
||||||
|
|
@ -1828,6 +2017,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
|
||||||
<AppearanceSettings />
|
<AppearanceSettings />
|
||||||
) : activeTab === "help" ? (
|
) : activeTab === "help" ? (
|
||||||
<HelpSettings />
|
<HelpSettings />
|
||||||
|
) : activeTab === "code-mode" ? (
|
||||||
|
<CodeModeSettings dialogOpen={open} />
|
||||||
) : loading ? (
|
) : loading ? (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
Loading...
|
Loading...
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js
|
||||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||||
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
|
||||||
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.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());
|
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
|
// Conditionally include Composio-related instruction sections
|
||||||
const emailDraftSuffix = composioEnabled
|
const emailDraftSuffix = composioEnabled
|
||||||
? ` Do NOT load this skill for reading, fetching, or checking emails — use the \`composio-integration\` skill for that instead.`
|
? ` 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.
|
**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.
|
**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 */
|
/** Keep backward-compatible export for any external consumers */
|
||||||
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
|
||||||
|
|
||||||
/**
|
|
||||||
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
|
|
||||||
*/
|
|
||||||
let cachedInstructions: string | null = null;
|
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 {
|
export function invalidateCopilotInstructionsCache(): void {
|
||||||
cachedInstructions = null;
|
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> {
|
export async function buildCopilotInstructions(): Promise<string> {
|
||||||
if (cachedInstructions !== null) return cachedInstructions;
|
if (cachedInstructions !== null) return cachedInstructions;
|
||||||
const composioEnabled = await isComposioConfigured();
|
const composioEnabled = await isComposioConfigured();
|
||||||
const catalog = composioEnabled
|
let codeModeEnabled = false;
|
||||||
? skillCatalog
|
try {
|
||||||
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
|
const repo = container.resolve<ICodeModeConfigRepo>('codeModeConfigRepo');
|
||||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
|
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();
|
const composioPrompt = await getComposioToolsPrompt();
|
||||||
cachedInstructions = composioPrompt
|
cachedInstructions = composioPrompt
|
||||||
? baseInstructions + '\n' + composioPrompt
|
? baseInstructions + '\n' + composioPrompt
|
||||||
|
|
|
||||||
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
3
apps/x/packages/core/src/code-mode/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { CodeModeConfig, CodeModeAgentStatus, AgentStatus } from './types.js';
|
||||||
|
export { FSCodeModeConfigRepo, type ICodeModeConfigRepo } from './repo.js';
|
||||||
|
export { checkCodeModeAgentStatus } from './status.js';
|
||||||
42
apps/x/packages/core/src/code-mode/repo.ts
Normal file
42
apps/x/packages/core/src/code-mode/repo.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
157
apps/x/packages/core/src/code-mode/status.ts
Normal file
157
apps/x/packages/core/src/code-mode/status.ts
Normal 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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
18
apps/x/packages/core/src/code-mode/types.ts
Normal file
18
apps/x/packages/core/src/code-mode/types.ts
Normal 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>;
|
||||||
|
|
@ -11,6 +11,7 @@ import { IAgentRuntime, AgentRuntime } from "../agents/runtime.js";
|
||||||
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
import { FSOAuthRepo, IOAuthRepo } from "../auth/repo.js";
|
||||||
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
import { FSClientRegistrationRepo, IClientRegistrationRepo } from "../auth/client-repo.js";
|
||||||
import { FSGranolaConfigRepo, IGranolaConfigRepo } from "../knowledge/granola/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 { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js";
|
||||||
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
|
||||||
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
|
||||||
|
|
@ -38,6 +39,7 @@ container.register({
|
||||||
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
oauthRepo: asClass<IOAuthRepo>(FSOAuthRepo).singleton(),
|
||||||
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
clientRegistrationRepo: asClass<IClientRegistrationRepo>(FSClientRegistrationRepo).singleton(),
|
||||||
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
granolaConfigRepo: asClass<IGranolaConfigRepo>(FSGranolaConfigRepo).singleton(),
|
||||||
|
codeModeConfigRepo: asClass<ICodeModeConfigRepo>(FSCodeModeConfigRepo).singleton(),
|
||||||
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
|
||||||
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
|
||||||
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
|
||||||
|
|
|
||||||
|
|
@ -416,6 +416,27 @@ const ipcSchemas = {
|
||||||
enabled: z.boolean(),
|
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': {
|
'granola:setConfig': {
|
||||||
req: z.object({
|
req: z.object({
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue