Code Mode: in-chat toggle, settings tab, and permission/command UX (#572)

* feat: add in-chat code mode toggle with claude/codex swap

* feat: show agent and add swap-and-retry on acpx permission card

* style: reorder permission card buttons (approve, deny, swap)

* feat: add tooltips to composer plus and web search buttons

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

* feat: show sign-in command when agent installed but signed out

* style: refine code-mode permission and command block UX

- Render permission block before the command block
- Collapse permission details after a response; click header to expand
- Drop status icons/badge; use minimal green / bold red blocks
- Auto-collapse the running command block once it completes

* feat: rotating progress labels for code-mode commands; darker tool borders

- Code-mode (acpx) command block shows status-aware labels: rotating
  'Working on the task…' phrases (5s each, holding on the last) while
  running, then 'Completed the task' / "Couldn't complete the task"
- Darken outer border on all tool blocks in light and dark modes

* fix: detect Claude Code sign-in via macOS Keychain

On macOS, Claude Code stores OAuth credentials in the login Keychain
(service 'Claude Code-credentials'), not in ~/.claude/.credentials.json.
Read the Keychain as a fallback so signed-in Mac users are detected.

* feat: persistent per-chat sessions for code-mode coding agents

- Use a named acpx session (rowboat-<runId>) per chat so follow-up
  coding requests resume the same agent and keep context
- Create the session once at chat start (sessions new --name), then
  prompt with -s <name>; reuse on follow-ups (no re-create)
- Drop the redundant in-chat 'reply yes' confirmation (the executeCommand
  permission card is the confirmation)
- Code-mode output uses plain-text paths (overrides global filepath rule)
- On not-installed/auth errors, point user to Settings -> Code Mode

* fix: code-mode session creation uses idempotent ensure, run sequentially

- Use 'sessions ensure --name' instead of 'sessions new' so reopening a
  chat resumes the existing session instead of erroring on a name clash
- Create the session and run the prompt as separate sequential calls so
  the permission/command blocks render one at a time (not all at once)

* fix: reliable Claude Code session resume on Windows (avoid claude.cmd EINVAL)

Resuming a code-mode chat after restarting the app spawns a fresh ACP
agent. On Windows + Node >=20.12 the bridge spawning claude.cmd throws
EINVAL, so the session queue owner fails to start. Rowboat injects
CLAUDE_CODE_EXECUTABLE=claude.exe to dodge this, but the override didn't
reliably reach the spawn. Windows-only; no-op on macOS/Linux.

- executeCommand now accepts an env override and the non-abortable
  fallback path passes it through (was silently dropped)
- resolveClaudeExeOnWindows also scans known npm/pnpm/volta global bin
  dirs, not just PATH (Electron's runtime PATH can omit them)
- add --timeout 600 to acpx prompt commands so a genuine stall fails
  cleanly instead of hanging on 'Running' forever
This commit is contained in:
gagan 2026-05-28 14:52:09 +05:30 committed by GitHub
parent b89b91258e
commit 537b6f66bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1084 additions and 171 deletions

View file

@ -31,6 +31,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';
@ -526,7 +529,7 @@ export function setupIpcHandlers() {
return runsCore.createRun(args);
},
'runs:createMessage': async (_event, args) => {
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext) };
return { messageId: await runsCore.createMessage(args.runId, args.message, args.voiceInput, args.voiceOutput, args.searchEnabled, args.middlePaneContext, args.codeMode) };
},
'runs:authorizePermission': async (_event, args) => {
await runsCore.authorizePermission(args.runId, args.authorization);
@ -630,6 +633,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

@ -966,7 +966,7 @@ function App() {
voice.start()
}, [voice])
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean) => Promise<void>) | null>(null)
const handlePromptSubmitRef = useRef<((message: PromptInputMessage, mentions?: FileMention[], stagedAttachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => Promise<void>) | null>(null)
const pendingVoiceInputRef = useRef(false)
// Palette: per-tab editor handles for capturing cursor context on Cmd+K, and pending payload
@ -2190,6 +2190,19 @@ function App() {
status: 'running',
timestamp: Date.now(),
}])
// Detect acpx-driven coding-agent runs so the composer can retroactively
// flip code mode on with the right agent (when the user reached the skill
// via plain prompt rather than the explicit toggle).
if (llmEvent.toolName === 'executeCommand') {
const input = llmEvent.input as { command?: unknown } | undefined
const cmd = typeof input?.command === 'string' ? input.command : ''
const match = cmd.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b/)
if (match) {
window.dispatchEvent(new CustomEvent('code-mode-detected', {
detail: { runId: event.runId, agent: match[1] as 'claude' | 'codex' },
}))
}
}
} else if (llmEvent.type === 'finish-step') {
const nextUsage = normalizeUsage(llmEvent.usage)
if (nextUsage) {
@ -2304,7 +2317,7 @@ function App() {
return next
})
if (event.toolCallId && event.toolName !== 'executeCommand') {
if (event.toolCallId) {
setToolOpenForTab(activeChatTabIdRef.current, event.toolCallId, false)
}
@ -2482,6 +2495,7 @@ function App() {
mentions?: FileMention[],
stagedAttachments: StagedAttachment[] = [],
searchEnabled?: boolean,
codeMode?: 'claude' | 'codex',
) => {
if (isProcessing) return
@ -2593,6 +2607,7 @@ function App() {
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
codeMode: codeMode || undefined,
middlePaneContext,
})
analytics.chatMessageSent({
@ -2608,6 +2623,7 @@ function App() {
voiceInput: pendingVoiceInputRef.current || undefined,
voiceOutput: ttsEnabledRef.current ? ttsModeRef.current : undefined,
searchEnabled: searchEnabled || undefined,
codeMode: codeMode || undefined,
middlePaneContext,
})
analytics.chatMessageSent({
@ -5836,7 +5852,6 @@ function App() {
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
permission={permRequest.permission}
@ -5844,9 +5859,28 @@ function App() {
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
onDeny={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')}
onSwitchAgent={async (newAgent) => {
const runIdForSwitch = tab.runId
await handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'deny')
window.dispatchEvent(new CustomEvent('code-mode-detected', {
detail: { runId: runIdForSwitch, agent: newAgent },
}))
if (runIdForSwitch) {
try {
await window.ipc.invoke('runs:createMessage', {
runId: runIdForSwitch,
message: `Use ${newAgent === 'claude' ? 'Claude Code' : 'Codex'} instead — rerun the same task with the same prompt, just swap the agent binary to \`${newAgent}\`.`,
codeMode: newAgent,
})
} catch (err) {
console.error('Failed to send swap-agent follow-up', err)
}
}
}}
isProcessing={isActive && isProcessing}
response={response}
/>
{rendered}
</React.Fragment>
)
}
@ -5858,6 +5892,7 @@ function App() {
<AskHumanRequest
key={request.toolCallId}
query={request.query}
options={request.options}
onResponse={(response) => handleAskHumanResponse(request.toolCallId, request.subflow, response)}
isProcessing={isActive && isProcessing}
/>

View file

@ -9,6 +9,7 @@ import { useState, useRef, useEffect } from "react";
export type AskHumanRequestProps = ComponentProps<"div"> & {
query: string;
options?: string[];
onResponse: (response: string) => void;
isProcessing?: boolean;
};
@ -16,17 +17,21 @@ export type AskHumanRequestProps = ComponentProps<"div"> & {
export const AskHumanRequest = ({
className,
query,
options,
onResponse,
isProcessing = false,
...props
}: AskHumanRequestProps) => {
const [response, setResponse] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const hasOptions = Array.isArray(options) && options.length > 0;
useEffect(() => {
// Auto-focus the textarea when component mounts
textareaRef.current?.focus();
}, []);
// Auto-focus the textarea when in free-text mode; nothing to focus for buttons.
if (!hasOptions) {
textareaRef.current?.focus();
}
}, [hasOptions]);
const handleSubmit = () => {
const trimmed = response.trim();
@ -36,6 +41,11 @@ export const AskHumanRequest = ({
}
};
const handleOptionClick = (option: string) => {
if (isProcessing) return;
onResponse(option);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@ -65,30 +75,47 @@ export const AskHumanRequest = ({
{query}
</p>
</div>
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
{hasOptions ? (
<div className="flex flex-wrap gap-2">
{options!.map((option) => (
<Button
key={option}
variant="outline"
size="sm"
onClick={() => handleOptionClick(option)}
disabled={isProcessing}
className="bg-background"
>
{option}
</Button>
))}
</div>
</div>
) : (
<div className="space-y-2">
<Textarea
ref={textareaRef}
value={response}
onChange={(e) => setResponse(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your response..."
disabled={isProcessing}
rows={3}
className="resize-none"
/>
<div className="flex justify-end">
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={!canSubmit}
className="gap-2"
>
<ArrowUpIcon className="size-4" />
Send Response
</Button>
</div>
</div>
)}
</div>
</div>
</div>

View file

@ -9,8 +9,8 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { AlertTriangleIcon, CheckIcon, ChevronDownIcon, RefreshCwIcon, Terminal, XIcon } from "lucide-react";
import { useState, type ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod";
@ -21,6 +21,7 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onApproveSession?: () => void;
onApproveAlways?: () => void;
onDeny?: () => void;
onSwitchAgent?: (newAgent: 'claude' | 'codex') => void;
isProcessing?: boolean;
response?: 'approve' | 'deny' | null;
permission?: z.infer<typeof ToolPermissionMetadata>;
@ -41,6 +42,7 @@ export const PermissionRequest = ({
onApproveSession,
onApproveAlways,
onDeny,
onSwitchAgent,
isProcessing = false,
response = null,
permission,
@ -54,17 +56,33 @@ export const PermissionRequest = ({
: null;
const filePermission = permission?.kind === "file" ? permission : null;
// Detect acpx coding-agent invocations so we can show the agent identity and
// offer a one-click swap-and-retry.
const acpxAgent: 'claude' | 'codex' | null = (() => {
if (!command) return null;
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/);
return match ? (match[1] as 'claude' | 'codex') : null;
})();
const otherAgent: 'claude' | 'codex' | null = acpxAgent === 'claude' ? 'codex' : acpxAgent === 'codex' ? 'claude' : null;
const agentDisplay = acpxAgent === 'claude' ? 'Claude Code' : acpxAgent === 'codex' ? 'Codex' : null;
const otherDisplay = otherAgent === 'claude' ? 'Claude Code' : otherAgent === 'codex' ? 'Codex' : null;
const isResponded = response !== null;
const isApproved = response === 'approve';
// Once a response is chosen, collapse the details to just the header.
// Users can click the header to expand them again.
const [expanded, setExpanded] = useState(false);
const showDetails = !isResponded || expanded;
return (
<div
className={cn(
"not-prose mb-4 w-full rounded-md border",
isResponded
? isApproved
? "border-green-500/50 bg-green-50/50 dark:bg-green-950/20"
: "border-red-500/50 bg-red-50/50 dark:bg-red-950/20"
? "border-green-500/60 bg-green-200/80 dark:border-green-500/40 dark:bg-green-900/40"
: "border-[#fa2525]/70 bg-[#fa2525]/30 dark:border-[#fa2525]/60 dark:bg-[#fa2525]/30"
: "border-amber-500/50 bg-amber-50/50 dark:bg-amber-950/20",
className
)}
@ -72,50 +90,41 @@ export const PermissionRequest = ({
>
<div className="p-4 space-y-4">
<div className="flex items-start gap-3">
{isResponded ? (
isApproved ? (
<CheckCircleIcon className="size-5 text-green-600 dark:text-green-500 shrink-0 mt-0.5" />
) : (
<XCircleIcon className="size-5 text-red-600 dark:text-red-500 shrink-0 mt-0.5" />
)
) : (
{!isResponded && (
<AlertTriangleIcon className="size-5 text-amber-600 dark:text-amber-500 shrink-0 mt-0.5" />
)}
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<div
className={cn("flex items-center gap-2", isResponded && "cursor-pointer select-none")}
onClick={isResponded ? () => setExpanded((v) => !v) : undefined}
>
<div className="flex-1">
<h3 className="font-semibold text-sm text-foreground">
{isResponded ? (isApproved ? "Permission Granted" : "Permission Denied") : "Permission Required"}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{isResponded ? "Requested:" : "The agent wants to execute:"} <span className="font-mono font-medium">{toolCall.toolName}</span>
{agentDisplay && (
<Badge
variant="secondary"
className="ml-2 align-middle bg-secondary text-foreground"
>
<Terminal className="size-3 mr-1" />
{agentDisplay}
</Badge>
)}
</p>
</div>
{isResponded && (
<Badge
variant="secondary"
<ChevronDownIcon
className={cn(
"shrink-0",
isApproved
? "bg-green-100 text-green-700 dark:bg-green-900/50 dark:text-green-400"
: "bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-400"
"size-4 shrink-0 text-muted-foreground transition-transform",
expanded ? "rotate-180" : "rotate-0"
)}
>
{isApproved ? (
<>
<CheckIcon className="size-3 mr-1" />
Approved
</>
) : (
<>
<XIcon className="size-3 mr-1" />
Denied
</>
)}
</Badge>
/>
)}
</div>
{command && (
{showDetails && command && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Command
@ -125,7 +134,7 @@ export const PermissionRequest = ({
</pre>
</div>
)}
{filePermission && (
{showDetails && filePermission && (
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
@ -153,7 +162,7 @@ export const PermissionRequest = ({
</div>
</div>
)}
{!command && !filePermission && toolCall.arguments && (
{showDetails && !command && !filePermission && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments
@ -211,6 +220,18 @@ export const PermissionRequest = ({
<XIcon className="size-4" />
Deny
</Button>
{otherAgent && otherDisplay && onSwitchAgent && (
<Button
variant="secondary"
size="sm"
onClick={() => onSwitchAgent(otherAgent)}
disabled={isProcessing}
className="flex-1"
>
<RefreshCwIcon className="size-4" />
Use {otherDisplay} instead
</Button>
)}
</div>
)}
</div>

View file

@ -18,6 +18,7 @@ import {
Mic,
Plus,
Square,
Terminal,
X,
} from 'lucide-react'
@ -108,7 +109,7 @@ function getAttachmentIcon(kind: AttachmentIconKind) {
}
interface ChatInputInnerProps {
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean
@ -178,6 +179,9 @@ function ChatInputInner({
const [searchEnabled, setSearchEnabled] = useState(false)
const [searchAvailable, setSearchAvailable] = useState(false)
const [isRowboatConnected, setIsRowboatConnected] = useState(false)
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(() => {
@ -260,8 +264,89 @@ 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.
useEffect(() => {
const handler = (ev: Event) => {
const detail = (ev as CustomEvent<{ runId?: string; agent?: 'claude' | 'codex' }>).detail
if (!detail || !detail.agent) return
if (runId && detail.runId && detail.runId !== runId) return
setCodeModeEnabled(true)
setCodingAgent(detail.agent)
}
window.addEventListener('code-mode-detected', handler)
return () => window.removeEventListener('code-mode-detected', handler)
}, [runId])
// Cross-platform basename — handles both / and \ separators.
const basename = useCallback((p: string): string => {
const trimmed = p.replace(/[\\/]+$/, '')
const idx = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return idx >= 0 ? trimmed.slice(idx + 1) : trimmed
}, [])
// Load coding-agent preference for a given workdir.
// Storage: config/coding-agents.json — { [workDirPath]: 'claude' | 'codex' }
const loadCodingAgentFor = useCallback(async (dir: string | null): Promise<'claude' | 'codex'> => {
if (!dir) return 'claude'
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
const value = parsed?.[dir]
if (value === 'codex' || value === 'claude') return value
} catch {
/* file missing or invalid — fall through to default */
}
return 'claude'
}, [])
const persistCodingAgent = useCallback(async (dir: string, agent: 'claude' | 'codex') => {
let existing: Record<string, 'claude' | 'codex'> = {}
try {
const result = await window.ipc.invoke('workspace:readFile', { path: 'config/coding-agents.json' })
const parsed = JSON.parse(result.data) as Record<string, unknown>
for (const [k, v] of Object.entries(parsed ?? {})) {
if (v === 'claude' || v === 'codex') existing[k] = v
}
} catch { /* start fresh */ }
existing[dir] = agent
await window.ipc.invoke('workspace:writeFile', {
path: 'config/coding-agents.json',
data: JSON.stringify(existing, null, 2),
})
}, [])
// Work directory is owned per-chat by the parent (App). This component only
// drives the picker dialog and reports changes up via onWorkDirChange.
// drives the picker dialog and reports changes up via onWorkDirChange. Whenever
// the work directory changes, load its persisted coding-agent preference.
useEffect(() => {
let cancelled = false
loadCodingAgentFor(workDir).then((agent) => {
if (!cancelled) setCodingAgent(agent)
})
return () => { cancelled = true }
}, [workDir, loadCodingAgentFor])
const handleSetWorkDir = useCallback(async () => {
try {
let defaultPath: string | undefined = workDir ?? undefined
@ -282,18 +367,35 @@ function ChatInputInner({
})
if (!chosen) return
onWorkDirChange?.(chosen)
setCodingAgent(await loadCodingAgentFor(chosen))
toast.success(`Work directory set: ${chosen}`)
} catch (err) {
console.error('Failed to set work directory', err)
toast.error('Failed to set work directory')
}
}, [workDir, onWorkDirChange])
}, [workDir, onWorkDirChange, loadCodingAgentFor])
const handleClearWorkDir = useCallback(() => {
onWorkDirChange?.(null)
setCodingAgent('claude')
toast.success('Work directory cleared')
}, [onWorkDirChange])
const handleToggleCodingAgent = useCallback(async () => {
const next: 'claude' | 'codex' = codingAgent === 'claude' ? 'codex' : 'claude'
setCodingAgent(next)
// Persist only when scoped to a workdir; without one there's nothing to key on.
if (!workDir) return
try {
await persistCodingAgent(workDir, next)
} catch (err) {
console.error('Failed to save coding agent', err)
toast.error('Failed to save coding agent')
// revert on failure
setCodingAgent(codingAgent)
}
}, [workDir, codingAgent, persistCodingAgent])
// Check search tool availability (exa or signed-in via gateway)
useEffect(() => {
const checkSearch = async () => {
@ -378,13 +480,15 @@ function ChatInputInner({
const handleSubmit = useCallback(() => {
if (!canSubmit) return
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined)
// codeMode is sticky per conversation — don't reset after send.
const effectiveCodeMode = codeModeEnabled ? codingAgent : undefined
onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments, searchEnabled || undefined, effectiveCodeMode)
controller.textInput.clear()
controller.mentions.clearMentions()
setAttachments([])
// Web search toggle stays on for the rest of the chat session; the user
// turns it off explicitly. (Not persisted across app restarts.)
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled])
}, [attachments, canSubmit, controller, message, onSubmit, searchEnabled, codeModeEnabled, codingAgent, workDir])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
@ -529,15 +633,20 @@ function ChatInputInner({
</div>
<div className="flex items-center gap-2 px-4 pb-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Add"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Add"
>
<Plus className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top">Add files or set work directory</TooltipContent>
</Tooltip>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuItem onSelect={() => fileInputRef.current?.click()}>
<ImagePlus className="size-4" />
@ -559,7 +668,7 @@ function ChatInputInner({
className="flex min-w-0 items-center gap-1.5"
>
<FolderCog className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{workDir.split('/').pop() || workDir}</span>
<span className="truncate">{basename(workDir) || workDir}</span>
</button>
<button
type="button"
@ -600,6 +709,52 @@ function ChatInputInner({
</span>
</button>
)}
{codeModeFeatureEnabled && (codeModeEnabled ? (
<div className="flex h-7 shrink-0 items-center rounded-full bg-secondary text-xs font-medium text-foreground">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(false)}
className="flex h-full items-center gap-1.5 rounded-l-full pl-2.5 pr-2 transition-colors hover:bg-secondary/70"
>
<Terminal className="h-3.5 w-3.5" />
<span>Code</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">Code mode on click to disable</TooltipContent>
</Tooltip>
<span className="text-foreground/30">·</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleToggleCodingAgent}
className="flex h-full items-center rounded-r-full pl-2 pr-2.5 transition-colors hover:bg-secondary/70"
>
<span>{codingAgent === 'claude' ? 'Claude' : 'Codex'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="top">
Coding agent: {codingAgent === 'claude' ? 'Claude Code' : 'Codex'} click to swap
</TooltipContent>
</Tooltip>
</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCodeModeEnabled(true)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Code mode"
>
<Terminal className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Use a coding agent (Claude Code or Codex)</TooltipContent>
</Tooltip>
))}
<div className="flex-1" />
{lockedModel ? (
<span
@ -760,7 +915,7 @@ export interface ChatInputWithMentionsProps {
knowledgeFiles: string[]
recentFiles: string[]
visibleFiles: string[]
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void
onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[], searchEnabled?: boolean, codeMode?: 'claude' | 'codex') => void
onStop?: () => void
isProcessing: boolean
isStopping?: boolean

View file

@ -645,7 +645,6 @@ export function ChatSidebar({
const response = tabState.permissionResponses.get(item.id) || null
return (
<React.Fragment key={item.id}>
{rendered}
<PermissionRequest
toolCall={permRequest.toolCall}
onApprove={() => onPermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
@ -655,6 +654,7 @@ export function ChatSidebar({
isProcessing={isActive && isProcessing}
response={response}
/>
{rendered}
</React.Fragment>
)
}

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,198 @@ function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
)
}
// --- Code Mode Settings ---
type AgentStatus = { installed: boolean; signedIn: boolean }
type CodeModeAgentStatus = { claude: AgentStatus; codex: AgentStatus }
function AgentStatusRow({
name,
installLink,
signInCommand,
status,
}: {
name: string
installLink: string
signInCommand: string
status: AgentStatus | null
}) {
const ready = status?.installed && status?.signedIn
const needsSignInOnly = 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>
) : needsSignInOnly ? (
<span className="text-xs text-muted-foreground shrink-0">
Run <code className="rounded bg-muted px-1 py-0.5 font-mono text-[11px] text-foreground">{signInCommand}</code>
</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"
signInCommand="claude login"
status={status?.claude ?? null}
/>
<AgentStatusRow
name="Codex"
installLink="https://developers.openai.com/codex/cli"
signInCommand="codex login"
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 +1893,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 +2001,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 +2026,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

@ -517,9 +517,41 @@ const TOOL_DISPLAY_NAMES: Record<string, string> = {
* For builtin tools, returns a static friendly name (e.g., "Reading file").
* Falls back to the raw tool name if no mapping exists.
*/
// Phrases shown while a code-mode task is running. They advance over time (5s
// each) to read as progress, then hold on the last one until the task finishes.
const CODE_MODE_RUNNING_LABELS = [
'Working on the task…',
'Inspecting the project…',
'Digging into the code…',
'Figuring it out…',
'Making the changes…',
'Wiring things up…',
'Putting it together…',
]
const CODE_MODE_LABEL_INTERVAL_MS = 5000
// Detect acpx coding-agent invocations (code mode) and produce a status-aware
// label, e.g. "Working on the task…" → "Completed the task".
export const getCodeModeCommandLabel = (tool: ToolCall): string | null => {
if (tool.name !== 'executeCommand') return null
const input = normalizeToolInput(tool.input) as Record<string, unknown> | undefined
const command = typeof input?.command === 'string' ? input.command : ''
const match = command.match(/\bacpx\b[\s\S]*?\b(claude|codex)\b\s+exec\b/)
if (!match) return null
if (tool.status === 'error') return `Couldn't complete the task`
if (tool.status === 'completed') return `Completed the task`
// Advance through the phrases from the tool's start, holding on the last.
const elapsed = Math.max(0, Date.now() - tool.timestamp)
const step = Math.floor(elapsed / CODE_MODE_LABEL_INTERVAL_MS)
const idx = Math.min(step, CODE_MODE_RUNNING_LABELS.length - 1)
return CODE_MODE_RUNNING_LABELS[idx]
}
export const getToolDisplayName = (tool: ToolCall): string => {
const browserLabel = getBrowserControlLabel(tool)
if (browserLabel) return browserLabel
const codeModeLabel = getCodeModeCommandLabel(tool)
if (codeModeLabel) return codeModeLabel
const composioData = getComposioActionCardData(tool)
if (composioData) return composioData.label
return TOOL_DISPLAY_NAMES[tool.name] || tool.name

View file

@ -392,9 +392,10 @@ export async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<T
case "builtin": {
if (t.name === "ask-human") {
return tool({
description: "Ask a human before proceeding",
description: "Ask a human before proceeding. Optionally pass `options` (an array of short button labels) to render the question as a one-click choice; the user's response will be the chosen label verbatim.",
inputSchema: z.object({
question: z.string().describe("The question to ask the human"),
options: z.array(z.string()).optional().describe("Optional short button labels (2-4 recommended). If provided, the user picks one with a single click instead of typing. The response you receive will be the chosen label."),
}),
});
}
@ -1065,6 +1066,7 @@ export async function* streamAgent({
let voiceInput = false;
let voiceOutput: 'summary' | 'full' | null = null;
let searchEnabled = false;
let codeMode: 'claude' | 'codex' | null = null;
let middlePaneContext:
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string }
@ -1213,6 +1215,9 @@ export async function* streamAgent({
if (msg.searchEnabled) {
searchEnabled = true;
}
// Code mode is per-message: latest message decides whether the assistant
// should route coding work through the code-with-agents skill / chosen agent.
codeMode = msg.codeMode ?? null;
if (msg.voiceOutput) {
voiceOutput = msg.voiceOutput;
}
@ -1316,6 +1321,50 @@ Do not announce the work directory unless it's relevant. Just use it.`;
loopLogger.log('search enabled, injecting search prompt');
instructionsWithDateTime += `\n\n# Search\nThe user has requested a search. Use the web-search tool to answer their query.`;
}
if (codeMode) {
loopLogger.log('code mode enabled, injecting coding-agent context', codeMode);
const agentDisplay = codeMode === 'claude' ? 'Claude Code' : 'Codex';
const otherAgent = codeMode === 'claude' ? 'codex' : 'claude';
const otherDisplay = codeMode === 'claude' ? 'Codex' : 'Claude Code';
// Deterministic, per-chat session name so the coding agent keeps
// context across the user's requests within this chat. Reusing the
// same -s <name> resumes the session; the first call creates it.
const sessionName = `rowboat-${runId}`;
instructionsWithDateTime += `\n\n# Code Mode (Active) — Default agent: ${agentDisplay}
The user has turned on **code mode** and the composer chip is set to **${agentDisplay}** (\`${codeMode}\`). Use this as the **default** agent for coding tasks in this turn.
**The user can override the agent at any time, two ways:**
1. By toggling the chip in the composer (preferred).
2. By asking you directly in chat ("use codex", "switch to claude", "do this with ${otherDisplay}", etc.). When the user explicitly asks to use a different agent in the current message, honor that use \`${otherAgent}\` instead of \`${codeMode}\` for this turn, and briefly mention they can also toggle it via the chip for stickiness.
**Persistent session for this chat session name: \`${sessionName}\`.** This chat uses one named agent session so the agent keeps context across your requests. The session must exist before it can be prompted (\`-s\` only resumes; it does not create).
**1. First coding action in this chat ensure the session exists:**
\`\`\`
npx acpx@latest --approve-all --cwd <workdir> <agent> sessions ensure --name ${sessionName}
\`\`\`
(\`ensure\` creates the session if missing and reuses it if it already exists — safe to call when reopening this chat later.)
**2. Then run the prompt:**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
\`\`\`
**3. Every follow-up coding request in this chat reuse the same session (do NOT create again):**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <workdir> <agent> -s ${sessionName} "<prompt>"
\`\`\`
Run these as **separate, sequential** \`executeCommand\` calls — issue the \`sessions ensure\` call first and WAIT for it to finish, then issue the prompt call. Do NOT fire both in the same turn / batch.
Where \`<agent>\` is either \`claude\` or \`codex\` — pick based on (in priority order): an explicit in-chat override → the chip setting (\`${codeMode}\`). Use \`${sessionName}\` exactly — do NOT invent a different name, and do NOT use \`exec\` (it is one-shot and forgets).
If the user's message is clearly NOT a coding request (small talk, an unrelated question), answer directly without invoking the coding agent. Code mode signals readiness, not that every message must route through the agent.`;
}
let streamError: string | null = null;
for await (const event of streamLlm(
model,
@ -1371,11 +1420,16 @@ Do not announce the work directory unless it's relevant. Just use it.`;
const underlyingTool = agent.tools![part.toolName];
if (underlyingTool.type === "builtin" && underlyingTool.name === "ask-human") {
loopLogger.log('emitting ask-human-request, toolCallId:', part.toolCallId);
const rawOptions = (part.arguments as { options?: unknown }).options;
const options = Array.isArray(rawOptions)
? rawOptions.filter((o): o is string => typeof o === 'string' && o.trim().length > 0)
: undefined;
yield* processEvent({
runId,
type: "ask-human-request",
toolCallId: part.toolCallId,
query: part.arguments.question,
...(options && options.length > 0 ? { options } : {}),
subflow: [],
});
}

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, load the \`code-with-agents\` skill first. It provides guidance for delegating coding work to Claude Code or Codex via acpx.
${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

@ -1,90 +1,140 @@
export const skill = String.raw`
# Code with Agents Skill
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
Use this skill whenever the user asks you to write code, build a project, create scripts, fix bugs, read/explain code, or do any software development task even simple file creations like "make a .c file".
## Important: delegate ALL coding work
Coding agents operate on **arbitrary file paths** (including paths outside the Rowboat workspace root, like \`G:/4th sem/CN\` or \`~/projects/foo\`). Do NOT raise "outside workspace" concerns, and do NOT fall back to your own \`executeCommand\` (PowerShell / bash) or workspace file tools to do code work yourself.
Once the user has chosen to use Claude Code or Codex, you MUST delegate ALL code-related tasks to the coding agent. This includes:
- Writing, editing, or refactoring code
- Reading, summarizing, or explaining code
- Debugging and fixing bugs
- Running tests or build commands
- Exploring project structure
- Any other task that involves interacting with a codebase
---
Do NOT attempt to do any of these yourself no reading files, no running commands, no writing code. You are the coordinator; the coding agent does the work. Your job is to translate the user's request into a clear prompt and pass it to the agent.
## STEP 1 MANDATORY FIRST ACTION
## Prerequisites
Look in your **system context** for a section titled **"# Code Mode (Active)"**.
The user must have one of the following installed on their machine:
- **Claude Code** https://claude.ai/code
- **Codex** https://codex.openai.com
### Case A "# Code Mode (Active)" IS present
These are external tools that you cannot install for the user.
Code mode is on and the user has selected an agent. Skip directly to Step 2. Do NOT call ask-human.
## Workflow
### Case B "# Code Mode (Active)" is NOT present
### Step 1: Gather requirements
Your **very next tool call MUST be \`ask-human\`** with options. Do not write any explanation text first. Do not describe a plan. Do not check the workspace boundary. Just call:
Before running anything, confirm the following with the user:
\`\`\`
ask-human({
question: "How should I handle this coding request?",
options: [
"Use code mode (Claude Code)",
"Use code mode (Codex)",
"Continue with default Rowboat"
]
})
\`\`\`
1. **Working directory** Ask which folder the code should be written in, unless the user has already specified it. Example: "Which folder should I work in?"
2. **Agent choice** Ask whether to use **Claude Code** or **Codex**. Mention that the chosen agent must already be installed on their machine.
This is non-negotiable. The user gets clickable buttons. Free-text "which agent?" questions are forbidden here.
### Step 2: Confirm execution plan
**Branch on the response:**
- "Use code mode (Claude Code)" proceed to Step 2 with agent = \`claude\`.
- "Use code mode (Codex)" proceed to Step 2 with agent = \`codex\`.
- "Continue with default Rowboat" ABANDON this skill. Handle the request yourself using your own tools (workspace file tools, \`executeCommand\` shell, etc.). The rest of this skill does not apply for this turn.
Once you know the folder and agent, tell the user:
---
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
## STEP 2 Resolve workdir, confirm, execute
### Step 3: Execute with acpx
**Resolve the workdir** (in this priority order):
1. A path the user named in their original message (e.g. \`G:/4th sem/CN\`).
2. The path from a "# User Work Directory" block in your context.
3. Ask once in plain text: "Which folder should I work in?"
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
**State your intent in one line, then execute immediately do NOT wait for a "yes".** The \`executeCommand\` call surfaces a permission card that is itself the user's confirmation, so an extra in-chat "reply yes to proceed" is redundant friction. Say something like:
**For Claude Code:**
` + "`" + `
npx acpx@latest --approve-all --cwd <folder> claude exec "<prompt>"
` + "`" + `
> Using [Claude Code / Codex] to [task description] in \`[folder]\`.
**For Codex:**
` + "`" + `
npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
` + "`" + `
and then immediately make the \`executeCommand\` call in the same turn.
**Execute** with the chosen agent using a **persistent named session** so follow-up coding requests in this chat resume the same agent and keep context.
Pick \`<agent>\` (\`claude\` or \`codex\`) by, in priority order:
- An explicit in-chat override from the user this turn ("use codex", "switch to claude") honor it.
- The agent chosen in Step 1 / the "# Code Mode (Active)" block.
Pick \`<session-name>\` — **stable for this whole chat**:
- If the "# Code Mode (Active)" block gives a session name (e.g. \`rowboat-<runId>\`), use that exact name.
- Otherwise pick one short, kebab-case name and **reuse it for every coding call this turn and in follow-ups** never a new name each time.
**\`-s\` resumes an existing session; it does NOT create one.** So ensure the session exists once at the start, then prompt:
**1. First coding action in this chat ensure the session exists:**
\`\`\`
npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>
\`\`\`
(\`ensure\` creates the session if missing and reuses it if it already exists — so reopening this chat later just resumes the same session instead of erroring.)
**2. Then run the prompt:**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
\`\`\`
**3. Every follow-up coding request in this chat reuse the same session (do NOT create again):**
\`\`\`
npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"
\`\`\`
**Run steps 1 and 2 as separate, sequential \`executeCommand\` calls.** Issue the \`sessions ensure\` call FIRST, wait for it to finish, and only THEN issue the prompt call. Do NOT fire both in the same turn / batch — each must surface and complete its own permission + command block before the next begins.
Do NOT use \`exec\` — it is one-shot and forgets everything.
Concrete example:
\`\`\`
# First coding message in the chat ensure the session, then prompt:
npx acpx@latest --approve-all --cwd "G:\\Blogging\\myblog" claude sessions ensure --name diskspace-check
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Check the system disk space and report total, used, and free space."
# Follow-up in the same chat reuse the session, no create:
npx acpx@latest --approve-all --timeout 600 --cwd "G:\\Blogging\\myblog" claude -s diskspace-check "Summarize what we did and the final findings."
\`\`\`
### Critical: flag order
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
\`--approve-all\`, \`--timeout\`, and \`--cwd\` are GLOBAL flags and MUST appear BEFORE the agent name. \`sessions ensure --name <name>\` and \`-s <session-name>\` come AFTER the agent name:
` + "`" + `
npx acpx@latest [global flags] <agent> exec "<prompt>"
` + "`" + `
- Correct: \`npx acpx@latest --approve-all --timeout 600 --cwd <folder> <agent> -s <session-name> "<prompt>"\`
- Wrong: \`npx acpx@latest <agent> --approve-all -s <name> "..."\` (will fail)
**Correct:**
` + "`" + `
npx acpx@latest --approve-all --cwd ~/projects/myapp claude exec "fix the bug"
` + "`" + `
### Writing good prompts for the agent
**Wrong (will fail):**
` + "`" + `
npx acpx@latest claude --approve-all exec "fix the bug"
` + "`" + `
- Be specific: file names, function signatures, expected behavior.
- Mention constraints (language, framework, style).
- Expand short user requests into clear, actionable prompts.
### Writing good prompts
---
When constructing the prompt for the coding agent:
- Be specific and detailed about what to build or fix
- Include file names, function signatures, and expected behavior
- Mention any constraints (language, framework, style)
- If the user gave you a short request, expand it into a clear, actionable prompt for the agent
## STEP 3 Report results
### Step 4: Report results
After the command finishes:
- Pass through the coding agent's summary as-is. Do not rewrite.
- Refer to file paths as plain text. Do NOT use \`\`\`file:path\`\`\` reference blocks. (This overrides the global "always wrap paths in filepath blocks" rule — for code-mode output, plain text.)
- Only add your own explanation if the command failed (non-zero exit):
- Exit code 5 permissions were denied (shouldn't happen with \`--approve-all\`; flag it).
- Exit code 4 / "No acpx session found" the \`-s <session-name>\` session doesn't exist yet. Create it once with \`npx acpx@latest --approve-all --cwd <folder> <agent> sessions ensure --name <session-name>\`, then retry the prompt. (\`-s\` only resumes; it never creates.)
- "command not found" / agent not installed, or an auth/sign-in error the agent isn't set up. Tell the user to install or sign in to the agent via **Settings Code Mode**, where Rowboat shows the install and sign-in status.
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
---
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
## Once delegating: delegate fully
- If the exit code is 5, it means permissions were denied this should not happen with \`--approve-all\`, but if it does, let the user know
After Step 2 fires, delegate ALL related coding tasks for this turn to the coding agent writing, editing, reading, debugging, exploring structure, running tests. You are the coordinator; the agent does the work.
## Prerequisites (informational)
The user must have one of these installed locally these are external tools you cannot install:
- Claude Code https://claude.ai/code
- Codex https://codex.openai.com
`;
export default skill;

View file

@ -95,21 +95,47 @@ const LLMPARSE_MIME_TYPES: Record<string, string> = {
// When the LLM invokes acpx via executeCommand, pre-resolve claude's real .exe
// from the npm-shim layout and inject it via env so the bridge can spawn it.
function resolveClaudeExeOnWindows(): string | undefined {
const pathDirs = (process.env.PATH ?? '').split(';');
for (const dir of pathDirs) {
const trimmed = dir.trim();
if (!trimmed) continue;
const cmdPath = path.join(trimmed, 'claude.cmd');
if (!existsSync(cmdPath)) continue;
const exeFromLayout = path.join(trimmed, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
// Candidate dirs = everything on PATH, plus well-known npm/pnpm/volta global
// bin dirs. Electron's runtime PATH can omit these even when the user's shell
// includes them, which would otherwise leave us unable to find claude.exe and
// force a fallback to claude.cmd (which Node refuses to spawn — EINVAL).
const home = process.env.USERPROFILE ?? '';
const appData = process.env.APPDATA || (home && path.join(home, 'AppData', 'Roaming'));
const localAppData = process.env.LOCALAPPDATA || (home && path.join(home, 'AppData', 'Local'));
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const knownDirs = [
appData && path.join(appData, 'npm'),
localAppData && path.join(localAppData, 'npm'),
appData && path.join(appData, 'pnpm'),
localAppData && path.join(localAppData, 'pnpm'),
home && path.join(home, '.volta', 'bin'),
path.join(programFiles, 'nodejs'),
].filter(Boolean) as string[];
const pathDirs = (process.env.PATH ?? '').split(';').map((d) => d.trim()).filter(Boolean);
const seen = new Set<string>();
const candidates = [...pathDirs, ...knownDirs].filter((d) => {
const key = d.toLowerCase();
if (seen.has(key)) return false;
seen.add(key);
return true;
});
for (const dir of candidates) {
// Direct npm-shim layout: <dir>\node_modules\@anthropic-ai\claude-code\bin\claude.exe
const exeFromLayout = path.join(dir, 'node_modules', '@anthropic-ai', 'claude-code', 'bin', 'claude.exe');
if (existsSync(exeFromLayout)) return exeFromLayout;
// Otherwise parse the claude.cmd shim for the real exe path.
const cmdPath = path.join(dir, 'claude.cmd');
if (!existsSync(cmdPath)) continue;
try {
const content = readFileSync(cmdPath, 'utf-8');
const absMatch = content.match(/[A-Z]:[\\/][^\s"]*claude\.exe/i);
if (absMatch && existsSync(absMatch[0])) return absMatch[0];
const relMatch = content.match(/%~dp0[\\/]?([^\s"%]+claude\.exe)/i);
if (relMatch) {
const resolved = path.join(trimmed, relMatch[1]);
const resolved = path.join(dir, relMatch[1]);
if (existsSync(resolved)) return resolved;
}
} catch {
@ -825,7 +851,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
// Fallback to original for backward compatibility
const result = await executeCommand(command, { cwd: workingDir });
const result = await executeCommand(command, { cwd: workingDir, env: envOverride });
return {
success: result.exitCode === 0,

View file

@ -80,6 +80,7 @@ export async function executeCommand(
cwd?: string;
timeout?: number; // timeout in milliseconds
maxBuffer?: number; // max buffer size in bytes
env?: NodeJS.ProcessEnv; // override environment (e.g. CLAUDE_CODE_EXECUTABLE for acpx)
}
): Promise<CommandResult> {
try {
@ -89,6 +90,7 @@ export async function executeCommand(
timeout: options?.timeout,
maxBuffer: options?.maxBuffer || 1024 * 1024, // default 1MB
shell,
env: options?.env,
});
return {

View file

@ -8,17 +8,20 @@ export type MiddlePaneContext =
| { kind: 'note'; path: string; content: string }
| { kind: 'browser'; url: string; title: string };
export type CodeMode = 'claude' | 'codex';
type EnqueuedMessage = {
messageId: string;
message: UserMessageContentType;
voiceInput?: boolean;
voiceOutput?: VoiceOutputMode;
searchEnabled?: boolean;
codeMode?: CodeMode;
middlePaneContext?: MiddlePaneContext;
};
export interface IMessageQueue {
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string>;
enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string>;
dequeue(runId: string): Promise<EnqueuedMessage | null>;
}
@ -34,7 +37,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
this.idGenerator = idGenerator;
}
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
async enqueue(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: CodeMode): Promise<string> {
if (!this.store[runId]) {
this.store[runId] = [];
}
@ -45,6 +48,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
voiceInput,
voiceOutput,
searchEnabled,
codeMode,
middlePaneContext,
});
return id;

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,199 @@
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;
}
}
// Given the raw credentials JSON (from a file or the macOS Keychain), decide
// whether it represents a usable signed-in state: a valid API key, an unexpired
// access token, or a refresh token (which can mint a new access token).
function isClaudeCredentialSignedIn(raw: string): boolean {
try {
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 {
// malformed JSON
}
return false;
}
// Reads Claude Code's credentials from the macOS login Keychain, where the
// CLI stores them on macOS (service "Claude Code-credentials"). On Linux/Windows
// it uses the ~/.claude/.credentials.json file instead, so this is a no-op there.
//
// Caveats:
// - The first read by this app (a different binary than the `claude` CLI that
// created the item) triggers a one-time macOS authorization dialog; the user
// must "Always Allow". Headless/SSH sessions can't show it and will fail.
// - If CLAUDE_CONFIG_DIR is set, Claude appends a SHA-256 suffix to the service
// name, which this lookup won't match — such setups usually keep the file too.
async function readClaudeKeychainCredential(): Promise<string | null> {
if (process.platform !== 'darwin') return null;
try {
const { stdout } = await execAsync(
`security find-generic-password -s "Claude Code-credentials" -w`,
{ timeout: 5000 },
);
const out = stdout.trim();
return out.length > 0 ? out : null;
} catch {
// not present in keychain
return null;
}
}
// Validates Claude Code auth. On macOS the credentials live in the login
// Keychain; on Linux/Windows in ~/.claude/.credentials.json (or ~/.config
// fallback). We check both so detection works across platforms.
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');
if (isClaudeCredentialSignedIn(raw)) return true;
} catch {
// try next candidate
}
}
// macOS: credentials are stored in the Keychain rather than on disk.
const keychainRaw = await readClaudeKeychainCredential();
if (keychainRaw && isClaudeCredentialSignedIn(keychainRaw)) return true;
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

@ -39,9 +39,9 @@ export async function createRun(opts: z.infer<typeof CreateRunOptions>): Promise
return run;
}
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext): Promise<string> {
export async function createMessage(runId: string, message: UserMessageContentType, voiceInput?: boolean, voiceOutput?: VoiceOutputMode, searchEnabled?: boolean, middlePaneContext?: MiddlePaneContext, codeMode?: 'claude' | 'codex'): Promise<string> {
const queue = container.resolve<IMessageQueue>('messageQueue');
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext);
const id = await queue.enqueue(runId, message, voiceInput, voiceOutput, searchEnabled, middlePaneContext, codeMode);
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
runtime.trigger(runId);
return id;

View file

@ -228,6 +228,7 @@ const ipcSchemas = {
voiceInput: z.boolean().optional(),
voiceOutput: z.enum(['summary', 'full']).optional(),
searchEnabled: z.boolean().optional(),
codeMode: z.enum(['claude', 'codex']).optional(),
middlePaneContext: z.discriminatedUnion('kind', [
z.object({
kind: z.literal('note'),
@ -424,6 +425,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(),

View file

@ -75,6 +75,7 @@ export const AskHumanRequestEvent = BaseRunEvent.extend({
type: z.literal("ask-human-request"),
toolCallId: z.string(),
query: z.string(),
options: z.array(z.string()).optional(),
});
export const AskHumanResponseEvent = BaseRunEvent.extend({