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

@ -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({